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.
- package/LICENSE +21 -0
- package/README.md +131 -0
- package/bin/faberun.mjs +25 -0
- package/integrations/claude-code/statusline-bench.sh +42 -0
- package/integrations/claude-code/statusline.sh +80 -0
- package/package.json +33 -0
- package/skills/faberun/SKILL.md +24 -0
- package/skills/faberun/references/contract.md +380 -0
- package/skills/faberun/references/engineering.md +29 -0
- package/skills/faberun/references/handoffs.md +26 -0
- package/skills/faberun/references/operations.md +184 -0
- package/skills/faberun/references/rules.md +35 -0
- package/skills/faberun/references/workflow.md +23 -0
- package/skills/init-agentkit/SKILL.md +108 -0
- package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
- package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
- package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
- package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
- package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
- package/skills/init-agentkit/templates/AGENTS.md +110 -0
- package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
- package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
- package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
- package/skills/init-agentkit/templates/docs/VISION.md +33 -0
- package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
- package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
- package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
- package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
- package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
- package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
- package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
- package/src/campaign/brief.mjs +394 -0
- package/src/campaign/chain.mjs +555 -0
- package/src/campaign/handoff.mjs +516 -0
- package/src/campaign/index.mjs +300 -0
- package/src/campaign/journal.mjs +347 -0
- package/src/campaign/layout.mjs +51 -0
- package/src/campaign/metrics-evals.mjs +25 -0
- package/src/campaign/metrics.mjs +517 -0
- package/src/campaign/projection.mjs +250 -0
- package/src/campaign/record.mjs +102 -0
- package/src/campaign/unpark.mjs +56 -0
- package/src/cli/brand.mjs +205 -0
- package/src/cli/campaign.mjs +730 -0
- package/src/cli/contract.mjs +67 -0
- package/src/cli/init.mjs +170 -0
- package/src/cli/launch.mjs +239 -0
- package/src/cli/seat.mjs +139 -0
- package/src/cli/setup.mjs +294 -0
- package/src/cli/skills.mjs +105 -0
- package/src/cli/update.mjs +216 -0
- package/src/cli.mjs +525 -0
- package/src/contract/articles.mjs +12 -0
- package/src/contract/assert.mjs +162 -0
- package/src/contract/definition-of-done.mjs +97 -0
- package/src/contract/final-verification.mjs +96 -0
- package/src/contract/index.mjs +641 -0
- package/src/contract/judge-envelope.mjs +25 -0
- package/src/contract/review-modes.mjs +151 -0
- package/src/contract/runtime.mjs +204 -0
- package/src/contract/schema-version.mjs +25 -0
- package/src/contract/scope-findings.mjs +77 -0
- package/src/contract/snapshot.mjs +639 -0
- package/src/contract/task-packet.mjs +495 -0
- package/src/contract/untrusted.mjs +75 -0
- package/src/contract/verification.mjs +185 -0
- package/src/contract/worker-result.mjs +138 -0
- package/src/engine/assignment.mjs +63 -0
- package/src/engine/backoff.mjs +492 -0
- package/src/engine/bulk-read.mjs +361 -0
- package/src/engine/cancel.mjs +177 -0
- package/src/engine/detach.mjs +101 -0
- package/src/engine/dispatch.mjs +752 -0
- package/src/engine/failover.mjs +192 -0
- package/src/engine/gate.mjs +183 -0
- package/src/engine/judge-gate.mjs +517 -0
- package/src/engine/lifecycle.mjs +772 -0
- package/src/engine/live-preflight.mjs +299 -0
- package/src/engine/mutation.mjs +146 -0
- package/src/engine/notify-queue.mjs +327 -0
- package/src/engine/process-identity.mjs +72 -0
- package/src/engine/process.mjs +774 -0
- package/src/engine/prompts.mjs +289 -0
- package/src/engine/recover.mjs +300 -0
- package/src/engine/result-file.mjs +222 -0
- package/src/engine/resume.mjs +635 -0
- package/src/engine/retry.mjs +334 -0
- package/src/engine/review.mjs +228 -0
- package/src/engine/run-command.mjs +287 -0
- package/src/engine/run-identity.mjs +411 -0
- package/src/engine/runtime-discovery.mjs +235 -0
- package/src/engine/scheduler.mjs +526 -0
- package/src/engine/scope.mjs +378 -0
- package/src/engine/settle.mjs +207 -0
- package/src/engine/state.mjs +148 -0
- package/src/engine/supervise.mjs +713 -0
- package/src/engine/verify.mjs +167 -0
- package/src/harnesses/agy/index.mjs +62 -0
- package/src/harnesses/catalogue.mjs +509 -0
- package/src/harnesses/claude/index.mjs +90 -0
- package/src/harnesses/codex/index.mjs +87 -0
- package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
- package/src/harnesses/dsh/index.mjs +210 -0
- package/src/harnesses/dsh/runner.mjs +259 -0
- package/src/harnesses/exec-jsonl/index.mjs +788 -0
- package/src/harnesses/index.mjs +508 -0
- package/src/harnesses/protocol.mjs +531 -0
- package/src/harnesses/replay/bin.mjs +386 -0
- package/src/harnesses/replay/index.mjs +238 -0
- package/src/harnesses/zcode/index.mjs +276 -0
- package/src/host/config.mjs +87 -0
- package/src/host/home.mjs +149 -0
- package/src/host/package.mjs +23 -0
- package/src/host/preflight.mjs +520 -0
- package/src/host/tool-policy-decisions.mjs +341 -0
- package/src/host/tool-policy-hook.mjs +270 -0
- package/src/notify/index.mjs +359 -0
- package/src/notify/os-macos.mjs +81 -0
- package/src/repo/declared-paths.mjs +220 -0
- package/src/repo/integrate.mjs +546 -0
- package/src/repo/scope-closure.mjs +665 -0
- package/src/repo/signal-block.mjs +16 -0
- package/src/repo/signal.mjs +222 -0
- package/src/repo/source-identity.mjs +295 -0
- package/src/repo/workspace.mjs +557 -0
- package/src/repo/worktree.mjs +352 -0
- package/src/report/final.mjs +200 -0
- package/src/report/metrics-report.mjs +99 -0
- package/src/report/next.mjs +383 -0
- package/src/report/render.mjs +716 -0
- package/src/run/disk-gc.mjs +251 -0
- package/src/run/lock.mjs +329 -0
- package/src/run/node-store.mjs +62 -0
- package/src/run/operations.mjs +286 -0
- package/src/run/store.mjs +187 -0
- package/src/run/usage.mjs +337 -0
- package/src/seat/harnesses.mjs +83 -0
- package/src/seat/index.mjs +239 -0
- package/src/seat/tmux.mjs +208 -0
- package/src/util.mjs +0 -0
- package/src/web/api.mjs +371 -0
- package/src/web/boundary.mjs +88 -0
- package/src/web/index.html +299 -0
- 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
|
+
}
|