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/AGENT.md +5 -0
- package/README.md +20 -8
- package/package.json +11 -9
- package/src/concurrency.ts +1 -0
- package/src/diff.ts +1 -0
- package/src/event-bus.ts +8 -2
- package/src/git.ts +68 -0
- package/src/history.ts +59 -1
- package/src/index.ts +244 -406
- package/src/kill-escalation.ts +94 -0
- package/src/logging.ts +58 -4
- package/src/messaging.ts +1 -0
- package/src/metrics.ts +1 -0
- package/src/model-availability.ts +1 -0
- package/src/notify-completion.ts +1 -0
- package/src/params.ts +1 -0
- package/src/paths.ts +1 -0
- package/src/preflight.ts +30 -14
- package/src/prelude.ts +1 -0
- package/src/presets.ts +1 -0
- package/src/proc.ts +140 -0
- package/src/prompt.ts +1 -0
- package/src/recovery-engine.ts +262 -0
- package/src/recovery.ts +325 -0
- package/src/reports.ts +64 -8
- package/src/router.ts +1 -0
- package/src/run-registry.ts +269 -0
- package/src/runner.ts +207 -16
- package/src/sanitize.ts +54 -3
- package/src/scheduler.ts +1 -0
- package/src/schema.ts +254 -0
- package/src/session-manager.ts +142 -111
- package/src/state.ts +52 -7
- package/src/templates.ts +1 -0
- package/src/transcript-path.ts +41 -0
- package/src/transcript-tail.ts +1 -0
- package/src/transcript.ts +14 -12
- package/src/tui-format.ts +1 -0
- package/src/tui.ts +231 -296
- package/src/types.ts +130 -2
- package/src/unit-run.ts +9 -0
- package/src/validate.ts +16 -2
package/src/router.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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>
|
|
792
|
-
|
|
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
|
-
|
|
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
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
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 =
|
|
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)}
|
|
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