-
Notifications
You must be signed in to change notification settings - Fork 0
feat: parallel multi-agent runs, stop button, grid layout #17
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 }) | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -31,6 +31,13 @@ export type AgentRunOptions = { | |||||||||||||||||||||||||||||||||||||
| systemPrompt?: string | ||||||||||||||||||||||||||||||||||||||
| model?: string | null | ||||||||||||||||||||||||||||||||||||||
| skills?: string[] | ||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||
| * Called once with the freshly spawned child process. The caller can | ||||||||||||||||||||||||||||||||||||||
| * use this to register the process in a kill-from-elsewhere registry | ||||||||||||||||||||||||||||||||||||||
| * (so a Stop button on a parallel pane can SIGTERM this run). The | ||||||||||||||||||||||||||||||||||||||
| * child is unspecified if spawn itself fails before this callback. | ||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||
| onChild?: (child: ChildProcessWithoutNullStreams) => void | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -93,6 +100,11 @@ export async function* runAgent(opts: AgentRunOptions) { | |||||||||||||||||||||||||||||||||||||
| child = spawn('claude', args, { | ||||||||||||||||||||||||||||||||||||||
| cwd: opts.cwd, | ||||||||||||||||||||||||||||||||||||||
| env: { ...process.env }, | ||||||||||||||||||||||||||||||||||||||
| // detached:true puts the child in its own process group. SIGTERM | ||||||||||||||||||||||||||||||||||||||
| // sent to -pid then kills the whole group, including any tool | ||||||||||||||||||||||||||||||||||||||
| // subprocesses claude spawned. We still wait for the child in the | ||||||||||||||||||||||||||||||||||||||
| // current process, so the OS treats this as a regular child. | ||||||||||||||||||||||||||||||||||||||
| detached: true, | ||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||
| } catch (err) { | ||||||||||||||||||||||||||||||||||||||
| yield { | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -102,6 +114,7 @@ export async function* runAgent(opts: AgentRunOptions) { | |||||||||||||||||||||||||||||||||||||
| yield { type: 'exit', code: 1 } | ||||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| if (opts.onChild) opts.onChild(child) | ||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Guard If Line 117 throws, the stream setup aborts but the spawned process can keep running unmanaged. 💡 Proposed fix- if (opts.onChild) opts.onChild(child)
+ if (opts.onChild) {
+ try {
+ opts.onChild(child)
+ } catch (err) {
+ try {
+ child.kill('SIGTERM')
+ } catch {
+ // ignore best-effort cleanup failure
+ }
+ yield {
+ type: 'error',
+ message: `onChild callback failed: ${err instanceof Error ? err.message : String(err)}`,
+ }
+ yield { type: 'exit', code: 1 }
+ return
+ }
+ }📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| // Forward stdout as JSONL events. Buffer partial lines across | ||||||||||||||||||||||||||||||||||||||
| // chunks so we never yield half a JSON object. | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| import type { ChildProcessWithoutNullStreams } from 'node:child_process' | ||
|
|
||
| /** | ||
| * Process-local registry mapping runId -> the child process spawned | ||
| * for that run. Used by the Stop endpoint to find and SIGTERM a run | ||
| * that was started in a different request. | ||
| * | ||
| * In-memory only, single process. A `next start` worker restart drops | ||
| * the map and any leftover process becomes an orphan (cleaned up by | ||
| * the OS or by exiting on its own). Good enough for a local single- | ||
| * user app; revisit if Argus ever runs multi-process. | ||
| * | ||
| * Cached on globalThis so Next.js dev's module hot reload doesn't | ||
| * silently fork the map. | ||
| */ | ||
|
|
||
| declare global { | ||
| // eslint-disable-next-line no-var | ||
| var __argus_run_registry: Map<string, ChildProcessWithoutNullStreams> | undefined | ||
| } | ||
|
|
||
| const registry: Map<string, ChildProcessWithoutNullStreams> = | ||
| globalThis.__argus_run_registry ?? | ||
| (globalThis.__argus_run_registry = new Map()) | ||
|
|
||
| export function registerRun( | ||
| runId: string, | ||
| child: ChildProcessWithoutNullStreams, | ||
| ): void { | ||
| registry.set(runId, child) | ||
| // Auto-clean when the process exits, so the map doesn't grow forever | ||
| // and so a Stop request after a natural exit just no-ops cleanly. | ||
| child.once('exit', () => { | ||
| if (registry.get(runId) === child) registry.delete(runId) | ||
| }) | ||
| } | ||
|
|
||
| export function unregisterRun(runId: string): void { | ||
| registry.delete(runId) | ||
| } | ||
|
|
||
| export function isRunning(runId: string): boolean { | ||
| return registry.has(runId) | ||
| } | ||
|
|
||
| /** | ||
| * SIGTERM the run's process group. Returns true if a process was | ||
| * found and signaled, false if there's nothing to kill (already | ||
| * exited, never registered, etc). | ||
| * | ||
| * Targets `-pid` (negative) so the signal hits the whole process group | ||
| * the child was placed into (see detached:true on spawn). That kills | ||
| * any tool subprocesses claude itself started. | ||
| */ | ||
| export function killRun(runId: string): boolean { | ||
| const child = registry.get(runId) | ||
| if (!child || child.pid == null) return false | ||
| try { | ||
| process.kill(-child.pid, 'SIGTERM') | ||
| return true | ||
|
Comment on lines
+55
to
+60
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: No. On Windows, Node’s process.kill does not support killing a “process group” via a negative PID; the Node docs state that Windows will throw an error if the pid is used to kill a process group [1]. Recommended cross-platform approach to terminate a child process tree: - Don’t rely on negative-PID / process-group signaling for tree termination; it’s Unix-oriented and not supported on Windows by Node [1]. - Instead, track the spawned child and explicitly terminate descendants in a platform-appropriate way (e.g., enumerate children and kill them recursively, or use a cross-platform library that does this). For example, libraries like node-tree-kill/execa’s “tree kill” approach exist specifically because Windows and Unix have very different process/tree and signaling semantics [2]. - If you control the child process, implement cooperative shutdown (listen for a shutdown message/signal and exit), and/or use an out-of-band watchdog so the parent/manager can kill the entire subtree when the parent is terminated [3]. If you want a practical pattern in Node: spawn with child_process (optionally {detached: true} only if you understand the lifecycle implications), then perform explicit recursive cleanup rather than process.kill(-pid) on Windows; use a maintained process-tree kill utility so behavior is consistent across OSes [2][4]. Citations:
Fix Windows process termination: avoid negative-PID process-group kill in File: export function killRun(runId: string): boolean {
const child = registry.get(runId)
if (!child || child.pid == null) return false
try {
process.kill(-child.pid, 'SIGTERM')
return true
export function killRun(runId: string): boolean {
const child = registry.get(runId)
if (!child || child.pid == null) return false
try {
- process.kill(-child.pid, 'SIGTERM')
+ if (process.platform === 'win32') {
+ // Best-effort fallback: terminate the child process itself.
+ // (Process-group kill via negative PID is POSIX-specific.)
+ process.kill(child.pid, 'SIGTERM')
+ } else {
+ process.kill(-child.pid, 'SIGTERM')
+ }
return true
} catch {This fallback avoids the Windows negative-PID issue but only targets the parent process; if the goal is to stop the whole process tree consistently across OSes, implement cross-platform descendant termination (e.g., recursive child enumeration or a maintained “tree kill” utility) instead of relying on process-group signaling. 🤖 Prompt for AI Agents |
||
| } catch { | ||
| // Process already gone or signal not permitted; either way, drop | ||
| // it from the map so we don't try again. | ||
| registry.delete(runId) | ||
| return false | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Avoid reporting successful-looking exit codes for cancelled runs.
When
statusiscancelled, persisting/emitting a normal exit code is misleading in history and live output.💡 Proposed fix
const status: 'completed' | 'failed' | 'cancelled' = cancelled ? 'cancelled' : exitCode === 0 ? 'completed' : 'failed' + const effectiveExitCode = status === 'cancelled' ? null : exitCode try { await db .update(schema.runs) .set({ output: collected.join('\n'), status, - exitCode: exitCode ?? -1, + exitCode: effectiveExitCode ?? -1, endedAt: new Date(), }) .where(eq(schema.runs.id, run.id)) } catch { // best-effort persistence; ignore. } - send({ type: 'run:end', runId: run.id, status, exitCode }) + send({ type: 'run:end', runId: run.id, status, exitCode: effectiveExitCode })Also applies to: 140-143, 150-150
🤖 Prompt for AI Agents