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.
- package/CHANGELOG.md +89 -0
- package/agents/critic.md +13 -0
- package/agents/oracle.md +14 -0
- package/dist/index.mjs +531 -526
- package/package.json +4 -3
- package/skills/delegation-patterns/SKILL.md +16 -0
- package/skills/real-test-pi-crew/SKILL.md +13 -2
- package/src/config/env-allowlist-registry.ts +22 -1
- package/src/config/env-vars.ts +23 -0
- package/src/extension/registration/foreground-run-controller.ts +21 -1
- package/src/extension/registration/lifecycle-handlers.ts +12 -0
- package/src/extension/team-tool/doctor.ts +125 -1
- package/src/prompt/prompt-runtime.ts +67 -5
- package/src/runtime/child-pi/child-pi-kill.ts +68 -2
- package/src/runtime/child-pi/child-pi-spawn.ts +20 -0
- package/src/runtime/child-pi/child-pi.ts +3 -1
- package/src/runtime/foreground-watchdog.ts +394 -17
- package/src/runtime/heartbeat/worker-heartbeat.ts +4 -0
- package/src/runtime/model/model-fallback.ts +5 -0
- package/src/runtime/process/orphan-reaper.ts +197 -22
- package/src/runtime/process/proc-stat.ts +8 -0
- package/src/runtime/process/reap-group.ts +499 -0
- package/src/runtime/process/run-descendant-sweep.ts +744 -0
- package/src/runtime/recovery/crash-recovery.ts +202 -0
- package/src/runtime/rpc/frame-client.ts +21 -1
- package/src/runtime/stale-reconciler.ts +343 -2
- package/src/runtime/task-runner/child-executor.ts +15 -1
- package/src/runtime/task-runner/post-execution.ts +112 -1
- package/src/runtime/task-runner/state-helpers.ts +28 -1
- package/src/runtime/verification/verify-gate-memo.ts +127 -0
- package/src/runtime/verification/verify-gate.ts +127 -1
- package/src/state/coordination/mailbox.ts +68 -0
- package/src/state/stores/state-store.ts +8 -0
- 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 } : {}),
|
package/src/state/types.ts
CHANGED
|
@@ -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;
|