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
@@ -0,0 +1,127 @@
1
+ /**
2
+ * verify-gate-memo.ts — W10-2 (v0.11.38): memoized green verify-gate by
3
+ * workspace tree-hash (pi-subagents lesson #3, tool-reference.md:432-496).
4
+ *
5
+ * WHY: sibling lanes / re-verifies on an UNCHANGED workspace re-run the full
6
+ * check set (typecheck + test:critical — minutes each) for a deterministic
7
+ * same-tree result. pisub memoizes gate acceptance by workspace tree-hash:
8
+ * tree unchanged ⇒ same verdict, no re-run.
9
+ *
10
+ * SAFETY MODEL (stronger than the M1-7 manifest bookends):
11
+ * - The key embeds the FULL workspace tree OID (`git write-tree` over the
12
+ * index — every tracked file, not just manifests) + the exact check list
13
+ * (name+command) + the running node version. A memo HIT therefore proves
14
+ * the tree is bit-identical to the tree that produced the recorded PASS —
15
+ * no manifest re-hash needed (tree equality subsumes it).
16
+ * - ONLY verdict PASS outcomes are recorded (ran>0). FAILED / INCONCLUSIVE
17
+ * (incl. every integrity-drift and determinism downgrade) never memoize.
18
+ * - The lookup ITSELF re-computes the tree OID — a hit can never serve a
19
+ * stale tree; a miss is an ordinary full gate run (fail-open).
20
+ * - Not a git repo / git unavailable / write-tree failure ⇒ key undefined ⇒
21
+ * memo silently disabled for that cwd (ordinary gate runs).
22
+ * - Typed-JSON gates (structured output, W10-9 candidate) must NEVER
23
+ * memoize — a JSON schema consumer reads fresh structuredOutput per run.
24
+ * The memo records only exit-code checks; a future structured gate kind
25
+ * must bypass this module entirely.
26
+ * - KNOWN TRADEOFF (accepted by design, mirrors pisub): tests that are
27
+ * time-dependent or environment-sensitive can pass once and be skipped on
28
+ * an unchanged tree. The kill-switch PI_CREW_VERIFY_GATE_MEMO=0 restores
29
+ * pre-W10-2 behavior bit-for-bit (default ON).
30
+ *
31
+ * Scope: host-process in-memory Map. Parallel sibling lanes within one host
32
+ * process share it; cross-process persistence is deliberately out of scope.
33
+ */
34
+ import { execFileSync } from "node:child_process";
35
+
36
+ import { getCrewEnvBool } from "../../config/env-vars.ts";
37
+ import type { VerifyGateCheck, VerifyGateOutcome } from "./verify-gate.ts";
38
+
39
+ /** W10-2 kill-switch: PI_CREW_VERIFY_GATE_MEMO beats default ON. */
40
+ export function resolveVerifyGateMemoEnabled(explicit?: boolean): boolean {
41
+ const env = getCrewEnvBool("PI_CREW_VERIFY_GATE_MEMO");
42
+ if (env !== undefined) return env;
43
+ return explicit ?? true;
44
+ }
45
+
46
+ /** Tree OID of `cwd`'s git index, or undefined when git/repo unavailable. */
47
+ export function computeWorkspaceTreeOid(cwd: string): string | undefined {
48
+ try {
49
+ const oid = execFileSync("git", ["write-tree"], {
50
+ cwd,
51
+ stdio: ["ignore", "pipe", "ignore"],
52
+ encoding: "utf-8",
53
+ timeout: 10_000,
54
+ }).trim();
55
+ return /^[0-9a-f]{40}$/.test(oid) ? oid : undefined;
56
+ } catch {
57
+ return undefined;
58
+ }
59
+ }
60
+
61
+ /** Deterministic memo key: tree OID + check list + node major.minor.
62
+ * Undefined tree OID ⇒ undefined key ⇒ memo disabled for this cwd. */
63
+ export function computeVerifyGateMemoKey(cwd: string, checks: VerifyGateCheck[]): string | undefined {
64
+ const oid = computeWorkspaceTreeOid(cwd);
65
+ if (!oid) return undefined;
66
+ const checkPart = checks.map((c) => `${c.name}::${c.command}::crit=${c.critical !== false}`).join("|");
67
+ return `${oid}::${process.version}::${checkPart}`;
68
+ }
69
+
70
+ /** In-host memo store (module-level: one per host process). */
71
+ const memoStore = new Map<string, VerifyGateOutcome>();
72
+
73
+ /** Test hook: wipe the store between unit tests. */
74
+ export function resetVerifyGateMemoStoreForTests(): void {
75
+ memoStore.clear();
76
+ }
77
+
78
+ /** Build the memo-HIT outcome: verdict PASS with every check marked
79
+ * skipped-memo — evidence lines make the hit auditable in prompts/metrics. */
80
+ function memoHitOutcome(key: string, checks: VerifyGateCheck[]): VerifyGateOutcome {
81
+ const oid = key.split("::")[0];
82
+ return {
83
+ verdict: "PASS",
84
+ checks: checks.map((c) => ({
85
+ name: c.name,
86
+ command: c.command,
87
+ exitCode: 0,
88
+ passed: true,
89
+ durationMs: 0,
90
+ outputTail: "",
91
+ skipped: true,
92
+ error: `memoized (tree ${oid.slice(0, 12)} unchanged since recorded PASS)`,
93
+ })),
94
+ evidenceLines: [
95
+ `verify-gate-memo: HIT — workspace tree ${oid.slice(0, 12)} + ${checks.length} check(s) unchanged since recorded PASS (W10-2)`,
96
+ ],
97
+ allPassed: true,
98
+ totalDurationMs: 0,
99
+ };
100
+ }
101
+
102
+ /** Lookup a recorded PASS for this exact (tree, checks, node) triple.
103
+ * Returns undefined on miss / disabled / unkeyable cwd. Typed-JSON gates
104
+ * (W10-9, check.output==="json") NEVER hit the memo — structured-output
105
+ * consumers read fresh structuredOutput every run. */
106
+ export function lookupVerifyGateMemo(cwd: string, checks: VerifyGateCheck[]): VerifyGateOutcome | undefined {
107
+ if (checks.length === 0) return undefined;
108
+ if (checks.some((c) => c.output === "json")) return undefined;
109
+ const key = computeVerifyGateMemoKey(cwd, checks);
110
+ if (!key) return undefined;
111
+ const hit = memoStore.get(key);
112
+ return hit ? memoHitOutcome(key, checks) : undefined;
113
+ }
114
+
115
+ /** Record an outcome — ONLY a ran PASS with real (non-memo) evidence.
116
+ * Memo-hit outcomes must not re-record (they carry skipped checks). */
117
+ export function recordVerifyGateMemo(cwd: string, checks: VerifyGateCheck[], outcome: VerifyGateOutcome): boolean {
118
+ if (checks.length === 0) return false;
119
+ if (checks.some((c) => c.output === "json")) return false;
120
+ if (outcome.verdict !== "PASS" || !outcome.allPassed) return false;
121
+ const ranReal = outcome.checks.some((c) => !c.skipped);
122
+ if (!ranReal) return false;
123
+ const key = computeVerifyGateMemoKey(cwd, checks);
124
+ if (!key) return false;
125
+ memoStore.set(key, outcome);
126
+ return true;
127
+ }
@@ -75,6 +75,7 @@ import { executeCommand } from "./verification-gates.ts";
75
75
  // index.bundle value-import graph (goal.ts/goal-wrap.ts/goal-loop-runner.ts
