faberun 0.12.1 → 0.14.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.
@@ -24,21 +24,23 @@ import {
24
24
  import { attemptWorkspace, createAttemptWorktree, sealAttempt } from "../repo/worktree.mjs";
25
25
  import { attemptWorktreePath } from "../run/paths.mjs";
26
26
  import { basename, dirname, join } from "node:path";
27
- import { boundedUtf8, errorCode, errorMessage, stableJson } from "../util.mjs";
27
+ import { errorCode, errorMessage } from "../util.mjs";
28
28
  import { captureWorkspaceScope, captureWorkspaceSnapshot } from "../repo/workspace.mjs";
29
- import { createHash } from "node:crypto";
30
29
  import { deterministicGate, judgeReaskReason, judgeRequired, judgeSkippedByScope } from "./judge-gate.mjs";
31
30
  import { emptyScope, persistedScopeBoundary, workerScope } from "./scope.mjs";
32
31
  import { hasOperationIntent, hasOperationSettlement, operationNeedsRecovery, operationNextState, persistInvocationIntent, providerReceipts, settleInvocation } from "../run/operations.mjs";
33
32
  import { invocationCost, invocationUsage } from "../run/usage.mjs";
34
- import { logPaths, readBoundedTail, startProcess } from "./process.mjs";
33
+ import { logPaths, startProcess } from "./process.mjs";
34
+ import { readBoundedTail } from "./transcript.mjs";
35
35
  import { mkdirSync, statSync } from "node:fs";
36
- import { READ_LINE_LIMIT, normalizeProviderResult, providerCommand } from "../harnesses/index.mjs";
37
- import { readJson, writeJsonAtomic } from "../run/store.mjs";
36
+ import { READ_BYTE_LIMIT, READ_LINE_LIMIT, normalizeProviderResult } from "../harnesses/index.mjs";
37
+ import { writeJsonAtomic } from "../run/store.mjs";
38
38
  import { judgeReaskInstruction, reviewMode } from "../contract/review-modes.mjs";
39
39
  import { routeRuntimeForState, runtimeSnapshot } from "./failover.mjs";
40
+ import { fingerprintRuntime, forceFreshSession, phaseInvocationPlan } from "./phase-session.mjs";
41
+
42
+ /** @typedef {import("./phase-session.mjs").SessionPolicy} SessionPolicy */
40
43
  import { transition, writeNode } from "./state.mjs";
41
- import { validateNodeSnapshot } from "../contract/snapshot.mjs";
42
44
 
43
45
  /**
44
46
  * What a judge round decided, for the caller to act on.
@@ -63,156 +65,8 @@ import { validateNodeSnapshot } from "../contract/snapshot.mjs";
63
65
  /** @typedef {import("../contract/index.mjs").VerificationState} VerificationState */
64
66
  /** @typedef {import("../contract/index.mjs").WorkspaceScopeBoundary} WorkspaceScopeBoundary */
65
67
 
66
- /** @typedef {{forceFresh?: boolean}} SessionPolicy */
67
68
 
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
69
 
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
70
  /** @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
71
  function stampInvocation(invocation, contract, node, runtime, state, runDir, role, mode, continuationId) {
218
72
  invocation.runId = basename(runDir);
@@ -235,38 +89,6 @@ function stampInvocation(invocation, contract, node, runtime, state, runDir, rol
235
89
  // generation across a controller crash.
236
90
  /** @type {{cycle?: number}} */ (invocation).cycle = state.routing?.tierExhaustionCycle ?? 0;
237
91
  }
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
92
  /**
271
93
  * The declared weight of a node's readFiles at dispatch time: the sum of the
272
94
  * byte sizes of the files that exist in the attempt workspace. This is the
@@ -311,6 +133,7 @@ function workerToolPolicy(runtime, node, workspace) {
311
133
  writeFiles: node.taskPacket.writeFiles ?? [],
312
134
  writeRoots: node.taskPacket.writeRoots ?? [],
313
135
  maxReadLines: READ_LINE_LIMIT,
136
+ maxReadBytes: READ_BYTE_LIMIT,
314
137
  };
315
138
  }
316
139
  /**
@@ -332,6 +155,9 @@ function invocationCommandOptions(contract, node, state, runtime, phasePlan, run
332
155
  return {
333
156
  ...extra,
334
157
  continuationId: runtime.capabilities.continuation === true ? phasePlan.continuationId : null,
158
+ // The attempt's request ceiling. An adapter that can enforce it natively
159
+ // takes it as a flag; the monitor enforces it for every streaming harness.
160
+ maxTurns: node.maxTurns ?? contract.maxTurns,
335
161
  };
336
162
  }
337
163
  /**
@@ -142,8 +142,13 @@ export function terminalErrorCode(state) {
142
142
  return typeof error.code === "string" && error.code ? error.code : null;
143
143
  }
144
144
 
145
- /** Error codes that earn exactly one automatic retry before parking. */
146
- export const AUTO_RETRY_CODES = new Set(["judge_unavailable", "provider_error", "stall_timeout", "wall_clock_timeout"]);
145
+ /**
146
+ * Error codes that earn exactly one automatic retry before parking.
147
+ * `turn_limit` is here and not among the timeout codes below: a turn the CLI
148
+ * stopped itself at `--max-turns` exits cleanly with no seal yet, and the
149
+ * next dispatch seals its worktree as it does for any previous attempt.
150
+ */
151
+ export const AUTO_RETRY_CODES = new Set(["judge_unavailable", "provider_error", "stall_timeout", "wall_clock_timeout", "turn_limit"]);
147
152
 
