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:
@@ -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 })
|
||||||
|
}
|
||||||
|
|||||||
@@ -175,14 +175,29 @@ 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
|
||||||
Ok(Some(topic_id)) => {
|
// has a team_id set (wizard picked "fresh coding team"), spawn/
|
||||||
match cm_db::repo::research_topics::get(pool, topic_id, job.workspace_id).await {
|
// reattach the team-scoped container and route this iteration
|
||||||
Ok(Some(t)) => t.zeroclaw_gateway_url,
|
// through it. The team container inherits the paired research
|
||||||
_ => None,
|
// 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
|
// 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)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user