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.
- package/package.json +1 -1
- package/skills/faberun/references/contract.md +10 -10
- package/src/campaign/index.mjs +74 -2
- package/src/campaign/record.mjs +28 -0
- package/src/contract/index.mjs +57 -10
- package/src/contract/runtime.mjs +4 -1
- package/src/contract/snapshot.mjs +46 -3
- package/src/contract/task-packet.mjs +5 -1
- package/src/engine/backoff.mjs +2 -1
- package/src/engine/capacity.mjs +74 -0
- package/src/engine/dispatch.mjs +12 -186
- package/src/engine/lifecycle.mjs +7 -2
- package/src/engine/phase-session.mjs +217 -0
- package/src/engine/process.mjs +26 -108
- package/src/engine/scheduler.mjs +15 -3
- package/src/engine/settle.mjs +14 -2
- package/src/engine/state.mjs +1 -0
- package/src/engine/transcript.mjs +145 -0
- package/src/harnesses/claude/index.mjs +5 -0
- package/src/harnesses/dsh/runner.mjs +38 -3
- package/src/harnesses/exec-jsonl/index.mjs +0 -499
- package/src/harnesses/index.mjs +55 -8
- package/src/harnesses/protocol.mjs +22 -2
- package/src/harnesses/session-metrics.mjs +674 -0
- package/src/host/tool-policy-decisions.mjs +78 -21
- package/src/host/tool-policy-hook.mjs +12 -3
- package/src/plan/freeze.mjs +53 -6
- package/src/plan/pipeline.mjs +139 -48
- package/src/plan/sizing.mjs +0 -0
- package/src/plan/template.mjs +102 -8
- package/src/run/operations.mjs +1 -1
- package/src/run/usage.mjs +9 -3
- package/src/util.mjs +0 -0
package/src/engine/dispatch.mjs
CHANGED
|
@@ -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 {
|
|
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,
|
|
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
|
|
37
|
-
import {
|
|
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
|
/**
|
package/src/engine/lifecycle.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
146
|
-
|
|
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
|
+
}
|