Commit Graph
453 Commits
Author SHA1 Message Date
Omar Sobh 62509a5090 fix(missions): a solo microVM run showed the operator an empty Live and Output tab
Found by a frontend wiring sweep, then confirmed in the database.

Everything the UI shows of a run's CONTENT reads
`topology_runs.checkpoint.records`: `/api/missions/{id}/documents` behind the
output reader, and `/api/topology-runs/{id}/events` behind the live pane. The
`team` and `microvm_graph` tiers write those records. The SOLO microVM path
never did — it updated `status` and nothing else:

    tier          | checkpoint_null | records
    microvm_graph | f               | 2-5
    team          | f               | 5
    microvm       | t               | 0      <-- every one

So a single-phase microVM mission ran real work, delivered a real branch, and
showed an empty Live tab and an empty Output tab. The agent's own account of the
turn went to stderr via eprintln and nowhere a user could reach.

Note what was NOT broken, since that was the initial suspicion: the SSE path
matches (`/api/topology-runs/{id}/events` on both sides), and a sweep of all 130
frontend `/api/` calls against the 164 registered routes found zero genuinely
missing endpoints. The wiring was fine; the data was absent.

The run now persists its turn as one record shaped exactly like the ones those
two readers already parse — `node_id`, `role` (the phase kind), `phase`,
`output` — so no reader changes. Written with `checkpoint || $3::jsonb` so a
future writer of other checkpoint keys is not clobbered.

246 lib tests.
2026-08-07 19:02:43 -07:00
Omar Sobh 3616bc4733 feat(missions): an operator button to merge a mission's branch into main
`MergePolicy::Never` — the default for anything touching code — has always meant
"do not merge on your own", deferring to a human. There was no way for that human
to say yes: `auto_merge` was reachable only from the paper-harvest path, no
workflow template declares `merge_policy`, and every mission ended at a branch.

`POST /api/missions/{id}/merge` is that yes, with a button on the artifacts tab.
The additive-only gate does NOT apply here, deliberately: an operator reading a
code change is exactly the judgement the policy was holding out for.

What is not waived:

  - the branch comes from the artifact delivery RECORDED, not rebuilt from the
    mission id, and must have `pushed: true`. A phase that never pushed shows no
    button instead of one that cannot work.
  - an empty branch is refused. A button reporting success for merging nothing
    is worse than no button.
  - a conflict refuses, aborts, and leaves the repo clean rather than forcing.

It works in a FRESH CLONE under `_merge/<mission>`, never the mission checkout:
that directory is reaped on a timer after a mission ends, so a merge using it
would succeed right after a run and fail inexplicably an hour later. The clone is
made by the server process, so nothing runs as root and ordinary cleanup works —
unlike the copies in `root_copy`.

`merge_and_push` is split out so the operator path and the automatic path run the
SAME git commands; only the gates differ. A test asserts both call it, that the
operator path does not re-apply the additive gate it exists to bypass, and that
it still refuses an empty branch.

Harness 43/43 across all five recipes before this change, with `_gate`, `_bench`
and `_verify` all at zero.

246 lib tests, 20 binaries, 89 frontend tests, clean build.
2026-08-07 18:53:38 -07:00
Omar Sobh a8b8efba6a fix(delivery): the on_green_tests gate ran the suite in the live checkout
Fourth instance of the same defect, and the last of the three commands that run
as root against a mission tree.

`verify_tests` execs the project's test command with `workdir = repo` — the live
checkout — inside a container running as ROOT. `cargo test` writes `target/`, so
the checkout ends up owned by two uids and the next phase's cargo hits
permission-denied. The harness reported `uids=0,65532` the first time this gate
ever ran end to end.

It survived because it had never run. Every one of the ten harness fixtures used
`commit_policy: "always"`; `on_green_tests` and `on_reviewer_approval` were
parsed, implemented, and never exercised — and `Gate`'s own doc already records
that three recipes carried this policy while it "did precisely nothing" for want
of a reader. A policy that is never exercised is indistinguishable from one that
is ignored.

Consolidated rather than fixed a third time. `root_copy` now owns the pattern —
copy through `mission_fs::pack_dir` into a SIBLING of the mission dir, run there,
and purge FROM INSIDE THE CONTAINER, because the copy's `target/` is root-owned
and the server (uid 65532) cannot delete it. `benchmark_runner` moved onto it;
`evaluator_tools::Sandbox` keeps its own copy logic for now (it carries an
allow-list and a judge-facing API, so folding it in is a larger change than this
moment warrants — noted, not done).

The gate fails CLOSED if the copy cannot be made: an unverifiable suite must not
license a push.

Also adds the `refactor` scenario, which is what found this. I had written it off
as "structurally identical to four existing scenarios" — wrong: it is the only
recipe carrying `on_green_tests`, and that made it the only one testing this
code path at all.

245 lib tests, 20 test binaries.
2026-08-07 17:48:29 -07:00
Omar Sobh a4b4d05b8d fix(evaluator): the verification sandbox leaked for the same reason the bench copy did
Found by checking `_verify` after fixing the identical bug in `_bench`: 16 MB
stranded across two copies, the oldest hours old.

`Sandbox::Drop` calls `std::fs::remove_dir_all` as uid 65532. The judge runs
`cargo test` in a container as ROOT — that is the entire point of the sandbox —
so the copy's `target/` is root-owned and the removal fails on it, leaving the
whole tree. The error was logged to a stream nobody reads, so the sandbox that
exists to protect the checkout quietly filled the disk instead.

Its doc comment also claimed "the copy lives under `_verify/<mission>`, which
the next pass clears anyway". That was wrong for exactly the same reason:
`for_checkout` removes a stale root before copying, with the same uid, and fails
the same way. A leaked copy was permanent, not transient.

`Sandbox::purge` removes it from inside the container, as root, where it was
written. `evaluate` now wraps its body so the purge runs on EVERY exit — that
function returns from several branches, and cleanup only some paths reach is the
same as no cleanup on the others. `Drop` stays as a fallback for the early paths
where nothing has run as root yet, and its comment no longer claims otherwise.

This is the third instance today of the same shape: cleanup that cannot clean up,
invisible because the failure was swallowed. The others were the leaked agent
containers in the runtime tests and the bench copy in e89a32f.

243 lib tests.
2026-08-07 17:34:54 -07:00
Omar Sobh e89a32ffef fix(benchmark): the bench copy leaked because only root could delete it
The copy fix in a93a411 restored the checkout's single-writer invariant but
stranded the copy: 1.2 MB per run, growing forever.

`cargo bench` runs as root inside the container and writes `target/` there, so
the copy is root-owned. The server process is uid 65532; its
`remove_dir_all` cannot delete those files, and `Drop` discarded the error — so
the tree survived and nothing said so. The same "cleanup that cannot clean up"
shape as the container leak in the runtime tests, and invisible for the same
reason: a swallowed error on a path nobody reads.

`purge_copy` removes it from INSIDE the container, as root, where it was
written. Called on BOTH the success and failure paths before `Drop`, and again
before creating a copy, since a stale one from a previous run is root-owned too.
`Drop` stays as a fallback for the early-error paths where nothing ran as root
yet, and now says in its doc comment that it cannot do the real job.

Found by checking `_bench` after the uid probe went green — the invariant it
asserts was satisfied while the fix that satisfied it was leaking.

243 lib tests.
2026-08-07 17:07:36 -07:00
Omar Sobh a93a4111e1 fix(benchmark): the baseline runner was writing root-owned files into the checkout
Caught by the harness: `benchmark: checkout has multiple writers (uids=0,65532)`.
The previous full run passed that same check, so this was introduced by wiring
`benchmark_runner` into the sweep one commit ago.

`docker_exec` enters a container running as ROOT with the missions root
bind-mounted, and `cargo bench` writes `target/`. Run in the live tree it leaves
root-owned build output in a checkout owned by uid 65532 — the single-writer
invariant broken, and the next phase's cargo hitting permission-denied on a
directory it cannot write.

This is the SAME defect `evaluator_tools::Sandbox` was written for, found by the
same probe, and fixed the same way: benchmark a COPY. `BenchCopy` packs the
checkout through `mission_fs::pack_dir` (so it excludes exactly what the
delivered diff excludes — one exclusion list, now four consumers) into
`<missions_root>/_bench/<mission>`, a SIBLING of the per-mission dirs like
`_verify` and `_outputs`, so a mission reap cannot race a running bench. Removed
on drop, including on error paths.

The operator-triggered path (POST /api/missions/{id}/benchmark) had this bug
from the start and is fixed by the same change — it shares `run`.

Worth naming the pattern: measurement must not mutate what it measures. It
applies to the judge, to the `verifier` subagent that has no Edit or Write, and
now to the benchmark runner.

243 lib tests, zero warnings.
2026-08-07 17:01:36 -07:00
Omar Sobh 0d8db7ff0b fix: close the three remaining gaps, and repair a test I silently disabled
FIRST, the self-inflicted one. My edit in a20702d inserted a test between an
existing `#[test]` and the function it belonged to. The result compiled and
looked fine: `every_anthropic_spelling_is_one_family` lost its attribute and
STOPPED BEING A TEST, its doc comment ended up describing my test instead, and
my test carried two `#[test]`s. It has not run since — in already-deployed code.
Nothing failed, which is the point: a test that does not run is indistinguishable
from one that passes. Found via a compiler warning I had not read.

The commit message on a20702d said "241 lib tests pass". 240 ran.

Then the three gaps.

1. A security scan could not read history. `ensure_checkout` clones with
   `--filter=blob:none` — full commits, blobs on demand — and the agent
   environment has NO network route to the forge. Measured: gitleaks on a
   4-commit repo reported "1 commits scanned" and "could not fetch <sha> from
   promisor remote". A credential committed and later deleted is exactly what a
   scanner looks for and exactly what a lazy blob withholds. Missions with a
   `security_scan` phase now clone fully; everything else keeps the cheap path.

   (I first blamed `--depth 1`, from a stale module doc comment. The code has
   said `--filter=blob:none` since it was written, and the comment at `clone`
   explains why NOT shallow — a shallow clone cannot push a branch back. Both
   the comment and my claim are fixed.)

2. `benchmark_runner` never ran as part of a benchmark phase. It was reachable
   only from an operator button, so the `author_and_baseline` recipe authored
   benchmarks and measured nothing — `benchmark_snapshots` stayed empty. Now
   baselined from the sweep, SPAWNED not awaited: BENCH_TIMEOUT is 30 minutes
   and that loop also starts, closes, evaluates and captures every phase on the
   platform. A `NOT EXISTS` guard on iteration 0 makes per-tick firing safe. A
   repo with no bench harness logs and does NOT fail the phase — but it logs,
   because "no baseline" must not read like "not attempted".

3. The World's rich layer was empty for every mission. `run_events::append` is
   called only from the a2a path, and `world.rs` tailed only that table —
   while mission per-step detail has always lived in
   `topology_runs.checkpoint.records`, which `topology::run_events_sse` streams.
   The data was never missing; the viz read the one source missions never write.
   Now both are tailed, mapped through the existing `step_started` vocabulary so
   no new event types are needed.

242 lib tests, 20 test binaries, zero warnings.
2026-08-07 16:09:49 -07:00
Omar Sobh a20702d55b fix(evaluator): a bare validator model name claimed independence it never had
`CLAWMATES_VALIDATOR_MODEL=gemini-2.5-flash` (or any bare model name) produced
an Anthropic judge grading Anthropic work, recorded `independent = true`.

