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

115
frontend/src/api/agents.ts Normal file
View File

@@ -0,0 +1,115 @@
export type AgentStatusValue = "pending" | "running" | "completed" | "failed";
export interface AgentInfo {
story_id: string;
status: AgentStatusValue;
session_id: string | null;
worktree_path: string | null;
}
export interface AgentEvent {
type: "status" | "output" | "agent_json" | "done" | "error" | "warning";
story_id?: string;
status?: string;
text?: string;
data?: unknown;
session_id?: string | null;
message?: string;
}
const DEFAULT_API_BASE = "/api";
function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string {
return `${baseUrl}${path}`;
}
async function requestJson<T>(
path: string,
options: RequestInit = {},
baseUrl = DEFAULT_API_BASE,
): Promise<T> {
const res = await fetch(buildApiUrl(path, baseUrl), {
headers: {
"Content-Type": "application/json",
...(options.headers ?? {}),
},
...options,
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `Request failed (${res.status})`);
}
return res.json() as Promise<T>;
}
export const agentsApi = {
startAgent(storyId: string, baseUrl?: string) {
return requestJson<AgentInfo>(
"/agents/start",
{
method: "POST",
body: JSON.stringify({ story_id: storyId }),
},
baseUrl,
);
},
stopAgent(storyId: string, baseUrl?: string) {
return requestJson<boolean>(
"/agents/stop",
{
method: "POST",
body: JSON.stringify({ story_id: storyId }),
},
baseUrl,
);
},
listAgents(baseUrl?: string) {
return requestJson<AgentInfo[]>("/agents", {}, baseUrl);
},
};
/**
* Subscribe to SSE events for a running agent.
* Returns a cleanup function to close the connection.
*/
export function subscribeAgentStream(
storyId: string,
onEvent: (event: AgentEvent) => void,
onError?: (error: Event) => void,
): () => void {
const host = import.meta.env.DEV ? "http://127.0.0.1:3001" : "";
const url = `${host}/agents/${encodeURIComponent(storyId)}/stream`;
const eventSource = new EventSource(url);
eventSource.onmessage = (e) => {
try {
const data = JSON.parse(e.data) as AgentEvent;
onEvent(data);
// Close on terminal events
if (
data.type === "done" ||
data.type === "error" ||
(data.type === "status" && data.status === "stopped")
) {
eventSource.close();
}
} catch (err) {
console.error("Failed to parse agent event:", err);
}
};
eventSource.onerror = (e) => {
onError?.(e);
eventSource.close();
};
return () => {
eventSource.close();
};
}

View File