76
76
  // import it), so this adds no bundle-graph file (check-lazy-imports budget).
77
77
  import { compareSnapshot, snapshotManifests } from "./verification-integrity.ts";
78
+ import { lookupVerifyGateMemo, recordVerifyGateMemo, resolveVerifyGateMemoEnabled } from "./verify-gate-memo.ts";
78
79
 
79
80
  /** One deterministic check to run. Mirrors the phase-gate shape
80
81
  * (verification-gates.ts) but kept local so the tool schema can reuse it. */
@@ -85,6 +86,15 @@ export interface VerifyGateCheck {
85
86
  command: string;
86
87
  /** Stop the gate on the first failure of this check (default true). */
87
88
  critical?: boolean;
89
+ /** W10-9 (v0.11.38, pi-subagents lesson #2): TYPED gate — stdout must be
90
+ * valid JSON matching `schema` (minimal subset below). Invalid JSON or a
91
+ * schema mismatch FAILS the check even at exit 0. Typed gates NEVER
92
+ * memoize (verify-gate-memo.ts guard): a structured-output consumer reads
93
+ * fresh structuredOutput every run. */
94
+ output?: "json";
95
+ /** Minimal JSON-schema subset: {type, required?, properties?, items?,
96
+ * enum?} — nested one level deep is the intended use (gate summaries). */
97
+ schema?: Record<string, unknown>;
88
98
  }