148
153
  /**
149
154
  * Timeout codes earn their automatic retry only when phase 5b sealed work
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Which provider session a node's next invocation runs in: fresh, a
3
+ * continuation of a compatible earlier session in the same plan phase, or a
4
+ * rotation -- a fresh session that carries the prior nodes' structured
5
+ * summaries. Separate from dispatch.mjs, which spawns whatever this decides,
6
+ * because the decision reads only persisted node snapshots and the routing
7
+ * table, and because dispatch.mjs crossed the 800-line ceiling carrying it.
8
+ */
9
+ import { basename, join } from "node:path";
10
+ import { createHash } from "node:crypto";
11
+ import { boundedUtf8, stableJson } from "../util.mjs";
12
+ import { providerCommand } from "../harnesses/index.mjs";
13
+ import { readJson } from "../run/store.mjs";
14
+ import { routeRuntimeForState } from "./failover.mjs";
15
+ import { validateNodeSnapshot } from "../contract/snapshot.mjs";
16
+
17
+ /** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
18
+ /** @typedef {import("../contract/index.mjs").ValidatedNode} ValidatedNode */
19
+ /** @typedef {import("../contract/index.mjs").NodeSnapshot} NodeSnapshot */
20
+ /** @typedef {import("../contract/index.mjs").RuntimeSnapshot} RuntimeSnapshot */
21
+ /** @typedef {import("./process.mjs").Invocation} Invocation */
22
+
23
+ /** @typedef {{forceFresh?: boolean}} SessionPolicy */
24
+
25
+ /**
26
+ * Resolve the session policy one dispatch runs under, then consume the copy the
27
+ * node persisted. The explicit argument comes from a caller that is dispatching
28
+ * on the spot; `state.sessionPolicy` is the copy a rejection decision left when
29
+ * it handed the node back to the scheduler, whose own `startWorker` call passes
30
+ * nothing at all.
31
+ *
32
+ * Persisting is the whole point: `phaseInvocationPlan` rediscovers a compatible
33
+ * continuation from the persisted ledger, so nulling a local continuation id at
34
+ * the call site would let the scheduler's later dispatch find it again. Clearing
35
+ * the stored policy here makes it one-shot — it governs exactly the dispatch it
36
+ * was recorded for, and the next unrelated attempt reuses normally.
37
+ *
38
+ * @param {{sessionPolicy?: SessionPolicy|null}} state
39
+ * @param {SessionPolicy} [explicit]
40
+ * @returns {SessionPolicy}
41
+ */
42
+ export function forceFreshSession(state, explicit = {}) {
43
+ const persisted = /** @type {SessionPolicy|undefined} */ (state?.sessionPolicy ?? undefined);
44
+ const policy = { ...(persisted ?? {}), ...explicit };
45
+ if (state && state.sessionPolicy !== undefined && state.sessionPolicy !== null) state.sessionPolicy = null;
46
+ return policy;
47
+ }
48
+
49
+ /**
50
+ * Select the only continuation that is allowed for this plan phase and role.
51
+ * The search is intentionally limited to persisted node snapshots in this run.
52
+ *
53
+ * `policy.forceFresh` is the explicit session policy a rejection decision
54
+ * carries: it short-circuits the search before it can rediscover a compatible
55
+ * continuation, so a retry after a gate rejection starts a fresh provider
56
+ * session instead of re-reading the failed transcript. Nulling a local id at
57
+ * the call site is not enough, because this function rediscovers the prior
58
+ * continuation from the persisted ledger.
59
+ *
60
+ * @param {ValidatedContract} contract
61
+ * @param {ValidatedNode} node
62
+ * @param {NodeSnapshot} state
63
+ * @param {string} runDir
64
+ * @param {"worker"|"judge"} role
65
+ * @param {string} prompt
66
+ * @param {SessionPolicy} [policy]
67
+ * @returns {{prompt: string, continuationId: string|null, mode: "fresh"|"reuse"|"rotate"}}
68
+ */
69
+ export function phaseInvocationPlan(contract, node, state, runDir, role, prompt, policy = {}) {
70
+ if (policy.forceFresh === true) {
71
+ return { prompt, continuationId: null, mode: "fresh" };
72
+ }
73
+ const runId = basename(runDir);
74
+ const session = phaseSessionCandidates(contract, node, state, runDir, role).at(-1);
75
+ const runtime = routeRuntimeForState(contract, node, state, role);
76
+ const identityMatches = session && session.invocation.runId === runId
77
+ && session.invocation.campaignId === contract.campaignId
78
+ && session.invocation.planPhase === node.phase
79
+ && session.invocation.role === role
80
+ && session.invocation.harness === runtime.harness
81
+ && session.invocation.runtimeId === runtime.id
82
+ && session.invocation.runtimeFingerprint === fingerprintRuntime(runtime)
83
+ && session.invocation.model === runtime.model
84
+ && session.invocation.reasoning === (runtime.reasoning ?? null)
85
+ && session.invocation.sandbox === (runtime.sandbox ?? null);
86
+ const canContinue = runtime.capabilities.continuation === true;
87
+ // A node continuing its own earlier session keeps it. A phase sibling's
88
+ // session is reused only when the contract opts in (`phaseSessionReuse`):
89
+ // measured 2026-09-20 over 21 runs with both kinds of turn, a turn opened
90
+ // on a sibling's session cost 1.87x the fresh one at the same request
91
+ // count -- it began with 200k tokens of context instead of 45k and re-read
92
+ // them on every request -- while the rotation below hands the sibling's
93
+ // structured summary to a fresh session whose first request is already 90%
94
+ // served from the shared prefix cache.
95
+ const ownSession = session !== undefined && session.nodeId === node.id;
96
+ if (identityMatches && canContinue && (ownSession || contract.phaseSessionReuse === true)) {
97
+ return { prompt, continuationId: session.invocation.continuationId ?? null, mode: "reuse" };
98
+ }
99
+ // A harness that cannot continue at all, or a session picked up from a
100
+ // different phase-sibling node whose identity does not match this one, has
101
+ // no native continuity: the fresh attempt carries the prior nodes'
102
+ // structured summaries forward instead of starting blind.
103
+ if (session && (!canContinue || session.nodeId !== node.id)) {
104
+ return {
105
+ prompt: phaseHandoffPrompt(contract, node, state, runDir, role),
106
+ continuationId: null,
107
+ mode: "rotate",
108
+ };
109
+ }
110
+ // A capable harness continuing its own node whose identity merely drifted
111
+ // (the run directory moved, or a runtime edge) still gets the caller's own
112
+ // prompt — already carrying the node's bounded "Previous attempt" section —
113
+ // in a fresh session, never a synthesized handoff.
114
+ return { prompt, continuationId: null, mode: session ? "rotate" : "fresh" };
115
+ }
116
+
117
+ /**
118
+ * Continuation ids a live invocation is already driving, anywhere in the run.
119
+ *
120
+ * This is what makes concurrent nodes of one phase safe, and it is read from
121
+ * the persisted ledger rather than from an in-memory registry so a controller
122
+ * that took over a run inherits the claims instead of racing them.
123
+ *
124
+ * @param {ValidatedContract} contract
125
+ * @param {NodeSnapshot} currentState
126
+ * @param {string} runDir
127
+ * @returns {Set<string>}
128
+ */
129
+ function claimedContinuations(contract, currentState, runDir) {
130
+ /** @type {Set<string>} */
131
+ const claimed = new Set();
132
+ for (const candidate of contract.nodes) {
133
+ let state = candidate.id === currentState.id ? currentState : null;
134
+ if (!state) {
135
+ try { state = validateNodeSnapshot(readJson(join(runDir, "nodes", `${candidate.id}.json`)), candidate); } catch { continue; }
136
+ }
137
+ for (const invocation of state.invocations ?? []) {
138
+ if (invocation.status === "active" && invocation.continuationId) claimed.add(invocation.continuationId);
139
+ }
140
+ }
141
+ return claimed;
142
+ }
143
+
144
+ /**
145
+ * @param {ValidatedContract} contract
146
+ * @param {ValidatedNode} node
147
+ * @param {NodeSnapshot} currentState
148
+ * @param {string} runDir
149
+ * @param {"worker"|"judge"} role
150
+ * @returns {{nodeId: string, invocation: Invocation}[]}
151
+ */
152
+ function phaseSessionCandidates(contract, node, currentState, runDir, role) {
153
+ /** @type {{nodeId: string, invocation: Invocation}[]} */
154
+ const candidates = [];
155
+ const claimed = claimedContinuations(contract, currentState, runDir);
156
+ for (const candidate of contract.nodes) {
157
+ if (candidate.phase !== node.phase) continue;
158
+ let state = candidate.id === currentState.id ? currentState : null;
159
+ if (!state) {
160
+ try { state = validateNodeSnapshot(readJson(join(runDir, "nodes", `${candidate.id}.json`)), candidate); } catch { continue; }
161
+ }
162
+ for (const invocation of state.invocations ?? []) {
163
+ if (invocation.role !== role || invocation.planPhase !== node.phase || !invocation.continuationId) continue;
164
+ if (invocation.nodeId !== candidate.id || invocation.attempt !== state.attempt || invocation.workspace !== state.worktree?.path) continue;
165
+ // One provider session, one live turn. With `maxParallel` above one,
166
+ // two nodes of a phase can be dispatched in the same tick, and without
167
+ // this both would hand the same continuation id to their own provider
168
+ // process. The claim is read from the persisted ledger, which the
169
+ // in-tick dispatch already wrote for the node that went first.
170
+ if (claimed.has(invocation.continuationId)) continue;
171
+ candidates.push({ nodeId: candidate.id, invocation });
172
+ }
173
+ }
174
+ return candidates.sort((left, right) => {
175
+ const leftStarted = Date.parse(left.invocation.startedAt);
176
+ const rightStarted = Date.parse(right.invocation.startedAt);
177
+ if (leftStarted !== rightStarted) return leftStarted - rightStarted;
178
+ const leftUpdated = Date.parse(left.invocation.updatedAt);
179
+ const rightUpdated = Date.parse(right.invocation.updatedAt);
180
+ if (leftUpdated !== rightUpdated) return leftUpdated - rightUpdated;
181
+ return left.invocation.id.localeCompare(right.invocation.id);
182
+ });
183
+ }
184
+
185
+ /** @param {RuntimeSnapshot} runtime @returns {string} */
186
+ export function fingerprintRuntime(runtime) {
187
+ const executable = providerCommand(runtime, "").executable;
188
+ return createHash("sha256").update(stableJson({ runtime, executable })).digest("hex");
189
+ }
190
+
191
+ /** @param {ValidatedContract} contract @param {ValidatedNode} node @param {NodeSnapshot} state @param {string} runDir @param {"worker"|"judge"} role @returns {string} */
192
+ function phaseHandoffPrompt(contract, node, state, runDir, role) {
193
+ const summaries = phaseSessionCandidates(contract, node, state, runDir, role)
194
+ .map(({ nodeId }) => {
195
+ const candidate = contract.nodes.find((item) => item.id === nodeId);
196
+ let snapshot = null;
197
+ try { snapshot = readJson(join(runDir, "nodes", `${nodeId}.json`)); } catch {
198
+ // ENOENT or unreadable snapshot: this prior node contributes no summary.
199
+ }
200
+ const result = snapshot?.result;
201
+ const record = result && typeof result === "object" && !Array.isArray(result)
202
+ ? /** @type {Record<string, unknown>} */ (result)
203
+ : null;
204
+ const summary = typeof record?.summary === "string" ? record.summary : null;
205
+ return summary && candidate ? `${candidate.id}: ${boundedUtf8(summary, 1024)}` : null;
206
+ })
207
+ .filter(Boolean)
208
+ .slice(-8);
209
+ const handoff = [
210
+ `Continue phase ${node.phase} as the ${role} agent in a fresh provider session.`,
211
+ "Prior structured node summaries:",
212
+ summaries.length ? summaries.map((summary) => `- ${summary}`).join("\n") : "- (none)",
213
+ "Current closed task packet:",
214
+ boundedUtf8(node.prompt, 48 * 1024),
215
+ ].join("\n\n");
216
+ return boundedUtf8(handoff, 60 * 1024);
217
+ }