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. /// kind='exec' loop bound to this topic with on_artifact_update.
#[serde(default)] #[serde(default)]
pub create_paired_coding_loop: bool, 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 // The wizard sends a denormalized display object for its own UI. Only
@@ -392,6 +400,7 @@ pub async fn create_topic(
&body.title, &body.title,
&sched.mode, &sched.mode,
body.create_paired_coding_loop, body.create_paired_coding_loop,
body.paired_coding_team_mode.as_deref(),
) )
.await; .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 /// (D1 fold). Fails soft — logs and returns, letting the topic land
/// even if loop creation stumbles. Skips the empty-roster gate /// even if loop creation stumbles. Skips the empty-roster gate
/// because `create_topic` already verified the workspace has agents. /// because `create_topic` already verified the workspace has agents.
#[allow(clippy::too_many_arguments)]
pub async fn materialize_topic_loops( pub async fn materialize_topic_loops(
pool: &PgPool, pool: &PgPool,
workspace_id: Uuid, workspace_id: Uuid,
@@ -314,6 +315,7 @@ pub async fn materialize_topic_loops(
topic_title: &str, topic_title: &str,
mode: &str, mode: &str,
also_coding: bool, also_coding: bool,
coding_team_mode: Option<&str>,
) { ) {
use serde_json::json; use serde_json::json;
// Build a valid topology graph up front — an empty {nodes: [], // Build a valid topology graph up front — an empty {nodes: [],
@@ -405,6 +407,17 @@ pub async fn materialize_topic_loops(
Some(topic_id), Some(topic_id),
) )
.await; .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( crate::routes::loops::fire_initial_burst_if_set(
pool, pool,
workspace_id, 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:?}");
}
}
@@ -123,6 +123,12 @@ export function ResearchWizard({
"once", "once",
); );
const [pairedCodingLoop, setPairedCodingLoop] = useState(false); const [pairedCodingLoop, setPairedCodingLoop] = useState(false);
// Team disposition for the paired coding loop. `fresh` (default when
// the loop is enabled) provisions a dedicated writable coding team
// so the research team's read-only posture isn't loosened. `reuse`
// is the legacy behavior — coding iterations share the research
// team's container + risk profile.
const [codingTeamMode, setCodingTeamMode] = useState<"fresh" | "reuse">("fresh");
const [prompt, setPrompt] = useState(""); const [prompt, setPrompt] = useState("");
const [repo, setRepo] = useState<PickedRepo | null>(null); const [repo, setRepo] = useState<PickedRepo | null>(null);
const [outcome, setOutcome] = useState<OutcomeKind>("spec"); const [outcome, setOutcome] = useState<OutcomeKind>("spec");
@@ -216,6 +222,9 @@ export function ResearchWizard({
...(repo ? { repo } : {}), ...(repo ? { repo } : {}),
schedule: { mode: scheduleMode }, schedule: { mode: scheduleMode },
create_paired_coding_loop: pairedCodingLoop, create_paired_coding_loop: pairedCodingLoop,
...(pairedCodingLoop
? { paired_coding_team_mode: codingTeamMode }
: {}),
}); });
setCommitted(true); setCommitted(true);
onCreated(id); onCreated(id);
@@ -678,6 +687,45 @@ export function ResearchWizard({
</div> </div>
</div> </div>
</label> </label>
{pairedCodingLoop && (
<div style={{ marginTop: 10, paddingLeft: 28, display: "flex", flexDirection: "column", gap: 6 }}>
<div style={{ fontFamily: mono, fontSize: 10, letterSpacing: ".12em", color: "#5a5a62" }}>
CODING TEAM
</div>
{[
{ k: "fresh" as const, title: "Provision a dedicated coding team", desc: "Fresh writable pipeline (planner → implementer → reviewer) with coding_readwrite risk profile. Recommended — keeps the research team's read-only posture intact." },
{ k: "reuse" as const, title: "Reuse the research team", desc: "Legacy behavior. Coding iterations spawn in the same container as research; both share the same (read-only) risk profile. Only pick this if you know the research team can safely write." },
].map((o) => {
const on = codingTeamMode === o.k;
return (
<label
key={o.k}
style={{
display: "flex",
gap: 8,
padding: 8,
borderRadius: 8,
border: `1px solid ${on ? "rgba(94,200,216,.4)" : "rgba(255,255,255,.08)"}`,
background: on ? "rgba(94,200,216,.05)" : "transparent",
cursor: "pointer",
}}
>
<input
type="radio"
name="coding-team"
checked={on}
onChange={() => setCodingTeamMode(o.k)}
style={{ marginTop: 3 }}
/>
<div>
<div style={{ fontSize: 13, color: "#eaeaee" }}>{o.title}</div>
<div style={{ fontFamily: mono, fontSize: 10.5, color: "#8a8a92", lineHeight: 1.5, marginTop: 3 }}>{o.desc}</div>
</div>
</label>
);
})}
</div>
)}
</div> </div>
{submitError && ( {submitError && (
<p style={{ fontFamily: mono, fontSize: 12, color: "#ff8a7a" }}> <p style={{ fontFamily: mono, fontSize: 12, color: "#ff8a7a" }}>
+5
View File
@@ -130,6 +130,11 @@ export const createTopic = (body: {
* topic with `on_artifact_update: true` so it consumes each new * topic with `on_artifact_update: true` so it consumes each new
* artifact version one INT-XX at a time. */ * artifact version one INT-XX at a time. */
create_paired_coding_loop?: boolean; create_paired_coding_loop?: boolean;
/** Team disposition for the paired coding loop.
* fresh — provision a new writable coding team (recommended)
* reuse — attach the coding loop to the research team (legacy)
* Ignored when create_paired_coding_loop is false. */
paired_coding_team_mode?: "fresh" | "reuse";
}) => }) =>
api<{ id: string }>("/api/research", { api<{ id: string }>("/api/research", {
method: "POST", method: "POST",