slice 9 cleanup: drop legacy research/loops backend + tables
Retires the legacy research/loops backend after the missions arc
(slices 1-9) fully replaced it. Frontend cutover was 4663348; this
commit finishes the job on the backend + database.
Migration:
- 0053_drop_legacy_research_loops.sql — drops the 8 legacy tables
(research_topics, research_topic_agents, research_outcomes,
research_publish_approvals, loops, loop_agents, loop_orgs,
loop_teams) and the 3 topology_runs FK columns
(research_topic_id, loop_id, iteration). parent_run_id stays;
recursive_exec still uses it.
Files deleted (11):
- crates/cm-api/src/routes/{research,loops,research_setup,
research_pipeline,wizard_repo,probe}.rs
- crates/cm-api/src/research_container.rs
- crates/cm-db/src/repo/{research_topics,research_outcomes,
research_publish_approvals,loops}.rs
- crates/cm-runtime/src/loops.rs
- crates/cm-api/tests/research_publish_role.rs
Files edited:
- crates/cm-api/src/lib.rs — dropped 20 legacy route registrations
(all /api/research/* + /api/loops/* + /webhooks/loops + probe)
and module decls
- crates/cm-api/src/topology_worker.rs — deleted legacy dispatch
(freeze_research_outcome, advance_loop_after_completion,
continue_initial_burst, maybe_transition_research_topic,
parse_reorder_rationale, per-topic/loop gateway resolver).
reap_stuck_runs now keys on mission_id (not topic_id).
Executor path unconditionally uses ZeroClawDriveExecutor::from_env
— mission_orchestrator provisions each claw as an agent inside
the shared runtime via RuntimeProvisioner, so per-team gateway
resolution is no longer applicable.
- crates/cm-api/src/routes/topology.rs — deleted container-log SSE
endpoint (research/loop-specific), dropped loop_id filter and
iteration field from ListRunsQuery/RunSummary
- crates/cm-api/src/routes/world.rs — removed
active_research_topics/active_loops/preseed_repo_paths;
World SSE no longer emits repo:{topic}/loop:{id} landmark orbs
(follow-up task #21 tracks adding mission:{id} equivalents)
- crates/cm-api/src/runtime_provision.rs — removed now-unused
mint_workspace_service_token
- crates/cm-db/src/repo/topology_runs.rs — removed 9 legacy
helpers (research_topic_id lookup, loop_id_for_run,
iteration_for_run, active_runs_for_research_topic, etc.)
- crates/cm-db/src/repo/teams.rs — removed 4 dead helpers
(team_for_loop, team_for_research_topic + setters)
- crates/cm-api/tests/topology_jobs.rs — removed loop/topic
tests, dropped enqueue_run_with_topic helper
- crates/bins/clawmates-server/src/main.rs — removed
spawn_loop_scheduler call
- crates/cm-api/src/routes/mod.rs, crates/cm-db/src/repo/mod.rs,
crates/cm-runtime/src/lib.rs — module decls stripped
sqlx cache: regenerated against post-migration schema
(71 files changed, ~+70 / -8896 net)
Test/build: SQLX_OFFLINE=true cargo check --workspace clean;
cargo test --workspace --no-run clean.
Follow-up (task #21): World view lost the in-flight-work landmarks
when repo:{topic} / loop:{id} orbs disappeared. Add mission:{id}
orbs as the missions-era replacement.
This commit is contained in:
@@ -66,21 +66,20 @@ pub fn spawn(pool: PgPool, runtime: cm_runtime::Runtime, poll: Duration) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Kill research team containers whose `running` topology_run has been
|
||||
/// alive past [`REAP_STUCK_AFTER_SECS`] without journaling a single
|
||||
/// step record. Marks the run `failed` with a diagnostic error so the
|
||||
/// user sees WHY instead of an infinitely-spinning pipeline.
|
||||
/// Mark `running` mission-bound topology_runs that have been alive past
|
||||
/// [`REAP_STUCK_AFTER_SECS`] without journaling a single step record as
|
||||
/// `failed`, with a diagnostic error so the user sees WHY instead of an
|
||||
/// infinitely-spinning pipeline.
|
||||
///
|
||||
/// Only reaps runs bound to a research topic — non-research runs (raw
|
||||
/// API-driven topology runs) don't own a container so there's nothing
|
||||
/// to kill; they're left to the existing stale-checkpoint requeuer.
|
||||
/// Only reaps runs bound to a mission — non-mission runs (raw API-driven
|
||||
/// topology runs) are left to the existing stale-checkpoint requeuer.
|
||||
async fn reap_stuck_runs(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
use sqlx::Row;
|
||||
let rows: Vec<sqlx::postgres::PgRow> = sqlx::query(
|
||||
"SELECT id, research_topic_id
|
||||
"SELECT id, mission_id
|
||||
FROM topology_runs
|
||||
WHERE status = 'running'
|
||||
AND research_topic_id IS NOT NULL
|
||||
AND mission_id IS NOT NULL
|
||||
AND created_at < now() - make_interval(secs => $1::float)
|
||||
AND coalesce(jsonb_array_length(coalesce(checkpoint->'records', '[]'::jsonb)), 0) = 0",
|
||||
)
|
||||
@@ -88,36 +87,19 @@ async fn reap_stuck_runs(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
if rows.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Best-effort docker cleanup; even if the container is already gone
|
||||
// (crashed, manually killed), we still want to mark the run failed.
|
||||
let docker = crate::research_container::connect().ok();
|
||||
|
||||
for row in rows {
|
||||
let id: Uuid = row.get("id");
|
||||
let topic_id: Uuid = row.get("research_topic_id");
|
||||
let container = crate::research_container::container_name_for(topic_id);
|
||||
let mission_id: Uuid = row.get("mission_id");
|
||||
eprintln!(
|
||||
"topology_worker::reaper: reaping stuck run run_id={} topic_id={} container={} (no step records after {}s)",
|
||||
id, topic_id, container, REAP_STUCK_AFTER_SECS,
|
||||
"topology_worker::reaper: reaping stuck run run_id={} mission_id={} (no step records after {}s)",
|
||||
id, mission_id, REAP_STUCK_AFTER_SECS,
|
||||
);
|
||||
if let Some(d) = &docker {
|
||||
let _ = d
|
||||
.stop_container(
|
||||
&container,
|
||||
None::<bollard::query_parameters::StopContainerOptions>,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let _ = cm_db::repo::topology_runs::fail(
|
||||
pool,
|
||||
id,
|
||||
&format!(
|
||||
"reaped: no step records after {}s (container {} stopped)",
|
||||
REAP_STUCK_AFTER_SECS, container
|
||||
"reaped: no step records after {}s",
|
||||
REAP_STUCK_AFTER_SECS
|
||||
),
|
||||
)
|
||||
.await;
|
||||
@@ -150,7 +132,7 @@ async fn run_job(
|
||||
let _ = cm_db::repo::topology_runs::fail(pool, id, &e).await;
|
||||
}
|
||||
}
|
||||
maybe_transition_research_topic(pool, id).await;
|
||||
maybe_teardown_ephemeral_team(pool, id).await;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -169,82 +151,11 @@ async fn run_job(
|
||||
.and_then(|c| serde_json::from_value(c).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
// If this run belongs to a research topic OR a loop with a per-team
|
||||
// ZeroClaw container spawned, point the executor at THAT container's
|
||||
// gateway URL so the run's turns hit its isolated daemon instead of
|
||||
// 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).
|
||||
// 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.
|
||||
// Team path is authoritative when the run's loop has team_id set:
|
||||
// if we can't spawn the team container, FAIL the run instead of
|
||||
// silently degrading to the shared runtime. The shared runtime
|
||||
// doesn't bind /workspace/repo, so coding agents would spend their
|
||||
// turns narrating without touching files — a much worse failure
|
||||
// mode than a red run with a clear error.
|
||||
let per_topic_url =
|
||||
match try_team_gateway_url(pool, id, WorkspaceId::from(job.workspace_id)).await {
|
||||
Ok(url) => url,
|
||||
Err(e) => {
|
||||
eprintln!("topology_worker: team gateway resolution failed for run {id}: {e}");
|
||||
let _ = cm_db::repo::topology_runs::fail(
|
||||
pool,
|
||||
id,
|
||||
&format!("team container unavailable — {e}"),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
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,
|
||||
}
|
||||
};
|
||||
// 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
|
||||
// existing research fast path unchanged.
|
||||
let per_topic_url = if per_topic_url.is_some() {
|
||||
per_topic_url
|
||||
} else {
|
||||
match cm_db::repo::topology_runs::loop_id_for_run(pool, id).await {
|
||||
Ok(Some(loop_id)) => cm_db::repo::loops::zeroclaw_gateway_url(pool, loop_id)
|
||||
.await
|
||||
.unwrap_or(None),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
if let Some(url) = &per_topic_url {
|
||||
// Best-effort readiness gate — a freshly-spawned team container may
|
||||
// still be starting when the worker claims the run. Cap the wait so
|
||||
// a broken image can't hang the worker.
|
||||
if let Err(e) =
|
||||
crate::research_container::wait_ready(url, std::time::Duration::from_secs(30)).await
|
||||
{
|
||||
eprintln!("topology_worker: research team {url} readiness: {e} — proceeding anyway");
|
||||
}
|
||||
}
|
||||
let leaf_result = match &per_topic_url {
|
||||
Some(url) => ZeroClawDriveExecutor::from_env_for_gateway(url.clone()),
|
||||
None => ZeroClawDriveExecutor::from_env(),
|
||||
};
|
||||
// Missions-era runs drive through the shared, env-derived ZeroClaw
|
||||
// gateway — per-claw provisioning happens ahead of time via
|
||||
// `RuntimeProvisioner` (see `mission_orchestrator::on_launch`), so
|
||||
// there's no per-run container/gateway resolution left to do here.
|
||||
let leaf_result = ZeroClawDriveExecutor::from_env();
|
||||
let leaf = match leaf_result {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
@@ -281,9 +192,6 @@ async fn run_job(
|
||||
if let Err(e) = cm_db::repo::topology_runs::complete(pool, id, &value).await {
|
||||
eprintln!("topology_worker: complete({id}) failed: {e}");
|
||||
}
|
||||
freeze_research_outcome(pool, id, &record.final_output).await;
|
||||
advance_loop_after_completion(pool, id, &record.final_output).await;
|
||||
continue_initial_burst(pool, id).await;
|
||||
}
|
||||
Err(e) => {
|
||||
// Don't clobber a cancellation (or any already-terminal state) with `failed`.
|
||||
@@ -296,231 +204,6 @@ async fn run_job(
|
||||
}
|
||||
}
|
||||
}
|
||||
maybe_transition_research_topic(pool, id).await;
|
||||
}
|
||||
|
||||
/// If this run belongs to a research topic, snapshot the orchestrator's
|
||||
/// final synthesis as a versioned `research_outcomes` row. The frontend
|
||||
/// canvas reads `latest_outcome` for anything past `standby` so reviewers
|
||||
/// see the produced draft rather than the original prompt. Best-effort:
|
||||
/// a failure here logs but doesn't fail the run.
|
||||
async fn freeze_research_outcome(pool: &PgPool, run_id: Uuid, final_output: &str) {
|
||||
let topic_id = match cm_db::repo::topology_runs::research_topic_id(pool, run_id).await {
|
||||
Ok(Some(id)) => id,
|
||||
Ok(None) => return,
|
||||
Err(e) => {
|
||||
eprintln!("topology_worker: research_topic_id({run_id}) failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if final_output.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Err(e) =
|
||||
cm_db::repo::research_outcomes::insert(pool, topic_id, final_output, Some(run_id)).await
|
||||
{
|
||||
eprintln!("topology_worker: research_outcomes::insert({run_id}) failed: {e}");
|
||||
return;
|
||||
}
|
||||
// Fan-out: any exec-kind loop bound to this topic with the
|
||||
// on_artifact_update trigger enabled wakes up now. Coalesced —
|
||||
// if a loop already has a queued/running run we skip (D3 fallback:
|
||||
// the coordinator sees the fresh artifact on its next iteration
|
||||
// anyway). Best-effort per loop; one loop's Docker/DB hiccup
|
||||
// doesn't affect the others.
|
||||
let awakened = cm_db::repo::loops::loops_awaiting_topic(pool, topic_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
for (loop_id, workspace_id, task_template, graph) in awakened {
|
||||
if cm_db::repo::loops::has_active_run(pool, loop_id)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue; // Coalesce.
|
||||
}
|
||||
// Fan-outs always target kind='exec' (filter enforced in
|
||||
// loops_awaiting_topic). compose_and_enqueue_iteration takes
|
||||
// the exec path and prepends the freshly-inserted artifact.
|
||||
if let Err(e) = crate::routes::loops::compose_and_enqueue_iteration(
|
||||
pool,
|
||||
loop_id,
|
||||
workspace_id,
|
||||
&graph,
|
||||
Some(run_id),
|
||||
Some(&task_template),
|
||||
)
|
||||
.await
|
||||
{
|
||||
eprintln!("topology_worker: on_artifact_update enqueue({loop_id}) failed: {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// If the just-completed run was a loop iteration with
|
||||
/// initial_burst_remaining > 0, enqueue the next iteration and
|
||||
/// decrement the counter (CAS-safe via take_initial_burst_slot).
|
||||
/// No-op for non-loop runs and for loops whose burst is exhausted.
|
||||
async fn continue_initial_burst(pool: &PgPool, run_id: Uuid) {
|
||||
let loop_id = match cm_db::repo::topology_runs::loop_id_for_run(pool, run_id).await {
|
||||
Ok(Some(id)) => id,
|
||||
_ => return,
|
||||
};
|
||||
// If another worker races us, only ONE gets the slot; the other
|
||||
// sees 0 (no-op).
|
||||
let prev = cm_db::repo::loops::take_initial_burst_slot(pool, loop_id)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
if prev == 0 {
|
||||
return;
|
||||
}
|
||||
// Coalesce with a concurrently-in-flight iteration (a webhook
|
||||
// arriving during a burst, say).
|
||||
if cm_db::repo::loops::has_active_run(pool, loop_id)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// Fetch the loop so we have the workspace + graph. The kind-aware
|
||||
// dispatcher pulls task_template + kind from the same helper it
|
||||
// uses at first fire, so bursts across an exec + research pair
|
||||
// behave identically.
|
||||
let Ok(Some(l)) = cm_db::repo::loops::get_any_workspace(pool, loop_id).await else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = crate::routes::loops::compose_and_enqueue_iteration(
|
||||
pool,
|
||||
loop_id,
|
||||
l.workspace_id,
|
||||
&l.graph,
|
||||
Some(run_id),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
eprintln!("topology_worker: continue_initial_burst enqueue failed: {e:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Post-terminal hook for loop-bound runs. Parses `COMPLETED: INT-<NN>`
|
||||
/// markers out of the run's final output and advances the loop's
|
||||
/// `consumed_int_ids` + `current_int_index`. Only fires for runs that
|
||||
/// belong to a loop AND that loop is bound to a source research topic
|
||||
/// (the integrations flow). Standalone loops or unbound runs no-op.
|
||||
///
|
||||
/// The marker parser is deliberately forgiving — accepts INT-XX and
|
||||
/// INT-XXX, optionally with surrounding backticks or dashes, so
|
||||
/// coordinator prompts that emit slightly different formats still
|
||||
/// advance the pointer.
|
||||
async fn advance_loop_after_completion(pool: &PgPool, run_id: Uuid, final_output: &str) {
|
||||
let loop_id = match cm_db::repo::topology_runs::loop_id_for_run(pool, run_id).await {
|
||||
Ok(Some(id)) => id,
|
||||
_ => return,
|
||||
};
|
||||
let ctx = match cm_db::repo::loops::source_research_context(pool, loop_id).await {
|
||||
Ok(Some(c)) => c,
|
||||
_ => return, // Not a research-bound loop; nothing to advance.
|
||||
};
|
||||
let mut completed = parse_completed_int_ids(final_output);
|
||||
// Drop items already recorded so re-runs don't double-count.
|
||||
let (_topic, already, _idx) = ctx;
|
||||
completed.retain(|id| !already.contains(id));
|
||||
if !completed.is_empty() {
|
||||
if let Err(e) =
|
||||
cm_db::repo::loops::advance_after_completion(pool, loop_id, &completed).await
|
||||
{
|
||||
eprintln!("topology_worker: loops::advance_after_completion({loop_id}) failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Reorder rationale — coordinator emits "REORDER: <text>" when it
|
||||
// works on an INT-XX out of order (usually because a prereq was
|
||||
// unmet). Append each occurrence to the loop's reorder_events
|
||||
// array so a mini-timeline UI can surface the history. Iteration
|
||||
// number comes from topology_runs; -1 if the lookup fails (best-
|
||||
// effort — we still record the event with a sentinel).
|
||||
let iteration = cm_db::repo::topology_runs::iteration_for_run(pool, run_id)
|
||||
.await
|
||||
.unwrap_or(Some(-1))
|
||||
.unwrap_or(-1);
|
||||
for text in parse_reorder_rationale(final_output) {
|
||||
if let Err(e) =
|
||||
cm_db::repo::loops::append_reorder_event(pool, loop_id, run_id, iteration, &text).await
|
||||
{
|
||||
eprintln!("topology_worker: loops::append_reorder_event({loop_id}) failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract "REORDER: <text>" rationales — one per line the coordinator
|
||||
/// emits when it works out of order. Same permissive line matcher as
|
||||
/// the completed-marker parser (list dashes, backticks, emphasis).
|
||||
/// Returns the text after the colon, trimmed. Skips empty rationales.
|
||||
fn parse_reorder_rationale(text: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for line in text.lines() {
|
||||
let normalized = line.trim_start_matches(|c: char| {
|
||||
c.is_whitespace() || c == '-' || c == '*' || c == '#' || c == '>'
|
||||
});
|
||||
let upper = normalized.to_ascii_uppercase();
|
||||
if !upper.starts_with("REORDER:") {
|
||||
continue;
|
||||
}
|
||||
// Preserve original case of the rationale text — only the
|
||||
// marker matched case-insensitively.
|
||||
let colon = normalized.find(':').map(|i| i + 1).unwrap_or(0);
|
||||
let rationale = normalized[colon..].trim();
|
||||
if !rationale.is_empty() {
|
||||
out.push(rationale.to_string());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Extract stable INT-XX ids from a completion line. Matches
|
||||
/// `COMPLETED: INT-01`, `COMPLETED: INT-01, INT-02`, or `- COMPLETED: `INT-01``.
|
||||
/// De-duplicates within a single output.
|
||||
fn parse_completed_int_ids(text: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for line in text.lines() {
|
||||
// Case-insensitive, tolerates surrounding whitespace, list dashes,
|
||||
// markdown emphasis, and backticks.
|
||||
let normalized = line.trim_start_matches(|c: char| {
|
||||
c.is_whitespace() || c == '-' || c == '*' || c == '#' || c == '>'
|
||||
});
|
||||
let upper = normalized.to_ascii_uppercase();
|
||||
if !upper.starts_with("COMPLETED:") {
|
||||
continue;
|
||||
}
|
||||
for token in upper
|
||||
.trim_start_matches("COMPLETED:")
|
||||
.split(|c: char| c == ',' || c == ';' || c.is_whitespace())
|
||||
{
|
||||
let stripped = token.trim_matches(|c: char| c == '`' || c == '*' || c == '.');
|
||||
if stripped.starts_with("INT-")
|
||||
&& stripped.len() >= 5
|
||||
&& seen.insert(stripped.to_string())
|
||||
{
|
||||
out.push(stripped.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Post-terminal hook: if this run belongs to a research topic and it was
|
||||
/// the last sibling in flight, transition the topic `processing → reviewing`.
|
||||
/// Best-effort — a DB hiccup here logs but doesn't fail the run.
|
||||
async fn maybe_transition_research_topic(pool: &PgPool, id: Uuid) {
|
||||
match cm_db::repo::topology_runs::notify_run_completed(pool, id).await {
|
||||
Ok(true) => {
|
||||
// Left intentionally quiet on success; the UI polls the topic
|
||||
// status. Future: emit a run_event so live viewers see it flip.
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(e) => eprintln!("topology_worker: notify_run_completed({id}) failed: {e}"),
|
||||
}
|
||||
maybe_teardown_ephemeral_team(pool, id).await;
|
||||
}
|
||||
|
||||
@@ -630,143 +313,3 @@ 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.
|
||||
/// Resolve the team-scoped ZeroClaw gateway URL for a run.
|
||||
///
|
||||
/// Returns:
|
||||
/// - `Ok(Some(url))` — this run's loop has a `team_id` and the team
|
||||
/// container is spawned (or reattached) and ready to drive.
|
||||
/// - `Ok(None)` — the run has no team binding at all; the caller should
|
||||
/// fall through to the legacy per-topic / per-loop / shared-runtime
|
||||
/// resolvers.
|
||||
/// - `Err(msg)` — the run's loop DOES have a `team_id` but the team
|
||||
/// container couldn't be spawned. The caller MUST fail the run;
|
||||
/// silently degrading to the shared runtime hides real infra breakage
|
||||
/// and leaves the agents narrating instead of touching the repo.
|
||||
async fn try_team_gateway_url(
|
||||
pool: &PgPool,
|
||||
run_id: Uuid,
|
||||
workspace_id: WorkspaceId,
|
||||
) -> Result<Option<String>, String> {
|
||||
let Some(loop_id) = cm_db::repo::topology_runs::loop_id_for_run(pool, run_id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(team_id) = cm_db::repo::teams::team_for_loop(pool, loop_id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// 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 Ok(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 = match cm_db::repo::loops::source_research_context(pool, loop_id).await {
|
||||
Ok(Some((tid, _consumed, _idx))) => tid,
|
||||
Ok(None) => {
|
||||
return Err(format!(
|
||||
"team {team_id} has no paired source_research_topic_id; \
|
||||
coding loops need a research topic to bind /workspace/repo"
|
||||
));
|
||||
}
|
||||
Err(e) => return Err(format!("source_research_context({loop_id}): {e}")),
|
||||
};
|
||||
let source_topic = match cm_db::repo::research_topics::get(
|
||||
pool,
|
||||
source_topic_id,
|
||||
workspace_id.as_uuid(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(t)) => t,
|
||||
Ok(None) => {
|
||||
return Err(format!(
|
||||
"source research topic {source_topic_id} not found in workspace"
|
||||
));
|
||||
}
|
||||
Err(e) => return Err(format!("research_topics::get({source_topic_id}): {e}")),
|
||||
};
|
||||
let Some(repo_path) = source_topic.repo_workspace_path.clone() else {
|
||||
return Err(format!(
|
||||
"source research topic {source_topic_id} has no repo_workspace_path; \
|
||||
team can't bind /workspace/repo"
|
||||
));
|
||||
};
|
||||
|
||||
// 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| format!("docker connect failed for team {team_id}: {e}"))?;
|
||||
let state_root = crate::research_container::team_state_root(team_id);
|
||||
|
||||
// Mint a workspace-owner service session so the team runtime's
|
||||
// clawmates_door MCP calls pass cm-auth (the static bearer baked
|
||||
// into the template config isn't a valid auth_sessions row and
|
||||
// gets 401'd, leaving every agent with 0 tools). MCP is best-effort:
|
||||
// if the mint fails we still spawn the container with the stale
|
||||
// bearer — some tools will 401 but the run isn't wholly broken.
|
||||
let mcp_bearer = crate::runtime_provision::mint_workspace_service_token(pool, workspace_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
eprintln!("try_team_gateway_url: mint MCP bearer failed for team {team_id}: {e}");
|
||||
e
|
||||
})
|
||||
.ok();
|
||||
|
||||
let spawned = crate::research_container::spawn_team(
|
||||
&docker,
|
||||
team_id,
|
||||
std::path::Path::new(&repo_path),
|
||||
&state_root,
|
||||
risk_profile.as_deref(),
|
||||
mcp_bearer.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("spawn_team({team_id}): {e}"))?;
|
||||
|
||||
// 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}");
|
||||
}
|
||||
|
||||
Ok(Some(spawned.gateway_url))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user