faberun 0.19.0 → 0.19.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 +1 -1
- package/src/cli/brand.mjs +4 -5
- package/src/cli/launch.mjs +1 -1
- package/src/engine/cancel.mjs +3 -3
- package/src/engine/failover.mjs +11 -4
- package/src/engine/judge-gate.mjs +7 -11
- package/src/engine/process.mjs +20 -2
- package/src/engine/resume.mjs +2 -1
- package/src/engine/runtime-discovery.mjs +11 -0
- package/src/engine/scheduler.mjs +13 -22
- package/src/engine/scope.mjs +13 -6
- package/src/host/home.mjs +38 -0
- package/src/host/preflight.mjs +46 -14
- package/src/plan/pipeline.mjs +9 -3
- package/src/plan/template.mjs +46 -14
- package/src/report/message.mjs +7 -7
- package/src/run/node-store.mjs +33 -0
- package/src/util.mjs +46 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "faberun",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.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": {
|
package/src/cli/brand.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* untouched.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import {
|
|
12
|
+
import { availableUpdate, faberunHome } from "../host/home.mjs";
|
|
13
13
|
|
|
14
14
|
/** @typedef {"brand"|"ok"|"progress"|"warn"|"fail"|"muted"|"text"} Role */
|
|
15
15
|
/** @typedef {"terra"|"argila"|"folha"} ColorName */
|
|
@@ -150,16 +150,15 @@ export function statusToken(kind, level) {
|
|
|
150
150
|
* opening is muted, the wordmark is the brand role, the tagline is plain text
|
|
151
151
|
* and the last line is muted and filled from the running process. The last line
|
|
152
152
|
* gains ` · update available: <latest>` when the cached check names a newer
|
|
153
|
-
* release
|
|
154
|
-
* fetches. ASCII apart from the middle-dot separator, so it survives every
|
|
153
|
+
* release and is still inside `UPDATE_CHECK_MAX_AGE_MS`; the banner reads only
|
|
154
|
+
* the cache (`update-check.json`) and never fetches. ASCII apart from the middle-dot separator, so it survives every
|
|
155
155
|
* monospace font.
|
|
156
156
|
*
|
|
157
157
|
* @param {BannerOptions} options
|
|
158
158
|
* @returns {string}
|
|
159
159
|
*/
|
|
160
160
|
export function renderBanner({ version, nodeVersion, harnessCount, level, env = process.env }) {
|
|
161
|
-
const
|
|
162
|
-
const latest = cached && compareVersions(cached.latest, version) > 0 ? cached.latest : null;
|
|
161
|
+
const latest = availableUpdate(faberunHome(env), version);
|
|
163
162
|
const terra = /** @param {string} text @returns {string} */ (text) => colorize(text, { color: "terra" }, level);
|
|
164
163
|
const lines = [
|
|
165
164
|
terra(" .-~~~-."),
|
package/src/cli/launch.mjs
CHANGED
|
@@ -20,7 +20,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
20
20
|
import { fileURLToPath } from "node:url";
|
|
21
21
|
import { join, resolve } from "node:path";
|
|
22
22
|
import { randomUUID } from "node:crypto";
|
|
23
|
-
import { readRunNodes } from "../
|
|
23
|
+
import { readRunNodes } from "../run/node-store.mjs";
|
|
24
24
|
import { spawn } from "node:child_process";
|
|
25
25
|
import { validateContract } from "../contract/index.mjs";
|
|
26
26
|
import { runDirectory } from "../run/paths.mjs";
|
package/src/engine/cancel.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import { invocationOwned } from "./process-identity.mjs";
|
|
|
16
16
|
import { terminateInvocation } from "./process.mjs";
|
|
17
17
|
import { join, resolve } from "node:path";
|
|
18
18
|
import { readFileSync } from "node:fs";
|
|
19
|
-
import { readRunNodes } from "
|
|
19
|
+
import { readRunNodes } from "../run/node-store.mjs";
|
|
20
20
|
import { createPreservedRef, deleteRef, releaseAttemptWorktree, runRefName } from "../repo/worktree.mjs";
|
|
21
21
|
import { syncAgentSignal } from "../repo/signal.mjs";
|
|
22
22
|
import { transition, writeNode } from "./state.mjs";
|
|
@@ -55,7 +55,7 @@ export async function cancelRun(runDirPath) {
|
|
|
55
55
|
}
|
|
56
56
|
const controllerLock = await acquireStaleLock(runDir);
|
|
57
57
|
try {
|
|
58
|
-
const states = readRunNodes(runDir, contract);
|
|
58
|
+
const states = readRunNodes(runDir, contract, { tolerateMissing: true });
|
|
59
59
|
/** @type {Error[]} */
|
|
60
60
|
const failures = [];
|
|
61
61
|
for (const state of states) {
|
|
@@ -196,7 +196,7 @@ async function waitForTerminal(runDir, timeoutMs) {
|
|
|
196
196
|
while (Date.now() < deadline) {
|
|
197
197
|
const contractPath = join(runDir, "contract.json");
|
|
198
198
|
const contract = validateContract(JSON.parse(readFileSync(contractPath, "utf8")), contractPath, { persisted: true });
|
|
199
|
-
const states = readRunNodes(runDir, contract);
|
|
199
|
+
const states = readRunNodes(runDir, contract, { tolerateMissing: true });
|
|
200
200
|
if (states.every((state) => SETTLED.has(state.status)) && states.every((state) => (state.invocations ?? []).every((invocation) => !invocationOwned(invocation)))) return true;
|
|
201
201
|
await delay(100);
|
|
202
202
|
}
|
package/src/engine/failover.mjs
CHANGED
|
@@ -136,15 +136,22 @@ export function failoverEdges(contract) {
|
|
|
136
136
|
* The first snapshot wins; every later requirement set is accumulated, so a
|
|
137
137
|
* runtime reached twice is still checked against both callers' demands.
|
|
138
138
|
*
|
|
139
|
-
*
|
|
139
|
+
* `routed` says whether some node or default names this runtime, directly or
|
|
140
|
+
* as a declared failover target. A runtime reached only because a role named
|
|
141
|
+
* none -- every catalogue entry is then a candidate availability discovery may
|
|
142
|
+
* pick -- is added with `routed: false`, and one route that names it at all
|
|
143
|
+
* makes it routed for good.
|
|
144
|
+
*
|
|
145
|
+
* @param {Map<string, {runtime: RuntimeSnapshot, requiredCapabilitySets: import("../harnesses/index.mjs").CapabilityRequirements[], routed: boolean}>} runtimes
|
|
140
146
|
* @param {RuntimeSnapshot} runtime
|
|
141
147
|
* @param {import("../harnesses/index.mjs").CapabilityRequirements[]} requiredCapabilitySets
|
|
148
|
+
* @param {boolean} [routed]
|
|
142
149
|
*/
|
|
143
|
-
export function addRuntimeRequirement(runtimes, runtime, requiredCapabilitySets) {
|
|
150
|
+
export function addRuntimeRequirement(runtimes, runtime, requiredCapabilitySets, routed = true) {
|
|
144
151
|
const incoming = requiredCapabilitySets.filter((requirements) => requirements && Object.keys(requirements).length);
|
|
145
152
|
const current = runtimes.get(runtime.id);
|
|
146
|
-
if (!current) runtimes.set(runtime.id, { runtime, requiredCapabilitySets: incoming });
|
|
147
|
-
else runtimes.set(runtime.id, { runtime: current.runtime, requiredCapabilitySets: [...current.requiredCapabilitySets, ...incoming] });
|
|
153
|
+
if (!current) runtimes.set(runtime.id, { runtime, requiredCapabilitySets: incoming, routed });
|
|
154
|
+
else runtimes.set(runtime.id, { runtime: current.runtime, requiredCapabilitySets: [...current.requiredCapabilitySets, ...incoming], routed: current.routed || routed });
|
|
148
155
|
}
|
|
149
156
|
|
|
150
157
|
/**
|
|
@@ -16,6 +16,7 @@ import { reviewMode, UNCITED_REJECTION_REASON } from "../contract/review-modes.m
|
|
|
16
16
|
import { JUDGE_LIMITS } from "../contract/judge-envelope.mjs";
|
|
17
17
|
import { sharedVerificationCommands } from "../contract/final-verification.mjs";
|
|
18
18
|
import { killTarget } from "../host/platform.mjs";
|
|
19
|
+
import { shellWords } from "../util.mjs";
|
|
19
20
|
|
|
20
21
|
/** @typedef {import("../contract/definition-of-done.mjs").DefinitionOfDoneItem} DefinitionOfDoneItem */
|
|
21
22
|
/** @typedef {import("../contract/verification.mjs").VerificationCommand} VerificationCommand */
|
|
@@ -174,31 +175,26 @@ function envForFilteredProof() {
|
|
|
174
175
|
/**
|
|
175
176
|
* The node:test filters a command string declares, in argv order, as flag and
|
|
176
177
|
* value. Presence alone changes behaviour (the appended reporter and the
|
|
177
|
-
* zero-plan look-up); the value
|
|
178
|
-
*
|
|
179
|
-
*
|
|
178
|
+
* zero-plan look-up); the value lands in the refusal detail, and it is read
|
|
179
|
+
* the way the shell this command runs under reads it -- `shellWords` groups a
|
|
180
|
+
* quoted pattern into one word instead of splitting the pattern itself.
|
|
180
181
|
*
|
|
181
182
|
* @param {string} ref
|
|
182
183
|
* @returns {Array<{flag: string, value: string}>}
|
|
183
184
|
*/
|
|
184
185
|
function declaredTestFilters(ref) {
|
|
185
|
-
const tokens = ref
|
|
186
|
+
const tokens = shellWords(ref);
|
|
186
187
|
/** @type {Array<{flag: string, value: string}>} */
|
|
187
188
|
const filters = [];
|
|
188
189
|
for (const [index, token] of tokens.entries()) {
|
|
189
190
|
for (const flag of TEST_FILTER_FLAGS) {
|
|
190
|
-
if (token.startsWith(`${flag}=`)) filters.push({ flag, value:
|
|
191
|
-
else if (token === flag) filters.push({ flag, value:
|
|
191
|
+
if (token.startsWith(`${flag}=`)) filters.push({ flag, value: token.slice(flag.length + 1) });
|
|
192
|
+
else if (token === flag) filters.push({ flag, value: tokens[index + 1] ?? "" });
|
|
192
193
|
}
|
|
193
194
|
}
|
|
194
195
|
return filters;
|
|
195
196
|
}
|
|
196
197
|
|
|
197
|
-
/** @param {string} value @returns {string} */
|
|
198
|
-
function unquote(value) {
|
|
199
|
-
return value.replace(/^['"]|['"]$/gu, "");
|
|
200
|
-
}
|
|
201
|
-
|
|
202
198
|
/** @param {Array<{flag: string, value: string}>} filters @returns {string} */
|
|
203
199
|
function filterNames(filters) {
|
|
204
200
|
return filters.map(({ flag, value }) => `${flag} "${value}"`).join(", ");
|
package/src/engine/process.mjs
CHANGED
|
@@ -36,7 +36,7 @@ import { killTarget } from "../host/platform.mjs";
|
|
|
36
36
|
/** @typedef {{prompt: string|null, stdout: string, stderr: string}} PathSet */
|
|
37
37
|
/** @typedef {{id: string, pid: number, processGroupId: number|null, processStartToken: string|null, harness: string, runtimeId: string|null, runtimeFingerprint?: string, revision?: number, phase: string, promptPath: string|null, stdoutPath: string, stderrPath: string, startedAt: string, deadlineAt: string|null, updatedAt: string, closedAt: string|null, exitCode: number|null, signal: string|null, status: "active"|"closed"|"terminated", executable: string, snapshotPath?: string, usage?: Usage, usageEstimated?: boolean, costUsd?: number|null, costProvenance?: "priced", runId?: string, campaignId?: string, nodeId?: string, attempt?: number, workspace?: string, worktreeBranch?: string|null, worktreeBaseSha?: string|null, planPhase?: string, role?: "worker"|"judge", model?: string, reasoning?: string|null, sandbox?: string|null, continuationId?: string|null, continuationMode?: "fresh"|"reuse"|"rotate", session?: import("../harnesses/session-metrics.mjs").SessionLedger|null}} Invocation */
|
|
38
38
|
/** @typedef {{pid: number|null, processGroupId?: number|null, processStartToken?: string|null}} InvocationProbe */
|
|
39
|
-
/** @typedef {{child: ChildProcess, contract: ValidatedContract, node: ValidatedNode, state: NodeSnapshot, runtime: HarnessRuntime & {id: string|null}, cwd: string, paths: PathSet, phase: string, invocation: Invocation, startedAt: string, startedTicks: bigint, progressTicks: bigint, lastOutputAt: number, closed: boolean, exitCode: number|null, signal: string|null, spawnError: Error|null, terminating: Promise<void>|null, gateConfigPath: string, gateReleasePath: string, scopeBaseline?: unknown, scopeChecked?: boolean, scopeViolation?: boolean, resultMaterialization?: boolean, recoveryBaseline?: unknown, observeTimer?: ReturnType<typeof setInterval>, monitorOffset?: number, monitorParser?: import("../harnesses/session-metrics.mjs").SessionMetricsParser, lastEventCount?: number, observedOnce?: boolean, onClose?: (invocation: Invocation) => void, onInvocationUpdate?: (invocation: Invocation) => void, onProgress?: (state: NodeSnapshot) => void}} Job */
|
|
39
|
+
/** @typedef {{child: ChildProcess, contract: ValidatedContract, node: ValidatedNode, state: NodeSnapshot, runtime: HarnessRuntime & {id: string|null}, cwd: string, paths: PathSet, phase: string, invocation: Invocation, startedAt: string, startedTicks: bigint, progressTicks: bigint, lastOutputAt: number, closed: boolean, exitCode: number|null, signal: string|null, spawnError: Error|null, terminating: Promise<void>|null, gateConfigPath: string, gateReleasePath: string, scopeBaseline?: unknown, scopeChecked?: boolean, scopeViolation?: boolean, resultMaterialization?: boolean, recoveryBaseline?: unknown, observeTimer?: ReturnType<typeof setInterval>, monitorOffset?: number, monitorParser?: import("../harnesses/session-metrics.mjs").SessionMetricsParser, lastEventCount?: number, lastMonitorOffset?: number, observedOnce?: boolean, onClose?: (invocation: Invocation) => void, onInvocationUpdate?: (invocation: Invocation) => void, onProgress?: (state: NodeSnapshot) => void}} Job */
|
|
40
40
|
/** @typedef {{graceMs?: number, killGraceMs?: number, escalate?: boolean, runDir?: string, kill?: (pid: number, signal: string|number) => unknown, child?: ChildProcess|null}} TerminateOptions */
|
|
41
41
|
|
|
42
42
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
@@ -434,8 +434,26 @@ export async function detectStalls(contract, running, onTimeout, onProgress, onB
|
|
|
434
434
|
if (streaming) {
|
|
435
435
|
const monitored = monitorInvocation(job);
|
|
436
436
|
const events = monitored.turns + monitored.toolCalls;
|
|
437
|
-
|
|
437
|
+
// Transcript bytes the monitor consumed count as liveness beside the
|
|
438
|
+
// events, because a turn can transmit for a long time without finishing
|
|
439
|
+
// one. Codex meters progress as `turn.completed` plus tool calls, so a
|
|
440
|
+
// single long reasoning stretch -- streaming `item.completed` records
|
|
441
|
+
// that are neither -- advanced no counter and was killed as a stall
|
|
442
|
+
// while it was actively transmitting. This is not the mtime the module
|
|
443
|
+
// header rejects: mtime moves for a buffered harness that has written
|
|
444
|
+
// nothing a provider produced, and this branch is the streaming
|
|
445
|
+
// harnesses only, where new bytes on the transcript are the provider's
|
|
446
|
+
// own output and the monitor's offset only ever advances. A process
|
|
447
|
+
// that transmits forever without ending is still held by the wall clock
|
|
448
|
+
// and the turn cap below.
|
|
449
|
+
// An offset this job has never recorded is not growth: `observedOnce`
|
|
450
|
+
// below is what covers the first pass, and reading `undefined` as a
|
|
451
|
+
// change would make every first observation look like progress.
|
|
452
|
+
const consumed = job.monitorOffset ?? 0;
|
|
453
|
+
const grew = consumed !== (job.lastMonitorOffset ?? consumed);
|
|
454
|
+
if (events !== job.lastEventCount || grew || job.observedOnce !== true) {
|
|
438
455
|
job.lastEventCount = events;
|
|
456
|
+
job.lastMonitorOffset = consumed;
|
|
439
457
|
job.progressTicks = now;
|
|
440
458
|
// `lastOutputAt` is the supervised controller's provider-progress
|
|
441
459
|
// signal (scheduler.mjs): keep it advancing for an event that counts
|
package/src/engine/resume.mjs
CHANGED
|
@@ -23,7 +23,8 @@ import { attemptWorktreePath } from "../run/paths.mjs";
|
|
|
23
23
|
import { canonicalWorkerResultText, isResultMaterializationInvocation, materializeAttemptResult, recoverWorkerResult } from "./result-file.mjs";
|
|
24
24
|
import { checkPersistedWorkerScope, persistedScopeBoundary, reconcileAmbiguousWorkerRestart, resolveUnknownEffect } from "./scope.mjs";
|
|
25
25
|
import { closePersistedInvocation, recoverOrphan, recoveryFromOverride } from "./recover.mjs";
|
|
26
|
-
import { driveRun
|
|
26
|
+
import { driveRun } from "./scheduler.mjs";
|
|
27
|
+
import { readRunNodes } from "../run/node-store.mjs";
|
|
27
28
|
import { emptyUsage, invocationCost, invocationUsage, persistRecoveryUsage } from "../run/usage.mjs";
|
|
28
29
|
import { ensureTerminalEvent, hasDoneEvent, recordExecutionOverride, transition, writeNode } from "./state.mjs";
|
|
29
30
|
import { errorMessage, excerpt } from "../util.mjs";
|
|
@@ -60,7 +60,18 @@ export const DISCOVERY_RUNTIME_DEFINITIONS = Object.freeze({
|
|
|
60
60
|
costRank: 1,
|
|
61
61
|
},
|
|
62
62
|
"agy-gemini": { harness: "agy", model: "gemini-3.8-flash-low", vendor: "google", tier: 1, costRank: 1 },
|
|
63
|
+
// Three codex rows, because the harness declares three models and an
|
|
64
|
+
// account is entitled to only some of them: measured 2026-09-21 on the
|
|
65
|
+
// owner's ChatGPT account, plain `gpt-5.6` answers HTTP 400 while
|
|
66
|
+
// `gpt-5.6-sol` answers normally. One row meant `setup` could offer only the
|
|
67
|
+
// model that account cannot use, and the operator's only way out was to hand
|
|
68
|
+
// every contract its own catalogue through `--runtimes`. Declaration order is
|
|
69
|
+
// unchanged, so the composed default is still `codex-gpt`: which of the three
|
|
70
|
+
// an account can reach is not something this file can know, and the live
|
|
71
|
+
// preflight is what reports it per id.
|
|
63
72
|
"codex-gpt": { harness: "codex", model: "gpt-5.6", vendor: "openai", tier: 2, costRank: 2 },
|
|
73
|
+
"codex-sol": { harness: "codex", model: "gpt-5.6-sol", vendor: "openai", tier: 2, costRank: 2 },
|
|
74
|
+
"codex-luna": { harness: "codex", model: "gpt-5.6-luna", vendor: "openai", tier: 2, costRank: 2 },
|
|
64
75
|
"claude-sonnet": { harness: "claude", model: "claude-sonnet-5", vendor: "anthropic", tier: 2, costRank: 2 },
|
|
65
76
|
});
|
|
66
77
|
|
package/src/engine/scheduler.mjs
CHANGED
|
@@ -37,13 +37,11 @@ import { delay, errorCode } from "../util.mjs";
|
|
|
37
37
|
import { alreadyNotified, emitNodeAdvisories, notifyQueueFor, notifyQueuesByRun, renderCampaignHandoffSafely } from "./notify-queue.mjs";
|
|
38
38
|
import { detectStalls, invocationAlive, terminateProcess } from "./process.mjs";
|
|
39
39
|
import { transition, writeNode } from "./state.mjs";
|
|
40
|
-
import { listNodeSnapshots, readNodeSnapshot } from "../run/node-store.mjs";
|
|
41
40
|
import { render, renderFinalReport, writeFindingsArtifact } from "../report/final.mjs";
|
|
42
41
|
import { operationNextState, providerReceipts, settleInvocation } from "../run/operations.mjs";
|
|
43
42
|
import { appendUsageRecord, invocationCost, invocationUsage, recordInvocationUsage } from "../run/usage.mjs";
|
|
44
43
|
import { captureNodeScopeBoundaries, checkWorkerScope, emptyScope } from "./scope.mjs";
|
|
45
44
|
import { validateContractForLaunch } from "../campaign/chain.mjs";
|
|
46
|
-
import { validateNodeSnapshot } from "../contract/snapshot.mjs";
|
|
47
45
|
import { finalVerificationCommands, sharedVerificationCommands } from "../contract/final-verification.mjs";
|
|
48
46
|
import { startJudge, startWorker } from "./dispatch.mjs";
|
|
49
47
|
import { assertEnvironmentReady, captureRunIdentity, createRunMetadata, serializableContract, statesFingerprint } from "./run-identity.mjs";
|
|
@@ -238,6 +236,19 @@ export async function runContract(contractPath, options = {}) {
|
|
|
238
236
|
}
|
|
239
237
|
const lock = acquireLock(runDir);
|
|
240
238
|
try {
|
|
239
|
+
// `contract.json` is written before anything else claims a name outside
|
|
240
|
+
// this directory, because `cancel` is the only verb that releases those
|
|
241
|
+
// names and it reads the contract from here. Everything below can fail or
|
|
242
|
+
// be killed -- `runtimeAssignments` probes providers, `captureRunIdentity`
|
|
243
|
+
// shells out to git, `createRunRef` claims `refs/faberun/<id>/run` -- and a
|
|
244
|
+
// launch that died between the ref and this write used to leave an
|
|
245
|
+
// occupied ref plus a run directory `cancel` could not parse: the operator
|
|
246
|
+
// was refused the directory, deleted it, was then refused the ref, and had
|
|
247
|
+
// no single verb for either. Written first, the directory is always
|
|
248
|
+
// cancellable from the instant it exists.
|
|
249
|
+
mkdirSync(join(runDir, "nodes"), { recursive: true });
|
|
250
|
+
mkdirSync(join(runDir, "logs"), { recursive: true });
|
|
251
|
+
writeJsonAtomic(join(runDir, "contract.json"), serializableContract(contract));
|
|
241
252
|
const runtimePlan = await runtimeAssignments(contract);
|
|
242
253
|
const scopeBoundaries = captureNodeScopeBoundaries(contract);
|
|
243
254
|
const sourceIdentity = await captureRunIdentity(contract, scopeBoundaries);
|
|
@@ -245,9 +256,6 @@ export async function runContract(contractPath, options = {}) {
|
|
|
245
256
|
lock.assert();
|
|
246
257
|
const runsDir = runsRoot(contract.cwd);
|
|
247
258
|
const campaign = resolveCampaign(runsDir, contract.campaignId);
|
|
248
|
-
mkdirSync(join(runDir, "nodes"), { recursive: true });
|
|
249
|
-
mkdirSync(join(runDir, "logs"), { recursive: true });
|
|
250
|
-
writeJsonAtomic(join(runDir, "contract.json"), serializableContract(contract));
|
|
251
259
|
writeJsonAtomic(join(runDir, "judge.schema.json"), JUDGE_SCHEMA);
|
|
252
260
|
writeJsonAtomic(join(runDir, "run.json"), createRunMetadata(lock, sourceIdentity, {}, integrationRef));
|
|
253
261
|
registerRun(campaign.path, contract.id);
|
|
@@ -755,20 +763,3 @@ export async function driveRun(contract, runDir, states, campaign, lock, sourceI
|
|
|
755
763
|
}
|
|
756
764
|
return { runDir, states, ok: failed.length === 0 };
|
|
757
765
|
}
|
|
758
|
-
|
|
759
|
-
/**
|
|
760
|
-
* @param {string} runDir
|
|
761
|
-
* @param {ValidatedContract} contract
|
|
762
|
-
* @returns {NodeSnapshot[]}
|
|
763
|
-
*/
|
|
764
|
-
export function readRunNodes(runDir, contract) {
|
|
765
|
-
const names = listNodeSnapshots(runDir);
|
|
766
|
-
const expected = new Map(contract.nodes.map((node) => [`${node.id}.json`, node]));
|
|
767
|
-
for (const name of names) if (!expected.has(name)) throw new TypeError(`unexpected persisted node snapshot ${name}`);
|
|
768
|
-
return contract.nodes.map((node) => {
|
|
769
|
-
const name = `${node.id}.json`;
|
|
770
|
-
if (!names.includes(name)) throw new TypeError(`missing persisted node snapshot ${name}`);
|
|
771
|
-
return validateNodeSnapshot(readNodeSnapshot(runDir, node.id), node);
|
|
772
|
-
});
|
|
773
|
-
}
|
|
774
|
-
|
package/src/engine/scope.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import { SETTLED } from "./prompts.mjs";
|
|
|
15
15
|
import { appendTransitionEvent, recordExecutionOverride, transition, writeNode } from "./state.mjs";
|
|
16
16
|
import { attemptWorkspace } from "../repo/worktree.mjs";
|
|
17
17
|
|
|
18
|
-
import { errorCode, errorMessage, excerpt, isContained } from "../util.mjs";
|
|
18
|
+
import { errorCode, errorMessage, excerpt, isContained, shellWords } from "../util.mjs";
|
|
19
19
|
import { executeControllerVerification } from "./verify.mjs";
|
|
20
20
|
import { providerReceiptsFromInvocationTail, settleInvocation } from "../run/operations.mjs";
|
|
21
21
|
import { readJson } from "../run/store.mjs";
|
|
@@ -108,10 +108,17 @@ export function workerScope(taskPacket) {
|
|
|
108
108
|
|
|
109
109
|
/**
|
|
110
110
|
* Everything a node's own proofs name: a Definition of Done `path` proof's
|
|
111
|
-
* path, the words of a `command` proof
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
111
|
+
* path, the words of a `command` proof, the argv of the verification entry a
|
|
112
|
+
* `verification` proof references, and the argv of every verification command
|
|
113
|
+
* the packet declares.
|
|
114
|
+
*
|
|
115
|
+
* A command proof carries a display string, not an argv, so its words are
|
|
116
|
+
* recovered with `shellWords` rather than a whitespace split -- the split lost
|
|
117
|
+
* exactly what the shell it runs under preserves. Measured 2026-09-22:
|
|
118
|
+
* `spawn(ref, {shell: true})` hands the whole string to `sh -c`, which groups
|
|
119
|
+
* `"b c"` into one argument, while the split here cut it into `"b` and `c"`,
|
|
120
|
+
* so a proof naming a real path with a space cited two fragments that matched
|
|
121
|
+
* no file and the write it excused read as unexpected.
|
|
115
122
|
*
|
|
116
123
|
* @param {ValidatedNode} node
|
|
117
124
|
* @returns {ProofCitation[]}
|
|
@@ -129,7 +136,7 @@ function proofCitations(node) {
|
|
|
129
136
|
if (proof.kind === "path") {
|
|
130
137
|
citations.push({ tokens: [proof.ref], cwd: ".", literal: true, citation: `${item.id} path proof` });
|
|
131
138
|
} else if (proof.kind === "command") {
|
|
132
|
-
citations.push({ tokens: proof.ref
|
|
139
|
+
citations.push({ tokens: shellWords(proof.ref), cwd: ".", literal: false, citation: `${item.id} command proof` });
|
|
133
140
|
} else {
|
|
134
141
|
const command = commands[Number.parseInt(proof.ref, 10)];
|
|
135
142
|
if (command) citations.push({ tokens: command.argv, cwd: command.cwd ?? ".", literal: false, citation: `${item.id} verification[${proof.ref}] proof` });
|
package/src/host/home.mjs
CHANGED
|
@@ -154,3 +154,41 @@ function parseVersion(text) {
|
|
|
154
154
|
if (!match) return null;
|
|
155
155
|
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
156
156
|
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* How old a cached update check may be and still be repeated as a fact about
|
|
160
|
+
* the world. The banner and the notification message read the cache and never
|
|
161
|
+
* the network -- a version hint must not put a request in front of every
|
|
162
|
+
* command -- so the cache is only ever as fresh as the last
|
|
163
|
+
* `faberun update --check` somebody remembered to run. Measured 2026-09-22 on
|
|
164
|
+
* the owner's machine: `update-check.json` was five days old and still named
|
|
165
|
+
* 0.10.0 as the latest release while 0.19.0 was being cut.
|
|
166
|
+
*
|
|
167
|
+
* A week is the bound, not a refresh: past it the hint stops being shown
|
|
168
|
+
* rather than being re-fetched, because a stale claim asserted with authority
|
|
169
|
+
* is worse than no claim. The command that does reach the network is
|
|
170
|
+
* unaffected, and the file keeps its `checkedAt` either way.
|
|
171
|
+
*/
|
|
172
|
+
export const UPDATE_CHECK_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The newer release a still-fresh cached check names, or null: no cache, a
|
|
176
|
+
* cache older than the window, a cache timestamped in the future (a record
|
|
177
|
+
* that would otherwise never expire), or a `latest` that is not newer than
|
|
178
|
+
* what is running. The one home of the "is there an update to mention" rule,
|
|
179
|
+
* so the banner and the notification message cannot come to disagree.
|
|
180
|
+
*
|
|
181
|
+
* @param {string} home
|
|
182
|
+
* @param {string} version the version actually running
|
|
183
|
+
* @param {number} [now] epoch milliseconds, injected by tests
|
|
184
|
+
* @returns {string|null}
|
|
185
|
+
*/
|
|
186
|
+
export function availableUpdate(home, version, now = Date.now()) {
|
|
187
|
+
const check = readUpdateCheck(home);
|
|
188
|
+
if (!check) return null;
|
|
189
|
+
const checkedAt = Date.parse(check.checkedAt);
|
|
190
|
+
if (!Number.isFinite(checkedAt)) return null;
|
|
191
|
+
const age = now - checkedAt;
|
|
192
|
+
if (age < 0 || age > UPDATE_CHECK_MAX_AGE_MS) return null;
|
|
193
|
+
return compareVersions(check.latest, version) > 0 ? check.latest : null;
|
|
194
|
+
}
|
package/src/host/preflight.mjs
CHANGED
|
@@ -50,7 +50,7 @@ import { availabilityKey, readAvailability, recordAvailability } from "../run/av
|
|
|
50
50
|
/** @typedef {import("../harnesses/index.mjs").CapabilityRequirements} CapabilityRequirements */
|
|
51
51
|
/** @typedef {import("../harnesses/index.mjs").ProbeResult} ProbeResult */
|
|
52
52
|
/** @typedef {import("../engine/runtime-discovery.mjs").RuntimeAvailability} RuntimeAvailability */
|
|
53
|
-
/** @typedef {Map<string, {runtime: RuntimeSnapshot, requiredCapabilitySets: CapabilityRequirements[]}>} ReachableRuntimes */
|
|
53
|
+
/** @typedef {Map<string, {runtime: RuntimeSnapshot, requiredCapabilitySets: CapabilityRequirements[], routed: boolean}>} ReachableRuntimes */
|
|
54
54
|
/** @typedef {{name: string, ok: boolean, advisory: boolean, detail: string}} EnvCheck */
|
|
55
55
|
/** @typedef {{schemaVersion: number, ok: boolean, checks: EnvCheck[]}} EnvReport */
|
|
56
56
|
|
|
@@ -169,9 +169,19 @@ export function checkWorktree(cwd, requireClean) {
|
|
|
169
169
|
}
|
|
170
170
|
|
|
171
171
|
/**
|
|
172
|
-
* Every runtime the run
|
|
173
|
-
*
|
|
174
|
-
*
|
|
172
|
+
* Every runtime the run committed to — a node's or a default's named runtime,
|
|
173
|
+
* and the failover target it declares — must resolve to a binary that exists
|
|
174
|
+
* and reports a version. A version-less runtime is fatal up front because a
|
|
175
|
+
* resume refuses a runtime whose probe came back null.
|
|
176
|
+
*
|
|
177
|
+
* A runtime that is merely a candidate is held to a weaker rule: at least one
|
|
178
|
+
* of them has to resolve. A role that names no runtime lets availability
|
|
179
|
+
* discovery choose at dispatch, so every catalogue entry is reachable without
|
|
180
|
+
* any of them being chosen, and demanding a binary for each of them refused a
|
|
181
|
+
* run over a harness it would never have started. Measured 2026-09-21 on the
|
|
182
|
+
* owner's machine: `codex reported no version` blocked a launch whose work was
|
|
183
|
+
* routed to `zcode`. What is still fatal is a catalogue where nothing resolves
|
|
184
|
+
* — then there is no route at all, and discovery has nothing to choose.
|
|
175
185
|
*
|
|
176
186
|
* @param {ReachableRuntimes} runtimes
|
|
177
187
|
* @param {Record<string, string|null>} harnessVersions
|
|
@@ -182,20 +192,34 @@ export function checkRuntimeBinaries(runtimes, harnessVersions, cwd = ".") {
|
|
|
182
192
|
/** @type {string[]} */
|
|
183
193
|
const problems = [];
|
|
184
194
|
/** @type {string[]} */
|
|
195
|
+
const unavailableCandidates = [];
|
|
196
|
+
/** @type {string[]} */
|
|
185
197
|
const resolved = [];
|
|
186
|
-
|
|
198
|
+
let candidates = 0;
|
|
199
|
+
let candidatesResolved = 0;
|
|
200
|
+
for (const [id, { runtime, routed }] of runtimes) {
|
|
187
201
|
// The harness owns the resolution: a per-runtime executable, an
|
|
188
202
|
// FABERUN_*_BIN override, and each harness's default binary all
|
|
189
203
|
// land here, and a relative path belongs to the run cwd, not to ours.
|
|
190
204
|
const executable = getHarness(runtime.harness).executable(runtime);
|
|
191
205
|
const found = findExecutable(executable.includes("/") || executable.includes("\\") ? resolve(cwd, executable) : executable);
|
|
192
206
|
const version = harnessVersions[id] ?? null;
|
|
193
|
-
if (
|
|
194
|
-
|
|
195
|
-
|
|
207
|
+
if (!routed) candidates += 1;
|
|
208
|
+
const problem = found === null
|
|
209
|
+
? `${id}: ${executable} not found on PATH`
|
|
210
|
+
: version === null ? `${id}: ${executable} reported no version` : null;
|
|
211
|
+
if (problem === null) {
|
|
212
|
+
resolved.push(`${id} ${version}`);
|
|
213
|
+
if (!routed) candidatesResolved += 1;
|
|
214
|
+
} else if (routed) problems.push(problem);
|
|
215
|
+
else unavailableCandidates.push(problem);
|
|
196
216
|
}
|
|
197
217
|
if (problems.length) return fail("runtime binaries", problems.join(" · "));
|
|
198
|
-
|
|
218
|
+
if (candidates > 0 && candidatesResolved === 0) {
|
|
219
|
+
return fail("runtime binaries", `no catalogue runtime resolves, so availability discovery has nothing to choose: ${unavailableCandidates.join(" · ")}`);
|
|
220
|
+
}
|
|
221
|
+
const aside = unavailableCandidates.length ? ` · not candidates: ${unavailableCandidates.join(" · ")}` : "";
|
|
222
|
+
return pass("runtime binaries", resolved.length ? `${resolved.join(" · ")}${aside}` : "no routed runtime");
|
|
199
223
|
}
|
|
200
224
|
|
|
201
225
|
/**
|
|
@@ -360,10 +384,10 @@ export function timeVerificationCommands(contract, probes = {}) {
|
|
|
360
384
|
* can take only one hop, and its reachable set stops at B.
|
|
361
385
|
*
|
|
362
386
|
* @param {ValidatedContract} contract
|
|
363
|
-
* @returns {
|
|
387
|
+
* @returns {ReachableRuntimes}
|
|
364
388
|
*/
|
|
365
389
|
export function reachableRuntimes(contract) {
|
|
366
|
-
/** @type {
|
|
390
|
+
/** @type {ReachableRuntimes} */
|
|
367
391
|
const runtimes = new Map();
|
|
368
392
|
for (const node of contract.nodes) {
|
|
369
393
|
for (const role of /** @type {("worker"|"judge")[]} */ (["worker", ...(node.gate.enabled ? ["judge"] : [])])) {
|
|
@@ -384,14 +408,22 @@ export function reachableRuntimes(contract) {
|
|
|
384
408
|
? [runtime.requiredCapabilities, node.gate.requiredCapabilities]
|
|
385
409
|
: [runtime.requiredCapabilities, node.requiredCapabilities];
|
|
386
410
|
const requiredCapabilitySets = required.filter((item) => item !== undefined);
|
|
387
|
-
addRuntimeRequirement(runtimes, runtime, requiredCapabilitySets);
|
|
411
|
+
if (explicit) addRuntimeRequirement(runtimes, runtime, requiredCapabilitySets);
|
|
388
412
|
const current = { node, role, runtimeId: /** @type {string} */ (runtime.id) };
|
|
389
413
|
for (const fallbackRuntime of failoverTargets(contract, current)) {
|
|
390
|
-
addRuntimeRequirement(runtimes, fallbackRuntime, requiredCapabilitySets);
|
|
414
|
+
addRuntimeRequirement(runtimes, fallbackRuntime, requiredCapabilitySets, Boolean(explicit));
|
|
391
415
|
}
|
|
392
416
|
if (!explicit) {
|
|
417
|
+
// Nothing names a runtime for this role, so availability discovery
|
|
418
|
+
// picks one at dispatch and every catalogue entry is a candidate --
|
|
419
|
+
// including the stand-in above, which was chosen as "the first entry"
|
|
420
|
+
// and is no more routed than the rest. They are reachable, so their
|
|
421
|
+
// capabilities still have to hold, but none of them is a runtime this
|
|
422
|
+
// run committed to: requiring a binary for each turned one absent
|
|
423
|
+
// harness into a refusal of a run that would never have used it. The
|
|
424
|
+
// loop covers the stand-in as well -- it is one of the keys.
|
|
393
425
|
for (const candidate of Object.keys(contract.runtimes)) {
|
|
394
|
-
addRuntimeRequirement(runtimes, runtimeSnapshot(contract, candidate), requiredCapabilitySets);
|
|
426
|
+
addRuntimeRequirement(runtimes, runtimeSnapshot(contract, candidate), requiredCapabilitySets, false);
|
|
395
427
|
}
|
|
396
428
|
}
|
|
397
429
|
}
|
package/src/plan/pipeline.mjs
CHANGED
|
@@ -27,7 +27,7 @@ import { allowanceDelta, allowanceEventFields, sampleAllowance } from "../seat/a
|
|
|
27
27
|
import { askPlanningRuntimes, refusePlanningSilence } from "./preflight.mjs";
|
|
28
28
|
import { parseSpec, validateSpec } from "./spec.mjs";
|
|
29
29
|
import { collectRepoFacts } from "./repo-facts.mjs";
|
|
30
|
-
import { RISK_TIERS, buildPlanningContract, validateFindings, validatePlanOutput } from "./template.mjs";
|
|
30
|
+
import { RISK_TIERS, TASK_KIND_CATALOGUE_FILE, buildPlanningContract, renderTaskKindCatalogue, validateFindings, validatePlanOutput } from "./template.mjs";
|
|
31
31
|
import { MIN_WRITE_FILES, applySizingRules, provenParallelism } from "./sizing.mjs";
|
|
32
32
|
import { resolveRuntimes } from "./routing.mjs";
|
|
33
33
|
import { freezePlan } from "./freeze.mjs";
|
|
@@ -135,6 +135,12 @@ export async function runPlanningPipeline(options) {
|
|
|
135
135
|
const repoFactsPath = join(scratchDir, "repo-facts.json");
|
|
136
136
|
writeFileSync(repoFactsPath, `${JSON.stringify(repoFacts, null, 2)}\n`);
|
|
137
137
|
const relativeRepoFactsPath = relative(cwd, repoFactsPath);
|
|
138
|
+
// The taskKind catalogue is staged like every other planning input. It used
|
|
139
|
+
// to be handed over as faberun's own `src/plan/template.mjs`, which resolves
|
|
140
|
+
// against the target repository and therefore exists in exactly one of them.
|
|
141
|
+
const cataloguePath = join(scratchDir, TASK_KIND_CATALOGUE_FILE);
|
|
142
|
+
writeFileSync(cataloguePath, renderTaskKindCatalogue());
|
|
143
|
+
const relativeCataloguePath = relative(cwd, cataloguePath);
|
|
138
144
|
logStage("repo-facts", { gitHead: repoFacts.gitHead });
|
|
139
145
|
|
|
140
146
|
let n = 0;
|
|
@@ -177,7 +183,7 @@ export async function runPlanningPipeline(options) {
|
|
|
177
183
|
return { contract: validated, output };
|
|
178
184
|
};
|
|
179
185
|
|
|
180
|
-
const draft = await runStage("draft", { specPath: relativeSpecPath, repoFactsPath: relativeRepoFactsPath });
|
|
186
|
+
const draft = await runStage("draft", { specPath: relativeSpecPath, repoFactsPath: relativeRepoFactsPath, cataloguePath: relativeCataloguePath });
|
|
181
187
|
/** @type {PlanOutput|null} */
|
|
182
188
|
let plan = null;
|
|
183
189
|
// Everything still open against the plan in hand, accumulated across rounds
|
|
@@ -365,7 +371,7 @@ export async function runPlanningPipeline(options) {
|
|
|
365
371
|
const findingsPath = join(scratchDir, `findings-round-${round}.json`);
|
|
366
372
|
writeJsonAtomic(findingsPath, findings);
|
|
367
373
|
const revise = await runStage("revise", {
|
|
368
|
-
specPath: relativeSpecPath, repoFactsPath: relativeRepoFactsPath, findingsPath: relative(cwd, findingsPath),
|
|
374
|
+
specPath: relativeSpecPath, repoFactsPath: relativeRepoFactsPath, cataloguePath: relativeCataloguePath, findingsPath: relative(cwd, findingsPath),
|
|
369
375
|
});
|
|
370
376
|
// Kept for the write-drop comparison: the plan the revise revised, against
|
|
371
377
|
// the plan it produced.
|
package/src/plan/template.mjs
CHANGED
|
@@ -23,25 +23,57 @@ import { validateVerificationCommands } from "../contract/verification.mjs";
|
|
|
23
23
|
/** @typedef {import("../contract/index.mjs").JsonObject} JsonObject */
|
|
24
24
|
/** @typedef {"draft"|"review"|"revise"|"spec-author"|"spec-review"} PlanningKind */
|
|
25
25
|
/** @typedef {"low"|"standard"|"high"} RiskTier */
|
|
26
|
-
/** @typedef {{campaignId: string, phase: string, n: number, goal?: string, cwd?: string, runtimes: Record<string, JsonObject>, runtimeDefaults: {worker?: string, judge?: string}, specPath?: string, repoFactsPath?: string, planPath?: string, findingsPath?: string, notesPath?: string}} PlanningContractInputs */
|
|
26
|
+
/** @typedef {{campaignId: string, phase: string, n: number, goal?: string, cwd?: string, runtimes: Record<string, JsonObject>, runtimeDefaults: {worker?: string, judge?: string}, specPath?: string, repoFactsPath?: string, cataloguePath?: string, planPath?: string, findingsPath?: string, notesPath?: string}} PlanningContractInputs */
|
|
27
27
|
/** @typedef {{id: string, objective: string, taskKind: string, riskTier: RiskTier, dependsOn: string[], readFiles: string[], writeFiles: string[], scopeAcknowledged: string[], definitionOfDone: import("../contract/definition-of-done.mjs").DefinitionOfDoneItem[], verification: import("../contract/verification.mjs").VerificationCommand[], expectedTurns?: number}} PlanOutputNode */
|
|
28
28
|
/** @typedef {{nodes: PlanOutputNode[], phases?: PlanPhase[], findings?: PlanFindingOutput[], justification?: string}} PlanOutput */
|
|
29
29
|
/** @typedef {{id: string, requirementIds: string[], deliverable: string}} PlanPhase */
|
|
30
30
|
/** @typedef {{id: string, severity: "critical"|"major"|"minor", nodeId: string, text: string}} PlanFindingOutput */
|
|
31
31
|
|
|
32
|
-
/**
|
|
33
|
-
* The taskKind catalogue a draft or revise classifies against. Exported here,
|
|
34
|
-
* not read from a separate document, so `TASK_KIND_CATALOGUE_PATH` (this
|
|
35
|
-
* module's own repo-relative path) is a real, always-present file a
|
|
36
|
-
* closed-context worker can be told to read for the authoritative list.
|
|
37
|
-
*/
|
|
32
|
+
/** The taskKind catalogue a draft or revise classifies against. */
|
|
38
33
|
export const TASK_KINDS = Object.freeze(["docs", "implement", "test", "refactor", "infra", "judge"]);
|
|
39
34
|
|
|
40
35
|
/** The risk tiers a draft or revise classifies against. */
|
|
41
36
|
export const RISK_TIERS = Object.freeze(["low", "standard", "high"]);
|
|
42
37
|
|
|
43
|
-
/**
|
|
44
|
-
|
|
38
|
+
/**
|
|
39
|
+
* The file name the planner stages the catalogue under, beside the spec and
|
|
40
|
+
* the repository facts it already stages.
|
|
41
|
+
*
|
|
42
|
+
* This used to be `src/plan/template.mjs` -- this module's own repo-relative
|
|
43
|
+
* path -- so that a closed-context worker had a real, always-present file to
|
|
44
|
+
* read for the authoritative list. It is always present in *this* repository.
|
|
45
|
+
* A planning contract's `readFiles` resolve against the target repository, and
|
|
46
|
+
* every other repository refuses the contract with `readFiles[2] does not
|
|
47
|
+
* exist: src/plan/template.mjs`, which made `faberun plan` able to plan only
|
|
48
|
+
* faberun. The catalogue is data, so it is written out like the other inputs
|
|
49
|
+
* instead of being pointed at across repository boundaries.
|
|
50
|
+
*/
|
|
51
|
+
export const TASK_KIND_CATALOGUE_FILE = "task-kinds.md";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The catalogue document itself, rendered from the two exported lists so the
|
|
55
|
+
* file a worker reads and the values `validatePlanOutput` accepts cannot
|
|
56
|
+
* drift apart.
|
|
57
|
+
*
|
|
58
|
+
* @returns {string}
|
|
59
|
+
*/
|
|
60
|
+
export function renderTaskKindCatalogue() {
|
|
61
|
+
return [
|
|
62
|
+
"# taskKind and riskTier catalogue",
|
|
63
|
+
"",
|
|
64
|
+
"Written by `faberun plan` for this stage. These are the only values a plan may use;",
|
|
65
|
+
"any other value is rejected when the plan is validated.",
|
|
66
|
+
"",
|
|
67
|
+
"## taskKind",
|
|
68
|
+
"",
|
|
69
|
+
...TASK_KINDS.map((kind) => `- ${kind}`),
|
|
70
|
+
"",
|
|
71
|
+
"## riskTier",
|
|
72
|
+
"",
|
|
73
|
+
...RISK_TIERS.map((tier) => `- ${tier}`),
|
|
74
|
+
"",
|
|
75
|
+
].join("\n");
|
|
76
|
+
}
|
|
45
77
|
|
|
46
78
|
/**
|
|
47
79
|
* Which of the caller's `runtimeDefaults` roles resolves this contract's
|
|
@@ -62,8 +94,8 @@ const KIND_ROLE = Object.freeze({
|
|
|
62
94
|
|
|
63
95
|
/** @type {Record<PlanningKind, string[]>} */
|
|
64
96
|
const REQUIRED_INPUTS = Object.freeze({
|
|
65
|
-
draft: ["specPath", "repoFactsPath"],
|
|
66
|
-
revise: ["specPath", "repoFactsPath", "findingsPath"],
|
|
97
|
+
draft: ["specPath", "repoFactsPath", "cataloguePath"],
|
|
98
|
+
revise: ["specPath", "repoFactsPath", "cataloguePath", "findingsPath"],
|
|
67
99
|
review: ["specPath", "repoFactsPath", "planPath"],
|
|
68
100
|
"spec-author": ["notesPath"],
|
|
69
101
|
"spec-review": ["specPath"],
|
|
@@ -110,7 +142,7 @@ const OBJECTIVES = Object.freeze({
|
|
|
110
142
|
/** @type {Record<PlanningKind, string[]>} */
|
|
111
143
|
const INSTRUCTIONS = Object.freeze({
|
|
112
144
|
draft: [
|
|
113
|
-
`Consult ${
|
|
145
|
+
`Consult the ${TASK_KIND_CATALOGUE_FILE} in readFiles before classifying any node; taskKind must be one of that catalogue and riskTier must be one of ${RISK_TIERS.join(", ")}.`,
|
|
114
146
|
"Declare every phase the plan serves in output.plan.phases: the requirement ids (R<n> from the spec) the phase satisfies and the deliverable it produces in one sentence. A phase associated with no requirement is reported as a finding, not refused.",
|
|
115
147
|
...SCOPE_CLOSURE_RULE,
|
|
116
148
|
`Return exactly one worker-result JSON object. Put the plan in output.plan as ${PLAN_OUTPUT_SHAPE} and nothing else in output.`,
|
|
@@ -156,9 +188,9 @@ const NON_GOALS = Object.freeze({
|
|
|
156
188
|
* @returns {string[]}
|
|
157
189
|
*/
|
|
158
190
|
function readFilesForKind(kind, inputs) {
|
|
159
|
-
if (kind === "draft") return [/** @type {string} */ (inputs.specPath), /** @type {string} */ (inputs.repoFactsPath),
|
|
191
|
+
if (kind === "draft") return [/** @type {string} */ (inputs.specPath), /** @type {string} */ (inputs.repoFactsPath), /** @type {string} */ (inputs.cataloguePath)];
|
|
160
192
|
if (kind === "revise") {
|
|
161
|
-
return [/** @type {string} */ (inputs.specPath), /** @type {string} */ (inputs.repoFactsPath),
|
|
193
|
+
return [/** @type {string} */ (inputs.specPath), /** @type {string} */ (inputs.repoFactsPath), /** @type {string} */ (inputs.cataloguePath), /** @type {string} */ (inputs.findingsPath)];
|
|
162
194
|
}
|
|
163
195
|
if (kind === "review") return [/** @type {string} */ (inputs.specPath), /** @type {string} */ (inputs.repoFactsPath), /** @type {string} */ (inputs.planPath)];
|
|
164
196
|
if (kind === "spec-author") return [/** @type {string} */ (inputs.notesPath)];
|
package/src/report/message.mjs
CHANGED
|
@@ -28,7 +28,7 @@ import { chooseLanguage, labelsFor } from "./locale.mjs";
|
|
|
28
28
|
import { readNodeSnapshot } from "../run/node-store.mjs";
|
|
29
29
|
import { campaignDir } from "../campaign/layout.mjs";
|
|
30
30
|
import { readJournal } from "../campaign/journal.mjs";
|
|
31
|
-
import {
|
|
31
|
+
import { availableUpdate, faberunHome } from "../host/home.mjs";
|
|
32
32
|
import { packageVersion } from "../host/package.mjs";
|
|
33
33
|
import { boundedUtf8, compactTokens } from "../util.mjs";
|
|
34
34
|
import { SETTLED, SUCCESS } from "../engine/prompts.mjs";
|
|
@@ -277,23 +277,23 @@ function footer(view, eventType) {
|
|
|
277
277
|
}
|
|
278
278
|
|
|
279
279
|
/**
|
|
280
|
-
* One line when
|
|
281
|
-
* running. The cache only, never the network: `faberun update --check`
|
|
280
|
+
* One line when a still-fresh cached update check names a release newer than
|
|
281
|
+
* the one running. The cache only, never the network: `faberun update --check`
|
|
282
282
|
* refreshes it, and a notification must never wait on a request.
|
|
283
283
|
*
|
|
284
284
|
* @param {(key: string) => string} label
|
|
285
285
|
* @returns {string[]}
|
|
286
286
|
*/
|
|
287
287
|
function updateLine(label) {
|
|
288
|
-
let
|
|
288
|
+
let latest;
|
|
289
289
|
try {
|
|
290
|
-
|
|
290
|
+
latest = availableUpdate(faberunHome(), packageVersion());
|
|
291
291
|
} catch {
|
|
292
292
|
// An unreadable install root is the banner's problem to report, not the message's.
|
|
293
293
|
return [];
|
|
294
294
|
}
|
|
295
|
-
if (!
|
|
296
|
-
return [`⬆️ faberun ${
|
|
295
|
+
if (!latest) return [];
|
|
296
|
+
return [`⬆️ faberun ${latest} ${label("available")} · ${label("runUpdate")} faberun update`];
|
|
297
297
|
}
|
|
298
298
|
|
|
299
299
|
/**
|
package/src/run/node-store.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { validateNodeSnapshot } from "../contract/snapshot.mjs";
|
|
|
11
11
|
|
|
12
12
|
/** @typedef {ReturnType<typeof import("./lock.mjs").acquire>} LockHandle */
|
|
13
13
|
/** @typedef {import("../contract/index.mjs").NodeSnapshot} NodeSnapshot */
|
|
14
|
+
/** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
|
|
14
15
|
|
|
15
16
|
const NODES_DIR_NAME = "nodes";
|
|
16
17
|
|
|
@@ -60,3 +61,35 @@ export function listNodeSnapshots(runDir) {
|
|
|
60
61
|
throw error;
|
|
61
62
|
}
|
|
62
63
|
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The persisted node snapshots of one run.
|
|
67
|
+
*
|
|
68
|
+
* `tolerateMissing` returns only the snapshots that exist instead of refusing
|
|
69
|
+
* the run. For resume and supervise a node the contract declares and the run
|
|
70
|
+
* never persisted is corruption and stays fatal; for `cancel` it is the
|
|
71
|
+
* ordinary shape of what is being cancelled. A launch writes `contract.json`
|
|
72
|
+
* first and can die before it writes any node -- probing providers, shelling
|
|
73
|
+
* out to git, claiming the run ref -- and the directory then holds a
|
|
74
|
+
* contract, an occupied ref and no node state at all. Such a node started
|
|
75
|
+
* nothing, holds no invocation and no worktree, so there is nothing to
|
|
76
|
+
* terminate and only the git names to release, which is what cancel is for.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} runDir
|
|
79
|
+
* @param {ValidatedContract} contract
|
|
80
|
+
* @param {{tolerateMissing?: boolean}} [options]
|
|
81
|
+
* @returns {NodeSnapshot[]}
|
|
82
|
+
*/
|
|
83
|
+
export function readRunNodes(runDir, contract, options = {}) {
|
|
84
|
+
const names = listNodeSnapshots(runDir);
|
|
85
|
+
const expected = new Map(contract.nodes.map((node) => [`${node.id}.json`, node]));
|
|
86
|
+
for (const name of names) if (!expected.has(name)) throw new TypeError(`unexpected persisted node snapshot ${name}`);
|
|
87
|
+
return contract.nodes.flatMap((node) => {
|
|
88
|
+
const name = `${node.id}.json`;
|
|
89
|
+
if (!names.includes(name)) {
|
|
90
|
+
if (options.tolerateMissing === true) return [];
|
|
91
|
+
throw new TypeError(`missing persisted node snapshot ${name}`);
|
|
92
|
+
}
|
|
93
|
+
return [validateNodeSnapshot(readNodeSnapshot(runDir, node.id), node)];
|
|
94
|
+
});
|
|
95
|
+
}
|
package/src/util.mjs
CHANGED
|
@@ -229,3 +229,49 @@ export function readJsonTolerant(path) {
|
|
|
229
229
|
export function finite(value) {
|
|
230
230
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
231
231
|
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Split a command string into the words a POSIX shell would pass as argv, for
|
|
235
|
+
* the two readers handed a command as a display string rather than an argv: a
|
|
236
|
+
* Definition of Done `command` proof, whose words `engine/scope.mjs` matches
|
|
237
|
+
* against the files the node wrote, and the node:test filters one declares
|
|
238
|
+
* (`engine/judge-gate.mjs`). Quotes group and are stripped; whitespace
|
|
239
|
+
* outside them separates.
|
|
240
|
+
*
|
|
241
|
+
* Deliberately not a shell: no expansion, no substitution, no operators, and
|
|
242
|
+
* no backslash escape -- on Windows a backslash is a path separator and
|
|
243
|
+
* `C:\Users\x` must survive this intact. The question both callers ask is
|
|
244
|
+
* "which words does this command name", and a quoted path holding a space is
|
|
245
|
+
* one word: splitting it on whitespace produced two fragments that matched no
|
|
246
|
+
* file, so a proof naming a real path read as naming none.
|
|
247
|
+
*
|
|
248
|
+
* @param {string} text
|
|
249
|
+
* @returns {string[]}
|
|
250
|
+
*/
|
|
251
|
+
export function shellWords(text) {
|
|
252
|
+
/** @type {string[]} */
|
|
253
|
+
const words = [];
|
|
254
|
+
/** @type {string|null} */
|
|
255
|
+
let current = null;
|
|
256
|
+
/** @type {string|null} */
|
|
257
|
+
let quote = null;
|
|
258
|
+
for (const character of text) {
|
|
259
|
+
if (quote === null && /\s/u.test(character)) {
|
|
260
|
+
if (current !== null) words.push(current);
|
|
261
|
+
current = null;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (quote === null && (character === '"' || character === "'")) {
|
|
265
|
+
quote = character;
|
|
266
|
+
current ??= "";
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (quote === character) {
|
|
270
|
+
quote = null;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
current = (current ?? "") + character;
|
|
274
|
+
}
|
|
275
|
+
if (current !== null) words.push(current);
|
|
276
|
+
return words;
|
|
277
|
+
}
|