pi-crew 0.11.37 → 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.
Files changed (34) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/agents/critic.md +13 -0
  3. package/agents/oracle.md +14 -0
  4. package/dist/index.mjs +531 -526
  5. package/package.json +4 -3
  6. package/skills/delegation-patterns/SKILL.md +16 -0
  7. package/skills/real-test-pi-crew/SKILL.md +13 -2
  8. package/src/config/env-allowlist-registry.ts +22 -1
  9. package/src/config/env-vars.ts +23 -0
  10. package/src/extension/registration/foreground-run-controller.ts +21 -1
  11. package/src/extension/registration/lifecycle-handlers.ts +12 -0
  12. package/src/extension/team-tool/doctor.ts +125 -1
  13. package/src/prompt/prompt-runtime.ts +67 -5
  14. package/src/runtime/child-pi/child-pi-kill.ts +68 -2
  15. package/src/runtime/child-pi/child-pi-spawn.ts +20 -0
  16. package/src/runtime/child-pi/child-pi.ts +3 -1
  17. package/src/runtime/foreground-watchdog.ts +394 -17
  18. package/src/runtime/heartbeat/worker-heartbeat.ts +4 -0
  19. package/src/runtime/model/model-fallback.ts +5 -0
  20. package/src/runtime/process/orphan-reaper.ts +197 -22
  21. package/src/runtime/process/proc-stat.ts +8 -0
  22. package/src/runtime/process/reap-group.ts +499 -0
  23. package/src/runtime/process/run-descendant-sweep.ts +744 -0
  24. package/src/runtime/recovery/crash-recovery.ts +202 -0
  25. package/src/runtime/rpc/frame-client.ts +21 -1
  26. package/src/runtime/stale-reconciler.ts +343 -2
  27. package/src/runtime/task-runner/child-executor.ts +15 -1
  28. package/src/runtime/task-runner/post-execution.ts +112 -1
  29. package/src/runtime/task-runner/state-helpers.ts +28 -1
  30. package/src/runtime/verification/verify-gate-memo.ts +127 -0
  31. package/src/runtime/verification/verify-gate.ts +127 -1
  32. package/src/state/coordination/mailbox.ts +68 -0
  33. package/src/state/stores/state-store.ts +8 -0
  34. package/src/state/types.ts +61 -0
@@ -29,6 +29,7 @@ import { spawn } from "node:child_process";
29
29
  import { DEFAULT_CHILD_PI } from "../../config/defaults.ts";
30
30
  import { logInternalError } from "../../utils/internal-error.ts";
31
31
  import { TailCaptureStage } from "../compaction/compact-stages/tail-capture-stage.ts";
32
+ import { type ProcIdentity, readProcIdentity, reapGroup, resolveReapGroupEnabled } from "../process/reap-group.ts";
32
33
  import { HARD_KILL_MS } from "./child-pi-constants.ts";
33
34
  import { adoptOwnedProcess, disposeAllOwnedProcesses, type OwnedProcess } from "./owned-process.ts";
34
35
 
@@ -44,6 +45,34 @@ const childHardKillTimers = new Map<number, NodeJS.Timeout>();
44
45
  // once the owner reaches its terminal state.
45
46
  const ownedByPid = new Map<number, OwnedProcess>();
46
47
 
48
+ // Wave 11a (F1): the /proc identity snapshot of each registered child, taken
49
+ // AT REGISTRATION (while the child is definitely alive). This is the only
50
+ // pre-exit record of the child's pgid/starttime — once the root exits, /proc is
51
+ // gone and `adoptOwnedProcess`'s live pgid resolution (owned-process.ts) has
52
+ // nothing left to read, so a still-live group becomes unreachable. killProcessTree
53
+ // uses this snapshot for the exited-root case; the 60 s sweep prunes it once the
54
+ // owner is terminal.
55
+ const identityByPid = new Map<number, ProcIdentity>();
56
+
57
+ // Wave 11a injection seam (same shape as zombie-scanner.ts `__test`): tests seed
58
+ // identity snapshots and swap the group reaper without a real child process.
59
+ let reapGroupImpl: typeof reapGroup = reapGroup;
60
+ export const __test = {
61
+ /** Seed the exit-time identity snapshot for a pid (as registerActiveChild would). */
62
+ seedIdentity(pid: number, identity: ProcIdentity | undefined): void {
63
+ if (identity === undefined) identityByPid.delete(pid);
64
+ else identityByPid.set(pid, identity);
65
+ },
66
+ /** Drop every seeded/recorded identity snapshot. */
67
+ clearIdentities(): void {
68
+ identityByPid.clear();
69
+ },
70
+ /** Replace the group reaper (undefined restores the real one). */
71
+ setReapGroup(fn: typeof reapGroup | undefined): void {
72
+ reapGroupImpl = fn ?? reapGroup;
73
+ },
74
+ };
75
+
47
76
  // Periodic cleanup of dead child process entries to prevent memory leaks.
48
77
  // If a child process never emits exit/close (zombie), the entry would leak.
