loops: Path B container isolation (P2)
ci / gates (push) Successful in 6s
ci / rust (push) Failing after 11s
ci / frontend (push) Successful in 28s
ci / e2e (push) Has been skipped
ci / publish (push) Has been skipped

Symmetric with the research pipeline: every enabled loop can now have
its own per-loop team container so scheduled runs don't share state
with other loops or with research. Same daemon image, same clawmates
network, deterministic name loop-<id>-team.

Backend surface:
- Migration 0040 adds nullable `zeroclaw_container` +
  `zeroclaw_gateway_url` columns to loops (parallel to
  research_topics).
- research_container.rs grows loop_container_name_for(), spawn_loop()
  (state-only mount, no repo), and teardown_loop(). Kept in the same
  module to share the docker connect() + inherited_env() plumbing;
  each pattern gets its own labels (clawmates.role=loop-team) so ps
  filters can tell them apart.
- cm_db::repo::loops gains set_zeroclaw_container() +
  zeroclaw_gateway_url() (dynamic sqlx queries — no offline cache
  regen needed).
- cm_db::repo::topology_runs gets loop_id_for_run(): mirror of
  research_topic_id, used by the worker.

Wiring:
- routes/loops::run_now + webhook_receive call ensure_loop_container()
  before enqueuing an iteration. Idempotent: an already-running
  container is just reattached. Failures are logged and do NOT block
  the enqueue — topology_worker falls back to the workspace gateway
  when the URL isn't set on the loop.
- routes/loops::disable_loop + delete_loop both fire teardown_loop()
  so paused / deleted loops don't hold a docker slot.
- topology_worker's per-run URL resolution: existing research fast
  path unchanged; when it doesn't hit, the worker now looks up
  loop_id and reads the loop's gateway URL.

Deploy step (required on gw-04 for state to persist across container
restarts): add a `/var/lib/clawmates-loops:/var/lib/clawmates-loops`
bind mount + `CLAWMATES_LOOPS_STATE_ROOT=/var/lib/clawmates-loops`
env var to clawmates_server_1 in the compose. Without it, loops still
run — the state dir lives inside the API container's filesystem so
persistence is limited to that container's lifetime.

Follow-up:
- Scheduler-tick fires (cron-driven, not run_now) — they call
  enqueue_iteration in cm-scheduler and don't yet go through
  ensure_loop_container. Add a symmetric spawn there so cron fires
  also land on the isolated daemon.
- Compose file reconciliation — deploy/compose/docker-compose.yml in
  the repo has drifted from prod; when we sync it, add the loops mount
  at the same time.
This commit is contained in:
Omar Sobh
2026-07-09 16:02:08 -07:00
parent 1896b78cb7
commit 03f1830d1f
6 changed files with 263 additions and 6 deletions
+21 -6
View File
@@ -91,12 +91,12 @@ async fn run_job(
.and_then(|c| serde_json::from_value(c).ok())
.unwrap_or_default();
// If this run belongs to a research topic and that topic has a per-team
// ZeroClaw container spawned (see research_container::spawn), point the
// executor at THAT container's gateway URL so the run's turns hit its
// isolated daemon instead of the workspace-wide one. Falls back to the
// env-derived executor when there's no per-topic container (non-research
// runs, or research runs where spawn failed and we recorded no URL).
// If this run belongs to a research topic OR a loop with a per-team
// ZeroClaw container spawned, point the executor at THAT container's
// gateway URL so the run's turns hit its isolated daemon instead of
// the workspace-wide one. Falls back to the env-derived executor when
// there's no per-topic/loop container (chat sessions, or research/loop
// runs where spawn failed and we recorded no URL).
let per_topic_url = match cm_db::repo::topology_runs::research_topic_id(pool, id).await {
Ok(Some(topic_id)) => {
match cm_db::repo::research_topics::get(pool, topic_id, job.workspace_id).await {
@@ -106,6 +106,21 @@ async fn run_job(
}
_ => None,
};
// Loop lookup runs only when the research lookup didn't hit — a run
// is bound to at most one of {topic, loop}. This preserves the
// existing research fast path unchanged.
let per_topic_url = if per_topic_url.is_some() {
per_topic_url
} else {
match cm_db::repo::topology_runs::loop_id_for_run(pool, id).await {
Ok(Some(loop_id)) => {
cm_db::repo::loops::zeroclaw_gateway_url(pool, loop_id)
.await
.unwrap_or(None)
}
_ => None,
}
};
if let Some(url) = &per_topic_url {
// Best-effort readiness gate — a freshly-spawned team container may
// still be starting when the worker claims the run. Cap the wait so