fix: what an audit of the documented behaviour found

An independent check of every behaviour AGENTS.md was to describe:
- `--units ,` and `--only ,` skipped their empty items and so had no
  filter left: a typo started a run of every unit. They are usage errors
  now (exit 3).
- Two fixes the plan lists were missing: a dry run now clears the prompts
  a dry run before it left in the dir they share (the report counted
  them), and `config` reports an absolute functional.mcp.command that is
  no program here - /usr/bin/npx inside the Flatpak, whose /usr is the
  Sdk's.
- A patch that is not UTF-8 stops `run` at preflight (exit 3), as the
  plan has it; it exited 1.
- An exception no command caught aborted the process (134), e.g. a home
  that holds no lenses: it ends with its message and exit 1. An error
  escaping `run` or `freeze` now also clears the signal handler's run and
  sweeps what is left below the subreaper.
- `config -h` printed "unrecognized arguments" (exit 3): it prints the
  usage, exit 0.
- An API seat's .claude.json is linked into place, so a second writer
  never replaces the first's file and what claude wrote to it since.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-10-31 16:45:53 +01:00
commit 32a0de1a07
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
7 changed files with 114 additions and 11 deletions

View file

@ -264,6 +264,16 @@ echo "{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\"
dry.returnCode == 2 && all > gate1 && gate1 > 1 && !securityUnit.empty() && !proverUnit.empty() && calls() == 0 && Count(planned, "lens", "repo-functional") == 0,
dry.out.substr(dry.out.size() > 2000 ? dry.out.size() - 2000 : 0) + dry.err);
// Dry runs with no full run between them share one dir: the next
// drops the last one's prompts.
const fs::path dryDir = scratchRoot / std::format("{}-run-1-dry", h8);
Write(dryDir / "prompts" / "stale.txt", "a prompt of another range");
const Process::Captured again = run({"--dry-run"});
std::int64_t prompts = 0;
for (const fs::directory_entry& entry : fs::directory_iterator(dryDir / "prompts")) prompts += entry.path().extension() == ".txt" ? 1 : 0;
Check("dry run: a second dry run on a hash counts its own prompts, the one before's gone", again.returnCode == 2 && !fs::exists(dryDir / "prompts" / "stale.txt") && prompts == all,
std::to_string(prompts));
// Killed in its last gate: one security pass never answers, and the
// driver is SIGKILLed once every other pass has.
const fs::path scratchA = scratchRoot / std::format("{}-run-1", h8);
@ -364,6 +374,33 @@ echo "{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\"
Check("preflight: an env file that is not UTF-8 stops the run, exit 3, with the decoder's own words",
badEnv.returnCode == 3 && badEnv.err.contains("preflight: 'utf-8' codec can't decode byte 0xff in position 0: invalid start byte"), badEnv.err);
// A filter that names nothing is refused: skipping its empty items
// must not leave no filter, which is a full run.
const Process::Captured noUnits = run({"--units", ","});
const Process::Captured noLenses = run({"--only", " , "});
Check("--units and --only that name nothing are usage errors (exit 3) - never a full run",
noUnits.returnCode == 3 && noUnits.err.contains("argument --units: names no unit id (',')") && noLenses.returnCode == 3
&& noLenses.err.contains("argument --only: names no lens file (' , ')"),
noUnits.err + noLenses.err);
// A patch that is not UTF-8 is no artifact: a preflight stop.
const fs::path badArtifact = repo / ".review-gates" / "push" / "bad";
fs::copy(artifact, badArtifact, fs::copy_options::recursive);
for (const fs::directory_entry& entry : fs::directory_iterator(badArtifact)) {
if (entry.path().extension() == ".patch") std::ofstream(entry.path(), std::ios::binary | std::ios::app) << std::string("\xff\n", 2);
}
const Process::Captured badPatch = Process::Run({Platform::SelfExe().string(), "run", "--artifact", ".review-gates/push/bad", "--base", baseSha, "--trees", ".", "--dispositions",
".review-gates/push/bad/gate-state/dispositions.json", "--claude-bin", fake.string(), "--scratch", scratchRoot.string(), "--dry-run"},
repo, std::chrono::seconds(60), {}, Env);
Check("preflight: a patch that is not UTF-8 stops the run, exit 3, saying so",
badPatch.returnCode == 3 && badPatch.err.contains("the artifact's patches are not UTF-8 ('utf-8' codec can't decode byte 0xff"), badPatch.err);
// An error that escapes a run ends it with its message, exit 1,
// through the run's cleanup.
const Process::Captured badReserve = run({"--no-reuse", "--no-salvage"}, {{"REVIEW_GATES_MEM_RESERVE_MB", "x"}});
Check("an error escaping a full run (a reserve that is no number) ends it with its ValueError, exit 1",
badReserve.returnCode == 1 && badReserve.err.contains("crafter-review: ValueError: could not convert string to float: 'x'"), badReserve.err);
// A stop between two gates: the next gate's pool starts aborted and
// runs nothing of it.
{