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
@@ -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()
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user