faberun 0.3.0

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 (144) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -0
  3. package/bin/faberun.mjs +25 -0
  4. package/integrations/claude-code/statusline-bench.sh +42 -0
  5. package/integrations/claude-code/statusline.sh +80 -0
  6. package/package.json +33 -0
  7. package/skills/faberun/SKILL.md +24 -0
  8. package/skills/faberun/references/contract.md +380 -0
  9. package/skills/faberun/references/engineering.md +29 -0
  10. package/skills/faberun/references/handoffs.md +26 -0
  11. package/skills/faberun/references/operations.md +184 -0
  12. package/skills/faberun/references/rules.md +35 -0
  13. package/skills/faberun/references/workflow.md +23 -0
  14. package/skills/init-agentkit/SKILL.md +108 -0
  15. package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
  16. package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
  17. package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
  18. package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
  19. package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
  20. package/skills/init-agentkit/templates/AGENTS.md +110 -0
  21. package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
  22. package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
  23. package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
  24. package/skills/init-agentkit/templates/docs/VISION.md +33 -0
  25. package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
  26. package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
  27. package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
  28. package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
  29. package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
  30. package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
  31. package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
  32. package/src/campaign/brief.mjs +394 -0
  33. package/src/campaign/chain.mjs +555 -0
  34. package/src/campaign/handoff.mjs +516 -0
  35. package/src/campaign/index.mjs +300 -0
  36. package/src/campaign/journal.mjs +347 -0
  37. package/src/campaign/layout.mjs +51 -0
  38. package/src/campaign/metrics-evals.mjs +25 -0
  39. package/src/campaign/metrics.mjs +517 -0
  40. package/src/campaign/projection.mjs +250 -0
  41. package/src/campaign/record.mjs +102 -0
  42. package/src/campaign/unpark.mjs +56 -0
  43. package/src/cli/brand.mjs +205 -0
  44. package/src/cli/campaign.mjs +730 -0
  45. package/src/cli/contract.mjs +67 -0
  46. package/src/cli/init.mjs +170 -0
  47. package/src/cli/launch.mjs +239 -0
  48. package/src/cli/seat.mjs +139 -0
  49. package/src/cli/setup.mjs +294 -0
  50. package/src/cli/skills.mjs +105 -0
  51. package/src/cli/update.mjs +216 -0
  52. package/src/cli.mjs +525 -0
  53. package/src/contract/articles.mjs +12 -0
  54. package/src/contract/assert.mjs +162 -0
  55. package/src/contract/definition-of-done.mjs +97 -0
  56. package/src/contract/final-verification.mjs +96 -0
  57. package/src/contract/index.mjs +641 -0
  58. package/src/contract/judge-envelope.mjs +25 -0
  59. package/src/contract/review-modes.mjs +151 -0
  60. package/src/contract/runtime.mjs +204 -0
  61. package/src/contract/schema-version.mjs +25 -0
  62. package/src/contract/scope-findings.mjs +77 -0
  63. package/src/contract/snapshot.mjs +639 -0
  64. package/src/contract/task-packet.mjs +495 -0
  65. package/src/contract/untrusted.mjs +75 -0
  66. package/src/contract/verification.mjs +185 -0
  67. package/src/contract/worker-result.mjs +138 -0
  68. package/src/engine/assignment.mjs +63 -0
  69. package/src/engine/backoff.mjs +492 -0
  70. package/src/engine/bulk-read.mjs +361 -0
  71. package/src/engine/cancel.mjs +177 -0
  72. package/src/engine/detach.mjs +101 -0
  73. package/src/engine/dispatch.mjs +752 -0
  74. package/src/engine/failover.mjs +192 -0
  75. package/src/engine/gate.mjs +183 -0
  76. package/src/engine/judge-gate.mjs +517 -0
  77. package/src/engine/lifecycle.mjs +772 -0
  78. package/src/engine/live-preflight.mjs +299 -0
  79. package/src/engine/mutation.mjs +146 -0
  80. package/src/engine/notify-queue.mjs +327 -0
  81. package/src/engine/process-identity.mjs +72 -0
  82. package/src/engine/process.mjs +774 -0
  83. package/src/engine/prompts.mjs +289 -0
  84. package/src/engine/recover.mjs +300 -0
  85. package/src/engine/result-file.mjs +222 -0
  86. package/src/engine/resume.mjs +635 -0
  87. package/src/engine/retry.mjs +334 -0
  88. package/src/engine/review.mjs +228 -0
  89. package/src/engine/run-command.mjs +287 -0
  90. package/src/engine/run-identity.mjs +411 -0
  91. package/src/engine/runtime-discovery.mjs +235 -0
  92. package/src/engine/scheduler.mjs +526 -0
  93. package/src/engine/scope.mjs +378 -0
  94. package/src/engine/settle.mjs +207 -0
  95. package/src/engine/state.mjs +148 -0
  96. package/src/engine/supervise.mjs +713 -0
  97. package/src/engine/verify.mjs +167 -0
  98. package/src/harnesses/agy/index.mjs +62 -0
  99. package/src/harnesses/catalogue.mjs +509 -0
  100. package/src/harnesses/claude/index.mjs +90 -0
  101. package/src/harnesses/codex/index.mjs +87 -0
  102. package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
  103. package/src/harnesses/dsh/index.mjs +210 -0
  104. package/src/harnesses/dsh/runner.mjs +259 -0
  105. package/src/harnesses/exec-jsonl/index.mjs +788 -0
  106. package/src/harnesses/index.mjs +508 -0
  107. package/src/harnesses/protocol.mjs +531 -0
  108. package/src/harnesses/replay/bin.mjs +386 -0
  109. package/src/harnesses/replay/index.mjs +238 -0
  110. package/src/harnesses/zcode/index.mjs +276 -0
  111. package/src/host/config.mjs +87 -0
  112. package/src/host/home.mjs +149 -0
  113. package/src/host/package.mjs +23 -0
  114. package/src/host/preflight.mjs +520 -0
  115. package/src/host/tool-policy-decisions.mjs +341 -0
  116. package/src/host/tool-policy-hook.mjs +270 -0
  117. package/src/notify/index.mjs +359 -0
  118. package/src/notify/os-macos.mjs +81 -0
  119. package/src/repo/declared-paths.mjs +220 -0
  120. package/src/repo/integrate.mjs +546 -0
  121. package/src/repo/scope-closure.mjs +665 -0
  122. package/src/repo/signal-block.mjs +16 -0
  123. package/src/repo/signal.mjs +222 -0
  124. package/src/repo/source-identity.mjs +295 -0
  125. package/src/repo/workspace.mjs +557 -0
  126. package/src/repo/worktree.mjs +352 -0
  127. package/src/report/final.mjs +200 -0
  128. package/src/report/metrics-report.mjs +99 -0
  129. package/src/report/next.mjs +383 -0
  130. package/src/report/render.mjs +716 -0
  131. package/src/run/disk-gc.mjs +251 -0
  132. package/src/run/lock.mjs +329 -0
  133. package/src/run/node-store.mjs +62 -0
  134. package/src/run/operations.mjs +286 -0
  135. package/src/run/store.mjs +187 -0
  136. package/src/run/usage.mjs +337 -0
  137. package/src/seat/harnesses.mjs +83 -0
  138. package/src/seat/index.mjs +239 -0
  139. package/src/seat/tmux.mjs +208 -0
  140. package/src/util.mjs +0 -0
  141. package/src/web/api.mjs +371 -0
  142. package/src/web/boundary.mjs +88 -0
  143. package/src/web/index.html +299 -0
  144. package/src/web/server.mjs +552 -0
