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.
- package/AGENT.md +9 -2
- package/README.md +6 -3
- package/package.json +6 -6
- package/src/history.ts +19 -9
- package/src/index.ts +174 -34
- package/src/kill-escalation.ts +338 -6
- package/src/metrics.ts +5 -3
- package/src/notify-completion.ts +23 -0
- package/src/proc.ts +270 -20
- package/src/recovery-engine.ts +100 -14
- package/src/recovery-surface.ts +374 -0
- package/src/recovery.ts +19 -1
- package/src/run-registry.ts +64 -3
- package/src/runner.ts +216 -13
- package/src/session-manager.ts +129 -7
- package/src/state.ts +32 -2
- package/src/tui-format.ts +57 -1
- package/src/tui.ts +102 -22
- package/src/types.ts +66 -1
- package/src/unit-run.ts +8 -1
package/src/kill-escalation.ts
CHANGED
|
@@ -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 (
|
|
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
|
|
55
|
-
* Never throws on a
|
|
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.
|
|
414
|
+
target.forceKillGroup();
|
|
88
415
|
} catch {
|
|
89
|
-
//
|
|
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
|
|
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;
|
package/src/notify-completion.ts
CHANGED
|
@@ -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
|