faberun 0.20.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 +2 -1
- package/skills/faberun/references/operations.md +7 -7
- package/src/campaign/brief-cli.mjs +174 -0
- package/src/campaign/brief-text.mjs +96 -0
- package/src/campaign/campaign-brief.mjs +772 -0
- package/src/campaign/chain.mjs +30 -1
- package/src/campaign/projection.mjs +34 -0
- package/src/cli/campaign.mjs +22 -2
- 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 +240 -35
- package/src/plan/pipeline-shape.mjs +100 -0
- package/src/plan/pipeline.mjs +68 -116
- package/src/plan/template.mjs +88 -25
- 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/campaign-brief-estimate.mjs +450 -0
- package/src/report/campaign-brief-html.mjs +439 -0
- package/src/report/campaign-brief.mjs +409 -0
- package/src/report/final.mjs +5 -4
- package/src/report/mdhtml-release.json +30 -0
- package/src/report/render.mjs +4 -3
- package/src/run/usage.mjs +265 -0
- package/src/web/campaign-brief-server.mjs +401 -0
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
|
+
}
|
|
@@ -17,6 +17,7 @@ import { readJournal, validateJournalEntry, withoutLegacyLivenessFields } from "
|
|
|
17
17
|
/** @typedef {import("../notify/index.mjs").JsonObject} JsonObject */
|
|
18
18
|
/** @typedef {import("./index.mjs").Projection} Projection */
|
|
19
19
|
/** @typedef {import("./index.mjs").ProjectionRecord} ProjectionRecord */
|
|
20
|
+
/** @typedef {{id: string, text: string, at: string, sessionId: string}} ProjectedDecision */
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* @param {string} campaignPath
|
|
@@ -58,6 +59,39 @@ export function readProjectionState(campaignPath, campaign) {
|
|
|
58
59
|
changed: true,
|
|
59
60
|
};
|
|
60
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* The active decisions a campaign journal folds to: every `decision` entry the
|
|
64
|
+
* projection still holds, ordered by journal position, with `supersede`
|
|
65
|
+
* bookkeeping already applied by the fold. This is the only journal view a
|
|
66
|
+
* Campaign Brief may use for decisions; nothing is inferred from a run graph, a
|
|
67
|
+
* run status or prose. A projection that carries no decisions yields an empty
|
|
68
|
+
* list, never a guess.
|
|
69
|
+
*
|
|
70
|
+
* @param {Projection} state
|
|
71
|
+
* @returns {ProjectedDecision[]}
|
|
72
|
+
*/
|
|
73
|
+
export function projectCampaignDecisions(state) {
|
|
74
|
+
const decisions = state && typeof state === "object" && typeof state.decisions === "object" && state.decisions !== null && !Array.isArray(state.decisions)
|
|
75
|
+
? /** @type {Record<string, JournalEntry>} */ (state.decisions)
|
|
76
|
+
: {};
|
|
77
|
+
/** @type {ProjectedDecision[]} */
|
|
78
|
+
const projected = [];
|
|
79
|
+
for (const entry of Object.values(decisions)) {
|
|
80
|
+
if (entry.type !== "decision" || typeof entry.decisionId !== "string") continue;
|
|
81
|
+
projected.push({
|
|
82
|
+
id: entry.decisionId,
|
|
83
|
+
text: typeof entry.text === "string" ? entry.text : "",
|
|
84
|
+
at: typeof entry.at === "string" ? entry.at : "",
|
|
85
|
+
sessionId: typeof entry.sessionId === "string" ? entry.sessionId : "",
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
projected.sort((left, right) => {
|
|
89
|
+
if (left.at !== right.at) return left.at < right.at ? -1 : 1;
|
|
90
|
+
if (left.id !== right.id) return left.id < right.id ? -1 : 1;
|
|
91
|
+
return 0;
|
|
92
|
+
});
|
|
93
|
+
return projected;
|
|
94
|
+
}
|
|
61
95
|
/**
|
|
62
96
|
* @param {unknown} stored
|
|
63
97
|
* @returns {stored is ProjectionRecord}
|
package/src/cli/campaign.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
resolveCampaign,
|
|
12
12
|
} from "../campaign/index.mjs";
|
|
13
13
|
import { addContract, replaceContract } from "./campaign-contract.mjs";
|
|
14
|
+
import { generateCampaignBrief } from "../campaign/brief-cli.mjs";
|
|
14
15
|
import { runsRoot } from "../run/paths.mjs";
|
|
15
16
|
import { syncAgentSignal } from "../repo/signal.mjs";
|
|
16
17
|
import { acknowledgeJournalEvent, appendJournal, appendSeatAllowanceEvent, readJournal, watchJournal } from "../campaign/journal.mjs";
|
|
@@ -83,12 +84,13 @@ const OPERATION_OPTIONS = {
|
|
|
83
84
|
unpark: { cwd: { type: "string" }, force: { type: "boolean" }, "event-id": { type: "string" } },
|
|
84
85
|
"add-contract": { cwd: { type: "string" }, path: { type: "string" } },
|
|
85
86
|
"replace-contract": { cwd: { type: "string" }, path: { type: "string" }, replace: { type: "string" } },
|
|
87
|
+
brief: { cwd: { type: "string" }, phase: { type: "string" } },
|
|
86
88
|
show: { cwd: { type: "string" } },
|
|
87
89
|
sync: { cwd: { type: "string" }, "session-id": { type: "string" } },
|
|
88
90
|
ack: { cwd: { type: "string" }, "session-id": { type: "string" }, "event-id": { type: "string" } },
|
|
89
91
|
};
|
|
90
92
|
|
|
91
|
-
/** @typedef {{cwd?: string, goal?: string, contract?: string[], landBranch?: string, tool?: string, sessionId?: string, transcript?: string, format?: string, cursor?: string, since?: string, kind?: string, text?: string, runId?: string, supersedes?: string, decisionId?: string, questionId?: string, eventId?: string, noTranscript?: boolean, wake?: boolean, detach?: boolean, interval?: string, once?: boolean, allowMain?: boolean, force?: boolean, path?: string, replace?: string}} CliValues */
|
|
93
|
+
/** @typedef {{cwd?: string, goal?: string, contract?: string[], landBranch?: string, tool?: string, sessionId?: string, transcript?: string, format?: string, cursor?: string, since?: string, kind?: string, text?: string, runId?: string, supersedes?: string, decisionId?: string, questionId?: string, eventId?: string, noTranscript?: boolean, wake?: boolean, detach?: boolean, interval?: string, once?: boolean, allowMain?: boolean, force?: boolean, path?: string, replace?: string, phase?: string}} CliValues */
|
|
92
94
|
/** @typedef {import("../campaign/index.mjs").Campaign} Campaign */
|
|
93
95
|
|
|
94
96
|
/**
|
|
@@ -99,6 +101,24 @@ export async function campaignCli(args) {
|
|
|
99
101
|
const operation = args[0];
|
|
100
102
|
if (!operation || !(operation in OPERATION_OPTIONS)) return usage();
|
|
101
103
|
const { positional, values } = parseArgs(args.slice(1), operation);
|
|
104
|
+
if (operation === "brief") {
|
|
105
|
+
const [action, campaignId, ...extra] = positional;
|
|
106
|
+
if (campaignId === undefined || extra.length) return usage();
|
|
107
|
+
if (action === "generate") {
|
|
108
|
+
generateCampaignBrief({ campaignId, phase: values.phase, cwd: values.cwd });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
// `serve` starts the separate R8 loopback server; the module owns the
|
|
112
|
+
// verification and HTTP behavior, the CLI only parses and dispatches. It is
|
|
113
|
+
// imported on demand so the ordinary CLI startup graph stays free of the
|
|
114
|
+
// HTTP server for every other verb.
|
|
115
|
+
if (action === "serve") {
|
|
116
|
+
const { serveCampaignBrief } = await import("../web/campaign-brief-server.mjs");
|
|
117
|
+
await serveCampaignBrief({ campaignId, phase: values.phase, cwd: values.cwd });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
return usage();
|
|
121
|
+
}
|
|
102
122
|
const [campaignId, ...extra] = positional;
|
|
103
123
|
if (operation === "list") {
|
|
104
124
|
if (campaignId !== undefined || extra.length) return usage();
|
|
@@ -625,7 +645,7 @@ function positiveIntervalMs(value) {
|
|
|
625
645
|
|
|
626
646
|
function usage() {
|
|
627
647
|
process.stderr.write(
|
|
628
|
-
"usage: faberun campaign <init|watch|attach|note|resolve|close|supervise|unpark|show|list|sync|ack|add-contract|replace-contract> <campaign-id> [--cwd <dir>] ...\n",
|
|
648
|
+
"usage: faberun campaign <init|watch|attach|note|resolve|close|supervise|unpark|show|list|sync|ack|add-contract|replace-contract|brief> <campaign-id> [--cwd <dir>] ...\n",
|
|
629
649
|
);
|
|
630
650
|
process.exitCode = 2;
|
|
631
651
|
}
|
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
|