faberun 0.21.0 → 0.22.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 +1 -1
- package/skills/faberun/references/operations.md +7 -7
- package/src/campaign/chain.mjs +30 -1
- package/src/contract/index.mjs +5 -3
- package/src/contract/runtime.mjs +22 -1
- package/src/contract/scope-findings.mjs +12 -0
- package/src/contract/snapshot.mjs +16 -5
- package/src/engine/dispatch.mjs +6 -4
- package/src/engine/process.mjs +1 -0
- package/src/engine/result-file.mjs +13 -1
- package/src/engine/settle.mjs +1 -0
- package/src/engine/verify.mjs +37 -2
- package/src/harnesses/codex/index.mjs +1 -0
- package/src/harnesses/index.mjs +18 -1
- package/src/plan/freeze.mjs +107 -3
- package/src/plan/pipeline.mjs +3 -2
- package/src/repo/declared-paths.mjs +16 -0
- package/src/repo/workspace.mjs +42 -3
- package/src/repo/worktree.mjs +34 -2
- package/src/report/final.mjs +5 -4
- package/src/report/render.mjs +4 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "faberun",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.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": {
|
|
@@ -45,22 +45,22 @@ attempt's sealed sha, the same continuation rule as any other retry.
|
|
|
45
45
|
One controller drives a run, holding `<run-dir>/controller.lock`: `{pid,
|
|
46
46
|
processStartToken, startedAt, hostname}`. Acquisition is an exclusive create
|
|
47
47
|
with no TTL. A contender treats the lock as stale only once it can prove the
|
|
48
|
-
holder dead —
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
installs its own; a capture that turns out live is handed back. Worker/judge/
|
|
48
|
+
holder dead — pid gone, or its start token no longer matches (pid recycled);
|
|
49
|
+
otherwise it exits `controller_active`, untouched. Takeover renames the lock
|
|
50
|
+
aside and re-checks it is stale before installing its own. Worker/judge/
|
|
52
51
|
verification children run detached in their own process group, so before
|
|
53
52
|
dispatching new work `resume`'s recovery pass terminates (`SIGTERM` then
|
|
54
53
|
`SIGKILL`, same as `cancel`) every invocation recorded for a `running` node —
|
|
55
54
|
unless it is still inside its deadline, when it is adopted and its result read.
|
|
56
|
-
`cancel
|
|
57
|
-
waits on an expiry.
|
|
55
|
+
`cancel` signals a live controller first, so its takeover never waits.
|
|
58
56
|
|
|
59
57
|
`supervise <run-dir> [--detach] [--interval <sec>]` is the watchdog above that.
|
|
60
58
|
It holds no lock and writes no state: every interval (default 30s) it launches
|
|
61
59
|
`resume --detach` when a node is unfinished and no controller is live, exits 0
|
|
62
60
|
once all are terminal, and stops after three failed launches. An empty run
|
|
63
|
-
directory is never resumed
|
|
61
|
+
directory is never resumed. A detached controller outlives its launcher,
|
|
62
|
+
not the session scope (cgroup) holding it: run long work under `tmux`,
|
|
63
|
+
`systemd-run` or the seat.
|
|
64
64
|
|
|
65
65
|
## Runtime discovery
|
|
66
66
|
|
package/src/campaign/chain.mjs
CHANGED
|
@@ -42,6 +42,7 @@ import { pidAlive, processStartToken } from "../run/lock.mjs";
|
|
|
42
42
|
import { delay, errorCode, errorMessage } from "../util.mjs";
|
|
43
43
|
import { writeJsonAtomic } from "../run/store.mjs";
|
|
44
44
|
import { runDirectory } from "../run/paths.mjs";
|
|
45
|
+
import { listNodeSnapshots, readNodeSnapshot } from "../run/node-store.mjs";
|
|
45
46
|
import { gitArguments, killTarget } from "../host/platform.mjs";
|
|
46
47
|
|
|
47
48
|
/** @typedef {import("../contract/index.mjs").ControllerIdentity} ControllerIdentity */
|
|
@@ -561,12 +562,14 @@ export async function driveCampaignChain(campaignPath, options = {}) {
|
|
|
561
562
|
try {
|
|
562
563
|
await launch(entry.path, { baseRef, controllerIdentity, runDir, contract });
|
|
563
564
|
} catch (error) {
|
|
565
|
+
const stranded = strandedRunNote(runDir);
|
|
564
566
|
return park({
|
|
565
567
|
code: errorCode(error) ?? "launch_failed",
|
|
566
|
-
message: errorMessage(error),
|
|
568
|
+
message: stranded ? `${errorMessage(error)}; ${stranded}` : errorMessage(error),
|
|
567
569
|
contractPath: entry.path,
|
|
568
570
|
contractId: id,
|
|
569
571
|
runId: id,
|
|
572
|
+
...(stranded ? { resume: `resume ${runDir}` } : {}),
|
|
570
573
|
});
|
|
571
574
|
}
|
|
572
575
|
heartbeat.progress();
|
|
@@ -578,3 +581,29 @@ export async function driveCampaignChain(campaignPath, options = {}) {
|
|
|
578
581
|
if (acquired) releaseCoordinatorLock(campaignPath, acquired.lock);
|
|
579
582
|
}
|
|
580
583
|
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* What a launch that died before readiness left on disk. RM-053, measured on
|
|
587
|
+
* `rec-audit-remediation`: the controller died with the launcher's cgroup,
|
|
588
|
+
* the park said only `detached bootstrap did not become ready`, and the run
|
|
589
|
+
* directory it left, every node pending, finished under a durable `resume`.
|
|
590
|
+
*
|
|
591
|
+
* @param {string} runDir
|
|
592
|
+
* @returns {string|null}
|
|
593
|
+
*/
|
|
594
|
+
function strandedRunNote(runDir) {
|
|
595
|
+
if (!runDir || !existsSync(runDir)) return null;
|
|
596
|
+
/** @type {Map<string, number>} */
|
|
597
|
+
const counts = new Map();
|
|
598
|
+
for (const name of listNodeSnapshots(runDir)) {
|
|
599
|
+
let status = "unreadable";
|
|
600
|
+
try {
|
|
601
|
+
status = String(/** @type {{status?: unknown}} */ (readNodeSnapshot(runDir, name.replace(/\.json$/u, ""))).status ?? "unknown");
|
|
602
|
+
} catch {
|
|
603
|
+
// A snapshot caught mid-write is counted as unreadable, not dropped.
|
|
604
|
+
}
|
|
605
|
+
counts.set(status, (counts.get(status) ?? 0) + 1);
|
|
606
|
+
}
|
|
607
|
+
const nodes = [...counts].sort(([left], [right]) => left.localeCompare(right)).map(([status, count]) => `${count} ${status}`).join(" · ");
|
|
608
|
+
return `run directory ${runDir} exists (nodes: ${nodes || "none written yet"}); \`faberun resume ${runDir}\` completes it, under tmux, systemd-run or faberun seat so it outlives this shell`;
|
|
609
|
+
}
|
package/src/contract/index.mjs
CHANGED
|
@@ -13,9 +13,9 @@ import { DISCOVERY_RUNTIME_DEFINITIONS } from "../engine/runtime-discovery.mjs";
|
|
|
13
13
|
import { stableJson } from "../util.mjs";
|
|
14
14
|
import { assertObject, boundedString, nonNegativeInteger, nonNegativeNumber, positiveInteger, positiveNumber, rejectUnknown, requireId, requireString } from "./assert.mjs";
|
|
15
15
|
import { validateMetadata } from "./schema-version.mjs";
|
|
16
|
-
import { assertRuntimeExecutesCommands, requireRuntime, validateRuntime } from "./runtime.mjs";
|
|
16
|
+
import { assertRuntimeExecutesCommands, judgeWriteWarnings, requireRuntime, validateRuntime } from "./runtime.mjs";
|
|
17
17
|
import { validateSourceIdentity } from "../repo/source-identity.mjs";
|
|
18
|
-
import { commandCoverageWarnings, mirrorCoverageWarnings, unsnapshottedWriteWarnings } from "../repo/declared-paths.mjs";
|
|
18
|
+
import { commandCoverageWarnings, ignoreSourceWriteWarnings, mirrorCoverageWarnings, unsnapshottedWriteWarnings } from "../repo/declared-paths.mjs";
|
|
19
19
|
import { crossNodeScopeFindings, scopeClosureFindings } from "../repo/scope-closure.mjs";
|
|
20
20
|
|
|
21
21
|
export { CONTRACT_VERSION, PROTOCOL_SCHEMA_VERSION } from "../harnesses/index.mjs";
|
|
@@ -98,7 +98,7 @@ const GATE_REVIEWS = new Set(["none", "advisory", "blocking"]);
|
|
|
98
98
|
/** @typedef {{history: RoutingHistoryEntry[], currentOverride: RoutingOverride|null, assignments?: RuntimeAssignments, availability?: Record<string, RuntimeAvailability>, tierExhaustion?: TierExhaustion, tierExhaustionCycle?: number}} RoutingState */
|
|
99
99
|
/** @typedef {{revision?: number, heartbeatCount: number, dryHeartbeatCount: number, progressSignature?: string|null, lastHeartbeatAt: string|null, lastProgressAt: string|null, nextCheckAt?: string|null}} ProgressState */
|
|
100
100
|
/** @typedef {{status: "unassigned"|"provisioning"|"ready"|"failed"|"removed", path: string|null, branch: string|null, commit: string|null, baseSha?: string|null, sealedSha?: string|null, sealError?: string|null, previousAttempt?: number|null}} WorktreeState */
|
|
101
|
-
/** @typedef {{schemaVersion: number, contractVersion: string, id: string, type: string, sourceIdentity: SourceIdentity, packetHash: string, requirementIds?: string[], status: NodeStatus, phase: NodePhase, attempt: number, revisions: number, judgeFailures?: number, review?: ("none"|"advisory"|"blocking"), runtime: RuntimeSnapshot|null, blockedBy: string[], startedAt: string|null, updatedAt: string, result: unknown, gate: GateResult|null, error: SnapshotError|null, usage?: Usage, costUsd?: number, routing?: RoutingState|null, progress?: ProgressState|null, worktree?: WorktreeState|null, integratedHead?: string|null, invocations?: Invocation[], executionOverrides?: ExecutionOverride[], verification?: VerificationState|null, scope?: BoundedScope|null, scopeFindings?: ScopeFindings|null, previousAttempt?: string, sessionPolicy?: {forceFresh?: boolean}|null, declaredReadBytes?: number|null}} NodeSnapshot */
|
|
101
|
+
/** @typedef {{schemaVersion: number, contractVersion: string, id: string, type: string, sourceIdentity: SourceIdentity, packetHash: string, requirementIds?: string[], status: NodeStatus, phase: NodePhase, attempt: number, revisions: number, judgeFailures?: number, review?: ("none"|"advisory"|"blocking"), runtime: RuntimeSnapshot|null, blockedBy: string[], startedAt: string|null, updatedAt: string, result: unknown, gate: GateResult|null, error: SnapshotError|null, usage?: Usage, costUsd?: number, routing?: RoutingState|null, progress?: ProgressState|null, worktree?: WorktreeState|null, integratedHead?: string|null, invocations?: Invocation[], executionOverrides?: ExecutionOverride[], verification?: VerificationState|null, scope?: BoundedScope|null, scopeFindings?: ScopeFindings|null, verificationArtifacts?: string[], previousAttempt?: string, sessionPolicy?: {forceFresh?: boolean}|null, declaredReadBytes?: number|null}} NodeSnapshot */
|
|
102
102
|
/** @typedef {{path: string, sha: string}} ControllerIdentity */
|
|
103
103
|
/** @typedef {{schemaVersion: number, contractVersion: string, pid: number, processStartToken: string|null, startedAt: string, sourceIdentity: SourceIdentity, controllerIdentity?: ControllerIdentity, integrationRef?: string, identityWarnings?: string[], relaunchCount?: number, lastRelaunchProgressAt?: string|null, attention?: {code: string, message: string, at: string}|null, contractDigest?: string, scopeDecision?: ScopeDecision, autoRetries?: Record<string, {code: string, at: string}>}} RunMetadata */
|
|
104
104
|
/** @typedef {{at: string, base: string|null, dirtyTreeFingerprint: string|null}} ScopeDecision */
|
|
@@ -380,8 +380,10 @@ export function validateContract(raw, contractPath, options = {}) {
|
|
|
380
380
|
...unquotedFilterValueWarnings(node.definitionOfDone ?? [], index),
|
|
381
381
|
...(persisted ? [] : mirrorCoverageWarnings(node, index, cwd, contractCommands, contractWrites)),
|
|
382
382
|
...(persisted ? [] : unsnapshottedWriteWarnings(node, index, cwd)),
|
|
383
|
+
...(persisted ? [] : ignoreSourceWriteWarnings(node, index)),
|
|
383
384
|
...(persisted ? [] : writeFileLineBudgetWarnings(node, index, cwd)),
|
|
384
385
|
]),
|
|
386
|
+
...judgeWriteWarnings(runtimes, defaults, nodes),
|
|
385
387
|
// Cross-node by construction: a requirement proven in two nodes is only
|
|
386
388
|
// visible when every node's commands are read together, which is the
|
|
387
389
|
// whole point -- one copy repaired and six left behind is what a per-node
|
package/src/contract/runtime.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { assertObject, nonNegativeNumber, positiveInteger, positiveNumber, rejectUnknown, requireId, requireString, requireStringArray, requireTimestamp } from "./assert.mjs";
|
|
13
13
|
import { composeAssignments } from "../engine/runtime-discovery.mjs";
|
|
14
|
-
import { harnessCapabilities, resolvePermissionExecution, resolveVendor, validateCapabilityRequirements } from "../harnesses/index.mjs";
|
|
14
|
+
import { harnessCapabilities, resolvePermissionExecution, resolveVendor, validateCapabilityRequirements, writesWorkspace } from "../harnesses/index.mjs";
|
|
15
15
|
import { stableJson } from "../util.mjs";
|
|
16
16
|
/** @typedef {import("./index.mjs").NodeStatus} NodeStatus */
|
|
17
17
|
/** @typedef {import("../engine/runtime-discovery.mjs").RuntimeAvailability} RuntimeAvailability */
|
|
@@ -212,6 +212,27 @@ export function assertRuntimeExecutesCommands(runtimes, runtimeId, index, nodeId
|
|
|
212
212
|
`nodes[${index}] (${nodeId}) has verification but ${label} ${runtimeId} uses ${execution.field}=${execution.mode}; ${runtime.harness} executes commands only in ${execution.executingModes.join(", ")}`,
|
|
213
213
|
);
|
|
214
214
|
}
|
|
215
|
+
/**
|
|
216
|
+
* A judge runtime that declares a writing mode its harness offers a read-only
|
|
217
|
+
* alternative to. The verdict reaches the gate without a write (RM-058), and
|
|
218
|
+
* `judge_protocol` blocks a judge that writes anyway, so the grant buys
|
|
219
|
+
* nothing but the chance to write where the judge should not.
|
|
220
|
+
*
|
|
221
|
+
* @param {Record<string, ValidatedRuntime>} runtimes
|
|
222
|
+
* @param {{judge?: string}} defaults
|
|
223
|
+
* @param {{gate?: {runtime?: string}|false|null}[]} nodes
|
|
224
|
+
* @returns {string[]}
|
|
225
|
+
*/
|
|
226
|
+
export function judgeWriteWarnings(runtimes, defaults, nodes) {
|
|
227
|
+
const judges = new Set([defaults.judge, ...nodes.map((node) => (node.gate ? node.gate.runtime : undefined))].filter((id) => typeof id === "string"));
|
|
228
|
+
return [...judges].flatMap((id) => {
|
|
229
|
+
const runtime = runtimes[/** @type {string} */ (id)];
|
|
230
|
+
const execution = resolvePermissionExecution(runtime);
|
|
231
|
+
if (!execution.field || runtime[execution.field] === undefined || !writesWorkspace(runtime)) return [];
|
|
232
|
+
if (writesWorkspace({ ...runtime, [execution.field]: "read-only" })) return [];
|
|
233
|
+
return [`judge runtime ${id} declares ${execution.field} ${execution.mode}; a judge's verdict reaches the gate without writing, so declare ${execution.field} read-only`];
|
|
234
|
+
});
|
|
235
|
+
}
|
|
215
236
|
/**
|
|
216
237
|
* @param {unknown} value
|
|
217
238
|
* @param {string} label
|
|
@@ -35,6 +35,18 @@ export function scopeFindingsNote(scopeFindings) {
|
|
|
35
35
|
return `scope: ${count} unexpected path${count === 1 ? "" : "s"}`;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* The `verification_artifact` finding: paths the controller's verification
|
|
40
|
+
* left untracked, which the seal kept out of the integration.
|
|
41
|
+
*
|
|
42
|
+
* @param {string[]|null|undefined} paths
|
|
43
|
+
* @returns {string|null}
|
|
44
|
+
*/
|
|
45
|
+
export function verificationArtifactsNote(paths) {
|
|
46
|
+
if (!paths?.length) return null;
|
|
47
|
+
return `verification_artifact: ${paths.length} path${paths.length === 1 ? "" : "s"} left unsealed (${paths.slice(0, 3).join(", ")})`;
|
|
48
|
+
}
|
|
49
|
+
|
|
38
50
|
/**
|
|
39
51
|
* @param {{unexpectedPaths: string[]}|null|undefined} scopeFindings
|
|
40
52
|
* @returns {string}
|
|
@@ -129,7 +129,7 @@ export function validateNodeSnapshot(value, expectedNode = null) {
|
|
|
129
129
|
"schemaVersion", "contractVersion", "id", "type", "sourceIdentity", "packetHash", "status", "phase",
|
|
130
130
|
"attempt", "revisions", "judgeFailures", "requirementIds", "runtime", "blockedBy", "startedAt", "updatedAt", "result", "gate", "error", "usage",
|
|
131
131
|
"costUsd", "routing", "progress", "worktree", "invocations", "executionOverrides", "verification", "scope",
|
|
132
|
-
"scopeFindings", "review", "previousAttempt", "sessionPolicy", "integratedHead", "declaredReadBytes",
|
|
132
|
+
"scopeFindings", "verificationArtifacts", "review", "previousAttempt", "sessionPolicy", "integratedHead", "declaredReadBytes",
|
|
133
133
|
]), "node snapshot");
|
|
134
134
|
validateMetadata(value, "node snapshot");
|
|
135
135
|
requireId(value.id, "node snapshot.id");
|
|
@@ -184,6 +184,7 @@ export function validateNodeSnapshot(value, expectedNode = null) {
|
|
|
184
184
|
if (value.verification !== undefined && value.verification !== null) validateVerificationSnapshot(value.verification);
|
|
185
185
|
if (value.scope !== undefined && value.scope !== null) validateScopeSnapshot(value.scope);
|
|
186
186
|
if (value.scopeFindings !== undefined && value.scopeFindings !== null) validateScopeFindings(value.scopeFindings);
|
|
187
|
+
if (value.verificationArtifacts !== undefined) validatePathList(value.verificationArtifacts, "node snapshot.verificationArtifacts");
|
|
187
188
|
// The session policy a rejection decision leaves for the dispatch that will
|
|
188
189
|
// run the retry. It is persisted because the decision can hand the node back
|
|
189
190
|
// to the scheduler, whose own `startWorker` call carries no argument; without
|
|
@@ -611,12 +612,22 @@ function validateScopeSnapshot(value) {
|
|
|
611
612
|
function validateScopeFindings(value) {
|
|
612
613
|
assertObject(value, "node snapshot.scopeFindings");
|
|
613
614
|
rejectUnknown(value, new Set(["unexpectedPaths"]), "node snapshot.scopeFindings");
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
615
|
+
validatePathList(value.unexpectedPaths, "node snapshot.scopeFindings.unexpectedPaths");
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* A bounded list of workspace paths: the shape of a scope finding and of the
|
|
619
|
+
* paths a verification left behind (`verificationArtifacts`).
|
|
620
|
+
*
|
|
621
|
+
* @param {unknown} value
|
|
622
|
+
* @param {string} label
|
|
623
|
+
*/
|
|
624
|
+
function validatePathList(value, label) {
|
|
625
|
+
const paths = /** @type {unknown[]} */ (value);
|
|
626
|
+
if (!Array.isArray(value) || paths.length > MAX_SCOPE_FINDING_PATHS || paths.some((path) => typeof path !== "string")) {
|
|
627
|
+
throw new TypeError(`${label} is invalid`);
|
|
617
628
|
}
|
|
618
629
|
if (paths.some((path) => Buffer.byteLength(/** @type {string} */ (path), "utf8") > 1024)) {
|
|
619
|
-
throw new TypeError(
|
|
630
|
+
throw new TypeError(`${label} contains an oversized path`);
|
|
620
631
|
}
|
|
621
632
|
}
|
|
622
633
|
/**
|
package/src/engine/dispatch.mjs
CHANGED
|
@@ -34,7 +34,7 @@ import { invocationCost, invocationUsage } from "../run/usage.mjs";
|
|
|
34
34
|
import { logPaths, startProcess } from "./process.mjs";
|
|
35
35
|
import { readBoundedTail } from "./transcript.mjs";
|
|
36
36
|
import { mkdirSync, statSync } from "node:fs";
|
|
37
|
-
import { READ_BYTE_LIMIT, READ_LINE_LIMIT, harnessCapabilities, normalizeProviderResult } from "../harnesses/index.mjs";
|
|
37
|
+
import { READ_BYTE_LIMIT, READ_LINE_LIMIT, harnessCapabilities, normalizeProviderResult, writesWorkspace } from "../harnesses/index.mjs";
|
|
38
38
|
import { writeJsonAtomic } from "../run/store.mjs";
|
|
39
39
|
import { judgeReaskInstruction, reviewMode } from "../contract/review-modes.mjs";
|
|
40
40
|
import { routeRuntimeForState, runtimeSnapshot } from "./failover.mjs";
|
|
@@ -194,6 +194,7 @@ function sealPreviousAttempt(contract, node, state) {
|
|
|
194
194
|
runId: contract.id,
|
|
195
195
|
nodeId: node.id,
|
|
196
196
|
attempt,
|
|
197
|
+
exclude: state.verificationArtifacts,
|
|
197
198
|
});
|
|
198
199
|
return sealed.empty ? null : { sha: sealed.sha, attempt };
|
|
199
200
|
}
|
|
@@ -269,7 +270,7 @@ export function startWorker(contract, node, state, runDir, running, prompt, lock
|
|
|
269
270
|
// make sure the directory exists before the provider is asked to.
|
|
270
271
|
const resultPath = attemptWorkerResultPath(runDir, node.id, workspace);
|
|
271
272
|
mkdirSync(dirname(resultPath), { recursive: true });
|
|
272
|
-
const effectivePrompt = workerProtocolPrompt(phasePlan.prompt, resultPath);
|
|
273
|
+
const effectivePrompt = workerProtocolPrompt(phasePlan.prompt, resultPath, writesWorkspace(runtime));
|
|
273
274
|
const paths = logPaths(runDir, node.id, "worker", state.attempt);
|
|
274
275
|
if (Buffer.byteLength(effectivePrompt, "utf8") > 64 * 1024) {
|
|
275
276
|
transition(runDir, state, "failed", { phase: "worker", error: { code: "worker_prompt_too_large", message: "worker prompt exceeds 65536 bytes" } }, lock);
|
|
@@ -390,8 +391,9 @@ export function startResultMaterialization(contract, node, state, runDir, runnin
|
|
|
390
391
|
const resultPath = attemptWorkerResultPath(runDir, node.id, workspace);
|
|
391
392
|
const prompt = appendSandboxNotice([
|
|
392
393
|
`${RESULT_MATERIALIZATION_PROMPT_HEADER} Do not inspect, implement, verify, or invoke tools.`,
|
|
393
|
-
|
|
394
|
-
|
|
394
|
+
...(writesWorkspace(materializationRuntime)
|
|
395
|
+
? [`Your only job in this single bounded turn is to write the required worker-result JSON object to: ${resultPath}`, "Then return that same JSON object as the final message."]
|
|
396
|
+
: ["Your sandbox is read-only, so write no file: your only job in this single bounded turn is to return the required worker-result JSON object as the final message."]),
|
|
395
397
|
].join("\n\n"), materializationRuntime);
|
|
396
398
|
let baseline;
|
|
397
399
|
try {
|
package/src/engine/process.mjs
CHANGED
|
@@ -143,11 +143,23 @@ export function resolveWorkerResult(runDir, node, providerResult) {
|
|
|
143
143
|
return result;
|
|
144
144
|
}
|
|
145
145
|
/**
|
|
146
|
+
* A runtime that cannot write (`writable` false) is told its final message is
|
|
147
|
+
* the result, and `resolveWorkerResult` persists it: asking it to write the
|
|
148
|
+
* file is what stalled a read-only reviewer (RM-058).
|
|
149
|
+
*
|
|
146
150
|
* @param {string} prompt
|
|
147
151
|
* @param {string} resultPath
|
|
152
|
+
* @param {boolean} [writable]
|
|
148
153
|
* @returns {string}
|
|
149
154
|
*/
|
|
150
|
-
export function workerProtocolPrompt(prompt, resultPath) {
|
|
155
|
+
export function workerProtocolPrompt(prompt, resultPath, writable = true) {
|
|
156
|
+
if (!writable) {
|
|
157
|
+
return [
|
|
158
|
+
prompt,
|
|
159
|
+
"Controller worker protocol:",
|
|
160
|
+
"Your sandbox is read-only, so write no file: your final response is the result, exactly the required worker-result JSON object, and the controller persists it.",
|
|
161
|
+
].join("\n\n");
|
|
162
|
+
}
|
|
151
163
|
return [
|
|
152
164
|
prompt,
|
|
153
165
|
"Controller worker protocol:",
|
package/src/engine/settle.mjs
CHANGED
|
@@ -166,6 +166,7 @@ export async function settleDone(contract, node, state, runDir, lock, states, ca
|
|
|
166
166
|
runId: contract.id,
|
|
167
167
|
nodeId: node.id,
|
|
168
168
|
attempt: state.attempt,
|
|
169
|
+
exclude: state.verificationArtifacts,
|
|
169
170
|
});
|
|
170
171
|
state.worktree = { ...state.worktree, commit: sealed.sha, status: "ready" };
|
|
171
172
|
writeNode(runDir, state, lock);
|
package/src/engine/verify.mjs
CHANGED
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
* catch. `recoverVerificationAttempts` reads back what a crashed controller had
|
|
9
9
|
* already proved, so a resume does not pay for the same suite twice.
|
|
10
10
|
*/
|
|
11
|
-
import { attemptWorkspace } from "../repo/worktree.mjs";
|
|
11
|
+
import { attemptWorkspace, untrackedPaths } from "../repo/worktree.mjs";
|
|
12
12
|
import { boundedUtf8, errorMessage } from "../util.mjs";
|
|
13
13
|
import { compactVerification } from "../contract/verification.mjs";
|
|
14
|
+
import { MAX_SCOPE_FINDING_PATHS } from "../contract/scope-findings.mjs";
|
|
14
15
|
import { finalVerificationCommands, phaseTerminalNode, sharedVerificationCommands } from "../contract/final-verification.mjs";
|
|
15
16
|
import { join } from "node:path";
|
|
16
17
|
import { listNodeSnapshots, readNodeSnapshot } from "../run/node-store.mjs";
|
|
@@ -137,7 +138,11 @@ export async function executeControllerVerification(contract, runDir, node, stat
|
|
|
137
138
|
attempts: [...(state.verification?.attempts ?? [])],
|
|
138
139
|
};
|
|
139
140
|
writeNode(runDir, state, lock);
|
|
140
|
-
const
|
|
141
|
+
const attempt = attemptWorkspace(state);
|
|
142
|
+
const workspace = attempt ?? contract.cwd;
|
|
143
|
+
// The mark between "worker finished" and "verification ran": what was
|
|
144
|
+
// already untracked is the worker's, what appears after is the suite's.
|
|
145
|
+
const untrackedBefore = attempt ? new Set(untrackedPaths(attempt)) : null;
|
|
141
146
|
const commands = [...node.taskPacket.verification, ...sharedVerificationCommands(contract), ...finalVerificationCommands(contract, node, settledSiblingIds(runDir, contract, node))];
|
|
142
147
|
/** @param {VerificationAttempt} attempt @returns {VerificationProgress} */
|
|
143
148
|
const progressFor = (attempt) => verificationProgress(attempt.commandIndex + 1, commands.length, /** @type {VerificationCommand|undefined} */ (commands[attempt.commandIndex])?.argv);
|
|
@@ -169,9 +174,39 @@ export async function executeControllerVerification(contract, runDir, node, stat
|
|
|
169
174
|
attempts: verificationAttemptRecords(state),
|
|
170
175
|
};
|
|
171
176
|
}
|
|
177
|
+
if (attempt && untrackedBefore) {
|
|
178
|
+
const artifacts = verificationArtifacts(node, state, untrackedBefore, untrackedPaths(attempt));
|
|
179
|
+
if (artifacts.length) state.verificationArtifacts = artifacts;
|
|
180
|
+
else delete state.verificationArtifacts;
|
|
181
|
+
}
|
|
172
182
|
writeNode(runDir, state, lock);
|
|
173
183
|
return state.verification;
|
|
174
184
|
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* RM-052, measured on `rec-audit-remediation`: the target's suite wrote
|
|
188
|
+
* `rec-wav-test-<pid>.*` into its working directory, and 14 of them crossed
|
|
189
|
+
* the seal into the remediation branch. A path the verification leaves
|
|
190
|
+
* untracked, outside the packet's declared writes, is recorded here and kept
|
|
191
|
+
* out of the seal. An earlier pass's artifact stays recorded while it is
|
|
192
|
+
* still on disk, since the next pass sees it as already there. Bounded like
|
|
193
|
+
* scope findings; an artifact past the bound would still be sealed.
|
|
194
|
+
*
|
|
195
|
+
* @param {ValidatedNode} node
|
|
196
|
+
* @param {NodeSnapshot} state
|
|
197
|
+
* @param {Set<string>} before
|
|
198
|
+
* @param {string[]} after
|
|
199
|
+
* @returns {string[]}
|
|
200
|
+
*/
|
|
201
|
+
function verificationArtifacts(node, state, before, after) {
|
|
202
|
+
const files = new Set(node.taskPacket.writeFiles ?? []);
|
|
203
|
+
const roots = (node.taskPacket.writeRoots ?? []).map((root) => root.replace(/\/+$/u, ""));
|
|
204
|
+
const previous = new Set(state.verificationArtifacts ?? []);
|
|
205
|
+
return after
|
|
206
|
+
.filter((path) => !before.has(path) || previous.has(path))
|
|
207
|
+
.filter((path) => !files.has(path) && !roots.some((root) => path === root || path.startsWith(`${root}/`)))
|
|
208
|
+
.slice(0, MAX_SCOPE_FINDING_PATHS);
|
|
209
|
+
}
|
|
175
210
|
/**
|
|
176
211
|
* @param {string} runDir
|
|
177
212
|
* @param {NodeSnapshot} state
|
|
@@ -51,6 +51,7 @@ export const codexHarness = {
|
|
|
51
51
|
field: "sandbox",
|
|
52
52
|
executingModes: ["read-only", "workspace-write", "danger-full-access"],
|
|
53
53
|
defaultMode: "workspace-write",
|
|
54
|
+
readOnlyModes: ["read-only"],
|
|
54
55
|
},
|
|
55
56
|
|
|
56
57
|
/** @param {import("../index.mjs").HarnessRuntime} runtime @returns {string} */
|
package/src/harnesses/index.mjs
CHANGED
|
@@ -55,7 +55,10 @@ const CAPABILITY_NAMES = new Set([
|
|
|
55
55
|
* the value used when the contract omits that field. `null` means the harness
|
|
56
56
|
* has no permission mode that can deny command execution.
|
|
57
57
|
*
|
|
58
|
-
*
|
|
58
|
+
* `readOnlyModes` names the modes in which the provider cannot write its
|
|
59
|
+
* workspace, so the controller must not ask it to.
|
|
60
|
+
*
|
|
61
|
+
* @typedef {{field: "permissionMode"|"sandbox", executingModes: string[], defaultMode: string, readOnlyModes?: string[]}|null} PermissionExecutionPolicy
|
|
59
62
|
*/
|
|
60
63
|
|
|
61
64
|
/** @typedef {{status: "done"|"no-op"|"blocked"|"failed"|"exhausted"|"stalled"|"canceled", result: string|null, continuationId: string|null, usage: {inputTokens: number|null, outputTokens: number|null, cacheReadInputTokens: number|null}, costUsd: number|null, error: {code: string, message: string, resetAt?: string|null}|null, exhaustedUntil?: string|null, judgeCandidates?: number}} ProviderEnvelope */
|
|
@@ -154,6 +157,20 @@ export function resolvePermissionExecution(runtime) {
|
|
|
154
157
|
return { executes: policy.executingModes.includes(mode), field: policy.field, mode, executingModes: policy.executingModes };
|
|
155
158
|
}
|
|
156
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Whether a runtime can write its workspace. False only in a mode its adapter
|
|
162
|
+
* declares read-only; RM-058 measured a codex reviewer under `read-only`
|
|
163
|
+
* whose result-file write was rejected and which then stalled for 300s.
|
|
164
|
+
*
|
|
165
|
+
* @param {{harness: string, permissionMode?: string, sandbox?: string}} runtime
|
|
166
|
+
* @returns {boolean}
|
|
167
|
+
*/
|
|
168
|
+
export function writesWorkspace(runtime) {
|
|
169
|
+
const policy = getHarness(runtime.harness).permissionExecution;
|
|
170
|
+
if (!policy?.readOnlyModes) return true;
|
|
171
|
+
return !policy.readOnlyModes.includes(/** @type {string} */ (runtime[policy.field] ?? policy.defaultMode));
|
|
172
|
+
}
|
|
173
|
+
|
|
157
174
|
/**
|
|
158
175
|
* The vendor a harness talks to when no provider configuration says
|
|
159
176
|
* otherwise. `replay` and `exec-jsonl` stand in for whatever the recording or
|
package/src/plan/freeze.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import { fileURLToPath } from "node:url";
|
|
|
19
19
|
import { CONTRACT_VERSION, PROTOCOL_SCHEMA_VERSION, contractDigest, validateContract } from "../contract/index.mjs";
|
|
20
20
|
import { assertObject, rejectUnknown, requirePacketHash, requireString } from "../contract/assert.mjs";
|
|
21
21
|
import { writeJsonAtomic, writeTextAtomic } from "../run/store.mjs";
|
|
22
|
+
import { VERIFICATION_LIMITS } from "../contract/verification.mjs";
|
|
22
23
|
import { validatePlanPhases } from "./template.mjs";
|
|
23
24
|
|
|
24
25
|
/** @typedef {import("../contract/index.mjs").JsonObject} JsonObject */
|
|
@@ -31,9 +32,108 @@ import { validatePlanPhases } from "./template.mjs";
|
|
|
31
32
|
/** @typedef {{path: string, digest: string}} PlanSpecIdentity */
|
|
32
33
|
/** @typedef {{formatVersion: number, contractDigest: string, spec?: PlanSpecIdentity, phases?: PlanPhase[], provenance: PlanProvenance}} FrozenPlan */
|
|
33
34
|
/** @typedef {{ok: boolean, digest: string, expectedDigest: string}} FrozenPlanVerdict */
|
|
35
|
+
/** @typedef {{scripts?: Record<string, string>, verificationCandidates: {argv: string[], measuredMs: number}[]}} MeasuredFacts */
|
|
34
36
|
|
|
35
37
|
const PLAN_FORMAT_VERSION = 1;
|
|
36
38
|
|
|
39
|
+
/**
|
|
40
|
+
* The margin a frozen verification timeout keeps over its measured duration.
|
|
41
|
+
* No measurement behind the number itself: it is the spec's (RM-057), and a
|
|
42
|
+
* timeout only bounds a failure, so a passing command never waits for it.
|
|
43
|
+
*/
|
|
44
|
+
const MEASURED_TIMEOUT_MARGIN = 1.5;
|
|
45
|
+
|
|
46
|
+
/** `node --test` options that run a subset of the files they name. */
|
|
47
|
+
const FILTER_OPTIONS = ["--test-name-pattern", "--test-skip-pattern", "--test-only", "--test-shard"];
|
|
48
|
+
|
|
49
|
+
/** `node` options whose value is the next argument, so it is not a path. */
|
|
50
|
+
const NODE_VALUE_OPTIONS = new Set(["--import", "--require", "-r", "--loader", "--experimental-loader", "--env-file", "--test-reporter", "--test-reporter-destination", "--test-name-pattern", "--test-skip-pattern", "--test-concurrency", "--test-timeout"]);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The `node --test <dir>` candidates one path argument includes: a directory
|
|
54
|
+
* includes itself and everything below it, and a glob includes the
|
|
55
|
+
* directories below its literal prefix only when it descends (`test/*` +
|
|
56
|
+
* `/…`); `test/*.test.mjs` names top-level files no candidate measured.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} arg
|
|
59
|
+
* @param {string[]} directories
|
|
60
|
+
* @returns {string[]}
|
|
61
|
+
*/
|
|
62
|
+
function includedDirectories(arg, directories) {
|
|
63
|
+
const path = arg.replace(/^\.\//u, "").replace(/\/+$/u, "");
|
|
64
|
+
const wildcard = path.search(/[*?[]/u);
|
|
65
|
+
if (wildcard < 0) return directories.filter((directory) => directory === path || directory.startsWith(`${path}/`));
|
|
66
|
+
const base = path.slice(0, path.lastIndexOf("/", wildcard));
|
|
67
|
+
if (!path.slice(wildcard).includes("/")) return [];
|
|
68
|
+
return directories.filter((directory) => base === "" || directory.startsWith(`${base}/`));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* What repo facts measured for a verification command: its own candidate, or
|
|
73
|
+
* the sum of the `node --test <dir>` candidates it includes, `npm test`
|
|
74
|
+
* resolved through the `test` script. A lower bound when the command also
|
|
75
|
+
* runs files no candidate measured; null when it includes nothing measured.
|
|
76
|
+
*
|
|
77
|
+
* @param {string[]} argv
|
|
78
|
+
* @param {MeasuredFacts} facts
|
|
79
|
+
* @returns {number|null}
|
|
80
|
+
*/
|
|
81
|
+
function measuredMsFor(argv, facts) {
|
|
82
|
+
const candidates = facts.verificationCandidates;
|
|
83
|
+
const exact = candidates.find((candidate) => candidate.argv.join(" ") === argv.join(" "));
|
|
84
|
+
if (exact) return exact.measuredMs;
|
|
85
|
+
const npmTest = argv[0] === "npm" && ["test", "run test"].includes(argv.slice(1).join(" "));
|
|
86
|
+
if (npmTest && typeof facts.scripts?.test === "string") return measuredMsFor(facts.scripts.test.trim().split(/\s+/u), facts);
|
|
87
|
+
if (argv[0] !== "node" || argv[1] !== "--test") return null;
|
|
88
|
+
// A filtered run measures nothing a directory candidate measured.
|
|
89
|
+
if (argv.some((arg) => FILTER_OPTIONS.some((option) => arg === option || arg.startsWith(`${option}=`)))) return null;
|
|
90
|
+
const measured = new Map(candidates
|
|
91
|
+
.filter((candidate) => candidate.argv.length === 3 && candidate.argv[0] === "node" && candidate.argv[1] === "--test")
|
|
92
|
+
.map((candidate) => [candidate.argv[2].replace(/\/+$/u, ""), candidate.measuredMs]));
|
|
93
|
+
/** @type {string[]} */
|
|
94
|
+
const paths = [];
|
|
95
|
+
for (let index = 2; index < argv.length; index += 1) {
|
|
96
|
+
if (NODE_VALUE_OPTIONS.has(argv[index])) index += 1;
|
|
97
|
+
else if (!argv[index].startsWith("-")) paths.push(argv[index]);
|
|
98
|
+
}
|
|
99
|
+
const included = new Set((paths.length ? paths : ["test"]).flatMap((path) => includedDirectories(path, [...measured.keys()])));
|
|
100
|
+
if (!included.size) return null;
|
|
101
|
+
return [...included].reduce((sum, directory) => sum + /** @type {number} */ (measured.get(directory)), 0);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Refuse a contract whose verification timeout sits under
|
|
106
|
+
* `MEASURED_TIMEOUT_MARGIN` times what repo facts measured for the command,
|
|
107
|
+
* and name a command no legal timeout can cover, so the plan is contested
|
|
108
|
+
* with the advice to split it. RM-057, measured on the Campaign Brief run:
|
|
109
|
+
* a gate frozen at 120s against parts measured at 178,904 ms and 246,955 ms.
|
|
110
|
+
*
|
|
111
|
+
* @param {import("../contract/index.mjs").ValidatedContract} contract
|
|
112
|
+
* @param {MeasuredFacts} facts
|
|
113
|
+
* @returns {void}
|
|
114
|
+
*/
|
|
115
|
+
export function assertTimeoutsCoverMeasured(contract, facts) {
|
|
116
|
+
const commands = [
|
|
117
|
+
...contract.nodes.flatMap((node) => node.taskPacket.verification ?? []),
|
|
118
|
+
...contract.sharedVerification ?? [],
|
|
119
|
+
...contract.finalVerification ?? [],
|
|
120
|
+
];
|
|
121
|
+
const problems = new Set();
|
|
122
|
+
for (const command of commands) {
|
|
123
|
+
const measuredMs = measuredMsFor(command.argv, facts);
|
|
124
|
+
if (measuredMs === null) continue;
|
|
125
|
+
const timeoutSec = command.timeoutSec ?? 120;
|
|
126
|
+
const requiredSec = Math.ceil((measuredMs * MEASURED_TIMEOUT_MARGIN) / 1_000);
|
|
127
|
+
const shown = `${command.argv.join(" ")} measured ${(measuredMs / 1_000).toFixed(1)}s`;
|
|
128
|
+
if (requiredSec > VERIFICATION_LIMITS.maxTimeoutSec) {
|
|
129
|
+
problems.add(`${shown}, and ${MEASURED_TIMEOUT_MARGIN} times that passes the ${VERIFICATION_LIMITS.maxTimeoutSec}s maxTimeoutSec: split it into commands that each fit`);
|
|
130
|
+
} else if (timeoutSec < requiredSec) {
|
|
131
|
+
problems.add(`verification ${command.argv.join(" ")} has timeoutSec ${timeoutSec}s, under ${MEASURED_TIMEOUT_MARGIN} times its measured ${(measuredMs / 1_000).toFixed(1)}s: raise it to at least ${requiredSec}s`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (problems.size) throw new TypeError(`verification timeouts do not cover their measured durations: ${[...problems].join("; ")}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
37
137
|
/** @returns {string} the installed package's own version, read once per call so a freeze always names the toolchain that produced it */
|
|
38
138
|
function packageVersion() {
|
|
39
139
|
const packageJsonPath = fileURLToPath(new URL("../../package.json", import.meta.url));
|
|
@@ -175,10 +275,13 @@ export function writeFrozenPlanRecord(outDir, record) {
|
|
|
175
275
|
* was planned from.
|
|
176
276
|
*
|
|
177
277
|
* @param {JsonObject} plan
|
|
178
|
-
*
|
|
278
|
+
* `options.facts` carries repo facts' measured durations; given, a
|
|
279
|
+
* verification timeout that does not cover one is refused.
|
|
280
|
+
*
|
|
281
|
+
* @param {{outDir: string, provenance: PlanProvenanceInput, phases?: import("./template.mjs").PlanPhase[], spec?: PlanSpecIdentity, facts?: MeasuredFacts}} options
|
|
179
282
|
* @returns {FrozenPlan}
|
|
180
283
|
*/
|
|
181
|
-
export function freezePlan(plan, { outDir, provenance, phases, spec }) {
|
|
284
|
+
export function freezePlan(plan, { outDir, provenance, phases, spec, facts }) {
|
|
182
285
|
// Shape-checked before anything is written, so a malformed declaration
|
|
183
286
|
// leaves the outDir exactly as it was — the same failure discipline as the
|
|
184
287
|
// validateContract rollback below. A declaration in the nodeIds shape must
|
|
@@ -199,7 +302,8 @@ export function freezePlan(plan, { outDir, provenance, phases, spec }) {
|
|
|
199
302
|
});
|
|
200
303
|
writeJsonAtomic(contractPath, raw);
|
|
201
304
|
try {
|
|
202
|
-
validateContract(raw, contractPath);
|
|
305
|
+
const validated = validateContract(raw, contractPath);
|
|
306
|
+
if (facts) assertTimeoutsCoverMeasured(validated, facts);
|
|
203
307
|
} catch (error) {
|
|
204
308
|
rmSync(contractPath, { force: true });
|
|
205
309
|
throw error;
|
package/src/plan/pipeline.mjs
CHANGED
|
@@ -30,7 +30,7 @@ import { collectRepoFacts } from "./repo-facts.mjs";
|
|
|
30
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
|
-
import { contentDigest, freezePlan, writeFrozenPlanRecord } from "./freeze.mjs";
|
|
33
|
+
import { assertTimeoutsCoverMeasured, contentDigest, freezePlan, writeFrozenPlanRecord } from "./freeze.mjs";
|
|
34
34
|
import { availabilityOf, fileLineCount, highestOf, modelOf, toContractNode, toSizingNode } from "./pipeline-shape.mjs";
|
|
35
35
|
import { campaignTree, runDirectory } from "../run/paths.mjs";
|
|
36
36
|
|
|
@@ -364,7 +364,7 @@ export async function runPlanningPipeline(options) {
|
|
|
364
364
|
/** @type {PlanFindingOutput|null} */
|
|
365
365
|
let freezeFailure = null;
|
|
366
366
|
try {
|
|
367
|
-
validateContract(frozenContractRaw(assembleFrozenNodes(plan)), join(plansDir, "contract.json"));
|
|
367
|
+
assertTimeoutsCoverMeasured(validateContract(frozenContractRaw(assembleFrozenNodes(plan)), join(plansDir, "contract.json")), repoFacts);
|
|
368
368
|
} catch (error) {
|
|
369
369
|
freezeFailure = invalidPlanFinding(`freeze-r${round}`, error);
|
|
370
370
|
findings = [...findings, freezeFailure];
|
|
@@ -457,6 +457,7 @@ export async function runPlanningPipeline(options) {
|
|
|
457
457
|
// The pipeline's own pinned spec bytes: a wrong or missing digest is the
|
|
458
458
|
// first thing the Campaign Brief refuses on, never a summary.
|
|
459
459
|
spec: { path: relativeSpecPath, digest: specDigest },
|
|
460
|
+
facts: repoFacts,
|
|
460
461
|
provenance: {
|
|
461
462
|
targetGitHead: repoFacts.gitHead,
|
|
462
463
|
planner: { runtimeId: runtimeDefaults.worker ?? "", model: modelOf(runtimes, runtimeDefaults.worker) },
|
|
@@ -17,6 +17,7 @@ import { join, resolve } from "node:path";
|
|
|
17
17
|
import { lstatSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
18
18
|
import { tmpdir } from "node:os";
|
|
19
19
|
import { RUNS_DIR_NAME } from "../run/paths.mjs";
|
|
20
|
+
import { isIgnoreSource } from "./workspace.mjs";
|
|
20
21
|
|
|
21
22
|
/** @typedef {import("../contract/index.mjs").ValidatedNode} ValidatedNode */
|
|
22
23
|
|
|
@@ -49,6 +50,21 @@ export function unsnapshottedWriteWarnings(node, index, cwd) {
|
|
|
49
50
|
`nodes[${index}] (${node.id}): ${path.includes("/") ? `${kind} under ${root}/` : `${kind} ${path}`} are outside the workspace snapshot, so the closed-scope gate cannot observe them`,
|
|
50
51
|
);
|
|
51
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* `writes_ignore_source`: a declared write the workspace snapshot fingerprints
|
|
55
|
+
* as an ignore source. The node fails with `snapshot_ignore_changed` the
|
|
56
|
+
* moment the worker changes it, and two campaigns each lost a node learning
|
|
57
|
+
* that (RM-051), so the author hears it before dispatch.
|
|
58
|
+
*
|
|
59
|
+
* @param {ValidatedNode} node
|
|
60
|
+
* @param {number} index
|
|
61
|
+
* @returns {string[]}
|
|
62
|
+
*/
|
|
63
|
+
export function ignoreSourceWriteWarnings(node, index) {
|
|
64
|
+
const sources = (node.taskPacket.writeFiles ?? []).filter(isIgnoreSource);
|
|
65
|
+
if (!sources.length) return [];
|
|
66
|
+
return [`nodes[${index}] (${node.id}): writes_ignore_source: writeFiles ${sources.join(", ")} ${sources.length === 1 ? "is an ignore source" : "are ignore sources"} the workspace snapshot fingerprints; a worker that changes one fails the node with snapshot_ignore_changed, so make that edit outside the run`];
|
|
67
|
+
}
|
|
52
68
|
/**
|
|
53
69
|
* @param {string|undefined} cwd
|
|
54
70
|
* @param {string} declaredPath
|
package/src/repo/workspace.mjs
CHANGED
|
@@ -53,7 +53,7 @@ export function captureWorkspaceSnapshot(cwd, expectedIgnoreSources) {
|
|
|
53
53
|
const root = realpathSync(cwd);
|
|
54
54
|
const ignoreSources = captureIgnoreSources(root);
|
|
55
55
|
if (expectedIgnoreSources !== undefined && !sameSnapshotEntries(expectedIgnoreSources, ignoreSources)) {
|
|
56
|
-
throw
|
|
56
|
+
throw ignoreSourcesChanged(expectedIgnoreSources, ignoreSources);
|
|
57
57
|
}
|
|
58
58
|
/** @type {SnapshotEntry[]} */
|
|
59
59
|
const entries = [];
|
|
@@ -123,7 +123,7 @@ export function compareWorkspaceSnapshot(before, cwd, scope = {}) {
|
|
|
123
123
|
});
|
|
124
124
|
const after = captureWorkspaceSnapshot(cwd, before.ignoreSources);
|
|
125
125
|
if (!sameSnapshotEntries(before.ignoreSources, after.ignoreSources)) {
|
|
126
|
-
throw
|
|
126
|
+
throw ignoreSourcesChanged(before.ignoreSources, after.ignoreSources);
|
|
127
127
|
}
|
|
128
128
|
const prior = new Map(before.entries.map((/** @type {SnapshotEntry} */ entry) => [entry.path, JSON.stringify(entry)]));
|
|
129
129
|
const current = new Map(after.entries.map((/** @type {SnapshotEntry} */ entry) => [entry.path, JSON.stringify(entry)]));
|
|
@@ -262,6 +262,45 @@ export function validateWorkspaceScopeBoundary(cwd, boundary, declared = {}) {
|
|
|
262
262
|
rootOrigins,
|
|
263
263
|
};
|
|
264
264
|
}
|
|
265
|
+
/** Directories `captureIgnoreSources` skips at any depth. */
|
|
266
|
+
const SKIPPED_SOURCE_DIRS = new Set([".git", RUNS_DIR_NAME, "node_modules", ".venv", "venv"]);
|
|
267
|
+
|
|
268
|
+
/** The ignore sources `captureIgnoreSources` always fingerprints at the root. */
|
|
269
|
+
const ROOT_IGNORE_SOURCES = [".faberunignore", ".gitignore", ".git/config"];
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Whether a workspace-relative path is one `captureIgnoreSources` fingerprints,
|
|
273
|
+
* so a worker that changes it fails its node with `snapshot_ignore_changed`.
|
|
274
|
+
* A `.gitignore` counts at any depth outside the directories the walk skips.
|
|
275
|
+
*
|
|
276
|
+
* @param {string} path
|
|
277
|
+
* @returns {boolean}
|
|
278
|
+
*/
|
|
279
|
+
export function isIgnoreSource(path) {
|
|
280
|
+
const normalized = path.replaceAll("\\", "/").replace(/^\.\//u, "");
|
|
281
|
+
if (ROOT_IGNORE_SOURCES.includes(normalized) || normalized === ".git/info/exclude" || normalized === ".git") return true;
|
|
282
|
+
const segments = normalized.split("/");
|
|
283
|
+
// The same directories `captureIgnoreSources` never walks.
|
|
284
|
+
if (segments.some((segment) => SKIPPED_SOURCE_DIRS.has(segment)) || [".claude", ".codex"].includes(segments[0])) return false;
|
|
285
|
+
return basename(normalized) === ".gitignore";
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The `snapshot_ignore_changed` failure, naming every source that moved.
|
|
290
|
+
* Measured on `rec-audit-remediation`: the message named none, and the
|
|
291
|
+
* operator had to diff the worktree to learn it was one `.gitignore` line.
|
|
292
|
+
*
|
|
293
|
+
* @param {SnapshotEntry[]} before
|
|
294
|
+
* @param {SnapshotEntry[]} after
|
|
295
|
+
* @returns {Error}
|
|
296
|
+
*/
|
|
297
|
+
function ignoreSourcesChanged(before, after) {
|
|
298
|
+
const prior = new Map(before.map((entry) => [entry.path, JSON.stringify(entry)]));
|
|
299
|
+
const current = new Map(after.map((entry) => [entry.path, JSON.stringify(entry)]));
|
|
300
|
+
const changed = [...new Set([...prior.keys(), ...current.keys()])].filter((path) => prior.get(path) !== current.get(path)).sort();
|
|
301
|
+
return fail("snapshot_ignore_changed", `workspace ignore sources changed during worker execution: ${changed.join(", ")}`);
|
|
302
|
+
}
|
|
303
|
+
|
|
265
304
|
/**
|
|
266
305
|
* Snapshot the files Git can use to hide workspace changes. These entries are
|
|
267
306
|
* kept separate from the relevant-file entry cap. A worker cannot replace
|
|
@@ -273,7 +312,7 @@ export function validateWorkspaceScopeBoundary(cwd, boundary, declared = {}) {
|
|
|
273
312
|
*/
|
|
274
313
|
function captureIgnoreSources(root) {
|
|
275
314
|
/** @type {Set<string>} */
|
|
276
|
-
const paths = new Set(
|
|
315
|
+
const paths = new Set(ROOT_IGNORE_SOURCES);
|
|
277
316
|
/** @type {Map<string, string>} */
|
|
278
317
|
const gitPaths = new Map();
|
|
279
318
|
/** @param {string} name @param {string} logical @returns {string|null} */
|
package/src/repo/worktree.mjs
CHANGED
|
@@ -322,10 +322,27 @@ function gitTracks(repo, read) {
|
|
|
322
322
|
}
|
|
323
323
|
|
|
324
324
|
/**
|
|
325
|
-
*
|
|
325
|
+
* Every untracked, unignored path in a worktree, outside the runner's own
|
|
326
|
+
* `.runs` tree and the linked `node_modules`: the same exclusions the seal
|
|
327
|
+
* applies, so the two agree on what "left in the worktree" means.
|
|
328
|
+
*
|
|
329
|
+
* @param {string} path
|
|
330
|
+
* @returns {string[]}
|
|
331
|
+
*/
|
|
332
|
+
export function untrackedPaths(path) {
|
|
333
|
+
const listed = git(path, ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--", ".", `:(exclude)${RUNS_DIR_NAME}`, ":(exclude)node_modules"]);
|
|
334
|
+
return listed.split("\0").filter((entry) => entry.startsWith("?? ")).map((entry) => entry.slice(3)).sort();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* `exclude` names paths the attempt holds but must not seal: what the
|
|
339
|
+
* controller's own verification left behind (`verificationArtifacts`). They
|
|
340
|
+
* stay on disk and out of the commit.
|
|
341
|
+
*
|
|
342
|
+
* @param {{repo: string, path: string, baseSha: string|null, runId: string, nodeId: string, attempt: number, exclude?: string[]}} args
|
|
326
343
|
* @returns {SealedAttempt}
|
|
327
344
|
*/
|
|
328
|
-
export function sealAttempt({ repo, path, baseSha, runId, nodeId, attempt }) {
|
|
345
|
+
export function sealAttempt({ repo, path, baseSha, runId, nodeId, attempt, exclude = [] }) {
|
|
329
346
|
// The attempt-local `.runs` result sidecar must never enter the attempt
|
|
330
347
|
// commit. Naming it through an exclude pathspec makes `git add` exit 1 with
|
|
331
348
|
// advice.addIgnoredFile as soon as the sidecar exists in a repository that
|
|
@@ -345,6 +362,11 @@ export function sealAttempt({ repo, path, baseSha, runId, nodeId, attempt }) {
|
|
|
345
362
|
// node_modules is linked into the worktree as a symlink, which `node_modules/`
|
|
346
363
|
// in .gitignore does not match; never let the link into the attempt commit.
|
|
347
364
|
runGit(["-C", path, "rm", "-r", "-q", "--cached", "--ignore-unmatch", "--", RUNS_DIR_NAME, "node_modules"]);
|
|
365
|
+
if (exclude.length) runGit(["-C", path, "rm", "-q", "--cached", "--ignore-unmatch", "--", ...exclude.map((item) => `:(literal)${item}`)]);
|
|
366
|
+
}
|
|
367
|
+
// A worktree whose only change was an excluded artifact stages nothing, and
|
|
368
|
+
// an empty commit exits 1; the attempt's head is then its seal.
|
|
369
|
+
if (dirty && !stagedNothing(path)) {
|
|
348
370
|
runGit([
|
|
349
371
|
"-C", path,
|
|
350
372
|
"-c", "user.email=runner@example.test",
|
|
@@ -371,6 +393,16 @@ export function sealAttempt({ repo, path, baseSha, runId, nodeId, attempt }) {
|
|
|
371
393
|
return { sha, empty };
|
|
372
394
|
}
|
|
373
395
|
|
|
396
|
+
/** @param {string} path @returns {boolean} */
|
|
397
|
+
function stagedNothing(path) {
|
|
398
|
+
try {
|
|
399
|
+
runGit(["-C", path, "diff", "--cached", "--quiet"]);
|
|
400
|
+
return true;
|
|
401
|
+
} catch {
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
374
406
|
/** @param {string} repo @param {string} base @param {string} head @returns {boolean} */
|
|
375
407
|
export function gitDiffEmpty(repo, base, head) {
|
|
376
408
|
try {
|
package/src/report/final.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { compactCost, compactTokens, errorCode } from "../util.mjs";
|
|
10
10
|
import { basename, join } from "node:path";
|
|
11
11
|
import { readJson, writeJsonAtomic, writeTextAtomic } from "../run/store.mjs";
|
|
12
|
-
import { scopeFindingsNote } from "../contract/scope-findings.mjs";
|
|
12
|
+
import { scopeFindingsNote, verificationArtifactsNote } from "../contract/scope-findings.mjs";
|
|
13
13
|
import { MARK, advisoryFindingCount, fit, judgeRuntimeLabel, roleCosts, roleUsage, statusNote, workerRuntimeLabel, writeStatusArtifacts } from "./render.mjs";
|
|
14
14
|
import { packetRepetitionByNode, packetRepetitionNote } from "./packet-repetition.mjs";
|
|
15
15
|
import { unlinkSync } from "node:fs";
|
|
@@ -131,9 +131,10 @@ export function renderFinalReport(runDir, contract, states) {
|
|
|
131
131
|
const judge = judgeRuntimeLabel(node) ?? "-";
|
|
132
132
|
const planNode = contract.nodes.find((candidate) => candidate.id === node.id);
|
|
133
133
|
const detail = node.gate?.summary ?? node.error?.message ?? (node.blockedBy?.length ? node.blockedBy.join(", ") : null) ?? (typeof node.result === "string" && node.result.trim() ? node.result.trim() : node.phase ?? "-");
|
|
134
|
-
// The advisory
|
|
135
|
-
const
|
|
136
|
-
|
|
134
|
+
// The advisory findings lead the note, as they do in STATUS.md.
|
|
135
|
+
const findings = [scopeFindingsNote(node.scopeFindings), verificationArtifactsNote(node.verificationArtifacts)].filter(Boolean).join(" · ");
|
|
136
|
+
const note = findings
|
|
137
|
+
? `${findings} · ${detail}`
|
|
137
138
|
: `${detail} · phase ${planNode?.phase ?? "-"} · ${node.invocations?.at(-1)?.continuationMode ?? "fresh"}`;
|
|
138
139
|
lines.push(row([
|
|
139
140
|
MARK[node.status] ?? "[?]",
|
package/src/report/render.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { basename, join } from "node:path";
|
|
|
3
3
|
import { validateContract } from "../contract/index.mjs";
|
|
4
4
|
import { readJson, writeJsonAtomic } from "../run/store.mjs";
|
|
5
5
|
import { lockStale, pidAlive, readLock } from "../run/lock.mjs";
|
|
6
|
-
import { scopeFindingsNote } from "../contract/scope-findings.mjs";
|
|
6
|
+
import { scopeFindingsNote, verificationArtifactsNote } from "../contract/scope-findings.mjs";
|
|
7
7
|
import { reviewNote } from "../contract/review-modes.mjs";
|
|
8
8
|
import { validateNodeSnapshot, validateRunMetadata } from "../contract/snapshot.mjs";
|
|
9
9
|
import { compactCost, compactTokens, truncateChars } from "../util.mjs";
|
|
@@ -32,7 +32,7 @@ const POINTER_ATTENTION_CHARS = 80;
|
|
|
32
32
|
/** @typedef {{inputTokens: number|null, outputTokens: number|null, cacheReadInputTokens: number|null}} StatusPayloadUsage */
|
|
33
33
|
/** @typedef {{index: number, total: number, argv: string}} VerificationProgress */
|
|
34
34
|
/** @typedef {{verdict: string, maxSeverity: string, findingCount: number, summary: string|null}} StatusPayloadGate */
|
|
35
|
-
/** @typedef {{id: string, status: NodeStatus, phase: string|null, executionPhase: string|null, runtime: string|null, workerRuntime: string|null, judgeRuntime: string|null, continuation: string, attempt: number, revisions: number, startedAt: string|null, updatedAt: string|null, usage: StatusPayloadUsage|null, costUsd: number|null, roleCostUsd: {worker: number|null, judge: number|null}, costProvenance: {worker: string, judge: string}, verdict: string|null, gate: StatusPayloadGate|null, gateOutcome: "passed"|"rejected"|null, pendingHandoff: {runtime: string, reason: string}|null, note: string|null, scopeFindings: string[]|null, errorCode: string|null, blockedBy: string[], verificationProgress: VerificationProgress|null, declaredReadBytes: number|null}} StatusPayloadNode */
|
|
35
|
+
/** @typedef {{id: string, status: NodeStatus, phase: string|null, executionPhase: string|null, runtime: string|null, workerRuntime: string|null, judgeRuntime: string|null, continuation: string, attempt: number, revisions: number, startedAt: string|null, updatedAt: string|null, usage: StatusPayloadUsage|null, costUsd: number|null, roleCostUsd: {worker: number|null, judge: number|null}, costProvenance: {worker: string, judge: string}, verdict: string|null, gate: StatusPayloadGate|null, gateOutcome: "passed"|"rejected"|null, pendingHandoff: {runtime: string, reason: string}|null, note: string|null, scopeFindings: string[]|null, verificationArtifacts: string[]|null, errorCode: string|null, blockedBy: string[], verificationProgress: VerificationProgress|null, declaredReadBytes: number|null}} StatusPayloadNode */
|
|
36
36
|
/** @typedef {{schemaVersion: 1, run: string, contractId: string, campaignId: string, goal: string, usage: {inputTokens: number, outputTokens: number, cacheReadInputTokens: number, costUsd: number|null}, roles: {worker: RoleUsage, judge: RoleUsage}, controller: JsonObject, identityWarnings: string[], summary: string, nodes: StatusPayloadNode[]}} StatusPayload */
|
|
37
37
|
|
|
38
38
|
/** The glyph each terminal state prints in a status table. */
|
|
@@ -296,6 +296,7 @@ function buildStatusPayload(runDir, contract, nodes, identityWarnings, usage) {
|
|
|
296
296
|
pendingHandoff: pendingHandoff(node),
|
|
297
297
|
note: statusNote(node),
|
|
298
298
|
scopeFindings: node.scopeFindings?.unexpectedPaths ?? null,
|
|
299
|
+
verificationArtifacts: node.verificationArtifacts ?? null,
|
|
299
300
|
errorCode: node.error?.code ?? null,
|
|
300
301
|
blockedBy: node.blockedBy ?? [],
|
|
301
302
|
verificationProgress: progress,
|
|
@@ -723,7 +724,7 @@ function boundedNote(segments, maxLength = MAX_NOTE_LENGTH) {
|
|
|
723
724
|
* @returns {string|null}
|
|
724
725
|
*/
|
|
725
726
|
export function statusNote(node) {
|
|
726
|
-
const scope = scopeFindingsNote(node.scopeFindings);
|
|
727
|
+
const scope = boundedNote([scopeFindingsNote(node.scopeFindings), verificationArtifactsNote(node.verificationArtifacts)]);
|
|
727
728
|
const review = reviewNote(node);
|
|
728
729
|
const detail = node.gate?.summary ?? node.error?.message ?? node.blockedBy?.join(", ") ?? (candidateVerificationActive(node) ? "candidate" : node.phase);
|
|
729
730
|
const note = boundedNote([review, detail]);
|