89
99
 
90
100
  /** Result of one executed check. */
@@ -98,6 +108,10 @@ export interface VerifyGateCheckResult {
98
108
  outputTail: string;
99
109
  skipped?: boolean;
100
110
  error?: string;
111
+ /** W10-9: parsed JSON stdout of a typed gate (present only when the JSON
112
+ * was valid — invalid stdout fails and carries the reason in `error`). */
113
+ structuredOutput?: unknown;
114
+ output?: "json";
101
115
  }
102
116
 
103
117
  /** Overall gate verdict. */
@@ -264,6 +278,52 @@ export function evaluateVerifyGateResults(checks: VerifyGateCheckResult[]): Veri
264
278
  };
265
279
  }
266
280
 
281
+ /**
282
+ * W10-9: minimal JSON-schema subset validator — {type, required, properties,
283
+ * items, enum}, nested arbitrarily. Returns undefined on success or the first
284
+ * mismatch reason. Deliberately tiny (no new dependency); unsupported schema
285
+ * constructs are IGNORED (permissive), never crash.
286
+ */
287
+ export function validateJsonLite(value: unknown, schema: unknown, at = "$"): string | undefined {
288
+ if (!schema || typeof schema !== "object" || Array.isArray(schema)) return undefined;
289
+ const s = schema as Record<string, unknown>;
290
+ if (typeof s.type === "string") {
291
+ const t = s.type;
292
+ const ok =
293
+ (t === "object" && !!value && typeof value === "object" && !Array.isArray(value)) ||
294
+ (t === "array" && Array.isArray(value)) ||
295
+ (t === "string" && typeof value === "string") ||
296
+ (t === "number" && typeof value === "number" && Number.isFinite(value)) ||
297
+ (t === "boolean" && typeof value === "boolean") ||
298
+ (t === "null" && value === null);
299
+ if (!ok) return `${at}: expected type ${t}`;
300
+ }
301
+ if (Array.isArray(s.enum) && !s.enum.some((e) => JSON.stringify(e) === JSON.stringify(value))) {
302
+ return `${at}: not in enum`;
303
+ }
304
+ if (value && typeof value === "object" && !Array.isArray(value)) {
305
+ const rec = value as Record<string, unknown>;
306
+ for (const key of Array.isArray(s.required) ? (s.required as string[]) : []) {
307
+ if (!(key in rec)) return `${at}: missing required "${key}"`;
308
+ }
309
+ if (s.properties && typeof s.properties === "object") {
310
+ for (const [k, sub] of Object.entries(s.properties as Record<string, unknown>)) {
311
+ if (k in rec) {
312
+ const err = validateJsonLite(rec[k], sub, `${at}.${k}`);
313
+ if (err) return err;
314
+ }
315
+ }
316
+ }
317
+ }
318
+ if (Array.isArray(value) && s.items !== undefined) {
319
+ for (let i = 0; i < value.length; i++) {
320
+ const err = validateJsonLite(value[i], s.items, `${at}[${i}]`);
321
+ if (err) return err;
322
+ }
323
+ }
324
+ return undefined;
325
+ }
326
+
267
327
  /** Run the gate checks sequentially through the injected executor.
268
328
  * Mirrors runPhaseGates semantics: stop on first critical failure; abort
269
329
  * signal between checks marks the rest skipped. NEVER rejects. */
