Accept story 30: Worktree-based agent orchestration

Add git worktree isolation for concurrent story agents. Each agent now
runs in its own worktree with setup/teardown commands driven by
.story_kit/project.toml config. Agents stream output via SSE and support
start/stop lifecycle with Pending/Running/Completed/Failed statuses.

Backend: config.rs (TOML parsing), worktree.rs (git worktree lifecycle),
refactored agents.rs (broadcast streaming), agents_sse.rs (SSE endpoint).
Frontend: AgentPanel.tsx with Run/Stop buttons and streaming output log.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dave
2026-02-19 17:58:53 +00:00
parent 7e56648954
commit 5e5cdd9b2f
15 changed files with 1440 additions and 281 deletions

View File

@@ -9,39 +9,16 @@ enum AgentsTags {
}
#[derive(Object)]
struct CreateAgentPayload {
name: String,
role: String,
cwd: String,
}
#[derive(Object)]
struct SendMessagePayload {
message: String,
struct StoryIdPayload {
story_id: String,
}
#[derive(Object, Serialize)]
struct AgentInfoResponse {
name: String,
role: String,
cwd: String,
session_id: Option<String>,
story_id: String,
status: String,
message_count: usize,
}
#[derive(Object, Serialize)]
struct AgentMessageResponse {
agent: String,
text: String,
session_id: Option<String>,
model: Option<String>,
api_key_source: Option<String>,
rate_limit_type: Option<String>,
cost_usd: Option<f64>,
input_tokens: Option<u64>,
output_tokens: Option<u64>,
duration_ms: Option<u64>,
worktree_path: Option<String>,
}
pub struct AgentsApi {
@@ -50,31 +27,52 @@ pub struct AgentsApi {
#[OpenApi(tag = "AgentsTags::Agents")]
impl AgentsApi {
/// Create a new agent with a name, role, and working directory.
#[oai(path = "/agents", method = "post")]
async fn create_agent(
/// Start an agent for a given story (creates worktree, runs setup, spawns agent).
#[oai(path = "/agents/start", method = "post")]
async fn start_agent(
&self,
payload: Json<CreateAgentPayload>,
payload: Json<StoryIdPayload>,
) -> OpenApiResult<Json<AgentInfoResponse>> {
let req = crate::agents::CreateAgentRequest {
name: payload.0.name,
role: payload.0.role,
cwd: payload.0.cwd,
};
let project_root = self
.ctx
.agents
.get_project_root(&self.ctx.state)
.map_err(bad_request)?;
let info = self.ctx.agents.create_agent(req).map_err(bad_request)?;
let info = self
.ctx
.agents
.start_agent(&project_root, &payload.0.story_id)
.await
.map_err(bad_request)?;
Ok(Json(AgentInfoResponse {
name: info.name,
role: info.role,
cwd: info.cwd,
story_id: info.story_id,
status: info.status.to_string(),
session_id: info.session_id,
status: "idle".to_string(),
message_count: info.message_count,
worktree_path: info.worktree_path,
}))
}
/// List all registered agents.
/// Stop a running agent and clean up its worktree.
#[oai(path = "/agents/stop", method = "post")]
async fn stop_agent(&self, payload: Json<StoryIdPayload>) -> OpenApiResult<Json<bool>> {
let project_root = self
.ctx
.agents
.get_project_root(&self.ctx.state)
.map_err(bad_request)?;
self.ctx
.agents
.stop_agent(&project_root, &payload.0.story_id)
.await
.map_err(bad_request)?;
Ok(Json(true))
}
/// List all agents with their status.
#[oai(path = "/agents", method = "get")]
async fn list_agents(&self) -> OpenApiResult<Json<Vec<AgentInfoResponse>>> {
let agents = self.ctx.agents.list_agents().map_err(bad_request)?;
@@ -83,45 +81,12 @@ impl AgentsApi {
agents
.into_iter()
.map(|info| AgentInfoResponse {
name: info.name,
role: info.role,
cwd: info.cwd,
story_id: info.story_id,
status: info.status.to_string(),
session_id: info.session_id,
status: match info.status {
crate::agents::AgentStatus::Idle => "idle".to_string(),
crate::agents::AgentStatus::Running => "running".to_string(),
},
message_count: info.message_count,
worktree_path: info.worktree_path,
})
.collect(),
))
}
/// Send a message to an agent and wait for its response.
#[oai(path = "/agents/:name/message", method = "post")]
async fn send_message(
&self,
name: poem_openapi::param::Path<String>,
payload: Json<SendMessagePayload>,
) -> OpenApiResult<Json<AgentMessageResponse>> {
let result = self
.ctx
.agents
.send_message(&name.0, &payload.0.message)
.await
.map_err(bad_request)?;
Ok(Json(AgentMessageResponse {
agent: result.agent,
text: result.text,
session_id: result.session_id,
model: result.model,
api_key_source: result.api_key_source,
rate_limit_type: result.rate_limit_type,
cost_usd: result.cost_usd,
input_tokens: result.input_tokens,
output_tokens: result.output_tokens,
duration_ms: result.duration_ms,
}))
}
}

View File

@@ -0,0 +1,58 @@
use crate::http::context::AppContext;
use poem::handler;
use poem::http::StatusCode;
use poem::web::{Data, Path};
use poem::{Body, IntoResponse, Response};
use std::sync::Arc;
/// SSE endpoint: `GET /agents/:story_id/stream`
///
/// Streams `AgentEvent`s as Server-Sent Events. Each event is JSON-encoded
/// with `data:` prefix and double newline terminator per the SSE spec.
#[handler]
pub async fn agent_stream(
Path(story_id): Path<String>,
ctx: Data<&Arc<AppContext>>,
) -> impl IntoResponse {
let mut rx = match ctx.agents.subscribe(&story_id) {
Ok(rx) => rx,
Err(e) => {
return Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from_string(e));
}
};
let stream = async_stream::stream! {
loop {
match rx.recv().await {
Ok(event) => {
if let Ok(json) = serde_json::to_string(&event) {
yield Ok::<_, std::io::Error>(format!("data: {json}\n\n"));
}
// Check for terminal events
match &event {
crate::agents::AgentEvent::Done { .. }
| crate::agents::AgentEvent::Error { .. } => break,
crate::agents::AgentEvent::Status { status, .. }
if status == "stopped" => break,
_ => {}
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
let msg = format!("{{\"type\":\"warning\",\"message\":\"Skipped {n} events\"}}");
yield Ok::<_, std::io::Error>(format!("data: {msg}\n\n"));
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
};
Response::builder()
.header("Content-Type", "text/event-stream")
.header("Cache-Control", "no-cache")
.header("Connection", "keep-alive")
.body(Body::from_bytes_stream(
futures::StreamExt::map(stream, |r| r.map(bytes::Bytes::from)),
))
}

View File

@@ -1,4 +1,5 @@
pub mod agents;
pub mod agents_sse;
pub mod anthropic;
pub mod assets;
pub mod chat;
@@ -33,6 +34,10 @@ pub fn build_routes(ctx: AppContext) -> impl poem::Endpoint {
.nest("/api", api_service)
.nest("/docs", docs_service.swagger_ui())
.at("/ws", get(ws::ws_handler))
.at(
"/agents/:story_id/stream",
get(agents_sse::agent_stream),
)
.at("/health", get(health::health))
.at("/assets/*path", get(assets::embedded_asset))
.at("/", get(assets::embedded_index))