teams: runtime spawn hookup for per-team containers (slice 3b)
ci / gates (push) Successful in 17s
ci / frontend (push) Successful in 28s
ci / rust (push) Successful in 3m58s
ci / e2e (push) Skipped
ci / publish (push) Successful in 2m54s

Wires the per-loop-team arc end-to-end. When a loop with team_id
fires an iteration, the worker now spawns/reattaches a dedicated
team container, mounts the paired research topic's repo at
/workspace/repo (rw), and stamps the team's risk_profile into every
[agents.*] binding on the freshly-written config.toml. Legacy loops
with team_id = NULL keep taking the per-topic / per-loop path
unchanged.

research_container.rs
- team_container_name_for(team_id) = 'team-<uuid>-container'
- team_state_root(team_id) — /var/lib/clawmates-team-state/<uuid>/state
  (overridable via CLAWMATES_TEAM_STATE_ROOT)
- prewrite_daemon_config_with_risk: line-based sed that swaps only
  the risk_profile line inside each [agents.<name>] block. Avoids
  the regex-eats-array-literal trap that bricked the shared runtime
  config on the earlier out-of-band edit.
- spawn_team: full-shape idempotent spawner. Same mount + env + label
  pattern as spawn/spawn_loop; additionally supports Claude settings
  bind-mount + external-bridge attach.

topology_worker.rs
- try_team_gateway_url resolver runs BEFORE the existing per-topic
  and per-loop lookups. Cold path: reads team runtime config + paired
  research topic repo path, spawns the container, persists coords
  back to teams.zeroclaw_container/zeroclaw_gateway_url. Any failure
  logs + returns None so the caller falls through to the legacy
  shared-container path — team spawn can never brick a run that
  could otherwise complete.

Not shipped in this slice:
- Wizard 'existing team' picker (currently just fresh vs reuse)
- Teams tier UI to list/edit/delete teams
- Auto-teardown for stale team containers (piggyback on existing
  reaper is a follow-up)
