herdr phase 1c: wizard runtime picker + on_launch auto-dispatch

Closes the operator loop for the second-runtime path. Missions
created with runtime='local_herdr' now spawn a Herdr pane on their
target_node automatically on draft→running.

Frontend (MissionWizard):
  - Step 4 grows a "Runtime" section above Schedule
  - Radio: "Hosted (ZeroClaw)" default | "On a fleet node (Herdr)"
  - Local-Herdr shows a dropdown of ONLINE nodes only (from
    /api/nodes filtered by status='online')
  - canNext blocks Next when local_herdr picked without a node
  - Review step shows "Runtime: Herdr on <node-name>" or "Hosted"
  - Empty-online-nodes state hints "Connect one from INFRA first"

Backend:
  - mission_orchestrator::on_launch grows a NodeHub param; when
    mission.runtime_kind='local_herdr' + target_node_id set +
    hub present → calls fleet_herdr::dispatch(). Non-fatal:
    logs and continues so a research_only mission with a Herdr
    runtime chosen accidentally still boots the team.
  - routes::missions::set_status passes state.node_hub through.
  - Test call sites updated to pass None for the new param
    (integration tests don't drive real fleet nodes).

CLI stub: on_launch currently hard-codes cli="claude" for the
Herdr pane. Phase 4 will read that from the team template so a
research team → kimi, gpu team → claude, etc.

Verified: cargo check --workspace + cargo test
-p cm-api --test mission_orchestrator + tsc --noEmit all green.
This commit is contained in:
Omar Sobh
2026-07-20 10:02:40 -07:00
parent 47f986257f
commit 2b3ec27757
4 changed files with 137 additions and 7 deletions
+33
View File
@@ -37,6 +37,7 @@ pub async fn on_launch(
workspace_id: WorkspaceId, workspace_id: WorkspaceId,
user_id: cm_domain::UserId, user_id: cm_domain::UserId,
mission_id: Uuid, mission_id: Uuid,
node_hub: Option<std::sync::Arc<crate::fleet::NodeHub>>,
) -> Result<Option<Uuid>, String> { ) -> Result<Option<Uuid>, String> {
let Some(mission) = cm_db::repo::missions::get(pool, mission_id, workspace_id.as_uuid()) let Some(mission) = cm_db::repo::missions::get(pool, mission_id, workspace_id.as_uuid())
.await .await
@@ -100,6 +101,38 @@ pub async fn on_launch(
), ),
} }
// Herdr second-runtime: if runtime_kind='local_herdr', spawn a
// pane on target_node running the first available local CLI.
// Non-fatal on failure — the operator sees the error in server
// logs and can manually retry via POST /herdr-dispatch.
if mission.runtime_kind == "local_herdr" {
if let (Some(hub), Some(node_id)) = (node_hub, mission.target_node_id) {
let prompt = mission.description.clone().unwrap_or_default();
let cli = "claude"; // TODO(phase-4): pick from team template
match crate::fleet_herdr::dispatch(
hub,
cm_domain::NodeId::from(node_id),
mission_id,
cli,
&prompt,
)
.await
{
Ok(handle) => eprintln!(
"mission_orchestrator: herdr pane {} spawned on node {}",
handle.pane_id, node_id
),
Err(e) => eprintln!(
"mission_orchestrator: herdr dispatch for {mission_id} failed (continuing): {e}"
),
}
} else {
eprintln!(
"mission_orchestrator: mission {mission_id} is local_herdr but node_hub or target_node missing"
);
}
}
Ok(Some(team_id)) Ok(Some(team_id))
} }
+7 -1
View File
@@ -432,7 +432,13 @@ pub async fn set_status(
if prior.status == "draft" && body.status == "running" { if prior.status == "draft" && body.status == "running" {
if let Err(e) = if let Err(e) =
crate::mission_orchestrator::on_launch(&state.pool, user.workspace_id, user.user_id, id) crate::mission_orchestrator::on_launch(
&state.pool,
user.workspace_id,
user.user_id,
id,
Some(state.node_hub.clone()),
)
.await .await
{ {
eprintln!("mission {id}: on_launch failed: {e}"); eprintln!("mission {id}: on_launch failed: {e}");
+4 -4
View File
@@ -122,7 +122,7 @@ async fn on_launch_materializes_team_from_template() {
let template_id = seed_test_template(&pool).await; let template_id = seed_test_template(&pool).await;
let mission_id = seed_mission(&pool, ws, template_id, "Test Mission").await; let mission_id = seed_mission(&pool, ws, template_id, "Test Mission").await;
let team_id = mission_orchestrator::on_launch(&pool, ws, owner, mission_id) let team_id = mission_orchestrator::on_launch(&pool, ws, owner, mission_id, None)
.await .await
.expect("on_launch succeeds") .expect("on_launch succeeds")
.expect("returns a team id"); .expect("returns a team id");
@@ -199,11 +199,11 @@ async fn on_launch_is_idempotent() {
let template_id = seed_test_template(&pool).await; let template_id = seed_test_template(&pool).await;
let mission_id = seed_mission(&pool, ws, template_id, "Idempotency Mission").await; let mission_id = seed_mission(&pool, ws, template_id, "Idempotency Mission").await;
let team_a = mission_orchestrator::on_launch(&pool, ws, owner, mission_id) let team_a = mission_orchestrator::on_launch(&pool, ws, owner, mission_id, None)
.await .await
.unwrap() .unwrap()
.unwrap(); .unwrap();
let team_b = mission_orchestrator::on_launch(&pool, ws, owner, mission_id) let team_b = mission_orchestrator::on_launch(&pool, ws, owner, mission_id, None)
.await .await
.unwrap() .unwrap()
.unwrap(); .unwrap();
@@ -240,7 +240,7 @@ async fn on_launch_no_template_returns_none() {
.await .await
.unwrap(); .unwrap();
let result = mission_orchestrator::on_launch(&pool, ws, owner, mission_id) let result = mission_orchestrator::on_launch(&pool, ws, owner, mission_id, None)
.await .await
.unwrap(); .unwrap();
assert!(result.is_none(), "no template + no team should return None"); assert!(result.is_none(), "no template + no team should return None");
@@ -59,6 +59,29 @@ export function MissionWizard({
}, []); }, []);
const [scheduleKind, setScheduleKind] = useState<"one_shot" | "cron">("one_shot"); const [scheduleKind, setScheduleKind] = useState<"one_shot" | "cron">("one_shot");
const [cron, setCron] = useState("0 */6 * * *"); const [cron, setCron] = useState("0 */6 * * *");
const [runtimeKind, setRuntimeKind] = useState<"zeroclaw" | "local_herdr">("zeroclaw");
const [targetNodeId, setTargetNodeId] = useState<string>("");
const [onlineNodes, setOnlineNodes] = useState<
Array<{ id: string; name: string }>
>([]);
useEffect(() => {
(async () => {
try {
const r = await fetch("/api/nodes");
if (!r.ok) return;
const data = (await r.json()) as {
nodes?: Array<{ id: string; name: string; status: string }>;
};
setOnlineNodes(
(data.nodes ?? [])
.filter((n) => n.status === "online")
.map((n) => ({ id: n.id, name: n.name })),
);
} catch {
// Non-fatal — user can still pick zeroclaw runtime without nodes.
}
})();
}, []);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -73,7 +96,8 @@ export function MissionWizard({
title.trim().length > 0 && title.trim().length > 0 &&
(!preset.requiresRepo || repo !== null)) || (!preset.requiresRepo || repo !== null)) ||
step === 3 || step === 3 ||
step === 4; (step === 4 &&
(runtimeKind === "zeroclaw" || targetNodeId !== ""));
async function submit() { async function submit() {
setError(null); setError(null);
@@ -89,6 +113,9 @@ export function MissionWizard({
schedule, schedule,
description: description.trim() || undefined, description: description.trim() || undefined,
phases: preset.phases, phases: preset.phases,
runtime_kind: runtimeKind,
target_node_id:
runtimeKind === "local_herdr" ? targetNodeId : undefined,
}); });
onCreated(created.id); onCreated(created.id);
} catch (e) { } catch (e) {
@@ -372,7 +399,63 @@ export function MissionWizard({
{step === 4 && ( {step === 4 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}> <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<span style={labelStyle}>Schedule</span> <span style={labelStyle}>Runtime</span>
<label style={radioRowStyle(runtimeKind === "zeroclaw")}>
<input
type="radio"
checked={runtimeKind === "zeroclaw"}
onChange={() => {
setRuntimeKind("zeroclaw");
setTargetNodeId("");
}}
/>
<div>
<div style={{ fontWeight: 600, color: "#f3f3f5" }}>
Hosted (ZeroClaw)
</div>
<div style={hintStyle}>
Runs headlessly in the shared ZeroClaw daemon. Default. No
fleet node required.
</div>
</div>
</label>
<label style={radioRowStyle(runtimeKind === "local_herdr")}>
<input
type="radio"
checked={runtimeKind === "local_herdr"}
onChange={() => setRuntimeKind("local_herdr")}
/>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, color: "#f3f3f5" }}>
On a fleet node (Herdr)
</div>
<div style={hintStyle}>
Executes in a Herdr pane on a fleet node, using that
node's local CLI (claude / kimi / codex). Operator-visible,
live pane view in the mission canvas.
</div>
{runtimeKind === "local_herdr" && (
<select
value={targetNodeId}
onChange={(e) => setTargetNodeId(e.target.value)}
style={{ ...fieldStyle, marginTop: 6 }}
>
<option value="">Pick a node…</option>
{onlineNodes.map((n) => (
<option key={n.id} value={n.id}>
{n.name}
</option>
))}
</select>
)}
{runtimeKind === "local_herdr" && onlineNodes.length === 0 && (
<div style={{ ...hintStyle, color: "#ff8a7a", marginTop: 4 }}>
No online nodes. Connect one from the INFRA tier first.
</div>
)}
</div>
</label>
<span style={{ ...labelStyle, marginTop: 6 }}>Schedule</span>
<label style={radioRowStyle(scheduleKind === "one_shot")}> <label style={radioRowStyle(scheduleKind === "one_shot")}>
<input <input
type="radio" type="radio"
@@ -426,6 +509,14 @@ export function MissionWizard({
: "auto-provision from prompt" : "auto-provision from prompt"
} }
/> />
<ReviewRow
k="Runtime"
v={
runtimeKind === "local_herdr"
? `Herdr on ${onlineNodes.find((n) => n.id === targetNodeId)?.name ?? targetNodeId}`
: "Hosted (ZeroClaw)"
}
/>
<ReviewRow <ReviewRow
k="Schedule" k="Schedule"
v={scheduleKind === "cron" ? `cron: ${cron}` : "one-shot"} v={scheduleKind === "cron" ? `cron: ${cron}` : "one-shot"}