wizard: 'fresh coding team' picker for paired coding loop
ci / gates (push) Successful in 5s
ci / frontend (push) Successful in 26s
ci / rust (push) Successful in 4m4s
ci / e2e (push) Skipped
ci / publish (push) Successful in 2m36s

Second slice of the per-loop-team arc. The paired-coding-loop checkbox
in ResearchWizard step 6 now exposes a two-option picker:

  ⦿ Provision a dedicated coding team (default when the loop is on)
     — fresh 'Coding · <topic>' team row, risk_profile =
       coding_readwrite, clawmates_door in mcp_bundles. Loop's
       team_id is bound at wizard-submit time.
  ○ Reuse the research team (legacy) — no team_id bound; coding
     iterations spawn against the research topic's container.

Frontend
- New codingTeamMode state, radio picker rendered under the checkbox.
- research.ts createTopic body gains paired_coding_team_mode?: 'fresh'|
  'reuse'.

Backend
- CreateTopicRequest gains paired_coding_team_mode: Option<String>.
- materialize_topic_loops takes it through and, when 'fresh', calls
  the new provision_fresh_coding_team helper — inserts a teams row
  via the existing insert_team_with_lifecycle (pipeline kind, same
  graph as the loop), sets its runtime-config via
  set_team_runtime_config, then binds loop.team_id.
- All operations best-effort with stderr logging — a team-provision
  failure leaves the loop functional under the legacy fallback.

Not shipped in this slice (deferred to runtime hookup slice):
- research_container::spawn keyed on team_id → per-team container
- Config template rewrite injecting the team's risk_profile
- Migration of existing paired loops onto their own teams

The plumbing lands now so the wizard's intent is recorded; the
runtime honors it in the next PR.
This commit is contained in:
Omar Sobh
2026-07-16 20:49:54 -07:00
parent 6066e93889
commit 0b7f247b0e
4 changed files with 128 additions and 0 deletions
+9
View File
@@ -147,6 +147,14 @@ pub struct CreateTopicRequest {
/// kind='exec' loop bound to this topic with on_artifact_update.
#[serde(default)]
pub create_paired_coding_loop: bool,
/// 0045 fold — team disposition for the paired coding loop:
/// Some("fresh") — provision a dedicated coding team with
/// coding_readwrite risk profile (recommended)
/// Some("reuse") — attach the coding loop to the research team
/// (legacy behavior, both share one container)
/// None — treated as "reuse" for backwards compat.
#[serde(default)]
pub paired_coding_team_mode: Option<String>,
}
// The wizard sends a denormalized display object for its own UI. Only
@@ -392,6 +400,7 @@ pub async fn create_topic(
&body.title,
&sched.mode,
body.create_paired_coding_loop,
body.paired_coding_team_mode.as_deref(),
)
.await;
}
@@ -306,6 +306,7 @@ pub async fn build_topic_graph_json(pool: &PgPool, topic_id: Uuid) -> serde_json
/// (D1 fold). Fails soft — logs and returns, letting the topic land
/// even if loop creation stumbles. Skips the empty-roster gate
/// because `create_topic` already verified the workspace has agents.
#[allow(clippy::too_many_arguments)]
pub async fn materialize_topic_loops(
pool: &PgPool,
workspace_id: Uuid,
@@ -314,6 +315,7 @@ pub async fn materialize_topic_loops(
topic_title: &str,
mode: &str,
also_coding: bool,
coding_team_mode: Option<&str>,
) {
use serde_json::json;
// Build a valid topology graph up front — an empty {nodes: [],
@@ -405,6 +407,17 @@ pub async fn materialize_topic_loops(
Some(topic_id),
)
.await;
// 0045 fold — when the wizard picked "fresh" for the
// coding team, provision a dedicated team row with a
// coding_readwrite risk profile and bind it. Runtime
// spawn hookup (per-team container + config write)
// ships in a follow-up slice; the binding here ensures
// the loop already carries its intended team by the
// time that lands.
if coding_team_mode == Some("fresh") {
provision_fresh_coding_team(pool, workspace_id, loop_id, topic_title, &graph)
.await;
}
crate::routes::loops::fire_initial_burst_if_set(
pool,
workspace_id,
@@ -420,3 +433,56 @@ pub async fn materialize_topic_loops(
}
}
}
/// Create a placeholder `teams` row + set the loop's `team_id`. The
/// team is deliberately member-less at this stage — the graph is
/// carried on the loop itself, and the runtime hookup slice will
/// either back-fill members lazily on first spawn or wire the loop's
/// existing agents against the team via `add_member`.
///
/// Best-effort throughout: any failure logs to stderr but the loop
/// itself stays intact and functional under the legacy shared-team
/// fallback.
async fn provision_fresh_coding_team(
pool: &PgPool,
workspace_id: Uuid,
loop_id: Uuid,
topic_title: &str,
graph: &serde_json::Value,
) {
let team_id = Uuid::now_v7();
let team_name = format!("Coding · {topic_title}");
// insert_team_with_lifecycle keeps the topology graph so the
// runtime can reproduce the roster without a second lookup.
let ws = cm_domain::WorkspaceId::from(workspace_id);
if let Err(e) = cm_db::repo::teams::insert_team_with_lifecycle(
pool,
team_id,
ws,
&team_name,
"pipeline",
graph,
"permanent",
)
.await
{
eprintln!("provision_fresh_coding_team: insert_team failed for loop {loop_id}: {e:?}");
return;
}
if let Err(e) = cm_db::repo::teams::set_team_runtime_config(
pool,
team_id,
ws,
&cm_db::repo::teams::TeamRuntimeConfig {
risk_profile: Some("coding_readwrite".to_string()),
mcp_bundles: vec!["clawmates_door".to_string()],
},
)
.await
{
eprintln!("provision_fresh_coding_team: set_runtime_config failed: {e:?}");
}
if let Err(e) = cm_db::repo::teams::set_team_for_loop(pool, loop_id, Some(team_id)).await {
eprintln!("provision_fresh_coding_team: set_team_for_loop failed: {e:?}");
}
}