faberun 0.23.0 → 0.24.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 +2 -2
- package/src/campaign/chain.mjs +21 -8
- package/src/campaign/index.mjs +13 -1
- package/src/campaign/metrics.mjs +59 -2
- package/src/cli/campaign.mjs +3 -2
- package/src/cli/contract.mjs +26 -7
- package/src/contract/definition-of-done.mjs +30 -3
- package/src/contract/index.mjs +2 -1
- package/src/engine/dispatch.mjs +23 -1
- package/src/engine/lifecycle.mjs +5 -6
- package/src/plan/proof-run.mjs +67 -13
- package/src/plan/template.mjs +1 -1
- package/src/repo/worktree.mjs +4 -1
- package/src/run/lock.mjs +2 -12
- package/src/run/store.mjs +30 -2
- package/src/util.mjs +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "faberun",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.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": {
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"node": ">=22"
|
|
25
25
|
},
|
|
26
26
|
"scripts": {
|
|
27
|
-
"check": "node -e \"const{readdirSync}=require('node:fs');const{spawnSync}=require('node:child_process');const roots=['bin','.claude/hooks','src','evals','test'];const files=roots.flatMap(r=>readdirSync(r,{recursive:true}).map(String).filter(p=>p.endsWith('.mjs')).map(p=>r+'/'+p));for(const f of files)if(spawnSync(process.execPath,['--check',f],{stdio:'inherit'}).status!==0)process.exit(1);console.log(files.length+' files checked')\"",
|
|
27
|
+
"check": "node -e \"const{readdirSync}=require('node:fs');const{spawnSync}=require('node:child_process');const roots=['bin','.claude/hooks','src','evals','test'];const files=roots.flatMap(r=>readdirSync(r,{recursive:true}).map(String).filter(p=>p.endsWith('.mjs')&&!p.includes('.runs')).map(p=>r+'/'+p));for(const f of files)if(spawnSync(process.execPath,['--check',f],{stdio:'inherit'}).status!==0)process.exit(1);console.log(files.length+' files checked')\"",
|
|
28
28
|
"typecheck": "tsc",
|
|
29
29
|
"check:campaign-brief-render": "node src/report/campaign-brief-html.mjs",
|
|
30
30
|
"test": "node --test --import ./test/scoped-home.mjs --import ./test/git-env.mjs --import ./test/setup.mjs test/*.test.mjs test/*/*.test.mjs",
|
package/src/campaign/chain.mjs
CHANGED
|
@@ -32,7 +32,7 @@ import { execFileSync } from "node:child_process";
|
|
|
32
32
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
33
33
|
import { hostname, tmpdir } from "node:os";
|
|
34
34
|
import { dirname, join, relative, resolve } from "node:path";
|
|
35
|
-
import { assertContractManifestIntact, parkCampaign, promoteRunInCampaign } from "./index.mjs";
|
|
35
|
+
import { assertContractManifestIntact, parkCampaign, promoteRunInCampaign, recordOperatorCommand } from "./index.mjs";
|
|
36
36
|
import { readCampaign } from "./record.mjs";
|
|
37
37
|
import { validateContract } from "../contract/index.mjs";
|
|
38
38
|
import { defaultControllerIdentity, storedContractDigest, verifyControllerIdentity } from "../engine/run-identity.mjs";
|
|
@@ -386,6 +386,7 @@ function storedContractId(stored) {
|
|
|
386
386
|
* maxTicks?: number,
|
|
387
387
|
* coordination?: boolean,
|
|
388
388
|
* controllerIdentity?: ControllerIdentity,
|
|
389
|
+
* refreshController?: boolean,
|
|
389
390
|
* launch?: (contractPath: string, context: {baseRef: string|undefined, controllerIdentity: ControllerIdentity, runDir: string, contract: ValidatedContract}) => Promise<void>|void,
|
|
390
391
|
* validate?: (entry: CampaignContract, context: {repo: string|undefined, baseRef: string|undefined, contractPath: string}) => ValidatedContract,
|
|
391
392
|
* progress?: (runDir: string) => RunProgress,
|
|
@@ -551,13 +552,25 @@ export async function driveCampaignChain(campaignPath, options = {}) {
|
|
|
551
552
|
try {
|
|
552
553
|
verifyControllerIdentity(controllerIdentity);
|
|
553
554
|
} catch (error) {
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
555
|
+
// A campaign whose contracts change faberun's own src/ changes the
|
|
556
|
+
// controller between launches (measured on evidence-you-can-recompute).
|
|
557
|
+
// Adopting the new snapshot is the operator's call, so it happens only
|
|
558
|
+
// under --refresh-controller and is recorded as an operator command.
|
|
559
|
+
if (options.refreshController === true && errorCode(error) === "controller_snapshot_changed") {
|
|
560
|
+
controllerIdentity = defaultControllerIdentity();
|
|
561
|
+
recordOperatorCommand(campaignPath, "campaign supervise --refresh-controller");
|
|
562
|
+
emit(`[campaign] controller snapshot refreshed · ${controllerIdentity.sha.slice(0, 12)}`);
|
|
563
|
+
} else {
|
|
564
|
+
return park({
|
|
565
|
+
code: errorCode(error) ?? "controller_snapshot_changed",
|
|
566
|
+
message: errorCode(error) === "controller_snapshot_changed"
|
|
567
|
+
? `${errorMessage(error)}: \`faberun campaign supervise ${readCampaign(campaignPath).id} --refresh-controller\``
|
|
568
|
+
: errorMessage(error),
|
|
569
|
+
contractPath: entry.path,
|
|
570
|
+
contractId: id,
|
|
571
|
+
runId: id,
|
|
572
|
+
});
|
|
573
|
+
}
|
|
561
574
|
}
|
|
562
575
|
try {
|
|
563
576
|
await launch(entry.path, { baseRef, controllerIdentity, runDir, contract });
|
package/src/campaign/index.mjs
CHANGED
|
@@ -401,10 +401,22 @@ export function addContractToCampaign(campaignPath, contractPath, { at = new Dat
|
|
|
401
401
|
: campaign.contracts.map((existing, index) => (index === existingIndex ? entry : existing));
|
|
402
402
|
const updated = /** @type {Campaign} */ ({ ...campaign, contracts, updatedAt: at });
|
|
403
403
|
writeJsonAtomic(join(campaignPath, CAMPAIGN_FILE), updated);
|
|
404
|
-
|
|
404
|
+
recordOperatorCommand(campaignPath, "campaign add-contract", at);
|
|
405
405
|
return { campaign: updated, added: true };
|
|
406
406
|
}
|
|
407
407
|
|
|
408
|
+
/**
|
|
409
|
+
* The one writer of `operator.command` (docs/FIELD-OWNERSHIP.md): an operator
|
|
410
|
+
* command that changed campaign state, which `humanTouches` counts.
|
|
411
|
+
*
|
|
412
|
+
* @param {string} campaignPath
|
|
413
|
+
* @param {string} command
|
|
414
|
+
* @param {string} [at]
|
|
415
|
+
*/
|
|
416
|
+
export function recordOperatorCommand(campaignPath, command, at = new Date().toISOString()) {
|
|
417
|
+
appendJournal(campaignPath, { type: "operator.command", eventId: randomUUID(), at, command });
|
|
418
|
+
}
|
|
419
|
+
|
|
408
420
|
/**
|
|
409
421
|
* Replace a manifest entry -- matched by its current, recorded path -- with a
|
|
410
422
|
* freshly authored contract. Re-authoring a phase after a blocked or failed
|
package/src/campaign/metrics.mjs
CHANGED
|
@@ -51,6 +51,7 @@ const UNKNOWN_COST_PROVENANCE = "unknown";
|
|
|
51
51
|
/** @typedef {"down"|"up"|"informative"} Direction */
|
|
52
52
|
/** @typedef {{value: number|null, direction: Direction, count: number, numerator?: number, denominator?: number, excludedRunIds?: string[], missingSources?: string[]}} Indicator */
|
|
53
53
|
/** @typedef {Indicator & {unknownCount: number, unknownCountByReason: Record<string, number>, unknownFractionByReason: Record<string, number>}} CostIndicator */
|
|
54
|
+
/** @typedef {Indicator & {unknownCount: number}} JudgeCostShareIndicator */
|
|
54
55
|
/** @typedef {{value: Record<string, number>|null, direction: Direction, count: number, numerator?: Record<string, number>, denominator?: Record<string, number>, excludedRunIds?: string[], missingSources?: string[]}} GroupedIndicator */
|
|
55
56
|
/** @typedef {{atMs: number, index: number, event: JsonObject}} RunEvent */
|
|
56
57
|
/** @typedef {{runId: string, id: string, status: string, attempt?: number|null, revisions?: number|null, review?: string|null}} RunNode */
|
|
@@ -79,6 +80,8 @@ const UNKNOWN_COST_PROVENANCE = "unknown";
|
|
|
79
80
|
* usageTokensByKind: GroupedIndicator,
|
|
80
81
|
* usageTokensByKindByRuntime: GroupedIndicator,
|
|
81
82
|
* usageCostUsd: CostIndicator,
|
|
83
|
+
* judgeFindingRate: Indicator,
|
|
84
|
+
* judgeCostShare: JudgeCostShareIndicator,
|
|
82
85
|
* blockingJudgeFirstPassRate: GroupedIndicator,
|
|
83
86
|
* notifyReceiptRate: Indicator,
|
|
84
87
|
* silentStallRate: Indicator,
|
|
@@ -101,6 +104,10 @@ export function projectMetrics({ events = [], usageRecords = [], notifications =
|
|
|
101
104
|
const excluded = excludedRunIdsFor(campaign, excludedRunIds);
|
|
102
105
|
const eligibleNodes = nodes.filter((node) => !excluded.includes(node.runId));
|
|
103
106
|
const eligibleEvents = eventList.filter((event) => typeof event.runId !== "string" || !excluded.includes(event.runId));
|
|
107
|
+
const eligibleUsageRecords = usageRecords.filter((record) => {
|
|
108
|
+
const object = jsonObjectOf(record);
|
|
109
|
+
return object === null || typeof object.runId !== "string" || !excluded.includes(object.runId);
|
|
110
|
+
});
|
|
104
111
|
const eligibleNotifications = notifications.filter((record) => {
|
|
105
112
|
const object = jsonObjectOf(record);
|
|
106
113
|
return object === null || typeof object.runId !== "string" || !excluded.includes(object.runId);
|
|
@@ -110,6 +117,7 @@ export function projectMetrics({ events = [], usageRecords = [], notifications =
|
|
|
110
117
|
const closedCheckpoints = closedCheckpointsOf(nodes);
|
|
111
118
|
const span = eventSpanOf(eventList);
|
|
112
119
|
const usage = usageTotalsOf(usageRecords);
|
|
120
|
+
const judgeYield = judgeYieldOf(eligibleEvents, eligibleUsageRecords);
|
|
113
121
|
const gates = blockingJudgeFirstPassRateOf(eligibleNodes, eligibleEvents);
|
|
114
122
|
const notify = notifyReceiptRateOf(eligibleNotifications);
|
|
115
123
|
const stalls = silentStallRateOf(eligibleNodes, eligibleEvents);
|
|
@@ -129,6 +137,11 @@ export function projectMetrics({ events = [], usageRecords = [], notifications =
|
|
|
129
137
|
unknownCountByReason: usage.unknownCountByReason,
|
|
130
138
|
unknownFractionByReason: usage.unknownFractionByReason,
|
|
131
139
|
},
|
|
140
|
+
judgeFindingRate: rate("up", judgeYield.findings, judgeYield.judged, excluded),
|
|
141
|
+
judgeCostShare: {
|
|
142
|
+
...measured("down", usage.costCount, usage.costUsd === null ? null : usage.judgeCostUsd / usage.costUsd),
|
|
143
|
+
unknownCount: usage.unknownJudgeCount,
|
|
144
|
+
},
|
|
132
145
|
blockingJudgeFirstPassRate: groupedRate("up", gates, excluded),
|
|
133
146
|
notifyReceiptRate: rate("up", notify.satisfied, notify.count, excluded),
|
|
134
147
|
silentStallRate: rate("down", stalls.stalled, stalls.activeHours, excluded, stalls.activeIntervals),
|
|
@@ -162,6 +175,8 @@ function applyMissingSources(metrics, missingSources) {
|
|
|
162
175
|
usageTokensByKind: withMissing(metrics.usageTokensByKind, ["usage.jsonl"]),
|
|
163
176
|
usageTokensByKindByRuntime: withMissing(metrics.usageTokensByKindByRuntime, ["usage.jsonl"]),
|
|
164
177
|
usageCostUsd: withMissing(metrics.usageCostUsd, ["usage.jsonl"]),
|
|
178
|
+
judgeFindingRate: withMissing(metrics.judgeFindingRate, ["events.jsonl", "usage.jsonl"]),
|
|
179
|
+
judgeCostShare: withMissing(metrics.judgeCostShare, ["usage.jsonl"]),
|
|
165
180
|
blockingJudgeFirstPassRate: withMissing(metrics.blockingJudgeFirstPassRate, ["nodes.json", "events.jsonl"]),
|
|
166
181
|
notifyReceiptRate: withMissing(metrics.notifyReceiptRate, ["notify.jsonl"]),
|
|
167
182
|
silentStallRate: withMissing(metrics.silentStallRate, ["nodes.json", "events.jsonl"]),
|
|
@@ -311,7 +326,7 @@ function humanTouchesOf(journal, events) {
|
|
|
311
326
|
/** @param {JsonObject} entry @returns {boolean} */
|
|
312
327
|
function isRecordedJournalTouch(entry) {
|
|
313
328
|
return entry.type === "question.resolved"
|
|
314
|
-
|| (entry.type === "operator.command" &&
|
|
329
|
+
|| (entry.type === "operator.command" && ["campaign add-contract", "campaign supervise --refresh-controller"].includes(String(entry.command)));
|
|
315
330
|
}
|
|
316
331
|
|
|
317
332
|
/** @param {JsonObject} entry @returns {boolean} */
|
|
@@ -397,7 +412,7 @@ function eventSpanOf(events) {
|
|
|
397
412
|
* invocation is counted separately rather than folded into a measured zero.
|
|
398
413
|
*
|
|
399
414
|
* @param {unknown[]} usageRecords
|
|
400
|
-
* @returns {{tokensByKind: Record<string, number>, tokensByKindByRuntime: Record<string, number>, tokenCount: number, costUsd: number|null, costCount: number, unknownCount: number, unknownCountByReason: Record<string, number>, unknownFractionByReason: Record<string, number>}}
|
|
415
|
+
* @returns {{tokensByKind: Record<string, number>, tokensByKindByRuntime: Record<string, number>, tokenCount: number, costUsd: number|null, judgeCostUsd: number, costCount: number, unknownCount: number, unknownJudgeCount: number, unknownCountByReason: Record<string, number>, unknownFractionByReason: Record<string, number>}}
|
|
401
416
|
*/
|
|
402
417
|
function usageTotalsOf(usageRecords) {
|
|
403
418
|
const tokensByKind = { inputTokens: 0, cacheReadInputTokens: 0, outputTokens: 0 };
|
|
@@ -405,8 +420,10 @@ function usageTotalsOf(usageRecords) {
|
|
|
405
420
|
const tokensByKindByRuntime = {};
|
|
406
421
|
let tokenCount = 0;
|
|
407
422
|
let costUsd = 0;
|
|
423
|
+
let judgeCostUsd = 0;
|
|
408
424
|
let costCount = 0;
|
|
409
425
|
let unknownCount = 0;
|
|
426
|
+
let unknownJudgeCount = 0;
|
|
410
427
|
/** @type {Record<string, number>} */
|
|
411
428
|
const unknownCountByReason = {};
|
|
412
429
|
const kinds = /** @type {("inputTokens"|"cacheReadInputTokens"|"outputTokens")[]} */ (["inputTokens", "cacheReadInputTokens", "outputTokens"]);
|
|
@@ -425,9 +442,11 @@ function usageTotalsOf(usageRecords) {
|
|
|
425
442
|
if (measuredAny) tokenCount += 1;
|
|
426
443
|
if (typeof record.costUsd === "number" && Number.isFinite(record.costUsd) && record.costProvenance !== UNKNOWN_COST_PROVENANCE) {
|
|
427
444
|
costUsd += record.costUsd;
|
|
445
|
+
if (record.role === "judge") judgeCostUsd += record.costUsd;
|
|
428
446
|
costCount += 1;
|
|
429
447
|
} else {
|
|
430
448
|
unknownCount += 1;
|
|
449
|
+
if (record.role === "judge") unknownJudgeCount += 1;
|
|
431
450
|
const reason = typeof record.unknownReason === "string" && UNKNOWN_COST_REASONS.includes(record.unknownReason)
|
|
432
451
|
? record.unknownReason
|
|
433
452
|
: "legacy";
|
|
@@ -443,13 +462,51 @@ function usageTotalsOf(usageRecords) {
|
|
|
443
462
|
tokensByKindByRuntime,
|
|
444
463
|
tokenCount,
|
|
445
464
|
costUsd: costCount === 0 ? null : costUsd,
|
|
465
|
+
judgeCostUsd,
|
|
446
466
|
costCount,
|
|
447
467
|
unknownCount,
|
|
468
|
+
unknownJudgeCount,
|
|
448
469
|
unknownCountByReason,
|
|
449
470
|
unknownFractionByReason,
|
|
450
471
|
};
|
|
451
472
|
}
|
|
452
473
|
|
|
474
|
+
/**
|
|
475
|
+
* Count logical nodes that reached a judge and those whose judge recorded at
|
|
476
|
+
* least one finding. A failed judge verdict is the durable ledger projection
|
|
477
|
+
* of a non-empty findings array; a complete-phase fail is included for
|
|
478
|
+
* advisory reviews that settle the node after reporting their finding.
|
|
479
|
+
*
|
|
480
|
+
* @param {JsonObject[]} events
|
|
481
|
+
* @param {unknown[]} usageRecords
|
|
482
|
+
* @returns {{judged: number, findings: number}}
|
|
483
|
+
*/
|
|
484
|
+
function judgeYieldOf(events, usageRecords) {
|
|
485
|
+
const judged = new Set();
|
|
486
|
+
const findings = new Set();
|
|
487
|
+
for (const raw of usageRecords) {
|
|
488
|
+
const record = jsonObjectOf(raw);
|
|
489
|
+
if (record === null || record.role !== "judge" || typeof record.nodeId !== "string") continue;
|
|
490
|
+
judged.add(logicalNodeKey(record.runId, record.nodeId));
|
|
491
|
+
}
|
|
492
|
+
for (const event of events) {
|
|
493
|
+
if (typeof event.node !== "string") continue;
|
|
494
|
+
const key = logicalNodeKey(event.runId, event.node);
|
|
495
|
+
if (event.phase === "judge") judged.add(key);
|
|
496
|
+
const hasFindingArray = Array.isArray(event.findings) && event.findings.length > 0;
|
|
497
|
+
if ((event.phase === "judge" || event.phase === "complete") && (event.verdict === "fail" || hasFindingArray)) {
|
|
498
|
+
judged.add(key);
|
|
499
|
+
findings.add(key);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return { judged: judged.size, findings: findings.size };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** @param {unknown} runId @param {string} nodeId @returns {string} */
|
|
506
|
+
function logicalNodeKey(runId, nodeId) {
|
|
507
|
+
return `${typeof runId === "string" ? runId : ""}:${nodeId}`;
|
|
508
|
+
}
|
|
509
|
+
|
|
453
510
|
/**
|
|
454
511
|
* Fraction of blocking-gated checkpoints whose first recorded verdict passed,
|
|
455
512
|
* per lane (the judge runtime that produced it). A node under `advisory` or
|
package/src/cli/campaign.mjs
CHANGED
|
@@ -84,7 +84,7 @@ const OPERATION_OPTIONS = {
|
|
|
84
84
|
},
|
|
85
85
|
close: { cwd: { type: "string" }, "event-id": { type: "string" } },
|
|
86
86
|
reledger: { cwd: { type: "string" } },
|
|
87
|
-
supervise: { cwd: { type: "string" }, "allow-main": { type: "boolean" } },
|
|
87
|
+
supervise: { cwd: { type: "string" }, "allow-main": { type: "boolean" }, "refresh-controller": { type: "boolean" } },
|
|
88
88
|
unpark: { cwd: { type: "string" }, force: { type: "boolean" }, "event-id": { type: "string" } },
|
|
89
89
|
"add-contract": { cwd: { type: "string" }, path: { type: "string" } },
|
|
90
90
|
"replace-contract": { cwd: { type: "string" }, path: { type: "string" }, replace: { type: "string" } },
|
|
@@ -94,7 +94,7 @@ const OPERATION_OPTIONS = {
|
|
|
94
94
|
ack: { cwd: { type: "string" }, "session-id": { type: "string" }, "event-id": { type: "string" } },
|
|
95
95
|
};
|
|
96
96
|
|
|
97
|
-
/** @typedef {{cwd?: string, goal?: string, contract?: string[], landBranch?: string, tool?: string, sessionId?: string, transcript?: string, format?: string, cursor?: string, since?: string, kind?: string, text?: string, runId?: string, supersedes?: string, decisionId?: string, questionId?: string, eventId?: string, noTranscript?: boolean, wake?: boolean, detach?: boolean, interval?: string, once?: boolean, allowMain?: boolean, force?: boolean, path?: string, replace?: string, phase?: string}} CliValues */
|
|
97
|
+
/** @typedef {{cwd?: string, goal?: string, contract?: string[], landBranch?: string, tool?: string, sessionId?: string, transcript?: string, format?: string, cursor?: string, since?: string, kind?: string, text?: string, runId?: string, supersedes?: string, decisionId?: string, questionId?: string, eventId?: string, noTranscript?: boolean, wake?: boolean, detach?: boolean, interval?: string, once?: boolean, allowMain?: boolean, refreshController?: boolean, force?: boolean, path?: string, replace?: string, phase?: string}} CliValues */
|
|
98
98
|
/** @typedef {import("../campaign/index.mjs").Campaign} Campaign */
|
|
99
99
|
|
|
100
100
|
/**
|
|
@@ -398,6 +398,7 @@ async function supervise(campaignId, values) {
|
|
|
398
398
|
const outcome = await driveCampaignChain(path, {
|
|
399
399
|
repo: cwd,
|
|
400
400
|
allowMain: values.allowMain === true,
|
|
401
|
+
refreshController: values.refreshController === true,
|
|
401
402
|
emit: (line) => process.stdout.write(`${line}\n`),
|
|
402
403
|
launch: async (contractPath, { baseRef, runDir }) => {
|
|
403
404
|
const child = detachSelf("run", contractPath, baseRef ? ["--base-ref", baseRef] : []);
|
package/src/cli/contract.mjs
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { existsSync, readFileSync } from "node:fs";
|
|
8
8
|
import { dirname, join, resolve } from "node:path";
|
|
9
|
+
import { parseArgs as parseFlags } from "node:util";
|
|
9
10
|
import { loadPersistedContract, validateContract } from "../contract/index.mjs";
|
|
10
11
|
|
|
11
12
|
/** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
|
|
@@ -13,7 +14,7 @@ import { loadPersistedContract, validateContract } from "../contract/index.mjs";
|
|
|
13
14
|
/** Flags are scoped to the operation that declares them; all others are rejected. */
|
|
14
15
|
/** @type {Record<string, import("node:util").ParseArgsOptionsConfig>} */
|
|
15
16
|
const OPERATION_OPTIONS = {
|
|
16
|
-
validate: {},
|
|
17
|
+
validate: { "strict-traceability": { type: "boolean" } },
|
|
17
18
|
};
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -23,9 +24,17 @@ const OPERATION_OPTIONS = {
|
|
|
23
24
|
export function contractCli(args) {
|
|
24
25
|
const operation = args[0];
|
|
25
26
|
if (!operation || !Object.hasOwn(OPERATION_OPTIONS, operation)) return usage();
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
let parsed;
|
|
28
|
+
try {
|
|
29
|
+
parsed = parseFlags({ args: args.slice(1), options: OPERATION_OPTIONS[operation], allowPositionals: true, strict: true });
|
|
30
|
+
} catch (error) {
|
|
31
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
32
|
+
return usage();
|
|
33
|
+
}
|
|
34
|
+
const target = parsed.positionals[0];
|
|
35
|
+
if (!target || parsed.positionals.length > 1) return usage();
|
|
36
|
+
const values = /** @type {{"strict-traceability"?: boolean}} */ (parsed.values);
|
|
37
|
+
if (operation === "validate") validateContractFile(resolve(target), { strictTraceability: values["strict-traceability"] === true });
|
|
29
38
|
}
|
|
30
39
|
|
|
31
40
|
/**
|
|
@@ -38,19 +47,29 @@ export function contractCli(args) {
|
|
|
38
47
|
* authoring validation, tree reads included.
|
|
39
48
|
*
|
|
40
49
|
* @param {string} path
|
|
50
|
+
* @param {{strictTraceability?: boolean}} [options]
|
|
41
51
|
* @returns {ValidatedContract}
|
|
42
52
|
*/
|
|
43
|
-
export function validateContractFile(path) {
|
|
53
|
+
export function validateContractFile(path, { strictTraceability = false } = {}) {
|
|
44
54
|
const runJsonPath = join(dirname(path), "run.json");
|
|
45
55
|
const contract = existsSync(runJsonPath)
|
|
46
56
|
? loadPersistedContract(path, readRunDigest(runJsonPath))
|
|
47
57
|
: validateContract(JSON.parse(readFileSync(path, "utf8")), path);
|
|
48
58
|
const count = contract.warnings.length;
|
|
49
|
-
|
|
59
|
+
const blockingTraceability = strictTraceability
|
|
60
|
+
? contract.warnings.filter((warning) => isTraceabilityFinding(warning))
|
|
61
|
+
: [];
|
|
62
|
+
process.stdout.write(`${blockingTraceability.length > 0 ? "invalid" : "valid"}${count ? ` (${count} warning${count === 1 ? "" : "s"})` : ""}\n`);
|
|
50
63
|
for (const warning of contract.warnings) process.stdout.write(`[warn] ${warning}\n`);
|
|
64
|
+
if (blockingTraceability.length > 0) process.exitCode = 1;
|
|
51
65
|
return contract;
|
|
52
66
|
}
|
|
53
67
|
|
|
68
|
+
/** @param {string} warning @returns {boolean} */
|
|
69
|
+
function isTraceabilityFinding(warning) {
|
|
70
|
+
return warning.includes("judgment_without_reason") || warning.includes("judgment_beside_mechanical_proof");
|
|
71
|
+
}
|
|
72
|
+
|
|
54
73
|
/**
|
|
55
74
|
* @param {string} runJsonPath
|
|
56
75
|
* @returns {string|undefined}
|
|
@@ -62,7 +81,7 @@ function readRunDigest(runJsonPath) {
|
|
|
62
81
|
|
|
63
82
|
/** @returns {void} */
|
|
64
83
|
function usage() {
|
|
65
|
-
process.stderr.write("usage: faberun contract validate <contract.json
|
|
84
|
+
process.stderr.write("usage: faberun contract validate <contract.json> [--strict-traceability]\n");
|
|
66
85
|
process.exitCode = 2;
|
|
67
86
|
}
|
|
68
87
|
|
|
@@ -9,7 +9,7 @@ import { rejectUnknown, requireId, requireString } from "./assert.mjs";
|
|
|
9
9
|
|
|
10
10
|
/** @typedef {{kind: "command"|"path"|"verification", ref: string}} DefinitionOfDoneProof */
|
|
11
11
|
|
|
12
|
-
/** @typedef {{id: string, text: string, proof?: DefinitionOfDoneProof, judgment?: true}} DefinitionOfDoneItem */
|
|
12
|
+
/** @typedef {{id: string, text: string, proof?: DefinitionOfDoneProof, judgment?: true, reason?: string}} DefinitionOfDoneItem */
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* @param {unknown} value
|
|
@@ -28,12 +28,16 @@ export function validateDefinitionOfDone(value, label, options = {}) {
|
|
|
28
28
|
throw new TypeError(`${itemLabel} must be an object with id, text, and proof or judgment: true`);
|
|
29
29
|
}
|
|
30
30
|
const record = /** @type {Record<string, unknown>} */ (item);
|
|
31
|
-
rejectUnknown(record, new Set(["id", "text", "proof", "judgment"]), itemLabel);
|
|
31
|
+
rejectUnknown(record, new Set(["id", "text", "proof", "judgment", "reason"]), itemLabel);
|
|
32
32
|
requireId(record.id, `${itemLabel}.id`);
|
|
33
33
|
requireString(record.text, `${itemLabel}.text`);
|
|
34
34
|
if (record.judgment !== undefined && record.judgment !== true) {
|
|
35
35
|
throw new TypeError(`${itemLabel}.judgment must be true when present`);
|
|
36
36
|
}
|
|
37
|
+
if (record.reason !== undefined) {
|
|
38
|
+
if (record.judgment !== true) throw new TypeError(`${itemLabel}.reason requires judgment: true`);
|
|
39
|
+
requireString(record.reason, `${itemLabel}.reason`);
|
|
40
|
+
}
|
|
37
41
|
const proof = record.proof === undefined
|
|
38
42
|
? undefined
|
|
39
43
|
: validateProof(record.proof, `${itemLabel}.proof`, options);
|
|
@@ -45,10 +49,34 @@ export function validateDefinitionOfDone(value, label, options = {}) {
|
|
|
45
49
|
text: /** @type {string} */ (record.text),
|
|
46
50
|
...(proof === undefined ? {} : { proof }),
|
|
47
51
|
...(record.judgment === true ? { judgment: true } : {}),
|
|
52
|
+
...(record.reason === undefined ? {} : { reason: /** @type {string} */ (record.reason) }),
|
|
48
53
|
};
|
|
49
54
|
});
|
|
50
55
|
}
|
|
51
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Report judgment items that do not explain what mechanical verification cannot
|
|
59
|
+
* observe. These remain advisory because `contract validate` has no strict
|
|
60
|
+
* traceability flag; a later strict contract command can promote these codes.
|
|
61
|
+
*
|
|
62
|
+
* @param {DefinitionOfDoneItem[]} items
|
|
63
|
+
* @param {number} index
|
|
64
|
+
* @returns {string[]}
|
|
65
|
+
*/
|
|
66
|
+
export function judgmentReasonWarnings(items, index) {
|
|
67
|
+
const judgmentItems = items.filter((item) => item.judgment === true);
|
|
68
|
+
const warnings = items.flatMap((item, itemIndex) => item.judgment === true && item.reason === undefined
|
|
69
|
+
? [`nodes[${index}] (definitionOfDone[${itemIndex}]): judgment_without_reason: judgment item must say what no command verifies`]
|
|
70
|
+
: []);
|
|
71
|
+
const hasMechanicalProof = items.some((item) => item.proof !== undefined);
|
|
72
|
+
if (judgmentItems.length > 0 && hasMechanicalProof && judgmentItems.every((item) => item.reason === undefined)) {
|
|
73
|
+
warnings.push(
|
|
74
|
+
`nodes[${index}]: judgment_beside_mechanical_proof: every judgment item shares this node with mechanical-proof items but declares no reason`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return warnings;
|
|
78
|
+
}
|
|
79
|
+
|
|
52
80
|
/**
|
|
53
81
|
* @param {unknown} value
|
|
54
82
|
* @param {string} label
|
|
@@ -144,4 +172,3 @@ export function unquotedFilterValueWarnings(items, index) {
|
|
|
144
172
|
|
|
145
173
|
/** The node:test filter flags a `kind: "command"` proof's shell can split on an unquoted value. */
|
|
146
174
|
const TEST_FILTER_FLAGS = ["--test-name-pattern", "--test-skip-pattern"];
|
|
147
|
-
|
package/src/contract/index.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { readFileSync, statSync } from "node:fs";
|
|
|
3
3
|
import { dirname, resolve } from "node:path";
|
|
4
4
|
import { loadTaskPacket, renderWorkerPrompt } from "./task-packet.mjs";
|
|
5
5
|
import { RESERVED_ARTICLES } from "./articles.mjs";
|
|
6
|
-
import { unquotedFilterValueWarnings, validateDefinitionOfDone } from "./definition-of-done.mjs";
|
|
6
|
+
import { judgmentReasonWarnings, unquotedFilterValueWarnings, validateDefinitionOfDone } from "./definition-of-done.mjs";
|
|
7
7
|
import { validateFinalVerification, validateSharedVerification } from "./final-verification.mjs";
|
|
8
8
|
import { VERIFICATION_LIMITS, requirementProofWarnings } from "./verification.mjs";
|
|
9
9
|
import {
|
|
@@ -378,6 +378,7 @@ export function validateContract(raw, contractPath, options = {}) {
|
|
|
378
378
|
...nodes.flatMap((node, index) => [
|
|
379
379
|
...commandCoverageWarnings(node, index),
|
|
380
380
|
...unquotedFilterValueWarnings(node.definitionOfDone ?? [], index),
|
|
381
|
+
...judgmentReasonWarnings(node.definitionOfDone ?? [], index),
|
|
381
382
|
...(persisted ? [] : mirrorCoverageWarnings(node, index, cwd, contractCommands, contractWrites)),
|
|
382
383
|
...(persisted ? [] : unsnapshottedWriteWarnings(node, index, cwd)),
|
|
383
384
|
...(persisted ? [] : ignoreSourceWriteWarnings(node, index)),
|
package/src/engine/dispatch.mjs
CHANGED
|
@@ -50,7 +50,7 @@ import { transition, writeNode } from "./state.mjs";
|
|
|
50
50
|
* `dispatched`: a judge is running (or failed to start and the node is already
|
|
51
51
|
* marked failed).
|
|
52
52
|
*
|
|
53
|
-
* @typedef {{kind: "rejected", verdict: JudgeVerdict}|{kind: "settle", gate: JudgeVerdict}|{kind: "dispatched"}} JudgeRound
|
|
53
|
+
* @typedef {{kind: "rejected", verdict: JudgeVerdict}|{kind: "settle", gate: JudgeVerdict|null}|{kind: "dispatched"}} JudgeRound
|
|
54
54
|
*/
|
|
55
55
|
/** @typedef {import("./prompts.mjs").JudgeVerdict} JudgeVerdict */
|
|
56
56
|
|
|
@@ -439,6 +439,28 @@ export function startResultMaterialization(contract, node, state, runDir, runnin
|
|
|
439
439
|
transition(runDir, state, "failed", { phase: "worker", error: { code: "result_materialization_failed", message: errorMessage(error) } }, lock);
|
|
440
440
|
}
|
|
441
441
|
}
|
|
442
|
+
/**
|
|
443
|
+
* The mechanical half of the gate, for a node whose gate is disabled: its
|
|
444
|
+
* Definition of Done command and path proofs still run, and no judge is
|
|
445
|
+
* asked. Measured 2026-09-23 on evidence-you-can-recompute: every node carried
|
|
446
|
+
* `gate: false`, and not one of its declared proofs ran.
|
|
447
|
+
*
|
|
448
|
+
* @param {ValidatedContract} contract
|
|
449
|
+
* @param {ValidatedNode} node
|
|
450
|
+
* @param {NodeSnapshot} state
|
|
451
|
+
* @returns {Promise<JudgeRound>}
|
|
452
|
+
*/
|
|
453
|
+
export async function startMechanicalGate(contract, node, state) {
|
|
454
|
+
const { verdict, results } = await deterministicGate(
|
|
455
|
+
node,
|
|
456
|
+
attemptWorkspace(state) ?? contract.cwd,
|
|
457
|
+
false,
|
|
458
|
+
gateProofTimeoutMs(node, contract),
|
|
459
|
+
/** @type {import("../contract/index.mjs").VerificationState|null} */ (state.verification),
|
|
460
|
+
);
|
|
461
|
+
if (verdict.verdict === "fail") return { kind: "rejected", verdict };
|
|
462
|
+
return { kind: "settle", gate: results.length ? verdict : null };
|
|
463
|
+
}
|
|
442
464
|
/**
|
|
443
465
|
* Gate a completed worker: mechanical proofs gate first, the judge arbitrates
|
|
444
466
|
* only judgment items and is skipped when the review mode is `none` or no
|
package/src/engine/lifecycle.mjs
CHANGED
|
@@ -52,7 +52,7 @@ import {
|
|
|
52
52
|
workerResultPath,
|
|
53
53
|
} from "./result-file.mjs";
|
|
54
54
|
import { canReuseResultEvidence, checkResultMaterializationScope, checkWorkerScope, recordScopeFinding, sourceWorkerRuntime } from "./scope.mjs";
|
|
55
|
-
import { startJudge, startResultMaterialization } from "./dispatch.mjs";
|
|
55
|
+
import { startJudge, startMechanicalGate, startResultMaterialization } from "./dispatch.mjs";
|
|
56
56
|
import { raiseNodeAttention, settleDone } from "./settle.mjs";
|
|
57
57
|
import { applyRejection, applyVerificationFailure } from "./settle.mjs";
|
|
58
58
|
import { emitNodeAdvisories } from "./notify-queue.mjs";
|
|
@@ -587,11 +587,10 @@ export async function finalizeClosedJobs(contract, runDir, states, closed, lock,
|
|
|
587
587
|
continue;
|
|
588
588
|
}
|
|
589
589
|
if (job.scopeViolation) recordScopeFinding(runDir, state, lock);
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
else await settleDone(contract, job.node, state, runDir, lock, states, campaignPath, { phase: "complete", result: workerResult });
|
|
590
|
+
const round = job.node.gate.enabled
|
|
591
|
+
? await startJudge(contract, job.node, state, runDir, running, workerResult, lock, states, campaignPath)
|
|
592
|
+
: await startMechanicalGate(contract, job.node, state);
|
|
593
|
+
await applyJudgeRound(round, contract, job.node, state, runDir, running, lock, states, campaignPath, workerResult);
|
|
595
594
|
continue;
|
|
596
595
|
}
|
|
597
596
|
}
|
package/src/plan/proof-run.mjs
CHANGED
|
@@ -25,6 +25,13 @@ import { NOTIFY_SESSION_ENV } from "../notify/session.mjs";
|
|
|
25
25
|
/** @typedef {{requirementId: string|null, kind: "command"|"path", ref: string, pass: boolean, detail: string}} RequirementProofResult */
|
|
26
26
|
|
|
27
27
|
export const MEASURE_TIMEOUT_MS = 30_000;
|
|
28
|
+
/**
|
|
29
|
+
* A proof is a test run, not a fact probe. Measured 2026-09-24: R6 of
|
|
30
|
+
* `evals-with-a-budget` takes 98 s pinned to its file, and under the 30 s fact
|
|
31
|
+
* bound the runner died on SIGTERM with exit 1, read as a failing proof. Ten
|
|
32
|
+
* minutes has no measurement behind it beyond covering the slowest proof seen.
|
|
33
|
+
*/
|
|
34
|
+
const PROOF_TIMEOUT_MS = 600_000;
|
|
28
35
|
export const MEASURE_OUTPUT_CAP_BYTES = 4096;
|
|
29
36
|
|
|
30
37
|
/** How much of a failing proof's own output a finding carries: enough to name the failure, never the whole log. */
|
|
@@ -65,18 +72,62 @@ export const MEASURE_SIDE_EFFECT_ENV_KEYS = [
|
|
|
65
72
|
* @returns {{output: string, exitCode: number|null, truncated: boolean}}
|
|
66
73
|
*/
|
|
67
74
|
export function runShellCapture(cwd, command, probes = {}) {
|
|
75
|
+
const { output, exitCode, truncated } = runShellFull(cwd, command, probes);
|
|
76
|
+
const bytes = Buffer.from(output, "utf8");
|
|
77
|
+
return {
|
|
78
|
+
output: truncated ? bytes.subarray(0, MEASURE_OUTPUT_CAP_BYTES).toString("utf8") : output,
|
|
79
|
+
exitCode,
|
|
80
|
+
truncated,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The shell run behind `runShellCapture`, returning the whole output: a
|
|
86
|
+
* pinned-test check has to see every test the runner reported, and the
|
|
87
|
+
* matching line can sit far past the 4 KiB a measurement keeps.
|
|
88
|
+
*
|
|
89
|
+
* @param {string} cwd
|
|
90
|
+
* @param {string} command
|
|
91
|
+
* @param {MeasureProbes} [probes]
|
|
92
|
+
* @param {number} [timeoutMs]
|
|
93
|
+
* @returns {{output: string, exitCode: number|null, truncated: boolean, timedOut: boolean}}
|
|
94
|
+
*/
|
|
95
|
+
function runShellFull(cwd, command, probes = {}, timeoutMs = MEASURE_TIMEOUT_MS) {
|
|
68
96
|
const run = probes.run ?? spawnSync;
|
|
69
97
|
const env = { ...process.env };
|
|
70
98
|
for (const key of MEASURE_SIDE_EFFECT_ENV_KEYS) delete env[key];
|
|
71
|
-
|
|
99
|
+
// A nested `node --test` that inherits NODE_TEST_CONTEXT stays a runner
|
|
100
|
+
// child and prints no result at all (measured 2026-09-21, AGENTS.md), so a
|
|
101
|
+
// proof run from inside a test runner would have nothing to show.
|
|
102
|
+
delete env.NODE_TEST_CONTEXT;
|
|
103
|
+
const result = run(command, { shell: true, cwd, timeout: timeoutMs, encoding: "utf8", env });
|
|
72
104
|
const combined = `${result.stdout ?? ""}${result.stderr ?? ""}`;
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
105
|
+
const timedOut = /** @type {NodeJS.ErrnoException|undefined} */ (result.error)?.code === "ETIMEDOUT";
|
|
106
|
+
return { output: combined, exitCode: result.status, truncated: Buffer.byteLength(combined, "utf8") > MEASURE_OUTPUT_CAP_BYTES, timedOut };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** `--test-name-pattern` in a shell command, quoted or bare, `=` or space. */
|
|
110
|
+
const NAME_PATTERN_FLAG = /--test-name-pattern(?:=|\s+)(?:"([^"]*)"|'([^']*)'|(\S+))/u;
|
|
111
|
+
/** A passing test as the spec reporter (`✔ name (1ms)`) or TAP (`ok 1 - name`) prints it. */
|
|
112
|
+
const PASSING_TEST_LINE = /^\s*(?:\u2714\s+|ok \d+ - )(.+?)(?:\s+\(\d[\d.]*m?s\))?\s*$/gmu;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Whether a `node --test --test-name-pattern=<p>` proof ran a test named by
|
|
116
|
+
* its pattern. Node exits 0 when the pattern matches nothing, printing only
|
|
117
|
+
* the file, so the exit code alone cannot tell a proof from its absence.
|
|
118
|
+
* Returns null when the command carries no pattern.
|
|
119
|
+
*
|
|
120
|
+
* @param {string} command
|
|
121
|
+
* @param {string} output
|
|
122
|
+
* @returns {boolean|null}
|
|
123
|
+
*/
|
|
124
|
+
function patternMatchedATest(command, output) {
|
|
125
|
+
if (!/\bnode\b[^|;&]*--test\b/u.test(command)) return null;
|
|
126
|
+
const flag = NAME_PATTERN_FLAG.exec(command);
|
|
127
|
+
if (!flag) return null;
|
|
128
|
+
const pattern = new RegExp(flag[1] ?? flag[2] ?? flag[3], "u");
|
|
129
|
+
const plain = output.replace(/\u001b\[[0-9;]*m/gu, "");
|
|
130
|
+
return [...plain.matchAll(PASSING_TEST_LINE)].some((line) => pattern.test(line[1]) && !/\.m?[jt]s$/u.test(line[1].trim()));
|
|
80
131
|
}
|
|
81
132
|
|
|
82
133
|
/**
|
|
@@ -104,11 +155,14 @@ export function proveRequirements(cwd, requirements, probes = {}) {
|
|
|
104
155
|
const proof = requirement.proof;
|
|
105
156
|
if (!proof?.ref) continue;
|
|
106
157
|
if (proof.kind === "command") {
|
|
107
|
-
const { output, exitCode } =
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
158
|
+
const { output, exitCode, timedOut } = runShellFull(cwd, proof.ref, probes, PROOF_TIMEOUT_MS);
|
|
159
|
+
const matched = exitCode === 0 ? patternMatchedATest(proof.ref, output) : null;
|
|
160
|
+
const detail = timedOut
|
|
161
|
+
? `timed out after ${PROOF_TIMEOUT_MS / 1000} s`
|
|
162
|
+
: exitCode !== 0
|
|
163
|
+
? `exit ${exitCode ?? "no exit code (killed or never started)"}${output.trim() ? `: ${output.trim().slice(0, PROOF_DETAIL_CAP)}` : ""}`
|
|
164
|
+
: matched === false ? "exit 0, but its --test-name-pattern matched no test: the proof ran nothing" : "exit 0";
|
|
165
|
+
results.push({ requirementId: requirement.id, kind: "command", ref: proof.ref, pass: exitCode === 0 && matched !== false, detail });
|
|
112
166
|
continue;
|
|
113
167
|
}
|
|
114
168
|
if (proof.kind === "path") {
|
package/src/plan/template.mjs
CHANGED
|
@@ -111,7 +111,7 @@ const REQUIRED_INPUTS = Object.freeze({
|
|
|
111
111
|
// run had already succeeded. The id charset is requireId's
|
|
112
112
|
// (contract/assert.mjs) verbatim, because an id that is present but invalid
|
|
113
113
|
// fails that same validator just as late.
|
|
114
|
-
const PLAN_OUTPUT_SHAPE = '{nodes: [{id, objective, taskKind, riskTier, dependsOn, readFiles, writeFiles, scopeAcknowledged, definitionOfDone: [{id, text, proof?: {kind: "command"|"path"|"verification", ref}, judgment?: true}], verification: [{argv: [string], cwd?, timeoutSec?, repeat?, env?, mutation?: {threshold}}], expectedTurns?}], phases?: [{id, requirementIds: [string], nodeIds: [string], deliverable}], justification?}; every id in it (node, phase, node assignment, and definitionOfDone item) must match [A-Za-z0-9._-]+ and never be exactly "." or ".."';
|
|
114
|
+
const PLAN_OUTPUT_SHAPE = '{nodes: [{id, objective, taskKind, riskTier, dependsOn, readFiles, writeFiles, scopeAcknowledged, definitionOfDone: [{id, text, proof?: {kind: "command"|"path"|"verification", ref}, judgment?: true, reason?: string}], verification: [{argv: [string], cwd?, timeoutSec?, repeat?, env?, mutation?: {threshold}}], expectedTurns?}], phases?: [{id, requirementIds: [string], nodeIds: [string], deliverable}], justification?}; every id in it (node, phase, node assignment, and definitionOfDone item) must match [A-Za-z0-9._-]+ and never be exactly "." or ".."';
|
|
115
115
|
/**
|
|
116
116
|
* The size guidance every draft and revise carries. measured 2026-09-20 over
|
|
117
117
|
* stored runs: median 49 provider requests per worker turn; cost per turn
|
package/src/repo/worktree.mjs
CHANGED
|
@@ -384,7 +384,10 @@ export function sealAttempt({ repo, path, baseSha, runId, nodeId, attempt, exclu
|
|
|
384
384
|
// schedule it chose. The identity and signing overrides above are the
|
|
385
385
|
// same argument: this commit answers to the factory, not to the repo's
|
|
386
386
|
// conventions for human commits.
|
|
387
|
-
|
|
387
|
+
// Conventional, so a campaign branch passes a PR's commitlint check
|
|
388
|
+
// (measured 2026-09-23: every `faberun <run> <node> attempt N` seal was
|
|
389
|
+
// refused); the run id moves to the body to keep the header short.
|
|
390
|
+
"commit", "--no-verify", "-q", "-m", `chore(faberun): seal ${nodeId} attempt ${attempt}`, "-m", `run ${runId}`,
|
|
388
391
|
]);
|
|
389
392
|
}
|
|
390
393
|
const sha = gitHead(path);
|
package/src/run/lock.mjs
CHANGED
|
@@ -17,7 +17,7 @@ import { closeSync, fsyncSync, linkSync, mkdirSync, openSync, readFileSync, rena
|
|
|
17
17
|
import { hostname } from "node:os";
|
|
18
18
|
import { join } from "node:path";
|
|
19
19
|
import { fsyncDirectory } from "./store.mjs";
|
|
20
|
-
import { errorCode } from "../util.mjs";
|
|
20
|
+
import { errorCode, sleepSync } from "../util.mjs";
|
|
21
21
|
|
|
22
22
|
const LOCK_FILE = "controller.lock";
|
|
23
23
|
const TAKEOVER_ATTEMPTS = 20;
|
|
@@ -84,7 +84,7 @@ export function processStartToken(pid) {
|
|
|
84
84
|
// and report nothing for a pid that is already alive. One short retry
|
|
85
85
|
// closes that window; a still-empty answer stays null.
|
|
86
86
|
if (!pidAlive(pid)) return null;
|
|
87
|
-
|
|
87
|
+
sleepSync(20);
|
|
88
88
|
const retried = psStartTime(pid);
|
|
89
89
|
return retried.length > 0 ? retried : null;
|
|
90
90
|
}
|
|
@@ -106,16 +106,6 @@ function psStartTime(pid) {
|
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
/**
|
|
110
|
-
* Block without a timer so the synchronous darwin retry above can wait out the
|
|
111
|
-
* spawn race. The duration is tiny and bounded, so blocking the loop is safe.
|
|
112
|
-
*
|
|
113
|
-
* @param {number} milliseconds
|
|
114
|
-
*/
|
|
115
|
-
function sleepMs(milliseconds) {
|
|
116
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
109
|
/** @param {number|null|undefined} pid @returns {boolean} */
|
|
120
110
|
export function pidAlive(pid) {
|
|
121
111
|
if (!Number.isInteger(pid) || /** @type {number} */ (pid) <= 0) return false;
|
package/src/run/store.mjs
CHANGED
|
@@ -14,9 +14,19 @@ import {
|
|
|
14
14
|
} from "node:fs";
|
|
15
15
|
import { randomUUID } from "node:crypto";
|
|
16
16
|
import { dirname, join } from "node:path";
|
|
17
|
-
import { errorCode } from "../util.mjs";
|
|
17
|
+
import { errorCode, sleepSync } from "../util.mjs";
|
|
18
18
|
|
|
19
19
|
const BOOTSTRAP_FILE = "bootstrap.json";
|
|
20
|
+
// Windows refuses to replace a file another process holds open: MoveFileEx
|
|
21
|
+
// answers EPERM (or EACCES, EBUSY) while a reader, an indexer or a virus
|
|
22
|
+
// scanner has the destination, where a POSIX rename replaces it regardless.
|
|
23
|
+
// Measured 2026-09-23 on Windows 11 with node 24: one process polling a file
|
|
24
|
+
// with readFileSync failed 882 of 2190 atomic writes onto it, and on CI that
|
|
25
|
+
// killed controllers mid-run (a heartbeat write threw out of driveRun). The
|
|
26
|
+
// retry and its bounds are graceful-fs's: back off 10 ms more per attempt up to
|
|
27
|
+
// 100 ms, and give up after 60 s.
|
|
28
|
+
const WINDOWS_RENAME_BUSY = new Set(["EPERM", "EACCES", "EBUSY"]);
|
|
29
|
+
const RENAME_RETRY_BUDGET_MS = 60_000;
|
|
20
30
|
const JSONL_RECOVERY_TAIL_BYTES = 64 * 1024;
|
|
21
31
|
|
|
22
32
|
/**
|
|
@@ -51,7 +61,7 @@ export function writeTextAtomic(path, text) {
|
|
|
51
61
|
} finally {
|
|
52
62
|
closeSync(fd);
|
|
53
63
|
}
|
|
54
|
-
|
|
64
|
+
renameReplacing(temporary, path);
|
|
55
65
|
fsyncDirectory(dirname(path));
|
|
56
66
|
committed = true;
|
|
57
67
|
} catch (error) {
|
|
@@ -68,6 +78,24 @@ export function writeTextAtomic(path, text) {
|
|
|
68
78
|
}
|
|
69
79
|
}
|
|
70
80
|
|
|
81
|
+
/**
|
|
82
|
+
* @param {string} from
|
|
83
|
+
* @param {string} to
|
|
84
|
+
*/
|
|
85
|
+
function renameReplacing(from, to) {
|
|
86
|
+
const deadline = Date.now() + RENAME_RETRY_BUDGET_MS;
|
|
87
|
+
for (let backoff = 0; ; backoff = Math.min(backoff + 10, 100)) {
|
|
88
|
+
try {
|
|
89
|
+
renameSync(from, to);
|
|
90
|
+
return;
|
|
91
|
+
} catch (error) {
|
|
92
|
+
const busy = process.platform === "win32" && WINDOWS_RENAME_BUSY.has(errorCode(error) ?? "");
|
|
93
|
+
if (!busy || Date.now() >= deadline) throw error;
|
|
94
|
+
sleepSync(backoff);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
71
99
|
/**
|
|
72
100
|
* @param {string} path
|
|
73
101
|
* @param {unknown} value
|
package/src/util.mjs
CHANGED
|
@@ -275,3 +275,13 @@ export function shellWords(text) {
|
|
|
275
275
|
if (current !== null) words.push(current);
|
|
276
276
|
return words;
|
|
277
277
|
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Block the thread without a timer, for a synchronous path that has to wait
|
|
281
|
+
* out a short race. Callers keep the duration tiny and bounded.
|
|
282
|
+
*
|
|
283
|
+
* @param {number} milliseconds
|
|
284
|
+
*/
|
|
285
|
+
export function sleepSync(milliseconds) {
|
|
286
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
|
|
287
|
+
}
|