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:
@@ -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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
Reference in New Issue
Block a user