pi-crew 0.11.38 → 0.11.39

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.
@@ -10,21 +10,52 @@
10
10
  *
11
11
  * Safety model (mirrors zombie-scanner.ts): this module NEVER kills on a bare
12
12
  * pid-liveness guess. A pid is reaped only when it is (a) recorded as the
13
- * worker of a run whose reconcile verdict was already "stale", AND (b) confirmed
14
- * by the AUTHORITATIVE zombie signal — `PI_CREW_KIND=subagent` with a DEAD
15
- * `PI_CREW_PARENT_PID` (i.e. its host is gone). A worker whose parent is still
16
- * alive is never a candidate, so a healthy in-flight worker cannot be killed by
17
- * accident. All IO is injectable for unit testing (no real kills in tests).
13
+ * worker of a run whose reconcile verdict was already "stale", AND (b) either
14
+ * confirmed by the AUTHORITATIVE zombie signal — `PI_CREW_KIND=subagent` with a
15
+ * DEAD `PI_CREW_PARENT_PID` — or IDENTITY-PROVEN (wave 11a: the recorded
16
+ * starttime matches the live /proc value, so we know it is the exact process we
17
+ * spawned, PID reuse excluded). A worker whose parent is still alive and whose
18
+ * identity we cannot prove is never a candidate, so a healthy in-flight worker
19
+ * cannot be killed by accident. All IO is injectable for unit testing (no real
20
+ * kills in tests).
21
+ *
22
+ * Wave 11a (F2) changes, in one place each:
23
+ * - evidence carries the persisted pgid/starttime/uid (GAP-3 fix feeds it),
24
+ * - a known pgid routes through reap-group.ts (guarded ladder, SIGTERM →
25
+ * SIGKILL escalation) instead of a single fire-and-forget pid SIGTERM,
26
+ * - the `scanZombieSubagents()` gate is FAIL-OPEN: a scan throw no longer
27
+ * disables reaping wholesale — identity-proven candidates still reap,
28
+ * - `PI_CREW_REAP_GROUP=0` degrades every path to the pre-wave pid-only kill.
29
+ *
30
+ * Wave 11a review fixes (round 1):
31
+ * - identity proof now ALSO requires leader-proof (`evidence.pgid ===
32
+ * evidence.pid`) — a non-leader pgid can be the HOST's group when the child
33
+ * was spawned with `useGroup=false` (MAJOR #2).
34
+ * - single accounting: a pid lands in `reaped` XOR `skipped` (MINOR #6).
35
+ * - an escalation promise is never dropped, and `escalate:false` wraps it with
36
+ * a settle-only catch so Node 22 cannot see an unhandled rejection (MINOR #5).
18
37
  */
19
38
 
20
39
  import type { TeamTaskState } from "../../state/types.ts";
40
+ import { logInternalError } from "../../utils/internal-error.ts";
21
41
  import { checkProcessLiveness } from "../process-status.ts";
42
+ import { type ProcIdentity, readProcIdentity, reapGroup, resolveReapGroupEnabled } from "./reap-group.ts";
22
43
  import { scanZombieSubagents } from "./zombie-scanner.ts";
23
44
 
24
- /** A recorded worker process for a run task (from heartbeat.pid / checkpoint.childPid). */
45
+ /**
46
+ * A recorded worker process for a run task (from heartbeat.pid /
47
+ * checkpoint.childPid) plus, when the record carries it, the spawn-time
48
+ * identity that makes the pid reuse-proof.
49
+ */
25
50
  export interface WorkerPidEvidence {
26
51
  taskId: string;
27
52
  pid: number;
53
+ /** Process-group id recorded at spawn (heartbeat.pgid / checkpoint.childPgid). */
54
+ pgid?: number;
55
+ /** `/proc/<pid>/stat` field 22 at spawn — survives PID reuse. */
56
+ starttime?: string;
57
+ /** uid at spawn — a group we may not signal is refused. */
58
+ uid?: number;
28
59
  }
29
60
 
30
61
  export interface ReapOrphanOptions {
@@ -32,6 +63,14 @@ export interface ReapOrphanOptions {
32
63
  isAlive?: (pid: number) => boolean;
33
64
  /** Signal sender (default: process.kill). Injected in tests. */
34
65
  kill?: (pid: number, signal: NodeJS.Signals) => void;
66
+ /** Group signal sender for the reap-group path (default: process.kill(-pgid, sig)). */
67
+ killGroup?: (pgid: number, signal: NodeJS.Signals) => void;
68
+ /**
69
+ * Group liveness probe for the reap-group path (default: `process.kill(-pgid, 0)`).
70
+ * Test seam: without it a unit test would have to pick a pgid that must not
71
+ * exist in the host's process table before it could observe the refusal path.
72
+ */
73
+ groupAlive?: (pgid: number) => boolean;
35
74
  /** Signal to send first (default SIGTERM — graceful). */
36
75
  signal?: NodeJS.Signals;
37
76
  /** Pids that must never be touched (self/ancestors). */
@@ -40,6 +79,27 @@ export interface ReapOrphanOptions {
40
79
  allowedPids?: ReadonlySet<number>;
41
80
  /** Report candidates without killing (used by doctor/dry-run). */
42
81
  dryRun?: boolean;
82
+ /** Grace window between SIGTERM and SIGKILL on the group path (ms). */
83
+ gracefulMs?: number;
84
+ /** Hard cap after SIGKILL on the group path (ms). */
85
+ capMs?: number;
86
+ /** Collect the async escalation promises in `pending` (default true). */
87
+ escalate?: boolean;
88
+ /** Let identity-proven pids bypass the `allowedPids` gate (default true). */
89
+ allowIdentityProven?: boolean;
90
+ /**
91
+ * When set, a pid that is NOT identity-proven is skipped even with no
92
+ * `allowedPids` gate at all. Used by the fail-open branch below: a scan that
93
+ * throws must not degrade into "SIGTERM every recorded pid" (a recycled pid
94
+ * would then be a stranger's process) — only identity-proven candidates pass.
95
+ */
96
+ requireIdentityProof?: boolean;
97
+ /** Platform override for the reap-group guard ladder (tests). */
98
+ platform?: NodeJS.Platform;
99
+ /** /proc identity reader override (tests). */
100
+ readIdentity?: (pid: number) => ProcIdentity | undefined;
101
+ /** Zombie-scan override (tests force the fail-open path). */
102
+ scanZombies?: () => { zombies: { pid: number }[] };
43
103
  }
44
104
 
45
105
  export interface ReapOrphanResult {
@@ -47,23 +107,43 @@ export interface ReapOrphanResult {
47
107
  skipped: { pid: number; reason: string }[];
48
108
  /** Every distinct pid considered. */
49
109
  candidates: number[];
110
+ /**
111
+ * In-flight group escalations (SIGKILL ladder). The caller that owns the
112
+ * process lifetime must await these — `stale-reconciler.ts` does — so the
113
+ * host does not exit before SIGKILL has been delivered.
114
+ */
115
+ pending: Promise<unknown>[];
50
116
  }
51
117
 
52
118
  /**
53
119
  * Collect the recorded worker pids of a run's in-flight tasks. A `running` or
54
120
  * `waiting` task that recorded its child pid (heartbeat or checkpoint) is the
55
- * evidence we reap against. Deduped by pid.
121
+ * evidence we reap against. Deduped by pid. Optional identity fields are copied
122
+ * verbatim — a legacy record simply yields the pre-wave shape.
56
123
  */
57
124
  export function collectWorkerPidEvidence(tasks: TeamTaskState[]): WorkerPidEvidence[] {
58
125
  const seen = new Set<number>();
59
126
  const evidence: WorkerPidEvidence[] = [];
60
127
  for (const task of tasks) {
61
128
  if (task.status !== "running" && task.status !== "waiting") continue;
62
- const pid = task.heartbeat?.pid ?? task.checkpoint?.childPid;
129
+ const heartbeat = task.heartbeat;
130
+ const checkpoint = task.checkpoint;
131
+ const pid = heartbeat?.pid ?? checkpoint?.childPid;
63
132
  if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 1) continue;
64
133
  if (seen.has(pid)) continue;
65
134
  seen.add(pid);
66
- evidence.push({ taskId: task.id, pid });
135
+ // Wave 11a (F3): the persisted identity, spread only when present so a
136
+ // legacy record keeps its exact pre-wave shape.
137
+ const pgid = heartbeat?.pgid ?? checkpoint?.childPgid;
138
+ const starttime = heartbeat?.starttime ?? checkpoint?.childStartTime;
139
+ const uid = heartbeat?.uid ?? checkpoint?.childUid;
140
+ evidence.push({
141
+ taskId: task.id,
142
+ pid,
143
+ ...(pgid !== undefined ? { pgid } : {}),
144
+ ...(starttime !== undefined ? { starttime } : {}),
145
+ ...(uid !== undefined ? { uid } : {}),
146
+ });
67
147
  }
68
148
  return evidence;
69
149
  }
@@ -72,21 +152,29 @@ export function collectWorkerPidEvidence(tasks: TeamTaskState[]): WorkerPidEvide
72
152
  * Reap orphaned worker processes. Conservative by construction:
73
153
  * * invalid / pid<=1 → skipped,
74
154
  * * protected (self/ancestors) → skipped,
75
- * * not alive → skipped (nothing to reap),
76
- * * `allowedPids` set → any pid outside it is skipped (authoritative gate),
77
- * * otherwise SIGTERM.
155
+ * * `allowedPids` set → any pid outside it is skipped UNLESS identity-proven
156
+ * (wave 11a fail-open: a matching starttime proves it is our process),
157
+ * * `dryRun` → skipped,
158
+ * * known pgid + kill switch on → guarded group reap (SIGTERM → escalation),
159
+ * falling back to the pid-only SIGTERM when the guard ladder refuses,
160
+ * * otherwise the pre-wave pid-only SIGTERM.
78
161
  */
79
162
  export function reapOrphanWorkers(evidences: WorkerPidEvidence[], opts: ReapOrphanOptions = {}): ReapOrphanResult {
163
+ const readIdentity = opts.readIdentity ?? readProcIdentity;
80
164
  const isAlive = opts.isAlive ?? ((pid: number) => checkProcessLiveness(pid).alive);
81
165
  const kill = opts.kill ?? ((pid: number, signal: NodeJS.Signals) => process.kill(pid, signal));
82
166
  const protectedPids = opts.protectedPids ?? new Set<number>([process.pid, process.ppid]);
83
167
  const signal = opts.signal ?? "SIGTERM";
168
+ const escalate = opts.escalate ?? true;
169
+ const allowIdentityProven = opts.allowIdentityProven ?? true;
84
170
  const reaped: number[] = [];
85
171
  const skipped: { pid: number; reason: string }[] = [];
86
172
  const candidates: number[] = [];
173
+ const pending: Promise<unknown>[] = [];
87
174
  const seen = new Set<number>();
88
175
 
89
- for (const { pid } of evidences) {
176
+ for (const evidence of evidences) {
177
+ const { pid } = evidence;
90
178
  if (seen.has(pid)) continue;
91
179
  seen.add(pid);
92
180
  candidates.push(pid);
@@ -98,18 +186,94 @@ export function reapOrphanWorkers(evidences: WorkerPidEvidence[], opts: ReapOrph
98
186
  skipped.push({ pid, reason: "protected-self-or-ancestor" });
99
187
  continue;
100
188
  }
101
- if (opts.allowedPids && !opts.allowedPids.has(pid)) {
189
+ const alive = isAlive(pid);
190
+ // Wave 11a review (MAJOR #2): a recorded starttime proves this is the very
191
+ // incarnation we spawned ONLY when it also proves LEADERSHIP
192
+ // (`evidence.pgid === evidence.pid`). A non-leader pgid cannot be reasoned
193
+ // about from a snapshot: it may be the group of the host that spawned the
194
+ // worker (rpc frames are `detached` but reuse the parent group when
195
+ // useGroup=false). Those candidates are refused here and are the target of
196
+ // the marker-based sweep in wave 11b. An already-dead pid needs no liveness
197
+ // cross-check (nothing can be reused), a live one must match exactly.
198
+ const liveIdentity = alive ? readIdentity(pid) : undefined;
199
+ const leaderProven = evidence.pgid !== undefined && evidence.pgid === pid;
200
+ const identityProven =
201
+ allowIdentityProven &&
202
+ leaderProven &&
203
+ evidence.starttime !== undefined &&
204
+ (alive ? liveIdentity?.starttime !== undefined && liveIdentity.starttime === evidence.starttime : true);
205
+ if (opts.allowedPids && !opts.allowedPids.has(pid) && !identityProven) {
102
206
  skipped.push({ pid, reason: "not-an-authoritative-orphan" });
103
207
  continue;
104
208
  }
105
- if (!isAlive(pid)) {
106
- skipped.push({ pid, reason: "already-dead" });
209
+ if (opts.requireIdentityProof && !identityProven) {
210
+ skipped.push({ pid, reason: "not-an-authoritative-orphan" });
107
211
  continue;
108
212
  }
109
213
  if (opts.dryRun) {
110
214
  skipped.push({ pid, reason: "dry-run" });
111
215
  continue;
112
216
  }
217
+ const pgid = evidence.pgid ?? liveIdentity?.pgid;
218
+ if (pgid !== undefined && Number.isInteger(pgid) && pgid > 1) {
219
+ if (!resolveReapGroupEnabled()) {
220
+ // Kill switch on ⇒ degrade to the pre-wave pid-only path (the pid is
221
+ // still SIGTERMed below). Not recorded as skipped: a pid only ever
222
+ // appears in ONE of the two result arrays (MINOR #6 — double
223
+ // accounting made `reaped` and `skipped` overlap).
224
+ logInternalError(
225
+ "orphan-reaper.group-disabled",
226
+ new Error(`PI_CREW_REAP_GROUP=0: pgid ${pgid} of pid=${pid} left untouched, pid-only kill path`),
227
+ `task=${evidence.taskId}`,
228
+ "debug",
229
+ );
230
+ } else {
231
+ const expected: ProcIdentity | undefined =
232
+ evidence.starttime !== undefined ? { pid, pgid, starttime: evidence.starttime, uid: evidence.uid } : undefined;
233
+ const groupResult = reapGroup(pgid, pid, {
234
+ ...(opts.gracefulMs !== undefined ? { gracefulMs: opts.gracefulMs } : {}),
235
+ ...(opts.capMs !== undefined ? { capMs: opts.capMs } : {}),
236
+ ...(opts.platform !== undefined ? { platform: opts.platform } : {}),
237
+ readIdentity,
238
+ ...(opts.killGroup !== undefined ? { signalGroup: opts.killGroup } : {}),
239
+ ...(opts.groupAlive !== undefined ? { groupAlive: opts.groupAlive } : {}),
240
+ ...(expected !== undefined ? { expected } : {}),
241
+ name: `orphan-reaper:${pid}`,
242
+ });
243
+ if (groupResult.refusal === undefined) {
244
+ // MINOR #5: never DROP an escalation promise. `escalate:false` callers
245
+ // (doctor/dry-run) still get it collected, with a settle-only catch so
246
+ // an unexpected rejection cannot become a fatal unhandled rejection.
247
+ pending.push(
248
+ escalate
249
+ ? groupResult.pending
250
+ : groupResult.pending.catch(() => {
251
+ /* settle-only: a dry-run/doctor caller has no verdict to act on */
252
+ }),
253
+ );
254
+ reaped.push(pid);
255
+ continue;
256
+ }
257
+ if (!alive) {
258
+ // Refused group + dead pid ⇒ nothing left to signal: this pid is
259
+ // SKIPPED, and only skipped (never also `reaped`).
260
+ skipped.push({ pid, reason: `group-refused:${groupResult.refusal}` });
261
+ continue;
262
+ }
263
+ // Refused group + live pid ⇒ the pid-only fallback below still runs and
264
+ // will decide the single accounting slot.
265
+ logInternalError(
266
+ "orphan-reaper.group-refused",
267
+ new Error(`group reap refused for pid=${pid} pgid=${pgid} (${groupResult.refusal}); pid-only fallback`),
268
+ `task=${evidence.taskId}`,
269
+ "debug",
270
+ );
271
+ }
272
+ }
273
+ if (!alive) {
274
+ skipped.push({ pid, reason: "already-dead" });
275
+ continue;
276
+ }
113
277
  try {
114
278
  kill(pid, signal);
115
279
  reaped.push(pid);
@@ -117,7 +281,7 @@ export function reapOrphanWorkers(evidences: WorkerPidEvidence[], opts: ReapOrph
117
281
  skipped.push({ pid, reason: error instanceof Error ? error.message : String(error) });
118
282
  }
119
283
  }
120
- return { reaped, skipped, candidates };
284
+ return { reaped, skipped, candidates, pending };
121
285
  }
122
286
 
123
287
  /**
@@ -129,15 +293,26 @@ export function reapOrphanWorkers(evidences: WorkerPidEvidence[], opts: ReapOrph
129
293
  * yields exactly the unreapable orphans, with no false-positive kill risk for a
130
294
  * live worker. Linux-only (the scanner needs /proc); on other platforms the
131
295
  * scan yields no zombies and this is a no-op. Best-effort: never throws.
296
+ *
297
+ * Wave 11a (F2) FAIL-OPEN: a scanner throw used to disable the whole pass
298
+ * ("zombie-scan-unavailable"). It now only removes the `allowedPids` gate and
299
+ * flips on `requireIdentityProof`, so IDENTITY-PROVEN candidates (recorded
300
+ * starttime === live starttime) are still reaped while unproven ones stay
301
+ * untouched (no blanket "SIGTERM every recorded pid" degrade).
132
302
  */
133
303
  export function reapRecordedWorkerOrphans(tasks: TeamTaskState[], opts: ReapOrphanOptions = {}): ReapOrphanResult {
134
304
  const evidence = collectWorkerPidEvidence(tasks);
135
- if (evidence.length === 0) return { reaped: [], skipped: [], candidates: [] };
136
- let zombiePids: Set<number>;
305
+ if (evidence.length === 0) return { reaped: [], skipped: [], candidates: [], pending: [] };
306
+ const scanZombies = opts.scanZombies ?? scanZombieSubagents;
307
+ let allowedPids: Set<number> | undefined;
308
+ let requireIdentityProof = opts.requireIdentityProof ?? false;
137
309
  try {
138
- zombiePids = new Set(scanZombieSubagents().zombies.map((zombie) => zombie.pid));
310
+ allowedPids = new Set(scanZombies().zombies.map((zombie) => zombie.pid));
139
311
  } catch {
140
- return { reaped: [], skipped: evidence.map((item) => ({ pid: item.pid, reason: "zombie-scan-unavailable" })), candidates: [] };
312
+ // FAIL-OPEN (wave 11a): no authoritative set at all — but NOT a licence to
313
+ // signal every recorded pid. Only identity-proven candidates pass.
314
+ allowedPids = undefined;
315
+ requireIdentityProof = true;
141
316
  }
142
- return reapOrphanWorkers(evidence, { ...opts, allowedPids: zombiePids });
317
+ return reapOrphanWorkers(evidence, { ...opts, allowedPids, requireIdentityProof });
143
318
  }
@@ -20,6 +20,14 @@ export const PROC_STAT_STARTTIME_INDEX = 19;
20
20
  export const PROC_STAT_STATE_INDEX = 0;
21
21
  /** Field 4 (ppid) → second entry after the comm cut. */
22
22
  export const PROC_STAT_PPID_INDEX = 1;
23
+ /** Field 5 (pgrp / pgid) → third entry after the comm cut (reap-group.ts). */
24
+ export const PROC_STAT_PGID_INDEX = 2;
25
+ /**
26
+ * Field 7 (tty_nr) → fifth entry after the comm cut (run-descendant-sweep.ts).
27
+ * `0` means "no controlling terminal": a heartbeat/daemon shape. Anything else
28
+ * means the process is attached to a terminal — i.e. an interactive shell.
29
+ */
30
+ export const PROC_STAT_TTY_INDEX = 4;
23
31
 
24
32
  /**
25
33
  * Fields of a `/proc/<pid>/stat` line AFTER the comm column (paren-aware).