2 Commits
Author SHA1 Message Date
Omar SobhandClaude Opus 4.8 0a647c0bfa fix(deploy): mkdir frontend/public/dl before staging the node binary
ci / rust (push) Failing after 9s
ci / gates (push) Successful in 6s
ci / frontend (push) Successful in 30s
ci / e2e (push) Skipped
ci / publish (push) Skipped
rsync --delete excludes frontend/public/dl/, so the directory does not exist
on the build host and the `cp` of clawmates-node into it aborted the deploy
(set -e) before anything was pushed.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-28 10:29:04 +02:00
Omar SobhandClaude Opus 4.8 06ae608d0c fix(missions): provision claws into the mission's own daemon + reload the pin
Mission turns execute against the per-mission runtime container, but claws
were provisioned via RuntimeProvisioner::from_env() — i.e. the GLOBAL gateway.
That daemon loads config once at boot and never re-reads the file, so the
per-mission daemon had no claw_* agents at all: querying it for a mission
claw's risk_profile returned 404 while the global daemon returned 200. With
the alias unresolvable, the daemon silently fell back to the default `scout`
agent, which is jailed to the global workspace — agents reported "the scout
agent workspace" and "/mission/repo isn't accessible", produced no files, and
burned tokens. This is the deeper cause behind the empty-output runs; the
tool-allowlist and workspace-pin fixes were necessary but not sufficient.

- RuntimeProvisioner::for_gateway(url) — aim the provisioner at a specific
  gateway (mirrors ZeroClawDriveExecutor::from_env_for_gateway); from_env now
  delegates to it.
- mission_orchestrator captures the per-mission endpoint from ensure_container
  and provisions every claw there, falling back to the global gateway only
  when there is no per-mission runtime (dev/no-docker).
