Files
clawmates/crates/cm-api/src/microvm_turn_executor.rs
T
Omar SobhandClaude Opus 5 9fc904a056
deploy / test (push) Failing after 1m36s
deploy / build (push) Skipped
feat(microvm): the tool gate's denials and inert marker reach the mission record
vm_tool_gate writes denied.jsonl for every call it refuses and an `inert`
marker each time it cannot parse its input and lets the call through. The
guest has written both since the gate existed; nothing read them out of a VM.
A denial, or a gate that had quietly stopped checking, left no trace — the
same shape the container tier closed with drain_inert on 09-14.

The executor probes both files (one exec, while /root still exists) into
VmOutcome.tool_gate; launch_microvm_phase records them on the mission as the
container tier's `gate.inert` (with the count) and `gate.denied` (one event
per refused call, the gate's own JSON as the detail). Absent gate is None,
not zero — "no gate" and "a gate that refused nothing" are different facts.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
2026-09-18 22:40:26 -05:00

722 lines
29 KiB
Rust

//! The two engines composed — Slice 4.
//!
//! Engine Z (the ZeroClaw graph in `cm_orchestrator`) owns durability and
//! heterogeneity: deterministic planners, per-step checkpoint/resume, a stale-run
//! sweep, cancellation, and a different model per node. Engine C (Claude Code in
//! a microVM) owns shared context, self-sizing and cheap fan-out. Neither has the
//! other's asset, which is why keeping both is a composition rather than a
//! compromise.
//!
//! This module is the join: a [`TurnExecutor`] whose "turn" is a whole
//! Claude-Code-in-a-VM session. Because `topology_worker` already dispatches by
//! tier, implementing the existing trait inherits the planners, checkpointing,
//! reaper, cancellation, `close_finished_phases`, evaluation, capture and
//! delivery unchanged. `recursive_exec::SubTopologyExecutor` is the precedent: a
//! `run_turn` may be arbitrarily heavy.
//!
//! # The file-handoff trap
//!
//! A VM is inject-tar → run → collect-tar → destroy. A graph of per-node VMs with
//! **text-only** handoff would silently lose every file an earlier node wrote:
//! node 2 would boot from the original checkout, see none of node 1's work, and
//! still report success — the exact silent-success shape this project keeps
//! paying for.
//!
//! The answer here is that the mission's **host checkout is the medium**. Every
//! node injects from `repo` and collects back over `repo`, so the tree carries
//! forward node to node and the last node's tree is what delivery diffs. Two
//! properties make that safe rather than lucky:
//!
//! - `execute_resumable` runs steps strictly **sequentially**, so two VMs are
//! never writing the same host directory at once;
//! - the vm id is deterministic per (phase, iteration, step), so a resumed step
//! whose VM is somehow still alive is refused by the node ("vm already exists")
//! instead of quietly producing a second writer.
//!
//! `a_later_node_sees_an_earlier_nodes_files` proves the handoff, and
//! `text_only_handoff_loses_the_earlier_nodes_work` is its negative control.
//!
//! # Keeping a long turn alive
//!
//! `requeue_stale` requeues a `running` job that has not touched `updated_at` in
//! 180 seconds, and one node here can run for an hour. `SubTopologyExecutor`
//! keeps its parent alive from each *leaf step*, which it has and this does not:
//! there is nothing between the start and end of a VM turn. So the turn holds a
//! ticker that touches `updated_at` every [`KEEPALIVE_SECS`] and is aborted on
//! drop. Without it a healthy composed run is requeued mid-node, claimed again,
//! and boots a second VM against the same checkout.
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use cm_domain::NodeId;
use cm_orchestrator::{OrchestratorError, TurnExecutor, TurnOutcome, TurnRequest};
use sqlx::PgPool;
use uuid::Uuid;
use crate::microvm_executor::{PhaseVm, VmPhase};
/// How often a running VM turn touches its run's `updated_at`.
///
/// Comfortably inside the 180s stale window, and cheap: one UPDATE per node per
/// half minute against a row nothing else is writing.
const KEEPALIVE_SECS: u64 = 30;
/// A [`TurnExecutor`] that runs each graph node as a full Claude-Code session
/// inside its own microVM, against the mission's shared host checkout.
pub struct MicroVmTurnExecutor<V: PhaseVm> {
vms: V,
pool: PgPool,
/// The durable outer run. Touched for keepalive; its status gates the turn.
run_id: Uuid,
mission_id: Uuid,
phase_id: Uuid,
iteration: i32,
/// The mission's host checkout — injected into every node's VM and collected
/// back over, which is how file work survives a node boundary.
repo: PathBuf,
/// Whether the mission has a repository. Carried so every graph node gets
/// the same workspace treatment as a solo phase — see `VmPhase::has_repo`.
has_repo: bool,
/// `missions.target_node_id`: the fleet node a mission was placed on. A node
/// may override it with `attrs["node_id"]`.
default_fleet_node: Option<Uuid>,
/// `missions.backend`: which rootfs image. A node may override it with
/// `attrs["backend"]`, which is what makes a graph heterogeneous — a
/// `validator` node on a different provider's image is then a first-class
/// graph node rather than a bolt-on.
default_backend: Option<String>,
/// `missions.team_engine`, passed through so a composed node can itself ask
/// for Claude Code fan-out inside its VM.
team_engine: Option<String>,
/// The phase's completion gate, enforced inside every node's VM.
gate: Option<crate::vm_stop_gate::StopGate>,
/// Which step is next. `execute_resumable` is sequential and gives the
/// executor no index, so the executor counts — and the count starts from the
/// checkpoint on resume, or two VMs would share an id across a restart.
step: std::sync::atomic::AtomicU32,
}
/// Everything a composed run needs that is not the graph itself.
pub struct ComposedRun {
pub run_id: Uuid,
pub mission_id: Uuid,
pub phase_id: Uuid,
pub iteration: i32,
pub repo: PathBuf,
pub has_repo: bool,
pub target_node_id: Option<Uuid>,
pub backend: Option<String>,
pub team_engine: Option<String>,
/// What must hold before a node's agent may stop. See [`crate::vm_stop_gate`].
pub gate: Option<crate::vm_stop_gate::StopGate>,
/// Steps already completed, from the durable checkpoint. Nonzero on resume.
pub completed_steps: u32,
}
impl<V: PhaseVm> MicroVmTurnExecutor<V> {
pub fn new(vms: V, pool: PgPool, r: ComposedRun) -> Self {
Self {
vms,
pool,
run_id: r.run_id,
mission_id: r.mission_id,
phase_id: r.phase_id,
iteration: r.iteration,
repo: r.repo,
has_repo: r.has_repo,
default_fleet_node: r.target_node_id,
default_backend: r.backend,
team_engine: r.team_engine,
gate: r.gate,
step: std::sync::atomic::AtomicU32::new(r.completed_steps),
}
}
/// Which fleet node this graph node runs on.
///
/// Fail-closed on a malformed override: placing a node on the mission's node
/// because its own `node_id` did not parse would run the work somewhere the
/// graph did not ask for and say nothing.
fn fleet_node(&self, req: &TurnRequest) -> Result<NodeId, OrchestratorError> {
let id = match req.attrs.get("node_id") {
Some(raw) => Uuid::parse_str(raw.trim()).map_err(|_| {
OrchestratorError::Executor(format!(
"node {} has an invalid node_id attr: {raw}",
req.node_id
))
})?,
None => self.default_fleet_node.ok_or_else(|| {
OrchestratorError::Executor(format!(
"node {} has no node_id attr and the mission has no \
target_node_id — a microVM node cannot run on the gateway, \
which has no /dev/kvm",
req.node_id
))
})?,
};
Ok(NodeId::from(id))
}
}
impl<V: PhaseVm> TurnExecutor for MicroVmTurnExecutor<V> {
async fn run_turn(&self, req: TurnRequest) -> Result<TurnOutcome, OrchestratorError> {
let fleet_node = self.fleet_node(&req)?;
let backend = req
.attrs
.get("backend")
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.or_else(|| self.default_backend.clone());
if !self.repo.is_dir() {
return Err(OrchestratorError::Executor(format!(
"mission has no checkout at {} — a composed node needs the \
repository, and it is also how the previous node's work reaches \
this one",
self.repo.display()
)));
}
let step = self
.step
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
// Held for the length of the VM turn: an hour of silence would otherwise
// look exactly like a dead worker to `requeue_stale`.
let _alive = Keepalive::spawn(self.pool.clone(), self.run_id);
let task = node_task_text(&req);
let outcome = self
.vms
.run(VmPhase {
// Every node of a composed graph streams to the same outer run,
// which is the one the operator is watching.
run_id: Some(self.run_id),
node_id: fleet_node,
mission_id: self.mission_id,
phase_id: self.phase_id,
iteration: self.iteration,
task: &task,
backend: backend.as_deref(),
repo: &self.repo,
has_repo: self.has_repo,
team_engine: self.team_engine.as_deref(),
// Each node is its own agent session, so each carries the
// phase's gate. Threaded from the run rather than rebuilt here:
// one source for what "done" means, whichever executor asks.
gate: self.gate.as_ref(),
step: Some(step),
// Same live drain as the solo path. A composed graph node can
// run for an hour too, and its files are the only account of
// what it did until the next node collects.
tap_sink: Some(crate::phase_runner::vm_tool_recorder(
&self.pool,
self.mission_id,
self.phase_id,
self.run_id,
)),
})
.await
.map_err(|e| {
OrchestratorError::Executor(format!("node {} in a microVM: {e}", req.node_id))
})?;
// Recorded BEFORE the failure branches below. A node that could not be
// collected, or whose gate capped, still touched files — and on this
// path those touches are the only account of what it did, since the
// work never reached a diff.
crate::phase_runner::record_vm_tools(
&self.pool,
self.mission_id,
self.phase_id,
self.run_id,
&outcome.tools,
// No turn agents supplied, so nothing is attributed — the same
// `agent_id: None` this path has always written. Resolving the
// graph node to an agent uuid is the fix, and it cannot be tested
// while the fleet is offline; guessing at it here would put one
// node's actions on another node's record.
&[],
)
.await;
// A node whose work never came back must fail the run rather than hand
// the next node a tree missing the previous one's edits. On this path an
// uncollected turn is worse than on the solo one: the loss is silent,
// because the next node still boots from a checkout that looks fine.
if !outcome.collected {
return Err(OrchestratorError::Executor(format!(
"node {}'s work could not be collected from its VM, so the next \
node would not see it: {}",
req.node_id,
outcome.summary.chars().take(400).collect::<String>()
)));
}
// Same rule as the solo path: the gate is the only thing that runs a
// `done_when_check`, so a release at the cap must fail the run rather
// than hand the next node a tree that does not satisfy the condition
// every node in this graph was told to satisfy.
if outcome.released_at_cap == Some(true) {
return Err(OrchestratorError::Executor(format!(
"node {}'s completion gate released it after {} refusal(s) with its check \
still failing: {}",
req.node_id,
crate::vm_stop_gate::MAX_BLOCKS,
outcome.summary.chars().take(400).collect::<String>()
)));
}
if outcome.rc != 0 {
return Err(OrchestratorError::Executor(format!(
"node {} exited {}: {}",
req.node_id,
outcome.rc,
outcome.summary.chars().take(400).collect::<String>()
)));
}
eprintln!(
"microvm_turn_executor: run {} node {} (role {}, step {}) ok — subagents: {}",
self.run_id,
req.node_id,
req.role,
step,
outcome
.subagents
.map(|n| n.to_string())
.unwrap_or_else(|| "?".into()),
);
Ok(TurnOutcome {
output: outcome.summary,
// `claude -p` does not report token usage on stdout, and inventing a
// number here would corrupt the run totals the harness reads. Zero is
// the honest value for "not measured on this path".
tokens: 0,
gated: Vec::new(),
spend: Default::default(),
})
}
}
/// What one graph node is told.
///
/// The upstream outputs are included as context, but the load-bearing sentence is
/// that the previous node's *files* are already in the tree: a node told only
/// about the text would re-do work it is standing on.
fn node_task_text(req: &TurnRequest) -> String {
let mut s = format!(
"You are the `{}` stage of a multi-stage mission.\n\nMISSION TASK\n{}\n",
req.role, req.task
);
if !req.context.is_empty() {
s.push_str(
"\nWHAT CAME BEFORE\nThe earlier stages' work is ALREADY IN THIS \
WORKING TREE — the repository you have been given is their output, \
not a fresh checkout. Read the files before changing them, and do \
not redo what is already done. Their closing reports:\n",
);
for (i, c) in req.context.iter().enumerate() {
s.push_str(&format!("\n--- stage {} ---\n{}\n", i + 1, c));
}
}
s
}
/// Touches a run's `updated_at` until dropped.
struct Keepalive(tokio::task::JoinHandle<()>);
impl Keepalive {
fn spawn(pool: PgPool, run_id: Uuid) -> Self {
Keepalive(tokio::spawn(async move {
let mut ticker = tokio::time::interval(Duration::from_secs(KEEPALIVE_SECS));
loop {
ticker.tick().await;
let _ = cm_db::repo::topology_runs::touch(&pool, run_id).await;
}
}))
}
}
impl Drop for Keepalive {
fn drop(&mut self) {
self.0.abort();
}
}
/// Build the executor the worker uses, over real VMs on the fleet.
pub fn for_fleet(
hub: Arc<crate::fleet::NodeHub>,
pool: PgPool,
r: ComposedRun,
) -> MicroVmTurnExecutor<crate::microvm_executor::HubVms> {
MicroVmTurnExecutor::new(crate::microvm_executor::HubVms::new(hub), pool, r)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::microvm_executor::VmOutcome;
use std::collections::BTreeMap;
use std::sync::Mutex;
/// A VM modelled honestly: the host tree is packed in, the "agent" works on a
/// COPY that no host path points at, and the result is unpacked back over the
/// host tree. That is the real inject → run → collect shape, which is what
/// makes the negative control below meaningful — remove the collect and the
/// handoff breaks exactly as it would in production.
struct FakeVms {
/// Whether the guest's tree is collected back to the host.
collect: bool,
/// vm ids used, in order — the id is what stops two nodes colliding.
ids: Mutex<Vec<String>>,
/// (backend, fleet node) per call, for the heterogeneity assertions.
placements: Mutex<Vec<(Option<String>, NodeId)>>,
}
impl FakeVms {
fn new(collect: bool) -> Self {
Self {
collect,
ids: Mutex::new(Vec::new()),
placements: Mutex::new(Vec::new()),
}
}
}
impl PhaseVm for FakeVms {
async fn run(&self, p: VmPhase<'_>) -> Result<VmOutcome, String> {
self.ids
.lock()
.unwrap()
.push(format!("{}-{:?}", p.phase_id.simple(), p.step));
self.placements
.lock()
.unwrap()
.push((p.backend.map(str::to_string), p.node_id));
// inject: the host checkout goes in as a tar.
let tar = crate::mission_fs::pack_dir(p.repo, "repo")?;
let guest = tempfile::tempdir().map_err(|e| e.to_string())?;
crate::mission_fs::unpack_into(&tar, guest.path())?;
let guest_repo = guest.path().join("repo");
// run: the agent records that it was here, and reports what it found
// of the previous stages — the observation the handoff test reads.
let seen: Vec<String> = std::fs::read_dir(&guest_repo)
.map_err(|e| e.to_string())?
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.filter(|n| n.starts_with("stage-"))
.collect();
let mine = guest_repo.join(format!("stage-{}.txt", p.step.unwrap_or(0)));
std::fs::write(&mine, "work").map_err(|e| e.to_string())?;
// collect: the guest tree comes back over the same host path.
if self.collect {
let back = crate::mission_fs::pack_dir(&guest_repo, "repo")?;
let parent = p.repo.parent().ok_or("no parent")?;
crate::mission_fs::unpack_into(&back, parent)?;
}
Ok(VmOutcome {
summary: format!("saw:[{}]", seen.join(",")),
rc: 0,
collected: true,
subagents: Some(0),
teammates: None,
stop_blocks: None,
released_at_cap: None,
tools: Vec::new(),
rootfs: None,
cli_version: None,
tool_gate: None,
})
}
}
fn req(node: &str, role: &str, context: Vec<String>) -> TurnRequest {
TurnRequest {
node_id: node.into(),
role: role.into(),
agent: None,
attrs: BTreeMap::new(),
task: "build the thing".into(),
context,
}
}
fn exec<V: PhaseVm>(vms: V, repo: PathBuf) -> MicroVmTurnExecutor<V> {
// A pool that is never connected: every test here fails the turn before
// any query, or drives one whose only DB touch is the best-effort
// keepalive (which swallows its own errors by design).
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(1)
.connect_lazy("postgres://invalid/invalid")
.expect("a lazy pool never dials");
MicroVmTurnExecutor::new(
vms,
pool,
ComposedRun {
run_id: Uuid::now_v7(),
mission_id: Uuid::now_v7(),
phase_id: Uuid::now_v7(),
iteration: 1,
repo,
has_repo: true,
target_node_id: Some(Uuid::now_v7()),
backend: Some("claude".into()),
team_engine: None,
gate: None,
completed_steps: 0,
},
)
}
fn a_checkout() -> tempfile::TempDir {
let d = tempfile::tempdir().unwrap();
std::fs::create_dir_all(d.path().join("repo")).unwrap();
std::fs::write(d.path().join("repo").join("README.md"), "hello").unwrap();
d
}
/// THE trap this slice exists to solve. A per-node VM is destroyed with its
/// filesystem, so unless the tree is carried forward, node 2 works from the
/// original checkout and silently loses node 1's edits — while still
/// reporting success.
#[tokio::test]
async fn a_later_node_sees_an_earlier_nodes_files() {
let d = a_checkout();
let e = exec(FakeVms::new(true), d.path().join("repo"));
let first = e.run_turn(req("n1", "implementer", vec![])).await.unwrap();
assert_eq!(first.output, "saw:[]", "the first node starts clean");
let second = e
.run_turn(req("n2", "verifier", vec![first.output.clone()]))
.await
.unwrap();
assert!(
second.output.contains("stage-0.txt"),
"node 2 could not see node 1's file: {}",
second.output
);
// And the host tree — what delivery diffs — holds both nodes' work.
for f in ["stage-0.txt", "stage-1.txt"] {
assert!(d.path().join("repo").join(f).exists(), "{f} missing on the host");
}
}
/// The negative control, run rather than assumed: with the collect removed —
/// i.e. a text-only handoff between nodes — the test above fails. A guard
/// that cannot detect the bug it was written for is decoration.
#[tokio::test]
async fn text_only_handoff_loses_the_earlier_nodes_work() {
let d = a_checkout();
let e = exec(FakeVms::new(false), d.path().join("repo"));
e.run_turn(req("n1", "implementer", vec![])).await.unwrap();
let second = e.run_turn(req("n2", "verifier", vec![])).await.unwrap();
assert_eq!(
second.output, "saw:[]",
"without a collect, node 2 must NOT see node 1's work — if it does, \
this test is no longer controlling anything"
);
assert!(!d.path().join("repo").join("stage-0.txt").exists());
}
/// Each node gets its own vm id within one phase and iteration. Two nodes
/// sharing an id means the second is refused by the fleet node while the
/// first is alive, and indistinguishable from a re-run once it is not.
#[tokio::test]
async fn every_node_runs_in_its_own_vm() {
let d = a_checkout();
let vms = FakeVms::new(true);
let e = exec(vms, d.path().join("repo"));
for n in ["n1", "n2", "n3"] {
e.run_turn(req(n, "worker", vec![])).await.unwrap();
}
let ids = e.vms.ids.lock().unwrap().clone();
let unique: std::collections::HashSet<_> = ids.iter().collect();
assert_eq!(unique.len(), ids.len(), "{ids:?}");
}
/// Resume must not re-use a completed step's vm id. The executor counts steps
/// itself, so the count has to start where the checkpoint left off.
#[tokio::test]
async fn a_resumed_run_continues_the_step_numbering() {
let d = a_checkout();
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(1)
.connect_lazy("postgres://invalid/invalid")
.unwrap();
let e = MicroVmTurnExecutor::new(
FakeVms::new(true),
pool,
ComposedRun {
run_id: Uuid::now_v7(),
mission_id: Uuid::now_v7(),
phase_id: Uuid::now_v7(),
iteration: 1,
repo: d.path().join("repo"),
has_repo: true,
target_node_id: Some(Uuid::now_v7()),
backend: None,
team_engine: None,
gate: None,
completed_steps: 2,
},
);
e.run_turn(req("n3", "worker", vec![])).await.unwrap();
let ids = e.vms.ids.lock().unwrap().clone();
assert!(
ids[0].ends_with("Some(2)"),
"the first step after a resume must be step 2, not 0: {ids:?}"
);
}
/// Per-node `backend` is what makes the outer graph heterogeneous — a
/// validator node on another provider's image. It must override the
/// mission's, and the mission's must still apply to nodes that say nothing.
#[tokio::test]
async fn a_node_may_pick_its_own_backend_and_fleet_node() {
let d = a_checkout();
let e = exec(FakeVms::new(true), d.path().join("repo"));
let elsewhere = Uuid::now_v7();
let mut r = req("n1", "worker", vec![]);
r.attrs.insert("backend".into(), "glm".into());
r.attrs.insert("node_id".into(), elsewhere.to_string());
e.run_turn(r).await.unwrap();
e.run_turn(req("n2", "worker", vec![])).await.unwrap();
let p = e.vms.placements.lock().unwrap().clone();
assert_eq!(p[0].0.as_deref(), Some("glm"));
assert_eq!(p[0].1, NodeId::from(elsewhere));
assert_eq!(p[1].0.as_deref(), Some("claude"), "the mission default");
assert_ne!(p[1].1, NodeId::from(elsewhere));
}
/// A malformed `node_id` must fail the node, not fall back to the mission's.
/// Silently running work somewhere the graph did not ask for is the same
/// class of bug as an alias that serde dropped.
#[tokio::test]
async fn a_malformed_node_placement_fails_closed() {
let d = a_checkout();
let e = exec(FakeVms::new(true), d.path().join("repo"));
let mut r = req("n1", "worker", vec![]);
r.attrs.insert("node_id".into(), "not-a-uuid".into());
let err = e.run_turn(r).await.unwrap_err().to_string();
assert!(err.contains("invalid node_id"), "{err}");
}
/// An uncollected node is a failed run here, not a warning: the next node
/// would boot from a tree that looks fine and is missing this node's work.
#[tokio::test]
async fn an_uncollected_node_fails_the_run() {
struct Lost;
impl PhaseVm for Lost {
async fn run(&self, _p: VmPhase<'_>) -> Result<VmOutcome, String> {
Ok(VmOutcome {
summary: "did plenty".into(),
rc: 0,
collected: false,
subagents: None,
teammates: None,
stop_blocks: None,
released_at_cap: None,
tools: Vec::new(),
rootfs: None,
cli_version: None,
tool_gate: None,
})
}
}
let d = a_checkout();
let e = exec(Lost, d.path().join("repo"));
let err = e.run_turn(req("n1", "worker", vec![])).await.unwrap_err().to_string();
assert!(err.contains("could not be collected"), "{err}");
}
/// A node whose gate gave up is a FAILED run, not a completed one.
///
/// The gate is the only thing in the system that ever runs a
/// `done_when_check`. If it releases the agent at the cap and this returns
/// Ok, the check's failure is never seen again: the node reports success,
/// the next node builds on a tree that does not satisfy the condition, and
/// the phase completes green. `rc` is 0 and the work IS collected here on
/// purpose — those are the two signals that used to decide this, and both
/// say "fine".
#[tokio::test]
async fn a_node_whose_gate_gave_up_fails_the_run() {
struct Capped;
impl PhaseVm for Capped {
async fn run(&self, _p: VmPhase<'_>) -> Result<VmOutcome, String> {
Ok(VmOutcome {
summary: "I could not get the tests passing, but here is what I did".into(),
rc: 0,
collected: true,
subagents: None,
teammates: None,
stop_blocks: Some(crate::vm_stop_gate::MAX_BLOCKS),
released_at_cap: Some(true),
tools: Vec::new(),
rootfs: None,
cli_version: None,
tool_gate: None,
})
}
}
let d = a_checkout();
let e = exec(Capped, d.path().join("repo"));
let err = e.run_turn(req("n1", "worker", vec![])).await.unwrap_err().to_string();
assert!(err.contains("released it after"), "{err}");
}
/// The negative control: the SAME number of blocks, without the cap. An
/// agent that was refused three times and then got it right on the fourth
/// try has succeeded, and reports `blocks: 3` exactly like the test above.
/// Failing on the count instead of the mark would fail this healthy run.
#[tokio::test]
async fn a_node_that_was_blocked_and_then_succeeded_passes() {
struct Recovered;
impl PhaseVm for Recovered {
async fn run(&self, _p: VmPhase<'_>) -> Result<VmOutcome, String> {
Ok(VmOutcome {
summary: "took me a few tries".into(),
rc: 0,
collected: true,
subagents: None,
teammates: None,
stop_blocks: Some(crate::vm_stop_gate::MAX_BLOCKS),
released_at_cap: Some(false),
tools: Vec::new(),
rootfs: None,
cli_version: None,
tool_gate: None,
})
}
}
let d = a_checkout();
let e = exec(Recovered, d.path().join("repo"));
e.run_turn(req("n1", "worker", vec![]))
.await
.expect("a run that recovered inside its own turn is a success");
}
/// A node must be told its predecessors' files are already in the tree.
/// Given only the text, an agent re-does work it is standing on.
#[test]
fn a_downstream_node_is_told_the_work_is_already_in_the_tree() {
let solo = node_task_text(&req("n1", "implementer", vec![]));
assert!(solo.contains("build the thing"));
assert!(!solo.contains("WHAT CAME BEFORE"), "{solo}");
let later = node_task_text(&req("n2", "verifier", vec!["I wrote foo.rs".into()]));
assert!(later.contains("ALREADY IN THIS WORKING TREE"), "{later}");
assert!(later.contains("I wrote foo.rs"), "{later}");
assert!(later.contains("verifier"), "the node's role: {later}");
}
}