@robmclarty/volley 0.8.0 → 0.10.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/CHANGELOG.md +44 -0
- package/README.md +194 -45
- package/dist/cli.js +1284 -457
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +136 -12
- package/dist/index.js +1162 -543
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/builder/presets/harness_append_acp.md +4 -3
- package/src/builder/presets/harness_append_local.md +5 -3
- package/src/critic/presets/harness_append_local.md +2 -1
package/dist/index.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { accessSync, constants, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
3
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
4
|
-
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { createHmac, randomBytes, randomUUID } from "node:crypto";
|
|
5
5
|
import { spawn, spawnSync } from "node:child_process";
|
|
6
6
|
import { aborted_error, branch, claude_cli_reported, create_engine, fallback, loop, model_call, pipe, retry, run, sequence, step, throughput } from "fascicle";
|
|
7
|
+
import { homedir } from "node:os";
|
|
7
8
|
import { filesystem_logger, tee_logger } from "fascicle/adapters";
|
|
8
9
|
import { lookup } from "node:dns";
|
|
9
10
|
import { Readability } from "@mozilla/readability";
|
|
@@ -13,6 +14,31 @@ import TurndownService from "turndown";
|
|
|
13
14
|
import { Agent, buildConnector, interceptors, request } from "undici";
|
|
14
15
|
import { z } from "zod";
|
|
15
16
|
import pc from "picocolors";
|
|
17
|
+
//#region src/types.ts
|
|
18
|
+
function config_error(message) {
|
|
19
|
+
return Object.assign(new Error(message), { kind: "config_error" });
|
|
20
|
+
}
|
|
21
|
+
function check_error(message) {
|
|
22
|
+
return Object.assign(new Error(message), { kind: "check_error" });
|
|
23
|
+
}
|
|
24
|
+
/** Wraps a provider/schema failure with the phase and iteration it hit. */
|
|
25
|
+
function phase_error(phase, iteration, cause) {
|
|
26
|
+
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
27
|
+
return Object.assign(/* @__PURE__ */ new Error(`${phase} failed on iteration ${iteration}: ${detail}`), {
|
|
28
|
+
kind: "phase_error",
|
|
29
|
+
phase,
|
|
30
|
+
iteration,
|
|
31
|
+
cause
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
function error_kind(err) {
|
|
35
|
+
if (typeof err === "object" && err !== null && "kind" in err) {
|
|
36
|
+
const kind = err.kind;
|
|
37
|
+
return typeof kind === "string" ? kind : null;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
//#endregion
|
|
16
42
|
//#region src/changes.ts
|
|
17
43
|
/**
|
|
18
44
|
* What the builder changed, and whether it touched the gate.
|
|
@@ -117,6 +143,29 @@ function capture_baseline(root) {
|
|
|
117
143
|
const sha = head.stdout.trim();
|
|
118
144
|
return sha.length > 0 ? sha : null;
|
|
119
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* The commit a critique's change is measured from: `rev` (`--baseline`)
|
|
148
|
+
* resolved in `root`, so the change is everything that differs from it,
|
|
149
|
+
* committed or not, untracked files included. With no revision named it's
|
|
150
|
+
* HEAD, so the uncommitted and untracked edits are the change, and, as for a
|
|
151
|
+
* run, null where there's no repository or no commit yet. A named revision
|
|
152
|
+
* that isn't a commit there, or one named where there's no repository, is a
|
|
153
|
+
* config error.
|
|
154
|
+
*/
|
|
155
|
+
function resolve_baseline(root, rev) {
|
|
156
|
+
if (rev === void 0) return capture_baseline(root);
|
|
157
|
+
if (!in_git_repo(root)) throw config_error(`--baseline needs the workspace to be a git repository: ${root}`);
|
|
158
|
+
const resolved = git$1(root, [
|
|
159
|
+
"rev-parse",
|
|
160
|
+
"--verify",
|
|
161
|
+
"--quiet",
|
|
162
|
+
"--end-of-options",
|
|
163
|
+
`${rev}^{commit}`
|
|
164
|
+
]);
|
|
165
|
+
const sha = resolved.stdout.trim();
|
|
166
|
+
if (resolved.status !== 0 || sha.length === 0) throw config_error(`--baseline names no commit in ${root}: ${rev}`);
|
|
167
|
+
return sha;
|
|
168
|
+
}
|
|
120
169
|
/** git's status letters, folded onto the four cases worth naming. Rename and
|
|
121
170
|
* copy both record the destination path — where the content lives now. */
|
|
122
171
|
function change_status(code) {
|
|
@@ -153,7 +202,8 @@ function parse_name_status(stdout) {
|
|
|
153
202
|
function parse_paths(stdout) {
|
|
154
203
|
return stdout.split("\0").filter((path) => path.length > 0);
|
|
155
204
|
}
|
|
156
|
-
function excluded(path) {
|
|
205
|
+
function excluded(path, excluded_dir) {
|
|
206
|
+
if (excluded_dir !== null && path.startsWith(`${excluded_dir}/`)) return true;
|
|
157
207
|
return EXCLUDED_PREFIXES.some((prefix) => path.startsWith(prefix));
|
|
158
208
|
}
|
|
159
209
|
/**
|
|
@@ -233,10 +283,11 @@ function collect_changes(options) {
|
|
|
233
283
|
path,
|
|
234
284
|
status: "added"
|
|
235
285
|
}));
|
|
286
|
+
const excluded_dir = options.excluded_dir ?? null;
|
|
236
287
|
const seen = /* @__PURE__ */ new Set();
|
|
237
288
|
const files = [];
|
|
238
289
|
for (const change of [...tracked, ...untracked]) {
|
|
239
|
-
if (excluded(change.path) || seen.has(change.path)) continue;
|
|
290
|
+
if (excluded(change.path, excluded_dir) || seen.has(change.path)) continue;
|
|
240
291
|
seen.add(change.path);
|
|
241
292
|
files.push({
|
|
242
293
|
...change,
|
|
@@ -278,29 +329,23 @@ function resolve_check_runner(check, workspace) {
|
|
|
278
329
|
return /(^|\s)checkride(\s|$)/.test(check.trim()) ? "checkride" : "command";
|
|
279
330
|
}
|
|
280
331
|
//#endregion
|
|
281
|
-
//#region src/
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
function error_kind(err) {
|
|
299
|
-
if (typeof err === "object" && err !== null && "kind" in err) {
|
|
300
|
-
const kind = err.kind;
|
|
301
|
-
return typeof kind === "string" ? kind : null;
|
|
302
|
-
}
|
|
303
|
-
return null;
|
|
332
|
+
//#region src/containment.ts
|
|
333
|
+
/**
|
|
334
|
+
* Whether volley is running inside its sandbox container. A leaf, so the
|
|
335
|
+
* config resolution, the preflight, and the run-config signing can all ask
|
|
336
|
+
* without importing one another.
|
|
337
|
+
*/
|
|
338
|
+
/** volley *detects* it is running inside its sandbox container rather than
|
|
339
|
+
* starting the container itself. The signal is the `VOLLEY_CONTAINED=1` marker
|
|
340
|
+
* volley's own image bakes in (`Dockerfile` `ENV`), so a `docker run <hardened
|
|
341
|
+
* flags> <image> volley run …` is recognized as contained out of the box. Kept
|
|
342
|
+
* env-only — no ambient `/.dockerenv` probe — so the safety gate is deterministic
|
|
343
|
+
* and testable, and so a CI job that itself runs in an unrelated container does
|
|
344
|
+
* not silently satisfy the gate; a hand-rolled sandbox must set the marker (or
|
|
345
|
+
* the operator uses `--allow-unsandboxed-builder`). */
|
|
346
|
+
function detect_containment(env) {
|
|
347
|
+
const value = env["VOLLEY_CONTAINED"];
|
|
348
|
+
return value === "1" || value === "true";
|
|
304
349
|
}
|
|
305
350
|
//#endregion
|
|
306
351
|
//#region src/engine.ts
|
|
@@ -516,7 +561,292 @@ async function dispose_seat_engines(engines) {
|
|
|
516
561
|
const distinct = engines.critic === engines.builder ? [engines.builder] : [engines.builder, engines.critic];
|
|
517
562
|
await Promise.all(distinct.map((engine) => engine.dispose()));
|
|
518
563
|
}
|
|
564
|
+
//#endregion
|
|
565
|
+
//#region src/trust.ts
|
|
566
|
+
/**
|
|
567
|
+
* Which run configs a host-side resume may trust. A resume runs what a run's
|
|
568
|
+
* `config.json` says: its check, a shell command, and its seats, permission
|
|
569
|
+
* mode and prompt included. Inside the sandbox the builder runs arbitrary
|
|
570
|
+
* commands with write access to its own run's state, so a contained run's
|
|
571
|
+
* config is the builder's to rewrite, and resuming it on the host would run
|
|
572
|
+
* whatever the builder left there.
|
|
573
|
+
*
|
|
574
|
+
* So volley on the host signs each config it writes, an HMAC under a key of
|
|
575
|
+
* this machine's that never enters the container, and a host-side resume
|
|
576
|
+
* trusts only a config whose signature holds. Inside the sandbox nothing is
|
|
577
|
+
* signed, since the key isn't there and one made there would be in the
|
|
578
|
+
* builder's reach, so a contained run's config never carries a signature the
|
|
579
|
+
* host accepts, and the builder can't forge one without the key.
|
|
580
|
+
*/
|
|
581
|
+
/** Prefixed to what is signed, so a signature over a run config can't pass for
|
|
582
|
+
* one over anything else under the same key. */
|
|
583
|
+
const DOMAIN = "volley run config v1\n";
|
|
584
|
+
/** Where this machine's key lives: `$XDG_CONFIG_HOME/volley/run-key`, else
|
|
585
|
+
* `~/.config/volley/run-key`, outside every state root, so a container a run
|
|
586
|
+
* root is mounted into still can't read it. A relative `XDG_CONFIG_HOME` is
|
|
587
|
+
* ignored, as the XDG spec says. */
|
|
588
|
+
function trust_key_path(env, home = homedir()) {
|
|
589
|
+
const xdg = env["XDG_CONFIG_HOME"];
|
|
590
|
+
const base = xdg !== void 0 && isAbsolute(xdg) ? xdg : join(home, ".config");
|
|
591
|
+
return join(base, "volley", "run-key");
|
|
592
|
+
}
|
|
593
|
+
function error_code(err) {
|
|
594
|
+
return err.code;
|
|
595
|
+
}
|
|
596
|
+
/** The key as stored, or null when there is none or it isn't one. */
|
|
597
|
+
function read_key(path) {
|
|
598
|
+
try {
|
|
599
|
+
const hex = readFileSync(path, "utf8").trim();
|
|
600
|
+
return /^[0-9a-f]{64}$/.test(hex) ? Buffer.from(hex, "hex") : null;
|
|
601
|
+
} catch {
|
|
602
|
+
return null;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
/** The key, created on first use: 32 random bytes in a file only its user can
|
|
606
|
+
* read, in a directory only its user can enter. Two runs that start together
|
|
607
|
+
* race to create it; the loser reads the winner's. */
|
|
608
|
+
function machine_key(env) {
|
|
609
|
+
const path = trust_key_path(env);
|
|
610
|
+
const existing = read_key(path);
|
|
611
|
+
if (existing !== null) return existing;
|
|
612
|
+
try {
|
|
613
|
+
mkdirSync(dirname(path), {
|
|
614
|
+
recursive: true,
|
|
615
|
+
mode: 448
|
|
616
|
+
});
|
|
617
|
+
writeFileSync(path, `${randomBytes(32).toString("hex")}\n`, {
|
|
618
|
+
flag: "wx",
|
|
619
|
+
mode: 384
|
|
620
|
+
});
|
|
621
|
+
} catch (err) {
|
|
622
|
+
if (error_code(err) !== "EEXIST") return null;
|
|
623
|
+
}
|
|
624
|
+
return read_key(path);
|
|
625
|
+
}
|
|
626
|
+
function hmac(key, config_text) {
|
|
627
|
+
return createHmac("sha256", key).update(DOMAIN).update(config_text).digest("hex");
|
|
628
|
+
}
|
|
629
|
+
/** This machine's signature over a run's `config.json` text, or null where
|
|
630
|
+
* there's none to give: inside the sandbox, or when the key can't be read or
|
|
631
|
+
* created. */
|
|
632
|
+
function sign_config(config_text, env = process.env) {
|
|
633
|
+
if (detect_containment(env)) return null;
|
|
634
|
+
const key = machine_key(env);
|
|
635
|
+
return key === null ? null : hmac(key, config_text);
|
|
636
|
+
}
|
|
637
|
+
//#endregion
|
|
638
|
+
//#region src/workspace.ts
|
|
639
|
+
/**
|
|
640
|
+
* Workspace lifecycle: run-state initialization, stale-run
|
|
641
|
+
* backup rotation, path helpers, and optional git checkpoints.
|
|
642
|
+
*/
|
|
643
|
+
/** A run id names the run's `volley/<run id>` branch and its state directory,
|
|
644
|
+
* so it is held to what both take without quoting: letters, digits, `-`, and
|
|
645
|
+
* `_`, starting with a letter or digit, so it never reads as an option and
|
|
646
|
+
* never climbs out of the directory it names. */
|
|
647
|
+
const RUN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
648
|
+
/** The root each run's state sits under when `--state-dir` names none:
|
|
649
|
+
* `$XDG_STATE_HOME/volley`, else `<home>/.local/state/volley`. A relative
|
|
650
|
+
* `XDG_STATE_HOME` is ignored, as the XDG spec says. `home` is the host's by
|
|
651
|
+
* default; the sandbox passes its container's to spell the same root inside. */
|
|
652
|
+
function default_state_root(env, home = homedir()) {
|
|
653
|
+
const xdg = env["XDG_STATE_HOME"];
|
|
654
|
+
const base = xdg !== void 0 && isAbsolute(xdg) ? xdg : join(home, ".local", "state");
|
|
655
|
+
return join(base, "volley");
|
|
656
|
+
}
|
|
657
|
+
/** Where a run keeps its config, summary, trajectory, feedback, verdict, and
|
|
658
|
+
* iterations: `<state_dir>/<run_id>/` under its state root, or, in the layout
|
|
659
|
+
* older runs used (`state_dir` null), `<workspace>/.volley/`. */
|
|
660
|
+
function run_state_dir(run) {
|
|
661
|
+
return run.state_dir === null ? join(run.workspace, ".volley") : join(run.state_dir, run.run_id);
|
|
662
|
+
}
|
|
663
|
+
function state_path(run, ...parts) {
|
|
664
|
+
return join(run_state_dir(run), ...parts);
|
|
665
|
+
}
|
|
666
|
+
function iteration_dir(run, iteration) {
|
|
667
|
+
return state_path(run, "iterations", String(iteration).padStart(3, "0"));
|
|
668
|
+
}
|
|
669
|
+
/** A state root that sits inside `root` (the build root), as the path from
|
|
670
|
+
* there in git's `/`-separated vocabulary, or null when it sits outside. A
|
|
671
|
+
* root inside the tree would otherwise pass for the builder's work: listed in
|
|
672
|
+
* the changes the critic reads, and committed by every `--git` checkpoint.
|
|
673
|
+
* The legacy `.volley/` (`state_dir` null) is excluded by name already. */
|
|
674
|
+
function state_root_within(root, state_dir) {
|
|
675
|
+
if (state_dir === null) return null;
|
|
676
|
+
const path = relative(resolve(root), resolve(state_dir));
|
|
677
|
+
if (path === "" || path === ".." || path.startsWith(`..${sep}`) || isAbsolute(path)) return null;
|
|
678
|
+
return path.split(sep).join("/");
|
|
679
|
+
}
|
|
680
|
+
/** Filesystem-safe timestamp for `.bak.<stamp>` rotation names. Shared by the
|
|
681
|
+
* run-state rotation here and the worktree rotation so both preserve
|
|
682
|
+
* prior state under the same never-destroy naming. */
|
|
683
|
+
function backup_stamp(date = /* @__PURE__ */ new Date()) {
|
|
684
|
+
return date.toISOString().replace(/[:.]/g, "-");
|
|
685
|
+
}
|
|
686
|
+
/** Fresh-run initialization: rotate a stale state directory aside (a prior
|
|
687
|
+
* `.volley/` becomes `.volley.bak.<stamp>`), then create the directory tree.
|
|
688
|
+
* Under a state root each run has a directory of its own, so only a reused
|
|
689
|
+
* `--run-id` ever finds one to rotate. An empty one is used as it is: that is
|
|
690
|
+
* what a contained run is handed when its run directory is the mount, which
|
|
691
|
+
* can't be renamed. Resume runs skip rotation (`preserve: true`). */
|
|
692
|
+
function initialize_run_state(run, options = {}) {
|
|
693
|
+
const dir = run_state_dir(run);
|
|
694
|
+
if (existsSync(dir) && options.preserve !== true && readdirSync(dir).length > 0) renameSync(dir, `${dir}.bak.${backup_stamp()}`);
|
|
695
|
+
if (run.state_dir !== null && !existsSync(run.state_dir)) mkdirSync(run.state_dir, {
|
|
696
|
+
recursive: true,
|
|
697
|
+
mode: 448
|
|
698
|
+
});
|
|
699
|
+
mkdirSync(join(dir, "iterations"), { recursive: true });
|
|
700
|
+
}
|
|
701
|
+
/** Beside a run's `config.json`: this machine's signature over it
|
|
702
|
+
* (`./trust.ts`), which a host-side resume checks before it trusts the
|
|
703
|
+
* config. */
|
|
704
|
+
const CONFIG_SIGNATURE = "config.sig";
|
|
705
|
+
/** Record the run's config, signed when volley runs on the host. Returns
|
|
706
|
+
* whether it was signed: inside the sandbox it never is, and on the host only
|
|
707
|
+
* when the machine's key can't be read or created. A signature a reused run
|
|
708
|
+
* directory held from before is removed rather than left to vouch for this
|
|
709
|
+
* config. */
|
|
710
|
+
function write_resolved_config(config, env = process.env) {
|
|
711
|
+
const record = {
|
|
712
|
+
version: config.version,
|
|
713
|
+
run_id: config.run_id,
|
|
714
|
+
started_at: config.started_at,
|
|
715
|
+
mode: config.mode,
|
|
716
|
+
prompt: config.prompt,
|
|
717
|
+
criteria: config.criteria,
|
|
718
|
+
check: config.check,
|
|
719
|
+
check_resolved: config.check_resolved,
|
|
720
|
+
builder_model: config.builder_model,
|
|
721
|
+
builder_provider: config.builder_provider,
|
|
722
|
+
builder_url: config.builder_url,
|
|
723
|
+
builder_max_steps: config.builder_max_steps,
|
|
724
|
+
builder_max_tool_calls: config.builder_max_tool_calls,
|
|
725
|
+
builder_max_output_tokens: config.builder_max_output_tokens,
|
|
726
|
+
critic_model: config.critic_model,
|
|
727
|
+
critic_provider: config.critic_provider,
|
|
728
|
+
critic_url: config.critic_url,
|
|
729
|
+
builder_permission_mode: config.builder_permission_mode,
|
|
730
|
+
critic_preset: config.critic_preset,
|
|
731
|
+
critic_prompt_path: config.critic_prompt_path,
|
|
732
|
+
max_iterations: config.max_iterations,
|
|
733
|
+
max_cost_usd: config.max_cost_usd,
|
|
734
|
+
git_checkpoints: config.git_checkpoints,
|
|
735
|
+
worktree: config.worktree,
|
|
736
|
+
discard_worktree: config.discard_worktree,
|
|
737
|
+
gate_paths: config.gate_paths,
|
|
738
|
+
fail_on_gate_edit: config.fail_on_gate_edit,
|
|
739
|
+
sandbox_image: config.sandbox_image,
|
|
740
|
+
state_dir: config.state_dir,
|
|
741
|
+
workspace: config.workspace
|
|
742
|
+
};
|
|
743
|
+
const text = `${JSON.stringify(record, null, 2)}\n`;
|
|
744
|
+
writeFileSync(state_path(config, "config.json"), text);
|
|
745
|
+
const signature = sign_config(text, env);
|
|
746
|
+
if (signature === null) {
|
|
747
|
+
rmSync(state_path(config, CONFIG_SIGNATURE), { force: true });
|
|
748
|
+
return false;
|
|
749
|
+
}
|
|
750
|
+
writeFileSync(state_path(config, CONFIG_SIGNATURE), `${signature}\n`);
|
|
751
|
+
return true;
|
|
752
|
+
}
|
|
753
|
+
/** The identity volley's own commits fall back to. git won't commit without a
|
|
754
|
+
* name and an email, and the sandbox image carries no git config, so inside
|
|
755
|
+
* it a checkpoint or a worktree's salvage commit failed outright; a machine
|
|
756
|
+
* never set up for git can be the same. `.invalid` is reserved, so the email
|
|
757
|
+
* can't reach anyone. */
|
|
758
|
+
const FALLBACK_IDENTITY = [["user.name", "volley"], ["user.email", "volley@volley.invalid"]];
|
|
759
|
+
/** The `-c` flags that give a commit in `cwd` the fallback identity for each
|
|
760
|
+
* part git has no value for. One the repo or the user configured wins, and so
|
|
761
|
+
* does a `GIT_AUTHOR_*` or `GIT_COMMITTER_*` variable, which git ranks above
|
|
762
|
+
* any config. Put them ahead of the subcommand. */
|
|
763
|
+
function commit_identity(cwd) {
|
|
764
|
+
return FALLBACK_IDENTITY.flatMap(([key, value]) => {
|
|
765
|
+
const set = spawnSync("git", [
|
|
766
|
+
"config",
|
|
767
|
+
"--get",
|
|
768
|
+
key
|
|
769
|
+
], {
|
|
770
|
+
cwd,
|
|
771
|
+
encoding: "utf8"
|
|
772
|
+
});
|
|
773
|
+
return set.status === 0 && set.stdout.trim() !== "" ? [] : ["-c", `${key}=${value}`];
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* `--git`: commit everything under `root` after a phase (`git -C <root>`, run
|
|
778
|
+
* here via the spawn `cwd`). `root` is the build root (`build_root`): the
|
|
779
|
+
* workspace on the default path, or the per-run worktree under `--worktree`, so
|
|
780
|
+
* checkpoints land on the worktree branch rather than the workspace's.
|
|
781
|
+
* A checkpoint with nothing to commit is fine; a missing git binary or repo is
|
|
782
|
+
* not. `excluded_dir` (from `state_root_within`) keeps a state root inside the
|
|
783
|
+
* tree out of the commit; `:/` keeps the rest of the staging repo-wide, as a
|
|
784
|
+
* bare `git add -A` is, and `literal` reads the path as written, wildcards and
|
|
785
|
+
* all.
|
|
786
|
+
*/
|
|
787
|
+
function git_checkpoint(root, message, excluded_dir = null) {
|
|
788
|
+
if (!existsSync(join(root, ".git"))) throw config_error(`git checkpoints require a git repository: ${root}`);
|
|
789
|
+
const pathspec = excluded_dir === null ? [] : [
|
|
790
|
+
"--",
|
|
791
|
+
":/",
|
|
792
|
+
`:(exclude,literal)${excluded_dir}`
|
|
793
|
+
];
|
|
794
|
+
const add = spawnSync("git", [
|
|
795
|
+
"add",
|
|
796
|
+
"-A",
|
|
797
|
+
...pathspec
|
|
798
|
+
], { cwd: root });
|
|
799
|
+
if (add.error !== void 0 || add.status !== 0) throw check_error(`git add failed: ${add.stderr?.toString() ?? String(add.error)}`);
|
|
800
|
+
const commit = spawnSync("git", [
|
|
801
|
+
...commit_identity(root),
|
|
802
|
+
"commit",
|
|
803
|
+
"-m",
|
|
804
|
+
message,
|
|
805
|
+
"--no-verify"
|
|
806
|
+
], { cwd: root });
|
|
807
|
+
if (commit.error !== void 0) throw check_error(`git commit failed to start: ${String(commit.error)}`);
|
|
808
|
+
const out = `${commit.stdout?.toString() ?? ""}${commit.stderr?.toString() ?? ""}`;
|
|
809
|
+
if (commit.status !== 0 && !/nothing (?:added )?to commit/.test(out)) throw check_error(`git commit failed: ${out.trim()}`);
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* Integration — bring a successful `--worktree --git` run's effects onto the
|
|
813
|
+
* workspace branch. The per-phase checkpoints on `branch` are the raw
|
|
814
|
+
* audit/replay trail; here they collapse into a single squash commit
|
|
815
|
+
* (`git merge --squash` then `commit`) on the workspace's current branch. The
|
|
816
|
+
* shared object store makes this a local, fetch-free merge, and — because the
|
|
817
|
+
* workspace branch never moved during the run (checkpoints went to the phase
|
|
818
|
+
* branch) — it is conflict-free by construction. A run whose phase branch has no
|
|
819
|
+
* new commits integrates to a no-op. Only successful runs integrate; an
|
|
820
|
+
* abandoned run's branch is discarded wholesale by the teardown trio instead.
|
|
821
|
+
*/
|
|
822
|
+
function integrate_worktree(workspace, branch, message) {
|
|
823
|
+
if (!existsSync(join(workspace, ".git"))) throw config_error(`--worktree integration requires a git repository: ${workspace}`);
|
|
824
|
+
const squash = spawnSync("git", [
|
|
825
|
+
"merge",
|
|
826
|
+
"--squash",
|
|
827
|
+
branch
|
|
828
|
+
], {
|
|
829
|
+
cwd: workspace,
|
|
830
|
+
encoding: "utf8"
|
|
831
|
+
});
|
|
832
|
+
if (squash.error !== void 0) throw check_error(`git merge --squash failed to start: ${String(squash.error)}`);
|
|
833
|
+
if (squash.status !== 0) throw check_error(`git merge --squash ${branch} failed: ${(squash.stderr || squash.stdout).trim()}`);
|
|
834
|
+
const commit = spawnSync("git", [
|
|
835
|
+
...commit_identity(workspace),
|
|
836
|
+
"commit",
|
|
837
|
+
"-m",
|
|
838
|
+
message,
|
|
839
|
+
"--no-verify"
|
|
840
|
+
], {
|
|
841
|
+
cwd: workspace,
|
|
842
|
+
encoding: "utf8"
|
|
843
|
+
});
|
|
844
|
+
if (commit.error !== void 0) throw check_error(`git commit failed to start: ${String(commit.error)}`);
|
|
845
|
+
const out = `${commit.stdout ?? ""}${commit.stderr ?? ""}`;
|
|
846
|
+
if (commit.status !== 0 && !out.includes("nothing to commit")) throw check_error(`git commit failed: ${out.trim()}`);
|
|
847
|
+
}
|
|
519
848
|
const DEFAULT_CRITIC_MODEL = "opus";
|
|
849
|
+
const DEFAULT_BUILDER_MAX_OUTPUT_TOKENS = 16384;
|
|
520
850
|
const DEFAULT_CRITIC = "reviewer";
|
|
521
851
|
const DEFAULT_BUILDER_PROVIDER = "claude_cli";
|
|
522
852
|
const DEFAULT_CRITIC_PROVIDER = "claude_cli";
|
|
@@ -598,17 +928,66 @@ function resolve_critic(critic, cwd) {
|
|
|
598
928
|
critic_prompt_path: path
|
|
599
929
|
};
|
|
600
930
|
}
|
|
601
|
-
/**
|
|
602
|
-
*
|
|
603
|
-
*
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
931
|
+
/** What only a builder, or the loop around it, uses, with the flag that sets
|
|
932
|
+
* each: a critique has no use for any of them, so naming one is an error
|
|
933
|
+
* rather than something quietly ignored. */
|
|
934
|
+
const CRITIQUE_REFUSED = [
|
|
935
|
+
["builder_model", "--builder-model"],
|
|
936
|
+
["builder_provider", "--builder-provider"],
|
|
937
|
+
["builder_url", "--builder-url"],
|
|
938
|
+
["builder_max_steps", "--builder-max-steps"],
|
|
939
|
+
["builder_max_tool_calls", "--builder-max-tool-calls"],
|
|
940
|
+
["builder_max_output_tokens", "--builder-max-output-tokens"],
|
|
941
|
+
["builder_permission_mode", "--builder-permission-mode"],
|
|
942
|
+
["allow_unsandboxed_builder", "--allow-unsandboxed-builder"],
|
|
943
|
+
["sandbox_image", "--sandbox-image"],
|
|
944
|
+
["max_iterations", "--max-iterations"],
|
|
945
|
+
["max_cost_usd", "--max-cost-usd"],
|
|
946
|
+
["git_checkpoints", "--git"],
|
|
947
|
+
["worktree", "--worktree"],
|
|
948
|
+
["discard_worktree", "--discard-worktree"],
|
|
949
|
+
["fail_on_gate_edit", "--fail-on-gate-edit"]
|
|
950
|
+
];
|
|
951
|
+
function refuse_builder_settings(raw) {
|
|
952
|
+
for (const [field, flag] of CRITIQUE_REFUSED) if (raw[field] !== void 0 && raw[field] !== false) throw config_error(`volley critique refuses ${flag} (${field}): a critique runs no builder and no loop`);
|
|
953
|
+
}
|
|
954
|
+
const BUILDER_MAX_STEPS = {
|
|
955
|
+
flag: "--builder-max-steps",
|
|
956
|
+
env_var: "VOLLEY_BUILDER_MAX_STEPS",
|
|
957
|
+
fallback: 50
|
|
958
|
+
};
|
|
959
|
+
const BUILDER_MAX_TOOL_CALLS = {
|
|
960
|
+
flag: "--builder-max-tool-calls",
|
|
961
|
+
env_var: "VOLLEY_BUILDER_MAX_TOOL_CALLS",
|
|
962
|
+
fallback: 1
|
|
963
|
+
};
|
|
964
|
+
/** The builders whose turns are capped when nothing sets a cap: the local
|
|
965
|
+
* ones, where nothing else bounds a turn. Ollama's MLX runner lets an unset
|
|
966
|
+
* `num_predict` run to the model's trained context less the prompt, which is
|
|
967
|
+
* how one qwen3.8 turn spent 30,847 tokens thinking. A hosted model on
|
|
968
|
+
* OpenRouter has its own output limit, and a request for more than it allows
|
|
969
|
+
* can be refused, so it's capped only when asked. */
|
|
970
|
+
const CAPPED_BY_DEFAULT = ["ollama", "lmstudio"];
|
|
971
|
+
/** The output cap's flag and env var, and its default for `provider`: 16k
|
|
972
|
+
* tokens on a local builder, else the provider's own limit (null). */
|
|
973
|
+
function builder_max_output_tokens_count(provider) {
|
|
974
|
+
return {
|
|
975
|
+
flag: "--builder-max-output-tokens",
|
|
976
|
+
env_var: "VOLLEY_BUILDER_MAX_OUTPUT_TOKENS",
|
|
977
|
+
fallback: CAPPED_BY_DEFAULT.includes(provider) ? DEFAULT_BUILDER_MAX_OUTPUT_TOKENS : null
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
/** A builder count's flag or config value wins over its env var, which wins
|
|
981
|
+
* over the default, and either must be a positive integer. Local builder
|
|
982
|
+
* only; ignored by the `claude_cli` arm. */
|
|
983
|
+
function resolve_builder_count(value, env, count) {
|
|
984
|
+
if (value !== void 0) return positive_count(value, value, count);
|
|
985
|
+
const env_value = env[count.env_var];
|
|
986
|
+
if (env_value !== void 0 && env_value !== "") return positive_count(Number(env_value), env_value, count);
|
|
987
|
+
return count.fallback;
|
|
988
|
+
}
|
|
989
|
+
function positive_count(value, original, count) {
|
|
990
|
+
if (!Number.isInteger(value) || value < 1) throw config_error(`${count.flag} / ${count.env_var} must be a positive integer; got: ${String(original)}`);
|
|
612
991
|
return value;
|
|
613
992
|
}
|
|
614
993
|
/** `--sandbox-image` (a merged flag/config value) wins over the
|
|
@@ -658,25 +1037,36 @@ function resolve_seat_url(seat, value, provider) {
|
|
|
658
1037
|
}
|
|
659
1038
|
/** `--state-dir` (`state_dir`): the root each run's state sits under at
|
|
660
1039
|
* `<root>/<run_id>/`, resolved against `cwd` so a resumed run finds the same
|
|
661
|
-
* place, or
|
|
662
|
-
*
|
|
663
|
-
|
|
664
|
-
|
|
1040
|
+
* place. Unset, or empty as a seat URL may be, it's the default root,
|
|
1041
|
+
* `$XDG_STATE_HOME/volley`, else `~/.local/state/volley`: outside the
|
|
1042
|
+
* workspace, where no check that reads every file can trip over the run's own
|
|
1043
|
+
* feedback. */
|
|
1044
|
+
function resolve_state_dir(value, cwd, env) {
|
|
1045
|
+
if (value === void 0 || value === null) return default_state_root(env);
|
|
665
1046
|
if (typeof value !== "string") throw config_error(`--state-dir (state_dir) must be a path string; got a ${typeof value}`);
|
|
666
1047
|
const path = value.trim();
|
|
667
|
-
return path === "" ?
|
|
1048
|
+
return path === "" ? default_state_root(env) : resolve(cwd, path);
|
|
668
1049
|
}
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
1050
|
+
function is_writable_dir(path) {
|
|
1051
|
+
try {
|
|
1052
|
+
if (!statSync(path).isDirectory()) return false;
|
|
1053
|
+
accessSync(path, constants.W_OK);
|
|
1054
|
+
return true;
|
|
1055
|
+
} catch {
|
|
1056
|
+
return false;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
/** The run's state directory must be one volley can write: the directory
|
|
1060
|
+
* itself when it's there, as a contained run's mount is, else the nearest
|
|
1061
|
+
* directory above it, where volley creates it. Checked before any spend,
|
|
1062
|
+
* because otherwise the run dies as it starts on a raw EROFS or EACCES. Inside
|
|
1063
|
+
* the sandbox the root filesystem is read-only, so that is what an unmounted
|
|
1064
|
+
* run directory looks like, and the message says what to mount. */
|
|
1065
|
+
function validate_run_state_dir(dir, contained) {
|
|
1066
|
+
let existing = dir;
|
|
1067
|
+
while (!existsSync(existing) && dirname(existing) !== existing) existing = dirname(existing);
|
|
1068
|
+
if (is_writable_dir(existing)) return;
|
|
1069
|
+
throw config_error(contained ? `run state directory not writable: ${dir}. Inside the sandbox, mount the run's own host directory there (sandbox_run_args run_state, or -v <root>/<run id>:${dir}), created on the host first, and pass volley the same --run-id; or point --state-dir at a writable mount` : `run state directory not writable: ${dir}. Point --state-dir at a directory volley can write`);
|
|
680
1070
|
}
|
|
681
1071
|
/** Whether a tool-loop builder's commands would reach the host with nothing in
|
|
682
1072
|
* between: volley isn't contained, and the operator hasn't opted out with
|
|
@@ -684,10 +1074,7 @@ function detect_containment(env) {
|
|
|
684
1074
|
function unguarded_builder(raw, env) {
|
|
685
1075
|
return !detect_containment(env) && !resolve_allow_unsandboxed_builder(raw, env);
|
|
686
1076
|
}
|
|
687
|
-
/** A caller's run id
|
|
688
|
-
* what a branch name accepts without quoting: letters, digits, `-`, and `_`,
|
|
689
|
-
* starting with a letter or digit so it never reads as an option. */
|
|
690
|
-
const RUN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
1077
|
+
/** A caller's run id, held to `RUN_ID_PATTERN`; none gets a fresh UUID. */
|
|
691
1078
|
function resolve_run_id(run_id) {
|
|
692
1079
|
if (run_id === void 0) return randomUUID();
|
|
693
1080
|
if (!RUN_ID_PATTERN.test(run_id)) throw config_error(`--run-id must be letters, digits, '-', and '_', starting with a letter or digit; got: ${run_id}`);
|
|
@@ -699,13 +1086,17 @@ function resolve_run_id(run_id) {
|
|
|
699
1086
|
function resolve_config(raw, options = {}) {
|
|
700
1087
|
const cwd = options.cwd ?? process.cwd();
|
|
701
1088
|
const env = options.env ?? process.env;
|
|
702
|
-
|
|
1089
|
+
const critique = options.mode === "critique";
|
|
1090
|
+
if (critique) refuse_builder_settings(raw);
|
|
1091
|
+
const builder_env = critique ? {} : env;
|
|
1092
|
+
const has_prompt = typeof raw.prompt === "string" && raw.prompt.length > 0;
|
|
1093
|
+
if (!has_prompt && !critique) throw config_error("missing required option: --prompt");
|
|
703
1094
|
if (typeof raw.workspace !== "string" || raw.workspace.length === 0) throw config_error("missing required option: --workspace");
|
|
704
1095
|
if (typeof raw.criteria !== "string" || raw.criteria.length === 0) throw config_error("missing required option: --criteria");
|
|
705
1096
|
const workspace = validate_workspace(resolve(cwd, raw.workspace));
|
|
706
|
-
const prompt = expand_at_file(raw.prompt, cwd);
|
|
1097
|
+
const prompt = has_prompt ? expand_at_file(raw.prompt, cwd) : "";
|
|
707
1098
|
const criteria = expand_at_file(raw.criteria, cwd);
|
|
708
|
-
const max_iterations = raw.max_iterations ?? 10;
|
|
1099
|
+
const max_iterations = raw.max_iterations ?? (critique ? 1 : 10);
|
|
709
1100
|
if (!Number.isInteger(max_iterations) || max_iterations < 1) throw config_error(`--max-iterations must be a positive integer; got: ${String(raw.max_iterations)}`);
|
|
710
1101
|
const max_cost_usd = raw.max_cost_usd ?? null;
|
|
711
1102
|
if (max_cost_usd !== null && !(typeof max_cost_usd === "number" && max_cost_usd > 0)) throw config_error(`--max-cost-usd must be a positive number; got: ${String(raw.max_cost_usd)}`);
|
|
@@ -714,17 +1105,19 @@ function resolve_config(raw, options = {}) {
|
|
|
714
1105
|
const { critic_preset, critic_prompt_path } = resolve_critic(raw.critic ?? DEFAULT_CRITIC, cwd);
|
|
715
1106
|
const builder_provider = raw.builder_provider ?? DEFAULT_BUILDER_PROVIDER;
|
|
716
1107
|
if (!BUILDER_PROVIDERS.includes(builder_provider)) throw config_error(`--builder-provider must be one of ${BUILDER_PROVIDERS.join(", ")}; got: ${String(raw.builder_provider)}`);
|
|
717
|
-
const allow_unsandboxed_builder = resolve_allow_unsandboxed_builder(raw,
|
|
1108
|
+
const allow_unsandboxed_builder = resolve_allow_unsandboxed_builder(raw, builder_env);
|
|
718
1109
|
const contained = detect_containment(env);
|
|
719
|
-
if (TOOL_LOOP_BUILDER_PROVIDERS.includes(builder_provider) && options.uncontained_builder !== "ask" && unguarded_builder(raw,
|
|
720
|
-
const builder_max_steps =
|
|
1110
|
+
if (TOOL_LOOP_BUILDER_PROVIDERS.includes(builder_provider) && options.uncontained_builder !== "ask" && unguarded_builder(raw, builder_env)) throw config_error(`builder-provider '${builder_provider}' runs volley's tool loop with a real bash (write + exec) — refused unless contained. Launch volley inside its sandbox container (docker run <hardened flags> <image> volley run …, which sets VOLLEY_CONTAINED=1), or pass --allow-unsandboxed-builder (VOLLEY_ALLOW_UNSANDBOXED_BUILDER=1) to run uncontained on the host.`);
|
|
1111
|
+
const builder_max_steps = resolve_builder_count(raw.builder_max_steps, builder_env, BUILDER_MAX_STEPS);
|
|
1112
|
+
const builder_max_tool_calls = resolve_builder_count(raw.builder_max_tool_calls, builder_env, BUILDER_MAX_TOOL_CALLS);
|
|
1113
|
+
const builder_max_output_tokens = resolve_builder_count(raw.builder_max_output_tokens, builder_env, builder_max_output_tokens_count(builder_provider));
|
|
721
1114
|
const critic_provider = raw.critic_provider ?? DEFAULT_CRITIC_PROVIDER;
|
|
722
1115
|
if (!CRITIC_PROVIDERS.includes(critic_provider)) throw config_error(`--critic-provider must be one of ${CRITIC_PROVIDERS.join(", ")}; got: ${String(raw.critic_provider)}`);
|
|
723
1116
|
const builder_url = resolve_seat_url("builder", raw.builder_url, builder_provider);
|
|
724
1117
|
const critic_url = resolve_seat_url("critic", raw.critic_url, critic_provider);
|
|
725
1118
|
if (builder_provider === "openrouter" || critic_provider === "openrouter") openrouter_api_key(env);
|
|
726
1119
|
if (contained) {
|
|
727
|
-
const claude_roles = [builder_provider === "claude_cli" ? "builder" : null, critic_provider === "claude_cli" ? "critic" : null].filter((role) => role !== null);
|
|
1120
|
+
const claude_roles = [builder_provider === "claude_cli" && !critique ? "builder" : null, critic_provider === "claude_cli" ? "critic" : null].filter((role) => role !== null);
|
|
728
1121
|
const auth_mode = env["VOLLEY_AUTH_MODE"] ?? "auto";
|
|
729
1122
|
if (claude_roles.length > 0 && auth_mode !== "api_key") throw config_error(`claude_cli (${claude_roles.join(", ")}) cannot use its subscription/OAuth token inside a container — it is mangled there. Set VOLLEY_AUTH_MODE=api_key (with ANTHROPIC_API_KEY) to drive Claude contained, or run claude_cli on the host uncontained.`);
|
|
730
1123
|
}
|
|
@@ -735,14 +1128,17 @@ function resolve_config(raw, options = {}) {
|
|
|
735
1128
|
if (discard_worktree && !worktree) throw config_error("--discard-worktree applies only to a --worktree run: there is no worktree to discard");
|
|
736
1129
|
const gate_paths = raw.gate_paths ?? DEFAULT_GATE_PATTERNS;
|
|
737
1130
|
if (!Array.isArray(gate_paths) || gate_paths.some((glob) => typeof glob !== "string")) throw config_error("gate_paths must be an array of glob strings");
|
|
738
|
-
const sandbox_image = resolve_sandbox_image(raw,
|
|
739
|
-
const state_dir = resolve_state_dir(raw.state_dir, cwd);
|
|
1131
|
+
const sandbox_image = resolve_sandbox_image(raw, builder_env);
|
|
1132
|
+
const state_dir = options.state_dir !== void 0 ? options.state_dir : resolve_state_dir(raw.state_dir, cwd, env);
|
|
1133
|
+
if (state_dir === workspace) throw config_error(`--state-dir (state_dir) can't be the workspace itself: ${workspace}`);
|
|
740
1134
|
const run_id = resolve_run_id(options.run_id);
|
|
1135
|
+
if (state_dir !== null) validate_run_state_dir(join(state_dir, run_id), contained);
|
|
741
1136
|
const check = raw.check ?? "auto";
|
|
742
1137
|
return {
|
|
743
1138
|
version: 2,
|
|
744
1139
|
run_id,
|
|
745
1140
|
started_at: options.started_at ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
1141
|
+
mode: critique ? "critique" : "run",
|
|
746
1142
|
prompt,
|
|
747
1143
|
criteria,
|
|
748
1144
|
check,
|
|
@@ -751,6 +1147,8 @@ function resolve_config(raw, options = {}) {
|
|
|
751
1147
|
builder_provider,
|
|
752
1148
|
builder_url,
|
|
753
1149
|
builder_max_steps,
|
|
1150
|
+
builder_max_tool_calls,
|
|
1151
|
+
builder_max_output_tokens,
|
|
754
1152
|
allow_unsandboxed_builder,
|
|
755
1153
|
critic_model: raw.critic_model ?? DEFAULT_CRITIC_MODEL,
|
|
756
1154
|
critic_provider,
|
|
@@ -830,6 +1228,34 @@ async function warn_small_local_context(provider, model, base_url, warn, abort)
|
|
|
830
1228
|
if (warning !== null) warn(warning);
|
|
831
1229
|
}
|
|
832
1230
|
//#endregion
|
|
1231
|
+
//#region src/builder/countdown.ts
|
|
1232
|
+
/** How many of a session's last steps carry the countdown: five, or a tenth
|
|
1233
|
+
* of a long budget, but never more than half of it, so a short session still
|
|
1234
|
+
* starts without one. */
|
|
1235
|
+
function countdown_window(max_steps) {
|
|
1236
|
+
return Math.min(Math.max(5, Math.ceil(max_steps / 10)), Math.floor(max_steps / 2));
|
|
1237
|
+
}
|
|
1238
|
+
/** The note for a turn with `steps_left` steps left, this one included.
|
|
1239
|
+
* `check` names how to run the check. On the last step fascicle runs only a
|
|
1240
|
+
* call that ends the session, which for the builder is `finish`. */
|
|
1241
|
+
function countdown_note(steps_left, check) {
|
|
1242
|
+
if (steps_left <= 1) return "[volley] This is the last step of this session, and only finish runs on it. Call finish now, with a summary that says what's done and what isn't verified.";
|
|
1243
|
+
return `[volley] ${String(steps_left)} steps left in this session, this one included, and on the last one only finish runs. If you haven't run ${check} since your last edit, run it now, then fix what it reports or call finish.`;
|
|
1244
|
+
}
|
|
1245
|
+
/** The `prepare_step` hook that adds the countdown to a session of
|
|
1246
|
+
* `max_steps` steps. */
|
|
1247
|
+
function step_countdown(max_steps, check) {
|
|
1248
|
+
const window = countdown_window(max_steps);
|
|
1249
|
+
return ({ step_index, messages }) => {
|
|
1250
|
+
const steps_left = max_steps - step_index;
|
|
1251
|
+
if (steps_left > window) return void 0;
|
|
1252
|
+
return { messages: [...messages, {
|
|
1253
|
+
role: "user",
|
|
1254
|
+
content: countdown_note(steps_left, check)
|
|
1255
|
+
}] };
|
|
1256
|
+
};
|
|
1257
|
+
}
|
|
1258
|
+
//#endregion
|
|
833
1259
|
//#region src/workspace_tools.ts
|
|
834
1260
|
/**
|
|
835
1261
|
* Shared workspace tools: `contain()` path confinement plus the read-only
|
|
@@ -860,7 +1286,7 @@ const IGNORED_DIRS = /* @__PURE__ */ new Set([
|
|
|
860
1286
|
* escape. Returns the absolute path; throws on containment violation. The
|
|
861
1287
|
* `workspace` root passed here is the build root, which is the per-run git
|
|
862
1288
|
* worktree under `--worktree` (`build_root`), not necessarily the
|
|
863
|
-
*
|
|
1289
|
+
* workspace the run was started on. */
|
|
864
1290
|
function contain(workspace, rel) {
|
|
865
1291
|
const root = resolve(workspace);
|
|
866
1292
|
const candidate = isAbsolute(rel) ? resolve(rel) : resolve(root, rel);
|
|
@@ -899,6 +1325,25 @@ function walk(root, start, limit) {
|
|
|
899
1325
|
}
|
|
900
1326
|
return out;
|
|
901
1327
|
}
|
|
1328
|
+
/** The files a read tool's optional `path` covers: the whole tree, the tree
|
|
1329
|
+
* under a directory, or the one file it names. A path that doesn't exist
|
|
1330
|
+
* throws, so the seat reads why rather than an empty result that looks like
|
|
1331
|
+
* "nothing matched". */
|
|
1332
|
+
function scoped_files(workspace, path, limit) {
|
|
1333
|
+
const root = resolve(workspace);
|
|
1334
|
+
if (path === void 0) return walk(root, root, limit);
|
|
1335
|
+
const start = contain(workspace, path);
|
|
1336
|
+
let is_dir;
|
|
1337
|
+
try {
|
|
1338
|
+
is_dir = statSync(start).isDirectory();
|
|
1339
|
+
} catch (err) {
|
|
1340
|
+
throw new Error(`no such file or directory: ${path}`, { cause: err });
|
|
1341
|
+
}
|
|
1342
|
+
return is_dir ? walk(root, start, limit) : [{
|
|
1343
|
+
rel: relative(root, start),
|
|
1344
|
+
abs: start
|
|
1345
|
+
}];
|
|
1346
|
+
}
|
|
902
1347
|
function build_regex(pattern, flags) {
|
|
903
1348
|
try {
|
|
904
1349
|
return new RegExp(pattern, flags);
|
|
@@ -910,17 +1355,18 @@ function build_regex(pattern, flags) {
|
|
|
910
1355
|
const read_file_input = z.object({ path: z.string().describe("Workspace-relative path to the file to read.") });
|
|
911
1356
|
const search_files_input = z.object({
|
|
912
1357
|
pattern: z.string().describe("JavaScript regular expression to match against each line."),
|
|
913
|
-
path: z.string().optional().describe("Optional workspace-relative
|
|
1358
|
+
path: z.string().optional().describe("Optional workspace-relative directory or file to limit the search to."),
|
|
914
1359
|
ignore_case: z.boolean().optional().describe("Case-insensitive match. Default false.")
|
|
915
1360
|
});
|
|
916
1361
|
const list_files_input = z.object({
|
|
917
|
-
path: z.string().optional().describe("Optional workspace-relative
|
|
1362
|
+
path: z.string().optional().describe("Optional workspace-relative directory or file. Default: the workspace root."),
|
|
918
1363
|
contains: z.string().optional().describe("Optional substring filter applied to each relative path.")
|
|
919
1364
|
});
|
|
920
1365
|
function read_only_tools(workspace) {
|
|
921
1366
|
return [
|
|
922
1367
|
{
|
|
923
1368
|
name: "read_file",
|
|
1369
|
+
read_only: true,
|
|
924
1370
|
description: `Read a UTF-8 text file from the workspace. Returns the file content (truncated at ${String(READ_FILE_MAX_BYTES)} bytes).`,
|
|
925
1371
|
input_schema: read_file_input,
|
|
926
1372
|
execute: (raw) => {
|
|
@@ -931,13 +1377,13 @@ function read_only_tools(workspace) {
|
|
|
931
1377
|
},
|
|
932
1378
|
{
|
|
933
1379
|
name: "search_files",
|
|
1380
|
+
read_only: true,
|
|
934
1381
|
description: `Search workspace files for lines matching a regular expression. Returns up to ${String(200)} "relative/path:line: text" hits.`,
|
|
935
1382
|
input_schema: search_files_input,
|
|
936
1383
|
execute: (raw) => {
|
|
937
1384
|
const input = search_files_input.parse(raw);
|
|
938
|
-
const start = input.path !== void 0 ? contain(workspace, input.path) : resolve(workspace);
|
|
939
1385
|
const regex = build_regex(input.pattern, input.ignore_case === true ? "i" : "");
|
|
940
|
-
const files =
|
|
1386
|
+
const files = scoped_files(workspace, input.path, LIST_MAX_ENTRIES);
|
|
941
1387
|
const hits = [];
|
|
942
1388
|
for (const file of files) {
|
|
943
1389
|
if (hits.length >= 200) break;
|
|
@@ -961,12 +1407,12 @@ function read_only_tools(workspace) {
|
|
|
961
1407
|
},
|
|
962
1408
|
{
|
|
963
1409
|
name: "list_files",
|
|
1410
|
+
read_only: true,
|
|
964
1411
|
description: "List files under a workspace directory (recursively; skips node_modules, .git, and other build/output directories).",
|
|
965
1412
|
input_schema: list_files_input,
|
|
966
1413
|
execute: (raw) => {
|
|
967
1414
|
const input = list_files_input.parse(raw);
|
|
968
|
-
|
|
969
|
-
let files = walk(resolve(workspace), start, LIST_MAX_ENTRIES).map((f) => f.rel);
|
|
1415
|
+
let files = scoped_files(workspace, input.path, LIST_MAX_ENTRIES).map((f) => f.rel);
|
|
970
1416
|
if (input.contains !== void 0) {
|
|
971
1417
|
const needle = input.contains;
|
|
972
1418
|
files = files.filter((f) => f.includes(needle));
|
|
@@ -1382,15 +1828,257 @@ function finish_tool() {
|
|
|
1382
1828
|
};
|
|
1383
1829
|
}
|
|
1384
1830
|
//#endregion
|
|
1385
|
-
//#region src/
|
|
1831
|
+
//#region src/check/checkride.ts
|
|
1386
1832
|
/**
|
|
1387
|
-
*
|
|
1388
|
-
*
|
|
1389
|
-
*
|
|
1833
|
+
* checkride runner: spawn `pnpm exec checkride --json --digest`, gate on the
|
|
1834
|
+
* `.check/summary.json` contract (`schema_version: 1`), and collect raw
|
|
1835
|
+
* failing-slot artifacts for the critic — the critic reads what the tool
|
|
1836
|
+
* actually said, not a normalized digest. The digest checkride writes on a red
|
|
1837
|
+
* run is kept beside them for the next builder, which needs a short list of
|
|
1838
|
+
* what to fix more than the whole of every slot's output.
|
|
1390
1839
|
*/
|
|
1391
|
-
const
|
|
1392
|
-
|
|
1393
|
-
|
|
1840
|
+
const CHECK_ARTIFACT_MAX_CHARS = 8e3;
|
|
1841
|
+
const CHECKRIDE_SCHEMA_VERSION = 1;
|
|
1842
|
+
/** How long a check's output may stay open after its shell exits, while
|
|
1843
|
+
* something that escaped its process group still holds it, before the reads
|
|
1844
|
+
* stop so the check can finish. */
|
|
1845
|
+
const OUTPUT_GRACE_MS = 2e3;
|
|
1846
|
+
/** Kill a check's whole process group with SIGKILL. A test runner's workers,
|
|
1847
|
+
* and anything the check started in the background, sit in the shell's group,
|
|
1848
|
+
* so killing the shell alone would leave them running. Windows has no process
|
|
1849
|
+
* groups to signal, so there only the shell goes. */
|
|
1850
|
+
function kill_group(proc) {
|
|
1851
|
+
if (proc.pid === void 0) return;
|
|
1852
|
+
try {
|
|
1853
|
+
if (process.platform === "win32") proc.kill("SIGKILL");
|
|
1854
|
+
else process.kill(-proc.pid, "SIGKILL");
|
|
1855
|
+
} catch {}
|
|
1856
|
+
}
|
|
1857
|
+
/** Run a check to its end in a process group of its own, capturing its
|
|
1858
|
+
* output. The abort kills the whole group and rejects with `aborted_error`,
|
|
1859
|
+
* and the shell's exit kills whatever it left behind, so a stopped run leaves
|
|
1860
|
+
* no test workers or servers running. */
|
|
1861
|
+
function spawn_capture(cmd, args, options) {
|
|
1862
|
+
const { abort } = options;
|
|
1863
|
+
if (abort?.aborted === true) return Promise.reject(new aborted_error("check aborted"));
|
|
1864
|
+
return new Promise((resolve_promise, reject) => {
|
|
1865
|
+
const proc = spawn(cmd, args, {
|
|
1866
|
+
cwd: options.cwd,
|
|
1867
|
+
...options.env !== void 0 ? { env: options.env } : {},
|
|
1868
|
+
detached: process.platform !== "win32",
|
|
1869
|
+
stdio: [
|
|
1870
|
+
"ignore",
|
|
1871
|
+
"pipe",
|
|
1872
|
+
"pipe"
|
|
1873
|
+
]
|
|
1874
|
+
});
|
|
1875
|
+
let stdout = "";
|
|
1876
|
+
let stderr = "";
|
|
1877
|
+
let grace;
|
|
1878
|
+
const on_abort = () => {
|
|
1879
|
+
kill_group(proc);
|
|
1880
|
+
reject(new aborted_error("check aborted"));
|
|
1881
|
+
};
|
|
1882
|
+
const finish = () => {
|
|
1883
|
+
clearTimeout(grace);
|
|
1884
|
+
abort?.removeEventListener("abort", on_abort);
|
|
1885
|
+
};
|
|
1886
|
+
proc.stdout.on("data", (chunk) => {
|
|
1887
|
+
stdout += chunk.toString();
|
|
1888
|
+
});
|
|
1889
|
+
proc.stderr.on("data", (chunk) => {
|
|
1890
|
+
stderr += chunk.toString();
|
|
1891
|
+
});
|
|
1892
|
+
proc.on("error", (err) => {
|
|
1893
|
+
finish();
|
|
1894
|
+
reject(check_error(`check command failed to start: ${err.message}`));
|
|
1895
|
+
});
|
|
1896
|
+
proc.on("exit", () => {
|
|
1897
|
+
kill_group(proc);
|
|
1898
|
+
grace = setTimeout(() => {
|
|
1899
|
+
proc.stdout.destroy();
|
|
1900
|
+
proc.stderr.destroy();
|
|
1901
|
+
}, OUTPUT_GRACE_MS);
|
|
1902
|
+
});
|
|
1903
|
+
proc.on("close", (code) => {
|
|
1904
|
+
finish();
|
|
1905
|
+
resolve_promise({
|
|
1906
|
+
exit_code: code,
|
|
1907
|
+
stdout,
|
|
1908
|
+
stderr
|
|
1909
|
+
});
|
|
1910
|
+
});
|
|
1911
|
+
abort?.addEventListener("abort", on_abort, { once: true });
|
|
1912
|
+
});
|
|
1913
|
+
}
|
|
1914
|
+
/** Best-effort parse of the summary contract. `pnpm exec` talks over the
|
|
1915
|
+
* command's stdout (pnpm 11's dep-verify prints an "Already up to date"
|
|
1916
|
+
* install line ahead of the JSON), so the object is extracted between the
|
|
1917
|
+
* first `{` and the last `}` rather than parsed from byte 0. An unexpected
|
|
1918
|
+
* schema_version warns and still parses; no JSON object at all is a harness
|
|
1919
|
+
* error (exit 4). */
|
|
1920
|
+
function parse_checkride_summary(stdout) {
|
|
1921
|
+
const start = stdout.indexOf("{");
|
|
1922
|
+
const end = stdout.lastIndexOf("}");
|
|
1923
|
+
let raw;
|
|
1924
|
+
try {
|
|
1925
|
+
if (start === -1 || end < start) throw new Error("no JSON object in stdout");
|
|
1926
|
+
raw = JSON.parse(stdout.slice(start, end + 1));
|
|
1927
|
+
} catch {
|
|
1928
|
+
throw check_error("checkride --json produced unparseable stdout");
|
|
1929
|
+
}
|
|
1930
|
+
if (typeof raw !== "object" || raw === null || !Array.isArray(raw.checks)) throw check_error("checkride summary missing checks array");
|
|
1931
|
+
const summary = raw;
|
|
1932
|
+
return {
|
|
1933
|
+
summary,
|
|
1934
|
+
warning: summary.schema_version === CHECKRIDE_SCHEMA_VERSION ? null : `checkride summary schema_version ${String(summary.schema_version)} != ${String(CHECKRIDE_SCHEMA_VERSION)}; parsing best-effort`
|
|
1935
|
+
};
|
|
1936
|
+
}
|
|
1937
|
+
/** Parse the summary from stdout, falling back to the on-disk contract:
|
|
1938
|
+
* `.check/summary.json` is what checkride documents; stdout is its mirror and
|
|
1939
|
+
* wrappers may talk over it beyond what the tolerant slice can recover. */
|
|
1940
|
+
function parse_summary_with_fallback(stdout, workspace) {
|
|
1941
|
+
try {
|
|
1942
|
+
return parse_checkride_summary(stdout);
|
|
1943
|
+
} catch (err) {
|
|
1944
|
+
const file = join(workspace, ".check", "summary.json");
|
|
1945
|
+
if (!existsSync(file)) throw err;
|
|
1946
|
+
return parse_checkride_summary(readFileSync(file, "utf8"));
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
function failing_checks(summary) {
|
|
1950
|
+
return summary.checks.filter((c) => !c.ok && c.skipped !== true);
|
|
1951
|
+
}
|
|
1952
|
+
/** Whether `path` sits under `dir`. */
|
|
1953
|
+
function within(dir, path) {
|
|
1954
|
+
const from_dir = relative(dir, path);
|
|
1955
|
+
return from_dir !== ".." && !from_dir.startsWith(`..${sep}`) && !isAbsolute(from_dir);
|
|
1956
|
+
}
|
|
1957
|
+
/** Per failing slot: the raw artifact (`<output_file>` when present, else
|
|
1958
|
+
* `<slot>.stdout.txt`) in `check_dir`, a workspace's `.check/` or an archived
|
|
1959
|
+
* iteration's copy of it, truncated with a pointer to the file. The names come
|
|
1960
|
+
* from a summary the builder could have written, so a path that leaves
|
|
1961
|
+
* `check_dir` has no artifact, rather than putting a file from elsewhere in
|
|
1962
|
+
* front of a model. */
|
|
1963
|
+
function read_failing_artifacts(check_dir, failing) {
|
|
1964
|
+
return failing.map((check) => {
|
|
1965
|
+
const file = check.output_file ?? `${check.name}.stdout.txt`;
|
|
1966
|
+
const path = join(check_dir, file);
|
|
1967
|
+
if (!within(check_dir, path) || !existsSync(path) || !statSync(path).isFile()) return {
|
|
1968
|
+
slot: check.name,
|
|
1969
|
+
path: null,
|
|
1970
|
+
content: "(no artifact found)",
|
|
1971
|
+
truncated: false
|
|
1972
|
+
};
|
|
1973
|
+
const raw = readFileSync(path, "utf8");
|
|
1974
|
+
const truncated = raw.length > CHECK_ARTIFACT_MAX_CHARS;
|
|
1975
|
+
return {
|
|
1976
|
+
slot: check.name,
|
|
1977
|
+
path,
|
|
1978
|
+
content: truncated ? raw.slice(0, CHECK_ARTIFACT_MAX_CHARS) : raw,
|
|
1979
|
+
truncated
|
|
1980
|
+
};
|
|
1981
|
+
});
|
|
1982
|
+
}
|
|
1983
|
+
/** checkride's digest of a red run (`.check/digest.md`, written under
|
|
1984
|
+
* `--digest` and removed on a green one), or undefined when there is none. It
|
|
1985
|
+
* is capped by checkride itself, at 8 kB. */
|
|
1986
|
+
function read_digest(workspace) {
|
|
1987
|
+
const path = join(workspace, ".check", "digest.md");
|
|
1988
|
+
return existsSync(path) ? readFileSync(path, "utf8") : void 0;
|
|
1989
|
+
}
|
|
1990
|
+
async function run_checkride(opts) {
|
|
1991
|
+
const { workspace, ...spawn_options } = opts;
|
|
1992
|
+
const proc = await spawn_capture("pnpm", [
|
|
1993
|
+
"exec",
|
|
1994
|
+
"checkride",
|
|
1995
|
+
"--json",
|
|
1996
|
+
"--digest"
|
|
1997
|
+
], {
|
|
1998
|
+
...spawn_options,
|
|
1999
|
+
cwd: workspace
|
|
2000
|
+
});
|
|
2001
|
+
if (proc.exit_code === 2) throw check_error(`checkride harness error: ${proc.stderr.trim()}`);
|
|
2002
|
+
const { summary } = parse_summary_with_fallback(proc.stdout, opts.workspace);
|
|
2003
|
+
const failing = failing_checks(summary);
|
|
2004
|
+
const digest = summary.ok ? void 0 : read_digest(opts.workspace);
|
|
2005
|
+
return {
|
|
2006
|
+
ran: true,
|
|
2007
|
+
runner: "checkride",
|
|
2008
|
+
ok: summary.ok,
|
|
2009
|
+
exit_code: proc.exit_code,
|
|
2010
|
+
duration_ms: summary.total_duration_ms,
|
|
2011
|
+
failing_slots: failing.map((c) => c.name),
|
|
2012
|
+
detail: read_failing_artifacts(join(opts.workspace, ".check"), failing),
|
|
2013
|
+
summary,
|
|
2014
|
+
...digest !== void 0 ? { digest } : {}
|
|
2015
|
+
};
|
|
2016
|
+
}
|
|
2017
|
+
//#endregion
|
|
2018
|
+
//#region src/check/command.ts
|
|
2019
|
+
/**
|
|
2020
|
+
* Generic command runner: `sh -c <command>`, combined
|
|
2021
|
+
* output captured, exit-0 semantics. Command-not-found is an operator error
|
|
2022
|
+
* (exit 4), not an iterable failure.
|
|
2023
|
+
*/
|
|
2024
|
+
async function run_command_check(opts) {
|
|
2025
|
+
const { command, workspace, ...spawn_options } = opts;
|
|
2026
|
+
const started = Date.now();
|
|
2027
|
+
const proc = await spawn_capture("sh", ["-c", command], {
|
|
2028
|
+
...spawn_options,
|
|
2029
|
+
cwd: workspace
|
|
2030
|
+
});
|
|
2031
|
+
if (proc.exit_code === 127 || proc.exit_code === 126) throw check_error(`check command failed to start (exit ${String(proc.exit_code)}): ${command}`);
|
|
2032
|
+
const log = `${proc.stdout}${proc.stderr}`;
|
|
2033
|
+
return {
|
|
2034
|
+
ran: true,
|
|
2035
|
+
runner: "command",
|
|
2036
|
+
ok: proc.exit_code === 0,
|
|
2037
|
+
exit_code: proc.exit_code,
|
|
2038
|
+
duration_ms: Date.now() - started,
|
|
2039
|
+
failing_slots: [],
|
|
2040
|
+
detail: [],
|
|
2041
|
+
log
|
|
2042
|
+
};
|
|
2043
|
+
}
|
|
2044
|
+
function skipped_check(reason) {
|
|
2045
|
+
return {
|
|
2046
|
+
ran: false,
|
|
2047
|
+
runner: "none",
|
|
2048
|
+
ok: reason === "none",
|
|
2049
|
+
exit_code: null,
|
|
2050
|
+
duration_ms: 0,
|
|
2051
|
+
failing_slots: [],
|
|
2052
|
+
detail: []
|
|
2053
|
+
};
|
|
2054
|
+
}
|
|
2055
|
+
/** What a rejected check says in place of its output. */
|
|
2056
|
+
const CHECK_REJECTED = "the check was rejected when it asked to run, so it never ran";
|
|
2057
|
+
/** A check the user rejected when it asked to run: recorded as a failed
|
|
2058
|
+
* check, with the refusal as its output, so a round can't converge on work
|
|
2059
|
+
* nothing checked, and the critic hears why there's no result. */
|
|
2060
|
+
function rejected_check(runner) {
|
|
2061
|
+
return {
|
|
2062
|
+
ran: true,
|
|
2063
|
+
runner,
|
|
2064
|
+
ok: false,
|
|
2065
|
+
exit_code: null,
|
|
2066
|
+
duration_ms: 0,
|
|
2067
|
+
failing_slots: [],
|
|
2068
|
+
detail: [],
|
|
2069
|
+
log: CHECK_REJECTED
|
|
2070
|
+
};
|
|
2071
|
+
}
|
|
2072
|
+
//#endregion
|
|
2073
|
+
//#region src/cost.ts
|
|
2074
|
+
/**
|
|
2075
|
+
* Cost and usage accumulation. All pricing knowledge lives in
|
|
2076
|
+
* fascicle; volley only folds `GenerateResult.usage` / `.cost` into loop
|
|
2077
|
+
* state and enforces the cap predicate in the guard.
|
|
2078
|
+
*/
|
|
2079
|
+
const EMPTY_USAGE = {
|
|
2080
|
+
input_tokens: 0,
|
|
2081
|
+
output_tokens: 0
|
|
1394
2082
|
};
|
|
1395
2083
|
function add_usage(a, b) {
|
|
1396
2084
|
const sum = {
|
|
@@ -1421,6 +2109,7 @@ function cost_source_of(result) {
|
|
|
1421
2109
|
* its whole degradation ladder. */
|
|
1422
2110
|
function phase_record(result, model, duration_ms = result.timing?.duration_ms ?? 0) {
|
|
1423
2111
|
const rate = throughput(result);
|
|
2112
|
+
const truncated = result.steps.filter((step) => step.truncated === true).length;
|
|
1424
2113
|
return {
|
|
1425
2114
|
provider: result.model_resolved.provider,
|
|
1426
2115
|
model,
|
|
@@ -1432,6 +2121,7 @@ function phase_record(result, model, duration_ms = result.timing?.duration_ms ??
|
|
|
1432
2121
|
finish_reason: result.finish_reason,
|
|
1433
2122
|
tool_calls: result.tool_calls.length,
|
|
1434
2123
|
salvaged_tool_calls: result.tool_calls.filter((call) => call.salvaged === true).length,
|
|
2124
|
+
...truncated > 0 ? { truncated_turns: truncated } : {},
|
|
1435
2125
|
...rate !== void 0 ? { throughput: rate } : {}
|
|
1436
2126
|
};
|
|
1437
2127
|
}
|
|
@@ -1500,130 +2190,6 @@ async function prewarm_ollama_model(base_url, model, abort) {
|
|
|
1500
2190
|
} catch {}
|
|
1501
2191
|
}
|
|
1502
2192
|
//#endregion
|
|
1503
|
-
//#region src/workspace.ts
|
|
1504
|
-
/**
|
|
1505
|
-
* Workspace lifecycle: run-state initialization, stale-run
|
|
1506
|
-
* backup rotation, path helpers, and optional git checkpoints.
|
|
1507
|
-
*/
|
|
1508
|
-
/** Where a run keeps its config, summary, trajectory, feedback, verdict, and
|
|
1509
|
-
* iterations: `<state_dir>/<run_id>/` under a state root, else
|
|
1510
|
-
* `<workspace>/.volley/`. */
|
|
1511
|
-
function run_state_dir(run) {
|
|
1512
|
-
return run.state_dir === null ? join(run.workspace, ".volley") : join(run.state_dir, run.run_id);
|
|
1513
|
-
}
|
|
1514
|
-
function state_path(run, ...parts) {
|
|
1515
|
-
return join(run_state_dir(run), ...parts);
|
|
1516
|
-
}
|
|
1517
|
-
function iteration_dir(run, iteration) {
|
|
1518
|
-
return state_path(run, "iterations", String(iteration).padStart(3, "0"));
|
|
1519
|
-
}
|
|
1520
|
-
/** Filesystem-safe timestamp for `.bak.<stamp>` rotation names. Shared by the
|
|
1521
|
-
* run-state rotation here and the worktree rotation so both preserve
|
|
1522
|
-
* prior state under the same never-destroy naming. */
|
|
1523
|
-
function backup_stamp(date = /* @__PURE__ */ new Date()) {
|
|
1524
|
-
return date.toISOString().replace(/[:.]/g, "-");
|
|
1525
|
-
}
|
|
1526
|
-
/** Fresh-run initialization: rotate a stale state directory aside (a prior
|
|
1527
|
-
* `.volley/` becomes `.volley.bak.<stamp>`), then create the directory tree.
|
|
1528
|
-
* Under a state root each run has a directory of its own, so only a reused
|
|
1529
|
-
* `--run-id` ever finds one to rotate. Resume runs skip rotation
|
|
1530
|
-
* (`preserve: true`). */
|
|
1531
|
-
function initialize_run_state(run, options = {}) {
|
|
1532
|
-
const dir = run_state_dir(run);
|
|
1533
|
-
if (existsSync(dir) && options.preserve !== true) renameSync(dir, `${dir}.bak.${backup_stamp()}`);
|
|
1534
|
-
mkdirSync(join(dir, "iterations"), { recursive: true });
|
|
1535
|
-
}
|
|
1536
|
-
function write_resolved_config(config) {
|
|
1537
|
-
const record = {
|
|
1538
|
-
version: config.version,
|
|
1539
|
-
run_id: config.run_id,
|
|
1540
|
-
started_at: config.started_at,
|
|
1541
|
-
prompt: config.prompt,
|
|
1542
|
-
criteria: config.criteria,
|
|
1543
|
-
check: config.check,
|
|
1544
|
-
check_resolved: config.check_resolved,
|
|
1545
|
-
builder_model: config.builder_model,
|
|
1546
|
-
builder_provider: config.builder_provider,
|
|
1547
|
-
builder_url: config.builder_url,
|
|
1548
|
-
builder_max_steps: config.builder_max_steps,
|
|
1549
|
-
critic_model: config.critic_model,
|
|
1550
|
-
critic_provider: config.critic_provider,
|
|
1551
|
-
critic_url: config.critic_url,
|
|
1552
|
-
builder_permission_mode: config.builder_permission_mode,
|
|
1553
|
-
critic_preset: config.critic_preset,
|
|
1554
|
-
critic_prompt_path: config.critic_prompt_path,
|
|
1555
|
-
max_iterations: config.max_iterations,
|
|
1556
|
-
max_cost_usd: config.max_cost_usd,
|
|
1557
|
-
git_checkpoints: config.git_checkpoints,
|
|
1558
|
-
worktree: config.worktree,
|
|
1559
|
-
discard_worktree: config.discard_worktree,
|
|
1560
|
-
gate_paths: config.gate_paths,
|
|
1561
|
-
fail_on_gate_edit: config.fail_on_gate_edit,
|
|
1562
|
-
sandbox_image: config.sandbox_image,
|
|
1563
|
-
state_dir: config.state_dir,
|
|
1564
|
-
workspace: config.workspace
|
|
1565
|
-
};
|
|
1566
|
-
writeFileSync(state_path(config, "config.json"), `${JSON.stringify(record, null, 2)}\n`);
|
|
1567
|
-
}
|
|
1568
|
-
/**
|
|
1569
|
-
* `--git`: commit everything under `root` after a phase (`git -C <root>`, run
|
|
1570
|
-
* here via the spawn `cwd`). `root` is the build root (`build_root`): the
|
|
1571
|
-
* workspace on the default path, or the per-run worktree under `--worktree`, so
|
|
1572
|
-
* checkpoints land on the worktree branch rather than the workspace's.
|
|
1573
|
-
* A checkpoint with nothing to commit is fine; a missing git binary or repo is
|
|
1574
|
-
* not.
|
|
1575
|
-
*/
|
|
1576
|
-
function git_checkpoint(root, message) {
|
|
1577
|
-
if (!existsSync(join(root, ".git"))) throw config_error(`git checkpoints require a git repository: ${root}`);
|
|
1578
|
-
const add = spawnSync("git", ["add", "-A"], { cwd: root });
|
|
1579
|
-
if (add.error !== void 0 || add.status !== 0) throw check_error(`git add failed: ${add.stderr?.toString() ?? String(add.error)}`);
|
|
1580
|
-
const commit = spawnSync("git", [
|
|
1581
|
-
"commit",
|
|
1582
|
-
"-m",
|
|
1583
|
-
message,
|
|
1584
|
-
"--no-verify"
|
|
1585
|
-
], { cwd: root });
|
|
1586
|
-
if (commit.error !== void 0) throw check_error(`git commit failed to start: ${String(commit.error)}`);
|
|
1587
|
-
const out = `${commit.stdout?.toString() ?? ""}${commit.stderr?.toString() ?? ""}`;
|
|
1588
|
-
if (commit.status !== 0 && !out.includes("nothing to commit")) throw check_error(`git commit failed: ${out.trim()}`);
|
|
1589
|
-
}
|
|
1590
|
-
/**
|
|
1591
|
-
* Integration — bring a successful `--worktree --git` run's effects onto the
|
|
1592
|
-
* workspace branch. The per-phase checkpoints on `branch` are the raw
|
|
1593
|
-
* audit/replay trail; here they collapse into a single squash commit
|
|
1594
|
-
* (`git merge --squash` then `commit`) on the workspace's current branch. The
|
|
1595
|
-
* shared object store makes this a local, fetch-free merge, and — because the
|
|
1596
|
-
* workspace branch never moved during the run (checkpoints went to the phase
|
|
1597
|
-
* branch) — it is conflict-free by construction. A run whose phase branch has no
|
|
1598
|
-
* new commits integrates to a no-op. Only successful runs integrate; an
|
|
1599
|
-
* abandoned run's branch is discarded wholesale by the teardown trio instead.
|
|
1600
|
-
*/
|
|
1601
|
-
function integrate_worktree(workspace, branch, message) {
|
|
1602
|
-
if (!existsSync(join(workspace, ".git"))) throw config_error(`--worktree integration requires a git repository: ${workspace}`);
|
|
1603
|
-
const squash = spawnSync("git", [
|
|
1604
|
-
"merge",
|
|
1605
|
-
"--squash",
|
|
1606
|
-
branch
|
|
1607
|
-
], {
|
|
1608
|
-
cwd: workspace,
|
|
1609
|
-
encoding: "utf8"
|
|
1610
|
-
});
|
|
1611
|
-
if (squash.error !== void 0) throw check_error(`git merge --squash failed to start: ${String(squash.error)}`);
|
|
1612
|
-
if (squash.status !== 0) throw check_error(`git merge --squash ${branch} failed: ${(squash.stderr || squash.stdout).trim()}`);
|
|
1613
|
-
const commit = spawnSync("git", [
|
|
1614
|
-
"commit",
|
|
1615
|
-
"-m",
|
|
1616
|
-
message,
|
|
1617
|
-
"--no-verify"
|
|
1618
|
-
], {
|
|
1619
|
-
cwd: workspace,
|
|
1620
|
-
encoding: "utf8"
|
|
1621
|
-
});
|
|
1622
|
-
if (commit.error !== void 0) throw check_error(`git commit failed to start: ${String(commit.error)}`);
|
|
1623
|
-
const out = `${commit.stdout ?? ""}${commit.stderr ?? ""}`;
|
|
1624
|
-
if (commit.status !== 0 && !out.includes("nothing to commit")) throw check_error(`git commit failed: ${out.trim()}`);
|
|
1625
|
-
}
|
|
1626
|
-
//#endregion
|
|
1627
2193
|
//#region src/worktree.ts
|
|
1628
2194
|
/**
|
|
1629
2195
|
* Git-worktree orchestration: isolate a builder run's
|
|
@@ -1656,9 +2222,8 @@ function worktree_branch(run_id) {
|
|
|
1656
2222
|
* when `--worktree` is on, else the workspace itself. This is the containment
|
|
1657
2223
|
* root the builder/critic file tools resolve through `contain()` and the
|
|
1658
2224
|
* `bash` tool's cwd — re-pointing it moves writes into the worktree without
|
|
1659
|
-
* disturbing the workspace.
|
|
1660
|
-
*
|
|
1661
|
-
* model-driven effect surface moves.
|
|
2225
|
+
* disturbing the workspace. The check, the change report, and the checkpoints
|
|
2226
|
+
* follow it there; the run's state stays wherever `state_dir` puts it.
|
|
1662
2227
|
*/
|
|
1663
2228
|
function build_root(workspace, worktree) {
|
|
1664
2229
|
return worktree ? worktree_path(workspace) : workspace;
|
|
@@ -1876,6 +2441,7 @@ function salvage_worktree(repo, path, branch) {
|
|
|
1876
2441
|
if (existsSync(path)) {
|
|
1877
2442
|
git_quiet(path, ["add", "-A"]);
|
|
1878
2443
|
git_quiet(path, [
|
|
2444
|
+
...commit_identity(path),
|
|
1879
2445
|
"commit",
|
|
1880
2446
|
"-m",
|
|
1881
2447
|
`volley: salvage unintegrated worktree (${branch})`,
|
|
@@ -1999,12 +2565,22 @@ const HARNESS_APPENDS = {
|
|
|
1999
2565
|
volley: "harness_append_local.md",
|
|
2000
2566
|
acp: "harness_append_acp.md"
|
|
2001
2567
|
};
|
|
2002
|
-
|
|
2003
|
-
|
|
2568
|
+
/** Step 2 of "Work this way", as both harness appends word it for a builder
|
|
2569
|
+
* held to one tool call a turn. */
|
|
2570
|
+
const ONE_CALL_A_TURN = "2. Call one tool at a time and read its result before deciding the next\n call.";
|
|
2571
|
+
/** Step 2 for a builder that may make several calls a turn: fascicle runs a
|
|
2572
|
+
* turn's calls in order, so a read can't race an edit, but each change or
|
|
2573
|
+
* command is still worth reading before the next. Reads don't count toward
|
|
2574
|
+
* the cap, so the step names no number. */
|
|
2575
|
+
const BATCHED_CALLS = ["2. Batch independent reads into one turn, but make one change or command per", " turn, and read its result before deciding the next call."].join("\n");
|
|
2576
|
+
function compose_builder_system_local(tool_set = "volley", max_tool_calls = 1) {
|
|
2577
|
+
const identity = [
|
|
2004
2578
|
"You are the builder inside the volley harness: an autonomous agent",
|
|
2005
2579
|
"iterating on a workspace until it satisfies a task and its acceptance",
|
|
2006
2580
|
"criteria."
|
|
2007
|
-
].join("\n")
|
|
2581
|
+
].join("\n");
|
|
2582
|
+
const append = readFileSync(join(builder_presets_dir(), HARNESS_APPENDS[tool_set]), "utf8");
|
|
2583
|
+
return `${identity}\n\n${(max_tool_calls > 1 ? append.replace(ONE_CALL_A_TURN, BATCHED_CALLS) : append).trimEnd()}`;
|
|
2008
2584
|
}
|
|
2009
2585
|
/** The builder system prompt for the active provider: the `claude_cli`
|
|
2010
2586
|
* prompt (implicit built-in tools, "the current working directory") for the
|
|
@@ -2012,18 +2588,28 @@ function compose_builder_system_local(tool_set = "volley") {
|
|
|
2012
2588
|
* the seat's tool set for every provider that runs volley's tool loop,
|
|
2013
2589
|
* `openrouter` included. Mirrors the critic's `resolve_critic_prompt`. */
|
|
2014
2590
|
function resolve_builder_system(config, tool_set) {
|
|
2015
|
-
return config.builder_provider === "claude_cli" ? compose_builder_system() : compose_builder_system_local(tool_set);
|
|
2591
|
+
return config.builder_provider === "claude_cli" ? compose_builder_system() : compose_builder_system_local(tool_set, config.builder_max_tool_calls);
|
|
2016
2592
|
}
|
|
2017
2593
|
const BUILDER_TOOL_CALL_REPAIR_ATTEMPTS = 3;
|
|
2594
|
+
/** How the step countdown tells the builder to run the check: the command
|
|
2595
|
+
* volley gates on, with the digest and without `--json`, the configured
|
|
2596
|
+
* command, or the project's own tests when no check gates the run. A
|
|
2597
|
+
* workspace can hold checkride with no `check` script, so not `pnpm check`. */
|
|
2598
|
+
function check_hint(config) {
|
|
2599
|
+
if (config.check_resolved === "checkride") return "the check (`pnpm exec checkride --digest`)";
|
|
2600
|
+
if (config.check_resolved === "command") return `the check (\`${config.check}\`)`;
|
|
2601
|
+
return "the project's tests";
|
|
2602
|
+
}
|
|
2018
2603
|
/** Per-provider tool wiring for the builder, mirroring `critic_tool_options`.
|
|
2019
2604
|
* The `claude_cli` builder is confined at the CLI permission layer (allowlist +
|
|
2020
2605
|
* permission mode) and brings its own built-in tools — this arm is unchanged
|
|
2021
2606
|
* from v2, and takes no injected tools or approval handler. Every other
|
|
2022
2607
|
* builder, local or `openrouter`, brings none, so volley supplies its whole
|
|
2023
2608
|
* workspace tool surface (or the set the caller injected in its place, with
|
|
2024
|
-
* the handler that answers its approvals)
|
|
2025
|
-
*
|
|
2026
|
-
*
|
|
2609
|
+
* the handler that answers its approvals), the per-call loop knobs, the step
|
|
2610
|
+
* countdown (`./builder/countdown.ts`), the output cap when one is set, and
|
|
2611
|
+
* **no** schema (it produces a workspace, not a verdict); per-call values win
|
|
2612
|
+
* over engine defaults. */
|
|
2027
2613
|
function builder_tool_options(deps) {
|
|
2028
2614
|
const { config } = deps;
|
|
2029
2615
|
const bash_executor = deps.bash_executor ?? null;
|
|
@@ -2037,9 +2623,75 @@ function builder_tool_options(deps) {
|
|
|
2037
2623
|
max_steps: config.builder_max_steps,
|
|
2038
2624
|
tool_error_policy: "feed_back",
|
|
2039
2625
|
tool_call_repair_attempts: BUILDER_TOOL_CALL_REPAIR_ATTEMPTS,
|
|
2040
|
-
max_tool_calls_per_step:
|
|
2626
|
+
max_tool_calls_per_step: config.builder_max_tool_calls,
|
|
2627
|
+
prepare_step: step_countdown(config.builder_max_steps, check_hint(config)),
|
|
2628
|
+
...config.builder_max_output_tokens !== null ? {
|
|
2629
|
+
max_tokens: config.builder_max_output_tokens,
|
|
2630
|
+
truncated_turn_policy: "continue"
|
|
2631
|
+
} : {}
|
|
2041
2632
|
};
|
|
2042
2633
|
}
|
|
2634
|
+
/** How much of the last check's output the builder's prompt carries: a few KB,
|
|
2635
|
+
* whatever the runner. checkride's digest already fits in 8 kB, and the builder
|
|
2636
|
+
* can run the check again for the rest. */
|
|
2637
|
+
const PREVIOUS_CHECK_MAX_CHARS = 6e3;
|
|
2638
|
+
/** The first `max` characters of `text`, saying where it was cut. */
|
|
2639
|
+
function head(text, max, rest) {
|
|
2640
|
+
return text.length <= max ? text : `${text.slice(0, max)}\n… (cut at ${String(max)} characters; ${rest})`;
|
|
2641
|
+
}
|
|
2642
|
+
/** The last `max` characters of `text`, saying what came before was cut. */
|
|
2643
|
+
function tail(text, max) {
|
|
2644
|
+
return text.length <= max ? text : `… (the last ${String(max)} characters of ${String(text.length)})\n${text.slice(-max)}`;
|
|
2645
|
+
}
|
|
2646
|
+
/** The part of a failed check the builder reads first: checkride's digest,
|
|
2647
|
+
* else the start of each failing slot's raw output, else the end of a
|
|
2648
|
+
* command's output, where test runners and compilers sum up. */
|
|
2649
|
+
function check_excerpt(check) {
|
|
2650
|
+
const max = PREVIOUS_CHECK_MAX_CHARS;
|
|
2651
|
+
if (check.digest !== void 0) return head(check.digest.trimEnd(), max, ".check/digest.md has the rest");
|
|
2652
|
+
if (check.detail.length > 0) return head(check.detail.map((artifact) => {
|
|
2653
|
+
const source = artifact.path === null ? "" : ` (.check/${basename(artifact.path)})`;
|
|
2654
|
+
return `${artifact.slot}${source}:\n${artifact.content.trimEnd()}`;
|
|
2655
|
+
}).join("\n\n"), max, "each slot's raw output under .check/ has the rest");
|
|
2656
|
+
return tail((check.log ?? "").trimEnd(), max);
|
|
2657
|
+
}
|
|
2658
|
+
/** The check section of a later iteration's prompt: what the deterministic
|
|
2659
|
+
* gate itself said about the last session's work, so the builder starts from
|
|
2660
|
+
* its words rather than the critic's account of them, or re-running it to
|
|
2661
|
+
* find out. Empty unless that check ran and failed. A check the user rejected
|
|
2662
|
+
* when it asked to run is recorded as failed but said nothing, and the
|
|
2663
|
+
* section says so rather than call the work failed. */
|
|
2664
|
+
function previous_check_section(check) {
|
|
2665
|
+
if (check === null || !check.ran || check.ok) return [];
|
|
2666
|
+
const heading = [
|
|
2667
|
+
"",
|
|
2668
|
+
"PREVIOUS CHECK RESULT",
|
|
2669
|
+
"---------------------"
|
|
2670
|
+
];
|
|
2671
|
+
if (check.log === "the check was rejected when it asked to run, so it never ran") return [
|
|
2672
|
+
...heading,
|
|
2673
|
+
"The check never ran on the work your last session left: it was rejected when it",
|
|
2674
|
+
"asked to run, so nothing has verified that work yet."
|
|
2675
|
+
];
|
|
2676
|
+
const runner = check.runner === "checkride" ? "checkride, " : "";
|
|
2677
|
+
const exit = check.exit_code === null ? "no exit code" : `exit ${String(check.exit_code)}`;
|
|
2678
|
+
const lines = [...heading, `The check failed on the work your last session left (${runner}${exit}).`];
|
|
2679
|
+
if (check.failing_slots.length > 0) lines.push(`Failing slots: ${check.failing_slots.join(", ")}.`);
|
|
2680
|
+
const excerpt = check_excerpt(check);
|
|
2681
|
+
if (excerpt.length > 0) lines.push("", excerpt);
|
|
2682
|
+
return lines;
|
|
2683
|
+
}
|
|
2684
|
+
/** The line a later iteration's prompt carries when the last session ran out
|
|
2685
|
+
* of steps: it never got to verify, so this one should check first. */
|
|
2686
|
+
function step_limit_lines(step_limit_hit) {
|
|
2687
|
+
if (step_limit_hit === null) return [];
|
|
2688
|
+
return [
|
|
2689
|
+
"",
|
|
2690
|
+
`Your last session hit its ${String(step_limit_hit)}-step limit before it called finish, so`,
|
|
2691
|
+
"it may never have verified its work. Run the check first this time, and keep",
|
|
2692
|
+
"enough steps in hand to verify again before you finish."
|
|
2693
|
+
];
|
|
2694
|
+
}
|
|
2043
2695
|
function compose_builder_prompt(input) {
|
|
2044
2696
|
const parts = [
|
|
2045
2697
|
"TASK",
|
|
@@ -2053,7 +2705,8 @@ function compose_builder_prompt(input) {
|
|
|
2053
2705
|
`ITERATION: ${input.iteration}`
|
|
2054
2706
|
];
|
|
2055
2707
|
if (input.iteration > 1 && input.feedback !== null) parts.push("", "PREVIOUS CRITIC FEEDBACK", "------------------------", input.feedback, "", "Address the feedback above. The workspace already contains your prior work;", "read the current state, then make the necessary changes.");
|
|
2056
|
-
if (input.
|
|
2708
|
+
if (input.iteration > 1 && input.previous !== null) parts.push(...previous_check_section(input.previous.check), ...step_limit_lines(input.previous.step_limit_hit));
|
|
2709
|
+
if (input.checkride) parts.push("", "The definition of done for this workspace includes checkride exiting 0. Run", "`pnpm exec checkride --digest`, the check volley gates on, after each batch of", "edits and again before you finish. When it fails, read .check/digest.md", "first: it lists each failing slot's first findings. Open a slot's raw", "output under .check/ only if the digest isn't enough, then fix and re-run.");
|
|
2057
2710
|
return parts.join("\n");
|
|
2058
2711
|
}
|
|
2059
2712
|
function builder_max_steps_warning(max_steps) {
|
|
@@ -2075,7 +2728,9 @@ function make_builder_step(deps) {
|
|
|
2075
2728
|
...builder_tool_options(deps)
|
|
2076
2729
|
});
|
|
2077
2730
|
}
|
|
2078
|
-
|
|
2731
|
+
/** Run one builder session on the opened iteration `state`, told what the
|
|
2732
|
+
* iteration before it left behind (`previous`, null on the first). */
|
|
2733
|
+
async function run_builder(deps, state, previous, ctx) {
|
|
2079
2734
|
const { config } = deps;
|
|
2080
2735
|
try {
|
|
2081
2736
|
if (config.builder_provider === "ollama") {
|
|
@@ -2088,10 +2743,16 @@ async function run_builder(deps, state, ctx) {
|
|
|
2088
2743
|
criteria: config.criteria,
|
|
2089
2744
|
feedback: state.feedback,
|
|
2090
2745
|
iteration: state.iteration,
|
|
2091
|
-
checkride: config.check_resolved === "checkride"
|
|
2746
|
+
checkride: config.check_resolved === "checkride",
|
|
2747
|
+
previous
|
|
2092
2748
|
}));
|
|
2093
|
-
|
|
2094
|
-
|
|
2749
|
+
const built = accumulate(state, "builder", result, config.builder_model);
|
|
2750
|
+
if (result.finish_reason !== "max_steps") return built;
|
|
2751
|
+
deps.warn(builder_max_steps_warning(config.builder_max_steps));
|
|
2752
|
+
return {
|
|
2753
|
+
...built,
|
|
2754
|
+
builder_max_steps_hits: built.builder_max_steps_hits + 1
|
|
2755
|
+
};
|
|
2095
2756
|
} catch (err) {
|
|
2096
2757
|
throw phase_error("builder", state.iteration, err);
|
|
2097
2758
|
}
|
|
@@ -2222,7 +2883,7 @@ function compose_critic_prompt(input) {
|
|
|
2222
2883
|
* Critic invocation: a read-only session (`claude_cli`, or a local or
|
|
2223
2884
|
* `openrouter` model with volley's read tools) returning a schema-validated
|
|
2224
2885
|
* structured verdict. The
|
|
2225
|
-
* harness — not the critic — writes
|
|
2886
|
+
* harness — not the critic — writes the run's `feedback.md` and `verdict`.
|
|
2226
2887
|
*/
|
|
2227
2888
|
const CRITIC_ALLOWED_TOOLS = [
|
|
2228
2889
|
"Read",
|
|
@@ -2230,10 +2891,16 @@ const CRITIC_ALLOWED_TOOLS = [
|
|
|
2230
2891
|
"Glob"
|
|
2231
2892
|
];
|
|
2232
2893
|
const CRITIC_DISALLOWED_TOOLS = "Write,Edit,MultiEdit,NotebookEdit,Bash";
|
|
2894
|
+
/** A critic that holds tools isn't constrain-decoded, so nothing stops it
|
|
2895
|
+
* answering null for the feedback or the unmet list, as GLM did on approving.
|
|
2896
|
+
* Both fields allow null and read it as empty, so that answer is a verdict
|
|
2897
|
+
* rather than a schema failure that ends the run. fascicle sends the schema's
|
|
2898
|
+
* input side, so the model sees each field as its type or null, still
|
|
2899
|
+
* required; `.describe()` comes last so the description stays on the field. */
|
|
2233
2900
|
const verdict_schema = z.object({
|
|
2234
2901
|
verdict: z.enum(["approved", "changes_requested"]),
|
|
2235
|
-
feedback: z.string().describe("Free-form markdown for the builder. Concrete and actionable. Will be passed verbatim into the next iteration."),
|
|
2236
|
-
unmet_criteria: z.array(z.string()).describe("The specific acceptance criteria judged unmet, verbatim. Empty when approved.")
|
|
2902
|
+
feedback: z.string().nullable().transform((feedback) => feedback ?? "").describe("Free-form markdown for the builder. Concrete and actionable. Will be passed verbatim into the next iteration."),
|
|
2903
|
+
unmet_criteria: z.array(z.string()).nullable().transform((unmet) => unmet ?? []).describe("The specific acceptance criteria judged unmet, verbatim. Empty when approved.")
|
|
2237
2904
|
});
|
|
2238
2905
|
/** Read-only tool wiring per provider. The claude_cli critic is confined at
|
|
2239
2906
|
* the CLI permission layer (allowlist + explicit disallow); every other critic,
|
|
@@ -2492,270 +3159,90 @@ function initial_state() {
|
|
|
2492
3159
|
total_usage: EMPTY_USAGE,
|
|
2493
3160
|
total_cost_usd: 0,
|
|
2494
3161
|
builder_cost_usd: 0,
|
|
2495
|
-
critic_cost_usd: 0,
|
|
2496
|
-
check_duration_ms: 0,
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
}
|
|
2502
|
-
/** How many rounds the loop may still run: the whole budget on a fresh run, what
|
|
2503
|
-
* the resumed run's completed iterations left of it otherwise. */
|
|
2504
|
-
function remaining_iterations(config, resume_from) {
|
|
2505
|
-
return config.max_iterations - (resume_from?.iteration ?? 0);
|
|
2506
|
-
}
|
|
2507
|
-
/** Did this iteration's builder edit the gate that judges it, with the operator
|
|
2508
|
-
* having asked for that to stop the run? Reported either way (the critic prompt
|
|
2509
|
-
* and the summary always carry it); only `--fail-on-gate-edit` makes it fatal. */
|
|
2510
|
-
function gate_edit_halt(config, state) {
|
|
2511
|
-
return config.fail_on_gate_edit && (state.changes?.gate_edits.length ?? 0) > 0;
|
|
2512
|
-
}
|
|
2513
|
-
/** All stopping conditions live here: acceptance, cost cap, the gate
|
|
2514
|
-
* edit refusal, and — implicitly via `max_rounds` — the iteration budget.
|
|
2515
|
-
* Success wins when it and the cap land on the same iteration.
|
|
2516
|
-
*
|
|
2517
|
-
* A gate edit is the one condition that *beats* success rather than losing to
|
|
2518
|
-
* it: the run passed a check the builder had rewritten, so the pass is exactly
|
|
2519
|
-
* what is in question. It stops the run instead of iterating, because a model
|
|
2520
|
-
* that just edited the gate is not a promising candidate to be asked again. */
|
|
2521
|
-
function gate(config, state) {
|
|
2522
|
-
const gate_edit = gate_edit_halt(config, state);
|
|
2523
|
-
const success = state.check?.ok === true && state.verdict === "approved" && !gate_edit;
|
|
2524
|
-
const cap = cost_cap_hit(config, state);
|
|
2525
|
-
const halt = gate_edit ? "gate_edit" : !success && cap ? "cost_cap" : null;
|
|
2526
|
-
return {
|
|
2527
|
-
stop: success || cap || gate_edit,
|
|
2528
|
-
state: {
|
|
2529
|
-
...state,
|
|
2530
|
-
halt
|
|
2531
|
-
}
|
|
2532
|
-
};
|
|
2533
|
-
}
|
|
2534
|
-
function status_of(value, converged) {
|
|
2535
|
-
if (value.halt === "gate_edit") return "gate_edit_blocked";
|
|
2536
|
-
if (value.halt === "cost_cap") return "cost_cap_reached";
|
|
2537
|
-
return converged ? "success" : "budget_exhausted";
|
|
2538
|
-
}
|
|
2539
|
-
//#endregion
|
|
2540
|
-
//#region src/check/checkride.ts
|
|
2541
|
-
/**
|
|
2542
|
-
* checkride runner: spawn `pnpm exec checkride --json`, gate on the
|
|
2543
|
-
* `.check/summary.json` contract (`schema_version: 1`), and collect raw
|
|
2544
|
-
* failing-slot artifacts for the critic — the critic reads what the tool
|
|
2545
|
-
* actually said, not a normalized digest.
|
|
2546
|
-
*/
|
|
2547
|
-
const CHECK_ARTIFACT_MAX_CHARS = 8e3;
|
|
2548
|
-
const CHECKRIDE_SCHEMA_VERSION = 1;
|
|
2549
|
-
/** How long a check's output may stay open after its shell exits, while
|
|
2550
|
-
* something that escaped its process group still holds it, before the reads
|
|
2551
|
-
* stop so the check can finish. */
|
|
2552
|
-
const OUTPUT_GRACE_MS = 2e3;
|
|
2553
|
-
/** Kill a check's whole process group with SIGKILL. A test runner's workers,
|
|
2554
|
-
* and anything the check started in the background, sit in the shell's group,
|
|
2555
|
-
* so killing the shell alone would leave them running. Windows has no process
|
|
2556
|
-
* groups to signal, so there only the shell goes. */
|
|
2557
|
-
function kill_group(proc) {
|
|
2558
|
-
if (proc.pid === void 0) return;
|
|
2559
|
-
try {
|
|
2560
|
-
if (process.platform === "win32") proc.kill("SIGKILL");
|
|
2561
|
-
else process.kill(-proc.pid, "SIGKILL");
|
|
2562
|
-
} catch {}
|
|
2563
|
-
}
|
|
2564
|
-
/** Run a check to its end in a process group of its own, capturing its
|
|
2565
|
-
* output. The abort kills the whole group and rejects with `aborted_error`,
|
|
2566
|
-
* and the shell's exit kills whatever it left behind, so a stopped run leaves
|
|
2567
|
-
* no test workers or servers running. */
|
|
2568
|
-
function spawn_capture(cmd, args, options) {
|
|
2569
|
-
const { abort } = options;
|
|
2570
|
-
if (abort?.aborted === true) return Promise.reject(new aborted_error("check aborted"));
|
|
2571
|
-
return new Promise((resolve_promise, reject) => {
|
|
2572
|
-
const proc = spawn(cmd, args, {
|
|
2573
|
-
cwd: options.cwd,
|
|
2574
|
-
...options.env !== void 0 ? { env: options.env } : {},
|
|
2575
|
-
detached: process.platform !== "win32",
|
|
2576
|
-
stdio: [
|
|
2577
|
-
"ignore",
|
|
2578
|
-
"pipe",
|
|
2579
|
-
"pipe"
|
|
2580
|
-
]
|
|
2581
|
-
});
|
|
2582
|
-
let stdout = "";
|
|
2583
|
-
let stderr = "";
|
|
2584
|
-
let grace;
|
|
2585
|
-
const on_abort = () => {
|
|
2586
|
-
kill_group(proc);
|
|
2587
|
-
reject(new aborted_error("check aborted"));
|
|
2588
|
-
};
|
|
2589
|
-
const finish = () => {
|
|
2590
|
-
clearTimeout(grace);
|
|
2591
|
-
abort?.removeEventListener("abort", on_abort);
|
|
2592
|
-
};
|
|
2593
|
-
proc.stdout.on("data", (chunk) => {
|
|
2594
|
-
stdout += chunk.toString();
|
|
2595
|
-
});
|
|
2596
|
-
proc.stderr.on("data", (chunk) => {
|
|
2597
|
-
stderr += chunk.toString();
|
|
2598
|
-
});
|
|
2599
|
-
proc.on("error", (err) => {
|
|
2600
|
-
finish();
|
|
2601
|
-
reject(check_error(`check command failed to start: ${err.message}`));
|
|
2602
|
-
});
|
|
2603
|
-
proc.on("exit", () => {
|
|
2604
|
-
kill_group(proc);
|
|
2605
|
-
grace = setTimeout(() => {
|
|
2606
|
-
proc.stdout.destroy();
|
|
2607
|
-
proc.stderr.destroy();
|
|
2608
|
-
}, OUTPUT_GRACE_MS);
|
|
2609
|
-
});
|
|
2610
|
-
proc.on("close", (code) => {
|
|
2611
|
-
finish();
|
|
2612
|
-
resolve_promise({
|
|
2613
|
-
exit_code: code,
|
|
2614
|
-
stdout,
|
|
2615
|
-
stderr
|
|
2616
|
-
});
|
|
2617
|
-
});
|
|
2618
|
-
abort?.addEventListener("abort", on_abort, { once: true });
|
|
2619
|
-
});
|
|
2620
|
-
}
|
|
2621
|
-
/** Best-effort parse of the summary contract. `pnpm exec` talks over the
|
|
2622
|
-
* command's stdout (pnpm 11's dep-verify prints an "Already up to date"
|
|
2623
|
-
* install line ahead of the JSON), so the object is extracted between the
|
|
2624
|
-
* first `{` and the last `}` rather than parsed from byte 0. An unexpected
|
|
2625
|
-
* schema_version warns and still parses; no JSON object at all is a harness
|
|
2626
|
-
* error (exit 4). */
|
|
2627
|
-
function parse_checkride_summary(stdout) {
|
|
2628
|
-
const start = stdout.indexOf("{");
|
|
2629
|
-
const end = stdout.lastIndexOf("}");
|
|
2630
|
-
let raw;
|
|
2631
|
-
try {
|
|
2632
|
-
if (start === -1 || end < start) throw new Error("no JSON object in stdout");
|
|
2633
|
-
raw = JSON.parse(stdout.slice(start, end + 1));
|
|
2634
|
-
} catch {
|
|
2635
|
-
throw check_error("checkride --json produced unparseable stdout");
|
|
2636
|
-
}
|
|
2637
|
-
if (typeof raw !== "object" || raw === null || !Array.isArray(raw.checks)) throw check_error("checkride summary missing checks array");
|
|
2638
|
-
const summary = raw;
|
|
2639
|
-
return {
|
|
2640
|
-
summary,
|
|
2641
|
-
warning: summary.schema_version === CHECKRIDE_SCHEMA_VERSION ? null : `checkride summary schema_version ${String(summary.schema_version)} != ${String(CHECKRIDE_SCHEMA_VERSION)}; parsing best-effort`
|
|
2642
|
-
};
|
|
2643
|
-
}
|
|
2644
|
-
/** Parse the summary from stdout, falling back to the on-disk contract:
|
|
2645
|
-
* `.check/summary.json` is what checkride documents; stdout is its mirror and
|
|
2646
|
-
* wrappers may talk over it beyond what the tolerant slice can recover. */
|
|
2647
|
-
function parse_summary_with_fallback(stdout, workspace) {
|
|
2648
|
-
try {
|
|
2649
|
-
return parse_checkride_summary(stdout);
|
|
2650
|
-
} catch (err) {
|
|
2651
|
-
const file = join(workspace, ".check", "summary.json");
|
|
2652
|
-
if (!existsSync(file)) throw err;
|
|
2653
|
-
return parse_checkride_summary(readFileSync(file, "utf8"));
|
|
2654
|
-
}
|
|
3162
|
+
critic_cost_usd: 0,
|
|
3163
|
+
check_duration_ms: 0,
|
|
3164
|
+
builder_max_steps_hits: 0,
|
|
3165
|
+
iteration_cost_usd: 0,
|
|
3166
|
+
halt: null,
|
|
3167
|
+
cost_warned: false
|
|
3168
|
+
};
|
|
2655
3169
|
}
|
|
2656
|
-
|
|
2657
|
-
|
|
3170
|
+
/** How many rounds the loop may still run: the whole budget on a fresh run, what
|
|
3171
|
+
* the resumed run's completed iterations left of it otherwise. */
|
|
3172
|
+
function remaining_iterations(config, resume_from) {
|
|
3173
|
+
return config.max_iterations - (resume_from?.iteration ?? 0);
|
|
2658
3174
|
}
|
|
2659
|
-
/**
|
|
2660
|
-
*
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
const path = join(workspace, ".check", file);
|
|
2665
|
-
if (!existsSync(path)) return {
|
|
2666
|
-
slot: check.name,
|
|
2667
|
-
path: null,
|
|
2668
|
-
content: "(no artifact found)",
|
|
2669
|
-
truncated: false
|
|
2670
|
-
};
|
|
2671
|
-
const raw = readFileSync(path, "utf8");
|
|
2672
|
-
const truncated = raw.length > CHECK_ARTIFACT_MAX_CHARS;
|
|
2673
|
-
return {
|
|
2674
|
-
slot: check.name,
|
|
2675
|
-
path,
|
|
2676
|
-
content: truncated ? raw.slice(0, CHECK_ARTIFACT_MAX_CHARS) : raw,
|
|
2677
|
-
truncated
|
|
2678
|
-
};
|
|
2679
|
-
});
|
|
3175
|
+
/** Did this iteration's builder edit the gate that judges it, with the operator
|
|
3176
|
+
* having asked for that to stop the run? Reported either way (the critic prompt
|
|
3177
|
+
* and the summary always carry it); only `--fail-on-gate-edit` makes it fatal. */
|
|
3178
|
+
function gate_edit_halt(config, state) {
|
|
3179
|
+
return config.fail_on_gate_edit && (state.changes?.gate_edits.length ?? 0) > 0;
|
|
2680
3180
|
}
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
const
|
|
2693
|
-
const
|
|
3181
|
+
/** All stopping conditions live here: acceptance, cost cap, the gate
|
|
3182
|
+
* edit refusal, and — implicitly via `max_rounds` — the iteration budget.
|
|
3183
|
+
* Success wins when it and the cap land on the same iteration.
|
|
3184
|
+
*
|
|
3185
|
+
* A gate edit is the one condition that *beats* success rather than losing to
|
|
3186
|
+
* it: the run passed a check the builder had rewritten, so the pass is exactly
|
|
3187
|
+
* what is in question. It stops the run instead of iterating, because a model
|
|
3188
|
+
* that just edited the gate is not a promising candidate to be asked again. */
|
|
3189
|
+
function gate(config, state) {
|
|
3190
|
+
const gate_edit = gate_edit_halt(config, state);
|
|
3191
|
+
const success = state.check?.ok === true && state.verdict === "approved" && !gate_edit;
|
|
3192
|
+
const cap = cost_cap_hit(config, state);
|
|
3193
|
+
const halt = gate_edit ? "gate_edit" : !success && cap ? "cost_cap" : null;
|
|
2694
3194
|
return {
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
failing_slots: failing.map((c) => c.name),
|
|
2701
|
-
detail: read_failing_artifacts(opts.workspace, failing),
|
|
2702
|
-
summary
|
|
3195
|
+
stop: success || cap || gate_edit,
|
|
3196
|
+
state: {
|
|
3197
|
+
...state,
|
|
3198
|
+
halt
|
|
3199
|
+
}
|
|
2703
3200
|
};
|
|
2704
3201
|
}
|
|
3202
|
+
function status_of(value, converged) {
|
|
3203
|
+
if (value.halt === "gate_edit") return "gate_edit_blocked";
|
|
3204
|
+
if (value.halt === "cost_cap") return "cost_cap_reached";
|
|
3205
|
+
return converged ? "success" : "budget_exhausted";
|
|
3206
|
+
}
|
|
2705
3207
|
//#endregion
|
|
2706
|
-
//#region src/
|
|
3208
|
+
//#region src/render/format.ts
|
|
2707
3209
|
/**
|
|
2708
|
-
*
|
|
2709
|
-
* output
|
|
2710
|
-
* (exit 4), not an iterable failure.
|
|
3210
|
+
* Display formatting: glyph taxonomy, role colors, truncation.
|
|
3211
|
+
* All human output goes to stderr; these helpers only build strings.
|
|
2711
3212
|
*/
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
ok: proc.exit_code === 0,
|
|
2725
|
-
exit_code: proc.exit_code,
|
|
2726
|
-
duration_ms: Date.now() - started,
|
|
2727
|
-
failing_slots: [],
|
|
2728
|
-
detail: [],
|
|
2729
|
-
log
|
|
2730
|
-
};
|
|
3213
|
+
const GLYPH_TOOL = "🔧";
|
|
3214
|
+
const GLYPH_COST = "💰";
|
|
3215
|
+
const VERBOSE_TRUNCATE_CHARS = 4e3;
|
|
3216
|
+
function paint(role, text, color) {
|
|
3217
|
+
if (!color) return text;
|
|
3218
|
+
switch (role) {
|
|
3219
|
+
case "builder": return pc.cyan(text);
|
|
3220
|
+
case "critic": return pc.yellow(text);
|
|
3221
|
+
case "check": return pc.green(text);
|
|
3222
|
+
case "cost": return pc.magenta(text);
|
|
3223
|
+
case "error": return pc.bold(pc.red(text));
|
|
3224
|
+
}
|
|
2731
3225
|
}
|
|
2732
|
-
function
|
|
2733
|
-
return
|
|
2734
|
-
ran: false,
|
|
2735
|
-
runner: "none",
|
|
2736
|
-
ok: reason === "none",
|
|
2737
|
-
exit_code: null,
|
|
2738
|
-
duration_ms: 0,
|
|
2739
|
-
failing_slots: [],
|
|
2740
|
-
detail: []
|
|
2741
|
-
};
|
|
3226
|
+
function dim(text, color) {
|
|
3227
|
+
return color ? pc.dim(text) : text;
|
|
2742
3228
|
}
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
3229
|
+
function truncate(text, max, pointer) {
|
|
3230
|
+
if (text.length <= max) return text;
|
|
3231
|
+
return `${text.slice(0, max)}\n… [truncated at ${String(max)} chars; ${pointer}]`;
|
|
3232
|
+
}
|
|
3233
|
+
function format_usd(value) {
|
|
3234
|
+
return value === null ? "$?" : `$${value.toFixed(3)}`;
|
|
3235
|
+
}
|
|
3236
|
+
/** One-line summary of a tool input for the default streaming mode. */
|
|
3237
|
+
function summarize_value(value, max = 120) {
|
|
3238
|
+
let text;
|
|
3239
|
+
try {
|
|
3240
|
+
text = typeof value === "string" ? value : JSON.stringify(value) ?? "";
|
|
3241
|
+
} catch {
|
|
3242
|
+
text = String(value);
|
|
3243
|
+
}
|
|
3244
|
+
const flat = text.replace(/\s+/g, " ");
|
|
3245
|
+
return flat.length > max ? `${flat.slice(0, max)}…` : flat;
|
|
2759
3246
|
}
|
|
2760
3247
|
//#endregion
|
|
2761
3248
|
//#region src/iteration.ts
|
|
@@ -2785,6 +3272,7 @@ function phase_summary(record) {
|
|
|
2785
3272
|
tool_calls: record.tool_calls,
|
|
2786
3273
|
salvaged_tool_calls: record.salvaged_tool_calls,
|
|
2787
3274
|
salvage_rate: record.tool_calls === 0 ? 0 : record.salvaged_tool_calls / record.tool_calls,
|
|
3275
|
+
...record.truncated_turns !== void 0 ? { truncated_turns: record.truncated_turns } : {},
|
|
2788
3276
|
...record.retries !== void 0 ? { retries: record.retries } : {},
|
|
2789
3277
|
...record.retry_cause_kind !== void 0 ? { retry_cause_kind: record.retry_cause_kind } : {},
|
|
2790
3278
|
...record.critic_degraded === true ? { critic_degraded: true } : {},
|
|
@@ -2807,6 +3295,8 @@ function archive_iteration(config, state) {
|
|
|
2807
3295
|
if (check.runner === "checkride") {
|
|
2808
3296
|
if (check.summary !== void 0) write_json(join(dir, "check", "summary.json"), check.summary);
|
|
2809
3297
|
for (const artifact of check.detail) if (artifact.path !== null && existsSync(artifact.path)) writeFileSync(join(dir, "check", basename(artifact.path)), readFileSync(artifact.path));
|
|
3298
|
+
if (check.digest !== void 0) writeFileSync(join(dir, "check", "digest.md"), check.digest);
|
|
3299
|
+
if (check.log !== void 0) writeFileSync(join(dir, "check", "check.log"), check.log);
|
|
2810
3300
|
} else {
|
|
2811
3301
|
writeFileSync(join(dir, "check", "check.log"), check.log ?? "");
|
|
2812
3302
|
writeFileSync(join(dir, "check", "check.exit"), `${String(check.exit_code)}\n`);
|
|
@@ -2847,8 +3337,11 @@ function run_result_from_state(config, state, status, completed_at) {
|
|
|
2847
3337
|
builder_cost_usd: state.builder_cost_usd,
|
|
2848
3338
|
critic_cost_usd: state.critic_cost_usd,
|
|
2849
3339
|
check_duration_ms: state.check_duration_ms,
|
|
3340
|
+
builder_max_steps_hits: state.builder_max_steps_hits,
|
|
2850
3341
|
final_verdict: state.verdict,
|
|
2851
|
-
salvaged_branch: null
|
|
3342
|
+
salvaged_branch: null,
|
|
3343
|
+
run_state_dir: run_state_dir(config),
|
|
3344
|
+
mode: config.mode
|
|
2852
3345
|
};
|
|
2853
3346
|
}
|
|
2854
3347
|
//#endregion
|
|
@@ -2948,7 +3441,7 @@ function build_run_summary(config, result) {
|
|
|
2948
3441
|
return {
|
|
2949
3442
|
...result,
|
|
2950
3443
|
comparison: {
|
|
2951
|
-
builder_transport: transport_of(config.builder_provider),
|
|
3444
|
+
builder_transport: config.mode === "critique" ? null : transport_of(config.builder_provider),
|
|
2952
3445
|
critic_transport: transport_of(config.critic_provider),
|
|
2953
3446
|
iterations_to_converge: result.status === "success" ? result.iterations_completed : null,
|
|
2954
3447
|
wall_clock_ms,
|
|
@@ -3004,7 +3497,7 @@ function record_check_chunk(ctx, chunk) {
|
|
|
3004
3497
|
}
|
|
3005
3498
|
/** The command line the check runs, or null when no check is configured. */
|
|
3006
3499
|
function check_command(config) {
|
|
3007
|
-
if (config.check_resolved === "checkride") return "pnpm exec checkride --json";
|
|
3500
|
+
if (config.check_resolved === "checkride") return "pnpm exec checkride --json --digest";
|
|
3008
3501
|
if (config.check_resolved === "command") return config.check;
|
|
3009
3502
|
return null;
|
|
3010
3503
|
}
|
|
@@ -3023,7 +3516,16 @@ function report_changes(config, renderer, changes) {
|
|
|
3023
3516
|
if (changes.gate_edits.length === 0) return;
|
|
3024
3517
|
const hidden = changes.gate_edits.length - LISTED_GATE_EDITS;
|
|
3025
3518
|
const listed = changes.gate_edits.slice(0, LISTED_GATE_EDITS).join(", ");
|
|
3026
|
-
|
|
3519
|
+
const [who, whose, edits] = config.mode === "critique" ? [
|
|
3520
|
+
"the change under review touches",
|
|
3521
|
+
"whether it passes",
|
|
3522
|
+
"the change edits"
|
|
3523
|
+
] : [
|
|
3524
|
+
"the builder changed",
|
|
3525
|
+
"whether its work passes",
|
|
3526
|
+
"the builder can edit"
|
|
3527
|
+
];
|
|
3528
|
+
renderer.warn(`gate edit: ${who} ${String(changes.gate_edits.length)} file(s) that decide ${whose} — ${listed}${hidden > 0 ? ` (+${String(hidden)} more)` : ""}. A green check proves less when ${edits} the gate; the critic is told about this and the run summary records it.` + (config.fail_on_gate_edit ? " This run will end without success (--fail-on-gate-edit, exit 8)." : ""));
|
|
3027
3529
|
}
|
|
3028
3530
|
/** Run the configured check, checkride or a command, with the environment
|
|
3029
3531
|
* the caller set for it, else volley's own. */
|
|
@@ -3077,6 +3579,33 @@ function next_iteration(s) {
|
|
|
3077
3579
|
unmet_criteria: []
|
|
3078
3580
|
};
|
|
3079
3581
|
}
|
|
3582
|
+
/** What the builder of the iteration after `s` hears about it beyond the
|
|
3583
|
+
* critic's feedback: its check, and whether its builder ran out of steps. Null
|
|
3584
|
+
* before the first iteration. A resumed run's state carries both, read back
|
|
3585
|
+
* from the last archived iteration. */
|
|
3586
|
+
function previous_round(config, s) {
|
|
3587
|
+
if (s.iteration === 0) return null;
|
|
3588
|
+
return {
|
|
3589
|
+
check: s.check,
|
|
3590
|
+
step_limit_hit: s.builder?.finish_reason === "max_steps" ? config.builder_max_steps : null
|
|
3591
|
+
};
|
|
3592
|
+
}
|
|
3593
|
+
/** What changed in the build root since the baseline, reported as it's
|
|
3594
|
+
* measured: the count, and a warning for any gate edit. A run measures the
|
|
3595
|
+
* builder's work this way after every build, and a critique measures the
|
|
3596
|
+
* change under review the same way, so the critic is shown one shape either
|
|
3597
|
+
* way. */
|
|
3598
|
+
function measure_changes(deps, root) {
|
|
3599
|
+
const { config, renderer } = deps;
|
|
3600
|
+
const changes = collect_changes({
|
|
3601
|
+
root,
|
|
3602
|
+
baseline: deps.baseline,
|
|
3603
|
+
gate_patterns: config.gate_paths,
|
|
3604
|
+
excluded_dir: state_root_within(root, config.state_dir)
|
|
3605
|
+
});
|
|
3606
|
+
report_changes(config, renderer, changes);
|
|
3607
|
+
return changes;
|
|
3608
|
+
}
|
|
3080
3609
|
/** `build`: one builder session edits the build root, then volley measures what
|
|
3081
3610
|
* it changed and, under `--git`, checkpoints it. */
|
|
3082
3611
|
async function build_phase(deps, builder, s, ctx) {
|
|
@@ -3091,22 +3620,32 @@ async function build_phase(deps, builder, s, ctx) {
|
|
|
3091
3620
|
builder,
|
|
3092
3621
|
config,
|
|
3093
3622
|
warn: renderer.warn
|
|
3094
|
-
}, next, ctx);
|
|
3623
|
+
}, next, previous_round(config, s), ctx);
|
|
3095
3624
|
renderer.phase_end(built.iteration, "builder", true);
|
|
3096
3625
|
renderer.cost_line(built.iteration, "builder", built.builder?.cost_usd ?? null, built.total_cost_usd);
|
|
3097
3626
|
if (built.cost_warned && !s.cost_warned) renderer.warn("cost unavailable for this phase; recording null (run totals sum what is known)");
|
|
3098
|
-
const
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
gate_patterns: config.gate_paths
|
|
3102
|
-
});
|
|
3103
|
-
report_changes(config, renderer, changes);
|
|
3104
|
-
if (config.git_checkpoints) git_checkpoint(build_root(config.workspace, config.worktree), `volley iter ${String(built.iteration)}: build`);
|
|
3627
|
+
const root = build_root(config.workspace, config.worktree);
|
|
3628
|
+
const changes = measure_changes(deps, root);
|
|
3629
|
+
if (config.git_checkpoints) git_checkpoint(root, `volley iter ${String(built.iteration)}: build`, state_root_within(root, config.state_dir));
|
|
3105
3630
|
return {
|
|
3106
3631
|
...built,
|
|
3107
3632
|
changes
|
|
3108
3633
|
};
|
|
3109
3634
|
}
|
|
3635
|
+
/** `measure`: a critique's one round opens, and the change under review is
|
|
3636
|
+
* measured from the baseline, as a run measures the builder's work. */
|
|
3637
|
+
function measure_phase(deps, s, ctx) {
|
|
3638
|
+
const next = next_iteration(s);
|
|
3639
|
+
emit(ctx, {
|
|
3640
|
+
volley: "round_start",
|
|
3641
|
+
round: next.iteration
|
|
3642
|
+
});
|
|
3643
|
+
const root = build_root(deps.config.workspace, deps.config.worktree);
|
|
3644
|
+
return {
|
|
3645
|
+
...next,
|
|
3646
|
+
changes: measure_changes(deps, root)
|
|
3647
|
+
};
|
|
3648
|
+
}
|
|
3110
3649
|
/** `skip_verify`: the build alone crossed the cost cap, so the check is recorded
|
|
3111
3650
|
* as skipped and no critic is called — a doomed iteration spends nothing more.
|
|
3112
3651
|
* The loop guard then halts the run on the cap. */
|
|
@@ -3220,7 +3759,10 @@ function record_phase(deps, s) {
|
|
|
3220
3759
|
const { config } = deps;
|
|
3221
3760
|
archive_iteration(config, s);
|
|
3222
3761
|
write_run_summary(config, run_result_from_state(config, s, "running", null));
|
|
3223
|
-
if (config.git_checkpoints)
|
|
3762
|
+
if (config.git_checkpoints) {
|
|
3763
|
+
const root = build_root(config.workspace, config.worktree);
|
|
3764
|
+
git_checkpoint(root, `volley iter ${String(s.iteration)}: critique (${s.verdict ?? "skipped"})`, state_root_within(root, config.state_dir));
|
|
3765
|
+
}
|
|
3224
3766
|
return s;
|
|
3225
3767
|
}
|
|
3226
3768
|
//#endregion
|
|
@@ -3232,6 +3774,11 @@ function record_phase(deps, s) {
|
|
|
3232
3774
|
* around the flow (workspace setup, the worktree lifecycle, `run`, the final
|
|
3233
3775
|
* summary) in `./orchestrator.ts`.
|
|
3234
3776
|
*
|
|
3777
|
+
* A critique (`build_critique_flow`) is one round over a change already made,
|
|
3778
|
+
* so it has no builder, no loop, and no guard: `measure`, which opens the
|
|
3779
|
+
* round and measures the change from its baseline, then the same `check`,
|
|
3780
|
+
* `critique`, and `record` a run's round ends with.
|
|
3781
|
+
*
|
|
3235
3782
|
* fascicle's `describe.diagram` draws the tree below from the flow itself, and
|
|
3236
3783
|
* `test/unit/flow_diagram.test.ts` holds it to the code: change a row by changing
|
|
3237
3784
|
* its step's `description`, then paste `pnpm diagram --prefix ' * '` over it.
|
|
@@ -3275,6 +3822,24 @@ function build_critic(deps) {
|
|
|
3275
3822
|
project: with_fallback_cause
|
|
3276
3823
|
});
|
|
3277
3824
|
}
|
|
3825
|
+
/** The steps a run and a critique share, built one way for both. */
|
|
3826
|
+
function check_step(deps) {
|
|
3827
|
+
return step("check", (s, ctx) => check_phase(deps, s, ctx), { description: "the deterministic gate: checkride, a command, or none" });
|
|
3828
|
+
}
|
|
3829
|
+
function critique_step(deps, seat) {
|
|
3830
|
+
const critic = build_critic({
|
|
3831
|
+
...seat,
|
|
3832
|
+
config: deps.config,
|
|
3833
|
+
on_chunk: deps.renderer.critic_chunk
|
|
3834
|
+
});
|
|
3835
|
+
return step("critique", (s, ctx) => critique_phase(deps, critic, s, ctx), {
|
|
3836
|
+
description: "the read-only critic's verdict and feedback",
|
|
3837
|
+
arm: critic
|
|
3838
|
+
});
|
|
3839
|
+
}
|
|
3840
|
+
function record_step(deps) {
|
|
3841
|
+
return step("record", (s) => record_phase(deps, s), { description: "archive the iteration and rewrite the run summary" });
|
|
3842
|
+
}
|
|
3278
3843
|
function build_flow(deps, resume_from) {
|
|
3279
3844
|
const { config, renderer } = deps;
|
|
3280
3845
|
const builder = make_builder_step({
|
|
@@ -3283,22 +3848,14 @@ function build_flow(deps, resume_from) {
|
|
|
3283
3848
|
on_chunk: renderer.builder_chunk,
|
|
3284
3849
|
bash_executor: deps.bash_executor
|
|
3285
3850
|
});
|
|
3286
|
-
const critic = build_critic({
|
|
3287
|
-
...deps.seats.critic,
|
|
3288
|
-
config,
|
|
3289
|
-
on_chunk: renderer.critic_chunk
|
|
3290
|
-
});
|
|
3291
3851
|
const build = step("build", (s, ctx) => build_phase(deps, builder, s, ctx), {
|
|
3292
3852
|
description: "open the iteration, run the builder, measure the change",
|
|
3293
3853
|
arm: builder
|
|
3294
3854
|
});
|
|
3295
3855
|
const skip_verify = step("skip_verify", (s) => skip_verify_phase(deps, s), { description: "record the check as skipped and call no critic" });
|
|
3296
|
-
const check =
|
|
3297
|
-
const critique =
|
|
3298
|
-
|
|
3299
|
-
arm: critic
|
|
3300
|
-
});
|
|
3301
|
-
const record = step("record", (s) => record_phase(deps, s), { description: "archive the iteration and rewrite the run summary" });
|
|
3856
|
+
const check = check_step(deps);
|
|
3857
|
+
const critique = critique_step(deps, deps.seats.critic);
|
|
3858
|
+
const record = record_step(deps);
|
|
3302
3859
|
const verify = branch({
|
|
3303
3860
|
name: "verify",
|
|
3304
3861
|
description: "did the build alone cross the cost cap?",
|
|
@@ -3324,6 +3881,19 @@ function build_flow(deps, resume_from) {
|
|
|
3324
3881
|
max_rounds: remaining_iterations(config, resume_from)
|
|
3325
3882
|
});
|
|
3326
3883
|
}
|
|
3884
|
+
/** A critique: one round over a change already made. `measure` opens it and
|
|
3885
|
+
* measures the change from the baseline, then the check, the critic, and the
|
|
3886
|
+
* record, as in a run's round. No loop and no guard: there is nothing to
|
|
3887
|
+
* iterate on, and the caller reads the outcome off the final state. */
|
|
3888
|
+
function build_critique_flow(deps) {
|
|
3889
|
+
const measure = step("measure", (s, ctx) => measure_phase(deps, s, ctx), { description: "open the round and measure the change under review" });
|
|
3890
|
+
return sequence([
|
|
3891
|
+
measure,
|
|
3892
|
+
check_step(deps),
|
|
3893
|
+
critique_step(deps, deps.critic),
|
|
3894
|
+
record_step(deps)
|
|
3895
|
+
], { name: "critique_run" });
|
|
3896
|
+
}
|
|
3327
3897
|
//#endregion
|
|
3328
3898
|
//#region src/orchestrator.ts
|
|
3329
3899
|
/**
|
|
@@ -3340,13 +3910,19 @@ function build_flow(deps, resume_from) {
|
|
|
3340
3910
|
* │ ├─ run_loop run the flow (streamed when a sink is given), then the final summary
|
|
3341
3911
|
* │ └─ integrate a successful --worktree --git run only
|
|
3342
3912
|
* └─ report a kept checkout, or a salvaged branch (re-stamping the summary)
|
|
3913
|
+
*
|
|
3914
|
+
* run_critique
|
|
3915
|
+
* ├─ resolve the baseline --baseline, else HEAD; a bad revision exits 5
|
|
3916
|
+
* ├─ initialize the run's state
|
|
3917
|
+
* └─ run the critique flow one round on the critic's engine alone, then the final summary
|
|
3343
3918
|
*/
|
|
3344
3919
|
async function run_volley(config, deps, resume_from = null) {
|
|
3345
3920
|
const { renderer } = deps;
|
|
3346
3921
|
if (resume_from === null) {
|
|
3347
3922
|
initialize_run_state(config);
|
|
3348
|
-
write_resolved_config(config);
|
|
3923
|
+
if (!write_resolved_config(config) && !detect_containment(process.env)) renderer.warn(`could not sign this run's config with this machine's key (${trust_key_path(process.env)}), so resuming it here will need --trust-config`);
|
|
3349
3924
|
} else initialize_run_state(config, { preserve: true });
|
|
3925
|
+
renderer.info(`state: ${run_state_dir(config)}`);
|
|
3350
3926
|
report_worktree_fate(config, renderer);
|
|
3351
3927
|
const branch = worktree_branch(config.run_id);
|
|
3352
3928
|
const fate = worktree_fate(config);
|
|
@@ -3462,59 +4038,65 @@ function finalize_failed_run(config, err) {
|
|
|
3462
4038
|
const status = kind === "aborted_error" || cause === "aborted_error" ? "interrupted" : "error";
|
|
3463
4039
|
try {
|
|
3464
4040
|
const summary_path = state_path(config, "summary.json");
|
|
4041
|
+
const prior = existsSync(summary_path) ? JSON.parse(readFileSync(summary_path, "utf8")) : run_result_from_state(config, initial_state(), status, null);
|
|
3465
4042
|
write_run_summary(config, {
|
|
3466
|
-
...
|
|
4043
|
+
...prior,
|
|
4044
|
+
builder_max_steps_hits: prior.builder_max_steps_hits ?? 0,
|
|
4045
|
+
run_state_dir: run_state_dir(config),
|
|
3467
4046
|
status,
|
|
3468
4047
|
completed_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
3469
4048
|
});
|
|
3470
4049
|
} catch {}
|
|
3471
4050
|
}
|
|
3472
|
-
//#endregion
|
|
3473
|
-
//#region src/render/format.ts
|
|
3474
4051
|
/**
|
|
3475
|
-
*
|
|
3476
|
-
*
|
|
4052
|
+
* `volley critique`: the check and the critic, once, over a change already
|
|
4053
|
+
* made. No builder is constructed, and the engine serves the critic's seat
|
|
4054
|
+
* alone, so a critic can be measured on fixed, labeled changes without paying
|
|
4055
|
+
* for builder turns. It writes the state a one-round run does, with the mode
|
|
4056
|
+
* recorded, and ends `success` on approval over a green check, else
|
|
4057
|
+
* `rejected`.
|
|
3477
4058
|
*/
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
const
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
}
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
}
|
|
3501
|
-
/** One-line summary of a tool input for the default streaming mode. */
|
|
3502
|
-
function summarize_value(value, max = 120) {
|
|
3503
|
-
let text;
|
|
4059
|
+
async function run_critique(config, deps) {
|
|
4060
|
+
if (config.mode !== "critique") throw config_error("run_critique needs a config resolved with mode 'critique'");
|
|
4061
|
+
const { renderer } = deps;
|
|
4062
|
+
const baseline = resolve_baseline(config.workspace, deps.baseline);
|
|
4063
|
+
initialize_run_state(config);
|
|
4064
|
+
write_resolved_config(config);
|
|
4065
|
+
renderer.info(`state: ${run_state_dir(config)}`);
|
|
4066
|
+
renderer.info(baseline === null ? "baseline: none (not a git repository, or no commit yet); the critic reviews the tree" : `baseline: ${baseline}`);
|
|
4067
|
+
const engine = deps.engine ?? create_volley_engine({
|
|
4068
|
+
workspace: config.workspace,
|
|
4069
|
+
critic_provider: config.critic_provider,
|
|
4070
|
+
critic_url: config.critic_url
|
|
4071
|
+
});
|
|
4072
|
+
const flow = build_critique_flow({
|
|
4073
|
+
config,
|
|
4074
|
+
renderer,
|
|
4075
|
+
baseline,
|
|
4076
|
+
critic: {
|
|
4077
|
+
...deps.critic,
|
|
4078
|
+
engine
|
|
4079
|
+
},
|
|
4080
|
+
...deps.check !== void 0 ? { check: deps.check } : {}
|
|
4081
|
+
});
|
|
3504
4082
|
try {
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
4083
|
+
const value = await execute_flow(config, deps, flow, initial_state());
|
|
4084
|
+
const result = run_result_from_state(config, value, value.check?.ok === true && value.verdict === "approved" ? "success" : "rejected", (/* @__PURE__ */ new Date()).toISOString());
|
|
4085
|
+
write_run_summary(config, result);
|
|
4086
|
+
return result;
|
|
4087
|
+
} catch (err) {
|
|
4088
|
+
finalize_failed_run(config, err);
|
|
4089
|
+
throw err;
|
|
4090
|
+
} finally {
|
|
4091
|
+
await engine.dispose();
|
|
3508
4092
|
}
|
|
3509
|
-
const flat = text.replace(/\s+/g, " ");
|
|
3510
|
-
return flat.length > max ? `${flat.slice(0, max)}…` : flat;
|
|
3511
4093
|
}
|
|
3512
4094
|
//#endregion
|
|
3513
4095
|
//#region src/render/renderer.ts
|
|
3514
4096
|
function create_renderer(options) {
|
|
3515
4097
|
const write = options.write ?? ((text) => process.stderr.write(text));
|
|
3516
4098
|
const { mode, color } = options;
|
|
3517
|
-
const see_trajectory = `see ${options.trajectory_path ?? "
|
|
4099
|
+
const see_trajectory = `see ${options.trajectory_path ?? "the run's trajectory.jsonl"}`;
|
|
3518
4100
|
const silent = mode === "json";
|
|
3519
4101
|
const streaming = mode === "default" || mode === "verbose";
|
|
3520
4102
|
let mid_line = false;
|
|
@@ -3592,6 +4174,7 @@ function create_renderer(options) {
|
|
|
3592
4174
|
line(`iterations: ${String(result.iterations_completed)} | verdict: ${result.final_verdict ?? "n/a"}`);
|
|
3593
4175
|
line(paint("cost", `${GLYPH_COST} total: ${format_usd(result.total_cost_usd)} (builder ${format_usd(result.builder_cost_usd)}, critic ${format_usd(result.critic_cost_usd)})`, color));
|
|
3594
4176
|
line(`tokens: ${String(result.total_usage.input_tokens)} in / ${String(result.total_usage.output_tokens)} out`);
|
|
4177
|
+
line(`state: ${result.run_state_dir}`);
|
|
3595
4178
|
}
|
|
3596
4179
|
};
|
|
3597
4180
|
}
|
|
@@ -3613,7 +4196,7 @@ function summarize_output(output) {
|
|
|
3613
4196
|
* volley detects it is already contained. The create/`exec`/reap/teardown
|
|
3614
4197
|
* lifecycle and the `docker exec` bash executor were retired; what
|
|
3615
4198
|
* remains here is the *spec* — pure, daemon-free flag-builders the blessed
|
|
3616
|
-
* examples and
|
|
4199
|
+
* examples and a parent harness render into the `docker run` line,
|
|
3617
4200
|
* unit-testable without a daemon.
|
|
3618
4201
|
*
|
|
3619
4202
|
* Inside that container volley's `bash` is the local `host_bash_executor`
|
|
@@ -3638,6 +4221,13 @@ const SANDBOX_WORKDIR = "/workspace";
|
|
|
3638
4221
|
* otherwise unset) and its `.cache` is a tmpfs so the read-only rootfs does not
|
|
3639
4222
|
* brick tools that scribble there. */
|
|
3640
4223
|
const SANDBOX_HOME = "/home/node";
|
|
4224
|
+
/** Where volley inside the container keeps each run's state when no
|
|
4225
|
+
* `--state-dir` names a root: the default root under the pinned `HOME`, since
|
|
4226
|
+
* the image sets no `XDG_STATE_HOME`. `sandbox_run_args`'s `run_state` binds one
|
|
4227
|
+
* run's host directory at that run's directory under it, so the state outlives
|
|
4228
|
+
* the `--rm` container. Exported so a harness can map a contained run's
|
|
4229
|
+
* `run_state_dir` back to the directory it mounted. */
|
|
4230
|
+
const SANDBOX_STATE_ROOT = default_state_root({}, SANDBOX_HOME);
|
|
3641
4231
|
/** The host spelling of the Docker gateway (Linux-portable; Docker Desktop
|
|
3642
4232
|
* already provides it). It is both the `--add-host` target for the allowlist
|
|
3643
4233
|
* bridge and the value the example injects as `VOLLEY_MODEL_HOST` so the
|
|
@@ -3669,7 +4259,7 @@ function network_run_args(network, network_name) {
|
|
|
3669
4259
|
/**
|
|
3670
4260
|
* The hardened `docker run` argv that launches volley inside its container.
|
|
3671
4261
|
* Pure and exported so the flag set is unit-testable without a
|
|
3672
|
-
* daemon and the blessed examples
|
|
4262
|
+
* daemon and the blessed examples and a parent harness can render it.
|
|
3673
4263
|
*
|
|
3674
4264
|
* A one-shot, foreground `--rm` container — the whole run *is* this
|
|
3675
4265
|
* container (an earlier design ran a detached `-d … sleep infinity` daemon volley
|
|
@@ -3681,6 +4271,25 @@ function network_run_args(network, network_name) {
|
|
|
3681
4271
|
* the image entrypoint (the opt-in real-docker isolation test runs a raw `sh`
|
|
3682
4272
|
* this way).
|
|
3683
4273
|
*
|
|
4274
|
+
* `run_state` binds the run's own state directory on the host,
|
|
4275
|
+
* `<root>/<run_id>/`, at `<SANDBOX_STATE_ROOT>/<run_id>/`, where volley inside
|
|
4276
|
+
* keeps that run's state when no `--state-dir` names a root, so it outlives the
|
|
4277
|
+
* container at the path a run on the host would use. Only the one directory
|
|
4278
|
+
* is mounted, never the root: the builder inside runs arbitrary commands, so
|
|
4279
|
+
* it can rewrite its own run's state but can't read another run's trajectory
|
|
4280
|
+
* or plant a check in another run's config. Its own run's state is still the
|
|
4281
|
+
* builder's to rewrite, config.json included, so a host-side resume refuses
|
|
4282
|
+
* that config, which volley signs only outside the sandbox (`./trust.ts`), and
|
|
4283
|
+
* a directory holding a symlink: resume a contained run inside the container,
|
|
4284
|
+
* with the same mount. Create the
|
|
4285
|
+
* directory on the host first, empty (Docker on Linux makes a missing bind
|
|
4286
|
+
* source owned by root, which the container's user can't write), and pass the
|
|
4287
|
+
* same id to volley as `--run-id`. Keep it outside `build_root`, so the check
|
|
4288
|
+
* never reads it, and pass no `XDG_STATE_HOME` in `env`, which would move the
|
|
4289
|
+
* root off the mount. Omit it and no state is mounted: a contained run keeping
|
|
4290
|
+
* its state under the default root then stops before spending (exit 5),
|
|
4291
|
+
* since the read-only root filesystem leaves it nowhere to write.
|
|
4292
|
+
*
|
|
3684
4293
|
* `store_volume` mounts the named volume as the pnpm store; omit it and no store
|
|
3685
4294
|
* is mounted. A store is shared by every run that mounts it, so a caller that
|
|
3686
4295
|
* installs dependencies in a separate container first can keep the builder's
|
|
@@ -3690,7 +4299,7 @@ function network_run_args(network, network_name) {
|
|
|
3690
4299
|
* Never emits `--privileged` and refuses `--user 0`: volley must not hand
|
|
3691
4300
|
* the sandbox root or full capabilities. Everything writable under the
|
|
3692
4301
|
* `--read-only` rootfs is an explicit tmpfs or volume; the bind-mounted worktree
|
|
3693
|
-
* is the
|
|
4302
|
+
* and, when one is named, the state root are the host-visible write paths.
|
|
3694
4303
|
*/
|
|
3695
4304
|
function sandbox_run_args(options) {
|
|
3696
4305
|
if (options.uid === 0) throw config_error("refusing to start the builder sandbox as root (uid 0)");
|
|
@@ -3699,6 +4308,7 @@ function sandbox_run_args(options) {
|
|
|
3699
4308
|
const entrypoint = options.entrypoint !== void 0 && options.entrypoint !== null ? ["--entrypoint", options.entrypoint] : [];
|
|
3700
4309
|
const env = (options.env ?? []).flatMap(([key, value]) => ["-e", `${key}=${value}`]);
|
|
3701
4310
|
const labels = (options.labels ?? []).flatMap(([key, value]) => ["--label", `${key}=${value}`]);
|
|
4311
|
+
const state = options.run_state !== void 0 && options.run_state !== null ? ["-v", run_state_mount(options.run_state)] : [];
|
|
3702
4312
|
const store = options.store_volume !== void 0 && options.store_volume !== null ? ["-v", `${options.store_volume}:${SANDBOX_HOME}/.local/share/pnpm/store`] : [];
|
|
3703
4313
|
return [
|
|
3704
4314
|
"run",
|
|
@@ -3732,6 +4342,7 @@ function sandbox_run_args(options) {
|
|
|
3732
4342
|
...env,
|
|
3733
4343
|
"-v",
|
|
3734
4344
|
`${resolve(options.build_root)}:${SANDBOX_WORKDIR}`,
|
|
4345
|
+
...state,
|
|
3735
4346
|
...store,
|
|
3736
4347
|
"-w",
|
|
3737
4348
|
SANDBOX_WORKDIR,
|
|
@@ -3740,6 +4351,13 @@ function sandbox_run_args(options) {
|
|
|
3740
4351
|
...options.command ?? []
|
|
3741
4352
|
];
|
|
3742
4353
|
}
|
|
4354
|
+
/** The `-v` value for one run's state directory. The run id becomes a path on
|
|
4355
|
+
* both sides, so it is held to volley's own `--run-id` rule, which keeps it to
|
|
4356
|
+
* one directory name. */
|
|
4357
|
+
function run_state_mount(run_state) {
|
|
4358
|
+
if (!RUN_ID_PATTERN.test(run_state.run_id)) throw config_error(`run_state.run_id must be letters, digits, '-', and '_', starting with a letter or digit; got: ${run_state.run_id}`);
|
|
4359
|
+
return `${join(resolve(run_state.root), run_state.run_id)}:${SANDBOX_STATE_ROOT}/${run_state.run_id}`;
|
|
4360
|
+
}
|
|
3743
4361
|
//#endregion
|
|
3744
4362
|
//#region src/exit_codes.ts
|
|
3745
4363
|
/**
|
|
@@ -3757,7 +4375,8 @@ const EXIT_INTERRUPTED = 130;
|
|
|
3757
4375
|
function exit_code_for_status(status) {
|
|
3758
4376
|
switch (status) {
|
|
3759
4377
|
case "success": return 0;
|
|
3760
|
-
case "budget_exhausted":
|
|
4378
|
+
case "budget_exhausted":
|
|
4379
|
+
case "rejected": return 2;
|
|
3761
4380
|
case "cost_cap_reached": return 7;
|
|
3762
4381
|
case "gate_edit_blocked": return 8;
|
|
3763
4382
|
case "interrupted": return 130;
|
|
@@ -3792,6 +4411,6 @@ function exit_code_for_error(err) {
|
|
|
3792
4411
|
return 3;
|
|
3793
4412
|
}
|
|
3794
4413
|
//#endregion
|
|
3795
|
-
export { EXIT_BUDGET_EXHAUSTED, EXIT_BUILDER_ERROR, EXIT_CHECK_ERROR, EXIT_CONFIG_ERROR, EXIT_COST_CAP, EXIT_CRITIC_ERROR, EXIT_INTERRUPTED, EXIT_SUCCESS, SANDBOX_NETWORK_NAME, SANDBOX_NETWORK_SUBNET, create_renderer, exit_code_for_error, exit_code_for_status, expand_at_file, gate, initial_state, load_config_file, network_run_args, resolve_config, run_volley, sandbox_run_args, status_of, verdict_schema };
|
|
4414
|
+
export { EXIT_BUDGET_EXHAUSTED, EXIT_BUILDER_ERROR, EXIT_CHECK_ERROR, EXIT_CONFIG_ERROR, EXIT_COST_CAP, EXIT_CRITIC_ERROR, EXIT_INTERRUPTED, EXIT_SUCCESS, SANDBOX_NETWORK_NAME, SANDBOX_NETWORK_SUBNET, SANDBOX_STATE_ROOT, create_renderer, default_state_root, exit_code_for_error, exit_code_for_status, expand_at_file, gate, initial_state, load_config_file, network_run_args, resolve_config, run_critique, run_volley, sandbox_run_args, status_of, verdict_schema };
|
|
3796
4415
|
|
|
3797
4416
|
//# sourceMappingURL=index.js.map
|