feat(runtime): start a saved Fleet member as a typed session child (CW-7182)

POST /v1/threads/{id}/members {operation_key, member_id, display_name,
prompt, fleet} starts one member of a complete Fleet v2 document as an
ordinary child of the thread's session. It reuses the `agent` tool's spawn
path (spawn_subagent_from_input) with a per-request roster built by
roster_from_fleet, so the member's exact provider/model is bound through the
existing profile pin, its instructions join the child prompt as its Fleet
profile, and session permissions and spawn limits apply unchanged. Provider
fallback is refused for these starts, as for exact Workflow spawns. The
document is used for this start only; the selected Fleet is untouched.

The host sends Op::StartFleetMember (wire twin `start_fleet_member`, reply
channel kept off the wire like GetSubAgentSettlement); the engine handles it
at its idle boundary with the off-turn child runtime used for follow-ups. The
route answers 200 with the child agent_id, or 202 "queued" when the thread is
mid-turn. The manager records operation_key -> (request, agent_id): the same
key and body return the original child, a different body is refused.
Runtime capability `thread_fleet_members` advertises it. Stop stays
POST /v1/agent-runs/{id}/cancel. docs/RUNTIME_API.md "Thread Fleet members".

Consumer: codewhale-platform a09ead01 (group member addressing).

Verification: cargo test -p codewhale-protocol 96 passed, 0 failed;
cargo test -p codewhale-tui --lib -- protocol_covers_engine_ops runtime_info
spawn_ fleet_member: 106 passed, 0 failed (the four existing
spawn_subagent_from_input callers in tests.rs updated for the new parameter).
No live provider or Boat call; the route was not exercised end to end against
a real model.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-10-25 00:15:03 -07:00
1 parent 2947e4c51d
commit 917e6b2969
10 files changed
+273 -3

No files matched your search

