faberun 0.7.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.
@@ -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
- if (Buffer.byteLength(value, "utf8") > RESULT_LIMITS.bytes) {
41
- throw new TypeError(`worker result exceeds ${RESULT_LIMITS.bytes} bytes`);
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
- const normalized = {
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
- if (Buffer.byteLength(JSON.stringify(normalized), "utf8") > RESULT_LIMITS.bytes) {
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 normalized;
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
  /**
@@ -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
- if (fromFile) return fromFile;
126
- const result = parseWorkerResult(String(extractJson(providerResult) ?? providerResult ?? ""));
127
- persistWorkerResultFile(runDir, node.id, result);
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
- if (signal) return failed("canceled", `provider ended after ${signal}`, "canceled");
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 failed(
96
- "quota_exhausted",
97
- boundedMessage(quotaText, 512),
98
- "exhausted",
99
- typeof resultEvent.session_id === "string" ? resultEvent.session_id : null,
100
- canonicalUsage(resultEvent.usage),
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
 
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Freezing a plan: the boundary between a session's draft and a contract the
3
+ * engine can execute. `freezePlan` writes the plan's nodes as a validated
4
+ * contract.json, plus a plan.json carrying that contract's digest and the
5
+ * full provenance of how it was produced — the two files travel together so
6
+ * a later launch and this record agree on exactly what was reviewed.
7
+ * `verifyFrozenPlan` is the one check that the pair still agree.
8
+ *
9
+ * Nothing here invokes a model or the engine; it only writes and hashes
10
+ * bytes, so freezing a plan can never be mistaken for starting a run.
11
+ */
12
+ import { mkdirSync, readFileSync, rmSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import { CONTRACT_VERSION, PROTOCOL_SCHEMA_VERSION, contractDigest, validateContract } from "../contract/index.mjs";
16
+ import { writeJsonAtomic } from "../run/store.mjs";
17
+
18
+ /** @typedef {import("../contract/index.mjs").JsonObject} JsonObject */
19
+
20
+ /** @typedef {{runtimeId: string, model: string}} PlanParticipant */
21
+ /** @typedef {{id: string, severity: "minor"|"major"|"critical", nodeId?: string, text: string}} PlanFinding */
22
+ /** @typedef {{targetGitHead: string|null, planner: PlanParticipant, reviewer: PlanParticipant, sizing: unknown, findings: PlanFinding[]}} PlanProvenanceInput */
23
+ /** @typedef {PlanProvenanceInput & {packageVersion: string, schemaVersion: number, contractVersion: string}} PlanProvenance */
24
+ /** @typedef {{formatVersion: number, contractDigest: string, provenance: PlanProvenance}} FrozenPlan */
25
+ /** @typedef {{ok: boolean, digest: string, expectedDigest: string}} FrozenPlanVerdict */
26
+
27
+ const PLAN_FORMAT_VERSION = 1;
28
+
29
+ /** @returns {string} the installed package's own version, read once per call so a freeze always names the toolchain that produced it */
30
+ function packageVersion() {
31
+ const packageJsonPath = fileURLToPath(new URL("../../package.json", import.meta.url));
32
+ return JSON.parse(readFileSync(packageJsonPath, "utf8")).version;
33
+ }
34
+
35
+ /**
36
+ * Validate `plan` as a contract and, only once it is valid, write it and a
37
+ * sibling plan.json naming its digest and provenance. `plan` supplies
38
+ * `schemaVersion`/`contractVersion` itself; when it does not, this fills in
39
+ * the runner's own current values.
40
+ *
41
+ * contract.json is written before validation runs, because a packet may
42
+ * declare `readFiles: ["contract.json"]` — an execution packet's own file,
43
+ * self-referenced the same way every fixture in this codebase already does.
44
+ * A validation failure removes that file again, so a caller never observes a
45
+ * contract.json that failed its own check.
46
+ *
47
+ * @param {JsonObject} plan
48
+ * @param {{outDir: string, provenance: PlanProvenanceInput}} options
49
+ * @returns {FrozenPlan}
50
+ */
51
+ export function freezePlan(plan, { outDir, provenance }) {
52
+ mkdirSync(outDir, { recursive: true });
53
+ const contractPath = join(outDir, "contract.json");
54
+ const raw = /** @type {JsonObject} */ ({
55
+ schemaVersion: PROTOCOL_SCHEMA_VERSION,
56
+ contractVersion: CONTRACT_VERSION,
57
+ ...plan,
58
+ });
59
+ writeJsonAtomic(contractPath, raw);
60
+ try {
61
+ validateContract(raw, contractPath);
62
+ } catch (error) {
63
+ rmSync(contractPath, { force: true });
64
+ throw error;
65
+ }
66
+ const frozen = /** @type {FrozenPlan} */ ({
67
+ formatVersion: PLAN_FORMAT_VERSION,
68
+ contractDigest: contractDigest(raw),
69
+ provenance: {
70
+ packageVersion: packageVersion(),
71
+ schemaVersion: /** @type {number} */ (raw.schemaVersion),
72
+ contractVersion: /** @type {string} */ (raw.contractVersion),
73
+ targetGitHead: provenance.targetGitHead,
74
+ planner: provenance.planner,
75
+ reviewer: provenance.reviewer,
76
+ sizing: provenance.sizing,
77
+ findings: provenance.findings,
78
+ },
79
+ });
80
+ // Atomic: the pipeline rewrites this file with its status straight after, and a
81
+ // reader polling for the frozen plan must never see a torn or half-written one
82
+ // (measured 2026-09-17: eval case D25 read a statusless plan.json on a slow runner).
83
+ writeJsonAtomic(join(outDir, "plan.json"), frozen);
84
+ return frozen;
85
+ }
86
+
87
+ /**
88
+ * Recompute contract.json's digest from the bytes on disk and compare it
89
+ * with the digest plan.json recorded at freeze time. A single byte changed
90
+ * in either file — the contract re-authored after review, or the plan
91
+ * record itself tampered with — is a mismatch.
92
+ *
93
+ * @param {string} outDir
94
+ * @returns {FrozenPlanVerdict}
95
+ */
96
+ export function verifyFrozenPlan(outDir) {
97
+ const raw = JSON.parse(readFileSync(join(outDir, "contract.json"), "utf8"));
98
+ const plan = JSON.parse(readFileSync(join(outDir, "plan.json"), "utf8"));
99
+ const digest = contractDigest(raw);
100
+ return { ok: digest === plan.contractDigest, digest, expectedDigest: plan.contractDigest };
101
+ }