mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-07 17:09:13 -10:00
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:
1 parent
2947e4c51d
commit
917e6b2969
10 files changed
+273
-3
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
})),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user