fix(missions): give repo-less container missions the workspace they are promised

Every agent on a research_only mission refused to work, each reporting it was
"in Claude Code", had no /mission/repo, and only had Read/Edit/Bash. All three
statements were true. The run still recorded completed — 5 turns, 7.4k tokens,
0 artifacts, no error.

The machinery is correct when a repo IS bound (verified on a live prod
per-mission container: /mission/repo present, all 5 agents pinned). Only the
repo-less path was broken, in three layers that disagreed by construction:

- sync_in no-oped without a host checkout and copy mode does not bind /mission,
  so NOTHING created /mission/repo. The microVM tier already creates it, for the
  stated reason that "the guest needs the workspace to exist before the agent
  writes into it". Creating it host-side also un-breaks sync_out, equally a
  no-op before, so work survives across phases instead of being wiped.
- pin_agent_workspaces returned Ok after pinning ZERO agents, so the
  deliberately-fatal guard in mission_orchestrator could never fire. Its error
  text already described the exact outcome we got.
- The prompt advertised ZeroClaw tool names and explicitly denied `bash`, while
  every executor ends in `claude -p`: microVM passes Read/Edit/Write/Bash/Agent,
  session passes Read/Edit/Write/Bash, and claude_cli agents get Claude Code's
  native toolset — ZeroClaw's gating never reaches the subprocess. It was
  telling agents to use missing tools and avoid present ones.

And it went green because mission_outputs logged the failed collect and
continued — with the fail-empty rule and the NO-OUTPUT marker both BELOW that
continue, so the phase was retried forever and never failed. The retry is now
bounded by a grace window off completed_at.

