teams: runtime spawn hookup for per-team containers (slice 3b)
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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user