+8
View File
@@ -305,6 +305,9 @@ pub enum Op {
agent_id: String,
text: String,
},
StartFleetMember {
member_id: String,
},
ChangeMode {
#[serde(default = "default_mode")]
@@ -460,6 +463,7 @@ pub const OP_KINDS: &[&str] = &[
"get_sub_agent_settlement",
"cancel_sub_agent",
"follow_up_sub_agent",
"start_fleet_member",
"change_mode",
"set_model",
"set_compaction",
@@ -505,6 +509,7 @@ impl Op {
Self::GetSubAgentSettlement => "get_sub_agent_settlement",
Self::CancelSubAgent { .. } => "cancel_sub_agent",
Self::FollowUpSubAgent { .. } => "follow_up_sub_agent",
Self::StartFleetMember { .. } => "start_fleet_member",
Self::ChangeMode { .. } => "change_mode",
Self::SetModel { .. } => "set_model",
Self::SetCompaction { .. } => "set_compaction",
@@ -646,6 +651,9 @@ mod tests {
agent_id: "a1".into(),
text: "go".into(),
},
Op::StartFleetMember {
member_id: "m1".into(),
},
Op::ChangeMode {
mode: "operate".into(),
allow_shell: true,
+3
View File
@@ -104,6 +104,8 @@ pub struct RuntimeCapabilities {
#[serde(default)]
pub fleet_run_create: bool,
#[serde(default)]
pub thread_fleet_members: bool,
#[serde(default)]
pub fleet_run_start: bool,
#[serde(default)]
pub fleet_event_replay: bool,
@@ -467,6 +469,7 @@ mod tests {
environments: false,
worker_runtime: false,
fleet_run_create: true,
thread_fleet_members: true,
fleet_run_start: true,
fleet_event_replay: true,
fleet_event_stream: true,
+32
View File
@@ -3994,6 +3994,38 @@ impl Engine {
}
}
}
Op::StartFleetMember { start, tx } => {
let result = match self.off_turn_subagent_runtime() {
Some(runtime) => {
crate::tools::subagent::start_fleet_member(
Arc::clone(&self.subagent_manager),
runtime,
*start,
)
.await
}
None => {
Err("This session has no model connection to start a member."
.to_string())
}
};
if let Some(tx) = tx
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take()
{
let _ = tx.send(result);
}
let refresh = {
let manager = self.subagent_manager.read().await;
agent_list_event(&manager, &self.session.id)
};
if self.tx_event.try_send(refresh).is_err() {
tracing::debug!(
"Event channel full; dropping StartFleetMember refresh"
);
}
}
Op::FollowUpSubAgent { agent_id, text } => {
let active_session_id = self.session.id.clone();
let runtime = self.off_turn_subagent_runtime();
+9
View File
@@ -17,6 +17,8 @@ use std::path::PathBuf;
pub const USER_SHELL_TOOL_ID_PREFIX: &str = "user_shell_";
/// Snapshot of session state for saving to disk.
pub type FleetMemberStarted = Result<String, String>;
/// Returned by `Op::GetSessionSnapshot` via a oneshot channel.
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct SessionSnapshot {
@@ -321,6 +323,13 @@ pub enum Op {
/// children answer with a receipt explaining why they cannot continue.
FollowUpSubAgent { agent_id: String, text: String },
StartFleetMember {
start: Box<crate::tools::subagent::FleetMemberStart>,
tx: std::sync::Arc<
std::sync::Mutex<Option<tokio::sync::oneshot::Sender<FleetMemberStarted>>>,
>,
},
/// Change the operating mode
ChangeMode {
mode: AppMode,
+3
View File
@@ -1144,6 +1144,9 @@ pub fn op_to_protocol(op: &Op) -> wire_op::Op {
agent_id: agent_id.clone(),
text: text.clone(),
},
Op::StartFleetMember { start, tx: _ } => wire_op::Op::StartFleetMember {
member_id: start.member_id.clone(),
},
Op::ChangeMode {
mode,
allow_shell,
+6
View File
@@ -108,6 +108,7 @@ mod jobs;
pub(crate) mod listener;
mod lsp;
mod mcp_import;
mod members;
mod memory_lens;
mod mobile;
mod plans;
@@ -1358,6 +1359,7 @@ fn default_runtime_capabilities() -> RuntimeCapabilities {
environments: false,
worker_runtime: true,
fleet_run_create: true,
thread_fleet_members: true,
fleet_run_start: true,
fleet_event_replay: true,
fleet_event_stream: true,
@@ -2752,6 +2754,10 @@ pub fn build_router(state: RuntimeApiState) -> Router {
post(jobs::resize_thread_job),
)
.route("/v1/threads/{id}/context", get(context::get_thread_context))
.route(
"/v1/threads/{id}/members",
post(members::start_thread_member),
)
.route("/v1/threads/{id}/plan", get(plans::get_thread_plan))
.route("/v1/threads/{id}/todo", get(plans::get_thread_todo))
.route("/v1/plan", get(plans::latest_plan))
+101
View File
@@ -0,0 +1,101 @@
use std::time::Duration;
use axum::Json;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use serde::Deserialize;
use serde_json::{Value, json};
use super::{ApiError, RuntimeApiState, map_thread_err};
const MEMBER_START_WAIT: Duration = Duration::from_secs(20);
const MAX_MEMBER_PROMPT_CHARS: usize = 256_000;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct StartMemberRequest {
operation_key: String,
member_id: String,
display_name: String,
prompt: String,
fleet: crate::fleet::store::FleetFile,
}
fn opaque(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 128
&& value.as_bytes()[0].is_ascii_alphanumeric()
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"._:-".contains(&byte))
}
pub(super) async fn start_thread_member(
State(state): State<RuntimeApiState>,
Path(thread_id): Path<String>,
Json(request): Json<StartMemberRequest>,
) -> Result<(StatusCode, Json<Value>), ApiError> {
let display_name = request.display_name.trim();
if !opaque(&request.operation_key) || !opaque(&request.member_id) {
return Err(ApiError::bad_request(
"operation_key and member_id must be 1-128 URL-safe characters",
));
}
if display_name.is_empty()
|| display_name.chars().count() > 80
|| display_name.chars().any(char::is_control)
{
return Err(ApiError::bad_request(
"display_name must be one printable line of at most 80 characters",
));
}
let prompt = request.prompt.trim();
if prompt.is_empty() || prompt.chars().count() > MAX_MEMBER_PROMPT_CHARS {
return Err(ApiError::bad_request(
"prompt must contain 1-256000 characters",
));
}
let engine = state
.runtime_threads
.get_engine(&thread_id)
.await
.map_err(map_thread_err)?;
let (tx, rx) = tokio::sync::oneshot::channel();
engine
.send(crate::core::ops::Op::StartFleetMember {
start: Box::new(crate::tools::subagent::FleetMemberStart {
operation_key: request.operation_key.clone(),
fleet: request.fleet,
member_id: request.member_id.clone(),
display_name: display_name.to_string(),
prompt: prompt.to_string(),
}),
tx: std::sync::Arc::new(std::sync::Mutex::new(Some(tx))),
})
.await
.map_err(|err| ApiError::internal(format!("Failed to reach the thread's engine: {err}")))?;
match tokio::time::timeout(MEMBER_START_WAIT, rx).await {
Ok(Ok(Ok(agent_id))) => Ok((
StatusCode::OK,
Json(json!({
"thread_id": thread_id,
"member_id": request.member_id,
"agent_id": agent_id,
"state": "started",
})),
)),
Ok(Ok(Err(message))) => Err(ApiError::conflict(message)),
Ok(Err(_)) => Err(ApiError::internal(
"The engine closed before starting the member",
)),
Err(_) => Ok((
StatusCode::ACCEPTED,
Json(json!({
"thread_id": thread_id,
"member_id": request.member_id,
"operation_key": request.operation_key,
"state": "queued",
})),
)),
}
}
+82 -2
View File
@@ -3992,6 +3992,7 @@ pub struct SubAgentManager {
/// agent id, so the task's failure text blames the route that failed.
/// Taken when the task settles.
rerouted_requests: HashMap<String, ReroutedRequest>,
member_starts: HashMap<String, (String, String)>,
}
/// One child request waiting on a person (approvals C2). The store is the
@@ -4438,6 +4439,7 @@ impl SubAgentManager {
reported_pending: HashSet::new(),
refused_routes: HashMap::new(),
rerouted_requests: HashMap::new(),
member_starts: HashMap::new(),
}
}
@@ -12037,6 +12039,7 @@ impl ToolSpec for AgentTool {
false,
None,
None,
None,
)
.await?;
let worker_record = {
@@ -12603,6 +12606,7 @@ async fn spawn_subagent_from_input(
verified_posture_denials: bool,
workflow_identity: Option<&WorkflowTaskSpawnIdentity>,
response_schema: Option<Value>,
member_roster: Option<&crate::fleet::roster::FleetRoster>,
) -> Result<(SubAgentResult, WorkflowTaskSpawnMetadata), ToolError> {
let exact_fleet_binding =
workflow_identity.and_then(|identity| identity.exact_fleet_binding.as_ref());
@@ -12634,7 +12638,12 @@ async fn spawn_subagent_from_input(
apply_spawn_profile(&mut spawn_request, &member)?;
Some(member)
}
None => resolve_spawn_route_profile(&runtime, &mut spawn_request, &spawn_roster(&runtime))?,
None => match member_roster {
Some(roster) => resolve_spawn_route_profile(&runtime, &mut spawn_request, roster)?,
None => {
resolve_spawn_route_profile(&runtime, &mut spawn_request, &spawn_roster(&runtime))?
}
},
};
// Role resolution runs before classification so the write contract sees
// the effective role: read-only roles stay ergonomic, and a write-capable
@@ -12685,7 +12694,7 @@ async fn spawn_subagent_from_input(
true,
// Exact-bound spawns refuse provider substitution: their route is
// preflighted and "will not guess or fall back".
exact_fleet_binding.is_none(),
exact_fleet_binding.is_none() && member_roster.is_none(),
)
.await?;
// The pause is per provider route, so it is checked against the route
@@ -13326,6 +13335,7 @@ pub(crate) async fn spawn_workflow_task(
identity.fleet_authority_fingerprint.is_some(),
Some(&identity),
request.response_schema,
None,
)
.await?;
let workflow_task_label = identity.workflow_task_label;
@@ -20310,3 +20320,73 @@ mod captured_worker_origin_tests {
manager.coordination.validate_replay().unwrap();
}
}
#[derive(Debug)]
pub struct FleetMemberStart {
pub operation_key: String,
pub fleet: crate::fleet::store::FleetFile,
pub member_id: String,
pub display_name: String,
pub prompt: String,
}
pub(crate) async fn start_fleet_member(
manager: SharedSubAgentManager,
runtime: SubAgentRuntime,
start: FleetMemberStart,
) -> Result<String, String> {
start.fleet.validate().map_err(|error| error.to_string())?;
let member = start
.fleet
.member(&start.member_id)
.ok_or_else(|| format!("Fleet member `{}` is not in this Fleet.", start.member_id))?;
if member.provider.is_none() || member.model.is_none() {
return Err(format!(
"Fleet member `{}` must pin an exact provider and model.",
start.member_id
));
}
let request = serde_json::to_string(&json!({
"fleet": &start.fleet,
"member_id": &start.member_id,
"display_name": &start.display_name,
"prompt": &start.prompt,
}))
.map_err(|error| error.to_string())?;
if let Some((accepted, agent_id)) = manager.read().await.member_starts.get(&start.operation_key)
{
return if *accepted == request {
Ok(agent_id.clone())
} else {
Err("This operation key already started a different member request.".to_string())
};
}
let roster = crate::fleet::identity::roster_from_fleet(
&start.fleet,
crate::fleet::store::FleetScope::Personal,
std::path::Path::new("account-fleet"),
);
let input = json!({
"action": "start",
"profile": start.member_id,
"name": start.display_name,
"prompt": start.prompt,
});
let (result, _) = spawn_subagent_from_input(
input,
Arc::clone(&manager),
runtime,
false,
None,
None,
Some(&roster),
)
.await
.map_err(|error| error.to_string())?;
manager
.write()
.await
.member_starts
.insert(start.operation_key, (request, result.agent_id.clone()));
Ok(result.agent_id)
}
+4 -1
View File
@@ -13164,6 +13164,7 @@ async fn rate_limit_pause_blocks_subagent_spawn() {
false,
None,
None,
None,
)
.await
.expect_err("active provider rate-limit pause must refuse new sub-agent work");
@@ -22613,6 +22614,7 @@ async fn runtime_surface_hardening_resume_from_requires_descendant_control() {
false,
None,
None,
None,
)
.await
.expect_err("a sibling is not a continuation source");
@@ -22622,7 +22624,7 @@ async fn runtime_surface_hardening_resume_from_requires_descendant_control() {
);
runtime.parent_agent_id = None;
let err = spawn_subagent_from_input(input, manager.clone(), runtime, false, None, None)
let err = spawn_subagent_from_input(input, manager.clone(), runtime, false, None, None, None)
.await
.expect_err("root still observes the workspace boundary");
assert!(err.to_string().contains("different workspace"), "{err}");
@@ -24114,6 +24116,7 @@ async fn refuse_worktree_spawn_and_expect_cleanup(repo: &tempfile::TempDir, bran
false,
None,
None,
None,
)
.await
.expect_err("a running resume source refuses the spawn");
+25
View File
@@ -3105,6 +3105,31 @@ Verification:
npm test --workspace @codewhale/runtime-sdk
```
## Thread Fleet members
`thread_fleet_members` in runtime capabilities enables
`POST /v1/threads/{id}/members`. The body is
`{operation_key, member_id, display_name, prompt, fleet}`, where `fleet` is a
complete Fleet v2 document (the same shape as a saved `fleets/*.toml`, as JSON)
and `member_id` names one of its members with an exact `provider` and `model`.
The thread's Engine starts that member as an ordinary child of the session,
through the same spawn path as the `agent` tool: the member's provider and
model are bound exactly and never fall back to the session route, its
instructions join the child's task prompt as its Fleet profile, and the child
keeps the session's permissions and spawn limits. The document is used for this
start only; it is not saved and does not change the selected Fleet.
The response is `200 {thread_id, member_id, agent_id, state: "started"}`, or
`202 {state: "queued"}` when the thread is mid-turn: host requests are handled
between turns. Repeating the same `operation_key` with the same body returns
the original `agent_id` once started; a different body under the same key is
refused. The child appears in `/v1/agent-runs` with `owner_session_id` set to the
thread and its `display_name` as `session_name`; `agent.spawned`,
`agent.progress` and `agent.completed` arrive on the thread's event stream.
Stop it with `POST /v1/agent-runs/{id}/cancel`, which also stops its
descendants. A second start for a member whose child is still running is
refused by the existing session-name rule; follow up with that child instead.
## Agent Run Receipts
Sub-agent lanes persist compact run receipts in