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
+37 -8
View File
@@ -35,6 +35,7 @@
use std::path::{Path, PathBuf};
use sqlx::{PgPool, Row};
use time::{Duration, OffsetDateTime};
use uuid::Uuid;
/// 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`.
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
/// one of these has already been captured.
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.
pub async fn capture_repo_less_phases(pool: &PgPool) -> Result<(), String> {
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
JOIN missions m ON m.id = mp.mission_id
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 kind: String = row.get("kind");
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 dest = outputs_dir(mission_id, phase_id);
let captured = match collect_into(mission_id, &dest, &runtime_kind).await {
Ok(files) => files,
Err(e) => {
// Loud and retryable, never silently "captured nothing": the
// whole defect this module exists for is work disappearing
// without a word. The next tick tries again; if the container is
// already gone the phase is failed below on the next pass.
// Retryable, but BOUNDED. A bare `continue` here is how a phase
// whose collect can never succeed stayed `completed` with zero
// artifacts forever: the fail-empty rule and the NO-OUTPUT
// 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!(
"mission_outputs: could NOT collect outputs for phase {phase_id} \
of mission {mission_id}: {e}"
"mission_outputs: giving up collecting phase {phase_id} of mission \
{mission_id} after {}s: {e} — treating it as having produced nothing",
COLLECT_GRACE.whole_seconds()
);
continue;
Vec::new()
}
};