faberun 0.25.0 → 0.25.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "faberun",
3
- "version": "0.25.0",
3
+ "version": "0.25.1",
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": {
@@ -140,8 +140,10 @@ missing something, never repository-wide exploration. Bounded: 32 KiB total,
140
140
  4 KiB summary, 32 entries each in `changedFiles`/`verification`/`artifacts`
141
141
  (16 in `missingContext`), 2 KiB per entry (16 KiB per artifact). Unknown
142
142
  provider-added fields are dropped; missing/malformed canonical fields are
143
- rejected (`worker-result.mjs`). A discovery node returns `done` with exactly
144
- one `artifacts` entry: the execution packet for the next node.
143
+ rejected (`worker-result.mjs`). A discovery node without `readFiles` returns
144
+ `done` with one `artifacts` entry, the next node's execution packet; one closed
145
+ to its `readFiles` (every planning stage) delivers through `output`, `artifacts`
146
+ `[]`.
145
147
 
146
148
  ## Runtimes and routing
147
149
 
@@ -426,8 +426,11 @@ function pathInside(path, root) {
426
426
  * @returns {string}
427
427
  */
428
428
  function renderDiscoveryPrompt(packet, nodeId) {
429
+ // The closed variant says what settlement accepts (engine/lifecycle.mjs):
430
+ // only a discovery packet with no read files owes an execution packet in
431
+ // artifacts[0]; one closed to its read files delivers through `output`.
429
432
  const closedContext = packet.readFiles.length
430
- ? "This discovery context is read-only and closed to the listed read files. Inspect only those files and do not perform repository-wide exploration. Return the worker-result JSON object below; put exactly one execution task packet JSON string in artifacts[0]."
433
+ ? "This discovery context is read-only and closed to the listed read files. Inspect only those files and do not perform repository-wide exploration. Return the worker-result JSON object below; deliver your result in `output` and send `artifacts` as []."
431
434
  : "This discovery context is read-only and is the one exception to closed inspection: no read files were pre-supplied, so you may inspect the repository read-only only as needed to produce the packet. Do not edit the repository. Return the worker-result JSON object below; put exactly one execution task packet JSON string in artifacts[0].";
432
435
  const lines = [
433
436
  `# Node ${nodeId} (discovery)`,
@@ -25,6 +25,23 @@ const RESULT_LIMITS = Object.freeze({
25
25
  */
26
26
  export const DERIVED_WORKER_RESULT_FIELDS = Object.freeze(["changedFiles"]);
27
27
 
28
+ /**
29
+ * A worker result that is well-formed JSON but breaks one of the byte
30
+ * ceilings above. Typed, not a plain TypeError, so the repair prompt can name
31
+ * the ceiling that was broken: observed 2026-09-25, a planning draft that
32
+ * copied its 38 KiB plan into `artifacts[0]` was told twice to drop markdown
33
+ * fences it never wrote, and repeated the copy.
34
+ */
35
+ export class WorkerResultSizeError extends TypeError {
36
+ /** @param {string} field @param {number} limit */
37
+ constructor(field, limit) {
38
+ super(`${field} exceeds ${limit} bytes`);
39
+ this.name = "WorkerResultSizeError";
40
+ this.field = field;
41
+ this.limit = limit;
42
+ }
43
+ }
44
+
28
45
  /** @typedef {"done"|"blocked_context"} WorkerResultStatus */
29
46
 
30
47
  /**
@@ -37,6 +54,20 @@ export const DERIVED_WORKER_RESULT_FIELDS = Object.freeze(["changedFiles"]);
37
54
  * @typedef {{status: WorkerResultStatus, summary: string, verification: string[], artifacts: string[], missingContext: string[], output?: Record<string, unknown>}} WorkerResult
38
55
  */
39
56
 
57
+ /**
58
+ * The repair instruction for an invalid worker result: a broken byte ceiling
59
+ * is named, since re-emitting the same object without fences cannot fix it.
60
+ *
61
+ * @param {unknown} cause
62
+ * @returns {string}
63
+ */
64
+ export function invalidResultRepair(cause) {
65
+ if (cause instanceof WorkerResultSizeError) {
66
+ return `the result breaks a size ceiling: ${cause.field} exceeds ${cause.limit} bytes. Keep every field within its ceiling; a node that delivers through \`output\` sends \`artifacts\` as [] and never copies \`output\` into \`artifacts\`.`;
67
+ }
68
+ return "the entire final message must be exactly the required JSON object: no markdown fences, no prose before or after it. Return it as the only content of the final message.";
69
+ }
70
+
40
71
  /**
41
72
  * @param {string} value
42
73
  * @returns {WorkerResult}
@@ -45,7 +76,7 @@ export function parseWorkerResult(value) {
45
76
  if (typeof value !== "string") throw new TypeError("worker result must be JSON text");
46
77
  const maxRawBytes = RESULT_LIMITS.bytes + RESULT_LIMITS.outputBytes;
47
78
  if (Buffer.byteLength(value, "utf8") > maxRawBytes) {
48
- throw new TypeError(`worker result exceeds ${maxRawBytes} bytes`);
79
+ throw new WorkerResultSizeError("worker result", maxRawBytes);
49
80
  }
50
81
  let parsed;
51
82
  try {
@@ -100,7 +131,7 @@ export function validateWorkerResult(value) {
100
131
  if (Object.hasOwn(record, "output") && record.output !== undefined) {
101
132
  assertObject(record.output, "worker result.output");
102
133
  if (Buffer.byteLength(JSON.stringify(record.output), "utf8") > RESULT_LIMITS.outputBytes) {
103
- throw new TypeError(`worker result.output exceeds ${RESULT_LIMITS.outputBytes} bytes`);
134
+ throw new WorkerResultSizeError("worker result.output", RESULT_LIMITS.outputBytes);
104
135
  }
105
136
  output = /** @type {Record<string, unknown>} */ (record.output);
106
137
  }
@@ -115,7 +146,7 @@ export function validateWorkerResult(value) {
115
146
  // it is a discovery node's deliverable, not incidental prose competing with
116
147
  // summary/verification for the same 32 KiB budget.
117
148
  if (Buffer.byteLength(JSON.stringify(envelope), "utf8") > RESULT_LIMITS.bytes) {
118
- throw new TypeError(`worker result exceeds ${RESULT_LIMITS.bytes} bytes`);
149
+ throw new WorkerResultSizeError("worker result", RESULT_LIMITS.bytes);
119
150
  }
120
151
  return output === undefined ? envelope : { ...envelope, output };
121
152
  }
@@ -165,6 +196,6 @@ function requireList(value, label, maxItems, itemBytes) {
165
196
  if (!Array.isArray(value) || value.length > maxItems) throw new TypeError(`${label} must be an array with at most ${maxItems} items`);
166
197
  for (const [index, item] of value.entries()) {
167
198
  if (typeof item !== "string") throw new TypeError(`${label}[${index}] must be a string`);
168
- if (Buffer.byteLength(item, "utf8") > itemBytes) throw new TypeError(`${label}[${index}] exceeds ${itemBytes} bytes`);
199
+ if (Buffer.byteLength(item, "utf8") > itemBytes) throw new WorkerResultSizeError(`${label}[${index}]`, itemBytes);
169
200
  }
170
201
  }
@@ -37,7 +37,7 @@ import { getHarness, normalizeProviderAvailability } from "../harnesses/index.mj
37
37
  import { availabilityKey, recordRefusal, refusalOfAvailability } from "../run/availability.mjs";
38
38
 
39
39
  import { acquire as acquireLock } from "../run/lock.mjs";
40
- import { parseDiscoveryResult } from "../contract/worker-result.mjs";
40
+ import { invalidResultRepair, parseDiscoveryResult } from "../contract/worker-result.mjs";
41
41
  import { boundedUtf8, errorCode, errorMessage, excerpt } from "../util.mjs";
42
42
  import { readJson, writeJsonAtomic } from "../run/store.mjs";
43
43
  import { invocationAlive } from "./process.mjs";
@@ -443,7 +443,7 @@ export async function finalizeClosedJobs(contract, runDir, states, closed, lock,
443
443
  // path fails it today.
444
444
  if (workerResultError) {
445
445
  if (envelope.status === "done") {
446
- await applyInvalidWorkerResult(contract, job.node, state, runDir, running, lock, errorMessage(workerResultError), states, campaignPath);
446
+ await applyInvalidWorkerResult(contract, job.node, state, runDir, running, lock, workerResultError, states, campaignPath);
447
447
  continue;
448
448
  }
449
449
  adoptedWorkerResult = null;
@@ -549,7 +549,7 @@ export async function finalizeClosedJobs(contract, runDir, states, closed, lock,
549
549
  }, lock);
550
550
  continue;
551
551
  }
552
- await applyInvalidWorkerResult(contract, job.node, state, runDir, running, lock, errorMessage(error), states, campaignPath);
552
+ await applyInvalidWorkerResult(contract, job.node, state, runDir, running, lock, error, states, campaignPath);
553
553
  continue;
554
554
  }
555
555
  // The artifact demand follows the documented discovery contract, not the
@@ -562,7 +562,7 @@ export async function finalizeClosedJobs(contract, runDir, states, closed, lock,
562
562
  try {
563
563
  parseDiscoveryResult(workerResult, attemptWorkspace(state) ?? contract.cwd);
564
564
  } catch (error) {
565
- await applyInvalidWorkerResult(contract, job.node, state, runDir, running, lock, errorMessage(error), states, campaignPath);
565
+ await applyInvalidWorkerResult(contract, job.node, state, runDir, running, lock, error, states, campaignPath);
566
566
  continue;
567
567
  }
568
568
  }
@@ -737,19 +737,21 @@ function applyRoute(contract, runDir, state, lock, { role, error, current, plan,
737
737
  * told to return. A second one is evidence about the provider rather than the
738
738
  * packet, so the node records a protocol_failure and takes its failover edge —
739
739
  * and when no edge remains, it blocks and raises attention rather than filing
740
- * a quiet exhaustion nobody reads.
740
+ * a quiet exhaustion nobody reads. The repair names what was wrong
741
+ * (invalidResultRepair), so a broken byte ceiling is not told about fences.
741
742
  *
742
- * @param {ValidatedContract} contract @param {ValidatedNode} node @param {NodeSnapshot} state @param {string} runDir @param {Map<string, Job>|null} running @param {LockHandle} lock @param {string} message @param {Map<string, NodeSnapshot>} states @param {string} campaignPath
743
+ * @param {ValidatedContract} contract @param {ValidatedNode} node @param {NodeSnapshot} state @param {string} runDir @param {Map<string, Job>|null} running @param {LockHandle} lock @param {unknown} cause @param {Map<string, NodeSnapshot>} states @param {string} campaignPath
743
744
  */
744
- export async function applyInvalidWorkerResult(contract, node, state, runDir, running, lock, message, states, campaignPath) {
745
+ export async function applyInvalidWorkerResult(contract, node, state, runDir, running, lock, cause, states, campaignPath) {
745
746
  clearTierExhaustion(state);
747
+ const message = errorMessage(cause);
746
748
  const verdict = /** @type {JudgeVerdict} */ ({
747
749
  verdict: "fail",
748
750
  maxSeverity: "critical",
749
751
  summary: "worker result did not match the structured result protocol",
750
752
  findings: [{
751
753
  severity: "critical",
752
- description: "the entire final message must be exactly the required JSON object: no markdown fences, no prose before or after it. Return it as the only content of the final message.",
754
+ description: invalidResultRepair(cause),
753
755
  evidence: boundedUtf8(message, 4 * 1024),
754
756
  }],
755
757
  });
@@ -284,7 +284,7 @@ export async function resumeRun(runDirPath, options = {}) {
284
284
  ? recoverWorkerResult(runDir, state, contract, node)
285
285
  : canonicalWorkerResultText(runDir, node.id) ?? recovery.result;
286
286
  } catch (error) {
287
- await applyInvalidWorkerResult(contract, node, state, runDir, null, lock, errorMessage(error), states, campaign.path);
287
+ await applyInvalidWorkerResult(contract, node, state, runDir, null, lock, error, states, campaign.path);
288
288
  continue;
289
289
  }
290
290
  if (recovery.phase === "worker" && workerResult !== null && workerResult !== undefined) {
@@ -301,7 +301,7 @@ export async function resumeRun(runDirPath, options = {}) {
301
301
  try {
302
302
  parsedWorkerResult = parseWorkerResult(String(extractJson(workerResult) ?? workerResult));
303
303
  } catch (error) {
304
- await applyInvalidWorkerResult(contract, node, state, runDir, null, lock, errorMessage(error), states, campaign.path);
304
+ await applyInvalidWorkerResult(contract, node, state, runDir, null, lock, error, states, campaign.path);
305
305
  continue;
306
306
  }
307
307
  // Same discriminator as lifecycle.mjs: the artifact is owed only by
@@ -311,7 +311,7 @@ export async function resumeRun(runDirPath, options = {}) {
311
311
  try {
312
312
  parseDiscoveryResult(parsedWorkerResult, attemptWorkspace(state) ?? contract.cwd);
313
313
  } catch (error) {
314
- await applyInvalidWorkerResult(contract, node, state, runDir, null, lock, errorMessage(error), states, campaign.path);
314
+ await applyInvalidWorkerResult(contract, node, state, runDir, null, lock, error, states, campaign.path);
315
315
  continue;
316
316
  }
317
317
  }