brl-subagent 2.3.9 → 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/router.ts CHANGED
@@ -1,3 +1,4 @@
1
+ // Purpose: Auto-route: keyword classification of a task description to the best preset.
1
2
  /**
2
3
  * brl-subagent — Skill-based Routing
3
4
  *
@@ -0,0 +1,269 @@
1
+ // Purpose: Durable per-run in-flight registry + the single run-record persist choke point that mirrors into it.
2
+ /**
3
+ * Option B U1 — durable in-flight run registry.
4
+ *
5
+ * A foreground/chain/parallel/graph run's `SubagentRun` record lives ONLY as a
6
+ * session custom entry, so after a conductor crash a fresh process (pi's
7
+ * default `SessionManager.create` startup) cannot see it at all. The agent
8
+ * store (`.pi/subagents/<id>.json`) covers background runs only. This module
9
+ * closes that gap: one small JSON per IN-FLIGHT run at the cwd-relative path
10
+ * `.pi/run-registry/<runId>.json`, written before the run's effect and cleared
11
+ * when it reaches a terminal state.
12
+ *
13
+ * `persistRunRecord` is the single choke point every run-record write goes
14
+ * through (`state.persistRun` delegates here; the background path in
15
+ * session-manager calls it directly). It mirrors the record into the registry:
16
+ * a `running` record with a kind REGISTERS (or refreshes) an entry; a terminal
17
+ * record CLEARS it; a `running` record without a kind is left alone (the boot
18
+ * scan's `interruptedAt` mark is written through `markInterrupted`).
19
+ *
20
+ * WHERE THE INTERRUPTION MARK LIVES (commitment for U3): for foreground/unit
21
+ * runs the registry entry IS the durable interruption mark — the boot scan
22
+ * writes `interruptedAt` there, not into a session entry it cannot reach.
23
+ * Background runs additionally mark their `.pi/subagents/<id>.json` agent
24
+ * record (the existing double-mark). U3's interrupted-run surfacing reads the
25
+ * registry.
26
+ *
27
+ * Reads are parse-tolerant and never throw on a malformed or vanished entry;
28
+ * writes are atomic (temp file + rename) and owner-only (0o600), mirroring the
29
+ * agent store's F6 discipline.
30
+ */
31
+
32
+ import * as fs from "node:fs";
33
+ import * as path from "node:path";
34
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
35
+ import type { ProcessOwner, SubagentRun } from "./types";
36
+ import { CUSTOM_ENTRY_TYPES } from "./types";
37
+ import { assertSafeAgentId } from "./sanitize";
38
+
39
+ /** Which execution path owns an in-flight run (drives the boot scan's mark). */
40
+ export type InflightRunKind = "foreground" | "unit" | "background";
41
+
42
+ /** The durable per-run registry entry (one small JSON file per run id). */
43
+ export interface InflightRun {
44
+ id: string;
45
+ dispatchId?: string;
46
+ attempt?: number;
47
+ kind: InflightRunKind;
48
+ owner?: ProcessOwner;
49
+ childMarker?: string;
50
+ startedAt: string;
51
+ cwd: string;
52
+ interruptedAt?: string;
53
+ }
54
+
55
+ /** Fields accepted when registering/updating an entry (cwd defaults to the process cwd). */
56
+ export interface InflightRunInput {
57
+ id: string;
58
+ dispatchId?: string;
59
+ attempt?: number;
60
+ kind: InflightRunKind;
61
+ owner?: ProcessOwner;
62
+ childMarker?: string;
63
+ startedAt: string;
64
+ cwd?: string;
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Storage location
69
+ // ---------------------------------------------------------------------------
70
+
71
+ /**
72
+ * Registry directory, cwd-relative like the agent store (`.pi/subagents`).
73
+ * Test-only override, mirroring `__setStorageDir` — the shared temp-lifecycle
74
+ * fixture points it at a throwaway dir.
75
+ */
76
+ let REGISTRY_DIR = ".pi/run-registry";
77
+
78
+ /** TEST-ONLY: point the registry at an alternate directory. */
79
+ export function __setRegistryDir(dir: string): void {
80
+ REGISTRY_DIR = dir;
81
+ }
82
+
83
+ /** The active registry directory (absolute or cwd-relative as configured). */
84
+ export function registryDir(): string {
85
+ return REGISTRY_DIR;
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Paths + atomic IO
90
+ // ---------------------------------------------------------------------------
91
+
92
+ /**
93
+ * Resolve an entry's file path, refusing anything that is not a safe UUID —
94
+ * ids reach this module from records loaded off disk, and `path.join` does not
95
+ * sanitize traversal segments (same F24 rule as the agent store).
96
+ */
97
+ function resolvePath(id: string): string | null {
98
+ try {
99
+ assertSafeAgentId(id);
100
+ } catch {
101
+ return null;
102
+ }
103
+ return path.join(REGISTRY_DIR, `${id}.json`);
104
+ }
105
+
106
+ function ensureDir(): void {
107
+ fs.mkdirSync(REGISTRY_DIR, { recursive: true, mode: 0o700 });
108
+ }
109
+
110
+ /** True when `value` looks like a registry entry (id + kind + startedAt). */
111
+ function isInflightRun(value: unknown): value is InflightRun {
112
+ if (!value || typeof value !== "object") return false;
113
+ const v = value as Record<string, unknown>;
114
+ if (typeof v.id !== "string" || typeof v.startedAt !== "string") return false;
115
+ return v.kind === "foreground" || v.kind === "unit" || v.kind === "background";
116
+ }
117
+
118
+ /** Parse one entry file; null on missing/corrupt/malformed. */
119
+ function readEntry(file: string): InflightRun | null {
120
+ try {
121
+ const parsed: unknown = JSON.parse(fs.readFileSync(file, "utf-8"));
122
+ return isInflightRun(parsed) ? parsed : null;
123
+ } catch {
124
+ return null;
125
+ }
126
+ }
127
+
128
+ /** Atomic, owner-only write: temp file in the same directory, then rename. */
129
+ function writeAtomic(file: string, entry: InflightRun): boolean {
130
+ try {
131
+ ensureDir();
132
+ const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
133
+ fs.writeFileSync(tmp, JSON.stringify(entry, null, 2), { encoding: "utf-8", mode: 0o600 });
134
+ fs.renameSync(tmp, file);
135
+ return true;
136
+ } catch {
137
+ return false;
138
+ }
139
+ }
140
+
141
+ /** Copy only defined fields from an input onto an entry (never clobber with undefined). */
142
+ function mergeInput(base: InflightRun, input: InflightRunInput): InflightRun {
143
+ const next: InflightRun = { ...base, id: input.id, kind: input.kind };
144
+ if (input.dispatchId !== undefined) next.dispatchId = input.dispatchId;
145
+ if (input.attempt !== undefined) next.attempt = input.attempt;
146
+ if (input.owner !== undefined) next.owner = input.owner;
147
+ if (input.childMarker !== undefined) next.childMarker = input.childMarker;
148
+ if (input.startedAt) next.startedAt = input.startedAt;
149
+ if (input.cwd !== undefined) next.cwd = input.cwd;
150
+ return next;
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Public API
155
+ // ---------------------------------------------------------------------------
156
+
157
+ /**
158
+ * Register (or refresh) an in-flight run. A re-register preserves an existing
159
+ * `interruptedAt` so a later write cannot erase a boot-scan mark.
160
+ */
161
+ export function registerInflightRun(input: InflightRunInput): boolean {
162
+ const file = resolvePath(input.id);
163
+ if (!file) return false;
164
+ const existing = readEntry(file);
165
+ const entry: InflightRun = {
166
+ ...mergeInput(existing ?? { id: input.id, kind: input.kind, startedAt: input.startedAt, cwd: input.cwd ?? process.cwd() }, input),
167
+ };
168
+ return writeAtomic(file, entry);
169
+ }
170
+
171
+ /**
172
+ * Merge a defined-field patch into an existing entry. A missing entry is left
173
+ * missing (registration is `registerInflightRun`'s job, so a stray update can
174
+ * never resurrect a cleared run).
175
+ */
176
+ export function updateInflightRun(input: InflightRunInput): boolean {
177
+ const file = resolvePath(input.id);
178
+ if (!file) return false;
179
+ const existing = readEntry(file);
180
+ if (!existing) return false;
181
+ return writeAtomic(file, mergeInput(existing, input));
182
+ }
183
+
184
+ /** Remove an entry (terminal finalize). Missing file is a no-op. */
185
+ export function clearInflightRun(id: string): void {
186
+ const file = resolvePath(id);
187
+ if (!file) return;
188
+ try {
189
+ fs.unlinkSync(file);
190
+ } catch {
191
+ // Already cleared (or never registered) — the desired end state.
192
+ }
193
+ }
194
+
195
+ /** Every parseable in-flight entry; [] when the registry is absent. */
196
+ export function listInflightRuns(): InflightRun[] {
197
+ let files: string[];
198
+ try {
199
+ files = fs.readdirSync(REGISTRY_DIR);
200
+ } catch {
201
+ return [];
202
+ }
203
+ const entries: InflightRun[] = [];
204
+ for (const file of files) {
205
+ if (!file.endsWith(".json")) continue;
206
+ const entry = readEntry(path.join(REGISTRY_DIR, file));
207
+ if (entry) entries.push(entry);
208
+ }
209
+ return entries;
210
+ }
211
+
212
+ /**
213
+ * Write the D3 `interruptedAt` mark onto an existing entry. Idempotent; returns
214
+ * false when the entry is absent, its id fails the write guard, or the write
215
+ * fails. Issue #304: this MUST never throw — the boot scan treats a false as a
216
+ * durable-write failure and counts it, whereas a thrown FS error would abort
217
+ * the whole scan via `performBootRecovery`'s catch-all. The boolean is the
218
+ * only truthful success signal, so callers must not assume the mark landed.
219
+ */
220
+ export function markInterrupted(id: string, interruptedAt: string): boolean {
221
+ try {
222
+ const file = resolvePath(id);
223
+ if (!file) return false;
224
+ const existing = readEntry(file);
225
+ if (!existing) return false;
226
+ if (existing.interruptedAt) return true;
227
+ return writeAtomic(file, { ...existing, interruptedAt });
228
+ } catch {
229
+ // Belt-and-braces: the inner read/write helpers already swallow FS errors,
230
+ // but the scan's no-throw contract must not depend on that staying true.
231
+ return false;
232
+ }
233
+ }
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // The persistence choke point
237
+ // ---------------------------------------------------------------------------
238
+
239
+ /** Project a run record into the registry input shape. */
240
+ function toRegistryInput(run: SubagentRun, kind: InflightRunKind): InflightRunInput {
241
+ return {
242
+ id: run.id,
243
+ dispatchId: run.dispatchId,
244
+ attempt: run.attempt,
245
+ kind,
246
+ owner: run.owner,
247
+ childMarker: run.childMarker,
248
+ startedAt: run.startedAt,
249
+ cwd: process.cwd(),
250
+ };
251
+ }
252
+
253
+ /**
254
+ * The ONE run-record writer: append the session entry AND keep the registry in
255
+ * lockstep. `state.persistRun` delegates here and the background path calls it
256
+ * directly, so every write site is mirrored by construction.
257
+ *
258
+ * `kind` is required only at dispatch (`running`); terminal writes clear the
259
+ * registry and ignore it. A `running` write without a kind is a refresh with
260
+ * nothing to register — the boot scan's mark is a separate `markInterrupted`.
261
+ */
262
+ export function persistRunRecord(pi: ExtensionAPI, run: SubagentRun, kind?: InflightRunKind): void {
263
+ if (run.status !== "running") {
264
+ clearInflightRun(run.id);
265
+ } else if (kind) {
266
+ registerInflightRun(toRegistryInput(run, kind));
267
+ }
268
+ pi.appendEntry(CUSTOM_ENTRY_TYPES.run, run);
269
+ }
package/src/runner.ts CHANGED
@@ -1,3 +1,4 @@
1
+ // Purpose: Foreground execution: spawns the `pi` subprocess, parses its JSON-line stream, and folds usage.
1
2
  /**
2
3
  * brl-subagent — Process Runner
3
4
  *
@@ -25,13 +26,16 @@ import type {
25
26
  import {
26
27
  EMPTY_USAGE,
27
28
  SIGKILL_GRACE_MS,
29
+ SIGKILL_POLL_MS,
28
30
  TEMP_FILE_MODE,
29
31
  MAX_TEMP_DIR_AGE_MS,
30
32
  isProviderError,
31
33
  classifyError,
32
34
  SUBAGENT_ABORTED_MESSAGE,
35
+ SUBAGENT_SIGNAL_KILLED_MESSAGE,
33
36
  } from "./types";
34
- 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";
35
39
  import type { Logger } from "./logging";
36
40
  import type { Intercom } from "./messaging";
37
41
  import { extractMessages, formatPendingMessages } from "./messaging";
@@ -40,14 +44,159 @@ import type {
40
44
  TranscriptContentBlock,
41
45
  TranscriptToolCallBlock,
42
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
+
43
171
  // ---------------------------------------------------------------------------
44
172
  // Pi binary resolution
45
173
  // ---------------------------------------------------------------------------
46
174
 
47
175
  /**
48
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.
49
193
  */
50
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
+
51
200
  const currentScript = process.argv[1];
52
201
  const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
53
202
  if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
@@ -219,10 +368,16 @@ function attachAbortHandler(
219
368
  // blind kill with no result access at all.
220
369
  result.errorMessage = SUBAGENT_ABORTED_MESSAGE;
221
370
  result.errorCategory = "aborted";
222
- proc.kill("SIGTERM");
223
- setTimeout(() => {
224
- if (!proc.killed) proc.kill("SIGKILL");
225
- }, 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
+ });
226
381
  };
227
382
  if (signal.aborted) killProc();
228
383
  else signal.addEventListener("abort", killProc, { once: true });
@@ -705,7 +860,7 @@ export function enrichFailureDiagnostics(result: SubagentResult, cwd?: string):
705
860
  // Track 1 already stamped an honest source (user abort / timeout) — that is
706
861
  // authoritative and must not be overlaid with the provider-error fallback.
707
862
  const msg = result.errorMessage.toLowerCase();
708
- 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;
709
864
  const errorTurns = countModelErrorTurns(result.messages);
710
865
  if (errorTurns === 0) return;
711
866
 
@@ -737,6 +892,7 @@ export async function runSubagent(
737
892
  depth?: number,
738
893
  intercom?: Intercom,
739
894
  subagentId?: string,
895
+ childMarker?: string,
740
896
  ): Promise<SubagentResult> {
741
897
  // E10: Inject pending intercom messages into the task prompt
742
898
  if (intercom && subagentId && intercom.hasMessages(subagentId)) {
@@ -788,15 +944,29 @@ ${msgBlock}`;
788
944
  const exitCode = await new Promise<number>((resolve) => {
789
945
  const invocation = getPiInvocation(args);
790
946
  const subDepth = depth !== undefined ? depth : undefined;
791
- const envOverrides: Record<string, string> | undefined =
792
- 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;
793
953
  const proc = spawn(invocation.command, invocation.args, {
794
954
  cwd,
795
955
  shell: false,
796
956
  stdio: ["ignore", "pipe", "pipe"],
797
- env: getSafeEnv(envOverrides), // F2: Environment isolation + depth tracking
957
+ env: getSafeEnv(hasEnvOverrides ? envOverrides : undefined), // F2: Environment isolation + depth tracking
798
958
  });
799
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
+
800
970
  let buffer = "";
801
971
 
802
972
  proc.stdout.on("data", (data: Buffer) => {
@@ -812,15 +982,32 @@ ${msgBlock}`;
812
982
  result.stderr += data.toString();
813
983
  });
814
984
 
815
- proc.on("close", (code) => {
985
+ proc.on("close", (code, signal) => {
986
+ activeChildren.delete(registryToken);
816
987
  if (buffer.trim()) {
817
988
  parseSubagentLine(buffer, result, onUpdate, getFinalOutputFn, log);
818
989
  }
819
- log?.info("Subagent process exited", { exitCode: code, pid: proc.pid });
820
- 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);
821
1007
  });
822
1008
 
823
1009
  proc.on("error", (err) => {
1010
+ activeChildren.delete(registryToken);
824
1011
  // F7 (issue #30): subprocess errors can embed the spawn command
825
1012
  // (absolute paths to the pi binary, temp files, cwd) — sanitize the
826
1013
  // errorMessage before it reaches the conductor. stderr is left RAW:
@@ -841,10 +1028,14 @@ ${msgBlock}`;
841
1028
  const timer = setTimeout(() => {
842
1029
  result.errorMessage = `Subagent timed out after ${timeout}ms`;
843
1030
  log?.warn("Subagent timed out", { timeout, pid: proc.pid });
844
- proc.kill("SIGTERM");
845
- setTimeout(() => {
846
- if (!proc.killed) proc.kill("SIGKILL");
847
- }, 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
+ });
848
1039
  }, timeout);
849
1040
  proc.on("close", () => clearTimeout(timer));
850
1041
  }
package/src/sanitize.ts CHANGED
@@ -1,3 +1,4 @@
1
+ // Purpose: Input validation, environment allowlisting, and output sanitization (task, cwd, outputFile, agent ids).
1
2
  /**
2
3
  * brl-subagent — Sanitization (F1, F2, F3)
3
4
  *
@@ -7,7 +8,7 @@
7
8
 
8
9
  import * as path from "node:path";
9
10
  import * as fs from "node:fs";
10
- import { EMPTY_USAGE } from "./types";
11
+ import { EMPTY_USAGE, DEFAULT_OUTPUT_CAP_BYTES } from "./types";
11
12
 
12
13
  // ---------------------------------------------------------------------------
13
14
  // F1: Input sanitization
@@ -208,6 +209,28 @@ export function stripAnsi(str: string): string {
208
209
  return str.replace(ANSI_ESCAPE_RE, "");
209
210
  }
210
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
+
211
234
  /**
212
235
  * Cap output size to prevent subagent results from overwhelming
213
236
  * the conductor's context window or TUI.
@@ -215,7 +238,7 @@ export function stripAnsi(str: string): string {
215
238
  * Returns the original string if within limits, or a truncated version
216
239
  * with a clear notice about how much was omitted.
217
240
  */
218
- export function capOutput(output: string, maxBytes: number = 100 * 1024): string {
241
+ export function capOutput(output: string, maxBytes: number = DEFAULT_OUTPUT_CAP_BYTES): string {
219
242
  const byteLength = Buffer.byteLength(output, "utf8");
220
243
  if (byteLength <= maxBytes) return output;
221
244
 
@@ -226,7 +249,24 @@ export function capOutput(output: string, maxBytes: number = 100 * 1024): string
226
249
  }
227
250
 
228
251
  const omitted = byteLength - Buffer.byteLength(truncated, "utf8");
229
- 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
+ );
230
270
  }
231
271
 
232
272
  function formatBytes(bytes: number): string {
@@ -353,6 +393,17 @@ export function buildCrashResult(mode: string, err: unknown, cwd: string): {
353
393
  /** Env var used to track subagent nesting depth. */
354
394
  export const DEPTH_ENV_KEY = "BRL_SUBAGENT_DEPTH";
355
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
+
356
407
  /**
357
408
  * Read the current subagent depth from the environment.
358
409
  * Returns 0 for the main (conductor) process, 1+ for nested subagents.
package/src/scheduler.ts CHANGED
@@ -1,3 +1,4 @@
1
+ // Purpose: Dependency-graph scheduler: cycle detection, topological waves, and graph validation.
1
2
  /**
2
3
  * brl-subagent — Dependency Graph Scheduler
3
4
  *