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,185 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { realpathSync, statSync } from "node:fs";
|
|
3
|
+
import { isAbsolute, resolve } from "node:path";
|
|
4
|
+
import { fail, isContained, tailText } from "../util.mjs";
|
|
5
|
+
|
|
6
|
+
export const VERIFICATION_LIMITS = Object.freeze({
|
|
7
|
+
stdoutBytes: 16 * 1024,
|
|
8
|
+
stderrBytes: 16 * 1024,
|
|
9
|
+
maxCommands: 32,
|
|
10
|
+
maxRepeat: 8,
|
|
11
|
+
maxTimeoutSec: 600,
|
|
12
|
+
stateStdoutBytes: 2 * 1024,
|
|
13
|
+
stateCommands: 16,
|
|
14
|
+
stateAttempts: 4,
|
|
15
|
+
stateAttemptRecords: 16,
|
|
16
|
+
stateArgvBytes: 8 * 1024,
|
|
17
|
+
stateEnvBytes: 4 * 1024,
|
|
18
|
+
maxArgvBytes: 32 * 1024,
|
|
19
|
+
maxEnvBytes: 8 * 1024,
|
|
20
|
+
snapshotEntries: 4096,
|
|
21
|
+
snapshotPathBytes: 1024,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/** @typedef {"active"|"closed"|"failed"|"crashed"|"canceled"} VerificationAttemptStatus */
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* One declared deterministic check: an argv command run by the controller.
|
|
28
|
+
*
|
|
29
|
+
* @typedef {{argv: string[], cwd?: string, timeoutSec?: number, repeat?: number, env?: string[], mutation?: {threshold: number}}} VerificationCommand
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A single attempt of a verification command.
|
|
34
|
+
*
|
|
35
|
+
* @typedef {{attempt: number, invocationId: string, commandIndex: number, pid: number|null, processStartToken: string|null, processGroupId: number|null, startedAt: string, deadlineAt: string, status: VerificationAttemptStatus, completedAt?: string|null, result?: VerificationAttemptResult|null}} VerificationAttempt
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Bounded evidence captured for one attempt.
|
|
40
|
+
*
|
|
41
|
+
* @typedef {{passed: boolean, stdout: string, stderr: string, error: string|null, exitCode: number|null, signal: string|null, timedOut: boolean, durationMs: number|null}} VerificationAttemptResult
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A command with its repeated attempts.
|
|
46
|
+
*
|
|
47
|
+
* @typedef {VerificationCommand & {passed: boolean, attempts: VerificationAttemptResult[]}} VerificationCommandResult
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Aggregated verification result.
|
|
52
|
+
*
|
|
53
|
+
* @typedef {{passed: boolean, commands: VerificationCommandResult[]}} VerificationResult
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Callbacks and options for {@link runVerification}.
|
|
58
|
+
*
|
|
59
|
+
* @typedef {{signal?: AbortSignal, logDir?: string, writeFiles?: string[], onAttemptStart?: (attempt: VerificationAttempt) => void, onAttemptSpawn?: (attempt: VerificationAttempt) => void, onAttemptComplete?: (attempt: VerificationAttempt) => void}} VerificationOptions
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {unknown} cwd
|
|
64
|
+
* @param {string} label
|
|
65
|
+
*/
|
|
66
|
+
function validateRelativeCwd(cwd, label) {
|
|
67
|
+
if (typeof cwd !== "string" || isAbsolute(cwd) || /^[A-Za-z]:[\\/]/u.test(cwd) || /(?:^|[\\/])\.\.(?:[\\/]|$)/u.test(cwd)) {
|
|
68
|
+
throw fail("verification_cwd_invalid", `${label}.cwd must be a relative path without ..`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {string} baseCwd
|
|
74
|
+
* @param {string} commandCwd
|
|
75
|
+
* @returns {string}
|
|
76
|
+
*/
|
|
77
|
+
export function resolveVerificationCwd(baseCwd, commandCwd = ".") {
|
|
78
|
+
validateRelativeCwd(commandCwd, "verification command");
|
|
79
|
+
const baseReal = realpathSync(baseCwd);
|
|
80
|
+
const candidate = resolve(baseReal, commandCwd);
|
|
81
|
+
const targetReal = realpathSync(candidate);
|
|
82
|
+
if (!isContained(baseReal, targetReal)) {
|
|
83
|
+
throw fail("verification_cwd_escape", `verification cwd escapes workspace: ${commandCwd}`);
|
|
84
|
+
}
|
|
85
|
+
if (!statSync(targetReal).isDirectory()) throw fail("verification_cwd_invalid", `verification cwd is not a directory: ${commandCwd}`);
|
|
86
|
+
return targetReal;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @param {unknown} commands
|
|
91
|
+
* @param {string} label
|
|
92
|
+
* @returns {VerificationCommand[]}
|
|
93
|
+
*/
|
|
94
|
+
export function validateVerificationCommands(commands, label = "verification") {
|
|
95
|
+
if (!Array.isArray(commands) || commands.length > VERIFICATION_LIMITS.maxCommands) {
|
|
96
|
+
throw new TypeError(`${label} must be an array of at most ${VERIFICATION_LIMITS.maxCommands} command objects`);
|
|
97
|
+
}
|
|
98
|
+
return commands.map((command, index) => validateVerificationCommand(command, `${label}[${index}]`));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* @param {unknown} command
|
|
103
|
+
* @param {string} label
|
|
104
|
+
* @returns {VerificationCommand}
|
|
105
|
+
*/
|
|
106
|
+
function validateVerificationCommand(command, label = "verification command") {
|
|
107
|
+
if (!command || typeof command !== "object" || Array.isArray(command)) throw new TypeError(`${label} must be an argv command object`);
|
|
108
|
+
const record = /** @type {Record<string, unknown>} */ (command);
|
|
109
|
+
const allowed = new Set(["argv", "cwd", "timeoutSec", "repeat", "env", "mutation"]);
|
|
110
|
+
for (const key of Object.keys(record)) if (!allowed.has(key)) throw new TypeError(`${label} has unexpected field ${key}`);
|
|
111
|
+
if (!Array.isArray(record.argv) || record.argv.length === 0 || record.argv.length > 64 || record.argv.some((item) => typeof item !== "string" || !item.trim() || Buffer.byteLength(item, "utf8") > 8 * 1024)) {
|
|
112
|
+
throw new TypeError(`${label}.argv must be a non-empty array of strings`);
|
|
113
|
+
}
|
|
114
|
+
const argvBytes = record.argv.reduce((sum, item) => sum + Buffer.byteLength(/** @type {string} */ (item), "utf8"), 0);
|
|
115
|
+
if (argvBytes > VERIFICATION_LIMITS.maxArgvBytes) throw new TypeError(`${label}.argv exceeds aggregate byte limit`);
|
|
116
|
+
if (record.cwd !== undefined) validateRelativeCwd(record.cwd, label);
|
|
117
|
+
const timeoutSec = record.timeoutSec === undefined ? 120 : record.timeoutSec;
|
|
118
|
+
if (typeof timeoutSec !== "number" || !Number.isFinite(timeoutSec) || timeoutSec <= 0 || timeoutSec > VERIFICATION_LIMITS.maxTimeoutSec) throw new TypeError(`${label}.timeoutSec must be between 0 and ${VERIFICATION_LIMITS.maxTimeoutSec}`);
|
|
119
|
+
// Default single attempt: the worker already ran these commands inside its
|
|
120
|
+
// session and the controller run is the independent confirmation; repeating
|
|
121
|
+
// by default doubled suite cost for no extra signal.
|
|
122
|
+
const repeat = record.repeat === undefined ? 1 : record.repeat;
|
|
123
|
+
if (typeof repeat !== "number" || !Number.isInteger(repeat) || repeat <= 0 || repeat > VERIFICATION_LIMITS.maxRepeat) throw new TypeError(`${label}.repeat must be between 1 and ${VERIFICATION_LIMITS.maxRepeat}`);
|
|
124
|
+
const env = record.env ?? [];
|
|
125
|
+
if (!Array.isArray(env) || env.some((name) => typeof name !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name))) throw new TypeError(`${label}.env must be an array of environment-variable names`);
|
|
126
|
+
const envBytes = env.reduce((sum, name) => sum + Buffer.byteLength(/** @type {string} */ (name), "utf8"), 0);
|
|
127
|
+
if (envBytes > VERIFICATION_LIMITS.maxEnvBytes) throw new TypeError(`${label}.env exceeds aggregate byte limit`);
|
|
128
|
+
// Mutation testing is opt-in per entry: it re-runs the same argv against
|
|
129
|
+
// deliberately broken copies of the node's written files. `threshold` is the
|
|
130
|
+
// fraction of mutants the suite must kill, so 0 accepts any suite and 1
|
|
131
|
+
// demands every sampled mutant fail it.
|
|
132
|
+
/** @type {{threshold: number}|undefined} */
|
|
133
|
+
let mutation;
|
|
134
|
+
if (record.mutation !== undefined) {
|
|
135
|
+
const rawMutation = record.mutation;
|
|
136
|
+
if (!rawMutation || typeof rawMutation !== "object" || Array.isArray(rawMutation)) throw new TypeError(`${label}.mutation must be an object with a threshold between 0 and 1`);
|
|
137
|
+
const mutationRecord = /** @type {Record<string, unknown>} */ (rawMutation);
|
|
138
|
+
for (const key of Object.keys(mutationRecord)) if (key !== "threshold") throw new TypeError(`${label}.mutation has unexpected field ${key}`);
|
|
139
|
+
if (typeof mutationRecord.threshold !== "number" || !Number.isFinite(mutationRecord.threshold) || mutationRecord.threshold < 0 || mutationRecord.threshold > 1) {
|
|
140
|
+
throw new TypeError(`${label}.mutation.threshold must be a number between 0 and 1`);
|
|
141
|
+
}
|
|
142
|
+
mutation = { threshold: mutationRecord.threshold };
|
|
143
|
+
}
|
|
144
|
+
/** @type {VerificationCommand} */
|
|
145
|
+
const normalized = { argv: [.../** @type {string[]} */ (record.argv)], timeoutSec, repeat, env: [.../** @type {string[]} */ (env)] };
|
|
146
|
+
if (record.cwd !== undefined) normalized.cwd = /** @type {string} */ (record.cwd);
|
|
147
|
+
if (mutation !== undefined) normalized.mutation = mutation;
|
|
148
|
+
return normalized;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Bound a verification result for persisted node state.
|
|
153
|
+
*
|
|
154
|
+
* @param {VerificationResult|undefined} result
|
|
155
|
+
* @returns {VerificationResult}
|
|
156
|
+
*/
|
|
157
|
+
export function compactVerification(result) {
|
|
158
|
+
/** @type {VerificationCommandResult[]} */
|
|
159
|
+
const commands = [];
|
|
160
|
+
let argvBytes = 0;
|
|
161
|
+
let envBytes = 0;
|
|
162
|
+
for (const command of result?.commands ?? []) {
|
|
163
|
+
if (commands.length >= VERIFICATION_LIMITS.stateCommands) break;
|
|
164
|
+
const nextArgvBytes = argvBytes + command.argv.reduce((sum, item) => sum + Buffer.byteLength(String(item), "utf8"), 0);
|
|
165
|
+
const nextEnvBytes = envBytes + (command.env ?? []).reduce((sum, item) => sum + Buffer.byteLength(String(item), "utf8"), 0);
|
|
166
|
+
if (nextArgvBytes > VERIFICATION_LIMITS.stateArgvBytes || nextEnvBytes > VERIFICATION_LIMITS.stateEnvBytes) break;
|
|
167
|
+
argvBytes = nextArgvBytes;
|
|
168
|
+
envBytes = nextEnvBytes;
|
|
169
|
+
commands.push({
|
|
170
|
+
argv: command.argv,
|
|
171
|
+
cwd: command.cwd,
|
|
172
|
+
timeoutSec: command.timeoutSec,
|
|
173
|
+
repeat: command.repeat,
|
|
174
|
+
env: command.env,
|
|
175
|
+
passed: Boolean(command.passed),
|
|
176
|
+
attempts: (command.attempts ?? []).slice(0, VERIFICATION_LIMITS.stateAttempts).map((attempt) => ({
|
|
177
|
+
...attempt,
|
|
178
|
+
stdout: tailText(attempt.stdout, VERIFICATION_LIMITS.stateStdoutBytes),
|
|
179
|
+
stderr: tailText(attempt.stderr, VERIFICATION_LIMITS.stateStdoutBytes),
|
|
180
|
+
})),
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
return { passed: Boolean(result?.passed), commands };
|
|
184
|
+
}
|
|
185
|
+
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { validateTaskPacket } from "./task-packet.mjs";
|
|
3
|
+
import { requireText } from "./assert.mjs";
|
|
4
|
+
|
|
5
|
+
const RESULT_LIMITS = Object.freeze({
|
|
6
|
+
bytes: 32 * 1024,
|
|
7
|
+
summaryBytes: 4 * 1024,
|
|
8
|
+
arrayItems: 32,
|
|
9
|
+
itemBytes: 2 * 1024,
|
|
10
|
+
artifactBytes: 16 * 1024,
|
|
11
|
+
missingContextItems: 16,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Worker-result fields the controller measures and derives itself, so the
|
|
16
|
+
* worker protocol has no owner for them. A result that declares one is
|
|
17
|
+
* rejected, never dropped: a field accepted after it lost its owner is how it
|
|
18
|
+
* comes back in the next prompt generation. The PRD's remaining derived fields
|
|
19
|
+
* -- commit, digest, worktreeIdentity, durationMs, exitCode -- enter this one
|
|
20
|
+
* list as their nodes land, so rejection stays a single edit.
|
|
21
|
+
*/
|
|
22
|
+
export const DERIVED_WORKER_RESULT_FIELDS = Object.freeze(["changedFiles"]);
|
|
23
|
+
|
|
24
|
+
/** @typedef {"done"|"blocked_context"} WorkerResultStatus */
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The worker-result protocol: exactly one JSON object returned as the only
|
|
28
|
+
* content of the final worker message. `blocked_context` requires at least one
|
|
29
|
+
* missingContext entry; `done` requires none.
|
|
30
|
+
*
|
|
31
|
+
* @typedef {{status: WorkerResultStatus, summary: string, verification: string[], artifacts: string[], missingContext: string[]}} WorkerResult
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {string} value
|
|
36
|
+
* @returns {WorkerResult}
|
|
37
|
+
*/
|
|
38
|
+
export function parseWorkerResult(value) {
|
|
39
|
+
if (typeof value !== "string") throw new TypeError("worker result must be JSON text");
|
|
40
|
+
if (Buffer.byteLength(value, "utf8") > RESULT_LIMITS.bytes) {
|
|
41
|
+
throw new TypeError(`worker result exceeds ${RESULT_LIMITS.bytes} bytes`);
|
|
42
|
+
}
|
|
43
|
+
let parsed;
|
|
44
|
+
try {
|
|
45
|
+
parsed = JSON.parse(value);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
throw new TypeError(`worker result is invalid JSON: ${(error instanceof Error ? error.message : String(error))}`);
|
|
48
|
+
}
|
|
49
|
+
return validateWorkerResult(parsed);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {unknown} value
|
|
54
|
+
* @returns {WorkerResult}
|
|
55
|
+
*/
|
|
56
|
+
export function validateWorkerResult(value) {
|
|
57
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
58
|
+
throw new TypeError("worker result must be an object");
|
|
59
|
+
}
|
|
60
|
+
// Worker output is an external LLM boundary: models add fields beyond the
|
|
61
|
+
// protocol. Unknown provider extras are dropped (the normalized pick below
|
|
62
|
+
// keeps only canonical fields), but a field the controller derives is a
|
|
63
|
+
// protocol failure: silently dropping it is how the worker learns to keep
|
|
64
|
+
// sending it. Missing or invalid canonical fields stay fatal too.
|
|
65
|
+
const record = /** @type {Record<string, unknown>} */ (value);
|
|
66
|
+
for (const field of DERIVED_WORKER_RESULT_FIELDS) {
|
|
67
|
+
if (Object.hasOwn(record, field)) {
|
|
68
|
+
throw new TypeError(`worker result.${field} is derived by the controller and must not be declared`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const expected = new Set(["status", "summary", "verification", "artifacts", "missingContext"]);
|
|
72
|
+
for (const key of expected) {
|
|
73
|
+
if (!Object.hasOwn(record, key)) throw new TypeError(`worker result.${key} is required`);
|
|
74
|
+
}
|
|
75
|
+
if (record.status !== "done" && record.status !== "blocked_context") {
|
|
76
|
+
throw new TypeError("worker result.status must be done or blocked_context");
|
|
77
|
+
}
|
|
78
|
+
requireText(record.summary, "worker result.summary", RESULT_LIMITS.summaryBytes);
|
|
79
|
+
requireList(record.verification, "worker result.verification", RESULT_LIMITS.arrayItems, RESULT_LIMITS.itemBytes);
|
|
80
|
+
requireList(record.artifacts, "worker result.artifacts", RESULT_LIMITS.arrayItems, RESULT_LIMITS.artifactBytes);
|
|
81
|
+
requireList(record.missingContext, "worker result.missingContext", RESULT_LIMITS.missingContextItems, RESULT_LIMITS.itemBytes);
|
|
82
|
+
const missingContext = /** @type {string[]} */ (record.missingContext);
|
|
83
|
+
if (record.status === "blocked_context" && missingContext.length === 0) {
|
|
84
|
+
throw new TypeError("worker result.missingContext must not be empty for blocked_context");
|
|
85
|
+
}
|
|
86
|
+
if (record.status === "done" && missingContext.length > 0) {
|
|
87
|
+
throw new TypeError("worker result.missingContext must be empty for done");
|
|
88
|
+
}
|
|
89
|
+
const normalized = {
|
|
90
|
+
status: /** @type {WorkerResultStatus} */ (record.status),
|
|
91
|
+
summary: /** @type {string} */ (record.summary),
|
|
92
|
+
verification: [.../** @type {string[]} */ (record.verification)],
|
|
93
|
+
artifacts: [.../** @type {string[]} */ (record.artifacts)],
|
|
94
|
+
missingContext: [...missingContext],
|
|
95
|
+
};
|
|
96
|
+
if (Buffer.byteLength(JSON.stringify(normalized), "utf8") > RESULT_LIMITS.bytes) {
|
|
97
|
+
throw new TypeError(`worker result exceeds ${RESULT_LIMITS.bytes} bytes`);
|
|
98
|
+
}
|
|
99
|
+
return normalized;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @param {string|WorkerResult} value
|
|
104
|
+
* @param {string} cwd
|
|
105
|
+
* @returns {WorkerResult & {discoveryPacket: import("./task-packet.mjs").TaskPacket}}
|
|
106
|
+
*/
|
|
107
|
+
export function parseDiscoveryResult(value, cwd) {
|
|
108
|
+
const result = typeof value === "string" ? parseWorkerResult(value) : validateWorkerResult(value);
|
|
109
|
+
if (result.status !== "done") throw new TypeError("discovery result must have status done");
|
|
110
|
+
if (result.artifacts.length !== 1) throw new TypeError("discovery result must contain exactly one task packet artifact");
|
|
111
|
+
let packet;
|
|
112
|
+
try {
|
|
113
|
+
packet = JSON.parse(result.artifacts[0]);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
throw new TypeError(`discovery task packet artifact is invalid JSON: ${(error instanceof Error ? error.message : String(error))}`);
|
|
116
|
+
}
|
|
117
|
+
validateTaskPacket(packet, 0, cwd);
|
|
118
|
+
if (packet.mode !== "execution") throw new TypeError("discovery task packet artifact must be execution mode");
|
|
119
|
+
const normalized = { ...result };
|
|
120
|
+
Object.defineProperty(normalized, "discoveryPacket", { value: packet, enumerable: false });
|
|
121
|
+
return /** @type {WorkerResult & {discoveryPacket: import("./task-packet.mjs").TaskPacket}} */ (
|
|
122
|
+
/** @type {unknown} */ (normalized)
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* @param {unknown} value
|
|
128
|
+
* @param {string} label
|
|
129
|
+
* @param {number} maxItems
|
|
130
|
+
* @param {number} itemBytes
|
|
131
|
+
*/
|
|
132
|
+
function requireList(value, label, maxItems, itemBytes) {
|
|
133
|
+
if (!Array.isArray(value) || value.length > maxItems) throw new TypeError(`${label} must be an array with at most ${maxItems} items`);
|
|
134
|
+
for (const [index, item] of value.entries()) {
|
|
135
|
+
if (typeof item !== "string") throw new TypeError(`${label}[${index}] must be a string`);
|
|
136
|
+
if (Buffer.byteLength(item, "utf8") > itemBytes) throw new TypeError(`${label}[${index}] exceeds ${itemBytes} bytes`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who runs a node, and what a failed dependency costs its dependants.
|
|
3
|
+
*
|
|
4
|
+
* `runtimeAssignments` composes the worker/judge pair only for the roles a
|
|
5
|
+
* contract left open, from the runtimes available right now -- which is why it
|
|
6
|
+
* is called on a fresh run *and* on a resume, and why it lives in neither.
|
|
7
|
+
* `blockDependents` walks the DAG forward from a terminal failure so a node
|
|
8
|
+
* whose dependency died never dispatches at all.
|
|
9
|
+
*/
|
|
10
|
+
import { PARKED } from "./prompts.mjs";
|
|
11
|
+
import { composeAssignments, discoverRuntimes } from "./runtime-discovery.mjs";
|
|
12
|
+
import { readUserConfig } from "../host/config.mjs";
|
|
13
|
+
import { transition } from "./state.mjs";
|
|
14
|
+
|
|
15
|
+
/** @typedef {import("../cli.mjs").LockHandle} LockHandle */
|
|
16
|
+
/** @typedef {import("../contract/index.mjs").NodeSnapshot} NodeSnapshot */
|
|
17
|
+
/** @typedef {import("./runtime-discovery.mjs").RuntimeAvailability} RuntimeAvailability */
|
|
18
|
+
/** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolve role assignments once at run creation. Discovery is used only for
|
|
22
|
+
* omitted roles; the resulting pair is persisted so resume is deterministic.
|
|
23
|
+
*
|
|
24
|
+
* @param {ValidatedContract} contract
|
|
25
|
+
* @returns {Promise<{assignments: Record<string, {worker: string, judge: string, composedWorker: boolean, composedJudge: boolean}>, availability: Record<string, import("./runtime-discovery.mjs").RuntimeAvailability>}>}
|
|
26
|
+
*/
|
|
27
|
+
export async function runtimeAssignments(contract) {
|
|
28
|
+
const needsComposition = contract.nodes.some((node) =>
|
|
29
|
+
(node.runtime === undefined && contract.runtimeDefaults?.worker === undefined)
|
|
30
|
+
|| (node.gate.enabled && node.gate.runtime === undefined && contract.runtimeDefaults?.judge === undefined));
|
|
31
|
+
const availability = needsComposition ? await discoverRuntimes(contract.runtimes, { cwd: contract.cwd }) : {};
|
|
32
|
+
const config = readUserConfig(process.env);
|
|
33
|
+
const assignments = composeAssignments(contract, availability, { config });
|
|
34
|
+
return {
|
|
35
|
+
assignments: Object.fromEntries(Object.entries(assignments).map(([nodeId, assignment]) => {
|
|
36
|
+
const node = contract.nodes.find((candidate) => candidate.id === nodeId);
|
|
37
|
+
return [nodeId, {
|
|
38
|
+
...assignment,
|
|
39
|
+
composedWorker: node?.runtime === undefined && contract.runtimeDefaults?.worker === undefined,
|
|
40
|
+
composedJudge: Boolean(node?.gate.enabled && node.gate.runtime === undefined && contract.runtimeDefaults?.judge === undefined),
|
|
41
|
+
}];
|
|
42
|
+
})),
|
|
43
|
+
availability,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* @param {ValidatedContract} contract
|
|
48
|
+
* @param {string} runDir
|
|
49
|
+
* @param {Map<string, NodeSnapshot>} states
|
|
50
|
+
* @param {LockHandle} lock
|
|
51
|
+
*/
|
|
52
|
+
export function blockDependents(contract, runDir, states, lock) {
|
|
53
|
+
for (const node of contract.nodes) {
|
|
54
|
+
const state = states.get(node.id);
|
|
55
|
+
if (!state) continue;
|
|
56
|
+
if (state.status !== "pending") continue;
|
|
57
|
+
// Only a parent that has parked blocks its dependants. A parent whose
|
|
58
|
+
// automatic retry is still unspent is re-opened to `pending` before this
|
|
59
|
+
// runs, so the dependants stay `pending`/`phase: "waiting"` until it parks.
|
|
60
|
+
const blockedBy = node.dependsOn.filter((id) => PARKED.has(states.get(id)?.status ?? ""));
|
|
61
|
+
if (blockedBy.length) transition(runDir, state, "blocked", { phase: "dependency", blockedBy, error: { code: "dependency_failed", message: `blocked by ${blockedBy.join(", ")}` } }, lock);
|
|
62
|
+
}
|
|
63
|
+
}
|