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/AGENT.md +9 -2
- package/README.md +6 -3
- package/package.json +6 -6
- package/src/event-bus.ts +7 -2
- package/src/git.ts +67 -0
- package/src/history.ts +77 -10
- package/src/index.ts +355 -63
- package/src/kill-escalation.ts +426 -0
- package/src/logging.ts +37 -3
- package/src/metrics.ts +5 -3
- package/src/notify-completion.ts +23 -0
- package/src/preflight.ts +29 -14
- package/src/proc.ts +390 -0
- package/src/recovery-engine.ts +348 -0
- package/src/recovery-surface.ts +374 -0
- package/src/recovery.ts +343 -0
- package/src/run-registry.ts +330 -0
- package/src/runner.ts +409 -16
- package/src/sanitize.ts +53 -3
- package/src/session-manager.ts +234 -29
- package/src/state.ts +81 -7
- package/src/transcript-path.ts +41 -0
- package/src/transcript.ts +13 -12
- package/src/tui-format.ts +57 -1
- package/src/tui.ts +323 -103
- package/src/types.ts +173 -1
- package/src/unit-run.ts +16 -1
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 {
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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>
|
|
793
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
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 =
|
|
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)}
|
|
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.
|