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:
@@ -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,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
58
server/src/http/agents_sse.rs
Normal file
58
server/src/http/agents_sse.rs
Normal 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)),
|
||||
))
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user