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
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
// Purpose: Pure recovery decision engine — identity, owner liveness classification, and the boot-scan plan over injected process deps.
|
|
2
|
+
/**
|
|
3
|
+
* Option B U1 — recovery decision engine (pure over `RecoveryDeps`).
|
|
4
|
+
*
|
|
5
|
+
* The engine decides, for one persisted `running` record, whether its
|
|
6
|
+
* conductor is still live, whether to reject ownership, and whether to reap a
|
|
7
|
+
* marker-verified orphan child. It performs NO process work itself: every
|
|
8
|
+
* effect goes through the injected `RecoveryDeps`, so the full matrix is
|
|
9
|
+
* unit-testable without spawning processes. The boot orchestration lives in
|
|
10
|
+
* `src/recovery.ts`; the production `/proc` wiring in `src/proc.ts`.
|
|
11
|
+
*
|
|
12
|
+
* Decision order (`decideRecovery`):
|
|
13
|
+
* - status not "running" → skip
|
|
14
|
+
* - `interruptedAt` already set → never re-mark, but re-check for a leftover
|
|
15
|
+
* live marker child and reap it again (B2 revisit)
|
|
16
|
+
* - absent owner → skip (`no-owner`)
|
|
17
|
+
* - owner present but unparsable → mark-only (`owner-unverifiable`), no kill
|
|
18
|
+
* - owner alive (pid + start token match) → leave untouched
|
|
19
|
+
* - owner alive but token unreadable → skip (`ownership-unverifiable`)
|
|
20
|
+
* - owner dead → reap marker-verified orphan children, then mark
|
|
21
|
+
*
|
|
22
|
+
* Reaping is the ONE `escalateKill` sequence (src/kill-escalation.ts): SIGTERM
|
|
23
|
+
* every pid, wait one grace window, SIGKILL any survivor.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { ProcessOwner, SubagentRun } from "./types";
|
|
27
|
+
import { resolveTerminalRunEntry } from "./state";
|
|
28
|
+
import { escalateKill, processTarget, type SignalGuard, type SignalPhase } from "./kill-escalation";
|
|
29
|
+
import type { Logger } from "./logging";
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Dependency injection surface
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/** The process primitives the decision engine needs (injected for testability). */
|
|
36
|
+
export interface RecoveryDeps {
|
|
37
|
+
/** True when the pid exists (SIGCONT/`kill -0` semantics; EPERM counts as alive). */
|
|
38
|
+
pidAlive(pid: number): boolean;
|
|
39
|
+
/** The process start-time token, or undefined when /proc is unavailable/unreadable. */
|
|
40
|
+
startTokenOf(pid: number): string | undefined;
|
|
41
|
+
/** Pids whose environment carries `CHILD_MARKER_ENV_KEY=<marker>`; [] when /proc is unavailable. */
|
|
42
|
+
findByMarker(marker: string): number[];
|
|
43
|
+
/** Send a signal to a pid (may throw when the pid is already gone). */
|
|
44
|
+
kill(pid: number, signal: NodeJS.Signals): void;
|
|
45
|
+
/**
|
|
46
|
+
* #299 D4: best-effort process-group signal (`kill(-pgid, sig)`; ESRCH
|
|
47
|
+
* tolerated). Present only where POSIX process groups exist — the effect is
|
|
48
|
+
* that `reapPids` targets become group-reaching (verified marker pids signal
|
|
49
|
+
* their group as well as the pid). When absent (Windows, D6) `reapPids`
|
|
50
|
+
* falls back to direct pid targets, exactly today's behavior.
|
|
51
|
+
*/
|
|
52
|
+
killGroup?(pgid: number, signal: NodeJS.Signals): void;
|
|
53
|
+
/**
|
|
54
|
+
* #299 review Major 2: true when process group `pgid` still has a LIVE member.
|
|
55
|
+
* Threaded into the group target's `verifyDeath` so a leader that exits
|
|
56
|
+
* promptly no longer reads as "group empty" — the escalation then honours the
|
|
57
|
+
* grace window for a member's cleanup. Absent/false on a platform without
|
|
58
|
+
* /proc → leader-only fallback (documented boundary).
|
|
59
|
+
*
|
|
60
|
+
* #299 fix A.2: THREE-state. `true` = live members observed; `false` =
|
|
61
|
+
* POSITIVELY empty (a completed inspection); `undefined` = UNKNOWN (the
|
|
62
|
+
* inspection was unavailable or partial). Callers treat `undefined` as NOT
|
|
63
|
+
* empty — absence of evidence is never evidence of absence.
|
|
64
|
+
*/
|
|
65
|
+
groupHasMembers?(pgid: number): boolean | undefined;
|
|
66
|
+
/**
|
|
67
|
+
* #299 review Major 1: signal-time marker re-check — true when the LIVE `pid`
|
|
68
|
+
* still carries `CHILD_MARKER_ENV_KEY=<marker>`. Threaded into the marker
|
|
69
|
+
* targets' signal guard so a pid that exited and was reused by a foreign
|
|
70
|
+
* process is never signaled. Absent → no signal-time gate (tests without /proc
|
|
71
|
+
* wiring); production always provides it via `defaultRecoveryDeps`.
|
|
72
|
+
*/
|
|
73
|
+
verifyMarker?(pid: number, marker: string): boolean;
|
|
74
|
+
/**
|
|
75
|
+
* #299 review Major 1: true when the pid is gone — reaped OR a not-yet-reaped
|
|
76
|
+
* zombie. The signal guard uses this to separate a REUSED pid from a merely
|
|
77
|
+
* dead/zombie one, so a zombie leader's pgid is still signaled (D5). Absent →
|
|
78
|
+
* `!pidAlive` (no zombie distinction).
|
|
79
|
+
*/
|
|
80
|
+
pidGone?(pid: number): boolean;
|
|
81
|
+
/** Sleep between SIGTERM and SIGKILL. */
|
|
82
|
+
sleep(ms: number): Promise<void>;
|
|
83
|
+
/** Grace period in ms between SIGTERM and SIGKILL. */
|
|
84
|
+
graceMs: number;
|
|
85
|
+
/**
|
|
86
|
+
* Poll interval for the early-exit wait (B4/Item 4): when > 0 the reap returns
|
|
87
|
+
* as soon as every target verifies dead, bounded by `graceMs`. Omitted/0 keeps
|
|
88
|
+
* the historical wait-the-full-grace-once shape.
|
|
89
|
+
*/
|
|
90
|
+
pollMs?: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Which persisted store a candidate record came from. */
|
|
94
|
+
export type RecoveryKind = "run" | "agent";
|
|
95
|
+
|
|
96
|
+
/** A persisted `running` record projected into the shape the engine reasons over. */
|
|
97
|
+
export interface RecoveryRecord {
|
|
98
|
+
id: string;
|
|
99
|
+
kind: RecoveryKind;
|
|
100
|
+
status: string;
|
|
101
|
+
interruptedAt?: string;
|
|
102
|
+
owner?: ProcessOwner;
|
|
103
|
+
childMarker?: string;
|
|
104
|
+
/** Original record for the caller's write-back; opaque to this module. */
|
|
105
|
+
source?: unknown;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export type RecoverySkipReason =
|
|
109
|
+
| "not-running"
|
|
110
|
+
| "already-interrupted"
|
|
111
|
+
| "no-owner"
|
|
112
|
+
| "owner-alive"
|
|
113
|
+
| "ownership-unverifiable";
|
|
114
|
+
|
|
115
|
+
export type RecoveryMarkReason = "owner-dead" | "child-orphaned" | "owner-unverifiable";
|
|
116
|
+
|
|
117
|
+
/** What the engine decided to do with one record (before any process work). */
|
|
118
|
+
export type RecoveryPlan =
|
|
119
|
+
| { decision: "skip"; reason: RecoverySkipReason }
|
|
120
|
+
| { decision: "mark"; reason: RecoveryMarkReason; reap: number[] }
|
|
121
|
+
/** Already-marked record re-checked this boot: reap leftovers, never re-mark. */
|
|
122
|
+
| { decision: "reap"; reason: "revisit-interrupted"; reap: number[] };
|
|
123
|
+
|
|
124
|
+
/** The engine's verdict on an owner identity. */
|
|
125
|
+
export type OwnerState = "alive" | "dead" | "unknown";
|
|
126
|
+
|
|
127
|
+
/** The result of applying a plan (kills performed, survivors logged). */
|
|
128
|
+
export interface RecoveryOutcome {
|
|
129
|
+
id: string;
|
|
130
|
+
kind: RecoveryKind;
|
|
131
|
+
decision: "skip" | "mark" | "reap";
|
|
132
|
+
reason: string;
|
|
133
|
+
reaped: number[];
|
|
134
|
+
survived: number[];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// Candidate projection
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Durability identity for a newly dispatched run record. A retry inherits the
|
|
143
|
+
* original's dispatchId and records `resumeOf`/`attempt`; a fresh dispatch
|
|
144
|
+
* mints a new dispatchId with attempt 1. These are RECORD fields, not
|
|
145
|
+
* delegate_task params (D4).
|
|
146
|
+
*/
|
|
147
|
+
export function newDispatchIdentity(source: SubagentRun | undefined): {
|
|
148
|
+
dispatchId: string;
|
|
149
|
+
resumeOf?: string;
|
|
150
|
+
attempt: number;
|
|
151
|
+
} {
|
|
152
|
+
return {
|
|
153
|
+
dispatchId: source?.dispatchId ?? crypto.randomUUID(),
|
|
154
|
+
resumeOf: source?.id,
|
|
155
|
+
attempt: source ? (source.attempt ?? 1) + 1 : 1,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Collapse run entries to ONE per id before the scan, using the ONE shared
|
|
161
|
+
* preference rule (`resolveTerminalRunEntry`, src/state.ts): terminal-first;
|
|
162
|
+
* among non-terminal entries the D3 `interruptedAt` mark wins over the
|
|
163
|
+
* original spawn entry; otherwise the first entry seen. Ids keep their
|
|
164
|
+
* first-seen order. Delegating keeps the recovery scan and U3's display/retry
|
|
165
|
+
* lookup from diverging (the historical duplicate rule picked the marked clone
|
|
166
|
+
* while the resolver picked the unmarked spawn).
|
|
167
|
+
*/
|
|
168
|
+
export function dedupeRunEntriesById(runs: SubagentRun[]): SubagentRun[] {
|
|
169
|
+
const ids: string[] = [];
|
|
170
|
+
const seen = new Set<string>();
|
|
171
|
+
for (const run of runs) {
|
|
172
|
+
if (seen.has(run.id)) continue;
|
|
173
|
+
seen.add(run.id);
|
|
174
|
+
ids.push(run.id);
|
|
175
|
+
}
|
|
176
|
+
const collapsed: SubagentRun[] = [];
|
|
177
|
+
for (const id of ids) {
|
|
178
|
+
const resolved = resolveTerminalRunEntry(runs, id);
|
|
179
|
+
if (resolved) collapsed.push(resolved);
|
|
180
|
+
}
|
|
181
|
+
return collapsed;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// The decision engine (pure over RecoveryDeps)
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Classify the recorded owner's liveness. `unknown` means the pid is alive but
|
|
190
|
+
* the start token could not be read, so pid reuse cannot be excluded — the
|
|
191
|
+
* caller must not act on it.
|
|
192
|
+
*/
|
|
193
|
+
export function classifyOwner(owner: ProcessOwner, deps: RecoveryDeps): OwnerState {
|
|
194
|
+
if (!deps.pidAlive(owner.pid)) return "dead";
|
|
195
|
+
// An empty recorded token means the WRITER could not read one (non-/proc
|
|
196
|
+
// platform). Even on a host that can read tokens now, the record's identity
|
|
197
|
+
// is unverifiable — never act on it.
|
|
198
|
+
if (!owner.start) return "unknown";
|
|
199
|
+
const token = deps.startTokenOf(owner.pid);
|
|
200
|
+
if (token === undefined) return "unknown";
|
|
201
|
+
return token === owner.start ? "alive" : "dead";
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* True when an owner carries a usable pid (positive integer). Anything else —
|
|
206
|
+
* a missing pid, a string, 0, a negative, NaN, a fraction — is UNPARSABLE
|
|
207
|
+
* ownership: the record is marked but the kill path is never touched.
|
|
208
|
+
*/
|
|
209
|
+
function hasParsableOwnerPid(owner: ProcessOwner): boolean {
|
|
210
|
+
return Number.isInteger(owner.pid) && owner.pid > 0;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Decide what to do with one persisted record. Pure — no process is touched.
|
|
215
|
+
* Reaping pids come ONLY from a marker scan: without a verified marker match
|
|
216
|
+
* the engine never proposes a kill. An unparsable owner is mark-only (see the
|
|
217
|
+
* module header's three-way owner policy): interrupt is certain, ownership is
|
|
218
|
+
* not, so `reap` is empty and the kill path stays untouched.
|
|
219
|
+
*/
|
|
220
|
+
export function decideRecovery(record: RecoveryRecord, deps: RecoveryDeps): RecoveryPlan {
|
|
221
|
+
if (record.status !== "running") return { decision: "skip", reason: "not-running" };
|
|
222
|
+
if (record.interruptedAt) {
|
|
223
|
+
// Already marked: NEVER re-mark (idempotent), but a SIGKILL survivor or a
|
|
224
|
+
// child whose marker was unreadable at mark time must stay revisitable.
|
|
225
|
+
// The registry entry is retained, so every later boot re-checks it.
|
|
226
|
+
const reap = record.childMarker ? deps.findByMarker(record.childMarker) : [];
|
|
227
|
+
return reap.length > 0
|
|
228
|
+
? { decision: "reap", reason: "revisit-interrupted", reap }
|
|
229
|
+
: { decision: "skip", reason: "already-interrupted" };
|
|
230
|
+
}
|
|
231
|
+
if (!record.owner) return { decision: "skip", reason: "no-owner" };
|
|
232
|
+
if (!hasParsableOwnerPid(record.owner)) {
|
|
233
|
+
return { decision: "mark", reason: "owner-unverifiable", reap: [] };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const ownerState = classifyOwner(record.owner, deps);
|
|
237
|
+
if (ownerState === "alive") return { decision: "skip", reason: "owner-alive" };
|
|
238
|
+
if (ownerState === "unknown") return { decision: "skip", reason: "ownership-unverifiable" };
|
|
239
|
+
|
|
240
|
+
const reap = record.childMarker ? deps.findByMarker(record.childMarker) : [];
|
|
241
|
+
return {
|
|
242
|
+
decision: "mark",
|
|
243
|
+
reason: reap.length > 0 ? "child-orphaned" : "owner-dead",
|
|
244
|
+
reap,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Escalate a set of pids together: SIGTERM every one, wait (with `deps.pollMs`,
|
|
250
|
+
* at most) ONE grace window returning as soon as all are dead, then SIGKILL any
|
|
251
|
+
* survivor. Returns the pids still alive afterwards. Thin
|
|
252
|
+
* adapter over the shared `escalateKill` helper (src/kill-escalation.ts), which
|
|
253
|
+
* `recoverRecord` (one record) and `runBootScan` (the whole scan) both call, so
|
|
254
|
+
* a boot with N orphans waits one grace period total, never N.
|
|
255
|
+
*
|
|
256
|
+
* #299 review Major 1: `markerOfPid` resolves the marker that verified a pid at
|
|
257
|
+
* scan time; when `deps.verifyMarker` is also wired (production), every signal
|
|
258
|
+
* on that pid is gated on an immediate re-read of the live marker (`SignalGuard`),
|
|
259
|
+
* so a pid reused by a foreign process is skipped, never signaled unverified.
|
|
260
|
+
* `onSkip` records a refused signal. Both are optional so direct `reapPids`
|
|
261
|
+
* callers without /proc wiring keep the historical unconditional behavior.
|
|
262
|
+
*/
|
|
263
|
+
export async function reapPids(
|
|
264
|
+
pids: number[],
|
|
265
|
+
deps: RecoveryDeps,
|
|
266
|
+
markerOfPid?: (pid: number) => string | undefined,
|
|
267
|
+
onSkip?: (pid: number, signal: NodeJS.Signals, phase: SignalPhase) => void,
|
|
268
|
+
): Promise<number[]> {
|
|
269
|
+
const unique = [...new Set(pids)];
|
|
270
|
+
const isGone = deps.pidGone ?? ((pid: number) => !deps.pidAlive(pid));
|
|
271
|
+
const targets = unique.map((pid) => {
|
|
272
|
+
const marker = markerOfPid?.(pid);
|
|
273
|
+
const guard: SignalGuard | undefined =
|
|
274
|
+
marker && deps.verifyMarker
|
|
275
|
+
? {
|
|
276
|
+
isOwn: (targetPid) => deps.verifyMarker!(targetPid, marker),
|
|
277
|
+
isGone,
|
|
278
|
+
onSkip,
|
|
279
|
+
}
|
|
280
|
+
: undefined;
|
|
281
|
+
return processTarget(
|
|
282
|
+
pid,
|
|
283
|
+
deps.kill,
|
|
284
|
+
deps.pidAlive,
|
|
285
|
+
deps.killGroup,
|
|
286
|
+
deps.groupHasMembers,
|
|
287
|
+
guard,
|
|
288
|
+
);
|
|
289
|
+
});
|
|
290
|
+
const survivors = await escalateKill(targets, {
|
|
291
|
+
graceMs: deps.graceMs,
|
|
292
|
+
pollMs: deps.pollMs,
|
|
293
|
+
sleep: deps.sleep,
|
|
294
|
+
});
|
|
295
|
+
const survivedSet = new Set(survivors);
|
|
296
|
+
return unique.filter((_, index) => survivedSet.has(targets[index]));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Apply a record's plan: SIGTERM every marker-verified orphan, wait the grace
|
|
301
|
+
* period, SIGKILL any survivor, then report. Never throws on a vanished pid.
|
|
302
|
+
*
|
|
303
|
+
* #299 review Major 1: every signal on this record's marker-verified pids is
|
|
304
|
+
* re-checked against the live marker at signal time; a refused signal is logged
|
|
305
|
+
* when `log` is supplied.
|
|
306
|
+
*/
|
|
307
|
+
export async function recoverRecord(
|
|
308
|
+
record: RecoveryRecord,
|
|
309
|
+
deps: RecoveryDeps,
|
|
310
|
+
log?: Pick<Logger, "warn">,
|
|
311
|
+
): Promise<RecoveryOutcome> {
|
|
312
|
+
const plan = decideRecovery(record, deps);
|
|
313
|
+
if (plan.decision === "skip") {
|
|
314
|
+
return {
|
|
315
|
+
id: record.id,
|
|
316
|
+
kind: record.kind,
|
|
317
|
+
decision: "skip",
|
|
318
|
+
reason: plan.reason,
|
|
319
|
+
reaped: [],
|
|
320
|
+
survived: [],
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const reaped = [...new Set(plan.reap)];
|
|
325
|
+
const marker = record.childMarker;
|
|
326
|
+
const survived = await reapPids(
|
|
327
|
+
reaped,
|
|
328
|
+
deps,
|
|
329
|
+
marker ? () => marker : undefined,
|
|
330
|
+
(pid, signal, phase) =>
|
|
331
|
+
log?.warn("Recovery: skipped a marker signal (pid no longer carries the run marker)", {
|
|
332
|
+
id: record.id,
|
|
333
|
+
pid,
|
|
334
|
+
signal,
|
|
335
|
+
phase,
|
|
336
|
+
marker,
|
|
337
|
+
}),
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
id: record.id,
|
|
342
|
+
kind: record.kind,
|
|
343
|
+
decision: plan.decision,
|
|
344
|
+
reason: plan.reason,
|
|
345
|
+
reaped,
|
|
346
|
+
survived,
|
|
347
|
+
};
|
|
348
|
+
}
|