loops: Path B container isolation (P2)
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user