The chain:

  - `provider_family` reads the SPEC. A bare `gemini-2.5-flash` matches none of
    the known needles, so it returns "unknown" — deliberately NOT "anthropic",
    so it passes the `family == IMPLEMENTER_FAMILY` guard.
  - `Runtime::resolve_provider` (runtime.rs:224) falls back to the DEFAULT
    provider for any spec it cannot route. A bare name has no `provider:` to
    route on, so it silently returns the house Anthropic provider.
  - The existing "no provider registered" guard checks `model.contains(':')`.
    That works for `glm:glm-4.7` — an unrouted colon-spec comes back carrying
    its colon — and can NEVER fire for a bare name.

So the one guarantee this path exists to make (the judge is not the implementer)
was reported as satisfied while being violated. That is the same shape as the
Goodhart incident the independent judge was built after: not a wrong answer, a
wrongly-trusted one.

A validator spec must now name its provider. `names_a_provider` is a named
predicate rather than an inline `contains(':')` so the rule is testable and the
reasoning has somewhere to live.

Found while auditing my own Gemini removal — which turned out to be
behaviour-neutral here (a gemini spec went from family "gemini" to "unknown",
both non-anthropic, same verdict). The bug is pre-existing and independent of
it; removing Gemini only made the bare `gemini-*` spelling more likely to be
left behind in someone's env.

Live config is `glm:glm-4.7`, a proper registry spec, so production behaviour is
unchanged. Negative control: make `names_a_provider` return true unconditionally
and `a_validator_spec_must_name_its_provider` fails.

241 lib tests pass.
2026-08-07 14:34:28 -07:00
Omar Sobh 87f188ae73 refactor: strip Gemini from the platform, and level up the architecture_mapper
Two things.

1. The architecture_mapper proposal, applied AND made durable.

The GLM proposal (019fddd9) was accepted in full: the agent's system_prompt now
carries the Mermaid-first constraint and its brain was rewritten. Both verified
against the live row and the .h5 file.

But `apply_identity` writes `UPDATE agents SET system_prompt` and
`apply_brain_consolidation` writes that agent's brain — neither touches the team
TEMPLATE. That agent is mission-scoped, so the improvement would have died with
the mission. The model's actual insight was sharp and worth keeping: "Mermaid
diagrams beat prose" lived in the brain SEED and not in the system PROMPT, so it
only applied when the agent happened to consult its brain. That constraint is
now in templates/teams/codebase_research.toml, where every future Codebase
Research team inherits it.

(The proposal's second item mostly restated anti-patterns the seed already
lists, so the seed is unchanged. Applying an LLM's suggestion is not the same as
agreeing with all of it.)

2. Gemini is gone.

Removed: the `gemini.default` provider alias and its `is_exact_provider_match`
prefix, GEMINI_API_KEY forwarding to agent containers, the evaluator's
gemini->gemini family row, the model selectors in claws/teams/planner and in
TeamWizard + AgentComputer, and the commented provider block in the runtime
config example (whose ZEROCLAW_AGENT_MAP example still mapped a worker_gemini
that no longer existed).

`provider_alias_for("gemini")` now returns claude_cli.default via the
unrecognised-model branch, which LOGS. A stray gemini binding degrades visibly
rather than resolving to a provider row we no longer ship. A test pins that, and
another pins that GEMINI_API_KEY is forwarded in NEITHER auth mode, so adding it
back to the list is a visible change rather than an accident.

Avatar generation is DELETED, not disabled — it called Gemini's image model, and
there is no alternative: Claude and Kimi are text-only, and z.ai answers
"Unknown Model" for cogview-3-flash and cogview-4 on our plan (measured, not
assumed). AvatarModal keeps UPLOAD, which never needed a provider; only the
prompt-generation half is gone.

240 backend lib tests, 89 frontend tests, clean tsc + eslint, build succeeds.
2026-08-07 14:15:53 -07:00
Omar Sobh f6c3ddbf81 refactor: no feature depends on Gemini any more
Depleted Gemini prepayment credits took out PDF rendering. The same key was the
only thing standing between level-up proposals and the same fate, so both are
off it.

- `pdf_renderer` is DELETED, not disabled. Nothing sets `render_pdf: true` since
  markdown became the deliverable (821cbb8), so the worker polled forever for
  rows that can no longer exist. It was also the only caller of the Gemini
  MD->HTML conversion. A worker that cannot do anything is worse than absent: it
  reads as a feature.

- `level_up` now resolves its proposer through the provider REGISTRY
  (`Runtime::resolve_provider`), the same path the evaluator uses, defaulting to
  `glm:glm-4.7` — the validator this project measured and chose in
  scripts/judge-eval.sh. `CLAWMATES_LEVEL_UP_MODEL` takes a registry spec
  (`glm:glm-4.7`, `kimi:k2`, `claude-sonnet-5`), so every provider the platform
  can already reach works and no single vendor's billing can take it down.

