research: spawn per-topic ZeroClaw team container on start (commit 1/3)
ci / gates (push) Successful in 6s
ci / frontend (push) Successful in 29s
ci / rust (push) Successful in 2m40s
ci / e2e (push) Has been skipped
ci / publish (push) Successful in 2m27s

Commit 1 of the path-B (real per-topic isolation) plan. The
container spawns and its coordinates persist — nothing talks to
it yet; commit 2 wires ZeroClawDriveExecutor to prefer the topic's
URL when populated. This split keeps each landing verifiable.

Backend

- Migration 0038: research_topics gets zeroclaw_container_name +
  zeroclaw_gateway_url columns. Both nullable so a topic can exist
  before a spawn and teardown just NULLs them out.

- cm-db: ResearchTopic struct extended; get/list SELECTs updated;
  new set_zeroclaw_container(id, workspace_id, name, url) helper
  used both for spawn (Some/Some) and teardown (None/None).

- cm-api: bollard added as a workspace dep (matches cm-sandbox's
  version). New research_container module:
    · connect() → uses DOCKER_HOST when set (prod's socket-proxy
      at tcp://socket-proxy:2375) else the local socket. Same
      pattern cm-sandbox already uses.
    · container_name_for(topic_id) → "research-<uuid>-team"
      (deterministic so a re-start reattaches to the same
      container instead of orphaning it).
    · inherited_env() → propagates ZEROCLAW_*, OPENAI_*,
      ANTHROPIC_*, GEMINI_*, GROQ_* from the parent server env
      (provider config + tokens), stripping the server's own
      ZEROCLAW_GATEWAY_URL/WORKSPACE so the team runtime doesn't
      loop back on itself. Appends ZEROCLAW_GATEWAY_PORT=42617
      and ZEROCLAW_WORKSPACE=/zeroclaw-data/workspace for the
      team's own listener.
    · spawn(docker, topic_id, repo_host_path, state_host_path):
        - inspect: if the container already exists, start it if
          stopped and return its coordinates (idempotent restart).
        - else create with:
            image  = CLAWMATES_RESEARCH_TEAM_IMAGE or
                     clawmates-runtime:latest
            cmd    = [daemon, --host, 0.0.0.0]
            env    = inherited_env()
            mounts = repo_host_path → /workspace/repo (rw)
                     state_host_path → /zeroclaw-data (rw)
            network = CLAWMATES_RESEARCH_TEAM_NETWORK or
                      clawmates_core
            labels = clawmates.role=research-team,
                     clawmates.research.topic_id=<uuid>
        - creates state_host_path first so bind doesn't ENOENT.
    · stop(docker, name) → stop + remove. Idempotent on 404/304.

- start_topic wires spawn after the clone completes:
    · state root = CLAWMATES_RESEARCH_WORKSPACE_ROOT / <topic> /
      state
    · on success, persists (name, url) on the topic row so commit
      2 can look them up when constructing the executor
    · every failure (docker connect, docker create/start, DB
      persist) is best-effort: logs and continues. A missing team
      container leaves the topic pointing at the workspace-wide
      gateway URL (env), preserving prior behavior.

Deploy prerequisites (not in this commit)

- The compose stack's clawmates_server service needs bind-mounts
  of CLAWMATES_RESEARCH_WORKSPACE_ROOT (e.g.
  /var/lib/clawmates-research:/var/lib/clawmates-research) so
  paths the server writes to are visible on the host and the
  spawned team container mounts the same underlying data.
- socket-proxy's ACL must allow POST + DELETE on /containers
  (already the case in prod per the audited compose file).
This commit is contained in:
Omar Sobh
2026-07-09 04:14:26 -07:00
parent 7984e65174
commit 21ac35c8d4
9 changed files with 360 additions and 6 deletions
+39 -2
View File
@@ -36,6 +36,15 @@ pub struct ResearchTopic {
/// repo. Written once on the first successful clone; subsequent starts
/// reuse it. Null until then.
pub repo_workspace_path: Option<String>,
/// Docker container name of the per-topic ZeroClaw team runtime, e.g.
/// "research-<topic_id>-team". Set by `research_container::spawn`;
/// cleared by teardown. Also used to look up the container for stop.
pub zeroclaw_container_name: Option<String>,
/// Reachable URL of the per-topic team's gateway, e.g.
/// `http://research-<topic_id>-team:42617`. Persisted so
/// topology_worker can point ZeroClawDriveExecutor at the isolated
/// endpoint for THIS topic's runs instead of the global env one.
pub zeroclaw_gateway_url: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
@@ -79,6 +88,32 @@ pub async fn create(pool: &PgPool, input: NewTopic<'_>) -> Result<Uuid, DbError>
Ok(id)
}
/// Persist the per-topic ZeroClaw container coordinates. Called from
/// `research_container::spawn` after `docker start` succeeds. Pass `None`
/// on both to clear the fields during teardown.
pub async fn set_zeroclaw_container(
pool: &PgPool,
id: Uuid,
workspace_id: Uuid,
container_name: Option<&str>,
gateway_url: Option<&str>,
) -> Result<(), DbError> {
sqlx::query!(
"UPDATE research_topics
SET zeroclaw_container_name = $3,
zeroclaw_gateway_url = $4,
updated_at = now()
WHERE id = $1 AND workspace_id = $2",
id,
workspace_id,
container_name,
gateway_url,
)
.execute(pool)
.await?;
Ok(())
}
/// Persist the clone path for a topic's bound repo. Set once, on the first
/// successful clone; a re-start reads it back and skips re-cloning.
pub async fn set_repo_workspace_path(
@@ -106,7 +141,8 @@ pub async fn list(pool: &PgPool, workspace_id: Uuid) -> Result<Vec<ResearchTopic
ResearchTopic,
"SELECT id, workspace_id, title, description, outcome_kind, status,
created_by, created_at, updated_at, published_at, topology_kind,
repo_id, repo_workspace_path
repo_id, repo_workspace_path,
zeroclaw_container_name, zeroclaw_gateway_url
FROM research_topics
WHERE workspace_id = $1
ORDER BY updated_at DESC",
@@ -126,7 +162,8 @@ pub async fn get(
ResearchTopic,
"SELECT id, workspace_id, title, description, outcome_kind, status,
created_by, created_at, updated_at, published_at, topology_kind,
repo_id, repo_workspace_path
repo_id, repo_workspace_path,
zeroclaw_container_name, zeroclaw_gateway_url
FROM research_topics
WHERE id = $1 AND workspace_id = $2",
id,