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.
@@ -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}
@@ -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
  }
@@ -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
@@ -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
- const paths = /** @type {unknown[]} */ (value.unexpectedPaths);
615
- if (!Array.isArray(value.unexpectedPaths) || paths.length > MAX_SCOPE_FINDING_PATHS || paths.some((path) => typeof path !== "string")) {
616
- throw new TypeError("node snapshot.scopeFindings.unexpectedPaths is invalid");
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("node snapshot.scopeFindings.unexpectedPaths contains an oversized path");
630
+ throw new TypeError(`${label} contains an oversized path`);
620
631
  }
621
632
  }
622
633
  /**
@@ -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
- `Your only job in this single bounded turn is to write the required worker-result JSON object to: ${resultPath}`,
394
- "Then return that same JSON object as the final message.",
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 {
@@ -396,6 +396,7 @@ export async function sealBeforeTerminate(job, timeout) {
396
396
  runId: job.contract.id,
397
397
  nodeId: job.node.id,
398
398
  attempt: job.state.attempt,
399
+ exclude: job.state.verificationArtifacts,
399
400
  });
400
401
  job.state.worktree = {
401
402
  ...job.state.worktree,
@@ -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:",
@@ -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);
@@ -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 workspace = attemptWorkspace(state) ?? contract.cwd;
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} */
@@ -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
- * @typedef {{field: "permissionMode"|"sandbox", executingModes: string[], defaultMode: string}|null} PermissionExecutionPolicy
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