The non-obvious part of that swap: Gemini was asked for
`response_mime_type: application/json` and obliged, so the old code parsed the
raw reply. Anthropic-format models are under no such obligation and wrap objects
in prose or a ```json fence. `extract_json_object` brace-counts to the matching
close — string-aware, so a `}` inside a value does not end it, and nested (these
proposals nest by design). Tested against bare, fenced, nested, brace-in-string
and absent. Parsing raw text would have worked in review and failed on the first
real proposal.

What deliberately still MENTIONS Gemini: `mission_runtime` forwards
GEMINI_API_KEY to agent containers alongside GROQ/OPENAI/ZAI/KIMI, and the claw
model selector offers it. Those are user options, not platform requirements —
the ask was to remove the NEED.

Also corrected a comment in mission_delivery that cited `pdf_renderer` as the
authority on artifact path resolution. It never was: it joined the mission id
first and produced a doubled path that never resolved.

238 lib tests, 20 test binaries.
2026-08-07 13:01:57 -07:00
Omar Sobh 821cbb8622 feat(missions): hold every producing phase to delivering, and read markdown instead of PDFs
Two changes the portal review asked for.

1. `benchmark` and `security_hardening` had no delivery guarantee.

`empty_delivery_is_a_failure` tested `kind == "coding"`, on the reasoning that
"research phases legitimately write nothing to the tree" — which the research
directive three modules over contradicts, since it tells the agent to save
findings under /mission/repo/research/. The cost: a `benchmark` mission is ONE
benchmark phase, and with that phase exempt nothing in the platform could fail
it. Same for `security_hardening`, whose first two phases are security_scan and
research.

Now keyed on PRODUCING_KINDS = coding, research, benchmark, security_scan.
`review` stays exempt — a reviewing phase that changes nothing has done its job,
the same distinction `vm_stop_gate::per_node` makes. The test that encoded the
old rule is rewritten rather than deleted, with the reasoning that replaced it.
All 8 harness fixtures are coding phases, so harness behaviour is unchanged.

2. PDFs are dropped; markdown is the deliverable.

Rendering a PDF meant asking an LLM to convert markdown to HTML — a paid API
call per document, on the critical path of "let me read my research", which
failed on depleted Gemini credits and left every artifact unreadable. Styling at
render time is free, offline, instant and cannot 429.

- `mission_outputs` no longer requests a render.
- New `GET /api/missions/{id}/artifacts/{artifact_id}/content`. The frontend had
  no way to READ an artifact at all: it listed paths and offered a PDF preview
  that never rendered (and whose `rendered_pdf_path` had no route serving it).
  Two containment rules, both enforced: the artifact must belong to a mission in
  the caller's workspace, and the CANONICALISED path must stay under `_outputs`
  — canonicalise first, because checking the string before resolving `..` is the
  classic hole.
- `MarkdownBlock` now uses react-markdown + remark-gfm + rehype-slug. It was a
  deliberate zero-dep renderer for "the subset the refiner emits", and that
  subset stopped matching reality: agent briefs are largely GFM pipe tables,
  which it showed as literal pipes. MissionOutputReader and RefineDiffModal use
  the same component and gain tables for free.
- Heading ids come from rehype-slug and `outlineOf` slugs with the same
  GithubSlugger, so the outline rail's anchors still resolve. A test pins that
  invariant, including duplicate headings.

Styles live in globals.css under `.md-view`: the markup is generated so there
are no class hooks, and this project has no styled-jsx registry — the app-router
requirement is documented in next/dist/docs/01-app/02-guides/css-in-js.md, which
frontend/AGENTS.md exists to make me read.

The artifacts tab moved to `MissionArtifacts.tsx`. MissionCanvas was 1341 lines
against a 1250 limit BEFORE this change — already failing lint; it is now 1248.

238 backend lib tests, 20 backend test binaries, 89 frontend tests, clean tsc,
clean eslint on every file touched, production build succeeds.
2026-08-07 12:19:06 -07:00
Omar Sobh da889f83ab fix(missions): an empty repo-less phase was re-processed on every tick forever
The guard added in ceab28b fails a repo-less phase that produced nothing. It
does not record that it looked — and the selection query asks "no artifact of
this kind exists", which stays true forever for a phase with no output. So the
phase matched on every sweep: a docker copy_out per tick, and with BATCH = 5,
five such phases would occupy every slot permanently and no repo-less mission
would ever be captured again.

Measured on the first live negative control: 4 occurrences of the guard's log
line, then 8 45 seconds later.

This is a bug this codebase has already fixed once. `record_uncapturable` exists
because "five reaped phases from earlier runs blocked the batch while a freshly
finished coding phase went untouched" — its own comment. I wrote the same defect
into new code on the same sweep, which is the argument for the marker being part
of the pattern rather than something each capture path remembers separately.

Same fix as the precedent: a real file (`NO-OUTPUT.md`) behind a real artifact
row, because a row pointing at nothing turns every reader into an unexplained
404. It carries `metadata.empty = true`, the convention `mission_delivery`
already uses for its "No code changes" artifact, so "captured, and there was
nothing" is distinguishable from "captured eight documents".

The guard itself was proven correct on that same run before this was noticed:
mission failed, phase failed, artifacts 0, with the reason and the
`allow_empty` escape hatch named in the log.

237 lib tests pass.
2026-08-07 11:48:23 -07:00
Omar Sobh c28c7a148f fix(pdf): the renderer resolved every artifact path against the wrong root
`render_one` joined `missions_root()/<mission_id>/` before the artifact path,
producing `<root>/<mission>/_outputs/<mission>/<phase>/...` — the mission id
twice, and no such file.

Artifact paths are relative to the MISSIONS ROOT. All three registration sites
write `_outputs/<mission>/<phase>/...`, and `_outputs` is deliberately a sibling
of the per-mission directories so it survives their reaping; joining the mission
id first put the lookup inside the very directory `_outputs` exists to escape.

It went unnoticed because until now the only artifacts on the system were
`code_diff` rows registered with `render_pdf: false`, which this worker never
reads. `produces = ["md","pdf"]` was inert, so nothing ever asked for a render.
The first artifacts to ask were the first to find it — both failed with ENOENT
on the doubled path.

Negative control: restore the extra join and
`an_artifact_path_resolves_against_the_missions_root` fails.

The worker's error handling is sound and needed no change: it recorded
`render_pdf_status = 'failed'` with the full path in `render_pdf_error`, which
is how this was diagnosed in one read.

237 lib tests pass.
2026-08-07 11:41:48 -07:00
Omar Sobh 89bc53b53d fix(missions): repo-less capture was publishing the agent's own identity files
First live run of `capture_repo_less_phases`: 9 artifacts, of which 2 were the
user's research. The other 7 were AGENTS.md, HEARTBEAT.md, IDENTITY.md,
MEMORY.md, SOUL.md, TOOLS.md and USER.md — the agent runtime's identity
scaffolding, seeded into the workspace root because that root is pinned to the
repository root.

The codebase already knew about these files and already had the list. What it
did not have is a defence that works without a repo: `ignore_agent_scaffolding`
writes them to `.git/info/exclude`, and a mission with no repository has no
`.git`. So the exact files that once got committed into a user's repo and
pushed (the reason that list exists) came back through a new channel.

`AGENT_SCAFFOLDING` is now `pub(crate)` and `mission_outputs` filters on it
directly — one list, two consumers, so the next file the runtime starts seeding
is excluded from both at once rather than from whichever was remembered.

Negative control: replace the filter with `&& true` and
`research_documents_are_kept_and_scaffolding_is_not` fails.

Found by running it against a live mission, not by reading it. The unit tests
passed the whole time — they seeded a tree that did not contain the scaffolding,
because I did not know it would be there.
2026-08-07 11:35:00 -07:00
Omar Sobh ceab28b902 fix(missions): a repo-less mission threw away everything its agents wrote
`capture_finished_coding_phases` selects `AND m.repo_id IS NOT NULL`. Every
`research_only` mission is repo-less by design (`requires_repo = false`), so the
whole capture path — including the `sync_out` that copies the agent's work OUT
of the container — never ran, and the container was reaped unread.

Measured on the real mission `019fdc35` ("ClawHDF5 Research"): four agents, 9.5
minutes, EIGHT research documents — an HDF5 parser design, a Rust ecosystem
survey, a seven-crate dependency map, tracing and fuzzing strategy. Result:
`mission_artifacts` = 0, mission `completed`. Not recoverable: no container, no
volume, nothing under the missions root.

The platform did not merely fail to save the work — it INSTRUCTED it. The task
preamble tells every agent "/mission/repo ... is the mission's git checkout",
whether or not one exists, and the research directive says to save findings
there. One agent recorded the contradiction verbatim: "No git repo — file is
written." It looked, saw no repo, complied anyway.

Three changes, one per link in that chain:

1. `mission_outputs::capture_repo_less_phases` — copies `/mission/repo` out of
   the container and registers each file as an artifact under `_outputs/`,
   which is a SIBLING of the mission dir and survives `teardown_container`.
   This is also the code that finally reads `produces`, until now an inert key:
   `produces = ["md","pdf"]` now drives `render_pdf` into the existing
   pdf_renderer worker.

2. The preamble is conditional. A repo-less mission is told its workspace is
   scratch, that git_operations has nothing to act on, and — the part that
   matters — that files left there ARE collected and published. An agent told
   only "there is no repo" has no reason to write anything to disk.

3. A repo-less phase that produced no files is FAILED, unless it declares
   `allow_empty`. The same rule `empty_delivery_is_a_failure` applies to coding,
   for the only channel these phases have. Note this is NOT that guard widened:
   it keys on `files_changed`, which is meaningless with no checkout, and would
   not have saved the ClawHDF5 documents.

Negative controls, each ablated and confirmed failing: ignore `has_repo` and the
preamble test fails; empty the skip-list and the capture test keeps `.git` and
`node_modules`; write artifacts inside the mission dir and the survives-the-reap
test fails.

236 lib tests pass.
2026-08-07 11:28:10 -07:00
Omar Sobh b36ae00ea5 fix(missions): a gate that gave up completed the phase green
`done_when_check` is run in exactly one place: the Stop hook inside the guest.
Nothing outside it has ever re-run the command — not the evaluator (which
judges the PROSE `done_when`), not capture, not delivery.

The hook is capped at MAX_BLOCKS so a stuck agent cannot wedge the turn. At the
cap it logs `cap: <reason>` and exits 0, releasing the agent with its check
still failing. That release was invisible: rc was 0 and the work collected, so
both signals the run status was decided from said "fine", and the phase
completed. Green phase, unmet condition, no error anywhere — the same
silent-success shape this project keeps paying for.

The block COUNT cannot fix it. Three blocks then a stop that finally passed and
three blocks then a surrender both report `blocks: 3`, and they are opposite
outcomes. So the gate now writes a `capped` marker file, probed back out of the
guest alongside the block count, and `Some(true)` fails the run on BOTH paths —
solo (phase_runner) and composed (microvm_turn_executor).

A marker file rather than grepping the log: a block reason embeds the check's
own output, so an output line starting `cap:` would read as a release that
never happened.

Also corrects the comment in `per_node` that sent me looking. It claimed "the
phase-level check still runs post-hoc", conflating two mechanisms — that is
true of `require_changes` (via `empty_delivery_is_a_failure`) and was never
true of `check`.

Negative controls, both ablated and confirmed failing: drop the enforcement and
`a_node_whose_gate_gave_up_fails_the_run` fails; stop writing the marker and
`a_gate_that_gives_up_records_that_it_gave_up` fails. And the control against
over-strictness — `a_node_that_was_blocked_and_then_succeeded_passes` — is why
this keys on the marker instead of the count.

231 lib tests pass.
2026-08-07 09:53:21 -07:00
Omar SobhandClaude Opus 5 d24823b6f3 fix(missions): a failed phase stranded its mission at running forever
Found by counting containers during a cleanup, not by a test. gw-04 was holding
a per-mission runtime container for a mission whose only topology run had failed
three days earlier — phases `pending,failed`, mission still `running`.

The interaction, which lived entirely between two queries' predicates:
`start_pending_phases` launches a phase only when EVERY lower-order phase is
`completed`, so once one fails the phases after it can never run. They stayed
`pending`. `close_finished_missions` closes a mission only when NO phase is
outside ('completed','failed','skipped') — so a `pending` phase that would never
run kept the mission `running` indefinitely. And `mission_runtime`'s sweeper
fires N minutes after a TERMINAL state, so the container was never reaped.

One leaked container per failed multi-phase mission, accumulating silently, with
nothing in any log saying so. Neither query is wrong alone; the bug is that
nothing marked the phases the failure had made unreachable.

`skip_unreachable_phases` says it: a `pending` phase with a `failed` phase at a
LOWER order_idx becomes `skipped` — strictly earlier, because order is what makes
a phase unreachable, and a failure later in the list says nothing about one still
queued ahead of it. `skipped` is not a new concept: `close_finished_missions`
already treats it as terminal, and it is the honest word for a phase that was
never run, as distinct from one that failed.

RETRY HAD TO MOVE WITH IT, or this trades one bug for another. `retry_phase`
required the mission to be `running`, so closing failed missions would have made
the one outcome you would actually want to retry the one you could not. It now
accepts `failed` too, and in one transaction: resets the phase, REOPENS the
phases its failure had skipped (without that, a retry runs the phase and stops,
because everything after it is terminal-by-skip), and puts the mission back to
`running` — every launcher and closer keys off that status. `completed` and
`cancelled` stay refused; reopening those is a different decision.

557 tests pass, clippy clean. Three DB tests against real SQL, including that a
phase queued BEFORE the failure is untouched and that a draft's phases are never
swept.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-07 06:58:26 -07:00
Omar SobhandClaude Opus 5 bf2055e725 fix(evaluator): the anti-Goodhart clause was failing work that RECORDS a value
Three consecutive production verdicts failed a phase that had done exactly what
its condition asked, each time with a different invented reason: "6.1.128 is not
a kernel release string like 'Linux 6.1.128'", then "line 2 should be 27.0.0",
then "line 1 must be empty or unrelated". I reworded the condition twice, and the
second rewording made it worse.

THE CONTROL THAT SETTLED IT: asked the same question with the same file and the
same condition — but WITHOUT our system prompt — glm-4.7 answered MET, citing the
exact line. The model judges this correctly. Our prompt does not.

The cause is a clause we wrote on purpose. `EVAL_SYSTEM_VERIFYING` is
deliberately adversarial because an earlier evidence-only judge was gamed by an
agent that emitted the string the judge had asked for, and it says to fail "a
required string or value hard-coded, stubbed, or printed rather than produced by
working code". A condition asking for a kernel version to be written into a file
IS that shape, read literally. The judge was obeying us.

Two clauses now, because each without the other is a known failure:

  - the trap stays: work that satisfies the letter and not the purpose — tests
    weakened, assertions fitted to wrong output, values stubbed — is not met.
  - some conditions are satisfied BY a recorded value, and for those, writing the
    value IS the work: a measured baseline, a scan report, a recorded environment
    fact. Hard-coding is cheating only when the condition is about behaviour code
    must produce.

And the other failure from those three verdicts: "judge the condition AS WRITTEN;
do not re-derive the expected value yourself" — a condition may describe a
DIFFERENT machine, an earlier run, or a remote environment, and the value the
judge would measure where it stands is not the one under judgement. That is
exactly what produced "line 2 should be 27.0.0": a tool-using judge ran `uname`
in its own container and compared.

This is not a niche fixture problem. The model-authored plans shipped today write
BASELINE.md and security-findings.md and gate on them — every one of those is a
recorded-value condition, and every one would have been rejected.

555 tests pass, clippy clean. A test pins both clauses, since removing either
reintroduces a failure this project has already paid for.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-07 06:43:44 -07:00
Omar SobhandClaude Opus 5 e2f576ec02 fix(evaluator): the judge verifies a COPY, never the mission's own tree
The harness's uid probe caught this the moment cross-provider validation came
back: `checkout has multiple writers (uids=0,65532)`. All 63 root-owned files
were under `repo/target/`.

The mechanism, confirmed rather than guessed: the judge's verification sandbox
execs into `clawmates-runtime`, which runs as ROOT with the missions root
bind-mounted, and its workdir was the mission's LIVE checkout. So when the judge
ran `cargo test` to check a condition — which is the entire point of the
verifying evaluator — cargo wrote `target/` into the checkout as uid 0, in a tree
otherwise owned by the server. The next phase's `cargo` would then hit
permission-denied on a directory it cannot write, which is the uid-split failure
class copy mode exists to eliminate.

IT WAS LATENT ALL DAY. While the z.ai credential was dead the judge never ran a
single check, so the uid probe kept passing; restoring the credential surfaced it
on the first gated mission. A guard that only holds while a dependency is broken
is not a guard, and this one was only visible because the harness measures the
invariant rather than the feature.

Running the checks as the checkout's uid was the obvious fix and is the wrong
one: `CARGO_HOME` is root-owned 0755 in that image, so a non-root uid fails, and
the evaluator treats "could not run" as unverified — trading a polluted tree for
phases that fail closed for a reason unrelated to their work.

So the sandbox verifies a copy, made through `mission_fs::pack_dir` so it carries
exactly what the delivered diff carries (no `target/`, no `node_modules/`) — one
exclusion list, three consumers. The copy lives at `_verify/<mission>`, a sibling
of the swept per-mission directories, and is removed on drop.

This is the rule the codebase already applies to the `verifier` subagent, which
has no Edit and no Write, stated for the judge: verification must not mutate what
it verifies. A judge that can change the tree it is judging can make its own
verdict true.

NEGATIVE CONTROL, run: pointing the sandbox back at the live checkout fails
`the_judge_verifies_a_copy_and_never_the_mission_tree`. The test seam
(`Sandbox::at`) is never `owned` and never deletes, so a destructive constructor
cannot masquerade as a plain one.

553 tests pass, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-07 06:32:13 -07:00
Omar SobhandClaude Opus 5 3300c9d149 feat(missions): say at boot whether the independent judge can be reached
The z.ai credential expired mid-session and the first symptom was a two-phase
mission failing after BOTH its VMs had run — the phase completed, delivered,
pushed, and then one evaluation row said "the independent validator could not be
reached this pass".

`cross_provider_judge` refusing to fall back to the agent's own provider is
correct: a verdict from the same family is not an independent check, and
producing one quietly would claim a property the verdict does not have. The cost
of that refusal is that a dead validator makes EVERY `done_when` phase
unmeetable — and the information needed to know that existed from the moment the
server booted. Nobody was told until it was expensive.

The sibling of `runtime_preflight`, and the same stance: a report, not a gate.
The server must still boot with a broken validator — refusing to start turns a
degraded deployment into a dead one, and a mission that opts out
(`validator_model = ''`) is unaffected.

Two faults, kept distinguishable because they send an operator to different
places: `Unregistered` (no provider by that name — the evaluator will refuse it
rather than judge with the default, so register one) versus `Unreachable` (it
resolved and the call failed — fix the credential). Collapsing them into "the
validator is broken" is the kind of merge that costs an hour.

The probe is a real completion through `Runtime::complete` — the same
resolve-then-stream path the judge itself takes. A models-list or a HEAD would
pass for an expired key, a revoked key, and a key with no quota, which are
exactly the cases worth catching; and a probe that dialled the provider its own
way could pass while the real call fails.

`NotConfigured` is reported too, and not as an error: a deployment may choose the
house model. It is still worth saying out loud that the check running is not an
independent one.

551 tests pass, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-07 05:16:54 -07:00
Omar SobhandClaude Opus 5 08dd227a45 feat(missions): give the planner the repository's contents, not just its names
The root listing was not enough. Given names alone the planner wrote "optimise
the hot path" for a crate whose hot path is `add(a: i64, b: i64) -> i64` — a
mission that was unachievable from the moment it was written, and that nothing
discovered until an agent had built a benchmark harness in a VM to measure an
integer addition, honestly reported no improvement was possible, and the judge
correctly failed the phase.

`repo_digest` fetches the whole tree (so "does this have benches/" is a fact, not
an inference) and then file CONTENTS in priority order: manifests first — they
say what the project is — then the README, then source ascending by size, since
a planner learns more from twenty small files than from one large one. Lockfiles
and build output are dropped: enormous, and they say nothing a manifest does not.

THE RULE THIS ENFORCES, and the reason the rendering is its own tested module: a
digest of any repository worth planning against is partial, and a model shown a
partial view without being told it is partial plans as though it saw everything.
So every omission is stated — how many files exist, how many were shown, what
was cut from each, and "anything not shown you have NOT seen". Same distinction
as `Option<u32>` for the subagent probe: "we did not look" and "there is nothing
there" are different facts.

Failures degrade to a stated absence rather than an empty string, and the three
cases stay distinguishable: no repository, a tree that could not be read, and a
tree read but no contents fetched. An unreadable tree is never rendered as an
empty repository.

Two more things the prompt now says, both learned from that run: plan for the
repository as it IS rather than as the description implies (and if the
description asks for something the code cannot support, say so in the task and
plan the phase that establishes the truth, rather than a phase that must fail);
and a mission agent has NO package-registry access. The agent discovered the
second one mid-run and wrote a dependency-free `std::time::Instant` harness after
Criterion could not be added — good adaptation, but nothing had warned it.

549 tests pass, clippy clean. The budget/priority/truncation logic is pure and
tested; only the fetching touches the network.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-07 05:01:02 -07:00
Omar SobhandClaude Opus 5 0aeae07db2 fix(missions): the planner was planning blind — show it the repository
The first real plan opened with "Identify the crate's hottest code path and run
its benchmark harness". This crate has no benchmark harness. The phase ran,
found nothing to baseline, delivered zero files, and the plan's second phase was
left with nothing to optimise against.

The planner saw the mission title, the description, and a boolean for whether a
repository was bound. It never saw the repository. A plan about a codebase
written without looking at the codebase is a guess that reads like a plan — and
the failure surfaces two phases and one VM boot later, as an agent reporting that
the thing it was told to run does not exist.

The prompt now carries the repository's root listing, read from the FORGE rather
than a checkout: at proposal time the mission is still a draft and
`ensure_checkout` has not run, so there is nothing on disk to list. It also says
outright that a phase needing something absent must CREATE it and say so in its
task — the failure was not only ignorance of the tree but the assumption that
missing tooling is someone else's problem.

A listing that cannot be fetched degrades to "(the repository listing could not
be read)" in the prompt rather than to an empty string. A model told the listing
is unavailable can hedge; a model told nothing assumes — which is the same
distinction as `Option<u32>` for the subagent probe, in a prompt instead of a
struct.

Found by running the thing end to end rather than by testing it: every unit test
here passes with a planner that has never seen a repository.

543 tests pass, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-06 23:23:34 -07:00
Omar SobhandClaude Opus 5 a33dbdcdc3 feat(missions): W1/#13 — let a model author the mission's phases
The last unstarted item from the missions-as-workflows plan, and the other half
of Slice 5: that one lets a model size the TEAM, this lets it decide what the
work IS.

Every mission's phases come from one of five hand-written recipes in
`templates/workflows/*.toml`, chosen by `template_kind` before anyone saw the
mission. That is the "do it this way: 1, 2, 3" over-specification that makes a
capable model follow a worse plan than it would have chosen. The recipes stay —
they are still the default for a mission nobody proposes a plan for, and the
fallback when a proposal is refused.

Same three verbs and the same review gate as the roster, deliberately: propose
and decide are separate because only the second changes a mission, and a second
shape would be a second thing to get right. Approving REPLACES the phases (a
plan is an answer to "what is this mission", not an addition to one), draft-only.

GROUNDED IN WHAT THE PLATFORM ACTUALLY READS, which is the part that makes this
more than a copy. `phase_config::KNOWN_KEYS` already names every phase-config key
and the code that reads it — the registry built after `task` sat unread through
every mission. A plan is validated against it, so a model cannot propose a phase
whose settings nothing will act on: the failure that registry exists to EXPOSE is
one this path cannot create. Phase kinds are checked the same way, because an
unknown kind does not error — it falls through to the catch-all purpose and runs
as a generic phase that looks like it worked.

TWO THINGS THE WORK ITSELF FOUND, both the same shape:

  - `done_when_check` — the stop-gate key added earlier today — was never
    registered in `phase_config`, so every mission that set it has been logging
    it as an unknown key. Found by a test written for a different purpose, which
    is the registry doing exactly its job. Now registered with its reader.
  - `done_when` and `max_iterations` are COLUMNS promoted out of config by
    `missions::create`; the evaluator sweep filters on the column in SQL every
    tick. My first insert wrote the config blob alone, which would have stored a
    plan's completion condition where nothing judges it. NEGATIVE CONTROL run:
    binding NULL instead of the promoted value fails
    `an_approved_plan_replaces_the_missions_phases`.

`order_idx` comes from the array's own order rather than a field the model sets:
two sources for one fact is how a plan ends up with two phase 0s, and order_idx
is what `start_pending_phases` sequences on.

MAX_PHASES is 4 and the prompt argues for one. Each phase is a full agent run in
sequence, and splitting one change into plan → implement → test is the documented
anti-pattern — a single agent doing all three keeps the context that makes the
later steps good.

543 tests pass, clippy clean. Migration 0072.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-06 20:05:28 -07:00
Omar SobhandClaude Opus 5 a48d78f8eb test(missions): a composed node is offered the same help as a solo one
Every composed run so far reports `subagents: 0`, and the honest question is
whether that is the tasks being small or the capability being absent. It is the
former, and this is what says so: a composed node's task text is built by
`microvm_turn_executor` and then wrapped by the SAME `vm_prompt` inside
`run_inside`, so one prompt builder serves both paths and both carry the `Agent`
tool offer and the `verifier` / `explorer` roles.

Asserted rather than left to code reading, because if someone gave composed
nodes their own prompt without the offer, the difference would show up only as a
count nobody was watching — and "the graph fanned out but no node did" is
indistinguishable from "no node needed to".

The roles are read from `agent_definitions()` rather than spelled out, so adding
a role without mentioning it in the prompt fails here instead of shipping a role
the lead is never told about.

535 tests pass, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-06 19:26:59 -07:00
Omar SobhandClaude Opus 5 742724e53c feat(fleet): Kimi as a microVM backend — the URL settled by measurement
The base URL took three measurements to find, and the first two were wrong in
instructive ways.

`api.moonshot.ai/anthropic/v1/messages` EXISTS and speaks the protocol — it
answers with Moonshot's own structured error rather than a 404. It also rejects
an `sk-kimi-` key, because it belongs to the platform.moonshot.ai account
namespace. Two endpoints that both "work" for different accounts is precisely
the shape that makes a guessed URL look like a broken key, and it is why this
was refused rather than guessed for as long as it was.

The Kimi CODE service is the one an `sk-kimi-` key belongs to:
`POST https://api.kimi.com/coding/v1/messages` returns a real Anthropic Messages
body — `msg_` id, `content` blocks, a `thinking` block with a signature. So
`ANTHROPIC_BASE_URL=https://api.kimi.com/coding`, WITHOUT the `/v1`: Claude Code
appends `/v1/messages` itself, and `/v1/v1/messages` would 404 in a way that
reads as a broken image rather than a bad URL.

Two more measured, each otherwise a silent failure at the first turn:
`Authorization: Bearer` is accepted (so ANTHROPIC_AUTH_TOKEN is the right
injection channel), and a `claude-*` model id is ACCEPTED AND ANSWERED — Kimi
maps it onto `kimi-for-coding` exactly as z.ai does, so no ANTHROPIC_MODEL
override is needed.

Claude Code rather than Moonshot's own `kimi` CLI, deliberately. The mission
harness is Claude-Code-shaped throughout: `--agents` JSON roles, the verifier's
tool allowlist, the `Stop` hook behind the completion gate, the per-subagent
transcripts counted as delegation evidence. `kimi` has none of those flags — its
equivalents are TOML files and markdown agent dirs — so using it would mean a
second executor with its own untested failure modes.

TWO STALE MAPS, caught by the rootfs harness refusing to bless the image: both
`fc-build-rootfs.sh` and the node's `required_cli` expected backend `kimi` to
contain Moonshot's `kimi` binary. That assumption predates the measurement, and
it failed a rootfs that was correct. Both now say `claude` for glm and kimi
alike — the binary is the same in all three images; only the endpoint differs.

Egress for `kimi` is `api.kimi.com` alone: not moonshot.ai (wrong namespace),
not z.ai, not Anthropic. Asserted both ways, like the other two.

The image and rootfs are built on tank and the rootfs passes all four checks
(boots, git, writable /mission, `claude --version`). 534 tests, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-06 17:52:45 -07:00
Omar SobhandClaude Opus 5 f7f3dfe495 feat(fleet): GLM as a real microVM backend, and per-role models for claws
Three threads, all of which end at the same place: a mission whose verifier does
not share a model with the coder it reviews.

**GLM has a credential contract now.** `microvm_credential_for` returned one env
var name, which quietly assumed every provider reads its secret from the same
place Anthropic does. It returns a `Credential { source, target }` instead —
z.ai's key lives in the server's `ZAI_API_KEY` and Claude Code reads it as
`ANTHROPIC_AUTH_TOKEN`, and collapsing those two names is what forces a guess at
the other end. A wrong guess here sends one provider's credential to another
provider's endpoint.

`images/agent-glm` is the same CLI at the same pinned version as `agent-claude`
with `ANTHROPIC_BASE_URL` baked in. The split is deliberate: the ENDPOINT is a
property of the image, the CREDENTIAL is a property of the turn. That makes the
dangerous mix-up unrepresentable — a GLM VM cannot be handed an Anthropic
subscription token, and a claude VM cannot be pointed at z.ai. Asserted both
ways, because "the GLM VM must not carry CLAUDE_CODE_OAUTH_TOKEN" is the
property that costs a credential if it ever stops holding.

Kimi stays refused. `KIMI_API_KEY` is set and Moonshot serves an
Anthropic-compatible API, but I have not verified its base URL against the
running service, and this function is precisely where guessing a URL is
expensive. It becomes an arm the day someone measures it.

`api.z.ai` joins the node's default egress allow-list. A default that cannot
run the images we ship is a trap rather than a policy — the alternative is an
operator discovering it as a hung agent with no model access.

**Per-role models for claws** (migration 0071). `template_roles` had no model
column, so `mint_team_from_template` bound every role of every mission team to
one literal — a template whose whole point is an independent reviewer minted a
reviewer sharing a model with the coder. A role may now name its own; roles that
say nothing still take the mint's default, so every template written before this
behaves exactly as it did. The literal is now that default rather than a
hardcode.

**A harness scenario for the roster flow.** `verify-mission-delivery.sh roster`
runs the whole Slice 5 loop — planner proposes, human approves, mission runs —
and asserts the roster LANDED on the mission row rather than trusting the API's
answer. That distinction is not theoretical: the first live approval returned an
error while leaving the proposal marked approved.

Built and proven on tank ahead of the deploy: `clawmates/agent-glm:dev` reports
`2.1.223` and `BASE=https://api.z.ai/api/anthropic`, and
`fc-build-rootfs.sh … glm 8G` boots a VM from it that has git, can write
/mission, and answers `claude --version`.

533 tests pass, clippy clean. Migration 0071.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-06 17:14:53 -07:00
Omar SobhandClaude Opus 5 75d09241fb fix(missions): the first real approval found two bugs the tests could not
Deploying Slice 5 and approving one roster in production broke it twice, in ways
528 green tests had nothing to say about.

**1. `jsonb_set` refuses a scalar.** A mission created through the API without a
`config` stores jsonb `null` — a scalar — and `jsonb_set` fails on it with
"cannot set path in scalar". The guard was `coalesce(config, '{}')`, which
protects against SQL NULL; this is a perfectly good JSON null of the wrong shape,
and coalesce passes it straight through. Every test wrote `'{}'::jsonb` because
that is what a test author types. Production types nothing at all.

**2. The approval was not atomic, and failing halfway is permanent.** The claim
and the mission write were two statements, claim first, so when the write failed
the proposal stood `approved` with nothing applied — and the partial unique index
then makes that state unrecoverable: no other proposal for that mission can ever
be approved. The mission ran solo with `team_engine` still NULL while its
proposal said otherwise.

`approve_and_apply` is now one transaction: claim, write, commit or roll back.
The type guard is `CASE WHEN jsonb_typeof(config) = 'object' THEN config ELSE
'{}'::jsonb END`, which answers the question that was actually being asked.

Both regressions are tested in the shape production had, and both NEGATIVE
CONTROLS were run rather than assumed:

  - restore `coalesce` → `a_roster_applies_to_a_mission_whose_config_is_json_null`
    FAILS with Postgres's own "cannot set path in scalar", the exact production
    error.
  - commit instead of roll back on a failed apply →
    `a_failed_apply_leaves_the_proposal_undecided` FAILS with the proposal stuck
    `approved`.

Worth stating plainly: the API returned 500 for that approval, so this was not
silent to the caller — but the row it left behind claimed the mission had a
roster it never received, and the mission then ran and delivered, which is the
shape that gets believed.

530 tests pass, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-06 16:41:22 -07:00
Omar SobhandClaude Opus 5 aa470091aa fix(missions): a bootable rootfs is not a runnable one
Found by looking at what the fleet actually reports, not by reasoning about it:
tank's `capabilities.rootfs` is `["agent-terminal", "claude", "default"]`. Slice 5
offered that list to the planner as the menu of backends and validated proposals
against it — so a roster naming `agent-terminal` would have been proposed,
validated, approved and launched, and then failed at the agent turn, because
`microvm_credential_for` has no contract for it and refuses rather than forward
an Anthropic subscription token to an unknown endpoint.

Refusing at boot is correct and is exactly the wrong PLACE: it is three steps and
one human approval after the point where the answer was already knowable. The
menu is now the intersection of "a node can boot it" and "a mission agent can
authenticate in it", which is what the question meant all along.

`backend_can_run_a_mission` derives from the credential contract rather than
restating it, so a backend gaining one (GLM and Kimi, when B4.6's base-URL
contract is settled) becomes proposable in the same commit that makes it
runnable — instead of in a second list someone has to remember.

528 tests pass, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-06 16:27:25 -07:00
Omar SobhandClaude Opus 5 1797669296 feat(missions): Slice 5 — let a model size the mission's team
`routes/planner.rs` has had Opus proposing rosters since the Master Planner
shipped, and none of it ever reached a mission: the proposal lived in React state
and died with the tab. A mission's shape came from a team template instead —
fixed roles, and every claw minted `claude-sonnet-5` from a literal in
`mint_team_from_template`. That literal is why no mission has ever run more than
one provider.

A roster is `(topology_kind, [(role, backend)])`, which is exactly what the
composed executor already consumes: `Roster::graph` builds a `TopologyGraph` with
the backend in `attrs`, and `MicroVmTurnExecutor` reads `attrs["backend"]` per
node. So a verifier on another provider's rootfs stops being a bolt-on and
becomes a graph node — the correlated-failure break the independent judge exists
for, one layer down.

Three verbs, and the split is the point. **suggest** asks the model and persists
the answer, changing nothing. **decide** approves (writes `config.roster` and
switches the mission to the composed engine) or rejects. A proposal is never
applied on arrival: a model sizing a team is a suggestion about how many VMs to
boot, and this codebase treats model output that costs money as evidence for a
decision, not the decision.

Fail-closed at every seam, because each of these otherwise surfaces much later
and much more expensively:

  - a backend no ONLINE node can boot is refused when PROPOSED, naming the ones
    the fleet actually has. Placement would refuse it too — at launch, after the
    roster was approved and someone believed the mission would run. The model is
    handed that same list in its prompt, so the usual case never arises.
  - an invented `topology_kind` is refused, not defaulted. `parse_topology_kind`
    defaults to hub-spoke, which is right for a template we wrote and wrong for a
    string a model just produced: running a `pipeline` proposal as a hub-and-spoke
    changes what every node sees and nothing would say so.
  - the roster is validated BEFORE it is stored, so a stored proposal is always
    one that could be approved; and again at approval, against the fleet as it is
    then — a node can go offline in between.
  - `MAX_MEMBERS = 6`. Each member is a whole VM, not a subagent, and a model
    asked to size a team proposes twelve happily.

Two properties live in SQL rather than in the handler: at most one approved
roster per mission (partial unique index — two approved rosters are two answers
to "what shape is this mission", and the executor reads one field), and
decide-once (`WHERE status = 'proposed'`, so a double-clicked approve claims
nothing the second time). Both tested against a real database, including that the
second approval is refused by Postgres rather than merely losing a race.

NEGATIVE CONTROL, run rather than assumed: with the roster preference removed
from `composed_graph`, `an_approved_roster_outranks_the_template` FAILS — 3 nodes
from the template instead of the roster's 2. A stored roster that is silently
ignored at launch is precisely the shape this project keeps paying for.

Not closed: per-role models for CLAWS. `template_roles` has no model column, so a
ZeroClaw team still mints one model for every role. The literal is now a named
constant that says so and points at the roster path, rather than sitting inline
where nobody reads it.

527 tests pass, clippy clean. Migration 0070. Not yet exercised against the
deployed stack — the route has never been called with a live model.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-06 16:25:34 -07:00
Omar SobhandClaude Opus 5 6991e21f94 feat(missions): the completion gate, moved into the agent's own loop
Every check this platform makes on a phase runs AFTER the agent has stopped: the
evaluator judges `done_when`, capture notices a coding phase delivered nothing,
and either verdict costs a whole new VM — a fresh boot, a fresh inject, and an
agent starting over with none of the context that got it that far. Meanwhile the
documented failure mode of a long-running agent is that it stops too early.

MEASURED FIRST, because the plan's chosen seam does not exist here. Probing every
hook name under `claude -p` (2.1.222, hermetic `--settings` file): `SessionStart`,
`UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `SubagentStop` and `Stop` fire;
`TaskCreated`, `TaskCompleted`, `TeammateIdle`, `SessionEnd`, `Notification` and
`PreCompact` do not. The agent-teams hooks Slice 3 deferred are inert on our path
BY CONSTRUCTION — no team forms in print mode at all — so `done_when` could never
have been wired through `TaskCompleted` exit 2. `Stop` is the seam.

`vm_stop_gate` generates a POSIX `sh` hook installed via `--settings`, under
`/root/gate` and never under `/mission/repo` (anything there is collected and
arrives in the user's delivered patch). It refuses a stop when:

  - the phase must deliver and the repository is untouched — asked as TWO
    questions, since an agent that committed leaves a clean tree and an agent
    that did not leaves HEAD alone; only both together mean nothing happened;
  - `config.done_when_check` — a command the phase author wrote — exits nonzero,
    in which case its OUTPUT is the feedback, not just "the check failed".

Deliberately mechanical. NOT the `done_when` verdict: that is an LLM judgement
made host-side by a different provider on purpose, and re-running it inside the
VM would put the agent's own environment in charge of grading the agent — the
correlated failure the independent judge exists to break.

THE CAP IS LOAD-BEARING. Without a ceiling a stuck agent is blocked, retries, is
blocked again, and burns the hour-long turn budget instead of failing visibly.
After 3 blocks the gate lets it stop, records that it gave up, and leaves the
verdict to the existing post-hoc path, which is unchanged.

PROVEN AGAINST A LIVE AGENT with the REAL generated artifacts, not a paraphrase:

  - a read-only task → blocked 3 times with our exact message, released at
    exactly the cap, and the agent took the escape hatch the message offers
    ("if the task genuinely requires no code change, say so explicitly") rather
    than touching a file to satisfy the gate. It did not Goodhart it.
  - a task that needs an edit → `blocks: 0`, log says `pass`. No false positives.

Two things that could fail silently, both closed. `--settings` is PROBED in the
image before use (`claude --help | grep`), because an unknown option is a hard
CLI error that would turn every gated phase into a failed one; a build without
it degrades to ungated and says so, since losing a check is better than losing
the work. And `stop_blocks` is reported out of the guest — `None` for no gate,
`0` for got-it-right-first-time — so a gate that never fires is distinguishable
from one that was never installed.

`require_changes` does NOT apply per node on a composed run: a graph's verifier
node is SUPPOSED to leave the tree alone, and a per-node gate would refuse its
stop three times for doing its job. `StopGate::per_node` drops it and keeps the
declared check. The phase-level rule still runs post-hoc against what the last
node collected.

An ungated phase's command is byte-identical to before, asserted by test — most
phases are gated, so the ungated path is the one nobody would notice breaking.

517 tests pass, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-06 15:03:47 -07:00
Omar SobhandClaude Opus 5 1d554396f4 fix(delivery): four failures from the #55 trace — auth, prompts, truncation, retry
All four were surfaced while tracing #55 and left open. Each one on its own is
small; together they are why a two-line git rejection took hours to read.

**1. `with_ambient_auth` failed open.** It matched one literal prefix,
`https://git.redclaw.dev/`, and returned the URL unchanged for everything else
with no log line. An `http://` remote, an explicit port, a different case in the
host, an ssh remote, a URL that already carried userinfo — all came back
unauthenticated and looked identical to success. It now returns `Authed`, which
carries the URL AND why no credential reached it, and recognises the forge in
every shape a remote can be written (host parsed with userinfo stripped BEFORE
the port, or `oauth2:token@host` reports its username as the host — the first
version of this function did exactly that and failed its own test).

**2. Nothing set `GIT_TERMINAL_PROMPT=0`.** So a credential-less URL did not
fail — git opened `/dev/tty`, and in a server container that surfaces as
`No such device or address`, several layers from the missing token. Now set on
every git invocation that can reach the network. And `push_url_for` refuses
outright when the URL is on OUR forge and unauthenticated: that push cannot
succeed, and letting it proceed only buys a symptom that looks like something
else.

**3. The truncation fix went to the wrong path.** e31688b clamped the caller,
but a rejected push comes back as `Ok(Publish { error })` — the string was
already cut to 300 head chars inside `git()`, so the reject reason had been
dropped before the both-ends clamp ever saw it. Clamped where the output is
produced, and redacted there too.

**4. #55: a mission that re-clones can never push.** The branch name is
deterministic per (mission, phase, iteration), so a checkout rebuilt after a
retry, a container teardown or disk loss produces divergent history against its
own branch, and git rejects it — leaving the work on a local branch in a
directory the sweeper deletes. Reachable in normal operation, not just by
deleting a checkout by hand.

The escape is a NEW ref, not `--force`: forcing would overwrite whatever the
earlier attempt pushed, which may be the only copy of that work, to make this
attempt look tidy. The retry lands on `<branch>-<sha8>` — deterministic,
self-describing in a branch list, and collision-free since divergent history is
by definition a different sha. "Never force" stays a rule.

NEGATIVE CONTROL, run rather than assumed: with the rescue arm disabled,
`diverged_history_lands_on_a_new_branch_instead_of_being_lost` FAILS with git's
real `! [rejected] ... (fetch first)` — which also demonstrates fix 3, since that
whole message now survives to the assertion. The test asserts the earlier
attempt's ref is byte-identical afterwards.

Also measured, not read off docs: which hooks fire under `claude -p` (2.1.222,
via `--settings`). SessionStart, UserPromptSubmit, PreToolUse, PostToolUse,
SubagentStop and Stop fire; TaskCreated, TaskCompleted, TeammateIdle, SessionEnd,
Notification and PreCompact do not. So the agent-teams hooks Slice 3 deferred are
inert on our path by construction, and `Stop` is the seam that could move
`done_when` into the agent's own loop.

507 tests pass, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-06 13:15:15 -07:00
Omar SobhandClaude Opus 5 12147a1e01 feat(missions): Slice 4 — the two engines composed, with the file handoff proven
`team_engine='composed'` (the third name migration 0069 anticipated) runs a
mission as a durable ZeroClaw graph whose every node is a whole
Claude-Code-in-a-microVM session. Engine Z owns checkpoint/resume, cancellation
and per-node heterogeneity; Engine C owns shared context and cheap fan-out;
neither has the other's asset, which is why this is a composition and not a
compromise.

`MicroVmTurnExecutor` implements the existing `TurnExecutor`, so it inherits the
planners, the checkpoint, the stale-run recovery, `close_finished_phases`, the
evaluator, capture and delivery unchanged — the same trick `SubTopologyExecutor`
already plays with a heavy `run_turn`. Producer side emits ONE `queued` row
carrying the real graph and lets the worker claim it: the durability IS being
worker-driven, and the solo path's `tokio::spawn` has none of it. Still exactly
one `topology_runs` row per unit of work and one completion path — `finish()` is
now that one place, shared by every tier.

THE TRAP, solved and proven. A VM is inject → run → collect → destroy, so a
per-node VM with text-only handoff silently loses every file an earlier node
wrote: node 2 boots from the original checkout, sees nothing, and still reports
success. The mission's host checkout is the medium — every node injects from it
and collects back over it — and two properties make that safe rather than lucky:
`execute_resumable` is strictly sequential, so two VMs never write one directory;
and the vm id is deterministic per (phase, iteration, step), so a duplicate is
refused by the node ("vm already exists") instead of becoming a second writer.

NEGATIVE CONTROL, run rather than assumed: with `repo` swapped for a private
per-node workspace, `a_later_node_sees_an_earlier_nodes_files` FAILS with
`saw:[]`; restored, it passes. The `PhaseVm` seam exists for exactly this — it
models inject/collect through the real `mission_fs` tar path in milliseconds.

Two durability traps this tier walks into, both closed:

  - `requeue_stale` fires at 180s on `updated_at`, and one node here can run for
    an hour. `SubTopologyExecutor` keeps its parent alive from each leaf step;
    there is nothing between the start and end of a VM turn, so the turn holds a
    ticker that touches `updated_at` every 30s and aborts on drop. Without it a
    healthy composed run is requeued mid-node and boots a second VM.
  - the 15-minute stuck-run reaper asks "any step records since it was CREATED?",
    which describes a healthy composed run as readily as a wedged one. Hence
    `REAPABLE_TIERS` — worker-driven minus this tier. Reaping it would be #54 in
    a different costume.

`on_launch` mints no team for a microVM mission, deliberately: claws in
containers are what a VM mission does not use. So `mission_orchestrator::
composed_graph` builds the shape from the team template directly — nodes, roles
and pattern, zero claws provisioned. Per-node `attrs["backend"]` and
`attrs["node_id"]` override the mission's, which is what makes a validator node
on another provider's image a first-class graph node; a malformed `node_id`
fails the node rather than quietly running it where the graph did not ask.

Refusals are recorded as a failed run, not returned as an error: `launch_phase`
is swept every ten seconds, so a returned error is a phase that retries forever
while the log repeats itself.

501 tests pass, clippy clean. NOT yet proven end to end: no composed mission has
run on the fleet, so the resume-after-a-killed-worker leg is argued from the DB
test and the step-numbering test, not from a real two-node run.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-06 13:00:44 -07:00
Omar Sobh e31688bac5 fix(delivery): keep the TAIL of a push error — git prints its reason last
A failed push recorded two identical auth lines, a URL, and a branch name cut off
mid-word. The reject reason was on the next line and the 500-char head clamp ate
it, so the artifact preserved the noise and dropped the answer. That is what left
#55 unresolvable: the evidence needed to distinguish "no credentials" from
"non-fast-forward" had been truncated away.

`evaluator_tools::clamp_output` already existed for exactly this — head AND tail
with a byte count of what it dropped, on char boundaries so multi-byte output
cannot panic. Reused rather than reinvented.

Investigation notes recorded on #55. Two hypotheses were disproved by measurement:
push credentials are rebuilt per push from GITEA_TOKEN + repos.clone_url and never
live on disk (the clone-time scrub guarantees it, and every checkout on the host —
including ones that pushed — has an identical credential-free origin), and neither
of the two ways `with_ambient_auth` can silently return an unauthenticated URL
applies here: the clone_url matches its required prefix and the token is non-empty
in a container that predates the failure.

489 tests pass, clippy clean.
2026-08-06 11:59:57 -07:00
Omar Sobh d49acaed5e fix(missions): exclude build output from COLLECT too, not just inject
The other half of the same bug. The previous commit filtered `mission_fs::pack_dir`
(the inject side) and left the guest's `op_get` tarring everything, so the re-run
that proved the #54 fix — it survived 480s where it used to die at 210 — still lost
its work to `vm_collect ... node timed out`. Two modules written, four subagents
used, nothing delivered.

`op_get` now takes an `exclude` list, sent by the host from
`mission_fs::transport_excludes()` — the same list `mission_delivery` uses for the
diff. Policy in one place, applied at both ends of the wire. Matched on directory
NAME at any depth, so a workspace's per-crate `target/` dirs are all covered, with
a test that plants a nested one and asserts it does not come along.

Also proven by that run: the worker no longer kills a live microVM run. It ran 480
seconds straight through the 180s requeue window and the 210s mark where mission
019fd43e died, untouched. And `subagents: 4` — the team addendum did drive real
fan-out this time, which is the first evidence the Slice 3 switch does anything.

483 tests pass, clippy clean. Still to prove: a >3-minute mission that actually
DELIVERS. The collect fix is tested in isolation but has not yet carried a real
mission's work back, and the guest agent needs rebuilding into the rootfs before it
can.
2026-08-06 09:18:53 -07:00
Omar Sobh 4efcde9d4f fix(missions): #54 — the worker was killing live microVM runs at 180 seconds
My hypothesis in #54 was WRONG, and it was wrong because I built it on a bad
measurement: `grep -c 'microvm phase'` returned 0, so I concluded the completion
log never printed and blamed the 15-minute reaper. The line was there all along, at
14:17:45. The real cause is worse.

`requeue_stale` has NO TIER FILTER. A microvm run's `updated_at` is written once at
insert and never again — it is driven by a `tokio::spawn` that owns it start to
finish, and nothing in `microvm_executor` writes `topology_runs`. So at 180s the
sweeper declared a perfectly healthy run stale and flipped it to `queued`;
`claim_next_queued` (no tier filter either) handed it to the worker; `run_job`
tried to parse the microvm graph placeholder, which `TopologyGraph` cannot
deserialize; and it failed the run with "missing or invalid graph".

Mission 019fd43e: run created 14:11:16, mission failed ~14:14:46. 210 seconds — the
180s window plus a tick. The agent went on working and finished at 14:17:45 with
three modules written, by which time the phase was already dead and the VM was
orphaned. A firecracker process was still alive 1h37m later.

THE UNCOMFORTABLE PART: every microVM mission that appeared to work this session
did so only by finishing inside three minutes. The 90-second ones dodged this. The
harness scenario dodges it. Nothing about that was visible.

`WORKER_DRIVEN_TIERS` (team, company, org, swarm, compare) is now the allowlist for
all three sweep paths — claim, requeue, reap. An allowlist rather than a denylist so
the next self-driven tier is safe by default instead of exposed until someone
remembers the file. `tier='session'` had exactly the same exposure and is covered
too. A unit test asserts microvm and session are NOT in it, next to the code that
inserts them.

Two more fixes from the same wreckage:

  - `destroy` reported `killed: pgid.is_some()` — true whenever there was a pgid to
    signal, whether or not anything died. It now sends the signal, polls /proc for
    the group leader, retries, and reports what it OBSERVED; `signalled` keeps the
    old meaning so "nothing to kill" is distinguishable from "it would not die".
  - the run-status update is now guarded with `AND status <> 'cancelled'`. An
    operator cancelling is a decision; this task reporting an outcome minutes later
    is an observation, and it must not overwrite one with the other.

And the root cause of the collect timeout itself: `mission_fs::pack_dir` shipped
`target/` in both directions. `mission_delivery` has excluded build output from the
DIFF since day one; the TRANSPORT never knew. The host checkout was 9.4 MB of which
8.9 MB was `target/`, tarred and base64'd over vsock each way. `EXCLUDED_PATHS` is
now one list shared by both layers, matched on directory name at any depth so a
workspace's per-crate `target/` dirs are all covered.

483 tests pass, clippy clean.
2026-08-06 09:01:04 -07:00
Omar Sobh 0d25a94a84 fix(missions): agent teams do not form in print mode — say so where it is set
MEASURED, against the CLI in our own image (2.1.223): with
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 and an explicit request to "spawn two
teammates", `claude -p` did the work with two SUBAGENTS, wrote both files, and
created no ~/.claude/teams/ directory at all. The docs allow for it — "Claude may
sometimes use subagents instead of creating a team" — and headless appears to be
always: the whole feature is described around an interactive agent panel, which a
print-mode session does not have.

So Slice 3's switch, as written yesterday, set a flag with no mechanism behind it.
The first team mission caught it, because the probe was built to look for teammates
rather than to assume them.

Corrected rather than removed:
  - `team_env` documents the measurement at the point the flag is set, so the next
    reader does not have to rediscover it. The flag stays: harmless, and free if a
    later version supports teams non-interactively.
  - the prompt addendum now asks for parallel DELEGATION rather than naming
    teammates, which is what print mode can actually deliver — and it keeps the two
    anti-patterns worth stating (own different files; do not split one change into
    stages).
  - the "no teammates" warning was blaming the flag and the config path. It now
    judges on the SUBAGENT count, which is the mechanism in play, and a zero
    teammate count is documented as expected rather than as a fault.

What the switch buys today is real but smaller than the plan assumed: it changes
the prompt so the lead parallelises across files instead of working through them
alone. Whether that beats solo on our own missions is still unmeasured, and the
plan's prediction — that it will not be faster — stands untested.

482 tests pass, clippy clean.

UNEXPLAINED, filed as #54: that team run's `topology_runs` row is `failed` while
the log line that sits immediately before the UPDATE never printed — zero matches
for 'microvm phase' in the container's whole log. The prime suspect is
`topology_worker`'s stuck-run reaper, which fails runs that are `running` with no
step records and does not filter by tier; a microvm run has no step records by
design. If that is it, any sufficiently long VM phase is failed out from under
itself. The system failed safely here — the empty-delivery guard caught that
nothing was produced, and nothing false was reported — but the cause is not known
and it is not being written up as if it were.
2026-08-06 07:22:05 -07:00
Omar Sobh cb48f7ff3b feat(missions): Slice 3 — agent teams behind a per-mission switch, solo by default
`missions.team_engine` (0069): NULL = solo, `'claude_code'` = Claude Code agent
teams inside the mission's VM. Solo stays the default deliberately — Anthropic
measure multi-agent at 3-10x the tokens with wall-clock often LONGER, since the
benefit is thoroughness rather than speed — so a mission that said nothing does not
get a team.

In-process teammates live in the lead's process, so ONE VM hosts the whole team.
That is why this is a prompt-and-env change rather than an orchestration one: no
N-VM fan-out, no placement per teammate, no new completion path.

The lead decides its own team size and there is no flag that limits it, so the cap
(4) is stated in the prompt. The addendum also carries the two anti-patterns from
Anthropic's guidance, because they are exactly the shapes our pipeline templates
have: teammates must own DIFFERENT FILES (two in one file overwrite each other),
and one change must not be split into stages across teammates (a handoff loses
context at every step). And: wait for your teammates — a summary written before
they report is the lead's own guess.

A solo mission's prompt and env are byte-identical to before this change. That is
enforced by test, not by intention: the comparison between solo and team is only
meaningful if the solo side did not also move.

Evidence, because a team mission that forms no team is silently just a solo run
that looked fine and spent fewer tokens: a second probe counts members in
`~/.claude/teams/*/config.json` (minus the lead), reported separately from the
subagent count, and a team mission with zero teammates logs loudly with the two
likely causes. The teammate path is DOCUMENTED BUT NOT YET VERIFIED in our image,
unlike the subagent transcript path which was measured — so a zero there means "no
evidence found", and the first real team mission is what turns it into a fact.
`Option<u32>`: None means no team was asked for or the probe could not run.

Hooks (`TaskCompleted` / `TeammateIdle` exit 2, which would move `done_when` from
post-hoc into the agent's own loop) are the highest-value part of this slice and
are deliberately NOT here — they deserve their own pass rather than a rushed tail.

482 tests pass, clippy clean.
2026-08-06 07:08:15 -07:00
Omar Sobh c840688adb feat(missions): choose the independent validator per mission (#53)
`CLAWMATES_VALIDATOR_MODEL` is deployment-wide, so proving Slice 2 put a second
provider on the critical path of EVERY phase verdict. `cross_provider_judge`
deliberately does not fall back when the independent judge fails — a verdict
quietly produced by a same-family model would claim a property it does not have —
so a z.ai outage makes phases unmeetable rather than merely unverified. That is a
per-mission trade, not a per-deployment one.

`missions.validator_model` (0068), settable at create, with three distinct states
because an empty string and NULL mean opposite things in a nullable text column:

  NULL          use the deployment default
  ''            explicitly NO independent validator — judge with the house model.
                The default must not quietly reinstate independence a mission was
                told to skip.
  'glm:glm-4.7' this spec, subject to the same three refusals as before:
                same-family rejected, unregistered provider rejected, and a failed
                independent judge does not fall back.

Whitespace counts as empty: a column hand-set to " " meant to say nothing.

478 tests pass, clippy clean. Behaviour is unchanged for existing missions — they
have NULL and so keep following the deployment default.
2026-08-05 23:07:38 -07:00
Omar Sobh 9aed20b6d0 fix(missions): capture a failed phase's work; harness gains a microvm scenario (#51)
A REGRESSION I INTRODUCED ONE COMMIT AGO. `capture_finished_coding_phases`
selects on `mp.status = 'completed'`, so the moment an unmet phase correctly began
reporting `failed`, its diff stopped being captured, committed or pushed — the work
was silently discarded. Found by the new harness scenario, whose phase legitimately
missed its condition and then had no artifact at all.

What was produced, and whether the goal was met, are different facts. The artifact
records the first; `mp.status` records the second. Capture now covers terminal
phases (`completed`, `failed`), so a phase that did real work and missed its goal
still delivers a reviewable diff — which is exactly what the next pass needs.

`scripts/verify-mission-delivery.sh microvm` — the regression net this session was
missing. Everything the microVM track proved by hand was guarded by nothing:

  - THE KERNEL LINE is the assertion that cannot pass by accident. Every other
    check would also pass if the phase had quietly run in a container on the
    gateway; only the kernel says WHERE it ran. Compared against the real gateway
    and node kernels read at start-up rather than pinned to a version, so
    upgrading vmlinux does not manufacture a failure.
  - subagent count > 0, from the server's own count of Claude Code's per-subagent
    transcripts. Before `Agent` was in the allowlist this was structurally
    impossible and nothing said so. A probe that could not run reports "?" and
    FAILS the check rather than reading as zero.
  - the verdict's judge and whether it was independent.
  - negative control, observed passing: a mission whose backend no node can run is
    refused at launch and stays draft. Without it the positive scenario would pass
    just as well against a scheduler that ignored `backend` entirely — which is
    what it did until the first real microvm mission landed on a node with no such
    rootfs.

Also fixed in the harness: `api` now sends the JSON body on STDIN (`curl -d @-`)
instead of interpolating it into a single-quoted argument inside a double-quoted
ssh command. A task description containing "the crate's test suite" ended the
quoting and killed the remote shell; two attempts to escape it were themselves
wrong, because the backslashes must survive bash AND sed AND sh. Removing the
interpolation removes the class, and the next author does not need to know that
apostrophes were forbidden.

475 tests pass, clippy clean.
2026-08-05 22:35:46 -07:00
Omar Sobh bb807c2f3a fix(missions): an unmet goal condition is no longer reported as success
Found by the Goodhart test for the independent judge, which is exactly what it was
built to find.

The test: a phase whose `done_when` demanded a passing suite, and a task that
deliberately left a failing test. glm-4.7 judged it, ran `cargo test` itself, saw
`parity_is_wrong_on_purpose ... FAILED` (exit 101), and returned met=false quoting
the assertion — while the agent's own summary said "All three steps are implemented
exactly as specified and independently verified". The verdict and the agent's
account diverged, which is the whole point of an independent judge.

And then the mission closed `completed`.

`if verdict.met || last_pass` marked BOTH outcomes completed, so a phase that ran
out of passes without ever meeting its condition reported success — and through
`close_finished_missions`, so did the mission. The verdict said met=false in a
column nobody reads before believing a green status. Anything consuming mission
status rather than digging into the verdict saw a goal that was never reached as a
goal achieved. Exhausted-and-unmet is now `failed`, and the log names the judge and
whether it was independent.

This changes observable behaviour: missions that would previously have finished
green with an unmet condition now finish failed. That is the correction, not a
regression — but it is worth knowing before the next scheduled run.

Also: `Verdict.independent` had no column. The field existed in the struct and in
the logs, so the audit question the mechanism exists to answer — was this checked
by something other than the model that wrote it? — could not be asked of the
database. Migration 0067 adds it, defaulting to false, which is the truth about
every row written before now.

Verified in production before the fix: glm-4.7, 4 checks all executed, the real
cargo failure quoted, met=false. 475 tests pass, clippy clean.

Note for whoever rebases: `sqlx::migrate!` embeds migrations at COMPILE time, so a
new migration needs cm-db rebuilt (`touch crates/cm-db/src/lib.rs`) or the
integration tests fail on a column that exists in the file and not in the binary.
2026-08-05 22:13:51 -07:00
Omar Sobh 8796fbbcbb feat(evaluator): Slice 2 — an independent judge, from a different provider, with the same teeth
Claude writes the code and Claude judges it. That is a correlated failure: the
model that talked itself into a shortcut is the one disposed to accept it, and it
is the structural cause of the "early victory" failure Anthropic documents and of
our own Goodhart incident.

`glm` and `kimi` are both already registered in production, so the fix needed no
new credential path.

THE UNLOCK: `judge_with_tools` took `&AnthropicProvider`, but `LlmProvider` is a
single method — `stream(ChatRequest)` — and the loop only ever used that. The
concrete type was incidental. Widening it to `&dyn LlmProvider` means a
cross-provider judge runs the SAME allow-listed command loop. Before, independence
and real verification were mutually exclusive: the tool loop existed only on the
subscription path and every other route "judged claims only", so choosing an
independent judge meant giving up the checks that make a verdict evidence. GLM is
registered in anthropic format, so tool calling reaches it unchanged.

`CLAWMATES_VALIDATOR_MODEL` (e.g. `glm:glm-4.7`) selects it. Three refusals, each
protecting the claim the field makes:
  - a spec in the implementer's own family is rejected, not used — `opus` judging
    `sonnet` is not independence, they share a lineage and most failure modes
  - a spec naming a provider this deployment never registered is rejected.
    `Runtime::resolve_provider` silently falls back to the DEFAULT provider when
    the registry has no such name, which would hand back Claude while the caller
    believed it had GLM. Detectable because the returned model keeps its `name:`
    prefix, so it is checked rather than trusted.
  - an independent judge that FAILS does not fall through to the house judge. A
    verdict quietly produced by a same-family model would claim a property it does
    not have. The pass stays unmet, says why, and the next sweep retries.

`Verdict.independent` records it, `#[serde(default)]` so verdicts stored before
this field read back as not independent — which is what they were. An unrecognised
model family resolves to "unknown", never to ours: guessing would report
independence nobody established.

