@robmclarty/volley 0.7.0 → 0.8.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 +20 -0
- package/README.md +277 -25
- package/dist/cli.js +3640 -2636
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +157 -30
- package/dist/index.js +589 -163
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/src/builder/presets/harness_append_acp.md +86 -0
- 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 +2 -2
package/dist/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
4
4
|
import { 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 { filesystem_logger } from "fascicle/adapters";
|
|
7
|
+
import { filesystem_logger, tee_logger } from "fascicle/adapters";
|
|
8
8
|
import { lookup } from "node:dns";
|
|
9
9
|
import { Readability } from "@mozilla/readability";
|
|
10
10
|
import ipaddr from "ipaddr.js";
|
|
@@ -315,6 +315,13 @@ function error_kind(err) {
|
|
|
315
315
|
* and 404s. */
|
|
316
316
|
const DEFAULT_OLLAMA_URL = "http://localhost:11434";
|
|
317
317
|
const DEFAULT_LMSTUDIO_URL = "http://localhost:1234/v1";
|
|
318
|
+
/** OpenRouter's API root, the one fascicle's openrouter adapter defaults to. */
|
|
319
|
+
const DEFAULT_OPENROUTER_URL = "https://openrouter.ai/api/v1";
|
|
320
|
+
/** The variable an `openrouter` seat's API key is read from. */
|
|
321
|
+
const OPENROUTER_KEY_VAR = "OPENROUTER_API_KEY";
|
|
322
|
+
/** The variable a `claude_cli` seat's API key is read from, which the Claude
|
|
323
|
+
* CLI also reads on its own. */
|
|
324
|
+
const ANTHROPIC_KEY_VAR = "ANTHROPIC_API_KEY";
|
|
318
325
|
/** Loopback authorities the container→host crossing rewrites. A base URL
|
|
319
326
|
* pointing at one of these is local to wherever volley runs; contained, that is
|
|
320
327
|
* *inside* the sandbox container, where the host LLM daemon is unreachable
|
|
@@ -361,9 +368,15 @@ const SEAT_URL_SOURCES = {
|
|
|
361
368
|
env: "VOLLEY_LMSTUDIO_URL",
|
|
362
369
|
default_url: DEFAULT_LMSTUDIO_URL,
|
|
363
370
|
normalize: strip_trailing_slashes
|
|
371
|
+
},
|
|
372
|
+
openrouter: {
|
|
373
|
+
env: "VOLLEY_OPENROUTER_URL",
|
|
374
|
+
default_url: DEFAULT_OPENROUTER_URL,
|
|
375
|
+
normalize: strip_trailing_slashes
|
|
364
376
|
}
|
|
365
377
|
};
|
|
366
|
-
/** The providers that take a seat URL (`--builder-url` / `--critic-url`)
|
|
378
|
+
/** The providers that take a seat URL (`--builder-url` / `--critic-url`), and
|
|
379
|
+
* so get their endpoint probed by `--dry-run`. */
|
|
367
380
|
const SEAT_URL_PROVIDERS = Object.keys(SEAT_URL_SOURCES);
|
|
368
381
|
/** Whether a seat on `provider` reaches its model at a base URL volley
|
|
369
382
|
* configures, and so takes a URL of its own. */
|
|
@@ -376,16 +389,25 @@ function present(value) {
|
|
|
376
389
|
return trimmed === "" ? null : trimmed;
|
|
377
390
|
}
|
|
378
391
|
/** 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. */
|
|
392
|
+
* provider's env var (`VOLLEY_OLLAMA_URL` / `VOLLEY_LMSTUDIO_URL` /
|
|
393
|
+
* `VOLLEY_OPENROUTER_URL`), else its default. A seat never falls back to the
|
|
394
|
+
* other seat's URL. The result is normalized, then a loopback authority is
|
|
395
|
+
* crossed to the host LLM endpoint when volley runs contained. Both seats can
|
|
396
|
+
* be loopback URLs on different ports, and each crosses on its own port. */
|
|
384
397
|
function seat_base_url(provider, seat_url, env) {
|
|
385
398
|
const source = SEAT_URL_SOURCES[provider];
|
|
386
399
|
const raw = present(seat_url) ?? present(env[source.env]) ?? source.default_url;
|
|
387
400
|
return cross_to_host_gateway(source.normalize(raw), env);
|
|
388
401
|
}
|
|
402
|
+
/** The API key an `openrouter` seat sends, from `OPENROUTER_API_KEY`. An unset
|
|
403
|
+
* or blank key is a config error naming the variable, which `resolve_config`
|
|
404
|
+
* raises before any work, because fascicle's own "requires a non-empty
|
|
405
|
+
* api_key" doesn't say where the key comes from. */
|
|
406
|
+
function openrouter_api_key(env) {
|
|
407
|
+
const key = present(env[OPENROUTER_KEY_VAR]);
|
|
408
|
+
if (key === null) throw config_error(`${OPENROUTER_KEY_VAR} is not set: an openrouter seat sends it as its API key`);
|
|
409
|
+
return key;
|
|
410
|
+
}
|
|
389
411
|
function load_pricing_overrides(path) {
|
|
390
412
|
if (!existsSync(path)) throw config_error(`VOLLEY_PRICING_PATH not found: ${path}`);
|
|
391
413
|
try {
|
|
@@ -398,29 +420,44 @@ function load_pricing_overrides(path) {
|
|
|
398
420
|
* peer (`ai-sdk-ollama` / `@ai-sdk/openai-compatible`) is loaded lazily by
|
|
399
421
|
* fascicle only when the provider actually runs. The entry is complete, because
|
|
400
422
|
* `Engine.with_providers` replaces a provider's whole entry: whatever this
|
|
401
|
-
* returns is all a seat's derived engine knows about the provider
|
|
423
|
+
* returns is all a seat's derived engine knows about the provider, so an
|
|
424
|
+
* `openrouter` entry carries its key and its transport along with its URL.
|
|
425
|
+
*
|
|
426
|
+
* `openrouter` runs on fascicle's native transport, its OpenAI-compatible core,
|
|
427
|
+
* so no ai-sdk peer gets installed for it, and its `X-Title` attributes the
|
|
428
|
+
* traffic to volley.
|
|
402
429
|
*
|
|
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
|
|
430
|
+
* For the local providers, native transport is the deferred future bridge, not
|
|
431
|
+
* adopted here: they stay on the ai_sdk transport, which fascicle 0.14.0 already
|
|
432
|
+
* selects by default (`transport?: 'ai_sdk' | 'native'` defaults to `'ai_sdk'`),
|
|
433
|
+
* so no field is needed today. Flipping later is a one-line change — add
|
|
434
|
+
* `transport: 'native'` to the ollama/lmstudio entry returned below, which both
|
|
435
|
+
* seats' engines then share. No URL re-pointing is required: `seat_base_url`
|
|
436
|
+
* already yields the daemon-root URL the native `/api/chat` transport wants
|
|
437
|
+
* (its adapter appends `/api/chat` itself), which is exactly what the v0.3.1
|
|
438
|
+
* base-URL fix produces.
|
|
411
439
|
* Kept as a proven-once-then-reverted option so the transport stays a clean
|
|
412
440
|
* second variable in the model-vs-transport comparison. Flipping it today would
|
|
413
441
|
* also lose the reasoning: fascicle's native Ollama adapter drops
|
|
414
442
|
* `message.thinking`, with `think` unset or true (robmclarty/fascicle#12), so a
|
|
415
443
|
* thinking seat's reasoning would vanish from the terminal and the trajectory.
|
|
416
444
|
*
|
|
417
|
-
*
|
|
418
|
-
* volley runs contained, so an in-container model client reaches the
|
|
445
|
+
* Every provider's base URL crosses a loopback authority to `VOLLEY_MODEL_HOST`
|
|
446
|
+
* when volley runs contained, so an in-container model client reaches the
|
|
419
447
|
* host LLM endpoint at `host.docker.internal`; on the host / unsandboxed path the
|
|
420
448
|
* override is unset and this is a no-op. */
|
|
421
449
|
function seat_provider_config(provider, seat_url, env) {
|
|
422
450
|
const base_url = seat_base_url(provider, seat_url, env);
|
|
423
|
-
|
|
451
|
+
switch (provider) {
|
|
452
|
+
case "ollama": return { ollama: { base_url } };
|
|
453
|
+
case "lmstudio": return { lmstudio: { base_url } };
|
|
454
|
+
case "openrouter": return { openrouter: {
|
|
455
|
+
api_key: openrouter_api_key(env),
|
|
456
|
+
base_url,
|
|
457
|
+
transport: "native",
|
|
458
|
+
x_title: "volley"
|
|
459
|
+
} };
|
|
460
|
+
}
|
|
424
461
|
}
|
|
425
462
|
/** Per-run engine. `claude_cli` is always configured with `options.workspace`
|
|
426
463
|
* (the run's build root) as its session cwd; any URL-taking provider selected
|
|
@@ -440,7 +477,7 @@ function create_volley_engine(options) {
|
|
|
440
477
|
setting_sources: ["project", "local"],
|
|
441
478
|
auth_mode,
|
|
442
479
|
...binary !== void 0 && binary.length > 0 ? { binary } : {},
|
|
443
|
-
...auth_mode === "api_key" && env[
|
|
480
|
+
...auth_mode === "api_key" && env[ANTHROPIC_KEY_VAR] !== void 0 ? { api_key: env[ANTHROPIC_KEY_VAR] } : {}
|
|
444
481
|
} };
|
|
445
482
|
if (takes_seat_url(options.critic_provider)) Object.assign(providers, seat_provider_config(options.critic_provider, options.critic_url ?? null, env));
|
|
446
483
|
if (takes_seat_url(options.builder_provider)) Object.assign(providers, seat_provider_config(options.builder_provider, options.builder_url ?? null, env));
|
|
@@ -490,18 +527,24 @@ const DEFAULT_SANDBOX_IMAGE = "volley-sandbox:latest";
|
|
|
490
527
|
const BUILDER_PROVIDERS = [
|
|
491
528
|
"claude_cli",
|
|
492
529
|
"ollama",
|
|
493
|
-
"lmstudio"
|
|
530
|
+
"lmstudio",
|
|
531
|
+
"openrouter"
|
|
532
|
+
];
|
|
533
|
+
/** Builder providers that drive their model through volley's own tool loop —
|
|
534
|
+
* a real `bash` (write + exec), wherever the model runs. Refused unless volley
|
|
535
|
+
* is running inside a container (the correct blast-radius default) or the
|
|
536
|
+
* operator explicitly opts out; `claude_cli` has its own permission model and
|
|
537
|
+
* is exempt. */
|
|
538
|
+
const TOOL_LOOP_BUILDER_PROVIDERS = [
|
|
539
|
+
"ollama",
|
|
540
|
+
"lmstudio",
|
|
541
|
+
"openrouter"
|
|
494
542
|
];
|
|
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
543
|
const CRITIC_PROVIDERS = [
|
|
502
544
|
"claude_cli",
|
|
503
545
|
"ollama",
|
|
504
|
-
"lmstudio"
|
|
546
|
+
"lmstudio",
|
|
547
|
+
"openrouter"
|
|
505
548
|
];
|
|
506
549
|
const CRITIC_PRESETS = [
|
|
507
550
|
"reviewer",
|
|
@@ -580,7 +623,8 @@ function resolve_sandbox_image(raw, env) {
|
|
|
580
623
|
}
|
|
581
624
|
/** The `--allow-unsandboxed-builder` flag/config value wins; otherwise
|
|
582
625
|
* `VOLLEY_ALLOW_UNSANDBOXED_BUILDER=1` (or `=true`) opts out. Any other value
|
|
583
|
-
* (incl. `0`/`false`/unset) leaves
|
|
626
|
+
* (incl. `0`/`false`/unset) leaves a tool-loop builder refused unless
|
|
627
|
+
* contained. */
|
|
584
628
|
function resolve_allow_unsandboxed_builder(raw, env) {
|
|
585
629
|
if (raw.allow_unsandboxed_builder === true) return true;
|
|
586
630
|
const env_value = env["VOLLEY_ALLOW_UNSANDBOXED_BUILDER"];
|
|
@@ -600,7 +644,7 @@ function resolve_seat_url(seat, value, provider) {
|
|
|
600
644
|
if (typeof value !== "string") throw config_error(`${setting} must be a URL string; got a ${typeof value}`);
|
|
601
645
|
const url = value.trim();
|
|
602
646
|
if (url === "") return null;
|
|
603
|
-
if (!takes_seat_url(provider)) throw config_error(`${setting} applies only to a ${seat} on ${
|
|
647
|
+
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
648
|
let parsed;
|
|
605
649
|
try {
|
|
606
650
|
parsed = new URL(url);
|
|
@@ -612,6 +656,16 @@ function resolve_seat_url(seat, value, provider) {
|
|
|
612
656
|
if (parsed.search !== "" || parsed.hash !== "") throw config_error(`${setting} must not carry a query or fragment; got: ${url}`);
|
|
613
657
|
return url;
|
|
614
658
|
}
|
|
659
|
+
/** `--state-dir` (`state_dir`): the root each run's state sits under at
|
|
660
|
+
* `<root>/<run_id>/`, resolved against `cwd` so a resumed run finds the same
|
|
661
|
+
* place, or null to keep `<workspace>/.volley/`. An empty value counts as unset,
|
|
662
|
+
* as it does for a seat URL. */
|
|
663
|
+
function resolve_state_dir(value, cwd) {
|
|
664
|
+
if (value === void 0 || value === null) return null;
|
|
665
|
+
if (typeof value !== "string") throw config_error(`--state-dir (state_dir) must be a path string; got a ${typeof value}`);
|
|
666
|
+
const path = value.trim();
|
|
667
|
+
return path === "" ? null : resolve(cwd, path);
|
|
668
|
+
}
|
|
615
669
|
/** volley *detects* it is running inside its sandbox container rather than
|
|
616
670
|
* starting the container itself. The signal is the `VOLLEY_CONTAINED=1` marker
|
|
617
671
|
* volley's own image bakes in (`Dockerfile` `ENV`), so a `docker run <hardened
|
|
@@ -624,6 +678,12 @@ function detect_containment(env) {
|
|
|
624
678
|
const value = env["VOLLEY_CONTAINED"];
|
|
625
679
|
return value === "1" || value === "true";
|
|
626
680
|
}
|
|
681
|
+
/** Whether a tool-loop builder's commands would reach the host with nothing in
|
|
682
|
+
* between: volley isn't contained, and the operator hasn't opted out with
|
|
683
|
+
* `--allow-unsandboxed-builder` or `VOLLEY_ALLOW_UNSANDBOXED_BUILDER`. */
|
|
684
|
+
function unguarded_builder(raw, env) {
|
|
685
|
+
return !detect_containment(env) && !resolve_allow_unsandboxed_builder(raw, env);
|
|
686
|
+
}
|
|
627
687
|
/** A caller's run id names the run's `volley/<run id>` branch, so it is held to
|
|
628
688
|
* what a branch name accepts without quoting: letters, digits, `-`, and `_`,
|
|
629
689
|
* starting with a letter or digit so it never reads as an option. */
|
|
@@ -656,12 +716,13 @@ function resolve_config(raw, options = {}) {
|
|
|
656
716
|
if (!BUILDER_PROVIDERS.includes(builder_provider)) throw config_error(`--builder-provider must be one of ${BUILDER_PROVIDERS.join(", ")}; got: ${String(raw.builder_provider)}`);
|
|
657
717
|
const allow_unsandboxed_builder = resolve_allow_unsandboxed_builder(raw, env);
|
|
658
718
|
const contained = detect_containment(env);
|
|
659
|
-
if (
|
|
719
|
+
if (TOOL_LOOP_BUILDER_PROVIDERS.includes(builder_provider) && options.uncontained_builder !== "ask" && unguarded_builder(raw, 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.`);
|
|
660
720
|
const builder_max_steps = resolve_builder_max_steps(raw, env);
|
|
661
721
|
const critic_provider = raw.critic_provider ?? DEFAULT_CRITIC_PROVIDER;
|
|
662
722
|
if (!CRITIC_PROVIDERS.includes(critic_provider)) throw config_error(`--critic-provider must be one of ${CRITIC_PROVIDERS.join(", ")}; got: ${String(raw.critic_provider)}`);
|
|
663
723
|
const builder_url = resolve_seat_url("builder", raw.builder_url, builder_provider);
|
|
664
724
|
const critic_url = resolve_seat_url("critic", raw.critic_url, critic_provider);
|
|
725
|
+
if (builder_provider === "openrouter" || critic_provider === "openrouter") openrouter_api_key(env);
|
|
665
726
|
if (contained) {
|
|
666
727
|
const claude_roles = [builder_provider === "claude_cli" ? "builder" : null, critic_provider === "claude_cli" ? "critic" : null].filter((role) => role !== null);
|
|
667
728
|
const auth_mode = env["VOLLEY_AUTH_MODE"] ?? "auto";
|
|
@@ -675,6 +736,7 @@ function resolve_config(raw, options = {}) {
|
|
|
675
736
|
const gate_paths = raw.gate_paths ?? DEFAULT_GATE_PATTERNS;
|
|
676
737
|
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
738
|
const sandbox_image = resolve_sandbox_image(raw, env);
|
|
739
|
+
const state_dir = resolve_state_dir(raw.state_dir, cwd);
|
|
678
740
|
const run_id = resolve_run_id(options.run_id);
|
|
679
741
|
const check = raw.check ?? "auto";
|
|
680
742
|
return {
|
|
@@ -704,6 +766,7 @@ function resolve_config(raw, options = {}) {
|
|
|
704
766
|
gate_paths: [...gate_paths],
|
|
705
767
|
fail_on_gate_edit: raw.fail_on_gate_edit ?? false,
|
|
706
768
|
sandbox_image,
|
|
769
|
+
state_dir,
|
|
707
770
|
workspace,
|
|
708
771
|
verbose: raw.verbose ?? false,
|
|
709
772
|
quiet: raw.quiet ?? false,
|
|
@@ -782,8 +845,9 @@ async function warn_small_local_context(provider, model, base_url, warn, abort)
|
|
|
782
845
|
*/
|
|
783
846
|
const READ_FILE_MAX_BYTES = 2e5;
|
|
784
847
|
const LIST_MAX_ENTRIES = 2e3;
|
|
785
|
-
/** Directories never worth handing a
|
|
786
|
-
* traversal budget.
|
|
848
|
+
/** Directories never worth handing a seat; they bury signal and blow the
|
|
849
|
+
* traversal budget. The acp seats' `list_files` and `search` skip the same
|
|
850
|
+
* names (`./acp_tools.ts`), so a seat sees one tree under acp and on the CLI. */
|
|
787
851
|
const IGNORED_DIRS = /* @__PURE__ */ new Set([
|
|
788
852
|
"node_modules",
|
|
789
853
|
".git",
|
|
@@ -1295,7 +1359,19 @@ function builder_tools(workspace, options = {}) {
|
|
|
1295
1359
|
max_bytes: options.fetch_max_bytes ?? FETCH_MAX_BYTES
|
|
1296
1360
|
})
|
|
1297
1361
|
};
|
|
1298
|
-
|
|
1362
|
+
return [
|
|
1363
|
+
...read_only_tools(workspace),
|
|
1364
|
+
write_file,
|
|
1365
|
+
edit_file,
|
|
1366
|
+
bash,
|
|
1367
|
+
fetch,
|
|
1368
|
+
finish_tool()
|
|
1369
|
+
];
|
|
1370
|
+
}
|
|
1371
|
+
/** The terminal `finish` tool, on its own for a builder set that brings its
|
|
1372
|
+
* own file and command tools (`../acp_tools.ts`). */
|
|
1373
|
+
function finish_tool() {
|
|
1374
|
+
return {
|
|
1299
1375
|
name: "finish",
|
|
1300
1376
|
description: "Declare the task complete. Calling this tool ends your turn — do not call it until the work is done and verified.",
|
|
1301
1377
|
input_schema: finish_input,
|
|
@@ -1304,14 +1380,6 @@ function builder_tools(workspace, options = {}) {
|
|
|
1304
1380
|
return `finished: ${finish_input.parse(raw).summary}`;
|
|
1305
1381
|
}
|
|
1306
1382
|
};
|
|
1307
|
-
return [
|
|
1308
|
-
...read_only_tools(workspace),
|
|
1309
|
-
write_file,
|
|
1310
|
-
edit_file,
|
|
1311
|
-
bash,
|
|
1312
|
-
fetch,
|
|
1313
|
-
finish
|
|
1314
|
-
];
|
|
1315
1383
|
}
|
|
1316
1384
|
//#endregion
|
|
1317
1385
|
//#region src/cost.ts
|
|
@@ -1339,9 +1407,13 @@ function add_usage(a, b) {
|
|
|
1339
1407
|
}
|
|
1340
1408
|
return sum;
|
|
1341
1409
|
}
|
|
1410
|
+
/** The providers whose calls carry the charge they report, which fascicle
|
|
1411
|
+
* records as the call's cost in place of a pricing table's estimate: the
|
|
1412
|
+
* Claude CLI's own total, and OpenRouter's per-response charge. */
|
|
1413
|
+
const REPORTING_PROVIDERS = /* @__PURE__ */ new Set(["claude_cli", "openrouter"]);
|
|
1342
1414
|
function cost_source_of(result) {
|
|
1343
1415
|
if (result.cost === void 0) return "unknown";
|
|
1344
|
-
return result.model_resolved.provider
|
|
1416
|
+
return REPORTING_PROVIDERS.has(result.model_resolved.provider) ? "provider_reported" : "engine_derived";
|
|
1345
1417
|
}
|
|
1346
1418
|
/** One phase's record from its generate result. The duration is fascicle's
|
|
1347
1419
|
* call-level wall clock (`GenerateResult.timing`: every turn and the tools
|
|
@@ -1430,27 +1502,36 @@ async function prewarm_ollama_model(base_url, model, abort) {
|
|
|
1430
1502
|
//#endregion
|
|
1431
1503
|
//#region src/workspace.ts
|
|
1432
1504
|
/**
|
|
1433
|
-
* Workspace lifecycle:
|
|
1505
|
+
* Workspace lifecycle: run-state initialization, stale-run
|
|
1434
1506
|
* backup rotation, path helpers, and optional git checkpoints.
|
|
1435
1507
|
*/
|
|
1436
|
-
|
|
1437
|
-
|
|
1508
|
+
/** Where a run keeps its config, summary, trajectory, feedback, verdict, and
|
|
1509
|
+
* iterations: `<state_dir>/<run_id>/` under a state root, else
|
|
1510
|
+
* `<workspace>/.volley/`. */
|
|
1511
|
+
function run_state_dir(run) {
|
|
1512
|
+
return run.state_dir === null ? join(run.workspace, ".volley") : join(run.state_dir, run.run_id);
|
|
1513
|
+
}
|
|
1514
|
+
function state_path(run, ...parts) {
|
|
1515
|
+
return join(run_state_dir(run), ...parts);
|
|
1438
1516
|
}
|
|
1439
|
-
function iteration_dir(
|
|
1440
|
-
return
|
|
1517
|
+
function iteration_dir(run, iteration) {
|
|
1518
|
+
return state_path(run, "iterations", String(iteration).padStart(3, "0"));
|
|
1441
1519
|
}
|
|
1442
1520
|
/** Filesystem-safe timestamp for `.bak.<stamp>` rotation names. Shared by the
|
|
1443
|
-
*
|
|
1521
|
+
* run-state rotation here and the worktree rotation so both preserve
|
|
1444
1522
|
* prior state under the same never-destroy naming. */
|
|
1445
1523
|
function backup_stamp(date = /* @__PURE__ */ new Date()) {
|
|
1446
1524
|
return date.toISOString().replace(/[:.]/g, "-");
|
|
1447
1525
|
}
|
|
1448
|
-
/** Fresh-run initialization: rotate a stale
|
|
1449
|
-
*
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1526
|
+
/** Fresh-run initialization: rotate a stale state directory aside (a prior
|
|
1527
|
+
* `.volley/` becomes `.volley.bak.<stamp>`), then create the directory tree.
|
|
1528
|
+
* Under a state root each run has a directory of its own, so only a reused
|
|
1529
|
+
* `--run-id` ever finds one to rotate. Resume runs skip rotation
|
|
1530
|
+
* (`preserve: true`). */
|
|
1531
|
+
function initialize_run_state(run, options = {}) {
|
|
1532
|
+
const dir = run_state_dir(run);
|
|
1533
|
+
if (existsSync(dir) && options.preserve !== true) renameSync(dir, `${dir}.bak.${backup_stamp()}`);
|
|
1534
|
+
mkdirSync(join(dir, "iterations"), { recursive: true });
|
|
1454
1535
|
}
|
|
1455
1536
|
function write_resolved_config(config) {
|
|
1456
1537
|
const record = {
|
|
@@ -1479,9 +1560,10 @@ function write_resolved_config(config) {
|
|
|
1479
1560
|
gate_paths: config.gate_paths,
|
|
1480
1561
|
fail_on_gate_edit: config.fail_on_gate_edit,
|
|
1481
1562
|
sandbox_image: config.sandbox_image,
|
|
1563
|
+
state_dir: config.state_dir,
|
|
1482
1564
|
workspace: config.workspace
|
|
1483
1565
|
};
|
|
1484
|
-
writeFileSync(
|
|
1566
|
+
writeFileSync(state_path(config, "config.json"), `${JSON.stringify(record, null, 2)}\n`);
|
|
1485
1567
|
}
|
|
1486
1568
|
/**
|
|
1487
1569
|
* `--git`: commit everything under `root` after a phase (`git -C <root>`, run
|
|
@@ -1879,7 +1961,7 @@ async function with_worktree(options, body) {
|
|
|
1879
1961
|
/**
|
|
1880
1962
|
* Builder invocation: one call to the builder leaf per iteration is one
|
|
1881
1963
|
* complete agentic session in the workspace — Claude Code under `claude_cli`,
|
|
1882
|
-
* or a
|
|
1964
|
+
* or a model driving volley's tools on any other provider, local or hosted.
|
|
1883
1965
|
*/
|
|
1884
1966
|
const BUILDER_ALLOWED_TOOLS = [
|
|
1885
1967
|
"Read",
|
|
@@ -1901,7 +1983,7 @@ function compose_builder_system() {
|
|
|
1901
1983
|
}
|
|
1902
1984
|
/** Walk up from this module to the package root (works from src/ under tsx
|
|
1903
1985
|
* and from dist/ in the published package, which ships src/builder/presets). */
|
|
1904
|
-
function
|
|
1986
|
+
function builder_presets_dir() {
|
|
1905
1987
|
let dir = dirname(fileURLToPath(import.meta.url));
|
|
1906
1988
|
for (let depth = 0; depth < 6; depth += 1) {
|
|
1907
1989
|
const candidate = join(dir, "src", "builder", "presets");
|
|
@@ -1910,36 +1992,48 @@ function presets_dir$1() {
|
|
|
1910
1992
|
}
|
|
1911
1993
|
throw config_error("builder presets directory not found");
|
|
1912
1994
|
}
|
|
1913
|
-
|
|
1995
|
+
/** The harness append each tool set's builder reads: one naming volley's own
|
|
1996
|
+
* tools, or one naming fascicle's workspace tools under `volley acp` and
|
|
1997
|
+
* saying what a call the user refused means. */
|
|
1998
|
+
const HARNESS_APPENDS = {
|
|
1999
|
+
volley: "harness_append_local.md",
|
|
2000
|
+
acp: "harness_append_acp.md"
|
|
2001
|
+
};
|
|
2002
|
+
function compose_builder_system_local(tool_set = "volley") {
|
|
1914
2003
|
return `${[
|
|
1915
2004
|
"You are the builder inside the volley harness: an autonomous agent",
|
|
1916
2005
|
"iterating on a workspace until it satisfies a task and its acceptance",
|
|
1917
2006
|
"criteria."
|
|
1918
|
-
].join("\n")}\n\n${readFileSync(join(
|
|
2007
|
+
].join("\n")}\n\n${readFileSync(join(builder_presets_dir(), HARNESS_APPENDS[tool_set]), "utf8").trimEnd()}`;
|
|
1919
2008
|
}
|
|
1920
2009
|
/** The builder system prompt for the active provider: the `claude_cli`
|
|
1921
2010
|
* 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();
|
|
2011
|
+
* CLI builder, which never takes an injected set, or the local prompt naming
|
|
2012
|
+
* the seat's tool set for every provider that runs volley's tool loop,
|
|
2013
|
+
* `openrouter` included. Mirrors the critic's `resolve_critic_prompt`. */
|
|
2014
|
+
function resolve_builder_system(config, tool_set) {
|
|
2015
|
+
return config.builder_provider === "claude_cli" ? compose_builder_system() : compose_builder_system_local(tool_set);
|
|
1927
2016
|
}
|
|
1928
2017
|
const BUILDER_TOOL_CALL_REPAIR_ATTEMPTS = 3;
|
|
1929
2018
|
/** Per-provider tool wiring for the builder, mirroring `critic_tool_options`.
|
|
1930
2019
|
* The `claude_cli` builder is confined at the CLI permission layer (allowlist +
|
|
1931
2020
|
* permission mode) and brings its own built-in tools — this arm is unchanged
|
|
1932
|
-
* from v2
|
|
1933
|
-
*
|
|
1934
|
-
*
|
|
1935
|
-
*
|
|
1936
|
-
|
|
2021
|
+
* from v2, and takes no injected tools or approval handler. Every other
|
|
2022
|
+
* builder, local or `openrouter`, brings none, so volley supplies its whole
|
|
2023
|
+
* workspace tool surface (or the set the caller injected in its place, with
|
|
2024
|
+
* the handler that answers its approvals) plus the five per-call loop knobs
|
|
2025
|
+
* and **no** schema (it produces a workspace, not a verdict); per-call values
|
|
2026
|
+
* win over engine defaults. */
|
|
2027
|
+
function builder_tool_options(deps) {
|
|
2028
|
+
const { config } = deps;
|
|
2029
|
+
const bash_executor = deps.bash_executor ?? null;
|
|
1937
2030
|
if (config.builder_provider === "claude_cli") return { provider_options: { claude_cli: {
|
|
1938
2031
|
allowed_tools: [...BUILDER_ALLOWED_TOOLS],
|
|
1939
2032
|
extra_args: ["--permission-mode", config.builder_permission_mode]
|
|
1940
2033
|
} } };
|
|
1941
2034
|
return {
|
|
1942
|
-
tools: builder_tools(build_root(config.workspace, config.worktree), bash_executor !== null ? { bash_executor } : {}),
|
|
2035
|
+
tools: deps.tools === void 0 ? builder_tools(build_root(config.workspace, config.worktree), bash_executor !== null ? { bash_executor } : {}) : [...deps.tools],
|
|
2036
|
+
...deps.on_tool_approval !== void 0 ? { on_tool_approval: deps.on_tool_approval } : {},
|
|
1943
2037
|
max_steps: config.builder_max_steps,
|
|
1944
2038
|
tool_error_policy: "feed_back",
|
|
1945
2039
|
tool_call_repair_attempts: BUILDER_TOOL_CALL_REPAIR_ATTEMPTS,
|
|
@@ -1976,9 +2070,9 @@ function make_builder_step(deps) {
|
|
|
1976
2070
|
description: "model call: one agentic session in the build root",
|
|
1977
2071
|
provider: config.builder_provider,
|
|
1978
2072
|
model: config.builder_model,
|
|
1979
|
-
system: resolve_builder_system(config),
|
|
2073
|
+
system: resolve_builder_system(config, deps.tool_set),
|
|
1980
2074
|
on_chunk: deps.on_chunk,
|
|
1981
|
-
...builder_tool_options(
|
|
2075
|
+
...builder_tool_options(deps)
|
|
1982
2076
|
});
|
|
1983
2077
|
}
|
|
1984
2078
|
async function run_builder(deps, state, ctx) {
|
|
@@ -2022,11 +2116,30 @@ function presets_dir() {
|
|
|
2022
2116
|
}
|
|
2023
2117
|
throw config_error("critic presets directory not found");
|
|
2024
2118
|
}
|
|
2025
|
-
|
|
2119
|
+
/** The harness append that names the critic's reads. The CLI critic reads
|
|
2120
|
+
* via built-in Read/Grep/Glob and never takes an injected set; every other
|
|
2121
|
+
* critic, local or `openrouter`, reads via volley's read_file/search_files/
|
|
2122
|
+
* list_files, or under `volley acp` via fascicle's read_file/list_files/
|
|
2123
|
+
* search. */
|
|
2124
|
+
function harness_append_file(config, tool_set) {
|
|
2125
|
+
if (config.critic_provider === "claude_cli") return "harness_append.md";
|
|
2126
|
+
return tool_set === "acp" ? "harness_append_acp.md" : "harness_append_local.md";
|
|
2127
|
+
}
|
|
2128
|
+
function resolve_critic_prompt(config, tool_set = "volley") {
|
|
2026
2129
|
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
|
-
|
|
2130
|
+
const append = readFileSync(join(presets_dir(), harness_append_file(config, tool_set)), "utf8");
|
|
2131
|
+
return `${base.trimEnd()}\n\n${place_iterations_dir(append, config).trimEnd()}`;
|
|
2132
|
+
}
|
|
2133
|
+
const ITERATIONS_DIR = "{{iterations_dir}}";
|
|
2134
|
+
/** Fill the harness append's line about prior iteration state with where the
|
|
2135
|
+
* run's `iterations/` sits, relative to the build root the critic reads from,
|
|
2136
|
+
* or drop the line when the run's state is outside that root: a confined
|
|
2137
|
+
* critic can't reach it, and naming it would only spend the critic's steps on
|
|
2138
|
+
* a path it can't open. */
|
|
2139
|
+
function place_iterations_dir(append, config) {
|
|
2140
|
+
const path = relative(build_root(config.workspace, config.worktree), state_path(config, "iterations"));
|
|
2141
|
+
const outside = path === ".." || path.startsWith(`..${sep}`) || isAbsolute(path);
|
|
2142
|
+
return append.split("\n").filter((line) => !(outside && line.includes(ITERATIONS_DIR))).join("\n").replaceAll(ITERATIONS_DIR, `${path}/`);
|
|
2030
2143
|
}
|
|
2031
2144
|
/** One-letter status column, so a hundred-path list stays readable. */
|
|
2032
2145
|
const STATUS_MARKS = {
|
|
@@ -2106,8 +2219,9 @@ function compose_critic_prompt(input) {
|
|
|
2106
2219
|
//#endregion
|
|
2107
2220
|
//#region src/critic/run.ts
|
|
2108
2221
|
/**
|
|
2109
|
-
* Critic invocation: a read-only session (`claude_cli`, or a local
|
|
2110
|
-
* volley's read tools) returning a schema-validated
|
|
2222
|
+
* Critic invocation: a read-only session (`claude_cli`, or a local or
|
|
2223
|
+
* `openrouter` model with volley's read tools) returning a schema-validated
|
|
2224
|
+
* structured verdict. The
|
|
2111
2225
|
* harness — not the critic — writes `.volley/feedback.md` and `.volley/verdict`.
|
|
2112
2226
|
*/
|
|
2113
2227
|
const CRITIC_ALLOWED_TOOLS = [
|
|
@@ -2122,12 +2236,12 @@ const verdict_schema = z.object({
|
|
|
2122
2236
|
unmet_criteria: z.array(z.string()).describe("The specific acceptance criteria judged unmet, verbatim. Empty when approved.")
|
|
2123
2237
|
});
|
|
2124
2238
|
/** 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.
|
|
2239
|
+
* the CLI permission layer (allowlist + explicit disallow); every other critic,
|
|
2240
|
+
* local or `openrouter`, gets volley's own workspace-scoped read-only tools (or
|
|
2241
|
+
* the set the caller injected in their place) and no write path at all.
|
|
2128
2242
|
*
|
|
2129
|
-
* The
|
|
2130
|
-
* verified live. The claude_cli critic compiles the schema for
|
|
2243
|
+
* The providers also enforce `verdict_schema` by different paths, the first
|
|
2244
|
+
* two verified live. The claude_cli critic compiles the schema for
|
|
2131
2245
|
* `claude --json-schema`; that only works because fascicle 0.14.0's
|
|
2132
2246
|
* `compile_schema` strips the top-level `$schema`/`$id` that zod v4 stamps (the
|
|
2133
2247
|
* CLI rejects them) — `live_smoke` asserts a structured verdict comes back, so a
|
|
@@ -2137,16 +2251,22 @@ const verdict_schema = z.object({
|
|
|
2137
2251
|
* structurally guaranteed at decode time, with no prompt-parse-repair;
|
|
2138
2252
|
* `live_local_builder` exercises that path. Unchanged in v3: on ai_sdk
|
|
2139
2253
|
* constrained decode needs no wiring, and under the deferred native flip it
|
|
2140
|
-
* would move to `provider_options.ollama.format` (parked).
|
|
2141
|
-
|
|
2254
|
+
* would move to `provider_options.ollama.format` (parked). An `openrouter`
|
|
2255
|
+
* critic rides fascicle's native OpenAI-compatible core, which has no
|
|
2256
|
+
* constrained decode: the schema travels in the prompt, and the reply is parsed
|
|
2257
|
+
* and repaired. */
|
|
2258
|
+
function critic_tool_options(config, tools) {
|
|
2142
2259
|
if (config.critic_provider === "claude_cli") return { provider_options: { claude_cli: {
|
|
2143
2260
|
allowed_tools: [...CRITIC_ALLOWED_TOOLS],
|
|
2144
2261
|
extra_args: ["--disallowedTools", CRITIC_DISALLOWED_TOOLS]
|
|
2145
2262
|
} } };
|
|
2146
|
-
return {
|
|
2263
|
+
return {
|
|
2264
|
+
tools: tools === void 0 ? read_only_tools(build_root(config.workspace, config.worktree)) : [...tools],
|
|
2265
|
+
max_steps: 10
|
|
2266
|
+
};
|
|
2147
2267
|
}
|
|
2148
|
-
/** Is this critic failure the transient
|
|
2149
|
-
* retries?
|
|
2268
|
+
/** Is this critic failure the transient provider stream death the ladder
|
|
2269
|
+
* retries? Every provider but `claude_cli` (that path is proven and its
|
|
2150
2270
|
* retries cost real money), and fascicle's typed `provider_error` only (no
|
|
2151
2271
|
* message string-matching). A user abort never gets here: `retry` and
|
|
2152
2272
|
* `fallback` pass control-flow signals through untouched, and `retry` checks the
|
|
@@ -2155,6 +2275,23 @@ function is_retryable_critic_error(config, err) {
|
|
|
2155
2275
|
if (config.critic_provider === "claude_cli") return false;
|
|
2156
2276
|
return error_kind(err) === "provider_error";
|
|
2157
2277
|
}
|
|
2278
|
+
/** Did a schema call end without a verdict because the tool loop ran out —
|
|
2279
|
+
* its step budget spent, or its last turn a tool call — rather than because
|
|
2280
|
+
* the response was cut off or blocked? fascicle throws
|
|
2281
|
+
* `incomplete_generation_error` for every such finish; only these two are
|
|
2282
|
+
* fixed by taking the tools away. Matched on `kind` and `finish_reason`. */
|
|
2283
|
+
function is_step_exhaustion(err) {
|
|
2284
|
+
if (error_kind(err) !== "incomplete_generation_error") return false;
|
|
2285
|
+
const finish = err.finish_reason;
|
|
2286
|
+
return finish === "max_steps" || finish === "tool_calls";
|
|
2287
|
+
}
|
|
2288
|
+
/** Does this failure send a local critic to the tool-less rung? A stream death
|
|
2289
|
+
* the retry rung gave up on, or step exhaustion, which no retry would fix. A
|
|
2290
|
+
* `length` or `content_filter` finish is neither, and stays fatal. */
|
|
2291
|
+
function is_degradable_critic_error(config, err) {
|
|
2292
|
+
if (config.critic_provider === "claude_cli") return false;
|
|
2293
|
+
return error_kind(err) === "provider_error" || is_step_exhaustion(err);
|
|
2294
|
+
}
|
|
2158
2295
|
/** fascicle's `provider_error.cause_kind` is `... | undefined`; fold the
|
|
2159
2296
|
* missing case into `'unknown'` so a recorded retry always names a cause. */
|
|
2160
2297
|
function retry_cause_kind_of(err) {
|
|
@@ -2173,7 +2310,8 @@ function finalize_critic(state, result, model, mark, duration_ms) {
|
|
|
2173
2310
|
...next.critic,
|
|
2174
2311
|
retries: mark.retries,
|
|
2175
2312
|
...mark.retry_cause_kind !== void 0 ? { retry_cause_kind: mark.retry_cause_kind } : {},
|
|
2176
|
-
...mark.critic_degraded === true ? { critic_degraded: true } : {}
|
|
2313
|
+
...mark.critic_degraded === true ? { critic_degraded: true } : {},
|
|
2314
|
+
...mark.degrade_cause !== void 0 ? { degrade_cause: mark.degrade_cause } : {}
|
|
2177
2315
|
},
|
|
2178
2316
|
verdict: result.content.verdict,
|
|
2179
2317
|
feedback: result.content.feedback,
|
|
@@ -2203,7 +2341,7 @@ function critic_call_config(deps) {
|
|
|
2203
2341
|
engine: deps.engine,
|
|
2204
2342
|
provider: config.critic_provider,
|
|
2205
2343
|
model: config.critic_model,
|
|
2206
|
-
system: resolve_critic_prompt(config),
|
|
2344
|
+
system: resolve_critic_prompt(config, deps.tool_set),
|
|
2207
2345
|
schema: verdict_schema,
|
|
2208
2346
|
on_chunk: deps.on_chunk
|
|
2209
2347
|
};
|
|
@@ -2215,7 +2353,7 @@ function make_critic_tools_step(deps) {
|
|
|
2215
2353
|
...critic_call_config(deps),
|
|
2216
2354
|
id: "critic_tools",
|
|
2217
2355
|
description: "model call with read-only workspace tools",
|
|
2218
|
-
...critic_tool_options(deps.config)
|
|
2356
|
+
...critic_tool_options(deps.config, deps.tools)
|
|
2219
2357
|
});
|
|
2220
2358
|
}
|
|
2221
2359
|
/** Rung 2's leaf: the same critic with no tools at all, so nothing enters
|
|
@@ -2228,9 +2366,28 @@ function make_critic_toolless_step(deps) {
|
|
|
2228
2366
|
description: "model call judging from the check output and a file list"
|
|
2229
2367
|
});
|
|
2230
2368
|
}
|
|
2231
|
-
|
|
2369
|
+
const ladder_traces = /* @__PURE__ */ new WeakMap();
|
|
2370
|
+
/** The ladder's retry predicate for this run's critic provider: stream deaths
|
|
2371
|
+
* only. It also traces every failure it sees, so a degraded verdict can say how
|
|
2372
|
+
* many retries preceded it and why. A first attempt starts a fresh critique, so
|
|
2373
|
+
* it forgets the last one's retries. */
|
|
2232
2374
|
function critic_retryable(config) {
|
|
2233
|
-
|
|
2375
|
+
let retried_cause;
|
|
2376
|
+
return (err, attempt) => {
|
|
2377
|
+
if (attempt === 1) retried_cause = void 0;
|
|
2378
|
+
if (typeof err === "object" && err !== null) ladder_traces.set(err, {
|
|
2379
|
+
attempt,
|
|
2380
|
+
retried_cause
|
|
2381
|
+
});
|
|
2382
|
+
const retryable = is_retryable_critic_error(config, err);
|
|
2383
|
+
if (retryable) retried_cause = retry_cause_kind_of(err);
|
|
2384
|
+
return retryable;
|
|
2385
|
+
};
|
|
2386
|
+
}
|
|
2387
|
+
/** The ladder's fallback predicate: a stream death the retry rung gave up on,
|
|
2388
|
+
* or step exhaustion, which it never retries. */
|
|
2389
|
+
function critic_degradable(config) {
|
|
2390
|
+
return (err) => is_degradable_critic_error(config, err);
|
|
2234
2391
|
}
|
|
2235
2392
|
/** Rung 1's projection: a verdict the tool-bearing critic produced, marked with
|
|
2236
2393
|
* how many stream deaths it took and the last one's cause. */
|
|
@@ -2244,27 +2401,42 @@ function with_retries(outcome) {
|
|
|
2244
2401
|
}
|
|
2245
2402
|
};
|
|
2246
2403
|
}
|
|
2247
|
-
/** Rung 2's answer
|
|
2248
|
-
*
|
|
2404
|
+
/** Rung 2's answer, marked degraded so it never passes silently as a full
|
|
2405
|
+
* critique. Why it degraded, and the retries before it, are the ladder's to
|
|
2406
|
+
* stamp (`with_fallback_cause`), since the tool-less call can't see them. */
|
|
2249
2407
|
function as_degraded(result) {
|
|
2250
2408
|
return {
|
|
2251
2409
|
result,
|
|
2252
2410
|
mark: {
|
|
2253
|
-
retries:
|
|
2411
|
+
retries: 0,
|
|
2254
2412
|
retry_cause_kind: void 0,
|
|
2255
2413
|
critic_degraded: true
|
|
2256
2414
|
}
|
|
2257
2415
|
};
|
|
2258
2416
|
}
|
|
2259
2417
|
/** The ladder's projection: the tool-less backup cannot see why it ran, so the
|
|
2260
|
-
*
|
|
2418
|
+
* mark comes from the error that sent it there.
|
|
2419
|
+
*
|
|
2420
|
+
* - A stream death the retry rung gave up on: every retry was spent, and the
|
|
2421
|
+
* cause is that error's own.
|
|
2422
|
+
* - Step exhaustion, never retried: the retries are the stream deaths before
|
|
2423
|
+
* it, usually none, and the cause is the last of those.
|
|
2424
|
+
*
|
|
2425
|
+
* An error the retry rung never traced reads as one that spent every retry. */
|
|
2261
2426
|
function with_fallback_cause(outcome) {
|
|
2262
2427
|
if (outcome.source === "primary") return outcome.value;
|
|
2428
|
+
const err = outcome.primary_error;
|
|
2429
|
+
const trace = typeof err === "object" && err !== null ? ladder_traces.get(err) : void 0;
|
|
2430
|
+
const retries = trace === void 0 ? 1 : trace.attempt - 1;
|
|
2431
|
+
const exhausted = is_step_exhaustion(err);
|
|
2432
|
+
const retry_cause_kind = exhausted ? trace?.retried_cause : retry_cause_kind_of(err);
|
|
2263
2433
|
return {
|
|
2264
2434
|
...outcome.value,
|
|
2265
2435
|
mark: {
|
|
2266
2436
|
...outcome.value.mark,
|
|
2267
|
-
|
|
2437
|
+
retries,
|
|
2438
|
+
retry_cause_kind,
|
|
2439
|
+
degrade_cause: exhausted ? "step_exhaustion" : "stream_error"
|
|
2268
2440
|
}
|
|
2269
2441
|
};
|
|
2270
2442
|
}
|
|
@@ -2374,14 +2546,50 @@ function status_of(value, converged) {
|
|
|
2374
2546
|
*/
|
|
2375
2547
|
const CHECK_ARTIFACT_MAX_CHARS = 8e3;
|
|
2376
2548
|
const CHECKRIDE_SCHEMA_VERSION = 1;
|
|
2549
|
+
/** How long a check's output may stay open after its shell exits, while
|
|
2550
|
+
* something that escaped its process group still holds it, before the reads
|
|
2551
|
+
* stop so the check can finish. */
|
|
2552
|
+
const OUTPUT_GRACE_MS = 2e3;
|
|
2553
|
+
/** Kill a check's whole process group with SIGKILL. A test runner's workers,
|
|
2554
|
+
* and anything the check started in the background, sit in the shell's group,
|
|
2555
|
+
* so killing the shell alone would leave them running. Windows has no process
|
|
2556
|
+
* groups to signal, so there only the shell goes. */
|
|
2557
|
+
function kill_group(proc) {
|
|
2558
|
+
if (proc.pid === void 0) return;
|
|
2559
|
+
try {
|
|
2560
|
+
if (process.platform === "win32") proc.kill("SIGKILL");
|
|
2561
|
+
else process.kill(-proc.pid, "SIGKILL");
|
|
2562
|
+
} catch {}
|
|
2563
|
+
}
|
|
2564
|
+
/** Run a check to its end in a process group of its own, capturing its
|
|
2565
|
+
* output. The abort kills the whole group and rejects with `aborted_error`,
|
|
2566
|
+
* and the shell's exit kills whatever it left behind, so a stopped run leaves
|
|
2567
|
+
* no test workers or servers running. */
|
|
2377
2568
|
function spawn_capture(cmd, args, options) {
|
|
2569
|
+
const { abort } = options;
|
|
2570
|
+
if (abort?.aborted === true) return Promise.reject(new aborted_error("check aborted"));
|
|
2378
2571
|
return new Promise((resolve_promise, reject) => {
|
|
2379
2572
|
const proc = spawn(cmd, args, {
|
|
2380
2573
|
cwd: options.cwd,
|
|
2381
|
-
...options.
|
|
2574
|
+
...options.env !== void 0 ? { env: options.env } : {},
|
|
2575
|
+
detached: process.platform !== "win32",
|
|
2576
|
+
stdio: [
|
|
2577
|
+
"ignore",
|
|
2578
|
+
"pipe",
|
|
2579
|
+
"pipe"
|
|
2580
|
+
]
|
|
2382
2581
|
});
|
|
2383
2582
|
let stdout = "";
|
|
2384
2583
|
let stderr = "";
|
|
2584
|
+
let grace;
|
|
2585
|
+
const on_abort = () => {
|
|
2586
|
+
kill_group(proc);
|
|
2587
|
+
reject(new aborted_error("check aborted"));
|
|
2588
|
+
};
|
|
2589
|
+
const finish = () => {
|
|
2590
|
+
clearTimeout(grace);
|
|
2591
|
+
abort?.removeEventListener("abort", on_abort);
|
|
2592
|
+
};
|
|
2385
2593
|
proc.stdout.on("data", (chunk) => {
|
|
2386
2594
|
stdout += chunk.toString();
|
|
2387
2595
|
});
|
|
@@ -2389,19 +2597,25 @@ function spawn_capture(cmd, args, options) {
|
|
|
2389
2597
|
stderr += chunk.toString();
|
|
2390
2598
|
});
|
|
2391
2599
|
proc.on("error", (err) => {
|
|
2392
|
-
|
|
2393
|
-
reject(new aborted_error("check aborted"));
|
|
2394
|
-
return;
|
|
2395
|
-
}
|
|
2600
|
+
finish();
|
|
2396
2601
|
reject(check_error(`check command failed to start: ${err.message}`));
|
|
2397
2602
|
});
|
|
2603
|
+
proc.on("exit", () => {
|
|
2604
|
+
kill_group(proc);
|
|
2605
|
+
grace = setTimeout(() => {
|
|
2606
|
+
proc.stdout.destroy();
|
|
2607
|
+
proc.stderr.destroy();
|
|
2608
|
+
}, OUTPUT_GRACE_MS);
|
|
2609
|
+
});
|
|
2398
2610
|
proc.on("close", (code) => {
|
|
2611
|
+
finish();
|
|
2399
2612
|
resolve_promise({
|
|
2400
2613
|
exit_code: code,
|
|
2401
2614
|
stdout,
|
|
2402
2615
|
stderr
|
|
2403
2616
|
});
|
|
2404
2617
|
});
|
|
2618
|
+
abort?.addEventListener("abort", on_abort, { once: true });
|
|
2405
2619
|
});
|
|
2406
2620
|
}
|
|
2407
2621
|
/** Best-effort parse of the summary contract. `pnpm exec` talks over the
|
|
@@ -2465,13 +2679,14 @@ function read_failing_artifacts(workspace, failing) {
|
|
|
2465
2679
|
});
|
|
2466
2680
|
}
|
|
2467
2681
|
async function run_checkride(opts) {
|
|
2682
|
+
const { workspace, ...spawn_options } = opts;
|
|
2468
2683
|
const proc = await spawn_capture("pnpm", [
|
|
2469
2684
|
"exec",
|
|
2470
2685
|
"checkride",
|
|
2471
2686
|
"--json"
|
|
2472
2687
|
], {
|
|
2473
|
-
|
|
2474
|
-
|
|
2688
|
+
...spawn_options,
|
|
2689
|
+
cwd: workspace
|
|
2475
2690
|
});
|
|
2476
2691
|
if (proc.exit_code === 2) throw check_error(`checkride harness error: ${proc.stderr.trim()}`);
|
|
2477
2692
|
const { summary } = parse_summary_with_fallback(proc.stdout, opts.workspace);
|
|
@@ -2495,12 +2710,13 @@ async function run_checkride(opts) {
|
|
|
2495
2710
|
* (exit 4), not an iterable failure.
|
|
2496
2711
|
*/
|
|
2497
2712
|
async function run_command_check(opts) {
|
|
2713
|
+
const { command, workspace, ...spawn_options } = opts;
|
|
2498
2714
|
const started = Date.now();
|
|
2499
|
-
const proc = await spawn_capture("sh", ["-c",
|
|
2500
|
-
|
|
2501
|
-
|
|
2715
|
+
const proc = await spawn_capture("sh", ["-c", command], {
|
|
2716
|
+
...spawn_options,
|
|
2717
|
+
cwd: workspace
|
|
2502
2718
|
});
|
|
2503
|
-
if (proc.exit_code === 127 || proc.exit_code === 126) throw check_error(`check command failed to start (exit ${String(proc.exit_code)}): ${
|
|
2719
|
+
if (proc.exit_code === 127 || proc.exit_code === 126) throw check_error(`check command failed to start (exit ${String(proc.exit_code)}): ${command}`);
|
|
2504
2720
|
const log = `${proc.stdout}${proc.stderr}`;
|
|
2505
2721
|
return {
|
|
2506
2722
|
ran: true,
|
|
@@ -2524,6 +2740,23 @@ function skipped_check(reason) {
|
|
|
2524
2740
|
detail: []
|
|
2525
2741
|
};
|
|
2526
2742
|
}
|
|
2743
|
+
/** What a rejected check says in place of its output. */
|
|
2744
|
+
const CHECK_REJECTED = "the check was rejected when it asked to run, so it never ran";
|
|
2745
|
+
/** A check the user rejected when it asked to run: recorded as a failed
|
|
2746
|
+
* check, with the refusal as its output, so a round can't converge on work
|
|
2747
|
+
* nothing checked, and the critic hears why there's no result. */
|
|
2748
|
+
function rejected_check(runner) {
|
|
2749
|
+
return {
|
|
2750
|
+
ran: true,
|
|
2751
|
+
runner,
|
|
2752
|
+
ok: false,
|
|
2753
|
+
exit_code: null,
|
|
2754
|
+
duration_ms: 0,
|
|
2755
|
+
failing_slots: [],
|
|
2756
|
+
detail: [],
|
|
2757
|
+
log: CHECK_REJECTED
|
|
2758
|
+
};
|
|
2759
|
+
}
|
|
2527
2760
|
//#endregion
|
|
2528
2761
|
//#region src/iteration.ts
|
|
2529
2762
|
/**
|
|
@@ -2534,9 +2767,9 @@ function skipped_check(reason) {
|
|
|
2534
2767
|
function write_json(path, value) {
|
|
2535
2768
|
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
2536
2769
|
}
|
|
2537
|
-
function persist_verdict_files(
|
|
2538
|
-
writeFileSync(
|
|
2539
|
-
writeFileSync(
|
|
2770
|
+
function persist_verdict_files(config, feedback, verdict) {
|
|
2771
|
+
writeFileSync(state_path(config, "feedback.md"), feedback.endsWith("\n") ? feedback : `${feedback}\n`);
|
|
2772
|
+
writeFileSync(state_path(config, "verdict"), `${verdict}\n`);
|
|
2540
2773
|
}
|
|
2541
2774
|
function phase_summary(record) {
|
|
2542
2775
|
if (record === null) return null;
|
|
@@ -2555,17 +2788,18 @@ function phase_summary(record) {
|
|
|
2555
2788
|
...record.retries !== void 0 ? { retries: record.retries } : {},
|
|
2556
2789
|
...record.retry_cause_kind !== void 0 ? { retry_cause_kind: record.retry_cause_kind } : {},
|
|
2557
2790
|
...record.critic_degraded === true ? { critic_degraded: true } : {},
|
|
2791
|
+
...record.degrade_cause !== void 0 ? { degrade_cause: record.degrade_cause } : {},
|
|
2558
2792
|
...record.throughput !== void 0 ? { throughput: record.throughput } : {}
|
|
2559
2793
|
};
|
|
2560
2794
|
}
|
|
2561
2795
|
/** Archive one completed iteration: verdict files, check artifacts, and the
|
|
2562
2796
|
* per-iteration summary. */
|
|
2563
2797
|
function archive_iteration(config, state) {
|
|
2564
|
-
const dir = iteration_dir(config
|
|
2798
|
+
const dir = iteration_dir(config, state.iteration);
|
|
2565
2799
|
mkdirSync(join(dir, "check"), { recursive: true });
|
|
2566
2800
|
if (state.verdict !== null && state.feedback !== null) {
|
|
2567
|
-
persist_verdict_files(config
|
|
2568
|
-
writeFileSync(join(dir, "feedback.md"), readFileSync(
|
|
2801
|
+
persist_verdict_files(config, state.feedback, state.verdict);
|
|
2802
|
+
writeFileSync(join(dir, "feedback.md"), readFileSync(state_path(config, "feedback.md")));
|
|
2569
2803
|
writeFileSync(join(dir, "verdict"), `${state.verdict}\n`);
|
|
2570
2804
|
}
|
|
2571
2805
|
const check = state.check;
|
|
@@ -2620,7 +2854,7 @@ function run_result_from_state(config, state, status, completed_at) {
|
|
|
2620
2854
|
//#endregion
|
|
2621
2855
|
//#region src/summary.ts
|
|
2622
2856
|
/**
|
|
2623
|
-
* Enriched run summary (
|
|
2857
|
+
* Enriched run summary (the run's `summary.json`): the `RunResult` projection plus
|
|
2624
2858
|
* a `comparison` block that makes the two blessed examples' runs diff-able.
|
|
2625
2859
|
* The block gathers the seven comparison metrics — transport,
|
|
2626
2860
|
* iterations-to-converge, wall-clock, cost, verdict, the deterministic check
|
|
@@ -2635,22 +2869,27 @@ function run_result_from_state(config, state, status, completed_at) {
|
|
|
2635
2869
|
* surviving branch on `salvaged_branch`, which teardown only names after the
|
|
2636
2870
|
* final status is known.
|
|
2637
2871
|
* Each write recomputes the comparison from what is currently archived under
|
|
2638
|
-
*
|
|
2872
|
+
* the run's `iterations/`, so the trajectory grows as the run does.
|
|
2639
2873
|
*/
|
|
2640
2874
|
function transport_of(provider) {
|
|
2641
|
-
|
|
2875
|
+
switch (provider) {
|
|
2876
|
+
case "claude_cli": return "claude_cli";
|
|
2877
|
+
case "openrouter": return "native";
|
|
2878
|
+
case "ollama":
|
|
2879
|
+
case "lmstudio": return "ai_sdk";
|
|
2880
|
+
}
|
|
2642
2881
|
}
|
|
2643
2882
|
/** Read the archived per-iteration summaries in order. A missing archive root
|
|
2644
2883
|
* (a run that failed before the first `record`) or an unreadable entry yields an
|
|
2645
2884
|
* empty/short list — the run summary still writes, just without that trajectory
|
|
2646
2885
|
* detail. */
|
|
2647
|
-
function read_iteration_archives(
|
|
2648
|
-
const root =
|
|
2886
|
+
function read_iteration_archives(config) {
|
|
2887
|
+
const root = state_path(config, "iterations");
|
|
2649
2888
|
if (!existsSync(root)) return [];
|
|
2650
2889
|
const dirs = readdirSync(root).filter((dir) => /^\d{3}$/.test(dir)).toSorted();
|
|
2651
2890
|
const archives = [];
|
|
2652
2891
|
for (const dir of dirs) {
|
|
2653
|
-
const path =
|
|
2892
|
+
const path = state_path(config, "iterations", dir, "summary.json");
|
|
2654
2893
|
if (!existsSync(path)) continue;
|
|
2655
2894
|
try {
|
|
2656
2895
|
archives.push(JSON.parse(readFileSync(path, "utf8")));
|
|
@@ -2672,6 +2911,13 @@ function check_trajectory(archives) {
|
|
|
2672
2911
|
function any_critic_degraded(archives) {
|
|
2673
2912
|
return archives.some((archive) => archive.critic?.critic_degraded === true);
|
|
2674
2913
|
}
|
|
2914
|
+
/** Each distinct reason an archived critic verdict degraded. An archive from
|
|
2915
|
+
* before the cause was recorded reads as a stream error, the only way a verdict
|
|
2916
|
+
* could degrade then. */
|
|
2917
|
+
function critic_degrade_causes(archives) {
|
|
2918
|
+
const causes = archives.filter((archive) => archive.critic?.critic_degraded === true).map((archive) => archive.critic?.degrade_cause ?? "stream_error");
|
|
2919
|
+
return [...new Set(causes)];
|
|
2920
|
+
}
|
|
2675
2921
|
/** Every gate path the run's builder touched, across all iterations: an edit in
|
|
2676
2922
|
* iteration 1 that a later iteration reverted still happened, so the run-level
|
|
2677
2923
|
* answer is the union rather than the last iteration's list. */
|
|
@@ -2697,7 +2943,7 @@ function salvage_stats(archives) {
|
|
|
2697
2943
|
* archive for the check trajectory and salvage aggregate. Pure over its inputs
|
|
2698
2944
|
* plus the on-disk archive; `write_run_summary` persists the result. */
|
|
2699
2945
|
function build_run_summary(config, result) {
|
|
2700
|
-
const archives = read_iteration_archives(config
|
|
2946
|
+
const archives = read_iteration_archives(config);
|
|
2701
2947
|
const wall_clock_ms = result.completed_at === null ? null : Date.parse(result.completed_at) - Date.parse(result.started_at);
|
|
2702
2948
|
return {
|
|
2703
2949
|
...result,
|
|
@@ -2714,17 +2960,54 @@ function build_run_summary(config, result) {
|
|
|
2714
2960
|
gate_edits: all_gate_edits(archives),
|
|
2715
2961
|
unmet_criteria: archives.at(-1)?.unmet_criteria ?? [],
|
|
2716
2962
|
local_salvage: salvage_stats(archives),
|
|
2717
|
-
critic_degraded: any_critic_degraded(archives)
|
|
2963
|
+
critic_degraded: any_critic_degraded(archives),
|
|
2964
|
+
critic_degrade_causes: critic_degrade_causes(archives)
|
|
2718
2965
|
}
|
|
2719
2966
|
};
|
|
2720
2967
|
}
|
|
2721
|
-
/** Write the enriched summary to
|
|
2968
|
+
/** Write the enriched summary to the run's `summary.json` (the projection the
|
|
2722
2969
|
* examples' comparison is read from). */
|
|
2723
2970
|
function write_run_summary(config, result) {
|
|
2724
|
-
writeFileSync(
|
|
2971
|
+
writeFileSync(state_path(config, "summary.json"), `${JSON.stringify(build_run_summary(config, result), null, 2)}\n`);
|
|
2725
2972
|
}
|
|
2726
2973
|
//#endregion
|
|
2727
2974
|
//#region src/phases.ts
|
|
2975
|
+
/**
|
|
2976
|
+
* The step bodies behind the nodes of `./flow.ts`, one function per node. Each
|
|
2977
|
+
* takes the loop's carry-state and returns the next one. The phase modules they
|
|
2978
|
+
* call (`./builder.ts`, `./check/`, `./critic/`) do the work; these bodies add
|
|
2979
|
+
* what surrounds it: the renderer lines an operator watches, change detection,
|
|
2980
|
+
* git checkpoints, the per-iteration archive, and the progress events a
|
|
2981
|
+
* trajectory reader follows the run by.
|
|
2982
|
+
*/
|
|
2983
|
+
/** The step id the check's card is recorded under: the check step's own, in
|
|
2984
|
+
* `./flow.ts`, so each round's check counts as a fresh run of it and opens a
|
|
2985
|
+
* card of its own. */
|
|
2986
|
+
const CHECK_STEP_ID = "check";
|
|
2987
|
+
/** The raw call id and tool name of the check's card. The name sits outside
|
|
2988
|
+
* the workspace tools, so nothing dresses the card as one of theirs. */
|
|
2989
|
+
const CHECK_CALL = {
|
|
2990
|
+
id: "check",
|
|
2991
|
+
name: "check"
|
|
2992
|
+
};
|
|
2993
|
+
function emit(ctx, event) {
|
|
2994
|
+
ctx.emit(event);
|
|
2995
|
+
}
|
|
2996
|
+
/** Record one chunk of the check's card, the way `model_call` records a tool
|
|
2997
|
+
* call's, under a streamed run only, since nothing reads a card otherwise. */
|
|
2998
|
+
function record_check_chunk(ctx, chunk) {
|
|
2999
|
+
if (ctx.streaming) ctx.trajectory.record({
|
|
3000
|
+
kind: "model_chunk",
|
|
3001
|
+
step_id: CHECK_STEP_ID,
|
|
3002
|
+
chunk
|
|
3003
|
+
});
|
|
3004
|
+
}
|
|
3005
|
+
/** The command line the check runs, or null when no check is configured. */
|
|
3006
|
+
function check_command(config) {
|
|
3007
|
+
if (config.check_resolved === "checkride") return "pnpm exec checkride --json";
|
|
3008
|
+
if (config.check_resolved === "command") return config.check;
|
|
3009
|
+
return null;
|
|
3010
|
+
}
|
|
2728
3011
|
/** How many gate edits a warning names before it summarises the rest. */
|
|
2729
3012
|
const LISTED_GATE_EDITS = 5;
|
|
2730
3013
|
/**
|
|
@@ -2742,18 +3025,42 @@ function report_changes(config, renderer, changes) {
|
|
|
2742
3025
|
const listed = changes.gate_edits.slice(0, LISTED_GATE_EDITS).join(", ");
|
|
2743
3026
|
renderer.warn(`gate edit: the builder changed ${String(changes.gate_edits.length)} file(s) that decide whether its work passes — ${listed}${hidden > 0 ? ` (+${String(hidden)} more)` : ""}. A green check proves less when the builder can edit 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
3027
|
}
|
|
2745
|
-
|
|
3028
|
+
/** Run the configured check, checkride or a command, with the environment
|
|
3029
|
+
* the caller set for it, else volley's own. */
|
|
3030
|
+
async function execute_check(deps, abort) {
|
|
3031
|
+
const { config } = deps;
|
|
2746
3032
|
const root = build_root(config.workspace, config.worktree);
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
}
|
|
2751
|
-
|
|
3033
|
+
const options = {
|
|
3034
|
+
abort,
|
|
3035
|
+
...deps.check?.env !== void 0 ? { env: deps.check.env } : {}
|
|
3036
|
+
};
|
|
3037
|
+
return config.check_resolved === "checkride" ? run_checkride({
|
|
3038
|
+
...options,
|
|
3039
|
+
workspace: root
|
|
3040
|
+
}) : run_command_check({
|
|
3041
|
+
...options,
|
|
2752
3042
|
command: config.check,
|
|
2753
|
-
workspace: root
|
|
3043
|
+
workspace: root
|
|
3044
|
+
});
|
|
3045
|
+
}
|
|
3046
|
+
/** Whether the check may run `command`: yes, unless the caller set an
|
|
3047
|
+
* approval and it answers no. It asks on the check's card under the command
|
|
3048
|
+
* line as its tool name, which titles the prompt the way the card is titled,
|
|
3049
|
+
* and which names none of fascicle's gated tools, so the prompt offers Allow
|
|
3050
|
+
* and Reject alone, never an "always" that would cover the builder's commands
|
|
3051
|
+
* too. A stop while it asks ends the check as a stop, not a rejection. */
|
|
3052
|
+
async function check_allowed(deps, command, abort) {
|
|
3053
|
+
const approve = deps.check?.on_approval;
|
|
3054
|
+
if (approve === void 0) return true;
|
|
3055
|
+
const allowed = await approve({
|
|
3056
|
+
tool_name: command,
|
|
3057
|
+
input: { command },
|
|
3058
|
+
tool_call_id: CHECK_CALL.id,
|
|
3059
|
+
step_index: 0,
|
|
2754
3060
|
abort
|
|
2755
3061
|
});
|
|
2756
|
-
|
|
3062
|
+
if (abort.aborted) throw new aborted_error("check aborted");
|
|
3063
|
+
return allowed;
|
|
2757
3064
|
}
|
|
2758
3065
|
/** Open the next iteration: advance the counter and clear every per-iteration
|
|
2759
3066
|
* field, so nothing from the last round leaks into this one. */
|
|
@@ -2775,6 +3082,10 @@ function next_iteration(s) {
|
|
|
2775
3082
|
async function build_phase(deps, builder, s, ctx) {
|
|
2776
3083
|
const { config, renderer } = deps;
|
|
2777
3084
|
const next = next_iteration(s);
|
|
3085
|
+
emit(ctx, {
|
|
3086
|
+
volley: "round_start",
|
|
3087
|
+
round: next.iteration
|
|
3088
|
+
});
|
|
2778
3089
|
renderer.phase_start(next.iteration, "builder");
|
|
2779
3090
|
const built = await run_builder({
|
|
2780
3091
|
builder,
|
|
@@ -2806,20 +3117,79 @@ function skip_verify_phase(deps, s) {
|
|
|
2806
3117
|
check: skipped_check("cost_cap")
|
|
2807
3118
|
};
|
|
2808
3119
|
}
|
|
2809
|
-
/**
|
|
3120
|
+
/** Open the check's card on a streamed run: the call's start, then the
|
|
3121
|
+
* command as its input, the two chunks `model_call` records for a tool call
|
|
3122
|
+
* before it runs. */
|
|
3123
|
+
function open_check_card(ctx, command) {
|
|
3124
|
+
record_check_chunk(ctx, {
|
|
3125
|
+
kind: "tool_call_start",
|
|
3126
|
+
...CHECK_CALL,
|
|
3127
|
+
step_index: 0
|
|
3128
|
+
});
|
|
3129
|
+
record_check_chunk(ctx, {
|
|
3130
|
+
kind: "tool_call_end",
|
|
3131
|
+
id: CHECK_CALL.id,
|
|
3132
|
+
input: { command },
|
|
3133
|
+
step_index: 0
|
|
3134
|
+
});
|
|
3135
|
+
}
|
|
3136
|
+
/** Close the check's card with its result, which the card's reader renders,
|
|
3137
|
+
* or with the error that stopped the check before it had one. */
|
|
3138
|
+
function close_check_card(ctx, outcome) {
|
|
3139
|
+
record_check_chunk(ctx, "result" in outcome ? {
|
|
3140
|
+
kind: "tool_result",
|
|
3141
|
+
id: CHECK_CALL.id,
|
|
3142
|
+
output: outcome.result,
|
|
3143
|
+
step_index: 0
|
|
3144
|
+
} : {
|
|
3145
|
+
kind: "tool_result",
|
|
3146
|
+
id: CHECK_CALL.id,
|
|
3147
|
+
error: { message: outcome.error instanceof Error ? outcome.error.message : String(outcome.error) },
|
|
3148
|
+
step_index: 0
|
|
3149
|
+
});
|
|
3150
|
+
}
|
|
3151
|
+
/** `check`: the deterministic gate over the tree the builder wrote. A check
|
|
3152
|
+
* that runs a command emits its start and end, and on a streamed run also
|
|
3153
|
+
* records its card as a tool call, so a reader of the stream shows it running
|
|
3154
|
+
* while it runs. When the caller set an approval, the check asks on that card
|
|
3155
|
+
* first, and a rejected check closes it failed and is recorded as failed. */
|
|
2810
3156
|
async function check_phase(deps, s, ctx) {
|
|
2811
3157
|
const { config, renderer } = deps;
|
|
2812
3158
|
renderer.phase_start(s.iteration, "check");
|
|
3159
|
+
const command = check_command(config);
|
|
3160
|
+
if (command === null) {
|
|
3161
|
+
renderer.info("no deterministic check configured; loop is critic-gated only");
|
|
3162
|
+
return {
|
|
3163
|
+
...s,
|
|
3164
|
+
check: skipped_check("none")
|
|
3165
|
+
};
|
|
3166
|
+
}
|
|
3167
|
+
emit(ctx, {
|
|
3168
|
+
volley: "check_start",
|
|
3169
|
+
round: s.iteration,
|
|
3170
|
+
command
|
|
3171
|
+
});
|
|
3172
|
+
open_check_card(ctx, command);
|
|
2813
3173
|
try {
|
|
2814
|
-
const
|
|
2815
|
-
|
|
2816
|
-
|
|
3174
|
+
const allowed = await check_allowed(deps, command, ctx.abort);
|
|
3175
|
+
const result = allowed ? await execute_check(deps, ctx.abort) : rejected_check(config.check_resolved);
|
|
3176
|
+
close_check_card(ctx, allowed ? { result } : { error: CHECK_REJECTED });
|
|
3177
|
+
emit(ctx, {
|
|
3178
|
+
volley: "check_end",
|
|
3179
|
+
round: s.iteration,
|
|
3180
|
+
ok: result.ok,
|
|
3181
|
+
exit_code: result.exit_code,
|
|
3182
|
+
failing_slots: result.failing_slots,
|
|
3183
|
+
duration_ms: result.duration_ms
|
|
3184
|
+
});
|
|
3185
|
+
renderer.phase_end(s.iteration, "check", result.ok, !allowed ? "rejected" : result.failing_slots.length > 0 ? `failing: ${result.failing_slots.join(", ")}` : void 0);
|
|
2817
3186
|
return {
|
|
2818
3187
|
...s,
|
|
2819
3188
|
check: result,
|
|
2820
3189
|
check_duration_ms: s.check_duration_ms + result.duration_ms
|
|
2821
3190
|
};
|
|
2822
3191
|
} catch (err) {
|
|
3192
|
+
close_check_card(ctx, { error: err });
|
|
2823
3193
|
if (error_kind(err) === "check_error") throw err;
|
|
2824
3194
|
throw phase_error("check", s.iteration, err);
|
|
2825
3195
|
}
|
|
@@ -2832,6 +3202,14 @@ async function critique_phase(deps, critic, s, ctx) {
|
|
|
2832
3202
|
critic,
|
|
2833
3203
|
config: deps.config
|
|
2834
3204
|
}, s, ctx);
|
|
3205
|
+
if (critiqued.verdict !== null) emit(ctx, {
|
|
3206
|
+
volley: "verdict",
|
|
3207
|
+
round: critiqued.iteration,
|
|
3208
|
+
verdict: critiqued.verdict,
|
|
3209
|
+
feedback: critiqued.feedback ?? "",
|
|
3210
|
+
unmet_criteria: critiqued.unmet_criteria,
|
|
3211
|
+
degraded: critiqued.critic?.critic_degraded === true
|
|
3212
|
+
});
|
|
2835
3213
|
renderer.phase_end(critiqued.iteration, "critic", critiqued.verdict === "approved", critiqued.verdict ?? void 0);
|
|
2836
3214
|
renderer.cost_line(critiqued.iteration, "critic", critiqued.critic?.cost_usd ?? null, critiqued.total_cost_usd);
|
|
2837
3215
|
return critiqued;
|
|
@@ -2867,30 +3245,32 @@ function record_phase(deps, s) {
|
|
|
2867
3245
|
* │ │ └─ else sequence
|
|
2868
3246
|
* │ │ ├─ check the deterministic gate: checkride, a command, or none
|
|
2869
3247
|
* │ │ └─ 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,
|
|
3248
|
+
* │ │ └─ critic fallback: judge without tools if a critic on any provider but claude_cli keeps dying or runs out of steps
|
|
3249
|
+
* │ │ ├─ retry one more try after a stream death, on any provider but claude_cli
|
|
2872
3250
|
* │ │ │ └─ critic_tools model call with read-only workspace tools
|
|
2873
3251
|
* │ │ └─ pipe mark the verdict degraded
|
|
2874
3252
|
* │ │ └─ critic_toolless model call judging from the check output and a file list
|
|
2875
3253
|
* │ └─ record archive the iteration and rewrite the run summary
|
|
2876
3254
|
* └─ guard gate stop on approval over a green check, the cost cap, or a gate edit
|
|
2877
3255
|
*/
|
|
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
|
-
*
|
|
3256
|
+
/** The critic arm: the degradation ladder. A critic on any provider but
|
|
3257
|
+
* `claude_cli` whose tool-bearing stream dies is retried at once (the death is
|
|
3258
|
+
* stochastic parser failure, not load, so waiting buys nothing), then judged
|
|
3259
|
+
* once without tools. One that runs out of steps skips the retry, since
|
|
3260
|
+
* another identical tool loop would only run out again, and goes straight to
|
|
3261
|
+
* the tool-less pass. Anything else propagates from either rung untouched. */
|
|
2882
3262
|
function build_critic(deps) {
|
|
2883
3263
|
const retryable = critic_retryable(deps.config);
|
|
2884
3264
|
return fallback(retry(make_critic_tools_step(deps), {
|
|
2885
|
-
description: "one more try after a stream death,
|
|
3265
|
+
description: "one more try after a stream death, on any provider but claude_cli",
|
|
2886
3266
|
max_attempts: 2,
|
|
2887
3267
|
backoff_ms: 0,
|
|
2888
3268
|
when: retryable,
|
|
2889
3269
|
project: with_retries
|
|
2890
3270
|
}), pipe(make_critic_toolless_step(deps), as_degraded, { description: "mark the verdict degraded" }), {
|
|
2891
3271
|
name: "critic",
|
|
2892
|
-
description: "judge without tools if a
|
|
2893
|
-
when:
|
|
3272
|
+
description: "judge without tools if a critic on any provider but claude_cli keeps dying or runs out of steps",
|
|
3273
|
+
when: critic_degradable(deps.config),
|
|
2894
3274
|
handoff: (prompt) => toolless_critic_prompt(deps.config, prompt),
|
|
2895
3275
|
project: with_fallback_cause
|
|
2896
3276
|
});
|
|
@@ -2898,13 +3278,13 @@ function build_critic(deps) {
|
|
|
2898
3278
|
function build_flow(deps, resume_from) {
|
|
2899
3279
|
const { config, renderer } = deps;
|
|
2900
3280
|
const builder = make_builder_step({
|
|
2901
|
-
|
|
3281
|
+
...deps.seats.builder,
|
|
2902
3282
|
config,
|
|
2903
3283
|
on_chunk: renderer.builder_chunk,
|
|
2904
3284
|
bash_executor: deps.bash_executor
|
|
2905
3285
|
});
|
|
2906
3286
|
const critic = build_critic({
|
|
2907
|
-
|
|
3287
|
+
...deps.seats.critic,
|
|
2908
3288
|
config,
|
|
2909
3289
|
on_chunk: renderer.critic_chunk
|
|
2910
3290
|
});
|
|
@@ -2954,19 +3334,19 @@ function build_flow(deps, resume_from) {
|
|
|
2954
3334
|
* substrate.
|
|
2955
3335
|
*
|
|
2956
3336
|
* run_volley
|
|
2957
|
-
* ├─ initialize the
|
|
3337
|
+
* ├─ initialize the run's state (or preserve it, on resume)
|
|
2958
3338
|
* ├─ with_worktree off unless --worktree
|
|
2959
3339
|
* │ ├─ capture the change baseline
|
|
2960
|
-
* │ ├─ run_loop run(
|
|
3340
|
+
* │ ├─ run_loop run the flow (streamed when a sink is given), then the final summary
|
|
2961
3341
|
* │ └─ integrate a successful --worktree --git run only
|
|
2962
3342
|
* └─ report a kept checkout, or a salvaged branch (re-stamping the summary)
|
|
2963
3343
|
*/
|
|
2964
3344
|
async function run_volley(config, deps, resume_from = null) {
|
|
2965
3345
|
const { renderer } = deps;
|
|
2966
3346
|
if (resume_from === null) {
|
|
2967
|
-
|
|
3347
|
+
initialize_run_state(config);
|
|
2968
3348
|
write_resolved_config(config);
|
|
2969
|
-
} else
|
|
3349
|
+
} else initialize_run_state(config, { preserve: true });
|
|
2970
3350
|
report_worktree_fate(config, renderer);
|
|
2971
3351
|
const branch = worktree_branch(config.run_id);
|
|
2972
3352
|
const fate = worktree_fate(config);
|
|
@@ -3007,18 +3387,24 @@ async function run_loop(config, deps, resume_from, bash_executor, baseline) {
|
|
|
3007
3387
|
critic: deps.engine
|
|
3008
3388
|
};
|
|
3009
3389
|
const flow = build_flow({
|
|
3010
|
-
|
|
3390
|
+
seats: {
|
|
3391
|
+
builder: {
|
|
3392
|
+
...deps.seats?.builder,
|
|
3393
|
+
engine: engines.builder
|
|
3394
|
+
},
|
|
3395
|
+
critic: {
|
|
3396
|
+
...deps.seats?.critic,
|
|
3397
|
+
engine: engines.critic
|
|
3398
|
+
}
|
|
3399
|
+
},
|
|
3011
3400
|
config,
|
|
3012
3401
|
renderer,
|
|
3013
3402
|
bash_executor,
|
|
3014
|
-
baseline
|
|
3403
|
+
baseline,
|
|
3404
|
+
...deps.check !== void 0 ? { check: deps.check } : {}
|
|
3015
3405
|
}, resume_from);
|
|
3016
3406
|
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
|
-
});
|
|
3407
|
+
const { value, converged } = await execute_flow(config, deps, flow, { resume_from });
|
|
3022
3408
|
const result = run_result_from_state(config, value, status_of(value, converged), (/* @__PURE__ */ new Date()).toISOString());
|
|
3023
3409
|
write_run_summary(config, result);
|
|
3024
3410
|
return result;
|
|
@@ -3029,6 +3415,45 @@ async function run_loop(config, deps, resume_from, bash_executor, baseline) {
|
|
|
3029
3415
|
await dispose_seat_engines(engines);
|
|
3030
3416
|
}
|
|
3031
3417
|
}
|
|
3418
|
+
/** Run the flow into its file trajectory and, when the caller passed one, its
|
|
3419
|
+
* own sink. Without a sink this is the one `run` it has always been. With one,
|
|
3420
|
+
* it goes through `run.stream`, because only a streamed run records each model
|
|
3421
|
+
* call's chunks as `model_chunk` events, which are what an outer consumer maps
|
|
3422
|
+
* to text, tool cards, and approval prompts. The sink is that consumer, so the
|
|
3423
|
+
* handle's own event stream is closed at once rather than left to buffer, and
|
|
3424
|
+
* the file skips the chunks, so it holds the same kinds of event either way.
|
|
3425
|
+
*
|
|
3426
|
+
* The file goes first in the tee because the first sink's span ids are the
|
|
3427
|
+
* ones the run hands out as parents, so the file's parent links stay whole.
|
|
3428
|
+
* The sink sees parent ids it never issued, which costs an ACP consumer
|
|
3429
|
+
* nothing: it counts a step's runs by the `id` on each `span_start`. */
|
|
3430
|
+
function execute_flow(config, deps, flow, input) {
|
|
3431
|
+
const file = filesystem_logger({ output_path: state_path(config, "trajectory.jsonl") });
|
|
3432
|
+
const options = {
|
|
3433
|
+
...deps.abort !== void 0 ? { abort: deps.abort } : {},
|
|
3434
|
+
...deps.install_signal_handlers !== void 0 ? { install_signal_handlers: deps.install_signal_handlers } : {}
|
|
3435
|
+
};
|
|
3436
|
+
if (deps.trajectory === void 0) return run(flow, input, {
|
|
3437
|
+
...options,
|
|
3438
|
+
trajectory: file
|
|
3439
|
+
});
|
|
3440
|
+
const handle = run.stream(flow, input, {
|
|
3441
|
+
...options,
|
|
3442
|
+
trajectory: tee_logger(without_model_chunks(file), deps.trajectory)
|
|
3443
|
+
});
|
|
3444
|
+
handle.events[Symbol.asyncIterator]().return?.();
|
|
3445
|
+
return handle.result;
|
|
3446
|
+
}
|
|
3447
|
+
/** A logger that passes `inner` every event but a model call's streamed
|
|
3448
|
+
* chunks. */
|
|
3449
|
+
function without_model_chunks(inner) {
|
|
3450
|
+
return {
|
|
3451
|
+
...inner,
|
|
3452
|
+
record: (event) => {
|
|
3453
|
+
if (event.kind !== "model_chunk") inner.record(event);
|
|
3454
|
+
}
|
|
3455
|
+
};
|
|
3456
|
+
}
|
|
3032
3457
|
/** Best-effort status stamp on the run summary when the loop threw; the
|
|
3033
3458
|
* on-disk iteration state is already consistent (record runs per round). */
|
|
3034
3459
|
function finalize_failed_run(config, err) {
|
|
@@ -3036,7 +3461,7 @@ function finalize_failed_run(config, err) {
|
|
|
3036
3461
|
const cause = kind === "phase_error" ? error_kind(err.cause) : null;
|
|
3037
3462
|
const status = kind === "aborted_error" || cause === "aborted_error" ? "interrupted" : "error";
|
|
3038
3463
|
try {
|
|
3039
|
-
const summary_path =
|
|
3464
|
+
const summary_path = state_path(config, "summary.json");
|
|
3040
3465
|
write_run_summary(config, {
|
|
3041
3466
|
...existsSync(summary_path) ? JSON.parse(readFileSync(summary_path, "utf8")) : run_result_from_state(config, initial_state(), status, null),
|
|
3042
3467
|
status,
|
|
@@ -3089,6 +3514,7 @@ function summarize_value(value, max = 120) {
|
|
|
3089
3514
|
function create_renderer(options) {
|
|
3090
3515
|
const write = options.write ?? ((text) => process.stderr.write(text));
|
|
3091
3516
|
const { mode, color } = options;
|
|
3517
|
+
const see_trajectory = `see ${options.trajectory_path ?? ".volley/trajectory.jsonl"}`;
|
|
3092
3518
|
const silent = mode === "json";
|
|
3093
3519
|
const streaming = mode === "default" || mode === "verbose";
|
|
3094
3520
|
let mid_line = false;
|
|
@@ -3110,7 +3536,7 @@ function create_renderer(options) {
|
|
|
3110
3536
|
mid_line = !chunk.text.endsWith("\n");
|
|
3111
3537
|
return;
|
|
3112
3538
|
case "tool_call_end": {
|
|
3113
|
-
const summary = mode === "verbose" ? truncate(JSON.stringify(chunk.input, null, 2) ?? "", VERBOSE_TRUNCATE_CHARS,
|
|
3539
|
+
const summary = mode === "verbose" ? truncate(JSON.stringify(chunk.input, null, 2) ?? "", VERBOSE_TRUNCATE_CHARS, see_trajectory) : summarize_value(chunk.input);
|
|
3114
3540
|
line(`${GLYPH_TOOL} ${paint(role, chunk.id, color)} ${summary}`);
|
|
3115
3541
|
return;
|
|
3116
3542
|
}
|
|
@@ -3118,7 +3544,7 @@ function create_renderer(options) {
|
|
|
3118
3544
|
const ok = chunk.error === void 0;
|
|
3119
3545
|
if (mode === "verbose") {
|
|
3120
3546
|
const body = ok ? summarize_output(chunk.output) : chunk.error?.message ?? "";
|
|
3121
|
-
line(`${ok ? "✅" : "❌"} ${truncate(body, VERBOSE_TRUNCATE_CHARS,
|
|
3547
|
+
line(`${ok ? "✅" : "❌"} ${truncate(body, VERBOSE_TRUNCATE_CHARS, see_trajectory)}`);
|
|
3122
3548
|
} else line(`${ok ? "✅" : "❌"} ${ok ? "" : summarize_value(chunk.error?.message)}`);
|
|
3123
3549
|
return;
|
|
3124
3550
|
}
|