@@ -297,14 +357,33 @@ export async function runVerifyGate(
297
357
  // blowup must degrade the CHECK, not the host.
298
358
  execResult = { exitCode: null, output: "", error: error instanceof Error ? error.message : String(error) };
299
359
  }
360
+ // W10-9: typed gate — validate stdout JSON against the schema; an
361
+ // exit-0 command with invalid/mismatching JSON is a FAIL (the consumer
362
+ // reads structuredOutput; silence must never count as data).
363
+ let typedError: string | undefined;
364
+ let structured: unknown;
365
+ if (check.output === "json") {
366
+ try {
367
+ structured = JSON.parse(execResult.output ?? "");
368
+ } catch {
369
+ typedError = "typed gate: stdout is not valid JSON";
370
+ }
371
+ if (structured !== undefined && check.schema) {
372
+ const mismatch = validateJsonLite(structured, check.schema);
373
+ if (mismatch) typedError = `typed gate: schema mismatch — ${mismatch}`;
374
+ }
375
+ }
300
376
  results.push({
301
377
  name: check.name,
302
378
  command: check.command,
303
379
  exitCode: execResult.exitCode,
304
- passed: execResult.exitCode === 0,
380
+ passed: execResult.exitCode === 0 && !typedError,
305
381
  durationMs: Date.now() - start,
306
382
  outputTail: tailOutput(execResult.output ?? ""),
307
383
  ...(execResult.error ? { error: execResult.error } : {}),
384
+ ...(typedError ? { error: typedError } : {}),
385
+ ...(check.output === "json" ? { output: "json" } : {}),
386
+ ...(structured !== undefined && !typedError ? { structuredOutput: structured } : {}),
308
387
  });
309
388
  // Stop on critical failure (default critical) — mirrors runPhaseGates.
310
389
  if (execResult.exitCode !== 0 && check.critical !== false) break;
@@ -582,6 +661,43 @@ export async function runVerifierGatePreGate(input: VerifierGatePreGateInput): P
582
661
  // nothing here reads the worker-mutable task cwd. Zero resolvable checks
583
662
  // ⇒ INCONCLUSIVE ⇒ escalate (fail-closed on absence, pre-R47-1 doctrine).
584
663
  const checks = input.checks ?? input.hostSpec?.checks ?? [];
664
+ // ── W10-2 (v0.11.38): memoized green gate by workspace tree-hash ──────
665
+ // Lookup BEFORE any command runs. A hit proves the FULL tree + check list
666
+ // + node version are bit-identical to the tree that produced a recorded
667
+ // PASS (tree equality subsumes the manifest bookends) — re-running
668
+ // deterministic checks on an unchanged tree buys nothing. Miss / disabled
669
+ // / non-git cwd ⇒ ordinary full gate below (fail-open).
670
+ if (resolveVerifyGateMemoEnabled()) {
671
+ const memoHit = lookupVerifyGateMemo(input.cwd, checks);
672
+ if (memoHit) {
673
+ const decision = resolveVerifierSpawnDecision(memoHit);
674
+ incMetric(input.metricRegistry, "crew.verification.verify_gate_runs_total", "Deterministic verify_gate executions by verdict", {
675
+ verdict: memoHit.verdict,
676
+ });
677
+ incMetric(
678
+ input.metricRegistry,
679
+ "crew.verification.verify_gate_memo_hits_total",
680
+ "W10-2 memoized PASS verdicts served without re-running checks",
681
+ {},
682
+ );
683
+ if (decision.skipVerifier) {
684
+ incMetric(
685
+ input.metricRegistry,
686
+ "crew.verification.verifier_spawns_skipped_total",
687
+ "LLM verifier spawns saved by the U13 deterministic pre-gate",
688
+ { reason: decision.reason },
689
+ );
690
+ } else {
691
+ incMetric(
692
+ input.metricRegistry,
693
+ "crew.verification.verifier_spawns_total",
694
+ "LLM verifier spawns that still ran, labeled by the deciding layer",
695
+ { reason: decision.reason },
696
+ );
697
+ }
698
+ return { outcome: memoHit, decision, promptContext: "" };
699
+ }
700
+ }
585
701
  const exec = createSubprocessVerifyGateExecutor();
