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,752 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Putting a worker or a judge in front of a node: resolve the runtime, choose
|
|
3
|
+
* the continuation, build the prompt and the attempt workspace, spawn, and
|
|
4
|
+
* record the invocation intent before a token is spent.
|
|
5
|
+
*
|
|
6
|
+
* It decides nothing about outcomes. `startJudge` runs the mechanical gate and
|
|
7
|
+
* returns what it found -- `rejected`, `settle` or `dispatched` -- for the
|
|
8
|
+
* caller to act on. It used to call `applyRejection` and `settleDone` itself,
|
|
9
|
+
* which made dispatch depend on review policy and on settlement, and that is
|
|
10
|
+
* the shape that kept `engine/` a web instead of a stack.
|
|
11
|
+
*/
|
|
12
|
+
import { JUDGE_SCHEMA, judgePrompt } from "./prompts.mjs";
|
|
13
|
+
import { LockLostError } from "../run/lock.mjs";
|
|
14
|
+
import { TOOL_OUTPUT_LIMIT_BYTES } from "../harnesses/exec-jsonl/index.mjs";
|
|
15
|
+
import { appendPreviousAttempt } from "./retry.mjs";
|
|
16
|
+
import {
|
|
17
|
+
RESULT_MATERIALIZATION_PROMPT_HEADER,
|
|
18
|
+
attemptWorkerResultPath,
|
|
19
|
+
clearAttemptWorkerResult,
|
|
20
|
+
clearWorkerResultFile,
|
|
21
|
+
readWorkerResultFile,
|
|
22
|
+
workerProtocolPrompt,
|
|
23
|
+
} from "./result-file.mjs";
|
|
24
|
+
import { attemptWorkspace } from "../repo/worktree.mjs";
|
|
25
|
+
import { attemptWorktreePath, createAttemptWorktree, sealAttempt } from "../repo/worktree.mjs";
|
|
26
|
+
import { basename, dirname, join } from "node:path";
|
|
27
|
+
import { boundedUtf8, errorCode, errorMessage, stableJson } from "../util.mjs";
|
|
28
|
+
import { captureWorkspaceScope, captureWorkspaceSnapshot } from "../repo/workspace.mjs";
|
|
29
|
+
import { createHash } from "node:crypto";
|
|
30
|
+
import { deterministicGate, judgeReaskReason, judgeRequired, judgeSkippedByScope } from "./judge-gate.mjs";
|
|
31
|
+
import { emptyScope, persistedScopeBoundary, workerScope } from "./scope.mjs";
|
|
32
|
+
import { hasOperationIntent, hasOperationSettlement, operationNeedsRecovery, operationNextState, persistInvocationIntent, providerReceipts, settleInvocation } from "../run/operations.mjs";
|
|
33
|
+
import { invocationCost, invocationUsage } from "../run/usage.mjs";
|
|
34
|
+
import { logPaths, readBoundedTail, startProcess } from "./process.mjs";
|
|
35
|
+
import { mkdirSync } from "node:fs";
|
|
36
|
+
import { READ_LINE_LIMIT, normalizeProviderResult, providerCommand } from "../harnesses/index.mjs";
|
|
37
|
+
import { readJson, writeJsonAtomic } from "../run/store.mjs";
|
|
38
|
+
import { judgeReaskInstruction, reviewMode } from "../contract/review-modes.mjs";
|
|
39
|
+
import { routeRuntimeForState, runtimeSnapshot } from "./failover.mjs";
|
|
40
|
+
import { transition, writeNode } from "./state.mjs";
|
|
41
|
+
import { validateNodeSnapshot } from "../contract/snapshot.mjs";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* What a judge round decided, for the caller to act on.
|
|
45
|
+
* `rejected`: the mechanical gate failed, so nothing was dispatched.
|
|
46
|
+
* `settle`: the gate passed and no judgment item needs arbitrating.
|
|
47
|
+
* `dispatched`: a judge is running (or failed to start and the node is already
|
|
48
|
+
* marked failed).
|
|
49
|
+
*
|
|
50
|
+
* @typedef {{kind: "rejected", verdict: JudgeVerdict}|{kind: "settle", gate: JudgeVerdict}|{kind: "dispatched"}} JudgeRound
|
|
51
|
+
*/
|
|
52
|
+
/** @typedef {import("./prompts.mjs").JudgeVerdict} JudgeVerdict */
|
|
53
|
+
|
|
54
|
+
/** @typedef {import("../harnesses/index.mjs").CommandOptions} CommandOptions */
|
|
55
|
+
/** @typedef {import("../harnesses/index.mjs").HarnessRuntime} HarnessRuntime */
|
|
56
|
+
/** @typedef {import("./lifecycle.mjs").Invocation} Invocation */
|
|
57
|
+
/** @typedef {import("./lifecycle.mjs").Job} Job */
|
|
58
|
+
/** @typedef {import("../cli.mjs").LockHandle} LockHandle */
|
|
59
|
+
/** @typedef {import("../contract/index.mjs").NodeSnapshot} NodeSnapshot */
|
|
60
|
+
/** @typedef {import("../contract/index.mjs").RuntimeSnapshot} RuntimeSnapshot */
|
|
61
|
+
/** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
|
|
62
|
+
/** @typedef {import("../contract/index.mjs").ValidatedNode} ValidatedNode */
|
|
63
|
+
/** @typedef {import("../contract/index.mjs").VerificationState} VerificationState */
|
|
64
|
+
/** @typedef {import("../contract/index.mjs").WorkspaceScopeBoundary} WorkspaceScopeBoundary */
|
|
65
|
+
|
|
66
|
+
/** @typedef {{forceFresh?: boolean}} SessionPolicy */
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Resolve the session policy one dispatch runs under, then consume the copy the
|
|
70
|
+
* node persisted. The explicit argument comes from a caller that is dispatching
|
|
71
|
+
* on the spot; `state.sessionPolicy` is the copy a rejection decision left when
|
|
72
|
+
* it handed the node back to the scheduler, whose own `startWorker` call passes
|
|
73
|
+
* nothing at all.
|
|
74
|
+
*
|
|
75
|
+
* Persisting is the whole point: `phaseInvocationPlan` rediscovers a compatible
|
|
76
|
+
* continuation from the persisted ledger, so nulling a local continuation id at
|
|
77
|
+
* the call site would let the scheduler's later dispatch find it again. Clearing
|
|
78
|
+
* the stored policy here makes it one-shot — it governs exactly the dispatch it
|
|
79
|
+
* was recorded for, and the next unrelated attempt reuses normally.
|
|
80
|
+
*
|
|
81
|
+
* @param {{sessionPolicy?: SessionPolicy|null}} state
|
|
82
|
+
* @param {SessionPolicy} [explicit]
|
|
83
|
+
* @returns {SessionPolicy}
|
|
84
|
+
*/
|
|
85
|
+
export function forceFreshSession(state, explicit = {}) {
|
|
86
|
+
const persisted = /** @type {SessionPolicy|undefined} */ (state?.sessionPolicy ?? undefined);
|
|
87
|
+
const policy = { ...(persisted ?? {}), ...explicit };
|
|
88
|
+
if (state && state.sessionPolicy !== undefined && state.sessionPolicy !== null) state.sessionPolicy = null;
|
|
89
|
+
return policy;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Select the only continuation that is allowed for this plan phase and role.
|
|
94
|
+
* The search is intentionally limited to persisted node snapshots in this run.
|
|
95
|
+
*
|
|
96
|
+
* `policy.forceFresh` is the explicit session policy a rejection decision
|
|
97
|
+
* carries: it short-circuits the search before it can rediscover a compatible
|
|
98
|
+
* continuation, so a retry after a gate rejection starts a fresh provider
|
|
99
|
+
* session instead of re-reading the failed transcript. Nulling a local id at
|
|
100
|
+
* the call site is not enough, because this function rediscovers the prior
|
|
101
|
+
* continuation from the persisted ledger.
|
|
102
|
+
*
|
|
103
|
+
* @param {ValidatedContract} contract
|
|
104
|
+
* @param {ValidatedNode} node
|
|
105
|
+
* @param {NodeSnapshot} state
|
|
106
|
+
* @param {string} runDir
|
|
107
|
+
* @param {"worker"|"judge"} role
|
|
108
|
+
* @param {string} prompt
|
|
109
|
+
* @param {SessionPolicy} [policy]
|
|
110
|
+
* @returns {{prompt: string, continuationId: string|null, mode: "fresh"|"reuse"|"rotate"}}
|
|
111
|
+
*/
|
|
112
|
+
function phaseInvocationPlan(contract, node, state, runDir, role, prompt, policy = {}) {
|
|
113
|
+
if (policy.forceFresh === true) {
|
|
114
|
+
return { prompt, continuationId: null, mode: "fresh" };
|
|
115
|
+
}
|
|
116
|
+
const runId = basename(runDir);
|
|
117
|
+
const session = phaseSessionCandidates(contract, node, state, runDir, role).at(-1);
|
|
118
|
+
const runtime = routeRuntimeForState(contract, node, state, role);
|
|
119
|
+
const identityMatches = session && session.invocation.runId === runId
|
|
120
|
+
&& session.invocation.campaignId === contract.campaignId
|
|
121
|
+
&& session.invocation.planPhase === node.phase
|
|
122
|
+
&& session.invocation.role === role
|
|
123
|
+
&& session.invocation.harness === runtime.harness
|
|
124
|
+
&& session.invocation.runtimeId === runtime.id
|
|
125
|
+
&& session.invocation.runtimeFingerprint === fingerprintRuntime(runtime)
|
|
126
|
+
&& session.invocation.model === runtime.model
|
|
127
|
+
&& session.invocation.reasoning === (runtime.reasoning ?? null)
|
|
128
|
+
&& session.invocation.sandbox === (runtime.sandbox ?? null);
|
|
129
|
+
const canContinue = runtime.capabilities.continuation === true;
|
|
130
|
+
if (identityMatches && canContinue) {
|
|
131
|
+
return { prompt, continuationId: session.invocation.continuationId ?? null, mode: "reuse" };
|
|
132
|
+
}
|
|
133
|
+
// A harness that cannot continue at all, or a session picked up from a
|
|
134
|
+
// different phase-sibling node whose identity does not match this one, has
|
|
135
|
+
// no native continuity: the fresh attempt carries the prior nodes'
|
|
136
|
+
// structured summaries forward instead of starting blind.
|
|
137
|
+
if (session && (!canContinue || session.nodeId !== node.id)) {
|
|
138
|
+
return {
|
|
139
|
+
prompt: phaseHandoffPrompt(contract, node, state, runDir, role),
|
|
140
|
+
continuationId: null,
|
|
141
|
+
mode: "rotate",
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
// A capable harness continuing its own node whose identity merely drifted
|
|
145
|
+
// (the run directory moved, or a runtime edge) still gets the caller's own
|
|
146
|
+
// prompt — already carrying the node's bounded "Previous attempt" section —
|
|
147
|
+
// in a fresh session, never a synthesized handoff.
|
|
148
|
+
return { prompt, continuationId: null, mode: session ? "rotate" : "fresh" };
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Continuation ids a live invocation is already driving, anywhere in the run.
|
|
152
|
+
*
|
|
153
|
+
* This is what makes concurrent nodes of one phase safe, and it is read from
|
|
154
|
+
* the persisted ledger rather than from an in-memory registry so a controller
|
|
155
|
+
* that took over a run inherits the claims instead of racing them.
|
|
156
|
+
*
|
|
157
|
+
* @param {ValidatedContract} contract
|
|
158
|
+
* @param {NodeSnapshot} currentState
|
|
159
|
+
* @param {string} runDir
|
|
160
|
+
* @returns {Set<string>}
|
|
161
|
+
*/
|
|
162
|
+
function claimedContinuations(contract, currentState, runDir) {
|
|
163
|
+
/** @type {Set<string>} */
|
|
164
|
+
const claimed = new Set();
|
|
165
|
+
for (const candidate of contract.nodes) {
|
|
166
|
+
let state = candidate.id === currentState.id ? currentState : null;
|
|
167
|
+
if (!state) {
|
|
168
|
+
try { state = validateNodeSnapshot(readJson(join(runDir, "nodes", `${candidate.id}.json`)), candidate); } catch { continue; }
|
|
169
|
+
}
|
|
170
|
+
for (const invocation of state.invocations ?? []) {
|
|
171
|
+
if (invocation.status === "active" && invocation.continuationId) claimed.add(invocation.continuationId);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return claimed;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* @param {ValidatedContract} contract
|
|
178
|
+
* @param {ValidatedNode} node
|
|
179
|
+
* @param {NodeSnapshot} currentState
|
|
180
|
+
* @param {string} runDir
|
|
181
|
+
* @param {"worker"|"judge"} role
|
|
182
|
+
* @returns {{nodeId: string, invocation: Invocation}[]}
|
|
183
|
+
*/
|
|
184
|
+
function phaseSessionCandidates(contract, node, currentState, runDir, role) {
|
|
185
|
+
/** @type {{nodeId: string, invocation: Invocation}[]} */
|
|
186
|
+
const candidates = [];
|
|
187
|
+
const claimed = claimedContinuations(contract, currentState, runDir);
|
|
188
|
+
for (const candidate of contract.nodes) {
|
|
189
|
+
if (candidate.phase !== node.phase) continue;
|
|
190
|
+
let state = candidate.id === currentState.id ? currentState : null;
|
|
191
|
+
if (!state) {
|
|
192
|
+
try { state = validateNodeSnapshot(readJson(join(runDir, "nodes", `${candidate.id}.json`)), candidate); } catch { continue; }
|
|
193
|
+
}
|
|
194
|
+
for (const invocation of state.invocations ?? []) {
|
|
195
|
+
if (invocation.role !== role || invocation.planPhase !== node.phase || !invocation.continuationId) continue;
|
|
196
|
+
if (invocation.nodeId !== candidate.id || invocation.attempt !== state.attempt || invocation.workspace !== state.worktree?.path) continue;
|
|
197
|
+
// One provider session, one live turn. With `maxParallel` above one,
|
|
198
|
+
// two nodes of a phase can be dispatched in the same tick, and without
|
|
199
|
+
// this both would hand the same continuation id to their own provider
|
|
200
|
+
// process. The claim is read from the persisted ledger, which the
|
|
201
|
+
// in-tick dispatch already wrote for the node that went first.
|
|
202
|
+
if (claimed.has(invocation.continuationId)) continue;
|
|
203
|
+
candidates.push({ nodeId: candidate.id, invocation });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return candidates.sort((left, right) => {
|
|
207
|
+
const leftStarted = Date.parse(left.invocation.startedAt);
|
|
208
|
+
const rightStarted = Date.parse(right.invocation.startedAt);
|
|
209
|
+
if (leftStarted !== rightStarted) return leftStarted - rightStarted;
|
|
210
|
+
const leftUpdated = Date.parse(left.invocation.updatedAt);
|
|
211
|
+
const rightUpdated = Date.parse(right.invocation.updatedAt);
|
|
212
|
+
if (leftUpdated !== rightUpdated) return leftUpdated - rightUpdated;
|
|
213
|
+
return left.invocation.id.localeCompare(right.invocation.id);
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
/** @param {Invocation} invocation @param {ValidatedContract} contract @param {ValidatedNode} node @param {RuntimeSnapshot} runtime @param {NodeSnapshot} state @param {string} runDir @param {"worker"|"judge"} role @param {"fresh"|"reuse"|"rotate"} mode @param {string|null} continuationId */
|
|
217
|
+
function stampInvocation(invocation, contract, node, runtime, state, runDir, role, mode, continuationId) {
|
|
218
|
+
invocation.runId = basename(runDir);
|
|
219
|
+
invocation.campaignId = contract.campaignId;
|
|
220
|
+
invocation.nodeId = node.id;
|
|
221
|
+
invocation.attempt = state.attempt;
|
|
222
|
+
invocation.workspace = attemptWorkspace(state) ?? contract.cwd;
|
|
223
|
+
invocation.worktreeBranch = state.worktree?.branch ?? null;
|
|
224
|
+
invocation.worktreeBaseSha = state.worktree?.baseSha ?? null;
|
|
225
|
+
invocation.planPhase = node.phase;
|
|
226
|
+
invocation.role = role;
|
|
227
|
+
invocation.runtimeFingerprint = fingerprintRuntime(runtime);
|
|
228
|
+
invocation.model = runtime.model;
|
|
229
|
+
invocation.reasoning = runtime.reasoning ?? null;
|
|
230
|
+
invocation.sandbox = runtime.sandbox ?? null;
|
|
231
|
+
invocation.continuationId = continuationId;
|
|
232
|
+
invocation.continuationMode = mode;
|
|
233
|
+
// The tier-exhaustion generation this invocation belongs to, stamped exactly
|
|
234
|
+
// like `revision` so `planRoute` can scope its attempted set to the current
|
|
235
|
+
// generation across a controller crash.
|
|
236
|
+
/** @type {{cycle?: number}} */ (invocation).cycle = state.routing?.tierExhaustionCycle ?? 0;
|
|
237
|
+
}
|
|
238
|
+
/** @param {RuntimeSnapshot} runtime @returns {string} */
|
|
239
|
+
function fingerprintRuntime(runtime) {
|
|
240
|
+
const executable = providerCommand(runtime, "").executable;
|
|
241
|
+
return createHash("sha256").update(stableJson({ runtime, executable })).digest("hex");
|
|
242
|
+
}
|
|
243
|
+
/** @param {ValidatedContract} contract @param {ValidatedNode} node @param {NodeSnapshot} state @param {string} runDir @param {"worker"|"judge"} role @returns {string} */
|
|
244
|
+
function phaseHandoffPrompt(contract, node, state, runDir, role) {
|
|
245
|
+
const summaries = phaseSessionCandidates(contract, node, state, runDir, role)
|
|
246
|
+
.map(({ nodeId }) => {
|
|
247
|
+
const candidate = contract.nodes.find((item) => item.id === nodeId);
|
|
248
|
+
let snapshot = null;
|
|
249
|
+
try { snapshot = readJson(join(runDir, "nodes", `${nodeId}.json`)); } catch {
|
|
250
|
+
// ENOENT or unreadable snapshot: this prior node contributes no summary.
|
|
251
|
+
}
|
|
252
|
+
const result = snapshot?.result;
|
|
253
|
+
const record = result && typeof result === "object" && !Array.isArray(result)
|
|
254
|
+
? /** @type {Record<string, unknown>} */ (result)
|
|
255
|
+
: null;
|
|
256
|
+
const summary = typeof record?.summary === "string" ? record.summary : null;
|
|
257
|
+
return summary && candidate ? `${candidate.id}: ${boundedUtf8(summary, 1024)}` : null;
|
|
258
|
+
})
|
|
259
|
+
.filter(Boolean)
|
|
260
|
+
.slice(-8);
|
|
261
|
+
const handoff = [
|
|
262
|
+
`Continue phase ${node.phase} as the ${role} agent in a fresh provider session.`,
|
|
263
|
+
"Prior structured node summaries:",
|
|
264
|
+
summaries.length ? summaries.map((summary) => `- ${summary}`).join("\n") : "- (none)",
|
|
265
|
+
"Current closed task packet:",
|
|
266
|
+
boundedUtf8(node.prompt, 48 * 1024),
|
|
267
|
+
].join("\n\n");
|
|
268
|
+
return boundedUtf8(handoff, 60 * 1024);
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* The mechanical worker tool policy for the provider boundary: hook settings
|
|
272
|
+
* on Claude-compatible commands. Only an adapter whose surface can prove
|
|
273
|
+
* enforcement (`capabilities.toolPolicy`) receives it; prompt text is not
|
|
274
|
+
* enforcement.
|
|
275
|
+
*
|
|
276
|
+
* @param {RuntimeSnapshot} runtime
|
|
277
|
+
* @param {ValidatedNode} node
|
|
278
|
+
* @param {string} workspace
|
|
279
|
+
* @returns {import("../harnesses/index.mjs").ToolPolicy|undefined}
|
|
280
|
+
*/
|
|
281
|
+
function workerToolPolicy(runtime, node, workspace) {
|
|
282
|
+
if (runtime.capabilities.toolPolicy !== true) return undefined;
|
|
283
|
+
return {
|
|
284
|
+
foregroundOnly: true,
|
|
285
|
+
maxToolOutputBytes: TOOL_OUTPUT_LIMIT_BYTES,
|
|
286
|
+
workspace,
|
|
287
|
+
writeFiles: node.taskPacket.writeFiles ?? [],
|
|
288
|
+
writeRoots: node.taskPacket.writeRoots ?? [],
|
|
289
|
+
maxReadLines: READ_LINE_LIMIT,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Build the bounded options shared by workers, judges, and gate revisions.
|
|
294
|
+
* Only provider session continuation travels here; time is the controller's
|
|
295
|
+
* only attempt control.
|
|
296
|
+
*
|
|
297
|
+
* @param {ValidatedContract} contract
|
|
298
|
+
* @param {ValidatedNode} node
|
|
299
|
+
* @param {NodeSnapshot} state
|
|
300
|
+
* @param {RuntimeSnapshot} runtime
|
|
301
|
+
* @param {{prompt: string, continuationId: string|null, mode: "fresh"|"reuse"|"rotate"}} phasePlan
|
|
302
|
+
* @param {string} runDir
|
|
303
|
+
* @param {LockHandle} lock
|
|
304
|
+
* @param {import("../harnesses/index.mjs").CommandOptions} [extra]
|
|
305
|
+
* @returns {import("../harnesses/index.mjs").CommandOptions}
|
|
306
|
+
*/
|
|
307
|
+
function invocationCommandOptions(contract, node, state, runtime, phasePlan, runDir, lock, extra = {}) {
|
|
308
|
+
return {
|
|
309
|
+
...extra,
|
|
310
|
+
continuationId: runtime.capabilities.continuation === true ? phasePlan.continuationId : null,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Seal the worktree the previous attempt left behind so its edits become the
|
|
315
|
+
* base of the next attempt instead of being abandoned in a discarded
|
|
316
|
+
* worktree (TECH-SPEC lean v0.3 section 3 rule 4). By the time this runs,
|
|
317
|
+
* `state.worktree` still points at the previous attempt — the caller always
|
|
318
|
+
* increments `state.attempt` before dispatching the next one — so that
|
|
319
|
+
* attempt's number is `state.attempt - 1`. Returns null when there is no
|
|
320
|
+
* previous worktree to seal, or it carries no diff from its own base: the
|
|
321
|
+
* next attempt is then cut from the integration head as before.
|
|
322
|
+
*
|
|
323
|
+
* @param {ValidatedContract} contract
|
|
324
|
+
* @param {ValidatedNode} node
|
|
325
|
+
* @param {NodeSnapshot} state
|
|
326
|
+
* @returns {{sha: string, attempt: number}|null}
|
|
327
|
+
*/
|
|
328
|
+
function sealPreviousAttempt(contract, node, state) {
|
|
329
|
+
const path = attemptWorkspace(state);
|
|
330
|
+
if (!path || !state.worktree?.branch || !state.worktree.baseSha) return null;
|
|
331
|
+
const attempt = state.attempt - 1;
|
|
332
|
+
const sealed = sealAttempt({
|
|
333
|
+
repo: contract.cwd,
|
|
334
|
+
path,
|
|
335
|
+
baseSha: state.worktree.baseSha,
|
|
336
|
+
runId: contract.id,
|
|
337
|
+
nodeId: node.id,
|
|
338
|
+
attempt,
|
|
339
|
+
});
|
|
340
|
+
return sealed.empty ? null : { sha: sealed.sha, attempt };
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Create the isolated workspace for the current attempt, or reuse the exact
|
|
344
|
+
* one already recorded for a controller restart. A retried attempt continues
|
|
345
|
+
* from the previous attempt's sealed sha rather than a fresh cut from the
|
|
346
|
+
* integration head, so sealed work is never abandoned in a discarded
|
|
347
|
+
* worktree.
|
|
348
|
+
*
|
|
349
|
+
* @param {ValidatedContract} contract
|
|
350
|
+
* @param {ValidatedNode} node
|
|
351
|
+
* @param {NodeSnapshot} state
|
|
352
|
+
* @param {string} runDir
|
|
353
|
+
* @param {LockHandle} lock
|
|
354
|
+
* @returns {string}
|
|
355
|
+
*/
|
|
356
|
+
function ensureAttemptWorkspace(contract, node, state, runDir, lock) {
|
|
357
|
+
const expectedPath = attemptWorktreePath(runDir, contract.id, node.id, state.attempt);
|
|
358
|
+
if (state.worktree?.path === expectedPath && attemptWorkspace(state)) return expectedPath;
|
|
359
|
+
const previous = sealPreviousAttempt(contract, node, state);
|
|
360
|
+
const worktree = createAttemptWorktree({
|
|
361
|
+
repo: contract.cwd,
|
|
362
|
+
runDir,
|
|
363
|
+
runId: contract.id,
|
|
364
|
+
nodeId: node.id,
|
|
365
|
+
attempt: state.attempt,
|
|
366
|
+
base: previous?.sha,
|
|
367
|
+
});
|
|
368
|
+
const boundary = captureWorkspaceScope(worktree.path, workerScope(node.taskPacket));
|
|
369
|
+
state.worktree = previous ? { ...worktree, previousAttempt: previous.attempt } : worktree;
|
|
370
|
+
state.scope = emptyScope(boundary);
|
|
371
|
+
writeNode(runDir, state, lock);
|
|
372
|
+
return worktree.path;
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* @param {ValidatedContract} contract
|
|
376
|
+
* @param {ValidatedNode} node
|
|
377
|
+
* @param {NodeSnapshot} state
|
|
378
|
+
* @param {string} runDir
|
|
379
|
+
* @param {Map<string, Job>} running
|
|
380
|
+
* @param {string} prompt
|
|
381
|
+
* @param {LockHandle} lock
|
|
382
|
+
* @param {Map<string, NodeSnapshot>} states
|
|
383
|
+
* @param {string} campaignPath
|
|
384
|
+
* @param {SessionPolicy} [policy] the session policy the rejection decision
|
|
385
|
+
* carried into dispatch; `forceFresh` starts a fresh provider session instead
|
|
386
|
+
* of reusing the failed attempt's continuation. A policy persisted on the
|
|
387
|
+
* snapshot by `applyRejection` is merged in and consumed.
|
|
388
|
+
*/
|
|
389
|
+
export function startWorker(contract, node, state, runDir, running, prompt, lock, states, campaignPath, policy = {}) {
|
|
390
|
+
// Resolve before the workspace is created: the one-shot policy governs this
|
|
391
|
+
// dispatch whether the caller passed it or `applyRejection` left it behind.
|
|
392
|
+
const sessionPolicy = forceFreshSession(state, policy);
|
|
393
|
+
let workspace;
|
|
394
|
+
try {
|
|
395
|
+
workspace = ensureAttemptWorkspace(contract, node, state, runDir, lock);
|
|
396
|
+
} catch (error) {
|
|
397
|
+
state.worktree = { ...(state.worktree ?? {}), status: "failed", path: state.worktree?.path ?? null, branch: state.worktree?.branch ?? null, commit: state.worktree?.commit ?? null, baseSha: state.worktree?.baseSha ?? null };
|
|
398
|
+
transition(runDir, state, "failed", { phase: "worker", error: { code: errorCode(error) ?? "worktree_create_failed", message: errorMessage(error) } }, lock);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
const runtime = routeRuntimeForState(contract, node, state, "worker");
|
|
402
|
+
const phasePlan = phaseInvocationPlan(contract, node, state, runDir, "worker", prompt, sessionPolicy);
|
|
403
|
+
// The previous-attempt section still has to survive on a retried attempt,
|
|
404
|
+
// so it is appended to the resolved prompt rather than the candidate handed
|
|
405
|
+
// to phaseInvocationPlan.
|
|
406
|
+
phasePlan.prompt = appendPreviousAttempt(phasePlan.prompt, state.previousAttempt);
|
|
407
|
+
// The worker prompt directs the provider to write the canonical result file;
|
|
408
|
+
// make sure the directory exists before the provider is asked to.
|
|
409
|
+
const resultPath = attemptWorkerResultPath(runDir, node.id, workspace);
|
|
410
|
+
mkdirSync(dirname(resultPath), { recursive: true });
|
|
411
|
+
const effectivePrompt = workerProtocolPrompt(phasePlan.prompt, resultPath);
|
|
412
|
+
const paths = logPaths(runDir, node.id, "worker", state.attempt);
|
|
413
|
+
if (Buffer.byteLength(effectivePrompt, "utf8") > 64 * 1024) {
|
|
414
|
+
transition(runDir, state, "failed", { phase: "worker", error: { code: "worker_prompt_too_large", message: "worker prompt exceeds 65536 bytes" } }, lock);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
/** @type {import("../repo/workspace.mjs").WorkspaceScopeBoundary} */
|
|
418
|
+
let boundary;
|
|
419
|
+
/** @type {unknown} */
|
|
420
|
+
let baseline;
|
|
421
|
+
try {
|
|
422
|
+
boundary = persistedScopeBoundary(contract, node, state, workspace);
|
|
423
|
+
baseline = captureWorkspaceSnapshot(workspace);
|
|
424
|
+
} catch (error) {
|
|
425
|
+
transition(runDir, state, "failed", { phase: "worker", error: { code: /** @type {string} */ (errorCode(error) ?? "scope_snapshot_invalid"), message: errorMessage(error) } }, lock);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
const snapshotPath = `${paths.prompt}.snapshot.json`;
|
|
429
|
+
writeJsonAtomic(snapshotPath, baseline);
|
|
430
|
+
state.phase = "worker";
|
|
431
|
+
state.runtime = runtime;
|
|
432
|
+
// A new worker attempt has no accepted result yet. The canonical result
|
|
433
|
+
// file is cleared when the previous attempt was explicitly rejected (failed
|
|
434
|
+
// gate verdict), when no valid canonical file exists, or when the stale file
|
|
435
|
+
// is a blocked_context result. A blocked_context result never reached a gate
|
|
436
|
+
// and is still a valid, non-null canonical file, so the failed-gate and
|
|
437
|
+
// missing-file conditions leave it in place and the re-dispatched node would
|
|
438
|
+
// adopt its own stale blocked_context result again. A valid non-blocked file
|
|
439
|
+
// at the start of a continuation attempt is durable evidence and must stay
|
|
440
|
+
// in place so the completion path can adopt it.
|
|
441
|
+
state.result = null;
|
|
442
|
+
state.verification = null;
|
|
443
|
+
state.scope = null;
|
|
444
|
+
let existingCanonicalResult = null;
|
|
445
|
+
try {
|
|
446
|
+
existingCanonicalResult = readWorkerResultFile(runDir, node.id);
|
|
447
|
+
} catch {
|
|
448
|
+
existingCanonicalResult = null;
|
|
449
|
+
}
|
|
450
|
+
clearAttemptWorkerResult(workspace, node.id);
|
|
451
|
+
if (state.gate?.verdict === "fail" || existingCanonicalResult === null || existingCanonicalResult?.status === "blocked_context") {
|
|
452
|
+
clearWorkerResultFile(runDir, node.id);
|
|
453
|
+
}
|
|
454
|
+
const previousInvocation = state.invocations?.at(-1);
|
|
455
|
+
if (previousInvocation && hasOperationSettlement(runDir, previousInvocation.id)) {
|
|
456
|
+
settleInvocation(runDir, previousInvocation, { nextState: operationNextState(state) });
|
|
457
|
+
}
|
|
458
|
+
state.startedAt ??= new Date().toISOString();
|
|
459
|
+
state.error = null;
|
|
460
|
+
state.scope = emptyScope(boundary);
|
|
461
|
+
writeNode(runDir, state, lock);
|
|
462
|
+
try {
|
|
463
|
+
const job = startProcess({
|
|
464
|
+
contract, node, state, runtime, workspace, prompt: effectivePrompt, paths, phase: "worker",
|
|
465
|
+
commandOptions: invocationCommandOptions(contract, node, state, runtime, phasePlan, runDir, lock, {
|
|
466
|
+
toolPolicy: workerToolPolicy(runtime, node, workspace),
|
|
467
|
+
// The pair a `bulk-read` delegation inside the worker accounts itself
|
|
468
|
+
// against; providerCommand merges it into the spawned environment.
|
|
469
|
+
env: { FABERUN_RUN_DIR: runDir, FABERUN_NODE_ID: node.id },
|
|
470
|
+
}),
|
|
471
|
+
onInvocation: (invocation, currentJob) => {
|
|
472
|
+
stampInvocation(invocation, contract, node, runtime, state, runDir, "worker", phasePlan.mode, phasePlan.continuationId);
|
|
473
|
+
invocation.snapshotPath = snapshotPath;
|
|
474
|
+
currentJob.scopeBaseline = baseline;
|
|
475
|
+
persistInvocation(runDir, state, invocation, currentJob, lock);
|
|
476
|
+
persistInvocationIntent(runDir, invocation, {
|
|
477
|
+
nodeId: node.id,
|
|
478
|
+
role: "worker",
|
|
479
|
+
attempt: state.attempt,
|
|
480
|
+
runtimeFingerprint: fingerprintRuntime(runtime),
|
|
481
|
+
prompt: effectivePrompt,
|
|
482
|
+
});
|
|
483
|
+
},
|
|
484
|
+
onInvocationUpdate: (invocation) => persistInvocationUpdate(runDir, state, invocation, lock),
|
|
485
|
+
onProgress: () => writeNode(runDir, state, lock),
|
|
486
|
+
});
|
|
487
|
+
transition(runDir, state, "running", { phase: "worker", runtime, error: null }, lock);
|
|
488
|
+
running.set(node.id, job);
|
|
489
|
+
} catch (error) {
|
|
490
|
+
const invocation = state.invocations?.at(-1);
|
|
491
|
+
if (invocation && hasOperationIntent(runDir, invocation.id) && operationNeedsRecovery(runDir, invocation.id)) {
|
|
492
|
+
settleInvocation(runDir, invocation, {
|
|
493
|
+
status: "failed",
|
|
494
|
+
error: { code: "spawn_error", message: errorMessage(error) },
|
|
495
|
+
reason: "provider did not start",
|
|
496
|
+
nextState: operationNextState(state),
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
transition(runDir, state, "failed", { phase: "worker", error: { code: "spawn_error", message: errorMessage(error) } }, lock);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* A completed implementation may have omitted only its durable result. Resume
|
|
504
|
+
* the exact provider session for one turn to materialize that file; never use
|
|
505
|
+
* this path to restart implementation work.
|
|
506
|
+
*
|
|
507
|
+
* @param {ValidatedContract} contract
|
|
508
|
+
* @param {ValidatedNode} node
|
|
509
|
+
* @param {NodeSnapshot} state
|
|
510
|
+
* @param {string} runDir
|
|
511
|
+
* @param {Map<string, Job>} running
|
|
512
|
+
* @param {Invocation} sourceInvocation
|
|
513
|
+
* @param {HarnessRuntime & {id: string|null}} runtime
|
|
514
|
+
* @param {string|null} continuationId
|
|
515
|
+
* @param {LockHandle} lock
|
|
516
|
+
*/
|
|
517
|
+
export function startResultMaterialization(contract, node, state, runDir, running, sourceInvocation, runtime, continuationId, lock) {
|
|
518
|
+
const materializationRuntime = runtime.id ? runtimeSnapshot(contract, runtime.id) : null;
|
|
519
|
+
if (!materializationRuntime || materializationRuntime.capabilities.continuation !== true || !continuationId) {
|
|
520
|
+
transition(runDir, state, "failed", {
|
|
521
|
+
phase: "worker",
|
|
522
|
+
error: { code: "missing_worker_result", message: "worker completed without a canonical result file and this runtime did not provide a resumable session for the one-turn materialization" },
|
|
523
|
+
}, lock);
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
const paths = logPaths(runDir, node.id, "worker", state.attempt);
|
|
527
|
+
const workspace = attemptWorkspace(state) ?? contract.cwd;
|
|
528
|
+
const resultPath = attemptWorkerResultPath(runDir, node.id, workspace);
|
|
529
|
+
const prompt = [
|
|
530
|
+
`${RESULT_MATERIALIZATION_PROMPT_HEADER} Do not inspect, implement, verify, or invoke tools.`,
|
|
531
|
+
`Your only job in this single bounded turn is to write the required worker-result JSON object to: ${resultPath}`,
|
|
532
|
+
"Then return that same JSON object as the final message.",
|
|
533
|
+
].join("\n\n");
|
|
534
|
+
let baseline;
|
|
535
|
+
try {
|
|
536
|
+
baseline = captureWorkspaceSnapshot(workspace);
|
|
537
|
+
writeJsonAtomic(`${paths.prompt}.snapshot.json`, baseline);
|
|
538
|
+
} catch (error) {
|
|
539
|
+
transition(runDir, state, "failed", { phase: "worker", error: { code: "result_materialization_snapshot_invalid", message: errorMessage(error) } }, lock);
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
state.phase = "worker";
|
|
543
|
+
state.runtime = materializationRuntime;
|
|
544
|
+
state.error = { code: "result_materialization_pending", message: "awaiting one-turn canonical worker-result materialization" };
|
|
545
|
+
writeNode(runDir, state, lock);
|
|
546
|
+
try {
|
|
547
|
+
const job = startProcess({
|
|
548
|
+
contract, node, state, runtime: materializationRuntime, workspace, prompt, paths, phase: "worker",
|
|
549
|
+
commandOptions: invocationCommandOptions(contract, node, state, materializationRuntime, {
|
|
550
|
+
prompt,
|
|
551
|
+
continuationId,
|
|
552
|
+
mode: "reuse",
|
|
553
|
+
}, runDir, lock, {
|
|
554
|
+
toolPolicy: workerToolPolicy(materializationRuntime, node, workspace),
|
|
555
|
+
}),
|
|
556
|
+
onInvocation: (invocation, currentJob) => {
|
|
557
|
+
stampInvocation(invocation, contract, node, materializationRuntime, state, runDir, "worker", "reuse", continuationId);
|
|
558
|
+
invocation.snapshotPath = `${paths.prompt}.snapshot.json`;
|
|
559
|
+
currentJob.resultMaterialization = true;
|
|
560
|
+
currentJob.recoveryBaseline = baseline;
|
|
561
|
+
persistInvocation(runDir, state, invocation, currentJob, lock);
|
|
562
|
+
persistInvocationIntent(runDir, invocation, {
|
|
563
|
+
nodeId: node.id,
|
|
564
|
+
role: "worker",
|
|
565
|
+
attempt: state.attempt,
|
|
566
|
+
runtimeFingerprint: fingerprintRuntime(materializationRuntime),
|
|
567
|
+
prompt,
|
|
568
|
+
});
|
|
569
|
+
},
|
|
570
|
+
onInvocationUpdate: (invocation) => persistInvocationUpdate(runDir, state, invocation, lock),
|
|
571
|
+
});
|
|
572
|
+
transition(runDir, state, "running", { phase: "worker", runtime: materializationRuntime }, lock);
|
|
573
|
+
running.set(node.id, job);
|
|
574
|
+
} catch (error) {
|
|
575
|
+
transition(runDir, state, "failed", { phase: "worker", error: { code: "result_materialization_failed", message: errorMessage(error) } }, lock);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Gate a completed worker: mechanical proofs gate first, the judge arbitrates
|
|
580
|
+
* only judgment items and is skipped when the review mode is `none` or no
|
|
581
|
+
* judgment item exists. A judge protocol re-ask never re-runs the round's
|
|
582
|
+
* mechanical proofs.
|
|
583
|
+
*
|
|
584
|
+
* Returns what it decided rather than acting on it. Calling `applyRejection` or
|
|
585
|
+
* `settleDone` from here made dispatch depend on review policy and on
|
|
586
|
+
* settlement, which is the shape that kept `engine/` a web instead of a stack:
|
|
587
|
+
* every caller of this function already reaches both, and now it is their call.
|
|
588
|
+
*
|
|
589
|
+
* @param {ValidatedContract} contract
|
|
590
|
+
* @param {ValidatedNode} node
|
|
591
|
+
* @param {NodeSnapshot} state
|
|
592
|
+
* @param {string} runDir
|
|
593
|
+
* @param {Map<string, Job>} running
|
|
594
|
+
* @param {unknown} workerResult
|
|
595
|
+
* @param {LockHandle} lock
|
|
596
|
+
* @param {Map<string, NodeSnapshot>} states
|
|
597
|
+
* @param {string} campaignPath
|
|
598
|
+
* @returns {Promise<JudgeRound>}
|
|
599
|
+
*/
|
|
600
|
+
export async function startJudge(contract, node, state, runDir, running, workerResult, lock, states, campaignPath) {
|
|
601
|
+
const reaskReason = judgeReaskReason(state);
|
|
602
|
+
const reask = reaskReason !== undefined;
|
|
603
|
+
const workspace = attemptWorkspace(state) ?? contract.cwd;
|
|
604
|
+
const { verdict, results } = await deterministicGate(
|
|
605
|
+
node,
|
|
606
|
+
workspace,
|
|
607
|
+
reask,
|
|
608
|
+
Math.max(1_000, Math.min((node.timeoutSec ?? contract.timeoutSec ?? 60) * 1000, 120_000)),
|
|
609
|
+
/** @type {import("../contract/index.mjs").VerificationState|null} */ (state.verification),
|
|
610
|
+
);
|
|
611
|
+
state.review = reviewMode(node.gate);
|
|
612
|
+
if (verdict.verdict === "fail") return { kind: "rejected", verdict };
|
|
613
|
+
// `skipWhen` is checked before the ordinary judgment rule so a green-and-small
|
|
614
|
+
// change settles mechanically even when a Definition of Done item carries
|
|
615
|
+
// `judgment: true`. Either condition failing falls through to `judgeRequired`,
|
|
616
|
+
// and a gate whose review mode is `none` is skipped there exactly as before.
|
|
617
|
+
if (judgeSkippedByScope(node, state)) return { kind: "settle", gate: verdict };
|
|
618
|
+
if (!judgeRequired(node)) return { kind: "settle", gate: verdict };
|
|
619
|
+
const runtime = routeRuntimeForState(contract, node, state, "judge");
|
|
620
|
+
const paths = logPaths(runDir, node.id, "judge", state.attempt);
|
|
621
|
+
state.phase = "judge";
|
|
622
|
+
state.runtime = runtime;
|
|
623
|
+
const previousInvocation = state.invocations?.at(-1);
|
|
624
|
+
if (previousInvocation && hasOperationSettlement(runDir, previousInvocation.id)) {
|
|
625
|
+
settleInvocation(runDir, previousInvocation, { nextState: operationNextState(state) });
|
|
626
|
+
}
|
|
627
|
+
try {
|
|
628
|
+
const prompt = `${judgePrompt(node, workerResult, {
|
|
629
|
+
diff: state.scope?.changedPaths,
|
|
630
|
+
verification: state.verification,
|
|
631
|
+
deterministic: results,
|
|
632
|
+
scopeFindings: state.scopeFindings,
|
|
633
|
+
previousAttempt: state.previousAttempt,
|
|
634
|
+
})}${reask ? judgeReaskInstruction(reaskReason) : ""}`;
|
|
635
|
+
const phasePlan = phaseInvocationPlan(contract, node, state, runDir, "judge", prompt);
|
|
636
|
+
// judgePrompt already carries the section when phaseInvocationPlan reuses
|
|
637
|
+
// that candidate; appendPreviousAttempt is a no-op then.
|
|
638
|
+
phasePlan.prompt = appendPreviousAttempt(phasePlan.prompt, state.previousAttempt);
|
|
639
|
+
if (Buffer.byteLength(phasePlan.prompt, "utf8") > 64 * 1024) {
|
|
640
|
+
const error = /** @type {Error & {code: string}} */ (new Error("judge prompt exceeds 65536 bytes"));
|
|
641
|
+
error.code = "judge_prompt_too_large";
|
|
642
|
+
throw error;
|
|
643
|
+
}
|
|
644
|
+
const job = startProcess({
|
|
645
|
+
contract, node, state, runtime, workspace,
|
|
646
|
+
prompt: phasePlan.prompt,
|
|
647
|
+
paths, phase: "judge",
|
|
648
|
+
commandOptions: invocationCommandOptions(contract, node, state, runtime, phasePlan, runDir, lock, {
|
|
649
|
+
schema: JUDGE_SCHEMA,
|
|
650
|
+
schemaPath: join(runDir, "judge.schema.json"),
|
|
651
|
+
}),
|
|
652
|
+
onInvocation: (invocation, currentJob) => {
|
|
653
|
+
stampInvocation(invocation, contract, node, runtime, state, runDir, "judge", phasePlan.mode, phasePlan.continuationId);
|
|
654
|
+
persistInvocation(runDir, state, invocation, currentJob, lock);
|
|
655
|
+
persistInvocationIntent(runDir, invocation, {
|
|
656
|
+
nodeId: node.id,
|
|
657
|
+
role: "judge",
|
|
658
|
+
attempt: state.attempt,
|
|
659
|
+
runtimeFingerprint: fingerprintRuntime(runtime),
|
|
660
|
+
prompt: phasePlan.prompt,
|
|
661
|
+
});
|
|
662
|
+
},
|
|
663
|
+
onInvocationUpdate: (invocation) => persistInvocationUpdate(runDir, state, invocation, lock),
|
|
664
|
+
});
|
|
665
|
+
transition(runDir, state, "running", { phase: "judge", runtime }, lock);
|
|
666
|
+
running.set(node.id, job);
|
|
667
|
+
} catch (error) {
|
|
668
|
+
const invocation = state.invocations?.at(-1);
|
|
669
|
+
if (invocation && hasOperationIntent(runDir, invocation.id) && operationNeedsRecovery(runDir, invocation.id)) {
|
|
670
|
+
settleInvocation(runDir, invocation, {
|
|
671
|
+
status: "failed",
|
|
672
|
+
error: { code: /** @type {string} */ (errorCode(error) ?? "spawn_error"), message: errorMessage(error) },
|
|
673
|
+
reason: "provider did not start",
|
|
674
|
+
nextState: operationNextState(state),
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
transition(runDir, state, "failed", { phase: "judge", error: { code: /** @type {string} */ (errorCode(error) ?? "spawn_error"), message: errorMessage(error) } }, lock);
|
|
678
|
+
}
|
|
679
|
+
return { kind: "dispatched" };
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* @param {string} runDir
|
|
683
|
+
* @param {NodeSnapshot} state
|
|
684
|
+
* @param {Invocation} invocation
|
|
685
|
+
* @param {Job} job
|
|
686
|
+
* @param {LockHandle} lock
|
|
687
|
+
*/
|
|
688
|
+
function persistInvocation(runDir, state, invocation, job, lock) {
|
|
689
|
+
state.invocations = [...(state.invocations ?? []), invocation];
|
|
690
|
+
state.updatedAt = invocation.updatedAt;
|
|
691
|
+
writeNode(runDir, state, lock);
|
|
692
|
+
job.onClose = (closed) => {
|
|
693
|
+
try {
|
|
694
|
+
let continuationId = closed.continuationId ?? null;
|
|
695
|
+
let usage = closed.usage;
|
|
696
|
+
let costUsd = closed.costUsd;
|
|
697
|
+
let envelopeStatus = "closed";
|
|
698
|
+
let structuredResult = null;
|
|
699
|
+
let envelopeResult = null;
|
|
700
|
+
let envelopeError = null;
|
|
701
|
+
try {
|
|
702
|
+
const envelope = normalizeProviderResult(job.runtime, readBoundedTail(job.paths.stdout), job.exitCode, null, { preferStructured: job.phase === "judge" });
|
|
703
|
+
continuationId = envelope.continuationId ?? continuationId;
|
|
704
|
+
usage = envelope.usage;
|
|
705
|
+
costUsd = envelope.costUsd;
|
|
706
|
+
envelopeStatus = envelope.status;
|
|
707
|
+
structuredResult = Boolean(envelope.result);
|
|
708
|
+
envelopeResult = envelope.result ?? null;
|
|
709
|
+
envelopeError = envelope.error ?? null;
|
|
710
|
+
} catch {
|
|
711
|
+
// Unparseable provider envelope: settle with the raw closed invocation instead.
|
|
712
|
+
}
|
|
713
|
+
const completed = { ...closed, continuationId, usage, costUsd };
|
|
714
|
+
state.invocations = (state.invocations ?? []).map((item) => item.id === completed.id ? completed : item);
|
|
715
|
+
state.usage = invocationUsage(state);
|
|
716
|
+
state.costUsd = invocationCost(state);
|
|
717
|
+
state.updatedAt = closed.updatedAt;
|
|
718
|
+
writeNode(runDir, state, lock);
|
|
719
|
+
settleInvocation(runDir, completed, {
|
|
720
|
+
status: envelopeStatus,
|
|
721
|
+
usage,
|
|
722
|
+
costUsd: typeof costUsd === "number" ? costUsd : null,
|
|
723
|
+
structuredResult,
|
|
724
|
+
result: envelopeResult,
|
|
725
|
+
receipts: providerReceipts({ continuationId }),
|
|
726
|
+
error: envelopeError,
|
|
727
|
+
nextState: operationNextState(state),
|
|
728
|
+
});
|
|
729
|
+
} catch (error) {
|
|
730
|
+
if (!(error instanceof LockLostError)) throw error;
|
|
731
|
+
}
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Persist live provider observations without creating a second invocation
|
|
736
|
+
* record. Continuation identity is authoritative before the provider log is
|
|
737
|
+
* capped or the process is terminated.
|
|
738
|
+
*
|
|
739
|
+
* @param {string} runDir
|
|
740
|
+
* @param {NodeSnapshot} state
|
|
741
|
+
* @param {Invocation} invocation
|
|
742
|
+
* @param {LockHandle} lock
|
|
743
|
+
*/
|
|
744
|
+
function persistInvocationUpdate(runDir, state, invocation, lock) {
|
|
745
|
+
try {
|
|
746
|
+
state.invocations = (state.invocations ?? []).map((item) => item.id === invocation.id ? invocation : item);
|
|
747
|
+
state.updatedAt = invocation.updatedAt;
|
|
748
|
+
writeNode(runDir, state, lock);
|
|
749
|
+
} catch (error) {
|
|
750
|
+
if (!(error instanceof LockLostError)) throw error;
|
|
751
|
+
}
|
|
752
|
+
}
|