brl-subagent 2.4.0 → 2.5.1

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,25 @@ 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 {
38
+ escalateKill,
39
+ pidTarget,
40
+ processTarget,
41
+ supportsProcessGroupKill,
42
+ groupTarget,
43
+ type EscalationTarget,
44
+ type SignalGuard,
45
+ } from "./kill-escalation";
46
+ import { findByMarker, pidAlive, pidHasMarker, pidGone, groupHasMembers, killProcessGroup } from "./proc";
47
+ import { getSafeEnv, DEPTH_ENV_KEY, CHILD_MARKER_ENV_KEY, sanitizeErrorMessage } from "./sanitize";
36
48
  import type { Logger } from "./logging";
37
49
  import type { Intercom } from "./messaging";
38
50
  import { extractMessages, formatPendingMessages } from "./messaging";
@@ -41,14 +53,319 @@ import type {
41
53
  TranscriptContentBlock,
42
54
  TranscriptToolCallBlock,
43
55
  } from "./transcript-tail";
56
+ // ---------------------------------------------------------------------------
57
+ // In-flight child registry (Option B U1)
58
+ // ---------------------------------------------------------------------------
59
+
60
+ /**
61
+ * One tracked foreground child. The registry is keyed by a UNIQUE internal
62
+ * spawn token (B5), never by the caller marker: two concurrent children may
63
+ * legitimately share a marker, and a marker-keyed map would collapse them —
64
+ * the first close would untrack the still-live second. `marker` is retained on
65
+ * the entry so reap/kill can still identify children by their caller marker.
66
+ */
67
+ interface ActiveChild {
68
+ proc: ChildProcess;
69
+ /** The caller-supplied child marker, when one was supplied. */
70
+ marker?: string;
71
+ }
72
+
73
+ /**
74
+ * Foreground child processes currently in flight, keyed by a process-unique
75
+ * spawn token. `reapActiveChildren` terminates every entry on `session_shutdown`
76
+ * so a clean extension exit never leaves an orphaned `pi` subprocess burning
77
+ * tokens.
78
+ *
79
+ * The registry is process-local and best-effort: a hard `kill -9` of the
80
+ * conductor cannot run this handler, which is exactly the case the boot scan
81
+ * (`src/recovery.ts`) covers on the next start.
82
+ */
83
+ const activeChildren = new Map<string, ActiveChild>();
84
+
85
+ /** Monotonic counter making every registry key unique (B5). */
86
+ let spawnTokenSeq = 0;
87
+
88
+ /** TEST-ONLY: how many foreground children are currently registered. */
89
+ export function activeChildCount(): number {
90
+ return activeChildren.size;
91
+ }
92
+
93
+ /** True once the child has really exited (a signal was delivered is not enough). */
94
+ function childExited(proc: ChildProcess): boolean {
95
+ // `!= null` (not `!== null`): a real ChildProcess uses `null` before exit, but
96
+ // narrow test doubles omit the field (`undefined`), which must also read as
97
+ // "not exited" so the liveness re-check does not skip a live child's TERM.
98
+ return proc.exitCode != null || proc.signalCode != null;
99
+ }
100
+
101
+ /** Default real sleep between SIGTERM and the escalation re-check. */
102
+ const realSleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
103
+
104
+ /**
105
+ * SIGTERM → grace → SIGKILL timing for the single-child abort/timeout kill
106
+ * paths. Production uses the shared `SIGKILL_GRACE_MS` / `SIGKILL_POLL_MS`
107
+ * constants. The test-only setter below shortens them so the real-child
108
+ * escalation tests need not wait the full 5 s grace.
109
+ */
110
+ const escalationTiming = { graceMs: SIGKILL_GRACE_MS, pollMs: SIGKILL_POLL_MS };
111
+
112
+ /**
113
+ * Issue #306 nit 2: the timing seam is test-only and must be UNREACHABLE in
114
+ * production. This package ships raw `.ts` (loaded via jiti) with no build step
115
+ * to strip test code, so the strongest available guarantee is a runtime gate:
116
+ * outside the test environment the setter THROWS instead of mutating the
117
+ * process-wide SIGTERM→SIGKILL timing, so a stray internal import cannot move
118
+ * production behavior. `vitest` sets `NODE_ENV=test` and `VITEST=true` in every
119
+ * worker, so the recovery escalation tests still shorten the grace.
120
+ */
121
+ function isTestEnvironment(): boolean {
122
+ return process.env.NODE_ENV === "test" || process.env.VITEST === "true";
123
+ }
124
+
125
+ /** TEST-ONLY: shorten (or reset with no argument) the abort/timeout escalation timing. Throws outside tests. */
126
+ export function __setEscalationTimingForTest(timing?: { graceMs?: number; pollMs?: number }): void {
127
+ if (!isTestEnvironment()) {
128
+ throw new Error(
129
+ "__setEscalationTimingForTest() is test-only and cannot run in production.",
130
+ );
131
+ }
132
+ escalationTiming.graceMs = timing?.graceMs ?? SIGKILL_GRACE_MS;
133
+ escalationTiming.pollMs = timing?.pollMs ?? SIGKILL_POLL_MS;
134
+ }
135
+
136
+ /**
137
+ * #322: TEST-ONLY registry seam. Register an arbitrary child (and optional
138
+ * marker) under a fresh unique spawn token so a test can drive the shutdown reap
139
+ * in isolation — with no `runSubagent` in the picture, the run's own exit sweep
140
+ * cannot be the killer and the reap's death assertion is attributable.
141
+ *
142
+ * Returns a deregister function that deletes that token. Throws outside the
143
+ * test environment (same runtime gate as `__setEscalationTimingForTest`): the
144
+ * registry is production state, and a stray import must never be able to point
145
+ * a real reap at a process the runner did not spawn.
146
+ */
147
+ export function __registerActiveChildForTest(proc: ChildProcess, marker?: string): () => void {
148
+ if (!isTestEnvironment()) {
149
+ throw new Error(
150
+ "__registerActiveChildForTest() is test-only and cannot run in production.",
151
+ );
152
+ }
153
+ const token = `spawn-${process.pid}-${++spawnTokenSeq}`;
154
+ activeChildren.set(token, { proc, marker });
155
+ return () => {
156
+ activeChildren.delete(token);
157
+ };
158
+ }
159
+
160
+ /**
161
+ * Escalation target for one spawned child. `verifyDeath` defaults to the
162
+ * REAL-exit check (`childExited`) AND — on a group target — group emptiness, and
163
+ * may be overridden per site. The abort and timeout paths must use the real
164
+ * check (issue #303): `child.killed` flips when a signal is *sent*, not when the
165
+ * process dies, so gating the SIGKILL on it would let a SIGTERM-ignoring child
166
+ * survive the escalation.
167
+ *
168
+ * #299 Option B D2: on POSIX the target ALSO signals the child's process group
169
+ * (best-effort) and unconditionally SIGKILLs it after the grace window
170
+ * (`groupTarget`), so an env-scrubbed grandchild — invisible to the marker scan
171
+ * — dies with the child on abort/timeout/shutdown. The direct `proc.kill`
172
+ * signal is never replaced. On Windows (`supportsProcessGroupKill()` false,
173
+ * D6) this is exactly today's direct-only target.
174
+ *
175
+ * #299 review (Major 1 + Major 2):
176
+ * - The tracked `ChildProcess` handle is its OWN identity class — it is not
177
+ * marker-derived, so there is no marker guard here; terminate/forceKill stay
178
+ * unconditional (D5). We add only a TERMINATE-time liveness re-check (skip
179
+ * when the child is already confirmed exited). The force-kill window is
180
+ * in-session and µs-class: the live handle cannot be reused by a foreign
181
+ * process while we hold it.
182
+ * - Major 2: the default death check becomes `childExited(proc) &&
183
+ * groupHasMembers(pid) === false` so a descendant still cleaning up keeps the
184
+ * grace window open. #299 fix A.2 makes that reader THREE-state: only a
185
+ * POSITIVELY empty group (`false`) permits the early exit; an UNKNOWN
186
+ * inspection (`undefined`) keeps the target alive. When /proc is unavailable
187
+ * as a PLATFORM (`groupHasMembers` false) it
188
+ * degrades to child-exited liveness — the documented platform boundary.
189
+ */
190
+ function childTarget(proc: ChildProcess, verifyDeath?: () => boolean): EscalationTarget {
191
+ const pid = proc.pid ?? 0;
192
+ const killDirect = (_pid: number, signal: NodeJS.Signals): void => {
193
+ proc.kill(signal);
194
+ };
195
+ const useGroup = supportsProcessGroupKill() && proc.pid !== undefined;
196
+ const base = useGroup
197
+ ? groupTarget(pid, killDirect, killProcessGroup, pidAlive, groupHasMembers)
198
+ : pidTarget(pid, killDirect, pidAlive);
199
+ const defaultVerifyDeath = useGroup
200
+ ? () => childExited(proc) && groupHasMembers(pid) === false
201
+ : () => childExited(proc);
202
+ return {
203
+ ...base,
204
+ terminate: () => {
205
+ // #299 review Major 1: an already-exited child is not signaled again.
206
+ if (childExited(proc)) return;
207
+ base.terminate();
208
+ },
209
+ verifyDeath: verifyDeath ?? defaultVerifyDeath,
210
+ };
211
+ }
212
+
213
+ /** Send a signal to a pid; the ONE production kill primitive for marker sweeps. */
214
+ const killPid = (pid: number, signal: NodeJS.Signals): void => {
215
+ process.kill(pid, signal);
216
+ };
217
+
218
+ /**
219
+ * #299 Option A: union the marker-carrying descendants of the given markers into
220
+ * pid-based escalation targets. A spawned child stamps its own subprocesses with
221
+ * the same per-run marker env, so `findByMarker` surfaces grandchildren the
222
+ * `ChildProcess`-based targets cannot see. `excludePids` drops anything already
223
+ * targeted (the tracked direct children). Markers are per-run UUIDs, so a sweep
224
+ * cannot reach another run's tree.
225
+ *
226
+ * #299 review Major 1: every pid here is marker-derived, so its targets carry a
227
+ * `SignalGuard` that re-reads the LIVE marker (`pidHasMarker`) immediately before
228
+ * each signal. A pid that exited and was reused by a foreign process no longer
229
+ * carries the marker, so both the group and direct signals are SKIPPED and the
230
+ * skip is logged — never an unverified signal. `log` is optional (the sweep and
231
+ * shutdown calls pass their logger).
232
+ */
233
+ function collectMarkerTargets(
234
+ markers: Iterable<string | undefined>,
235
+ excludePids: ReadonlySet<number>,
236
+ log?: Logger,
237
+ ): { pids: number[]; targets: EscalationTarget[] } {
238
+ const seen = new Set<number>();
239
+ const pids: number[] = [];
240
+ const targets: EscalationTarget[] = [];
241
+ // D3 (#299): a marker-verified pid is identity proof, so its group is also
242
+ // signaled best-effort. When the carrier is a group leader (the spawned child
243
+ // or a descendant that `setsid`'d) `-pid` reaches its env-scrubbed
244
+ // descendants; when it is merely a member, `-pid` is ESRCH and the direct
245
+ // signal still lands.
246
+ const groupKill = supportsProcessGroupKill() ? killProcessGroup : undefined;
247
+ for (const marker of markers) {
248
+ if (!marker) continue;
249
+ for (const pid of findByMarker(marker)) {
250
+ if (!Number.isInteger(pid) || pid <= 0) continue;
251
+ if (excludePids.has(pid) || seen.has(pid)) continue;
252
+ seen.add(pid);
253
+ pids.push(pid);
254
+ const guard: SignalGuard = {
255
+ isOwn: (targetPid) => pidHasMarker(targetPid, marker),
256
+ isGone: (targetPid) => pidGone(targetPid),
257
+ onSkip: (targetPid, signal, phase) =>
258
+ log?.warn("Skipped marker signal: pid no longer carries the run marker", {
259
+ pid: targetPid,
260
+ signal,
261
+ phase,
262
+ marker,
263
+ }),
264
+ };
265
+ targets.push(processTarget(pid, killPid, pidAlive, groupKill, groupHasMembers, guard));
266
+ }
267
+ }
268
+ return { pids, targets };
269
+ }
270
+
271
+ /**
272
+ * #299 Option A: kill every pid still carrying `marker` once the direct child has
273
+ * exited — the marker-inheriting descendants (grandchildren) that would
274
+ * otherwise survive the run in-session (probe 1c). No marker / no match is a
275
+ * no-op. Uses the same SIGTERM → grace → SIGKILL window (and test-only timing
276
+ * seam) as every other kill path; never rejects.
277
+ */
278
+ async function sweepMarkerChildren(
279
+ marker: string | undefined,
280
+ excludePids: ReadonlySet<number>,
281
+ log?: Logger,
282
+ ): Promise<number[]> {
283
+ const { pids, targets } = collectMarkerTargets([marker], excludePids, log);
284
+ if (targets.length === 0) return [];
285
+ try {
286
+ await escalateKill(targets, {
287
+ graceMs: escalationTiming.graceMs,
288
+ pollMs: escalationTiming.pollMs,
289
+ sleep: realSleep,
290
+ });
291
+ log?.info("Swept leftover marker children after run exit", { marker, pids });
292
+ } catch (err) {
293
+ // A sweep must never block the run result from settling.
294
+ log?.warn("Marker sweep failed after run exit", { marker, error: String(err) });
295
+ }
296
+ return pids;
297
+ }
298
+
299
+ /**
300
+ * Terminate every registered in-flight child: SIGTERM all, wait up to the shared
301
+ * grace period (returning as soon as every child is dead — B4), then SIGKILL any
302
+ * survivor. Returns the pids targeted. Safe to call when nothing is in flight.
303
+ *
304
+ * #299 Option A: each tracked entry's marker is also swept (`findByMarker`),
305
+ * because a marker-carrying grandchild is invisible to the `ChildProcess` handle
306
+ * and would otherwise survive a clean session_shutdown (probe 1b). Marker pids
307
+ * are deduped against the tracked pids and share the ONE escalation window, so
308
+ * the whole inherited tree dies together.
309
+ */
310
+ export async function reapActiveChildren(log?: Logger): Promise<number[]> {
311
+ const pids: number[] = [];
312
+ const trackedPids = new Set<number>();
313
+ const targets: EscalationTarget[] = [];
314
+ const markers: Array<string | undefined> = [];
315
+ for (const [token, entry] of activeChildren) {
316
+ if (entry.proc.pid === undefined) {
317
+ activeChildren.delete(token);
318
+ continue;
319
+ }
320
+ trackedPids.add(entry.proc.pid);
321
+ pids.push(entry.proc.pid);
322
+ targets.push(childTarget(entry.proc));
323
+ markers.push(entry.marker);
324
+ }
325
+ const swept = collectMarkerTargets(markers, trackedPids, log);
326
+ pids.push(...swept.pids);
327
+ targets.push(...swept.targets);
328
+ if (targets.length === 0) return [];
329
+
330
+ await escalateKill(targets, {
331
+ graceMs: SIGKILL_GRACE_MS,
332
+ pollMs: SIGKILL_POLL_MS,
333
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
334
+ });
335
+
336
+ log?.info("Reaped in-flight subagent children", { pids });
337
+ return pids;
338
+ }
339
+
44
340
  // ---------------------------------------------------------------------------
