faberun 0.8.0 → 0.9.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/src/campaign/journal.mjs +48 -0
- package/src/cli/brand.mjs +1 -0
- package/src/cli/campaign.mjs +15 -2
- package/src/cli/plan.mjs +142 -0
- package/src/cli.mjs +15 -0
- package/src/contract/task-packet.mjs +1 -1
- package/src/contract/worker-result.mjs +40 -8
- package/src/engine/lifecycle.mjs +11 -0
- package/src/engine/result-file.mjs +18 -4
- package/src/harnesses/protocol.mjs +91 -11
- package/src/plan/freeze.mjs +7 -3
- package/src/plan/pipeline.mjs +371 -0
- package/src/plan/template.mjs +278 -0
- package/src/seat/allowance.mjs +177 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "faberun",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Faberun is a development orchestration system that turns intent into verified software: harness- and model-agnostic, it keeps the intent, coordinates the work, verifies the result and decides what happens next.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/campaign/journal.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { JOURNAL_FILE, JOURNAL_TEXT_BYTES, JOURNAL_WATCH_CURSOR_DIR, JOURNAL_WAT
|
|
|
11
11
|
import { boundedText, collapseLines } from "../util.mjs";
|
|
12
12
|
import { campaignIdOf } from "./record.mjs";
|
|
13
13
|
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { randomUUID } from "node:crypto";
|
|
14
15
|
import { dirname, isAbsolute, join } from "node:path";
|
|
15
16
|
import { requireText, requireTimestamp } from "../contract/assert.mjs";
|
|
16
17
|
import { writeJsonAtomic } from "../run/store.mjs";
|
|
@@ -34,6 +35,7 @@ const JOURNAL_TYPES = new Set([
|
|
|
34
35
|
"question.resolved",
|
|
35
36
|
"retrospective",
|
|
36
37
|
"liveness",
|
|
38
|
+
"seat.allowance",
|
|
37
39
|
]);
|
|
38
40
|
const SESSION_REQUIRED_TYPES = new Set([
|
|
39
41
|
"session.attached",
|
|
@@ -63,7 +65,9 @@ const ENTRY_SHAPES = {
|
|
|
63
65
|
"question.resolved": ["at", "type", "eventId", "sessionId", "questionId", "text"],
|
|
64
66
|
retrospective: ["at", "type", "eventId", "sessionId", "text"],
|
|
65
67
|
liveness: ["at", "type", "eventId", "campaignId", "runId", "nodeId", "phase", "checkpointsDone", "checkpointsTotal", "runtime", "state", "lastProgressAt", "attention"],
|
|
68
|
+
"seat.allowance": ["at", "type", "eventId", "sample", "harness", "remaining", "limit", "resetsAt", "delta", "window"],
|
|
66
69
|
};
|
|
70
|
+
const SEAT_ALLOWANCE_SAMPLES = new Set(["start", "freeze"]);
|
|
67
71
|
/**
|
|
68
72
|
* Fields a liveness fact carried before the budget ceiling was removed. A
|
|
69
73
|
* historical journal (like the live campaign's own) still has lines shaped
|
|
@@ -272,6 +276,7 @@ export function validateJournalEntry(entry) {
|
|
|
272
276
|
// rejects an unexpected key; no deeper shape validation is needed for a type
|
|
273
277
|
// nothing produces.
|
|
274
278
|
if (type === "liveness") return;
|
|
279
|
+
if (type === "seat.allowance") return validateSeatAllowanceEntry(record);
|
|
275
280
|
if (type === "session.attached") return validateSessionEntry(record);
|
|
276
281
|
if (type === "run.registered") {
|
|
277
282
|
requireText(record.runId, "entry.runId");
|
|
@@ -293,6 +298,49 @@ export function validateJournalEntry(entry) {
|
|
|
293
298
|
}
|
|
294
299
|
if (type === "outcome" && record.runId !== undefined) requireText(record.runId, "entry.runId");
|
|
295
300
|
}
|
|
301
|
+
/**
|
|
302
|
+
* @param {JsonObject} entry
|
|
303
|
+
*/
|
|
304
|
+
function validateSeatAllowanceEntry(entry) {
|
|
305
|
+
if (!SEAT_ALLOWANCE_SAMPLES.has(/** @type {string} */ (entry.sample))) {
|
|
306
|
+
throw new TypeError("seat.allowance sample must be start or freeze");
|
|
307
|
+
}
|
|
308
|
+
if (entry.harness !== null && typeof entry.harness !== "string") {
|
|
309
|
+
throw new TypeError("seat.allowance harness must be null or a string");
|
|
310
|
+
}
|
|
311
|
+
for (const field of ["remaining", "limit", "delta"]) {
|
|
312
|
+
if (entry[field] !== null && typeof entry[field] !== "number") {
|
|
313
|
+
throw new TypeError(`seat.allowance ${field} must be null or a number`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (entry.resetsAt !== null && typeof entry.resetsAt !== "string") {
|
|
317
|
+
throw new TypeError("seat.allowance resetsAt must be null or a string");
|
|
318
|
+
}
|
|
319
|
+
// Optional: a historical entry, and every call site not yet updated to
|
|
320
|
+
// sample it, carries no window at all.
|
|
321
|
+
if (entry.window !== undefined && entry.window !== null && typeof entry.window !== "string") {
|
|
322
|
+
throw new TypeError("seat.allowance window must be null or a string");
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* The one writer of `seat.allowance`: `campaign init` calls it for the
|
|
327
|
+
* `start` sample, `plan freeze` for the `freeze` sample. Kept as a single
|
|
328
|
+
* function, rather than two call sites building the literal themselves, so
|
|
329
|
+
* the field-ownership ratchet does not grow by one for every field this event
|
|
330
|
+
* carries.
|
|
331
|
+
*
|
|
332
|
+
* @param {string} campaignPath
|
|
333
|
+
* @param {{sample: "start"|"freeze", harness: string|null, remaining: number|null, limit: number|null, resetsAt: string|null, delta: number|null, window?: string|null}} allowance
|
|
334
|
+
* @returns {{entry: JournalEntry, deduplicated: boolean}}
|
|
335
|
+
*/
|
|
336
|
+
export function appendSeatAllowanceEvent(campaignPath, allowance) {
|
|
337
|
+
return appendJournal(campaignPath, {
|
|
338
|
+
type: "seat.allowance",
|
|
339
|
+
eventId: randomUUID(),
|
|
340
|
+
at: new Date().toISOString(),
|
|
341
|
+
...allowance,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
296
344
|
/**
|
|
297
345
|
* @param {JsonObject} entry
|
|
298
346
|
*/
|
package/src/cli/brand.mjs
CHANGED
|
@@ -197,6 +197,7 @@ export function renderUsage() {
|
|
|
197
197
|
"contract validate <contract.json>",
|
|
198
198
|
"spec validate <file> [--strict-traceability] [--json]",
|
|
199
199
|
"spec scaffold <path> [--id <id>]",
|
|
200
|
+
"plan <spec.md> --campaign <id> [--phase <phase>] [--review-rounds <n>] [--approve-below standard|high|none] [--runtime-defaults worker=<id>,judge=<id>] [--detach] [--json]",
|
|
200
201
|
"metrics <campaign-id> [--cwd <dir>] [--json]",
|
|
201
202
|
"campaign <init|watch|attach|note|resolve|close|supervise|show|list|sync|ack> ...",
|
|
202
203
|
"seat <start|attach|status|stop> [<campaign-id>] [--cwd <dir>] ...",
|
package/src/cli/campaign.mjs
CHANGED
|
@@ -12,12 +12,14 @@ import {
|
|
|
12
12
|
} from "../campaign/index.mjs";
|
|
13
13
|
import { lockStale, pidAlive, processStartToken, readLock } from "../run/lock.mjs";
|
|
14
14
|
import { syncAgentSignal } from "../repo/signal.mjs";
|
|
15
|
-
import { acknowledgeJournalEvent, appendJournal, readJournal, watchJournal } from "../campaign/journal.mjs";
|
|
15
|
+
import { acknowledgeJournalEvent, appendJournal, appendSeatAllowanceEvent, readJournal, watchJournal } from "../campaign/journal.mjs";
|
|
16
16
|
import { driveCampaignChain } from "../campaign/chain.mjs";
|
|
17
17
|
import { unparkCampaign } from "../campaign/unpark.mjs";
|
|
18
18
|
import { readCampaign } from "../campaign/record.mjs";
|
|
19
19
|
import { notifyQueueFor } from "../engine/notify-queue.mjs";
|
|
20
20
|
import { appendInbox, readInbox, wakeCapabilityNotice } from "../notify/index.mjs";
|
|
21
|
+
import { allowanceEventFields, sampleAllowance } from "../seat/allowance.mjs";
|
|
22
|
+
import { detectOperatorHarness } from "../seat/harnesses.mjs";
|
|
21
23
|
import { detachArgv, detachSelf, waitForBootstrap } from "./launch.mjs";
|
|
22
24
|
import { errorCode, readJsonTolerant } from "../util.mjs";
|
|
23
25
|
|
|
@@ -326,12 +328,23 @@ function watchLockStale(occupant) {
|
|
|
326
328
|
* @param {string} campaignId
|
|
327
329
|
* @param {CliValues} values
|
|
328
330
|
*/
|
|
329
|
-
function init(campaignId, values) {
|
|
331
|
+
async function init(campaignId, values) {
|
|
330
332
|
const cwd = resolve(values.cwd ?? ".");
|
|
331
333
|
const runsDir = join(cwd, ".runs");
|
|
332
334
|
const goal = textValue(values.goal, "--goal");
|
|
333
335
|
const contracts = contractManifest(values.contract);
|
|
334
336
|
const created = initializeCampaign(runsDir, { campaignId, goal, contracts, landBranch: values.landBranch });
|
|
337
|
+
// The operator's own seat: whichever harness this CLI is running inside
|
|
338
|
+
// (env-marker detection, see seat/harnesses.mjs), the only harness whose
|
|
339
|
+
// allowance is meaningful at a point before any node runtime exists.
|
|
340
|
+
const harness = detectOperatorHarness();
|
|
341
|
+
const allowance = await sampleAllowance({ harness });
|
|
342
|
+
appendSeatAllowanceEvent(created.path, {
|
|
343
|
+
sample: "start",
|
|
344
|
+
harness,
|
|
345
|
+
delta: null,
|
|
346
|
+
...allowanceEventFields(allowance),
|
|
347
|
+
});
|
|
335
348
|
renderHandoff(created.path, runsDir);
|
|
336
349
|
process.stdout.write(`[campaign] ${campaignId} initialized · ${created.path} · landBranch ${created.campaign.landBranch} · ${created.campaign.contracts.length} contract(s)\n`);
|
|
337
350
|
if (syncAgentSignal(runsDir)) process.stdout.write(`[campaign] AGENTS.md signal updated\n`);
|
package/src/cli/plan.mjs
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `plan` argv: run the planning pipeline as successive ordinary runs (draft,
|
|
3
|
+
* review, revise up to a round budget) and freeze the result, or park it
|
|
4
|
+
* contested. This file only owns the wire — `src/plan/pipeline.mjs` owns the
|
|
5
|
+
* sequencing and every decision the pipeline makes.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { join, resolve } from "node:path";
|
|
9
|
+
import { detachArgv, detachSelf, waitForBootstrap } from "./launch.mjs";
|
|
10
|
+
import { classifyRunProgress } from "../campaign/chain.mjs";
|
|
11
|
+
import { runProgress } from "../engine/supervise.mjs";
|
|
12
|
+
import { DISCOVERY_RUNTIME_DEFINITIONS } from "../engine/runtime-discovery.mjs";
|
|
13
|
+
import { validateRuntime } from "../contract/runtime.mjs";
|
|
14
|
+
import { delay } from "../util.mjs";
|
|
15
|
+
import { runPlanningPipeline } from "../plan/pipeline.mjs";
|
|
16
|
+
|
|
17
|
+
/** How often a foreground `plan` polls a launched stage's run directory. */
|
|
18
|
+
const DEFAULT_POLL_MS = 1_000;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `--runtime-defaults worker=<id>,judge=<id>`, either key optional, comma
|
|
22
|
+
* separated. Absent entirely, the pipeline falls through to plain
|
|
23
|
+
* availability discovery for every node.
|
|
24
|
+
*
|
|
25
|
+
* @param {string|undefined} value
|
|
26
|
+
* @returns {{worker?: string, judge?: string}}
|
|
27
|
+
*/
|
|
28
|
+
export function parseRuntimeDefaults(value) {
|
|
29
|
+
/** @type {{worker?: string, judge?: string}} */
|
|
30
|
+
const result = {};
|
|
31
|
+
if (value === undefined) return result;
|
|
32
|
+
for (const pair of value.split(",")) {
|
|
33
|
+
const eq = pair.indexOf("=");
|
|
34
|
+
if (eq < 0) throw new Error(`--runtime-defaults entries must be worker=<id> or judge=<id>: ${pair}`);
|
|
35
|
+
const role = pair.slice(0, eq).trim();
|
|
36
|
+
const id = pair.slice(eq + 1).trim();
|
|
37
|
+
if (role !== "worker" && role !== "judge") throw new Error(`--runtime-defaults role must be worker or judge: ${role}`);
|
|
38
|
+
if (!id) throw new Error(`--runtime-defaults ${role} needs a runtime id`);
|
|
39
|
+
result[role] = id;
|
|
40
|
+
}
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {unknown} value
|
|
46
|
+
* @returns {number}
|
|
47
|
+
*/
|
|
48
|
+
function reviewRoundsOf(value) {
|
|
49
|
+
if (value === undefined) return 2;
|
|
50
|
+
const rounds = Number(value);
|
|
51
|
+
if (!Number.isInteger(rounds) || rounds < 1) throw new Error(`--review-rounds must be a positive integer: ${String(value)}`);
|
|
52
|
+
return rounds;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A `--runtimes <path>` catalogue: a JSON object in the same shape a
|
|
57
|
+
* contract's own `runtimes` field takes, validated entry-by-entry with the
|
|
58
|
+
* same validator `validateContract` uses, so a malformed catalogue is
|
|
59
|
+
* rejected before any planning stage launches rather than surfacing as an
|
|
60
|
+
* opaque failure deep inside the pipeline.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} path
|
|
63
|
+
* @returns {Record<string, import("../contract/index.mjs").ValidatedRuntime>}
|
|
64
|
+
*/
|
|
65
|
+
export function loadRuntimesCatalogue(path) {
|
|
66
|
+
const resolved = resolve(path);
|
|
67
|
+
/** @type {unknown} */
|
|
68
|
+
let raw;
|
|
69
|
+
try {
|
|
70
|
+
raw = JSON.parse(readFileSync(resolved, "utf8"));
|
|
71
|
+
} catch (error) {
|
|
72
|
+
throw new Error(`--runtimes ${path} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
73
|
+
}
|
|
74
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`--runtimes ${path} must be a JSON object`);
|
|
75
|
+
/** @type {Record<string, import("../contract/index.mjs").ValidatedRuntime>} */
|
|
76
|
+
const runtimes = {};
|
|
77
|
+
for (const [id, runtime] of Object.entries(raw)) runtimes[id] = validateRuntime(id, runtime);
|
|
78
|
+
return runtimes;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {string} target
|
|
83
|
+
* @param {{campaign?: string, phase?: string, "review-rounds"?: string, "approve-below"?: string, "runtime-defaults"?: string, runtimes?: string, detach?: boolean, json?: boolean}} values
|
|
84
|
+
* @returns {Promise<void>}
|
|
85
|
+
*/
|
|
86
|
+
export async function planCli(target, values) {
|
|
87
|
+
const specPath = resolve(target);
|
|
88
|
+
if (typeof values.campaign !== "string" || !values.campaign) throw new Error("plan requires --campaign <id>");
|
|
89
|
+
const campaignId = values.campaign;
|
|
90
|
+
const phase = typeof values.phase === "string" && values.phase ? values.phase : "default";
|
|
91
|
+
const reviewRounds = reviewRoundsOf(values["review-rounds"]);
|
|
92
|
+
const approveBelow = /** @type {"standard"|"high"|"none"|undefined} */ (values["approve-below"]);
|
|
93
|
+
const runtimeDefaults = parseRuntimeDefaults(values["runtime-defaults"]);
|
|
94
|
+
const runtimes = typeof values.runtimes === "string" && values.runtimes
|
|
95
|
+
? loadRuntimesCatalogue(values.runtimes)
|
|
96
|
+
: DISCOVERY_RUNTIME_DEFINITIONS;
|
|
97
|
+
|
|
98
|
+
if (values.detach === true) {
|
|
99
|
+
const argv = ["plan", specPath, "--campaign", campaignId, "--phase", phase, "--review-rounds", String(reviewRounds)];
|
|
100
|
+
if (approveBelow !== undefined) argv.push("--approve-below", approveBelow);
|
|
101
|
+
if (values["runtime-defaults"] !== undefined) argv.push("--runtime-defaults", values["runtime-defaults"]);
|
|
102
|
+
if (typeof values.runtimes === "string" && values.runtimes) argv.push("--runtimes", resolve(values.runtimes));
|
|
103
|
+
const child = detachArgv(argv);
|
|
104
|
+
if (child.pid === undefined) throw new Error("detached plan has no pid");
|
|
105
|
+
process.stdout.write(`[plan] detached · pid ${child.pid} · ${specPath}\n`);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const result = await runPlanningPipeline({
|
|
110
|
+
specPath,
|
|
111
|
+
campaignId,
|
|
112
|
+
phase,
|
|
113
|
+
reviewRounds,
|
|
114
|
+
approveBelow,
|
|
115
|
+
runtimeDefaults,
|
|
116
|
+
runtimes,
|
|
117
|
+
launch: async (contractPath, contract) => {
|
|
118
|
+
const child = detachSelf("run", contractPath);
|
|
119
|
+
if (child.pid === undefined) throw new Error("detached planning run has no pid");
|
|
120
|
+
await waitForBootstrap(join(contract.cwd, ".runs", contract.id), child.pid, child);
|
|
121
|
+
},
|
|
122
|
+
wait: async (runDir) => {
|
|
123
|
+
for (;;) {
|
|
124
|
+
const progress = runProgress(runDir);
|
|
125
|
+
const classification = classifyRunProgress(progress);
|
|
126
|
+
if (classification !== "unfinished" && classification !== "waiting") return progress;
|
|
127
|
+
await delay(DEFAULT_POLL_MS);
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
if (values.json === true) {
|
|
133
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (result.status === "contested") {
|
|
137
|
+
process.stdout.write(`[plan] ${campaignId} phase ${phase} contested after ${result.round} round(s) · ${result.findings.length} finding(s) · ${result.planPath}\n`);
|
|
138
|
+
process.exitCode = 1;
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
process.stdout.write(`[plan] ${campaignId} phase ${phase} frozen · approved ${result.approved} · ${result.contractPath}\n`);
|
|
142
|
+
}
|
package/src/cli.mjs
CHANGED
|
@@ -32,6 +32,7 @@ import { skillsCli } from "./cli/skills.mjs";
|
|
|
32
32
|
import { updateCommand } from "./cli/update.mjs";
|
|
33
33
|
import { contractCli, validateContractFile } from "./cli/contract.mjs";
|
|
34
34
|
import { specCli } from "./cli/spec.mjs";
|
|
35
|
+
import { planCli } from "./cli/plan.mjs";
|
|
35
36
|
import { METRICS_OPTIONS, renderCampaignMetrics } from "./campaign/metrics.mjs";
|
|
36
37
|
import { runContract } from "./engine/scheduler.mjs";
|
|
37
38
|
import { resumeRun } from "./engine/resume.mjs";
|
|
@@ -106,6 +107,16 @@ export const COMMAND_OPTIONS = {
|
|
|
106
107
|
setup: { yes: { type: "boolean" }, harnesses: { type: "string" }, worker: { type: "string" }, judge: { type: "string" }, "no-skill": { type: "boolean" }, json: { type: "boolean" } },
|
|
107
108
|
init: { cwd: { type: "string" }, yes: { type: "boolean" }, "no-skill": { type: "boolean" }, agentkit: { type: "boolean" }, greenfield: { type: "boolean" }, stable: { type: "boolean" }, json: { type: "boolean" } },
|
|
108
109
|
metrics: METRICS_OPTIONS,
|
|
110
|
+
plan: {
|
|
111
|
+
campaign: { type: "string" },
|
|
112
|
+
phase: { type: "string" },
|
|
113
|
+
"review-rounds": { type: "string" },
|
|
114
|
+
"approve-below": { type: "string" },
|
|
115
|
+
"runtime-defaults": { type: "string" },
|
|
116
|
+
runtimes: { type: "string" },
|
|
117
|
+
detach: { type: "boolean" },
|
|
118
|
+
json: { type: "boolean" },
|
|
119
|
+
},
|
|
109
120
|
};
|
|
110
121
|
|
|
111
122
|
/**
|
|
@@ -421,6 +432,10 @@ async function main(argv) {
|
|
|
421
432
|
}
|
|
422
433
|
if (command === "metrics") { process.stdout.write(renderCampaignMetrics(target, values)); return; }
|
|
423
434
|
if (command === "findings") { process.stdout.write(renderFindings(resolve(target))); return; }
|
|
435
|
+
if (command === "plan") {
|
|
436
|
+
await planCli(target, /** @type {Parameters<typeof planCli>[1]} */ (values));
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
424
439
|
if (command === "validate") { validateContractFile(resolve(target)); return; }
|
|
425
440
|
usage();
|
|
426
441
|
}
|
|
@@ -432,7 +432,7 @@ function renderDiscoveryPrompt(packet, nodeId) {
|
|
|
432
432
|
...bulletOrNone(packet.nonGoals),
|
|
433
433
|
"",
|
|
434
434
|
"## Required output",
|
|
435
|
-
'Return exactly one worker-result JSON object, with no markdown or prose. Set status to "done", missingContext to [], and artifacts to an array containing exactly one JSON-stringified execution task packet with every required taskPacket field. The packet readFiles and writeFiles must be non-empty and scoped to this repository.',
|
|
435
|
+
'Return exactly one worker-result JSON object, with no markdown or prose. Set status to "done", missingContext to [], and artifacts to an array containing exactly one JSON-stringified execution task packet with every required taskPacket field. The packet readFiles and writeFiles must be non-empty and scoped to this repository. Put structured findings meant to inform that packet in `output` (a JSON object, at most 65536 bytes); prose belongs in `summary`.',
|
|
436
436
|
"",
|
|
437
437
|
"## Verification",
|
|
438
438
|
VERIFICATION_PARAGRAPH,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Buffer } from "node:buffer";
|
|
2
2
|
import { validateTaskPacket } from "./task-packet.mjs";
|
|
3
|
-
import { requireText } from "./assert.mjs";
|
|
3
|
+
import { assertObject, requireText } from "./assert.mjs";
|
|
4
4
|
|
|
5
5
|
const RESULT_LIMITS = Object.freeze({
|
|
6
6
|
bytes: 32 * 1024,
|
|
@@ -9,6 +9,10 @@ const RESULT_LIMITS = Object.freeze({
|
|
|
9
9
|
itemBytes: 2 * 1024,
|
|
10
10
|
artifactBytes: 16 * 1024,
|
|
11
11
|
missingContextItems: 16,
|
|
12
|
+
// A discovery worker's structured findings are the deliverable, not
|
|
13
|
+
// incidental prose, so `output` gets its own ceiling outside the 32 KiB
|
|
14
|
+
// envelope instead of competing with summary/verification for it.
|
|
15
|
+
outputBytes: 64 * 1024,
|
|
12
16
|
});
|
|
13
17
|
|
|
14
18
|
/**
|
|
@@ -26,9 +30,11 @@ export const DERIVED_WORKER_RESULT_FIELDS = Object.freeze(["changedFiles"]);
|
|
|
26
30
|
/**
|
|
27
31
|
* The worker-result protocol: exactly one JSON object returned as the only
|
|
28
32
|
* content of the final worker message. `blocked_context` requires at least one
|
|
29
|
-
* missingContext entry; `done` requires none.
|
|
33
|
+
* missingContext entry; `done` requires none. `output` is optional and carries
|
|
34
|
+
* a discovery node's structured findings; an execution result must not
|
|
35
|
+
* declare it (enforced where the node's mode is known, not here).
|
|
30
36
|
*
|
|
31
|
-
* @typedef {{status: WorkerResultStatus, summary: string, verification: string[], artifacts: string[], missingContext: string[]}} WorkerResult
|
|
37
|
+
* @typedef {{status: WorkerResultStatus, summary: string, verification: string[], artifacts: string[], missingContext: string[], output?: Record<string, unknown>}} WorkerResult
|
|
32
38
|
*/
|
|
33
39
|
|
|
34
40
|
/**
|
|
@@ -37,8 +43,9 @@ export const DERIVED_WORKER_RESULT_FIELDS = Object.freeze(["changedFiles"]);
|
|
|
37
43
|
*/
|
|
38
44
|
export function parseWorkerResult(value) {
|
|
39
45
|
if (typeof value !== "string") throw new TypeError("worker result must be JSON text");
|
|
40
|
-
|
|
41
|
-
|
|
46
|
+
const maxRawBytes = RESULT_LIMITS.bytes + RESULT_LIMITS.outputBytes;
|
|
47
|
+
if (Buffer.byteLength(value, "utf8") > maxRawBytes) {
|
|
48
|
+
throw new TypeError(`worker result exceeds ${maxRawBytes} bytes`);
|
|
42
49
|
}
|
|
43
50
|
let parsed;
|
|
44
51
|
try {
|
|
@@ -86,17 +93,42 @@ export function validateWorkerResult(value) {
|
|
|
86
93
|
if (record.status === "done" && missingContext.length > 0) {
|
|
87
94
|
throw new TypeError("worker result.missingContext must be empty for done");
|
|
88
95
|
}
|
|
89
|
-
|
|
96
|
+
// `output` is accepted on any result here: the schema does not know which
|
|
97
|
+
// node mode produced it. Refusing it for execution results happens exactly
|
|
98
|
+
// once, at the ingestion point that does know the mode (resolveWorkerResult).
|
|
99
|
+
let output;
|
|
100
|
+
if (Object.hasOwn(record, "output") && record.output !== undefined) {
|
|
101
|
+
assertObject(record.output, "worker result.output");
|
|
102
|
+
if (Buffer.byteLength(JSON.stringify(record.output), "utf8") > RESULT_LIMITS.outputBytes) {
|
|
103
|
+
throw new TypeError(`worker result.output exceeds ${RESULT_LIMITS.outputBytes} bytes`);
|
|
104
|
+
}
|
|
105
|
+
output = /** @type {Record<string, unknown>} */ (record.output);
|
|
106
|
+
}
|
|
107
|
+
const envelope = {
|
|
90
108
|
status: /** @type {WorkerResultStatus} */ (record.status),
|
|
91
109
|
summary: /** @type {string} */ (record.summary),
|
|
92
110
|
verification: [.../** @type {string[]} */ (record.verification)],
|
|
93
111
|
artifacts: [.../** @type {string[]} */ (record.artifacts)],
|
|
94
112
|
missingContext: [...missingContext],
|
|
95
113
|
};
|
|
96
|
-
|
|
114
|
+
// `output` is bounded on its own above and kept outside this envelope cap:
|
|
115
|
+
// it is a discovery node's deliverable, not incidental prose competing with
|
|
116
|
+
// summary/verification for the same 32 KiB budget.
|
|
117
|
+
if (Buffer.byteLength(JSON.stringify(envelope), "utf8") > RESULT_LIMITS.bytes) {
|
|
97
118
|
throw new TypeError(`worker result exceeds ${RESULT_LIMITS.bytes} bytes`);
|
|
98
119
|
}
|
|
99
|
-
return
|
|
120
|
+
return output === undefined ? envelope : { ...envelope, output };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A discovery node's structured findings, or null when the result carries
|
|
125
|
+
* none. The one accessor for `output` so callers never read the raw field.
|
|
126
|
+
*
|
|
127
|
+
* @param {WorkerResult} result
|
|
128
|
+
* @returns {Record<string, unknown>|null}
|
|
129
|
+
*/
|
|
130
|
+
export function discoveryOutput(result) {
|
|
131
|
+
return result.output ?? null;
|
|
100
132
|
}
|
|
101
133
|
|
|
102
134
|
/**
|
package/src/engine/lifecycle.mjs
CHANGED
|
@@ -54,6 +54,7 @@ import { appendUsageRecord, invocationCost, invocationUsage, recordInvocationUsa
|
|
|
54
54
|
import { attemptWorkspace } from "../repo/worktree.mjs";
|
|
55
55
|
import { executeControllerVerification } from "./verify.mjs";
|
|
56
56
|
import {
|
|
57
|
+
ExecutionOutputNotAllowedError,
|
|
57
58
|
materializeAttemptResult,
|
|
58
59
|
readWorkerResultFile,
|
|
59
60
|
resolveWorkerResult,
|
|
@@ -528,6 +529,16 @@ export async function finalizeClosedJobs(contract, runDir, states, running, lock
|
|
|
528
529
|
try {
|
|
529
530
|
workerResult = resolveWorkerResult(runDir, job.node, envelope.result);
|
|
530
531
|
} catch (error) {
|
|
532
|
+
// A definitive protocol violation, not a malformed result: an
|
|
533
|
+
// execution node has no legitimate way to earn the repair path here.
|
|
534
|
+
if (error instanceof ExecutionOutputNotAllowedError) {
|
|
535
|
+
clearTierExhaustion(state);
|
|
536
|
+
transition(runDir, state, "failed", {
|
|
537
|
+
phase: "worker",
|
|
538
|
+
error: { code: "execution_output_not_allowed", message: errorMessage(error) },
|
|
539
|
+
}, lock);
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
531
542
|
if (job.resultMaterialization) {
|
|
532
543
|
clearTierExhaustion(state);
|
|
533
544
|
transition(runDir, state, "failed", {
|
|
@@ -19,10 +19,18 @@ import { extractJson } from "../harnesses/protocol.mjs";
|
|
|
19
19
|
import { invocationResult } from "./process.mjs";
|
|
20
20
|
import { join } from "node:path";
|
|
21
21
|
import { parseJudge } from "./prompts.mjs";
|
|
22
|
-
import { parseWorkerResult } from "../contract/worker-result.mjs";
|
|
22
|
+
import { discoveryOutput, parseWorkerResult } from "../contract/worker-result.mjs";
|
|
23
23
|
import { readJson, writeJsonAtomic, writeTextAtomic } from "../run/store.mjs";
|
|
24
24
|
import { routeRuntimeForState, runtimeSnapshot } from "./failover.mjs";
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Thrown by `resolveWorkerResult` when an execution result declares `output`.
|
|
28
|
+
* A distinct type, not a plain TypeError: this is a definitive protocol
|
|
29
|
+
* violation the engine can fail terminally on sight, not a malformed result
|
|
30
|
+
* worth the generic invalid-result repair attempt.
|
|
31
|
+
*/
|
|
32
|
+
export class ExecutionOutputNotAllowedError extends TypeError {}
|
|
33
|
+
|
|
26
34
|
/** @typedef {import("./lifecycle.mjs").Invocation} Invocation */
|
|
27
35
|
/** @typedef {import("../contract/index.mjs").NodeSnapshot} NodeSnapshot */
|
|
28
36
|
/** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
|
|
@@ -115,6 +123,10 @@ export function isResultMaterializationInvocation(invocation) {
|
|
|
115
123
|
}
|
|
116
124
|
}
|
|
117
125
|
/**
|
|
126
|
+
* The single ingestion point that knows the node's mode, so it is the one
|
|
127
|
+
* place `output` is refused for an execution result: the schema itself
|
|
128
|
+
* accepts the field on any result, mode-blind.
|
|
129
|
+
*
|
|
118
130
|
* @param {string} runDir
|
|
119
131
|
* @param {ValidatedNode} node
|
|
120
132
|
* @param {unknown} providerResult
|
|
@@ -122,9 +134,11 @@ export function isResultMaterializationInvocation(invocation) {
|
|
|
122
134
|
*/
|
|
123
135
|
export function resolveWorkerResult(runDir, node, providerResult) {
|
|
124
136
|
const fromFile = readWorkerResultFile(runDir, node.id);
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
137
|
+
const result = fromFile ?? parseWorkerResult(String(extractJson(providerResult) ?? providerResult ?? ""));
|
|
138
|
+
if (node.taskPacket.mode === "execution" && discoveryOutput(result) !== null) {
|
|
139
|
+
throw new ExecutionOutputNotAllowedError("worker result.output is only allowed for a discovery node, not execution");
|
|
140
|
+
}
|
|
141
|
+
if (!fromFile) persistWorkerResultFile(runDir, node.id, result);
|
|
128
142
|
return result;
|
|
129
143
|
}
|
|
130
144
|
/**
|
|
@@ -5,6 +5,8 @@ import { finite } from "../util.mjs";
|
|
|
5
5
|
* `zcode.mjs`, `replay.mjs` and `exec-jsonl.mjs` itself all depend on it.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
/** @typedef {{remaining: number|null, limit: number|null, resetsAt: string|null, window: string|null}} ClaudeAllowance */
|
|
9
|
+
|
|
8
10
|
/**
|
|
9
11
|
* Parse newline-delimited JSON without accepting provider prose.
|
|
10
12
|
*
|
|
@@ -75,33 +77,110 @@ function withStartupReason(message, options = {}) {
|
|
|
75
77
|
return reason ? `${message}: ${boundedMessage(reason, 512)}` : message;
|
|
76
78
|
}
|
|
77
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Claude's account-wide rate-limit signal, reported on its own `rate_limit_event`
|
|
82
|
+
* stream line, never on the terminal `result` event. Measured 2026-09-17
|
|
83
|
+
* against `claude -p 'Reply with exactly OK and use no tools.' --output-format
|
|
84
|
+
* stream-json --verbose`, cost `total_cost_usd: 0.27848` for that one probe
|
|
85
|
+
* (opus-tier default model, most of it `cache_creation_input_tokens` for this
|
|
86
|
+
* project's session context -- a probe against a smaller/cheaper model would
|
|
87
|
+
* cost far less, but the seat sampled is whichever model the operator's own
|
|
88
|
+
* session already has configured, so this is the honest per-probe figure for
|
|
89
|
+
* that seat). No `rate_limits` or `rate_limit_status` field appears anywhere
|
|
90
|
+
* in the stream. The measured line, verbatim:
|
|
91
|
+
* `{"type":"rate_limit_event","rate_limit_info":{"status":"allowed_warning",
|
|
92
|
+
* "resetsAt":1789837200,"rateLimitType":"seven_day","utilization":0.77,
|
|
93
|
+
* "isUsingOverage":false,"surpassedThreshold":0.75,"unifiedWindows":
|
|
94
|
+
* {"five_hour":{"utilization":0.1,"resetsAt":1789663200},"seven_day":
|
|
95
|
+
* {"utilization":0.77,"resetsAt":1789837200}}}}`. `utilization` is a 0..1
|
|
96
|
+
* fraction of the window named by `rateLimitType`, confirmed (not merely
|
|
97
|
+
* assumed) by `surpassedThreshold: 0.75` sitting just below the measured
|
|
98
|
+
* `0.77` at `status: "allowed_warning"` -- a 0-100 percentage scale would put
|
|
99
|
+
* 0.77 nowhere near a 75-scaled threshold. This is a different field, on a
|
|
100
|
+
* different scale, from the `used_percentage` (0-100) the statusline
|
|
101
|
+
* integration already reads off `rate_limits.five_hour` (see
|
|
102
|
+
* `integrations/claude-code/statusline.sh`); the two must not be confused.
|
|
103
|
+
* `remaining` here is the fraction left (`1 - utilization`) and `limit` is
|
|
104
|
+
* the fixed ceiling `1` -- a coarse proxy, declared as such. A `utilization`
|
|
105
|
+
* outside `[0, 1]` is treated as no signal (null) rather than silently
|
|
106
|
+
* clamped, so a future scale change on the stream fails visibly instead of
|
|
107
|
+
* pinning every sample to 0.
|
|
108
|
+
*
|
|
109
|
+
* `unifiedWindows` carries every window's own utilization side by side
|
|
110
|
+
* (`five_hour: 0.1` next to `seven_day: 0.77` in the measured line above): the
|
|
111
|
+
* top-level `utilization` is only ever the figure for the window `rateLimitType`
|
|
112
|
+
* names, so a sample must read the named window out of `unifiedWindows` (falling
|
|
113
|
+
* back to the top-level field only when `unifiedWindows` carries no entry for
|
|
114
|
+
* it) and record which window that was. Two samples of different windows are
|
|
115
|
+
* not comparable -- a `five_hour` utilization minus a `seven_day` one is not a
|
|
116
|
+
* delta -- so the window travels with the sample for `allowanceDelta` to pin.
|
|
117
|
+
*
|
|
118
|
+
* @param {Record<string, unknown>[]} events
|
|
119
|
+
* @returns {ClaudeAllowance}
|
|
120
|
+
*/
|
|
121
|
+
function extractClaudeAllowance(events) {
|
|
122
|
+
const event = events.findLast((candidate) => candidate.type === "rate_limit_event");
|
|
123
|
+
const info = event?.rate_limit_info;
|
|
124
|
+
const record = info && typeof info === "object" && !Array.isArray(info) ? /** @type {Record<string, unknown>} */ (info) : null;
|
|
125
|
+
const window = record && typeof record.rateLimitType === "string" ? record.rateLimitType : null;
|
|
126
|
+
const unifiedWindows = record?.unifiedWindows && typeof record.unifiedWindows === "object" && !Array.isArray(record.unifiedWindows)
|
|
127
|
+
? /** @type {Record<string, unknown>} */ (record.unifiedWindows)
|
|
128
|
+
: null;
|
|
129
|
+
const namedWindowEntry = window && unifiedWindows?.[window] && typeof unifiedWindows[window] === "object" && !Array.isArray(unifiedWindows[window])
|
|
130
|
+
? /** @type {Record<string, unknown>} */ (unifiedWindows[window])
|
|
131
|
+
: null;
|
|
132
|
+
const rawUtilization = finite(namedWindowEntry ? namedWindowEntry.utilization : record?.utilization);
|
|
133
|
+
const utilization = rawUtilization !== null && rawUtilization >= 0 && rawUtilization <= 1 ? rawUtilization : null;
|
|
134
|
+
const resetsAtSeconds = finite(namedWindowEntry ? namedWindowEntry.resetsAt : record?.resetsAt);
|
|
135
|
+
return {
|
|
136
|
+
remaining: utilization === null ? null : 1 - utilization,
|
|
137
|
+
limit: utilization === null ? null : 1,
|
|
138
|
+
resetsAt: resetsAtSeconds === null ? null : new Date(resetsAtSeconds * 1000).toISOString(),
|
|
139
|
+
window,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The claude allowance shape with every member null: no signal was read. */
|
|
144
|
+
const NULL_CLAUDE_ALLOWANCE = { remaining: null, limit: null, resetsAt: null, window: null };
|
|
145
|
+
|
|
78
146
|
/**
|
|
79
147
|
* @param {string} stdout
|
|
80
148
|
* @param {number|null} exitCode
|
|
81
149
|
* @param {string|null} signal
|
|
82
150
|
* @param {import("./index.mjs").NormalizeOptions} [options]
|
|
83
|
-
* @returns {import("./index.mjs").ProviderEnvelope}
|
|
151
|
+
* @returns {import("./index.mjs").ProviderEnvelope & {allowance: ClaudeAllowance}}
|
|
84
152
|
*/
|
|
85
153
|
export function normalizeClaudeResult(stdout, exitCode, signal, options = {}) {
|
|
86
|
-
|
|
154
|
+
// The signal check must stay the first statement, byte-identical to before
|
|
155
|
+
// the allowance signal existed: `parseJsonLines` throws on any unparseable
|
|
156
|
+
// line past the first, and a killed process routinely leaves a truncated
|
|
157
|
+
// *later* line (the bounded-tail tolerance only covers the first). Parsing
|
|
158
|
+
// stdout before checking `signal` would turn a clean cancellation into a
|
|
159
|
+
// thrown error, which callers (`src/run/usage.mjs`, `src/engine/process.mjs`)
|
|
160
|
+
// catch into `invalid_output` or `null`, losing the envelope entirely.
|
|
161
|
+
if (signal) return { ...failed("canceled", `provider ended after ${signal}`, "canceled"), allowance: NULL_CLAUDE_ALLOWANCE };
|
|
87
162
|
const events = parseJsonLines(stdout, "claude");
|
|
163
|
+
const allowance = extractClaudeAllowance(events);
|
|
88
164
|
const resultEvent = events.findLast((event) => event.type === "result");
|
|
89
|
-
if (!resultEvent) return failed("incomplete_stream", withStartupReason("Claude emitted no result event", options));
|
|
165
|
+
if (!resultEvent) return { ...failed("incomplete_stream", withStartupReason("Claude emitted no result event", options)), allowance };
|
|
90
166
|
const result = typeof resultEvent.result === "string" ? resultEvent.result : null;
|
|
91
167
|
// A provider-reported quota stop is exhaustion: the declared failover edge
|
|
92
168
|
// must fire instead of settling the node as an ordinary provider failure.
|
|
93
169
|
const quotaText = claudeQuotaText(resultEvent, events);
|
|
94
170
|
if (quotaText) {
|
|
95
|
-
return
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
171
|
+
return {
|
|
172
|
+
...failed(
|
|
173
|
+
"quota_exhausted",
|
|
174
|
+
boundedMessage(quotaText, 512),
|
|
175
|
+
"exhausted",
|
|
176
|
+
typeof resultEvent.session_id === "string" ? resultEvent.session_id : null,
|
|
177
|
+
canonicalUsage(resultEvent.usage),
|
|
178
|
+
),
|
|
179
|
+
allowance,
|
|
180
|
+
};
|
|
102
181
|
}
|
|
103
182
|
if (resultEvent.is_error || exitCode !== 0) {
|
|
104
|
-
return failed("provider_error", result ?? `Claude exited with code ${exitCode}`);
|
|
183
|
+
return { ...failed("provider_error", result ?? `Claude exited with code ${exitCode}`), allowance };
|
|
105
184
|
}
|
|
106
185
|
return {
|
|
107
186
|
status: result?.trim() ? "done" : "no-op",
|
|
@@ -110,6 +189,7 @@ export function normalizeClaudeResult(stdout, exitCode, signal, options = {}) {
|
|
|
110
189
|
usage: canonicalUsage(resultEvent.usage),
|
|
111
190
|
costUsd: finite(resultEvent.total_cost_usd),
|
|
112
191
|
error: null,
|
|
192
|
+
allowance,
|
|
113
193
|
};
|
|
114
194
|
}
|
|
115
195
|
|