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