@robmclarty/volley 0.7.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 +42 -0
- package/README.md +398 -38
- package/dist/cli.js +4469 -2930
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +260 -36
- package/dist/index.js +1296 -450
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/src/builder/presets/harness_append_acp.md +86 -0
- package/src/builder/presets/harness_append_local.md +2 -1
- package/src/critic/presets/harness_append.md +2 -1
- package/src/critic/presets/harness_append_acp.md +27 -0
- package/src/critic/presets/harness_append_local.md +4 -3
package/dist/index.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
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 {
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { filesystem_logger, tee_logger } from "fascicle/adapters";
|
|
8
9
|
import { lookup } from "node:dns";
|
|
9
10
|
import { Readability } from "@mozilla/readability";
|
|
10
11
|
import ipaddr from "ipaddr.js";
|
|
@@ -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
|
|
@@ -315,6 +360,13 @@ function error_kind(err) {
|
|
|
315
360
|
* and 404s. */
|
|
316
361
|
const DEFAULT_OLLAMA_URL = "http://localhost:11434";
|
|
317
362
|
const DEFAULT_LMSTUDIO_URL = "http://localhost:1234/v1";
|
|
363
|
+
/** OpenRouter's API root, the one fascicle's openrouter adapter defaults to. */
|
|
364
|
+
const DEFAULT_OPENROUTER_URL = "https://openrouter.ai/api/v1";
|
|
365
|
+
/** The variable an `openrouter` seat's API key is read from. */
|
|
366
|
+
const OPENROUTER_KEY_VAR = "OPENROUTER_API_KEY";
|
|
367
|
+
/** The variable a `claude_cli` seat's API key is read from, which the Claude
|
|
368
|
+
* CLI also reads on its own. */
|
|
369
|
+
const ANTHROPIC_KEY_VAR = "ANTHROPIC_API_KEY";
|
|
318
370
|
/** Loopback authorities the container→host crossing rewrites. A base URL
|
|
319
371
|
* pointing at one of these is local to wherever volley runs; contained, that is
|
|
320
372
|
* *inside* the sandbox container, where the host LLM daemon is unreachable
|
|
@@ -361,9 +413,15 @@ const SEAT_URL_SOURCES = {
|
|
|
361
413
|
env: "VOLLEY_LMSTUDIO_URL",
|
|
362
414
|
default_url: DEFAULT_LMSTUDIO_URL,
|
|
363
415
|
normalize: strip_trailing_slashes
|
|
416
|
+
},
|
|
417
|
+
openrouter: {
|
|
418
|
+
env: "VOLLEY_OPENROUTER_URL",
|
|
419
|
+
default_url: DEFAULT_OPENROUTER_URL,
|
|
420
|
+
normalize: strip_trailing_slashes
|
|
364
421
|
}
|
|
365
422
|
};
|
|
366
|
-
/** The providers that take a seat URL (`--builder-url` / `--critic-url`)
|
|
423
|
+
/** The providers that take a seat URL (`--builder-url` / `--critic-url`), and
|
|
424
|
+
* so get their endpoint probed by `--dry-run`. */
|
|
367
425
|
const SEAT_URL_PROVIDERS = Object.keys(SEAT_URL_SOURCES);
|
|
368
426
|
/** Whether a seat on `provider` reaches its model at a base URL volley
|
|
369
427
|
* configures, and so takes a URL of its own. */
|
|
@@ -376,16 +434,25 @@ function present(value) {
|
|
|
376
434
|
return trimmed === "" ? null : trimmed;
|
|
377
435
|
}
|
|
378
436
|
/** The base URL a seat's model calls go to: the seat's own URL, else the
|
|
379
|
-
* provider's env var (`VOLLEY_OLLAMA_URL` / `VOLLEY_LMSTUDIO_URL`
|
|
380
|
-
* default. A seat never falls back to the
|
|
381
|
-
* normalized, then a loopback authority is
|
|
382
|
-
* when volley runs contained. Both seats can
|
|
383
|
-
* ports, and each crosses on its own port. */
|
|
437
|
+
* provider's env var (`VOLLEY_OLLAMA_URL` / `VOLLEY_LMSTUDIO_URL` /
|
|
438
|
+
* `VOLLEY_OPENROUTER_URL`), else its default. A seat never falls back to the
|
|
439
|
+
* other seat's URL. The result is normalized, then a loopback authority is
|
|
440
|
+
* crossed to the host LLM endpoint when volley runs contained. Both seats can
|
|
441
|
+
* be loopback URLs on different ports, and each crosses on its own port. */
|
|
384
442
|
function seat_base_url(provider, seat_url, env) {
|
|
385
443
|
const source = SEAT_URL_SOURCES[provider];
|
|
386
444
|
const raw = present(seat_url) ?? present(env[source.env]) ?? source.default_url;
|
|
387
445
|
return cross_to_host_gateway(source.normalize(raw), env);
|
|
388
446
|
}
|
|
447
|
+
/** The API key an `openrouter` seat sends, from `OPENROUTER_API_KEY`. An unset
|
|
448
|
+
* or blank key is a config error naming the variable, which `resolve_config`
|
|
449
|
+
* raises before any work, because fascicle's own "requires a non-empty
|
|
450
|
+
* api_key" doesn't say where the key comes from. */
|
|
451
|
+
function openrouter_api_key(env) {
|
|
452
|
+
const key = present(env[OPENROUTER_KEY_VAR]);
|
|
453
|
+
if (key === null) throw config_error(`${OPENROUTER_KEY_VAR} is not set: an openrouter seat sends it as its API key`);
|
|
454
|
+
return key;
|
|
455
|
+
}
|
|
389
456
|
function load_pricing_overrides(path) {
|
|
390
457
|
if (!existsSync(path)) throw config_error(`VOLLEY_PRICING_PATH not found: ${path}`);
|
|
391
458
|
try {
|
|
@@ -398,29 +465,44 @@ function load_pricing_overrides(path) {
|
|
|
398
465
|
* peer (`ai-sdk-ollama` / `@ai-sdk/openai-compatible`) is loaded lazily by
|
|
399
466
|
* fascicle only when the provider actually runs. The entry is complete, because
|
|
400
467
|
* `Engine.with_providers` replaces a provider's whole entry: whatever this
|
|
401
|
-
* returns is all a seat's derived engine knows about the provider
|
|
468
|
+
* returns is all a seat's derived engine knows about the provider, so an
|
|
469
|
+
* `openrouter` entry carries its key and its transport along with its URL.
|
|
470
|
+
*
|
|
471
|
+
* `openrouter` runs on fascicle's native transport, its OpenAI-compatible core,
|
|
472
|
+
* so no ai-sdk peer gets installed for it, and its `X-Title` attributes the
|
|
473
|
+
* traffic to volley.
|
|
402
474
|
*
|
|
403
|
-
*
|
|
404
|
-
* on the ai_sdk transport, which fascicle 0.14.0 already
|
|
405
|
-
* (`transport?: 'ai_sdk' | 'native'` defaults to `'ai_sdk'`),
|
|
406
|
-
* needed today. Flipping later is a one-line change — add
|
|
407
|
-
* to the ollama/lmstudio entry returned below, which both
|
|
408
|
-
* share. No URL re-pointing is required: `seat_base_url`
|
|
409
|
-
* daemon-root URL the native `/api/chat` transport wants
|
|
410
|
-
* `/api/chat` itself), which is exactly what the v0.3.1
|
|
475
|
+
* For the local providers, native transport is the deferred future bridge, not
|
|
476
|
+
* adopted here: they stay on the ai_sdk transport, which fascicle 0.14.0 already
|
|
477
|
+
* selects by default (`transport?: 'ai_sdk' | 'native'` defaults to `'ai_sdk'`),
|
|
478
|
+
* so no field is needed today. Flipping later is a one-line change — add
|
|
479
|
+
* `transport: 'native'` to the ollama/lmstudio entry returned below, which both
|
|
480
|
+
* seats' engines then share. No URL re-pointing is required: `seat_base_url`
|
|
481
|
+
* already yields the daemon-root URL the native `/api/chat` transport wants
|
|
482
|
+
* (its adapter appends `/api/chat` itself), which is exactly what the v0.3.1
|
|
483
|
+
* base-URL fix produces.
|
|
411
484
|
* Kept as a proven-once-then-reverted option so the transport stays a clean
|
|
412
485
|
* second variable in the model-vs-transport comparison. Flipping it today would
|
|
413
486
|
* also lose the reasoning: fascicle's native Ollama adapter drops
|
|
414
487
|
* `message.thinking`, with `think` unset or true (robmclarty/fascicle#12), so a
|
|
415
488
|
* thinking seat's reasoning would vanish from the terminal and the trajectory.
|
|
416
489
|
*
|
|
417
|
-
*
|
|
418
|
-
* volley runs contained, so an in-container model client reaches the
|
|
490
|
+
* Every provider's base URL crosses a loopback authority to `VOLLEY_MODEL_HOST`
|
|
491
|
+
* when volley runs contained, so an in-container model client reaches the
|
|
419
492
|
* host LLM endpoint at `host.docker.internal`; on the host / unsandboxed path the
|
|
420
493
|
* override is unset and this is a no-op. */
|
|
421
494
|
function seat_provider_config(provider, seat_url, env) {
|
|
422
495
|
const base_url = seat_base_url(provider, seat_url, env);
|
|
423
|
-
|
|
496
|
+
switch (provider) {
|
|
497
|
+
case "ollama": return { ollama: { base_url } };
|
|
498
|
+
case "lmstudio": return { lmstudio: { base_url } };
|
|
499
|
+
case "openrouter": return { openrouter: {
|
|
500
|
+
api_key: openrouter_api_key(env),
|
|
501
|
+
base_url,
|
|
502
|
+
transport: "native",
|
|
503
|
+
x_title: "volley"
|
|
504
|
+
} };
|
|
505
|
+
}
|
|
424
506
|
}
|
|
425
507
|
/** Per-run engine. `claude_cli` is always configured with `options.workspace`
|
|
426
508
|
* (the run's build root) as its session cwd; any URL-taking provider selected
|
|
@@ -440,7 +522,7 @@ function create_volley_engine(options) {
|
|
|
440
522
|
setting_sources: ["project", "local"],
|
|
441
523
|
auth_mode,
|
|
442
524
|
...binary !== void 0 && binary.length > 0 ? { binary } : {},
|
|
443
|
-
...auth_mode === "api_key" && env[
|
|
525
|
+
...auth_mode === "api_key" && env[ANTHROPIC_KEY_VAR] !== void 0 ? { api_key: env[ANTHROPIC_KEY_VAR] } : {}
|
|
444
526
|
} };
|
|
445
527
|
if (takes_seat_url(options.critic_provider)) Object.assign(providers, seat_provider_config(options.critic_provider, options.critic_url ?? null, env));
|
|
446
528
|
if (takes_seat_url(options.builder_provider)) Object.assign(providers, seat_provider_config(options.builder_provider, options.builder_url ?? null, env));
|
|
@@ -479,6 +561,264 @@ async function dispose_seat_engines(engines) {
|
|
|
479
561
|
const distinct = engines.critic === engines.builder ? [engines.builder] : [engines.builder, engines.critic];
|
|
480
562
|
await Promise.all(distinct.map((engine) => engine.dispose()));
|
|
481
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
|
+
}
|
|
482
822
|
const DEFAULT_CRITIC_MODEL = "opus";
|
|
483
823
|
const DEFAULT_CRITIC = "reviewer";
|
|
484
824
|
const DEFAULT_BUILDER_PROVIDER = "claude_cli";
|
|
@@ -490,18 +830,24 @@ const DEFAULT_SANDBOX_IMAGE = "volley-sandbox:latest";
|
|
|
490
830
|
const BUILDER_PROVIDERS = [
|
|
491
831
|
"claude_cli",
|
|
492
832
|
"ollama",
|
|
493
|
-
"lmstudio"
|
|
833
|
+
"lmstudio",
|
|
834
|
+
"openrouter"
|
|
835
|
+
];
|
|
836
|
+
/** Builder providers that drive their model through volley's own tool loop —
|
|
837
|
+
* a real `bash` (write + exec), wherever the model runs. Refused unless volley
|
|
838
|
+
* is running inside a container (the correct blast-radius default) or the
|
|
839
|
+
* operator explicitly opts out; `claude_cli` has its own permission model and
|
|
840
|
+
* is exempt. */
|
|
841
|
+
const TOOL_LOOP_BUILDER_PROVIDERS = [
|
|
842
|
+
"ollama",
|
|
843
|
+
"lmstudio",
|
|
844
|
+
"openrouter"
|
|
494
845
|
];
|
|
495
|
-
/** Builder providers that run a local model through volley's own tool loop —
|
|
496
|
-
* a real `bash` (write + exec). Refused unless volley is running
|
|
497
|
-
* inside a container (the correct blast-radius default) or the operator
|
|
498
|
-
* explicitly opts out; `claude_cli` has its own permission model and is
|
|
499
|
-
* exempt. */
|
|
500
|
-
const LOCAL_BUILDER_PROVIDERS = ["ollama", "lmstudio"];
|
|
501
846
|
const CRITIC_PROVIDERS = [
|
|
502
847
|
"claude_cli",
|
|
503
848
|
"ollama",
|
|
504
|
-
"lmstudio"
|
|
849
|
+
"lmstudio",
|
|
850
|
+
"openrouter"
|
|
505
851
|
];
|
|
506
852
|
const CRITIC_PRESETS = [
|
|
507
853
|
"reviewer",
|
|
@@ -555,17 +901,49 @@ function resolve_critic(critic, cwd) {
|
|
|
555
901
|
critic_prompt_path: path
|
|
556
902
|
};
|
|
557
903
|
}
|
|
558
|
-
/**
|
|
559
|
-
*
|
|
560
|
-
*
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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)}`);
|
|
569
947
|
return value;
|
|
570
948
|
}
|
|
571
949
|
/** `--sandbox-image` (a merged flag/config value) wins over the
|
|
@@ -580,7 +958,8 @@ function resolve_sandbox_image(raw, env) {
|
|
|
580
958
|
}
|
|
581
959
|
/** The `--allow-unsandboxed-builder` flag/config value wins; otherwise
|
|
582
960
|
* `VOLLEY_ALLOW_UNSANDBOXED_BUILDER=1` (or `=true`) opts out. Any other value
|
|
583
|
-
* (incl. `0`/`false`/unset) leaves
|
|
961
|
+
* (incl. `0`/`false`/unset) leaves a tool-loop builder refused unless
|
|
962
|
+
* contained. */
|
|
584
963
|
function resolve_allow_unsandboxed_builder(raw, env) {
|
|
585
964
|
if (raw.allow_unsandboxed_builder === true) return true;
|
|
586
965
|
const env_value = env["VOLLEY_ALLOW_UNSANDBOXED_BUILDER"];
|
|
@@ -600,7 +979,7 @@ function resolve_seat_url(seat, value, provider) {
|
|
|
600
979
|
if (typeof value !== "string") throw config_error(`${setting} must be a URL string; got a ${typeof value}`);
|
|
601
980
|
const url = value.trim();
|
|
602
981
|
if (url === "") return null;
|
|
603
|
-
if (!takes_seat_url(provider)) throw config_error(`${setting} applies only to a ${seat} on ${
|
|
982
|
+
if (!takes_seat_url(provider)) throw config_error(`${setting} applies only to a ${seat} on ${new Intl.ListFormat("en", { type: "disjunction" }).format(SEAT_URL_PROVIDERS)}; ${seat}-provider is ${provider}`);
|
|
604
983
|
let parsed;
|
|
605
984
|
try {
|
|
606
985
|
parsed = new URL(url);
|
|
@@ -612,22 +991,46 @@ function resolve_seat_url(seat, value, provider) {
|
|
|
612
991
|
if (parsed.search !== "" || parsed.hash !== "") throw config_error(`${setting} must not carry a query or fragment; got: ${url}`);
|
|
613
992
|
return url;
|
|
614
993
|
}
|
|
615
|
-
/**
|
|
616
|
-
*
|
|
617
|
-
*
|
|
618
|
-
*
|
|
619
|
-
*
|
|
620
|
-
*
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
const
|
|
625
|
-
return
|
|
994
|
+
/** `--state-dir` (`state_dir`): the root each run's state sits under at
|
|
995
|
+
* `<root>/<run_id>/`, resolved against `cwd` so a resumed run finds the same
|
|
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);
|
|
1002
|
+
if (typeof value !== "string") throw config_error(`--state-dir (state_dir) must be a path string; got a ${typeof value}`);
|
|
1003
|
+
const path = value.trim();
|
|
1004
|
+
return path === "" ? default_state_root(env) : resolve(cwd, path);
|
|
1005
|
+
}
|
|
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
|
+
}
|
|
626
1014
|
}
|
|
627
|
-
/**
|
|
628
|
-
*
|
|
629
|
-
*
|
|
630
|
-
|
|
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`);
|
|
1026
|
+
}
|
|
1027
|
+
/** Whether a tool-loop builder's commands would reach the host with nothing in
|
|
1028
|
+
* between: volley isn't contained, and the operator hasn't opted out with
|
|
1029
|
+
* `--allow-unsandboxed-builder` or `VOLLEY_ALLOW_UNSANDBOXED_BUILDER`. */
|
|
1030
|
+
function unguarded_builder(raw, env) {
|
|
1031
|
+
return !detect_containment(env) && !resolve_allow_unsandboxed_builder(raw, env);
|
|
1032
|
+
}
|
|
1033
|
+
/** A caller's run id, held to `RUN_ID_PATTERN`; none gets a fresh UUID. */
|
|
631
1034
|
function resolve_run_id(run_id) {
|
|
632
1035
|
if (run_id === void 0) return randomUUID();
|
|
633
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}`);
|
|
@@ -639,13 +1042,17 @@ function resolve_run_id(run_id) {
|
|
|
639
1042
|
function resolve_config(raw, options = {}) {
|
|
640
1043
|
const cwd = options.cwd ?? process.cwd();
|
|
641
1044
|
const env = options.env ?? process.env;
|
|
642
|
-
|
|
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");
|
|
643
1050
|
if (typeof raw.workspace !== "string" || raw.workspace.length === 0) throw config_error("missing required option: --workspace");
|
|
644
1051
|
if (typeof raw.criteria !== "string" || raw.criteria.length === 0) throw config_error("missing required option: --criteria");
|
|
645
1052
|
const workspace = validate_workspace(resolve(cwd, raw.workspace));
|
|
646
|
-
const prompt = expand_at_file(raw.prompt, cwd);
|
|
1053
|
+
const prompt = has_prompt ? expand_at_file(raw.prompt, cwd) : "";
|
|
647
1054
|
const criteria = expand_at_file(raw.criteria, cwd);
|
|
648
|
-
const max_iterations = raw.max_iterations ?? 10;
|
|
1055
|
+
const max_iterations = raw.max_iterations ?? (critique ? 1 : 10);
|
|
649
1056
|
if (!Number.isInteger(max_iterations) || max_iterations < 1) throw config_error(`--max-iterations must be a positive integer; got: ${String(raw.max_iterations)}`);
|
|
650
1057
|
const max_cost_usd = raw.max_cost_usd ?? null;
|
|
651
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)}`);
|
|
@@ -654,16 +1061,18 @@ function resolve_config(raw, options = {}) {
|
|
|
654
1061
|
const { critic_preset, critic_prompt_path } = resolve_critic(raw.critic ?? DEFAULT_CRITIC, cwd);
|
|
655
1062
|
const builder_provider = raw.builder_provider ?? DEFAULT_BUILDER_PROVIDER;
|
|
656
1063
|
if (!BUILDER_PROVIDERS.includes(builder_provider)) throw config_error(`--builder-provider must be one of ${BUILDER_PROVIDERS.join(", ")}; got: ${String(raw.builder_provider)}`);
|
|
657
|
-
const allow_unsandboxed_builder = resolve_allow_unsandboxed_builder(raw,
|
|
1064
|
+
const allow_unsandboxed_builder = resolve_allow_unsandboxed_builder(raw, builder_env);
|
|
658
1065
|
const contained = detect_containment(env);
|
|
659
|
-
if (
|
|
660
|
-
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);
|
|
661
1069
|
const critic_provider = raw.critic_provider ?? DEFAULT_CRITIC_PROVIDER;
|
|
662
1070
|
if (!CRITIC_PROVIDERS.includes(critic_provider)) throw config_error(`--critic-provider must be one of ${CRITIC_PROVIDERS.join(", ")}; got: ${String(raw.critic_provider)}`);
|
|
663
1071
|
const builder_url = resolve_seat_url("builder", raw.builder_url, builder_provider);
|
|
664
1072
|
const critic_url = resolve_seat_url("critic", raw.critic_url, critic_provider);
|
|
1073
|
+
if (builder_provider === "openrouter" || critic_provider === "openrouter") openrouter_api_key(env);
|
|
665
1074
|
if (contained) {
|
|
666
|
-
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);
|
|
667
1076
|
const auth_mode = env["VOLLEY_AUTH_MODE"] ?? "auto";
|
|
668
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.`);
|
|
669
1078
|
}
|
|
@@ -674,13 +1083,17 @@ function resolve_config(raw, options = {}) {
|
|
|
674
1083
|
if (discard_worktree && !worktree) throw config_error("--discard-worktree applies only to a --worktree run: there is no worktree to discard");
|
|
675
1084
|
const gate_paths = raw.gate_paths ?? DEFAULT_GATE_PATTERNS;
|
|
676
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");
|
|
677
|
-
const sandbox_image = resolve_sandbox_image(raw,
|
|
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}`);
|
|
678
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);
|
|
679
1091
|
const check = raw.check ?? "auto";
|
|
680
1092
|
return {
|
|
681
1093
|
version: 2,
|
|
682
1094
|
run_id,
|
|
683
1095
|
started_at: options.started_at ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
1096
|
+
mode: critique ? "critique" : "run",
|
|
684
1097
|
prompt,
|
|
685
1098
|
criteria,
|
|
686
1099
|
check,
|
|
@@ -689,6 +1102,7 @@ function resolve_config(raw, options = {}) {
|
|
|
689
1102
|
builder_provider,
|
|
690
1103
|
builder_url,
|
|
691
1104
|
builder_max_steps,
|
|
1105
|
+
builder_max_tool_calls,
|
|
692
1106
|
allow_unsandboxed_builder,
|
|
693
1107
|
critic_model: raw.critic_model ?? DEFAULT_CRITIC_MODEL,
|
|
694
1108
|
critic_provider,
|
|
@@ -704,6 +1118,7 @@ function resolve_config(raw, options = {}) {
|
|
|
704
1118
|
gate_paths: [...gate_paths],
|
|
705
1119
|
fail_on_gate_edit: raw.fail_on_gate_edit ?? false,
|
|
706
1120
|
sandbox_image,
|
|
1121
|
+
state_dir,
|
|
707
1122
|
workspace,
|
|
708
1123
|
verbose: raw.verbose ?? false,
|
|
709
1124
|
quiet: raw.quiet ?? false,
|
|
@@ -782,8 +1197,9 @@ async function warn_small_local_context(provider, model, base_url, warn, abort)
|
|
|
782
1197
|
*/
|
|
783
1198
|
const READ_FILE_MAX_BYTES = 2e5;
|
|
784
1199
|
const LIST_MAX_ENTRIES = 2e3;
|
|
785
|
-
/** Directories never worth handing a
|
|
786
|
-
* traversal budget.
|
|
1200
|
+
/** Directories never worth handing a seat; they bury signal and blow the
|
|
1201
|
+
* traversal budget. The acp seats' `list_files` and `search` skip the same
|
|
1202
|
+
* names (`./acp_tools.ts`), so a seat sees one tree under acp and on the CLI. */
|
|
787
1203
|
const IGNORED_DIRS = /* @__PURE__ */ new Set([
|
|
788
1204
|
"node_modules",
|
|
789
1205
|
".git",
|
|
@@ -796,7 +1212,7 @@ const IGNORED_DIRS = /* @__PURE__ */ new Set([
|
|
|
796
1212
|
* escape. Returns the absolute path; throws on containment violation. The
|
|
797
1213
|
* `workspace` root passed here is the build root, which is the per-run git
|
|
798
1214
|
* worktree under `--worktree` (`build_root`), not necessarily the
|
|
799
|
-
*
|
|
1215
|
+
* workspace the run was started on. */
|
|
800
1216
|
function contain(workspace, rel) {
|
|
801
1217
|
const root = resolve(workspace);
|
|
802
1218
|
const candidate = isAbsolute(rel) ? resolve(rel) : resolve(root, rel);
|
|
@@ -835,6 +1251,25 @@ function walk(root, start, limit) {
|
|
|
835
1251
|
}
|
|
836
1252
|
return out;
|
|
837
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
|
+
}
|
|
838
1273
|
function build_regex(pattern, flags) {
|
|
839
1274
|
try {
|
|
840
1275
|
return new RegExp(pattern, flags);
|
|
@@ -846,11 +1281,11 @@ function build_regex(pattern, flags) {
|
|
|
846
1281
|
const read_file_input = z.object({ path: z.string().describe("Workspace-relative path to the file to read.") });
|
|
847
1282
|
const search_files_input = z.object({
|
|
848
1283
|
pattern: z.string().describe("JavaScript regular expression to match against each line."),
|
|
849
|
-
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."),
|
|
850
1285
|
ignore_case: z.boolean().optional().describe("Case-insensitive match. Default false.")
|
|
851
1286
|
});
|
|
852
1287
|
const list_files_input = z.object({
|
|
853
|
-
path: z.string().optional().describe("Optional workspace-relative
|
|
1288
|
+
path: z.string().optional().describe("Optional workspace-relative directory or file. Default: the workspace root."),
|
|
854
1289
|
contains: z.string().optional().describe("Optional substring filter applied to each relative path.")
|
|
855
1290
|
});
|
|
856
1291
|
function read_only_tools(workspace) {
|
|
@@ -871,9 +1306,8 @@ function read_only_tools(workspace) {
|
|
|
871
1306
|
input_schema: search_files_input,
|
|
872
1307
|
execute: (raw) => {
|
|
873
1308
|
const input = search_files_input.parse(raw);
|
|
874
|
-
const start = input.path !== void 0 ? contain(workspace, input.path) : resolve(workspace);
|
|
875
1309
|
const regex = build_regex(input.pattern, input.ignore_case === true ? "i" : "");
|
|
876
|
-
const files =
|
|
1310
|
+
const files = scoped_files(workspace, input.path, LIST_MAX_ENTRIES);
|
|
877
1311
|
const hits = [];
|
|
878
1312
|
for (const file of files) {
|
|
879
1313
|
if (hits.length >= 200) break;
|
|
@@ -901,8 +1335,7 @@ function read_only_tools(workspace) {
|
|
|
901
1335
|
input_schema: list_files_input,
|
|
902
1336
|
execute: (raw) => {
|
|
903
1337
|
const input = list_files_input.parse(raw);
|
|
904
|
-
|
|
905
|
-
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);
|
|
906
1339
|
if (input.contains !== void 0) {
|
|
907
1340
|
const needle = input.contains;
|
|
908
1341
|
files = files.filter((f) => f.includes(needle));
|
|
@@ -1295,7 +1728,19 @@ function builder_tools(workspace, options = {}) {
|
|
|
1295
1728
|
max_bytes: options.fetch_max_bytes ?? FETCH_MAX_BYTES
|
|
1296
1729
|
})
|
|
1297
1730
|
};
|
|
1298
|
-
|
|
1731
|
+
return [
|
|
1732
|
+
...read_only_tools(workspace),
|
|
1733
|
+
write_file,
|
|
1734
|
+
edit_file,
|
|
1735
|
+
bash,
|
|
1736
|
+
fetch,
|
|
1737
|
+
finish_tool()
|
|
1738
|
+
];
|
|
1739
|
+
}
|
|
1740
|
+
/** The terminal `finish` tool, on its own for a builder set that brings its
|
|
1741
|
+
* own file and command tools (`../acp_tools.ts`). */
|
|
1742
|
+
function finish_tool() {
|
|
1743
|
+
return {
|
|
1299
1744
|
name: "finish",
|
|
1300
1745
|
description: "Declare the task complete. Calling this tool ends your turn — do not call it until the work is done and verified.",
|
|
1301
1746
|
input_schema: finish_input,
|
|
@@ -1304,14 +1749,6 @@ function builder_tools(workspace, options = {}) {
|
|
|
1304
1749
|
return `finished: ${finish_input.parse(raw).summary}`;
|
|
1305
1750
|
}
|
|
1306
1751
|
};
|
|
1307
|
-
return [
|
|
1308
|
-
...read_only_tools(workspace),
|
|
1309
|
-
write_file,
|
|
1310
|
-
edit_file,
|
|
1311
|
-
bash,
|
|
1312
|
-
fetch,
|
|
1313
|
-
finish
|
|
1314
|
-
];
|
|
1315
1752
|
}
|
|
1316
1753
|
//#endregion
|
|
1317
1754
|
//#region src/cost.ts
|
|
@@ -1339,9 +1776,13 @@ function add_usage(a, b) {
|
|
|
1339
1776
|
}
|
|
1340
1777
|
return sum;
|
|
1341
1778
|
}
|
|
1779
|
+
/** The providers whose calls carry the charge they report, which fascicle
|
|
1780
|
+
* records as the call's cost in place of a pricing table's estimate: the
|
|
1781
|
+
* Claude CLI's own total, and OpenRouter's per-response charge. */
|
|
1782
|
+
const REPORTING_PROVIDERS = /* @__PURE__ */ new Set(["claude_cli", "openrouter"]);
|
|
1342
1783
|
function cost_source_of(result) {
|
|
1343
1784
|
if (result.cost === void 0) return "unknown";
|
|
1344
|
-
return result.model_resolved.provider
|
|
1785
|
+
return REPORTING_PROVIDERS.has(result.model_resolved.provider) ? "provider_reported" : "engine_derived";
|
|
1345
1786
|
}
|
|
1346
1787
|
/** One phase's record from its generate result. The duration is fascicle's
|
|
1347
1788
|
* call-level wall clock (`GenerateResult.timing`: every turn and the tools
|
|
@@ -1376,170 +1817,56 @@ function accumulate(state, role, result, model, duration_ms) {
|
|
|
1376
1817
|
iteration_cost_usd: state.iteration_cost_usd + cost,
|
|
1377
1818
|
builder_cost_usd: state.builder_cost_usd + (role === "builder" ? cost : 0),
|
|
1378
1819
|
critic_cost_usd: state.critic_cost_usd + (role === "critic" ? cost : 0),
|
|
1379
|
-
cost_warned: state.cost_warned || record.cost_usd === null
|
|
1380
|
-
};
|
|
1381
|
-
}
|
|
1382
|
-
function cost_cap_hit(config, state) {
|
|
1383
|
-
return config.max_cost_usd !== null && state.total_cost_usd >= config.max_cost_usd;
|
|
1384
|
-
}
|
|
1385
|
-
//#endregion
|
|
1386
|
-
//#region src/prewarm.ts
|
|
1387
|
-
/**
|
|
1388
|
-
* Best-effort Ollama model pre-load. A cold multi-GB model can spend minutes
|
|
1389
|
-
* in load + first prompt eval — longer than the provider path's in-request
|
|
1390
|
-
* header timeout — so the first real `generate` dies with an opaque
|
|
1391
|
-
* `stream interrupted: fetch failed`. Ollama's documented load call (a
|
|
1392
|
-
* `/api/generate` POST with a model and no prompt) returns as soon as the
|
|
1393
|
-
* model is resident, so absorbing the load here keeps the real call's
|
|
1394
|
-
* time-to-first-byte inside its budget.
|
|
1395
|
-
*
|
|
1396
|
-
* Best-effort like the context probe: any failure (server down, unknown
|
|
1397
|
-
* model, timeout) is swallowed — the real generate call surfaces the error
|
|
1398
|
-
* with retry and reporting behind it. Ollama-only; LM Studio has no
|
|
1399
|
-
* equivalent load-without-generating endpoint.
|
|
1400
|
-
*/
|
|
1401
|
-
/** Generous ceiling on the load itself: a disk-cold 30B-class model takes
|
|
1402
|
-
* tens of seconds to minutes. Past this, give up and let the real call try. */
|
|
1403
|
-
const PREWARM_TIMEOUT_MS = 3e5;
|
|
1404
|
-
/** Load `model` into the Ollama server's memory (`base_url` is the server
|
|
1405
|
-
* root). Resolves when the model is resident, on any failure, or at the
|
|
1406
|
-
* timeout — never throws, never gates the phase. An already-resident model
|
|
1407
|
-
* returns immediately, so warming the same model for both roles is free.
|
|
1408
|
-
*
|
|
1409
|
-
* Load-bearing only on the ai_sdk transport: the cold-load death this
|
|
1410
|
-
* absorbs is the ai-sdk/undici in-request header timeout, not an Ollama
|
|
1411
|
-
* limit. On fascicle's native transport — the deferred future bridge, see
|
|
1412
|
-
* `seat_provider_config` in engine.ts — the per-turn call is a bare
|
|
1413
|
-
* `fetch(..., { signal })` with no default body timeout, so a cold model can
|
|
1414
|
-
* take as long as it needs and this pre-warm's necessity drops away (keep it
|
|
1415
|
-
* regardless: a resident model makes it a no-op). Native would also move the
|
|
1416
|
-
* `num_ctx`/`keep_alive` levers to per-call `provider_options.ollama`, rather
|
|
1417
|
-
* than the server-side Modelfile defaults volley probes today. */
|
|
1418
|
-
async function prewarm_ollama_model(base_url, model, abort) {
|
|
1419
|
-
const signals = [AbortSignal.timeout(PREWARM_TIMEOUT_MS)];
|
|
1420
|
-
if (abort !== void 0) signals.push(abort);
|
|
1421
|
-
try {
|
|
1422
|
-
await fetch(`${base_url.replace(/\/$/, "")}/api/generate`, {
|
|
1423
|
-
method: "POST",
|
|
1424
|
-
headers: { "content-type": "application/json" },
|
|
1425
|
-
body: JSON.stringify({ model }),
|
|
1426
|
-
signal: AbortSignal.any(signals)
|
|
1427
|
-
});
|
|
1428
|
-
} catch {}
|
|
1429
|
-
}
|
|
1430
|
-
//#endregion
|
|
1431
|
-
//#region src/workspace.ts
|
|
1432
|
-
/**
|
|
1433
|
-
* Workspace lifecycle: `.volley/` initialization, stale-run
|
|
1434
|
-
* backup rotation, path helpers, and optional git checkpoints.
|
|
1435
|
-
*/
|
|
1436
|
-
function volley_path(workspace, ...parts) {
|
|
1437
|
-
return join(workspace, ".volley", ...parts);
|
|
1438
|
-
}
|
|
1439
|
-
function iteration_dir(workspace, iteration) {
|
|
1440
|
-
return volley_path(workspace, "iterations", String(iteration).padStart(3, "0"));
|
|
1441
|
-
}
|
|
1442
|
-
/** Filesystem-safe timestamp for `.bak.<stamp>` rotation names. Shared by the
|
|
1443
|
-
* `.volley/` rotation here and the worktree rotation so both preserve
|
|
1444
|
-
* prior state under the same never-destroy naming. */
|
|
1445
|
-
function backup_stamp(date = /* @__PURE__ */ new Date()) {
|
|
1446
|
-
return date.toISOString().replace(/[:.]/g, "-");
|
|
1447
|
-
}
|
|
1448
|
-
/** Fresh-run initialization: rotate a stale `.volley/` aside, then create the
|
|
1449
|
-
* directory tree. Resume runs skip rotation (`preserve: true`). */
|
|
1450
|
-
function initialize_workspace(workspace, options = {}) {
|
|
1451
|
-
const dir = volley_path(workspace);
|
|
1452
|
-
if (existsSync(dir) && options.preserve !== true) renameSync(dir, join(workspace, `.volley.bak.${backup_stamp()}`));
|
|
1453
|
-
mkdirSync(volley_path(workspace, "iterations"), { recursive: true });
|
|
1454
|
-
}
|
|
1455
|
-
function write_resolved_config(config) {
|
|
1456
|
-
const record = {
|
|
1457
|
-
version: config.version,
|
|
1458
|
-
run_id: config.run_id,
|
|
1459
|
-
started_at: config.started_at,
|
|
1460
|
-
prompt: config.prompt,
|
|
1461
|
-
criteria: config.criteria,
|
|
1462
|
-
check: config.check,
|
|
1463
|
-
check_resolved: config.check_resolved,
|
|
1464
|
-
builder_model: config.builder_model,
|
|
1465
|
-
builder_provider: config.builder_provider,
|
|
1466
|
-
builder_url: config.builder_url,
|
|
1467
|
-
builder_max_steps: config.builder_max_steps,
|
|
1468
|
-
critic_model: config.critic_model,
|
|
1469
|
-
critic_provider: config.critic_provider,
|
|
1470
|
-
critic_url: config.critic_url,
|
|
1471
|
-
builder_permission_mode: config.builder_permission_mode,
|
|
1472
|
-
critic_preset: config.critic_preset,
|
|
1473
|
-
critic_prompt_path: config.critic_prompt_path,
|
|
1474
|
-
max_iterations: config.max_iterations,
|
|
1475
|
-
max_cost_usd: config.max_cost_usd,
|
|
1476
|
-
git_checkpoints: config.git_checkpoints,
|
|
1477
|
-
worktree: config.worktree,
|
|
1478
|
-
discard_worktree: config.discard_worktree,
|
|
1479
|
-
gate_paths: config.gate_paths,
|
|
1480
|
-
fail_on_gate_edit: config.fail_on_gate_edit,
|
|
1481
|
-
sandbox_image: config.sandbox_image,
|
|
1482
|
-
workspace: config.workspace
|
|
1483
|
-
};
|
|
1484
|
-
writeFileSync(volley_path(config.workspace, "config.json"), `${JSON.stringify(record, null, 2)}\n`);
|
|
1485
|
-
}
|
|
1486
|
-
/**
|
|
1487
|
-
* `--git`: commit everything under `root` after a phase (`git -C <root>`, run
|
|
1488
|
-
* here via the spawn `cwd`). `root` is the build root (`build_root`): the
|
|
1489
|
-
* workspace on the default path, or the per-run worktree under `--worktree`, so
|
|
1490
|
-
* checkpoints land on the worktree branch rather than the workspace's.
|
|
1491
|
-
* A checkpoint with nothing to commit is fine; a missing git binary or repo is
|
|
1492
|
-
* not.
|
|
1493
|
-
*/
|
|
1494
|
-
function git_checkpoint(root, message) {
|
|
1495
|
-
if (!existsSync(join(root, ".git"))) throw config_error(`git checkpoints require a git repository: ${root}`);
|
|
1496
|
-
const add = spawnSync("git", ["add", "-A"], { cwd: root });
|
|
1497
|
-
if (add.error !== void 0 || add.status !== 0) throw check_error(`git add failed: ${add.stderr?.toString() ?? String(add.error)}`);
|
|
1498
|
-
const commit = spawnSync("git", [
|
|
1499
|
-
"commit",
|
|
1500
|
-
"-m",
|
|
1501
|
-
message,
|
|
1502
|
-
"--no-verify"
|
|
1503
|
-
], { cwd: root });
|
|
1504
|
-
if (commit.error !== void 0) throw check_error(`git commit failed to start: ${String(commit.error)}`);
|
|
1505
|
-
const out = `${commit.stdout?.toString() ?? ""}${commit.stderr?.toString() ?? ""}`;
|
|
1506
|
-
if (commit.status !== 0 && !out.includes("nothing to commit")) throw check_error(`git commit failed: ${out.trim()}`);
|
|
1507
|
-
}
|
|
1508
|
-
/**
|
|
1509
|
-
* Integration — bring a successful `--worktree --git` run's effects onto the
|
|
1510
|
-
* workspace branch. The per-phase checkpoints on `branch` are the raw
|
|
1511
|
-
* audit/replay trail; here they collapse into a single squash commit
|
|
1512
|
-
* (`git merge --squash` then `commit`) on the workspace's current branch. The
|
|
1513
|
-
* shared object store makes this a local, fetch-free merge, and — because the
|
|
1514
|
-
* workspace branch never moved during the run (checkpoints went to the phase
|
|
1515
|
-
* branch) — it is conflict-free by construction. A run whose phase branch has no
|
|
1516
|
-
* new commits integrates to a no-op. Only successful runs integrate; an
|
|
1517
|
-
* abandoned run's branch is discarded wholesale by the teardown trio instead.
|
|
1518
|
-
*/
|
|
1519
|
-
function integrate_worktree(workspace, branch, message) {
|
|
1520
|
-
if (!existsSync(join(workspace, ".git"))) throw config_error(`--worktree integration requires a git repository: ${workspace}`);
|
|
1521
|
-
const squash = spawnSync("git", [
|
|
1522
|
-
"merge",
|
|
1523
|
-
"--squash",
|
|
1524
|
-
branch
|
|
1525
|
-
], {
|
|
1526
|
-
cwd: workspace,
|
|
1527
|
-
encoding: "utf8"
|
|
1528
|
-
});
|
|
1529
|
-
if (squash.error !== void 0) throw check_error(`git merge --squash failed to start: ${String(squash.error)}`);
|
|
1530
|
-
if (squash.status !== 0) throw check_error(`git merge --squash ${branch} failed: ${(squash.stderr || squash.stdout).trim()}`);
|
|
1531
|
-
const commit = spawnSync("git", [
|
|
1532
|
-
"commit",
|
|
1533
|
-
"-m",
|
|
1534
|
-
message,
|
|
1535
|
-
"--no-verify"
|
|
1536
|
-
], {
|
|
1537
|
-
cwd: workspace,
|
|
1538
|
-
encoding: "utf8"
|
|
1539
|
-
});
|
|
1540
|
-
if (commit.error !== void 0) throw check_error(`git commit failed to start: ${String(commit.error)}`);
|
|
1541
|
-
const out = `${commit.stdout ?? ""}${commit.stderr ?? ""}`;
|
|
1542
|
-
if (commit.status !== 0 && !out.includes("nothing to commit")) throw check_error(`git commit failed: ${out.trim()}`);
|
|
1820
|
+
cost_warned: state.cost_warned || record.cost_usd === null
|
|
1821
|
+
};
|
|
1822
|
+
}
|
|
1823
|
+
function cost_cap_hit(config, state) {
|
|
1824
|
+
return config.max_cost_usd !== null && state.total_cost_usd >= config.max_cost_usd;
|
|
1825
|
+
}
|
|
1826
|
+
//#endregion
|
|
1827
|
+
//#region src/prewarm.ts
|
|
1828
|
+
/**
|
|
1829
|
+
* Best-effort Ollama model pre-load. A cold multi-GB model can spend minutes
|
|
1830
|
+
* in load + first prompt eval — longer than the provider path's in-request
|
|
1831
|
+
* header timeout — so the first real `generate` dies with an opaque
|
|
1832
|
+
* `stream interrupted: fetch failed`. Ollama's documented load call (a
|
|
1833
|
+
* `/api/generate` POST with a model and no prompt) returns as soon as the
|
|
1834
|
+
* model is resident, so absorbing the load here keeps the real call's
|
|
1835
|
+
* time-to-first-byte inside its budget.
|
|
1836
|
+
*
|
|
1837
|
+
* Best-effort like the context probe: any failure (server down, unknown
|
|
1838
|
+
* model, timeout) is swallowed — the real generate call surfaces the error
|
|
1839
|
+
* with retry and reporting behind it. Ollama-only; LM Studio has no
|
|
1840
|
+
* equivalent load-without-generating endpoint.
|
|
1841
|
+
*/
|
|
1842
|
+
/** Generous ceiling on the load itself: a disk-cold 30B-class model takes
|
|
1843
|
+
* tens of seconds to minutes. Past this, give up and let the real call try. */
|
|
1844
|
+
const PREWARM_TIMEOUT_MS = 3e5;
|
|
1845
|
+
/** Load `model` into the Ollama server's memory (`base_url` is the server
|
|
1846
|
+
* root). Resolves when the model is resident, on any failure, or at the
|
|
1847
|
+
* timeout — never throws, never gates the phase. An already-resident model
|
|
1848
|
+
* returns immediately, so warming the same model for both roles is free.
|
|
1849
|
+
*
|
|
1850
|
+
* Load-bearing only on the ai_sdk transport: the cold-load death this
|
|
1851
|
+
* absorbs is the ai-sdk/undici in-request header timeout, not an Ollama
|
|
1852
|
+
* limit. On fascicle's native transport — the deferred future bridge, see
|
|
1853
|
+
* `seat_provider_config` in engine.ts — the per-turn call is a bare
|
|
1854
|
+
* `fetch(..., { signal })` with no default body timeout, so a cold model can
|
|
1855
|
+
* take as long as it needs and this pre-warm's necessity drops away (keep it
|
|
1856
|
+
* regardless: a resident model makes it a no-op). Native would also move the
|
|
1857
|
+
* `num_ctx`/`keep_alive` levers to per-call `provider_options.ollama`, rather
|
|
1858
|
+
* than the server-side Modelfile defaults volley probes today. */
|
|
1859
|
+
async function prewarm_ollama_model(base_url, model, abort) {
|
|
1860
|
+
const signals = [AbortSignal.timeout(PREWARM_TIMEOUT_MS)];
|
|
1861
|
+
if (abort !== void 0) signals.push(abort);
|
|
1862
|
+
try {
|
|
1863
|
+
await fetch(`${base_url.replace(/\/$/, "")}/api/generate`, {
|
|
1864
|
+
method: "POST",
|
|
1865
|
+
headers: { "content-type": "application/json" },
|
|
1866
|
+
body: JSON.stringify({ model }),
|
|
1867
|
+
signal: AbortSignal.any(signals)
|
|
1868
|
+
});
|
|
1869
|
+
} catch {}
|
|
1543
1870
|
}
|
|
1544
1871
|
//#endregion
|
|
1545
1872
|
//#region src/worktree.ts
|
|
@@ -1574,9 +1901,8 @@ function worktree_branch(run_id) {
|
|
|
1574
1901
|
* when `--worktree` is on, else the workspace itself. This is the containment
|
|
1575
1902
|
* root the builder/critic file tools resolve through `contain()` and the
|
|
1576
1903
|
* `bash` tool's cwd — re-pointing it moves writes into the worktree without
|
|
1577
|
-
* disturbing the workspace.
|
|
1578
|
-
*
|
|
1579
|
-
* 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.
|
|
1580
1906
|
*/
|
|
1581
1907
|
function build_root(workspace, worktree) {
|
|
1582
1908
|
return worktree ? worktree_path(workspace) : workspace;
|
|
@@ -1879,7 +2205,7 @@ async function with_worktree(options, body) {
|
|
|
1879
2205
|
/**
|
|
1880
2206
|
* Builder invocation: one call to the builder leaf per iteration is one
|
|
1881
2207
|
* complete agentic session in the workspace — Claude Code under `claude_cli`,
|
|
1882
|
-
* or a
|
|
2208
|
+
* or a model driving volley's tools on any other provider, local or hosted.
|
|
1883
2209
|
*/
|
|
1884
2210
|
const BUILDER_ALLOWED_TOOLS = [
|
|
1885
2211
|
"Read",
|
|
@@ -1901,7 +2227,7 @@ function compose_builder_system() {
|
|
|
1901
2227
|
}
|
|
1902
2228
|
/** Walk up from this module to the package root (works from src/ under tsx
|
|
1903
2229
|
* and from dist/ in the published package, which ships src/builder/presets). */
|
|
1904
|
-
function
|
|
2230
|
+
function builder_presets_dir() {
|
|
1905
2231
|
let dir = dirname(fileURLToPath(import.meta.url));
|
|
1906
2232
|
for (let depth = 0; depth < 6; depth += 1) {
|
|
1907
2233
|
const candidate = join(dir, "src", "builder", "presets");
|
|
@@ -1910,40 +2236,67 @@ function presets_dir$1() {
|
|
|
1910
2236
|
}
|
|
1911
2237
|
throw config_error("builder presets directory not found");
|
|
1912
2238
|
}
|
|
1913
|
-
|
|
1914
|
-
|
|
2239
|
+
/** The harness append each tool set's builder reads: one naming volley's own
|
|
2240
|
+
* tools, or one naming fascicle's workspace tools under `volley acp` and
|
|
2241
|
+
* saying what a call the user refused means. */
|
|
2242
|
+
const HARNESS_APPENDS = {
|
|
2243
|
+
volley: "harness_append_local.md",
|
|
2244
|
+
acp: "harness_append_acp.md"
|
|
2245
|
+
};
|
|
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 = [
|
|
1915
2261
|
"You are the builder inside the volley harness: an autonomous agent",
|
|
1916
2262
|
"iterating on a workspace until it satisfies a task and its acceptance",
|
|
1917
2263
|
"criteria."
|
|
1918
|
-
].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()}`;
|
|
1919
2267
|
}
|
|
1920
2268
|
/** The builder system prompt for the active provider: the `claude_cli`
|
|
1921
2269
|
* prompt (implicit built-in tools, "the current working directory") for the
|
|
1922
|
-
* CLI builder,
|
|
1923
|
-
* the
|
|
1924
|
-
* `resolve_critic_prompt`. */
|
|
1925
|
-
function resolve_builder_system(config) {
|
|
1926
|
-
return config.builder_provider === "claude_cli" ? compose_builder_system() : compose_builder_system_local();
|
|
2270
|
+
* CLI builder, which never takes an injected set, or the local prompt naming
|
|
2271
|
+
* the seat's tool set for every provider that runs volley's tool loop,
|
|
2272
|
+
* `openrouter` included. Mirrors the critic's `resolve_critic_prompt`. */
|
|
2273
|
+
function resolve_builder_system(config, tool_set) {
|
|
2274
|
+
return config.builder_provider === "claude_cli" ? compose_builder_system() : compose_builder_system_local(tool_set, config.builder_max_tool_calls);
|
|
1927
2275
|
}
|
|
1928
2276
|
const BUILDER_TOOL_CALL_REPAIR_ATTEMPTS = 3;
|
|
1929
2277
|
/** Per-provider tool wiring for the builder, mirroring `critic_tool_options`.
|
|
1930
2278
|
* The `claude_cli` builder is confined at the CLI permission layer (allowlist +
|
|
1931
2279
|
* permission mode) and brings its own built-in tools — this arm is unchanged
|
|
1932
|
-
* from v2
|
|
1933
|
-
*
|
|
1934
|
-
*
|
|
1935
|
-
*
|
|
1936
|
-
|
|
2280
|
+
* from v2, and takes no injected tools or approval handler. Every other
|
|
2281
|
+
* builder, local or `openrouter`, brings none, so volley supplies its whole
|
|
2282
|
+
* workspace tool surface (or the set the caller injected in its place, with
|
|
2283
|
+
* the handler that answers its approvals) plus the five per-call loop knobs
|
|
2284
|
+
* and **no** schema (it produces a workspace, not a verdict); per-call values
|
|
2285
|
+
* win over engine defaults. */
|
|
2286
|
+
function builder_tool_options(deps) {
|
|
2287
|
+
const { config } = deps;
|
|
2288
|
+
const bash_executor = deps.bash_executor ?? null;
|
|
1937
2289
|
if (config.builder_provider === "claude_cli") return { provider_options: { claude_cli: {
|
|
1938
2290
|
allowed_tools: [...BUILDER_ALLOWED_TOOLS],
|
|
1939
2291
|
extra_args: ["--permission-mode", config.builder_permission_mode]
|
|
1940
2292
|
} } };
|
|
1941
2293
|
return {
|
|
1942
|
-
tools: builder_tools(build_root(config.workspace, config.worktree), bash_executor !== null ? { bash_executor } : {}),
|
|
2294
|
+
tools: deps.tools === void 0 ? builder_tools(build_root(config.workspace, config.worktree), bash_executor !== null ? { bash_executor } : {}) : [...deps.tools],
|
|
2295
|
+
...deps.on_tool_approval !== void 0 ? { on_tool_approval: deps.on_tool_approval } : {},
|
|
1943
2296
|
max_steps: config.builder_max_steps,
|
|
1944
2297
|
tool_error_policy: "feed_back",
|
|
1945
2298
|
tool_call_repair_attempts: BUILDER_TOOL_CALL_REPAIR_ATTEMPTS,
|
|
1946
|
-
max_tool_calls_per_step:
|
|
2299
|
+
max_tool_calls_per_step: config.builder_max_tool_calls
|
|
1947
2300
|
};
|
|
1948
2301
|
}
|
|
1949
2302
|
function compose_builder_prompt(input) {
|
|
@@ -1976,9 +2329,9 @@ function make_builder_step(deps) {
|
|
|
1976
2329
|
description: "model call: one agentic session in the build root",
|
|
1977
2330
|
provider: config.builder_provider,
|
|
1978
2331
|
model: config.builder_model,
|
|
1979
|
-
system: resolve_builder_system(config),
|
|
2332
|
+
system: resolve_builder_system(config, deps.tool_set),
|
|
1980
2333
|
on_chunk: deps.on_chunk,
|
|
1981
|
-
...builder_tool_options(
|
|
2334
|
+
...builder_tool_options(deps)
|
|
1982
2335
|
});
|
|
1983
2336
|
}
|
|
1984
2337
|
async function run_builder(deps, state, ctx) {
|
|
@@ -2022,11 +2375,30 @@ function presets_dir() {
|
|
|
2022
2375
|
}
|
|
2023
2376
|
throw config_error("critic presets directory not found");
|
|
2024
2377
|
}
|
|
2025
|
-
|
|
2378
|
+
/** The harness append that names the critic's reads. The CLI critic reads
|
|
2379
|
+
* via built-in Read/Grep/Glob and never takes an injected set; every other
|
|
2380
|
+
* critic, local or `openrouter`, reads via volley's read_file/search_files/
|
|
2381
|
+
* list_files, or under `volley acp` via fascicle's read_file/list_files/
|
|
2382
|
+
* search. */
|
|
2383
|
+
function harness_append_file(config, tool_set) {
|
|
2384
|
+
if (config.critic_provider === "claude_cli") return "harness_append.md";
|
|
2385
|
+
return tool_set === "acp" ? "harness_append_acp.md" : "harness_append_local.md";
|
|
2386
|
+
}
|
|
2387
|
+
function resolve_critic_prompt(config, tool_set = "volley") {
|
|
2026
2388
|
const base = config.critic_prompt_path !== null ? readFileSync(config.critic_prompt_path, "utf8") : readFileSync(join(presets_dir(), `${config.critic_preset}.md`), "utf8");
|
|
2027
|
-
const
|
|
2028
|
-
|
|
2029
|
-
|
|
2389
|
+
const append = readFileSync(join(presets_dir(), harness_append_file(config, tool_set)), "utf8");
|
|
2390
|
+
return `${base.trimEnd()}\n\n${place_iterations_dir(append, config).trimEnd()}`;
|
|
2391
|
+
}
|
|
2392
|
+
const ITERATIONS_DIR = "{{iterations_dir}}";
|
|
2393
|
+
/** Fill the harness append's line about prior iteration state with where the
|
|
2394
|
+
* run's `iterations/` sits, relative to the build root the critic reads from,
|
|
2395
|
+
* or drop the line when the run's state is outside that root: a confined
|
|
2396
|
+
* critic can't reach it, and naming it would only spend the critic's steps on
|
|
2397
|
+
* a path it can't open. */
|
|
2398
|
+
function place_iterations_dir(append, config) {
|
|
2399
|
+
const path = relative(build_root(config.workspace, config.worktree), state_path(config, "iterations"));
|
|
2400
|
+
const outside = path === ".." || path.startsWith(`..${sep}`) || isAbsolute(path);
|
|
2401
|
+
return append.split("\n").filter((line) => !(outside && line.includes(ITERATIONS_DIR))).join("\n").replaceAll(ITERATIONS_DIR, `${path}/`);
|
|
2030
2402
|
}
|
|
2031
2403
|
/** One-letter status column, so a hundred-path list stays readable. */
|
|
2032
2404
|
const STATUS_MARKS = {
|
|
@@ -2106,9 +2478,10 @@ function compose_critic_prompt(input) {
|
|
|
2106
2478
|
//#endregion
|
|
2107
2479
|
//#region src/critic/run.ts
|
|
2108
2480
|
/**
|
|
2109
|
-
* Critic invocation: a read-only session (`claude_cli`, or a local
|
|
2110
|
-
* volley's read tools) returning a schema-validated
|
|
2111
|
-
*
|
|
2481
|
+
* Critic invocation: a read-only session (`claude_cli`, or a local or
|
|
2482
|
+
* `openrouter` model with volley's read tools) returning a schema-validated
|
|
2483
|
+
* structured verdict. The
|
|
2484
|
+
* harness — not the critic — writes the run's `feedback.md` and `verdict`.
|
|
2112
2485
|
*/
|
|
2113
2486
|
const CRITIC_ALLOWED_TOOLS = [
|
|
2114
2487
|
"Read",
|
|
@@ -2116,18 +2489,24 @@ const CRITIC_ALLOWED_TOOLS = [
|
|
|
2116
2489
|
"Glob"
|
|
2117
2490
|
];
|
|
2118
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. */
|
|
2119
2498
|
const verdict_schema = z.object({
|
|
2120
2499
|
verdict: z.enum(["approved", "changes_requested"]),
|
|
2121
|
-
feedback: z.string().describe("Free-form markdown for the builder. Concrete and actionable. Will be passed verbatim into the next iteration."),
|
|
2122
|
-
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.")
|
|
2123
2502
|
});
|
|
2124
2503
|
/** Read-only tool wiring per provider. The claude_cli critic is confined at
|
|
2125
|
-
* the CLI permission layer (allowlist + explicit disallow);
|
|
2126
|
-
*
|
|
2127
|
-
* path at all.
|
|
2504
|
+
* the CLI permission layer (allowlist + explicit disallow); every other critic,
|
|
2505
|
+
* local or `openrouter`, gets volley's own workspace-scoped read-only tools (or
|
|
2506
|
+
* the set the caller injected in their place) and no write path at all.
|
|
2128
2507
|
*
|
|
2129
|
-
* The
|
|
2130
|
-
* verified live. The claude_cli critic compiles the schema for
|
|
2508
|
+
* The providers also enforce `verdict_schema` by different paths, the first
|
|
2509
|
+
* two verified live. The claude_cli critic compiles the schema for
|
|
2131
2510
|
* `claude --json-schema`; that only works because fascicle 0.14.0's
|
|
2132
2511
|
* `compile_schema` strips the top-level `$schema`/`$id` that zod v4 stamps (the
|
|
2133
2512
|
* CLI rejects them) — `live_smoke` asserts a structured verdict comes back, so a
|
|
@@ -2137,16 +2516,22 @@ const verdict_schema = z.object({
|
|
|
2137
2516
|
* structurally guaranteed at decode time, with no prompt-parse-repair;
|
|
2138
2517
|
* `live_local_builder` exercises that path. Unchanged in v3: on ai_sdk
|
|
2139
2518
|
* constrained decode needs no wiring, and under the deferred native flip it
|
|
2140
|
-
* would move to `provider_options.ollama.format` (parked).
|
|
2141
|
-
|
|
2519
|
+
* would move to `provider_options.ollama.format` (parked). An `openrouter`
|
|
2520
|
+
* critic rides fascicle's native OpenAI-compatible core, which has no
|
|
2521
|
+
* constrained decode: the schema travels in the prompt, and the reply is parsed
|
|
2522
|
+
* and repaired. */
|
|
2523
|
+
function critic_tool_options(config, tools) {
|
|
2142
2524
|
if (config.critic_provider === "claude_cli") return { provider_options: { claude_cli: {
|
|
2143
2525
|
allowed_tools: [...CRITIC_ALLOWED_TOOLS],
|
|
2144
2526
|
extra_args: ["--disallowedTools", CRITIC_DISALLOWED_TOOLS]
|
|
2145
2527
|
} } };
|
|
2146
|
-
return {
|
|
2528
|
+
return {
|
|
2529
|
+
tools: tools === void 0 ? read_only_tools(build_root(config.workspace, config.worktree)) : [...tools],
|
|
2530
|
+
max_steps: 10
|
|
2531
|
+
};
|
|
2147
2532
|
}
|
|
2148
|
-
/** Is this critic failure the transient
|
|
2149
|
-
* retries?
|
|
2533
|
+
/** Is this critic failure the transient provider stream death the ladder
|
|
2534
|
+
* retries? Every provider but `claude_cli` (that path is proven and its
|
|
2150
2535
|
* retries cost real money), and fascicle's typed `provider_error` only (no
|
|
2151
2536
|
* message string-matching). A user abort never gets here: `retry` and
|
|
2152
2537
|
* `fallback` pass control-flow signals through untouched, and `retry` checks the
|
|
@@ -2155,6 +2540,23 @@ function is_retryable_critic_error(config, err) {
|
|
|
2155
2540
|
if (config.critic_provider === "claude_cli") return false;
|
|
2156
2541
|
return error_kind(err) === "provider_error";
|
|
2157
2542
|
}
|
|
2543
|
+
/** Did a schema call end without a verdict because the tool loop ran out —
|
|
2544
|
+
* its step budget spent, or its last turn a tool call — rather than because
|
|
2545
|
+
* the response was cut off or blocked? fascicle throws
|
|
2546
|
+
* `incomplete_generation_error` for every such finish; only these two are
|
|
2547
|
+
* fixed by taking the tools away. Matched on `kind` and `finish_reason`. */
|
|
2548
|
+
function is_step_exhaustion(err) {
|
|
2549
|
+
if (error_kind(err) !== "incomplete_generation_error") return false;
|
|
2550
|
+
const finish = err.finish_reason;
|
|
2551
|
+
return finish === "max_steps" || finish === "tool_calls";
|
|
2552
|
+
}
|
|
2553
|
+
/** Does this failure send a local critic to the tool-less rung? A stream death
|
|
2554
|
+
* the retry rung gave up on, or step exhaustion, which no retry would fix. A
|
|
2555
|
+
* `length` or `content_filter` finish is neither, and stays fatal. */
|
|
2556
|
+
function is_degradable_critic_error(config, err) {
|
|
2557
|
+
if (config.critic_provider === "claude_cli") return false;
|
|
2558
|
+
return error_kind(err) === "provider_error" || is_step_exhaustion(err);
|
|
2559
|
+
}
|
|
2158
2560
|
/** fascicle's `provider_error.cause_kind` is `... | undefined`; fold the
|
|
2159
2561
|
* missing case into `'unknown'` so a recorded retry always names a cause. */
|
|
2160
2562
|
function retry_cause_kind_of(err) {
|
|
@@ -2173,7 +2575,8 @@ function finalize_critic(state, result, model, mark, duration_ms) {
|
|
|
2173
2575
|
...next.critic,
|
|
2174
2576
|
retries: mark.retries,
|
|
2175
2577
|
...mark.retry_cause_kind !== void 0 ? { retry_cause_kind: mark.retry_cause_kind } : {},
|
|
2176
|
-
...mark.critic_degraded === true ? { critic_degraded: true } : {}
|
|
2578
|
+
...mark.critic_degraded === true ? { critic_degraded: true } : {},
|
|
2579
|
+
...mark.degrade_cause !== void 0 ? { degrade_cause: mark.degrade_cause } : {}
|
|
2177
2580
|
},
|
|
2178
2581
|
verdict: result.content.verdict,
|
|
2179
2582
|
feedback: result.content.feedback,
|
|
@@ -2203,7 +2606,7 @@ function critic_call_config(deps) {
|
|
|
2203
2606
|
engine: deps.engine,
|
|
2204
2607
|
provider: config.critic_provider,
|
|
2205
2608
|
model: config.critic_model,
|
|
2206
|
-
system: resolve_critic_prompt(config),
|
|
2609
|
+
system: resolve_critic_prompt(config, deps.tool_set),
|
|
2207
2610
|
schema: verdict_schema,
|
|
2208
2611
|
on_chunk: deps.on_chunk
|
|
2209
2612
|
};
|
|
@@ -2215,7 +2618,7 @@ function make_critic_tools_step(deps) {
|
|
|
2215
2618
|
...critic_call_config(deps),
|
|
2216
2619
|
id: "critic_tools",
|
|
2217
2620
|
description: "model call with read-only workspace tools",
|
|
2218
|
-
...critic_tool_options(deps.config)
|
|
2621
|
+
...critic_tool_options(deps.config, deps.tools)
|
|
2219
2622
|
});
|
|
2220
2623
|
}
|
|
2221
2624
|
/** Rung 2's leaf: the same critic with no tools at all, so nothing enters
|
|
@@ -2228,9 +2631,28 @@ function make_critic_toolless_step(deps) {
|
|
|
2228
2631
|
description: "model call judging from the check output and a file list"
|
|
2229
2632
|
});
|
|
2230
2633
|
}
|
|
2231
|
-
|
|
2634
|
+
const ladder_traces = /* @__PURE__ */ new WeakMap();
|
|
2635
|
+
/** The ladder's retry predicate for this run's critic provider: stream deaths
|
|
2636
|
+
* only. It also traces every failure it sees, so a degraded verdict can say how
|
|
2637
|
+
* many retries preceded it and why. A first attempt starts a fresh critique, so
|
|
2638
|
+
* it forgets the last one's retries. */
|
|
2232
2639
|
function critic_retryable(config) {
|
|
2233
|
-
|
|
2640
|
+
let retried_cause;
|
|
2641
|
+
return (err, attempt) => {
|
|
2642
|
+
if (attempt === 1) retried_cause = void 0;
|
|
2643
|
+
if (typeof err === "object" && err !== null) ladder_traces.set(err, {
|
|
2644
|
+
attempt,
|
|
2645
|
+
retried_cause
|
|
2646
|
+
});
|
|
2647
|
+
const retryable = is_retryable_critic_error(config, err);
|
|
2648
|
+
if (retryable) retried_cause = retry_cause_kind_of(err);
|
|
2649
|
+
return retryable;
|
|
2650
|
+
};
|
|
2651
|
+
}
|
|
2652
|
+
/** The ladder's fallback predicate: a stream death the retry rung gave up on,
|
|
2653
|
+
* or step exhaustion, which it never retries. */
|
|
2654
|
+
function critic_degradable(config) {
|
|
2655
|
+
return (err) => is_degradable_critic_error(config, err);
|
|
2234
2656
|
}
|
|
2235
2657
|
/** Rung 1's projection: a verdict the tool-bearing critic produced, marked with
|
|
2236
2658
|
* how many stream deaths it took and the last one's cause. */
|
|
@@ -2244,27 +2666,42 @@ function with_retries(outcome) {
|
|
|
2244
2666
|
}
|
|
2245
2667
|
};
|
|
2246
2668
|
}
|
|
2247
|
-
/** Rung 2's answer
|
|
2248
|
-
*
|
|
2669
|
+
/** Rung 2's answer, marked degraded so it never passes silently as a full
|
|
2670
|
+
* critique. Why it degraded, and the retries before it, are the ladder's to
|
|
2671
|
+
* stamp (`with_fallback_cause`), since the tool-less call can't see them. */
|
|
2249
2672
|
function as_degraded(result) {
|
|
2250
2673
|
return {
|
|
2251
2674
|
result,
|
|
2252
2675
|
mark: {
|
|
2253
|
-
retries:
|
|
2676
|
+
retries: 0,
|
|
2254
2677
|
retry_cause_kind: void 0,
|
|
2255
2678
|
critic_degraded: true
|
|
2256
2679
|
}
|
|
2257
2680
|
};
|
|
2258
2681
|
}
|
|
2259
2682
|
/** The ladder's projection: the tool-less backup cannot see why it ran, so the
|
|
2260
|
-
*
|
|
2683
|
+
* mark comes from the error that sent it there.
|
|
2684
|
+
*
|
|
2685
|
+
* - A stream death the retry rung gave up on: every retry was spent, and the
|
|
2686
|
+
* cause is that error's own.
|
|
2687
|
+
* - Step exhaustion, never retried: the retries are the stream deaths before
|
|
2688
|
+
* it, usually none, and the cause is the last of those.
|
|
2689
|
+
*
|
|
2690
|
+
* An error the retry rung never traced reads as one that spent every retry. */
|
|
2261
2691
|
function with_fallback_cause(outcome) {
|
|
2262
2692
|
if (outcome.source === "primary") return outcome.value;
|
|
2693
|
+
const err = outcome.primary_error;
|
|
2694
|
+
const trace = typeof err === "object" && err !== null ? ladder_traces.get(err) : void 0;
|
|
2695
|
+
const retries = trace === void 0 ? 1 : trace.attempt - 1;
|
|
2696
|
+
const exhausted = is_step_exhaustion(err);
|
|
2697
|
+
const retry_cause_kind = exhausted ? trace?.retried_cause : retry_cause_kind_of(err);
|
|
2263
2698
|
return {
|
|
2264
2699
|
...outcome.value,
|
|
2265
2700
|
mark: {
|
|
2266
2701
|
...outcome.value.mark,
|
|
2267
|
-
|
|
2702
|
+
retries,
|
|
2703
|
+
retry_cause_kind,
|
|
2704
|
+
degrade_cause: exhausted ? "step_exhaustion" : "stream_error"
|
|
2268
2705
|
}
|
|
2269
2706
|
};
|
|
2270
2707
|
}
|
|
@@ -2374,14 +2811,50 @@ function status_of(value, converged) {
|
|
|
2374
2811
|
*/
|
|
2375
2812
|
const CHECK_ARTIFACT_MAX_CHARS = 8e3;
|
|
2376
2813
|
const CHECKRIDE_SCHEMA_VERSION = 1;
|
|
2814
|
+
/** How long a check's output may stay open after its shell exits, while
|
|
2815
|
+
* something that escaped its process group still holds it, before the reads
|
|
2816
|
+
* stop so the check can finish. */
|
|
2817
|
+
const OUTPUT_GRACE_MS = 2e3;
|
|
2818
|
+
/** Kill a check's whole process group with SIGKILL. A test runner's workers,
|
|
2819
|
+
* and anything the check started in the background, sit in the shell's group,
|
|
2820
|
+
* so killing the shell alone would leave them running. Windows has no process
|
|
2821
|
+
* groups to signal, so there only the shell goes. */
|
|
2822
|
+
function kill_group(proc) {
|
|
2823
|
+
if (proc.pid === void 0) return;
|
|
2824
|
+
try {
|
|
2825
|
+
if (process.platform === "win32") proc.kill("SIGKILL");
|
|
2826
|
+
else process.kill(-proc.pid, "SIGKILL");
|
|
2827
|
+
} catch {}
|
|
2828
|
+
}
|
|
2829
|
+
/** Run a check to its end in a process group of its own, capturing its
|
|
2830
|
+
* output. The abort kills the whole group and rejects with `aborted_error`,
|
|
2831
|
+
* and the shell's exit kills whatever it left behind, so a stopped run leaves
|
|
2832
|
+
* no test workers or servers running. */
|
|
2377
2833
|
function spawn_capture(cmd, args, options) {
|
|
2834
|
+
const { abort } = options;
|
|
2835
|
+
if (abort?.aborted === true) return Promise.reject(new aborted_error("check aborted"));
|
|
2378
2836
|
return new Promise((resolve_promise, reject) => {
|
|
2379
2837
|
const proc = spawn(cmd, args, {
|
|
2380
2838
|
cwd: options.cwd,
|
|
2381
|
-
...options.
|
|
2839
|
+
...options.env !== void 0 ? { env: options.env } : {},
|
|
2840
|
+
detached: process.platform !== "win32",
|
|
2841
|
+
stdio: [
|
|
2842
|
+
"ignore",
|
|
2843
|
+
"pipe",
|
|
2844
|
+
"pipe"
|
|
2845
|
+
]
|
|
2382
2846
|
});
|
|
2383
2847
|
let stdout = "";
|
|
2384
2848
|
let stderr = "";
|
|
2849
|
+
let grace;
|
|
2850
|
+
const on_abort = () => {
|
|
2851
|
+
kill_group(proc);
|
|
2852
|
+
reject(new aborted_error("check aborted"));
|
|
2853
|
+
};
|
|
2854
|
+
const finish = () => {
|
|
2855
|
+
clearTimeout(grace);
|
|
2856
|
+
abort?.removeEventListener("abort", on_abort);
|
|
2857
|
+
};
|
|
2385
2858
|
proc.stdout.on("data", (chunk) => {
|
|
2386
2859
|
stdout += chunk.toString();
|
|
2387
2860
|
});
|
|
@@ -2389,19 +2862,25 @@ function spawn_capture(cmd, args, options) {
|
|
|
2389
2862
|
stderr += chunk.toString();
|
|
2390
2863
|
});
|
|
2391
2864
|
proc.on("error", (err) => {
|
|
2392
|
-
|
|
2393
|
-
reject(new aborted_error("check aborted"));
|
|
2394
|
-
return;
|
|
2395
|
-
}
|
|
2865
|
+
finish();
|
|
2396
2866
|
reject(check_error(`check command failed to start: ${err.message}`));
|
|
2397
2867
|
});
|
|
2868
|
+
proc.on("exit", () => {
|
|
2869
|
+
kill_group(proc);
|
|
2870
|
+
grace = setTimeout(() => {
|
|
2871
|
+
proc.stdout.destroy();
|
|
2872
|
+
proc.stderr.destroy();
|
|
2873
|
+
}, OUTPUT_GRACE_MS);
|
|
2874
|
+
});
|
|
2398
2875
|
proc.on("close", (code) => {
|
|
2876
|
+
finish();
|
|
2399
2877
|
resolve_promise({
|
|
2400
2878
|
exit_code: code,
|
|
2401
2879
|
stdout,
|
|
2402
2880
|
stderr
|
|
2403
2881
|
});
|
|
2404
2882
|
});
|
|
2883
|
+
abort?.addEventListener("abort", on_abort, { once: true });
|
|
2405
2884
|
});
|
|
2406
2885
|
}
|
|
2407
2886
|
/** Best-effort parse of the summary contract. `pnpm exec` talks over the
|
|
@@ -2465,13 +2944,14 @@ function read_failing_artifacts(workspace, failing) {
|
|
|
2465
2944
|
});
|
|
2466
2945
|
}
|
|
2467
2946
|
async function run_checkride(opts) {
|
|
2947
|
+
const { workspace, ...spawn_options } = opts;
|
|
2468
2948
|
const proc = await spawn_capture("pnpm", [
|
|
2469
2949
|
"exec",
|
|
2470
2950
|
"checkride",
|
|
2471
2951
|
"--json"
|
|
2472
2952
|
], {
|
|
2473
|
-
|
|
2474
|
-
|
|
2953
|
+
...spawn_options,
|
|
2954
|
+
cwd: workspace
|
|
2475
2955
|
});
|
|
2476
2956
|
if (proc.exit_code === 2) throw check_error(`checkride harness error: ${proc.stderr.trim()}`);
|
|
2477
2957
|
const { summary } = parse_summary_with_fallback(proc.stdout, opts.workspace);
|
|
@@ -2495,12 +2975,13 @@ async function run_checkride(opts) {
|
|
|
2495
2975
|
* (exit 4), not an iterable failure.
|
|
2496
2976
|
*/
|
|
2497
2977
|
async function run_command_check(opts) {
|
|
2978
|
+
const { command, workspace, ...spawn_options } = opts;
|
|
2498
2979
|
const started = Date.now();
|
|
2499
|
-
const proc = await spawn_capture("sh", ["-c",
|
|
2500
|
-
|
|
2501
|
-
|
|
2980
|
+
const proc = await spawn_capture("sh", ["-c", command], {
|
|
2981
|
+
...spawn_options,
|
|
2982
|
+
cwd: workspace
|
|
2502
2983
|
});
|
|
2503
|
-
if (proc.exit_code === 127 || proc.exit_code === 126) throw check_error(`check command failed to start (exit ${String(proc.exit_code)}): ${
|
|
2984
|
+
if (proc.exit_code === 127 || proc.exit_code === 126) throw check_error(`check command failed to start (exit ${String(proc.exit_code)}): ${command}`);
|
|
2504
2985
|
const log = `${proc.stdout}${proc.stderr}`;
|
|
2505
2986
|
return {
|
|
2506
2987
|
ran: true,
|
|
@@ -2524,6 +3005,63 @@ function skipped_check(reason) {
|
|
|
2524
3005
|
detail: []
|
|
2525
3006
|
};
|
|
2526
3007
|
}
|
|
3008
|
+
/** What a rejected check says in place of its output. */
|
|
3009
|
+
const CHECK_REJECTED = "the check was rejected when it asked to run, so it never ran";
|
|
3010
|
+
/** A check the user rejected when it asked to run: recorded as a failed
|
|
3011
|
+
* check, with the refusal as its output, so a round can't converge on work
|
|
3012
|
+
* nothing checked, and the critic hears why there's no result. */
|
|
3013
|
+
function rejected_check(runner) {
|
|
3014
|
+
return {
|
|
3015
|
+
ran: true,
|
|
3016
|
+
runner,
|
|
3017
|
+
ok: false,
|
|
3018
|
+
exit_code: null,
|
|
3019
|
+
duration_ms: 0,
|
|
3020
|
+
failing_slots: [],
|
|
3021
|
+
detail: [],
|
|
3022
|
+
log: CHECK_REJECTED
|
|
3023
|
+
};
|
|
3024
|
+
}
|
|
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
|
+
}
|
|
2527
3065
|
//#endregion
|
|
2528
3066
|
//#region src/iteration.ts
|
|
2529
3067
|
/**
|
|
@@ -2534,9 +3072,9 @@ function skipped_check(reason) {
|
|
|
2534
3072
|
function write_json(path, value) {
|
|
2535
3073
|
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
2536
3074
|
}
|
|
2537
|
-
function persist_verdict_files(
|
|
2538
|
-
writeFileSync(
|
|
2539
|
-
writeFileSync(
|
|
3075
|
+
function persist_verdict_files(config, feedback, verdict) {
|
|
3076
|
+
writeFileSync(state_path(config, "feedback.md"), feedback.endsWith("\n") ? feedback : `${feedback}\n`);
|
|
3077
|
+
writeFileSync(state_path(config, "verdict"), `${verdict}\n`);
|
|
2540
3078
|
}
|
|
2541
3079
|
function phase_summary(record) {
|
|
2542
3080
|
if (record === null) return null;
|
|
@@ -2555,17 +3093,18 @@ function phase_summary(record) {
|
|
|
2555
3093
|
...record.retries !== void 0 ? { retries: record.retries } : {},
|
|
2556
3094
|
...record.retry_cause_kind !== void 0 ? { retry_cause_kind: record.retry_cause_kind } : {},
|
|
2557
3095
|
...record.critic_degraded === true ? { critic_degraded: true } : {},
|
|
3096
|
+
...record.degrade_cause !== void 0 ? { degrade_cause: record.degrade_cause } : {},
|
|
2558
3097
|
...record.throughput !== void 0 ? { throughput: record.throughput } : {}
|
|
2559
3098
|
};
|
|
2560
3099
|
}
|
|
2561
3100
|
/** Archive one completed iteration: verdict files, check artifacts, and the
|
|
2562
3101
|
* per-iteration summary. */
|
|
2563
3102
|
function archive_iteration(config, state) {
|
|
2564
|
-
const dir = iteration_dir(config
|
|
3103
|
+
const dir = iteration_dir(config, state.iteration);
|
|
2565
3104
|
mkdirSync(join(dir, "check"), { recursive: true });
|
|
2566
3105
|
if (state.verdict !== null && state.feedback !== null) {
|
|
2567
|
-
persist_verdict_files(config
|
|
2568
|
-
writeFileSync(join(dir, "feedback.md"), readFileSync(
|
|
3106
|
+
persist_verdict_files(config, state.feedback, state.verdict);
|
|
3107
|
+
writeFileSync(join(dir, "feedback.md"), readFileSync(state_path(config, "feedback.md")));
|
|
2569
3108
|
writeFileSync(join(dir, "verdict"), `${state.verdict}\n`);
|
|
2570
3109
|
}
|
|
2571
3110
|
const check = state.check;
|
|
@@ -2614,13 +3153,15 @@ function run_result_from_state(config, state, status, completed_at) {
|
|
|
2614
3153
|
critic_cost_usd: state.critic_cost_usd,
|
|
2615
3154
|
check_duration_ms: state.check_duration_ms,
|
|
2616
3155
|
final_verdict: state.verdict,
|
|
2617
|
-
salvaged_branch: null
|
|
3156
|
+
salvaged_branch: null,
|
|
3157
|
+
run_state_dir: run_state_dir(config),
|
|
3158
|
+
mode: config.mode
|
|
2618
3159
|
};
|
|
2619
3160
|
}
|
|
2620
3161
|
//#endregion
|
|
2621
3162
|
//#region src/summary.ts
|
|
2622
3163
|
/**
|
|
2623
|
-
* Enriched run summary (
|
|
3164
|
+
* Enriched run summary (the run's `summary.json`): the `RunResult` projection plus
|
|
2624
3165
|
* a `comparison` block that makes the two blessed examples' runs diff-able.
|
|
2625
3166
|
* The block gathers the seven comparison metrics — transport,
|
|
2626
3167
|
* iterations-to-converge, wall-clock, cost, verdict, the deterministic check
|
|
@@ -2635,22 +3176,27 @@ function run_result_from_state(config, state, status, completed_at) {
|
|
|
2635
3176
|
* surviving branch on `salvaged_branch`, which teardown only names after the
|
|
2636
3177
|
* final status is known.
|
|
2637
3178
|
* Each write recomputes the comparison from what is currently archived under
|
|
2638
|
-
*
|
|
3179
|
+
* the run's `iterations/`, so the trajectory grows as the run does.
|
|
2639
3180
|
*/
|
|
2640
3181
|
function transport_of(provider) {
|
|
2641
|
-
|
|
3182
|
+
switch (provider) {
|
|
3183
|
+
case "claude_cli": return "claude_cli";
|
|
3184
|
+
case "openrouter": return "native";
|
|
3185
|
+
case "ollama":
|
|
3186
|
+
case "lmstudio": return "ai_sdk";
|
|
3187
|
+
}
|
|
2642
3188
|
}
|
|
2643
3189
|
/** Read the archived per-iteration summaries in order. A missing archive root
|
|
2644
3190
|
* (a run that failed before the first `record`) or an unreadable entry yields an
|
|
2645
3191
|
* empty/short list — the run summary still writes, just without that trajectory
|
|
2646
3192
|
* detail. */
|
|
2647
|
-
function read_iteration_archives(
|
|
2648
|
-
const root =
|
|
3193
|
+
function read_iteration_archives(config) {
|
|
3194
|
+
const root = state_path(config, "iterations");
|
|
2649
3195
|
if (!existsSync(root)) return [];
|
|
2650
3196
|
const dirs = readdirSync(root).filter((dir) => /^\d{3}$/.test(dir)).toSorted();
|
|
2651
3197
|
const archives = [];
|
|
2652
3198
|
for (const dir of dirs) {
|
|
2653
|
-
const path =
|
|
3199
|
+
const path = state_path(config, "iterations", dir, "summary.json");
|
|
2654
3200
|
if (!existsSync(path)) continue;
|
|
2655
3201
|
try {
|
|
2656
3202
|
archives.push(JSON.parse(readFileSync(path, "utf8")));
|
|
@@ -2672,6 +3218,13 @@ function check_trajectory(archives) {
|
|
|
2672
3218
|
function any_critic_degraded(archives) {
|
|
2673
3219
|
return archives.some((archive) => archive.critic?.critic_degraded === true);
|
|
2674
3220
|
}
|
|
3221
|
+
/** Each distinct reason an archived critic verdict degraded. An archive from
|
|
3222
|
+
* before the cause was recorded reads as a stream error, the only way a verdict
|
|
3223
|
+
* could degrade then. */
|
|
3224
|
+
function critic_degrade_causes(archives) {
|
|
3225
|
+
const causes = archives.filter((archive) => archive.critic?.critic_degraded === true).map((archive) => archive.critic?.degrade_cause ?? "stream_error");
|
|
3226
|
+
return [...new Set(causes)];
|
|
3227
|
+
}
|
|
2675
3228
|
/** Every gate path the run's builder touched, across all iterations: an edit in
|
|
2676
3229
|
* iteration 1 that a later iteration reverted still happened, so the run-level
|
|
2677
3230
|
* answer is the union rather than the last iteration's list. */
|
|
@@ -2697,12 +3250,12 @@ function salvage_stats(archives) {
|
|
|
2697
3250
|
* archive for the check trajectory and salvage aggregate. Pure over its inputs
|
|
2698
3251
|
* plus the on-disk archive; `write_run_summary` persists the result. */
|
|
2699
3252
|
function build_run_summary(config, result) {
|
|
2700
|
-
const archives = read_iteration_archives(config
|
|
3253
|
+
const archives = read_iteration_archives(config);
|
|
2701
3254
|
const wall_clock_ms = result.completed_at === null ? null : Date.parse(result.completed_at) - Date.parse(result.started_at);
|
|
2702
3255
|
return {
|
|
2703
3256
|
...result,
|
|
2704
3257
|
comparison: {
|
|
2705
|
-
builder_transport: transport_of(config.builder_provider),
|
|
3258
|
+
builder_transport: config.mode === "critique" ? null : transport_of(config.builder_provider),
|
|
2706
3259
|
critic_transport: transport_of(config.critic_provider),
|
|
2707
3260
|
iterations_to_converge: result.status === "success" ? result.iterations_completed : null,
|
|
2708
3261
|
wall_clock_ms,
|
|
@@ -2714,17 +3267,54 @@ function build_run_summary(config, result) {
|
|
|
2714
3267
|
gate_edits: all_gate_edits(archives),
|
|
2715
3268
|
unmet_criteria: archives.at(-1)?.unmet_criteria ?? [],
|
|
2716
3269
|
local_salvage: salvage_stats(archives),
|
|
2717
|
-
critic_degraded: any_critic_degraded(archives)
|
|
3270
|
+
critic_degraded: any_critic_degraded(archives),
|
|
3271
|
+
critic_degrade_causes: critic_degrade_causes(archives)
|
|
2718
3272
|
}
|
|
2719
3273
|
};
|
|
2720
3274
|
}
|
|
2721
|
-
/** Write the enriched summary to
|
|
3275
|
+
/** Write the enriched summary to the run's `summary.json` (the projection the
|
|
2722
3276
|
* examples' comparison is read from). */
|
|
2723
3277
|
function write_run_summary(config, result) {
|
|
2724
|
-
writeFileSync(
|
|
3278
|
+
writeFileSync(state_path(config, "summary.json"), `${JSON.stringify(build_run_summary(config, result), null, 2)}\n`);
|
|
2725
3279
|
}
|
|
2726
3280
|
//#endregion
|
|
2727
3281
|
//#region src/phases.ts
|
|
3282
|
+
/**
|
|
3283
|
+
* The step bodies behind the nodes of `./flow.ts`, one function per node. Each
|
|
3284
|
+
* takes the loop's carry-state and returns the next one. The phase modules they
|
|
3285
|
+
* call (`./builder.ts`, `./check/`, `./critic/`) do the work; these bodies add
|
|
3286
|
+
* what surrounds it: the renderer lines an operator watches, change detection,
|
|
3287
|
+
* git checkpoints, the per-iteration archive, and the progress events a
|
|
3288
|
+
* trajectory reader follows the run by.
|
|
3289
|
+
*/
|
|
3290
|
+
/** The step id the check's card is recorded under: the check step's own, in
|
|
3291
|
+
* `./flow.ts`, so each round's check counts as a fresh run of it and opens a
|
|
3292
|
+
* card of its own. */
|
|
3293
|
+
const CHECK_STEP_ID = "check";
|
|
3294
|
+
/** The raw call id and tool name of the check's card. The name sits outside
|
|
3295
|
+
* the workspace tools, so nothing dresses the card as one of theirs. */
|
|
3296
|
+
const CHECK_CALL = {
|
|
3297
|
+
id: "check",
|
|
3298
|
+
name: "check"
|
|
3299
|
+
};
|
|
3300
|
+
function emit(ctx, event) {
|
|
3301
|
+
ctx.emit(event);
|
|
3302
|
+
}
|
|
3303
|
+
/** Record one chunk of the check's card, the way `model_call` records a tool
|
|
3304
|
+
* call's, under a streamed run only, since nothing reads a card otherwise. */
|
|
3305
|
+
function record_check_chunk(ctx, chunk) {
|
|
3306
|
+
if (ctx.streaming) ctx.trajectory.record({
|
|
3307
|
+
kind: "model_chunk",
|
|
3308
|
+
step_id: CHECK_STEP_ID,
|
|
3309
|
+
chunk
|
|
3310
|
+
});
|
|
3311
|
+
}
|
|
3312
|
+
/** The command line the check runs, or null when no check is configured. */
|
|
3313
|
+
function check_command(config) {
|
|
3314
|
+
if (config.check_resolved === "checkride") return "pnpm exec checkride --json";
|
|
3315
|
+
if (config.check_resolved === "command") return config.check;
|
|
3316
|
+
return null;
|
|
3317
|
+
}
|
|
2728
3318
|
/** How many gate edits a warning names before it summarises the rest. */
|
|
2729
3319
|
const LISTED_GATE_EDITS = 5;
|
|
2730
3320
|
/**
|
|
@@ -2740,20 +3330,53 @@ function report_changes(config, renderer, changes) {
|
|
|
2740
3330
|
if (changes.gate_edits.length === 0) return;
|
|
2741
3331
|
const hidden = changes.gate_edits.length - LISTED_GATE_EDITS;
|
|
2742
3332
|
const listed = changes.gate_edits.slice(0, LISTED_GATE_EDITS).join(", ");
|
|
2743
|
-
|
|
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)." : ""));
|
|
2744
3343
|
}
|
|
2745
|
-
|
|
3344
|
+
/** Run the configured check, checkride or a command, with the environment
|
|
3345
|
+
* the caller set for it, else volley's own. */
|
|
3346
|
+
async function execute_check(deps, abort) {
|
|
3347
|
+
const { config } = deps;
|
|
2746
3348
|
const root = build_root(config.workspace, config.worktree);
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
}
|
|
2751
|
-
|
|
3349
|
+
const options = {
|
|
3350
|
+
abort,
|
|
3351
|
+
...deps.check?.env !== void 0 ? { env: deps.check.env } : {}
|
|
3352
|
+
};
|
|
3353
|
+
return config.check_resolved === "checkride" ? run_checkride({
|
|
3354
|
+
...options,
|
|
3355
|
+
workspace: root
|
|
3356
|
+
}) : run_command_check({
|
|
3357
|
+
...options,
|
|
2752
3358
|
command: config.check,
|
|
2753
|
-
workspace: root
|
|
3359
|
+
workspace: root
|
|
3360
|
+
});
|
|
3361
|
+
}
|
|
3362
|
+
/** Whether the check may run `command`: yes, unless the caller set an
|
|
3363
|
+
* approval and it answers no. It asks on the check's card under the command
|
|
3364
|
+
* line as its tool name, which titles the prompt the way the card is titled,
|
|
3365
|
+
* and which names none of fascicle's gated tools, so the prompt offers Allow
|
|
3366
|
+
* and Reject alone, never an "always" that would cover the builder's commands
|
|
3367
|
+
* too. A stop while it asks ends the check as a stop, not a rejection. */
|
|
3368
|
+
async function check_allowed(deps, command, abort) {
|
|
3369
|
+
const approve = deps.check?.on_approval;
|
|
3370
|
+
if (approve === void 0) return true;
|
|
3371
|
+
const allowed = await approve({
|
|
3372
|
+
tool_name: command,
|
|
3373
|
+
input: { command },
|
|
3374
|
+
tool_call_id: CHECK_CALL.id,
|
|
3375
|
+
step_index: 0,
|
|
2754
3376
|
abort
|
|
2755
3377
|
});
|
|
2756
|
-
|
|
3378
|
+
if (abort.aborted) throw new aborted_error("check aborted");
|
|
3379
|
+
return allowed;
|
|
2757
3380
|
}
|
|
2758
3381
|
/** Open the next iteration: advance the counter and clear every per-iteration
|
|
2759
3382
|
* field, so nothing from the last round leaks into this one. */
|
|
@@ -2770,11 +3393,31 @@ function next_iteration(s) {
|
|
|
2770
3393
|
unmet_criteria: []
|
|
2771
3394
|
};
|
|
2772
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
|
+
}
|
|
2773
3412
|
/** `build`: one builder session edits the build root, then volley measures what
|
|
2774
3413
|
* it changed and, under `--git`, checkpoints it. */
|
|
2775
3414
|
async function build_phase(deps, builder, s, ctx) {
|
|
2776
3415
|
const { config, renderer } = deps;
|
|
2777
3416
|
const next = next_iteration(s);
|
|
3417
|
+
emit(ctx, {
|
|
3418
|
+
volley: "round_start",
|
|
3419
|
+
round: next.iteration
|
|
3420
|
+
});
|
|
2778
3421
|
renderer.phase_start(next.iteration, "builder");
|
|
2779
3422
|
const built = await run_builder({
|
|
2780
3423
|
builder,
|
|
@@ -2784,18 +3427,28 @@ async function build_phase(deps, builder, s, ctx) {
|
|
|
2784
3427
|
renderer.phase_end(built.iteration, "builder", true);
|
|
2785
3428
|
renderer.cost_line(built.iteration, "builder", built.builder?.cost_usd ?? null, built.total_cost_usd);
|
|
2786
3429
|
if (built.cost_warned && !s.cost_warned) renderer.warn("cost unavailable for this phase; recording null (run totals sum what is known)");
|
|
2787
|
-
const
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
gate_patterns: config.gate_paths
|
|
2791
|
-
});
|
|
2792
|
-
report_changes(config, renderer, changes);
|
|
2793
|
-
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));
|
|
2794
3433
|
return {
|
|
2795
3434
|
...built,
|
|
2796
3435
|
changes
|
|
2797
3436
|
};
|
|
2798
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
|
+
}
|
|
2799
3452
|
/** `skip_verify`: the build alone crossed the cost cap, so the check is recorded
|
|
2800
3453
|
* as skipped and no critic is called — a doomed iteration spends nothing more.
|
|
2801
3454
|
* The loop guard then halts the run on the cap. */
|
|
@@ -2806,20 +3459,79 @@ function skip_verify_phase(deps, s) {
|
|
|
2806
3459
|
check: skipped_check("cost_cap")
|
|
2807
3460
|
};
|
|
2808
3461
|
}
|
|
2809
|
-
/**
|
|
3462
|
+
/** Open the check's card on a streamed run: the call's start, then the
|
|
3463
|
+
* command as its input, the two chunks `model_call` records for a tool call
|
|
3464
|
+
* before it runs. */
|
|
3465
|
+
function open_check_card(ctx, command) {
|
|
3466
|
+
record_check_chunk(ctx, {
|
|
3467
|
+
kind: "tool_call_start",
|
|
3468
|
+
...CHECK_CALL,
|
|
3469
|
+
step_index: 0
|
|
3470
|
+
});
|
|
3471
|
+
record_check_chunk(ctx, {
|
|
3472
|
+
kind: "tool_call_end",
|
|
3473
|
+
id: CHECK_CALL.id,
|
|
3474
|
+
input: { command },
|
|
3475
|
+
step_index: 0
|
|
3476
|
+
});
|
|
3477
|
+
}
|
|
3478
|
+
/** Close the check's card with its result, which the card's reader renders,
|
|
3479
|
+
* or with the error that stopped the check before it had one. */
|
|
3480
|
+
function close_check_card(ctx, outcome) {
|
|
3481
|
+
record_check_chunk(ctx, "result" in outcome ? {
|
|
3482
|
+
kind: "tool_result",
|
|
3483
|
+
id: CHECK_CALL.id,
|
|
3484
|
+
output: outcome.result,
|
|
3485
|
+
step_index: 0
|
|
3486
|
+
} : {
|
|
3487
|
+
kind: "tool_result",
|
|
3488
|
+
id: CHECK_CALL.id,
|
|
3489
|
+
error: { message: outcome.error instanceof Error ? outcome.error.message : String(outcome.error) },
|
|
3490
|
+
step_index: 0
|
|
3491
|
+
});
|
|
3492
|
+
}
|
|
3493
|
+
/** `check`: the deterministic gate over the tree the builder wrote. A check
|
|
3494
|
+
* that runs a command emits its start and end, and on a streamed run also
|
|
3495
|
+
* records its card as a tool call, so a reader of the stream shows it running
|
|
3496
|
+
* while it runs. When the caller set an approval, the check asks on that card
|
|
3497
|
+
* first, and a rejected check closes it failed and is recorded as failed. */
|
|
2810
3498
|
async function check_phase(deps, s, ctx) {
|
|
2811
3499
|
const { config, renderer } = deps;
|
|
2812
3500
|
renderer.phase_start(s.iteration, "check");
|
|
3501
|
+
const command = check_command(config);
|
|
3502
|
+
if (command === null) {
|
|
3503
|
+
renderer.info("no deterministic check configured; loop is critic-gated only");
|
|
3504
|
+
return {
|
|
3505
|
+
...s,
|
|
3506
|
+
check: skipped_check("none")
|
|
3507
|
+
};
|
|
3508
|
+
}
|
|
3509
|
+
emit(ctx, {
|
|
3510
|
+
volley: "check_start",
|
|
3511
|
+
round: s.iteration,
|
|
3512
|
+
command
|
|
3513
|
+
});
|
|
3514
|
+
open_check_card(ctx, command);
|
|
2813
3515
|
try {
|
|
2814
|
-
const
|
|
2815
|
-
|
|
2816
|
-
|
|
3516
|
+
const allowed = await check_allowed(deps, command, ctx.abort);
|
|
3517
|
+
const result = allowed ? await execute_check(deps, ctx.abort) : rejected_check(config.check_resolved);
|
|
3518
|
+
close_check_card(ctx, allowed ? { result } : { error: CHECK_REJECTED });
|
|
3519
|
+
emit(ctx, {
|
|
3520
|
+
volley: "check_end",
|
|
3521
|
+
round: s.iteration,
|
|
3522
|
+
ok: result.ok,
|
|
3523
|
+
exit_code: result.exit_code,
|
|
3524
|
+
failing_slots: result.failing_slots,
|
|
3525
|
+
duration_ms: result.duration_ms
|
|
3526
|
+
});
|
|
3527
|
+
renderer.phase_end(s.iteration, "check", result.ok, !allowed ? "rejected" : result.failing_slots.length > 0 ? `failing: ${result.failing_slots.join(", ")}` : void 0);
|
|
2817
3528
|
return {
|
|
2818
3529
|
...s,
|
|
2819
3530
|
check: result,
|
|
2820
3531
|
check_duration_ms: s.check_duration_ms + result.duration_ms
|
|
2821
3532
|
};
|
|
2822
3533
|
} catch (err) {
|
|
3534
|
+
close_check_card(ctx, { error: err });
|
|
2823
3535
|
if (error_kind(err) === "check_error") throw err;
|
|
2824
3536
|
throw phase_error("check", s.iteration, err);
|
|
2825
3537
|
}
|
|
@@ -2832,6 +3544,14 @@ async function critique_phase(deps, critic, s, ctx) {
|
|
|
2832
3544
|
critic,
|
|
2833
3545
|
config: deps.config
|
|
2834
3546
|
}, s, ctx);
|
|
3547
|
+
if (critiqued.verdict !== null) emit(ctx, {
|
|
3548
|
+
volley: "verdict",
|
|
3549
|
+
round: critiqued.iteration,
|
|
3550
|
+
verdict: critiqued.verdict,
|
|
3551
|
+
feedback: critiqued.feedback ?? "",
|
|
3552
|
+
unmet_criteria: critiqued.unmet_criteria,
|
|
3553
|
+
degraded: critiqued.critic?.critic_degraded === true
|
|
3554
|
+
});
|
|
2835
3555
|
renderer.phase_end(critiqued.iteration, "critic", critiqued.verdict === "approved", critiqued.verdict ?? void 0);
|
|
2836
3556
|
renderer.cost_line(critiqued.iteration, "critic", critiqued.critic?.cost_usd ?? null, critiqued.total_cost_usd);
|
|
2837
3557
|
return critiqued;
|
|
@@ -2842,7 +3562,10 @@ function record_phase(deps, s) {
|
|
|
2842
3562
|
const { config } = deps;
|
|
2843
3563
|
archive_iteration(config, s);
|
|
2844
3564
|
write_run_summary(config, run_result_from_state(config, s, "running", null));
|
|
2845
|
-
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
|
+
}
|
|
2846
3569
|
return s;
|
|
2847
3570
|
}
|
|
2848
3571
|
//#endregion
|
|
@@ -2854,6 +3577,11 @@ function record_phase(deps, s) {
|
|
|
2854
3577
|
* around the flow (workspace setup, the worktree lifecycle, `run`, the final
|
|
2855
3578
|
* summary) in `./orchestrator.ts`.
|
|
2856
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
|
+
*
|
|
2857
3585
|
* fascicle's `describe.diagram` draws the tree below from the flow itself, and
|
|
2858
3586
|
* `test/unit/flow_diagram.test.ts` holds it to the code: change a row by changing
|
|
2859
3587
|
* its step's `description`, then paste `pnpm diagram --prefix ' * '` over it.
|
|
@@ -2867,58 +3595,70 @@ function record_phase(deps, s) {
|
|
|
2867
3595
|
* │ │ └─ else sequence
|
|
2868
3596
|
* │ │ ├─ check the deterministic gate: checkride, a command, or none
|
|
2869
3597
|
* │ │ └─ critique the read-only critic's verdict and feedback
|
|
2870
|
-
* │ │ └─ critic fallback: judge without tools if a
|
|
2871
|
-
* │ │ ├─ retry one more try after a stream death,
|
|
3598
|
+
* │ │ └─ critic fallback: judge without tools if a critic on any provider but claude_cli keeps dying or runs out of steps
|
|
3599
|
+
* │ │ ├─ retry one more try after a stream death, on any provider but claude_cli
|
|
2872
3600
|
* │ │ │ └─ critic_tools model call with read-only workspace tools
|
|
2873
3601
|
* │ │ └─ pipe mark the verdict degraded
|
|
2874
3602
|
* │ │ └─ critic_toolless model call judging from the check output and a file list
|
|
2875
3603
|
* │ └─ record archive the iteration and rewrite the run summary
|
|
2876
3604
|
* └─ guard gate stop on approval over a green check, the cost cap, or a gate edit
|
|
2877
3605
|
*/
|
|
2878
|
-
/** The critic arm: the degradation ladder. A
|
|
2879
|
-
* stream dies is retried at once (the death is
|
|
2880
|
-
* load, so waiting buys nothing), then judged
|
|
2881
|
-
*
|
|
3606
|
+
/** The critic arm: the degradation ladder. A critic on any provider but
|
|
3607
|
+
* `claude_cli` whose tool-bearing stream dies is retried at once (the death is
|
|
3608
|
+
* stochastic parser failure, not load, so waiting buys nothing), then judged
|
|
3609
|
+
* once without tools. One that runs out of steps skips the retry, since
|
|
3610
|
+
* another identical tool loop would only run out again, and goes straight to
|
|
3611
|
+
* the tool-less pass. Anything else propagates from either rung untouched. */
|
|
2882
3612
|
function build_critic(deps) {
|
|
2883
3613
|
const retryable = critic_retryable(deps.config);
|
|
2884
3614
|
return fallback(retry(make_critic_tools_step(deps), {
|
|
2885
|
-
description: "one more try after a stream death,
|
|
3615
|
+
description: "one more try after a stream death, on any provider but claude_cli",
|
|
2886
3616
|
max_attempts: 2,
|
|
2887
3617
|
backoff_ms: 0,
|
|
2888
3618
|
when: retryable,
|
|
2889
3619
|
project: with_retries
|
|
2890
3620
|
}), pipe(make_critic_toolless_step(deps), as_degraded, { description: "mark the verdict degraded" }), {
|
|
2891
3621
|
name: "critic",
|
|
2892
|
-
description: "judge without tools if a
|
|
2893
|
-
when:
|
|
3622
|
+
description: "judge without tools if a critic on any provider but claude_cli keeps dying or runs out of steps",
|
|
3623
|
+
when: critic_degradable(deps.config),
|
|
2894
3624
|
handoff: (prompt) => toolless_critic_prompt(deps.config, prompt),
|
|
2895
3625
|
project: with_fallback_cause
|
|
2896
3626
|
});
|
|
2897
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
|
+
}
|
|
2898
3646
|
function build_flow(deps, resume_from) {
|
|
2899
3647
|
const { config, renderer } = deps;
|
|
2900
3648
|
const builder = make_builder_step({
|
|
2901
|
-
|
|
3649
|
+
...deps.seats.builder,
|
|
2902
3650
|
config,
|
|
2903
3651
|
on_chunk: renderer.builder_chunk,
|
|
2904
3652
|
bash_executor: deps.bash_executor
|
|
2905
3653
|
});
|
|
2906
|
-
const critic = build_critic({
|
|
2907
|
-
engine: deps.engines.critic,
|
|
2908
|
-
config,
|
|
2909
|
-
on_chunk: renderer.critic_chunk
|
|
2910
|
-
});
|
|
2911
3654
|
const build = step("build", (s, ctx) => build_phase(deps, builder, s, ctx), {
|
|
2912
3655
|
description: "open the iteration, run the builder, measure the change",
|
|
2913
3656
|
arm: builder
|
|
2914
3657
|
});
|
|
2915
3658
|
const skip_verify = step("skip_verify", (s) => skip_verify_phase(deps, s), { description: "record the check as skipped and call no critic" });
|
|
2916
|
-
const check =
|
|
2917
|
-
const critique =
|
|
2918
|
-
|
|
2919
|
-
arm: critic
|
|
2920
|
-
});
|
|
2921
|
-
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);
|
|
2922
3662
|
const verify = branch({
|
|
2923
3663
|
name: "verify",
|
|
2924
3664
|
description: "did the build alone cross the cost cap?",
|
|
@@ -2944,6 +3684,19 @@ function build_flow(deps, resume_from) {
|
|
|
2944
3684
|
max_rounds: remaining_iterations(config, resume_from)
|
|
2945
3685
|
});
|
|
2946
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
|
+
}
|
|
2947
3700
|
//#endregion
|
|
2948
3701
|
//#region src/orchestrator.ts
|
|
2949
3702
|
/**
|
|
@@ -2954,19 +3707,25 @@ function build_flow(deps, resume_from) {
|
|
|
2954
3707
|
* substrate.
|
|
2955
3708
|
*
|
|
2956
3709
|
* run_volley
|
|
2957
|
-
* ├─ initialize the
|
|
3710
|
+
* ├─ initialize the run's state (or preserve it, on resume)
|
|
2958
3711
|
* ├─ with_worktree off unless --worktree
|
|
2959
3712
|
* │ ├─ capture the change baseline
|
|
2960
|
-
* │ ├─ run_loop run(
|
|
3713
|
+
* │ ├─ run_loop run the flow (streamed when a sink is given), then the final summary
|
|
2961
3714
|
* │ └─ integrate a successful --worktree --git run only
|
|
2962
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
|
|
2963
3721
|
*/
|
|
2964
3722
|
async function run_volley(config, deps, resume_from = null) {
|
|
2965
3723
|
const { renderer } = deps;
|
|
2966
3724
|
if (resume_from === null) {
|
|
2967
|
-
|
|
2968
|
-
write_resolved_config(config);
|
|
2969
|
-
} else
|
|
3725
|
+
initialize_run_state(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`);
|
|
3727
|
+
} else initialize_run_state(config, { preserve: true });
|
|
3728
|
+
renderer.info(`state: ${run_state_dir(config)}`);
|
|
2970
3729
|
report_worktree_fate(config, renderer);
|
|
2971
3730
|
const branch = worktree_branch(config.run_id);
|
|
2972
3731
|
const fate = worktree_fate(config);
|
|
@@ -3007,18 +3766,24 @@ async function run_loop(config, deps, resume_from, bash_executor, baseline) {
|
|
|
3007
3766
|
critic: deps.engine
|
|
3008
3767
|
};
|
|
3009
3768
|
const flow = build_flow({
|
|
3010
|
-
|
|
3769
|
+
seats: {
|
|
3770
|
+
builder: {
|
|
3771
|
+
...deps.seats?.builder,
|
|
3772
|
+
engine: engines.builder
|
|
3773
|
+
},
|
|
3774
|
+
critic: {
|
|
3775
|
+
...deps.seats?.critic,
|
|
3776
|
+
engine: engines.critic
|
|
3777
|
+
}
|
|
3778
|
+
},
|
|
3011
3779
|
config,
|
|
3012
3780
|
renderer,
|
|
3013
3781
|
bash_executor,
|
|
3014
|
-
baseline
|
|
3782
|
+
baseline,
|
|
3783
|
+
...deps.check !== void 0 ? { check: deps.check } : {}
|
|
3015
3784
|
}, resume_from);
|
|
3016
3785
|
try {
|
|
3017
|
-
const { value, converged } = await
|
|
3018
|
-
trajectory: filesystem_logger({ output_path: volley_path(config.workspace, "trajectory.jsonl") }),
|
|
3019
|
-
...deps.abort !== void 0 ? { abort: deps.abort } : {},
|
|
3020
|
-
...deps.install_signal_handlers !== void 0 ? { install_signal_handlers: deps.install_signal_handlers } : {}
|
|
3021
|
-
});
|
|
3786
|
+
const { value, converged } = await execute_flow(config, deps, flow, { resume_from });
|
|
3022
3787
|
const result = run_result_from_state(config, value, status_of(value, converged), (/* @__PURE__ */ new Date()).toISOString());
|
|
3023
3788
|
write_run_summary(config, result);
|
|
3024
3789
|
return result;
|
|
@@ -3029,6 +3794,45 @@ async function run_loop(config, deps, resume_from, bash_executor, baseline) {
|
|
|
3029
3794
|
await dispose_seat_engines(engines);
|
|
3030
3795
|
}
|
|
3031
3796
|
}
|
|
3797
|
+
/** Run the flow into its file trajectory and, when the caller passed one, its
|
|
3798
|
+
* own sink. Without a sink this is the one `run` it has always been. With one,
|
|
3799
|
+
* it goes through `run.stream`, because only a streamed run records each model
|
|
3800
|
+
* call's chunks as `model_chunk` events, which are what an outer consumer maps
|
|
3801
|
+
* to text, tool cards, and approval prompts. The sink is that consumer, so the
|
|
3802
|
+
* handle's own event stream is closed at once rather than left to buffer, and
|
|
3803
|
+
* the file skips the chunks, so it holds the same kinds of event either way.
|
|
3804
|
+
*
|
|
3805
|
+
* The file goes first in the tee because the first sink's span ids are the
|
|
3806
|
+
* ones the run hands out as parents, so the file's parent links stay whole.
|
|
3807
|
+
* The sink sees parent ids it never issued, which costs an ACP consumer
|
|
3808
|
+
* nothing: it counts a step's runs by the `id` on each `span_start`. */
|
|
3809
|
+
function execute_flow(config, deps, flow, input) {
|
|
3810
|
+
const file = filesystem_logger({ output_path: state_path(config, "trajectory.jsonl") });
|
|
3811
|
+
const options = {
|
|
3812
|
+
...deps.abort !== void 0 ? { abort: deps.abort } : {},
|
|
3813
|
+
...deps.install_signal_handlers !== void 0 ? { install_signal_handlers: deps.install_signal_handlers } : {}
|
|
3814
|
+
};
|
|
3815
|
+
if (deps.trajectory === void 0) return run(flow, input, {
|
|
3816
|
+
...options,
|
|
3817
|
+
trajectory: file
|
|
3818
|
+
});
|
|
3819
|
+
const handle = run.stream(flow, input, {
|
|
3820
|
+
...options,
|
|
3821
|
+
trajectory: tee_logger(without_model_chunks(file), deps.trajectory)
|
|
3822
|
+
});
|
|
3823
|
+
handle.events[Symbol.asyncIterator]().return?.();
|
|
3824
|
+
return handle.result;
|
|
3825
|
+
}
|
|
3826
|
+
/** A logger that passes `inner` every event but a model call's streamed
|
|
3827
|
+
* chunks. */
|
|
3828
|
+
function without_model_chunks(inner) {
|
|
3829
|
+
return {
|
|
3830
|
+
...inner,
|
|
3831
|
+
record: (event) => {
|
|
3832
|
+
if (event.kind !== "model_chunk") inner.record(event);
|
|
3833
|
+
}
|
|
3834
|
+
};
|
|
3835
|
+
}
|
|
3032
3836
|
/** Best-effort status stamp on the run summary when the loop threw; the
|
|
3033
3837
|
* on-disk iteration state is already consistent (record runs per round). */
|
|
3034
3838
|
function finalize_failed_run(config, err) {
|
|
@@ -3036,59 +3840,64 @@ function finalize_failed_run(config, err) {
|
|
|
3036
3840
|
const cause = kind === "phase_error" ? error_kind(err.cause) : null;
|
|
3037
3841
|
const status = kind === "aborted_error" || cause === "aborted_error" ? "interrupted" : "error";
|
|
3038
3842
|
try {
|
|
3039
|
-
const summary_path =
|
|
3843
|
+
const summary_path = state_path(config, "summary.json");
|
|
3040
3844
|
write_run_summary(config, {
|
|
3041
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),
|
|
3042
3847
|
status,
|
|
3043
3848
|
completed_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
3044
3849
|
});
|
|
3045
3850
|
} catch {}
|
|
3046
3851
|
}
|
|
3047
|
-
//#endregion
|
|
3048
|
-
//#region src/render/format.ts
|
|
3049
3852
|
/**
|
|
3050
|
-
*
|
|
3051
|
-
*
|
|
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`.
|
|
3052
3859
|
*/
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
const
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
}
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
}
|
|
3076
|
-
/** One-line summary of a tool input for the default streaming mode. */
|
|
3077
|
-
function summarize_value(value, max = 120) {
|
|
3078
|
-
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
|
+
});
|
|
3079
3883
|
try {
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
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();
|
|
3083
3893
|
}
|
|
3084
|
-
const flat = text.replace(/\s+/g, " ");
|
|
3085
|
-
return flat.length > max ? `${flat.slice(0, max)}…` : flat;
|
|
3086
3894
|
}
|
|
3087
3895
|
//#endregion
|
|
3088
3896
|
//#region src/render/renderer.ts
|
|
3089
3897
|
function create_renderer(options) {
|
|
3090
3898
|
const write = options.write ?? ((text) => process.stderr.write(text));
|
|
3091
3899
|
const { mode, color } = options;
|
|
3900
|
+
const see_trajectory = `see ${options.trajectory_path ?? "the run's trajectory.jsonl"}`;
|
|
3092
3901
|
const silent = mode === "json";
|
|
3093
3902
|
const streaming = mode === "default" || mode === "verbose";
|
|
3094
3903
|
let mid_line = false;
|
|
@@ -3110,7 +3919,7 @@ function create_renderer(options) {
|
|
|
3110
3919
|
mid_line = !chunk.text.endsWith("\n");
|
|
3111
3920
|
return;
|
|
3112
3921
|
case "tool_call_end": {
|
|
3113
|
-
const summary = mode === "verbose" ? truncate(JSON.stringify(chunk.input, null, 2) ?? "", VERBOSE_TRUNCATE_CHARS,
|
|
3922
|
+
const summary = mode === "verbose" ? truncate(JSON.stringify(chunk.input, null, 2) ?? "", VERBOSE_TRUNCATE_CHARS, see_trajectory) : summarize_value(chunk.input);
|
|
3114
3923
|
line(`${GLYPH_TOOL} ${paint(role, chunk.id, color)} ${summary}`);
|
|
3115
3924
|
return;
|
|
3116
3925
|
}
|
|
@@ -3118,7 +3927,7 @@ function create_renderer(options) {
|
|
|
3118
3927
|
const ok = chunk.error === void 0;
|
|
3119
3928
|
if (mode === "verbose") {
|
|
3120
3929
|
const body = ok ? summarize_output(chunk.output) : chunk.error?.message ?? "";
|
|
3121
|
-
line(`${ok ? "✅" : "❌"} ${truncate(body, VERBOSE_TRUNCATE_CHARS,
|
|
3930
|
+
line(`${ok ? "✅" : "❌"} ${truncate(body, VERBOSE_TRUNCATE_CHARS, see_trajectory)}`);
|
|
3122
3931
|
} else line(`${ok ? "✅" : "❌"} ${ok ? "" : summarize_value(chunk.error?.message)}`);
|
|
3123
3932
|
return;
|
|
3124
3933
|
}
|
|
@@ -3166,6 +3975,7 @@ function create_renderer(options) {
|
|
|
3166
3975
|
line(`iterations: ${String(result.iterations_completed)} | verdict: ${result.final_verdict ?? "n/a"}`);
|
|
3167
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));
|
|
3168
3977
|
line(`tokens: ${String(result.total_usage.input_tokens)} in / ${String(result.total_usage.output_tokens)} out`);
|
|
3978
|
+
line(`state: ${result.run_state_dir}`);
|
|
3169
3979
|
}
|
|
3170
3980
|
};
|
|
3171
3981
|
}
|
|
@@ -3187,7 +3997,7 @@ function summarize_output(output) {
|
|
|
3187
3997
|
* volley detects it is already contained. The create/`exec`/reap/teardown
|
|
3188
3998
|
* lifecycle and the `docker exec` bash executor were retired; what
|
|
3189
3999
|
* remains here is the *spec* — pure, daemon-free flag-builders the blessed
|
|
3190
|
-
* examples and
|
|
4000
|
+
* examples and a parent harness render into the `docker run` line,
|
|
3191
4001
|
* unit-testable without a daemon.
|
|
3192
4002
|
*
|
|
3193
4003
|
* Inside that container volley's `bash` is the local `host_bash_executor`
|
|
@@ -3212,6 +4022,13 @@ const SANDBOX_WORKDIR = "/workspace";
|
|
|
3212
4022
|
* otherwise unset) and its `.cache` is a tmpfs so the read-only rootfs does not
|
|
3213
4023
|
* brick tools that scribble there. */
|
|
3214
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);
|
|
3215
4032
|
/** The host spelling of the Docker gateway (Linux-portable; Docker Desktop
|
|
3216
4033
|
* already provides it). It is both the `--add-host` target for the allowlist
|
|
3217
4034
|
* bridge and the value the example injects as `VOLLEY_MODEL_HOST` so the
|
|
@@ -3243,7 +4060,7 @@ function network_run_args(network, network_name) {
|
|
|
3243
4060
|
/**
|
|
3244
4061
|
* The hardened `docker run` argv that launches volley inside its container.
|
|
3245
4062
|
* Pure and exported so the flag set is unit-testable without a
|
|
3246
|
-
* daemon and the blessed examples
|
|
4063
|
+
* daemon and the blessed examples and a parent harness can render it.
|
|
3247
4064
|
*
|
|
3248
4065
|
* A one-shot, foreground `--rm` container — the whole run *is* this
|
|
3249
4066
|
* container (an earlier design ran a detached `-d … sleep infinity` daemon volley
|
|
@@ -3255,6 +4072,25 @@ function network_run_args(network, network_name) {
|
|
|
3255
4072
|
* the image entrypoint (the opt-in real-docker isolation test runs a raw `sh`
|
|
3256
4073
|
* this way).
|
|
3257
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
|
+
*
|
|
3258
4094
|
* `store_volume` mounts the named volume as the pnpm store; omit it and no store
|
|
3259
4095
|
* is mounted. A store is shared by every run that mounts it, so a caller that
|
|
3260
4096
|
* installs dependencies in a separate container first can keep the builder's
|
|
@@ -3264,7 +4100,7 @@ function network_run_args(network, network_name) {
|
|
|
3264
4100
|
* Never emits `--privileged` and refuses `--user 0`: volley must not hand
|
|
3265
4101
|
* the sandbox root or full capabilities. Everything writable under the
|
|
3266
4102
|
* `--read-only` rootfs is an explicit tmpfs or volume; the bind-mounted worktree
|
|
3267
|
-
* is the
|
|
4103
|
+
* and, when one is named, the state root are the host-visible write paths.
|
|
3268
4104
|
*/
|
|
3269
4105
|
function sandbox_run_args(options) {
|
|
3270
4106
|
if (options.uid === 0) throw config_error("refusing to start the builder sandbox as root (uid 0)");
|
|
@@ -3273,6 +4109,7 @@ function sandbox_run_args(options) {
|
|
|
3273
4109
|
const entrypoint = options.entrypoint !== void 0 && options.entrypoint !== null ? ["--entrypoint", options.entrypoint] : [];
|
|
3274
4110
|
const env = (options.env ?? []).flatMap(([key, value]) => ["-e", `${key}=${value}`]);
|
|
3275
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)] : [];
|
|
3276
4113
|
const store = options.store_volume !== void 0 && options.store_volume !== null ? ["-v", `${options.store_volume}:${SANDBOX_HOME}/.local/share/pnpm/store`] : [];
|
|
3277
4114
|
return [
|
|
3278
4115
|
"run",
|
|
@@ -3306,6 +4143,7 @@ function sandbox_run_args(options) {
|
|
|
3306
4143
|
...env,
|
|
3307
4144
|
"-v",
|
|
3308
4145
|
`${resolve(options.build_root)}:${SANDBOX_WORKDIR}`,
|
|
4146
|
+
...state,
|
|
3309
4147
|
...store,
|
|
3310
4148
|
"-w",
|
|
3311
4149
|
SANDBOX_WORKDIR,
|
|
@@ -3314,6 +4152,13 @@ function sandbox_run_args(options) {
|
|
|
3314
4152
|
...options.command ?? []
|
|
3315
4153
|
];
|
|
3316
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
|
+
}
|
|
3317
4162
|
//#endregion
|
|
3318
4163
|
//#region src/exit_codes.ts
|
|
3319
4164
|
/**
|
|
@@ -3331,7 +4176,8 @@ const EXIT_INTERRUPTED = 130;
|
|
|
3331
4176
|
function exit_code_for_status(status) {
|
|
3332
4177
|
switch (status) {
|
|
3333
4178
|
case "success": return 0;
|
|
3334
|
-
case "budget_exhausted":
|
|
4179
|
+
case "budget_exhausted":
|
|
4180
|
+
case "rejected": return 2;
|
|
3335
4181
|
case "cost_cap_reached": return 7;
|
|
3336
4182
|
case "gate_edit_blocked": return 8;
|
|
3337
4183
|
case "interrupted": return 130;
|
|
@@ -3366,6 +4212,6 @@ function exit_code_for_error(err) {
|
|
|
3366
4212
|
return 3;
|
|
3367
4213
|
}
|
|
3368
4214
|
//#endregion
|
|
3369
|
-
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 };
|
|
3370
4216
|
|
|
3371
4217
|
//# sourceMappingURL=index.js.map
|