474 tests pass, clippy clean. Not yet enabled in production — the env var is unset,
so behaviour is identical until it is set deliberately.
2026-08-05 21:53:21 -07:00
Omar Sobh 11b274edc6 chore(images): Claude Code 2.1.223, and make the verifier foreground
Reviewed the changelog rather than bumping on principle. 2.1.220 → 2.1.223 for one
reason that bears on how we use subagents:

  2.1.222 — "Fixed PreToolUse auto-allow hooks bypassing tool restrictions in
  background agent tasks."

Subagents run in the background by default since 2.1.198, and the `verifier`
role's entire guarantee is a TOOL restriction — no Edit, no Write. So on 2.1.220
the one property we rely on was the one that bug could undo. 2.1.221 also fixes
`--mcp-config` servers not connecting before the first turn in print mode, which
is the mode we run and will matter when the MCP door reaches a VM.

Two findings from the changelog that we already had at 2.1.220, both worth knowing:
  - 2.1.219: subagents can nest to depth 3 (was 1), so our roles can delegate
    further than assumed.
  - 2.1.212: a subagent inherits the parent's permission mode, which confirms the
    verifier's read-only property must come from `tools` and not from permissions.
    That is how it was written; now the reasoning is recorded next to it.

And a correctness fix that follows from the background default: the verifier is now
`background: false`. A background verifier lets the lead carry on and write its
report before the check has finished — the finding would arrive after the
conclusion it was supposed to inform.

