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,526 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { basename, join, resolve } from "node:path";
|
|
3
|
+
import { syncAgentSignal } from "../repo/signal.mjs";
|
|
4
|
+
import { JUDGE_SCHEMA, PARKED, SETTLED, retryPrompt } from "./prompts.mjs";
|
|
5
|
+
import {
|
|
6
|
+
applyJudgeProtocolFailure,
|
|
7
|
+
applyJudgeRound,
|
|
8
|
+
} from "./review.mjs";
|
|
9
|
+
import { CONTRACT_VERSION, PROTOCOL_SCHEMA_VERSION } from "../harnesses/index.mjs";
|
|
10
|
+
import { routingBackoffActive } from "./failover.mjs";
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
bootstrapAttemptPath,
|
|
14
|
+
bootstrapPath,
|
|
15
|
+
cleanupBootstrapAttempts,
|
|
16
|
+
readJson,
|
|
17
|
+
writeJsonAtomic,
|
|
18
|
+
} from "../run/store.mjs";
|
|
19
|
+
import {
|
|
20
|
+
acquire as acquireLock,
|
|
21
|
+
LockLostError,
|
|
22
|
+
processStartToken,
|
|
23
|
+
} from "../run/lock.mjs";
|
|
24
|
+
import { registerRun, resolveCampaign } from "../campaign/index.mjs";
|
|
25
|
+
import { createRunRef, runRefName } from "../repo/worktree.mjs";
|
|
26
|
+
import { bootstrapNonceForProcess, waitForBootstrapAcknowledgement } from "./detach.mjs";
|
|
27
|
+
import {
|
|
28
|
+
autoRetryNode,
|
|
29
|
+
autoRetryParkedNodes,
|
|
30
|
+
finalizeClosedJobs,
|
|
31
|
+
terminalErrorCode,
|
|
32
|
+
} from "./lifecycle.mjs";
|
|
33
|
+
import { delay, errorCode } from "../util.mjs";
|
|
34
|
+
import { alreadyNotified, notifyQueueFor, notifyQueuesByRun, renderCampaignHandoffSafely } from "./notify-queue.mjs";
|
|
35
|
+
import { detectStalls, terminateProcess } from "./process.mjs";
|
|
36
|
+
import { transition, writeNode } from "./state.mjs";
|
|
37
|
+
import { listNodeSnapshots, readNodeSnapshot } from "../run/node-store.mjs";
|
|
38
|
+
import { render, renderFinalReport, writeFindingsArtifact } from "../report/final.mjs";
|
|
39
|
+
import { operationNextState, providerReceipts, settleInvocation } from "../run/operations.mjs";
|
|
40
|
+
import { appendUsageRecord, invocationCost, invocationUsage, recordInvocationUsage } from "../run/usage.mjs";
|
|
41
|
+
import { captureNodeScopeBoundaries, checkWorkerScope, emptyScope } from "./scope.mjs";
|
|
42
|
+
import { validateContract } from "../contract/index.mjs";
|
|
43
|
+
import { validateNodeSnapshot } from "../contract/snapshot.mjs";
|
|
44
|
+
import { finalVerificationCommands } from "../contract/final-verification.mjs";
|
|
45
|
+
import { startJudge, startWorker } from "./dispatch.mjs";
|
|
46
|
+
import { assertEnvironmentReady, captureRunIdentity, createRunMetadata, serializableContract, statesFingerprint } from "./run-identity.mjs";
|
|
47
|
+
import { blockDependents, runtimeAssignments } from "./assignment.mjs";
|
|
48
|
+
import { createHeartbeat, HEARTBEAT_INTERVAL_MS } from "./supervise.mjs";
|
|
49
|
+
|
|
50
|
+
/** @typedef {import("../contract/index.mjs").WorkspaceScopeBoundary} WorkspaceScopeBoundary */
|
|
51
|
+
|
|
52
|
+
/** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
|
|
53
|
+
/** @typedef {import("../contract/index.mjs").ValidatedNode} ValidatedNode */
|
|
54
|
+
/** @typedef {import("../contract/index.mjs").NodeSnapshot} NodeSnapshot */
|
|
55
|
+
/** @typedef {import("../contract/index.mjs").RuntimeSnapshot} RuntimeSnapshot */
|
|
56
|
+
/** @typedef {import("../contract/index.mjs").RunMetadata} RunMetadata */
|
|
57
|
+
/** @typedef {import("../contract/index.mjs").SourceIdentity} SourceIdentity */
|
|
58
|
+
/** @typedef {import("../contract/index.mjs").EventRecord} EventRecord */
|
|
59
|
+
/** @typedef {import("../contract/index.mjs").Usage} Usage */
|
|
60
|
+
/** @typedef {import("../contract/index.mjs").GateResult} GateResult */
|
|
61
|
+
/** @typedef {import("../contract/index.mjs").SnapshotError} SnapshotError */
|
|
62
|
+
/** @typedef {import("../contract/index.mjs").BoundedScope} BoundedScope */
|
|
63
|
+
/** @typedef {import("../run/lock.mjs").LockRecord} LockRecord */
|
|
64
|
+
/** @typedef {ReturnType<typeof acquireLock>} LockHandle */
|
|
65
|
+
/** @typedef {import("../harnesses/index.mjs").HarnessRuntime} HarnessRuntime */
|
|
66
|
+
/** @typedef {import("../harnesses/index.mjs").ProviderEnvelope} ProviderEnvelope */
|
|
67
|
+
/** @typedef {import("../campaign/index.mjs").Campaign} Campaign */
|
|
68
|
+
/** @typedef {{path: string, campaign: Campaign}} CampaignRef */
|
|
69
|
+
/** @typedef {import("./prompts.mjs").JudgeVerdict} JudgeVerdict */
|
|
70
|
+
/** @typedef {import("./lifecycle.mjs").Job} Job */
|
|
71
|
+
/** @typedef {import("./lifecycle.mjs").Invocation} Invocation */
|
|
72
|
+
/** @typedef {import("./lifecycle.mjs").RecoveryOutcome} RecoveryOutcome */
|
|
73
|
+
/** @typedef {import("./lifecycle.mjs").InvocationProbe} InvocationProbe */
|
|
74
|
+
/** @typedef {import("../contract/worker-result.mjs").WorkerResult} WorkerResult */
|
|
75
|
+
/** @typedef {{runDir: string, states: Map<string, NodeSnapshot>, ok: boolean, error?: Error}} RunOutcome */
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The wall-clock milliseconds one verification-command set may legitimately
|
|
79
|
+
* occupy: each command's own `timeoutSec` repeated `repeat` times. The schema
|
|
80
|
+
* already normalizes `timeoutSec` to 120 and `repeat` to 1, but a caller may
|
|
81
|
+
* hand an un-normalized command, so both are defaulted here too.
|
|
82
|
+
*
|
|
83
|
+
* @param {import("../contract/index.mjs").VerificationCommand[]|undefined} commands
|
|
84
|
+
* @returns {number}
|
|
85
|
+
*/
|
|
86
|
+
export function verificationBudgetMs(commands) {
|
|
87
|
+
return (commands ?? []).reduce((total, command) => {
|
|
88
|
+
const timeoutSec = typeof command?.timeoutSec === "number" ? command.timeoutSec : 120;
|
|
89
|
+
const repeat = Number.isInteger(command?.repeat) && /** @type {number} */ (command.repeat) > 0 ? /** @type {number} */ (command.repeat) : 1;
|
|
90
|
+
return total + timeoutSec * repeat * 1_000;
|
|
91
|
+
}, 0);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The budget a node is judged against, in milliseconds. It is the sum of every
|
|
96
|
+
* bounded phase the node can legitimately occupy without a state transition:
|
|
97
|
+
* its worker invocation (`timeoutSec`), its packet verification and the
|
|
98
|
+
* contract's `finalVerification` when it is phase-terminal, an integration
|
|
99
|
+
* candidate run of that same set, and the bounded command proofs of its gate.
|
|
100
|
+
* A frozen node is one that has been silent longer than this, not merely
|
|
101
|
+
* longer than the worker timeout, because a legitimate verification can be
|
|
102
|
+
* minutes long and must not be mistaken for a freeze.
|
|
103
|
+
*
|
|
104
|
+
* @param {ValidatedContract} contract
|
|
105
|
+
* @param {ValidatedNode} node
|
|
106
|
+
* @returns {number}
|
|
107
|
+
*/
|
|
108
|
+
export function nodeBudgetBasisMs(contract, node) {
|
|
109
|
+
const defaultTimeoutMs = (node.timeoutSec ?? contract.timeoutSec ?? 60) * 1_000;
|
|
110
|
+
const packetMs = verificationBudgetMs(node.taskPacket?.verification);
|
|
111
|
+
const finalMs = verificationBudgetMs(finalVerificationCommands(contract, node));
|
|
112
|
+
// The controller runs the packet set once after the worker and once against
|
|
113
|
+
// the integration candidate, and the finalVerification set with each.
|
|
114
|
+
const candidateMs = packetMs + finalMs;
|
|
115
|
+
const gateTimeoutMs = Math.max(1_000, Math.min(defaultTimeoutMs, 120_000));
|
|
116
|
+
const commandProofs = (node.definitionOfDone ?? []).filter((item) => item.proof?.kind === "command").length;
|
|
117
|
+
return defaultTimeoutMs + packetMs + candidateMs + commandProofs * gateTimeoutMs + finalMs;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The loop invariant that keeps a controller from spinning: a node that says it
|
|
122
|
+
* is `running` while the controller holds no invocation for it can never be
|
|
123
|
+
* finalized by anything, and the loop would tick forever at the poll interval.
|
|
124
|
+
* Park it as `blocked`/`integration_unresolved` so the run reports attention.
|
|
125
|
+
*
|
|
126
|
+
* It is deliberately narrow — `running` and absent from `running` only — so it
|
|
127
|
+
* never touches phase 2's parked `blocked`/`failed`/`exhausted`/`stalled`
|
|
128
|
+
* states, and never overwrites the `runtime_tier_exhausted` waiting shape.
|
|
129
|
+
*
|
|
130
|
+
* @param {string} runDir
|
|
131
|
+
* @param {Map<string, NodeSnapshot>} states
|
|
132
|
+
* @param {Map<string, Job>} running
|
|
133
|
+
* @param {LockHandle|null} lock
|
|
134
|
+
* @returns {string[]} the node ids this pass parked
|
|
135
|
+
*/
|
|
136
|
+
export function enforceRunningInvariant(runDir, states, running, lock) {
|
|
137
|
+
const parked = [];
|
|
138
|
+
for (const [nodeId, state] of states) {
|
|
139
|
+
if (state.status !== "running" || running.has(nodeId)) continue;
|
|
140
|
+
transition(runDir, state, "blocked", {
|
|
141
|
+
phase: state.phase,
|
|
142
|
+
error: {
|
|
143
|
+
code: "integration_unresolved",
|
|
144
|
+
message: "node is running but the controller holds no invocation for it",
|
|
145
|
+
},
|
|
146
|
+
}, lock);
|
|
147
|
+
parked.push(nodeId);
|
|
148
|
+
}
|
|
149
|
+
return parked;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* @param {string} contractPath
|
|
154
|
+
* @param {{detachedBootstrap?: boolean}} [options] `detachedBootstrap` is set
|
|
155
|
+
* only by the CLI entry when this process is its own detached child, and
|
|
156
|
+
* makes the controller wait for the launcher's acknowledgement
|
|
157
|
+
* @returns {Promise<RunOutcome>}
|
|
158
|
+
*/
|
|
159
|
+
export async function runContract(contractPath, options = {}) {
|
|
160
|
+
const absoluteContractPath = resolve(contractPath);
|
|
161
|
+
const contract = validateContract(JSON.parse(readFileSync(absoluteContractPath, "utf8")), absoluteContractPath);
|
|
162
|
+
const runDir = join(contract.cwd, ".runs", contract.id);
|
|
163
|
+
if (existsSync(runDir)) throw new Error(`run already exists: ${runDir}`);
|
|
164
|
+
mkdirSync(join(contract.cwd, ".runs"), { recursive: true });
|
|
165
|
+
try {
|
|
166
|
+
mkdirSync(runDir);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (errorCode(error) === "EEXIST") throw new Error(`run already exists: ${runDir}`);
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
const lock = acquireLock(runDir);
|
|
172
|
+
try {
|
|
173
|
+
const runtimePlan = await runtimeAssignments(contract);
|
|
174
|
+
const scopeBoundaries = captureNodeScopeBoundaries(contract);
|
|
175
|
+
const sourceIdentity = await captureRunIdentity(contract, scopeBoundaries);
|
|
176
|
+
const integrationRef = createRunRef(contract.cwd, contract.id, sourceIdentity.gitHead);
|
|
177
|
+
lock.assert();
|
|
178
|
+
const runsDir = join(contract.cwd, ".runs");
|
|
179
|
+
const campaign = resolveCampaign(runsDir, contract.campaignId);
|
|
180
|
+
mkdirSync(join(runDir, "nodes"), { recursive: true });
|
|
181
|
+
mkdirSync(join(runDir, "logs"), { recursive: true });
|
|
182
|
+
writeJsonAtomic(join(runDir, "contract.json"), serializableContract(contract));
|
|
183
|
+
writeJsonAtomic(join(runDir, "judge.schema.json"), JUDGE_SCHEMA);
|
|
184
|
+
writeJsonAtomic(join(runDir, "run.json"), createRunMetadata(lock, sourceIdentity, {}, integrationRef));
|
|
185
|
+
registerRun(campaign.path, contract.id);
|
|
186
|
+
renderCampaignHandoffSafely(campaign, runsDir, runDir);
|
|
187
|
+
|
|
188
|
+
const states = new Map();
|
|
189
|
+
for (const node of contract.nodes) {
|
|
190
|
+
/** @type {NodeSnapshot} */
|
|
191
|
+
const state = {
|
|
192
|
+
schemaVersion: PROTOCOL_SCHEMA_VERSION,
|
|
193
|
+
contractVersion: CONTRACT_VERSION,
|
|
194
|
+
id: node.id,
|
|
195
|
+
type: node.type,
|
|
196
|
+
sourceIdentity: node.sourceIdentity,
|
|
197
|
+
packetHash: node.packetHash,
|
|
198
|
+
status: "pending",
|
|
199
|
+
phase: "waiting",
|
|
200
|
+
attempt: 0,
|
|
201
|
+
revisions: 0,
|
|
202
|
+
runtime: null,
|
|
203
|
+
blockedBy: [],
|
|
204
|
+
startedAt: null,
|
|
205
|
+
updatedAt: new Date().toISOString(),
|
|
206
|
+
result: null,
|
|
207
|
+
verification: null,
|
|
208
|
+
scope: emptyScope(/** @type {import("../repo/workspace.mjs").WorkspaceScopeBoundary} */ (scopeBoundaries.get(node.id))),
|
|
209
|
+
gate: null,
|
|
210
|
+
error: null,
|
|
211
|
+
judgeFailures: 0,
|
|
212
|
+
routing: {
|
|
213
|
+
history: [],
|
|
214
|
+
currentOverride: null,
|
|
215
|
+
assignments: runtimePlan.assignments[node.id],
|
|
216
|
+
availability: runtimePlan.availability,
|
|
217
|
+
},
|
|
218
|
+
progress: null,
|
|
219
|
+
invocations: [],
|
|
220
|
+
executionOverrides: [],
|
|
221
|
+
worktree: { status: "unassigned", path: null, branch: null, commit: null, baseSha: null },
|
|
222
|
+
integratedHead: null,
|
|
223
|
+
};
|
|
224
|
+
states.set(node.id, state);
|
|
225
|
+
writeNode(runDir, state, lock);
|
|
226
|
+
}
|
|
227
|
+
syncAgentSignal(runsDir);
|
|
228
|
+
const outcome = await driveRun(contract, runDir, states, campaign, lock, sourceIdentity, {}, options);
|
|
229
|
+
syncAgentSignal(runsDir);
|
|
230
|
+
return outcome;
|
|
231
|
+
} catch (error) {
|
|
232
|
+
lock.release();
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* @param {ValidatedContract} contract
|
|
239
|
+
* @param {string} runDir
|
|
240
|
+
* @param {Map<string, NodeSnapshot>} states
|
|
241
|
+
* @param {CampaignRef} campaign
|
|
242
|
+
* @param {LockHandle} lock
|
|
243
|
+
* @param {SourceIdentity} sourceIdentity
|
|
244
|
+
* @param {{identityWarnings?: string[], relaunchCount?: number, lastRelaunchProgressAt?: string|null, attention?: {code: string, message: string, at: string}|null}} [resume] resume-only records persisted on the run metadata
|
|
245
|
+
* @param {{detachedBootstrap?: boolean}} [options] set by the CLI entry alone
|
|
246
|
+
* @returns {Promise<RunOutcome>}
|
|
247
|
+
*/
|
|
248
|
+
export async function driveRun(contract, runDir, states, campaign, lock, sourceIdentity, resume = {}, options = {}) {
|
|
249
|
+
lock.assert();
|
|
250
|
+
assertEnvironmentReady(contract, runDir, sourceIdentity);
|
|
251
|
+
const runsDir = join(contract.cwd, ".runs");
|
|
252
|
+
const bootstrapNonce = bootstrapNonceForProcess();
|
|
253
|
+
// Only the CLI entry can answer this: a nonce inherited by evals/run.mjs or
|
|
254
|
+
// by a test must not make the controller wait for an acknowledgement nobody
|
|
255
|
+
// is going to write.
|
|
256
|
+
const detachedBootstrap = options.detachedBootstrap === true;
|
|
257
|
+
// A controller restart must not erase the supervisor's durable relaunch
|
|
258
|
+
// guard. `resume.mjs` does not thread those fields, so the controller reads
|
|
259
|
+
// the run it is rewriting and carries them itself.
|
|
260
|
+
const persistedMetadata = existsSync(join(runDir, "run.json")) ? readJson(join(runDir, "run.json")) : {};
|
|
261
|
+
const runMetadata = createRunMetadata(lock, sourceIdentity, {
|
|
262
|
+
...resume,
|
|
263
|
+
...(persistedMetadata.relaunchCount !== undefined ? { relaunchCount: /** @type {number} */ (persistedMetadata.relaunchCount) } : {}),
|
|
264
|
+
...(persistedMetadata.lastRelaunchProgressAt !== undefined ? { lastRelaunchProgressAt: /** @type {string|null} */ (persistedMetadata.lastRelaunchProgressAt) } : {}),
|
|
265
|
+
// A resume that actually changed node state may clear attention by passing
|
|
266
|
+
// `attention: null`; only then does the persisted record lose.
|
|
267
|
+
...(persistedMetadata.attention !== undefined && resume.attention === undefined ? { attention: /** @type {{code: string, message: string, at: string}|null} */ (persistedMetadata.attention) } : {}),
|
|
268
|
+
}, runRefName(contract.id));
|
|
269
|
+
writeJsonAtomic(join(runDir, "run.json"), runMetadata);
|
|
270
|
+
writeJsonAtomic(bootstrapPath(runDir), {
|
|
271
|
+
status: "ready",
|
|
272
|
+
nonce: bootstrapNonce,
|
|
273
|
+
pid: process.pid,
|
|
274
|
+
processStartToken: processStartToken(process.pid),
|
|
275
|
+
runDir,
|
|
276
|
+
metadataPath: join(runDir, "run.json"),
|
|
277
|
+
at: new Date().toISOString(),
|
|
278
|
+
});
|
|
279
|
+
writeJsonAtomic(bootstrapAttemptPath(runDir, bootstrapNonce), readJson(bootstrapPath(runDir)));
|
|
280
|
+
cleanupBootstrapAttempts(runDir, bootstrapNonce);
|
|
281
|
+
if (detachedBootstrap) await waitForBootstrapAcknowledgement(runDir, {
|
|
282
|
+
nonce: bootstrapNonce,
|
|
283
|
+
pid: process.pid,
|
|
284
|
+
processStartToken: processStartToken(process.pid),
|
|
285
|
+
});
|
|
286
|
+
lock.assert();
|
|
287
|
+
renderCampaignHandoffSafely(campaign, runsDir, runDir);
|
|
288
|
+
|
|
289
|
+
/** @type {string|null} */
|
|
290
|
+
let statusFingerprint = null;
|
|
291
|
+
/** @param {boolean} force @param {LockHandle|null} [renderLock] */
|
|
292
|
+
const renderStatusIfChanged = (force = false, renderLock = lock) => {
|
|
293
|
+
const fingerprint = statesFingerprint(states);
|
|
294
|
+
if (!force && fingerprint === statusFingerprint) return;
|
|
295
|
+
statusFingerprint = fingerprint;
|
|
296
|
+
render(runDir, runsDir, contract, states, renderLock);
|
|
297
|
+
};
|
|
298
|
+
let handoffFingerprint = statesFingerprint(states);
|
|
299
|
+
const renderHandoffIfChanged = () => {
|
|
300
|
+
const fingerprint = statesFingerprint(states);
|
|
301
|
+
if (fingerprint === handoffFingerprint) return;
|
|
302
|
+
handoffFingerprint = fingerprint;
|
|
303
|
+
renderCampaignHandoffSafely(campaign, runsDir, runDir);
|
|
304
|
+
};
|
|
305
|
+
// Progress never notifies (TECH-SPEC lean, rule 6): only a node reaching a
|
|
306
|
+
// terminal state wakes the notify queue. status.json (written every render)
|
|
307
|
+
// is the progress surface now.
|
|
308
|
+
const notifyQueue = notifyQueueFor(runDir);
|
|
309
|
+
/** @type {string|null} */
|
|
310
|
+
let notificationFingerprint = null;
|
|
311
|
+
const notifyStateChanges = async () => {
|
|
312
|
+
const fingerprint = statesFingerprint(states);
|
|
313
|
+
if (fingerprint === notificationFingerprint) return;
|
|
314
|
+
notificationFingerprint = fingerprint;
|
|
315
|
+
for (const state of states.values()) {
|
|
316
|
+
if (!SETTLED.has(state.status)) continue;
|
|
317
|
+
const runId = basename(runDir);
|
|
318
|
+
const dedupeKey = `node.terminal:${runId}:${state.id}:${state.status}:${state.attempt ?? 0}:${state.revisions ?? 0}`;
|
|
319
|
+
if (alreadyNotified(runDir, dedupeKey)) continue;
|
|
320
|
+
await notifyQueue.enqueue({
|
|
321
|
+
type: "node.terminal",
|
|
322
|
+
campaignId: campaign.campaign.id,
|
|
323
|
+
runId,
|
|
324
|
+
nodeId: state.id,
|
|
325
|
+
status: state.status,
|
|
326
|
+
attempt: state.attempt ?? 0,
|
|
327
|
+
errorCode: terminalErrorCode(state),
|
|
328
|
+
dedupeKey,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
/** @type {Map<string, Job>} */
|
|
334
|
+
const running = new Map();
|
|
335
|
+
let canceled = false;
|
|
336
|
+
const cancel = () => { canceled = true; };
|
|
337
|
+
process.once("SIGINT", cancel);
|
|
338
|
+
process.once("SIGTERM", cancel);
|
|
339
|
+
process.once("SIGHUP", cancel);
|
|
340
|
+
// The heartbeat's `at` is owned by an unref'd timer inside this writer, never
|
|
341
|
+
// by the loop body below: the loop awaits controller verification on its own
|
|
342
|
+
// critical path and must keep answering "the process is alive" while it does.
|
|
343
|
+
const heartbeat = createHeartbeat({ runDir, intervalMs: HEARTBEAT_INTERVAL_MS });
|
|
344
|
+
const heartbeatNodes = new Map(contract.nodes.map((node) => [node.id, node]));
|
|
345
|
+
let heartbeatFingerprint = statesFingerprint(states);
|
|
346
|
+
/** @type {Map<string, number>} */
|
|
347
|
+
const heartbeatOutputAt = new Map();
|
|
348
|
+
const activeHeartbeatNodes = () => [...states.values()]
|
|
349
|
+
.filter((state) => state.status === "running" && heartbeatNodes.has(state.id))
|
|
350
|
+
.map((state) => ({ nodeId: state.id, budgetBasis: nodeBudgetBasisMs(contract, /** @type {ValidatedNode} */ (heartbeatNodes.get(state.id))) }));
|
|
351
|
+
try {
|
|
352
|
+
while ([...states.values()].some((state) => !SETTLED.has(state.status))) {
|
|
353
|
+
lock.assert();
|
|
354
|
+
if (existsSync(join(runDir, "cancel.request.json"))) canceled = true;
|
|
355
|
+
if (canceled) {
|
|
356
|
+
const jobs = [...running.values()];
|
|
357
|
+
await Promise.all(jobs.map((job) => terminateProcess(job)));
|
|
358
|
+
const envelopes = new Map();
|
|
359
|
+
for (const job of jobs) envelopes.set(job.invocation.id, recordInvocationUsage(job, { accumulate: false }));
|
|
360
|
+
for (const job of jobs) {
|
|
361
|
+
const invocation = job.state.invocations?.find((item) => item.id === job.invocation.id) ?? job.invocation;
|
|
362
|
+
const scopeOk = job.phase !== "worker" || checkWorkerScope(contract, runDir, job, lock);
|
|
363
|
+
settleInvocation(runDir, invocation, {
|
|
364
|
+
status: scopeOk ? "canceled" : "failed",
|
|
365
|
+
usage: invocation.usage ?? null,
|
|
366
|
+
costUsd: typeof invocation.costUsd === "number" ? invocation.costUsd : null,
|
|
367
|
+
receipts: providerReceipts(envelopes.get(job.invocation.id)),
|
|
368
|
+
error: scopeOk ? null : job.state.error ?? { code: "scope_check_failed", message: "worker scope check failed" },
|
|
369
|
+
nextState: operationNextState(job.state),
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
running.clear();
|
|
373
|
+
for (const state of states.values()) {
|
|
374
|
+
if (!SETTLED.has(state.status)) transition(runDir, state, "canceled", { phase: "canceled" }, lock);
|
|
375
|
+
}
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const parkedBefore = new Set([...states.values()].filter((state) => PARKED.has(state.status)).map((state) => state.id));
|
|
380
|
+
await finalizeClosedJobs(contract, runDir, states, running, lock, campaign.path);
|
|
381
|
+
await detectStalls(contract, running, async (job, status, error) => {
|
|
382
|
+
const envelope = recordInvocationUsage(job);
|
|
383
|
+
job.state.usage = invocationUsage(job.state);
|
|
384
|
+
job.state.costUsd = invocationCost(job.state);
|
|
385
|
+
const invocation = job.state.invocations?.find((item) => item.id === job.invocation.id) ?? job.invocation;
|
|
386
|
+
appendUsageRecord(runDir, invocation);
|
|
387
|
+
if (job.phase === "worker" && !checkWorkerScope(contract, runDir, job, lock)) {
|
|
388
|
+
settleInvocation(runDir, invocation, {
|
|
389
|
+
status: "failed",
|
|
390
|
+
usage: invocation.usage ?? null,
|
|
391
|
+
costUsd: typeof invocation.costUsd === "number" ? invocation.costUsd : null,
|
|
392
|
+
receipts: providerReceipts(envelope),
|
|
393
|
+
error: job.state.error ?? { code: "scope_check_failed", message: "worker scope check failed" },
|
|
394
|
+
nextState: operationNextState(job.state),
|
|
395
|
+
});
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
settleInvocation(runDir, invocation, {
|
|
399
|
+
status,
|
|
400
|
+
usage: invocation.usage ?? null,
|
|
401
|
+
costUsd: typeof invocation.costUsd === "number" ? invocation.costUsd : null,
|
|
402
|
+
receipts: providerReceipts(envelope),
|
|
403
|
+
error,
|
|
404
|
+
nextState: operationNextState(job.state),
|
|
405
|
+
});
|
|
406
|
+
// A judge killed on its own wall clock produced no verdict. That is a
|
|
407
|
+
// judge protocol defect, not a node outcome: it earns the one bounded
|
|
408
|
+
// re-ask, and only then the review mode settles the node.
|
|
409
|
+
if (job.phase === "judge" && error.code === "wall_clock_timeout") {
|
|
410
|
+
await applyJudgeProtocolFailure(contract, job.node, job.state, runDir, running, lock, states, campaign.path, error.message);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
// A timeout whose attempt seal is non-empty earns the one automatic
|
|
414
|
+
// retry (phase 5b supplies the seal); with no seal it parks here.
|
|
415
|
+
if (autoRetryNode(runDir, job.state, error.code, lock)) return;
|
|
416
|
+
transition(runDir, job.state, status, { phase: job.phase, error }, lock);
|
|
417
|
+
}, async (job) => {
|
|
418
|
+
writeNode(runDir, job.state, lock);
|
|
419
|
+
});
|
|
420
|
+
// A node left `running` with no job is a dead end; park it before the
|
|
421
|
+
// dispatch pass so it cannot hide behind a healthy sibling.
|
|
422
|
+
enforceRunningInvariant(runDir, states, running, lock);
|
|
423
|
+
// Before dependants are blocked, a node that parked on this tick gets
|
|
424
|
+
// its one automatic retry: it becomes pending, so `blockDependents` sees
|
|
425
|
+
// nothing to block and the dependants stay `pending`/`phase: "waiting"`
|
|
426
|
+
// until it parks for good.
|
|
427
|
+
autoRetryParkedNodes(contract, runDir, states, lock, parkedBefore);
|
|
428
|
+
blockDependents(contract, runDir, states, lock);
|
|
429
|
+
|
|
430
|
+
const slots = contract.maxParallel - running.size;
|
|
431
|
+
if (slots > 0) {
|
|
432
|
+
const ready = contract.nodes.filter((node) => {
|
|
433
|
+
const state = states.get(node.id);
|
|
434
|
+
return state?.status === "pending" && node.dependsOn.every((id) => states.get(id)?.status === "done");
|
|
435
|
+
});
|
|
436
|
+
for (const node of ready.slice(0, slots)) {
|
|
437
|
+
const state = states.get(node.id);
|
|
438
|
+
if (!state || routingBackoffActive(state, state.phase)) continue;
|
|
439
|
+
if (state.phase === "judge" && state.result) {
|
|
440
|
+
await applyJudgeRound(await startJudge(contract, node, state, runDir, running, state.result, lock, states, campaign.path),
|
|
441
|
+
contract, node, state, runDir, running, lock, states, campaign.path, state.result);
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
state.attempt += 1;
|
|
445
|
+
const prompt = state.gate?.verdict === "fail" ? retryPrompt(node, state.gate) : node.prompt;
|
|
446
|
+
startWorker(contract, node, state, runDir, running, prompt, lock, states, campaign.path);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
renderHandoffIfChanged();
|
|
451
|
+
renderStatusIfChanged();
|
|
452
|
+
await notifyStateChanges();
|
|
453
|
+
// A node state transition is progress; provider output is progress; a
|
|
454
|
+
// node merely still existing is neither, which is what keeps a frozen
|
|
455
|
+
// sibling from hiding behind a healthy one.
|
|
456
|
+
const heartbeatFingerprintNow = statesFingerprint(states);
|
|
457
|
+
if (heartbeatFingerprintNow !== heartbeatFingerprint) {
|
|
458
|
+
heartbeatFingerprint = heartbeatFingerprintNow;
|
|
459
|
+
heartbeat.progress();
|
|
460
|
+
}
|
|
461
|
+
for (const [nodeId, job] of running) {
|
|
462
|
+
const observed = typeof job.lastOutputAt === "number" ? job.lastOutputAt : 0;
|
|
463
|
+
if (observed > (heartbeatOutputAt.get(nodeId) ?? 0)) {
|
|
464
|
+
heartbeatOutputAt.set(nodeId, observed);
|
|
465
|
+
const node = heartbeatNodes.get(nodeId);
|
|
466
|
+
if (node) heartbeat.progress(nodeId, nodeBudgetBasisMs(contract, node));
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
heartbeat.setActive(activeHeartbeatNodes());
|
|
470
|
+
if ([...states.values()].some((state) => !SETTLED.has(state.status))) await delay(contract.pollIntervalMs);
|
|
471
|
+
}
|
|
472
|
+
} catch (error) {
|
|
473
|
+
if (!(error instanceof LockLostError)) throw error;
|
|
474
|
+
await Promise.all([...running.values()].map((job) => terminateProcess(job)));
|
|
475
|
+
notifyQueuesByRun.delete(runDir);
|
|
476
|
+
return { runDir, states, ok: false, error };
|
|
477
|
+
} finally {
|
|
478
|
+
process.removeListener("SIGINT", cancel);
|
|
479
|
+
process.removeListener("SIGTERM", cancel);
|
|
480
|
+
process.removeListener("SIGHUP", cancel);
|
|
481
|
+
heartbeat.stop();
|
|
482
|
+
lock.release();
|
|
483
|
+
}
|
|
484
|
+
renderStatusIfChanged(false, null);
|
|
485
|
+
renderCampaignHandoffSafely(campaign, runsDir, runDir);
|
|
486
|
+
writeFindingsArtifact(runDir, contract, states);
|
|
487
|
+
const failed = [...states.values()].filter((state) => state.status !== "done");
|
|
488
|
+
const runId = basename(runDir);
|
|
489
|
+
const runDedupeKey = `run.terminal:${runId}:${failed.length ? "attention" : "done"}`;
|
|
490
|
+
if (!alreadyNotified(runDir, runDedupeKey)) {
|
|
491
|
+
await notifyQueue.enqueue({
|
|
492
|
+
type: "run.terminal",
|
|
493
|
+
campaignId: campaign.campaign.id,
|
|
494
|
+
runId,
|
|
495
|
+
done: states.size - failed.length,
|
|
496
|
+
total: states.size,
|
|
497
|
+
dedupeKey: runDedupeKey,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
// Delivery is lossy: there is no retry budget to wait out, so the controller
|
|
501
|
+
// returns as soon as the terminal notification has been attempted once.
|
|
502
|
+
notifyQueuesByRun.delete(runDir);
|
|
503
|
+
process.stdout.write(`[run] ${contract.id} ${failed.length ? `failed · ${runDir} · findings.json` : `done · ${runDir}`}\n`);
|
|
504
|
+
if ([...states.values()].some((state) => state.usage)) {
|
|
505
|
+
const report = renderFinalReport(runDir, contract, states);
|
|
506
|
+
process.stdout.write(report);
|
|
507
|
+
}
|
|
508
|
+
return { runDir, states, ok: failed.length === 0 };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* @param {string} runDir
|
|
513
|
+
* @param {ValidatedContract} contract
|
|
514
|
+
* @returns {NodeSnapshot[]}
|
|
515
|
+
*/
|
|
516
|
+
export function readRunNodes(runDir, contract) {
|
|
517
|
+
const names = listNodeSnapshots(runDir);
|
|
518
|
+
const expected = new Map(contract.nodes.map((node) => [`${node.id}.json`, node]));
|
|
519
|
+
for (const name of names) if (!expected.has(name)) throw new TypeError(`unexpected persisted node snapshot ${name}`);
|
|
520
|
+
return contract.nodes.map((node) => {
|
|
521
|
+
const name = `${node.id}.json`;
|
|
522
|
+
if (!names.includes(name)) throw new TypeError(`missing persisted node snapshot ${name}`);
|
|
523
|
+
return validateNodeSnapshot(readNodeSnapshot(runDir, node.id), node);
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|