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,
}))
}
}