Verified on tank: image reports 2.1.223, rootfs rebuilt, `--vm-selftest` all green
including a real agent turn on subscription auth, egress allow and deny both firing.
2026-08-05 21:37:49 -07:00
Omar Sobh 2dee941080 feat(missions): Slice 1 — a microVM agent can delegate, and we can see that it did
`microvm_executor` passed `--allowedTools Read Edit Write Bash`, which omits the
`Agent` tool, so Claude Code could not spawn a single subagent in any of our VMs.
The tool existed, the model knew how to use it, and the allowlist quietly removed
the ability. Nothing in any output said so.

Now: `Agent` in the allowlist, two roles supplied as `--agents` JSON, and a probe
that counts what actually ran.

Roles are JSON on the command line, not files, because `/mission/repo` is
collected and diffed — a role definition written into the checkout would arrive in
the delivered patch as if the agent had authored it.

Two roles only, and the choice is the research talking:
  - `verifier` — the one multi-agent pattern Anthropic endorses for coding work.
    It gets Read/Grep/Glob/Bash and deliberately NOT Edit or Write: an agent that
    can fix what it is checking will fix it and report success, and the report is
    then about a tree nobody reviewed. Its prompt demands the COMPLETE suite,
    which is the counter to the "early victory problem" — the same failure as our
    own Goodhart incident.
  - `explorer` — context protection, read-only.