- workspace.path is file-only (the config prop API cannot set a PathBuf), and
  the daemon never re-reads the file, so pin_agent_workspaces is now followed
  by restart_container(): restart + wait for /health to answer. Agents created
  through the daemon's own config API are already persisted to that file, so
  they survive; the pairing code is re-minted on every launch.
  The readiness probe inspects the /health BODY — exec_capture only fails on
  docker errors, so a curl that cannot connect still "succeeds".

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-28 10:27:54 +02:00
4 changed files with 92 additions and 7 deletions
+34 -6
View File
@@ -11,7 +11,13 @@
//! against the world) is layered on top by Slices 5–8. //! against the world) is layered on top by Slices 5–8.
//! //!
//! Design notes: //! Design notes:
//! - Runtime provisioning is opt-in via `RuntimeProvisioner::from_env`. //! - Runtime provisioning is opt-in. Claws are provisioned against the
//! mission's OWN daemon (`RuntimeProvisioner::for_gateway` with the
//! per-mission endpoint), falling back to the global gateway only when
//! there is no per-mission runtime. Provisioning into the global gateway
//! while the run executes on a per-mission daemon leaves that daemon
//! without the `claw_*` agents — it falls back to the default `scout`
//! agent, which cannot see `/mission/repo`.
//! Missing runtime = "insert DB rows only, no live claw" — the //! Missing runtime = "insert DB rows only, no live claw" — the
//! mission still boots; live claws land the moment the runtime //! mission still boots; live claws land the moment the runtime
//! env is configured + the mission re-launches. //! env is configured + the mission re-launches.
@@ -70,9 +76,13 @@ pub async fn on_launch(
// running. Falls back silently when docker is unreachable so // running. Falls back silently when docker is unreachable so
// dev-mode + tests still work — the topology_worker will use the // dev-mode + tests still work — the topology_worker will use the
// shared runtime endpoint in that case. // shared runtime endpoint in that case.
// The mission's own runtime endpoint. Claws MUST be provisioned against
// THIS gateway, not the global one — see RuntimeProvisioner::for_gateway.
let mut mission_gateway: Option<String> = None;
if let Some(prov) = crate::mission_runtime::MissionRuntimeProvisioner::from_env() { if let Some(prov) = crate::mission_runtime::MissionRuntimeProvisioner::from_env() {
match prov.ensure_container(mission_id).await { match prov.ensure_container(mission_id).await {
Ok(ec) => { Ok(ec) => {
mission_gateway = Some(ec.endpoint.clone());
let container_name = crate::mission_runtime::container_name(mission_id); let container_name = crate::mission_runtime::container_name(mission_id);
if let Err(e) = cm_db::repo::missions::set_runtime_binding( if let Err(e) = cm_db::repo::missions::set_runtime_binding(
pool, pool,
@@ -157,7 +167,13 @@ pub async fn on_launch(
); );
} }
let provisioner = RuntimeProvisioner::from_env(); // Provision into the mission's own daemon when we have one (so the daemon
// that actually runs the turns knows these claws); fall back to the global
// gateway only for dev/no-docker setups where the run uses it too.
let provisioner = match mission_gateway.clone() {
Some(url) => RuntimeProvisioner::for_gateway(url),
None => RuntimeProvisioner::from_env(),
};
let mut first_team_id: Option<Uuid> = None; let mut first_team_id: Option<Uuid> = None;
let mut provisioned_claws: Vec<cm_domain::AgentId> = Vec::new(); let mut provisioned_claws: Vec<cm_domain::AgentId> = Vec::new();
for (purpose, template_id) in &picks { for (purpose, template_id) in &picks {
@@ -213,15 +229,27 @@ pub async fn on_launch(
// the freshly-provisioned claws for the run. Non-fatal: without the // the freshly-provisioned claws for the run. Non-fatal: without the
// pin, agents still write (to the sandbox) but the committer can't // pin, agents still write (to the sandbox) but the committer can't
// find the changes in /mission/repo. // find the changes in /mission/repo.
if !provisioned_claws.is_empty() { if !provisioned_claws.is_empty() && mission_gateway.is_some() {
if let Some(mp) = crate::mission_runtime::MissionRuntimeProvisioner::from_env() { if let Some(mp) = crate::mission_runtime::MissionRuntimeProvisioner::from_env() {
if let Err(e) = mp match mp
.pin_agent_workspaces(mission_id, &provisioned_claws, "/mission/repo") .pin_agent_workspaces(mission_id, &provisioned_claws, "/mission/repo")
.await .await
{ {
eprintln!( Ok(()) => {
// The daemon reads config ONCE at boot and never re-reads
// the file, so the pin is invisible until it restarts. Its
// agents were created through its own config API, so they
// are already persisted to the file and survive the
// restart; the pairing code is re-minted on every launch.
if let Err(e) = mp.restart_container(mission_id).await {
eprintln!(
"mission_orchestrator: restart runtime for {mission_id} failed (continuing, workspace pin will not apply): {e}"
);
}
}
Err(e) => eprintln!(
"mission_orchestrator: pin workspaces for mission {mission_id} failed (continuing): {e}" "mission_orchestrator: pin workspaces for mission {mission_id} failed (continuing): {e}"
); ),
} }
} }
} }
+40
View File
@@ -482,6 +482,46 @@ fn extract_pairing_code_from_json(body: &str) -> Option<String> {
} }
impl MissionRuntimeProvisioner { impl MissionRuntimeProvisioner {
/// Restart the mission's runtime container and wait for its gateway to
/// answer again. Needed after `pin_agent_workspaces`: the daemon reads
/// config once at boot and never re-reads the file, so a file-only setting
/// (`workspace.path`, which the config prop API cannot set) only takes
/// effect across a restart. Agents provisioned through the daemon's own
/// config API are already persisted to that file, so they survive.
pub async fn restart_container(&self, mission_id: Uuid) -> Result<(), String> {
let name = container_name(mission_id);
self.docker
.restart_container(&name, None::<bollard::query_parameters::RestartContainerOptions>)
.await
.map_err(|e| format!("restart mission runtime container: {e}"))?;
// Wait for the gateway to serve again so the caller can launch a run
// immediately after. ~20s ceiling; the daemon normally boots in ~2s.
for _ in 0..40 {
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
// NOTE: exec_capture only fails on docker errors — a curl that
// can't connect still "succeeds" (it prints `curl: (7) …`), so we
// must inspect the BODY. /health answers `{"paired":…}`.
let body = self
.exec_capture(
&name,
vec![
"curl".into(),
"-fsS".into(),
"-m".into(),
"2".into(),
format!("http://127.0.0.1:{GATEWAY_PORT}/health"),
],
)
.await
.unwrap_or_default();
if body.contains("\"paired\"") {
eprintln!("mission_runtime: restarted {name}, gateway healthy");
return Ok(());
}
}
Err(format!("{name} gateway did not come back after restart"))
}
/// Force-remove the mission's runtime container AND its host workspace /// Force-remove the mission's runtime container AND its host workspace
/// dir (the `/mission/repo` checkout bind source). Idempotent: a missing /// dir (the `/mission/repo` checkout bind source). Idempotent: a missing
/// container or dir is not an error — this is called both by the terminal /// container or dir is not an error — this is called both by the terminal
+15 -1
View File
@@ -66,12 +66,26 @@ impl RuntimeProvisioner {
let gateway_url = std::env::var("ZEROCLAW_GATEWAY_URL") let gateway_url = std::env::var("ZEROCLAW_GATEWAY_URL")
.ok() .ok()
.filter(|u| !u.is_empty())?; .filter(|u| !u.is_empty())?;
Self::for_gateway(gateway_url)
}
/// Build a provisioner aimed at a SPECIFIC gateway, reusing the durable
/// `ZEROCLAW_TOKEN`. Mirrors `ZeroClawDriveExecutor::from_env_for_gateway`.
///
/// Missions MUST use this with their own per-mission runtime endpoint:
/// each mission runs its turns against its own daemon, and that daemon
/// loads config once at boot and never re-reads the file. Provisioning a
/// mission's claws against the global gateway therefore leaves the
/// per-mission daemon with no `claw_*` agents at all — it silently falls
/// back to the default agent (`scout`), which is jailed to the global
/// workspace and cannot see `/mission/repo`.
pub fn for_gateway(gateway_url: String) -> Option<RuntimeProvisioner> {
let token = std::env::var("ZEROCLAW_TOKEN") let token = std::env::var("ZEROCLAW_TOKEN")
.ok() .ok()
.filter(|t| !t.is_empty())?; .filter(|t| !t.is_empty())?;
Some(RuntimeProvisioner { Some(RuntimeProvisioner {
http: reqwest::Client::new(), http: reqwest::Client::new(),
gateway_url, gateway_url: gateway_url.trim_end_matches('/').to_string(),
token, token,
}) })
} }
+3
View File
@@ -60,6 +60,9 @@ if [ -z "${IMAGES_ONLY:-}" ]; then
ssh "$BUILD_HOST" 'set -e; cd ~/clawmates ssh "$BUILD_HOST" 'set -e; cd ~/clawmates
export PATH=$HOME/.cargo/bin:$PATH CARGO_NET_GIT_FETCH_WITH_CLI=true SQLX_OFFLINE=true export PATH=$HOME/.cargo/bin:$PATH CARGO_NET_GIT_FETCH_WITH_CLI=true SQLX_OFFLINE=true
cargo build --release -p clawmates-node cargo build --release -p clawmates-node
# rsync excludes frontend/public/dl/ (build artifacts), so the dir may not
# exist on the build host — create it before staging the daemon binary.
mkdir -p frontend/public/dl
cp target/release/clawmates-node frontend/public/dl/clawmates-node-linux-amd64 cp target/release/clawmates-node frontend/public/dl/clawmates-node-linux-amd64
for svc in server frontend; do for svc in server frontend; do
docker build -f images/$svc.Dockerfile -t '"$REGISTRY"'/clawmates/$svc:'"$TAG"' \ docker build -f images/$svc.Dockerfile -t '"$REGISTRY"'/clawmates/$svc:'"$TAG"' \