@@ -0,0 +1,289 @@
1
+ import { validateWorkerResult } from "../contract/worker-result.mjs";
2
+ import { scopeFindingsPromptSection } from "../contract/scope-findings.mjs";
3
+ import { JUDGE_ENVELOPE_REASON, JUDGE_FINDING_ENVELOPE_REASON, JUDGE_LIMITS } from "../contract/judge-envelope.mjs";
4
+
5
+ /** @typedef {{id: string, definitionOfDone: import("../contract/definition-of-done.mjs").DefinitionOfDoneItem[], taskPacket: {mode?: "execution"|"discovery"|"autonomous", objective: string, instructions: string[], writeFiles?: string[], writeRoots?: string[], verification: {argv: string[]}[]}}} JudgeNode */
6
+ /** @typedef {{verdict: "pass"|"fail"|"invalid_judge_output", maxSeverity: "none"|"minor"|"major"|"critical", summary: string, findings: {severity: "minor"|"major"|"critical", description: string, evidence: string}[]}} JudgeVerdict */
7
+
8
+ /**
9
+ * A node whose work is over and whose outcome needs nothing more from the
10
+ * controller: success or an explicit cancellation.
11
+ */
12
+ export const TERMINAL = new Set([
13
+ "done",
14
+ "no-op",
15
+ "canceled",
16
+ ]);
17
+
18
+ /**
19
+ * A node that stopped without succeeding and cannot proceed by itself. Parking
20
+ * is not finishing: the run needs attention, not a "done" report. Kept apart
21
+ * from `TERMINAL` so the watchdog can tell the two apart instead of returning
22
+ * the moment every node has stopped.
23
+ */
24
+ export const PARKED = new Set([
25
+ "blocked",
26
+ "failed",
27
+ "exhausted",
28
+ "stalled",
29
+ ]);
30
+
31
+ /** Every status a node never leaves on its own: terminal plus parked. */
32
+ export const SETTLED = new Set([...TERMINAL, ...PARKED]);
33
+
34
+ /** The statuses that count as a node's success. */
35
+ export const SUCCESS = new Set(["done", "no-op"]);
36
+
37
+ export const JUDGE_SCHEMA = {
38
+ type: "object",
39
+ additionalProperties: false,
40
+ properties: {
41
+ verdict: { type: "string", enum: ["pass", "fail"] },
42
+ maxSeverity: { type: "string", enum: ["none", "minor", "major", "critical"] },
43
+ summary: { type: "string" },
44
+ findings: {
45
+ type: "array",
46
+ items: {
47
+ type: "object",
48
+ additionalProperties: false,
49
+ properties: {
50
+ severity: { type: "string", enum: ["minor", "major", "critical"] },
51
+ description: { type: "string" },
52
+ evidence: { type: "string" },
53
+ },
54
+ required: ["severity", "description", "evidence"],
55
+ },
56
+ },
57
+ },
58
+ // Every property, and it has to be every property: OpenAI's structured
59
+ // output rejects the schema itself -- `invalid_json_schema`, 400, before the
60
+ // model runs -- unless `required` lists every key in `properties`. Dropping
61
+ // `findings` from here to spare Claude an occasional omission took the codex
62
+ // judge down completely, which is the worse trade: Claude's failure was
63
+ // intermittent and recoverable, this one was every invocation.
64
+ //
65
+ // The tolerance lives in `parseJudge`, which reads an absent `findings` as
66
+ // `[]`, and in the prompt below, which says out loud that a clean pass still
67
+ // sends the empty array.
68
+ required: ["verdict", "maxSeverity", "summary", "findings"],
69
+ };
70
+
71
+ /**
72
+ * @param {string} result
73
+ * @returns {JudgeVerdict}
74
+ */
75
+ export function parseJudge(result) {
76
+ let parsed;
77
+ try {
78
+ parsed = /** @type {unknown} */ (JSON.parse(result));
79
+ } catch (error) {
80
+ throw new Error(`judge returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
81
+ }
82
+ const verdict = /** @type {Record<string, unknown>} */ (parsed);
83
+ if (!verdict || typeof verdict !== "object" || Array.isArray(verdict)) throw new Error("judge result must be an object");
84
+ // Judge output is an external LLM boundary: models add fields beyond the
85
+ // schema (toolAction, confidence, …). Unknown keys are dropped here; every
86
+ // canonical field keeps strict value validation below.
87
+ const judgeVerdict = verdict.verdict;
88
+ const maxSeverity = verdict.maxSeverity;
89
+ if (typeof judgeVerdict !== "string" || !JUDGE_SCHEMA.properties.verdict.enum.includes(judgeVerdict)) throw new Error("judge verdict must be pass or fail");
90
+ if (typeof maxSeverity !== "string" || !JUDGE_SCHEMA.properties.maxSeverity.enum.includes(maxSeverity)) throw new Error("judge maxSeverity is invalid");
91
+ if (typeof verdict.summary !== "string") throw new Error("judge result is missing summary");
92
+ if (verdict.findings !== undefined && !Array.isArray(verdict.findings)) throw new Error("judge findings must be an array");
93
+ const rawFindings = /** @type {unknown[]} */ (verdict.findings ?? []);
94
+ if (Buffer.byteLength(verdict.summary, "utf8") > JUDGE_LIMITS.summaryBytes || rawFindings.length > JUDGE_LIMITS.findings) throw new Error(JUDGE_ENVELOPE_REASON);
95
+ const severityRank = { none: 0, minor: 1, major: 2, critical: 3 };
96
+ const findings = rawFindings.map((finding) => {
97
+ if (!finding || typeof finding !== "object" || Array.isArray(finding)) throw new Error("judge finding is invalid");
98
+ const record = /** @type {Record<string, unknown>} */ (finding);
99
+ const severity = record.severity;
100
+ if (typeof severity !== "string" || !["minor", "major", "critical"].includes(severity) || typeof record.description !== "string" || typeof record.evidence !== "string") {
101
+ throw new Error("judge finding is invalid");
102
+ }
103
+ if (Buffer.byteLength(record.description, "utf8") > JUDGE_LIMITS.descriptionBytes || Buffer.byteLength(record.evidence, "utf8") > JUDGE_LIMITS.evidenceBytes) throw new Error(JUDGE_FINDING_ENVELOPE_REASON);
104
+ return {
105
+ severity: /** @type {"minor"|"major"|"critical"} */ (severity),
106
+ description: record.description,
107
+ evidence: record.evidence,
108
+ };
109
+ });
110
+ const findingSeverities = findings.map((finding) => finding.severity);
111
+ const actualMax = findingSeverities.reduce(
112
+ (highest, severity) => severityRank[severity] > severityRank[highest] ? severity : highest,
113
+ /** @type {"none"|"minor"|"major"|"critical"} */ ("none"),
114
+ );
115
+ if (actualMax !== maxSeverity) throw new Error("judge maxSeverity does not match findings");
116
+ if ((judgeVerdict === "pass") !== (maxSeverity === "none")) throw new Error("judge verdict and maxSeverity are inconsistent");
117
+ return {
118
+ verdict: /** @type {"pass"|"fail"} */ (judgeVerdict),
119
+ maxSeverity: /** @type {"none"|"minor"|"major"|"critical"} */ (maxSeverity),
120
+ summary: verdict.summary,
121
+ findings,
122
+ };
123
+ }
124
+
125
+ /**
126
+ * How much of a red command's captured output the judge prompt carries, per
127
+ * stream, and the total budget across all red commands. Green commands send no
128
+ * output at all: the controller already ran them and their result is the
129
+ * `passed` bit, so echoing 2 KiB stdout plus 2 KiB stderr per green command per
130
+ * attempt is pure spend. This campaign already lost a node to that: a packet
131
+ * with seven verification commands serialized the whole `state.verification`
132
+ * and crossed the 64 KiB judge-prompt guard.
133
+ */
134
+ const JUDGE_RED_STREAM_BYTES = 2 * 1024;
135
+ const JUDGE_RED_TOTAL_BYTES = 16 * 1024;
136
+
137
+ /**
138
+ * The judge's view of controller verification: `{argv, passed}` for every green
139
+ * command and output tails only for red ones. The state carries up to 2 KiB per
140
+ * stream per attempt for every command, green or red; the judge only needs the
141
+ * red evidence, and bounding the red evidence in aggregate keeps the whole
142
+ * prompt inside the dispatch guard.
143
+ *
144
+ * @param {unknown} verification
145
+ * @returns {{passed: boolean, error?: string, commands: Array<Record<string, unknown>>}}
146
+ */
147
+ function judgeVerificationEvidence(verification) {
148
+ const record = verification && typeof verification === "object" && !Array.isArray(verification)
149
+ ? /** @type {{passed?: unknown, error?: unknown, commands?: unknown}} */ (verification)
150
+ : {};
151
+ const commands = Array.isArray(record.commands) ? record.commands : [];
152
+ let budget = JUDGE_RED_TOTAL_BYTES;
153
+ const compact = commands.map((command) => {
154
+ const entry = command && typeof command === "object" && !Array.isArray(command)
155
+ ? /** @type {Record<string, unknown>} */ (command)
156
+ : {};
157
+ if (entry.passed === true) return { argv: entry.argv, passed: true };
158
+ /** @type {Record<string, unknown>} */
159
+ const red = { argv: entry.argv, passed: false };
160
+ const attempts = Array.isArray(entry.attempts) ? entry.attempts : [];
161
+ const last = attempts.length ? /** @type {Record<string, unknown>} */ (attempts[attempts.length - 1]) : null;
162
+ if (typeof last?.exitCode === "number") red.exitCode = last.exitCode;
163
+ if (last?.timedOut === true) red.timedOut = true;
164
+ for (const stream of /** @type {const} */ (["stdout", "stderr"])) {
165
+ const text = typeof last?.[stream] === "string" ? last[stream] : "";
166
+ if (!text.trim() || budget <= 0) continue;
167
+ const bounded = boundedPromptText(text, Math.min(JUDGE_RED_STREAM_BYTES, budget));
168
+ budget -= Buffer.byteLength(bounded, "utf8");
169
+ red[stream] = bounded;
170
+ }
171
+ return red;
172
+ });
173
+ return {
174
+ passed: record.passed === true,
175
+ ...(typeof record.error === "string" && record.error ? { error: record.error } : {}),
176
+ commands: compact,
177
+ };
178
+ }
179
+
180
+ /**
181
+ * @param {JudgeNode} node
182
+ * @param {unknown} workerResult
183
+ * @param {{diff?: unknown[], verification?: unknown, deterministic?: unknown, scopeFindings?: {unexpectedPaths: string[]}|null, previousAttempt?: string}} context
184
+ * @returns {string}
185
+ */
186
+ export function judgePrompt(node, workerResult, context = {}) {
187
+ const criteria = node.definitionOfDone.length
188
+ ? judgeDoDChecklist(node.definitionOfDone, context.deterministic)
189
+ : "- The requested work is complete, correct, tested, and limited to scope.";
190
+ const taskInstructions = node.taskPacket.instructions.length
191
+ ? node.taskPacket.instructions.map((item, index) => `${index + 1}. ${item}`).join("\n")
192
+ : "(none)";
193
+ const writeBoundary = node.taskPacket.mode === "autonomous"
194
+ ? node.taskPacket.writeRoots ?? []
195
+ : node.taskPacket.writeFiles ?? [];
196
+ const writeBoundaryLabel = node.taskPacket.mode === "autonomous" ? "Write roots" : "Write files";
197
+ const writeFiles = writeBoundary.length
198
+ ? writeBoundary.map((path) => `- ${path}`).join("\n")
199
+ : "- (none)";
200
+ const verification = node.taskPacket.verification.length
201
+ ? node.taskPacket.verification.map((command) => `- ${command.argv.join(" ")}`).join("\n")
202
+ : "- (none)";
203
+ let structured = null;
204
+ try { structured = typeof workerResult === "string" ? JSON.parse(workerResult) : workerResult; } catch {
205
+ // Non-JSON worker result: leave it withheld from the judge prompt.
206
+ }
207
+ if (structured) {
208
+ try { structured = validateWorkerResult(structured); } catch { structured = null; }
209
+ }
210
+ const diff = Array.isArray(context.diff) ? /** @type {unknown[]} */ (context.diff).slice(0, 64) : [];
211
+ const verificationResult = judgeVerificationEvidence(context.verification);
212
+ const scopeSection = scopeFindingsPromptSection(context.scopeFindings);
213
+ return `Review node ${node.id} independently. The review context is closed: inspect only the ${node.taskPacket.mode === "autonomous" ? "write roots" : "write files"} below and do not perform repository-wide discovery. Do not re-run the verification commands — the controller already executed them and attached the results; re-running suites duplicates cost without adding evidence.\n\n` +
214
+ `${writeBoundaryLabel}:\n${writeFiles}\n\nVerification commands (already executed by the controller):\n${verification}\n\n` +
215
+ `Task brief:\n${node.taskPacket.objective}\n\nInstructions:\n${taskInstructions}\n\nDefinition of Done:\n${criteria}\n\n` +
216
+ `Worker result (structured):\n${structured ? JSON.stringify(structured) : "(invalid worker result withheld)"}\n\n` +
217
+ `Controller diff paths:\n${diff.length ? diff.map((path) => `- ${path}`).join("\n") : "- (none)"}\n\n` +
218
+ `Controller verification:\n${JSON.stringify(verificationResult)}\n\n` +
219
+ (scopeSection ? `${scopeSection}\n\n` : "") +
220
+ (context.previousAttempt ? `${context.previousAttempt}\n\n` : "") +
221
+ "Return only the JSON object required by the output schema, with every field present: a verdict with nothing to report still carries `findings: []`, never an omitted key. The verdict is a record, not a report: keep `summary` within " + JUDGE_LIMITS.summaryBytes + " bytes, use at most " + JUDGE_LIMITS.findings + " findings, and keep each finding's `description` within " + JUDGE_LIMITS.descriptionBytes + " bytes and its `evidence` within " + JUDGE_LIMITS.evidenceBytes + " bytes. A verdict that overshoots this envelope is rejected unread, however sound the arbitration. Evidence must be concrete. " +
222
+ "Use verdict pass only when findings is empty and maxSeverity is none. " +
223
+ "Use verdict fail whenever findings is non-empty, including advisory findings below failOn. " +
224
+ (node.definitionOfDone.length
225
+ ? "Arbitrate only the judgment items; deterministic items are already proven by the controller and must not be re-arbitrated. " +
226
+ "Use pass only when every judgment item is satisfied. Every finding must cite the id of the judgment item it addresses; a fail verdict whose findings cite no id is a protocol failure."
227
+ : "Use pass only when every Definition of Done item is satisfied. " +
228
+ "Assess every Definition of Done item by its id and cite the id you are addressing in each finding.");
229
+ }
230
+
231
+ /**
232
+ * The Definition of Done checklist for the judge prompt: judgment items the
233
+ * judge must arbitrate, plus each deterministic item with the controller-run
234
+ * proof result when one is attached.
235
+ *
236
+ * @param {import("../contract/definition-of-done.mjs").DefinitionOfDoneItem[]} items
237
+ * @param {unknown} deterministic
238
+ * @returns {string}
239
+ */
240
+ function judgeDoDChecklist(items, deterministic) {
241
+ const results = Array.isArray(deterministic)
242
+ ? /** @type {Array<{id: string, pass: boolean, detail: string}>} */ (deterministic)
243
+ : null;
244
+ const parts = [];
245
+ const judgmentItems = items.filter((item) => item.judgment === true);
246
+ parts.push(judgmentItems.length
247
+ ? `Judgment items — arbitrate only these:\n${judgmentItems.map((item) => `- [${item.id}] ${item.text} (judgment)`).join("\n")}`
248
+ : "No judgment items require arbitration.");
249
+ const proofItems = items.filter((item) => item.proof !== undefined);
250
+ if (proofItems.length) {
251
+ parts.push(`Deterministic items — already proven by the controller; do not re-arbitrate them:\n${proofItems.map((item) => {
252
+ const proof = /** @type {import("../contract/definition-of-done.mjs").DefinitionOfDoneProof} */ (item.proof);
253
+ const result = results?.find((entry) => entry.id === item.id);
254
+ const outcome = result === undefined
255
+ ? "controller proof recorded"
256
+ : result.pass ? "PASS" : `FAIL — ${result.detail}`;
257
+ return `- [${item.id}] ${outcome} — ${item.text} (proof: ${proof.kind} ${proof.ref})`;
258
+ }).join("\n")}`);
259
+ }
260
+ return parts.join("\n\n");
261
+ }
262
+
263
+ /**
264
+ * @param {{prompt: string}} node
265
+ * @param {{summary: string, findings: {severity: "minor"|"major"|"critical", description: string, evidence: string}[]}} verdict
266
+ * @returns {string}
267
+ */
268
+ export function retryPrompt(node, verdict) {
269
+ const findings = verdict.findings
270
+ .map((finding) => `- [${finding.severity}] ${boundedPromptText(finding.description, 2 * 1024)} Evidence: ${boundedPromptText(finding.evidence, 4 * 1024)}`)
271
+ .join("\n");
272
+ const prompt = `${node.prompt}\n\nA quality gate rejected the previous attempt. Fix the current working tree in place inside the closed context above.\n` +
273
+ `Gate summary: ${boundedPromptText(verdict.summary, 4 * 1024)}\n${findings}`;
274
+ return Buffer.byteLength(prompt, "utf8") > 64 * 1024
275
+ ? `${node.prompt}\n\nA quality gate rejected the previous attempt. Review the bounded structured findings in the state snapshot and fix the working tree inside the closed context.`
276
+ : prompt;
277
+ }
278
+
279
+ /**
280
+ * @param {unknown} value
281
+ * @param {number} maxBytes
282
+ * @returns {string}
283
+ */
284
+ function boundedPromptText(value, maxBytes) {
285
+ const text = String(value ?? "");
286
+ const bytes = Buffer.from(text, "utf8");
287
+ return bytes.length <= maxBytes ? text : `${bytes.subarray(0, maxBytes - 1).toString("utf8")}…`;
288
+ }
289
+
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Adopting what a previous controller left running.
3
+ *
4
+ * A resumed run finds nodes marked `running` whose process may be alive, dead,
5
+ * or finished-but-unrecorded. `recoverOrphan` decides which, from the pid, the
6
+ * process start token, the operation ledger and the transcript on disk -- never
7
+ * by re-dispatching hopefully. Adopt a finished turn, re-judge a finished
8
+ * worker, restart only what left no usable evidence.
9
+ */
10
+ import { delay } from "../util.mjs";
11
+ import { hasOperationIntent, operationNeedsRecovery, operationNextState, readOperationSettlement, settleInvocation } from "../run/operations.mjs";
12
+ import { invocationOwned } from "./process-identity.mjs";
13
+ import { invocationResult, terminateInvocation } from "./process.mjs";
14
+ import { latestTimeoutSec } from "./backoff.mjs";
15
+ import { markRecovering } from "./supervise.mjs";
16
+ import { parseJudge } from "./prompts.mjs";
17
+ import { persistedJudgeResult, persistedWorkerResult } from "./result-file.mjs";
18
+ import { routeRuntimeForState, runtimeSnapshot } from "./failover.mjs";
19
+
20
+ /** @typedef {import("./lifecycle.mjs").Invocation} Invocation */
21
+ /** @typedef {import("../cli.mjs").LockHandle} LockHandle */
22
+ /** @typedef {import("../contract/index.mjs").NodeSnapshot} NodeSnapshot */
23
+ /** @typedef {import("../harnesses/index.mjs").ProviderEnvelope} ProviderEnvelope */
24
+ /** @typedef {ProviderEnvelope & {costProvenance?: "priced"}} PricedEnvelope */
25
+ /** @typedef {import("../run/usage.mjs").RecoveryOutcome} RecoveryOutcome */
26
+ /** @typedef {import("../contract/index.mjs").Usage} Usage */
27
+ /** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
28
+ /** @typedef {import("../contract/index.mjs").ValidatedNode} ValidatedNode */
29
+
30
+ /**
31
+ * @param {string} runDir
32
+ * @param {ValidatedContract} contract
33
+ * @param {ValidatedNode} node
34
+ * @param {NodeSnapshot} state
35
+ * @param {LockHandle} lock
36
+ * @returns {Promise<RecoveryOutcome|null>}
37
+ */
38
+ export async function recoverOrphan(runDir, contract, node, state, lock) {
39
+ const invocation = state.invocations?.at(-1);
40
+ if (state.status !== "running") return null;
41
+ if (!invocation) return { kind: "restart", reason: `node ${state.id} has no live invocation` };
42
+ if (hasOperationIntent(runDir, invocation.id) && operationNeedsRecovery(runDir, invocation.id)) {
43
+ // Persist the classification before inspecting any provider or workspace
44
+ // evidence. A controller can therefore die again without losing the fact
45
+ // that this invocation's external effect was ambiguous.
46
+ settleInvocation(runDir, invocation, {
47
+ status: "unknown_effect",
48
+ terminalOutcome: "unknown_effect",
49
+ unknownEffect: true,
50
+ classification: "unknown_effect",
51
+ reason: `invocation ${invocation.id} has an intent but no terminal settlement`,
52
+ nextState: operationNextState(state),
53
+ });
54
+ }
55
+ const settlement = readOperationSettlement(runDir, invocation.id);
56
+ const runtime = invocation?.runtimeId
57
+ ? runtimeSnapshot(contract, invocation.runtimeId)
58
+ : routeRuntimeForState(contract, node, state, invocation?.phase === "judge" ? "judge" : "worker");
59
+ const startedAt = Date.parse(invocation.startedAt);
60
+ const timeoutSec = latestTimeoutSec(state, node.timeoutSec ?? contract.timeoutSec);
61
+ const deadline = Number.isFinite(startedAt) && Number.isFinite(timeoutSec)
62
+ ? startedAt + timeoutSec * 1_000
63
+ : null;
64
+ if (deadline === null || !Number.isFinite(deadline)) {
65
+ if (invocationOwned(invocation)) await terminateInvocation(invocation, { runDir });
66
+ const terminal = invocationResult(invocation, runtime, { preferStructured: invocation.phase === "judge" });
67
+ if (terminal?.status === "done") {
68
+ if (invocation.phase === "judge") return adoptOrRejudgeJudge(state, contract, node, invocation, terminal);
69
+ return /** @type {RecoveryOutcome} */ ({ kind: "adopted", ...terminal, phase: invocation.phase, invocationId: invocation.id });
70
+ }
71
+ const persisted = invocation.phase === "worker"
72
+ ? persistedWorkerResult(runDir, state, invocation, settlement)
73
+ : persistedJudgeResult(state, invocation, settlement);
74
+ if (persisted) {
75
+ if (invocation.phase === "judge") {
76
+ return /** @type {RecoveryOutcome} */ ({ kind: "adopted", phase: "judge", result: persisted, invocationId: invocation.id });
77
+ }
78
+ return /** @type {RecoveryOutcome} */ ({ kind: "adopted", phase: "worker", result: persisted, invocationId: invocation.id });
79
+ }
80
+ return restartRecovery(invocation, terminal, `invocation ${invocation.id} has no reliable start time or timeout deadline`);
81
+ }
82
+ if (invocation.closedAt !== null && !Number.isFinite(Date.parse(invocation.closedAt))) {
83
+ if (invocationOwned(invocation)) await terminateInvocation(invocation, { runDir });
84
+ const terminal = invocationResult(invocation, runtime, { preferStructured: invocation.phase === "judge" });
85
+ if (terminal?.status === "done") {
86
+ if (invocation.phase === "judge") return adoptOrRejudgeJudge(state, contract, node, invocation, terminal);
87
+ return /** @type {RecoveryOutcome} */ ({ kind: "adopted", ...terminal, phase: invocation.phase, invocationId: invocation.id });
88
+ }
89
+ const persisted = invocation.phase === "worker"
90
+ ? persistedWorkerResult(runDir, state, invocation, settlement)
91
+ : persistedJudgeResult(state, invocation, settlement);
92
+ if (persisted) {
93
+ if (invocation.phase === "judge") {
94
+ return /** @type {RecoveryOutcome} */ ({ kind: "adopted", phase: "judge", result: persisted, invocationId: invocation.id });
95
+ }
96
+ return /** @type {RecoveryOutcome} */ ({ kind: "adopted", phase: "worker", result: persisted, invocationId: invocation.id });
97
+ }
98
+ return restartRecovery(invocation, terminal, `invocation ${invocation.id} has no reliable close time`);
99
+ }
100
+ if (invocation && invocationOwned(invocation)) {
101
+ if (Date.now() > deadline) {
102
+ await terminateInvocation(invocation, { runDir });
103
+ return restartRecovery(invocation, invocationResult(invocation, runtime), `${invocation.phase} invocation ${invocation.id} exceeded its wall-clock budget`);
104
+ }
105
+ // A resumed controller waiting on a live orphan is a bounded recovery, not
106
+ // progress. The heartbeat records that phase against the orphan's own
107
+ // deadline so the supervisor judges `until + grace` instead of a stale
108
+ // `lastProgressAt`; the poll must never refresh `lastProgressAt` itself, or
109
+ // an orphan that is alive but never advancing would look like progress.
110
+ const recoveryUntil = typeof invocation.deadlineAt === "string" ? invocation.deadlineAt : new Date(deadline).toISOString();
111
+ markRecovering(runDir, recoveryUntil);
112
+ while (invocationOwned(invocation) && Date.now() < deadline) {
113
+ const result = invocationResult(invocation, runtime, { preferStructured: invocation.phase === "judge" });
114
+ if (result?.status === "done") {
115
+ if (Date.now() > deadline || (invocation.closedAt !== null && Date.parse(invocation.closedAt) > deadline)) {
116
+ await terminateInvocation(invocation, { runDir });
117
+ return restartRecovery(invocation, result, `${invocation.phase} invocation ${invocation.id} completed after its wall-clock budget`);
118
+ }
119
+ await terminateInvocation(invocation, { runDir });
120
+ if (invocation.phase === "judge") return adoptOrRejudgeJudge(state, contract, node, invocation, result);
121
+ return /** @type {RecoveryOutcome} */ ({ kind: "adopted", ...result, phase: invocation.phase, invocationId: invocation.id });
122
+ }
123
+ await delay(Math.min(contract.pollIntervalMs, 250));
124
+ markRecovering(runDir, recoveryUntil);
125
+ }
126
+ const expired = Date.now() >= deadline;
127
+ if (invocationOwned(invocation)) await terminateInvocation(invocation, { runDir });
128
+ if (expired) return restartRecovery(invocation, null, `${invocation.phase} invocation ${invocation.id} exceeded its wall-clock budget`);
129
+ if (invocation.closedAt === null) {
130
+ const persisted = invocation.phase === "worker"
131
+ ? persistedWorkerResult(runDir, state, invocation, settlement)
132
+ : persistedJudgeResult(state, invocation, settlement);
133
+ if (persisted) return /** @type {RecoveryOutcome} */ ({ kind: "adopted", phase: invocation.phase === "judge" ? "judge" : "worker", result: persisted, invocationId: invocation.id });
134
+ return restartRecovery(invocation, null, `${invocation.phase} invocation ${invocation.id} has no reliable close time`);
135
+ }
136
+ if (invocation.phase === "judge") {
137
+ const result = invocationResult(invocation, runtime, { preferStructured: true, exitCode: invocation.exitCode, signal: invocation.signal });
138
+ if (result?.status === "done") return adoptOrRejudgeJudge(state, contract, node, invocation, result);
139
+ if (result?.status === "exhausted") return {
140
+ kind: "exhausted",
141
+ phase: "judge",
142
+ invocationId: invocation.id,
143
+ usage: result.usage,
144
+ costUsd: result.costUsd,
145
+ costProvenance: result.costProvenance,
146
+ exhaustedUntil: result.exhaustedUntil ?? null,
147
+ error: result.error,
148
+ reason: result.error?.message,
149
+ };
150
+ const persisted = persistedJudgeResult(state, invocation, settlement);
151
+ if (persisted) return /** @type {RecoveryOutcome} */ ({ kind: "adopted", phase: "judge", result: persisted, invocationId: invocation.id });
152
+ return rejudgeOrRestart(state, contract, node, invocation, result);
153
+ }
154
+ }
155
+ if (invocation) {
156
+ const closedAt = Date.parse(invocation.closedAt ?? "");
157
+ if (!Number.isFinite(closedAt) || closedAt > deadline) {
158
+ return restartRecovery(invocation, null, `${invocation.phase} invocation ${invocation.id} completed after its wall-clock budget`);
159
+ }
160
+ if (invocation.closedAt === null) {
161
+ return restartRecovery(invocation, null, `${invocation.phase} invocation ${invocation.id} has no reliable close time`);
162
+ }
163
+ const result = invocationResult(invocation, runtime, { preferStructured: invocation.phase === "judge", exitCode: invocation.exitCode, signal: invocation.signal });
164
+ if (invocation.phase === "judge") {
165
+ if (result?.status === "done") return adoptOrRejudgeJudge(state, contract, node, invocation, result);
166
+ if (result?.status === "exhausted") return {
167
+ kind: "exhausted",
168
+ phase: "judge",
169
+ invocationId: invocation.id,
170
+ usage: result.usage,
171
+ costUsd: result.costUsd,
172
+ costProvenance: result.costProvenance,
173
+ exhaustedUntil: result.exhaustedUntil ?? null,
174
+ error: result.error,
175
+ reason: result.error?.message,
176
+ };
177
+ return rejudgeOrRestart(state, contract, node, invocation, result);
178
+ }
179
+ if (result?.status === "done") return /** @type {RecoveryOutcome} */ ({ kind: "adopted", ...result, phase: invocation.phase, invocationId: invocation.id });
180
+ if (result?.status === "exhausted") return /** @type {RecoveryOutcome} */ ({ kind: "exhausted", ...result, phase: invocation.phase, invocationId: invocation.id, reason: result.error?.message });
181
+ const persisted = persistedWorkerResult(runDir, state, invocation, settlement);
182
+ if (persisted) return /** @type {RecoveryOutcome} */ ({ kind: "adopted", phase: "worker", result: persisted, invocationId: invocation.id });
183
+ return restartRecovery(invocation, result, `${invocation.phase} invocation ${invocation.id} died without a completed stream`);
184
+ }
185
+ return { kind: "restart", reason: `node ${state.id} died without a completed stream` };
186
+ }
187
+ /** @param {Invocation} invocation @param {PricedEnvelope|null} result @param {string} reason @returns {RecoveryOutcome} */
188
+ function restartRecovery(invocation, result, reason) {
189
+ return /** @type {RecoveryOutcome} */ ({
190
+ kind: "restart",
191
+ phase: invocation.phase,
192
+ invocationId: invocation.id,
193
+ usage: result?.usage ?? invocation.usage,
194
+ costUsd: result?.costUsd ?? invocation.costUsd,
195
+ costProvenance: result?.costProvenance ?? invocation.costProvenance,
196
+ reason,
197
+ });
198
+ }
199
+ /**
200
+ * @param {Record<string, unknown>} override
201
+ * @param {string|undefined} invocationId
202
+ * @returns {RecoveryOutcome}
203
+ */
204
+ export function recoveryFromOverride(override, invocationId) {
205
+ const decision = override.decision;
206
+ return /** @type {RecoveryOutcome} */ ({
207
+ kind: decision === "rejudge"
208
+ ? "rejudge"
209
+ : decision === "restart" || decision === "safe_replay"
210
+ ? "restart"
211
+ : decision === "reconciled"
212
+ ? "reconciled"
213
+ : "adopted",
214
+ phase: override.phase,
215
+ result: override.result,
216
+ usage: override.usage,
217
+ costUsd: override.costUsd,
218
+ costProvenance: override.costProvenance,
219
+ reason: override.reason,
220
+ invocationId,
221
+ });
222
+ }
223
+ /**
224
+ * @param {NodeSnapshot} state
225
+ * @param {ValidatedContract} contract
226
+ * @param {ValidatedNode} node
227
+ * @param {Invocation} judgeInvocation
228
+ * @param {PricedEnvelope|null} [judgeResult]
229
+ * @returns {RecoveryOutcome}
230
+ */
231
+ function rejudgeOrRestart(state, contract, node, judgeInvocation, judgeResult = null) {
232
+ const workerInvocation = [...(state.invocations ?? [])].reverse().find((item) => item.phase === "worker");
233
+ const workerResult = workerInvocation && invocationResult(
234
+ workerInvocation,
235
+ workerInvocation.runtimeId ? runtimeSnapshot(contract, workerInvocation.runtimeId) : routeRuntimeForState(contract, node, state, "worker"),
236
+ );
237
+ if (workerResult?.status === "done") {
238
+ return /** @type {RecoveryOutcome} */ ({
239
+ kind: "rejudge",
240
+ phase: "worker",
241
+ result: workerResult.result,
242
+ usage: judgeResult?.usage,
243
+ costUsd: judgeResult?.costUsd,
244
+ costProvenance: judgeResult?.costProvenance,
245
+ invocationId: judgeInvocation.id,
246
+ });
247
+ }
248
+ return /** @type {RecoveryOutcome} */ ({
249
+ kind: "restart",
250
+ phase: "judge",
251
+ invocationId: judgeInvocation.id,
252
+ usage: judgeResult?.usage,
253
+ costUsd: judgeResult?.costUsd,
254
+ costProvenance: judgeResult?.costProvenance,
255
+ reason: `judge invocation ${judgeInvocation.id} completed but its worker stream is unavailable`,
256
+ });
257
+ }
258
+ /**
259
+ * @param {NodeSnapshot} state
260
+ * @param {ValidatedContract} contract
261
+ * @param {ValidatedNode} node
262
+ * @param {Invocation} judgeInvocation
263
+ * @param {PricedEnvelope} result
264
+ * @returns {RecoveryOutcome}
265
+ */
266
+ function adoptOrRejudgeJudge(state, contract, node, judgeInvocation, result) {
267
+ try {
268
+ parseJudge(result.result ?? "");
269
+ return /** @type {RecoveryOutcome} */ ({
270
+ kind: "adopted",
271
+ phase: "judge",
272
+ result: result.result,
273
+ usage: result.usage,
274
+ costUsd: result.costUsd,
275
+ costProvenance: result.costProvenance,
276
+ invocationId: judgeInvocation.id,
277
+ });
278
+ } catch {
279
+ return rejudgeOrRestart(state, contract, node, judgeInvocation, result);
280
+ }
281
+ }
282
+ /**
283
+ * @param {Invocation[]|undefined} invocations
284
+ * @param {string|undefined} invocationId
285
+ * @param {Usage|undefined} [usage]
286
+ * @param {number|null|undefined} [costUsd]
287
+ * @param {"priced"|undefined} [costProvenance]
288
+ * @returns {Invocation[]}
289
+ */
290
+ export function closePersistedInvocation(invocations, invocationId, usage = undefined, costUsd = undefined, costProvenance = undefined) {
291
+ return (invocations ?? []).map((invocation) => invocation.id === invocationId ? {
292
+ ...invocation,
293
+ status: "closed",
294
+ usage: usage ?? invocation.usage,
295
+ costUsd: costUsd ?? invocation.costUsd ?? null,
296
+ costProvenance: costProvenance ?? invocation.costProvenance,
297
+ closedAt: invocation.closedAt ?? new Date().toISOString(),
298
+ updatedAt: new Date().toISOString(),
299
+ } : invocation);
300
+ }