Roles like "tester" or "committer" are absent on purpose: splitting sequential
phases of the same work is a named anti-pattern, and it is the shape our pipeline
templates already have.

THREE THINGS THE IMAGE CORRECTED, none of which review would have caught:

1. `CLAUDE_AGENT_SDK_DISABLE_BUILTIN_AGENTS=1` (in the plan) removes EVERY agent
   type, including the ones `--agents` defines. Measured: the lead reported "an
   empty available-agents list" after trying four role names and — to its credit —
   refused to fabricate a subagent result. Worse, the unit test asserting
   "builtins off is paired with our own roles" PASSED throughout, because the
   pairing holds in our code and not in the CLI. Dropped, and the test rewritten
   to assert only what a unit test can speak to.
2. `--forward-subagent-text` refuses to run without `--output-format=stream-json`,
   which would change how this module reads output. Dropped.
3. `--append-subagent-system-prompt` does not exist in 2.1.220 despite being
   documented. The anti-shortcut rule is inlined per role instead — better anyway,
   since a verifier and an explorer need different wording.

Evidence instead of assumption: Claude Code writes a per-subagent transcript at
`<session>/subagents/agent-*.jsonl`, so the guest is asked to count them before
collection (they live in /root, outside the collected tree). `VmOutcome.subagents`
is `Option<u32>` and the phase log prints it: `None`/"?" means the probe could not
run, which is a different fact from "delegated to nobody" and only one of those is
about the agent.

