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.
88 lines
3.4 KiB
TypeScript
88 lines
3.4 KiB
TypeScript
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) {
|
|
let inFlight: AbortController | undefined;
|
|
|
|
pi.on("session_start", async (_event, ctx) => {
|
|
// Defensive: if this instance ever starts twice, only the newest run lives.
|
|
inFlight?.abort();
|
|
inFlight = new AbortController();
|
|
const signal = inFlight.signal;
|
|
// 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);
|
|
});
|
|
});
|
|
|
|
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, 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.
|
|
let recorded = false;
|
|
const record = (pass: boolean) => {
|
|
if (recorded) return;
|
|
recorded = true;
|
|
pi.events.emit("test-balls:record", { pass, command: "make test", ts: Date.now() });
|
|
};
|
|
|
|
try {
|
|
const { stdout, stderr, code } = await pi.exec("make", ["test"], { signal });
|
|
if (signal.aborted) return; // session replaced: abandon silently
|
|
|
|
record(code === 0);
|
|
if (showStatus) ctx.ui.setStatus("make-test", undefined);
|
|
|
|
const output = stdout + stderr;
|
|
if (code === 0) {
|
|
ctx.ui.notify("make test passed", "info");
|
|
} else {
|
|
ctx.ui.notify(`make test failed (exit ${code})`, "error");
|
|
}
|
|
|
|
// Also send the output as a message so it's visible in the session
|
|
pi.sendMessage({
|
|
customType: "run-make-test",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `Here's the result of running \`make test\`:\n\n\`\`\`\n${output}\`\`\``,
|
|
},
|
|
],
|
|
display: true,
|
|
}, { deliverAs: "followUp" });
|
|
} catch (error) {
|
|
if (signal.aborted || isStaleCtxError(error)) return; // session replaced: abandon silently
|
|
if (showStatus) ctx.ui.setStatus("make-test", undefined);
|
|
record(false);
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
ctx.ui.notify(`run_make_test failed: ${message}`, "error");
|
|
throw error; // the session_start catch logs it
|
|
}
|
|
}
|