@robmclarty/volley 0.8.0 → 0.9.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 +22 -0
- package/README.md +145 -37
- package/dist/cli.js +720 -185
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +109 -12
- package/dist/index.js +692 -272
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/builder/presets/harness_append_local.md +2 -1
- 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,6 +561,264 @@ 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
|
+
critic_model: config.critic_model,
|
|
726
|
+
critic_provider: config.critic_provider,
|
|
727
|
+
critic_url: config.critic_url,
|
|
728
|
+
builder_permission_mode: config.builder_permission_mode,
|
|
729
|
+
critic_preset: config.critic_preset,
|
|
730
|
+
critic_prompt_path: config.critic_prompt_path,
|
|
731
|
+
max_iterations: config.max_iterations,
|
|
732
|
+
max_cost_usd: config.max_cost_usd,
|
|
733
|
+
git_checkpoints: config.git_checkpoints,
|
|
734
|
+
worktree: config.worktree,
|
|
735
|
+
discard_worktree: config.discard_worktree,
|
|
736
|
+
gate_paths: config.gate_paths,
|
|
737
|
+
fail_on_gate_edit: config.fail_on_gate_edit,
|
|
738
|
+
sandbox_image: config.sandbox_image,
|
|
739
|
+
state_dir: config.state_dir,
|
|
740
|
+
workspace: config.workspace
|
|
741
|
+
};
|
|
742
|
+
const text = `${JSON.stringify(record, null, 2)}\n`;
|
|
743
|
+
writeFileSync(state_path(config, "config.json"), text);
|
|
744
|
+
const signature = sign_config(text, env);
|
|
745
|
+
if (signature === null) {
|
|
746
|
+
rmSync(state_path(config, CONFIG_SIGNATURE), { force: true });
|
|
747
|
+
return false;
|
|
748
|
+
}
|
|
749
|
+
writeFileSync(state_path(config, CONFIG_SIGNATURE), `${signature}\n`);
|
|
750
|
+
return true;
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* `--git`: commit everything under `root` after a phase (`git -C <root>`, run
|
|
754
|
+
* here via the spawn `cwd`). `root` is the build root (`build_root`): the
|
|
755
|
+
* workspace on the default path, or the per-run worktree under `--worktree`, so
|
|
756
|
+
* checkpoints land on the worktree branch rather than the workspace's.
|
|
757
|
+
* A checkpoint with nothing to commit is fine; a missing git binary or repo is
|
|
758
|
+
* not. `excluded_dir` (from `state_root_within`) keeps a state root inside the
|
|
759
|
+
* tree out of the commit; `:/` keeps the rest of the staging repo-wide, as a
|
|
760
|
+
* bare `git add -A` is, and `literal` reads the path as written, wildcards and
|
|
761
|
+
* all.
|
|
762
|
+
*/
|
|
763
|
+
function git_checkpoint(root, message, excluded_dir = null) {
|
|
764
|
+
if (!existsSync(join(root, ".git"))) throw config_error(`git checkpoints require a git repository: ${root}`);
|
|
765
|
+
const pathspec = excluded_dir === null ? [] : [
|
|
766
|
+
"--",
|
|
767
|
+
":/",
|
|
768
|
+
`:(exclude,literal)${excluded_dir}`
|
|
769
|
+
];
|
|
770
|
+
const add = spawnSync("git", [
|
|
771
|
+
"add",
|
|
772
|
+
"-A",
|
|
773
|
+
...pathspec
|
|
774
|
+
], { cwd: root });
|
|
775
|
+
if (add.error !== void 0 || add.status !== 0) throw check_error(`git add failed: ${add.stderr?.toString() ?? String(add.error)}`);
|
|
776
|
+
const commit = spawnSync("git", [
|
|
777
|
+
"commit",
|
|
778
|
+
"-m",
|
|
779
|
+
message,
|
|
780
|
+
"--no-verify"
|
|
781
|
+
], { cwd: root });
|
|
782
|
+
if (commit.error !== void 0) throw check_error(`git commit failed to start: ${String(commit.error)}`);
|
|
783
|
+
const out = `${commit.stdout?.toString() ?? ""}${commit.stderr?.toString() ?? ""}`;
|
|
784
|
+
if (commit.status !== 0 && !/nothing (?:added )?to commit/.test(out)) throw check_error(`git commit failed: ${out.trim()}`);
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* Integration — bring a successful `--worktree --git` run's effects onto the
|
|
788
|
+
* workspace branch. The per-phase checkpoints on `branch` are the raw
|
|
789
|
+
* audit/replay trail; here they collapse into a single squash commit
|
|
790
|
+
* (`git merge --squash` then `commit`) on the workspace's current branch. The
|
|
791
|
+
* shared object store makes this a local, fetch-free merge, and — because the
|
|
792
|
+
* workspace branch never moved during the run (checkpoints went to the phase
|
|
793
|
+
* branch) — it is conflict-free by construction. A run whose phase branch has no
|
|
794
|
+
* new commits integrates to a no-op. Only successful runs integrate; an
|
|
795
|
+
* abandoned run's branch is discarded wholesale by the teardown trio instead.
|
|
796
|
+
*/
|
|
797
|
+
function integrate_worktree(workspace, branch, message) {
|
|
798
|
+
if (!existsSync(join(workspace, ".git"))) throw config_error(`--worktree integration requires a git repository: ${workspace}`);
|
|
799
|
+
const squash = spawnSync("git", [
|
|
800
|
+
"merge",
|
|
801
|
+
"--squash",
|
|
802
|
+
branch
|
|
803
|
+
], {
|
|
804
|
+
cwd: workspace,
|
|
805
|
+
encoding: "utf8"
|
|
806
|
+
});
|
|
807
|
+
if (squash.error !== void 0) throw check_error(`git merge --squash failed to start: ${String(squash.error)}`);
|
|
808
|
+
if (squash.status !== 0) throw check_error(`git merge --squash ${branch} failed: ${(squash.stderr || squash.stdout).trim()}`);
|
|
809
|
+
const commit = spawnSync("git", [
|
|
810
|
+
"commit",
|
|
811
|
+
"-m",
|
|
812
|
+
message,
|
|
813
|
+
"--no-verify"
|
|
814
|
+
], {
|
|
815
|
+
cwd: workspace,
|
|
816
|
+
encoding: "utf8"
|
|
817
|
+
});
|
|
818
|
+
if (commit.error !== void 0) throw check_error(`git commit failed to start: ${String(commit.error)}`);
|
|
819
|
+
const out = `${commit.stdout ?? ""}${commit.stderr ?? ""}`;
|
|
820
|
+
if (commit.status !== 0 && !out.includes("nothing to commit")) throw check_error(`git commit failed: ${out.trim()}`);
|
|
821
|
+
}
|
|
519
822
|
const DEFAULT_CRITIC_MODEL = "opus";
|
|
520
823
|
const DEFAULT_CRITIC = "reviewer";
|
|
521
824
|
const DEFAULT_BUILDER_PROVIDER = "claude_cli";
|
|
@@ -598,17 +901,49 @@ function resolve_critic(critic, cwd) {
|
|
|
598
901
|
critic_prompt_path: path
|
|
599
902
|
};
|
|
600
903
|
}
|
|
601
|
-
/**
|
|
602
|
-
*
|
|
603
|
-
*
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
904
|
+
/** What only a builder, or the loop around it, uses, with the flag that sets
|
|
905
|
+
* each: a critique has no use for any of them, so naming one is an error
|
|
906
|
+
* rather than something quietly ignored. */
|
|
907
|
+
const CRITIQUE_REFUSED = [
|
|
908
|
+
["builder_model", "--builder-model"],
|
|
909
|
+
["builder_provider", "--builder-provider"],
|
|
910
|
+
["builder_url", "--builder-url"],
|
|
911
|
+
["builder_max_steps", "--builder-max-steps"],
|
|
912
|
+
["builder_max_tool_calls", "--builder-max-tool-calls"],
|
|
913
|
+
["builder_permission_mode", "--builder-permission-mode"],
|
|
914
|
+
["allow_unsandboxed_builder", "--allow-unsandboxed-builder"],
|
|
915
|
+
["sandbox_image", "--sandbox-image"],
|
|
916
|
+
["max_iterations", "--max-iterations"],
|
|
917
|
+
["max_cost_usd", "--max-cost-usd"],
|
|
918
|
+
["git_checkpoints", "--git"],
|
|
919
|
+
["worktree", "--worktree"],
|
|
920
|
+
["discard_worktree", "--discard-worktree"],
|
|
921
|
+
["fail_on_gate_edit", "--fail-on-gate-edit"]
|
|
922
|
+
];
|
|
923
|
+
function refuse_builder_settings(raw) {
|
|
924
|
+
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`);
|
|
925
|
+
}
|
|
926
|
+
const BUILDER_MAX_STEPS = {
|
|
927
|
+
flag: "--builder-max-steps",
|
|
928
|
+
env_var: "VOLLEY_BUILDER_MAX_STEPS",
|
|
929
|
+
fallback: 50
|
|
930
|
+
};
|
|
931
|
+
const BUILDER_MAX_TOOL_CALLS = {
|
|
932
|
+
flag: "--builder-max-tool-calls",
|
|
933
|
+
env_var: "VOLLEY_BUILDER_MAX_TOOL_CALLS",
|
|
934
|
+
fallback: 1
|
|
935
|
+
};
|
|
936
|
+
/** A builder count's flag or config value wins over its env var, which wins
|
|
937
|
+
* over the default, and either must be a positive integer. Local builder
|
|
938
|
+
* only; ignored by the `claude_cli` arm. */
|
|
939
|
+
function resolve_builder_count(value, env, count) {
|
|
940
|
+
if (value !== void 0) return positive_count(value, value, count);
|
|
941
|
+
const env_value = env[count.env_var];
|
|
942
|
+
if (env_value !== void 0 && env_value !== "") return positive_count(Number(env_value), env_value, count);
|
|
943
|
+
return count.fallback;
|
|
944
|
+
}
|
|
945
|
+
function positive_count(value, original, count) {
|
|
946
|
+
if (!Number.isInteger(value) || value < 1) throw config_error(`${count.flag} / ${count.env_var} must be a positive integer; got: ${String(original)}`);
|
|
612
947
|
return value;
|
|
613
948
|
}
|
|
614
949
|
/** `--sandbox-image` (a merged flag/config value) wins over the
|
|
@@ -658,25 +993,36 @@ function resolve_seat_url(seat, value, provider) {
|
|
|
658
993
|
}
|
|
659
994
|
/** `--state-dir` (`state_dir`): the root each run's state sits under at
|
|
660
995
|
* `<root>/<run_id>/`, resolved against `cwd` so a resumed run finds the same
|
|
661
|
-
* place, or
|
|
662
|
-
*
|
|
663
|
-
|
|
664
|
-
|
|
996
|
+
* place. Unset, or empty as a seat URL may be, it's the default root,
|
|
997
|
+
* `$XDG_STATE_HOME/volley`, else `~/.local/state/volley`: outside the
|
|
998
|
+
* workspace, where no check that reads every file can trip over the run's own
|
|
999
|
+
* feedback. */
|
|
1000
|
+
function resolve_state_dir(value, cwd, env) {
|
|
1001
|
+
if (value === void 0 || value === null) return default_state_root(env);
|
|
665
1002
|
if (typeof value !== "string") throw config_error(`--state-dir (state_dir) must be a path string; got a ${typeof value}`);
|
|
666
1003
|
const path = value.trim();
|
|
667
|
-
return path === "" ?
|
|
1004
|
+
return path === "" ? default_state_root(env) : resolve(cwd, path);
|
|
668
1005
|
}
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
1006
|
+
function is_writable_dir(path) {
|
|
1007
|
+
try {
|
|
1008
|
+
if (!statSync(path).isDirectory()) return false;
|
|
1009
|
+
accessSync(path, constants.W_OK);
|
|
1010
|
+
return true;
|
|
1011
|
+
} catch {
|
|
1012
|
+
return false;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
/** The run's state directory must be one volley can write: the directory
|
|
1016
|
+
* itself when it's there, as a contained run's mount is, else the nearest
|
|
1017
|
+
* directory above it, where volley creates it. Checked before any spend,
|
|
1018
|
+
* because otherwise the run dies as it starts on a raw EROFS or EACCES. Inside
|
|
1019
|
+
* the sandbox the root filesystem is read-only, so that is what an unmounted
|
|
1020
|
+
* run directory looks like, and the message says what to mount. */
|
|
1021
|
+
function validate_run_state_dir(dir, contained) {
|
|
1022
|
+
let existing = dir;
|
|
1023
|
+
while (!existsSync(existing) && dirname(existing) !== existing) existing = dirname(existing);
|
|
1024
|
+
if (is_writable_dir(existing)) return;
|
|
1025
|
+
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
1026
|
}
|
|
681
1027
|
/** Whether a tool-loop builder's commands would reach the host with nothing in
|
|
682
1028
|
* between: volley isn't contained, and the operator hasn't opted out with
|
|
@@ -684,10 +1030,7 @@ function detect_containment(env) {
|
|
|
684
1030
|
function unguarded_builder(raw, env) {
|
|
685
1031
|
return !detect_containment(env) && !resolve_allow_unsandboxed_builder(raw, env);
|
|
686
1032
|
}
|
|
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_-]*$/;
|
|
1033
|
+
/** A caller's run id, held to `RUN_ID_PATTERN`; none gets a fresh UUID. */
|
|
691
1034
|
function resolve_run_id(run_id) {
|
|
692
1035
|
if (run_id === void 0) return randomUUID();
|
|
693
1036
|
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 +1042,17 @@ function resolve_run_id(run_id) {
|
|
|
699
1042
|
function resolve_config(raw, options = {}) {
|
|
700
1043
|
const cwd = options.cwd ?? process.cwd();
|
|
701
1044
|
const env = options.env ?? process.env;
|
|
702
|
-
|
|
1045
|
+
const critique = options.mode === "critique";
|
|
1046
|
+
if (critique) refuse_builder_settings(raw);
|
|
1047
|
+
const builder_env = critique ? {} : env;
|
|
1048
|
+
const has_prompt = typeof raw.prompt === "string" && raw.prompt.length > 0;
|
|
1049
|
+
if (!has_prompt && !critique) throw config_error("missing required option: --prompt");
|
|
703
1050
|
if (typeof raw.workspace !== "string" || raw.workspace.length === 0) throw config_error("missing required option: --workspace");
|
|
704
1051
|
if (typeof raw.criteria !== "string" || raw.criteria.length === 0) throw config_error("missing required option: --criteria");
|
|
705
1052
|
const workspace = validate_workspace(resolve(cwd, raw.workspace));
|
|
706
|
-
const prompt = expand_at_file(raw.prompt, cwd);
|
|
1053
|
+
const prompt = has_prompt ? expand_at_file(raw.prompt, cwd) : "";
|
|
707
1054
|
const criteria = expand_at_file(raw.criteria, cwd);
|
|
708
|
-
const max_iterations = raw.max_iterations ?? 10;
|
|
1055
|
+
const max_iterations = raw.max_iterations ?? (critique ? 1 : 10);
|
|
709
1056
|
if (!Number.isInteger(max_iterations) || max_iterations < 1) throw config_error(`--max-iterations must be a positive integer; got: ${String(raw.max_iterations)}`);
|
|
710
1057
|
const max_cost_usd = raw.max_cost_usd ?? null;
|
|
711
1058
|
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 +1061,18 @@ function resolve_config(raw, options = {}) {
|
|
|
714
1061
|
const { critic_preset, critic_prompt_path } = resolve_critic(raw.critic ?? DEFAULT_CRITIC, cwd);
|
|
715
1062
|
const builder_provider = raw.builder_provider ?? DEFAULT_BUILDER_PROVIDER;
|
|
716
1063
|
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,
|
|
1064
|
+
const allow_unsandboxed_builder = resolve_allow_unsandboxed_builder(raw, builder_env);
|
|
718
1065
|
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 =
|
|
1066
|
+
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.`);
|
|
1067
|
+
const builder_max_steps = resolve_builder_count(raw.builder_max_steps, builder_env, BUILDER_MAX_STEPS);
|
|
1068
|
+
const builder_max_tool_calls = resolve_builder_count(raw.builder_max_tool_calls, builder_env, BUILDER_MAX_TOOL_CALLS);
|
|
721
1069
|
const critic_provider = raw.critic_provider ?? DEFAULT_CRITIC_PROVIDER;
|
|
722
1070
|
if (!CRITIC_PROVIDERS.includes(critic_provider)) throw config_error(`--critic-provider must be one of ${CRITIC_PROVIDERS.join(", ")}; got: ${String(raw.critic_provider)}`);
|
|
723
1071
|
const builder_url = resolve_seat_url("builder", raw.builder_url, builder_provider);
|
|
724
1072
|
const critic_url = resolve_seat_url("critic", raw.critic_url, critic_provider);
|
|
725
1073
|
if (builder_provider === "openrouter" || critic_provider === "openrouter") openrouter_api_key(env);
|
|
726
1074
|
if (contained) {
|
|
727
|
-
const claude_roles = [builder_provider === "claude_cli" ? "builder" : null, critic_provider === "claude_cli" ? "critic" : null].filter((role) => role !== null);
|
|
1075
|
+
const claude_roles = [builder_provider === "claude_cli" && !critique ? "builder" : null, critic_provider === "claude_cli" ? "critic" : null].filter((role) => role !== null);
|
|
728
1076
|
const auth_mode = env["VOLLEY_AUTH_MODE"] ?? "auto";
|
|
729
1077
|
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
1078
|
}
|
|
@@ -735,14 +1083,17 @@ function resolve_config(raw, options = {}) {
|
|
|
735
1083
|
if (discard_worktree && !worktree) throw config_error("--discard-worktree applies only to a --worktree run: there is no worktree to discard");
|
|
736
1084
|
const gate_paths = raw.gate_paths ?? DEFAULT_GATE_PATTERNS;
|
|
737
1085
|
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);
|
|
1086
|
+
const sandbox_image = resolve_sandbox_image(raw, builder_env);
|
|
1087
|
+
const state_dir = options.state_dir !== void 0 ? options.state_dir : resolve_state_dir(raw.state_dir, cwd, env);
|
|
1088
|
+
if (state_dir === workspace) throw config_error(`--state-dir (state_dir) can't be the workspace itself: ${workspace}`);
|
|
740
1089
|
const run_id = resolve_run_id(options.run_id);
|
|
1090
|
+
if (state_dir !== null) validate_run_state_dir(join(state_dir, run_id), contained);
|
|
741
1091
|
const check = raw.check ?? "auto";
|
|
742
1092
|
return {
|
|
743
1093
|
version: 2,
|
|
744
1094
|
run_id,
|
|
745
1095
|
started_at: options.started_at ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
1096
|
+
mode: critique ? "critique" : "run",
|
|
746
1097
|
prompt,
|
|
747
1098
|
criteria,
|
|
748
1099
|
check,
|
|
@@ -751,6 +1102,7 @@ function resolve_config(raw, options = {}) {
|
|
|
751
1102
|
builder_provider,
|
|
752
1103
|
builder_url,
|
|
753
1104
|
builder_max_steps,
|
|
1105
|
+
builder_max_tool_calls,
|
|
754
1106
|
allow_unsandboxed_builder,
|
|
755
1107
|
critic_model: raw.critic_model ?? DEFAULT_CRITIC_MODEL,
|
|
756
1108
|
critic_provider,
|
|
@@ -860,7 +1212,7 @@ const IGNORED_DIRS = /* @__PURE__ */ new Set([
|
|
|
860
1212
|
* escape. Returns the absolute path; throws on containment violation. The
|
|
861
1213
|
* `workspace` root passed here is the build root, which is the per-run git
|
|
862
1214
|
* worktree under `--worktree` (`build_root`), not necessarily the
|
|
863
|
-
*
|
|
1215
|
+
* workspace the run was started on. */
|
|
864
1216
|
function contain(workspace, rel) {
|
|
865
1217
|
const root = resolve(workspace);
|
|
866
1218
|
const candidate = isAbsolute(rel) ? resolve(rel) : resolve(root, rel);
|
|
@@ -899,6 +1251,25 @@ function walk(root, start, limit) {
|
|
|
899
1251
|
}
|
|
900
1252
|
return out;
|
|
901
1253
|
}
|
|
1254
|
+
/** The files a read tool's optional `path` covers: the whole tree, the tree
|
|
1255
|
+
* under a directory, or the one file it names. A path that doesn't exist
|
|
1256
|
+
* throws, so the seat reads why rather than an empty result that looks like
|
|
1257
|
+
* "nothing matched". */
|
|
1258
|
+
function scoped_files(workspace, path, limit) {
|
|
1259
|
+
const root = resolve(workspace);
|
|
1260
|
+
if (path === void 0) return walk(root, root, limit);
|
|
1261
|
+
const start = contain(workspace, path);
|
|
1262
|
+
let is_dir;
|
|
1263
|
+
try {
|
|
1264
|
+
is_dir = statSync(start).isDirectory();
|
|
1265
|
+
} catch (err) {
|
|
1266
|
+
throw new Error(`no such file or directory: ${path}`, { cause: err });
|
|
1267
|
+
}
|
|
1268
|
+
return is_dir ? walk(root, start, limit) : [{
|
|
1269
|
+
rel: relative(root, start),
|
|
1270
|
+
abs: start
|
|
1271
|
+
}];
|
|
1272
|
+
}
|
|
902
1273
|
function build_regex(pattern, flags) {
|
|
903
1274
|
try {
|
|
904
1275
|
return new RegExp(pattern, flags);
|
|
@@ -910,11 +1281,11 @@ function build_regex(pattern, flags) {
|
|
|
910
1281
|
const read_file_input = z.object({ path: z.string().describe("Workspace-relative path to the file to read.") });
|
|
911
1282
|
const search_files_input = z.object({
|
|
912
1283
|
pattern: z.string().describe("JavaScript regular expression to match against each line."),
|
|
913
|
-
path: z.string().optional().describe("Optional workspace-relative
|
|
1284
|
+
path: z.string().optional().describe("Optional workspace-relative directory or file to limit the search to."),
|
|
914
1285
|
ignore_case: z.boolean().optional().describe("Case-insensitive match. Default false.")
|
|
915
1286
|
});
|
|
916
1287
|
const list_files_input = z.object({
|
|
917
|
-
path: z.string().optional().describe("Optional workspace-relative
|
|
1288
|
+
path: z.string().optional().describe("Optional workspace-relative directory or file. Default: the workspace root."),
|
|
918
1289
|
contains: z.string().optional().describe("Optional substring filter applied to each relative path.")
|
|
919
1290
|
});
|
|
920
1291
|
function read_only_tools(workspace) {
|
|
@@ -935,9 +1306,8 @@ function read_only_tools(workspace) {
|
|
|
935
1306
|
input_schema: search_files_input,
|
|
936
1307
|
execute: (raw) => {
|
|
937
1308
|
const input = search_files_input.parse(raw);
|
|
938
|
-
const start = input.path !== void 0 ? contain(workspace, input.path) : resolve(workspace);
|
|
939
1309
|
const regex = build_regex(input.pattern, input.ignore_case === true ? "i" : "");
|
|
940
|
-
const files =
|
|
1310
|
+
const files = scoped_files(workspace, input.path, LIST_MAX_ENTRIES);
|
|
941
1311
|
const hits = [];
|
|
942
1312
|
for (const file of files) {
|
|
943
1313
|
if (hits.length >= 200) break;
|
|
@@ -965,8 +1335,7 @@ function read_only_tools(workspace) {
|
|
|
965
1335
|
input_schema: list_files_input,
|
|
966
1336
|
execute: (raw) => {
|
|
967
1337
|
const input = list_files_input.parse(raw);
|
|
968
|
-
|
|
969
|
-
let files = walk(resolve(workspace), start, LIST_MAX_ENTRIES).map((f) => f.rel);
|
|
1338
|
+
let files = scoped_files(workspace, input.path, LIST_MAX_ENTRIES).map((f) => f.rel);
|
|
970
1339
|
if (input.contains !== void 0) {
|
|
971
1340
|
const needle = input.contains;
|
|
972
1341
|
files = files.filter((f) => f.includes(needle));
|
|
@@ -1500,130 +1869,6 @@ async function prewarm_ollama_model(base_url, model, abort) {
|
|
|
1500
1869
|
} catch {}
|
|
1501
1870
|
}
|
|
1502
1871
|
//#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
1872
|
//#region src/worktree.ts
|
|
1628
1873
|
/**
|
|
1629
1874
|
* Git-worktree orchestration: isolate a builder run's
|
|
@@ -1656,9 +1901,8 @@ function worktree_branch(run_id) {
|
|
|
1656
1901
|
* when `--worktree` is on, else the workspace itself. This is the containment
|
|
1657
1902
|
* root the builder/critic file tools resolve through `contain()` and the
|
|
1658
1903
|
* `bash` tool's cwd — re-pointing it moves writes into the worktree without
|
|
1659
|
-
* disturbing the workspace.
|
|
1660
|
-
*
|
|
1661
|
-
* model-driven effect surface moves.
|
|
1904
|
+
* disturbing the workspace. The check, the change report, and the checkpoints
|
|
1905
|
+
* follow it there; the run's state stays wherever `state_dir` puts it.
|
|
1662
1906
|
*/
|
|
1663
1907
|
function build_root(workspace, worktree) {
|
|
1664
1908
|
return worktree ? worktree_path(workspace) : workspace;
|
|
@@ -1999,12 +2243,27 @@ const HARNESS_APPENDS = {
|
|
|
1999
2243
|
volley: "harness_append_local.md",
|
|
2000
2244
|
acp: "harness_append_acp.md"
|
|
2001
2245
|
};
|
|
2002
|
-
|
|
2003
|
-
|
|
2246
|
+
/** Step 2 of "Work this way", as both harness appends word it for a builder
|
|
2247
|
+
* held to one tool call a turn. */
|
|
2248
|
+
const ONE_CALL_A_TURN = "2. Call one tool at a time and read its result before deciding the next\n call.";
|
|
2249
|
+
/** Step 2 for a builder that may make several calls a turn: fascicle runs a
|
|
2250
|
+
* turn's calls in order, so a read can't race an edit, but each change or
|
|
2251
|
+
* command is still worth reading before the next. */
|
|
2252
|
+
function batched_calls(max_tool_calls) {
|
|
2253
|
+
return [
|
|
2254
|
+
`2. Batch independent reads into one turn, up to ${String(max_tool_calls)} calls, but`,
|
|
2255
|
+
" make one change or command per turn, and read its result before",
|
|
2256
|
+
" deciding the next call."
|
|
2257
|
+
].join("\n");
|
|
2258
|
+
}
|
|
2259
|
+
function compose_builder_system_local(tool_set = "volley", max_tool_calls = 1) {
|
|
2260
|
+
const identity = [
|
|
2004
2261
|
"You are the builder inside the volley harness: an autonomous agent",
|
|
2005
2262
|
"iterating on a workspace until it satisfies a task and its acceptance",
|
|
2006
2263
|
"criteria."
|
|
2007
|
-
].join("\n")
|
|
2264
|
+
].join("\n");
|
|
2265
|
+
const append = readFileSync(join(builder_presets_dir(), HARNESS_APPENDS[tool_set]), "utf8");
|
|
2266
|
+
return `${identity}\n\n${(max_tool_calls > 1 ? append.replace(ONE_CALL_A_TURN, batched_calls(max_tool_calls)) : append).trimEnd()}`;
|
|
2008
2267
|
}
|
|
2009
2268
|
/** The builder system prompt for the active provider: the `claude_cli`
|
|
2010
2269
|
* prompt (implicit built-in tools, "the current working directory") for the
|
|
@@ -2012,7 +2271,7 @@ function compose_builder_system_local(tool_set = "volley") {
|
|
|
2012
2271
|
* the seat's tool set for every provider that runs volley's tool loop,
|
|
2013
2272
|
* `openrouter` included. Mirrors the critic's `resolve_critic_prompt`. */
|
|
2014
2273
|
function resolve_builder_system(config, tool_set) {
|
|
2015
|
-
return config.builder_provider === "claude_cli" ? compose_builder_system() : compose_builder_system_local(tool_set);
|
|
2274
|
+
return config.builder_provider === "claude_cli" ? compose_builder_system() : compose_builder_system_local(tool_set, config.builder_max_tool_calls);
|
|
2016
2275
|
}
|
|
2017
2276
|
const BUILDER_TOOL_CALL_REPAIR_ATTEMPTS = 3;
|
|
2018
2277
|
/** Per-provider tool wiring for the builder, mirroring `critic_tool_options`.
|
|
@@ -2037,7 +2296,7 @@ function builder_tool_options(deps) {
|
|
|
2037
2296
|
max_steps: config.builder_max_steps,
|
|
2038
2297
|
tool_error_policy: "feed_back",
|
|
2039
2298
|
tool_call_repair_attempts: BUILDER_TOOL_CALL_REPAIR_ATTEMPTS,
|
|
2040
|
-
max_tool_calls_per_step:
|
|
2299
|
+
max_tool_calls_per_step: config.builder_max_tool_calls
|
|
2041
2300
|
};
|
|
2042
2301
|
}
|
|
2043
2302
|
function compose_builder_prompt(input) {
|
|
@@ -2222,7 +2481,7 @@ function compose_critic_prompt(input) {
|
|
|
2222
2481
|
* Critic invocation: a read-only session (`claude_cli`, or a local or
|
|
2223
2482
|
* `openrouter` model with volley's read tools) returning a schema-validated
|
|
2224
2483
|
* structured verdict. The
|
|
2225
|
-
* harness — not the critic — writes
|
|
2484
|
+
* harness — not the critic — writes the run's `feedback.md` and `verdict`.
|
|
2226
2485
|
*/
|
|
2227
2486
|
const CRITIC_ALLOWED_TOOLS = [
|
|
2228
2487
|
"Read",
|
|
@@ -2230,10 +2489,16 @@ const CRITIC_ALLOWED_TOOLS = [
|
|
|
2230
2489
|
"Glob"
|
|
2231
2490
|
];
|
|
2232
2491
|
const CRITIC_DISALLOWED_TOOLS = "Write,Edit,MultiEdit,NotebookEdit,Bash";
|
|
2492
|
+
/** A critic that holds tools isn't constrain-decoded, so nothing stops it
|
|
2493
|
+
* answering null for the feedback or the unmet list, as GLM did on approving.
|
|
2494
|
+
* Both fields allow null and read it as empty, so that answer is a verdict
|
|
2495
|
+
* rather than a schema failure that ends the run. fascicle sends the schema's
|
|
2496
|
+
* input side, so the model sees each field as its type or null, still
|
|
2497
|
+
* required; `.describe()` comes last so the description stays on the field. */
|
|
2233
2498
|
const verdict_schema = z.object({
|
|
2234
2499
|
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.")
|
|
2500
|
+
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."),
|
|
2501
|
+
unmet_criteria: z.array(z.string()).nullable().transform((unmet) => unmet ?? []).describe("The specific acceptance criteria judged unmet, verbatim. Empty when approved.")
|
|
2237
2502
|
});
|
|
2238
2503
|
/** Read-only tool wiring per provider. The claude_cli critic is confined at
|
|
2239
2504
|
* the CLI permission layer (allowlist + explicit disallow); every other critic,
|
|
@@ -2758,6 +3023,46 @@ function rejected_check(runner) {
|
|
|
2758
3023
|
};
|
|
2759
3024
|
}
|
|
2760
3025
|
//#endregion
|
|
3026
|
+
//#region src/render/format.ts
|
|
3027
|
+
/**
|
|
3028
|
+
* Display formatting: glyph taxonomy, role colors, truncation.
|
|
3029
|
+
* All human output goes to stderr; these helpers only build strings.
|
|
3030
|
+
*/
|
|
3031
|
+
const GLYPH_TOOL = "🔧";
|
|
3032
|
+
const GLYPH_COST = "💰";
|
|
3033
|
+
const VERBOSE_TRUNCATE_CHARS = 4e3;
|
|
3034
|
+
function paint(role, text, color) {
|
|
3035
|
+
if (!color) return text;
|
|
3036
|
+
switch (role) {
|
|
3037
|
+
case "builder": return pc.cyan(text);
|
|
3038
|
+
case "critic": return pc.yellow(text);
|
|
3039
|
+
case "check": return pc.green(text);
|
|
3040
|
+
case "cost": return pc.magenta(text);
|
|
3041
|
+
case "error": return pc.bold(pc.red(text));
|
|
3042
|
+
}
|
|
3043
|
+
}
|
|
3044
|
+
function dim(text, color) {
|
|
3045
|
+
return color ? pc.dim(text) : text;
|
|
3046
|
+
}
|
|
3047
|
+
function truncate(text, max, pointer) {
|
|
3048
|
+
if (text.length <= max) return text;
|
|
3049
|
+
return `${text.slice(0, max)}\n… [truncated at ${String(max)} chars; ${pointer}]`;
|
|
3050
|
+
}
|
|
3051
|
+
function format_usd(value) {
|
|
3052
|
+
return value === null ? "$?" : `$${value.toFixed(3)}`;
|
|
3053
|
+
}
|
|
3054
|
+
/** One-line summary of a tool input for the default streaming mode. */
|
|
3055
|
+
function summarize_value(value, max = 120) {
|
|
3056
|
+
let text;
|
|
3057
|
+
try {
|
|
3058
|
+
text = typeof value === "string" ? value : JSON.stringify(value) ?? "";
|
|
3059
|
+
} catch {
|
|
3060
|
+
text = String(value);
|
|
3061
|
+
}
|
|
3062
|
+
const flat = text.replace(/\s+/g, " ");
|
|
3063
|
+
return flat.length > max ? `${flat.slice(0, max)}…` : flat;
|
|
3064
|
+
}
|
|
3065
|
+
//#endregion
|
|
2761
3066
|
//#region src/iteration.ts
|
|
2762
3067
|
/**
|
|
2763
3068
|
* Iteration archiving, run-level summary, and resume-state recovery.
|
|
@@ -2848,7 +3153,9 @@ function run_result_from_state(config, state, status, completed_at) {
|
|
|
2848
3153
|
critic_cost_usd: state.critic_cost_usd,
|
|
2849
3154
|
check_duration_ms: state.check_duration_ms,
|
|
2850
3155
|
final_verdict: state.verdict,
|
|
2851
|
-
salvaged_branch: null
|
|
3156
|
+
salvaged_branch: null,
|
|
3157
|
+
run_state_dir: run_state_dir(config),
|
|
3158
|
+
mode: config.mode
|
|
2852
3159
|
};
|
|
2853
3160
|
}
|
|
2854
3161
|
//#endregion
|
|
@@ -2948,7 +3255,7 @@ function build_run_summary(config, result) {
|
|
|
2948
3255
|
return {
|
|
2949
3256
|
...result,
|
|
2950
3257
|
comparison: {
|
|
2951
|
-
builder_transport: transport_of(config.builder_provider),
|
|
3258
|
+
builder_transport: config.mode === "critique" ? null : transport_of(config.builder_provider),
|
|
2952
3259
|
critic_transport: transport_of(config.critic_provider),
|
|
2953
3260
|
iterations_to_converge: result.status === "success" ? result.iterations_completed : null,
|
|
2954
3261
|
wall_clock_ms,
|
|
@@ -3023,7 +3330,16 @@ function report_changes(config, renderer, changes) {
|
|
|
3023
3330
|
if (changes.gate_edits.length === 0) return;
|
|
3024
3331
|
const hidden = changes.gate_edits.length - LISTED_GATE_EDITS;
|
|
3025
3332
|
const listed = changes.gate_edits.slice(0, LISTED_GATE_EDITS).join(", ");
|
|
3026
|
-
|
|
3333
|
+
const [who, whose, edits] = config.mode === "critique" ? [
|
|
3334
|
+
"the change under review touches",
|
|
3335
|
+
"whether it passes",
|
|
3336
|
+
"the change edits"
|
|
3337
|
+
] : [
|
|
3338
|
+
"the builder changed",
|
|
3339
|
+
"whether its work passes",
|
|
3340
|
+
"the builder can edit"
|
|
3341
|
+
];
|
|
3342
|
+
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
3343
|
}
|
|
3028
3344
|
/** Run the configured check, checkride or a command, with the environment
|
|
3029
3345
|
* the caller set for it, else volley's own. */
|
|
@@ -3077,6 +3393,22 @@ function next_iteration(s) {
|
|
|
3077
3393
|
unmet_criteria: []
|
|
3078
3394
|
};
|
|
3079
3395
|
}
|
|
3396
|
+
/** What changed in the build root since the baseline, reported as it's
|
|
3397
|
+
* measured: the count, and a warning for any gate edit. A run measures the
|
|
3398
|
+
* builder's work this way after every build, and a critique measures the
|
|
3399
|
+
* change under review the same way, so the critic is shown one shape either
|
|
3400
|
+
* way. */
|
|
3401
|
+
function measure_changes(deps, root) {
|
|
3402
|
+
const { config, renderer } = deps;
|
|
3403
|
+
const changes = collect_changes({
|
|
3404
|
+
root,
|
|
3405
|
+
baseline: deps.baseline,
|
|
3406
|
+
gate_patterns: config.gate_paths,
|
|
3407
|
+
excluded_dir: state_root_within(root, config.state_dir)
|
|
3408
|
+
});
|
|
3409
|
+
report_changes(config, renderer, changes);
|
|
3410
|
+
return changes;
|
|
3411
|
+
}
|
|
3080
3412
|
/** `build`: one builder session edits the build root, then volley measures what
|
|
3081
3413
|
* it changed and, under `--git`, checkpoints it. */
|
|
3082
3414
|
async function build_phase(deps, builder, s, ctx) {
|
|
@@ -3095,18 +3427,28 @@ async function build_phase(deps, builder, s, ctx) {
|
|
|
3095
3427
|
renderer.phase_end(built.iteration, "builder", true);
|
|
3096
3428
|
renderer.cost_line(built.iteration, "builder", built.builder?.cost_usd ?? null, built.total_cost_usd);
|
|
3097
3429
|
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`);
|
|
3430
|
+
const root = build_root(config.workspace, config.worktree);
|
|
3431
|
+
const changes = measure_changes(deps, root);
|
|
3432
|
+
if (config.git_checkpoints) git_checkpoint(root, `volley iter ${String(built.iteration)}: build`, state_root_within(root, config.state_dir));
|
|
3105
3433
|
return {
|
|
3106
3434
|
...built,
|
|
3107
3435
|
changes
|
|
3108
3436
|
};
|
|
3109
3437
|
}
|
|
3438
|
+
/** `measure`: a critique's one round opens, and the change under review is
|
|
3439
|
+
* measured from the baseline, as a run measures the builder's work. */
|
|
3440
|
+
function measure_phase(deps, s, ctx) {
|
|
3441
|
+
const next = next_iteration(s);
|
|
3442
|
+
emit(ctx, {
|
|
3443
|
+
volley: "round_start",
|
|
3444
|
+
round: next.iteration
|
|
3445
|
+
});
|
|
3446
|
+
const root = build_root(deps.config.workspace, deps.config.worktree);
|
|
3447
|
+
return {
|
|
3448
|
+
...next,
|
|
3449
|
+
changes: measure_changes(deps, root)
|
|
3450
|
+
};
|
|
3451
|
+
}
|
|
3110
3452
|
/** `skip_verify`: the build alone crossed the cost cap, so the check is recorded
|
|
3111
3453
|
* as skipped and no critic is called — a doomed iteration spends nothing more.
|
|
3112
3454
|
* The loop guard then halts the run on the cap. */
|
|
@@ -3220,7 +3562,10 @@ function record_phase(deps, s) {
|
|
|
3220
3562
|
const { config } = deps;
|
|
3221
3563
|
archive_iteration(config, s);
|
|
3222
3564
|
write_run_summary(config, run_result_from_state(config, s, "running", null));
|
|
3223
|
-
if (config.git_checkpoints)
|
|
3565
|
+
if (config.git_checkpoints) {
|
|
3566
|
+
const root = build_root(config.workspace, config.worktree);
|
|
3567
|
+
git_checkpoint(root, `volley iter ${String(s.iteration)}: critique (${s.verdict ?? "skipped"})`, state_root_within(root, config.state_dir));
|
|
3568
|
+
}
|
|
3224
3569
|
return s;
|
|
3225
3570
|
}
|
|
3226
3571
|
//#endregion
|
|
@@ -3232,6 +3577,11 @@ function record_phase(deps, s) {
|
|
|
3232
3577
|
* around the flow (workspace setup, the worktree lifecycle, `run`, the final
|
|
3233
3578
|
* summary) in `./orchestrator.ts`.
|
|
3234
3579
|
*
|
|
3580
|
+
* A critique (`build_critique_flow`) is one round over a change already made,
|
|
3581
|
+
* so it has no builder, no loop, and no guard: `measure`, which opens the
|
|
3582
|
+
* round and measures the change from its baseline, then the same `check`,
|
|
3583
|
+
* `critique`, and `record` a run's round ends with.
|
|
3584
|
+
*
|
|
3235
3585
|
* fascicle's `describe.diagram` draws the tree below from the flow itself, and
|
|
3236
3586
|
* `test/unit/flow_diagram.test.ts` holds it to the code: change a row by changing
|
|
3237
3587
|
* its step's `description`, then paste `pnpm diagram --prefix ' * '` over it.
|
|
@@ -3275,6 +3625,24 @@ function build_critic(deps) {
|
|
|
3275
3625
|
project: with_fallback_cause
|
|
3276
3626
|
});
|
|
3277
3627
|
}
|
|
3628
|
+
/** The steps a run and a critique share, built one way for both. */
|
|
3629
|
+
function check_step(deps) {
|
|
3630
|
+
return step("check", (s, ctx) => check_phase(deps, s, ctx), { description: "the deterministic gate: checkride, a command, or none" });
|
|
3631
|
+
}
|
|
3632
|
+
function critique_step(deps, seat) {
|
|
3633
|
+
const critic = build_critic({
|
|
3634
|
+
...seat,
|
|
3635
|
+
config: deps.config,
|
|
3636
|
+
on_chunk: deps.renderer.critic_chunk
|
|
3637
|
+
});
|
|
3638
|
+
return step("critique", (s, ctx) => critique_phase(deps, critic, s, ctx), {
|
|
3639
|
+
description: "the read-only critic's verdict and feedback",
|
|
3640
|
+
arm: critic
|
|
3641
|
+
});
|
|
3642
|
+
}
|
|
3643
|
+
function record_step(deps) {
|
|
3644
|
+
return step("record", (s) => record_phase(deps, s), { description: "archive the iteration and rewrite the run summary" });
|
|
3645
|
+
}
|
|
3278
3646
|
function build_flow(deps, resume_from) {
|
|
3279
3647
|
const { config, renderer } = deps;
|
|
3280
3648
|
const builder = make_builder_step({
|
|
@@ -3283,22 +3651,14 @@ function build_flow(deps, resume_from) {
|
|
|
3283
3651
|
on_chunk: renderer.builder_chunk,
|
|
3284
3652
|
bash_executor: deps.bash_executor
|
|
3285
3653
|
});
|
|
3286
|
-
const critic = build_critic({
|
|
3287
|
-
...deps.seats.critic,
|
|
3288
|
-
config,
|
|
3289
|
-
on_chunk: renderer.critic_chunk
|
|
3290
|
-
});
|
|
3291
3654
|
const build = step("build", (s, ctx) => build_phase(deps, builder, s, ctx), {
|
|
3292
3655
|
description: "open the iteration, run the builder, measure the change",
|
|
3293
3656
|
arm: builder
|
|
3294
3657
|
});
|
|
3295
3658
|
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" });
|
|
3659
|
+
const check = check_step(deps);
|
|
3660
|
+
const critique = critique_step(deps, deps.seats.critic);
|
|
3661
|
+
const record = record_step(deps);
|
|
3302
3662
|
const verify = branch({
|
|
3303
3663
|
name: "verify",
|
|
3304
3664
|
description: "did the build alone cross the cost cap?",
|
|
@@ -3324,6 +3684,19 @@ function build_flow(deps, resume_from) {
|
|
|
3324
3684
|
max_rounds: remaining_iterations(config, resume_from)
|
|
3325
3685
|
});
|
|
3326
3686
|
}
|
|
3687
|
+
/** A critique: one round over a change already made. `measure` opens it and
|
|
3688
|
+
* measures the change from the baseline, then the check, the critic, and the
|
|
3689
|
+
* record, as in a run's round. No loop and no guard: there is nothing to
|
|
3690
|
+
* iterate on, and the caller reads the outcome off the final state. */
|
|
3691
|
+
function build_critique_flow(deps) {
|
|
3692
|
+
const measure = step("measure", (s, ctx) => measure_phase(deps, s, ctx), { description: "open the round and measure the change under review" });
|
|
3693
|
+
return sequence([
|
|
3694
|
+
measure,
|
|
3695
|
+
check_step(deps),
|
|
3696
|
+
critique_step(deps, deps.critic),
|
|
3697
|
+
record_step(deps)
|
|
3698
|
+
], { name: "critique_run" });
|
|
3699
|
+
}
|
|
3327
3700
|
//#endregion
|
|
3328
3701
|
//#region src/orchestrator.ts
|
|
3329
3702
|
/**
|
|
@@ -3340,13 +3713,19 @@ function build_flow(deps, resume_from) {
|
|
|
3340
3713
|
* │ ├─ run_loop run the flow (streamed when a sink is given), then the final summary
|
|
3341
3714
|
* │ └─ integrate a successful --worktree --git run only
|
|
3342
3715
|
* └─ report a kept checkout, or a salvaged branch (re-stamping the summary)
|
|
3716
|
+
*
|
|
3717
|
+
* run_critique
|
|
3718
|
+
* ├─ resolve the baseline --baseline, else HEAD; a bad revision exits 5
|
|
3719
|
+
* ├─ initialize the run's state
|
|
3720
|
+
* └─ run the critique flow one round on the critic's engine alone, then the final summary
|
|
3343
3721
|
*/
|
|
3344
3722
|
async function run_volley(config, deps, resume_from = null) {
|
|
3345
3723
|
const { renderer } = deps;
|
|
3346
3724
|
if (resume_from === null) {
|
|
3347
3725
|
initialize_run_state(config);
|
|
3348
|
-
write_resolved_config(config);
|
|
3726
|
+
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
3727
|
} else initialize_run_state(config, { preserve: true });
|
|
3728
|
+
renderer.info(`state: ${run_state_dir(config)}`);
|
|
3350
3729
|
report_worktree_fate(config, renderer);
|
|
3351
3730
|
const branch = worktree_branch(config.run_id);
|
|
3352
3731
|
const fate = worktree_fate(config);
|
|
@@ -3464,57 +3843,61 @@ function finalize_failed_run(config, err) {
|
|
|
3464
3843
|
const summary_path = state_path(config, "summary.json");
|
|
3465
3844
|
write_run_summary(config, {
|
|
3466
3845
|
...existsSync(summary_path) ? JSON.parse(readFileSync(summary_path, "utf8")) : run_result_from_state(config, initial_state(), status, null),
|
|
3846
|
+
run_state_dir: run_state_dir(config),
|
|
3467
3847
|
status,
|
|
3468
3848
|
completed_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
3469
3849
|
});
|
|
3470
3850
|
} catch {}
|
|
3471
3851
|
}
|
|
3472
|
-
//#endregion
|
|
3473
|
-
//#region src/render/format.ts
|
|
3474
3852
|
/**
|
|
3475
|
-
*
|
|
3476
|
-
*
|
|
3853
|
+
* `volley critique`: the check and the critic, once, over a change already
|
|
3854
|
+
* made. No builder is constructed, and the engine serves the critic's seat
|
|
3855
|
+
* alone, so a critic can be measured on fixed, labeled changes without paying
|
|
3856
|
+
* for builder turns. It writes the state a one-round run does, with the mode
|
|
3857
|
+
* recorded, and ends `success` on approval over a green check, else
|
|
3858
|
+
* `rejected`.
|
|
3477
3859
|
*/
|
|
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;
|
|
3860
|
+
async function run_critique(config, deps) {
|
|
3861
|
+
if (config.mode !== "critique") throw config_error("run_critique needs a config resolved with mode 'critique'");
|
|
3862
|
+
const { renderer } = deps;
|
|
3863
|
+
const baseline = resolve_baseline(config.workspace, deps.baseline);
|
|
3864
|
+
initialize_run_state(config);
|
|
3865
|
+
write_resolved_config(config);
|
|
3866
|
+
renderer.info(`state: ${run_state_dir(config)}`);
|
|
3867
|
+
renderer.info(baseline === null ? "baseline: none (not a git repository, or no commit yet); the critic reviews the tree" : `baseline: ${baseline}`);
|
|
3868
|
+
const engine = deps.engine ?? create_volley_engine({
|
|
3869
|
+
workspace: config.workspace,
|
|
3870
|
+
critic_provider: config.critic_provider,
|
|
3871
|
+
critic_url: config.critic_url
|
|
3872
|
+
});
|
|
3873
|
+
const flow = build_critique_flow({
|
|
3874
|
+
config,
|
|
3875
|
+
renderer,
|
|
3876
|
+
baseline,
|
|
3877
|
+
critic: {
|
|
3878
|
+
...deps.critic,
|
|
3879
|
+
engine
|
|
3880
|
+
},
|
|
3881
|
+
...deps.check !== void 0 ? { check: deps.check } : {}
|
|
3882
|
+
});
|
|
3504
3883
|
try {
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
3884
|
+
const value = await execute_flow(config, deps, flow, initial_state());
|
|
3885
|
+
const result = run_result_from_state(config, value, value.check?.ok === true && value.verdict === "approved" ? "success" : "rejected", (/* @__PURE__ */ new Date()).toISOString());
|
|
3886
|
+
write_run_summary(config, result);
|
|
3887
|
+
return result;
|
|
3888
|
+
} catch (err) {
|
|
3889
|
+
finalize_failed_run(config, err);
|
|
3890
|
+
throw err;
|
|
3891
|
+
} finally {
|
|
3892
|
+
await engine.dispose();
|
|
3508
3893
|
}
|
|
3509
|
-
const flat = text.replace(/\s+/g, " ");
|
|
3510
|
-
return flat.length > max ? `${flat.slice(0, max)}…` : flat;
|
|
3511
3894
|
}
|
|
3512
3895
|
//#endregion
|
|
3513
3896
|
//#region src/render/renderer.ts
|
|
3514
3897
|
function create_renderer(options) {
|
|
3515
3898
|
const write = options.write ?? ((text) => process.stderr.write(text));
|
|
3516
3899
|
const { mode, color } = options;
|
|
3517
|
-
const see_trajectory = `see ${options.trajectory_path ?? "
|
|
3900
|
+
const see_trajectory = `see ${options.trajectory_path ?? "the run's trajectory.jsonl"}`;
|
|
3518
3901
|
const silent = mode === "json";
|
|
3519
3902
|
const streaming = mode === "default" || mode === "verbose";
|
|
3520
3903
|
let mid_line = false;
|
|
@@ -3592,6 +3975,7 @@ function create_renderer(options) {
|
|
|
3592
3975
|
line(`iterations: ${String(result.iterations_completed)} | verdict: ${result.final_verdict ?? "n/a"}`);
|
|
3593
3976
|
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
3977
|
line(`tokens: ${String(result.total_usage.input_tokens)} in / ${String(result.total_usage.output_tokens)} out`);
|
|
3978
|
+
line(`state: ${result.run_state_dir}`);
|
|
3595
3979
|
}
|
|
3596
3980
|
};
|
|
3597
3981
|
}
|
|
@@ -3613,7 +3997,7 @@ function summarize_output(output) {
|
|
|
3613
3997
|
* volley detects it is already contained. The create/`exec`/reap/teardown
|
|
3614
3998
|
* lifecycle and the `docker exec` bash executor were retired; what
|
|
3615
3999
|
* remains here is the *spec* — pure, daemon-free flag-builders the blessed
|
|
3616
|
-
* examples and
|
|
4000
|
+
* examples and a parent harness render into the `docker run` line,
|
|
3617
4001
|
* unit-testable without a daemon.
|
|
3618
4002
|
*
|
|
3619
4003
|
* Inside that container volley's `bash` is the local `host_bash_executor`
|
|
@@ -3638,6 +4022,13 @@ const SANDBOX_WORKDIR = "/workspace";
|
|
|
3638
4022
|
* otherwise unset) and its `.cache` is a tmpfs so the read-only rootfs does not
|
|
3639
4023
|
* brick tools that scribble there. */
|
|
3640
4024
|
const SANDBOX_HOME = "/home/node";
|
|
4025
|
+
/** Where volley inside the container keeps each run's state when no
|
|
4026
|
+
* `--state-dir` names a root: the default root under the pinned `HOME`, since
|
|
4027
|
+
* the image sets no `XDG_STATE_HOME`. `sandbox_run_args`'s `run_state` binds one
|
|
4028
|
+
* run's host directory at that run's directory under it, so the state outlives
|
|
4029
|
+
* the `--rm` container. Exported so a harness can map a contained run's
|
|
4030
|
+
* `run_state_dir` back to the directory it mounted. */
|
|
4031
|
+
const SANDBOX_STATE_ROOT = default_state_root({}, SANDBOX_HOME);
|
|
3641
4032
|
/** The host spelling of the Docker gateway (Linux-portable; Docker Desktop
|
|
3642
4033
|
* already provides it). It is both the `--add-host` target for the allowlist
|
|
3643
4034
|
* bridge and the value the example injects as `VOLLEY_MODEL_HOST` so the
|
|
@@ -3669,7 +4060,7 @@ function network_run_args(network, network_name) {
|
|
|
3669
4060
|
/**
|
|
3670
4061
|
* The hardened `docker run` argv that launches volley inside its container.
|
|
3671
4062
|
* Pure and exported so the flag set is unit-testable without a
|
|
3672
|
-
* daemon and the blessed examples
|
|
4063
|
+
* daemon and the blessed examples and a parent harness can render it.
|
|
3673
4064
|
*
|
|
3674
4065
|
* A one-shot, foreground `--rm` container — the whole run *is* this
|
|
3675
4066
|
* container (an earlier design ran a detached `-d … sleep infinity` daemon volley
|
|
@@ -3681,6 +4072,25 @@ function network_run_args(network, network_name) {
|
|
|
3681
4072
|
* the image entrypoint (the opt-in real-docker isolation test runs a raw `sh`
|
|
3682
4073
|
* this way).
|
|
3683
4074
|
*
|
|
4075
|
+
* `run_state` binds the run's own state directory on the host,
|
|
4076
|
+
* `<root>/<run_id>/`, at `<SANDBOX_STATE_ROOT>/<run_id>/`, where volley inside
|
|
4077
|
+
* keeps that run's state when no `--state-dir` names a root, so it outlives the
|
|
4078
|
+
* container at the path a run on the host would use. Only the one directory
|
|
4079
|
+
* is mounted, never the root: the builder inside runs arbitrary commands, so
|
|
4080
|
+
* it can rewrite its own run's state but can't read another run's trajectory
|
|
4081
|
+
* or plant a check in another run's config. Its own run's state is still the
|
|
4082
|
+
* builder's to rewrite, config.json included, so a host-side resume refuses
|
|
4083
|
+
* that config, which volley signs only outside the sandbox (`./trust.ts`), and
|
|
4084
|
+
* a directory holding a symlink: resume a contained run inside the container,
|
|
4085
|
+
* with the same mount. Create the
|
|
4086
|
+
* directory on the host first, empty (Docker on Linux makes a missing bind
|
|
4087
|
+
* source owned by root, which the container's user can't write), and pass the
|
|
4088
|
+
* same id to volley as `--run-id`. Keep it outside `build_root`, so the check
|
|
4089
|
+
* never reads it, and pass no `XDG_STATE_HOME` in `env`, which would move the
|
|
4090
|
+
* root off the mount. Omit it and no state is mounted: a contained run keeping
|
|
4091
|
+
* its state under the default root then stops before spending (exit 5),
|
|
4092
|
+
* since the read-only root filesystem leaves it nowhere to write.
|
|
4093
|
+
*
|
|
3684
4094
|
* `store_volume` mounts the named volume as the pnpm store; omit it and no store
|
|
3685
4095
|
* is mounted. A store is shared by every run that mounts it, so a caller that
|
|
3686
4096
|
* installs dependencies in a separate container first can keep the builder's
|
|
@@ -3690,7 +4100,7 @@ function network_run_args(network, network_name) {
|
|
|
3690
4100
|
* Never emits `--privileged` and refuses `--user 0`: volley must not hand
|
|
3691
4101
|
* the sandbox root or full capabilities. Everything writable under the
|
|
3692
4102
|
* `--read-only` rootfs is an explicit tmpfs or volume; the bind-mounted worktree
|
|
3693
|
-
* is the
|
|
4103
|
+
* and, when one is named, the state root are the host-visible write paths.
|
|
3694
4104
|
*/
|
|
3695
4105
|
function sandbox_run_args(options) {
|
|
3696
4106
|
if (options.uid === 0) throw config_error("refusing to start the builder sandbox as root (uid 0)");
|
|
@@ -3699,6 +4109,7 @@ function sandbox_run_args(options) {
|
|
|
3699
4109
|
const entrypoint = options.entrypoint !== void 0 && options.entrypoint !== null ? ["--entrypoint", options.entrypoint] : [];
|
|
3700
4110
|
const env = (options.env ?? []).flatMap(([key, value]) => ["-e", `${key}=${value}`]);
|
|
3701
4111
|
const labels = (options.labels ?? []).flatMap(([key, value]) => ["--label", `${key}=${value}`]);
|
|
4112
|
+
const state = options.run_state !== void 0 && options.run_state !== null ? ["-v", run_state_mount(options.run_state)] : [];
|
|
3702
4113
|
const store = options.store_volume !== void 0 && options.store_volume !== null ? ["-v", `${options.store_volume}:${SANDBOX_HOME}/.local/share/pnpm/store`] : [];
|
|
3703
4114
|
return [
|
|
3704
4115
|
"run",
|
|
@@ -3732,6 +4143,7 @@ function sandbox_run_args(options) {
|
|
|
3732
4143
|
...env,
|
|
3733
4144
|
"-v",
|
|
3734
4145
|
`${resolve(options.build_root)}:${SANDBOX_WORKDIR}`,
|
|
4146
|
+
...state,
|
|
3735
4147
|
...store,
|
|
3736
4148
|
"-w",
|
|
3737
4149
|
SANDBOX_WORKDIR,
|
|
@@ -3740,6 +4152,13 @@ function sandbox_run_args(options) {
|
|
|
3740
4152
|
...options.command ?? []
|
|
3741
4153
|
];
|
|
3742
4154
|
}
|
|
4155
|
+
/** The `-v` value for one run's state directory. The run id becomes a path on
|
|
4156
|
+
* both sides, so it is held to volley's own `--run-id` rule, which keeps it to
|
|
4157
|
+
* one directory name. */
|
|
4158
|
+
function run_state_mount(run_state) {
|
|
4159
|
+
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}`);
|
|
4160
|
+
return `${join(resolve(run_state.root), run_state.run_id)}:${SANDBOX_STATE_ROOT}/${run_state.run_id}`;
|
|
4161
|
+
}
|
|
3743
4162
|
//#endregion
|
|
3744
4163
|
//#region src/exit_codes.ts
|
|
3745
4164
|
/**
|
|
@@ -3757,7 +4176,8 @@ const EXIT_INTERRUPTED = 130;
|
|
|
3757
4176
|
function exit_code_for_status(status) {
|
|
3758
4177
|
switch (status) {
|
|
3759
4178
|
case "success": return 0;
|
|
3760
|
-
case "budget_exhausted":
|
|
4179
|
+
case "budget_exhausted":
|
|
4180
|
+
case "rejected": return 2;
|
|
3761
4181
|
case "cost_cap_reached": return 7;
|
|
3762
4182
|
case "gate_edit_blocked": return 8;
|
|
3763
4183
|
case "interrupted": return 130;
|
|
@@ -3792,6 +4212,6 @@ function exit_code_for_error(err) {
|
|
|
3792
4212
|
return 3;
|
|
3793
4213
|
}
|
|
3794
4214
|
//#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 };
|
|
4215
|
+
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
4216
|
|
|
3797
4217
|
//# sourceMappingURL=index.js.map
|