Verified in a container against the real CLI on tank before any of this shipped:
`FANOUT-OK`, a subagent transcript on disk, and zero errored Agent calls.

469 tests pass, clippy clean.
2026-08-05 21:21:43 -07:00
Omar Sobh 7696009b25 fix(fleet): name the BACKEND in a placement refusal, not just "microvm capability"
Observed on the negative control: a mission with backend='kimi' was correctly
refused, but the message read "no online node reports microvm capability" — and
both nodes do report it. What one lacked was the image. That first clause would
have sent an operator to reinstall firecracker on a node that already had it.

The refusal now names the backend, and the remedy still names both halves.
2026-08-05 17:31:23 -07:00
Omar Sobh d9f53a3f96 fix(fleet): placement requires the backend's rootfs image, not just KVM
The first real microVM mission was placed on morpheus because it reports
{"microvm": true}, while only tank had rootfs-claude.ext4. It failed by name
rather than booting the wrong image — but whether a mission ran came down to
which capable node was listed first, which is a coin flip dressed as scheduling.
`missions.backend` was invisible to the scheduler.

The node now enumerates the images on its disk and reports them as a `rootfs`
ARRAY. `microvm::available_backends` lives beside `rootfs_for`, its inverse,
because the two must agree on what a backend name means; split apart, one drifts
and the scheduler starts promising images the booter cannot find. It only
advertises names `rootfs_for` would accept, and reports an empty array rather than
omitting the key — set_capabilities REPLACES, so a deleted image stops being
advertised instead of leaving a stale claim.

