faberun 0.26.3 → 0.28.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/README.md +11 -4
- package/package.json +1 -1
- package/skills/faberun/SKILL.md +3 -3
- package/skills/faberun/references/contract.md +9 -9
- package/skills/faberun/references/local-env.md +11 -0
- package/skills/faberun/references/operations.md +26 -33
- package/src/campaign/brief.mjs +41 -9
- package/src/campaign/campaign-brief.mjs +18 -3
- package/src/campaign/index.mjs +22 -9
- package/src/campaign/ledger.mjs +72 -4
- package/src/campaign/metrics.mjs +29 -2
- package/src/campaign/record.mjs +34 -0
- package/src/cli/campaign.mjs +35 -12
- package/src/cli/contract.mjs +11 -1
- package/src/cli/launch.mjs +12 -2
- package/src/cli/manual.mjs +24 -8
- package/src/cli/setup.mjs +6 -1
- package/src/cli/skills.mjs +18 -7
- package/src/cli/uninstall.mjs +542 -0
- package/src/cli/walkthrough.mjs +462 -0
- package/src/cli.mjs +68 -8
- package/src/contract/articles.mjs +30 -0
- package/src/contract/definition-of-done.mjs +109 -0
- package/src/contract/index.mjs +7 -4
- package/src/contract/judge-independence.mjs +18 -0
- package/src/contract/runtime.mjs +6 -2
- package/src/contract/scope-findings.mjs +120 -0
- package/src/contract/snapshot.mjs +19 -5
- package/src/contract/task-packet.mjs +49 -1
- package/src/contract/worker-result.mjs +10 -0
- package/src/engine/assignment.mjs +45 -0
- package/src/engine/detach.mjs +18 -0
- package/src/engine/gate.mjs +10 -2
- package/src/engine/judge-gate.mjs +24 -9
- package/src/engine/live-preflight.mjs +114 -18
- package/src/engine/prompts.mjs +41 -3
- package/src/engine/resume.mjs +119 -12
- package/src/engine/retry.mjs +11 -11
- package/src/engine/run-command.mjs +80 -4
- package/src/engine/run-identity.mjs +27 -14
- package/src/engine/runtime-discovery.mjs +61 -13
- package/src/engine/worker-env.mjs +179 -0
- package/src/harnesses/agy/index.mjs +11 -0
- package/src/harnesses/catalogue.mjs +54 -2
- package/src/harnesses/claude/index.mjs +177 -2
- package/src/harnesses/codex/index.mjs +15 -0
- package/src/harnesses/dsh/index.mjs +18 -116
- package/src/harnesses/dsh/runner.mjs +1 -17
- package/src/harnesses/exec-jsonl/index.mjs +12 -0
- package/src/harnesses/fx/home.mjs +57 -0
- package/src/harnesses/fx/index.mjs +137 -0
- package/src/harnesses/fx/native/build.zig +20 -0
- package/src/harnesses/fx/native/build.zig.zon +7 -0
- package/src/harnesses/fx/native/home.zig +85 -0
- package/src/harnesses/fx/native/main.zig +503 -0
- package/src/harnesses/fx/native/permissions.zig +51 -0
- package/src/harnesses/fx/native/relay.zig +253 -0
- package/src/harnesses/fx/native/usage.zig +112 -0
- package/src/harnesses/fx/permissions.mjs +35 -0
- package/src/harnesses/fx/runner.mjs +350 -0
- package/src/harnesses/fx/usage-proxy.mjs +176 -0
- package/src/harnesses/index.mjs +29 -14
- package/src/harnesses/replay/index.mjs +12 -0
- package/src/harnesses/runner-io.mjs +25 -0
- package/src/harnesses/runner-transcript.mjs +127 -0
- package/src/harnesses/session-metrics.mjs +38 -13
- package/src/harnesses/zcode/index.mjs +19 -0
- package/src/host/config.mjs +8 -2
- package/src/host/environment-report.mjs +85 -0
- package/src/host/home.mjs +27 -0
- package/src/host/install-registry.mjs +511 -0
- package/src/host/preflight.mjs +34 -10
- package/src/host/uninstall-roots.mjs +196 -0
- package/src/plan/proof-check.mjs +15 -0
- package/src/plan/repo-facts.mjs +95 -11
- package/src/plan/rounds.mjs +27 -0
- package/src/repo/declared-paths.mjs +74 -6
- package/src/repo/integrate.mjs +1 -1
- package/src/repo/scope-closure.mjs +59 -0
- package/src/repo/signal-block.mjs +26 -0
- package/src/repo/signal.mjs +9 -24
- package/src/repo/workspace.mjs +189 -14
- package/src/report/campaign-brief.mjs +4 -0
- package/src/report/message.mjs +37 -6
- package/src/report/progress.mjs +13 -2
- package/src/run/paths.mjs +14 -1
- package/src/run/usage.mjs +66 -1
- package/src/util.mjs +18 -2
|
@@ -177,18 +177,31 @@ function tapSelectedNothing(stdout) {
|
|
|
177
177
|
}
|
|
178
178
|
|
|
179
179
|
/**
|
|
180
|
-
* The environment a
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
180
|
+
* The full controller environment a Definition of Done verification proof runs
|
|
181
|
+
* under. A worker or judge receives the allowlist of `worker-env.mjs`; a
|
|
182
|
+
* verification command is the operator's own check and keeps today's
|
|
183
|
+
* environment (R1 constraint), secrets included. Exported so the gate test can
|
|
184
|
+
* assert that the verification spawn really carries the whole thing.
|
|
185
|
+
*
|
|
186
|
+
* @returns {NodeJS.ProcessEnv}
|
|
187
|
+
*/
|
|
188
|
+
export function verificationEnvironment() {
|
|
189
|
+
return { ...process.env };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The environment a filtered proof runs under: the full controller environment
|
|
194
|
+
* with the TAP reporter selected through `NODE_OPTIONS`, minus
|
|
195
|
+
* `NODE_TEST_CONTEXT`. That marker belongs to whichever test runner spawned
|
|
196
|
+
* this process; a nested `node --test` that inherits it stays a runner child
|
|
197
|
+
* and emits no TAP at all (measured 2026-09-21: with the marker the zero-plan
|
|
198
|
+
* line never appears, without it exactly once) -- and a proof is judged as its
|
|
199
|
+
* own top-level run, not as the suite's child.
|
|
187
200
|
*
|
|
188
201
|
* @returns {NodeJS.ProcessEnv}
|
|
189
202
|
*/
|
|
190
203
|
function envForFilteredProof() {
|
|
191
|
-
const { NODE_TEST_CONTEXT: _outer, NODE_OPTIONS: existing, ...ambient } =
|
|
204
|
+
const { NODE_TEST_CONTEXT: _outer, NODE_OPTIONS: existing, ...ambient } = verificationEnvironment();
|
|
192
205
|
return { ...ambient, NODE_OPTIONS: existing ? `${existing} --test-reporter=tap` : "--test-reporter=tap" };
|
|
193
206
|
}
|
|
194
207
|
|
|
@@ -239,7 +252,9 @@ async function proveCommand(id, proof, cwd, timeoutMs) {
|
|
|
239
252
|
shell: true,
|
|
240
253
|
detached: process.platform !== "win32",
|
|
241
254
|
stdio: ["ignore", "pipe", "pipe"],
|
|
242
|
-
|
|
255
|
+
// A proof keeps the full controller environment, filtered or not; only
|
|
256
|
+
// the test-filter case swaps in the TAP reporter.
|
|
257
|
+
env: filters.length ? envForFilteredProof() : verificationEnvironment(),
|
|
243
258
|
});
|
|
244
259
|
let stdout = "";
|
|
245
260
|
let stderr = "";
|
|
@@ -23,6 +23,7 @@ import { spawn } from "node:child_process";
|
|
|
23
23
|
import { boundedGitSync } from "../repo/worktree.mjs";
|
|
24
24
|
import { tmpdir } from "node:os";
|
|
25
25
|
import { validateContract } from "../contract/index.mjs";
|
|
26
|
+
import { assertInlineScriptProofsParse } from "../contract/definition-of-done.mjs";
|
|
26
27
|
import { validateNodeSnapshot } from "../contract/snapshot.mjs";
|
|
27
28
|
import { compactCost as formatCost } from "../util.mjs";
|
|
28
29
|
import { runsRoot } from "../run/paths.mjs";
|
|
@@ -34,6 +35,46 @@ import { runsRoot } from "../run/paths.mjs";
|
|
|
34
35
|
|
|
35
36
|
const LIVE_PREFLIGHT_PROMPT = "Respond with exactly FABERUN_PREFLIGHT_OK and do not use tools.";
|
|
36
37
|
const LIVE_PREFLIGHT_OUTPUT_LIMIT_BYTES = 512 * 1024;
|
|
38
|
+
/** How much of a timed-out provider's tail reaches the detail. */
|
|
39
|
+
const LIVE_PREFLIGHT_EXCERPT_BYTES = 512;
|
|
40
|
+
/**
|
|
41
|
+
* The measured live-preflight budget, in seconds, and the floor under every
|
|
42
|
+
* runtime's default. Measured 2026-09-22: four routed runtimes asked in
|
|
43
|
+
* parallel took about 18s, so 60s holds for every caller -- the dispatch gate,
|
|
44
|
+
* `doctor`, `faberun plan` and `faberun preflight`. It lives here and only
|
|
45
|
+
* here; `run-identity.mjs` and `host/preflight.mjs` used to carry their own
|
|
46
|
+
* copies, and the CLI's preflight used to default to 15s.
|
|
47
|
+
* `FABERUN_PREFLIGHT_TIMEOUT_SEC` stays the operator override.
|
|
48
|
+
*/
|
|
49
|
+
export const LIVE_PREFLIGHT_TIMEOUT_SEC = 60;
|
|
50
|
+
/**
|
|
51
|
+
* The lowest reasoning effort each harness accepts, applied to the liveness
|
|
52
|
+
* hello. The hello is one token: paying the effort a contract declares for the
|
|
53
|
+
* node's work -- a judge at `xhigh`, F10 -- to prove a credential works is what
|
|
54
|
+
* turned a deadline into a false failure. `zcode`, `exec-jsonl` and `replay`
|
|
55
|
+
* expose no effort transport, so there is nothing to lower for them.
|
|
56
|
+
*
|
|
57
|
+
* @type {Readonly<Record<string, string>>}
|
|
58
|
+
*/
|
|
59
|
+
const LIVE_PREFLIGHT_REASONING = Object.freeze({
|
|
60
|
+
claude: "low",
|
|
61
|
+
codex: "low",
|
|
62
|
+
agy: "low",
|
|
63
|
+
dsh: "off",
|
|
64
|
+
});
|
|
65
|
+
/**
|
|
66
|
+
* The causes `normalizeProviderAvailability` classifies out of provider text.
|
|
67
|
+
* A timeout whose tail names one of these is a provider that answered and then
|
|
68
|
+
* stopped talking, not silence, and the code the gate reads has to say so: a
|
|
69
|
+
* 401 the provider re-tried used to surface as `preflight_timeout` (F9).
|
|
70
|
+
*/
|
|
71
|
+
const PROVIDER_NAMED_CAUSES = new Set([
|
|
72
|
+
"authentication_failed",
|
|
73
|
+
"quota_exhausted",
|
|
74
|
+
"insufficient_balance",
|
|
75
|
+
"model_not_supported",
|
|
76
|
+
"credentials_missing",
|
|
77
|
+
]);
|
|
37
78
|
/**
|
|
38
79
|
* `persisted` is for the dispatch gate, which hands over the run's own
|
|
39
80
|
* serialized contract: that one was validated when it was authored, and
|
|
@@ -46,12 +87,16 @@ const LIVE_PREFLIGHT_OUTPUT_LIMIT_BYTES = 512 * 1024;
|
|
|
46
87
|
* whole is part of the answer.
|
|
47
88
|
*
|
|
48
89
|
* @param {string} contractPath
|
|
49
|
-
* @param {{static?: boolean, liveTimeoutSec?: number,
|
|
90
|
+
* @param {{static?: boolean, liveTimeoutSec?: number, persisted?: boolean}} [options]
|
|
50
91
|
* @returns {Promise<ProbeResult[]>}
|
|
51
92
|
*/
|
|
52
93
|
export async function preflightContract(contractPath, options = {}) {
|
|
53
94
|
const absoluteContractPath = resolve(contractPath);
|
|
54
95
|
const contract = validateContract(JSON.parse(readFileSync(absoluteContractPath, "utf8")), absoluteContractPath, options.persisted === true ? { persisted: true } : {});
|
|
96
|
+
// R34: every command proof that is an inline script is parsed before the
|
|
97
|
+
// first dispatch, so a body no attempt could ever make exit 0 refuses the
|
|
98
|
+
// launch here, naming its node and item, instead of after three paid tries.
|
|
99
|
+
assertInlineScriptProofsParse(contract.nodes);
|
|
55
100
|
return preflightRuntimes([...reachableRuntimes(contract).values()], { ...options, cwd: contract.cwd });
|
|
56
101
|
}
|
|
57
102
|
|
|
@@ -70,7 +115,7 @@ export async function preflightContract(contractPath, options = {}) {
|
|
|
70
115
|
* once, so a caller cannot accidentally ask a different question.
|
|
71
116
|
*
|
|
72
117
|
* @param {{runtime: RuntimeSnapshot, requiredCapabilitySets?: import("../harnesses/index.mjs").CapabilityRequirements[]}[]} entries
|
|
73
|
-
* @param {{static?: boolean, liveTimeoutSec?: number,
|
|
118
|
+
* @param {{static?: boolean, liveTimeoutSec?: number, cwd?: string}} [options]
|
|
74
119
|
* @returns {Promise<ProbeResult[]>}
|
|
75
120
|
*/
|
|
76
121
|
export async function preflightRuntimes(entries, options = {}) {
|
|
@@ -95,7 +140,7 @@ export async function preflightRuntimes(entries, options = {}) {
|
|
|
95
140
|
}
|
|
96
141
|
try {
|
|
97
142
|
return await Promise.all(staticChecks.map(async (check, index) => {
|
|
98
|
-
const live = await livePreflight(runtimes[index], liveRepo, configuredTimeoutSec ??
|
|
143
|
+
const live = await livePreflight(runtimes[index], liveRepo, configuredTimeoutSec ?? defaultLivePreflightTimeout(runtimes[index]));
|
|
99
144
|
const liveDetail = live.status === "done"
|
|
100
145
|
? `live done · usage ${formatUsage(live.usage)} · cost ${formatCost(live.costUsd)}`
|
|
101
146
|
: `live ${live.status} · ${live.error?.code ?? "provider_error"}: ${redactProviderText(live.error?.message ?? "generation failed")} · usage ${formatUsage(live.usage)} · cost ${formatCost(live.costUsd)}`;
|
|
@@ -146,8 +191,7 @@ export function defaultLivePreflightTimeout(runtime) {
|
|
|
146
191
|
// Keep LIVE_PREFLIGHT_CEILING_SEC equal to the largest value returned here.
|
|
147
192
|
const reasoning = /** @type {{reasoning?: unknown}} */ (runtime).reasoning;
|
|
148
193
|
if (reasoning === "xhigh" || reasoning === "max") return 180;
|
|
149
|
-
|
|
150
|
-
return 15;
|
|
194
|
+
return LIVE_PREFLIGHT_TIMEOUT_SEC;
|
|
151
195
|
}
|
|
152
196
|
/**
|
|
153
197
|
* The longest a live preflight can take without an explicit budget: what a
|
|
@@ -180,13 +224,15 @@ function createLivePreflightRepo() {
|
|
|
180
224
|
* @returns {RuntimeSnapshot}
|
|
181
225
|
*/
|
|
182
226
|
function safeLiveRuntime(runtime) {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
if (
|
|
227
|
+
const effort = LIVE_PREFLIGHT_REASONING[runtime.harness];
|
|
228
|
+
const safe = effort ? { ...runtime, reasoning: effort } : { ...runtime };
|
|
229
|
+
if (safe.harness === "codex") return { ...safe, sandbox: "read-only" };
|
|
230
|
+
if (safe.harness === "dsh" || safe.harness === "fx") return { ...safe, sandbox: "read-only" };
|
|
231
|
+
if (safe.harness === "claude") return { ...safe, permissionMode: "plan" };
|
|
186
232
|
// `plan` is the ZCode mode that reads without writing; the adapter's own
|
|
187
233
|
// default is `yolo`, which a preflight prompt must never reach.
|
|
188
|
-
if (
|
|
189
|
-
return
|
|
234
|
+
if (safe.harness === "zcode") return { ...safe, permissionMode: "plan" };
|
|
235
|
+
return safe;
|
|
190
236
|
}
|
|
191
237
|
/**
|
|
192
238
|
* @param {RuntimeSnapshot} runtime
|
|
@@ -274,14 +320,7 @@ function livePreflight(runtime, cwd, timeoutSec) {
|
|
|
274
320
|
}));
|
|
275
321
|
child.once("close", (exitCode, signalName) => {
|
|
276
322
|
if (timedOut) {
|
|
277
|
-
finish(
|
|
278
|
-
status: "failed",
|
|
279
|
-
result: null,
|
|
280
|
-
continuationId: null,
|
|
281
|
-
usage: emptyUsage(),
|
|
282
|
-
costUsd: null,
|
|
283
|
-
error: { code: "preflight_timeout", message: `live generation timed out after ${timeoutSec}s` },
|
|
284
|
-
});
|
|
323
|
+
finish(timedOutEnvelope(safeRuntime, stdout, stderr, timeoutSec));
|
|
285
324
|
return;
|
|
286
325
|
}
|
|
287
326
|
/** @type {ProviderEnvelope} */
|
|
@@ -314,6 +353,63 @@ function appendBounded(current, chunk, limit) {
|
|
|
314
353
|
const combined = Buffer.concat([Buffer.from(current), Buffer.from(chunk)]);
|
|
315
354
|
return (combined.length > limit ? combined.subarray(combined.length - limit) : combined).toString("utf8");
|
|
316
355
|
}
|
|
356
|
+
/**
|
|
357
|
+
* A timed-out process is not automatically a silent one. A provider that
|
|
358
|
+
* re-tries a 401, or prints a quota refusal and then hangs, said plenty before
|
|
359
|
+
* the wall-clock kill; the kill signal is ours, not the provider's, so the tail
|
|
360
|
+
* is classified the way a closed process is classified and the redacted excerpt
|
|
361
|
+
* is attached. Only a tail that names no provider cause stays
|
|
362
|
+
* `preflight_timeout`.
|
|
363
|
+
*
|
|
364
|
+
* @param {RuntimeSnapshot} runtime
|
|
365
|
+
* @param {string} stdout
|
|
366
|
+
* @param {string} stderr
|
|
367
|
+
* @param {number} timeoutSec
|
|
368
|
+
* @returns {ProviderEnvelope}
|
|
369
|
+
*/
|
|
370
|
+
function timedOutEnvelope(runtime, stdout, stderr, timeoutSec) {
|
|
371
|
+
const tail = appendBounded(stdout, `\n${stderr}`, LIVE_PREFLIGHT_EXCERPT_BYTES).trim();
|
|
372
|
+
const cause = classifiedTimeoutCause(runtime, stdout, stderr, tail);
|
|
373
|
+
const excerpt = redactProviderText(tail);
|
|
374
|
+
return {
|
|
375
|
+
status: "failed",
|
|
376
|
+
result: null,
|
|
377
|
+
continuationId: null,
|
|
378
|
+
usage: emptyUsage(),
|
|
379
|
+
costUsd: null,
|
|
380
|
+
error: {
|
|
381
|
+
code: cause ?? "preflight_timeout",
|
|
382
|
+
message: `live generation timed out after ${timeoutSec}s${excerpt ? ` · ${excerpt}` : ""}`,
|
|
383
|
+
},
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* The provider cause a timed-out tail names, or null when it names none. The
|
|
388
|
+
* harness adapter knows which event or line carries it, so the output is first
|
|
389
|
+
* normalized as if it had exited -- with the signal suppressed, because the
|
|
390
|
+
* signal at this point is ours -- and then classified through the same
|
|
391
|
+
* availability vocabulary an exited process uses. A harness that wrote the
|
|
392
|
+
* cause only to stderr still named it: the bounded raw tail is classified
|
|
393
|
+
* directly as the fallback.
|
|
394
|
+
*
|
|
395
|
+
* @param {RuntimeSnapshot} runtime
|
|
396
|
+
* @param {string} stdout
|
|
397
|
+
* @param {string} stderr
|
|
398
|
+
* @param {string} tail
|
|
399
|
+
* @returns {string|null}
|
|
400
|
+
*/
|
|
401
|
+
function classifiedTimeoutCause(runtime, stdout, stderr, tail) {
|
|
402
|
+
try {
|
|
403
|
+
const envelope = normalizeProviderResult(runtime, stdout, null, null, { stderr });
|
|
404
|
+
const availability = normalizeProviderAvailability(runtime, envelope);
|
|
405
|
+
if (PROVIDER_NAMED_CAUSES.has(availability.reason)) return availability.reason;
|
|
406
|
+
} catch {
|
|
407
|
+
// An unparsable tail still gets the raw-text classification below.
|
|
408
|
+
}
|
|
409
|
+
if (!tail) return null;
|
|
410
|
+
const availability = normalizeProviderAvailability(runtime, { status: "failed", error: { code: "provider_error", message: tail } });
|
|
411
|
+
return PROVIDER_NAMED_CAUSES.has(availability.reason) ? availability.reason : null;
|
|
412
|
+
}
|
|
317
413
|
/** @param {{inputTokens: number|null, outputTokens: number|null, cacheReadInputTokens: number|null}|undefined} usage */
|
|
318
414
|
function formatUsage(usage) {
|
|
319
415
|
if (!usage) return "in - out - cache -";
|
package/src/engine/prompts.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { validateWorkerResult } from "../contract/worker-result.mjs";
|
|
2
2
|
import { scopeFindingsPromptSection } from "../contract/scope-findings.mjs";
|
|
3
3
|
import { JUDGE_ENVELOPE_REASON, JUDGE_FINDING_ENVELOPE_REASON, JUDGE_LIMITS } from "../contract/judge-envelope.mjs";
|
|
4
|
+
import { RESERVED_OWNER_DECISIONS, uncoveredReservedOwnerDecisions } from "../contract/articles.mjs";
|
|
4
5
|
import { harnessCapabilities } from "../harnesses/index.mjs";
|
|
5
6
|
|
|
6
|
-
/** @typedef {{id: string, definitionOfDone: import("../contract/definition-of-done.mjs").DefinitionOfDoneItem[], taskPacket: {mode?: "execution"|"discovery"|"autonomous", objective: string, instructions: string[], writeFiles?: string[], writeRoots?: string[], verification: {argv: string[]}[]}}} JudgeNode */
|
|
7
|
+
/** @typedef {{id: string, definitionOfDone: import("../contract/definition-of-done.mjs").DefinitionOfDoneItem[], taskPacket: {mode?: "execution"|"discovery"|"autonomous", objective: string, instructions: string[], decisions?: string[], writeFiles?: string[], writeRoots?: string[], verification: {argv: string[]}[]}}} JudgeNode */
|
|
7
8
|
/** @typedef {{verdict: "pass"|"fail"|"invalid_judge_output", maxSeverity: "none"|"minor"|"major"|"critical", summary: string, findings: {severity: "minor"|"major"|"critical", description: string, evidence: string}[]}} JudgeVerdict */
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -178,10 +179,33 @@ function judgeVerificationEvidence(verification) {
|
|
|
178
179
|
};
|
|
179
180
|
}
|
|
180
181
|
|
|
182
|
+
/** Heading `renderPreviousAttemptSection` (engine/retry.mjs) writes an operator answer under. */
|
|
183
|
+
const OPERATOR_ANSWER_HEADING = "Operator answer:";
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The latest operator answer carried by a `Previous attempt` section, or null.
|
|
187
|
+
* `renderPreviousAttemptSection` writes it verbatim under the heading, so the
|
|
188
|
+
* judge reads the same newest answer the worker read without a second copy on
|
|
189
|
+
* the node snapshot. A second `--answer` appends an override and the section is
|
|
190
|
+
* rebuilt from the newest record on the next resume.
|
|
191
|
+
*
|
|
192
|
+
* @param {unknown} previousAttempt
|
|
193
|
+
* @returns {string|null}
|
|
194
|
+
*/
|
|
195
|
+
export function previousAttemptOperatorAnswer(previousAttempt) {
|
|
196
|
+
if (typeof previousAttempt !== "string") return null;
|
|
197
|
+
const start = previousAttempt.indexOf(OPERATOR_ANSWER_HEADING);
|
|
198
|
+
if (start < 0) return null;
|
|
199
|
+
const rest = previousAttempt.slice(start + OPERATOR_ANSWER_HEADING.length);
|
|
200
|
+
const stop = rest.search(/^(?:Attempt \d+ failed|Error:|Judge findings|Scope findings|Failing verification:)/mu);
|
|
201
|
+
const answer = (stop >= 0 ? rest.slice(0, stop) : rest).trim();
|
|
202
|
+
return answer || null;
|
|
203
|
+
}
|
|
204
|
+
|
|
181
205
|
/**
|
|
182
206
|
* @param {JudgeNode} node
|
|
183
207
|
* @param {unknown} workerResult
|
|
184
|
-
* @param {{diff?: unknown[], verification?: unknown, deterministic?: unknown, scopeFindings?: {unexpectedPaths: string[]}|null, previousAttempt?: string}} context
|
|
208
|
+
* @param {{diff?: unknown[], verification?: unknown, deterministic?: unknown, scopeFindings?: {unexpectedPaths: string[]}|null, previousAttempt?: string, operatorAnswer?: string}} context
|
|
185
209
|
* @returns {string}
|
|
186
210
|
*/
|
|
187
211
|
export function judgePrompt(node, workerResult, context = {}) {
|
|
@@ -211,9 +235,23 @@ export function judgePrompt(node, workerResult, context = {}) {
|
|
|
211
235
|
const diff = Array.isArray(context.diff) ? /** @type {unknown[]} */ (context.diff).slice(0, 64) : [];
|
|
212
236
|
const verificationResult = judgeVerificationEvidence(context.verification);
|
|
213
237
|
const scopeSection = scopeFindingsPromptSection(context.scopeFindings);
|
|
238
|
+
const decisions = node.taskPacket.decisions ?? [];
|
|
239
|
+
const uncoveredReserved = uncoveredReservedOwnerDecisions(decisions);
|
|
240
|
+
const operatorAnswer = typeof context.operatorAnswer === "string" && context.operatorAnswer.trim()
|
|
241
|
+
? context.operatorAnswer.trim()
|
|
242
|
+
: previousAttemptOperatorAnswer(context.previousAttempt);
|
|
243
|
+
const decisionsSection =
|
|
244
|
+
`Decisions already made by the operator:\n${decisions.length ? decisions.map((decision) => `- ${decision}`).join("\n") : "- (none)"}\n\n` +
|
|
245
|
+
`Reserved owner decisions (the repository owner alone decides these):\n${RESERVED_OWNER_DECISIONS.map((decision) => `- ${decision}`).join("\n")}\n\n` +
|
|
246
|
+
`Reserved owner decisions with no coverage in the decisions above:\n${uncoveredReserved.length ? uncoveredReserved.map((decision) => `- ${decision}`).join("\n") : "- (none)"}\n\n` +
|
|
247
|
+
"A diff that takes an owner decision with no coverage is a blocking finding: fail and name the decision. Referencing an existing owner decision does not take it.";
|
|
248
|
+
const operatorSection = operatorAnswer
|
|
249
|
+
? `Operator answer:\n${operatorAnswer}\n\nThat operator answer is a blocking judgment item: an unmet answer is a blocking finding, so fail with a finding that cites the Definition of Done judgment item the answer bears on, and do not pass work that ignores it.`
|
|
250
|
+
: "";
|
|
214
251
|
return `Review node ${node.id} independently. The review context is closed: inspect only the ${node.taskPacket.mode === "autonomous" ? "write roots" : "write files"} below and do not perform repository-wide discovery. Do not re-run the verification commands — the controller already executed them and attached the results; re-running suites duplicates cost without adding evidence.\n\n` +
|
|
215
252
|
`${writeBoundaryLabel}:\n${writeFiles}\n\nVerification commands (already executed by the controller):\n${verification}\n\n` +
|
|
216
|
-
`Task brief:\n${node.taskPacket.objective}\n\nInstructions:\n${taskInstructions}\n\nDefinition of Done:\n${criteria}\n\n` +
|
|
253
|
+
`Task brief:\n${node.taskPacket.objective}\n\nInstructions:\n${taskInstructions}\n\n${decisionsSection}\n\nDefinition of Done:\n${criteria}\n\n` +
|
|
254
|
+
(operatorSection ? `${operatorSection}\n\n` : "") +
|
|
217
255
|
`Worker result (structured):\n${structured ? JSON.stringify(structured) : "(invalid worker result withheld)"}\n\n` +
|
|
218
256
|
`Controller diff paths:\n${diff.length ? diff.map((path) => `- ${path}`).join("\n") : "- (none)"}\n\n` +
|
|
219
257
|
`Controller verification:\n${JSON.stringify(verificationResult)}\n\n` +
|
package/src/engine/resume.mjs
CHANGED
|
@@ -12,13 +12,14 @@
|
|
|
12
12
|
* contract left open, and only from runtimes that are available now: a resume
|
|
13
13
|
* after an exhausted provider is exactly when that matters.
|
|
14
14
|
*/
|
|
15
|
-
import { JUDGE_SCHEMA, SETTLED } from "./prompts.mjs";
|
|
15
|
+
import { JUDGE_SCHEMA, SETTLED, TERMINAL } from "./prompts.mjs";
|
|
16
16
|
import { Buffer } from "node:buffer";
|
|
17
17
|
import { acquire as acquireLock } from "../run/lock.mjs";
|
|
18
18
|
import { applyInvalidWorkerResult, assertRunMutable, handleProviderExhaustion } from "./lifecycle.mjs";
|
|
19
19
|
import { applyJudgeResult } from "./review.mjs";
|
|
20
20
|
import { assertSourceUnchanged, captureRunIdentity, recordedBaseRef, statesFingerprint } from "./run-identity.mjs";
|
|
21
21
|
import { attemptWorkspace, gitHead, removeWorktree, runRefName } from "../repo/worktree.mjs";
|
|
22
|
+
import { replacePacketInContract, widenTaskPacket } from "../repo/scope-closure.mjs";
|
|
22
23
|
import { attemptWorktreePath } from "../run/paths.mjs";
|
|
23
24
|
import { canonicalWorkerResultText, isResultMaterializationInvocation, materializeAttemptResult, recoverWorkerResult } from "./result-file.mjs";
|
|
24
25
|
import { checkPersistedWorkerScope, persistedScopeBoundary, reconcileAmbiguousWorkerRestart, resolveUnknownEffect } from "./scope.mjs";
|
|
@@ -35,16 +36,17 @@ import { hasOperationIntent, hasOperationSettlement, operationNeedsRecovery, pro
|
|
|
35
36
|
import { isUnknownEffectStop, planResumeRetry, renderPreviousAttemptSection } from "./retry.mjs";
|
|
36
37
|
import { join, resolve } from "node:path";
|
|
37
38
|
import { parseDiscoveryResult, parseWorkerResult } from "../contract/worker-result.mjs";
|
|
38
|
-
import { readJson, writeJsonAtomic } from "../run/store.mjs";
|
|
39
|
+
import { readJson, writeJsonAtomic, appendJsonl } from "../run/store.mjs";
|
|
39
40
|
import { recoverIntegrations } from "../repo/integrate.mjs";
|
|
40
41
|
import { registerRun, resolveCampaign } from "../campaign/index.mjs";
|
|
41
42
|
import { syncAgentSignal } from "../repo/signal.mjs";
|
|
42
43
|
import { validateContract } from "../contract/index.mjs";
|
|
43
44
|
import { validateRunMetadata, OPERATOR_ANSWER_MAX_BYTES } from "../contract/snapshot.mjs";
|
|
44
45
|
import { verificationFailureVerdict } from "./judge-gate.mjs";
|
|
45
|
-
import { verificationFailureWithScope } from "../contract/scope-findings.mjs";
|
|
46
|
+
import { verificationFailureWithScope, reauthorProposalRecord, reauthorWriteConflicts, reauthorRiskTier, reauthorApproved } from "../contract/scope-findings.mjs";
|
|
46
47
|
import { applyRejection, applyVerificationFailure, raiseNodeAttention, settleDone } from "./settle.mjs";
|
|
47
48
|
import { isHumanStepPending } from "../contract/human-step.mjs";
|
|
49
|
+
import { runBoundedRounds } from "../plan/rounds.mjs";
|
|
48
50
|
|
|
49
51
|
/** @typedef {import("../repo/integrate.mjs").IntegrationResult} IntegrationResult */
|
|
50
52
|
/** @typedef {import("./lifecycle.mjs").Invocation} Invocation */
|
|
@@ -84,11 +86,14 @@ export function recoveryExhaustionEnvelope(recovery, invocation) {
|
|
|
84
86
|
|
|
85
87
|
/**
|
|
86
88
|
* @param {string} runDirPath
|
|
87
|
-
* @param {{node?: string, reconcile?: string, answer?: {node: string, path: string}, detachedBootstrap?: boolean}} [options]
|
|
89
|
+
* @param {{node?: string, reconcile?: string, answer?: {node: string, path: string}, detachedBootstrap?: boolean, reauthor?: {node: string, discover: (context: {round: number, node: import("../contract/index.mjs").ValidatedNode, state: NodeSnapshot, missingContext: string[], findings: string[]}) => (import("../contract/scope-findings.mjs").ReauthorAdditions|Promise<import("../contract/scope-findings.mjs").ReauthorAdditions>), rounds?: number, approve?: boolean, approveBelow?: string}}} [options]
|
|
88
90
|
* `node` limits the retry in place to one node and its dependants,
|
|
89
91
|
* `reconcile` acknowledges a node stopped as `unknown_effect_reconciled`,
|
|
90
|
-
* `answer` records an operator's answer for
|
|
91
|
-
*
|
|
92
|
+
* `answer` records an operator's answer for any non-terminal node and
|
|
93
|
+
* re-dispatches it,
|
|
94
|
+
* `reauthor` widens one `context_missing` node's packet from a bounded
|
|
95
|
+
* discovery pass and re-dispatches it once the widening validates and is
|
|
96
|
+
* approved, and
|
|
92
97
|
* `detachedBootstrap` is set only by the CLI entry when this process is its
|
|
93
98
|
* own detached child
|
|
94
99
|
* @returns {Promise<RunOutcome>}
|
|
@@ -100,10 +105,16 @@ export async function resumeRun(runDirPath, options = {}) {
|
|
|
100
105
|
if (options.node && options.answer && options.node !== options.answer.node) {
|
|
101
106
|
throw new Error(`--answer ${options.answer.node} conflicts with --node ${options.node}`);
|
|
102
107
|
}
|
|
108
|
+
if (options.reauthor && options.node && options.reauthor.node !== options.node) {
|
|
109
|
+
throw new Error(`--reauthor ${options.reauthor.node} conflicts with --node ${options.node}`);
|
|
110
|
+
}
|
|
111
|
+
if (options.reauthor && options.answer) {
|
|
112
|
+
throw new Error(`--reauthor ${options.reauthor.node} cannot be combined with --answer ${options.answer.node}`);
|
|
113
|
+
}
|
|
103
114
|
const runDir = resolve(runDirPath);
|
|
104
115
|
assertRunMutable(runDir);
|
|
105
116
|
const contractPath = join(runDir, "contract.json");
|
|
106
|
-
|
|
117
|
+
let contract = validateContract(JSON.parse(readFileSync(contractPath, "utf8")), contractPath, { persisted: true });
|
|
107
118
|
const lock = acquireLock(runDir);
|
|
108
119
|
try {
|
|
109
120
|
const storedMetadata = validateRunMetadata(readJson(join(runDir, "run.json")), { requireSourceIdentity: true });
|
|
@@ -161,15 +172,51 @@ export async function resumeRun(runDirPath, options = {}) {
|
|
|
161
172
|
result: { status: "done", summary: boundedSummary(answerText.trim() || answerNode.humanStep.step), verification: [], artifacts: [], missingContext: [] },
|
|
162
173
|
}, lock);
|
|
163
174
|
} else {
|
|
164
|
-
|
|
165
|
-
|
|
175
|
+
// Any non-terminal node accepts an operator answer, not only the
|
|
176
|
+
// `context_missing` boundary. A node that already reached a terminal
|
|
177
|
+
// status has no attempt left to receive the answer, so it is refused
|
|
178
|
+
// here -- in the engine, where the resume decisions are made and the
|
|
179
|
+
// engine test can reach the gate.
|
|
180
|
+
if (!answerState || TERMINAL.has(answerState.status)) {
|
|
181
|
+
throw new Error(`node ${options.answer.node} is not blocked on missing context and is terminal, so it cannot accept an operator answer`);
|
|
166
182
|
}
|
|
167
183
|
const answerOverride = /** @type {import("../contract/index.mjs").ExecutionOverride} */ (/** @type {unknown} */ ({
|
|
168
184
|
kind: "operator-answer",
|
|
169
|
-
reason: `operator answered
|
|
185
|
+
reason: `operator answered node ${options.answer.node}`,
|
|
170
186
|
text: answerText,
|
|
171
187
|
}));
|
|
172
188
|
recordExecutionOverride(runDir, answerState, answerOverride, lock);
|
|
189
|
+
// Render the answer into the bounded `Previous attempt` section before
|
|
190
|
+
// classification. The retry path below renders it too, but a parked
|
|
191
|
+
// node the recover path re-dispatches would otherwise carry the
|
|
192
|
+
// override without ever showing it to the worker.
|
|
193
|
+
answerState.previousAttempt = renderPreviousAttemptSection(answerState) ?? answerState.previousAttempt;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/** @type {string|null} */
|
|
197
|
+
let reauthorAppliedNode = null;
|
|
198
|
+
if (options.reauthor) {
|
|
199
|
+
const targetNode = contract.nodes.find((node) => node.id === options.reauthor?.node);
|
|
200
|
+
const targetState = states.get(options.reauthor.node);
|
|
201
|
+
if (!targetNode) throw new Error(`unknown node id: ${options.reauthor.node}`);
|
|
202
|
+
if (!targetState) throw new Error(`missing persisted node snapshot for ${options.reauthor.node}`);
|
|
203
|
+
const reauthorBudget = options.reauthor.rounds ?? DEFAULT_REAUTHOR_ROUNDS;
|
|
204
|
+
const outcome = await reauthorRefusedNode({
|
|
205
|
+
contract, contractPath, runDir, node: targetNode, state: targetState,
|
|
206
|
+
discover: options.reauthor.discover,
|
|
207
|
+
rounds: reauthorBudget,
|
|
208
|
+
approve: options.reauthor.approve === true,
|
|
209
|
+
approveBelow: options.reauthor.approveBelow,
|
|
210
|
+
});
|
|
211
|
+
process.stdout.write(`[resume] ${contract.id} · reauthor ${targetNode.id} · ${outcome.outcome} (${outcome.roundsUsed}/${reauthorBudget} round${reauthorBudget === 1 ? "" : "s"})\n`);
|
|
212
|
+
if (outcome.applied && outcome.contract && outcome.raw) {
|
|
213
|
+
contract = outcome.contract;
|
|
214
|
+
const replacement = contract.nodes.find((node) => node.id === targetNode.id);
|
|
215
|
+
if (!replacement) throw new Error(`widened contract lost node ${targetNode.id}`);
|
|
216
|
+
targetState.packetHash = replacement.packetHash;
|
|
217
|
+
targetState.sourceIdentity = replacement.sourceIdentity;
|
|
218
|
+
writeJsonAtomic(contractPath, outcome.raw);
|
|
219
|
+
reauthorAppliedNode = targetNode.id;
|
|
173
220
|
}
|
|
174
221
|
}
|
|
175
222
|
const plan = planResumeRetry(contract, states, { node: options.node, reconcile: options.reconcile, answer: options.answer?.node });
|
|
@@ -185,9 +232,9 @@ export async function resumeRun(runDirPath, options = {}) {
|
|
|
185
232
|
const state = states.get(node.id);
|
|
186
233
|
if (!state) continue;
|
|
187
234
|
if (state.status === "done") continue;
|
|
188
|
-
if (isBlockedContextTerminal(state) && node.id !== options.answer?.node) continue;
|
|
235
|
+
if (isBlockedContextTerminal(state) && node.id !== options.answer?.node && node.id !== reauthorAppliedNode) continue;
|
|
189
236
|
if (isHumanStepPending(state) && node.id !== options.answer?.node) continue;
|
|
190
|
-
const action = plan.actions.get(node.id) ?? "recover";
|
|
237
|
+
const action = node.id === reauthorAppliedNode ? "retry" : plan.actions.get(node.id) ?? "recover";
|
|
191
238
|
// Adoption before retry: an unresolved blocking review is re-judged from
|
|
192
239
|
// the preserved worker result, never reset to a fresh worker attempt.
|
|
193
240
|
if (action === "rejudge") {
|
|
@@ -546,6 +593,66 @@ export async function resumeRun(runDirPath, options = {}) {
|
|
|
546
593
|
export function isBlockedContextTerminal(state) {
|
|
547
594
|
return state.status === "blocked" && state.error?.code === "context_missing";
|
|
548
595
|
}
|
|
596
|
+
/**
|
|
597
|
+
* The default number of discovery rounds a `resume --reauthor` spends widening
|
|
598
|
+
* one refused packet. The budget is hard: the round after this one never runs.
|
|
599
|
+
*/
|
|
600
|
+
export const DEFAULT_REAUTHOR_ROUNDS = 1;
|
|
601
|
+
/** One round of the reauthor budget: its proposal, candidate, verdict, findings. */
|
|
602
|
+
/** @typedef {{accepted: boolean, round: number, additions: import("../contract/scope-findings.mjs").ReauthorAdditions, packet: import("../contract/index.mjs").TaskPacket, candidate: Record<string, unknown>, widened: ValidatedContract|null, findings: string[]}} ReauthorRound */
|
|
603
|
+
/**
|
|
604
|
+
* Run the bounded discovery pass for one `context_missing` node, validate the
|
|
605
|
+
* widened packet against the whole contract (per-node closure, the cross-node
|
|
606
|
+
* pair, and the write-overlap check no single packet can see), and return the
|
|
607
|
+
* decision. The caller owns applying it: an accepted packet below the approval
|
|
608
|
+
* threshold, or any packet the operator approved, is persisted and the node
|
|
609
|
+
* re-dispatched; everything else leaves the node refused.
|
|
610
|
+
*
|
|
611
|
+
* @param {{contract: ValidatedContract, contractPath: string, runDir: string, node: import("../contract/index.mjs").ValidatedNode, state: NodeSnapshot, discover: (context: {round: number, node: import("../contract/index.mjs").ValidatedNode, state: NodeSnapshot, missingContext: string[], findings: string[]}) => (import("../contract/scope-findings.mjs").ReauthorAdditions|Promise<import("../contract/scope-findings.mjs").ReauthorAdditions>), rounds?: number, approve?: boolean, approveBelow?: string}} options
|
|
612
|
+
* @returns {Promise<{accepted: boolean, roundsUsed: number, last: ReauthorRound|null, history: ReauthorRound[], applied: boolean, outcome: string, record: Record<string, unknown>, contract: ValidatedContract|null, raw: Record<string, unknown>|null}>}
|
|
613
|
+
*/
|
|
614
|
+
export async function reauthorRefusedNode(options) {
|
|
615
|
+
const { contract, contractPath, runDir, node, state, discover, rounds = DEFAULT_REAUTHOR_ROUNDS, approve = false, approveBelow } = options;
|
|
616
|
+
if (!isBlockedContextTerminal(state)) throw new Error(`node ${node.id} is not blocked on missing context, so it cannot be reauthored`);
|
|
617
|
+
if (typeof discover !== "function") throw new TypeError("resume --reauthor requires a discovery runner");
|
|
618
|
+
const raw = JSON.parse(readFileSync(contractPath, "utf8"));
|
|
619
|
+
const refused = /** @type {{missingContext?: string[]}|null|undefined} */ (state.result);
|
|
620
|
+
const missingContext = Array.isArray(refused?.missingContext) ? refused.missingContext : [];
|
|
621
|
+
const budget = await runBoundedRounds(rounds, async (round, history) => {
|
|
622
|
+
const additions = (await discover({ round, node, state, missingContext, findings: history.flatMap((item) => item.findings) })) ?? {};
|
|
623
|
+
const packet = widenTaskPacket(node.taskPacket, additions);
|
|
624
|
+
const candidate = replacePacketInContract(raw, node.id, packet);
|
|
625
|
+
const findings = reauthorWriteConflicts(contract.nodes, node.id, additions.writeFiles).map((conflict) => conflict.reason);
|
|
626
|
+
/** @type {ValidatedContract|null} */
|
|
627
|
+
let widened = null;
|
|
628
|
+
try {
|
|
629
|
+
widened = validateContract(candidate, contractPath);
|
|
630
|
+
} catch (error) {
|
|
631
|
+
findings.push(errorMessage(error));
|
|
632
|
+
}
|
|
633
|
+
const accepted = widened !== null && findings.length === 0;
|
|
634
|
+
if (accepted && widened) {
|
|
635
|
+
const replacement = widened.nodes.find((item) => item.id === node.id);
|
|
636
|
+
if (replacement) /** @type {any} */ (candidate).nodes.find((/** @type {any} */ item) => item.id === node.id).packetHash = replacement.packetHash;
|
|
637
|
+
}
|
|
638
|
+
return { accepted, round, additions, packet, candidate, widened, findings };
|
|
639
|
+
});
|
|
640
|
+
const last = budget.last;
|
|
641
|
+
const outcome = budget.accepted ? (reauthorApproved(node, { approve, approveBelow }) ? "applied" : "approval_required") : "rounds_exhausted";
|
|
642
|
+
const record = reauthorProposalRecord({
|
|
643
|
+
nodeId: node.id, outcome, riskTier: reauthorRiskTier(node), budget: rounds, roundsUsed: budget.roundsUsed,
|
|
644
|
+
additions: last?.additions, packet: last?.packet, findings: last?.findings, history: budget.history,
|
|
645
|
+
});
|
|
646
|
+
appendJsonl(join(runDir, "reauthor.jsonl"), record);
|
|
647
|
+
return {
|
|
648
|
+
...budget,
|
|
649
|
+
applied: outcome === "applied",
|
|
650
|
+
outcome,
|
|
651
|
+
record,
|
|
652
|
+
contract: budget.accepted ? last?.widened ?? null : null,
|
|
653
|
+
raw: budget.accepted ? last?.candidate ?? null : null,
|
|
654
|
+
};
|
|
655
|
+
}
|
|
549
656
|
/**
|
|
550
657
|
* The operator-answer result summary for a human-step node's `done`
|
|
551
658
|
* transition, bounded to the worker-result protocol's own summary ceiling
|
package/src/engine/retry.mjs
CHANGED
|
@@ -15,12 +15,11 @@
|
|
|
15
15
|
* exhausted run budget needs `--max-input-tokens <n>`.
|
|
16
16
|
*/
|
|
17
17
|
import { Buffer } from "node:buffer";
|
|
18
|
+
import { PREVIOUS_ATTEMPT_EVIDENCE_MAX_BYTES } from "../contract/snapshot.mjs";
|
|
18
19
|
|
|
19
20
|
/** Heading of the section appended to a retried attempt's worker and judge prompt. */
|
|
20
21
|
const PREVIOUS_ATTEMPT_HEADING = "Previous attempt";
|
|
21
22
|
|
|
22
|
-
/** Hard ceiling for the whole `Previous attempt` section, in bytes. */
|
|
23
|
-
const PREVIOUS_ATTEMPT_MAX_BYTES = 8 * 1024;
|
|
24
23
|
|
|
25
24
|
/** Heading under which an operator's answer is rendered inside the section. */
|
|
26
25
|
const OPERATOR_ANSWER_HEADING = "Operator answer";
|
|
@@ -267,11 +266,6 @@ export function renderPreviousAttemptSection(state) {
|
|
|
267
266
|
if (state.error?.code || state.error?.message) {
|
|
268
267
|
parts.push(`Error: ${state.error?.code ?? "unknown"}${state.error?.message ? ` — ${bounded(state.error.message, 1024)}` : ""}`);
|
|
269
268
|
}
|
|
270
|
-
const answer = mostRecentOperatorAnswer(state);
|
|
271
|
-
if (answer !== null) {
|
|
272
|
-
parts.push(`${OPERATOR_ANSWER_HEADING}:`);
|
|
273
|
-
parts.push(bounded(answer, 1024));
|
|
274
|
-
}
|
|
275
269
|
const findings = state.gate?.findings ?? [];
|
|
276
270
|
if (findings.length) {
|
|
277
271
|
parts.push(`Judge findings (verdict ${state.gate?.verdict ?? "unknown"}, maxSeverity ${state.gate?.maxSeverity ?? "none"}):`);
|
|
@@ -293,8 +287,14 @@ export function renderPreviousAttemptSection(state) {
|
|
|
293
287
|
if (typeof last?.exitCode === "number" && last.exitCode !== 0) parts.push(` exit code: ${last.exitCode}`);
|
|
294
288
|
}
|
|
295
289
|
}
|
|
296
|
-
|
|
297
|
-
|
|
290
|
+
const answer = mostRecentOperatorAnswer(state);
|
|
291
|
+
if (parts.length === 1 && answer === null) return null;
|
|
292
|
+
const evidence = boundSection([`## ${PREVIOUS_ATTEMPT_HEADING}`, "", ...parts].join("\n"));
|
|
293
|
+
// The answer was bounded where it was accepted (OPERATOR_ANSWER_MAX_BYTES),
|
|
294
|
+
// so it is carried whole, after the evidence and outside its budget.
|
|
295
|
+
// Measured 2026-09-28: bounded here at 1 KiB inside an 8 KiB section, an
|
|
296
|
+
// answer the engine had accepted and recorded reached the worker cut short.
|
|
297
|
+
return answer === null ? evidence : `${evidence}\n${OPERATOR_ANSWER_HEADING}:\n${answer}`;
|
|
298
298
|
}
|
|
299
299
|
|
|
300
300
|
/**
|
|
@@ -315,9 +315,9 @@ export function appendPreviousAttempt(prompt, section) {
|
|
|
315
315
|
* @returns {string} the text bounded to the section ceiling, with a marker
|
|
316
316
|
*/
|
|
317
317
|
function boundSection(text) {
|
|
318
|
-
if (Buffer.byteLength(text, "utf8") <=
|
|
318
|
+
if (Buffer.byteLength(text, "utf8") <= PREVIOUS_ATTEMPT_EVIDENCE_MAX_BYTES) return text;
|
|
319
319
|
const marker = "\n… (previous attempt section truncated)";
|
|
320
|
-
const room =
|
|
320
|
+
const room = PREVIOUS_ATTEMPT_EVIDENCE_MAX_BYTES - Buffer.byteLength(marker, "utf8") - 1;
|
|
321
321
|
const cut = Buffer.from(text, "utf8").subarray(0, room).toString("utf8");
|
|
322
322
|
return `${cut}${marker}`;
|
|
323
323
|
}
|