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]>
This commit is contained in:
Omar Sobh
2026-08-06 15:03:47 -07:00
co-authored by Claude Opus 5
parent 1d554396f4
commit 6991e21f94
6 changed files with 628 additions and 8 deletions
+12
View File
@@ -293,6 +293,16 @@ async fn run_composed(
.await
.map_err(|e| OrchestratorError::Executor(format!("load mission {mission_id}: {e}")))?;
// The phase's completion gate, read here rather than carried on the run row
// so an edited `done_when_check` takes effect on the next node instead of at
// the next mission.
let phase: (String, serde_json::Value) =
sqlx::query_as("SELECT kind, config FROM mission_phases WHERE id = $1")
.bind(phase_id)
.fetch_one(pool)
.await
.map_err(|e| OrchestratorError::Executor(format!("load phase {phase_id}: {e}")))?;
let exec = crate::microvm_turn_executor::for_fleet(
hub.clone(),
pool.clone(),
@@ -305,6 +315,8 @@ async fn run_composed(
target_node_id: mission.0,
backend: mission.1,
team_engine: mission.2,
gate: crate::vm_stop_gate::StopGate::for_phase(&phase.0, &phase.1)
.and_then(crate::vm_stop_gate::StopGate::per_node),
// Resume continues the step numbering; restarting it would re-use a
// finished node's vm id.
completed_steps: progress.completed as u32,