Verified end to end: mission completed, agent wrote
/mission/repo/research/firecracker_vs_docker.md, collected and registered as a
document artifact (6.6 kB of real content).

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-13 10:46:45 -07:00
co-authored by Claude Opus 5
parent af89020dfd
commit 4dec77ae6d
4 changed files with 134 additions and 44 deletions
+18 -3
View File
@@ -249,12 +249,27 @@ fn host_repo(mission_id: uuid::Uuid) -> std::path::PathBuf {
/// Push the host checkout into the container before a phase runs. /// Push the host checkout into the container before a phase runs.
/// ///
/// No-op when the mission has no repo — research-only missions have no /// A repo-less mission has no checkout to push, but it still needs
/// checkout, and that must not fail a phase launch. /// `/mission/repo` to EXIST inside the container: the phase prompt tells the
/// agent that is its working directory, `mission_orchestrator` pins every
/// claw's `workspace.path` to it, and `mission_outputs` copies it back out to
/// register artifacts. This used to return early instead, so none of those three
/// were true — the pin resolved to nothing, ZeroClaw fell back to each agent's
/// own sandbox, and the agents (correctly) reported they had no such directory
/// and refused to work. Creating it empty is what the microVM tier already does,
/// for the same reason: see `microvm_executor::inject` ("the guest needs the
/// workspace to exist before the agent writes into it").
///
/// Creating it host-side rather than `mkdir`-ing in the container keeps the copy
/// cycle symmetric — `sync_out` unpacks over this same path, so work written by
/// one phase survives into the next instead of being wiped by the next
/// `sync_in`.
pub async fn sync_in(container: &str, mission_id: uuid::Uuid) -> Result<(), String> { pub async fn sync_in(container: &str, mission_id: uuid::Uuid) -> Result<(), String> {
let repo = host_repo(mission_id); let repo = host_repo(mission_id);
if !repo.is_dir() { if !repo.is_dir() {
return Ok(()); tokio::fs::create_dir_all(&repo)
.await
.map_err(|e| format!("create empty workspace {}: {e}", repo.display()))?;
} }
let docker = crate::container_exec::connect()?; let docker = crate::container_exec::connect()?;
copy_in(&docker, container, &repo, "repo").await copy_in(&docker, container, &repo, "repo").await
+37 -8
View File
@@ -35,6 +35,7 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use sqlx::{PgPool, Row}; use sqlx::{PgPool, Row};
use time::{Duration, OffsetDateTime};
use uuid::Uuid; use uuid::Uuid;
/// Directories never worth capturing, whatever an agent leaves behind. /// Directories never worth capturing, whatever an agent leaves behind.
@@ -56,6 +57,14 @@ const SKIP_DIRS: &[&str] = &[
/// How many phases to capture per tick, matching `CAPTURE_BATCH`. /// How many phases to capture per tick, matching `CAPTURE_BATCH`.
const BATCH: i64 = 5; const BATCH: i64 = 5;
/// How long a phase's outputs may stay uncollectable before the sweep stops
/// retrying and calls it empty.
///
/// Generous on purpose: the container is torn down asynchronously after a
/// phase, so an early tick can legitimately fail. What must NOT happen is
/// retrying forever — that is the state this constant exists to end.
const COLLECT_GRACE: Duration = Duration::minutes(10);
/// The artifact kind this path registers. Also the idempotency key: a phase with /// The artifact kind this path registers. Also the idempotency key: a phase with
/// one of these has already been captured. /// one of these has already been captured.
pub const OUTPUT_KIND: &str = "document"; pub const OUTPUT_KIND: &str = "document";
@@ -66,7 +75,7 @@ const EMPTY_MARKER: &str = "NO-OUTPUT.md";
/// Capture the outputs of finished phases on missions that have no repo. /// Capture the outputs of finished phases on missions that have no repo.
pub async fn capture_repo_less_phases(pool: &PgPool) -> Result<(), String> { pub async fn capture_repo_less_phases(pool: &PgPool) -> Result<(), String> {
let rows = sqlx::query( let rows = sqlx::query(
"SELECT mp.id, mp.mission_id, mp.kind, mp.config, m.runtime_kind "SELECT mp.id, mp.mission_id, mp.kind, mp.config, mp.completed_at, m.runtime_kind
FROM mission_phases mp FROM mission_phases mp
JOIN missions m ON m.id = mp.mission_id JOIN missions m ON m.id = mp.mission_id
WHERE mp.status IN ('completed', 'failed') WHERE mp.status IN ('completed', 'failed')
@@ -97,21 +106,41 @@ pub async fn capture_repo_less_phases(pool: &PgPool) -> Result<(), String> {
let mission_id: Uuid = row.get("mission_id"); let mission_id: Uuid = row.get("mission_id");
let kind: String = row.get("kind"); let kind: String = row.get("kind");
let config: serde_json::Value = row.get("config"); let config: serde_json::Value = row.get("config");
let completed_at: Option<OffsetDateTime> = row.get("completed_at");
let runtime_kind: String = row.get("runtime_kind"); let runtime_kind: String = row.get("runtime_kind");
let dest = outputs_dir(mission_id, phase_id); let dest = outputs_dir(mission_id, phase_id);
let captured = match collect_into(mission_id, &dest, &runtime_kind).await { let captured = match collect_into(mission_id, &dest, &runtime_kind).await {
Ok(files) => files, Ok(files) => files,
Err(e) => { Err(e) => {
// Loud and retryable, never silently "captured nothing": the // Retryable, but BOUNDED. A bare `continue` here is how a phase
// whole defect this module exists for is work disappearing // whose collect can never succeed stayed `completed` with zero
// without a word. The next tick tries again; if the container is // artifacts forever: the fail-empty rule and the NO-OUTPUT
// already gone the phase is failed below on the next pass. // marker both live below this point, so neither was ever
// reached, and the phase was re-attempted on every tick for the
// life of the deployment.
//
// The grace window exists because the container may legitimately
// not be ready on the first tick after a phase finishes. Past
// that, "cannot collect" and "collected nothing" are the same
// fact for the operator, so we fall through and let the rules
// below fail the phase and leave a marker explaining why.
let settled = completed_at
.map(|t| OffsetDateTime::now_utc() - t > COLLECT_GRACE)
.unwrap_or(true);
if !settled {
eprintln!(
"mission_outputs: could NOT collect outputs for phase {phase_id} \
of mission {mission_id} (will retry): {e}"
);
continue;
}
eprintln!( eprintln!(
"mission_outputs: could NOT collect outputs for phase {phase_id} \ "mission_outputs: giving up collecting phase {phase_id} of mission \
of mission {mission_id}: {e}" {mission_id} after {}s: {e} — treating it as having produced nothing",
COLLECT_GRACE.whole_seconds()
); );
continue; Vec::new()
} }
}; };
+13 -1
View File
@@ -897,8 +897,20 @@ impl MissionRuntimeProvisioner {
.map(|c| crate::runtime_provision::claw_alias(c.as_uuid())) .map(|c| crate::runtime_provision::claw_alias(c.as_uuid()))
.collect(); .collect();
let (edited, pinned) = stamp_workspace_paths(&raw, &aliases, workspace_path)?; let (edited, pinned) = stamp_workspace_paths(&raw, &aliases, workspace_path)?;
// Pinning NOTHING is a failure, not a no-op. Returning Ok here meant the
// caller's deliberately-fatal guard could not fire, so a mission whose
// aliases were missing from the config launched anyway with every agent
// writing into its own sandbox and delivering nothing — the outcome that
// guard's error message already describes. Name the aliases: the only
// way this happens is a config/alias mismatch, and the aliases are the
// evidence needed to find it.
if pinned == 0 { if pinned == 0 {
return Ok(()); return Err(format!(
"pinned 0 of {} agent workspace(s) to {workspace_path} — none of these \
aliases exist in {CONFIG_PATH}: {}",
aliases.len(),
aliases.join(", ")
));
} }
// Upload rather than exec. This used to base64 the whole config into a // Upload rather than exec. This used to base64 the whole config into a
+66 -32
View File
@@ -250,9 +250,38 @@ mod repo_less_text_tests {
fn both_variants_still_demand_files_on_disk() { fn both_variants_still_demand_files_on_disk() {
for has_repo in [true, false] { for has_repo in [true, false] {
let t = phase_task_text("research", "T", None, None, has_repo); let t = phase_task_text("research", "T", None, None, has_repo);
assert!(t.contains("REAL files with file_edit"), "has_repo={has_repo}: {t}"); assert!(t.contains("REAL files with Write/Edit"), "has_repo={has_repo}: {t}");
} }
} }
/// The prompt must advertise the tools the agent actually has.
///
/// Every executor ends in `claude -p`, so the names are Claude Code's.
/// Advertising ZeroClaw's names instead (`file_edit`, `content_search`) —
/// and denying Bash — is what made five agents stop and ask what environment
/// they were in rather than do the work.
#[test]
fn the_prompt_names_the_tools_the_agent_actually_has() {
let t = phase_task_text("coding", "T", None, None, true);
for real in ["Read", "Edit", "Write", "Bash", "Glob", "Grep"] {
assert!(t.contains(real), "missing {real}: {t}");
}
for absent in ["file_edit", "content_search", "glob_search", "git_operations"] {
assert!(
!t.contains(absent),
"{absent} does not exist under claude_cli — advertising it is the bug: {t}"
);
}
}
/// A repo-less agent must be told the workspace EXISTS. It is created by
/// `mission_fs::sync_in`; saying so is what stops the agent concluding the
/// environment is broken and refusing.
#[test]
fn a_repo_less_workspace_is_promised_to_exist() {
let t = phase_task_text("research", "T", None, None, false);
assert!(t.contains("EXISTS and is writable"), "{t}");
}
} }
/// Record the baseline for benchmark phases that have finished and have none. /// Record the baseline for benchmark phases that have finished and have none.
@@ -1387,31 +1416,36 @@ fn phase_task_text(
has_repo: bool, has_repo: bool,
) -> String { ) -> String {
let base = description.unwrap_or("").trim(); let base = description.unwrap_or("").trim();
// The prior template-derived system prompts trained agents to look // These are Claude Code's OWN tool names, because every executor that runs a
// for `file_read`/`file_write` — tools that no longer exist under // mission turn ends in `claude -p`: the microVM passes
// ZeroClaw v0.8+. The current toolset uses `file_edit` (create / // `--allowedTools Read Edit Write Bash Agent`
// overwrite / patch) plus `content_search`/`glob_search`. Injecting // (`microvm_executor::LEAD_TOOLS`), the direct path passes
// the real tool inventory + concrete workspace path stops the agent // `Read Edit Write Bash` (`session_executor::ALLOWED_TOOLS`), and the
// from hallucinating "I only have file_read" and dumping the entire // container tier binds every claw to `claude_cli.default`
// implementation into the context window instead of onto disk. // (`runtime_provision::provider_alias_for`), whose subprocess gets Claude
// Code's native toolset — ZeroClaw's own tool gating "never reaches the
// subprocess" (see the note above `direct_mode`).
//
// This block previously advertised ZeroClaw tool names (`file_edit`,
// `content_search`, …) and explicitly told the agent that `bash` did NOT
// exist. On every tier in service that was backwards: those tools were the
// ones absent, and Bash was one of the ones present. Agents answered by
// describing the mismatch and asking what to do — five of them, on one
// mission, for 7.4k tokens and zero artifacts.
let tool_preamble = format!("\ let tool_preamble = format!("\
TOOLS AVAILABLE (use these exact names — do NOT assume older tool names like file_read / file_write / bash exist):\n\ TOOLS AVAILABLE (Claude Code's standard tools — use these exact names):\n\
- file_edit — create, overwrite, or patch files in your workspace\n\ - Read — read a file\n\
- content_search — grep across your workspace (regex on file contents)\n\ - Edit — modify an existing file\n\
- glob_search — find files by path glob\n\ - Write — create or overwrite a file\n\
- git_operations — git status / add / commit / diff / log\n\ - Bash — run a shell command\n\
- git_forge — Gitea PR / branch / issue operations\n\ - Glob — find files by path glob\n\
- web_search_tool / web_fetch — external references (research-tier profiles only)\n\ - Grep — search file contents\n\
- spawn_subagent — hand off a subtask to another claw\n\
- delegate — call a peer role by name\n\
- memory_store / memory_recall — durable per-agent notes\n\
\n\ \n\
{workspace}\n\ {workspace}\n\
All file_edit / content_search / glob_search\n\ All file operations resolve there — use absolute paths under it, or cd\n\
operations resolve there. To read a file: file_edit with mode='read'\n\ there first. Write your outputs as REAL files with Write/Edit — do NOT\n\
or content_search first, then file_edit to patch. Write your outputs\n\ paste code blocks in your reply expecting the platform to save them;\n\
as REAL files with file_edit — do NOT paste code blocks in your reply\n\ nothing else writes files for you.\n",
expecting the platform to save them; nothing else writes files for you.\n",
workspace = if has_repo { workspace = if has_repo {
"WORKSPACE: Your working directory is /mission/repo. That path is the\n\ "WORKSPACE: Your working directory is /mission/repo. That path is the\n\
mission's git checkout." mission's git checkout."
@@ -1421,11 +1455,11 @@ fn phase_task_text(
// found no repo, wrote the files anyway, and nothing collected them. // found no repo, wrote the files anyway, and nothing collected them.
// Now `mission_outputs` DOES collect them, and the agent is told so // Now `mission_outputs` DOES collect them, and the agent is told so
// — an instruction the platform can actually keep. // — an instruction the platform can actually keep.
"WORKSPACE: Your working directory is /mission/repo. This mission has\n\ "WORKSPACE: Your working directory is /mission/repo. It EXISTS and is writable.\n\
NO git repository — that path is a scratch workspace, so git_operations\n\ This mission has NO git repository — that path is a scratch workspace, so\n\
and git_forge have nothing to act on. Every file you leave there is\n\ there is nothing to commit or push. Every file you leave there is collected\n\
collected when the phase ends and published as a mission artifact, so\n\ when the phase ends and published as a mission artifact, so write your\n\
write your output as files exactly as you would in a repo." output as files exactly as you would in a repo."
} }
); );
// The INT-XX markers are a machine contract, not a style preference: // The INT-XX markers are a machine contract, not a style preference:
@@ -1456,14 +1490,14 @@ fn phase_task_text(
Investigate the topic, gather sources, and produce a \ Investigate the topic, gather sources, and produce a \
sectioned Markdown brief the coding phase can implement \ sectioned Markdown brief the coding phase can implement \
directly. Save findings under /mission/repo/research/ \ directly. Save findings under /mission/repo/research/ \
using file_edit — one Markdown file per topic. Emit INT-XX \ using Write — one Markdown file per topic. Emit INT-XX \
task markers in the last file for concrete follow-ups." task markers in the last file for concrete follow-ups."
} }
"coding" => { "coding" => {
"Your team is running the CODING phase of this mission. \ "Your team is running the CODING phase of this mission. \
Implement the mission's acceptance criteria against the \ Implement the mission's acceptance criteria against the \
/mission/repo checkout using file_edit for every source \ /mission/repo checkout using Write/Edit for every source \
file, then git_operations to commit small focused changes \ file, then git via Bash to commit small focused changes \
with test coverage. Emit COMPLETED: <INT-id> markers as you \ with test coverage. Emit COMPLETED: <INT-id> markers as you \
close research-produced tasks. Do NOT respond with source \ close research-produced tasks. Do NOT respond with source \
code in text — write it as files." code in text — write it as files."
@@ -1471,7 +1505,7 @@ fn phase_task_text(
"benchmark" => { "benchmark" => {
"Your team is running the BENCHMARK phase of this mission. \ "Your team is running the BENCHMARK phase of this mission. \
Author or extend benchmarks under /mission/repo/benches or \ Author or extend benchmarks under /mission/repo/benches or \
the crate's bench harness using file_edit. Baseline the \ the crate's bench harness using Write/Edit. Baseline the \
pre-change performance, apply the change (or use the \ pre-change performance, apply the change (or use the \
mission's committed diff), then measure after." mission's committed diff), then measure after."
} }