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
@@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "SELECT id, workspace_id, title, description, outcome_kind, status,\n created_by, created_at, updated_at, published_at, topology_kind,\n repo_id, repo_workspace_path\n FROM research_topics\n WHERE workspace_id = $1\n ORDER BY updated_at DESC", "query": "SELECT id, workspace_id, title, description, outcome_kind, status,\n created_by, created_at, updated_at, published_at, topology_kind,\n repo_id, repo_workspace_path,\n zeroclaw_container_name, zeroclaw_gateway_url\n FROM research_topics\n WHERE workspace_id = $1\n ORDER BY updated_at DESC",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -67,6 +67,16 @@
"ordinal": 12, "ordinal": 12,
"name": "repo_workspace_path", "name": "repo_workspace_path",
"type_info": "Text" "type_info": "Text"
},
{
"ordinal": 13,
"name": "zeroclaw_container_name",
"type_info": "Text"
},
{
"ordinal": 14,
"name": "zeroclaw_gateway_url",
"type_info": "Text"
} }
], ],
"parameters": { "parameters": {
@@ -87,8 +97,10 @@
true, true,
false, false,
true, true,
true,
true,
true true
] ]
}, },
"hash": "9a823772908b51bb76d5e162e9dde8265fd9cdf106828ada234b75ab106ad21e" "hash": "40584d4cb37a3e98c260b113c807a5f7e0e6775c922e60d4321b790a1bad8d27"
} }
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE research_topics\n SET zeroclaw_container_name = $3,\n zeroclaw_gateway_url = $4,\n updated_at = now()\n WHERE id = $1 AND workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "918d70440223ce0131ce14fca077f4ad1fb9de17c2685d8f3039a92cd2d38022"
}
@@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "SELECT id, workspace_id, title, description, outcome_kind, status,\n created_by, created_at, updated_at, published_at, topology_kind,\n repo_id, repo_workspace_path\n FROM research_topics\n WHERE id = $1 AND workspace_id = $2", "query": "SELECT id, workspace_id, title, description, outcome_kind, status,\n created_by, created_at, updated_at, published_at, topology_kind,\n repo_id, repo_workspace_path,\n zeroclaw_container_name, zeroclaw_gateway_url\n FROM research_topics\n WHERE id = $1 AND workspace_id = $2",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -67,6 +67,16 @@
"ordinal": 12, "ordinal": 12,
"name": "repo_workspace_path", "name": "repo_workspace_path",
"type_info": "Text" "type_info": "Text"
},
{
"ordinal": 13,
"name": "zeroclaw_container_name",
"type_info": "Text"
},
{
"ordinal": 14,
"name": "zeroclaw_gateway_url",
"type_info": "Text"
} }
], ],
"parameters": { "parameters": {
@@ -88,8 +98,10 @@
true, true,
false, false,
true, true,
true,
true,
true true
] ]
}, },
"hash": "a40940823a2d1e3105140d59c513fd6c7b27430c4350bdce82ce4427c9954ee7" "hash": "ba4f424960acc864d6df32e793cec83c173bfea181f19c6b7b9ce223d0c2ded0"
} }
+4
View File
@@ -14,6 +14,10 @@ sha2 = "0.10"
base64 = "0.22" base64 = "0.22"
async-stream = "0.3" async-stream = "0.3"
axum = { version = "0.8", features = ["ws"] } axum = { version = "0.8", features = ["ws"] }
# Docker API — matches cm-sandbox's version so the workspace resolves cleanly.
# Used by research_container to spawn/stop per-topic team runtimes via the
# same socket-proxy the server already talks to.
bollard = "0.19"
futures = "0.3" futures = "0.3"
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
+1
View File
@@ -9,6 +9,7 @@ mod mcp_door;
pub mod node_rules; pub mod node_rules;
pub mod quota; pub mod quota;
mod recursive_exec; mod recursive_exec;
pub mod research_container;
mod routes; mod routes;
mod runtime_provision; mod runtime_provision;
pub mod swarm; pub mod swarm;
+218
View File
@@ -0,0 +1,218 @@
//! Per-topic ZeroClaw team containers.
//!
//! `start_topic` calls [`spawn`] after the git clone succeeds; each active
//! research topic gets its own clawmates-runtime container reachable by
//! name over the compose network. The container inherits the parent
//! server's provider config (ZEROCLAW_providers__* + ZEROCLAW_TOKEN),
//! bind-mounts the cloned repo at `/workspace/repo`, and stores per-team
//! ZeroClaw state under `/zeroclaw-data`. The container name and gateway
//! URL persist on `research_topics` so the topology_worker can point
//! `ZeroClawDriveExecutor` at the isolated endpoint for each run.
//!
//! [`stop`] tears the container down on teardown or topic delete. Both
//! functions are idempotent: an already-running container is left alone;
//! an already-stopped container is silently pruned.
use std::collections::HashMap;
use std::path::Path;
use bollard::models::{ContainerCreateBody, HostConfig, Mount, MountTypeEnum};
use bollard::query_parameters::{
CreateContainerOptions, InspectContainerOptions, RemoveContainerOptions, StartContainerOptions,
StopContainerOptions,
};
use bollard::Docker;
use uuid::Uuid;
/// Result of [`spawn`]. Persist both on `research_topics` so the worker
/// and the teardown path can find the container later.
pub struct SpawnedContainer {
pub name: String,
pub gateway_url: String,
}
/// Image tag the spawned team runs. Overridable in prod so a specific
/// pinned digest is used instead of `:latest`. Matches the image the
/// compose stack's `clawmates-runtime` service already uses.
fn team_image() -> String {
std::env::var("CLAWMATES_RESEARCH_TEAM_IMAGE")
.unwrap_or_else(|_| "clawmates-runtime:latest".into())
}
/// Docker network the team joins so `clawmates_server` can reach it by
/// container name (`http://<name>:42617`). Prod: `clawmates_core`.
fn team_network() -> String {
std::env::var("CLAWMATES_RESEARCH_TEAM_NETWORK").unwrap_or_else(|_| "clawmates_core".into())
}
/// The container name for a topic. Deterministic so a restart re-spawns
/// the SAME container (or reattaches if it's still there).
pub fn container_name_for(topic_id: Uuid) -> String {
format!("research-{topic_id}-team")
}
/// Connect to the Docker engine. Uses `DOCKER_HOST` when the compose
/// stack points at the socket-proxy sidecar (prod); falls back to the
/// local socket for dev.
pub fn connect() -> Result<Docker, String> {
if let Ok(host) = std::env::var("DOCKER_HOST") {
Docker::connect_with_http(&host, 30, bollard::API_DEFAULT_VERSION)
.map_err(|e| format!("connect DOCKER_HOST={host}: {e}"))
} else {
Docker::connect_with_local_defaults().map_err(|e| format!("connect local docker: {e}"))
}
}
/// Env vars from the parent server process worth propagating into the
/// team runtime — provider config, tokens, gateway port. Filtered by
/// prefix so we don't drag `PATH`, `HOME`, unrelated secrets, etc.
fn inherited_env() -> Vec<String> {
const PREFIXES: &[&str] = &["ZEROCLAW_", "OPENAI_", "ANTHROPIC_", "GEMINI_", "GROQ_"];
let mut out = Vec::new();
for (k, v) in std::env::vars() {
if PREFIXES.iter().any(|p| k.starts_with(p)) {
// The team runtime binds its own listener + workspace — don't
// let the parent's ZEROCLAW_GATEWAY_URL leak in and confuse it.
if k == "ZEROCLAW_GATEWAY_URL" || k == "ZEROCLAW_WORKSPACE" {
continue;
}
out.push(format!("{k}={v}"));
}
}
// Fixed shape for the team runtime's own listener + workspace root.
out.push("ZEROCLAW_GATEWAY_PORT=42617".into());
out.push("ZEROCLAW_WORKSPACE=/zeroclaw-data/workspace".into());
out
}
/// Spawn (or reattach to) the per-topic team container. Bind-mounts the
/// cloned repo at `/workspace/repo` (rw) and a per-topic state directory
/// at `/zeroclaw-data`. Idempotent: if a container by the expected name
/// already exists it's left alone; if it exists but isn't running it's
/// (re)started. Returns the deterministic gateway URL either way.
pub async fn spawn(
docker: &Docker,
topic_id: Uuid,
repo_host_path: &Path,
state_host_path: &Path,
) -> Result<SpawnedContainer, String> {
let name = container_name_for(topic_id);
let gateway_url = format!("http://{name}:42617");
// If a container by this name already exists, just make sure it's
// running and return its coordinates. Never blow it away — Commit 3
// will add explicit teardown; here we're conservative.
match docker
.inspect_container(&name, None::<InspectContainerOptions>)
.await
{
Ok(info) => {
let running = info.state.as_ref().and_then(|s| s.running).unwrap_or(false);
if !running {
docker
.start_container(&name, None::<StartContainerOptions>)
.await
.map_err(|e| format!("start existing {name}: {e}"))?;
}
return Ok(SpawnedContainer { name, gateway_url });
}
Err(bollard::errors::Error::DockerResponseServerError {
status_code: 404, ..
}) => { /* fall through to create */ }
Err(e) => return Err(format!("inspect {name}: {e}")),
}
// Ensure the host state dir exists so the mount doesn't fail with
// "no such file or directory" the first time a topic starts.
std::fs::create_dir_all(state_host_path)
.map_err(|e| format!("mkdir {}: {e}", state_host_path.display()))?;
let mounts = vec![
Mount {
target: Some("/workspace/repo".into()),
source: Some(repo_host_path.to_string_lossy().into_owned()),
typ: Some(MountTypeEnum::BIND),
read_only: Some(false),
..Default::default()
},
Mount {
target: Some("/zeroclaw-data".into()),
source: Some(state_host_path.to_string_lossy().into_owned()),
typ: Some(MountTypeEnum::BIND),
read_only: Some(false),
..Default::default()
},
];
let host_config = HostConfig {
mounts: Some(mounts),
network_mode: Some(team_network()),
..Default::default()
};
let body = ContainerCreateBody {
image: Some(team_image()),
cmd: Some(vec!["daemon".into(), "--host".into(), "0.0.0.0".into()]),
env: Some(inherited_env()),
host_config: Some(host_config),
labels: Some(HashMap::from([
("clawmates.role".into(), "research-team".into()),
("clawmates.research.topic_id".into(), topic_id.to_string()),
])),
..Default::default()
};
docker
.create_container(
Some(CreateContainerOptions {
name: Some(name.clone()),
..Default::default()
}),
body,
)
.await
.map_err(|e| format!("create {name}: {e}"))?;
docker
.start_container(&name, None::<StartContainerOptions>)
.await
.map_err(|e| format!("start {name}: {e}"))?;
Ok(SpawnedContainer { name, gateway_url })
}
/// Stop and remove the per-topic container. Called on topic delete and on
/// terminal-state cleanup. Idempotent: a missing container is a no-op.
#[allow(dead_code)]
pub async fn stop(docker: &Docker, name: &str) -> Result<(), String> {
match docker
.stop_container(name, None::<StopContainerOptions>)
.await
{
Ok(_) => {}
Err(bollard::errors::Error::DockerResponseServerError {
status_code: 404, ..
}) => return Ok(()),
// 304 = already stopped — fine.
Err(bollard::errors::Error::DockerResponseServerError {
status_code: 304, ..
}) => {}
Err(e) => return Err(format!("stop {name}: {e}")),
}
match docker
.remove_container(
name,
Some(RemoveContainerOptions {
force: true,
..Default::default()
}),
)
.await
{
Ok(_) => Ok(()),
Err(bollard::errors::Error::DockerResponseServerError {
status_code: 404, ..
}) => Ok(()),
Err(e) => Err(format!("remove {name}: {e}")),
}
}
+39
View File
@@ -604,6 +604,45 @@ pub async fn start_topic(
} else { } else {
None None
}; };
// Spawn the per-topic ZeroClaw team runtime (or reattach if one from a
// prior start already exists). Best-effort in this pass — a failure
// leaves the topic pointing at the workspace-wide gateway (env), which
// still works but skips the isolation. Commit 2 wires the executor to
// prefer the topic's URL when populated.
let state_root = research_workspace_root().join(id.to_string()).join("state");
let repo_path_for_container = repo_context
.as_ref()
.map(|c| std::path::PathBuf::from(&c.path));
if let Some(repo_path) = repo_path_for_container {
match crate::research_container::connect() {
Ok(docker) => {
match crate::research_container::spawn(&docker, id, &repo_path, &state_root).await {
Ok(spawned) => {
if let Err(e) = cm_db::repo::research_topics::set_zeroclaw_container(
&state.pool,
id,
user.workspace_id.as_uuid(),
Some(&spawned.name),
Some(&spawned.gateway_url),
)
.await
{
eprintln!(
"research::start_topic({id}): persist container coords failed: {e}"
);
}
}
Err(e) => {
eprintln!("research::start_topic({id}): spawn team container failed: {e}")
}
}
}
Err(e) => eprintln!(
"research::start_topic({id}): docker connect failed: {e} — skipping team container"
),
}
}
// Coordinator = first slot whose role_slot mentions "coordinator" (case- // Coordinator = first slot whose role_slot mentions "coordinator" (case-
// insensitive), else the first slot. The coordinator becomes the hub of // insensitive), else the first slot. The coordinator becomes the hub of
// the hub_spoke graph, so it can talk to every other agent per-turn. // the hub_spoke graph, so it can talk to every other agent per-turn.
+39 -2
View File
@@ -36,6 +36,15 @@ pub struct ResearchTopic {
/// repo. Written once on the first successful clone; subsequent starts /// repo. Written once on the first successful clone; subsequent starts
/// reuse it. Null until then. /// reuse it. Null until then.
pub repo_workspace_path: Option<String>, 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)] #[derive(Debug, Clone, Serialize)]
@@ -79,6 +88,32 @@ pub async fn create(pool: &PgPool, input: NewTopic<'_>) -> Result<Uuid, DbError>
Ok(id) 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 /// 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. /// successful clone; a re-start reads it back and skips re-cloning.
pub async fn set_repo_workspace_path( pub async fn set_repo_workspace_path(
@@ -106,7 +141,8 @@ pub async fn list(pool: &PgPool, workspace_id: Uuid) -> Result<Vec<ResearchTopic
ResearchTopic, ResearchTopic,
"SELECT id, workspace_id, title, description, outcome_kind, status, "SELECT id, workspace_id, title, description, outcome_kind, status,
created_by, created_at, updated_at, published_at, topology_kind, 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 FROM research_topics
WHERE workspace_id = $1 WHERE workspace_id = $1
ORDER BY updated_at DESC", ORDER BY updated_at DESC",
@@ -126,7 +162,8 @@ pub async fn get(
ResearchTopic, ResearchTopic,
"SELECT id, workspace_id, title, description, outcome_kind, status, "SELECT id, workspace_id, title, description, outcome_kind, status,
created_by, created_at, updated_at, published_at, topology_kind, 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 FROM research_topics
WHERE id = $1 AND workspace_id = $2", WHERE id = $1 AND workspace_id = $2",
id, id,
@@ -0,0 +1,14 @@
-- Per-topic ZeroClaw container coordinates. When `start_topic` fires, it
-- spawns an isolated clawmates-runtime container for the team via the
-- socket-proxy the server already talks to, mounts the topic's cloned
-- repo into it, and records the container name + reachable gateway URL
-- here. The topology_worker looks these up when constructing
-- ZeroClawDriveExecutor so each run's turns hit ITS team's daemon, not
-- the shared workspace one.
--
-- Both nullable so a topic can exist before the container spawns and so
-- teardown just NULLs them out.
ALTER TABLE research_topics
ADD COLUMN zeroclaw_container_name TEXT,
ADD COLUMN zeroclaw_gateway_url TEXT;