brl-subagent 2.4.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/runner.ts CHANGED
@@ -26,13 +26,16 @@ import type {
26
26
  import {
27
27
  EMPTY_USAGE,
28
28
  SIGKILL_GRACE_MS,
29
+ SIGKILL_POLL_MS,
29
30
  TEMP_FILE_MODE,
30
31
  MAX_TEMP_DIR_AGE_MS,
31
32
  isProviderError,
32
33
  classifyError,
33
34
  SUBAGENT_ABORTED_MESSAGE,
35
+ SUBAGENT_SIGNAL_KILLED_MESSAGE,
34
36
  } from "./types";
35
- import { getSafeEnv, DEPTH_ENV_KEY, sanitizeErrorMessage } from "./sanitize";
37
+ import { escalateKill, type EscalationTarget } from "./kill-escalation";
38
+ import { getSafeEnv, DEPTH_ENV_KEY, CHILD_MARKER_ENV_KEY, sanitizeErrorMessage } from "./sanitize";
36
39
  import type { Logger } from "./logging";
37
40
  import type { Intercom } from "./messaging";
38
41
  import { extractMessages, formatPendingMessages } from "./messaging";
@@ -41,14 +44,159 @@ import type {
41
44
  TranscriptContentBlock,
42
45
  TranscriptToolCallBlock,
43
46
  } from "./transcript-tail";
47
+ // ---------------------------------------------------------------------------
48
+ // In-flight child registry (Option B U1)
49
+ // ---------------------------------------------------------------------------
50
+
51
+ /**
52
+ * One tracked foreground child. The registry is keyed by a UNIQUE internal
53
+ * spawn token (B5), never by the caller marker: two concurrent children may
54
+ * legitimately share a marker, and a marker-keyed map would collapse them —
55
+ * the first close would untrack the still-live second. `marker` is retained on
56
+ * the entry so reap/kill can still identify children by their caller marker.
57
+ */
58
+ interface ActiveChild {
59
+ proc: ChildProcess;
60
+ /** The caller-supplied child marker, when one was supplied. */
61
+ marker?: string;
62
+ }
63
+
64
+ /**
65
+ * Foreground child processes currently in flight, keyed by a process-unique
66
+ * spawn token. `reapActiveChildren` terminates every entry on `session_shutdown`
67
+ * so a clean extension exit never leaves an orphaned `pi` subprocess burning
68
+ * tokens.
69
+ *
70
+ * The registry is process-local and best-effort: a hard `kill -9` of the
71
+ * conductor cannot run this handler, which is exactly the case the boot scan
72
+ * (`src/recovery.ts`) covers on the next start.
73
+ */
74
+ const activeChildren = new Map<string, ActiveChild>();
75
+
76
+ /** Monotonic counter making every registry key unique (B5). */
77
+ let spawnTokenSeq = 0;
78
+
79
+ /** TEST-ONLY: how many foreground children are currently registered. */
80
+ export function activeChildCount(): number {
81
+ return activeChildren.size;
82
+ }
83
+
84
+ /** True once the child has really exited (a signal was delivered is not enough). */
85
+ function childExited(proc: ChildProcess): boolean {
86
+ return proc.exitCode !== null || proc.signalCode !== null;
87
+ }
88
+
89
+ /** Default real sleep between SIGTERM and the escalation re-check. */
90
+ const realSleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
91
+
92
+ /**
93
+ * SIGTERM → grace → SIGKILL timing for the single-child abort/timeout kill
94
+ * paths. Production uses the shared `SIGKILL_GRACE_MS` / `SIGKILL_POLL_MS`
95
+ * constants. The test-only setter below shortens them so the real-child
96
+ * escalation tests need not wait the full 5 s grace.
97
+ */
98
+ const escalationTiming = { graceMs: SIGKILL_GRACE_MS, pollMs: SIGKILL_POLL_MS };
99
+
100
+ /**
101
+ * Issue #306 nit 2: the timing seam is test-only and must be UNREACHABLE in
102
+ * production. This package ships raw `.ts` (loaded via jiti) with no build step
103
+ * to strip test code, so the strongest available guarantee is a runtime gate:
104
+ * outside the test environment the setter THROWS instead of mutating the
105
+ * process-wide SIGTERM→SIGKILL timing, so a stray internal import cannot move
106
+ * production behavior. `vitest` sets `NODE_ENV=test` and `VITEST=true` in every
107
+ * worker, so the recovery escalation tests still shorten the grace.
108
+ */
109
+ function isTestEnvironment(): boolean {
110
+ return process.env.NODE_ENV === "test" || process.env.VITEST === "true";
111
+ }
112
+
113
+ /** TEST-ONLY: shorten (or reset with no argument) the abort/timeout escalation timing. Throws outside tests. */
114
+ export function __setEscalationTimingForTest(timing?: { graceMs?: number; pollMs?: number }): void {
115
+ if (!isTestEnvironment()) {
116
+ throw new Error(
117
+ "__setEscalationTimingForTest() is test-only and cannot run in production.",
118
+ );
119
+ }
120
+ escalationTiming.graceMs = timing?.graceMs ?? SIGKILL_GRACE_MS;
121
+ escalationTiming.pollMs = timing?.pollMs ?? SIGKILL_POLL_MS;
122
+ }
123
+
124
+ /**
125
+ * Escalation target for one spawned child. `verifyDeath` defaults to the
126
+ * REAL-exit check (`childExited`) and may be overridden per site. The abort and
127
+ * timeout paths must use the real check (issue #303): `child.killed` flips
128
+ * when a signal is *sent*, not when the process dies, so gating the SIGKILL on
129
+ * it would let a SIGTERM-ignoring child survive the escalation.
130
+ */
131
+ function childTarget(proc: ChildProcess, verifyDeath: () => boolean = () => childExited(proc)): EscalationTarget {
132
+ return {
133
+ terminate: () => {
134
+ proc.kill("SIGTERM");
135
+ },
136
+ forceKill: () => {
137
+ proc.kill("SIGKILL");
138
+ },
139
+ verifyDeath,
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Terminate every registered in-flight child: SIGTERM all, wait up to the shared
145
+ * grace period (returning as soon as every child is dead — B4), then SIGKILL any
146
+ * survivor. Returns the pids targeted. Safe to call when nothing is in flight.
147
+ */
148
+ export async function reapActiveChildren(log?: Logger): Promise<number[]> {
149
+ const pids: number[] = [];
150
+ const targets: EscalationTarget[] = [];
151
+ for (const [token, entry] of activeChildren) {
152
+ if (entry.proc.pid === undefined) {
153
+ activeChildren.delete(token);
154
+ continue;
155
+ }
156
+ pids.push(entry.proc.pid);
157
+ targets.push(childTarget(entry.proc));
158
+ }
159
+ if (targets.length === 0) return [];
160
+
161
+ await escalateKill(targets, {
162
+ graceMs: SIGKILL_GRACE_MS,
163
+ pollMs: SIGKILL_POLL_MS,
164
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
165
+ });
166
+
167
+ log?.info("Reaped in-flight subagent children", { pids });
168
+ return pids;
169
+ }
170
+
44
171
  // ---------------------------------------------------------------------------
45
172
  // Pi binary resolution
46
173
  // ---------------------------------------------------------------------------
47
174
 
48
175
  /**
49
176
  * Resolve the pi binary and command-line invocation for subprocess spawning.
177
+ *
178
+ * Precedence:
179
+ * 1. `BRL_PI_BIN` — an explicit override, read per call. A non-empty value
180
+ * (after trimming surrounding whitespace) is used as the command;
181
+ * whitespace-only is ignored and falls through. This exists for the
182
+ * Tier-2 e2e harness
183
+ * (issue #271): it spawns a synthetic node process that loads the
184
+ * extension, so `process.argv[1]` is the HARNESS script, not pi's CLI.
185
+ * Without this override the argv[1] heuristic below re-runs the harness
186
+ * as if it were pi (a delegation no-op that still "passes"). Production
187
+ * behaviour is unchanged while the variable is unset.
188
+ * 2. `process.argv[1]` — a real script path (the dev/pi-CLI case). Spawn it
189
+ * with the current runtime.
190
+ * 3. A non-generic `process.execPath` (a bundled/custom binary) — spawn it
191
+ * directly.
192
+ * 4. `"pi"` on PATH.
50
193
  */
51
194
  export function getPiInvocation(extraArgs: string[]): { command: string; args: string[] } {
195
+ const override = process.env.BRL_PI_BIN?.trim();
196
+ if (override) {
197
+ return { command: override, args: extraArgs };
198
+ }
199
+
52
200
  const currentScript = process.argv[1];
53
201
  const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
54
202
  if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
@@ -220,10 +368,16 @@ function attachAbortHandler(
220
368
  // blind kill with no result access at all.
221
369
  result.errorMessage = SUBAGENT_ABORTED_MESSAGE;
222
370
  result.errorCategory = "aborted";
223
- proc.kill("SIGTERM");
224
- setTimeout(() => {
225
- if (!proc.killed) proc.kill("SIGKILL");
226
- }, SIGKILL_GRACE_MS);
371
+ // Issue #303: use childTarget's default REAL-exit check. `child.killed`
372
+ // only records that SIGTERM was SENT, so gating the SIGKILL on it declared
373
+ // a SIGTERM-ignoring child dead and skipped the force-kill. The same
374
+ // poll/early-exit wait `reapActiveChildren` uses means a cooperative child
375
+ // completes in ms while a stubborn one gets SIGKILL at the grace boundary.
376
+ void escalateKill([childTarget(proc)], {
377
+ graceMs: escalationTiming.graceMs,
378
+ pollMs: escalationTiming.pollMs,
379
+ sleep: realSleep,
380
+ });
227
381
  };
228
382
  if (signal.aborted) killProc();
229
383
  else signal.addEventListener("abort", killProc, { once: true });
@@ -706,7 +860,7 @@ export function enrichFailureDiagnostics(result: SubagentResult, cwd?: string):
706
860
  // Track 1 already stamped an honest source (user abort / timeout) — that is
707
861
  // authoritative and must not be overlaid with the provider-error fallback.
708
862
  const msg = result.errorMessage.toLowerCase();
709
- if (msg.includes("aborted") || msg.includes("timed out")) return;
863
+ if (msg.includes("aborted") || msg.includes("timed out") || msg.includes("killed by external signal")) return;
710
864
  const errorTurns = countModelErrorTurns(result.messages);
711
865
  if (errorTurns === 0) return;
712
866
 
@@ -738,6 +892,7 @@ export async function runSubagent(
738
892
  depth?: number,
739
893
  intercom?: Intercom,
740
894
  subagentId?: string,
895
+ childMarker?: string,
741
896
  ): Promise<SubagentResult> {
742
897
  // E10: Inject pending intercom messages into the task prompt
743
898
  if (intercom && subagentId && intercom.hasMessages(subagentId)) {
@@ -789,15 +944,29 @@ ${msgBlock}`;
789
944
  const exitCode = await new Promise<number>((resolve) => {
790
945
  const invocation = getPiInvocation(args);
791
946
  const subDepth = depth !== undefined ? depth : undefined;
792
- const envOverrides: Record<string, string> | undefined =
793
- subDepth !== undefined ? { [DEPTH_ENV_KEY]: String(subDepth) } : undefined;
947
+ const envOverrides: Record<string, string> = {};
948
+ if (subDepth !== undefined) envOverrides[DEPTH_ENV_KEY] = String(subDepth);
949
+ // Option B U1: stamp the per-run marker so boot recovery can discover
950
+ // this child by scanning /proc/*/environ after a conductor crash.
951
+ if (childMarker) envOverrides[CHILD_MARKER_ENV_KEY] = childMarker;
952
+ const hasEnvOverrides = Object.keys(envOverrides).length > 0;
794
953
  const proc = spawn(invocation.command, invocation.args, {
795
954
  cwd,
796
955
  shell: false,
797
956
  stdio: ["ignore", "pipe", "pipe"],
798
- env: getSafeEnv(envOverrides), // F2: Environment isolation + depth tracking
957
+ env: getSafeEnv(hasEnvOverrides ? envOverrides : undefined), // F2: Environment isolation + depth tracking
799
958
  });
800
959
 
960
+ // Option B U1: register the child for shutdown reap. Keyed by the
961
+ // marker when present (guaranteed unique per run), else the intercom
962
+ // id, else a generated fallback.
963
+ // Option B U1 + B5: register the child under a UNIQUE spawn token, not the
964
+ // caller marker — two concurrent children may share a marker, and a
965
+ // marker-keyed map would collapse them (one close untracking the other).
966
+ // The marker is kept on the entry for reap/kill identification.
967
+ const registryToken = `spawn-${process.pid}-${++spawnTokenSeq}`;
968
+ activeChildren.set(registryToken, { proc, marker: childMarker });
969
+
801
970
  let buffer = "";
802
971
 
803
972
  proc.stdout.on("data", (data: Buffer) => {
@@ -813,15 +982,32 @@ ${msgBlock}`;
813
982
  result.stderr += data.toString();
814
983
  });
815
984
 
816
- proc.on("close", (code) => {
985
+ proc.on("close", (code, signal) => {
986
+ activeChildren.delete(registryToken);
817
987
  if (buffer.trim()) {
818
988
  parseSubagentLine(buffer, result, onUpdate, getFinalOutputFn, log);
819
989
  }
820
- log?.info("Subagent process exited", { exitCode: code, pid: proc.pid });
821
- resolve(code ?? 0);
990
+ log?.info("Subagent process exited", { exitCode: code, signal, pid: proc.pid });
991
+ // Issue #295: Node gives NO numeric code for a signal death
992
+ // (code === null) — the old `code ?? 0` fabricated a clean exit and
993
+ // masked a SIGKILL'd run as a success. Always record the non-zero
994
+ // sentinel -1, REGARDLESS of whether a reason was already staged:
995
+ // our own timeout/abort SIGTERM/SIGKILL lands here with code === null
996
+ // too, and a masked 0 would hide it behind the same false 'done'.
997
+ result.exitCode = code === null ? -1 : code;
998
+ if (code === null && !result.errorMessage && !result.errorCategory) {
999
+ // No reason staged (a timeout/abort stamp already carries its own)
1000
+ // — this was an external kill we did not initiate. Stamp the cause
1001
+ // and the signal name; classifyError maps it to the existing
1002
+ // "crash" category (no new ErrorCategory value).
1003
+ result.errorMessage = `${SUBAGENT_SIGNAL_KILLED_MESSAGE} (${signal ?? "unknown"})`;
1004
+ log?.warn("Subagent killed by external signal", { signal, pid: proc.pid });
1005
+ }
1006
+ resolve(result.exitCode);
822
1007
  });
823
1008
 
824
1009
  proc.on("error", (err) => {
1010
+ activeChildren.delete(registryToken);
825
1011
  // F7 (issue #30): subprocess errors can embed the spawn command
826
1012
  // (absolute paths to the pi binary, temp files, cwd) — sanitize the
827
1013
  // errorMessage before it reaches the conductor. stderr is left RAW:
@@ -842,10 +1028,14 @@ ${msgBlock}`;
842
1028
  const timer = setTimeout(() => {
843
1029
  result.errorMessage = `Subagent timed out after ${timeout}ms`;
844
1030
  log?.warn("Subagent timed out", { timeout, pid: proc.pid });
845
- proc.kill("SIGTERM");
846
- setTimeout(() => {
847
- if (!proc.killed) proc.kill("SIGKILL");
848
- }, SIGKILL_GRACE_MS);
1031
+ // Same REAL-exit check as the abort path (issue #303 — see there):
1032
+ // `child.killed` flips on signal-sent, not process-death, so it would
1033
+ // skip the SIGKILL for a SIGTERM-ignoring child.
1034
+ void escalateKill([childTarget(proc)], {
1035
+ graceMs: escalationTiming.graceMs,
1036
+ pollMs: escalationTiming.pollMs,
1037
+ sleep: realSleep,
1038
+ });
849
1039
  }, timeout);
850
1040
  proc.on("close", () => clearTimeout(timer));
851
1041
  }
package/src/sanitize.ts CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  import * as path from "node:path";
10
10
  import * as fs from "node:fs";
11
- import { EMPTY_USAGE } from "./types";
11
+ import { EMPTY_USAGE, DEFAULT_OUTPUT_CAP_BYTES } from "./types";
12
12
 
13
13
  // ---------------------------------------------------------------------------
14
14
  // F1: Input sanitization
@@ -209,6 +209,28 @@ export function stripAnsi(str: string): string {
209
209
  return str.replace(ANSI_ESCAPE_RE, "");
210
210
  }
211
211
 
212
+ /**
213
+ * End-anchored trailing marker `capOutput` appends when it truncates output.
214
+ * Exported (issue #261) so callers can detect a genuinely capped record by its
215
+ * notice instead of re-deriving the format; a byte-length comparison cannot
216
+ * tell a capped record from an uncapped one (the background/crash paths store
217
+ * raw `liveOutput` without calling `capOutput`).
218
+ *
219
+ * Reworded in issue #275 to point at the transcript (run-history details now
220
+ * show the capped text). `capOutput` writes ONLY this suffix.
221
+ */
222
+ export const OUTPUT_TRUNCATION_SUFFIX =
223
+ "omitted. Full text in the run transcript.]";
224
+
225
+ /**
226
+ * Pre-#275 truncation marker, retained for DETECTION ONLY. Session files
227
+ * written before the reword persist records capped with this suffix; matching
228
+ * it keeps their truncation honesty line from silently regressing. No code
229
+ * writes this suffix.
230
+ */
231
+ export const LEGACY_OUTPUT_TRUNCATION_SUFFIX =
232
+ "omitted. Full output available in run history details.]";
233
+
212
234
  /**
213
235
  * Cap output size to prevent subagent results from overwhelming
214
236
  * the conductor's context window or TUI.
@@ -216,7 +238,7 @@ export function stripAnsi(str: string): string {
216
238
  * Returns the original string if within limits, or a truncated version
217
239
  * with a clear notice about how much was omitted.
218
240
  */
219
- export function capOutput(output: string, maxBytes: number = 100 * 1024): string {
241
+ export function capOutput(output: string, maxBytes: number = DEFAULT_OUTPUT_CAP_BYTES): string {
220
242
  const byteLength = Buffer.byteLength(output, "utf8");
221
243
  if (byteLength <= maxBytes) return output;
222
244
 
@@ -227,7 +249,24 @@ export function capOutput(output: string, maxBytes: number = 100 * 1024): string
227
249
  }
228
250
 
229
251
  const omitted = byteLength - Buffer.byteLength(truncated, "utf8");
230
- return `${truncated}\n\n[Output truncated: ${formatBytes(omitted)} omitted. Full output available in run history details.]`;
252
+ return `${truncated}\n\n[Output truncated: ${formatBytes(omitted)} ${OUTPUT_TRUNCATION_SUFFIX}`;
253
+ }
254
+
255
+ /**
256
+ * True when `output` was truncated by `capOutput` — i.e. it carries the exact,
257
+ * end-anchored truncation notice. This is the authoritative "hit the cap"
258
+ * signal: `capOutput` returns the input unchanged while it fits, so a capped
259
+ * record always ends with this suffix and a non-capped one never does.
260
+ *
261
+ * Matches the legacy (#275) suffix too, so records persisted before the reword
262
+ * keep detecting as truncated.
263
+ */
264
+ export function isOutputTruncated(output: string | undefined | null): boolean {
265
+ return (
266
+ typeof output === "string" &&
267
+ (output.endsWith(OUTPUT_TRUNCATION_SUFFIX) ||
268
+ output.endsWith(LEGACY_OUTPUT_TRUNCATION_SUFFIX))
269
+ );
231
270
  }
232
271
 
233
272
  function formatBytes(bytes: number): string {
@@ -354,6 +393,17 @@ export function buildCrashResult(mode: string, err: unknown, cwd: string): {
354
393
  /** Env var used to track subagent nesting depth. */
355
394
  export const DEPTH_ENV_KEY = "BRL_SUBAGENT_DEPTH";
356
395
 
396
+ /**
397
+ * Option B U1: env var carrying the per-run child marker injected into a
398
+ * foreground child's environment. Boot recovery scans `/proc/<pid>/environ` for
399
+ * this exact `KEY=value` pair to discover an orphaned child whose conductor
400
+ * died. It is passed via spawn-time overrides (never inherited from the
401
+ * parent's environment, because it is not in SAFE_ENV_KEYS), so an unrelated
402
+ * process can never carry it. Linux/POSIX only; non-/proc platforms skip the
403
+ * scan (see recovery.ts).
404
+ */
405
+ export const CHILD_MARKER_ENV_KEY = "BRL_SUBAGENT_CHILD_MARKER";
406
+
357
407
  /**
358
408
  * Read the current subagent depth from the environment.
359
409
  * Returns 0 for the main (conductor) process, 1+ for nested subagents.
@@ -1,9 +1,9 @@
1
1
  // Purpose: Background execution: SDK sessions (`createAgentSession`), agent records, timeouts, steering, and settle paths.
2
2
  import { randomUUID } from 'crypto';
3
3
  import { join } from 'path';
4
- import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'fs';
4
+ import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync } from 'fs';
5
5
  import type { BackgroundAgent, AgentStatus, GitMode, SubagentResult, SubagentRun, ThinkingLevel, SubagentToolOptions, UsageStats, ErrorCategory } from './types';
6
- import { EMPTY_USAGE, CUSTOM_ENTRY_TYPES, classifyError, classifyTerminalOutcome, isProviderError, coherentFailureReason, SUBAGENT_ABORTED_MESSAGE } from './types';
6
+ import { EMPTY_USAGE, classifyError, classifyTerminalOutcome, isProviderError, coherentFailureReason, SUBAGENT_ABORTED_MESSAGE } from './types';
7
7
  import { accumulateUsage } from './runner';
8
8
  import * as eventBus from './event-bus';
9
9
  import * as transcript from './transcript';
@@ -11,8 +11,11 @@ import { createEvent } from './event-bus';
11
11
  import { assertSafeAgentId, sanitizeErrorMessage } from './sanitize';
12
12
  import { wrapTask } from './prompt';
13
13
  import { createLogger } from './logging';
14
- import { getCurrentBranch, createWorkBranch, captureDiff, switchToBranch, deleteBranch, hasUncommittedChanges, getRepoRoot, commitAll, captureWorkingDiff } from './git';
14
+ import { getCurrentBranch, getHeadState, restoreHead, createWorkBranch, captureDiff, deleteBranch, hasUncommittedChanges, getRepoRoot, commitAll, captureWorkingDiff } from './git';
15
+ import type { HeadState } from './git';
15
16
  import { normalizeTimeout, DEFAULT_BACKGROUND_DEADLINE_MS } from './validate';
17
+ import { currentProcessOwner } from './recovery';
18
+ import { persistRunRecord } from './run-registry';
16
19
 
17
20
  const log = createLogger('brl-subagent');
18
21
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
@@ -85,7 +88,7 @@ function persistAgent(agent: BackgroundAgent): void {
85
88
  try {
86
89
  assertSafeAgentId(agent.id);
87
90
  } catch {
88
- console.error(`[brl-subagent] Refusing to persist agent with invalid id: ${agent.id}`);
91
+ log.error(`Refusing to persist agent with invalid id: ${agent.id}`);
89
92
  return;
90
93
  }
91
94
  ensureStorageDir();
@@ -102,7 +105,7 @@ function persistAgent(agent: BackgroundAgent): void {
102
105
  writeFileSync(filePath, JSON.stringify(persistable, null, 2), { encoding: 'utf-8', mode: 0o600 });
103
106
  } catch (err) {
104
107
  // Log but never throw — persistence must not break execution
105
- console.error(`[brl-subagent] Failed to persist agent ${agent.id}:`, err);
108
+ log.error(`Failed to persist agent ${agent.id}`, { error: err instanceof Error ? err.message : String(err) });
106
109
  }
107
110
  }
108
111
 
@@ -136,6 +139,54 @@ export function getAgent(id: string): BackgroundAgent | null {
136
139
  return agents.get(id) || loadAgent(id);
137
140
  }
138
141
 
142
+ /**
143
+ * Option B U1: every agent record persisted on disk (cross-session — the
144
+ * `.pi/subagents/` store survives a process death; the conductor's session run
145
+ * entries do not). Boot recovery does NOT use this: it sources candidates from
146
+ * the durable run registry and reads background records ONE at a time by id
147
+ * (`recoverInflightRuns`'s `readAgentRecord`), precisely to avoid parsing the
148
+ * whole store; production has no caller for this sweep. Unreadable/corrupt
149
+ * files and unsafe ids are skipped, never thrown.
150
+ */
151
+ export function listPersistedAgents(): BackgroundAgent[] {
152
+ let files: string[];
153
+ try {
154
+ if (!existsSync(STORAGE_DIR)) return [];
155
+ files = readdirSync(STORAGE_DIR);
156
+ } catch {
157
+ return [];
158
+ }
159
+ const records: BackgroundAgent[] = [];
160
+ for (const file of files) {
161
+ if (!file.endsWith('.json')) continue;
162
+ const id = file.slice(0, -'.json'.length);
163
+ try {
164
+ assertSafeAgentId(id);
165
+ } catch {
166
+ continue;
167
+ }
168
+ const record = loadAgent(id);
169
+ if (record) records.push(record);
170
+ }
171
+ return records;
172
+ }
173
+
174
+ /**
175
+ * Option B U1 (D3): read-modify-write the interrupted mark onto a persisted
176
+ * agent record. Idempotent: an already-marked record is returned unchanged.
177
+ * Returns null when the record does not exist.
178
+ */
179
+ export function markAgentInterrupted(id: string, interruptedAt: string): BackgroundAgent | null {
180
+ const agent = getAgent(id);
181
+ if (!agent) return null;
182
+ if (!agent.interruptedAt) {
183
+ agent.interruptedAt = interruptedAt;
184
+ agents.set(id, agent);
185
+ persistAgent(agent);
186
+ }
187
+ return agent;
188
+ }
189
+
139
190
  /**
140
191
  * Update agent status
141
192
  */
@@ -320,15 +371,13 @@ export async function steerAgent(id: string, message: string): Promise<Backgroun
320
371
  }
321
372
 
322
373
  /**
323
- * Get transcript path for an agent
374
+ * Get transcript path for an agent.
375
+ *
376
+ * Issue #276: the format lives in `transcript-path.ts` (the single source).
377
+ * This re-export preserves the historical `session-manager.getTranscriptPath`
378
+ * surface for `index.ts`'s dynamic import and the existing tests.
324
379
  */
325
- export function getTranscriptPath(id: string): string {
326
- // F24: reachable with LLM-controlled ids (get_agent_result). Throw on invalid
327
- // ids — the tool caller surfaces it as an error. (Defense in depth: getAgent
328
- // already validated before this is reached with a live agent.)
329
- assertSafeAgentId(id);
330
- return join('.pi', 'output', `agent-${id}.jsonl`);
331
- }
380
+ export { transcriptPath as getTranscriptPath } from './transcript-path';
332
381
 
333
382
  /**
334
383
  * Spawn a background session using pi's session API
@@ -359,6 +408,11 @@ export async function spawnBackgroundSession(
359
408
  /** Issue #114: per-unit priority — recorded on the run entry + agent record
360
409
  * so the drill-in header and history carry it (absent → no segment). */
361
410
  priority?: string;
411
+ /** Option B U1: durability identity. A retry inherits the original's
412
+ * dispatchId and records resumeOf/attempt; a fresh dispatch omits them. */
413
+ dispatchId?: string;
414
+ resumeOf?: string;
415
+ attempt?: number;
362
416
  }
363
417
  ): Promise<BackgroundAgent> {
364
418
  // Serialize access to pi API to prevent concurrent import races
@@ -469,6 +523,13 @@ export async function spawnBackgroundSession(
469
523
  // Set session name
470
524
  session.setSessionName(`background-${id.slice(0, 8)}`);
471
525
 
526
+ // Option B U1: durability identity. Background runs have no child process
527
+ // (the session is in-process), so there is no childMarker — only the owner
528
+ // identity that a boot scan uses to tell a live conductor from a dead one.
529
+ const dispatchId = params.dispatchId ?? generateUUID();
530
+ const attempt = params.attempt ?? 1;
531
+ const owner = currentProcessOwner();
532
+
472
533
  // Create agent record
473
534
  const agent: BackgroundAgent = {
474
535
  id,
@@ -481,6 +542,10 @@ export async function spawnBackgroundSession(
481
542
  model: params.model || 'unknown',
482
543
  thinkingLevel: params.thinkingLevel || 'medium',
483
544
  priority: params.priority,
545
+ dispatchId,
546
+ resumeOf: params.resumeOf,
547
+ attempt,
548
+ owner,
484
549
  };
485
550
 
486
551
  agents.set(id, agent);
@@ -506,8 +571,12 @@ export async function spawnBackgroundSession(
506
571
  priority: params.priority,
507
572
  startedAt: new Date().toISOString(),
508
573
  originalParams: params.originalParams,
574
+ dispatchId,
575
+ resumeOf: params.resumeOf,
576
+ attempt,
577
+ owner,
509
578
  };
510
- pi.appendEntry(CUSTOM_ENTRY_TYPES.run, run);
579
+ persistRunRecord(pi, run, "background");
511
580
 
512
581
  // Issue #98: keep the session run entry in lockstep with the agent record —
513
582
  // every terminal branch that flips agent.status also finalizes the run entry
@@ -576,7 +645,7 @@ export async function spawnBackgroundSession(
576
645
  ? { ...(run.originalParams ?? {}), errorCategory }
577
646
  : run.originalParams,
578
647
  };
579
- pi.appendEntry(CUSTOM_ENTRY_TYPES.run, entry);
648
+ persistRunRecord(pi, entry);
580
649
  // Issue #179 (D6): the explicit settle line — the log's job is to record the
581
650
  // CLASSIFIED OUTCOME, which cwd alone can never convey.
582
651
  log.info("Background run settled", {
@@ -661,9 +730,14 @@ export async function spawnBackgroundSession(
661
730
  // hard failure — never spawn an unisolated background agent that was asked
662
731
  // for isolation (fail-loud, same principle as foreground's fallback but
663
732
  // background cannot warn-and-continue safely).
664
- let originalBranch: string | undefined;
733
+ let headState: HeadState | undefined;
665
734
  let workBranchName: string | undefined;
666
735
  let releaseGitLock: (() => void) | undefined;
736
+ // Idempotency guard for cleanupWorkBranch: it must be safe to call from both
737
+ // the normal settle path and the catch-all that runs when a settle step
738
+ // throws FIRST (before its own cleanup call). Without this the second call
739
+ // would re-restore/re-delete and log spurious warnings.
740
+ let gitCleanupDone = false;
667
741
  const gitCwd = params.cwd ?? ctx.cwd;
668
742
  if (params.gitMode === 'branch') {
669
743
  // C2: serialize the FULL branch lifecycle per repo. The spawnQueue only
@@ -696,13 +770,13 @@ export async function spawnBackgroundSession(
696
770
  'requesting background gitMode=branch isolation'
697
771
  );
698
772
  }
699
- originalBranch = getCurrentBranch(gitCwd);
700
- const branchResult = createWorkBranch(gitCwd, originalBranch);
773
+ headState = getHeadState(gitCwd);
774
+ const branchResult = createWorkBranch(gitCwd, headState.sha);
701
775
  if (branchResult.ok) {
702
776
  workBranchName = branchResult.branch;
703
777
  log.info('Created work branch for background agent', {
704
778
  branch: workBranchName,
705
- base: originalBranch,
779
+ base: headState.branch,
706
780
  agentId: id,
707
781
  });
708
782
  } else {
@@ -740,7 +814,8 @@ export async function spawnBackgroundSession(
740
814
  // uncommitted edits would leak into the base working tree on switch —
741
815
  // commitAll first makes the diff real and the switch clean.
742
816
  const cleanupWorkBranch = (): { gitBranch?: string; gitDiff?: string } => {
743
- if (!workBranchName || !originalBranch) return {};
817
+ if (gitCleanupDone || !workBranchName || !headState) return {};
818
+ gitCleanupDone = true;
744
819
  const info: { gitBranch?: string; gitDiff?: string } = { gitBranch: workBranchName };
745
820
  // The lock MUST be released on every exit path — early returns included.
746
821
  try {
@@ -778,7 +853,7 @@ export async function spawnBackgroundSession(
778
853
  if (workingDiff) info.gitDiff = workingDiff;
779
854
  }
780
855
  }
781
- const diffResult = captureDiff(gitCwd, originalBranch);
856
+ const diffResult = captureDiff(gitCwd, headState.sha);
782
857
  if (diffResult.ok && diffResult.diff.trim()) {
783
858
  // Merge committed + working diffs — either can exist alone.
784
859
  info.gitDiff = info.gitDiff
@@ -789,9 +864,12 @@ export async function spawnBackgroundSession(
789
864
  log.warn(`captureDiff failed for background agent ${id}`, { error: (err as Error).message });
790
865
  }
791
866
  try {
792
- switchToBranch(gitCwd, originalBranch);
867
+ const restoreResult = restoreHead(gitCwd, headState);
868
+ if (!restoreResult.ok) {
869
+ log.warn(`restoreHead failed for background agent ${id}`, { error: restoreResult.error });
870
+ }
793
871
  } catch (err) {
794
- log.warn(`switchToBranch failed for background agent ${id}`, { error: (err as Error).message });
872
+ log.warn(`restoreHead failed for background agent ${id}`, { error: (err as Error).message });
795
873
  }
796
874
  try {
797
875
  deleteBranch(gitCwd, workBranchName);
@@ -812,6 +890,11 @@ export async function spawnBackgroundSession(
812
890
  // terminal so no zombie 'running' record survives; never rethrow.
813
891
  const markTerminalBestEffort = (fallback: AgentStatus): void => {
814
892
  try {
893
+ // Issue #53 / review C: this catch-all fires when a settle handler step
894
+ // threw — possibly BEFORE the branch's own cleanupWorkBranch() call. Run
895
+ // it here too (idempotent) so a throw can never bypass branch teardown
896
+ // and strand the work branch in the repo working tree.
897
+ cleanupWorkBranch();
815
898
  // Issue #31 (PR #76 review): this catch-all fires when a settle handler
816
899
  // threw — either BEFORE the branch's own capture (ref still live: capture
817
900
  // now, it is the last chance to record the session's output) or AFTER it