research: spawn per-topic ZeroClaw team container on start (commit 1/3)
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:
@@ -604,6 +604,45 @@ pub async fn start_topic(
|
||||
} else {
|
||||
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-
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user