586
702
  let outcome = await runVerifyGate(checks, exec, {
587
703
  cwd: input.cwd,
@@ -644,6 +760,16 @@ export async function runVerifierGatePreGate(input: VerifierGatePreGateInput): P
644
760
  // `input.classifier` stays on the interface (deprecated) because the dispatch
645
761
  // call site in task-runner.ts passes it; removing the field is a follow-up
646
762
  // that must land with that call site.
763
+ // ── W10-2: record the green verdict for same-tree memoization (AFTER the
764
+ // post-bookend — a drift-downgraded INCONCLUSIVE must never memoize).
765
+ if (outcome.verdict === "PASS" && recordVerifyGateMemo(input.cwd, checks, outcome)) {
766
+ incMetric(
767
+ input.metricRegistry,
768
+ "crew.verification.verify_gate_memo_records_total",
769
+ "W10-2 PASS verdicts recorded for same-tree memoization",
770
+ {},
771
+ );
772
+ }
647
773
  const preGate: VerifierPreGateResult | undefined = undefined;
648
774
  const decision = resolveVerifierSpawnDecision(outcome, preGate);
649
775
  incMetric(input.metricRegistry, "crew.verification.verify_gate_runs_total", "Deterministic verify_gate executions by verdict", {
@@ -13,6 +13,14 @@ export type MailboxMessageStatus = "queued" | "delivered" | "acknowledged";
13
13
  export type MailboxMessageKind = "message" | "notify" | "steer" | "follow-up" | "response" | "group_join";
14
14
  export type MailboxMessagePriority = "urgent" | "normal" | "low";
15
15
  export type MailboxDeliveryMode = "interrupt" | "next_turn";
16
+ /**
17
+ * W10-3 (2026-10-22, lesson #22 — intercom typed reason): why the sender is
18
+ * contacting the supervisor/peer channel. Optional + backward-compatible —
19
+ * pre-W10-3 rows simply carry no `reason`; an out-of-enum value parses as
20
+ * `undefined` (dropped field, NOT a repair-worthy error — same graceful
21
+ * semantics as `kind`/`priority`).
22
+ */
23
+ export type MailboxReason = "need_decision" | "progress_update" | "interview_request";
16
24
 
17
25
  // ============================================================================
18
26
  // Phase 1.3: post-append observer (single notification point)
@@ -81,6 +89,10 @@ export interface MailboxMessage {
81
89
  requestId?: string;
82
90
  acknowledgedAt?: string;
83
91
  data?: Record<string, unknown>;
92
+ /** W10-3 (2026-10-22, lesson #22): typed supervisor-contact reason
93
+ * ({@link MailboxReason}). Optional — old messages without it parse
94
+ * unchanged. */
95
+ reason?: MailboxReason;
84
96
  /** ID of the original message this is a reply to. */
85
97
  replyTo?: string;
86
98
  /** Task ID sending the reply. */
@@ -292,6 +304,10 @@ function isDeliveryMode(value: unknown): value is MailboxDeliveryMode {
292
304
  return value === "interrupt" || value === "next_turn";
293
305
  }
294
306
 
307
+ function isReason(value: unknown): value is MailboxReason {
308
+ return value === "need_decision" || value === "progress_update" || value === "interview_request";
309
+ }
310
+
295
311
  function parseMailboxMessage(raw: unknown, expectedDirection: MailboxDirection): MailboxMessage | undefined {
296
312
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
297
313
  const obj = raw as Record<string, unknown>;
@@ -322,6 +338,7 @@ function parseMailboxMessage(raw: unknown, expectedDirection: MailboxDirection):
322
338
  priority: isPriority(obj.priority) ? obj.priority : undefined,
323
339
  deliveryMode: isDeliveryMode(obj.deliveryMode) ? obj.deliveryMode : undefined,
324
340
  taskId: typeof obj.taskId === "string" ? obj.taskId : undefined,
341
+ reason: isReason(obj.reason) ? obj.reason : undefined,
325
342
  questionId: typeof obj.questionId === "string" ? obj.questionId : undefined,
326
343
  requestId: typeof obj.requestId === "string" ? obj.requestId : undefined,
327
344
  acknowledgedAt: typeof obj.acknowledgedAt === "string" ? obj.acknowledgedAt : undefined,
@@ -805,6 +822,7 @@ function buildMailboxMessage(manifest: TeamRunManifest, message: MailboxAppendIn
805
822
  priority: message.priority,
806
823
  deliveryMode: message.deliveryMode,
807
824
  taskId: message.taskId,
825
+ reason: message.reason,
808
826
  questionId: message.questionId,
809
827
  // U5: every send carries a requestId — explicit (idempotent senders)
810
828
  // or auto-stamped uuid (one per row; still unique per send).
@@ -1186,6 +1204,56 @@ export function readMailboxMessage(manifest: TeamRunManifest, messageId: string)
1186
1204
  return readMailbox(manifest).find((message) => message.id === messageId);
1187
1205
  }
1188
1206
 
1207
+ /** Verdict of the W10-3 strict replyTo origin check (lesson #22, A→B→C). */
1208
+ export type ReplyToRequesterOrigin =
1209
+ | { verdict: "no-reply-to" }
1210
+ | { verdict: "match"; replyTo: string; requester: string }
1211
+ | { verdict: "origin-not-found"; replyTo: string }
1212
+ | { verdict: "mismatch"; replyTo: string; originalFrom: string; responseTo: string };
1213
+
1214
+ /**
1215
+ * W10-3 (2026-10-22, lesson #22): strict replyTo routing predicate.
1216
+ *
1217
+ * Gap (verified 2-way): `replyTo` was parse/serialize-only — NOTHING validated
1218
+ * that a response tagged with a `replyTo` was resolving a request opened by
1219
+ * the SAME requester the response is addressed to. With parallel lanes asking
1220
+ * concurrently, a reply meant for B (replyTo → a request B opened) could
1221
+ * resolve C's waiting park (A→B→C confusion) when its questionId happened to
1222
+ * tag C.
1223
+ *
1224
+ * Rule: when a response carries `replyTo`, the ORIGINAL message that id points
1225
+ * at (searched across global + every task mailbox, live + archives — the same
1226
+ * scan space `updateMailboxMessageReply` uses) must have been opened by
1227
+ * (`from` ===) the requester the response is addressed to (`to`). Verdicts:
1228
+ * - "no-reply-to" — untagged legacy responses: nothing to verify.
1229
+ * - "match" — right pair (original.from === response.to).
1230
+ * - "origin-not-found" — the replyTo id cannot be resolved (rotated out,
1231
+ * garbage, cross-run): unverifiable — callers keep
1232
+ * the exact-questionId correlation as the primary
1233
+ * defense and only log (fail-open, no availability
1234
+ * regression on stale ids).
1235
+ * - "mismatch" — A→B→C: original found but opened by a DIFFERENT
1236
+ * requester ⇒ callers MUST NOT resolve the waiting
1237
+ * task with this response (log + skip).
1238
+ *
1239
+ * Pure read-only; throws never (a mailbox read failure surfaces as
1240
+ * "origin-not-found" so the caller's own error handling decides).
1241
+ */
1242
+ export function checkReplyToRequesterOrigin(manifest: TeamRunManifest, response: MailboxMessage): ReplyToRequesterOrigin {
1243
+ const replyTo = response.replyTo;
1244
+ if (replyTo === undefined) return { verdict: "no-reply-to" };
1245
+ let original: MailboxMessage | undefined;
1246
+ try {
1247
+ original = readAllMailboxMessages(manifest).find((message) => message.id === replyTo);
1248
+ } catch {
1249
+ // Unreadable mailbox during the lookup — unverifiable, same as not found.
1250
+ return { verdict: "origin-not-found", replyTo };
1251
+ }
1252
+ if (!original) return { verdict: "origin-not-found", replyTo };
1253
+ if (original.from === response.to) return { verdict: "match", replyTo, requester: original.from };
1254
+ return { verdict: "mismatch", replyTo, originalFrom: original.from, responseTo: response.to };
1255
+ }
1256
+
1189
1257
  export function acknowledgeMailboxMessage(manifest: TeamRunManifest, messageId: string): MailboxDeliveryState {
1190
1258
  // BUGFIX (Round 12 I6): unlocked read-modify-write on delivery.json could
1191
1259
  // clobber concurrent appends. FIX: wrap in a file lock.
@@ -475,6 +475,14 @@ export function createRunManifest(params: {
475
475
  tasksPath: paths.tasksPath,
476
476
  eventsPath: paths.eventsPath,
477
477
  artifacts: [],
478
+ // Wave 11c (battery F2): record the SPAWNING process pid at creation — for
479
+ // a SYNC/foreground run (no async runner ever spawned) this is the only
480
+ // recorded parent its leaked descendants can name in PI_CREW_PARENT_PID,
481
+ // and without it the descendant sweep could never prove their descent.
482
+ // Sync-vs-async is NOT known at creation time (the async dispatch happens
483
+ // after this), so every run records it; async runs keep using
484
+ // `manifest.async.pid` as their primary recorded runner.
485
+ sessionPid: process.pid,
478
486
  ...(params.ownerSessionId ? { ownerSessionId: params.ownerSessionId } : {}),
479
487
  runKind: params.runKind ?? "team-run",
480
488
  ...(params.args !== undefined ? { args: params.args } : {}),
@@ -368,6 +368,18 @@ export interface TeamRunManifest {
368
368
  eventsPath: string;
369
369
  artifacts: ArtifactDescriptor[];
370
370
  async?: AsyncRunState;
371
+ /** Wave 11c (battery 2026-10-24 F2): pid of the process that CREATED the
372
+ * run — `process.pid` at createRunManifest time (the session pi for a
373
+ * foreground team-tool dispatch; the detached runner itself when it
374
+ * creates sub-runs). A SYNC (foreground) run records no `async.pid`, so a
375
+ * leaked descendant carrying `PI_CREW_PARENT_PID=<session pi pid>` could
376
+ * never satisfy the run-descendant sweep's descent proof and was skipped
377
+ * forever; `sweepTerminalRunDescendants` now accepts this pid as a
378
+ * recorded proof parent (DEAD-only, like every other proof parent — a
379
+ * live session is never proof, the operator may still be using it).
380
+ * Purely additive/optional: absent on every manifest written before 11c,
381
+ * and async runs keep `async.pid` as their primary recorded runner. */
382
+ sessionPid?: number;
371
383
  /** @deprecated-plan-pointer T2/R4 (ADR-4 §2): dual-write era — PlanRecord at
372
384
  * `<stateRoot>/plans/plans.json` is authoritative; this field is kept (never
373
385
  * dropped) as the pre-v2 fallback and legacy UI surface. Deprecate-only. */
@@ -437,6 +449,26 @@ export interface TeamRunManifest {
437
449
  /** MuxSurface A1 (spec §8.3): pane/pid/lockout state of this run. Absent on
438
450
  * runs that never booted a surface worker and on older manifests. */
439
451
  surface?: ManifestSurfaceState;
452
+ /** Wave 11b.2 (F7 reachability): ISO timestamp of the LAST completed
453
+ * run-descendant sweep for this run (`sweepTerminalRunDescendants`).
454
+ * Purely additive/optional — absent on every manifest written before
455
+ * 11b.2, and absent means "never swept" (⇒ still eligible for the
456
+ * startup sweep). Startup reconciliation enumerates terminal runs that
457
+ * lack it, because a leak OUTLIVES its run: the incident run was
458
+ * `completed` the day before its 6 grandchildren were finally reaped.
459
+ * Stamped only after a REAL sweep (never on dry-run, kill switch off, or
460
+ * a throwing sweep) so a failed attempt is retried on the next pass. */
461
+ descendantSweepAt?: string;
462
+ /** Wave 11b.4 (HIGH-2): the last sweep attempt PROVED descendants it could
463
+ * not signal (a live unmatched group leader, a candidate with no usable
464
+ * pgid, a refused group reap, an interactive tty). Without this the run
465
+ * would be stamped as done and the leak never looked at again — the exact
466
+ * failure mode 11b.2 introduced. Purely additive/optional: absent (or
467
+ * `false`) means "nothing left to retry". Startup reconciliation re-selects
468
+ * a pending run once `TERMINAL_SWEEP_RETRY_MS` has elapsed since
469
+ * `descendantSweepAt`, so a permanently un-signallable leak does not burn a
470
+ * /proc scan on every pass. */
471
+ descendantSweepPending?: boolean;
440
472
  }
441
473
 
442
474
  export interface RunModelContext {
@@ -591,6 +623,8 @@ export interface ModelAttemptState {
591
623
  success: boolean;
592
624
  exitCode?: number | null;
593
625
  error?: string;
626
+ /** W10-7: per-attempt usage mirrored from ModelAttemptSummary. */
627
+ usage?: UsageState;
594
628
  }
595
629
 
596
630
  export interface ModelRoutingState {
@@ -619,6 +653,16 @@ export interface TaskCheckpointState {
619
653
  phase: "started" | "child-spawned" | "child-stdout-final" | "artifact-written";
620
654
  updatedAt: string;
621
655
  childPid?: number;
656
+ /**
657
+ * Wave 11a (F3): spawn-time process identity of `childPid`, written from
658
+ * `readProcIdentity(pid)` right after the spawn. `starttime` is the
659
+ * PID-reuse-proof field and `pgid` is only meaningful when the child leads
660
+ * its own group (detached spawn). All three are OPTIONAL: records written
661
+ * before this wave keep loading unchanged.
662
+ */
663
+ childPgid?: number;
664
+ childStartTime?: string;
665
+ childUid?: number;
622
666
  }
623
667
 
624
668
  export interface TaskAttemptState {
@@ -663,6 +707,23 @@ export interface TeamTaskState {
663
707
  modelAttempts?: ModelAttemptState[];
664
708
  modelRouting?: ModelRoutingState;
665
709
  usage?: UsageState;
710
+ /** W10-7 (v0.11.38): usage reconciliation — sum of ALL attempts' cost
711
+ * (incl. failed fallbacks) vs the final run's cost. Present only when the
712
+ * gap exceeds 5% AND more than one attempt carried usage — observability
713
+ * only, never a task failure. */
714
+ usageReconcile?: {
715
+ attemptTotalCostUsd: number;
716
+ finalRunCostUsd: number;
717
+ deltaPct: number;
718
+ attemptsWithUsage: number;
719
+ };
720
+ /** W10-8 (v0.11.38): a READ-ONLY lane dirtied its worktree (modified or
721
+ * untracked files). Note-only — evidence lives in the task's diff
722
+ * artifact; the leader decides whether it invalidates the result. */
723
+ unexpectedMutation?: {
724
+ filesChanged: number;
725
+ sample: string[];
726
+ };
666
727
  jsonEvents?: number;
667
728
  agentProgress?: CrewAgentProgress;
668
729
  error?: string;