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.
|
|
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
|
|
144
|
-
one `artifacts` entry
|
|
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;
|
|
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
|
|
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
|
|
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
|
|
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
|
|
199
|
+
if (Buffer.byteLength(item, "utf8") > itemBytes) throw new WorkerResultSizeError(`${label}[${index}]`, itemBytes);
|
|
169
200
|
}
|
|
170
201
|
}
|
package/src/engine/lifecycle.mjs
CHANGED
|
@@ -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,
|
|
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,
|
|
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,
|
|
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 {
|
|
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,
|
|
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:
|
|
754
|
+
description: invalidResultRepair(cause),
|
|
753
755
|
evidence: boundedUtf8(message, 4 * 1024),
|
|
754
756
|
}],
|
|
755
757
|
});
|
package/src/engine/resume.mjs
CHANGED
|
@@ -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,
|
|
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,
|
|
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,
|
|
314
|
+
await applyInvalidWorkerResult(contract, node, state, runDir, null, lock, error, states, campaign.path);
|
|
315
315
|
continue;
|
|
316
316
|
}
|
|
317
317
|
}
|