@@ -0,0 +1,470 @@
import * as React from "react";
import type { AgentEvent, AgentInfo, AgentStatusValue } from "../api/agents";
import { agentsApi, subscribeAgentStream } from "../api/agents";
import type { UpcomingStory } from "../api/workflow";
const { useCallback, useEffect, useRef, useState } = React;
interface AgentPanelProps {
stories: UpcomingStory[];
}
interface AgentState {
status: AgentStatusValue;
log: string[];
sessionId: string | null;
worktreePath: string | null;
}
const STATUS_COLORS: Record<AgentStatusValue, string> = {
pending: "#e3b341",
running: "#58a6ff",
completed: "#7ee787",
failed: "#ff7b72",
};
const STATUS_LABELS: Record<AgentStatusValue, string> = {
pending: "Pending",
running: "Running",
completed: "Completed",
failed: "Failed",
};
const formatTimestamp = (value: Date | null): string => {
if (!value) return "";
return value.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
};
function StatusBadge({ status }: { status: AgentStatusValue }) {
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: "4px",
padding: "2px 8px",
borderRadius: "999px",
fontSize: "0.75em",
fontWeight: 600,
background: `${STATUS_COLORS[status]}22`,
color: STATUS_COLORS[status],
border: `1px solid ${STATUS_COLORS[status]}44`,
}}
>
{status === "running" && (
<span
style={{
width: "6px",
height: "6px",
borderRadius: "50%",
background: STATUS_COLORS[status],
animation: "pulse 1.5s infinite",
}}
/>
)}
{STATUS_LABELS[status]}
</span>
);
}
export function AgentPanel({ stories }: AgentPanelProps) {
const [agents, setAgents] = useState<Record<string, AgentState>>({});
const [expandedStory, setExpandedStory] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
const cleanupRefs = useRef<Record<string, () => void>>({});
const logEndRefs = useRef<Record<string, HTMLDivElement | null>>({});
// Load existing agents on mount
useEffect(() => {
agentsApi
.listAgents()
.then((agentList) => {
const agentMap: Record<string, AgentState> = {};
for (const a of agentList) {
agentMap[a.story_id] = {
status: a.status,
log: [],
sessionId: a.session_id,
worktreePath: a.worktree_path,
};
// Re-subscribe to running agents
if (a.status === "running" || a.status === "pending") {
subscribeToAgent(a.story_id);
}
}
setAgents(agentMap);
setLastRefresh(new Date());
})
.catch((err) => console.error("Failed to load agents:", err));
return () => {
for (const cleanup of Object.values(cleanupRefs.current)) {
cleanup();
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const subscribeToAgent = useCallback((storyId: string) => {
// Clean up existing subscription
cleanupRefs.current[storyId]?.();
const cleanup = subscribeAgentStream(
storyId,
(event: AgentEvent) => {
setAgents((prev) => {
const current = prev[storyId] ?? {
status: "pending" as AgentStatusValue,
log: [],
sessionId: null,
worktreePath: null,
};
switch (event.type) {
case "status":
return {
...prev,
[storyId]: {
...current,
status: (event.status as AgentStatusValue) ?? current.status,
},
};
case "output":
return {
...prev,
[storyId]: {
...current,
log: [...current.log, event.text ?? ""],
},
};
case "done":
return {
...prev,
[storyId]: {
...current,
status: "completed",
sessionId: event.session_id ?? current.sessionId,
},
};
case "error":
return {
...prev,
[storyId]: {
...current,
status: "failed",
log: [
...current.log,
`[ERROR] ${event.message ?? "Unknown error"}`,
],
},
};
default:
return prev;
}
});
},
() => {
// SSE error — agent may not be streaming yet
},
);
cleanupRefs.current[storyId] = cleanup;
}, []);
// Auto-scroll log when expanded
useEffect(() => {
if (expandedStory) {
const el = logEndRefs.current[expandedStory];
el?.scrollIntoView({ behavior: "smooth" });
}
}, [expandedStory, agents]);
const handleStart = async (storyId: string) => {
setActionError(null);
try {
const info: AgentInfo = await agentsApi.startAgent(storyId);
setAgents((prev) => ({
...prev,
[storyId]: {
status: info.status,
log: [],
sessionId: info.session_id,
worktreePath: info.worktree_path,
},
}));
setExpandedStory(storyId);
subscribeToAgent(storyId);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setActionError(`Failed to start agent for ${storyId}: ${message}`);
}
};
const handleStop = async (storyId: string) => {
setActionError(null);
try {
await agentsApi.stopAgent(storyId);
cleanupRefs.current[storyId]?.();
delete cleanupRefs.current[storyId];
setAgents((prev) => {
const next = { ...prev };
delete next[storyId];
return next;
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setActionError(`Failed to stop agent for ${storyId}: ${message}`);
}
};
const isAgentActive = (storyId: string): boolean => {
const agent = agents[storyId];
return agent?.status === "running" || agent?.status === "pending";
};
return (
<div
style={{
border: "1px solid #333",
borderRadius: "10px",
padding: "12px 16px",
background: "#1f1f1f",
display: "flex",
flexDirection: "column",
gap: "8px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "12px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<div style={{ fontWeight: 600 }}>Agents</div>
<div
style={{
fontSize: "0.75em",
color: "#777",
fontFamily: "monospace",
}}
>
{Object.values(agents).filter((a) => a.status === "running").length}{" "}
running
</div>
</div>
{lastRefresh && (
<div style={{ fontSize: "0.7em", color: "#555" }}>
Loaded {formatTimestamp(lastRefresh)}
</div>
)}
</div>
{actionError && (
<div
style={{
fontSize: "0.85em",
color: "#ff7b72",
padding: "4px 8px",
background: "#ff7b7211",
borderRadius: "6px",
}}
>
{actionError}
</div>
)}
{stories.length === 0 ? (
<div style={{ fontSize: "0.85em", color: "#aaa" }}>
No stories available. Add stories to .story_kit/stories/upcoming/.
</div>
) : (
<div
style={{
display: "flex",
flexDirection: "column",
gap: "6px",
}}
>
{stories.map((story) => {
const agent = agents[story.story_id];
const isExpanded = expandedStory === story.story_id;
return (
<div
key={`agent-${story.story_id}`}
style={{
border: "1px solid #2a2a2a",
borderRadius: "8px",
background: "#191919",
overflow: "hidden",
}}
>
<div
style={{
padding: "8px 12px",
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<button
type="button"
onClick={() =>
setExpandedStory(isExpanded ? null : story.story_id)
}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
setExpandedStory(isExpanded ? null : story.story_id);
}
}}
style={{
background: "none",
border: "none",
color: "#aaa",
cursor: "pointer",
fontSize: "0.8em",
padding: "0 4px",
transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)",
transition: "transform 0.15s",
}}
>
&#9654;
</button>
<div
style={{
flex: 1,
fontWeight: 600,
fontSize: "0.9em",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{story.name ?? story.story_id}
</div>
{agent && <StatusBadge status={agent.status} />}
{isAgentActive(story.story_id) ? (
<button
type="button"
onClick={() => handleStop(story.story_id)}
style={{
padding: "4px 10px",
borderRadius: "999px",
border: "1px solid #ff7b7244",
background: "#ff7b7211",
color: "#ff7b72",
cursor: "pointer",
fontSize: "0.75em",
fontWeight: 600,
}}
>
Stop
</button>
) : (
<button
type="button"
onClick={() => handleStart(story.story_id)}
style={{
padding: "4px 10px",
borderRadius: "999px",
border: "1px solid #7ee78744",
background: "#7ee78711",
color: "#7ee787",
cursor: "pointer",
fontSize: "0.75em",
fontWeight: 600,
}}
>
Run
</button>
)}
</div>
{isExpanded && agent && (
<div
style={{
borderTop: "1px solid #2a2a2a",
padding: "8px 12px",
}}
>
{agent.worktreePath && (
<div
style={{
fontSize: "0.75em",
color: "#666",
fontFamily: "monospace",
marginBottom: "6px",
}}
>
Worktree: {agent.worktreePath}
</div>
)}
<div
style={{
maxHeight: "300px",
overflowY: "auto",
background: "#111",
borderRadius: "6px",
padding: "8px",
fontFamily: "monospace",
fontSize: "0.8em",
lineHeight: "1.5",
color: "#ccc",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{agent.log.length === 0 ? (
<span style={{ color: "#555" }}>
{agent.status === "pending" ||
agent.status === "running"
? "Waiting for output..."
: "No output captured."}
</span>
) : (
agent.log.map((line, i) => (
<div
key={`log-${story.story_id}-${i}`}
style={{
color: line.startsWith("[ERROR]")
? "#ff7b72"
: "#ccc",
}}
>
{line}
</div>
))
)}
<div
ref={(el) => {
logEndRefs.current[story.story_id] = el;
}}
/>
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -6,6 +6,7 @@ import { api, ChatWebSocket } from "../api/client";
import type { ReviewStory, UpcomingStory } from "../api/workflow";
import { workflowApi } from "../api/workflow";
import type { Message, ProviderConfig, ToolCall } from "../types";
import { AgentPanel } from "./AgentPanel";
import { ChatHeader } from "./ChatHeader";
import { GatePanel } from "./GatePanel";
import { ReviewPanel } from "./ReviewPanel";
@@ -743,6 +744,8 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
lastRefresh={lastUpcomingRefresh}
onRefresh={refreshUpcomingStories}
/>
<AgentPanel stories={upcomingStories} />
</div>
</div>