45
341
  // Pi binary resolution
46
342
  // ---------------------------------------------------------------------------
47
343
 
48
344
  /**
49
345
  * Resolve the pi binary and command-line invocation for subprocess spawning.
346
+ *
347
+ * Precedence:
348
+ * 1. `BRL_PI_BIN` — an explicit override, read per call. A non-empty value
349
+ * (after trimming surrounding whitespace) is used as the command;
350
+ * whitespace-only is ignored and falls through. This exists for the
351
+ * Tier-2 e2e harness
352
+ * (issue #271): it spawns a synthetic node process that loads the
353
+ * extension, so `process.argv[1]` is the HARNESS script, not pi's CLI.
354
+ * Without this override the argv[1] heuristic below re-runs the harness
355
+ * as if it were pi (a delegation no-op that still "passes"). Production
356
+ * behaviour is unchanged while the variable is unset.
357
+ * 2. `process.argv[1]` — a real script path (the dev/pi-CLI case). Spawn it
358
+ * with the current runtime.
359
+ * 3. A non-generic `process.execPath` (a bundled/custom binary) — spawn it
360
+ * directly.
361
+ * 4. `"pi"` on PATH.
50
362
  */
51
363
  export function getPiInvocation(extraArgs: string[]): { command: string; args: string[] } {
364
+ const override = process.env.BRL_PI_BIN?.trim();
365
+ if (override) {
366
+ return { command: override, args: extraArgs };
367
+ }
368
+
52
369
  const currentScript = process.argv[1];
53
370
  const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
54
371
  if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
@@ -220,10 +537,16 @@ function attachAbortHandler(
220
537
  // blind kill with no result access at all.
221
538
  result.errorMessage = SUBAGENT_ABORTED_MESSAGE;
222
539
  result.errorCategory = "aborted";
223
- proc.kill("SIGTERM");
224
- setTimeout(() => {
225
- if (!proc.killed) proc.kill("SIGKILL");
226
- }, SIGKILL_GRACE_MS);
540
+ // Issue #303: use childTarget's default REAL-exit check. `child.killed`
541
+ // only records that SIGTERM was SENT, so gating the SIGKILL on it declared
542
+ // a SIGTERM-ignoring child dead and skipped the force-kill. The same
543
+ // poll/early-exit wait `reapActiveChildren` uses means a cooperative child
544
+ // completes in ms while a stubborn one gets SIGKILL at the grace boundary.
545
+ void escalateKill([childTarget(proc)], {
546
+ graceMs: escalationTiming.graceMs,
547
+ pollMs: escalationTiming.pollMs,
548
+ sleep: realSleep,
549
+ });
227
550
  };
228
551
  if (signal.aborted) killProc();
229
552
  else signal.addEventListener("abort", killProc, { once: true });
@@ -706,7 +1029,7 @@ export function enrichFailureDiagnostics(result: SubagentResult, cwd?: string):
706
1029
  // Track 1 already stamped an honest source (user abort / timeout) — that is
707
1030
  // authoritative and must not be overlaid with the provider-error fallback.
708
1031
  const msg = result.errorMessage.toLowerCase();
709
- if (msg.includes("aborted") || msg.includes("timed out")) return;
1032
+ if (msg.includes("aborted") || msg.includes("timed out") || msg.includes("killed by external signal")) return;
710
1033
  const errorTurns = countModelErrorTurns(result.messages);
711
1034
  if (errorTurns === 0) return;
712
1035
 
@@ -738,6 +1061,7 @@ export async function runSubagent(
738
1061
  depth?: number,
739
1062
  intercom?: Intercom,
740
1063
  subagentId?: string,
1064
+ childMarker?: string,
741
1065
  ): Promise<SubagentResult> {
742
1066
  // E10: Inject pending intercom messages into the task prompt
743
1067
  if (intercom && subagentId && intercom.hasMessages(subagentId)) {
@@ -789,15 +1113,42 @@ ${msgBlock}`;
789
1113
  const exitCode = await new Promise<number>((resolve) => {
790
1114
  const invocation = getPiInvocation(args);
791
1115
  const subDepth = depth !== undefined ? depth : undefined;
792
- const envOverrides: Record<string, string> | undefined =
793
- subDepth !== undefined ? { [DEPTH_ENV_KEY]: String(subDepth) } : undefined;
1116
+ const envOverrides: Record<string, string> = {};
1117
+ if (subDepth !== undefined) envOverrides[DEPTH_ENV_KEY] = String(subDepth);
1118
+ // Option B U1: stamp the per-run marker so boot recovery can discover
1119
+ // this child by scanning /proc/*/environ after a conductor crash.
1120
+ if (childMarker) envOverrides[CHILD_MARKER_ENV_KEY] = childMarker;
1121
+ const hasEnvOverrides = Object.keys(envOverrides).length > 0;
794
1122
  const proc = spawn(invocation.command, invocation.args, {
795
1123
  cwd,
796
1124
  shell: false,
797
1125
  stdio: ["ignore", "pipe", "pipe"],
798
- env: getSafeEnv(envOverrides), // F2: Environment isolation + depth tracking
1126
+ env: getSafeEnv(hasEnvOverrides ? envOverrides : undefined), // F2: Environment isolation + depth tracking
1127
+ // #299 Option B D1: POSIX only — the child becomes its OWN session and
1128
+ // process-group leader (`pgid == pid`), so every kill path can signal
1129
+ // the whole tree (including env-scrubbed descendants) with
1130
+ // `process.kill(-pid, sig)`. No `unref()`: we track the child, and the
1131
+ // boot scan is its recovery path if the conductor crashes. Boundaries:
1132
+ // (a) an env-scrubbed subtree whose leader is already dead before any
1133
+ // sweep stays unreachable safely (only a surviving marker carrier is
1134
+ // swept); (b) a descendant that calls `setsid` leaves the group by
1135
+ // definition; (c) macOS has no /proc, so the boot scan is unchanged
1136
+ // while the in-session group kill still works; (d) detached children no
1137
+ // longer receive terminal-generated signals — the boot reap is their
1138
+ // recovery path. Windows passes `false` (D6), exactly today's behavior.
1139
+ detached: supportsProcessGroupKill(),
799
1140
  });
800
1141
 
1142
+ // Option B U1: register the child for shutdown reap. Keyed by the
1143
+ // marker when present (guaranteed unique per run), else the intercom
1144
+ // id, else a generated fallback.
1145
+ // Option B U1 + B5: register the child under a UNIQUE spawn token, not the
1146
+ // caller marker — two concurrent children may share a marker, and a
1147
+ // marker-keyed map would collapse them (one close untracking the other).
1148
+ // The marker is kept on the entry for reap/kill identification.
1149
+ const registryToken = `spawn-${process.pid}-${++spawnTokenSeq}`;
1150
+ activeChildren.set(registryToken, { proc, marker: childMarker });
1151
+
801
1152
  let buffer = "";
802
1153
 
803
1154
  proc.stdout.on("data", (data: Buffer) => {
@@ -813,15 +1164,53 @@ ${msgBlock}`;
813
1164
  result.stderr += data.toString();
814
1165
  });
815
1166
 
816
- proc.on("close", (code) => {
1167
+ // #299 Option A: sweep on `exit`, not `close`. `exit` is where the direct
1168
+ // child is confirmed dead; `close` can be delayed indefinitely when a
1169
+ // grandchild inherited the child's stdio and keeps the pipes open. Killing
1170
+ // the leftover marker tree here also releases those pipes so the run can
1171
+ // settle. Success, timeout, and abort all terminate the direct child and so
1172
+ // all funnel through this ONE exit sweep (probe 1c). The per-run marker
1173
+ // keeps it foreign-tree-safe; already-tracked concurrent children are
1174
+ // excluded because two children may legitimately share a marker (B5).
1175
+ let sweepPromise: Promise<number[]> | undefined;
1176
+ proc.on("exit", () => {
1177
+ const excludePids = new Set<number>();
1178
+ for (const entry of activeChildren.values()) {
1179
+ if (entry.proc.pid !== undefined) excludePids.add(entry.proc.pid);
1180
+ }
1181
+ if (proc.pid !== undefined) excludePids.add(proc.pid);
1182
+ sweepPromise = sweepMarkerChildren(childMarker, excludePids, log);
1183
+ });
1184
+
1185
+ proc.on("close", (code, signal) => {
1186
+ activeChildren.delete(registryToken);
817
1187
  if (buffer.trim()) {
818
1188
  parseSubagentLine(buffer, result, onUpdate, getFinalOutputFn, log);
819
1189
  }
820
- log?.info("Subagent process exited", { exitCode: code, pid: proc.pid });
821
- resolve(code ?? 0);
1190
+ log?.info("Subagent process exited", { exitCode: code, signal, pid: proc.pid });
1191
+ // Issue #295: Node gives NO numeric code for a signal death
1192
+ // (code === null) — the old `code ?? 0` fabricated a clean exit and
1193
+ // masked a SIGKILL'd run as a success. Always record the non-zero
1194
+ // sentinel -1, REGARDLESS of whether a reason was already staged:
1195
+ // our own timeout/abort SIGTERM/SIGKILL lands here with code === null
1196
+ // too, and a masked 0 would hide it behind the same false 'done'.
1197
+ result.exitCode = code === null ? -1 : code;
1198
+ if (code === null && !result.errorMessage && !result.errorCategory) {
1199
+ // No reason staged (a timeout/abort stamp already carries its own)
1200
+ // — this was an external kill we did not initiate. Stamp the cause
1201
+ // and the signal name; classifyError maps it to the existing
1202
+ // "crash" category (no new ErrorCategory value).
1203
+ result.errorMessage = `${SUBAGENT_SIGNAL_KILLED_MESSAGE} (${signal ?? "unknown"})`;
1204
+ log?.warn("Subagent killed by external signal", { signal, pid: proc.pid });
1205
+ }
1206
+ // #299 Option A: settle only AFTER the exit sweep finished, so no
1207
+ // in-session marker tree outlives the run (probe 1c). Result semantics
1208
+ // above are untouched.
1209
+ void (sweepPromise ?? Promise.resolve([])).then(() => resolve(result.exitCode));
822
1210
  });
823
1211
 
824
1212
  proc.on("error", (err) => {
1213
+ activeChildren.delete(registryToken);
825
1214
  // F7 (issue #30): subprocess errors can embed the spawn command
826
1215
  // (absolute paths to the pi binary, temp files, cwd) — sanitize the
827
1216
  // errorMessage before it reaches the conductor. stderr is left RAW:
@@ -842,10 +1231,14 @@ ${msgBlock}`;
842
1231
  const timer = setTimeout(() => {
843
1232
  result.errorMessage = `Subagent timed out after ${timeout}ms`;
844
1233
  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);
1234
+ // Same REAL-exit check as the abort path (issue #303 — see there):
1235
+ // `child.killed` flips on signal-sent, not process-death, so it would
1236
+ // skip the SIGKILL for a SIGTERM-ignoring child.
1237
+ void escalateKill([childTarget(proc)], {
1238
+ graceMs: escalationTiming.graceMs,
1239
+ pollMs: escalationTiming.pollMs,
1240
+ sleep: realSleep,
1241
+ });
849
1242
  }, timeout);
850
1243
  proc.on("close", () => clearTimeout(timer));
851
1244
  }
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.