`nodes::online_for_backend` requires microvm AND that the node's list contains the
mission's backend. A node on an older daemon has no `rootfs` key and matches
nothing: unknown is not permission, the same treatment every other capability
gets. `backend_key` maps the three spellings of "the default image" to the one
name the node advertises, and is tested — a mismatch there would reject every node
for an ordinary mission with no backend set.

The launch error now names both halves of the fix, since "no capable node" was
true but unhelpful when the node was capable and merely lacked the image.

Mission gains `backend` on the domain struct; it was a column the executor read
from the phase query while the struct that placement uses could not see it.

464 tests pass, clippy clean.
2026-08-05 17:22:12 -07:00
Omar Sobh 1cd81a8b2a fix(missions): place a microvm mission before returning from on_launch
Self-inflicted, one commit old, and found by running a real mission: the early
return I added for "a microvm mission materialises no team" sat ABOVE the microVM
placement block in the same function, so on_launch returned before ever choosing
a node. The mission then failed with the executor's own guard — "mission has no
target_node_id ... a microvm mission cannot run on the gateway, which has no
/dev/kvm" — which is the guard working exactly as designed, on a cause one layer
further up.

Placement now runs first. Worth noting the shape: adding an early return to a
long function silently skipped everything below it that the same runtime_kind
depends on.

461 tests pass, clippy clean.
2026-08-05 16:55:58 -07:00
Omar Sobh 521b8dea10 fix(missions): the third team gate, and a container a microvm mission never uses
on_launch demanded a team template too — "pick teams in the wizard" — so a
microvm mission still could not launch after the first two gates were exempted.
Three separate places required a claw graph for a path that runs one `claude -p`
inside a VM: routes::missions (draft→running), phase_runner::launch_phase (no
matching teams → stay pending), and here.

Returning before team materialisation rather than filtering its picks: claws that
never run are not a cheaper version of the same thing, they are a runtime binding
and a pairing code describing something nothing speaks to.

Also stops provisioning the per-mission ZeroClaw container for a microvm mission.
The first real run was observed starting one and leaving it holding a pairing code
and ~3 GB of image for the life of a mission that never contacts it.

461 tests pass, clippy clean.
2026-08-05 16:47:08 -07:00
Omar Sobh 0a9747091f fix(missions): a microvm mission needs no team, and two checks required one
Found by running one: the mission was created with runtime_kind='microvm' and
then refused to launch with a bare 400, because draft→running requires a
materializable team. Past that, `launch_phase` returns early when a phase has no
matching teams — so even with the launch allowed, the phase would have sat
`pending` forever while the log said only "no matching teams", and the executor
would never have been reached.

Neither check applies to this path: microvm_executor runs the agent CLI directly
in the VM, so there is no claw graph to materialise. Satisfying the checks by
attaching a team template would have provisioned claws that never run.

The repo checkout still happens — the VM needs the repository.

461 tests pass, clippy clean.
2026-08-05 16:39:48 -07:00
Omar SobhandClaude Opus 5 c9b7d8b6ca fix(missions): a microvm mission could not be created at all
`runtime_kind='microvm'` passes the DB CHECK, is honoured by placement, and now
has an executor — but `POST /api/missions` rejected the value with 400, so the
only interface that creates missions could not produce one. And `backend`, which
selects the per-CLI rootfs, was not in the create payload at all: it existed as a
column and as a parameter to `vm_create`, with nothing able to set it.

microvm needs no target_node_id at create time, unlike local_herdr: placement
resolves a KVM-capable node at launch and fails the launch when there is none, so
an explicit target is a request rather than a requirement.

461 tests pass, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-05 16:21:39 -07:00
Omar SobhandClaude Opus 5 4f07430e92 feat(missions): B4.5 — phase_runner runs a microvm mission in a VM
`runtime_kind='microvm'` placed a mission on a KVM-capable node and then nothing
executed it: config accepted without a reader, one of the four seams this project
keeps closing. This is the reader.

`microvm_executor` — inject → run → collect → destroy, the shape copy mode
already proved for containers with a VM boundary instead of a namespace one. The
checkout goes in as a tar, the work comes back as a tar over the SAME host path,
so `mission_delivery::capture_phase_diff_at` needs no change at all.

The agent is told NOT to push, unlike the container path's session prompt. Two
reasons: delivery is already host-side and diffs the collected tree against the
recorded clone point (covering committed, staged and unstaged work in one pass),
so pushing would add a second untested way for work to arrive; and pushing would
mean forge credentials inside the VM, when the point of collecting is that the
guest never holds them.

Exactly ONE topology_runs row (tier='microvm'), mirroring launch_direct_session:
close_finished_phases, evaluation, capture and delivery all key off those rows,
and a second completion path would be a second way for a phase to finish with one
of them untested. The row and the phase flip happen BEFORE any fallible VM work,
so a missing token or a node that lost its capability shows up as a failed run an
operator can see — not a phase that stays pending and retries every ten seconds.

Fail-closed points, each the reader for a guarantee built earlier:
  - credentials resolve BEFORE the VM boots, so a missing subscription token
    fails the phase instead of booting a VM whose agent sits unauthenticated
  - a VM reporting egress:false is REFUSED, which is what makes create's
    egress/egress_host/egress_guest fields more than decoration — a turn without
    egress does not fail, it hangs
  - the injected checkout is PROVEN present in the guest before an agent turn is
    spent on it; an inject that reports success while landing nothing would
    otherwise become an agent reporting an empty repository
  - work is collected even when the agent exits non-zero — a turn that failed
    partway still wrote files, and a retry needs to see them
  - a turn that ran but could not be collected is a FAILED phase, not a happy one
  - destroy runs on every exit path, or an 8 GB sparse rootfs leaks

Two integration gaps found while wiring, both of which would have produced a
mission that completed having delivered nothing:
  - `capture_finished_coding_phases` pulls work out of a CONTAINER before
    capturing. A microvm mission has none, so the docker connect would fail, the
    loop would `continue`, and capture would be skipped forever while the phase
    sat marked completed. Its work is already collected by the executor.
  - `launch_phase` provisioned a runtime container, copied the checkout into it
    and wrote a runtime binding + pairing code describing a runtime nothing uses;
    and the orchestrator's workspace pin — deliberately FATAL — would have failed
    a microVM launch on a container it was never going to use.

461 tests pass, clippy clean.

NOT YET PROVEN END TO END: no mission has run through this path. The pieces under
it are each verified on tank (image, credentials, egress, a real agent turn), but
this executor has only been compiled and unit-tested. Deploy + one real microvm
mission is the remaining step.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-05 15:57:43 -07:00