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.
@@ -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
+ }