2 Commits
Author SHA1 Message Date
Omar SobhandClaude Opus 5 fd5e71ccfe fix(missions): re-assert a mission's crew when its container is recreated
deploy / test (push) Successful in 4m10s
deploy / build (push) Successful in 5m15s
Claws are provisioned exactly once, at on_launch. `ensure_container`
RECREATES a container that is not running, and recreation reseeds
.zeroclaw from the seed directory — which does not hold this mission's
claws. The agents still exist in Postgres and the crew query looks
perfect, so nothing reads as broken; the alias is simply gone from the
daemon and /ws/chat answers 400 Bad Request. A retried mission could
therefore never connect again.

Re-assert the crew after ensure_container. provision_claw is idempotent,
so this costs one call per claw on the happy path and is the difference
between a resumable mission and a dead one.

Two things this has to get right, both of which fail silently:
  - Aim at the per-mission daemon via for_gateway(ec.endpoint), never
    from_env() — that targets the shared global gateway and leaves this
    container with no claws at all, exactly as for_gateway's own doc
    comment warns.
  - Provisioning creates the agent but cannot set workspace.path (the
    config prop-schema has no way to express it), so follow with
    pin_agent_workspaces or every claw runs in its own sandbox and
    delivers nothing.

Verified on mission 01a00538: research and coding phases both completed
after four straight failures, with all five graph aliases present and
ten claws pinned to /mission/repo.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-16 07:25:58 -07:00
Omar SobhandClaude Opus 5 85a6038c08 fix(missions): create /mission before copying the checkout in
`copy_in` cannot create its own destination, so a mission whose container
had no /mission directory failed its checkout sync outright. In copy mode
that is how the agent gets the code at all, so the phase launched against
an empty tree.

Exec `mkdir -p /mission` as root first. Idempotent, and it costs one exec
on a path that already shells out.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-16 07:25:58 -07:00
2 changed files with 90 additions and 0 deletions
+23
View File
@@ -272,6 +272,29 @@ pub async fn sync_in(container: &str, mission_id: uuid::Uuid) -> Result<(), Stri
.map_err(|e| format!("create empty workspace {}: {e}", repo.display()))?; .map_err(|e| format!("create empty workspace {}: {e}", repo.display()))?;
} }
let docker = crate::container_exec::connect()?; let docker = crate::container_exec::connect()?;
// `upload_to_container` requires the DESTINATION to exist: uploading into
// `/mission` when the container has no `/mission` fails with
// "404 Could not find the file /mission in container", which reads like a
// missing source file rather than a missing target directory. Nothing else
// creates it — not the image, not the container spec (in copy mode there is
// no `/mission` bind) — so create it here, immediately before the copy that
// depends on it.
let mkdir = [
"mkdir".to_string(),
"-p".to_string(),
CONTAINER_MISSION_DIR.to_string(),
];
if let Err(e) = crate::container_exec::exec_as_root(
&docker,
container,
None,
&mkdir,
std::time::Duration::from_secs(20),
)
.await
{
return Err(format!("create {CONTAINER_MISSION_DIR} in {container}: {e}"));
}
copy_in(&docker, container, &repo, "repo").await copy_in(&docker, container, &repo, "repo").await
} }
+67
View File
@@ -678,6 +678,73 @@ async fn launch_phase(
return Err(format!("copy checkout into {name}: {e}")); return Err(format!("copy checkout into {name}: {e}"));
} }
} }
// Re-assert this mission's crew in the runtime config.
//
// Claws are provisioned once, at on_launch. `ensure_container`
// RECREATES a container that is not running, and recreation
// reseeds `.zeroclaw` from the seed directory — which does not
// contain this mission's claws. The agents still exist in
// Postgres, so nothing looks wrong, but the alias the turn
// dials is gone from the daemon and `/ws/chat` answers
// `400 Bad Request`. That is what a retry of mission 01a00538
// hit after its container was recreated.
//
// provision_claw is idempotent (created:false when present), so
// this costs one call per claw on the happy path and is the
// difference between a resumable mission and a dead one.
// Aim at THIS MISSION's daemon, never the global gateway. Each
// mission's turns run against its own container, which loads
// config at boot and never re-reads the file, so provisioning
// against `from_env()` writes the claws into the shared runtime
// and leaves this one with none — the failure mode
// `RuntimeProvisioner::for_gateway`'s doc comment describes, and
// the one this block exists to repair.
if let Some(rp) =
crate::runtime_provision::RuntimeProvisioner::for_gateway(ec.endpoint.clone())
{
let crew = sqlx::query(
"SELECT DISTINCT a.id, a.model_binding, t.risk_profile
FROM team_members tm
JOIN mission_teams mt ON mt.team_id = tm.team_id
JOIN teams t ON t.id = tm.team_id
JOIN agents a ON a.id = tm.claw_id
WHERE mt.mission_id = $1 AND a.deleted_at IS NULL",
)
.bind(mission_id)
.fetch_all(pool)
.await
.unwrap_or_default();
let mut reasserted: Vec<cm_domain::AgentId> = Vec::new();
for row in &crew {
let aid: uuid::Uuid = row.get("id");
let model: Option<String> = row.get("model_binding");
let risk: Option<String> = row.get("risk_profile");
match rp
.provision_claw(
aid,
model.as_deref().unwrap_or("claude"),
risk.as_deref().unwrap_or("research_readonly"),
)
.await
{
Ok(_) => reasserted.push(cm_domain::AgentId::from(aid)),
Err(e) => eprintln!(
"phase_runner: re-provision claw {aid} for {mission_id} failed \
(continuing): {e}"
),
}
}
// Provisioning creates the agent; it does NOT set
// workspace.path (the prop-schema cannot). Without this the
// claws exist and every one of them runs in its own sandbox
// instead of the mission tree.
if let Err(e) = prov
.pin_agent_workspaces(mission_id, &reasserted, "/mission/repo")
.await
{
return Err(format!("re-pin mission workspaces: {e}"));
}
}
if let Err(e) = cm_db::repo::missions::set_runtime_binding( if let Err(e) = cm_db::repo::missions::set_runtime_binding(
pool, pool,
mission_id, mission_id,