wizard: 'fresh coding team' picker for paired coding loop
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:
@@ -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" }}>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user