fix(pi): abort session-start make test on session replacement to avoid stale ctx

The fire-and-forget run captured its session's pi/ctx; after /new, fork,
switch, or reload pi invalidates those, so the orphaned run's notify/
sendMessage threw 'This extension ctx is stale after session replacement
or reload'. Now session_shutdown aborts the in-flight run before
invalidation (pi.exec supports AbortSignal), and stale errors are
swallowed as a race guard.

Also show a 'running tests...' footer status widget while the run is
in flight, cleared on completion.
This commit is contained in:
Firehose Bot
2026-10-15 14:00:38 +01:00
parent a1fc8486e9
commit fc2bafdfa1
+46 -16
View File
@@ -1,18 +1,48 @@
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
/**
* Fires `make test` in the background on session start.
*
* The run is tied to the session that started it:
* - a footer status widget shows "running tests…" while it runs
* - on completion it records a test-balls ball, notifies, and posts the output
* - `session_shutdown` (quit/reload/new/resume/fork) aborts an in-flight run:
* pi invalidates captured pi/ctx objects on session replacement, so any
* post-run work from an orphaned run would throw "This extension ctx is
* stale after session replacement or reload". The replacement session
* starts its own run, so an aborted run is abandoned silently.
*/
const STALE_CTX_PREFIX = "This extension ctx is stale after session replacement or reload.";
const isStaleCtxError = (error: unknown): boolean =>
error instanceof Error && error.message.startsWith(STALE_CTX_PREFIX);
export default function (pi: ExtensionAPI) { export default function (pi: ExtensionAPI) {
let inFlight: AbortController | undefined;
pi.on("session_start", async (_event, ctx) => { pi.on("session_start", async (_event, ctx) => {
// Fire-and-forget: do not block session start on the test run, and let the // Defensive: if this instance ever starts twice, only the newest run lives.
// "test-balls:record" event land after every extension's session_start handler inFlight?.abort();
// has run (project extensions load before global ones, so emitting synchronously inFlight = new AbortController();
// from this handler would race test-balls' runs reset). const signal = inFlight.signal;
void runMakeTest(pi, ctx).catch((error) => { // Fire-and-forget: do not block session start on the test run.
void runMakeTest(pi, ctx, signal).catch((error) => {
if (isStaleCtxError(error)) return; // session replaced mid-finish: nothing left to report to
console.error("run_make_test:", error); console.error("run_make_test:", error);
}); });
}); });
pi.on("session_shutdown", () => {
// Runs before the runner is invalidated: kill the child process so an
// orphaned run can never touch the stale pi/ctx that spawned it.
inFlight?.abort();
});
} }
async function runMakeTest(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> { async function runMakeTest(pi: ExtensionAPI, ctx: ExtensionContext, signal: AbortSignal): Promise<void> {
const showStatus = ctx.mode === "tui";
if (showStatus) ctx.ui.setStatus("make-test", "running tests…");
// Record exactly one ball per run, even if UI reporting below fails. // Record exactly one ball per run, even if UI reporting below fails.
let recorded = false; let recorded = false;
const record = (pass: boolean) => { const record = (pass: boolean) => {
@@ -22,10 +52,13 @@ async function runMakeTest(pi: ExtensionAPI, ctx: ExtensionContext): Promise<voi
}; };
try { try {
const { stdout, stderr, code } = await pi.exec("make", ["test"]); const { stdout, stderr, code } = await pi.exec("make", ["test"], { signal });
record(code === 0); if (signal.aborted) return; // session replaced: abandon silently
const output = stdout + stderr;
record(code === 0);
if (showStatus) ctx.ui.setStatus("make-test", undefined);
const output = stdout + stderr;
if (code === 0) { if (code === 0) {
ctx.ui.notify("make test passed", "info"); ctx.ui.notify("make test passed", "info");
} else { } else {
@@ -44,14 +77,11 @@ async function runMakeTest(pi: ExtensionAPI, ctx: ExtensionContext): Promise<voi
display: true, display: true,
}, { deliverAs: "followUp" }); }, { deliverAs: "followUp" });
} catch (error) { } catch (error) {
if (signal.aborted || isStaleCtxError(error)) return; // session replaced: abandon silently
if (showStatus) ctx.ui.setStatus("make-test", undefined);
record(false); record(false);
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
pi.sendMessage({ ctx.ui.notify(`run_make_test failed: ${message}`, "error");
customType: "run-make-test", throw error; // the session_start catch logs it
content: [
{ type: "text", text: `Error running make test: ${message}` },
],
display: true,
}, { deliverAs: "followUp" });
} }
} }