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
+116 -6
View File
@@ -175,14 +175,29 @@ async fn run_job(
// the workspace-wide one. Falls back to the env-derived executor when
// there's no per-topic/loop container (chat sessions, or research/loop
// 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 {
Ok(Some(topic_id)) => {
match cm_db::repo::research_topics::get(pool, topic_id, job.workspace_id).await {
Ok(Some(t)) => t.zeroclaw_gateway_url,
_ => None,
// 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)) => {
match cm_db::repo::research_topics::get(pool, topic_id, job.workspace_id).await {
Ok(Some(t)) => t.zeroclaw_gateway_url,
_ => None,
}
}
_ => None,
}
_ => None,
};
// 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
@@ -595,3 +610,98 @@ async fn drive<E: TurnExecutor>(
})
.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)
}