49
78
  setInterval(() => {
@@ -57,7 +86,10 @@ setInterval(() => {
57
86
  // U15: prune adopted owners that reached terminal state (group confirmed
58
87
  // gone via dispose or drain-reconcile). Non-terminal owners stay routable.
59
88
  for (const [pid, owner] of ownedByPid) {
60
- if (owner.terminated) ownedByPid.delete(pid);
89
+ if (!owner.terminated) continue;
90
+ ownedByPid.delete(pid);
91
+ // Wave 11a: the owner confirmed its group is gone — the snapshot is spent.
92
+ identityByPid.delete(pid);
61
93
  }
62
94
  }, 60_000).unref();
63
95
 
@@ -69,6 +101,11 @@ export function registerActiveChild(pid: number, child: ChildProcess): void {
69
101
  // (authoritative /proc check on Linux — the rpc branch child, spawned
70
102
  // without detached, lands on the single-process fallback automatically).
71
103
  ownedByPid.set(pid, adoptOwnedProcess(child, { name: `child-pi:${pid}` }));
104
+ // Wave 11a (F1): snapshot the identity NOW — the child is alive here, so
105
+ // /proc is readable. killProcessTree's exited-root branch reaps the group
106
+ // from this snapshot after the root is gone.
107
+ const identity = readProcIdentity(pid);
108
+ if (identity) identityByPid.set(pid, identity);
72
109
  }
73
110
 
74
111
  /**
@@ -220,7 +257,36 @@ export function killProcessTree(pid: number | undefined, child?: ChildProcess):
220
257
  void owned.dispose();
221
258
  return;
222
259
  }
223
- if (child && child.exitCode !== null) return;
260
+ if (child && child.exitCode !== null) {
261
+ // Wave 11a (F1): an EXITED ROOT IS NOT AN EMPTY GROUP. The root may have
262
+ // backgrounded descendants that are still alive in its process group; the
263
+ // pre-wave early-out returned here and left them running forever. We reap
264
+ // the group only when the registration-time snapshot PROVES the root was
265
+ // its own group leader (pgid === pid) — reapGroup re-validates everything
266
+ // (uid/pgid/starttime) and refuses on a recycled pgid. No snapshot, or a
267
+ // non-leader root, keeps the pre-wave behaviour exactly (no signal, and
268
+ // nothing logged above debug).
269
+ const identity = identityByPid.get(pid);
270
+ if (identity?.pgid === pid && resolveReapGroupEnabled()) {
271
+ const result = reapGroupImpl(pid, pid, { name: `child-pi:exited-root:${pid}`, expected: identity });
272
+ // MINOR #5 (review): the SIGKILL escalation is fire-and-forget here (this
273
+ // caller is sync), so it must be explicitly swallowed — `reapGroup` never
274
+ // rejects by contract, but a `void` promise is exactly what turns an
275
+ // unexpected rejection into a FATAL unhandled rejection on Node 22.
276
+ void result.pending.catch(() => {
277
+ /* settle-only: the escalation verdict is not actionable here */
278
+ });
279
+ identityByPid.delete(pid);
280
+ } else {
281
+ logInternalError(
282
+ "child-pi.kill-exited-root-no-snapshot",
283
+ new Error(`exited root pid=${pid} has no group-leader snapshot; no signal sent`),
284
+ `pid=${pid}`,
285
+ "debug",
286
+ );
287
+ }
288
+ return;
289
+ }
224
290
  killProcessPid(pid);
225
291
  child?.once("exit", () => clearHardKillTimer(pid));
226
292
  }
@@ -37,6 +37,13 @@ import {
37
37
  requireInferenceBrokerIfEnabled,
38
38
  } from "./inference-broker.ts";
39
39
 
40
+ // v0.11.38 W10-1 (pi-subagents lesson #10): pi platform env that selects
41
+ // short-lived prompt-cache retention for single-run worker processes
42
+ // (long-retention cache writes carry a higher per-write surcharge — wasted on
43
+ // a worker that exits after one task). Surface panes and RPC warm sessions do
44
+ // NOT set this (their sessions persist and benefit from long-lived cache).
45
+ export const PI_CACHE_RETENTION_ENV = "PI_CACHE_RETENTION";
46
+
40
47
  // ── Env allowlist (base set always passed to children) ──────────────────
41
48
  // Provider API keys are injected dynamically via buildScopedAllowList() only
42
49
  // when a model is assigned to the task (per-task key scoping).
@@ -191,6 +198,10 @@ export function buildChildPiSpawnOptions(
191
198
  },
192
199
  stdio: ["ignore", "pipe", "pipe"], // stdin=ignore: child doesn't wait for input; task comes via CLI args
193
200
  detached: process.platform !== "win32",
201
+ // Wave 11a (GAP-5): `setsid` is NOT a Node spawn option — it is silently
202
+ // ignored. Group leadership comes from `detached` alone (POSIX setsid() in
203
+ // the child). Kept verbatim instead of deleted: removing it would suggest a
204
+ // behaviour change that never happened.
194
205
  setsid: true,
195
206
  // NOTE: setsid creates a new session; the child process becomes the session leader
196
207
  // and its parent becomes that session leader (still the team-runner in the same
