From 49ba6bd3c2f0bb8a10ab8dbd0d80d1baf09e33fa Mon Sep 17 00:00:00 2001 From: Luis Costigan Date: Tue, 22 Sep 2026 16:04:39 +0900 Subject: [PATCH 1/2] fix: detach completion callbacks on reload --- README.md | 15 ++++++++--- extension/index.test.ts | 48 +++++++++++++++++++++++++++++++++++ extension/index.ts | 55 +++++++++++++++++++++++++++++++++++++---- 3 files changed, 109 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index e27e8c9..3d13ecd 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,11 @@ pi-background-run **wakes the live agent session** so it proactively reads a condensed digest of the results and continues — no polling, no human intervention. Built as a [pi](https://github.com/earendil-works/pi-coding-agent) extension. No -shell runner and no external daemon — the extension spawns the job in-process, -detects completion via the child `exit` event, and calls `pi.sendUserMessage` to wake -the agent. The log file is self-describing (full output + a trailing +shell runner and no external daemon — the extension spawns the job in-process +and uses the child `exit` event while that extension generation remains active. +On `/reload` or session shutdown it detaches generation-bound callbacks; the +replacement extension reconciles completion from the self-describing log and +never invokes stale Pi APIs. The log contains full output plus a trailing `__BGRUN_EXIT__=N` marker), so exit codes survive pi restarting. Two small pieces exist beyond the spawn: a 30s timer that only re-checks jobs whose live child handle is gone (reconstructed from a restart, or adopted from another session), and a @@ -74,12 +76,17 @@ agent calls bgrun(command: "make test-short", name: "unit-tests") → records job in-memory + appends a bgrun-job entry to the session → returns "started: " -child 'exit' event fires: +child 'exit' event fires while the same extension generation is active: → extension records exit code, appends a done entry → pi.sendUserMessage(wake) when idle (triggers a turn) or pi.sendUserMessage(wake, { deliverAs: 'followUp' }) when busy → ctx.ui.notify(...) — toast for the human → ctx.ui.setWidget("bgrun", ...) — updates/clears the live status widget + +/reload or session shutdown happens first: + → old generation invalidates itself and detaches child listeners + → detached process continues writing its log and exit marker + → active replacement generation reconstructs and persists completion once ``` The child writes the log directly via its own stdout fd (no pipe to pi), so the job diff --git a/extension/index.test.ts b/extension/index.test.ts index bd82996..ee513d7 100644 --- a/extension/index.test.ts +++ b/extension/index.test.ts @@ -334,6 +334,19 @@ function waitForWakes( }); } +async function waitForLogExit(logPath: string, timeoutMs = 4000): Promise { + const start = Date.now(); + while (Date.now() - start <= timeoutMs) { + try { + if (/__BGRUN_EXIT__=-?\d+/.test(readFileSync(logPath, "utf8"))) return; + } catch { + // log may not exist yet + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error(`timed out waiting for exit marker in ${logPath}`); +} + test("bgrun: exit marker survives commands with # and explicit exit codes", async () => { const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-")); process.env.PI_BGRUN_DIR = dir; @@ -5272,6 +5285,41 @@ test("bgrun: type flows into the started result, entries, and resume reconstruct } }); +test("session_shutdown: detached completion is reconciled by the active session without stale callbacks", async () => { + await withJobsDir(async (dir, h) => { + const { entries, wakes, tools, ctx, fireSessionShutdown } = h; + const bgrun = tools.get("bgrun")!; + const result = await bgrun.execute( + "reload-race", + { command: "sleep 0.15; echo after-reload", wake: "always" }, + undefined, + undefined, + ctx, + ); + const id = (result.content[0].text as string).match(/^started: ([^\n]+)/)![1]; + const logPath = join(dir, `${id}.log`); + + await fireSessionShutdown(); + await waitForLogExit(logPath); + await new Promise((resolve) => setTimeout(resolve, 25)); + + assert.equal(wakes.length, 0, "disposed generation did not wake the agent"); + assert.equal( + entries.filter((entry) => entry.data?.id === id).length, + 1, + "disposed generation persisted only the running entry", + ); + + const replacement = makeFakePi({ priorEntries: entries }); + await loadExtension(replacement.pi); + await replacement.fireSessionStart(); + const records = replacement.entries.filter((entry) => entry.data?.id === id); + assert.equal(records.length, 2, "active generation reconciled completion once"); + assert.equal(records[1].data?.state, "done"); + assert.equal(records[1].data?.exitCode, 0); + }); +}); + test("session_shutdown: sweeps this session's old logs and does not throw", async () => { const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-")); process.env.PI_BGRUN_DIR = dir; diff --git a/extension/index.ts b/extension/index.ts index 1de3644..c2de3fb 100644 --- a/extension/index.ts +++ b/extension/index.ts @@ -1382,6 +1382,8 @@ interface JobRecord { exitCode?: number; donePersisted?: boolean; // done entry already appended to the transcript child?: ReturnType; // absent for adopted (fs-discovered) jobs + exitListener?: (code: number | null, signal: NodeJS.Signals | null) => void; + errorListener?: (error: Error) => void; ctx: ExtensionContext; // captured at tool-call time for isIdle() in the exit handler adopted?: boolean; // true when discovered from the jobs dir (another session's job) } @@ -1414,6 +1416,9 @@ interface BgStatusDetails { export default function (pi: ExtensionAPI) { const jobs = new Map(); + // Extension APIs and contexts are generation-bound. Detached processes and + // their logs outlive /reload; callbacks registered by this generation do not. + let disposed = false; // bgtail's delta-tailing bookmarks: one entry per job id ever tailed, holding // the high-water mark of what the caller has already had the opportunity to // see. Declared here, ahead of the cleanup helpers, so removing a log can @@ -1560,8 +1565,10 @@ export default function (pi: ExtensionAPI) { ctx: ExtensionContext, opts: { persistRevalidate?: boolean } = {}, ): void { - if (!ctx.hasUI) return; + // Reconciliation is lifecycle state, not a UI side effect. Headless/RPC + // sessions must persist terminal evidence too. revalidateStaleJobs({ persist: opts.persistRevalidate ?? true }); + if (!ctx.hasUI) return; const running: JobRecord[] = []; for (const rec of jobs.values()) { if (rec.exitCode === undefined) running.push(rec); @@ -1848,6 +1855,7 @@ export default function (pi: ExtensionAPI) { // accurate view, but asking for status must not append transcript cards. // The stale poller / session_start re-run with persistence and reconcile. function persistDoneEntry(rec: JobRecord, exit: number): void { + if (disposed) return; rec.donePersisted = true; pi.appendEntry("bgrun-job", { id: rec.id, @@ -1907,6 +1915,7 @@ export default function (pi: ExtensionAPI) { function ensureStalePoller(ctx: ExtensionContext): void { if (stalePoller !== undefined || !hasUnsupervisedRunning()) return; stalePoller = setInterval(() => { + if (disposed) return; revalidateStaleJobs(); updateWidget(ctx); if (!hasUnsupervisedRunning()) stopStalePoller(); @@ -1980,6 +1989,11 @@ export default function (pi: ExtensionAPI) { // ── session_start: reconstruct Map from entries + auto-cleanup ──────────── pi.on("session_start", async (_event, ctx) => { + disposed = false; + // A single extension instance can observe a session switch. Never carry + // another session's in-memory ownership into the new session. + jobs.clear(); + tailBookmarks.clear(); // Reconstruct the in-memory Map from this session's bgrun-job entries. // Only the current session's entries are visible; jobs from other sessions // remain discoverable via the filesystem scan in bgstatus. @@ -2066,7 +2080,21 @@ export default function (pi: ExtensionAPI) { }); pi.on("session_shutdown", async (_event, ctx) => { + // Invalidate before any cleanup. A child may complete while shutdown is in + // progress, but this generation must never call Pi APIs afterward. + disposed = true; stopStalePoller(); + for (const rec of jobs.values()) { + if (!rec.child) continue; + if (rec.exitListener) rec.child.removeListener("exit", rec.exitListener); + if (rec.errorListener) rec.child.removeListener("error", rec.errorListener); + // Avoid an unhandled late spawn error after detaching our generation- + // bound listener. Completion itself remains authoritative in the log. + rec.child.on("error", () => {}); + delete rec.child; + delete rec.exitListener; + delete rec.errorListener; + } // Sweep old logs on the way out. Throttled via the .last-clean marker so // restart-heavy workflows don't sweep more than once per cleanupDays. try { @@ -2118,6 +2146,9 @@ export default function (pi: ExtensionAPI) { ), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + if (disposed) { + throw new Error("bgrun: extension session is shutting down; retry after reload"); + } const { command, name: rawName, type: rawType } = params; if (!command || !command.trim()) { throw new Error("bgrun: command is required"); @@ -2237,6 +2268,7 @@ export default function (pi: ExtensionAPI) { updateWidget(ctx); const finishSpawnFailure = (err: Error) => { + if (disposed) return; const rec = jobs.get(id); if (!rec || rec.exitCode !== undefined) return; rec.exitedAt = Date.now(); @@ -2289,7 +2321,11 @@ export default function (pi: ExtensionAPI) { }; // ── exit handler: record exit, persist done entry, wake, notify, widget ─ - child.on("exit", async (code, signal) => { + const exitListener = async ( + code: number | null, + signal: NodeJS.Signals | null, + ) => { + if (disposed) return; const rec = jobs.get(id); if (!rec) return; // A spawn that emitted 'error' first already finalized this job; a @@ -2407,6 +2443,10 @@ export default function (pi: ExtensionAPI) { ); } + // A digest awaits an external child. Shutdown/reload may happen in + // that gap; the old generation must make no further Pi API calls. + if (disposed) return; + // Wake the agent. const namePrefix = rec.name ? `"${rec.name}" ` : ""; let wake = `${exitEmoji} Background job ${namePrefix}\`${id}\` finished (exit ${exitStr}).\n`; @@ -2445,12 +2485,17 @@ export default function (pi: ExtensionAPI) { // Update/clear the widget. updateWidget(rec.ctx); - }); + }; - child.on("error", (err) => { + const errorListener = (err: Error) => { + if (disposed) return; console.error(`[pi-bgrun] spawn error for job ${id}:`, err.message); finishSpawnFailure(err); - }); + }; + record.exitListener = exitListener; + record.errorListener = errorListener; + child.on("exit", exitListener); + child.on("error", errorListener); const startedLines = [`started: ${id}`]; if (name) startedLines.push(` name: ${name}`); From 9a3d7b0e7c01b1d1508ae661f196f1786fc3cb38 Mon Sep 17 00:00:00 2001 From: Luis Costigan Date: Tue, 22 Sep 2026 16:55:45 +0900 Subject: [PATCH 2/2] feat: expose compact running-job status --- README.md | 6 +++++- extension/index.test.ts | 27 +++++++++++++++++++++++++++ extension/index.ts | 16 ++++++++++++++-- 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3d13ecd..8a85c3b 100644 --- a/README.md +++ b/README.md @@ -205,7 +205,9 @@ the dir is shared by every pi session working in that checkout — that sharing enables cross-session job lookup, session-restart reconstruction, and per-project cleanup. By default each session only *tracks its own jobs*: the widget and `bgstatus` listings show this session's running jobs, and finished -jobs are hidden (ask for them explicitly with `bgstatus includeDone: true`). +jobs are hidden (ask for them explicitly with `bgstatus includeDone: true`). The +extension also emits `bgrun:status` with `{ running, tracked }`, allowing a +custom footer to replace the widget by setting `showWidget: false`. Jobs started by other sessions can still be inspected by id, but they don't clutter your widget. @@ -225,6 +227,7 @@ run locally (completed jobs visible, a scorecard on `bun test` runs). { "adoptForeignJobs": false, "showCompletedJobs": false, + "showWidget": true, "cleanupDays": 7, "maxLogBytes": 67108864, "globalAutoClean": true, @@ -324,6 +327,7 @@ Environment variables (same knobs, handy for one-off overrides): | `PI_BGRUN_GLOBAL_DIR` | `~/.pi-bgrun/jobs` | **Deprecated.** Overrides the machine-global jobs base — the fallback used only when the cwd has no project root (see [deprecation](#deprecated-machine-global-jobs-dir)). A leading `~` or `~/` is expanded to the home dir; `~user` is not. | | `PI_BGRUN_FOREIGN_JOBS` | `false` | Adopt other sessions' running jobs into this session's widget and job list. Adopted jobs are polled so they leave the widget when they finish. | | `PI_BGRUN_SHOW_COMPLETED` | `false` | Include finished jobs in `bgstatus` listings by default. | +| `PI_BGRUN_SHOW_WIDGET` | `true` | Render the built-in multiline widget. Set false when a footer consumes `bgrun:status`. | | `PI_BGRUN_CLEANUP_DAYS` | `7` | Log retention for cleanup sweeps and the `bgclean` default. | | `PI_BGRUN_MAX_LOG_BYTES` | `67108864` (64 MiB) | Byte ceiling for a job's log (stdout+stderr). `0` disables it (unlimited). See [Log size ceiling](#log-size-ceiling). | | `PI_BGRUN_GLOBAL_AUTO_CLEAN` | `true` | Set `0`/`false` to disable the automatic orphan sweep (see below). | diff --git a/extension/index.test.ts b/extension/index.test.ts index ee513d7..61811a8 100644 --- a/extension/index.test.ts +++ b/extension/index.test.ts @@ -120,6 +120,7 @@ type UsedExtensionAPI = Pick< | "registerEntryRenderer" | "sendUserMessage" | "appendEntry" + | "events" >; // Tools as the tests consume them: real metadata types from ToolDefinition, but @@ -173,6 +174,7 @@ interface FakePiHandles { pi: ExtensionAPI; wakes: CapturedWake[]; entries: CapturedEntry[]; + jobStatuses: Array<{ running: number; tracked: number }>; tools: Map; commands: Map; entryRenderers: Map; @@ -193,6 +195,7 @@ function makeFakePi( const entries: CapturedEntry[] = opts.priorEntries ? [...opts.priorEntries] : []; + const jobStatuses: Array<{ running: number; tracked: number }> = []; const tools = new Map(); const commands = new Map(); const entryRenderers = new Map(); @@ -210,6 +213,12 @@ function makeFakePi( // compile-time drift guard. `as ExtensionAPI` below is the unavoidable seam // (the fake is deliberately partial); the *shapes* here are the real ones. const used: UsedExtensionAPI = { + events: { + emit(name: string, data: unknown) { + if (name === "bgrun:status") + jobStatuses.push(data as { running: number; tracked: number }); + }, + } as ExtensionAPI["events"], sendUserMessage(content, options) { wakes.push({ text: content as string, @@ -256,6 +265,7 @@ function makeFakePi( pi, wakes, entries, + jobStatuses, tools, commands, entryRenderers, @@ -425,6 +435,23 @@ test("bgrun: successful command writes log + exit marker and wakes with ✅", as }); }); +test("bgrun: emits compact running-job status for footer integrations", async () => { + await withJobsDir(async (_dir, h) => { + const { jobStatuses, tools, ctx } = h; + const bgrun = tools.get("bgrun")!; + await bgrun.execute( + "footer-status", + { command: "sleep 0.1" }, + undefined, + undefined, + ctx, + ); + assert.equal(jobStatuses.at(-1)?.running, 1); + await new Promise((resolve) => setTimeout(resolve, 175)); + assert.equal(jobStatuses.at(-1)?.running, 0); + }); +}); + test("bgrun: failing command wakes with ❌ and the non-zero exit code", async () => { await withJobsDir(async (_dir, h) => { const { wakes, tools, ctx } = h; diff --git a/extension/index.ts b/extension/index.ts index c2de3fb..f744eac 100644 --- a/extension/index.ts +++ b/extension/index.ts @@ -659,6 +659,9 @@ interface BgrunConfig { // Include finished jobs in bgstatus listings by default. Default false — // completed jobs are noise; ask for them explicitly (bgstatus includeDone). showCompletedJobs: boolean; + // Render the built-in multiline status widget. Integrations can disable it + // and consume the `bgrun:status` event in a compact footer instead. + showWidget: boolean; // Log retention for cleanup (auto-sweeps and the bgclean default). cleanupDays: number; // Byte ceiling for a job's log (stdout+stderr). A runaway job (`yes`, a spew @@ -693,6 +696,7 @@ interface BgrunConfigFile { jobsDir?: unknown; adoptForeignJobs?: unknown; showCompletedJobs?: unknown; + showWidget?: unknown; cleanupDays?: unknown; maxLogBytes?: unknown; globalAutoClean?: unknown; @@ -1249,6 +1253,8 @@ export function resolveConfig(ctx?: { typeof merged.showCompletedJobs === "boolean" ? merged.showCompletedJobs : undefined; + const widgetFile = + typeof merged.showWidget === "boolean" ? merged.showWidget : undefined; const globalCleanFile = typeof merged.globalAutoClean === "boolean" ? merged.globalAutoClean @@ -1324,6 +1330,8 @@ export function resolveConfig(ctx?: { parseBoolEnv(process.env.PI_BGRUN_SHOW_COMPLETED) ?? completedFile ?? false, + showWidget: + parseBoolEnv(process.env.PI_BGRUN_SHOW_WIDGET) ?? widgetFile ?? true, cleanupDays: daysEnv ?? daysFile ?? DEFAULT_CLEANUP_DAYS, maxLogBytes: maxBytesEnv ?? maxBytesFile ?? DEFAULT_MAX_LOG_BYTES, globalAutoClean: @@ -1568,12 +1576,16 @@ export default function (pi: ExtensionAPI) { // Reconciliation is lifecycle state, not a UI side effect. Headless/RPC // sessions must persist terminal evidence too. revalidateStaleJobs({ persist: opts.persistRevalidate ?? true }); - if (!ctx.hasUI) return; const running: JobRecord[] = []; for (const rec of jobs.values()) { if (rec.exitCode === undefined) running.push(rec); } - if (running.length === 0) { + pi.events.emit("bgrun:status", { + running: running.length, + tracked: jobs.size, + }); + if (!ctx.hasUI) return; + if (!resolveConfig(ctx).showWidget || running.length === 0) { ctx.ui.setWidget("bgrun", undefined); return; }