This commit is contained in:
Omar Sobh
2026-07-17 06:58:35 -07:00
parent d687a00524
commit 0d0bb5ffaa
2 changed files with 302 additions and 6 deletions
+186
View File
@@ -551,3 +551,189 @@ pub async fn teardown_loop(loop_id: Uuid) {
} }
} }
} }
// ── 0046 slice 3b: per-team containers ─────────────────────────────
//
// A `team-<team_id>-container` runs the same ZeroClaw daemon image
// as research-* + loop-* containers but binds the paired research
// topic's repo at /workspace/repo (rw, so a coding team can actually
// write patches) and injects the team's risk_profile into every
// [agents.<name>] binding before boot. That lets a coding team
// operate under `coding_readwrite` without loosening the research
// team's read-only posture on the sibling container.
//
// State dir: /var/lib/clawmates-team-state/<team_id>/state — separate
// from research (per-topic) and loop (per-loop) dirs so the three
// families can't step on each other's config/brain/workspace state.
/// Deterministic container name for a team-scoped runtime.
pub fn team_container_name_for(team_id: Uuid) -> String {
format!("team-{team_id}-container")
}
/// Root for per-team state dirs on the docker host. Overridable via
/// `CLAWMATES_TEAM_STATE_ROOT` for local dev / relocations.
pub fn team_state_root(team_id: Uuid) -> std::path::PathBuf {
let root = std::env::var("CLAWMATES_TEAM_STATE_ROOT")
.unwrap_or_else(|_| "/var/lib/clawmates-team-state".to_string());
std::path::PathBuf::from(root)
.join(team_id.to_string())
.join("state")
}
/// Same as `prewrite_daemon_config` but also rewrites every
/// `[agents.<name>]` block's `risk_profile = "..."` line to the given
/// override. Idempotent: skips if the config file already exists.
///
/// The rewrite is line-based so it can't accidentally eat a `[...]`
/// array literal (which the earlier regex-based patcher on gw-04
/// tripped over): only lines that both (a) live inside an
/// `[agents.<name>]` table AND (b) start with the literal
/// `risk_profile = "` prefix are touched.
fn prewrite_daemon_config_with_risk(
state_host_path: &Path,
risk_profile_override: Option<&str>,
) -> Result<(), String> {
prewrite_daemon_config(state_host_path)?;
let Some(override_name) = risk_profile_override else {
return Ok(());
};
let cfg_path = state_host_path.join(".zeroclaw/config.toml");
let src = std::fs::read_to_string(&cfg_path)
.map_err(|e| format!("read {}: {e}", cfg_path.display()))?;
let mut out = String::with_capacity(src.len());
let mut in_agent_block = false;
let replacement = format!("risk_profile = \"{override_name}\"\n");
for line in src.lines() {
if line.starts_with("[agents.") {
in_agent_block = true;
out.push_str(line);
out.push('\n');
continue;
}
if in_agent_block && line.starts_with('[') {
in_agent_block = false;
}
if in_agent_block && line.starts_with("risk_profile = \"") {
out.push_str(&replacement);
} else {
out.push_str(line);
out.push('\n');
}
}
std::fs::write(&cfg_path, out).map_err(|e| format!("write {}: {e}", cfg_path.display()))?;
Ok(())
}
/// Spawn (or reattach to) a team-scoped ZeroClaw container.
///
/// Bind-mounts `repo_host_path` at `/workspace/repo` (RW — coding teams
/// write here) plus `state_host_path` at `/zeroclaw-data`. When set,
/// `risk_profile` gets stamped into every `[agents.*]` binding in the
/// pre-written config so all roles inherit the team's constitution.
///
/// Idempotent on the container name so a re-fire of the topology
/// worker reattaches instead of blowing up.
pub async fn spawn_team(
docker: &Docker,
team_id: Uuid,
repo_host_path: &Path,
state_host_path: &Path,
risk_profile: Option<&str>,
) -> Result<SpawnedContainer, String> {
let name = team_container_name_for(team_id);
let gateway_url = format!("http://{name}:42617");
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}"))?;
}
attach_external_bridge(docker, &name).await;
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}")),
}
std::fs::create_dir_all(state_host_path)
.map_err(|e| format!("mkdir {}: {e}", state_host_path.display()))?;
prewrite_daemon_config_with_risk(state_host_path, risk_profile)?;
let mut 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()
},
];
if let Ok(claude_settings_path) = std::env::var("CLAWMATES_CLAUDE_SETTINGS_PATH") {
if !claude_settings_path.is_empty() {
mounts.push(Mount {
target: Some("/root/.claude/settings.json".into()),
source: Some(claude_settings_path),
typ: Some(MountTypeEnum::BIND),
read_only: Some(true),
..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(),
"--verbose".into(),
]),
env: Some(inherited_env()),
host_config: Some(host_config),
labels: Some(HashMap::from([
("clawmates.role".into(), "team".into()),
("clawmates.team_id".into(), team_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}"))?;
attach_external_bridge(docker, &name).await;
Ok(SpawnedContainer { name, gateway_url })
}
+111 -1
View File
@@ -175,7 +175,21 @@ async fn run_job(
// the workspace-wide one. Falls back to the env-derived executor when // the workspace-wide one. Falls back to the env-derived executor when
// there's no per-topic/loop container (chat sessions, or research/loop // there's no per-topic/loop container (chat sessions, or research/loop
// runs where spawn failed and we recorded no URL). // runs where spawn failed and we recorded no URL).
let per_topic_url = match cm_db::repo::topology_runs::research_topic_id(pool, id).await { // 0046 slice 3b — highest-priority resolver: when the run's loop
// has a team_id set (wizard picked "fresh coding team"), spawn/
// reattach the team-scoped container and route this iteration
// through it. The team container inherits the paired research
// topic's repo path (needed for coding agents to write patches)
// + the team's configured risk_profile.
//
// Falls through to the legacy per-topic / per-loop URL resolvers
// when there's no team binding — safe backward-compat for every
// existing loop with team_id = NULL.
let per_topic_url = try_team_gateway_url(pool, id, WorkspaceId::from(job.workspace_id)).await;
let per_topic_url = if per_topic_url.is_some() {
per_topic_url
} else {
match cm_db::repo::topology_runs::research_topic_id(pool, id).await {
Ok(Some(topic_id)) => { Ok(Some(topic_id)) => {
match cm_db::repo::research_topics::get(pool, topic_id, job.workspace_id).await { match cm_db::repo::research_topics::get(pool, topic_id, job.workspace_id).await {
Ok(Some(t)) => t.zeroclaw_gateway_url, Ok(Some(t)) => t.zeroclaw_gateway_url,
@@ -183,6 +197,7 @@ async fn run_job(
} }
} }
_ => None, _ => None,
}
}; };
// Loop lookup runs only when the research lookup didn't hit — a run // Loop lookup runs only when the research lookup didn't hit — a run
// is bound to at most one of {topic, loop}. This preserves the // is bound to at most one of {topic, loop}. This preserves the
@@ -595,3 +610,98 @@ async fn drive<E: TurnExecutor>(
}) })
.await .await
} }
/// 0046 slice 3b: resolve the run's team-scoped gateway URL.
///
/// Returns `Some(url)` when the run belongs to a loop whose team_id
/// is set and either the team already has a persisted gateway URL
/// or we can spawn one now (the paired research topic's repo path
/// must resolve so the team container has something to bind at
/// `/workspace/repo`).
///
/// Any missing prereq returns `None` so the caller falls through to
/// the legacy per-topic / per-loop resolvers. Every failure logs to
/// stderr and downgrades to `None` — a broken team resolution must
/// never brick a run that could otherwise complete on the shared
/// research container.
async fn try_team_gateway_url(
pool: &PgPool,
run_id: Uuid,
workspace_id: WorkspaceId,
) -> Option<String> {
let loop_id = cm_db::repo::topology_runs::loop_id_for_run(pool, run_id)
.await
.ok()
.flatten()?;
let team_id = cm_db::repo::teams::team_for_loop(pool, loop_id)
.await
.ok()
.flatten()?;
// Reattach fast path — team already has a persisted URL.
if let Ok(Some((_container, Some(url)))) =
cm_db::repo::teams::team_container_coords(pool, team_id, workspace_id).await
{
return Some(url);
}
// Cold path — need to spawn. Repo path comes from the paired
// research topic (loops.source_research_topic_id + research_topics.
// repo_workspace_path). Without a repo we can't spawn a coding
// team container (nothing meaningful to bind at /workspace/repo).
let (source_topic_id, _consumed, _idx) =
cm_db::repo::loops::source_research_context(pool, loop_id)
.await
.ok()
.flatten()?;
let source_topic =
cm_db::repo::research_topics::get(pool, source_topic_id, workspace_id.as_uuid())
.await
.ok()
.flatten()?;
let repo_path = source_topic.repo_workspace_path.clone()?;
// Team's risk_profile → stamped into every [agents.*] binding on
// the freshly-written config.toml.
let risk_profile = cm_db::repo::teams::get_team_runtime_config(pool, team_id, workspace_id)
.await
.ok()
.flatten()
.and_then(|c| c.risk_profile);
let docker = crate::research_container::connect()
.map_err(|e| {
eprintln!("try_team_gateway_url: docker connect failed for team {team_id}: {e}");
e
})
.ok()?;
let state_root = crate::research_container::team_state_root(team_id);
let spawned = crate::research_container::spawn_team(
&docker,
team_id,
std::path::Path::new(&repo_path),
&state_root,
risk_profile.as_deref(),
)
.await
.map_err(|e| {
eprintln!("try_team_gateway_url: spawn_team failed for team {team_id}: {e}");
e
})
.ok()?;
// Persist coords so future iterations skip the spawn dance.
if let Err(e) = cm_db::repo::teams::set_team_container_coords(
pool,
team_id,
workspace_id,
Some(&spawned.name),
Some(&spawned.gateway_url),
)
.await
{
eprintln!("try_team_gateway_url: persist coords failed for team {team_id}: {e}");
}
Some(spawned.gateway_url)
}