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:
co-authored by
Claude Opus 5
parent
af89020dfd
commit
4dec77ae6d
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user