From e412edd0ea7112272a51ca98d76cae40ca7e8664 Mon Sep 17 00:00:00 2001 From: sstefdev Date: Mon, 25 May 2026 13:30:38 +0200 Subject: [PATCH] feat: parallel multi-agent runs with stop + grid layout (closes #6) --- src/app/api/agents/[id]/run/route.ts | 21 ++++++++- src/app/api/runs/[id]/stop/route.ts | 24 ++++++++++ src/app/workspace/[id]/page.tsx | 9 +++- src/components/AgentPanel.tsx | 60 +++++++++++++++++++++---- src/components/RunHistory.tsx | 2 +- src/lib/claude.ts | 13 ++++++ src/lib/run-registry.ts | 67 ++++++++++++++++++++++++++++ 7 files changed, 182 insertions(+), 14 deletions(-) create mode 100644 src/app/api/runs/[id]/stop/route.ts create mode 100644 src/lib/run-registry.ts diff --git a/src/app/api/agents/[id]/run/route.ts b/src/app/api/agents/[id]/run/route.ts index 1ae0c90..6bab04a 100644 --- a/src/app/api/agents/[id]/run/route.ts +++ b/src/app/api/agents/[id]/run/route.ts @@ -3,6 +3,7 @@ import { db, schema } from '@/lib/db' import { eq } from 'drizzle-orm' import { runAgent, writeAgentSkillConfig } from '@/lib/claude' import { resolveAgentCwd } from '@/lib/directories' +import { registerRun, unregisterRun } from '@/lib/run-registry' export const runtime = 'nodejs' // Allow long-running streams; default Vercel limit is short, but we're @@ -72,6 +73,9 @@ export async function POST( const encoder = new TextEncoder() const collected: string[] = [] let exitCode: number | null = null + // Track whether the run was killed via the Stop endpoint so we can + // record status='cancelled' instead of 'failed' on a non-zero exit. + let cancelled = false const stream = new ReadableStream({ async start(controller) { @@ -94,6 +98,14 @@ export async function POST( systemPrompt: agent.systemPrompt ?? '', model: agent.model, skills: agent.skills ?? [], + onChild: (child) => { + registerRun(run.id, child) + // Detect external SIGTERM (i.e. our Stop endpoint) so we + // can persist 'cancelled' instead of 'failed' on exit. + child.once('exit', (_code, signal) => { + if (signal === 'SIGTERM') cancelled = true + }) + }, })) { send(event as object) collected.push(JSON.stringify(event)) @@ -112,10 +124,15 @@ export async function POST( type: 'error', message: err instanceof Error ? err.message : String(err), }) + } finally { + unregisterRun(run.id) } - const status: 'completed' | 'failed' = - exitCode === 0 ? 'completed' : 'failed' + const status: 'completed' | 'failed' | 'cancelled' = cancelled + ? 'cancelled' + : exitCode === 0 + ? 'completed' + : 'failed' try { await db .update(schema.runs) diff --git a/src/app/api/runs/[id]/stop/route.ts b/src/app/api/runs/[id]/stop/route.ts new file mode 100644 index 0000000..974c9cd --- /dev/null +++ b/src/app/api/runs/[id]/stop/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from 'next/server' +import { killRun, isRunning } from '@/lib/run-registry' + +export const runtime = 'nodejs' + +/** + * POST /api/runs/[id]/stop + * + * SIGTERM the run's child process group if it's still alive. The + * run-route's SSE loop sees the child exit, persists `status='cancelled'` + * (detected via the SIGTERM signal), and closes the stream. Idempotent: + * stopping an already-finished run returns 200 with stopped:false. + */ +export async function POST( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params + if (!isRunning(id)) { + return NextResponse.json({ ok: true, stopped: false }) + } + const killed = killRun(id) + return NextResponse.json({ ok: true, stopped: killed }) +} diff --git a/src/app/workspace/[id]/page.tsx b/src/app/workspace/[id]/page.tsx index 2058935..a102aac 100644 --- a/src/app/workspace/[id]/page.tsx +++ b/src/app/workspace/[id]/page.tsx @@ -105,9 +105,14 @@ export default async function WorkspacePage({

Agents

-
+

+ Each agent runs independently. Hit Run on more than one to + watch them work in parallel; Stop sends SIGTERM to that + agent's child without touching the others. +

+
{agents.length === 0 && ( -
+
No agents in this workspace yet. Create one below.
)} diff --git a/src/components/AgentPanel.tsx b/src/components/AgentPanel.tsx index 6eed0ca..9d5dd5a 100644 --- a/src/components/AgentPanel.tsx +++ b/src/components/AgentPanel.tsx @@ -30,15 +30,30 @@ export function AgentPanel({ const [prompt, setPrompt] = useState('') const [events, setEvents] = useState([]) const [running, setRunning] = useState(false) + // RunId becomes known after the server emits its run:start frame. + // The Stop button needs this to address the right child process. + const [runId, setRunId] = useState(null) // Bumped each time a live run ends, so RunHistory re-fetches the // newly persisted row without a full page reload. const [historyVersion, setHistoryVersion] = useState(0) const outputRef = useRef(null) + async function stop() { + if (!runId) return + try { + await fetch(`/api/runs/${runId}/stop`, { method: 'POST' }) + } catch { + // Server might be gone or restarted. The run-end frame will + // never arrive in that case; the UI just stays in "running" + // until the user reloads. Not worth more handling for v1. + } + } + async function run() { if (!prompt.trim() || running) return setRunning(true) setEvents([]) + setRunId(null) try { const res = await fetch(`/api/agents/${agent.id}/run`, { method: 'POST', @@ -70,6 +85,14 @@ export function AgentPanel({ if (!dataLine) continue try { const event = JSON.parse(dataLine.slice('data: '.length)) + if ( + event && + typeof event === 'object' && + event.type === 'run:start' && + typeof event.runId === 'string' + ) { + setRunId(event.runId) + } setEvents((prev) => [...prev, event]) // Auto-scroll to bottom of output requestAnimationFrame(() => { @@ -92,6 +115,7 @@ export function AgentPanel({ ]) } finally { setRunning(false) + setRunId(null) setHistoryVersion((v) => v + 1) } } @@ -159,14 +183,26 @@ export function AgentPanel({ disabled={running} className="rounded border border-neutral-800 bg-neutral-950 px-3 py-2 text-sm focus:border-neutral-600 focus:outline-none disabled:opacity-50" /> -
- +
+
+ + {running && ( + + )} +
{events.length > 0 && (