@@ -246,6 +257,7 @@ export function buildFinalChildPiSpawnOptions(
246
257
  builtEnv: Record<string, string | undefined>,
247
258
  model?: string,
248
259
  modelChain?: (string | undefined)[],
260
+ workerCacheSplit?: boolean,
249
261
  ): SpawnOptions {
250
262
  // (a) Canary: builtEnv must contain ONLY PI_CREW_*/PI_TEAMS_* keys.
251
263
  assertOnlyControlEnvKeys(builtEnv);
@@ -267,6 +279,14 @@ export function buildFinalChildPiSpawnOptions(
267
279
  if (inferenceSnapshot.active && builtEnv[INFERENCE_TOKEN_ENV]) {
268
280
  spawnOptions.env[PI_AGENT_DIR_ENV] = inferenceSnapshot.scopedAgentDir ?? "";
269
281
  }
282
+ // (c″) v0.11.38 W10-1 (pi-subagents lesson #10): cache TTL split. Worker
283
+ // processes are single-run and short-lived — pi's default prompt-cache
284
+ // retention is long (higher per-write surcharge). Only the headless worker
285
+ // branch opts in; surface panes and RPC warm sessions keep the parent's
286
+ // default (they persist and benefit from long-lived cache).
287
+ if (workerCacheSplit) {
288
+ spawnOptions.env[PI_CACHE_RETENTION_ENV] = "short";
289
+ }
270
290
  // (d) Return the composed SpawnOptions for spawn(...).
271
291
  return spawnOptions;
272
292
  }
@@ -850,7 +850,9 @@ async function runChildPiInner(input: ChildPiRunInput): Promise<ChildPiRunResult
850
850
  // Compose the final SpawnOptions: canary + filter + spread are now
851
851
  // owned by buildFinalChildPiSpawnOptions (see child-pi-spawn.ts, BLOCKER 2 / S5).
852
852
  // R6-8: modelChain threads the candidate chain for provider-key scoping.
853
- const spawnOptions = buildFinalChildPiSpawnOptions(input.cwd, mergedEnv, builtEnv, input.model, input.modelChain);
853
+ // v0.11.38 W10-1: workerCacheSplit — headless single-run workers opt into
854
+ // short prompt-cache retention (see PI_CACHE_RETENTION_ENV).
855
+ const spawnOptions = buildFinalChildPiSpawnOptions(input.cwd, mergedEnv, builtEnv, input.model, input.modelChain, true);
854
856
  // v0.11.36 OS boundary: when the sandbox is on, the headless spawn is
855
857
  // wrapped in bwrap. Mounts: cwd RW (the worker edits the project), the
856
858
  // scoped inference agent dir RW (pi writes session state under it), and
@@ -10,9 +10,17 @@
10
10
  * 2. Injects a followUp message via pi.sendUserMessage() so the assistant
11
11
  * is automatically notified — no manual sleep+check needed.
12
12
  * 3. Cleans up after itself when the run completes or the session ends.
13
+ *
14
+ * Hung alerts are additionally DELIVERY-gated (WD-DELIVER-GUARD): the message
15
+ * is only handed to the host when the host will deliver it immediately, and
16
+ * the run state is re-read at that instant — an alert whose run finished in
17
+ * the meantime is dropped instead of nagging the user minutes late.
13
18
  */
14
19
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
20
+ import { getCrewEnv, getCrewEnvInt } from "../config/env-vars.ts";
21
+ import { TEAM_TERMINAL_TASK_STATUSES } from "../state/contracts.ts";
15
22
  import { loadRunManifestById } from "../state/stores/state-store.ts";
23
+ import type { TeamTaskState } from "../state/types.ts";
16
24
  import { readCrewAgents } from "./crew-agent-records.ts";
17
25
  import { isActiveRunStatus, isLikelyOrphanedActiveRun } from "./process-status.ts";
18
26
 
@@ -20,10 +28,22 @@ export interface WatchdogOptions {
20
28
  pi: ExtensionAPI;
21
29
  cwd: string;
22
30
  runId: string;
23
- /** Check interval in ms. Default: 5 minutes. */
31
+ /**
32
+ * Check interval in ms. Default: 5 minutes, or the
33
+ * `PI_CREW_WATCHDOG_INTERVAL_MS` override when set (WD-INTERVAL).
34
+ */
24
35
  checkIntervalMs?: number;
25
36
  /** Maximum time to monitor in ms. Default: 2 hours. */
26
37
  maxMonitorMs?: number;
38
+ /**
39
+ * WD-DELIVER-GUARD (2026-10-23): capability-guarded probe for "the host will
40
+ * deliver a followUp message NOW" (`ctx.isIdle()`). When it reports false,
41
+ * a hung alert is held in this module's pending map and retried at the idle
42
+ * boundary instead of being handed to the host's unretractable followUp
43
+ * queue (where it can land minutes later, for a run that already finished).
44
+ * Absent / throwing → treated as idle (pre-guard behavior, fail-open).
45
+ */
46
+ isSessionIdle?: () => boolean | undefined;
27
47
  }
28
48
 
29
49
  const DEFAULT_CHECK_INTERVAL_MS = 300_000; // 5 minutes
@@ -32,6 +52,302 @@ const DEFAULT_MAX_MONITOR_MS = 7_200_000; // 2 hours
32
52
  /** Active watchdog timers — keyed by runId for cleanup. */
33
53
  const activeWatchdogs = new Map<string, ReturnType<typeof setTimeout>>();
34
54
 
55
+ /**
56
+ * WD-DEDUP (2026-10-23): hung-alert dedup state, keyed by runId at module
57
+ * scope so a *new* watchdog instance (or a re-armed one after stop/start)
58
+ * cannot re-alert for a run the user already heard about. Bounded FIFO —
59
+ * the oldest entry is evicted once the cap is reached, so long-lived
60
+ * sessions cannot leak memory on this map.
61
+ */
62
+ interface HungAlertRecord {
63
+ /** A "run … appears hung" alert has already been emitted for this runId. */
64
+ alerted: boolean;
65
+ /** The one-time re-verify-skip debug log has already been emitted. */
66
+ skipLogged: boolean;
67
+ /**
68
+ * WD-STALE-RECORD (2026-10-23): the one-time "record stale (all tasks
69
+ * terminal) — skipped hung alert" notice has already been emitted.
70
+ */
71
+ staleNoticeSent?: boolean;
72
+ /**
73
+ * WD-DELIVER-GUARD (2026-10-23): the one-time "drop hung alert at delivery"
74
+ * debug log has already been emitted for this runId.
75
+ */
76
+ droppedAtDeliveryLogged?: boolean;
77
+ }
78
+ const HUNG_ALERT_RECORDS_CAP = 256;
79
+ const hungAlertRecords = new Map<string, HungAlertRecord>();
80
+
81
+ function hungAlertAlreadySent(runId: string): boolean {
82
+ return hungAlertRecords.get(runId)?.alerted === true;
83
+ }
84
+
85
+ /** Insert/update a record, evicting the oldest entry when the cap is hit. */
86
+ function updateHungAlertRecord(runId: string, patch: Partial<HungAlertRecord>): void {
87
+ if (!hungAlertRecords.has(runId) && hungAlertRecords.size >= HUNG_ALERT_RECORDS_CAP) {
88
+ const oldest = hungAlertRecords.keys().next().value;
89
+ if (oldest !== undefined) hungAlertRecords.delete(oldest);
90
+ }
91
+ const prev = hungAlertRecords.get(runId) ?? { alerted: false, skipLogged: false, staleNoticeSent: false };
92
+ hungAlertRecords.set(runId, { ...prev, ...patch });
93
+ }
94
+
95
+ /** Debug log gated behind PI_CREW_DEBUG (same convention as background-runner). */
96
+ function debugLog(message: string): void {
97
+ if (getCrewEnv("PI_CREW_DEBUG")) console.log(message);
98
+ }
99
+
100
+ /**
101
+ * WD-STALE-RECORD (2026-10-23): a run record whose manifest is still `running`
102
+ * but whose EVERY task is terminal is a stale record, not a hung run. The
103
+ * worker(s) finished; the manifest write is merely behind (finalize racing, or
104
+ * a finalize write lost to a crash). Alerting "appears hung" for such a run is
105
+ * the exact pain users reported (alert for a run that is actually done), so the
106
+ * re-verify gate treats it as terminal and stays silent.
107
+ *
108
+ * Fail-open by design: no tasks recorded (`[]`), a non-array, or an unreadable
109
+ * tasks.json (loadRunManifestById degrades to `tasks: []`) returns false so the
110
+ * legacy alert behavior is preserved — a broken tasks file must never silence
111
+ * a genuinely hung run.
112
+ */
113
+ function areAllTasksTerminal(tasks: TeamTaskState[] | undefined): boolean {
114
+ if (!Array.isArray(tasks) || tasks.length === 0) return false;
115
+ return tasks.every((task) => TEAM_TERMINAL_TASK_STATUSES.has(task.status));
116
+ }
117
+
118
+ /**
119
+ * WD-INTERVAL (2026-10-23): resolve the poll interval. An explicit
120
+ * `checkIntervalMs` (tests / callers) wins, then the `PI_CREW_WATCHDOG_INTERVAL_MS`
121
+ * env override (live repro at 20s instead of 5 min), then the 5-minute default.
122
+ * Only positive finite values are honored — `0`/negative/garbage fall through.
123
+ */
124
+ function resolveCheckIntervalMs(overrideMs: number | undefined): number {
125
+ if (typeof overrideMs === "number" && Number.isFinite(overrideMs) && overrideMs > 0) return overrideMs;
126
+ const configured = getCrewEnvInt("PI_CREW_WATCHDOG_INTERVAL_MS");
127
+ if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) return configured;
128
+ return DEFAULT_CHECK_INTERVAL_MS;
129
+ }
130
+
131
+ /**
132
+ * Pre-emit state re-verify loader. Injectable for tests to simulate the run
133
+ * going terminal between the cycle's initial read and the emit point.
134
+ */
135
+ type ReverifyLoader = typeof loadRunManifestById;
136
+ let reverifyLoader: ReverifyLoader = loadRunManifestById;
137
+
138
+ /**
139
+ * WD-DELIVER-GUARD (2026-10-23) — the residual the emit-time gate cannot see.
140
+ *
141
+ * Real incident (run team_20261023050900): the alert was EMITTED while the run
142
+ * was still running (so emit-time re-verify was correct), then
143
+ * `pi.sendUserMessage(..., {deliverAs:"followUp"})` pushed it into the host's
144
+ * follow-up queue while the session was streaming (`agent-session.js`:
145
+ * `if (this.isStreaming) this.agent.followUp(appMessage)`) and the host
146
+ * DELIVERED it 28 minutes later — after `run.completed` had landed. The user
147
+ * still saw "appears hung" for a finished run. `clearQueue()` is all-or-
148
+ * nothing, so a queued item cannot be retracted: the only fix is to not hand
149
+ * it over until delivery is imminent.
150
+ *
151
+ * Mechanism: while the session is streaming, the alert stays in OUR pending
152
+ * map; at the idle boundary (the moment the host delivers immediately) the run
153
+ * state is re-read one final time. Run terminal (completed/failed/cancelled/
154
+ * blocked) or all tasks terminal ⇒ DROP (debug-logged once per runId, no
155
+ * user-facing noise). FAIL-OPEN: an unreadable/absent record still delivers —
156
+ * a broken state file must never swallow a genuine hung alert.
157
+ *
158
+ * BOTH exits re-verify: the idle boundary AND the delivery cap. A session that
159
+ * never idles (one tool call blocking past the cap) is not a license to hand
160
+ * over a stale alert — the cap exit runs the same classify/verify, so an
161
+ * unverified delivery is impossible. "Late beats never" only applies to alerts
162
+ * whose run still reads as hung.
163
+ */
164
+ const DEFAULT_DELIVER_RETRY_MS = 500;
165
+ const DEFAULT_DELIVER_CAP_MS = 10 * 60_000; // 10 min
166
+ /** Defensive bound — a burst of distinct hung runs must not grow the map. */
167
+ const HUNG_DELIVERY_CAP = 256;
168
+
169
+ interface PendingHungDelivery {
170
+ /** Full alert text, handed over verbatim at the idle boundary. */
171
+ text: string;
172
+ /** Past this instant the alert is handed over anyway (late beats never). */
173
+ deadline: number;
174
+ /** Idle probe captured at queue time (see WatchdogOptions.isSessionIdle). */
175
+ isSessionIdle?: () => boolean | undefined;
176
+ timer?: ReturnType<typeof setTimeout>;
177
+ }
178
+
179
+ /** Hung alerts queued but not yet handed to the host, keyed by runId. */
180
+ const pendingHungDeliveries = new Map<string, PendingHungDelivery>();
181
+
182
+ let deliverRetryMs = DEFAULT_DELIVER_RETRY_MS;
183
+ let deliverCapMs = DEFAULT_DELIVER_CAP_MS;
184
+ /** Delivery-time state loader (separate seam from the emit-time re-verify). */
185
+ let deliveryLoader: ReverifyLoader = loadRunManifestById;
186
+
187
+ /** @internal Test seam — reset module-level hung-alert dedup + delivery state. */
188
+ export function resetHungAlertDedupForTests(): void {
189
+ hungAlertRecords.clear();
190
+ for (const entry of pendingHungDeliveries.values()) {
191
+ if (entry.timer !== undefined) clearTimeout(entry.timer);
192
+ }
193
+ pendingHungDeliveries.clear();
194
+ reverifyLoader = loadRunManifestById;
195
+ deliveryLoader = loadRunManifestById;
196
+ deliverRetryMs = DEFAULT_DELIVER_RETRY_MS;
197
+ deliverCapMs = DEFAULT_DELIVER_CAP_MS;
198
+ }
199
+
200
+ /** @internal Test seam — snapshot of the dedup map size (cap/leak assertions). */
201
+ export function hungAlertDedupSizeForTests(): number {
202
+ return hungAlertRecords.size;
203
+ }
204
+
205
+ /** @internal Test seam — force-mark a runId as already alerted (cap assertions). */
206
+ export function markHungAlertForTests(runId: string): void {
207
+ updateHungAlertRecord(runId, { alerted: true });
208
+ }
209
+
210
+ /** @internal Test seam — replace the pre-emit re-verify loader (null = real). */
211
+ export function setReverifyLoaderForTests(loader: ReverifyLoader | null): void {
212
+ reverifyLoader = loader ?? loadRunManifestById;
213
+ }
214
+
215
+ /** @internal Test seam — replace the DELIVERY-time state loader (null = real). */
216
+ export function setDeliveryLoaderForTests(loader: ReverifyLoader | null): void {
217
+ deliveryLoader = loader ?? loadRunManifestById;
218
+ }
219
+
220
+ /** @internal Test seam — shrink the idle-gate retry/cap timings. */
221
+ export function setDeliveryTimingForTests(retryMs?: number, capMs?: number): void {
222
+ deliverRetryMs = typeof retryMs === "number" && retryMs > 0 ? retryMs : DEFAULT_DELIVER_RETRY_MS;
223
+ deliverCapMs = typeof capMs === "number" && capMs > 0 ? capMs : DEFAULT_DELIVER_CAP_MS;
224
+ }
225
+
226
+ /** @internal Test seam — hung alerts queued but not yet handed to the host. */
227
+ export function hungAlertDeliveryPendingForTests(): number {
228
+ return pendingHungDeliveries.size;
229
+ }
230
+
231
+ /**
232
+ * Delivery-time verdict for a queued hung alert. `hung` = hand it to the host;
233
+ * `dropped` = the run is provably no longer hung, stay silent.
234
+ */
235
+ type DeliveryVerdict = { kind: "hung" } | { kind: "dropped"; reason: string };
236
+
237
+ /**
238
+ * Read the run state at handoff time. This is the WD-DELIVER-GUARD decision
239
+ * point: it runs immediately before `pi.sendUserMessage`, i.e. at the moment
240
+ * the host will actually deliver the message (session idle).
241
+ *
242
+ * Drop conditions (the exact user-visible pain):
243
+ * • manifest status terminal (completed/failed/cancelled/blocked), or
244
+ * • manifest still active BUT every task is terminal (stale record — the
245
+ * finalize/worker write is merely behind).
246
+ * FAIL-OPEN: a loader that throws, or a vanished manifest, yields `hung` — an
247
+ * unreadable record must never swallow a genuine alert.
248
+ */
249
+ function classifyAtDelivery(cwd: string, runId: string): DeliveryVerdict {
250
+ let loaded: Awaited<ReturnType<ReverifyLoader>>;
251
+ try {
252
+ loaded = deliveryLoader(cwd, runId);
253
+ } catch {
254
+ return { kind: "hung" }; // unreadable state → fail-open
255
+ }
256
+ if (!loaded) return { kind: "hung" }; // indeterminate → fail-open
257
+ if (!isActiveRunStatus(loaded.manifest.status)) {
258
+ return { kind: "dropped", reason: `status=${loaded.manifest.status}` };
259
+ }
260
+ if (areAllTasksTerminal(loaded.tasks)) {
261
+ return { kind: "dropped", reason: `all ${loaded.tasks.length} task(s) terminal (manifest ${loaded.manifest.status})` };
262
+ }
263
+ return { kind: "hung" };
264
+ }
265
+
266
+ /** True when the host would deliver a followUp message right now. */
267
+ function sessionLooksIdle(probe: (() => boolean | undefined) | undefined): boolean {
268
+ if (typeof probe !== "function") return true; // no capability → pre-guard behavior
269
+ try {
270
+ return probe() !== false;
271
+ } catch {
272
+ return true;
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Try to hand one pending hung alert to the host. Re-arms itself while the
278
+ * session is streaming (the host would queue instead of deliver) and, at the
279
+ * idle boundary or past the cap, re-reads the run state ONCE and either drops
280
+ * or delivers.
281
+ */
282
+ function attemptHungAlertDelivery(pi: ExtensionAPI, cwd: string, runId: string, force: boolean): void {
283
+ const entry = pendingHungDeliveries.get(runId);
284
+ if (!entry) return;
285
+ if (entry.timer !== undefined) {
286
+ clearTimeout(entry.timer);
287
+ entry.timer = undefined;
288
+ }
289
+ if (!force && !sessionLooksIdle(entry.isSessionIdle) && Date.now() < entry.deadline) {
290
+ const timer = setTimeout(() => attemptHungAlertDelivery(pi, cwd, runId, false), deliverRetryMs);
291
+ timer.unref();
292
+ entry.timer = timer;
293
+ return;
294
+ }
295
+
296
+ // Fallthrough covers BOTH exits: the idle boundary and the cap expiry
297
+ // (`Date.now() >= entry.deadline`, session still streaming), plus any forced
298
+ // flush from the map cap. No exit skips the re-verify — an alert that got
299
+ // old is not evidence the run is still hung, so the same drop conditions
300
+ // apply here and the debug log names the reason it was dropped.
301
+ const verdict = classifyAtDelivery(cwd, runId);
302
+ pendingHungDeliveries.delete(runId);
303
+ if (verdict.kind === "dropped") {
304
+ if (!hungAlertRecords.get(runId)?.droppedAtDeliveryLogged) {
305
+ updateHungAlertRecord(runId, { droppedAtDeliveryLogged: true });
306
+ debugLog(`[foreground-watchdog] drop hung alert for ${runId} at delivery: run is no longer hung (${verdict.reason})`);
307
+ }
308
+ return;
309
+ }
310
+ try {
311
+ pi.sendUserMessage(entry.text, { deliverAs: "followUp" });
312
+ } catch {
313
+ /* non-critical */
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Queue a hung alert for guarded delivery (WD-DELIVER-GUARD). One in flight
319
+ * per runId — a second notice for the same run while the first is still
320
+ * pending is dropped as redundant drip.
321
+ */
322
+ function queueHungAlertDelivery(
323
+ pi: ExtensionAPI,
324
+ cwd: string,
325
+ runId: string,
326
+ text: string,
327
+ isSessionIdle: (() => boolean | undefined) | undefined,
328
+ ): void {
329
+ if (pendingHungDeliveries.has(runId)) {
330
+ debugLog(`[foreground-watchdog] hung alert for ${runId} already pending delivery — not stacking another`);
331
+ return;
332
+ }
333
+ pendingHungDeliveries.set(runId, { text, deadline: Date.now() + deliverCapMs, isSessionIdle });
334
+ if (pendingHungDeliveries.size > HUNG_DELIVERY_CAP) {
335
+ // Defensive: never let the map grow and never delay a real alert —
336
+ // flush the oldest pending alert immediately (verify-then-send).
337
+ const oldest = pendingHungDeliveries.keys().next().value;
338
+ if (oldest !== undefined) attemptHungAlertDelivery(pi, cwd, oldest, true);
339
+ }
340
+ attemptHungAlertDelivery(pi, cwd, runId, false);
341
+ }
342
+
343
+ /** Drop a still-pending (not yet handed-over) hung alert for a runId. */
344
+ function cancelPendingHungDelivery(runId: string): void {
345
+ const entry = pendingHungDeliveries.get(runId);
346
+ if (!entry) return;
347
+ if (entry.timer !== undefined) clearTimeout(entry.timer);
348
+ pendingHungDeliveries.delete(runId);
349
+ }
350
+
35
351
  /** Stop a specific watchdog by runId. */
36
352
  export function stopWatchdog(runId: string): void {
37
353
  const timer = activeWatchdogs.get(runId);
@@ -39,6 +355,7 @@ export function stopWatchdog(runId: string): void {
39
355
  clearTimeout(timer);
40
356
  activeWatchdogs.delete(runId);
41
357
  }
358
+ cancelPendingHungDelivery(runId);
42
359
  }
43
360
 
44
361
  /** @internal Stop all active watchdogs. Called on session shutdown (RC-01). */
@@ -47,6 +364,10 @@ export function stopAllWatchdogs(): void {
47
364
  clearTimeout(timer);
48
365
  }
49
366
  activeWatchdogs.clear();
367
+ for (const entry of pendingHungDeliveries.values()) {
368
+ if (entry.timer !== undefined) clearTimeout(entry.timer);
369
+ }
370
+ pendingHungDeliveries.clear();
50
371
  }
51
372
 
52
373
  /**
@@ -62,7 +383,7 @@ export function stopAllWatchdogs(): void {
62
383
  */
63
384
  export function startForegroundWatchdog(opts: WatchdogOptions): void {
64
385
  const { pi, cwd, runId } = opts;
65
- const checkIntervalMs = opts.checkIntervalMs ?? DEFAULT_CHECK_INTERVAL_MS;
386
+ const checkIntervalMs = resolveCheckIntervalMs(opts.checkIntervalMs);
66
387
  const maxMonitorMs = opts.maxMonitorMs ?? DEFAULT_MAX_MONITOR_MS;
67
388
  const startTime = Date.now();
68
389
 
@@ -111,23 +432,79 @@ export function startForegroundWatchdog(opts: WatchdogOptions): void {
111
432
  const agents = readCrewAgents(manifest);
112
433
  const now = Date.now();
113
434
  if (isLikelyOrphanedActiveRun(manifest, agents, now)) {
114
- const detail = `status=${manifest.status}, updatedAt=${manifest.updatedAt}, agents=${agents.length}`;
115
- consecutiveHungNotices += 1;
116
- try {
117
- if (consecutiveHungNotices <= HUNG_NOTICE_CAP) {
118
- pi.sendUserMessage(
119
- `pi-crew watchdog: run ${runId} appears hung (${detail}). Consider running team action='cancel' runId='${runId}' or team action='doctor'.`,
120
- { deliverAs: "followUp" },
121
- );
122
- } else if (consecutiveHungNotices === HUNG_NOTICE_CAP + 1) {
123
- // One-time handoff, then silence — still monitoring, no more drips.
124
- pi.sendUserMessage(
125
- `pi-crew watchdog: run ${runId} is still hung after ${consecutiveHungNotices} checks — going quiet now. Intervene via team action='cancel' runId='${runId}', team action='doctor', or leave it; a completion notice will still fire.`,
126
- { deliverAs: "followUp" },
435
+ // WD-DEDUP (2026-10-23): re-verify the run state right before any
436
+ // emit. A check armed by an older poll cycle can race the run
437
+ // reaching a terminal status — alerting "appears hung" for a run
438
+ // that already finished is exactly the repeated-alert pain.
439
+ // Terminal (or vanished) at re-verify → skip the emit entirely;
440
+ // the next cycle's terminal branch delivers the completion notice.
441
+ const reverified = reverifyLoader(cwd, runId);
442
+ if (!reverified || !isActiveRunStatus(reverified.manifest.status)) {
443
+ const reason = reverified ? `status=${reverified.manifest.status}` : "manifest gone";
444
+ if (!hungAlertRecords.get(runId)?.skipLogged) {
445
+ updateHungAlertRecord(runId, { skipLogged: true });
446
+ debugLog(`[foreground-watchdog] skip hung alert for ${runId}: re-verify saw ${reason}`);
447
+ }
448
+ // Fall through to the shared schedule — keep monitoring; the next
449
+ // cycle hits the terminal branch (completion notice) or !loaded.
450
+ } else if (areAllTasksTerminal(reverified.tasks)) {
451
+ // WD-STALE-RECORD: manifest still says running, but every task is
452
+ // terminal → the run is done and only the record is behind. Stay
453
+ // silent about "hung" and tell the user ONCE that the record is
454
+ // stale (per runId, module-scope — no spam across restarts).
455
+ if (!hungAlertRecords.get(runId)?.skipLogged) {
456
+ updateHungAlertRecord(runId, { skipLogged: true });
457
+ debugLog(
458
+ `[foreground-watchdog] skip hung alert for ${runId}: record stale (manifest ${reverified.manifest.status}, all ${reverified.tasks.length} task(s) terminal)`,
127
459
  );
128
460
  }
129
- } catch {
130
- /* non-critical */
461
+ if (!hungAlertRecords.get(runId)?.staleNoticeSent) {
462
+ updateHungAlertRecord(runId, { staleNoticeSent: true });
463
+ try {
464
+ pi.sendUserMessage(`pi-crew watchdog: run ${runId} record stale (all tasks terminal) — skipped hung alert.`, {
465
+ deliverAs: "followUp",
466
+ });
467
+ } catch {
468
+ /* non-critical */
469
+ }
470
+ }
471
+ // Keep monitoring: if the manifest eventually finalizes, the
472
+ // terminal branch delivers the completion notice.
473
+ } else {
474
+ const detail = `status=${manifest.status}, updatedAt=${manifest.updatedAt}, agents=${agents.length}`;
475
+ consecutiveHungNotices += 1;
476
+ try {
477
+ if (consecutiveHungNotices <= HUNG_NOTICE_CAP) {
478
+ // WD-DEDUP: alert-once per runId at module scope — even a
479
+ // fresh watchdog instance must not re-alert the same run.
480
+ if (!hungAlertAlreadySent(runId)) {
481
+ // Mark BEFORE queueing so a racing cycle cannot double-queue.
482
+ updateHungAlertRecord(runId, { alerted: true });
483
+ // WD-DELIVER-GUARD: the host queues a followUp while the
484
+ // session streams and delivers it arbitrarily later — so
485
+ // hand it over only at the idle boundary, re-verifying the
486
+ // run state at THAT moment.
487
+ queueHungAlertDelivery(
488
+ pi,
489
+ cwd,
490
+ runId,
491
+ `pi-crew watchdog: run ${runId} appears hung (${detail}). Consider running team action='cancel' runId='${runId}' or team action='doctor'.`,
492
+ opts.isSessionIdle,
493
+ );
494
+ }
495
+ } else if (consecutiveHungNotices === HUNG_NOTICE_CAP + 1) {
496
+ // One-time handoff, then silence — still monitoring, no more drips.
497
+ queueHungAlertDelivery(
498
+ pi,
499
+ cwd,
500
+ runId,
501
+ `pi-crew watchdog: run ${runId} is still hung after ${consecutiveHungNotices} checks — going quiet now. Intervene via team action='cancel' runId='${runId}', team action='doctor', or leave it; a completion notice will still fire.`,
502
+ opts.isSessionIdle,
503
+ );
504
+ }
505
+ } catch {
506
+ /* non-critical */
507
+ }
131
508
  }
132
509
  // Keep monitoring (ARCH-5: silently past the cap — no more notices).
133
510
  } else {
@@ -1,6 +1,10 @@
1
1
  export interface WorkerHeartbeatState {
2
2
  workerId: string;
3
3
  pid?: number;
4
+ /** Wave 11a (F3): spawn-time identity of `pid` (see `readProcIdentity`). */
5
+ pgid?: number;
6
+ starttime?: string;
7
+ uid?: number;
4
8
  lastSeenAt: string;
5
9
  lastStdoutAt?: string;
6
10
  lastEventAt?: string;
@@ -3,6 +3,7 @@ import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { getCrewEnv } from "../../config/env-vars.ts";
5
5
  import { errors } from "../../errors.ts";
6
+ import type { UsageState } from "../../state/types.ts";
6
7
  import { logInternalError } from "../../utils/internal-error.ts";
7
8
  import { fuzzyResolveModelId } from "./model-resolver.ts";
8
9
  import { checkModelScope } from "./model-scope.ts";
@@ -19,6 +20,10 @@ export interface ModelAttemptSummary {
19
20
  success: boolean;
20
21
  exitCode?: number | null;
21
22
  error?: string;
23
+ /** W10-7 (v0.11.38): THIS attempt's own usage (from its parsed output) —
24
+ * failed-fallback attempts burned real tokens that task.usage (the FINAL
25
+ * run's usage) never shows; post-execution reconciles the two. */
26
+ usage?: UsageState;
22
27
  }
23
28
 
24
29
  export interface ModelLike {