brl-subagent 2.5.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.
@@ -22,6 +22,49 @@
22
22
  * flips when SIGTERM is SENT, so gating SIGKILL on it skips escalation for a
23
23
  * SIGTERM-ignoring child (issue #303). What is shared is the
24
24
  * terminate-then-escalate order and the no-throw-on-vanished discipline.
25
+ *
26
+ * #299 Option B adds two target shapes and one sequence addition:
27
+ * - `groupTarget` signals the process GROUP best-effort AND the pid directly
28
+ * (D2/D3) — the group catches env-scrubbed descendants the marker cannot;
29
+ * the direct signal is never replaced (a `#322` non-detached child has no
30
+ * group of its own, so `-pid` is ESRCH while the direct signal still lands).
31
+ * - `processTarget` picks the group shape when a `killGroup` primitive is
32
+ * supplied (POSIX; D4), else the direct-only `pidTarget` (Windows; D6).
33
+ * - after the grace window `escalateKill` calls an optional `forceKillGroup`
34
+ * UNCONDITIONALLY (D5), so a TERM-stubborn member survives neither the
35
+ * leader exiting early nor the leader-based survivor check.
36
+ *
37
+ * #299 REVIEW FIX (review round on `62e2537`) closes the two majors:
38
+ * - Major 1 (pid reuse): a marker pid verified by a `/proc` scan at SCAN time
39
+ * may exit and be reused before the signal. `SignalGuard` re-reads the live
40
+ * marker IMMEDIATELY before every marker-derived signal; a pid that is alive
41
+ * but no longer carries the marker (the reuse case) is never signaled, group
42
+ * or direct. The residual read→`kill` window is documented on the guard.
43
+ * - Major 2 (grace bypassed): a group target's `verifyDeath` is now LEADER DEAD
44
+ * **AND** GROUP EMPTY (`groupHasMembers`), so a dead leader no longer reads
45
+ * as "all dead" and the escalation honours the grace window for a member's
46
+ * cleanup. The unconditional `forceKillGroup` still ends a TERM-stubborn
47
+ * member, but only after the grace window. `verifyProcessAlive` keeps the
48
+ * direct `forceKill` (and the survivor report) honest for a group target
49
+ * whose leader already exited, so the final group SIGKILL stays the sole
50
+ * mechanism for that case (the D5 tripwire).
51
+ *
52
+ * #299 RE-REVIEW FIX A.1 closes one more major on `0fa2fc2`:
53
+ * - A reaped pid must classify as GONE in `pidGone` (positive `kill(pid,0)`
54
+ * sample + `/proc/<pid>/stat` ENOENT — see src/proc.ts), so our own reaped
55
+ * leader is not mistaken for a live FOREIGN pid and refused its group kill.
56
+ * - Honest reporting: a REFUSED final group SIGKILL over a group that still
57
+ * has live members is counted as a survivor (`refusedForceKillGroup`), so a
58
+ * guard decision can never yield a clean `survivors=[]` over a live group we
59
+ * targeted. The skip log is unchanged.
60
+ *
61
+ * #299 FIX A.2 closes one more major on `ae6d313`:
62
+ * - Group membership is THREE-state (`true` / positively-empty `false` /
63
+ * `unknown` = `undefined`). A partial or unavailable inspection must never
64
+ * read as empty: `groupTarget.verifyDeath` treats `unknown` as NOT dead (no
65
+ * early-exit; the unconditional final group SIGKILL still fires) and
66
+ * `refusedForceKillGroup` treats `unknown` as a survivor (fail-safe
67
+ * reporting). Only a POSITIVELY empty group may report clean.
25
68
  */
26
69
 
27
70
  /** A process handle the escalation helper can signal and liveness-check. */
@@ -30,8 +73,260 @@ export interface EscalationTarget {
30
73
  terminate(): void;
31
74
  /** Send SIGKILL; may throw when the target is already gone. */
32
75
  forceKill(): void;
33
- /** True when the target is confirmed dead (so no SIGKILL is needed). */
76
+ /** True when the target is confirmed dead (group-empty for a group target). */
34
77
  verifyDeath(): boolean;
78
+ /**
79
+ * #299 review Major 2: true when the DIRECT process the target names is still
80
+ * alive. A group target's `verifyDeath` is group-empty (a dead leader with a
81
+ * live member is NOT dead), but `forceKill` signals the leader directly; the
82
+ * escalation consults this so it never force-kills an already-exited leader.
83
+ * Such a group is ended by the unconditional `forceKillGroup` instead, which
84
+ * keeps D5's tripwire honest. Absent = treat as alive (direct-only targets).
85
+ */
86
+ verifyProcessAlive?(): boolean;
87
+ /**
88
+ * #299 Option B D5: optional UNCONDITIONAL final group SIGKILL, called for
89
+ * every target after the grace window even when `verifyDeath()` reports the
90
+ * leader dead. A group target's `verifyDeath` is group-empty, so a
91
+ * TERM-stubborn member whose group leader already exited would otherwise never
92
+ * be force-killed. Must tolerate ESRCH (a vanished/empty group is the desired
93
+ * end state). Absent on direct-only pid targets.
94
+ */
95
+ forceKillGroup?(): void;
96
+ /**
97
+ * #299 re-review A.1: true when the FINAL group SIGKILL above was REFUSED by
98
+ * the signal guard AND the targeted group still has live members — i.e. the
99
+ * escalation deliberately left a live group it could not verify as ours.
100
+ * `escalateKill` counts such a target as a survivor, so a refused group kill
101
+ * can never be reported as a clean `survivors=[]`. Absent on direct-only
102
+ * targets (no group to leave behind).
103
+ */
104
+ refusedForceKillGroup?(): boolean;
105
+ }
106
+
107
+ /**
108
+ * True when the platform offers POSIX process groups: `detached: true` makes a
109
+ * child a session/group leader and `kill(-pgid)` signals the whole group.
110
+ * Windows has neither, so every group path falls back to direct signals (D6).
111
+ *
112
+ * `__setProcessGroupKillSupportedForTest` is the injectable seam that lets a
113
+ * test prove the Windows fallback without running on Windows.
114
+ */
115
+ let processGroupKillOverride: boolean | undefined;
116
+
117
+ export function supportsProcessGroupKill(): boolean {
118
+ if (processGroupKillOverride !== undefined) return processGroupKillOverride;
119
+ return process.platform !== "win32";
120
+ }
121
+
122
+ /**
123
+ * TEST-ONLY: force the process-group capability answer (undefined restores real
124
+ * platform detection), mirroring `__setProcAvailableForTest` in `src/proc.ts`.
125
+ */
126
+ export function __setProcessGroupKillSupportedForTest(value: boolean | undefined): void {
127
+ processGroupKillOverride = value;
128
+ }
129
+
130
+ /**
131
+ * #299 review Major 1 — signal-time identity guard for MARKER-DERIVED targets.
132
+ *
133
+ * A marker pid comes from a `/proc` scan at SCAN time; between that scan and the
134
+ * signal the pid may exit and be reused by an unrelated process. Every signal
135
+ * derived from a marker pid must therefore be preceded by an immediate
136
+ * re-verification that the pid is still OURS. `isOwn(pid)` performs that read
137
+ * (the LIVE `/proc/<pid>/environ` marker) and is consulted at each dispatch:
138
+ *
139
+ * - direct pid signal — allowed only when `isOwn(pid)` is true;
140
+ * - group signal — allowed when `isOwn(pid)` is true OR the pid is confirmed
141
+ * GONE (`!pidAlive(pid)`). A dead leader's pgid cannot have been reused by a
142
+ * LIVE foreign leader, so `kill(-pgid)` can only reach our surviving members
143
+ * (this keeps D5's group SIGKILL for a TERM-stubborn member working after the
144
+ * leader exits). An ALIVE pid that no longer carries the marker IS the
145
+ * review's reused-pid case: EVERY signal is skipped, never unverified.
146
+ *
147
+ * `onSkip` records a refused signal. A skipped signal is never retried
148
+ * unverified.
149
+ *
150
+ * `isGone(pid)` separates a REUSED pid from a merely GONE one: a zombie (not yet
151
+ * reaped) and a reaped pid are both "gone", while an alive process that no
152
+ * longer carries the marker is the reuse case. The group signal is skipped ONLY
153
+ * for the alive-foreign case — a gone/zombie leader's pgid must still be signaled
154
+ * so its surviving members (D5) are reached.
155
+ *
156
+ * RESIDUAL (documented at the call sites too): the re-check narrows the reuse
157
+ * window to the read→`kill` syscall boundary — the same exposure the direct
158
+ * signals have carried since Option A. It is narrowed, not eliminated.
159
+ */
160
+ export interface SignalGuard {
161
+ /** True when `pid` still carries OUR marker at this instant. */
162
+ isOwn(pid: number): boolean;
163
+ /** True when `pid` is gone: reaped OR a not-yet-reaped zombie. */
164
+ isGone(pid: number): boolean;
165
+ /** Called when a signal is refused (verification failed). */
166
+ onSkip?(pid: number, signal: NodeJS.Signals, phase: SignalPhase): void;
167
+ }
168
+
169
+ /** Which dispatch attempt a guard check precedes (for skip logging). */
170
+ export type SignalPhase = "terminate" | "forceKill" | "forceKillGroup";
171
+
172
+ /**
173
+ * The DIRECT-signal guard dispatch shared by `pidTarget` and `groupTarget`: the
174
+ * signal runs only while `pid` still carries OUR marker, otherwise the refusal
175
+ * is recorded and the signal is skipped (Major 1). `groupTarget` keeps a
176
+ * separate dispatch for the GROUP signal, which additionally honours a
177
+ * confirmed-gone leader and remembers a refused final group SIGKILL.
178
+ */
179
+ function dispatchIfOwned(
180
+ pid: number,
181
+ guard: SignalGuard | undefined,
182
+ signal: NodeJS.Signals,
183
+ phase: SignalPhase,
184
+ run: () => void,
185
+ ): void {
186
+ if (guard && !guard.isOwn(pid)) {
187
+ guard.onSkip?.(pid, signal, phase);
188
+ return;
189
+ }
190
+ run();
191
+ }
192
+
193
+ /**
194
+ * Build the `EscalationTarget` for ONE pid from injected kill/liveness
195
+ * primitives. Shared by the boot-scan reap (`reapPids`) and the marker sweeps
196
+ * (`reapActiveChildren`, the run-exit sweep) so the pid-target shape —
197
+ * SIGTERM, SIGKILL, `verifyDeath` = pid-not-alive — cannot drift between sites.
198
+ *
199
+ * `guard` (optional) re-verifies a marker-derived pid at signal time (Major 1);
200
+ * an absent guard leaves the historical unconditional direct target.
201
+ */
202
+ export function pidTarget(
203
+ pid: number,
204
+ kill: (pid: number, signal: NodeJS.Signals) => void,
205
+ pidAlive: (pid: number) => boolean,
206
+ guard?: SignalGuard,
207
+ ): EscalationTarget {
208
+ return {
209
+ terminate: () => dispatchIfOwned(pid, guard, "SIGTERM", "terminate", () => kill(pid, "SIGTERM")),
210
+ forceKill: () => dispatchIfOwned(pid, guard, "SIGKILL", "forceKill", () => kill(pid, "SIGKILL")),
211
+ verifyDeath: () => {
212
+ if (guard) return guard.isGone(pid) || !guard.isOwn(pid);
213
+ return !pidAlive(pid);
214
+ },
215
+ };
216
+ }
217
+
218
+ /** Best-effort group signal: a vanished/empty group (ESRCH) is success. */
219
+ function bestEffortGroupKill(
220
+ killGroup: (pgid: number, signal: NodeJS.Signals) => void,
221
+ pgid: number,
222
+ signal: NodeJS.Signals,
223
+ ): void {
224
+ try {
225
+ killGroup(pgid, signal);
226
+ } catch {
227
+ // Best-effort per D2/D3: the group may not exist (a non-detached target,
228
+ // or a leader that already exited). The direct signal is never skipped.
229
+ }
230
+ }
231
+
232
+ /**
233
+ * #299 Option B D2/D3: build a GROUP-reaching target for one pid — signal the
234
+ * process group (`kill(-pid)`) best-effort AND the pid directly. The direct
235
+ * signal is never replaced: a `#322` test-seam child may not be a group leader,
236
+ * so `-pid` names no group (ESRCH) while the direct signal still lands.
237
+ *
238
+ * #299 review Major 2: `verifyDeath` is LEADER DEAD **AND** GROUP EMPTY — a
239
+ * leader exiting does not empty the group, so the escalation waits out the grace
240
+ * for a member's cleanup. `groupHasMembers` is the injected /proc reader; when
241
+ * absent (no /proc platform) it degrades to leader liveness (documented
242
+ * boundary). #299 fix A.2 makes that reader THREE-state: `undefined` (inspection
243
+ * unavailable or partial) is UNKNOWN and must never read as empty, so
244
+ * `verifyDeath` reports NOT dead until a COMPLETED inspection proves emptiness.
245
+ * `verifyProcessAlive` exposes the DIRECT leader liveness so `forceKill` is never
246
+ * sent to an already-exited leader — the unconditional `forceKillGroup` below
247
+ * remains the sole end for a stubborn GROUP member after the leader is gone
248
+ * (D5).
249
+ *
250
+ * `guard` (optional) re-verifies a marker-derived pid at signal time (Major 1).
251
+ */
252
+ export function groupTarget(
253
+ pid: number,
254
+ kill: (pid: number, signal: NodeJS.Signals) => void,
255
+ killGroup: (pgid: number, signal: NodeJS.Signals) => void,
256
+ pidAlive: (pid: number) => boolean,
257
+ groupHasMembers?: (pgid: number) => boolean | undefined,
258
+ guard?: SignalGuard,
259
+ ): EscalationTarget {
260
+ // A group signal is skipped ONLY for a live pid that is no longer ours (the
261
+ // reused-pid case). A gone/zombie leader's pgid can only name our surviving
262
+ // members, so the group signal must still land (D5).
263
+ // #299 re-review A.1: remember a REFUSED final group SIGKILL so the escalation
264
+ // can report the still-live group it could not end (never a clean report).
265
+ let finalGroupRefused = false;
266
+ const dispatchGroup = (signal: NodeJS.Signals, phase: SignalPhase, run: () => void): void => {
267
+ if (guard && !guard.isOwn(pid) && !guard.isGone(pid)) {
268
+ guard.onSkip?.(pid, signal, phase);
269
+ if (phase === "forceKillGroup") finalGroupRefused = true;
270
+ return;
271
+ }
272
+ run();
273
+ };
274
+ return {
275
+ terminate: () => {
276
+ dispatchGroup("SIGTERM", "terminate", () => bestEffortGroupKill(killGroup, pid, "SIGTERM"));
277
+ dispatchIfOwned(pid, guard, "SIGTERM", "terminate", () => kill(pid, "SIGTERM"));
278
+ },
279
+ forceKill: () => {
280
+ dispatchGroup("SIGKILL", "forceKill", () => bestEffortGroupKill(killGroup, pid, "SIGKILL"));
281
+ dispatchIfOwned(pid, guard, "SIGKILL", "forceKill", () => kill(pid, "SIGKILL"));
282
+ },
283
+ verifyProcessAlive: () => (guard ? !guard.isGone(pid) && guard.isOwn(pid) : pidAlive(pid)),
284
+ verifyDeath: () => {
285
+ // A live member keeps the group (and so the target) alive even after the
286
+ // leader is gone — including a not-yet-reaped ZOMBIE leader, which
287
+ // `pidAlive` still reports as alive (Major 2).
288
+ //
289
+ // #299 fix A.2: membership is THREE-state. `true` (live members) and
290
+ // `undefined` (inspection unavailable or partial — UNKNOWN) both mean the
291
+ // group is NOT confirmed empty, so the target is NOT dead and the escalation
292
+ // honours the full grace window. Only `false` (a COMPLETED inspection found
293
+ // no live member) lets the leader-liveness check decide. An ABSENT reader (no
294
+ // /proc platform) keeps the documented leader-only fallback.
295
+ if (groupHasMembers && groupHasMembers(pid) !== false) return false;
296
+ if (guard) return guard.isGone(pid) || !guard.isOwn(pid);
297
+ return !pidAlive(pid); // no guard: leader-only fallback
298
+ },
299
+ forceKillGroup: () => {
300
+ dispatchGroup("SIGKILL", "forceKillGroup", () => bestEffortGroupKill(killGroup, pid, "SIGKILL"));
301
+ },
302
+ // #299 re-review A.1: a refused final group SIGKILL over a group that still
303
+ // has live members is a real survivor — the escalation left a live group it
304
+ // could not verify as ours.
305
+ //
306
+ // #299 fix A.2: only a POSITIVELY empty group (`false`) may report clean.
307
+ // `undefined` (unknown inspection) and an ABSENT reader both mean emptiness
308
+ // could not be established, so the fail-safe is to count a survivor.
309
+ refusedForceKillGroup: () =>
310
+ finalGroupRefused && (groupHasMembers ? groupHasMembers(pid) !== false : true),
311
+ };
312
+ }
313
+
314
+ /**
315
+ * Choose the group-reaching target when a group primitive is available (POSIX;
316
+ * D4), else the direct-only `pidTarget` (Windows fallback; D6). The presence of
317
+ * `killGroup` IS the capability gate — callers pass it only where supported.
318
+ */
319
+ export function processTarget(
320
+ pid: number,
321
+ kill: (pid: number, signal: NodeJS.Signals) => void,
322
+ pidAlive: (pid: number) => boolean,
323
+ killGroup?: (pgid: number, signal: NodeJS.Signals) => void,
324
+ groupHasMembers?: (pgid: number) => boolean | undefined,
325
+ guard?: SignalGuard,
326
+ ): EscalationTarget {
327
+ return killGroup
328
+ ? groupTarget(pid, kill, killGroup, pidAlive, groupHasMembers, guard)
329
+ : pidTarget(pid, kill, pidAlive, guard);
35
330
  }
36
331
 
37
332
  export interface EscalationOptions {
@@ -51,8 +346,21 @@ export interface EscalationOptions {
51
346
 
52
347
  /**
53
348
  * Escalate every target together: SIGTERM all, wait (at most) one grace window,
54
- * then SIGKILL every target that still verifies alive. Returns the survivors.
55
- * Never throws on a target that vanished mid-sequence.
349
+ * then SIGKILL every target that still verifies alive (direct), then the
350
+ * unconditional final group SIGKILL, then report survivors. Never throws on a
351
+ * target that vanished mid-sequence.
352
+ *
353
+ * #299 review Major 2: the wait uses `verifyDeath`, which for a group target is
354
+ * LEADER DEAD **AND** GROUP EMPTY, so a leader that dies promptly no longer
355
+ * short-circuits the grace window for a member still doing cleanup. The direct
356
+ * `forceKill` (and the survivor report) consult `verifyProcessAlive` so a group
357
+ * whose leader already exited is not direct-signaled — its surviving member is
358
+ * ended by the unconditional `forceKillGroup` below (the D5 mechanism).
359
+ *
360
+ * #299 re-review A.1: after the unconditional group SIGKILL loop, any target
361
+ * whose final group signal was REFUSED while its group still had live members is
362
+ * appended to the survivors (`refusedForceKillGroup`), so a refined guard
363
+ * decision can never report a clean result over a live group we targeted.
56
364
  */
57
365
  export async function escalateKill(
58
366
  targets: EscalationTarget[],
@@ -83,12 +391,36 @@ export async function escalateKill(
83
391
  const survivors: EscalationTarget[] = [];
84
392
  for (const target of targets) {
85
393
  if (target.verifyDeath()) continue;
394
+ // Only force-kill the DIRECT process while it is still alive; a group whose
395
+ // leader already exited is ended by the unconditional group SIGKILL below.
396
+ if (target.verifyProcessAlive?.() ?? true) {
397
+ try {
398
+ target.forceKill();
399
+ } catch {
400
+ // Exited between the liveness check and the signal.
401
+ }
402
+ }
403
+ if (!target.verifyDeath() && (target.verifyProcessAlive?.() ?? true)) survivors.push(target);
404
+ }
405
+
406
+ // D5 (option b): the final GROUP SIGKILL is UNCONDITIONAL. `verifyDeath` is
407
+ // group-empty (Major 2), so the escalation may reach here while a group is
408
+ // still non-empty; a TERM-stubborn member whose leader has already exited must
409
+ // still be killed, and this is the only signal that reaches it. Group targets
410
+ // swallow ESRCH; pid targets have no `forceKillGroup` and are untouched.
411
+ for (const target of targets) {
412
+ if (!target.forceKillGroup) continue;
86
413
  try {
87
- target.forceKill();
414
+ target.forceKillGroup();
88
415
  } catch {
89
- // Exited between the liveness check and the signal.
416
+ // Vanished group — the desired end state.
417
+ }
418
+ // #299 re-review A.1: a REFUSED final group SIGKILL over a still-live group
419
+ // leaves members we targeted alive. Surface that as a survivor so a refined
420
+ // guard decision can never produce a dishonest `survivors=[]`.
421
+ if (target.refusedForceKillGroup?.() && !survivors.includes(target)) {
422
+ survivors.push(target);
90
423
  }
91
- if (!target.verifyDeath()) survivors.push(target);
92
424
  }
93
425
  return survivors;
94
426
  }
package/src/metrics.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * against a baseline.
7
7
  */
8
8
 
9
- import type { SubagentRun, SLAMetrics, DegradationReport, ErrorCategory } from "./types";
9
+ import { isAbortedRun, type SubagentRun, type SLAMetrics, type DegradationReport, type ErrorCategory } from "./types";
10
10
 
11
11
  // ---------------------------------------------------------------------------
12
12
  // Percentile helper
@@ -58,9 +58,11 @@ export function computeSLAMetrics(runs: SubagentRun[]): SLAMetrics {
58
58
  };
59
59
  }
60
60
 
61
- // Count successes and failures (running runs are neither)
61
+ // Count successes and failures (running runs are neither). Aborted runs
62
+ // (abandoned or user-stopped) are excluded from the failure tally — see
63
+ // `isAbortedRun`; they are also not successes, so they leave the denominator.
62
64
  const succeeded = runs.filter((r) => r.status === "done").length;
63
- const failed = runs.filter((r) => r.status === "failed").length;
65
+ const failed = runs.filter((r) => r.status === "failed" && !isAbortedRun(r)).length;
64
66
  const finishedRuns = succeeded + failed;
65
67
 
66
68
  const successRate = finishedRuns > 0 ? succeeded / finishedRuns : 0;
@@ -219,6 +219,29 @@ export function markTerminalSeen(seen: Set<string>, id: string, cap = DEDUPE_CAP
219
219
  return true;
220
220
  }
221
221
 
222
+ /**
223
+ * Shared terminal-notice claim set (issue #315). ONE module-level surface so
224
+ * the event-bus wake (`subagent-completion`) and the poller's crash notices
225
+ * (`subagent-notification`) can never both fire for the same run id. Wraps
226
+ * `markTerminalSeen`, so the dedupe + cap rule lives in exactly one place.
227
+ */
228
+ const terminalClaims = new Set<string>();
229
+
230
+ /**
231
+ * Claim the single terminal notice for `id`. Returns true when THIS caller
232
+ * wins the claim (and must deliver), false when another terminal path already
233
+ * claimed the id. The wake is canonical: its subscriber claims synchronously
234
+ * on the terminal event, so a later crash notice is suppressed.
235
+ */
236
+ export function claimTerminalNotice(id: string): boolean {
237
+ return markTerminalSeen(terminalClaims, id);
238
+ }
239
+
240
+ /** TEST-ONLY: clear the shared terminal-claim set between test cases. */
241
+ export function __resetTerminalClaims(): void {
242
+ terminalClaims.clear();
243
+ }
244
+
222
245
  /**
223
246
  * THIN sender — the only `pi.sendMessage` call site. One call, no logic.
224
247
  * The `delivery` is the resolved output of `resolveDelivery` (D1+D2); it