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
+40
View File
@@ -221,6 +221,46 @@ pub async fn delete(pool: &PgPool, id: Uuid, workspace_id: Uuid) -> Result<(), D
Ok(())
}
/// Persist the per-loop team container name + gateway URL after a
/// successful `spawn_loop` (P2). Dynamic query so the new columns don't
/// need a fresh .sqlx offline cache entry.
pub async fn set_zeroclaw_container(
pool: &PgPool,
loop_id: Uuid,
workspace_id: Uuid,
container: &str,
gateway_url: &str,
) -> Result<(), DbError> {
sqlx::query(
"UPDATE loops
SET zeroclaw_container = $3,
zeroclaw_gateway_url = $4,
updated_at = now()
WHERE id = $1 AND workspace_id = $2",
)
.bind(loop_id)
.bind(workspace_id)
.bind(container)
.bind(gateway_url)
.execute(pool)
.await?;
Ok(())
}
/// Read the per-loop gateway URL (or None if the loop hasn't spawned a
/// container yet). Used by `topology_worker` to prefer the isolated
/// daemon over the workspace-wide one.
pub async fn zeroclaw_gateway_url(pool: &PgPool, loop_id: Uuid) -> Result<Option<String>, DbError> {
use sqlx::Row;
let row = sqlx::query(
"SELECT zeroclaw_gateway_url FROM loops WHERE id = $1",
)
.bind(loop_id)
.fetch_optional(pool)
.await?;
Ok(row.and_then(|r| r.try_get::<Option<String>, _>("zeroclaw_gateway_url").ok().flatten()))
}
// --- Staffing ---------------------------------------------------------------
//
// Loops attach agents, teams, and/or orgs. The three join tables are
+15
View File
@@ -180,6 +180,21 @@ pub async fn research_topic_id(pool: &PgPool, id: Uuid) -> Result<Option<Uuid>,
Ok(row.and_then(|r| r.research_topic_id))
}
/// The loop this run belongs to, if any. Mirror of `research_topic_id`.
/// Used by `topology_worker` to look up the per-loop gateway URL so a
/// loop's runs land on its isolated daemon (P2). Non-loop runs return
/// None.
pub async fn loop_id_for_run(pool: &PgPool, id: Uuid) -> Result<Option<Uuid>, DbError> {
use sqlx::Row;
let row: Option<sqlx::postgres::PgRow> = sqlx::query(
"SELECT loop_id FROM topology_runs WHERE id = $1",
)
.bind(id)
.fetch_optional(pool)
.await?;
Ok(row.and_then(|r| r.try_get::<Option<Uuid>, _>("loop_id").ok().flatten()))
}
/// Enqueue a durable run bound to a research topic. `research_topic_id` is
/// stored so `notify_run_completed` can flip the owning topic
/// `processing → reviewing` when its last run terminates (see