From 1eddb753f450ec07ad8e4182721d4787d8a37429 Mon Sep 17 00:00:00 2001 From: Adam Creeger Date: Thu, 5 Feb 2026 19:01:18 -0500 Subject: [PATCH 1/9] feat(swarm): add BeadsManager, BeadsSyncService, swarm settings schema, and epic label support (#558) --- src/lib/BeadsManager.test.ts | 361 ++++++++++++++++++++++++++++++ src/lib/BeadsManager.ts | 310 +++++++++++++++++++++++++ src/lib/BeadsSyncService.test.ts | 231 +++++++++++++++++++ src/lib/BeadsSyncService.ts | 164 ++++++++++++++ src/lib/SettingsManager.ts | 50 +++++ templates/prompts/plan-prompt.txt | 4 + 6 files changed, 1120 insertions(+) create mode 100644 src/lib/BeadsManager.test.ts create mode 100644 src/lib/BeadsManager.ts create mode 100644 src/lib/BeadsSyncService.test.ts create mode 100644 src/lib/BeadsSyncService.ts diff --git a/src/lib/BeadsManager.test.ts b/src/lib/BeadsManager.test.ts new file mode 100644 index 0000000..4a476eb --- /dev/null +++ b/src/lib/BeadsManager.test.ts @@ -0,0 +1,361 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { BeadsManager, BeadsError } from './BeadsManager.js' +import { execa } from 'execa' + +// Mock execa +vi.mock('execa', () => ({ + execa: vi.fn(), +})) + +// Mock prompt utilities +vi.mock('../utils/prompt.js', () => ({ + promptConfirmation: vi.fn(), + isInteractiveEnvironment: vi.fn(), +})) + +// Mock logger +vi.mock('../utils/logger.js', () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})) + +import { promptConfirmation, isInteractiveEnvironment } from '../utils/prompt.js' + +describe('BeadsManager', () => { + let beadsManager: BeadsManager + + beforeEach(() => { + beadsManager = new BeadsManager('/test/project') + }) + + describe('constructor', () => { + it('should compute a stable beads directory from project path', () => { + const manager = new BeadsManager('/test/project') + const beadsDir = manager.getBeadsDir() + + // Should be under the default base dir with a hash suffix + expect(beadsDir).toContain('iloom-ai/beads/') + expect(beadsDir).toMatch(/\/[a-f0-9]{12}$/) + }) + + it('should produce different directories for different project paths', () => { + const manager1 = new BeadsManager('/project/one') + const manager2 = new BeadsManager('/project/two') + + expect(manager1.getBeadsDir()).not.toEqual(manager2.getBeadsDir()) + }) + + it('should produce the same directory for the same project path', () => { + const manager1 = new BeadsManager('/project/same') + const manager2 = new BeadsManager('/project/same') + + expect(manager1.getBeadsDir()).toEqual(manager2.getBeadsDir()) + }) + + it('should use custom beadsDir from settings', () => { + const manager = new BeadsManager('/test/project', { + beadsDir: '/custom/beads/path', + }) + const beadsDir = manager.getBeadsDir() + + expect(beadsDir).toMatch(/^\/custom\/beads\/path\/[a-f0-9]{12}$/) + }) + + it('should expand tilde in beadsDir', () => { + const manager = new BeadsManager('/test/project', { + beadsDir: '~/.config/iloom-ai/beads', + }) + const beadsDir = manager.getBeadsDir() + + // Should not contain tilde + expect(beadsDir).not.toContain('~') + expect(beadsDir).toContain('iloom-ai/beads/') + }) + }) + + describe('isInstalled', () => { + it('should return true when bd is on PATH', async () => { + vi.mocked(execa).mockResolvedValueOnce({ stdout: '/usr/local/bin/bd' } as never) + + const result = await beadsManager.isInstalled() + expect(result).toBe(true) + }) + + it('should return false when bd is not on PATH', async () => { + vi.mocked(execa).mockRejectedValueOnce(new Error('not found')) + + const result = await beadsManager.isInstalled() + expect(result).toBe(false) + }) + }) + + describe('ensureInstalled', () => { + it('should return immediately when bd is already installed', async () => { + vi.mocked(execa).mockResolvedValueOnce({ stdout: '/usr/local/bin/bd' } as never) + + await beadsManager.ensureInstalled() + + // Only one call for `command -v bd` + expect(execa).toHaveBeenCalledTimes(1) + }) + + it('should auto-install when autoInstall is true', async () => { + // First call: command -v bd (not found) + vi.mocked(execa).mockRejectedValueOnce(new Error('not found')) + // Second call: curl | bash install script + vi.mocked(execa).mockResolvedValueOnce({ stdout: '' } as never) + // Third call: verify installation (command -v bd) + vi.mocked(execa).mockResolvedValueOnce({ stdout: '/usr/local/bin/bd' } as never) + + await beadsManager.ensureInstalled(true) + + expect(execa).toHaveBeenCalledTimes(3) + }) + + it('should prompt in interactive mode when autoInstall is false', async () => { + // First call: command -v bd (not found) + vi.mocked(execa).mockRejectedValueOnce(new Error('not found')) + vi.mocked(isInteractiveEnvironment).mockReturnValue(true) + vi.mocked(promptConfirmation).mockResolvedValueOnce(true) + // Second call: curl | bash install script + vi.mocked(execa).mockResolvedValueOnce({ stdout: '' } as never) + // Third call: verify installation + vi.mocked(execa).mockResolvedValueOnce({ stdout: '/usr/local/bin/bd' } as never) + + await beadsManager.ensureInstalled(false) + + expect(promptConfirmation).toHaveBeenCalledWith( + 'Swarm mode requires Beads. Install now?', + true, + ) + }) + + it('should throw when user declines installation in interactive mode', async () => { + vi.mocked(execa).mockRejectedValueOnce(new Error('not found')) + vi.mocked(isInteractiveEnvironment).mockReturnValue(true) + vi.mocked(promptConfirmation).mockResolvedValueOnce(false) + + await expect(beadsManager.ensureInstalled(false)).rejects.toThrow( + 'Beads CLI is required for swarm mode', + ) + }) + + it('should auto-install in non-interactive mode', async () => { + vi.mocked(execa).mockRejectedValueOnce(new Error('not found')) + vi.mocked(isInteractiveEnvironment).mockReturnValue(false) + // Install + vi.mocked(execa).mockResolvedValueOnce({ stdout: '' } as never) + // Verify + vi.mocked(execa).mockResolvedValueOnce({ stdout: '/usr/local/bin/bd' } as never) + + await beadsManager.ensureInstalled(false) + + expect(promptConfirmation).not.toHaveBeenCalled() + }) + + it('should throw when installation completes but bd is still not on PATH', async () => { + vi.mocked(execa).mockRejectedValueOnce(new Error('not found')) + // Install + vi.mocked(execa).mockResolvedValueOnce({ stdout: '' } as never) + // Verify fails + vi.mocked(execa).mockRejectedValueOnce(new Error('not found')) + + await expect(beadsManager.ensureInstalled(true)).rejects.toThrow( + 'Beads CLI installation completed but bd is not available on PATH', + ) + }) + }) + + describe('init', () => { + it('should call bd init with correct flags and environment', async () => { + vi.mocked(execa).mockResolvedValueOnce({ stdout: '', stderr: '' } as never) + + await beadsManager.init() + + expect(execa).toHaveBeenCalledWith( + 'bd', + ['init', '--quiet', '--skip-hooks', '--skip-merge-driver'], + expect.objectContaining({ + cwd: '/test/project', + env: expect.objectContaining({ + BEADS_DIR: beadsManager.getBeadsDir(), + BEADS_NO_DAEMON: '1', + }), + }), + ) + }) + + it('should throw BeadsError on failure', async () => { + const error = new Error('init failed') as Error & { stderr: string; exitCode: number } + error.stderr = 'Permission denied' + error.exitCode = 1 + vi.mocked(execa).mockRejectedValueOnce(error) + + await expect(beadsManager.init()).rejects.toThrow(BeadsError) + }) + }) + + describe('create', () => { + it('should create a task with title', async () => { + vi.mocked(execa).mockResolvedValueOnce({ + stdout: 'task-123', + stderr: '', + } as never) + + const taskId = await beadsManager.create('Implement feature X') + + expect(execa).toHaveBeenCalledWith( + 'bd', + ['create', 'Implement feature X'], + expect.objectContaining({ + env: expect.objectContaining({ + BEADS_DIR: beadsManager.getBeadsDir(), + BEADS_NO_DAEMON: '1', + }), + }), + ) + expect(taskId).toBe('task-123') + }) + + it('should create a task with custom id and priority', async () => { + vi.mocked(execa).mockResolvedValueOnce({ + stdout: '42', + stderr: '', + } as never) + + const taskId = await beadsManager.create('Fix bug', { + id: '42', + priority: 5, + }) + + expect(execa).toHaveBeenCalledWith( + 'bd', + ['create', 'Fix bug', '--id', '42', '--priority', '5'], + expect.anything(), + ) + expect(taskId).toBe('42') + }) + }) + + describe('addDependency', () => { + it('should add a blocking dependency', async () => { + vi.mocked(execa).mockResolvedValueOnce({ stdout: '', stderr: '' } as never) + + await beadsManager.addDependency('child-1', 'parent-1') + + expect(execa).toHaveBeenCalledWith( + 'bd', + ['dep', 'add', 'child-1', 'parent-1'], + expect.anything(), + ) + }) + }) + + describe('ready', () => { + it('should return parsed tasks with no open blockers', async () => { + const tasks = [ + { id: '1', title: 'Task A', status: 'open' }, + { id: '2', title: 'Task B', status: 'open' }, + ] + vi.mocked(execa).mockResolvedValueOnce({ + stdout: JSON.stringify(tasks), + stderr: '', + } as never) + + const result = await beadsManager.ready() + + expect(execa).toHaveBeenCalledWith( + 'bd', + ['ready', '--json'], + expect.anything(), + ) + expect(result).toEqual(tasks) + }) + + it('should return empty array when output is not valid JSON', async () => { + vi.mocked(execa).mockResolvedValueOnce({ + stdout: 'not json', + stderr: '', + } as never) + + const result = await beadsManager.ready() + expect(result).toEqual([]) + }) + }) + + describe('claim', () => { + it('should atomically claim a task', async () => { + vi.mocked(execa).mockResolvedValueOnce({ stdout: '', stderr: '' } as never) + + await beadsManager.claim('task-1') + + expect(execa).toHaveBeenCalledWith( + 'bd', + ['update', '--claim', 'task-1'], + expect.anything(), + ) + }) + }) + + describe('close', () => { + it('should close a task', async () => { + vi.mocked(execa).mockResolvedValueOnce({ stdout: '', stderr: '' } as never) + + await beadsManager.close('task-1') + + expect(execa).toHaveBeenCalledWith( + 'bd', + ['close', 'task-1'], + expect.anything(), + ) + }) + + it('should close a task with reason', async () => { + vi.mocked(execa).mockResolvedValueOnce({ stdout: '', stderr: '' } as never) + + await beadsManager.close('task-1', 'Completed successfully') + + expect(execa).toHaveBeenCalledWith( + 'bd', + ['close', 'task-1', '--reason', 'Completed successfully'], + expect.anything(), + ) + }) + }) + + describe('releaseClaim', () => { + it('should release a claimed task', async () => { + vi.mocked(execa).mockResolvedValueOnce({ stdout: '', stderr: '' } as never) + + await beadsManager.releaseClaim('task-1') + + expect(execa).toHaveBeenCalledWith( + 'bd', + ['update', '--release', 'task-1'], + expect.anything(), + ) + }) + }) + + describe('environment variables', () => { + it('should set BEADS_DIR and BEADS_NO_DAEMON=1 in all bd commands', async () => { + vi.mocked(execa).mockResolvedValueOnce({ stdout: '[]', stderr: '' } as never) + + await beadsManager.ready() + + const callArgs = vi.mocked(execa).mock.calls[0] + expect(callArgs[2]).toEqual( + expect.objectContaining({ + env: expect.objectContaining({ + BEADS_DIR: beadsManager.getBeadsDir(), + BEADS_NO_DAEMON: '1', + }), + }), + ) + }) + }) +}) diff --git a/src/lib/BeadsManager.ts b/src/lib/BeadsManager.ts new file mode 100644 index 0000000..cbd4e80 --- /dev/null +++ b/src/lib/BeadsManager.ts @@ -0,0 +1,310 @@ +import { createHash } from 'crypto' +import path from 'path' +import os from 'os' +import { execa, type ExecaError } from 'execa' +import { logger } from '../utils/logger.js' +import { promptConfirmation, isInteractiveEnvironment } from '../utils/prompt.js' +import type { SwarmSettings } from './SettingsManager.js' + +/** + * Error class for Beads CLI failures + * Preserves exit code and stderr for precise error handling + */ +export class BeadsError extends Error { + constructor( + message: string, + public readonly exitCode: number | undefined, + public readonly stderr: string, + ) { + super(message) + this.name = 'BeadsError' + } +} + +/** + * Represents a task in the Beads DAG + */ +export interface BeadsTask { + id: string + title: string + status: string + priority?: number + blockers?: string[] +} + +/** + * Options for creating a Beads task + */ +export interface BeadsCreateOptions { + priority?: number + id?: string +} + +/** + * Manages Beads CLI integration for swarm mode DAG operations. + * + * Beads provides dependency-aware task resolution and atomic claiming + * via SQLite WAL. State is stored outside the git repo to avoid pollution. + * + * All `bd` invocations set BEADS_DIR and BEADS_NO_DAEMON=1 in the environment. + */ +export class BeadsManager { + private readonly beadsDir: string + private readonly installScript = 'https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh' + + constructor( + private readonly projectPath: string, + swarmSettings?: Partial, + ) { + const baseDir = swarmSettings?.beadsDir ?? '~/.config/iloom-ai/beads' + const resolvedBaseDir = baseDir.startsWith('~') + ? path.join(os.homedir(), baseDir.slice(1)) + : baseDir + const projectHash = this.computeProjectHash(projectPath) + this.beadsDir = path.join(resolvedBaseDir, projectHash) + } + + /** + * Get the resolved BEADS_DIR path for this project + */ + getBeadsDir(): string { + return this.beadsDir + } + + /** + * Check if Beads CLI (bd) is installed and available on PATH + */ + async isInstalled(): Promise { + try { + await execa('command', ['-v', 'bd'], { + shell: true, + timeout: 5000, + }) + return true + } catch { + return false + } + } + + /** + * Ensure Beads CLI is installed. + * + * Detection and install logic: + * - If already installed, returns immediately + * - Interactive TTY: prompts user for confirmation + * - Non-interactive (no TTY or CI): auto-installs silently + * - autoInstall setting: auto-installs without prompting + * + * @param autoInstall - Whether to auto-install without prompting (from settings) + * @throws BeadsError if installation fails + */ + async ensureInstalled(autoInstall = false): Promise { + if (await this.isInstalled()) { + logger.debug('Beads CLI (bd) already installed') + return + } + + logger.debug('Beads CLI (bd) not found on PATH') + + // Determine whether to prompt or auto-install + if (!autoInstall && isInteractiveEnvironment()) { + const shouldInstall = await promptConfirmation( + 'Swarm mode requires Beads. Install now?', + true, + ) + if (!shouldInstall) { + throw new BeadsError( + 'Beads CLI is required for swarm mode. Install it manually or enable autoInstallBeads in settings.', + undefined, + 'User declined installation', + ) + } + } + + logger.info('Installing Beads CLI...') + await this.runInstallScript() + + // Verify installation succeeded + if (!(await this.isInstalled())) { + throw new BeadsError( + 'Beads CLI installation completed but bd is not available on PATH. Check your shell PATH configuration.', + undefined, + 'Post-install verification failed', + ) + } + + logger.info('Beads CLI installed successfully') + } + + /** + * Initialize Beads for this project. + * Idempotent - safe to re-run. + * + * @throws BeadsError if init fails + */ + async init(): Promise { + logger.debug('Initializing Beads', { beadsDir: this.beadsDir, projectPath: this.projectPath }) + + await this.execBd([ + 'init', + '--quiet', + '--skip-hooks', + '--skip-merge-driver', + ], { cwd: this.projectPath }) + + logger.debug('Beads initialized successfully') + } + + /** + * Create a new task in the Beads DAG + * + * @param title - Task title/description + * @param options - Optional creation options (priority, custom id) + * @returns The created task ID + * @throws BeadsError if creation fails + */ + async create(title: string, options?: BeadsCreateOptions): Promise { + const args = ['create', title] + + if (options?.id) { + args.push('--id', options.id) + } + + if (options?.priority !== undefined) { + args.push('--priority', String(options.priority)) + } + + const result = await this.execBd(args) + // bd create outputs the task ID + return result.stdout.trim() + } + + /** + * Add a blocking dependency between tasks. + * The parent task must be completed before the child can start. + * + * @param child - Task ID that is blocked + * @param parent - Task ID that blocks + * @throws BeadsError if dependency creation fails + */ + async addDependency(child: string, parent: string): Promise { + await this.execBd(['dep', 'add', child, parent]) + } + + /** + * List tasks with no open blockers (ready to be worked on). + * Returns parsed JSON array of ready tasks. + * + * @returns Array of tasks ready for claiming + * @throws BeadsError if the command fails + */ + async ready(): Promise { + const result = await this.execBd(['ready', '--json']) + try { + return JSON.parse(result.stdout) as BeadsTask[] + } catch { + // If JSON parsing fails, return empty array + logger.debug('Failed to parse bd ready output, returning empty', { stdout: result.stdout }) + return [] + } + } + + /** + * Atomically claim a task for processing. + * Uses SQLite WAL for atomic claiming to prevent race conditions. + * + * @param taskId - Task ID to claim + * @throws BeadsError if claiming fails (e.g., already claimed) + */ + async claim(taskId: string): Promise { + await this.execBd(['update', '--claim', taskId]) + } + + /** + * Mark a task as complete. + * + * @param taskId - Task ID to close + * @param reason - Optional reason for closing + * @throws BeadsError if close fails + */ + async close(taskId: string, reason?: string): Promise { + const args = ['close', taskId] + if (reason) { + args.push('--reason', reason) + } + await this.execBd(args) + } + + /** + * Release a claimed task (for failure recovery). + * Returns the task to ready state if it has no open blockers. + * + * @param taskId - Task ID to release + * @throws BeadsError if release fails + */ + async releaseClaim(taskId: string): Promise { + await this.execBd(['update', '--release', taskId]) + } + + /** + * Execute a bd CLI command with proper environment variables set. + * All bd commands must have BEADS_DIR and BEADS_NO_DAEMON=1. + */ + private async execBd( + args: string[], + options?: { cwd?: string }, + ): Promise<{ stdout: string; stderr: string }> { + const env = { + ...process.env, + BEADS_DIR: this.beadsDir, + BEADS_NO_DAEMON: '1', + } + + try { + const result = await execa('bd', args, { + cwd: options?.cwd ?? this.projectPath, + timeout: 30000, + encoding: 'utf8', + env, + }) + return { stdout: result.stdout, stderr: result.stderr } + } catch (error) { + const execaError = error as ExecaError + const stderr = execaError.stderr ?? execaError.message ?? 'Unknown Beads error' + throw new BeadsError( + `Beads command failed: bd ${args.join(' ')}: ${stderr}`, + execaError.exitCode, + stderr, + ) + } + } + + /** + * Run the Beads install script + */ + private async runInstallScript(): Promise { + try { + await execa('bash', ['-c', `curl -fsSL ${this.installScript} | bash`], { + timeout: 120000, + encoding: 'utf8', + stdio: 'inherit', + }) + } catch (error) { + const execaError = error as ExecaError + const stderr = execaError.stderr ?? execaError.message ?? 'Unknown error' + throw new BeadsError( + `Failed to install Beads CLI: ${stderr}`, + execaError.exitCode, + stderr, + ) + } + } + + /** + * Compute a stable hash from the project path to avoid collisions + * between different projects using the same beads base directory. + */ + private computeProjectHash(projectPath: string): string { + return createHash('sha256').update(projectPath).digest('hex').slice(0, 12) + } +} diff --git a/src/lib/BeadsSyncService.test.ts b/src/lib/BeadsSyncService.test.ts new file mode 100644 index 0000000..f35ae4c --- /dev/null +++ b/src/lib/BeadsSyncService.test.ts @@ -0,0 +1,231 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { BeadsSyncService } from './BeadsSyncService.js' +import type { BeadsManager, BeadsTask } from './BeadsManager.js' +import type { IssueManagementProvider, ChildIssueResult, DependenciesResult } from '../mcp/types.js' + +// Mock logger +vi.mock('../utils/logger.js', () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})) + +function createMockBeadsManager(): { + [K in keyof BeadsManager]: ReturnType +} { + return { + getBeadsDir: vi.fn().mockReturnValue('/mock/beads/dir'), + isInstalled: vi.fn().mockResolvedValue(true), + ensureInstalled: vi.fn().mockResolvedValue(undefined), + init: vi.fn().mockResolvedValue(undefined), + create: vi.fn().mockResolvedValue(''), + addDependency: vi.fn().mockResolvedValue(undefined), + ready: vi.fn().mockResolvedValue([]), + claim: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + releaseClaim: vi.fn().mockResolvedValue(undefined), + } +} + +function createMockIssueProvider(): { + [K in keyof IssueManagementProvider]: ReturnType +} { + return { + getIssue: vi.fn(), + getPR: vi.fn(), + getComment: vi.fn(), + createComment: vi.fn(), + updateComment: vi.fn(), + createIssue: vi.fn(), + createChildIssue: vi.fn(), + createDependency: vi.fn(), + getDependencies: vi.fn().mockResolvedValue({ blocking: [], blockedBy: [] }), + removeDependency: vi.fn(), + getChildIssues: vi.fn().mockResolvedValue([]), + } +} + +describe('BeadsSyncService', () => { + let syncService: BeadsSyncService + let mockBeadsManager: ReturnType + let mockIssueProvider: ReturnType + + beforeEach(() => { + mockBeadsManager = createMockBeadsManager() + mockIssueProvider = createMockIssueProvider() + syncService = new BeadsSyncService( + mockBeadsManager as unknown as BeadsManager, + mockIssueProvider as unknown as IssueManagementProvider, + ) + }) + + describe('syncEpicToBeads', () => { + it('should sync open child issues to Beads', async () => { + const children: ChildIssueResult[] = [ + { id: '101', title: 'Task A', url: 'https://github.com/org/repo/issues/101', state: 'open' }, + { id: '102', title: 'Task B', url: 'https://github.com/org/repo/issues/102', state: 'open' }, + ] + mockIssueProvider.getChildIssues.mockResolvedValue(children) + mockBeadsManager.create.mockResolvedValueOnce('101').mockResolvedValueOnce('102') + + const result = await syncService.syncEpicToBeads('100') + + expect(mockIssueProvider.getChildIssues).toHaveBeenCalledWith({ number: '100' }) + expect(mockBeadsManager.create).toHaveBeenCalledTimes(2) + expect(mockBeadsManager.create).toHaveBeenCalledWith('Task A', { id: '101' }) + expect(mockBeadsManager.create).toHaveBeenCalledWith('Task B', { id: '102' }) + expect(result.created).toHaveLength(2) + expect(result.skipped).toHaveLength(0) + }) + + it('should skip closed issues', async () => { + const children: ChildIssueResult[] = [ + { id: '101', title: 'Task A', url: 'url', state: 'open' }, + { id: '102', title: 'Task B', url: 'url', state: 'closed' }, + ] + mockIssueProvider.getChildIssues.mockResolvedValue(children) + mockBeadsManager.create.mockResolvedValueOnce('101') + + const result = await syncService.syncEpicToBeads('100') + + expect(mockBeadsManager.create).toHaveBeenCalledTimes(1) + expect(result.created).toHaveLength(1) + }) + + it('should skip tasks that already exist in Beads ready list', async () => { + const children: ChildIssueResult[] = [ + { id: '101', title: 'Task A', url: 'url', state: 'open' }, + { id: '102', title: 'Task B', url: 'url', state: 'open' }, + ] + const existingTasks: BeadsTask[] = [ + { id: '101', title: 'Task A', status: 'open' }, + ] + mockIssueProvider.getChildIssues.mockResolvedValue(children) + mockBeadsManager.ready.mockResolvedValue(existingTasks) + mockBeadsManager.create.mockResolvedValueOnce('102') + + const result = await syncService.syncEpicToBeads('100') + + expect(mockBeadsManager.create).toHaveBeenCalledTimes(1) + expect(mockBeadsManager.create).toHaveBeenCalledWith('Task B', { id: '102' }) + expect(result.created).toHaveLength(1) + expect(result.skipped).toEqual(['101']) + }) + + it('should skip tasks that already exist when create throws "already exists"', async () => { + const children: ChildIssueResult[] = [ + { id: '101', title: 'Task A', url: 'url', state: 'open' }, + ] + mockIssueProvider.getChildIssues.mockResolvedValue(children) + mockBeadsManager.create.mockRejectedValueOnce(new Error('Task already exists')) + + const result = await syncService.syncEpicToBeads('100') + + expect(result.created).toHaveLength(0) + expect(result.skipped).toEqual(['101']) + }) + + it('should sync dependencies between child issues', async () => { + const children: ChildIssueResult[] = [ + { id: '101', title: 'Task A', url: 'url', state: 'open' }, + { id: '102', title: 'Task B', url: 'url', state: 'open' }, + ] + mockIssueProvider.getChildIssues.mockResolvedValue(children) + mockBeadsManager.create + .mockResolvedValueOnce('101') + .mockResolvedValueOnce('102') + + // Task 102 is blocked by 101 + mockIssueProvider.getDependencies + .mockResolvedValueOnce({ blocking: [], blockedBy: [] } as DependenciesResult) // for 101 + .mockResolvedValueOnce({ + blocking: [], + blockedBy: [{ id: '101', title: 'Task A', url: 'url', state: 'open' }], + } as DependenciesResult) // for 102 + + const result = await syncService.syncEpicToBeads('100') + + expect(mockBeadsManager.addDependency).toHaveBeenCalledWith('102', '101') + expect(result.dependenciesCreated).toBe(1) + }) + + it('should skip dependencies where blocker is not in the epic', async () => { + const children: ChildIssueResult[] = [ + { id: '101', title: 'Task A', url: 'url', state: 'open' }, + ] + mockIssueProvider.getChildIssues.mockResolvedValue(children) + mockBeadsManager.create.mockResolvedValueOnce('101') + + // Task 101 is blocked by issue 999 (not part of this epic) + mockIssueProvider.getDependencies.mockResolvedValueOnce({ + blocking: [], + blockedBy: [{ id: '999', title: 'External', url: 'url', state: 'open' }], + } as DependenciesResult) + + const result = await syncService.syncEpicToBeads('100') + + expect(mockBeadsManager.addDependency).not.toHaveBeenCalled() + expect(result.dependenciesCreated).toBe(0) + }) + + it('should handle empty epic gracefully', async () => { + mockIssueProvider.getChildIssues.mockResolvedValue([]) + + const result = await syncService.syncEpicToBeads('100') + + expect(result.created).toHaveLength(0) + expect(result.skipped).toHaveLength(0) + expect(result.dependenciesCreated).toBe(0) + }) + + it('should handle ready() failure gracefully when checking existing tasks', async () => { + const children: ChildIssueResult[] = [ + { id: '101', title: 'Task A', url: 'url', state: 'open' }, + ] + mockIssueProvider.getChildIssues.mockResolvedValue(children) + mockBeadsManager.ready.mockRejectedValue(new Error('No tasks')) + mockBeadsManager.create.mockResolvedValueOnce('101') + + const result = await syncService.syncEpicToBeads('100') + + // Should still create the task since we can't check existing + expect(result.created).toHaveLength(1) + }) + + it('should continue syncing when individual dependency fetch fails', async () => { + const children: ChildIssueResult[] = [ + { id: '101', title: 'Task A', url: 'url', state: 'open' }, + { id: '102', title: 'Task B', url: 'url', state: 'open' }, + ] + mockIssueProvider.getChildIssues.mockResolvedValue(children) + mockBeadsManager.create + .mockResolvedValueOnce('101') + .mockResolvedValueOnce('102') + + // First dependency fetch fails, second succeeds + mockIssueProvider.getDependencies + .mockRejectedValueOnce(new Error('Network error')) + .mockResolvedValueOnce({ blocking: [], blockedBy: [] } as DependenciesResult) + + const result = await syncService.syncEpicToBeads('100') + + // Should still complete without throwing + expect(result.created).toHaveLength(2) + }) + + it('should handle OPEN state for Linear issues', async () => { + const children: ChildIssueResult[] = [ + { id: 'ENG-101', title: 'Task A', url: 'url', state: 'OPEN' }, + ] + mockIssueProvider.getChildIssues.mockResolvedValue(children) + mockBeadsManager.create.mockResolvedValueOnce('ENG-101') + + const result = await syncService.syncEpicToBeads('ENG-100') + + expect(result.created).toHaveLength(1) + }) + }) +}) diff --git a/src/lib/BeadsSyncService.ts b/src/lib/BeadsSyncService.ts new file mode 100644 index 0000000..7a2d8ce --- /dev/null +++ b/src/lib/BeadsSyncService.ts @@ -0,0 +1,164 @@ +import { logger } from '../utils/logger.js' +import type { BeadsManager } from './BeadsManager.js' +import type { IssueManagementProvider, DependenciesResult } from '../mcp/types.js' + +/** + * Mapping between issue tracker IDs and Beads task IDs + */ +export interface TaskMapping { + issueId: string + beadsTaskId: string + title: string +} + +/** + * Result of a sync operation + */ +export interface SyncResult { + created: TaskMapping[] + skipped: string[] + dependenciesCreated: number +} + +/** + * Syncs GitHub/Linear child issues and their dependency graph into a Beads DAG. + * + * Handles re-sync by skipping tasks that already exist in Beads (for resume scenarios). + * Only syncs open issues; closed issues are skipped. + */ +export class BeadsSyncService { + constructor( + private readonly beadsManager: BeadsManager, + private readonly issueProvider: IssueManagementProvider, + ) {} + + /** + * Sync an epic's child issues and dependencies into Beads. + * + * 1. Fetches child issues via issue tracker API + * 2. Fetches dependency graph for each child + * 3. Creates Beads tasks for each open child issue (skipping existing) + * 4. Creates Beads dependencies matching the issue tracker graph + * + * @param epicId - The parent epic issue identifier + * @returns SyncResult with created tasks, skipped tasks, and dependency count + */ + async syncEpicToBeads(epicId: string): Promise { + logger.debug('Starting Beads sync for epic', { epicId }) + + // Step 1: Fetch child issues + const children = await this.issueProvider.getChildIssues({ number: epicId }) + logger.debug('Fetched child issues', { count: children.length }) + + // Filter to only open issues + const openChildren = children.filter(child => child.state === 'open' || child.state === 'OPEN') + logger.debug('Open child issues', { count: openChildren.length }) + + // Step 2: Get existing Beads tasks to detect already-synced issues + let existingTaskIds: Set + try { + const readyTasks = await this.beadsManager.ready() + existingTaskIds = new Set(readyTasks.map(t => t.id)) + } catch { + // If ready() fails (e.g., no tasks yet), start with empty set + existingTaskIds = new Set() + } + + // Step 3: Create Beads tasks for each open child issue + const created: TaskMapping[] = [] + const skipped: string[] = [] + + for (const child of openChildren) { + if (existingTaskIds.has(child.id)) { + logger.debug('Skipping already-synced task', { issueId: child.id }) + skipped.push(child.id) + continue + } + + try { + const beadsTaskId = await this.beadsManager.create(child.title, { + id: child.id, + }) + created.push({ + issueId: child.id, + beadsTaskId, + title: child.title, + }) + logger.debug('Created Beads task', { issueId: child.id, beadsTaskId }) + } catch (error) { + // If task creation fails with "already exists", skip it + if (error instanceof Error && error.message.includes('already exists')) { + logger.debug('Task already exists in Beads, skipping', { issueId: child.id }) + skipped.push(child.id) + } else { + throw error + } + } + } + + // Step 4: Sync dependencies + let dependenciesCreated = 0 + const childIds = new Set(openChildren.map(c => c.id)) + + for (const child of openChildren) { + try { + const deps = await this.fetchDependencies(child.id) + + for (const blocker of deps.blockedBy) { + // Only create dependencies between children of this epic + if (!childIds.has(blocker.id)) { + logger.debug('Skipping dependency - blocker not in epic', { + child: child.id, + blocker: blocker.id, + }) + continue + } + + try { + await this.beadsManager.addDependency(child.id, blocker.id) + dependenciesCreated++ + logger.debug('Created Beads dependency', { + child: child.id, + parent: blocker.id, + }) + } catch (error) { + // If dependency already exists, skip + if (error instanceof Error && error.message.includes('already exists')) { + logger.debug('Dependency already exists, skipping', { + child: child.id, + parent: blocker.id, + }) + } else { + throw error + } + } + } + } catch (error) { + // Log but don't fail sync for individual dependency fetch failures + logger.warn(`Failed to fetch dependencies for issue ${child.id}: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + } + + const result: SyncResult = { + created, + skipped, + dependenciesCreated, + } + + logger.info( + `Beads sync complete: ${created.length} tasks created, ${skipped.length} skipped, ${dependenciesCreated} dependencies`, + ) + + return result + } + + /** + * Fetch dependencies for a single issue + */ + private async fetchDependencies(issueId: string): Promise { + return this.issueProvider.getDependencies({ + number: issueId, + direction: 'both', + }) + } +} diff --git a/src/lib/SettingsManager.ts b/src/lib/SettingsManager.ts index e00b899..0936ad6 100644 --- a/src/lib/SettingsManager.ts +++ b/src/lib/SettingsManager.ts @@ -233,6 +233,38 @@ export const CapabilitiesSettingsSchemaNoDefaults = z }) .optional() +/** + * Zod schema for swarm mode settings + */ +export const SwarmSettingsSchema = z.object({ + maxConcurrent: z + .number() + .min(1) + .max(10) + .default(3) + .describe('Maximum number of concurrent swarm agents'), + maxRetries: z + .number() + .min(0) + .max(5) + .default(1) + .describe('Maximum retries for failed swarm tasks'), + maxConflictRetries: z + .number() + .min(0) + .max(10) + .default(3) + .describe('Maximum retries for merge conflict resolution'), + beadsDir: z + .string() + .default('~/.config/iloom-ai/beads') + .describe('Directory for Beads DAG state files'), + autoInstallBeads: z + .boolean() + .default(false) + .describe('Automatically install Beads CLI without prompting'), +}) + /** * Zod schema for Neon database provider settings */ @@ -429,6 +461,9 @@ export const IloomSettingsSchema = z.object({ }) .optional() .describe('Color synchronization settings for workspace identification'), + swarm: SwarmSettingsSchema.optional().describe( + 'Swarm mode configuration for parallel agent execution with Beads DAG engine', + ), attribution: z .enum(['off', 'upstreamOnly', 'on']) .default('upstreamOnly') @@ -618,6 +653,16 @@ export const IloomSettingsSchemaNoDefaults = z.object({ }) .optional() .describe('Color synchronization settings for workspace identification'), + swarm: z + .object({ + maxConcurrent: z.number().min(1).max(10).optional(), + maxRetries: z.number().min(0).max(5).optional(), + maxConflictRetries: z.number().min(0).max(10).optional(), + beadsDir: z.string().optional(), + autoInstallBeads: z.boolean().optional(), + }) + .optional() + .describe('Swarm mode configuration'), attribution: z .enum(['off', 'upstreamOnly', 'on']) .optional() @@ -629,6 +674,11 @@ export const IloomSettingsSchemaNoDefaults = z.object({ ), }) +/** + * TypeScript type for swarm settings derived from Zod schema + */ +export type SwarmSettings = z.infer + /** * TypeScript type for Neon settings derived from Zod schema */ diff --git a/templates/prompts/plan-prompt.txt b/templates/prompts/plan-prompt.txt index 737c13e..95f378d 100644 --- a/templates/prompts/plan-prompt.txt +++ b/templates/prompts/plan-prompt.txt @@ -277,6 +277,9 @@ Task( Wait for the subagent to complete, then present its summary to the user for planning decisions. **Creation Order:** +1. **Ensure the parent epic has the `iloom-epic` label** + - This label enables swarm mode detection for automated parallel execution + - If the parent issue does not already have it, the planning tool should note this for the user 1. **Create child issues using the existing issue as parent** - Use `create_child_issue` with `parentId: {{PARENT_ISSUE_NUMBER}}` - Each child represents one focused unit of work (1 loom = 1 PR) @@ -299,6 +302,7 @@ Wait for the subagent to complete, then present its summary to the user for plan 1. **Create the parent epic issue first** (using `create_issue`) - This is the top-level issue describing the overall feature or initiative - Title format: "Epic: [Feature Name]" or "[Feature Name] Implementation" + - **IMPORTANT**: Always include the `iloom-epic` label when creating the parent epic issue. Pass `labels: ["iloom-epic"]` to the create_issue tool. This label enables swarm mode detection for automated parallel execution. 2. **Create all child issues linked to the parent** (using `create_child_issue`) - Pass the parent epic's issue number as `parentId` - Each child represents one focused unit of work (1 loom = 1 PR) From f92ea78cd541d25c2348798b6a9681582f230896 Mon Sep 17 00:00:00 2001 From: Adam Creeger Date: Thu, 5 Feb 2026 19:01:36 -0500 Subject: [PATCH 2/9] feat(swarm): add minimal worktree-only start path for swarm agents (#560) --- src/lib/LoomManager.test.ts | 217 ++++++++++++++++++++++++++++++++ src/lib/LoomManager.ts | 84 ++++++++++++- src/lib/MetadataManager.test.ts | 5 + src/lib/MetadataManager.ts | 5 + src/types/loom.ts | 2 + 5 files changed, 312 insertions(+), 1 deletion(-) diff --git a/src/lib/LoomManager.test.ts b/src/lib/LoomManager.test.ts index b00a07a..5aa45e4 100644 --- a/src/lib/LoomManager.test.ts +++ b/src/lib/LoomManager.test.ts @@ -939,6 +939,223 @@ describe('LoomManager', () => { ) }) }) + + describe('swarmMode', () => { + it('should create loom with minimal setup when swarmMode is true', async () => { + const swarmInput: CreateLoomInput = { + type: 'issue', + identifier: 560, + originalInput: '560', + baseBranch: 'feat/epic-branch', + parentLoom: { + type: 'issue', + identifier: 557, + branchName: 'feat/epic-branch', + worktreePath: '/test/epic-worktree', + }, + options: { + swarmMode: true, + }, + } + + vi.mocked(mockGitHub.fetchIssue).mockResolvedValue({ + number: 560, + title: 'Child Issue for Swarm', + body: 'Implement feature X', + state: 'open', + labels: [], + assignees: [], + url: 'https://github.com/owner/repo/issues/560', + }) + + const expectedPath = '/test/epic-looms/issue-560' + vi.mocked(mockGitWorktree.generateWorktreePath).mockReturnValue(expectedPath) + vi.mocked(mockGitWorktree.createWorktree).mockResolvedValue(expectedPath) + vi.mocked(mockEnvironment.calculatePort).mockReturnValue(3560) + vi.mocked(mockCapabilityDetector.detectCapabilities).mockResolvedValue({ + capabilities: ['web'], + binEntries: {}, + }) + + const result = await manager.createIloom(swarmInput) + + expect(result.id).toBe('issue-560') + expect(result.path).toBe(expectedPath) + expect(result.branch).toBeDefined() + expect(result.port).toBe(3560) + expect(result.type).toBe('issue') + expect(result.identifier).toBe(560) + expect(result.description).toBe('Child Issue for Swarm') + expect(result.issueData?.title).toBe('Child Issue for Swarm') + }) + + it('should skip dependency installation in swarm mode', async () => { + const swarmInput: CreateLoomInput = { + type: 'issue', + identifier: 560, + originalInput: '560', + options: { swarmMode: true }, + } + + vi.mocked(mockGitHub.fetchIssue).mockResolvedValue({ + number: 560, + title: 'Test', + body: '', + state: 'open', + labels: [], + assignees: [], + url: 'https://github.com/owner/repo/issues/560', + }) + + vi.mocked(mockGitWorktree.generateWorktreePath).mockReturnValue('/test/path') + vi.mocked(mockGitWorktree.createWorktree).mockResolvedValue('/test/path') + vi.mocked(mockEnvironment.calculatePort).mockReturnValue(3560) + + await manager.createIloom(swarmInput) + + expect(installDependencies).not.toHaveBeenCalled() + }) + + it('should write metadata with swarmAgent flag', async () => { + const swarmInput: CreateLoomInput = { + type: 'issue', + identifier: 560, + originalInput: '560', + parentLoom: { + type: 'issue', + identifier: 557, + branchName: 'feat/epic-branch', + worktreePath: '/test/epic-worktree', + }, + options: { swarmMode: true }, + } + + vi.mocked(mockGitHub.fetchIssue).mockResolvedValue({ + number: 560, + title: 'Test', + body: '', + state: 'open', + labels: [], + assignees: [], + url: 'https://github.com/owner/repo/issues/560', + }) + + const expectedPath = '/test/path' + vi.mocked(mockGitWorktree.generateWorktreePath).mockReturnValue(expectedPath) + vi.mocked(mockGitWorktree.createWorktree).mockResolvedValue(expectedPath) + vi.mocked(mockEnvironment.calculatePort).mockReturnValue(3560) + + await manager.createIloom(swarmInput) + + expect(mockWriteMetadata).toHaveBeenCalledWith( + expectedPath, + expect.objectContaining({ + swarmAgent: true, + parentLoom: { + type: 'issue', + identifier: 557, + branchName: 'feat/epic-branch', + worktreePath: '/test/epic-worktree', + }, + }) + ) + }) + + it('should not launch any workspace components in swarm mode', async () => { + const { LoomLauncher } = await import('./LoomLauncher.js') + + const swarmInput: CreateLoomInput = { + type: 'issue', + identifier: 560, + originalInput: '560', + options: { swarmMode: true }, + } + + vi.mocked(mockGitHub.fetchIssue).mockResolvedValue({ + number: 560, + title: 'Test', + body: '', + state: 'open', + labels: [], + assignees: [], + url: 'https://github.com/owner/repo/issues/560', + }) + + vi.mocked(mockGitWorktree.generateWorktreePath).mockReturnValue('/test/path') + vi.mocked(mockGitWorktree.createWorktree).mockResolvedValue('/test/path') + vi.mocked(mockEnvironment.calculatePort).mockReturnValue(3560) + + await manager.createIloom(swarmInput) + + // LoomLauncher should not have been instantiated + expect(LoomLauncher).not.toHaveBeenCalled() + }) + + it('should still create worktree and copy environment files in swarm mode', async () => { + const swarmInput: CreateLoomInput = { + type: 'issue', + identifier: 560, + originalInput: '560', + options: { swarmMode: true }, + } + + vi.mocked(mockGitHub.fetchIssue).mockResolvedValue({ + number: 560, + title: 'Test', + body: '', + state: 'open', + labels: [], + assignees: [], + url: 'https://github.com/owner/repo/issues/560', + }) + + const expectedPath = '/test/path' + vi.mocked(mockGitWorktree.generateWorktreePath).mockReturnValue(expectedPath) + vi.mocked(mockGitWorktree.createWorktree).mockResolvedValue(expectedPath) + vi.mocked(mockEnvironment.calculatePort).mockReturnValue(3560) + + await manager.createIloom(swarmInput) + + // Worktree should be created + expect(mockGitWorktree.createWorktree).toHaveBeenCalled() + + // Environment files should be copied (copyIfExists is called for env files) + // We verify by checking metadata was written (which proves we got past env copy) + expect(mockWriteMetadata).toHaveBeenCalled() + }) + + it('should not create draft PR in swarm mode even with github-draft-pr merge behavior', async () => { + vi.mocked(mockSettings.loadSettings).mockResolvedValue({ + mergeBehavior: { mode: 'github-draft-pr' }, + }) + + const swarmInput: CreateLoomInput = { + type: 'issue', + identifier: 560, + originalInput: '560', + options: { swarmMode: true }, + } + + vi.mocked(mockGitHub.fetchIssue).mockResolvedValue({ + number: 560, + title: 'Test', + body: '', + state: 'open', + labels: [], + assignees: [], + url: 'https://github.com/owner/repo/issues/560', + }) + + vi.mocked(mockGitWorktree.generateWorktreePath).mockReturnValue('/test/path') + vi.mocked(mockGitWorktree.createWorktree).mockResolvedValue('/test/path') + vi.mocked(mockEnvironment.calculatePort).mockReturnValue(3560) + + await manager.createIloom(swarmInput) + + expect(mockCreateDraftPR).not.toHaveBeenCalled() + expect(mockCheckForExistingPR).not.toHaveBeenCalled() + }) + }) }) describe('listLooms', () => { diff --git a/src/lib/LoomManager.ts b/src/lib/LoomManager.ts index d113443..5c262fd 100644 --- a/src/lib/LoomManager.ts +++ b/src/lib/LoomManager.ts @@ -19,7 +19,7 @@ import { generateColorFromBranchName, selectDistinctColor, hexToRgb, type ColorD import { detectDarkMode } from '../utils/terminal.js' import { DatabaseManager } from './DatabaseManager.js' import { loadEnvIntoProcess, findEnvFileForDatabaseUrl, isNoEnvFilesFoundError } from '../utils/env.js' -import type { Loom, CreateLoomInput } from '../types/loom.js' +import type { Loom, CreateLoomInput, ProjectCapability } from '../types/loom.js' import type { GitWorktree } from '../types/worktree.js' import type { Issue, PullRequest } from '../types/index.js' import { getLogger } from '../utils/logger-context.js' @@ -139,6 +139,12 @@ export class LoomManager { port = await this.setupPortForWeb(worktreePath, input, basePort) } + // SWARM MODE: Fast path - skip dependency install, database, CLI isolation, + // draft PR, color sync, issue status update, and launching + if (input.options?.swarmMode) { + return await this.finishSwarmLoom(input, issueData, branchName, worktreePath, port, capabilities) + } + // 9. Install dependencies AFTER environment setup (like bash script line 757-769) try { await installDependencies(worktreePath, true, true) @@ -1170,6 +1176,82 @@ export class LoomManager { })) } + /** + * Swarm mode fast path: write metadata and return loom data. + * Called after worktree, env files, settings, and port are set up. + * Skips: dependency install, database, CLI isolation, draft PR, color sync, issue status, launching. + */ + private async finishSwarmLoom( + input: CreateLoomInput, + issueData: Issue | PullRequest | null, + branchName: string, + worktreePath: string, + port: number, + capabilities: ProjectCapability[], + ): Promise { + const description = issueData?.title ?? branchName + + // Build issue/pr numbers arrays + let issue_numbers: string[] = [] + if (input.type === 'issue') { + issue_numbers = [String(input.identifier)] + } + const pr_numbers: string[] = input.type === 'pr' ? [String(input.identifier)] : [] + + const sessionId = generateRandomSessionId() + + let issueUrls: Record = {} + if (input.type === 'issue' && issueData?.url) { + issueUrls = { [String(input.identifier)]: issueData.url } + } + const prUrls: Record = input.type === 'pr' && issueData?.url + ? { [String(input.identifier)]: issueData.url } + : {} + + const metadataInput: WriteMetadataInput = { + description, + branchName, + worktreePath, + issueType: input.type, + issue_numbers, + pr_numbers, + issueTracker: this.issueTracker.providerName, + colorHex: '#888888', // Neutral color for swarm agents (no visual components) + sessionId, + projectPath: this.gitWorktree.workingDirectory, + issueUrls, + prUrls, + capabilities, + swarmAgent: true, + ...(input.parentLoom && { parentLoom: input.parentLoom }), + } + await this.metadataManager.writeMetadata(worktreePath, metadataInput) + + const loom: Loom = { + id: this.generateLoomId(input), + path: worktreePath, + branch: branchName, + type: input.type, + identifier: input.identifier, + port, + description, + createdAt: new Date(), + lastAccessed: new Date(), + ...(capabilities.length > 0 && { capabilities }), + ...(issueData !== null && { + issueData: { + title: issueData.title, + body: issueData.body, + url: issueData.url, + state: issueData.state, + }, + }), + } + + getLogger().success(`Created swarm loom: ${loom.id} at ${loom.path}`) + return loom + } + /** * NEW: Find existing loom for the given input * Checks for worktrees matching the issue/PR identifier diff --git a/src/lib/MetadataManager.test.ts b/src/lib/MetadataManager.test.ts index 89822e7..15ef8d9 100644 --- a/src/lib/MetadataManager.test.ts +++ b/src/lib/MetadataManager.test.ts @@ -324,6 +324,7 @@ describe('MetadataManager', () => { draftPrNumber: null, oneShot: null, capabilities: ['web'], + swarmAgent: false, parentLoom: null, }) }) @@ -419,6 +420,7 @@ describe('MetadataManager', () => { draftPrNumber: null, oneShot: null, capabilities: [], + swarmAgent: false, parentLoom: null, }) }) @@ -739,6 +741,7 @@ describe('MetadataManager', () => { draftPrNumber: null, oneShot: null, capabilities: ['cli'], + swarmAgent: false, parentLoom: null, }) expect(result[1]).toEqual({ @@ -758,6 +761,7 @@ describe('MetadataManager', () => { draftPrNumber: null, oneShot: null, capabilities: ['web'], + swarmAgent: false, parentLoom: null, }) }) @@ -861,6 +865,7 @@ describe('MetadataManager', () => { draftPrNumber: null, oneShot: null, capabilities: [], + swarmAgent: false, parentLoom: null, }) }) diff --git a/src/lib/MetadataManager.ts b/src/lib/MetadataManager.ts index c1d26e0..3817ea2 100644 --- a/src/lib/MetadataManager.ts +++ b/src/lib/MetadataManager.ts @@ -28,6 +28,7 @@ export interface MetadataFile { draftPrNumber?: number // Draft PR number if github-draft-pr mode was used oneShot?: OneShotMode // One-shot automation mode stored during loom creation capabilities?: ProjectCapability[] // Detected project capabilities + swarmAgent?: boolean // Whether this loom was created for a swarm agent parentLoom?: { type: 'issue' | 'pr' | 'branch' identifier: string | number @@ -58,6 +59,7 @@ export interface WriteMetadataInput { draftPrNumber?: number // Draft PR number for github-draft-pr mode oneShot?: OneShotMode // One-shot automation mode to persist capabilities: ProjectCapability[] // Detected project capabilities (required for new looms) + swarmAgent?: boolean // Whether this loom was created for a swarm agent parentLoom?: { type: 'issue' | 'pr' | 'branch' identifier: string | number @@ -89,6 +91,7 @@ export interface LoomMetadata { draftPrNumber: number | null // Draft PR number (null if not draft mode) oneShot: OneShotMode | null // One-shot mode (null for legacy looms) capabilities: ProjectCapability[] // Detected project capabilities (empty for legacy looms) + swarmAgent: boolean // Whether this loom was created for a swarm agent parentLoom: { type: 'issue' | 'pr' | 'branch' identifier: string | number @@ -139,6 +142,7 @@ export class MetadataManager { draftPrNumber: data.draftPrNumber ?? null, oneShot: data.oneShot ?? null, capabilities: data.capabilities ?? [], + swarmAgent: data.swarmAgent ?? false, parentLoom: data.parentLoom ?? null, } } @@ -217,6 +221,7 @@ export class MetadataManager { capabilities: input.capabilities, ...(input.draftPrNumber && { draftPrNumber: input.draftPrNumber }), ...(input.oneShot && { oneShot: input.oneShot }), + ...(input.swarmAgent && { swarmAgent: input.swarmAgent }), ...(input.parentLoom && { parentLoom: input.parentLoom }), } diff --git a/src/types/loom.ts b/src/types/loom.ts index afeb49e..761781e 100644 --- a/src/types/loom.ts +++ b/src/types/loom.ts @@ -54,6 +54,8 @@ export interface CreateLoomInput { executablePath?: string // Control .env sourcing in terminal launches sourceEnvOnStart?: boolean + // Minimal setup for swarm agents: worktree + branch + env + metadata + port only + swarmMode?: boolean } } From ed8a37478fdfd63eb394395ad90b1392ae4904fb Mon Sep 17 00:00:00 2001 From: Adam Creeger Date: Thu, 5 Feb 2026 19:01:53 -0500 Subject: [PATCH 3/9] feat(swarm): add swarm mode template variables and prompt changes (#561) --- src/commands/ignite.test.ts | 119 +++++++++++++++++++++++++++++ src/commands/ignite.ts | 11 +++ src/lib/PromptTemplateManager.ts | 4 + templates/prompts/issue-prompt.txt | 65 +++++++++++++++- 4 files changed, 198 insertions(+), 1 deletion(-) diff --git a/src/commands/ignite.test.ts b/src/commands/ignite.test.ts index 45aae4a..399aa9f 100644 --- a/src/commands/ignite.test.ts +++ b/src/commands/ignite.test.ts @@ -2836,6 +2836,125 @@ describe('IgniteCommand', () => { }) }) + describe('SWARM_MODE template variables', () => { + it('should set SWARM_MODE, EPIC_BRANCH, and EPIC_ISSUE_NUMBER when env vars are set', async () => { + const launchClaudeSpy = vi.spyOn(claudeUtils, 'launchClaude').mockResolvedValue(undefined) + + const originalCwd = process.cwd + process.cwd = vi.fn().mockReturnValue('/path/to/feat/issue-100__swarm-test') + + // Set swarm env vars + const originalSwarmMode = process.env.ILOOM_SWARM_MODE + const originalEpicBranch = process.env.ILOOM_EPIC_BRANCH + const originalEpicIssue = process.env.ILOOM_EPIC_ISSUE + process.env.ILOOM_SWARM_MODE = '1' + process.env.ILOOM_EPIC_BRANCH = 'issue-42-swarm-mode' + process.env.ILOOM_EPIC_ISSUE = '42' + + try { + await command.execute() + + expect(mockTemplateManager.getPrompt).toHaveBeenCalledWith( + 'issue', + expect.objectContaining({ + SWARM_MODE: true, + EPIC_BRANCH: 'issue-42-swarm-mode', + EPIC_ISSUE_NUMBER: '42', + }) + ) + } finally { + process.cwd = originalCwd + launchClaudeSpy.mockRestore() + // Restore env vars + if (originalSwarmMode === undefined) delete process.env.ILOOM_SWARM_MODE + else process.env.ILOOM_SWARM_MODE = originalSwarmMode + if (originalEpicBranch === undefined) delete process.env.ILOOM_EPIC_BRANCH + else process.env.ILOOM_EPIC_BRANCH = originalEpicBranch + if (originalEpicIssue === undefined) delete process.env.ILOOM_EPIC_ISSUE + else process.env.ILOOM_EPIC_ISSUE = originalEpicIssue + } + }) + + it('should not set SWARM_MODE when ILOOM_SWARM_MODE env var is not set', async () => { + const launchClaudeSpy = vi.spyOn(claudeUtils, 'launchClaude').mockResolvedValue(undefined) + + const originalCwd = process.cwd + process.cwd = vi.fn().mockReturnValue('/path/to/feat/issue-101__no-swarm') + + // Ensure swarm env vars are not set + const originalSwarmMode = process.env.ILOOM_SWARM_MODE + delete process.env.ILOOM_SWARM_MODE + + try { + await command.execute() + + const templateCall = vi.mocked(mockTemplateManager.getPrompt).mock.calls[0] + expect(templateCall[1].SWARM_MODE).toBeUndefined() + expect(templateCall[1].EPIC_BRANCH).toBeUndefined() + expect(templateCall[1].EPIC_ISSUE_NUMBER).toBeUndefined() + } finally { + process.cwd = originalCwd + launchClaudeSpy.mockRestore() + if (originalSwarmMode === undefined) delete process.env.ILOOM_SWARM_MODE + else process.env.ILOOM_SWARM_MODE = originalSwarmMode + } + }) + + it('should not set SWARM_MODE when ILOOM_SWARM_MODE is not "1"', async () => { + const launchClaudeSpy = vi.spyOn(claudeUtils, 'launchClaude').mockResolvedValue(undefined) + + const originalCwd = process.cwd + process.cwd = vi.fn().mockReturnValue('/path/to/feat/issue-102__swarm-false') + + const originalSwarmMode = process.env.ILOOM_SWARM_MODE + process.env.ILOOM_SWARM_MODE = 'false' + + try { + await command.execute() + + const templateCall = vi.mocked(mockTemplateManager.getPrompt).mock.calls[0] + expect(templateCall[1].SWARM_MODE).toBeUndefined() + } finally { + process.cwd = originalCwd + launchClaudeSpy.mockRestore() + if (originalSwarmMode === undefined) delete process.env.ILOOM_SWARM_MODE + else process.env.ILOOM_SWARM_MODE = originalSwarmMode + } + }) + + it('should set SWARM_MODE without EPIC_BRANCH or EPIC_ISSUE_NUMBER when those env vars are missing', async () => { + const launchClaudeSpy = vi.spyOn(claudeUtils, 'launchClaude').mockResolvedValue(undefined) + + const originalCwd = process.cwd + process.cwd = vi.fn().mockReturnValue('/path/to/feat/issue-103__swarm-minimal') + + const originalSwarmMode = process.env.ILOOM_SWARM_MODE + const originalEpicBranch = process.env.ILOOM_EPIC_BRANCH + const originalEpicIssue = process.env.ILOOM_EPIC_ISSUE + process.env.ILOOM_SWARM_MODE = '1' + delete process.env.ILOOM_EPIC_BRANCH + delete process.env.ILOOM_EPIC_ISSUE + + try { + await command.execute() + + const templateCall = vi.mocked(mockTemplateManager.getPrompt).mock.calls[0] + expect(templateCall[1].SWARM_MODE).toBe(true) + expect(templateCall[1].EPIC_BRANCH).toBeUndefined() + expect(templateCall[1].EPIC_ISSUE_NUMBER).toBeUndefined() + } finally { + process.cwd = originalCwd + launchClaudeSpy.mockRestore() + if (originalSwarmMode === undefined) delete process.env.ILOOM_SWARM_MODE + else process.env.ILOOM_SWARM_MODE = originalSwarmMode + if (originalEpicBranch === undefined) delete process.env.ILOOM_EPIC_BRANCH + else process.env.ILOOM_EPIC_BRANCH = originalEpicBranch + if (originalEpicIssue === undefined) delete process.env.ILOOM_EPIC_ISSUE + else process.env.ILOOM_EPIC_ISSUE = originalEpicIssue + } + }) + }) + describe('Main worktree validation', () => { it('should throw WorktreeValidationError when running from main worktree', async () => { const launchClaudeSpy = vi.spyOn(claudeUtils, 'launchClaude').mockResolvedValue(undefined) diff --git a/src/commands/ignite.ts b/src/commands/ignite.ts index f1deff7..4d69322 100644 --- a/src/commands/ignite.ts +++ b/src/commands/ignite.ts @@ -536,6 +536,17 @@ export class IgniteCommand { variables.STANDARD_ISSUE_MODE = true } + // Set swarm mode variables from environment + if (process.env.ILOOM_SWARM_MODE === '1') { + variables.SWARM_MODE = true + if (process.env.ILOOM_EPIC_BRANCH) { + variables.EPIC_BRANCH = process.env.ILOOM_EPIC_BRANCH + } + if (process.env.ILOOM_EPIC_ISSUE) { + variables.EPIC_ISSUE_NUMBER = process.env.ILOOM_EPIC_ISSUE + } + } + return variables } diff --git a/src/lib/PromptTemplateManager.ts b/src/lib/PromptTemplateManager.ts index bfc40fd..b40382a 100644 --- a/src/lib/PromptTemplateManager.ts +++ b/src/lib/PromptTemplateManager.ts @@ -102,6 +102,10 @@ export interface TemplateVariables { HAS_REVIEWER?: boolean // Git remote configuration GIT_REMOTE?: string // Remote name for push (defaults to 'origin') + // Swarm mode variables + SWARM_MODE?: boolean // True when running as a swarm agent + EPIC_BRANCH?: string // Epic integration branch name (e.g., 'issue-42-swarm-mode') + EPIC_ISSUE_NUMBER?: string // Parent epic's issue number } /** diff --git a/templates/prompts/issue-prompt.txt b/templates/prompts/issue-prompt.txt index 0b2a642..6dd8bbc 100644 --- a/templates/prompts/issue-prompt.txt +++ b/templates/prompts/issue-prompt.txt @@ -203,6 +203,18 @@ Since this is a first-time user: {{/if}} +{{#if SWARM_MODE}} +## Swarm Mode Instructions + +You are running as an autonomous swarm agent. Follow these specific rules: + +1. **PR Target**: Create your PR targeting the `{{EPIC_BRANCH}}` branch, NOT main. +2. **Do NOT merge**: Create the PR but do not merge it. The supervisor handles merges sequentially. +3. **Close the issue**: After creating the PR, close issue #{{ISSUE_NUMBER}} via the issue management MCP tool with a comment linking to the PR. +4. **No review phase**: Skip the review step (already forced by -p flag). +5. **Be concise**: Minimize token usage. Focus on implementation, not explanation. +{{/if}} + You are orchestrating a set of agents through a development process, with human review at each step. This is referred to as the "iloom workflow". **IMPORTANT: Unless otherwise instructed, each step requires explicit human approval. Do not proceed to any step until explicitly told to do so.** @@ -243,6 +255,10 @@ You are orchestrating a set of agents through a development process, with human {{#if ARTIFACT_REVIEW_ENABLED}}{{#if IMPLEMENTER_REVIEW_ENABLED}} 16a. Run artifact review on implementation output using @agent-iloom-artifact-reviewer {{/if}}{{/if}} +{{#if SWARM_MODE}} +17. Commit and push changes, then create PR targeting `{{EPIC_BRANCH}}` branch (STEP 5-SWARM) +18. Close issue #{{ISSUE_NUMBER}} with a comment linking to the PR (STEP 5-SWARM) +{{else}} 17. Run code review using @agent-iloom-code-reviewer {{#if DRAFT_PR_MODE}} {{#if AUTO_COMMIT_PUSH}} @@ -254,6 +270,7 @@ You are orchestrating a set of agents through a development process, with human {{else}} 18. Provide final summary with links to all issue comments created. Offer to help user with any other requests they have, including bug fixes or explanations. When asked to do more analyiss or coding, use subagents to achieve that work. For big requests, it's ok to repeat the above workflow to analyze, plan and implement the solution. For simple tasks, use a generalized subagent. {{/if}} +{{/if}} ## Workflow Details @@ -838,7 +855,11 @@ Only execute if workflow plan determined NEEDS_IMPLEMENTATION: - If review approves: Proceed to STEP 5 - Review Phase {{/if}} {{/if}} +{{#if SWARM_MODE}} +6. After implementation completes, proceed to STEP 5-SWARM - PR Creation and Issue Closing +{{else}} 6. After implementation completes, proceed to STEP 5 - Review Phase (do NOT skip to Post-Workflow Help) +{{/if}} If workflow plan determined SKIP_IMPLEMENTATION: 1. Mark todos #16 and #17 as completed @@ -846,6 +867,45 @@ If workflow plan determined SKIP_IMPLEMENTATION: --- +{{#if SWARM_MODE}} +## STEP 5-SWARM - PR Creation and Issue Closing + +**IMPORTANT**: This step replaces the review phase and post-workflow help for swarm agents. + +1. **Stage and commit all changes:** + ```bash + git add -A + git commit -m "feat(issue-{{ISSUE_NUMBER}}): [generated summary of changes]" + ``` + +2. **Push to remote:** + ```bash + git push {{GIT_REMOTE}} HEAD + ``` + If push fails due to non-fast-forward, stop and report the error. + +3. **Create PR targeting the epic branch:** + - Use `gh pr create` to create a PR targeting `{{EPIC_BRANCH}}` + - Title: Use the issue title or a summary of the changes + - Body: Include a brief summary of changes and reference `Closes #{{ISSUE_NUMBER}}` + ```bash + gh pr create --base {{EPIC_BRANCH}} --title "[generated title]" --body "Closes #{{ISSUE_NUMBER}}\n\n[summary of changes]" + ``` + +4. **Close the issue:** + - Use the issue management MCP tool to add a comment on issue #{{ISSUE_NUMBER}} linking to the created PR + - Then close issue #{{ISSUE_NUMBER}} via: + ```bash + gh issue close {{ISSUE_NUMBER}} --reason completed + ``` + +5. Mark todos #17 and #18 as completed + +6. **Exit**: The swarm agent's work is complete. Do not enter post-workflow help mode. + +--- + +{{else}} ## STEP 5 - Review Phase This section is about reviewing uncommitted code changes for quality, security, and compliance issues. @@ -1009,9 +1069,11 @@ This is NOT optional - if the reviewer requests Claude Local Review, it must be {{else}} **MANDATORY CHECKPOINT: You MUST complete STEP 5 - Review Phase before proceeding below. Do NOT skip the review.** {{/if}} +{{/if}} --- +{{#unless SWARM_MODE}} ## Post-Workflow Help After completing the implementation phase, tell the user: @@ -1060,4 +1122,5 @@ This will automatically detect the current issue and: - Delete the database branch (if applicable) - Remove the workspace -3. Once the finish command completes, you can close any terminal or IDE windows that were opened specifically for this issue" \ No newline at end of file +3. Once the finish command completes, you can close any terminal or IDE windows that were opened specifically for this issue" +{{/unless}} \ No newline at end of file From 05312ed51eee74e47ea3b9bece22cec2485ae8c7 Mon Sep 17 00:00:00 2001 From: Adam Creeger Date: Thu, 5 Feb 2026 19:17:26 -0500 Subject: [PATCH 4/9] feat(swarm): add epic detection, confirmation prompt, --swarm/--max-agents flags (#559) --- src/cli.ts | 4 +- src/commands/start.ts | 253 ++++++++++++++++++++++++++------ src/lib/EpicDetector.test.ts | 218 +++++++++++++++++++++++++++ src/lib/EpicDetector.ts | 151 +++++++++++++++++++ src/lib/LoomManager.ts | 2 + src/lib/MetadataManager.test.ts | 10 ++ src/lib/MetadataManager.ts | 10 ++ src/types/index.ts | 5 + src/types/loom.ts | 3 + 9 files changed, 612 insertions(+), 44 deletions(-) create mode 100644 src/lib/EpicDetector.test.ts create mode 100644 src/lib/EpicDetector.ts diff --git a/src/cli.ts b/src/cli.ts index 92b5cce..2797ae0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -380,7 +380,9 @@ program .default('default') ) .option('--yolo', 'Enable autonomous mode (shorthand for --one-shot=bypassPermissions)') - .action(async (identifier: string | undefined, options: StartOptions & { yolo?: boolean }) => { + .option('--swarm', 'Bypass epic confirmation and start swarm mode immediately') + .option('--max-agents ', 'Maximum concurrent agents for swarm mode (overrides swarm.maxConcurrent setting)', parseInt) + .action(async (identifier: string | undefined, options: StartOptions & { yolo?: boolean; maxAgents?: number }) => { // Handle --yolo flag: set oneShot to bypassPermissions if (options.yolo) { options.oneShot = 'bypassPermissions' diff --git a/src/commands/start.ts b/src/commands/start.ts index 8763257..84576d0 100644 --- a/src/commands/start.ts +++ b/src/commands/start.ts @@ -22,6 +22,9 @@ import { capitalizeFirstLetter } from '../utils/text.js' import type { StartOptions, StartResult } from '../types/index.js' import { launchFirstRunSetup, needsFirstRunSetup } from '../utils/first-run-setup.js' import { IssueTrackerFactory } from '../lib/IssueTrackerFactory.js' +import { EpicDetector } from '../lib/EpicDetector.js' +import type { EpicDetectionResult } from '../lib/EpicDetector.js' +import { IssueManagementProviderFactory } from '../mcp/IssueManagementProviderFactory.js' export interface StartCommandInput { identifier: string @@ -207,6 +210,18 @@ export class StartCommand { } // Note: --no-child-loom when no parent is a no-op (already independent) + // Step 2.45: Epic detection for swarm mode + let epicDetection: EpicDetectionResult | null = null + + if (parsed.type === 'issue' && parsed.number !== undefined) { + epicDetection = await this.detectEpic(parsed.number) + } + + // If --swarm on non-epic issue, silently ignore + if (input.options.swarm && epicDetection && !epicDetection.isEpic) { + getLogger().debug('--swarm flag provided but issue is not an epic (ignored)') + } + // Step 2.5: Handle description input - create GitHub issue if (parsed.type === 'description') { getLogger().info('Creating GitHub issue from description...') @@ -246,6 +261,14 @@ export class StartCommand { const workflowType = parsed.type === 'branch' ? 'regular' : parsed.type const workflowConfig = settings.workflows?.[workflowType] + // Step 2.85: Confirm swarm mode for detected epics + const enterSwarmMode = await this.confirmSwarmMode( + epicDetection, + input.options, + settings, + isJsonMode, + ) + // Step 2.9: Extract raw --set arguments and executable path for forwarding to spin const { extractRawSetArguments, getExecutablePath } = await import('../utils/cli-overrides.js') const setArguments = extractRawSetArguments() @@ -260,56 +283,102 @@ export class StartCommand { ? parsed.branchName ?? '' : parsed.number ?? 0 - // Apply configuration precedence: CLI flags > workflow config > defaults (true) - const enableClaude = input.options.claude ?? workflowConfig?.startAiAgent ?? true - const enableCode = input.options.code ?? workflowConfig?.startIde ?? true - const enableDevServer = input.options.devServer ?? workflowConfig?.startDevServer ?? true - const enableTerminal = input.options.terminal ?? workflowConfig?.startTerminal ?? false - - getLogger().debug('Final workflow config values:', { - enableClaude, - enableCode, - enableDevServer, - enableTerminal, - }) + if (enterSwarmMode) { + // Epic loom creation: integration branch with no interactive components + getLogger().info('Creating epic loom (integration branch for swarm mode)...') - const loom = await loomManager.createIloom({ - type: parsed.type, - identifier, - originalInput: parsed.originalInput, - ...(parentLoom && { parentLoom }), - options: { + const loom = await loomManager.createIloom({ + type: parsed.type, + identifier, + originalInput: parsed.originalInput, + ...(parentLoom && { parentLoom }), + options: { + enableClaude: false, + enableCode: false, + enableDevServer: false, + enableTerminal: false, + isEpic: true, + swarmStatus: 'pending', + }, + }) + + getLogger().success(`Created epic loom: ${loom.id} at ${loom.path}`) + getLogger().info(` Branch: ${loom.branch}`) + getLogger().info(` Mode: Swarm (integration branch)`) + if (epicDetection) { + getLogger().info(` Child issues: ${epicDetection.totalChildren} (${epicDetection.readyChildren} ready, ${epicDetection.blockedChildren} blocked)`) + } + if (loom.issueData?.title) { + getLogger().info(` Title: ${loom.issueData.title}`) + } + + if (isJsonMode) { + return { + id: loom.id, + path: loom.path, + branch: loom.branch, + type: parsed.type, + identifier: loom.identifier, + ...(loom.port !== undefined && { port: loom.port }), + ...(loom.issueData?.title && { title: loom.issueData.title }), + ...(loom.capabilities && { capabilities: loom.capabilities }), + isEpic: true, + swarmStatus: 'pending', + } + } + } else { + // Normal loom creation + // Apply configuration precedence: CLI flags > workflow config > defaults (true) + const enableClaude = input.options.claude ?? workflowConfig?.startAiAgent ?? true + const enableCode = input.options.code ?? workflowConfig?.startIde ?? true + const enableDevServer = input.options.devServer ?? workflowConfig?.startDevServer ?? true + const enableTerminal = input.options.terminal ?? workflowConfig?.startTerminal ?? false + + getLogger().debug('Final workflow config values:', { enableClaude, enableCode, enableDevServer, enableTerminal, - ...(input.options.oneShot && { oneShot: input.options.oneShot }), - ...(setArguments.length > 0 && { setArguments }), - ...(executablePath && { executablePath }), - }, - }) - - getLogger().success(`Created loom: ${loom.id} at ${loom.path}`) - getLogger().info(` Branch: ${loom.branch}`) - // Only show port for web projects - if (loom.capabilities?.includes('web')) { - getLogger().info(` Port: ${loom.port}`) - } - if (loom.issueData?.title) { - getLogger().info(` Title: ${loom.issueData.title}`) - } + }) - // Return StartResult in JSON mode - if (isJsonMode) { - return { - id: loom.id, - path: loom.path, - branch: loom.branch, + const loom = await loomManager.createIloom({ type: parsed.type, - identifier: loom.identifier, - ...(loom.port !== undefined && { port: loom.port }), - ...(loom.issueData?.title && { title: loom.issueData.title }), - ...(loom.capabilities && { capabilities: loom.capabilities }), + identifier, + originalInput: parsed.originalInput, + ...(parentLoom && { parentLoom }), + options: { + enableClaude, + enableCode, + enableDevServer, + enableTerminal, + ...(input.options.oneShot && { oneShot: input.options.oneShot }), + ...(setArguments.length > 0 && { setArguments }), + ...(executablePath && { executablePath }), + }, + }) + + getLogger().success(`Created loom: ${loom.id} at ${loom.path}`) + getLogger().info(` Branch: ${loom.branch}`) + // Only show port for web projects + if (loom.capabilities?.includes('web')) { + getLogger().info(` Port: ${loom.port}`) + } + if (loom.issueData?.title) { + getLogger().info(` Title: ${loom.issueData.title}`) + } + + // Return StartResult in JSON mode + if (isJsonMode) { + return { + id: loom.id, + path: loom.path, + branch: loom.branch, + type: parsed.type, + identifier: loom.identifier, + ...(loom.port !== undefined && { port: loom.port }), + ...(loom.issueData?.title && { title: loom.issueData.title }), + ...(loom.capabilities && { capabilities: loom.capabilities }), + } } } } catch (error) { @@ -607,4 +676,102 @@ export class StartCommand { } } + /** + * Detect if an issue is an epic suitable for swarm mode. + * + * Checks for the iloom-epic label, child issues, and dependencies. + * Only runs for issue-type inputs. Returns null for non-issues. + */ + private async detectEpic( + issueNumber: string | number, + ): Promise { + try { + // Fetch the issue to check labels + const issue = await this.issueTracker.fetchIssue(issueNumber) + + // Check if issue has the epic label + const hasEpicLabel = issue.labels.some( + label => label.toLowerCase() === 'iloom-epic' + ) + + if (!hasEpicLabel) { + return { isEpic: false, totalChildren: 0, readyChildren: 0, blockedChildren: 0, hasDependencies: false } + } + + // Create issue management provider for child/dependency queries + const settings = await this.settingsManager.loadSettings() + const providerName = settings.issueManagement?.provider ?? 'github' + const issueProvider = IssueManagementProviderFactory.create(providerName) + const detector = new EpicDetector(issueProvider) + + const result = await detector.detect(issue, String(issueNumber)) + + // Log warnings if any + if (result.warning) { + getLogger().warn(result.warning) + } + + return result + } catch (error) { + getLogger().debug(`Epic detection failed: ${error instanceof Error ? error.message : 'Unknown error'}`) + return null + } + } + + /** + * Confirm swarm mode entry with the user. + * + * Decision logic: + * - --swarm flag: bypass confirmation, auto-confirm swarm + * - --swarm on non-epic: silently ignored (handled by caller) + * - Non-interactive (no TTY): skip (require --swarm flag) + * - Interactive: show prompt, respect answer + * - Already have an active epic loom: offer to resume + * + * @returns true if swarm mode should be entered, false otherwise + */ + private async confirmSwarmMode( + epicDetection: EpicDetectionResult | null, + options: StartOptions, + settings: import('../lib/SettingsManager.js').IloomSettings, + isJsonMode: boolean, + ): Promise { + // Not an epic, or detection failed + if (!epicDetection?.isEpic) { + return false + } + + // --swarm flag bypasses confirmation + if (options.swarm) { + const maxAgents = options.maxAgents ?? settings.swarm?.maxConcurrent ?? 3 + getLogger().info(`Starting swarm mode (--swarm flag). Max agents: ${maxAgents}`) + return true + } + + // JSON mode requires explicit --swarm flag + if (isJsonMode) { + getLogger().debug('Epic detected in JSON mode but --swarm flag not provided, proceeding as normal issue') + return false + } + + // Non-interactive environment requires --swarm flag + const { isInteractiveEnvironment } = await import('../utils/prompt.js') + if (!isInteractiveEnvironment()) { + getLogger().debug('Epic detected in non-interactive environment but --swarm flag not provided, proceeding as normal issue') + return false + } + + // Interactive: show confirmation prompt + const maxAgents = options.maxAgents ?? settings.swarm?.maxConcurrent ?? 3 + const { promptConfirmation } = await import('../utils/prompt.js') + const confirmed = await promptConfirmation( + `Issue #${epicDetection.totalChildren > 0 + ? `is an epic with ${epicDetection.totalChildren} child issue${epicDetection.totalChildren === 1 ? '' : 's'} (${epicDetection.readyChildren} ready, ${epicDetection.blockedChildren} blocked).\nStart swarm mode? Max ${maxAgents} concurrent agents.` + : 'is an epic. Start swarm mode?'}`, + true + ) + + return confirmed + } + } diff --git a/src/lib/EpicDetector.test.ts b/src/lib/EpicDetector.test.ts new file mode 100644 index 0000000..5c9829a --- /dev/null +++ b/src/lib/EpicDetector.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect, vi } from 'vitest' +import { EpicDetector } from './EpicDetector.js' +import type { IssueManagementProvider } from '../mcp/types.js' +import type { Issue } from '../types/index.js' + +// Mock logger +vi.mock('../utils/logger-context.js', () => ({ + getLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + success: vi.fn(), + }), +})) + +function createMockIssue(overrides: Partial = {}): Issue { + return { + number: 42, + title: 'Test Epic', + body: 'An epic issue', + state: 'open', + labels: [], + assignees: [], + url: 'https://github.com/owner/repo/issues/42', + ...overrides, + } +} + +function createMockProvider(overrides: Partial = {}): IssueManagementProvider { + return { + providerName: 'github', + issuePrefix: '#', + getIssue: vi.fn(), + getPR: vi.fn(), + getComment: vi.fn(), + createComment: vi.fn(), + updateComment: vi.fn(), + createIssue: vi.fn(), + createChildIssue: vi.fn(), + createDependency: vi.fn(), + getDependencies: vi.fn().mockResolvedValue({ blocking: [], blockedBy: [] }), + removeDependency: vi.fn(), + getChildIssues: vi.fn().mockResolvedValue([]), + ...overrides, + } +} + +describe('EpicDetector', () => { + describe('detect', () => { + it('returns isEpic=false when issue has no iloom-epic label', async () => { + const provider = createMockProvider() + const detector = new EpicDetector(provider) + const issue = createMockIssue({ labels: ['bug', 'enhancement'] }) + + const result = await detector.detect(issue, '42') + + expect(result.isEpic).toBe(false) + expect(result.totalChildren).toBe(0) + expect(provider.getChildIssues).not.toHaveBeenCalled() + }) + + it('detects iloom-epic label case-insensitively', async () => { + const provider = createMockProvider({ + getChildIssues: vi.fn().mockResolvedValue([ + { id: '1', title: 'Child 1', url: 'https://example.com/1', state: 'open' }, + ]), + }) + const detector = new EpicDetector(provider) + const issue = createMockIssue({ labels: ['ILOOM-EPIC'] }) + + const result = await detector.detect(issue, '42') + + expect(result.isEpic).toBe(true) + }) + + it('returns isEpic=false with warning when epic has no child issues', async () => { + const provider = createMockProvider({ + getChildIssues: vi.fn().mockResolvedValue([]), + }) + const detector = new EpicDetector(provider) + const issue = createMockIssue({ labels: ['iloom-epic'] }) + + const result = await detector.detect(issue, '42') + + expect(result.isEpic).toBe(false) + expect(result.warning).toContain('no child issues') + }) + + it('returns isEpic=false with warning when all children are closed', async () => { + const provider = createMockProvider({ + getChildIssues: vi.fn().mockResolvedValue([ + { id: '1', title: 'Child 1', url: 'https://example.com/1', state: 'closed' }, + { id: '2', title: 'Child 2', url: 'https://example.com/2', state: 'closed' }, + ]), + }) + const detector = new EpicDetector(provider) + const issue = createMockIssue({ labels: ['iloom-epic'] }) + + const result = await detector.detect(issue, '42') + + expect(result.isEpic).toBe(false) + expect(result.warning).toContain('all child issues are closed') + }) + + it('returns isEpic=true with correct ready/blocked counts', async () => { + const provider = createMockProvider({ + getChildIssues: vi.fn().mockResolvedValue([ + { id: '1', title: 'Child 1', url: 'https://example.com/1', state: 'open' }, + { id: '2', title: 'Child 2', url: 'https://example.com/2', state: 'open' }, + { id: '3', title: 'Child 3', url: 'https://example.com/3', state: 'open' }, + ]), + getDependencies: vi.fn() + .mockResolvedValueOnce({ blocking: [], blockedBy: [] }) // Child 1: no blockers + .mockResolvedValueOnce({ blocking: [], blockedBy: [{ id: '1', title: 'Child 1', url: '', state: 'open' }] }) // Child 2: blocked by 1 + .mockResolvedValueOnce({ blocking: [], blockedBy: [{ id: '2', title: 'Child 2', url: '', state: 'open' }] }), // Child 3: blocked by 2 + }) + const detector = new EpicDetector(provider) + const issue = createMockIssue({ labels: ['iloom-epic'] }) + + const result = await detector.detect(issue, '42') + + expect(result.isEpic).toBe(true) + expect(result.totalChildren).toBe(3) + expect(result.readyChildren).toBe(1) // Only Child 1 is ready + expect(result.blockedChildren).toBe(2) // Child 2 and 3 are blocked + expect(result.hasDependencies).toBe(true) + }) + + it('warns when epic has children but no dependencies', async () => { + const provider = createMockProvider({ + getChildIssues: vi.fn().mockResolvedValue([ + { id: '1', title: 'Child 1', url: 'https://example.com/1', state: 'open' }, + { id: '2', title: 'Child 2', url: 'https://example.com/2', state: 'open' }, + ]), + getDependencies: vi.fn().mockResolvedValue({ blocking: [], blockedBy: [] }), + }) + const detector = new EpicDetector(provider) + const issue = createMockIssue({ labels: ['iloom-epic'] }) + + const result = await detector.detect(issue, '42') + + expect(result.isEpic).toBe(true) + expect(result.hasDependencies).toBe(false) + expect(result.readyChildren).toBe(2) + expect(result.blockedChildren).toBe(0) + expect(result.warning).toContain('no dependencies') + expect(result.warning).toContain('parallel') + }) + + it('treats children as ready when dependency fetch fails', async () => { + const provider = createMockProvider({ + getChildIssues: vi.fn().mockResolvedValue([ + { id: '1', title: 'Child 1', url: 'https://example.com/1', state: 'open' }, + ]), + getDependencies: vi.fn().mockRejectedValue(new Error('API error')), + }) + const detector = new EpicDetector(provider) + const issue = createMockIssue({ labels: ['iloom-epic'] }) + + const result = await detector.detect(issue, '42') + + expect(result.isEpic).toBe(true) + expect(result.readyChildren).toBe(1) + expect(result.blockedChildren).toBe(0) + }) + + it('returns isEpic=false with warning when child fetch fails', async () => { + const provider = createMockProvider({ + getChildIssues: vi.fn().mockRejectedValue(new Error('API error')), + }) + const detector = new EpicDetector(provider) + const issue = createMockIssue({ labels: ['iloom-epic'] }) + + const result = await detector.detect(issue, '42') + + expect(result.isEpic).toBe(false) + expect(result.warning).toContain('could not be fetched') + }) + + it('ignores closed blockers when determining if child is blocked', async () => { + const provider = createMockProvider({ + getChildIssues: vi.fn().mockResolvedValue([ + { id: '1', title: 'Child 1', url: 'https://example.com/1', state: 'open' }, + ]), + getDependencies: vi.fn().mockResolvedValue({ + blocking: [], + blockedBy: [{ id: '99', title: 'Closed blocker', url: '', state: 'closed' }], + }), + }) + const detector = new EpicDetector(provider) + const issue = createMockIssue({ labels: ['iloom-epic'] }) + + const result = await detector.detect(issue, '42') + + expect(result.isEpic).toBe(true) + expect(result.readyChildren).toBe(1) + expect(result.blockedChildren).toBe(0) + expect(result.hasDependencies).toBe(true) // Still has deps, just all closed + }) + + it('handles Linear OPEN state variant', async () => { + const provider = createMockProvider({ + getChildIssues: vi.fn().mockResolvedValue([ + { id: 'ENG-1', title: 'Child 1', url: 'https://linear.app/1', state: 'OPEN' }, + ]), + getDependencies: vi.fn().mockResolvedValue({ blocking: [], blockedBy: [] }), + }) + const detector = new EpicDetector(provider) + const issue = createMockIssue({ labels: ['iloom-epic'] }) + + const result = await detector.detect(issue, 'ENG-42') + + expect(result.isEpic).toBe(true) + expect(result.totalChildren).toBe(1) + }) + }) +}) diff --git a/src/lib/EpicDetector.ts b/src/lib/EpicDetector.ts new file mode 100644 index 0000000..b8f8f47 --- /dev/null +++ b/src/lib/EpicDetector.ts @@ -0,0 +1,151 @@ +import { getLogger } from '../utils/logger-context.js' +import type { IssueManagementProvider, ChildIssueResult, DependenciesResult } from '../mcp/types.js' +import type { Issue } from '../types/index.js' + +/** + * Result of epic detection analysis + */ +export interface EpicDetectionResult { + /** Whether the issue qualifies as an epic for swarm mode */ + isEpic: boolean + /** Total number of child issues */ + totalChildren: number + /** Number of child issues that are ready (no open blockers) */ + readyChildren: number + /** Number of child issues that are blocked */ + blockedChildren: number + /** Whether the epic has dependency information between children */ + hasDependencies: boolean + /** Warning message if label is present but conditions aren't fully met */ + warning?: string +} + +/** + * Detects whether an issue is an epic suitable for swarm mode. + * + * An issue qualifies as an epic when: + * 1. It has the `iloom-epic` label + * 2. It has child issues (sub-issues) + * 3. Child issues have dependencies defined + * + * If the label is present but children or dependencies are missing, + * warnings are returned to inform the user. + */ +export class EpicDetector { + constructor( + private readonly issueProvider: IssueManagementProvider, + ) {} + + /** + * Detect if an issue is an epic with child issues and dependencies. + * + * @param issue - The fetched issue data (must include labels) + * @param issueIdentifier - The issue identifier string (for API calls) + * @returns EpicDetectionResult with detection details + */ + async detect(issue: Issue, issueIdentifier: string): Promise { + // Check for iloom-epic label + const hasEpicLabel = issue.labels.some( + label => label.toLowerCase() === 'iloom-epic' + ) + + if (!hasEpicLabel) { + return { + isEpic: false, + totalChildren: 0, + readyChildren: 0, + blockedChildren: 0, + hasDependencies: false, + } + } + + getLogger().debug('Found iloom-epic label, checking for child issues...') + + // Fetch child issues + let children: ChildIssueResult[] + try { + children = await this.issueProvider.getChildIssues({ + number: String(issueIdentifier), + }) + } catch (error) { + getLogger().warn( + `Failed to fetch child issues: ${error instanceof Error ? error.message : 'Unknown error'}` + ) + return { + isEpic: false, + totalChildren: 0, + readyChildren: 0, + blockedChildren: 0, + hasDependencies: false, + warning: 'Issue has iloom-epic label but child issues could not be fetched.', + } + } + + // Filter to open children only + const openChildren = children.filter( + child => child.state === 'open' || child.state === 'OPEN' + ) + + if (openChildren.length === 0) { + return { + isEpic: false, + totalChildren: children.length, + readyChildren: 0, + blockedChildren: 0, + hasDependencies: false, + warning: children.length === 0 + ? 'Issue has iloom-epic label but no child issues. Proceeding as normal issue.' + : 'Issue has iloom-epic label but all child issues are closed. Proceeding as normal issue.', + } + } + + // Fetch dependencies for each child to determine ready vs blocked + let hasDependencies = false + let readyCount = 0 + let blockedCount = 0 + + for (const child of openChildren) { + try { + const deps: DependenciesResult = await this.issueProvider.getDependencies({ + number: child.id, + direction: 'blocked_by', + }) + + // A child has dependencies if it is blocked by at least one issue + const openBlockers = deps.blockedBy.filter( + b => b.state === 'open' || b.state === 'OPEN' + ) + + if (deps.blockedBy.length > 0) { + hasDependencies = true + } + + if (openBlockers.length > 0) { + blockedCount++ + } else { + readyCount++ + } + } catch (error) { + // If dependencies can't be fetched, treat the child as ready + getLogger().debug( + `Failed to fetch dependencies for child ${child.id}: ${error instanceof Error ? error.message : 'Unknown error'}` + ) + readyCount++ + } + } + + const result: EpicDetectionResult = { + isEpic: true, + totalChildren: openChildren.length, + readyChildren: readyCount, + blockedChildren: blockedCount, + hasDependencies, + } + + if (!hasDependencies) { + result.warning = 'Epic has child issues but no dependencies defined between them. All tasks will run in parallel.' + } + + return result + } +} diff --git a/src/lib/LoomManager.ts b/src/lib/LoomManager.ts index 5c262fd..25d3f0c 100644 --- a/src/lib/LoomManager.ts +++ b/src/lib/LoomManager.ts @@ -447,6 +447,8 @@ export class LoomManager { capabilities, ...(draftPrNumber && { draftPrNumber }), ...(input.options?.oneShot && { oneShot: input.options.oneShot }), + ...(input.options?.isEpic && { isEpic: input.options.isEpic }), + ...(input.options?.swarmStatus && { swarmStatus: input.options.swarmStatus }), ...(input.parentLoom && { parentLoom: input.parentLoom }), } await this.metadataManager.writeMetadata(worktreePath, metadataInput) diff --git a/src/lib/MetadataManager.test.ts b/src/lib/MetadataManager.test.ts index 15ef8d9..9a72d87 100644 --- a/src/lib/MetadataManager.test.ts +++ b/src/lib/MetadataManager.test.ts @@ -325,6 +325,8 @@ describe('MetadataManager', () => { oneShot: null, capabilities: ['web'], swarmAgent: false, + isEpic: false, + swarmStatus: null, parentLoom: null, }) }) @@ -421,6 +423,8 @@ describe('MetadataManager', () => { oneShot: null, capabilities: [], swarmAgent: false, + isEpic: false, + swarmStatus: null, parentLoom: null, }) }) @@ -742,6 +746,8 @@ describe('MetadataManager', () => { oneShot: null, capabilities: ['cli'], swarmAgent: false, + isEpic: false, + swarmStatus: null, parentLoom: null, }) expect(result[1]).toEqual({ @@ -762,6 +768,8 @@ describe('MetadataManager', () => { oneShot: null, capabilities: ['web'], swarmAgent: false, + isEpic: false, + swarmStatus: null, parentLoom: null, }) }) @@ -866,6 +874,8 @@ describe('MetadataManager', () => { oneShot: null, capabilities: [], swarmAgent: false, + isEpic: false, + swarmStatus: null, parentLoom: null, }) }) diff --git a/src/lib/MetadataManager.ts b/src/lib/MetadataManager.ts index 3817ea2..f35e5dc 100644 --- a/src/lib/MetadataManager.ts +++ b/src/lib/MetadataManager.ts @@ -29,6 +29,8 @@ export interface MetadataFile { oneShot?: OneShotMode // One-shot automation mode stored during loom creation capabilities?: ProjectCapability[] // Detected project capabilities swarmAgent?: boolean // Whether this loom was created for a swarm agent + isEpic?: boolean // Whether this loom is an epic (parent of swarm children) + swarmStatus?: 'pending' | 'active' | 'completed' // Swarm orchestration status parentLoom?: { type: 'issue' | 'pr' | 'branch' identifier: string | number @@ -60,6 +62,8 @@ export interface WriteMetadataInput { oneShot?: OneShotMode // One-shot automation mode to persist capabilities: ProjectCapability[] // Detected project capabilities (required for new looms) swarmAgent?: boolean // Whether this loom was created for a swarm agent + isEpic?: boolean // Whether this loom is an epic (parent of swarm children) + swarmStatus?: 'pending' | 'active' | 'completed' // Swarm orchestration status parentLoom?: { type: 'issue' | 'pr' | 'branch' identifier: string | number @@ -92,6 +96,8 @@ export interface LoomMetadata { oneShot: OneShotMode | null // One-shot mode (null for legacy looms) capabilities: ProjectCapability[] // Detected project capabilities (empty for legacy looms) swarmAgent: boolean // Whether this loom was created for a swarm agent + isEpic: boolean // Whether this loom is an epic (parent of swarm children) + swarmStatus: 'pending' | 'active' | 'completed' | null // Swarm orchestration status parentLoom: { type: 'issue' | 'pr' | 'branch' identifier: string | number @@ -143,6 +149,8 @@ export class MetadataManager { oneShot: data.oneShot ?? null, capabilities: data.capabilities ?? [], swarmAgent: data.swarmAgent ?? false, + isEpic: data.isEpic ?? false, + swarmStatus: data.swarmStatus ?? null, parentLoom: data.parentLoom ?? null, } } @@ -222,6 +230,8 @@ export class MetadataManager { ...(input.draftPrNumber && { draftPrNumber: input.draftPrNumber }), ...(input.oneShot && { oneShot: input.oneShot }), ...(input.swarmAgent && { swarmAgent: input.swarmAgent }), + ...(input.isEpic && { isEpic: input.isEpic }), + ...(input.swarmStatus && { swarmStatus: input.swarmStatus }), ...(input.parentLoom && { parentLoom: input.parentLoom }), } diff --git a/src/types/index.ts b/src/types/index.ts index 27d4ac5..6d2227f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -154,6 +154,9 @@ export interface StartOptions { body?: string // Output result as JSON json?: boolean + // Swarm mode flags + swarm?: boolean + maxAgents?: number } export interface AddIssueOptions { @@ -233,6 +236,8 @@ export interface StartResult { identifier: string | number title?: string capabilities?: string[] + isEpic?: boolean + swarmStatus?: 'pending' | 'active' | 'completed' } export interface FinishResult { diff --git a/src/types/loom.ts b/src/types/loom.ts index 761781e..101da9d 100644 --- a/src/types/loom.ts +++ b/src/types/loom.ts @@ -56,6 +56,9 @@ export interface CreateLoomInput { sourceEnvOnStart?: boolean // Minimal setup for swarm agents: worktree + branch + env + metadata + port only swarmMode?: boolean + // Epic metadata for swarm orchestration + isEpic?: boolean + swarmStatus?: 'pending' | 'active' | 'completed' } } From 6726cf79ea67dc46e014f9a95ee9ce94cd9b26db Mon Sep 17 00:00:00 2001 From: Adam Creeger Date: Thu, 5 Feb 2026 19:17:44 -0500 Subject: [PATCH 5/9] feat(swarm): add SwarmSupervisor with DAG-driven agent orchestration and sequential merge queue (#562) --- src/lib/SwarmSupervisor.test.ts | 657 ++++++++++++++++++++++++++++++++ src/lib/SwarmSupervisor.ts | 438 +++++++++++++++++++++ 2 files changed, 1095 insertions(+) create mode 100644 src/lib/SwarmSupervisor.test.ts create mode 100644 src/lib/SwarmSupervisor.ts diff --git a/src/lib/SwarmSupervisor.test.ts b/src/lib/SwarmSupervisor.test.ts new file mode 100644 index 0000000..735e356 --- /dev/null +++ b/src/lib/SwarmSupervisor.test.ts @@ -0,0 +1,657 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { SwarmSupervisor, type EpicLoomContext } from './SwarmSupervisor.js' +import type { BeadsManager, BeadsTask } from './BeadsManager.js' +import type { BeadsSyncService, SyncResult } from './BeadsSyncService.js' +import type { LoomManager } from './LoomManager.js' +import type { SwarmSettings } from './SettingsManager.js' +import type { Loom } from '../types/loom.js' + +// Mock timers/promises so sleep(2000) resolves immediately +vi.mock('timers/promises', () => ({ + setTimeout: vi.fn().mockResolvedValue(undefined), +})) + +// Mock execa +vi.mock('execa', () => ({ + execa: vi.fn(), +})) + +// Mock fs-extra +vi.mock('fs-extra', () => ({ + default: { + ensureDir: vi.fn(), + createWriteStream: vi.fn(() => ({ + write: vi.fn(), + end: vi.fn(), + })), + }, +})) + +// Mock github utils +vi.mock('../utils/github.js', () => ({ + executeGhCommand: vi.fn(), +})) + +// Mock logger +vi.mock('../utils/logger.js', () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + success: vi.fn(), + }, +})) + +import { execa } from 'execa' +import { executeGhCommand } from '../utils/github.js' +import { logger } from '../utils/logger.js' + +// --- Helpers --- + +function createMockBeadsManager(): BeadsManager { + return { + init: vi.fn().mockResolvedValue(undefined), + ready: vi.fn().mockResolvedValue([]), + claim: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + releaseClaim: vi.fn().mockResolvedValue(undefined), + create: vi.fn().mockResolvedValue('task-1'), + addDependency: vi.fn().mockResolvedValue(undefined), + ensureInstalled: vi.fn().mockResolvedValue(undefined), + isInstalled: vi.fn().mockResolvedValue(true), + getBeadsDir: vi.fn().mockReturnValue('/tmp/beads'), + } as unknown as BeadsManager +} + +function createMockSyncService(): BeadsSyncService { + return { + syncEpicToBeads: vi.fn().mockResolvedValue({ + created: [], + skipped: [], + dependenciesCreated: 0, + } as SyncResult), + } as unknown as BeadsSyncService +} + +function createMockLoomManager(): LoomManager { + return { + createIloom: vi.fn().mockResolvedValue({ + id: 'issue-100', + path: '/tmp/worktree/issue-100', + branch: 'feat/issue-100', + type: 'issue', + identifier: 100, + port: 3100, + createdAt: new Date(), + lastAccessed: new Date(), + } as Loom), + } as unknown as LoomManager +} + +function createDefaultSettings(): SwarmSettings { + return { + maxConcurrent: 3, + maxRetries: 1, + maxConflictRetries: 3, + beadsDir: '~/.config/iloom-ai/beads', + autoInstallBeads: false, + } +} + +function createEpicLoomContext(): EpicLoomContext { + return { + epicIssueNumber: '50', + epicBranch: 'feat/epic-50', + epicLoomPath: '/tmp/worktree/epic-50', + projectPath: '/tmp/project', + } +} + +function createBeadsTask(id: string, title: string): BeadsTask { + return { id, title, status: 'ready' } +} + +/** + * Creates a mock execa child process that resolves immediately. + * The .then() callback fires synchronously via microtask queue, + * so by the next await point the agent's exitCode is set. + */ +function createMockChildProcess(exitCode: number, pid: number = 1234) { + const resolved = Promise.resolve({ exitCode }) + + const mockProcess = Object.assign(resolved, { + pid, + all: { pipe: vi.fn() }, + stdout: null, + stderr: null, + kill: vi.fn(), + }) + + return mockProcess +} + +describe('SwarmSupervisor', () => { + let beadsManager: ReturnType + let syncService: ReturnType + let loomManager: ReturnType + let settings: SwarmSettings + let supervisor: SwarmSupervisor + let epicLoom: EpicLoomContext + + beforeEach(() => { + beadsManager = createMockBeadsManager() + syncService = createMockSyncService() + loomManager = createMockLoomManager() + settings = createDefaultSettings() + epicLoom = createEpicLoomContext() + supervisor = new SwarmSupervisor(beadsManager, syncService, loomManager, settings) + }) + + describe('run', () => { + it('should initialize Beads and sync epic children', async () => { + const result = await supervisor.run(epicLoom) + + expect(beadsManager.init).toHaveBeenCalled() + expect(syncService.syncEpicToBeads).toHaveBeenCalledWith('50') + expect(result.totalTasks).toBe(0) + expect(result.completed).toBe(0) + expect(result.failed).toBe(0) + }) + + it('should return totalTasks from sync result', async () => { + vi.mocked(syncService.syncEpicToBeads).mockResolvedValue({ + created: [ + { issueId: '100', beadsTaskId: '100', title: 'Task 1' }, + { issueId: '101', beadsTaskId: '101', title: 'Task 2' }, + ], + skipped: ['102'], + dependenciesCreated: 1, + } as SyncResult) + + const result = await supervisor.run(epicLoom) + + expect(result.totalTasks).toBe(3) + }) + + it('should track duration', async () => { + const result = await supervisor.run(epicLoom) + expect(result.duration).toBeGreaterThanOrEqual(0) + }) + + it('should claim ready tasks and spawn agents', async () => { + const task1 = createBeadsTask('100', 'Fix bug') + let readyCallCount = 0 + + vi.mocked(beadsManager.ready).mockImplementation(async () => { + readyCallCount++ + if (readyCallCount === 1) return [task1] + return [] + }) + + vi.mocked(syncService.syncEpicToBeads).mockResolvedValue({ + created: [{ issueId: '100', beadsTaskId: '100', title: 'Fix bug' }], + skipped: [], + dependenciesCreated: 0, + }) + + // Mock child process that completes immediately with success + const mockProcess = createMockChildProcess(0) + vi.mocked(execa).mockReturnValue(mockProcess as never) + + // No PR found (agent completed without creating one) + vi.mocked(executeGhCommand).mockResolvedValue([] as never) + + const result = await supervisor.run(epicLoom) + + expect(beadsManager.claim).toHaveBeenCalledWith('100') + expect(loomManager.createIloom).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'issue', + identifier: 100, + baseBranch: 'feat/epic-50', + options: { swarmMode: true }, + }), + ) + expect(result.completed).toBe(1) + }) + + it('should respect maxConcurrent setting', async () => { + settings.maxConcurrent = 2 + + const task1 = createBeadsTask('100', 'Task 1') + const task2 = createBeadsTask('101', 'Task 2') + const task3 = createBeadsTask('102', 'Task 3') + + let readyCallCount = 0 + vi.mocked(beadsManager.ready).mockImplementation(async () => { + readyCallCount++ + if (readyCallCount === 1) return [task1, task2, task3] + return [] + }) + + vi.mocked(syncService.syncEpicToBeads).mockResolvedValue({ + created: [ + { issueId: '100', beadsTaskId: '100', title: 'Task 1' }, + { issueId: '101', beadsTaskId: '101', title: 'Task 2' }, + { issueId: '102', beadsTaskId: '102', title: 'Task 3' }, + ], + skipped: [], + dependenciesCreated: 0, + }) + + const proc1 = createMockChildProcess(0, 1001) + const proc2 = createMockChildProcess(0, 1002) + let callIndex = 0 + vi.mocked(execa).mockImplementation(() => { + const proc = callIndex === 0 ? proc1 : proc2 + callIndex++ + return proc as never + }) + + vi.mocked(executeGhCommand).mockResolvedValue([] as never) + + vi.mocked(loomManager.createIloom) + .mockResolvedValueOnce({ + id: 'issue-100', path: '/tmp/worktree/issue-100', branch: 'feat/100', + type: 'issue', identifier: 100, port: 3100, + createdAt: new Date(), lastAccessed: new Date(), + } as Loom) + .mockResolvedValueOnce({ + id: 'issue-101', path: '/tmp/worktree/issue-101', branch: 'feat/101', + type: 'issue', identifier: 101, port: 3101, + createdAt: new Date(), lastAccessed: new Date(), + } as Loom) + + await supervisor.run(epicLoom) + + // Should only claim 2 tasks (maxConcurrent), not 3 + expect(beadsManager.claim).toHaveBeenCalledTimes(2) + expect(beadsManager.claim).toHaveBeenCalledWith('100') + expect(beadsManager.claim).toHaveBeenCalledWith('101') + }) + + it('should handle failed agents by releasing their claim', async () => { + const task1 = createBeadsTask('100', 'Failing task') + + let readyCallCount = 0 + vi.mocked(beadsManager.ready).mockImplementation(async () => { + readyCallCount++ + if (readyCallCount === 1) return [task1] + return [] + }) + + vi.mocked(syncService.syncEpicToBeads).mockResolvedValue({ + created: [{ issueId: '100', beadsTaskId: '100', title: 'Failing task' }], + skipped: [], + dependenciesCreated: 0, + }) + + // Mock agent that exits with failure + const mockProcess = createMockChildProcess(1) + vi.mocked(execa).mockReturnValue(mockProcess as never) + + const result = await supervisor.run(epicLoom) + + expect(beadsManager.releaseClaim).toHaveBeenCalledWith('100') + expect(result.failed).toBe(1) + expect(result.completed).toBe(0) + }) + + it('should enqueue and merge PRs sequentially on success', async () => { + const task1 = createBeadsTask('100', 'Task with PR') + + let readyCallCount = 0 + vi.mocked(beadsManager.ready).mockImplementation(async () => { + readyCallCount++ + if (readyCallCount === 1) return [task1] + return [] + }) + + vi.mocked(syncService.syncEpicToBeads).mockResolvedValue({ + created: [{ issueId: '100', beadsTaskId: '100', title: 'Task with PR' }], + skipped: [], + dependenciesCreated: 0, + }) + + const mockProcess = createMockChildProcess(0) + vi.mocked(execa).mockReturnValue(mockProcess as never) + + // Mock PR search - found a PR, then merge, then issue close + vi.mocked(executeGhCommand) + .mockResolvedValueOnce([{ number: 42 }] as never) // PR search + .mockResolvedValueOnce(undefined as never) // PR merge + .mockResolvedValueOnce(undefined as never) // issue close + + const result = await supervisor.run(epicLoom) + + // Verify PR was merged + expect(executeGhCommand).toHaveBeenCalledWith( + ['pr', 'merge', '42', '--merge', '--delete-branch'], + ) + expect(result.mergedPRs).toBe(1) + expect(result.completed).toBe(1) + }) + + it('should handle merge failures', async () => { + const task1 = createBeadsTask('100', 'Task with failing merge') + + let readyCallCount = 0 + vi.mocked(beadsManager.ready).mockImplementation(async () => { + readyCallCount++ + if (readyCallCount === 1) return [task1] + return [] + }) + + vi.mocked(syncService.syncEpicToBeads).mockResolvedValue({ + created: [{ issueId: '100', beadsTaskId: '100', title: 'Task' }], + skipped: [], + dependenciesCreated: 0, + }) + + const mockProcess = createMockChildProcess(0) + vi.mocked(execa).mockReturnValue(mockProcess as never) + + // PR search finds PR, but merge fails + vi.mocked(executeGhCommand) + .mockResolvedValueOnce([{ number: 42 }] as never) // PR search + .mockRejectedValueOnce(new Error('Merge conflict')) // PR merge fails + + const result = await supervisor.run(epicLoom) + + expect(result.failedMerges).toBe(1) + expect(result.failed).toBe(1) + }) + + it('should close Beads task and issue after successful merge', async () => { + const task1 = createBeadsTask('100', 'Complete task') + + let readyCallCount = 0 + vi.mocked(beadsManager.ready).mockImplementation(async () => { + readyCallCount++ + if (readyCallCount === 1) return [task1] + return [] + }) + + vi.mocked(syncService.syncEpicToBeads).mockResolvedValue({ + created: [{ issueId: '100', beadsTaskId: '100', title: 'Complete task' }], + skipped: [], + dependenciesCreated: 0, + }) + + const mockProcess = createMockChildProcess(0) + vi.mocked(execa).mockReturnValue(mockProcess as never) + + vi.mocked(executeGhCommand) + .mockResolvedValueOnce([{ number: 42 }] as never) // PR search + .mockResolvedValueOnce(undefined as never) // PR merge + .mockResolvedValueOnce(undefined as never) // issue close + + await supervisor.run(epicLoom) + + expect(beadsManager.close).toHaveBeenCalledWith('100', 'merged PR #42') + expect(executeGhCommand).toHaveBeenCalledWith( + ['issue', 'close', '100'], + ) + }) + + it('should set swarm environment variables when spawning agents', async () => { + const task1 = createBeadsTask('100', 'Task 1') + + let readyCallCount = 0 + vi.mocked(beadsManager.ready).mockImplementation(async () => { + readyCallCount++ + if (readyCallCount === 1) return [task1] + return [] + }) + + vi.mocked(syncService.syncEpicToBeads).mockResolvedValue({ + created: [{ issueId: '100', beadsTaskId: '100', title: 'Task 1' }], + skipped: [], + dependenciesCreated: 0, + }) + + const mockProcess = createMockChildProcess(0) + vi.mocked(execa).mockReturnValue(mockProcess as never) + vi.mocked(executeGhCommand).mockResolvedValue([] as never) + + await supervisor.run(epicLoom) + + expect(execa).toHaveBeenCalledWith( + 'il', + ['spin', '-p'], + expect.objectContaining({ + cwd: '/tmp/worktree/issue-100', + env: expect.objectContaining({ + ILOOM_SWARM_MODE: '1', + ILOOM_EPIC_BRANCH: 'feat/epic-50', + ILOOM_EPIC_ISSUE: '50', + }), + reject: false, + all: true, + }), + ) + }) + + it('should handle claim failures gracefully', async () => { + const task1 = createBeadsTask('100', 'Task 1') + + let readyCallCount = 0 + vi.mocked(beadsManager.ready).mockImplementation(async () => { + readyCallCount++ + if (readyCallCount === 1) return [task1] + return [] + }) + + vi.mocked(syncService.syncEpicToBeads).mockResolvedValue({ + created: [{ issueId: '100', beadsTaskId: '100', title: 'Task 1' }], + skipped: [], + dependenciesCreated: 0, + }) + + // Claim fails + vi.mocked(beadsManager.claim).mockRejectedValue(new Error('Already claimed')) + + const result = await supervisor.run(epicLoom) + + expect(result.failed).toBe(1) + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to claim/spawn agent for task 100'), + ) + }) + + it('should complete immediately when no tasks exist', async () => { + vi.mocked(syncService.syncEpicToBeads).mockResolvedValue({ + created: [], + skipped: [], + dependenciesCreated: 0, + }) + + const result = await supervisor.run(epicLoom) + + expect(result.totalTasks).toBe(0) + expect(result.completed).toBe(0) + expect(result.failed).toBe(0) + }) + + it('should pass parentLoom info when creating child looms', async () => { + const task1 = createBeadsTask('100', 'Task 1') + + let readyCallCount = 0 + vi.mocked(beadsManager.ready).mockImplementation(async () => { + readyCallCount++ + if (readyCallCount === 1) return [task1] + return [] + }) + + vi.mocked(syncService.syncEpicToBeads).mockResolvedValue({ + created: [{ issueId: '100', beadsTaskId: '100', title: 'Task 1' }], + skipped: [], + dependenciesCreated: 0, + }) + + const mockProcess = createMockChildProcess(0) + vi.mocked(execa).mockReturnValue(mockProcess as never) + vi.mocked(executeGhCommand).mockResolvedValue([] as never) + + await supervisor.run(epicLoom) + + expect(loomManager.createIloom).toHaveBeenCalledWith( + expect.objectContaining({ + parentLoom: { + type: 'issue', + identifier: '50', + branchName: 'feat/epic-50', + worktreePath: '/tmp/worktree/epic-50', + }, + }), + ) + }) + + it('should handle alphanumeric issue IDs (e.g., Linear)', async () => { + const task1 = createBeadsTask('ENG-123', 'Linear task') + + let readyCallCount = 0 + vi.mocked(beadsManager.ready).mockImplementation(async () => { + readyCallCount++ + if (readyCallCount === 1) return [task1] + return [] + }) + + vi.mocked(syncService.syncEpicToBeads).mockResolvedValue({ + created: [{ issueId: 'ENG-123', beadsTaskId: 'ENG-123', title: 'Linear task' }], + skipped: [], + dependenciesCreated: 0, + }) + + const mockProcess = createMockChildProcess(0) + vi.mocked(execa).mockReturnValue(mockProcess as never) + vi.mocked(executeGhCommand).mockResolvedValue([] as never) + + await supervisor.run(epicLoom) + + expect(loomManager.createIloom).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'issue', + identifier: 'ENG-123', + }), + ) + }) + }) + + describe('graceful shutdown', () => { + it('should stop claiming tasks when shuttingDown is true', async () => { + const task1 = createBeadsTask('100', 'Task 1') + + let readyCallCount = 0 + vi.mocked(beadsManager.ready).mockImplementation(async () => { + readyCallCount++ + if (readyCallCount === 1) { + // Trigger shutdown before claiming + ;(supervisor as unknown as { shuttingDown: boolean }).shuttingDown = true + return [task1] + } + return [] + }) + + vi.mocked(syncService.syncEpicToBeads).mockResolvedValue({ + created: [{ issueId: '100', beadsTaskId: '100', title: 'Task 1' }], + skipped: [], + dependenciesCreated: 0, + }) + + await supervisor.run(epicLoom) + + // Should NOT have claimed any tasks since shuttingDown was set + expect(beadsManager.claim).not.toHaveBeenCalled() + }) + + it('should handle double SIGINT by process.exit', () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never) + + const handler = (supervisor as unknown as { handleSignal: () => void }).handleSignal.bind(supervisor) + + // First signal: sets shuttingDown + handler() + expect((supervisor as unknown as { shuttingDown: boolean }).shuttingDown).toBe(true) + + // Second signal: force exit + handler() + expect(exitSpy).toHaveBeenCalledWith(1) + + exitSpy.mockRestore() + }) + }) + + describe('issue close failure', () => { + it('should not fail the merge if issue close fails', async () => { + const task1 = createBeadsTask('100', 'Task') + + let readyCallCount = 0 + vi.mocked(beadsManager.ready).mockImplementation(async () => { + readyCallCount++ + if (readyCallCount === 1) return [task1] + return [] + }) + + vi.mocked(syncService.syncEpicToBeads).mockResolvedValue({ + created: [{ issueId: '100', beadsTaskId: '100', title: 'Task' }], + skipped: [], + dependenciesCreated: 0, + }) + + const mockProcess = createMockChildProcess(0) + vi.mocked(execa).mockReturnValue(mockProcess as never) + + vi.mocked(executeGhCommand) + .mockResolvedValueOnce([{ number: 42 }] as never) // PR search + .mockResolvedValueOnce(undefined as never) // PR merge succeeds + .mockRejectedValueOnce(new Error('Cannot close')) // issue close fails + + const result = await supervisor.run(epicLoom) + + expect(result.mergedPRs).toBe(1) + expect(result.completed).toBe(1) + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('Failed to close issue 100'), + ) + }) + }) + + describe('Beads task close failure', () => { + it('should not fail the merge if Beads close fails', async () => { + const task1 = createBeadsTask('100', 'Task') + + let readyCallCount = 0 + vi.mocked(beadsManager.ready).mockImplementation(async () => { + readyCallCount++ + if (readyCallCount === 1) return [task1] + return [] + }) + + vi.mocked(syncService.syncEpicToBeads).mockResolvedValue({ + created: [{ issueId: '100', beadsTaskId: '100', title: 'Task' }], + skipped: [], + dependenciesCreated: 0, + }) + + const mockProcess = createMockChildProcess(0) + vi.mocked(execa).mockReturnValue(mockProcess as never) + + vi.mocked(executeGhCommand) + .mockResolvedValueOnce([{ number: 42 }] as never) // PR search + .mockResolvedValueOnce(undefined as never) // PR merge + .mockResolvedValueOnce(undefined as never) // issue close + + // Beads close fails + vi.mocked(beadsManager.close).mockRejectedValue(new Error('Beads error')) + + const result = await supervisor.run(epicLoom) + + expect(result.mergedPRs).toBe(1) + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('Failed to close Beads task 100'), + ) + }) + }) +}) diff --git a/src/lib/SwarmSupervisor.ts b/src/lib/SwarmSupervisor.ts new file mode 100644 index 0000000..1ac9e71 --- /dev/null +++ b/src/lib/SwarmSupervisor.ts @@ -0,0 +1,438 @@ +import path from 'path' +import fs from 'fs-extra' +import { setTimeout as sleep } from 'timers/promises' +import { execa, type ExecaChildProcess } from 'execa' +import type { BeadsManager, BeadsTask } from './BeadsManager.js' +import type { BeadsSyncService } from './BeadsSyncService.js' +import type { LoomManager } from './LoomManager.js' +import type { SwarmSettings } from './SettingsManager.js' +import { executeGhCommand } from '../utils/github.js' +import { logger } from '../utils/logger.js' + +/** + * Context for the epic loom (parent workspace) that the swarm operates on + */ +export interface EpicLoomContext { + epicIssueNumber: string + epicBranch: string + epicLoomPath: string + projectPath: string +} + +/** + * Tracks an active swarm agent process + */ +export interface ActiveAgent { + issueId: string + pid: number + loomPath: string + logFile: string + process: ExecaChildProcess + beadsTaskId: string + /** Set when the process exits. Null means still running. */ + exitCode: number | null +} + +/** + * Tracks a PR ready to be merged into the epic branch + */ +export interface MergeQueueEntry { + issueId: string + prNumber: number + beadsTaskId: string +} + +/** + * Aggregate result of a swarm run + */ +export interface SwarmResult { + totalTasks: number + completed: number + failed: number + mergedPRs: number + failedMerges: number + duration: number +} + +/** + * Orchestrates headless Claude agents working on an epic's child issues. + * + * Uses Beads for DAG-based task ordering and atomic claiming. + * Spawns `il spin -p` as child processes in minimal worktrees. + * Merges PRs sequentially into the epic branch to prevent race conditions. + * + * Lifecycle: + * 1. Init Beads and sync epic children into DAG + * 2. Loop: claim ready tasks, spawn agents, monitor, merge PRs + * 3. Complete when DAG is empty and no agents are running + */ +export class SwarmSupervisor { + private activeAgents: Map = new Map() + private mergeQueue: MergeQueueEntry[] = [] + private shuttingDown = false + private signalHandlersInstalled = false + + constructor( + private readonly beadsManager: BeadsManager, + private readonly syncService: BeadsSyncService, + private readonly loomManager: LoomManager, + private readonly settings: SwarmSettings, + ) {} + + /** + * Run the swarm supervisor loop. + * + * Orchestrates the full lifecycle: init, sync, claim, spawn, monitor, merge. + * Returns aggregate stats when all tasks are complete or shutdown is requested. + */ + async run(epicLoom: EpicLoomContext): Promise { + const startTime = Date.now() + const result: SwarmResult = { + totalTasks: 0, + completed: 0, + failed: 0, + mergedPRs: 0, + failedMerges: 0, + duration: 0, + } + + this.installSignalHandlers() + + try { + // Step 1: Initialize Beads + logger.info('Initializing Beads DAG...') + await this.beadsManager.init() + + // Step 2: Sync epic children to Beads + logger.info('Syncing epic children to Beads...') + const syncResult = await this.syncService.syncEpicToBeads(epicLoom.epicIssueNumber) + result.totalTasks = syncResult.created.length + syncResult.skipped.length + logger.info(`Synced ${result.totalTasks} tasks (${syncResult.created.length} new, ${syncResult.skipped.length} existing)`) + + // Step 3: Ensure agent-logs directory exists + const logDir = path.join(epicLoom.epicLoomPath, 'agent-logs') + await fs.ensureDir(logDir) + + // Step 4: Main supervisor loop + while (!this.isComplete()) { + // 4a: Query ready tasks + const readyTasks = await this.beadsManager.ready() + + // 4b: Claim and spawn agents for unblocked tasks + if (!this.shuttingDown) { + const slotsAvailable = this.settings.maxConcurrent - this.activeAgents.size + const tasksToClaim = readyTasks.slice(0, slotsAvailable) + + for (const task of tasksToClaim) { + try { + await this.claimAndSpawnAgent(task, epicLoom, logDir) + } catch (error) { + logger.error(`Failed to claim/spawn agent for task ${task.id}: ${error instanceof Error ? error.message : 'Unknown error'}`) + result.failed++ + } + } + } + + // 4c: Check for completed agents + await this.checkCompletedAgents(result, epicLoom) + + // 4d: Process merge queue + await this.processMergeQueue(result, epicLoom) + + // 4e: Check if we're done + if (readyTasks.length === 0 && this.activeAgents.size === 0 && this.mergeQueue.length === 0) { + break + } + + // Poll interval - avoid tight loop + await sleep(2000) + } + + // If shutting down, wait for remaining agents + if (this.shuttingDown && this.activeAgents.size > 0) { + logger.info(`Shutting down gracefully. Waiting for ${this.activeAgents.size} running agent(s) to complete...`) + await this.waitForRemainingAgents(result, epicLoom) + } + } finally { + this.removeSignalHandlers() + result.duration = Date.now() - startTime + } + + logger.info(`Swarm complete: ${result.completed} completed, ${result.failed} failed, ${result.mergedPRs} PRs merged`) + return result + } + + /** + * Claim a Beads task and spawn an agent process for it. + */ + private async claimAndSpawnAgent( + task: BeadsTask, + epicLoom: EpicLoomContext, + logDir: string, + ): Promise { + // Atomically claim the task + await this.beadsManager.claim(task.id) + logger.info(`Claimed task ${task.id}: ${task.title}`) + + // Create child loom (minimal worktree) + const loom = await this.loomManager.createIloom({ + type: 'issue', + identifier: this.parseIssueIdentifier(task.id), + originalInput: task.id, + baseBranch: epicLoom.epicBranch, + parentLoom: { + type: 'issue', + identifier: epicLoom.epicIssueNumber, + branchName: epicLoom.epicBranch, + worktreePath: epicLoom.epicLoomPath, + }, + options: { + swarmMode: true, + }, + }) + + // Set up log file + const logFile = path.join(logDir, `${task.id}.log`) + const logStream = fs.createWriteStream(logFile, { flags: 'a' }) + + // Spawn il spin -p in the child loom directory + const childProcess = execa('il', ['spin', '-p'], { + cwd: loom.path, + env: { + ...process.env, + ILOOM_SWARM_MODE: '1', + ILOOM_EPIC_BRANCH: epicLoom.epicBranch, + ILOOM_EPIC_ISSUE: epicLoom.epicIssueNumber, + }, + reject: false, + all: true, + }) + + // Pipe output to log file + if (childProcess.all) { + childProcess.all.pipe(logStream) + } + + const agent: ActiveAgent = { + issueId: task.id, + pid: childProcess.pid ?? 0, + loomPath: loom.path, + logFile, + process: childProcess, + beadsTaskId: task.id, + exitCode: null, + } + + // Track process completion via callback + childProcess.then( + (r: { exitCode: number }) => { agent.exitCode = r.exitCode }, + () => { agent.exitCode = 1 }, + ) + + this.activeAgents.set(task.id, agent) + logger.info(`Spawned agent for issue ${task.id} (PID: ${agent.pid}) in ${loom.path}`) + } + + /** + * Check for completed agent processes and handle their results. + */ + private async checkCompletedAgents( + result: SwarmResult, + epicLoom: EpicLoomContext, + ): Promise { + for (const [issueId, agent] of this.activeAgents) { + if (agent.exitCode === null) { + continue + } + + this.activeAgents.delete(issueId) + + if (agent.exitCode === 0) { + logger.info(`Agent for issue ${issueId} completed successfully`) + + // Find the PR created by the agent + const prNumber = await this.findPRForBranch(issueId, epicLoom) + + if (prNumber) { + this.mergeQueue.push({ + issueId, + prNumber, + beadsTaskId: agent.beadsTaskId, + }) + logger.info(`Enqueued PR #${prNumber} for merge (issue ${issueId})`) + } else { + // Agent succeeded but no PR found - still mark as complete + logger.warn(`Agent for issue ${issueId} completed but no PR found, marking task as closed`) + await this.closeTask(agent.beadsTaskId, 'completed without PR') + result.completed++ + } + } else { + logger.error(`Agent for issue ${issueId} failed with exit code ${agent.exitCode}. See log: ${agent.logFile}`) + result.failed++ + // Release claim so task can be retried (failure handling in #563) + try { + await this.beadsManager.releaseClaim(agent.beadsTaskId) + } catch (releaseError) { + logger.error(`Failed to release claim for task ${agent.beadsTaskId}: ${releaseError instanceof Error ? releaseError.message : 'Unknown error'}`) + } + } + } + } + + /** + * Process the merge queue sequentially - one PR at a time. + */ + private async processMergeQueue( + result: SwarmResult, + _epicLoom: EpicLoomContext, + ): Promise { + while (this.mergeQueue.length > 0) { + const entry = this.mergeQueue.shift() + if (!entry) break + + try { + logger.info(`Merging PR #${entry.prNumber} for issue ${entry.issueId}...`) + await this.mergePR(entry.prNumber) + logger.info(`Successfully merged PR #${entry.prNumber}`) + + // Close the Beads task + await this.closeTask(entry.beadsTaskId, `merged PR #${entry.prNumber}`) + + // Close the issue via GitHub + await this.closeIssue(entry.issueId) + + result.mergedPRs++ + result.completed++ + } catch (error) { + logger.error(`Failed to merge PR #${entry.prNumber} for issue ${entry.issueId}: ${error instanceof Error ? error.message : 'Unknown error'}`) + result.failedMerges++ + result.failed++ + } + } + } + + /** + * Merge a PR into the epic branch using gh CLI. + */ + private async mergePR(prNumber: number): Promise { + await executeGhCommand( + ['pr', 'merge', String(prNumber), '--merge', '--delete-branch'], + ) + } + + /** + * Close a GitHub issue via gh CLI. + */ + private async closeIssue(issueId: string): Promise { + try { + await executeGhCommand( + ['issue', 'close', issueId], + ) + logger.info(`Closed issue ${issueId}`) + } catch (error) { + // Log but don't fail - issue closure is not critical + logger.warn(`Failed to close issue ${issueId}: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + } + + /** + * Close a Beads task with an optional reason. + */ + private async closeTask(taskId: string, reason?: string): Promise { + try { + await this.beadsManager.close(taskId, reason) + } catch (error) { + logger.warn(`Failed to close Beads task ${taskId}: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + } + + /** + * Find a PR for a given issue branch. + * Looks for open PRs created by the swarm agent. + */ + private async findPRForBranch(issueId: string, _epicLoom: EpicLoomContext): Promise { + try { + const prList = await executeGhCommand>( + ['pr', 'list', '--state', 'open', '--json', 'number', '--search', `is:pr is:open ${issueId} in:title`], + ) + + if (prList.length > 0 && prList[0]) { + return prList[0].number + } + return null + } catch { + return null + } + } + + /** + * Wait for all remaining active agents to complete. + * Called during graceful shutdown. + */ + private async waitForRemainingAgents( + result: SwarmResult, + epicLoom: EpicLoomContext, + ): Promise { + while (this.activeAgents.size > 0) { + await this.checkCompletedAgents(result, epicLoom) + await this.processMergeQueue(result, epicLoom) + + if (this.activeAgents.size > 0) { + await sleep(2000) + } + } + } + + /** + * Check if the supervisor loop should exit. + */ + private isComplete(): boolean { + return this.shuttingDown && this.activeAgents.size === 0 && this.mergeQueue.length === 0 + } + + /** + * Parse an issue identifier from a Beads task ID. + * Task IDs may be numeric (GitHub) or alphanumeric (Linear). + */ + private parseIssueIdentifier(taskId: string): string | number { + const numericId = parseInt(taskId, 10) + return isNaN(numericId) ? taskId : numericId + } + + /** + * Install signal handlers for graceful shutdown. + */ + private installSignalHandlers(): void { + if (this.signalHandlersInstalled) return + + this.handleSignal = this.handleSignal.bind(this) + process.on('SIGINT', this.handleSignal) + process.on('SIGTERM', this.handleSignal) + this.signalHandlersInstalled = true + } + + /** + * Remove signal handlers. + */ + private removeSignalHandlers(): void { + if (!this.signalHandlersInstalled) return + + process.removeListener('SIGINT', this.handleSignal) + process.removeListener('SIGTERM', this.handleSignal) + this.signalHandlersInstalled = false + } + + /** + * Handle SIGINT/SIGTERM for graceful shutdown. + */ + private handleSignal(): void { + if (this.shuttingDown) { + logger.warn('Forced shutdown requested. Exiting immediately.') + process.exit(1) + } + + this.shuttingDown = true + logger.info(`Shutting down gracefully. Waiting for ${this.activeAgents.size} running agent(s) to complete...`) + } + +} From 3fc72f121676f80cef2946b8358740582f902891 Mon Sep 17 00:00:00 2001 From: Adam Creeger Date: Thu, 5 Feb 2026 19:43:22 -0500 Subject: [PATCH 6/9] feat(swarm): add failure retry, conflict resolution, resume support, and progress reporting (#563) --- src/lib/BeadsManager.ts | 17 + src/lib/SwarmSupervisor.test.ts | 655 ++++++++++++++++++++++++++++---- src/lib/SwarmSupervisor.ts | 503 ++++++++++++++++++++++-- 3 files changed, 1078 insertions(+), 97 deletions(-) diff --git a/src/lib/BeadsManager.ts b/src/lib/BeadsManager.ts index cbd4e80..a73b206 100644 --- a/src/lib/BeadsManager.ts +++ b/src/lib/BeadsManager.ts @@ -246,6 +246,23 @@ export class BeadsManager { await this.execBd(['update', '--release', taskId]) } + /** + * List all tasks in the Beads DAG regardless of status. + * Returns parsed JSON array of all tasks. + * + * @returns Array of all tasks + * @throws BeadsError if the command fails + */ + async list(): Promise { + const result = await this.execBd(['list', '--json']) + try { + return JSON.parse(result.stdout) as BeadsTask[] + } catch { + logger.debug('Failed to parse bd list output, returning empty', { stdout: result.stdout }) + return [] + } + } + /** * Execute a bd CLI command with proper environment variables set. * All bd commands must have BEADS_DIR and BEADS_NO_DAEMON=1. diff --git a/src/lib/SwarmSupervisor.test.ts b/src/lib/SwarmSupervisor.test.ts index 735e356..3e3c115 100644 --- a/src/lib/SwarmSupervisor.test.ts +++ b/src/lib/SwarmSupervisor.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { SwarmSupervisor, type EpicLoomContext } from './SwarmSupervisor.js' +import { SwarmSupervisor, type EpicLoomContext, type SwarmProgress } from './SwarmSupervisor.js' import type { BeadsManager, BeadsTask } from './BeadsManager.js' import type { BeadsSyncService, SyncResult } from './BeadsSyncService.js' import type { LoomManager } from './LoomManager.js' @@ -24,6 +24,7 @@ vi.mock('fs-extra', () => ({ write: vi.fn(), end: vi.fn(), })), + writeJson: vi.fn(), }, })) @@ -44,6 +45,7 @@ vi.mock('../utils/logger.js', () => ({ })) import { execa } from 'execa' +import fs from 'fs-extra' import { executeGhCommand } from '../utils/github.js' import { logger } from '../utils/logger.js' @@ -61,6 +63,7 @@ function createMockBeadsManager(): BeadsManager { ensureInstalled: vi.fn().mockResolvedValue(undefined), isInstalled: vi.fn().mockResolvedValue(true), getBeadsDir: vi.fn().mockReturnValue('/tmp/beads'), + list: vi.fn().mockResolvedValue([]), } as unknown as BeadsManager } @@ -108,8 +111,8 @@ function createEpicLoomContext(): EpicLoomContext { } } -function createBeadsTask(id: string, title: string): BeadsTask { - return { id, title, status: 'ready' } +function createBeadsTask(id: string, title: string, status = 'ready'): BeadsTask { + return { id, title, status } } /** @@ -271,8 +274,8 @@ describe('SwarmSupervisor', () => { expect(beadsManager.claim).toHaveBeenCalledWith('101') }) - it('should handle failed agents by releasing their claim', async () => { - const task1 = createBeadsTask('100', 'Failing task') + it('should enqueue and merge PRs sequentially on success', async () => { + const task1 = createBeadsTask('100', 'Task with PR') let readyCallCount = 0 vi.mocked(beadsManager.ready).mockImplementation(async () => { @@ -282,24 +285,73 @@ describe('SwarmSupervisor', () => { }) vi.mocked(syncService.syncEpicToBeads).mockResolvedValue({ - created: [{ issueId: '100', beadsTaskId: '100', title: 'Failing task' }], + created: [{ issueId: '100', beadsTaskId: '100', title: 'Task with PR' }], skipped: [], dependenciesCreated: 0, }) - // Mock agent that exits with failure - const mockProcess = createMockChildProcess(1) + const mockProcess = createMockChildProcess(0) vi.mocked(execa).mockReturnValue(mockProcess as never) + // Mock PR search - found a PR, then merge, then issue close + vi.mocked(executeGhCommand) + .mockResolvedValueOnce([{ number: 42 }] as never) // PR search + .mockResolvedValueOnce(undefined as never) // PR merge + .mockResolvedValueOnce(undefined as never) // issue close + + const result = await supervisor.run(epicLoom) + + // Verify PR was merged + expect(executeGhCommand).toHaveBeenCalledWith( + ['pr', 'merge', '42', '--merge', '--delete-branch'], + ) + expect(result.mergedPRs).toBe(1) + expect(result.completed).toBe(1) + }) + + it('should handle claim failures gracefully', async () => { + const task1 = createBeadsTask('100', 'Task 1') + + let readyCallCount = 0 + vi.mocked(beadsManager.ready).mockImplementation(async () => { + readyCallCount++ + if (readyCallCount === 1) return [task1] + return [] + }) + + vi.mocked(syncService.syncEpicToBeads).mockResolvedValue({ + created: [{ issueId: '100', beadsTaskId: '100', title: 'Task 1' }], + skipped: [], + dependenciesCreated: 0, + }) + + // Claim fails + vi.mocked(beadsManager.claim).mockRejectedValue(new Error('Already claimed')) + const result = await supervisor.run(epicLoom) - expect(beadsManager.releaseClaim).toHaveBeenCalledWith('100') expect(result.failed).toBe(1) + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to claim/spawn agent for task 100'), + ) + }) + + it('should complete immediately when no tasks exist', async () => { + vi.mocked(syncService.syncEpicToBeads).mockResolvedValue({ + created: [], + skipped: [], + dependenciesCreated: 0, + }) + + const result = await supervisor.run(epicLoom) + + expect(result.totalTasks).toBe(0) expect(result.completed).toBe(0) + expect(result.failed).toBe(0) }) - it('should enqueue and merge PRs sequentially on success', async () => { - const task1 = createBeadsTask('100', 'Task with PR') + it('should pass parentLoom info when creating child looms', async () => { + const task1 = createBeadsTask('100', 'Task 1') let readyCallCount = 0 vi.mocked(beadsManager.ready).mockImplementation(async () => { @@ -309,32 +361,31 @@ describe('SwarmSupervisor', () => { }) vi.mocked(syncService.syncEpicToBeads).mockResolvedValue({ - created: [{ issueId: '100', beadsTaskId: '100', title: 'Task with PR' }], + created: [{ issueId: '100', beadsTaskId: '100', title: 'Task 1' }], skipped: [], dependenciesCreated: 0, }) const mockProcess = createMockChildProcess(0) vi.mocked(execa).mockReturnValue(mockProcess as never) + vi.mocked(executeGhCommand).mockResolvedValue([] as never) - // Mock PR search - found a PR, then merge, then issue close - vi.mocked(executeGhCommand) - .mockResolvedValueOnce([{ number: 42 }] as never) // PR search - .mockResolvedValueOnce(undefined as never) // PR merge - .mockResolvedValueOnce(undefined as never) // issue close - - const result = await supervisor.run(epicLoom) + await supervisor.run(epicLoom) - // Verify PR was merged - expect(executeGhCommand).toHaveBeenCalledWith( - ['pr', 'merge', '42', '--merge', '--delete-branch'], + expect(loomManager.createIloom).toHaveBeenCalledWith( + expect.objectContaining({ + parentLoom: { + type: 'issue', + identifier: '50', + branchName: 'feat/epic-50', + worktreePath: '/tmp/worktree/epic-50', + }, + }), ) - expect(result.mergedPRs).toBe(1) - expect(result.completed).toBe(1) }) - it('should handle merge failures', async () => { - const task1 = createBeadsTask('100', 'Task with failing merge') + it('should handle alphanumeric issue IDs (e.g., Linear)', async () => { + const task1 = createBeadsTask('ENG-123', 'Linear task') let readyCallCount = 0 vi.mocked(beadsManager.ready).mockImplementation(async () => { @@ -344,23 +395,61 @@ describe('SwarmSupervisor', () => { }) vi.mocked(syncService.syncEpicToBeads).mockResolvedValue({ - created: [{ issueId: '100', beadsTaskId: '100', title: 'Task' }], + created: [{ issueId: 'ENG-123', beadsTaskId: 'ENG-123', title: 'Linear task' }], skipped: [], dependenciesCreated: 0, }) const mockProcess = createMockChildProcess(0) vi.mocked(execa).mockReturnValue(mockProcess as never) + vi.mocked(executeGhCommand).mockResolvedValue([] as never) - // PR search finds PR, but merge fails - vi.mocked(executeGhCommand) - .mockResolvedValueOnce([{ number: 42 }] as never) // PR search - .mockRejectedValueOnce(new Error('Merge conflict')) // PR merge fails + await supervisor.run(epicLoom) - const result = await supervisor.run(epicLoom) + expect(loomManager.createIloom).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'issue', + identifier: 'ENG-123', + }), + ) + }) - expect(result.failedMerges).toBe(1) - expect(result.failed).toBe(1) + it('should set swarm environment variables when spawning agents', async () => { + const task1 = createBeadsTask('100', 'Task 1') + + let readyCallCount = 0 + vi.mocked(beadsManager.ready).mockImplementation(async () => { + readyCallCount++ + if (readyCallCount === 1) return [task1] + return [] + }) + + vi.mocked(syncService.syncEpicToBeads).mockResolvedValue({ + created: [{ issueId: '100', beadsTaskId: '100', title: 'Task 1' }], + skipped: [], + dependenciesCreated: 0, + }) + + const mockProcess = createMockChildProcess(0) + vi.mocked(execa).mockReturnValue(mockProcess as never) + vi.mocked(executeGhCommand).mockResolvedValue([] as never) + + await supervisor.run(epicLoom) + + expect(execa).toHaveBeenCalledWith( + 'il', + ['spin', '-p'], + expect.objectContaining({ + cwd: '/tmp/worktree/issue-100', + env: expect.objectContaining({ + ILOOM_SWARM_MODE: '1', + ILOOM_EPIC_BRANCH: 'feat/epic-50', + ILOOM_EPIC_ISSUE: '50', + }), + reject: false, + all: true, + }), + ) }) it('should close Beads task and issue after successful merge', async () => { @@ -394,47 +483,217 @@ describe('SwarmSupervisor', () => { ['issue', 'close', '100'], ) }) + }) - it('should set swarm environment variables when spawning agents', async () => { - const task1 = createBeadsTask('100', 'Task 1') + describe('failure handling and retry', () => { + it('should retry a failed task when below maxRetries', async () => { + settings.maxRetries = 2 + const task1 = createBeadsTask('100', 'Flaky task') let readyCallCount = 0 + let execaCallCount = 0 + vi.mocked(beadsManager.ready).mockImplementation(async () => { readyCallCount++ + // First call: return the task (initial attempt) if (readyCallCount === 1) return [task1] + // Second call: task re-appears after claim release (retry) + if (readyCallCount === 2) return [task1] return [] }) vi.mocked(syncService.syncEpicToBeads).mockResolvedValue({ - created: [{ issueId: '100', beadsTaskId: '100', title: 'Task 1' }], + created: [{ issueId: '100', beadsTaskId: '100', title: 'Flaky task' }], skipped: [], dependenciesCreated: 0, }) - const mockProcess = createMockChildProcess(0) - vi.mocked(execa).mockReturnValue(mockProcess as never) + vi.mocked(execa).mockImplementation(() => { + execaCallCount++ + // First attempt fails, second succeeds + if (execaCallCount === 1) return createMockChildProcess(1) as never + return createMockChildProcess(0) as never + }) + + vi.mocked(loomManager.createIloom) + .mockResolvedValueOnce({ + id: 'issue-100', path: '/tmp/worktree/issue-100', branch: 'feat/100', + type: 'issue', identifier: 100, port: 3100, + createdAt: new Date(), lastAccessed: new Date(), + } as Loom) + .mockResolvedValueOnce({ + id: 'issue-100-retry', path: '/tmp/worktree/issue-100-retry', branch: 'feat/100', + type: 'issue', identifier: 100, port: 3100, + createdAt: new Date(), lastAccessed: new Date(), + } as Loom) + vi.mocked(executeGhCommand).mockResolvedValue([] as never) - await supervisor.run(epicLoom) + const result = await supervisor.run(epicLoom) + + // Claim released after first failure, then re-claimed on retry + expect(beadsManager.releaseClaim).toHaveBeenCalledWith('100') + expect(beadsManager.claim).toHaveBeenCalledTimes(2) + // Second attempt succeeded + expect(result.completed).toBe(1) + expect(result.failed).toBe(0) + // Should log the retry + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('Retrying task 100'), + ) + }) + + it('should permanently fail a task after exhausting maxRetries', async () => { + settings.maxRetries = 1 + + const task1 = createBeadsTask('100', 'Permanently failing') + let readyCallCount = 0 + + vi.mocked(beadsManager.ready).mockImplementation(async () => { + readyCallCount++ + if (readyCallCount === 1) return [task1] + return [] + }) + + vi.mocked(syncService.syncEpicToBeads).mockResolvedValue({ + created: [{ issueId: '100', beadsTaskId: '100', title: 'Permanently failing' }], + skipped: [], + dependenciesCreated: 0, + }) + + const mockProcess = createMockChildProcess(1) + vi.mocked(execa).mockReturnValue(mockProcess as never) + + const result = await supervisor.run(epicLoom) + + expect(result.failed).toBe(1) + expect(result.completed).toBe(0) + // Should close the task in Beads as failed + expect(beadsManager.close).toHaveBeenCalledWith( + '100', + expect.stringContaining('failed after 1 attempts'), + ) + }) + + it('should not retry when maxRetries is 0', async () => { + settings.maxRetries = 0 + + const task1 = createBeadsTask('100', 'No retry task') + let readyCallCount = 0 + + vi.mocked(beadsManager.ready).mockImplementation(async () => { + readyCallCount++ + if (readyCallCount === 1) return [task1] + return [] + }) + + vi.mocked(syncService.syncEpicToBeads).mockResolvedValue({ + created: [{ issueId: '100', beadsTaskId: '100', title: 'No retry task' }], + skipped: [], + dependenciesCreated: 0, + }) + + const mockProcess = createMockChildProcess(1) + vi.mocked(execa).mockReturnValue(mockProcess as never) + + const result = await supervisor.run(epicLoom) + + // Should fail immediately without retry since maxRetries=0 means 0 retries allowed, + // but the first attempt still counts as attempt 1 + // With maxRetries=0, attempt (1) >= maxRetries (0) should trigger permanent failure + expect(result.failed).toBe(1) + expect(beadsManager.claim).toHaveBeenCalledTimes(1) + }) + + it('should skip permanently failed tasks even if they appear in ready()', async () => { + settings.maxRetries = 1 + + const task1 = createBeadsTask('100', 'Failing task') + let readyCallCount = 0 + + vi.mocked(beadsManager.ready).mockImplementation(async () => { + readyCallCount++ + // Task keeps appearing in ready() even after permanent failure + if (readyCallCount <= 3) return [task1] + return [] + }) + + vi.mocked(syncService.syncEpicToBeads).mockResolvedValue({ + created: [{ issueId: '100', beadsTaskId: '100', title: 'Failing task' }], + skipped: [], + dependenciesCreated: 0, + }) + + const mockProcess = createMockChildProcess(1) + vi.mocked(execa).mockReturnValue(mockProcess as never) + + const result = await supervisor.run(epicLoom) + + // Should only have claimed once (first attempt fails and gets permanently failed) + expect(beadsManager.claim).toHaveBeenCalledTimes(1) + expect(result.failed).toBe(1) + }) + }) + + describe('merge conflict resolution', () => { + it('should spawn a conflict resolver when merge conflict detected', async () => { + const task1 = createBeadsTask('100', 'Task with conflict') + + let readyCallCount = 0 + vi.mocked(beadsManager.ready).mockImplementation(async () => { + readyCallCount++ + if (readyCallCount === 1) return [task1] + return [] + }) + + vi.mocked(syncService.syncEpicToBeads).mockResolvedValue({ + created: [{ issueId: '100', beadsTaskId: '100', title: 'Task with conflict' }], + skipped: [], + dependenciesCreated: 0, + }) + + // First execa call: agent process (succeeds) + // Second execa call: conflict resolver (succeeds) + const agentProcess = createMockChildProcess(0, 1001) + const resolverProcess = createMockChildProcess(0, 2001) + let execaCallCount = 0 + vi.mocked(execa).mockImplementation(() => { + execaCallCount++ + if (execaCallCount === 1) return agentProcess as never + return resolverProcess as never + }) + + vi.mocked(executeGhCommand) + .mockResolvedValueOnce([{ number: 42 }] as never) // PR search + .mockRejectedValueOnce(new Error('merge conflict')) // first merge attempt fails + .mockResolvedValueOnce(undefined as never) // retry merge succeeds + .mockResolvedValueOnce(undefined as never) // issue close + + const result = await supervisor.run(epicLoom) + + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('Merge conflict detected for PR #42'), + ) + // Resolver spawned with conflict env vars expect(execa).toHaveBeenCalledWith( 'il', ['spin', '-p'], expect.objectContaining({ - cwd: '/tmp/worktree/issue-100', env: expect.objectContaining({ - ILOOM_SWARM_MODE: '1', - ILOOM_EPIC_BRANCH: 'feat/epic-50', - ILOOM_EPIC_ISSUE: '50', + ILOOM_CONFLICT_RESOLUTION: '1', + ILOOM_CONFLICT_PR: '42', }), - reject: false, - all: true, }), ) + expect(result.mergedPRs).toBe(1) + expect(result.completed).toBe(1) }) - it('should handle claim failures gracefully', async () => { - const task1 = createBeadsTask('100', 'Task 1') + it('should fail after exhausting maxConflictRetries', async () => { + settings.maxConflictRetries = 1 + + const task1 = createBeadsTask('100', 'Unresolvable conflict') let readyCallCount = 0 vi.mocked(beadsManager.ready).mockImplementation(async () => { @@ -444,37 +703,211 @@ describe('SwarmSupervisor', () => { }) vi.mocked(syncService.syncEpicToBeads).mockResolvedValue({ - created: [{ issueId: '100', beadsTaskId: '100', title: 'Task 1' }], + created: [{ issueId: '100', beadsTaskId: '100', title: 'Unresolvable conflict' }], skipped: [], dependenciesCreated: 0, }) - // Claim fails - vi.mocked(beadsManager.claim).mockRejectedValue(new Error('Already claimed')) + const agentProcess = createMockChildProcess(0, 1001) + const resolverProcess = createMockChildProcess(0, 2001) + let execaCallCount = 0 + vi.mocked(execa).mockImplementation(() => { + execaCallCount++ + if (execaCallCount === 1) return agentProcess as never + return resolverProcess as never + }) + + vi.mocked(executeGhCommand) + .mockResolvedValueOnce([{ number: 42 }] as never) // PR search + .mockRejectedValueOnce(new Error('merge conflict')) // first merge fails + .mockRejectedValueOnce(new Error('merge conflict')) // retry after resolution also fails + .mockRejectedValueOnce(new Error('merge conflict')) // conflicts exhausted const result = await supervisor.run(epicLoom) + expect(result.failedMerges).toBe(1) expect(result.failed).toBe(1) expect(logger.error).toHaveBeenCalledWith( - expect.stringContaining('Failed to claim/spawn agent for task 100'), + expect.stringContaining('could not be resolved after'), ) }) - it('should complete immediately when no tasks exist', async () => { + it('should detect various conflict error messages', async () => { + const task1 = createBeadsTask('100', 'Task') + + let readyCallCount = 0 + vi.mocked(beadsManager.ready).mockImplementation(async () => { + readyCallCount++ + if (readyCallCount === 1) return [task1] + return [] + }) + vi.mocked(syncService.syncEpicToBeads).mockResolvedValue({ - created: [], + created: [{ issueId: '100', beadsTaskId: '100', title: 'Task' }], skipped: [], dependenciesCreated: 0, }) + const mockProcess = createMockChildProcess(0) + const resolverProcess = createMockChildProcess(0) + let execaCallCount = 0 + vi.mocked(execa).mockImplementation(() => { + execaCallCount++ + if (execaCallCount === 1) return mockProcess as never + return resolverProcess as never + }) + + // Test "CONFLICT" pattern + vi.mocked(executeGhCommand) + .mockResolvedValueOnce([{ number: 42 }] as never) // PR search + .mockRejectedValueOnce(new Error('CONFLICT in file.ts')) // merge with CONFLICT + .mockResolvedValueOnce(undefined as never) // retry merge succeeds + .mockResolvedValueOnce(undefined as never) // issue close + const result = await supervisor.run(epicLoom) - expect(result.totalTasks).toBe(0) + expect(result.mergedPRs).toBe(1) + }) + + it('should handle non-conflict merge failures without spawning resolver', async () => { + const task1 = createBeadsTask('100', 'Task with auth failure') + + let readyCallCount = 0 + vi.mocked(beadsManager.ready).mockImplementation(async () => { + readyCallCount++ + if (readyCallCount === 1) return [task1] + return [] + }) + + vi.mocked(syncService.syncEpicToBeads).mockResolvedValue({ + created: [{ issueId: '100', beadsTaskId: '100', title: 'Task with auth failure' }], + skipped: [], + dependenciesCreated: 0, + }) + + const mockProcess = createMockChildProcess(0) + vi.mocked(execa).mockReturnValue(mockProcess as never) + + // Non-conflict error + vi.mocked(executeGhCommand) + .mockResolvedValueOnce([{ number: 42 }] as never) // PR search + .mockRejectedValueOnce(new Error('Authentication failed')) // non-conflict error + + const result = await supervisor.run(epicLoom) + + expect(result.failedMerges).toBe(1) + expect(result.failed).toBe(1) + // Should NOT have spawned a resolver (only one execa call for the agent) + expect(execa).toHaveBeenCalledTimes(1) + }) + }) + + describe('resume support', () => { + it('should skip completed tasks on resume', async () => { + // Mock list() returning some completed tasks + vi.mocked(beadsManager.list).mockResolvedValue([ + createBeadsTask('100', 'Done task', 'closed'), + createBeadsTask('101', 'Ready task', 'ready'), + ]) + + vi.mocked(syncService.syncEpicToBeads).mockResolvedValue({ + created: [], + skipped: ['100', '101'], + dependenciesCreated: 0, + }) + + // Mock ready() to return only the non-closed task on first call, then none + let readyCallCount = 0 + vi.mocked(beadsManager.ready).mockImplementation(async () => { + readyCallCount++ + if (readyCallCount === 1) return [createBeadsTask('101', 'Ready task')] + return [] + }) + + const mockProcess = createMockChildProcess(0) + vi.mocked(execa).mockReturnValue(mockProcess as never) + vi.mocked(executeGhCommand).mockResolvedValue([] as never) + + const result = await supervisor.run(epicLoom) + + // completed=1 from resume + 1 from agent completing + expect(result.completed).toBe(2) + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('Resuming swarm'), + ) + }) + + it('should release stale in_progress claims on resume', async () => { + vi.mocked(beadsManager.list).mockResolvedValue([ + createBeadsTask('100', 'Stale task', 'in_progress'), + ]) + + vi.mocked(syncService.syncEpicToBeads).mockResolvedValue({ + created: [], + skipped: ['100'], + dependenciesCreated: 0, + }) + + // After claim release, the task shows up as ready + let readyCallCount = 0 + vi.mocked(beadsManager.ready).mockImplementation(async () => { + readyCallCount++ + if (readyCallCount === 1) return [createBeadsTask('100', 'Stale task')] + return [] + }) + + const mockProcess = createMockChildProcess(0) + vi.mocked(execa).mockReturnValue(mockProcess as never) + vi.mocked(executeGhCommand).mockResolvedValue([] as never) + + const result = await supervisor.run(epicLoom) + + // Stale claim should have been released + expect(beadsManager.releaseClaim).toHaveBeenCalledWith('100') + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('Released stale claim on task 100'), + ) + // Task was re-attempted and completed + expect(result.completed).toBe(1) + }) + + it('should not resume when list returns empty', async () => { + vi.mocked(beadsManager.list).mockResolvedValue([]) + + const result = await supervisor.run(epicLoom) + + expect(logger.info).not.toHaveBeenCalledWith( + expect.stringContaining('Resuming swarm'), + ) + expect(result.completed).toBe(0) + }) + + it('should handle list() failure gracefully', async () => { + vi.mocked(beadsManager.list).mockRejectedValue(new Error('DB error')) + + const result = await supervisor.run(epicLoom) + + // Should not crash, just continue without resume expect(result.completed).toBe(0) expect(result.failed).toBe(0) }) + }) - it('should pass parentLoom info when creating child looms', async () => { + describe('progress reporting', () => { + it('should write progress file on state changes', async () => { + vi.mocked(syncService.syncEpicToBeads).mockResolvedValue({ + created: [], + skipped: [], + dependenciesCreated: 0, + }) + + await supervisor.run(epicLoom) + + // Should have written progress at least twice (initial + final) + expect(fs.writeJson).toHaveBeenCalled() + }) + + it('should write correct progress structure', async () => { const task1 = createBeadsTask('100', 'Task 1') let readyCallCount = 0 @@ -496,20 +929,27 @@ describe('SwarmSupervisor', () => { await supervisor.run(epicLoom) - expect(loomManager.createIloom).toHaveBeenCalledWith( - expect.objectContaining({ - parentLoom: { - type: 'issue', - identifier: '50', - branchName: 'feat/epic-50', - worktreePath: '/tmp/worktree/epic-50', - }, - }), - ) + // Get the last call to writeJson to check the final progress + const writeJsonCalls = vi.mocked(fs.writeJson).mock.calls + const lastCall = writeJsonCalls[writeJsonCalls.length - 1] + const progress = lastCall[1] as SwarmProgress + + expect(progress.epicIssue).toBe('50') + expect(progress.epicBranch).toBe('feat/epic-50') + expect(progress.status).toBe('completed') + expect(progress.startedAt).toBeTruthy() + expect(progress.updatedAt).toBeTruthy() + expect(progress.dag).toBeDefined() + expect(progress.stats).toBeDefined() + expect(progress.stats.total).toBe(1) + expect(progress.stats.completed).toBe(1) + expect(progress.failures).toEqual([]) }) - it('should handle alphanumeric issue IDs (e.g., Linear)', async () => { - const task1 = createBeadsTask('ENG-123', 'Linear task') + it('should include failures in progress file', async () => { + settings.maxRetries = 1 + + const task1 = createBeadsTask('100', 'Failing task') let readyCallCount = 0 vi.mocked(beadsManager.ready).mockImplementation(async () => { @@ -519,7 +959,37 @@ describe('SwarmSupervisor', () => { }) vi.mocked(syncService.syncEpicToBeads).mockResolvedValue({ - created: [{ issueId: 'ENG-123', beadsTaskId: 'ENG-123', title: 'Linear task' }], + created: [{ issueId: '100', beadsTaskId: '100', title: 'Failing task' }], + skipped: [], + dependenciesCreated: 0, + }) + + const mockProcess = createMockChildProcess(1) + vi.mocked(execa).mockReturnValue(mockProcess as never) + + await supervisor.run(epicLoom) + + const writeJsonCalls = vi.mocked(fs.writeJson).mock.calls + const lastCall = writeJsonCalls[writeJsonCalls.length - 1] + const progress = lastCall[1] as SwarmProgress + + expect(progress.failures.length).toBe(1) + expect(progress.failures[0].issue).toBe('100') + expect(progress.failures[0].reason).toContain('Agent exited with code 1') + }) + + it('should log progress summary during loop', async () => { + const task1 = createBeadsTask('100', 'Task 1') + + let readyCallCount = 0 + vi.mocked(beadsManager.ready).mockImplementation(async () => { + readyCallCount++ + if (readyCallCount === 1) return [task1] + return [] + }) + + vi.mocked(syncService.syncEpicToBeads).mockResolvedValue({ + created: [{ issueId: '100', beadsTaskId: '100', title: 'Task 1' }], skipped: [], dependenciesCreated: 0, }) @@ -530,13 +1000,50 @@ describe('SwarmSupervisor', () => { await supervisor.run(epicLoom) - expect(loomManager.createIloom).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'issue', - identifier: 'ENG-123', - }), + // Should have logged a progress summary at least once + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('Active:'), ) }) + + it('should set status to failed when all tasks fail', async () => { + settings.maxRetries = 1 + + const task1 = createBeadsTask('100', 'Fail') + + let readyCallCount = 0 + vi.mocked(beadsManager.ready).mockImplementation(async () => { + readyCallCount++ + if (readyCallCount === 1) return [task1] + return [] + }) + + vi.mocked(syncService.syncEpicToBeads).mockResolvedValue({ + created: [{ issueId: '100', beadsTaskId: '100', title: 'Fail' }], + skipped: [], + dependenciesCreated: 0, + }) + + const mockProcess = createMockChildProcess(1) + vi.mocked(execa).mockReturnValue(mockProcess as never) + + await supervisor.run(epicLoom) + + const writeJsonCalls = vi.mocked(fs.writeJson).mock.calls + const lastCall = writeJsonCalls[writeJsonCalls.length - 1] + const progress = lastCall[1] as SwarmProgress + + expect(progress.status).toBe('failed') + }) + + it('should not crash when progress file write fails', async () => { + vi.mocked(fs.writeJson).mockRejectedValue(new Error('disk full')) + + const result = await supervisor.run(epicLoom) + + // Should complete without error + expect(result.duration).toBeGreaterThanOrEqual(0) + }) }) describe('graceful shutdown', () => { diff --git a/src/lib/SwarmSupervisor.ts b/src/lib/SwarmSupervisor.ts index 1ac9e71..ff6d43f 100644 --- a/src/lib/SwarmSupervisor.ts +++ b/src/lib/SwarmSupervisor.ts @@ -1,4 +1,5 @@ import path from 'path' +import os from 'os' import fs from 'fs-extra' import { setTimeout as sleep } from 'timers/promises' import { execa, type ExecaChildProcess } from 'execa' @@ -54,6 +55,62 @@ export interface SwarmResult { duration: number } +/** + * Tracks failure information for a specific task + */ +export interface TaskFailure { + issue: string + reason: string + attempts: number +} + +/** + * Node in the progress DAG + */ +export interface ProgressNode { + issue: string + title: string + status: 'completed' | 'in_progress' | 'blocked' | 'ready' | 'failed' + agentPid: number | null + logFile: string | null + attempts: number + prNumber: number | null + startedAt: string | null + completedAt: string | null +} + +/** + * Edge in the progress DAG + */ +export interface ProgressEdge { + from: string + to: string +} + +/** + * Progress file structure written to disk on every state change + */ +export interface SwarmProgress { + epicIssue: string + epicBranch: string + status: 'running' | 'completed' | 'failed' | 'paused' + startedAt: string + updatedAt: string + dag: { + nodes: ProgressNode[] + edges: ProgressEdge[] + } + stats: { + total: number + completed: number + inProgress: number + failed: number + blocked: number + ready: number + } + failures: TaskFailure[] +} + /** * Orchestrates headless Claude agents working on an epic's child issues. * @@ -61,10 +118,17 @@ export interface SwarmResult { * Spawns `il spin -p` as child processes in minimal worktrees. * Merges PRs sequentially into the epic branch to prevent race conditions. * + * Resilience features: + * - Failure handling with configurable retries + * - Merge conflict resolution via lightweight resolver agents + * - Resume support: detects existing Beads state and picks up where left off + * - Progress reporting: terminal output + JSON progress file + * * Lifecycle: * 1. Init Beads and sync epic children into DAG - * 2. Loop: claim ready tasks, spawn agents, monitor, merge PRs - * 3. Complete when DAG is empty and no agents are running + * 2. Resume check: skip completed, recover dead in-progress tasks + * 3. Loop: claim ready tasks, spawn agents, monitor, merge PRs + * 4. Complete when DAG is empty and no agents are running */ export class SwarmSupervisor { private activeAgents: Map = new Map() @@ -72,6 +136,27 @@ export class SwarmSupervisor { private shuttingDown = false private signalHandlersInstalled = false + /** Tracks how many times each task has been attempted (for retry logic) */ + private taskAttempts: Map = new Map() + /** Tracks how many times conflict resolution has been attempted for a PR */ + private conflictRetries: Map = new Map() + /** Records of all task failures for progress reporting */ + private failures: TaskFailure[] = [] + /** Maps task IDs to their titles for progress reporting */ + private taskTitles: Map = new Map() + /** Maps task IDs to PR numbers discovered during merging */ + private taskPRNumbers: Map = new Map() + /** Maps task IDs to their start times */ + private taskStartTimes: Map = new Map() + /** Maps task IDs to their completion times */ + private taskCompleteTimes: Map = new Map() + /** Tracks which tasks have been permanently failed (exhausted retries) */ + private permanentlyFailed: Set = new Set() + /** Start time for progress reporting */ + private startedAt: string = '' + /** Log directory path */ + private logDir: string = '' + constructor( private readonly beadsManager: BeadsManager, private readonly syncService: BeadsSyncService, @@ -82,11 +167,12 @@ export class SwarmSupervisor { /** * Run the swarm supervisor loop. * - * Orchestrates the full lifecycle: init, sync, claim, spawn, monitor, merge. + * Orchestrates the full lifecycle: init, sync, resume, claim, spawn, monitor, merge. * Returns aggregate stats when all tasks are complete or shutdown is requested. */ async run(epicLoom: EpicLoomContext): Promise { const startTime = Date.now() + this.startedAt = new Date().toISOString() const result: SwarmResult = { totalTasks: 0, completed: 0, @@ -109,23 +195,44 @@ export class SwarmSupervisor { result.totalTasks = syncResult.created.length + syncResult.skipped.length logger.info(`Synced ${result.totalTasks} tasks (${syncResult.created.length} new, ${syncResult.skipped.length} existing)`) + // Store task titles for progress reporting + for (const mapping of syncResult.created) { + this.taskTitles.set(mapping.issueId, mapping.title) + } + // Step 3: Ensure agent-logs directory exists - const logDir = path.join(epicLoom.epicLoomPath, 'agent-logs') - await fs.ensureDir(logDir) + this.logDir = path.join(epicLoom.epicLoomPath, 'agent-logs') + await fs.ensureDir(this.logDir) + + // Step 4: Resume check - recover state from previous run + const resumeResult = await this.resumeFromExistingState(epicLoom, result) + if (resumeResult.resumed) { + logger.info( + `Resuming swarm: ${resumeResult.completed} completed, ${resumeResult.inProgress} in progress, ${resumeResult.remaining} remaining`, + ) + } - // Step 4: Main supervisor loop + // Write initial progress + await this.writeProgress(epicLoom, result, 'running') + + // Step 5: Main supervisor loop while (!this.isComplete()) { - // 4a: Query ready tasks + // 5a: Query ready tasks const readyTasks = await this.beadsManager.ready() - // 4b: Claim and spawn agents for unblocked tasks + // 5b: Claim and spawn agents for unblocked tasks if (!this.shuttingDown) { const slotsAvailable = this.settings.maxConcurrent - this.activeAgents.size const tasksToClaim = readyTasks.slice(0, slotsAvailable) for (const task of tasksToClaim) { + // Skip tasks that have been permanently failed + if (this.permanentlyFailed.has(task.id)) { + continue + } + try { - await this.claimAndSpawnAgent(task, epicLoom, logDir) + await this.claimAndSpawnAgent(task, epicLoom, this.logDir) } catch (error) { logger.error(`Failed to claim/spawn agent for task ${task.id}: ${error instanceof Error ? error.message : 'Unknown error'}`) result.failed++ @@ -133,13 +240,19 @@ export class SwarmSupervisor { } } - // 4c: Check for completed agents + // 5c: Check for completed agents await this.checkCompletedAgents(result, epicLoom) - // 4d: Process merge queue + // 5d: Process merge queue await this.processMergeQueue(result, epicLoom) - // 4e: Check if we're done + // 5e: Log periodic progress summary + this.logProgressSummary(result) + + // 5f: Write progress file + await this.writeProgress(epicLoom, result, 'running') + + // 5g: Check if we're done if (readyTasks.length === 0 && this.activeAgents.size === 0 && this.mergeQueue.length === 0) { break } @@ -156,12 +269,78 @@ export class SwarmSupervisor { } finally { this.removeSignalHandlers() result.duration = Date.now() - startTime + + // Write final progress + const finalStatus = result.failed > 0 && result.completed === 0 ? 'failed' : 'completed' + await this.writeProgress(epicLoom, result, finalStatus) } logger.info(`Swarm complete: ${result.completed} completed, ${result.failed} failed, ${result.mergedPRs} PRs merged`) return result } + /** + * Attempt to resume from existing Beads state. + * + * When the supervisor starts and detects existing Beads state: + * 1. Read all task statuses via `beadsManager.list()` + * 2. Skip tasks marked as `closed` (already completed) + * 3. For tasks `in_progress`: release claim, treat as failure (retry applies) + * 4. For tasks `open`/`ready`: proceed normally + */ + private async resumeFromExistingState( + _epicLoom: EpicLoomContext, + result: SwarmResult, + ): Promise<{ resumed: boolean; completed: number; inProgress: number; remaining: number }> { + let allTasks: BeadsTask[] + try { + allTasks = await this.beadsManager.list() + } catch { + // No existing state to resume from + return { resumed: false, completed: 0, inProgress: 0, remaining: 0 } + } + + if (allTasks.length === 0) { + return { resumed: false, completed: 0, inProgress: 0, remaining: 0 } + } + + let completed = 0 + let inProgress = 0 + let remaining = 0 + + for (const task of allTasks) { + // Store title for progress reporting + if (task.title) { + this.taskTitles.set(task.id, task.title) + } + + if (task.status === 'closed') { + completed++ + result.completed++ + this.taskCompleteTimes.set(task.id, new Date().toISOString()) + } else if (task.status === 'in_progress') { + inProgress++ + // The process from a previous run is dead - release claim and let retry logic handle it + try { + await this.beadsManager.releaseClaim(task.id) + logger.info(`Released stale claim on task ${task.id}`) + } catch (releaseError) { + logger.warn(`Failed to release stale claim for task ${task.id}: ${releaseError instanceof Error ? releaseError.message : 'Unknown error'}`) + } + // Increment attempt counter so retry limit is respected + const currentAttempts = this.taskAttempts.get(task.id) ?? 0 + this.taskAttempts.set(task.id, currentAttempts + 1) + remaining++ + } else { + // open / ready + remaining++ + } + } + + const hasExistingState = completed > 0 || inProgress > 0 + return { resumed: hasExistingState, completed, inProgress, remaining } + } + /** * Claim a Beads task and spawn an agent process for it. */ @@ -170,9 +349,17 @@ export class SwarmSupervisor { epicLoom: EpicLoomContext, logDir: string, ): Promise { + // Track attempt + const attempt = (this.taskAttempts.get(task.id) ?? 0) + 1 + this.taskAttempts.set(task.id, attempt) + this.taskStartTimes.set(task.id, new Date().toISOString()) + // Atomically claim the task await this.beadsManager.claim(task.id) - logger.info(`Claimed task ${task.id}: ${task.title}`) + logger.info(`Claimed task ${task.id}: ${task.title} (attempt ${attempt})`) + + // Store title for progress reporting + this.taskTitles.set(task.id, task.title) // Create child loom (minimal worktree) const loom = await this.loomManager.createIloom({ @@ -235,6 +422,7 @@ export class SwarmSupervisor { /** * Check for completed agent processes and handle their results. + * Includes retry logic for failed agents. */ private async checkCompletedAgents( result: SwarmResult, @@ -254,6 +442,7 @@ export class SwarmSupervisor { const prNumber = await this.findPRForBranch(issueId, epicLoom) if (prNumber) { + this.taskPRNumbers.set(issueId, prNumber) this.mergeQueue.push({ issueId, prNumber, @@ -264,27 +453,66 @@ export class SwarmSupervisor { // Agent succeeded but no PR found - still mark as complete logger.warn(`Agent for issue ${issueId} completed but no PR found, marking task as closed`) await this.closeTask(agent.beadsTaskId, 'completed without PR') + this.taskCompleteTimes.set(issueId, new Date().toISOString()) result.completed++ } } else { - logger.error(`Agent for issue ${issueId} failed with exit code ${agent.exitCode}. See log: ${agent.logFile}`) - result.failed++ - // Release claim so task can be retried (failure handling in #563) - try { - await this.beadsManager.releaseClaim(agent.beadsTaskId) - } catch (releaseError) { - logger.error(`Failed to release claim for task ${agent.beadsTaskId}: ${releaseError instanceof Error ? releaseError.message : 'Unknown error'}`) - } + await this.handleAgentFailure(agent, result, epicLoom) } } } + /** + * Handle a failed agent by releasing its claim and retrying if below maxRetries. + */ + private async handleAgentFailure( + agent: ActiveAgent, + result: SwarmResult, + _epicLoom: EpicLoomContext, + ): Promise { + const issueId = agent.issueId + const attempts = this.taskAttempts.get(issueId) ?? 1 + const failureReason = `Agent exited with code ${agent.exitCode}` + + logger.error(`Agent for issue ${issueId} failed with exit code ${agent.exitCode}. See log: ${agent.logFile}`) + + // Release the Beads claim + try { + await this.beadsManager.releaseClaim(agent.beadsTaskId) + } catch (releaseError) { + logger.error(`Failed to release claim for task ${agent.beadsTaskId}: ${releaseError instanceof Error ? releaseError.message : 'Unknown error'}`) + } + + // Check if we should retry + if (attempts < this.settings.maxRetries) { + logger.info(`Retrying task ${issueId} (attempt ${attempts + 1} of ${this.settings.maxRetries})...`) + // The task's claim was released, so it will appear in ready() on the next loop iteration + // and be re-claimed and re-spawned. The attempt counter is already incremented. + } else { + // Exhausted retries - mark as permanently failed + logger.error(`Task ${issueId} failed after ${attempts} attempt(s). Marking as permanently failed.`) + this.permanentlyFailed.add(issueId) + + // Mark as failed in Beads + await this.closeTask(agent.beadsTaskId, `failed after ${attempts} attempts: ${failureReason}`) + + this.failures.push({ + issue: issueId, + reason: failureReason, + attempts, + }) + + result.failed++ + } + } + /** * Process the merge queue sequentially - one PR at a time. + * Includes conflict detection and resolution. */ private async processMergeQueue( result: SwarmResult, - _epicLoom: EpicLoomContext, + epicLoom: EpicLoomContext, ): Promise { while (this.mergeQueue.length > 0) { const entry = this.mergeQueue.shift() @@ -301,16 +529,149 @@ export class SwarmSupervisor { // Close the issue via GitHub await this.closeIssue(entry.issueId) + this.taskCompleteTimes.set(entry.issueId, new Date().toISOString()) + this.conflictRetries.delete(entry.issueId) result.mergedPRs++ result.completed++ } catch (error) { - logger.error(`Failed to merge PR #${entry.prNumber} for issue ${entry.issueId}: ${error instanceof Error ? error.message : 'Unknown error'}`) + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + + // Check if this is a merge conflict + if (this.isMergeConflict(errorMessage)) { + await this.handleMergeConflict(entry, result, epicLoom) + } else { + logger.error(`Failed to merge PR #${entry.prNumber} for issue ${entry.issueId}: ${errorMessage}`) + result.failedMerges++ + result.failed++ + this.failures.push({ + issue: entry.issueId, + reason: `Merge failed: ${errorMessage}`, + attempts: this.taskAttempts.get(entry.issueId) ?? 1, + }) + } + } + } + } + + /** + * Detect whether a merge error is a conflict. + */ + private isMergeConflict(errorMessage: string): boolean { + const conflictPatterns = [ + 'merge conflict', + 'CONFLICT', + 'could not merge', + 'not possible to fast-forward', + 'conflicts', + ] + const lowerMessage = errorMessage.toLowerCase() + return conflictPatterns.some(pattern => lowerMessage.includes(pattern.toLowerCase())) + } + + /** + * Handle a merge conflict by spawning a resolver agent. + * + * 1. Detect merge failure + * 2. Spawn a lightweight Claude Code agent to rebase and resolve + * 3. Retry merge after resolution + * 4. If still failing after maxConflictRetries, mark as failed + */ + private async handleMergeConflict( + entry: MergeQueueEntry, + result: SwarmResult, + epicLoom: EpicLoomContext, + ): Promise { + const retries = this.conflictRetries.get(entry.issueId) ?? 0 + + if (retries >= this.settings.maxConflictRetries) { + logger.error( + `Merge conflict for PR #${entry.prNumber} (issue ${entry.issueId}) could not be resolved after ${retries} attempt(s). Marking as failed.`, + ) + result.failedMerges++ + result.failed++ + this.permanentlyFailed.add(entry.issueId) + this.failures.push({ + issue: entry.issueId, + reason: `Unresolvable merge conflict after ${retries} resolution attempts`, + attempts: this.taskAttempts.get(entry.issueId) ?? 1, + }) + return + } + + this.conflictRetries.set(entry.issueId, retries + 1) + logger.info(`Merge conflict detected for PR #${entry.prNumber}. Spawning resolver (attempt ${retries + 1} of ${this.settings.maxConflictRetries})...`) + + try { + await this.spawnConflictResolver(entry, epicLoom) + + // Retry the merge after conflict resolution + logger.info(`Retrying merge for PR #${entry.prNumber} after conflict resolution...`) + await this.mergePR(entry.prNumber) + logger.info(`Successfully merged PR #${entry.prNumber} after conflict resolution`) + + await this.closeTask(entry.beadsTaskId, `merged PR #${entry.prNumber} (after conflict resolution)`) + await this.closeIssue(entry.issueId) + + this.taskCompleteTimes.set(entry.issueId, new Date().toISOString()) + this.conflictRetries.delete(entry.issueId) + result.mergedPRs++ + result.completed++ + } catch (retryError) { + const retryMessage = retryError instanceof Error ? retryError.message : 'Unknown error' + + if (this.isMergeConflict(retryMessage)) { + // Still conflicting - re-enqueue for another attempt + logger.warn(`PR #${entry.prNumber} still has conflicts after resolution attempt ${retries + 1}`) + this.mergeQueue.push(entry) + } else { + logger.error(`Merge failed for PR #${entry.prNumber} after conflict resolution: ${retryMessage}`) result.failedMerges++ result.failed++ + this.failures.push({ + issue: entry.issueId, + reason: `Merge failed after conflict resolution: ${retryMessage}`, + attempts: this.taskAttempts.get(entry.issueId) ?? 1, + }) } } } + /** + * Spawn a lightweight Claude Code agent to resolve merge conflicts. + * + * The resolver rebases the branch onto the epic branch, resolves conflicts, + * and force-pushes. + */ + private async spawnConflictResolver( + entry: MergeQueueEntry, + epicLoom: EpicLoomContext, + ): Promise { + // Find the worktree for this issue's agent + const agent = this.activeAgents.get(entry.issueId) + const cwd = agent?.loomPath ?? epicLoom.epicLoomPath + + const resolverProcess = execa('il', ['spin', '-p'], { + cwd, + env: { + ...process.env, + ILOOM_SWARM_MODE: '1', + ILOOM_EPIC_BRANCH: epicLoom.epicBranch, + ILOOM_EPIC_ISSUE: epicLoom.epicIssueNumber, + ILOOM_CONFLICT_RESOLUTION: '1', + ILOOM_CONFLICT_PR: String(entry.prNumber), + }, + reject: false, + all: true, + timeout: 300000, // 5 minute timeout for conflict resolution + }) + + const resolverResult = await resolverProcess + + if (resolverResult.exitCode !== 0) { + throw new Error(`Conflict resolver exited with code ${resolverResult.exitCode}`) + } + } + /** * Merge a PR into the epic branch using gh CLI. */ @@ -399,6 +760,102 @@ export class SwarmSupervisor { return isNaN(numericId) ? taskId : numericId } + /** + * Log a periodic progress summary to the terminal. + */ + private logProgressSummary(result: SwarmResult): void { + logger.info( + `Active: ${this.activeAgents.size}/${this.settings.maxConcurrent} | Completed: ${result.completed}/${result.totalTasks} | Failed: ${result.failed} | Blocked: ${this.permanentlyFailed.size}`, + ) + } + + /** + * Write a progress file to disk. Called on every state change. + * + * Written to ~/.config/iloom-ai/looms//swarm-progress.json + * where epicLoomId is derived from the epicLoomPath directory name. + */ + private async writeProgress( + epicLoom: EpicLoomContext, + result: SwarmResult, + status: 'running' | 'completed' | 'failed' | 'paused', + ): Promise { + try { + const progressDir = this.getProgressDir(epicLoom) + await fs.ensureDir(progressDir) + + const progressFile = path.join(progressDir, 'swarm-progress.json') + + // Build DAG nodes from all known tasks + const nodes: ProgressNode[] = [] + for (const [taskId, title] of this.taskTitles) { + const agent = this.activeAgents.get(taskId) + let taskStatus: ProgressNode['status'] = 'ready' + + if (this.taskCompleteTimes.has(taskId)) { + taskStatus = 'completed' + } else if (this.permanentlyFailed.has(taskId)) { + taskStatus = 'failed' + } else if (agent) { + taskStatus = 'in_progress' + } else if (this.mergeQueue.some(e => e.issueId === taskId)) { + taskStatus = 'in_progress' + } + + nodes.push({ + issue: taskId, + title, + status: taskStatus, + agentPid: agent?.pid ?? null, + logFile: agent?.logFile ?? (this.logDir ? path.join(this.logDir, `${taskId}.log`) : null), + attempts: this.taskAttempts.get(taskId) ?? 0, + prNumber: this.taskPRNumbers.get(taskId) ?? null, + startedAt: this.taskStartTimes.get(taskId) ?? null, + completedAt: this.taskCompleteTimes.get(taskId) ?? null, + }) + } + + const inProgressCount = this.activeAgents.size + this.mergeQueue.length + const failedCount = this.permanentlyFailed.size + const completedCount = result.completed + const readyCount = Math.max(0, result.totalTasks - completedCount - inProgressCount - failedCount) + + const progress: SwarmProgress = { + epicIssue: epicLoom.epicIssueNumber, + epicBranch: epicLoom.epicBranch, + status, + startedAt: this.startedAt, + updatedAt: new Date().toISOString(), + dag: { + nodes, + edges: [], // Edges could be populated from Beads dependency data in the future + }, + stats: { + total: result.totalTasks, + completed: completedCount, + inProgress: inProgressCount, + failed: failedCount, + blocked: 0, // Tasks blocked by failed dependencies + ready: readyCount, + }, + failures: [...this.failures], + } + + await fs.writeJson(progressFile, progress, { spaces: 2 }) + } catch (error) { + // Progress file writing should never fail the swarm + logger.debug(`Failed to write progress file: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + } + + /** + * Get the progress directory for this epic. + */ + private getProgressDir(epicLoom: EpicLoomContext): string { + const loomId = path.basename(epicLoom.epicLoomPath) + return path.join(os.homedir(), '.config', 'iloom-ai', 'looms', loomId) + } + /** * Install signal handlers for graceful shutdown. */ From 12338f9f9293d54dc7899c6cb37603eb34b50438 Mon Sep 17 00:00:00 2001 From: Adam Creeger Date: Thu, 5 Feb 2026 19:52:44 -0500 Subject: [PATCH 7/9] feat(swarm): wire swarm supervisor into il start, add integration tests and docs (#564) --- README.md | 28 ++ docs/iloom-commands.md | 32 ++ src/commands/start-swarm.test.ts | 640 +++++++++++++++++++++++++++++++ src/commands/start.ts | 111 ++++++ 4 files changed, 811 insertions(+) create mode 100644 src/commands/start-swarm.test.ts diff --git a/README.md b/README.md index c029d66..409df21 100644 --- a/README.md +++ b/README.md @@ -528,6 +528,34 @@ il plan --yolo "Add GitLab integration" See the [Complete Command Reference](docs/iloom-commands.md#il-plan) for all options including `--model`, `--planner`, and `--reviewer` flags. +### Swarm Mode (Autonomous Epic Execution) + +For issues labeled `iloom-epic`, `il start` can enter swarm mode -- launching multiple Claude agents in parallel to work through all child issues automatically. + +```bash +# Start swarm on an epic (interactive confirmation) +il start 50 + +# Skip confirmation with --swarm flag +il start 50 --swarm + +# Limit concurrent agents +il start 50 --swarm --max-agents 5 +``` + +Swarm mode uses the [Beads](https://github.com/steveyegge/beads) DAG engine for dependency-aware task ordering and atomic claiming. Each child issue gets its own minimal worktree. PRs are merged sequentially into the epic's integration branch to prevent conflicts. + +Configure via `.iloom.yml`: + +```yaml +swarm: + maxConcurrent: 3 # Default: 3 + maxRetries: 1 # Default: 1 + autoInstallBeads: false +``` + +See the [Complete Command Reference](docs/iloom-commands.md#il-start) for details. + System Requirements & Limitations --------------------------------- diff --git a/docs/iloom-commands.md b/docs/iloom-commands.md index d2d7ae1..bf9e747 100644 --- a/docs/iloom-commands.md +++ b/docs/iloom-commands.md @@ -70,6 +70,8 @@ il start "" | `--dev-server` / `--no-dev-server` | - | Enable/disable dev server in terminal (default: enabled) | | `--terminal` / `--no-terminal` | - | Enable/disable terminal without dev server (default: disabled) | | `--body` | `` | Body text for issue (skips AI enhancement) | +| `--swarm` | - | Bypass epic confirmation and start swarm mode immediately | +| `--max-agents` | `` | Maximum concurrent agents for swarm mode (overrides `swarm.maxConcurrent` setting) | **One-Shot Modes:** - `default` - Standard behavior with approval prompts at each phase @@ -113,6 +115,36 @@ il start 42 --child-loom # Create independent loom even when inside another loom il start 99 --no-child-loom + +# Start swarm mode on an epic (skip confirmation prompt) +il start 50 --swarm + +# Start swarm with custom concurrency +il start 50 --swarm --max-agents 5 +``` + +**Swarm Mode:** + +When `il start` is run on an issue with the `iloom-epic` label, it detects the issue as an epic and offers to enter swarm mode. In swarm mode, iloom: + +1. Creates an integration branch (epic loom) with no interactive components +2. Initializes the Beads DAG engine to track child issue dependencies +3. Syncs all child issues and their dependency graph into the DAG +4. Launches a supervisor that concurrently spawns Claude agents for ready tasks +5. Merges completed PRs sequentially into the epic branch +6. Reports aggregate results on completion + +The `--swarm` flag skips the confirmation prompt for automated workflows. The `--max-agents` flag overrides the `swarm.maxConcurrent` setting. In non-interactive environments, `--swarm` is required to enter swarm mode. In JSON mode (`--json`), the command returns the epic loom metadata without running the supervisor. + +Swarm behavior is configured via the `swarm` section in `.iloom.yml`: + +```yaml +swarm: + maxConcurrent: 3 # Max concurrent agents (default: 3) + maxRetries: 1 # Retries per failed task (default: 1) + maxConflictRetries: 3 # Merge conflict resolution retries (default: 3) + beadsDir: ~/.config/iloom-ai/beads # Beads state directory + autoInstallBeads: false # Auto-install Beads CLI without prompting ``` **Notes:** diff --git a/src/commands/start-swarm.test.ts b/src/commands/start-swarm.test.ts new file mode 100644 index 0000000..24f4527 --- /dev/null +++ b/src/commands/start-swarm.test.ts @@ -0,0 +1,640 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { StartCommand } from './start.js' +import { GitHubService } from '../lib/GitHubService.js' +import { LoomManager } from '../lib/LoomManager.js' +import { SettingsManager } from '../lib/SettingsManager.js' +import { BeadsManager, BeadsError } from '../lib/BeadsManager.js' +import { SwarmSupervisor } from '../lib/SwarmSupervisor.js' +import { findMainWorktreePathWithSettings } from '../utils/git.js' + +// Mock all external dependencies +vi.mock('../lib/GitHubService.js') +vi.mock('../lib/LoomManager.js', () => ({ + LoomManager: vi.fn(() => ({ + createIloom: vi.fn().mockResolvedValue({ + id: 'epic-loom-100', + path: '/test/worktrees/issue-100', + branch: 'epic/issue-100', + type: 'issue', + identifier: 100, + port: 3100, + createdAt: new Date(), + issueData: { title: 'Epic: Build feature X' }, + }), + listLooms: vi.fn().mockResolvedValue([]), + })), +})) +vi.mock('../lib/GitWorktreeManager.js') +vi.mock('../lib/EnvironmentManager.js') +vi.mock('../lib/ClaudeContextManager.js') +vi.mock('../lib/AgentManager.js') +vi.mock('../lib/SettingsManager.js', () => ({ + SettingsManager: vi.fn(() => ({ + loadSettings: vi.fn().mockResolvedValue({}), + })), +})) +vi.mock('../lib/BeadsManager.js', () => { + const BeadsError = class extends Error { + constructor( + message: string, + public readonly exitCode: number | undefined, + public readonly stderr: string, + ) { + super(message) + this.name = 'BeadsError' + } + } + return { + BeadsManager: vi.fn(() => ({ + ensureInstalled: vi.fn().mockResolvedValue(undefined), + init: vi.fn().mockResolvedValue(undefined), + isInstalled: vi.fn().mockResolvedValue(true), + ready: vi.fn().mockResolvedValue([]), + list: vi.fn().mockResolvedValue([]), + create: vi.fn().mockResolvedValue('task-1'), + claim: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + releaseClaim: vi.fn().mockResolvedValue(undefined), + addDependency: vi.fn().mockResolvedValue(undefined), + getBeadsDir: vi.fn().mockReturnValue('/test/beads'), + })), + BeadsError, + } +}) +vi.mock('../lib/BeadsSyncService.js', () => ({ + BeadsSyncService: vi.fn(() => ({ + syncEpicToBeads: vi.fn().mockResolvedValue({ + created: [], + skipped: [], + dependenciesCreated: 0, + }), + })), +})) +vi.mock('../lib/SwarmSupervisor.js', () => ({ + SwarmSupervisor: vi.fn(() => ({ + run: vi.fn().mockResolvedValue({ + totalTasks: 3, + completed: 3, + failed: 0, + mergedPRs: 3, + failedMerges: 0, + duration: 60000, + }), + })), +})) + +vi.mock('../utils/git.js', async () => { + const actual = await vi.importActual('../utils/git.js') + return { + ...actual, + branchExists: vi.fn().mockResolvedValue(false), + findMainWorktreePathWithSettings: vi.fn().mockResolvedValue('/test/main'), + executeGitCommand: vi.fn().mockResolvedValue(''), + } +}) +vi.mock('../utils/remote.js', () => ({ + hasMultipleRemotes: vi.fn().mockResolvedValue(false), + getConfiguredRepoFromSettings: vi.fn().mockResolvedValue('owner/repo'), + parseGitRemotes: vi.fn().mockResolvedValue([]), + validateConfiguredRemote: vi.fn().mockResolvedValue(undefined), +})) +vi.mock('../utils/claude.js', () => ({ + launchClaude: vi.fn().mockResolvedValue('Enhanced description'), +})) +vi.mock('../utils/browser.js', () => ({ + openBrowser: vi.fn().mockResolvedValue(undefined), +})) +vi.mock('../utils/prompt.js', () => ({ + waitForKeypress: vi.fn().mockResolvedValue('a'), + promptInput: vi.fn(), + promptConfirmation: vi.fn().mockResolvedValue(true), + isInteractiveEnvironment: vi.fn().mockReturnValue(true), +})) +vi.mock('../utils/first-run-setup.js', () => ({ + needsFirstRunSetup: vi.fn().mockResolvedValue(false), + launchFirstRunSetup: vi.fn().mockResolvedValue(undefined), +})) +vi.mock('../utils/logger.js', () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + success: vi.fn(), + }, + createLogger: vi.fn(() => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + success: vi.fn(), + })), +})) +vi.mock('../mcp/IssueManagementProviderFactory.js', () => ({ + IssueManagementProviderFactory: { + create: vi.fn().mockReturnValue({ + getChildIssues: vi.fn().mockResolvedValue([]), + getDependencies: vi.fn().mockResolvedValue({ blocking: [], blockedBy: [] }), + }), + }, +})) +vi.mock('../lib/EpicDetector.js', () => ({ + EpicDetector: vi.fn(() => ({ + detect: vi.fn().mockResolvedValue({ + isEpic: true, + totalChildren: 3, + readyChildren: 2, + blockedChildren: 1, + hasDependencies: true, + }), + })), +})) + +/** + * Helper to create a mock issue with the iloom-epic label + */ +function createMockEpicIssue(number: number) { + return { + number, + title: 'Epic: Build feature X', + body: 'Epic description', + state: 'open' as const, + labels: ['iloom-epic'], + assignees: [], + url: `https://github.com/owner/repo/issues/${number}`, + } +} + +/** + * Helper to create a mock non-epic issue + */ +function createMockIssue(number: number) { + return { + number, + title: 'Regular issue', + body: 'Issue description', + state: 'open' as const, + labels: [], + assignees: [], + url: `https://github.com/owner/repo/issues/${number}`, + } +} + +describe('StartCommand - Swarm Mode Integration', () => { + let mockGitHubService: GitHubService + let mockLoomManager: { createIloom: ReturnType; listLooms: ReturnType } + let mockSettingsManager: { loadSettings: ReturnType } + + beforeEach(() => { + mockGitHubService = new GitHubService() + mockGitHubService.supportsPullRequests = true + mockGitHubService.providerName = 'github' + + mockLoomManager = new LoomManager() as unknown as typeof mockLoomManager + mockSettingsManager = new SettingsManager() as unknown as typeof mockSettingsManager + mockSettingsManager.loadSettings = vi.fn().mockResolvedValue({}) + + // Re-setup mocks that get cleared by mockReset + vi.mocked(findMainWorktreePathWithSettings).mockResolvedValue('/test/main') + }) + + describe('epic detection triggers swarm flow', () => { + it('should detect epic, confirm, create loom, and run supervisor', async () => { + // Set up epic issue + const epicIssue = createMockEpicIssue(100) + vi.mocked(mockGitHubService.detectInputType).mockResolvedValue({ + type: 'issue', + number: 100, + rawInput: '100', + }) + vi.mocked(mockGitHubService.fetchIssue).mockResolvedValue(epicIssue) + vi.mocked(mockGitHubService.validateIssueState).mockResolvedValue() + + // Use --swarm to bypass interactive confirmation + const command = new StartCommand( + mockGitHubService, + mockLoomManager as unknown as LoomManager, + undefined, + mockSettingsManager as unknown as SettingsManager, + ) + + await command.execute({ + identifier: '100', + options: { swarm: true }, + }) + + // Verify epic loom was created + expect(mockLoomManager.createIloom).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ + isEpic: true, + swarmStatus: 'pending', + enableClaude: false, + enableCode: false, + }), + }), + ) + + // Verify BeadsManager was constructed and ensureInstalled called + expect(BeadsManager).toHaveBeenCalledWith('/test/main', expect.objectContaining({ + maxConcurrent: 3, + })) + + // Verify SwarmSupervisor was constructed and run called + expect(SwarmSupervisor).toHaveBeenCalled() + const supervisorInstance = vi.mocked(SwarmSupervisor).mock.results[0]?.value + expect(supervisorInstance.run).toHaveBeenCalledWith( + expect.objectContaining({ + epicIssueNumber: '100', + epicBranch: 'epic/issue-100', + epicLoomPath: '/test/worktrees/issue-100', + projectPath: '/test/main', + }), + ) + }) + + it('should skip swarm and run normally when --swarm on non-epic issue', async () => { + const normalIssue = createMockIssue(200) + vi.mocked(mockGitHubService.detectInputType).mockResolvedValue({ + type: 'issue', + number: 200, + rawInput: '200', + }) + vi.mocked(mockGitHubService.fetchIssue).mockResolvedValue(normalIssue) + vi.mocked(mockGitHubService.validateIssueState).mockResolvedValue() + + const command = new StartCommand( + mockGitHubService, + mockLoomManager as unknown as LoomManager, + undefined, + mockSettingsManager as unknown as SettingsManager, + ) + + await command.execute({ + identifier: '200', + options: { swarm: true }, + }) + + // Normal loom creation should happen (not epic) + expect(mockLoomManager.createIloom).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.not.objectContaining({ + isEpic: true, + }), + }), + ) + + // SwarmSupervisor should NOT be called + expect(SwarmSupervisor).not.toHaveBeenCalled() + }) + }) + + describe('--swarm flag bypasses confirmation', () => { + it('should skip confirmation when --swarm is passed', async () => { + const epicIssue = createMockEpicIssue(100) + vi.mocked(mockGitHubService.detectInputType).mockResolvedValue({ + type: 'issue', + number: 100, + rawInput: '100', + }) + vi.mocked(mockGitHubService.fetchIssue).mockResolvedValue(epicIssue) + vi.mocked(mockGitHubService.validateIssueState).mockResolvedValue() + + const { promptConfirmation } = await import('../utils/prompt.js') + + const command = new StartCommand( + mockGitHubService, + mockLoomManager as unknown as LoomManager, + undefined, + mockSettingsManager as unknown as SettingsManager, + ) + + await command.execute({ + identifier: '100', + options: { swarm: true }, + }) + + // Confirmation should NOT be called when --swarm is set + expect(promptConfirmation).not.toHaveBeenCalled() + + // Supervisor should still run + expect(SwarmSupervisor).toHaveBeenCalled() + }) + }) + + describe('--max-agents override', () => { + it('should pass --max-agents override to SwarmSupervisor settings', async () => { + const epicIssue = createMockEpicIssue(100) + vi.mocked(mockGitHubService.detectInputType).mockResolvedValue({ + type: 'issue', + number: 100, + rawInput: '100', + }) + vi.mocked(mockGitHubService.fetchIssue).mockResolvedValue(epicIssue) + vi.mocked(mockGitHubService.validateIssueState).mockResolvedValue() + + const command = new StartCommand( + mockGitHubService, + mockLoomManager as unknown as LoomManager, + undefined, + mockSettingsManager as unknown as SettingsManager, + ) + + await command.execute({ + identifier: '100', + options: { swarm: true, maxAgents: 5 }, + }) + + // SwarmSupervisor should be constructed with maxConcurrent: 5 + expect(SwarmSupervisor).toHaveBeenCalledWith( + expect.anything(), // beadsManager + expect.anything(), // syncService + expect.anything(), // loomManager + expect.objectContaining({ + maxConcurrent: 5, + }), + ) + }) + + it('should use settings maxConcurrent when --max-agents not provided', async () => { + const epicIssue = createMockEpicIssue(100) + vi.mocked(mockGitHubService.detectInputType).mockResolvedValue({ + type: 'issue', + number: 100, + rawInput: '100', + }) + vi.mocked(mockGitHubService.fetchIssue).mockResolvedValue(epicIssue) + vi.mocked(mockGitHubService.validateIssueState).mockResolvedValue() + + // Configure swarm settings + mockSettingsManager.loadSettings.mockResolvedValue({ + swarm: { + maxConcurrent: 7, + maxRetries: 2, + maxConflictRetries: 4, + }, + }) + + const command = new StartCommand( + mockGitHubService, + mockLoomManager as unknown as LoomManager, + undefined, + mockSettingsManager as unknown as SettingsManager, + ) + + await command.execute({ + identifier: '100', + options: { swarm: true }, + }) + + expect(SwarmSupervisor).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + expect.objectContaining({ + maxConcurrent: 7, + maxRetries: 2, + maxConflictRetries: 4, + }), + ) + }) + }) + + describe('non-epic issue is unaffected', () => { + it('should proceed with normal flow for non-epic issues', async () => { + const normalIssue = createMockIssue(200) + vi.mocked(mockGitHubService.detectInputType).mockResolvedValue({ + type: 'issue', + number: 200, + rawInput: '200', + }) + vi.mocked(mockGitHubService.fetchIssue).mockResolvedValue(normalIssue) + vi.mocked(mockGitHubService.validateIssueState).mockResolvedValue() + + const command = new StartCommand( + mockGitHubService, + mockLoomManager as unknown as LoomManager, + undefined, + mockSettingsManager as unknown as SettingsManager, + ) + + await command.execute({ + identifier: '200', + options: {}, + }) + + // Normal loom creation (not epic) + expect(mockLoomManager.createIloom).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ + enableClaude: true, + enableCode: true, + }), + }), + ) + + // No swarm components involved + expect(BeadsManager).not.toHaveBeenCalled() + expect(SwarmSupervisor).not.toHaveBeenCalled() + }) + }) + + describe('error handling', () => { + it('should propagate BeadsError when Beads install fails', async () => { + const epicIssue = createMockEpicIssue(100) + vi.mocked(mockGitHubService.detectInputType).mockResolvedValue({ + type: 'issue', + number: 100, + rawInput: '100', + }) + vi.mocked(mockGitHubService.fetchIssue).mockResolvedValue(epicIssue) + vi.mocked(mockGitHubService.validateIssueState).mockResolvedValue() + + // Make BeadsManager.ensureInstalled throw + const MockedBeadsManager = vi.mocked(BeadsManager) + MockedBeadsManager.mockImplementationOnce(() => ({ + ensureInstalled: vi.fn().mockRejectedValue( + new BeadsError('Beads CLI is required for swarm mode', undefined, 'User declined installation'), + ), + init: vi.fn(), + isInstalled: vi.fn(), + ready: vi.fn(), + list: vi.fn(), + create: vi.fn(), + claim: vi.fn(), + close: vi.fn(), + releaseClaim: vi.fn(), + addDependency: vi.fn(), + getBeadsDir: vi.fn().mockReturnValue('/test/beads'), + }) as unknown as BeadsManager) + + const command = new StartCommand( + mockGitHubService, + mockLoomManager as unknown as LoomManager, + undefined, + mockSettingsManager as unknown as SettingsManager, + ) + + await expect( + command.execute({ + identifier: '100', + options: { swarm: true }, + }), + ).rejects.toThrow('Beads CLI is required for swarm mode') + }) + + it('should propagate error when supervisor crashes', async () => { + const epicIssue = createMockEpicIssue(100) + vi.mocked(mockGitHubService.detectInputType).mockResolvedValue({ + type: 'issue', + number: 100, + rawInput: '100', + }) + vi.mocked(mockGitHubService.fetchIssue).mockResolvedValue(epicIssue) + vi.mocked(mockGitHubService.validateIssueState).mockResolvedValue() + + // Make supervisor.run throw + const MockedSupervisor = vi.mocked(SwarmSupervisor) + MockedSupervisor.mockImplementationOnce(() => ({ + run: vi.fn().mockRejectedValue(new Error('Supervisor crashed unexpectedly')), + }) as unknown as SwarmSupervisor) + + const command = new StartCommand( + mockGitHubService, + mockLoomManager as unknown as LoomManager, + undefined, + mockSettingsManager as unknown as SettingsManager, + ) + + await expect( + command.execute({ + identifier: '100', + options: { swarm: true }, + }), + ).rejects.toThrow('Supervisor crashed unexpectedly') + }) + + it('should set exit code 1 when all tasks fail', async () => { + const epicIssue = createMockEpicIssue(100) + vi.mocked(mockGitHubService.detectInputType).mockResolvedValue({ + type: 'issue', + number: 100, + rawInput: '100', + }) + vi.mocked(mockGitHubService.fetchIssue).mockResolvedValue(epicIssue) + vi.mocked(mockGitHubService.validateIssueState).mockResolvedValue() + + // Supervisor returns all-failed result + const MockedSupervisor = vi.mocked(SwarmSupervisor) + MockedSupervisor.mockImplementationOnce(() => ({ + run: vi.fn().mockResolvedValue({ + totalTasks: 3, + completed: 0, + failed: 3, + mergedPRs: 0, + failedMerges: 0, + duration: 30000, + }), + }) as unknown as SwarmSupervisor) + + const originalExitCode = process.exitCode + + const command = new StartCommand( + mockGitHubService, + mockLoomManager as unknown as LoomManager, + undefined, + mockSettingsManager as unknown as SettingsManager, + ) + + await command.execute({ + identifier: '100', + options: { swarm: true }, + }) + + expect(process.exitCode).toBe(1) + + // Restore + process.exitCode = originalExitCode + }) + + it('should not set exit code 1 when some tasks succeed', async () => { + const epicIssue = createMockEpicIssue(100) + vi.mocked(mockGitHubService.detectInputType).mockResolvedValue({ + type: 'issue', + number: 100, + rawInput: '100', + }) + vi.mocked(mockGitHubService.fetchIssue).mockResolvedValue(epicIssue) + vi.mocked(mockGitHubService.validateIssueState).mockResolvedValue() + + // Supervisor returns partial success + const MockedSupervisor = vi.mocked(SwarmSupervisor) + MockedSupervisor.mockImplementationOnce(() => ({ + run: vi.fn().mockResolvedValue({ + totalTasks: 3, + completed: 2, + failed: 1, + mergedPRs: 2, + failedMerges: 0, + duration: 45000, + }), + }) as unknown as SwarmSupervisor) + + const originalExitCode = process.exitCode + process.exitCode = undefined + + const command = new StartCommand( + mockGitHubService, + mockLoomManager as unknown as LoomManager, + undefined, + mockSettingsManager as unknown as SettingsManager, + ) + + await command.execute({ + identifier: '100', + options: { swarm: true }, + }) + + // Should not set exitCode to 1 when some tasks completed + expect(process.exitCode).toBeUndefined() + + // Restore + process.exitCode = originalExitCode + }) + }) + + describe('JSON mode with swarm', () => { + it('should return StartResult in JSON mode without running supervisor', async () => { + const epicIssue = createMockEpicIssue(100) + vi.mocked(mockGitHubService.detectInputType).mockResolvedValue({ + type: 'issue', + number: 100, + rawInput: '100', + }) + vi.mocked(mockGitHubService.fetchIssue).mockResolvedValue(epicIssue) + vi.mocked(mockGitHubService.validateIssueState).mockResolvedValue() + + const command = new StartCommand( + mockGitHubService, + mockLoomManager as unknown as LoomManager, + undefined, + mockSettingsManager as unknown as SettingsManager, + ) + + const result = await command.execute({ + identifier: '100', + options: { swarm: true, json: true }, + }) + + // Should return StartResult + expect(result).toEqual(expect.objectContaining({ + id: 'epic-loom-100', + isEpic: true, + swarmStatus: 'pending', + })) + + // Supervisor should NOT be called in JSON mode + expect(SwarmSupervisor).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/commands/start.ts b/src/commands/start.ts index 84576d0..72c462a 100644 --- a/src/commands/start.ts +++ b/src/commands/start.ts @@ -10,8 +10,13 @@ import { ClaudeContextManager } from '../lib/ClaudeContextManager.js' import { ProjectCapabilityDetector } from '../lib/ProjectCapabilityDetector.js' import { CLIIsolationManager } from '../lib/CLIIsolationManager.js' import { SettingsManager } from '../lib/SettingsManager.js' +import type { SwarmSettings } from '../lib/SettingsManager.js' import { AgentManager } from '../lib/AgentManager.js' import { DatabaseManager } from '../lib/DatabaseManager.js' +import { BeadsManager } from '../lib/BeadsManager.js' +import { BeadsSyncService } from '../lib/BeadsSyncService.js' +import { SwarmSupervisor } from '../lib/SwarmSupervisor.js' +import type { SwarmResult } from '../lib/SwarmSupervisor.js' import { findMainWorktreePathWithSettings } from '../utils/git.js' import { matchIssueIdentifier } from '../utils/IdentifierParser.js' import { loadEnvIntoProcess } from '../utils/env.js' @@ -326,6 +331,21 @@ export class StartCommand { swarmStatus: 'pending', } } + + // Step 5: Wire up swarm supervisor and run + const swarmResult = await this.runSwarmSupervisor( + loom, + settings, + input.options, + loomManager, + ) + + // Step 6: Report results and set exit code + this.reportSwarmResult(swarmResult) + + if (swarmResult.failed > 0 && swarmResult.completed === 0) { + process.exitCode = 1 + } } else { // Normal loom creation // Apply configuration precedence: CLI flags > workflow config > defaults (true) @@ -718,6 +738,97 @@ export class StartCommand { } } + /** + * Wire up and run the SwarmSupervisor with all dependencies. + * + * Initializes BeadsManager, syncs epic children via BeadsSyncService, + * and launches the supervisor loop as a foreground process. + * + * @param loom - The created epic loom + * @param settings - Loaded iloom settings + * @param options - Start command options (for --max-agents override) + * @param loomManager - LoomManager instance for child loom creation + * @returns SwarmResult with completion stats + */ + private async runSwarmSupervisor( + loom: import('../types/loom.js').Loom, + settings: import('../lib/SettingsManager.js').IloomSettings, + options: StartOptions, + loomManager: LoomManager, + ): Promise { + const mainWorktreePath = await findMainWorktreePathWithSettings() + + // Build SwarmSettings with --max-agents override + const swarmSettings: SwarmSettings = { + maxConcurrent: options.maxAgents ?? settings.swarm?.maxConcurrent ?? 3, + maxRetries: settings.swarm?.maxRetries ?? 1, + maxConflictRetries: settings.swarm?.maxConflictRetries ?? 3, + beadsDir: settings.swarm?.beadsDir ?? '~/.config/iloom-ai/beads', + autoInstallBeads: settings.swarm?.autoInstallBeads ?? false, + } + + // Initialize BeadsManager + const beadsManager = new BeadsManager(mainWorktreePath, swarmSettings) + + // Ensure Beads CLI is installed + getLogger().info('Checking Beads CLI availability...') + await beadsManager.ensureInstalled(swarmSettings.autoInstallBeads) + + // Create issue management provider for BeadsSyncService + const providerName = settings.issueManagement?.provider ?? 'github' + const issueProvider = IssueManagementProviderFactory.create(providerName) + const syncService = new BeadsSyncService(beadsManager, issueProvider) + + // Create SwarmSupervisor + const supervisor = new SwarmSupervisor( + beadsManager, + syncService, + loomManager, + swarmSettings, + ) + + // Run the supervisor loop + getLogger().info(`Starting swarm supervisor (max ${swarmSettings.maxConcurrent} concurrent agents)...`) + return supervisor.run({ + epicIssueNumber: String(loom.identifier), + epicBranch: loom.branch, + epicLoomPath: loom.path, + projectPath: mainWorktreePath, + }) + } + + /** + * Report swarm completion results to the user. + */ + private reportSwarmResult(result: SwarmResult): void { + const durationSeconds = Math.round(result.duration / 1000) + const durationMinutes = Math.floor(durationSeconds / 60) + const remainingSeconds = durationSeconds % 60 + const durationStr = durationMinutes > 0 + ? `${durationMinutes}m ${remainingSeconds}s` + : `${durationSeconds}s` + + getLogger().info('') + getLogger().info('--- Swarm Results ---') + getLogger().info(` Total tasks: ${result.totalTasks}`) + getLogger().info(` Completed: ${result.completed}`) + getLogger().info(` Failed: ${result.failed}`) + getLogger().info(` PRs merged: ${result.mergedPRs}`) + if (result.failedMerges > 0) { + getLogger().info(` Failed merges: ${result.failedMerges}`) + } + getLogger().info(` Duration: ${durationStr}`) + getLogger().info('---------------------') + + if (result.failed === 0) { + getLogger().success('Swarm completed successfully!') + } else if (result.completed > 0) { + getLogger().warn(`Swarm completed with ${result.failed} failure(s). Check agent logs for details.`) + } else { + getLogger().error('Swarm failed. No tasks completed. Check agent logs for details.') + } + } + /** * Confirm swarm mode entry with the user. * From e4d4a83dc97721aa6c0a900bfed262aa9edb258a Mon Sep 17 00:00:00 2001 From: Adam Creeger Date: Thu, 5 Feb 2026 22:27:54 -0500 Subject: [PATCH 8/9] fix(swarm): address 34 code review findings across swarm mode implementation Fix all critical, medium, and low priority issues from code review: - Eliminate error swallowing in BeadsManager, SwarmSupervisor, and start.ts - Add input validation for --max-agents (NaN check + range 1-20) - Fix silent auto-install in CI when autoInstallBeads is false - Update PATH after Beads install for verification check - Prevent infinite loop on permanently failed tasks - Fix conflict resolver using wrong working directory - Close log file streams on agent completion - Add 30s timeout to graceful shutdown - Write progress file atomically via temp+rename - Fix broken prompt interpolation in confirmSwarmMode - Set GIT_REMOTE and validate EPIC_BRANCH in swarm mode - Make templates swarm-aware (autonomous mode, SKIP_IMPLEMENTATION) - Parallel dependency fetching via Promise.allSettled - Filter subprocess environment to prevent secret leakage - Fix parseInt truncation for mixed-format task IDs - Propagate isEpic/swarmStatus metadata in swarm finish path - Replace dynamic import with static import in tests --- src/cli.ts | 11 ++- src/commands/ignite.test.ts | 15 ++-- src/commands/ignite.ts | 10 +++ src/commands/start-swarm.test.ts | 3 +- src/commands/start.test.ts | 20 ++++- src/commands/start.ts | 37 +++++--- src/lib/BeadsManager.test.ts | 80 +++++++++++++++-- src/lib/BeadsManager.ts | 59 +++++++++++-- src/lib/BeadsSyncService.test.ts | 13 +-- src/lib/BeadsSyncService.ts | 10 ++- src/lib/EpicDetector.ts | 20 +++-- src/lib/LoomManager.ts | 14 +++ src/lib/SettingsManager.ts | 2 +- src/lib/SwarmSupervisor.test.ts | 28 +++--- src/lib/SwarmSupervisor.ts | 136 ++++++++++++++++++++++++++--- templates/prompts/issue-prompt.txt | 14 ++- templates/prompts/plan-prompt.txt | 8 +- 17 files changed, 400 insertions(+), 80 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 2797ae0..78e846e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -381,7 +381,16 @@ program ) .option('--yolo', 'Enable autonomous mode (shorthand for --one-shot=bypassPermissions)') .option('--swarm', 'Bypass epic confirmation and start swarm mode immediately') - .option('--max-agents ', 'Maximum concurrent agents for swarm mode (overrides swarm.maxConcurrent setting)', parseInt) + .option('--max-agents ', 'Maximum concurrent agents for swarm mode (overrides swarm.maxConcurrent setting)', (value: string) => { + const parsed = parseInt(value, 10) + if (isNaN(parsed)) { + throw new Error('--max-agents must be a number') + } + if (parsed < 1 || parsed > 20) { + throw new Error('--max-agents must be between 1 and 20') + } + return parsed + }) .action(async (identifier: string | undefined, options: StartOptions & { yolo?: boolean; maxAgents?: number }) => { // Handle --yolo flag: set oneShot to bypassPermissions if (options.yolo) { diff --git a/src/commands/ignite.test.ts b/src/commands/ignite.test.ts index 399aa9f..94329c7 100644 --- a/src/commands/ignite.test.ts +++ b/src/commands/ignite.test.ts @@ -2837,7 +2837,7 @@ describe('IgniteCommand', () => { }) describe('SWARM_MODE template variables', () => { - it('should set SWARM_MODE, EPIC_BRANCH, and EPIC_ISSUE_NUMBER when env vars are set', async () => { + it('should set SWARM_MODE, EPIC_BRANCH, EPIC_ISSUE_NUMBER, and GIT_REMOTE when env vars are set', async () => { const launchClaudeSpy = vi.spyOn(claudeUtils, 'launchClaude').mockResolvedValue(undefined) const originalCwd = process.cwd @@ -2860,6 +2860,7 @@ describe('IgniteCommand', () => { SWARM_MODE: true, EPIC_BRANCH: 'issue-42-swarm-mode', EPIC_ISSUE_NUMBER: '42', + GIT_REMOTE: 'origin', }) ) } finally { @@ -2922,7 +2923,7 @@ describe('IgniteCommand', () => { } }) - it('should set SWARM_MODE without EPIC_BRANCH or EPIC_ISSUE_NUMBER when those env vars are missing', async () => { + it('should throw error when ILOOM_SWARM_MODE is set but ILOOM_EPIC_BRANCH is missing', async () => { const launchClaudeSpy = vi.spyOn(claudeUtils, 'launchClaude').mockResolvedValue(undefined) const originalCwd = process.cwd @@ -2936,12 +2937,12 @@ describe('IgniteCommand', () => { delete process.env.ILOOM_EPIC_ISSUE try { - await command.execute() + await expect(command.execute()).rejects.toThrow( + 'ILOOM_EPIC_BRANCH is required when ILOOM_SWARM_MODE is enabled' + ) - const templateCall = vi.mocked(mockTemplateManager.getPrompt).mock.calls[0] - expect(templateCall[1].SWARM_MODE).toBe(true) - expect(templateCall[1].EPIC_BRANCH).toBeUndefined() - expect(templateCall[1].EPIC_ISSUE_NUMBER).toBeUndefined() + // Verify launchClaude was NOT called + expect(launchClaudeSpy).not.toHaveBeenCalled() } finally { process.cwd = originalCwd launchClaudeSpy.mockRestore() diff --git a/src/commands/ignite.ts b/src/commands/ignite.ts index 4d69322..fea29e5 100644 --- a/src/commands/ignite.ts +++ b/src/commands/ignite.ts @@ -541,10 +541,20 @@ export class IgniteCommand { variables.SWARM_MODE = true if (process.env.ILOOM_EPIC_BRANCH) { variables.EPIC_BRANCH = process.env.ILOOM_EPIC_BRANCH + } else { + throw new Error('ILOOM_EPIC_BRANCH is required when ILOOM_SWARM_MODE is enabled') } if (process.env.ILOOM_EPIC_ISSUE) { variables.EPIC_ISSUE_NUMBER = process.env.ILOOM_EPIC_ISSUE } + // Ensure GIT_REMOTE is set for swarm mode (needed for git push) + if (!variables.GIT_REMOTE) { + const remote = this.settings?.mergeBehavior?.remote ?? 'origin' + if (!/^[a-zA-Z0-9_-]+$/.test(remote)) { + throw new Error(`Invalid git remote name: "${remote}". Remote names can only contain alphanumeric characters, underscores, and hyphens.`) + } + variables.GIT_REMOTE = remote + } } return variables diff --git a/src/commands/start-swarm.test.ts b/src/commands/start-swarm.test.ts index 24f4527..76b3005 100644 --- a/src/commands/start-swarm.test.ts +++ b/src/commands/start-swarm.test.ts @@ -6,6 +6,7 @@ import { SettingsManager } from '../lib/SettingsManager.js' import { BeadsManager, BeadsError } from '../lib/BeadsManager.js' import { SwarmSupervisor } from '../lib/SwarmSupervisor.js' import { findMainWorktreePathWithSettings } from '../utils/git.js' +import { promptConfirmation } from '../utils/prompt.js' // Mock all external dependencies vi.mock('../lib/GitHubService.js') @@ -300,8 +301,6 @@ describe('StartCommand - Swarm Mode Integration', () => { vi.mocked(mockGitHubService.fetchIssue).mockResolvedValue(epicIssue) vi.mocked(mockGitHubService.validateIssueState).mockResolvedValue() - const { promptConfirmation } = await import('../utils/prompt.js') - const command = new StartCommand( mockGitHubService, mockLoomManager as unknown as LoomManager, diff --git a/src/commands/start.test.ts b/src/commands/start.test.ts index 6738623..825993d 100644 --- a/src/commands/start.test.ts +++ b/src/commands/start.test.ts @@ -105,6 +105,16 @@ describe('StartCommand', () => { // Set IssueTracker interface properties mockGitHubService.supportsPullRequests = true mockGitHubService.providerName = 'github' + // Default fetchIssue mock returns an issue with no labels (needed for epic detection) + vi.mocked(mockGitHubService.fetchIssue).mockResolvedValue({ + number: 123, + title: 'Test Issue', + body: '', + state: 'open', + labels: [], + assignees: [], + url: 'https://github.com/owner/repo/issues/123', + }) command = new StartCommand(mockGitHubService) }) @@ -1574,7 +1584,15 @@ describe('StartCommand', () => { supportsPullRequests: false, providerName: 'linear', detectInputType: vi.fn(), - fetchIssue: vi.fn(), + fetchIssue: vi.fn().mockResolvedValue({ + number: 123, + title: 'Test Linear Issue', + body: '', + state: 'open', + labels: [], + assignees: [], + url: 'https://linear.app/team/ENG-123', + }), validateIssueState: vi.fn(), // Linear does NOT have fetchPR or validatePRState } diff --git a/src/commands/start.ts b/src/commands/start.ts index 72c462a..1f6acaf 100644 --- a/src/commands/start.ts +++ b/src/commands/start.ts @@ -219,12 +219,12 @@ export class StartCommand { let epicDetection: EpicDetectionResult | null = null if (parsed.type === 'issue' && parsed.number !== undefined) { - epicDetection = await this.detectEpic(parsed.number) + epicDetection = await this.detectEpic(parsed.number, initialSettings) } - // If --swarm on non-epic issue, silently ignore - if (input.options.swarm && epicDetection && !epicDetection.isEpic) { - getLogger().debug('--swarm flag provided but issue is not an epic (ignored)') + // If --swarm on non-epic issue, warn the user + if (input.options.swarm && !epicDetection?.isEpic) { + getLogger().warn('--swarm flag provided but issue is not detected as an epic. Proceeding as normal issue.') } // Step 2.5: Handle description input - create GitHub issue @@ -704,6 +704,7 @@ export class StartCommand { */ private async detectEpic( issueNumber: string | number, + settings: import('../lib/SettingsManager.js').IloomSettings, ): Promise { try { // Fetch the issue to check labels @@ -719,7 +720,6 @@ export class StartCommand { } // Create issue management provider for child/dependency queries - const settings = await this.settingsManager.loadSettings() const providerName = settings.issueManagement?.provider ?? 'github' const issueProvider = IssueManagementProviderFactory.create(providerName) const detector = new EpicDetector(issueProvider) @@ -733,8 +733,25 @@ export class StartCommand { return result } catch (error) { - getLogger().debug(`Epic detection failed: ${error instanceof Error ? error.message : 'Unknown error'}`) - return null + // Only catch expected API/network errors. Re-throw unexpected ones. + if (error instanceof Error) { + const msg = error.message.toLowerCase() + if ( + msg.includes('not found') || + msg.includes('404') || + msg.includes('could not resolve') || + msg.includes('enotfound') || + msg.includes('econnrefused') || + msg.includes('etimedout') || + msg.includes('fetch failed') || + msg.includes('graphql') || + msg.includes('rate limit') + ) { + getLogger().debug(`Epic detection failed (expected): ${error.message}`) + return null + } + } + throw error } } @@ -876,9 +893,9 @@ export class StartCommand { const maxAgents = options.maxAgents ?? settings.swarm?.maxConcurrent ?? 3 const { promptConfirmation } = await import('../utils/prompt.js') const confirmed = await promptConfirmation( - `Issue #${epicDetection.totalChildren > 0 - ? `is an epic with ${epicDetection.totalChildren} child issue${epicDetection.totalChildren === 1 ? '' : 's'} (${epicDetection.readyChildren} ready, ${epicDetection.blockedChildren} blocked).\nStart swarm mode? Max ${maxAgents} concurrent agents.` - : 'is an epic. Start swarm mode?'}`, + epicDetection.totalChildren > 0 + ? `Issue is an epic with ${epicDetection.totalChildren} child issue${epicDetection.totalChildren === 1 ? '' : 's'} (${epicDetection.readyChildren} ready, ${epicDetection.blockedChildren} blocked).\nStart swarm mode? Max ${maxAgents} concurrent agents.` + : 'Issue is an epic. Start swarm mode?', true ) diff --git a/src/lib/BeadsManager.test.ts b/src/lib/BeadsManager.test.ts index 4a476eb..fa583a5 100644 --- a/src/lib/BeadsManager.test.ts +++ b/src/lib/BeadsManager.test.ts @@ -144,7 +144,18 @@ describe('BeadsManager', () => { ) }) - it('should auto-install in non-interactive mode', async () => { + it('should throw in non-interactive mode when autoInstall is false', async () => { + vi.mocked(execa).mockRejectedValueOnce(new Error('not found')) + vi.mocked(isInteractiveEnvironment).mockReturnValue(false) + + await expect(beadsManager.ensureInstalled(false)).rejects.toThrow( + 'Beads CLI is required for swarm mode but is not installed', + ) + + expect(promptConfirmation).not.toHaveBeenCalled() + }) + + it('should auto-install in non-interactive mode when autoInstall is true', async () => { vi.mocked(execa).mockRejectedValueOnce(new Error('not found')) vi.mocked(isInteractiveEnvironment).mockReturnValue(false) // Install @@ -152,7 +163,7 @@ describe('BeadsManager', () => { // Verify vi.mocked(execa).mockResolvedValueOnce({ stdout: '/usr/local/bin/bd' } as never) - await beadsManager.ensureInstalled(false) + await beadsManager.ensureInstalled(true) expect(promptConfirmation).not.toHaveBeenCalled() }) @@ -276,14 +287,14 @@ describe('BeadsManager', () => { expect(result).toEqual(tasks) }) - it('should return empty array when output is not valid JSON', async () => { - vi.mocked(execa).mockResolvedValueOnce({ + it('should throw BeadsError when output is not valid JSON', async () => { + vi.mocked(execa).mockResolvedValue({ stdout: 'not json', stderr: '', } as never) - const result = await beadsManager.ready() - expect(result).toEqual([]) + await expect(beadsManager.ready()).rejects.toThrow(BeadsError) + await expect(beadsManager.ready()).rejects.toThrow('Failed to parse bd ready output as JSON') }) }) @@ -357,5 +368,62 @@ describe('BeadsManager', () => { }), ) }) + + it('should only pass allowed environment variables to bd subprocess', async () => { + // Set a secret env var that should NOT be passed through + process.env.SECRET_API_KEY = 'super-secret' + process.env.BD_CUSTOM = 'bd-value' + + vi.mocked(execa).mockResolvedValueOnce({ stdout: '[]', stderr: '' } as never) + + await beadsManager.ready() + + const callArgs = vi.mocked(execa).mock.calls[0] + const env = (callArgs[2] as { env: Record }).env + + // Should NOT include arbitrary env vars + expect(env).not.toHaveProperty('SECRET_API_KEY') + // Should include BD_* prefixed vars + expect(env.BD_CUSTOM).toBe('bd-value') + // Should include required vars + expect(env.BEADS_DIR).toBe(beadsManager.getBeadsDir()) + expect(env.BEADS_NO_DAEMON).toBe('1') + + // Clean up + delete process.env.SECRET_API_KEY + delete process.env.BD_CUSTOM + }) + }) + + describe('list', () => { + it('should return parsed tasks', async () => { + const tasks = [ + { id: '1', title: 'Task A', status: 'open' }, + { id: '2', title: 'Task B', status: 'claimed' }, + ] + vi.mocked(execa).mockResolvedValueOnce({ + stdout: JSON.stringify(tasks), + stderr: '', + } as never) + + const result = await beadsManager.list() + + expect(execa).toHaveBeenCalledWith( + 'bd', + ['list', '--json'], + expect.anything(), + ) + expect(result).toEqual(tasks) + }) + + it('should throw BeadsError when output is not valid JSON', async () => { + vi.mocked(execa).mockResolvedValue({ + stdout: 'not json', + stderr: '', + } as never) + + await expect(beadsManager.list()).rejects.toThrow(BeadsError) + await expect(beadsManager.list()).rejects.toThrow('Failed to parse bd list output as JSON') + }) }) }) diff --git a/src/lib/BeadsManager.ts b/src/lib/BeadsManager.ts index a73b206..76bc1aa 100644 --- a/src/lib/BeadsManager.ts +++ b/src/lib/BeadsManager.ts @@ -50,6 +50,9 @@ export interface BeadsCreateOptions { */ export class BeadsManager { private readonly beadsDir: string + // TODO(security): This install script is fetched from an unpinned `main` branch via curl|bash. + // There is no integrity verification (e.g., checksum or GPG signature). Pin to a specific + // release tag or commit SHA when one becomes available to mitigate supply-chain risk. private readonly installScript = 'https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh' constructor( @@ -119,11 +122,31 @@ export class BeadsManager { 'User declined installation', ) } + } else if (!autoInstall && !isInteractiveEnvironment()) { + throw new BeadsError( + 'Beads CLI is required for swarm mode but is not installed. Install it manually or set autoInstallBeads: true in settings.', + undefined, + 'Non-interactive environment requires autoInstallBeads or manual installation', + ) } logger.info('Installing Beads CLI...') await this.runInstallScript() + // Append common install locations to PATH so isInstalled() can find the + // newly installed binary without requiring a shell restart. + const homeDir = os.homedir() + const candidatePaths = [ + path.join(homeDir, '.local', 'bin'), + path.join(homeDir, '.cargo', 'bin'), + '/usr/local/bin', + ] + const currentPath = process.env.PATH ?? '' + const newPaths = candidatePaths.filter(p => !currentPath.split(path.delimiter).includes(p)) + if (newPaths.length > 0) { + process.env.PATH = `${currentPath}${path.delimiter}${newPaths.join(path.delimiter)}` + } + // Verify installation succeeded if (!(await this.isInstalled())) { throw new BeadsError( @@ -202,10 +225,12 @@ export class BeadsManager { const result = await this.execBd(['ready', '--json']) try { return JSON.parse(result.stdout) as BeadsTask[] - } catch { - // If JSON parsing fails, return empty array - logger.debug('Failed to parse bd ready output, returning empty', { stdout: result.stdout }) - return [] + } catch (error) { + throw new BeadsError( + `Failed to parse bd ready output as JSON: ${error instanceof Error ? error.message : 'Unknown error'}`, + undefined, + `stdout: ${result.stdout}`, + ) } } @@ -257,9 +282,12 @@ export class BeadsManager { const result = await this.execBd(['list', '--json']) try { return JSON.parse(result.stdout) as BeadsTask[] - } catch { - logger.debug('Failed to parse bd list output, returning empty', { stdout: result.stdout }) - return [] + } catch (error) { + throw new BeadsError( + `Failed to parse bd list output as JSON: ${error instanceof Error ? error.message : 'Unknown error'}`, + undefined, + `stdout: ${result.stdout}`, + ) } } @@ -271,8 +299,23 @@ export class BeadsManager { args: string[], options?: { cwd?: string }, ): Promise<{ stdout: string; stderr: string }> { + // Only pass necessary environment variables to the bd subprocess to + // avoid leaking secrets (e.g., API keys, tokens) from the parent process. + const allowedKeys = ['PATH', 'HOME', 'SHELL', 'TERM', 'USER', 'LANG', 'LC_ALL'] + const filteredEnv: Record = {} + for (const key of allowedKeys) { + if (process.env[key] !== undefined) { + filteredEnv[key] = process.env[key] as string + } + } + // Include any BD_* or BEADS_* prefixed vars that bd may need + for (const [key, value] of Object.entries(process.env)) { + if ((key.startsWith('BD_') || key.startsWith('BEADS_')) && value !== undefined) { + filteredEnv[key] = value + } + } const env = { - ...process.env, + ...filteredEnv, BEADS_DIR: this.beadsDir, BEADS_NO_DAEMON: '1', } diff --git a/src/lib/BeadsSyncService.test.ts b/src/lib/BeadsSyncService.test.ts index f35ae4c..9d7f024 100644 --- a/src/lib/BeadsSyncService.test.ts +++ b/src/lib/BeadsSyncService.test.ts @@ -24,6 +24,7 @@ function createMockBeadsManager(): { create: vi.fn().mockResolvedValue(''), addDependency: vi.fn().mockResolvedValue(undefined), ready: vi.fn().mockResolvedValue([]), + list: vi.fn().mockResolvedValue([]), claim: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), releaseClaim: vi.fn().mockResolvedValue(undefined), @@ -31,9 +32,11 @@ function createMockBeadsManager(): { } function createMockIssueProvider(): { - [K in keyof IssueManagementProvider]: ReturnType + [K in keyof IssueManagementProvider]: K extends 'providerName' | 'issuePrefix' ? string : ReturnType } { return { + providerName: 'github', + issuePrefix: '#', getIssue: vi.fn(), getPR: vi.fn(), getComment: vi.fn(), @@ -95,7 +98,7 @@ describe('BeadsSyncService', () => { expect(result.created).toHaveLength(1) }) - it('should skip tasks that already exist in Beads ready list', async () => { + it('should skip tasks that already exist in Beads task list', async () => { const children: ChildIssueResult[] = [ { id: '101', title: 'Task A', url: 'url', state: 'open' }, { id: '102', title: 'Task B', url: 'url', state: 'open' }, @@ -104,7 +107,7 @@ describe('BeadsSyncService', () => { { id: '101', title: 'Task A', status: 'open' }, ] mockIssueProvider.getChildIssues.mockResolvedValue(children) - mockBeadsManager.ready.mockResolvedValue(existingTasks) + mockBeadsManager.list.mockResolvedValue(existingTasks) mockBeadsManager.create.mockResolvedValueOnce('102') const result = await syncService.syncEpicToBeads('100') @@ -181,12 +184,12 @@ describe('BeadsSyncService', () => { expect(result.dependenciesCreated).toBe(0) }) - it('should handle ready() failure gracefully when checking existing tasks', async () => { + it('should handle list() failure gracefully when checking existing tasks', async () => { const children: ChildIssueResult[] = [ { id: '101', title: 'Task A', url: 'url', state: 'open' }, ] mockIssueProvider.getChildIssues.mockResolvedValue(children) - mockBeadsManager.ready.mockRejectedValue(new Error('No tasks')) + mockBeadsManager.list.mockRejectedValue(new Error('No tasks')) mockBeadsManager.create.mockResolvedValueOnce('101') const result = await syncService.syncEpicToBeads('100') diff --git a/src/lib/BeadsSyncService.ts b/src/lib/BeadsSyncService.ts index 7a2d8ce..7031304 100644 --- a/src/lib/BeadsSyncService.ts +++ b/src/lib/BeadsSyncService.ts @@ -54,13 +54,15 @@ export class BeadsSyncService { const openChildren = children.filter(child => child.state === 'open' || child.state === 'OPEN') logger.debug('Open child issues', { count: openChildren.length }) - // Step 2: Get existing Beads tasks to detect already-synced issues + // Step 2: Get existing Beads tasks to detect already-synced issues. + // Uses list() instead of ready() to include tasks in all states + // (blocked, claimed, completed), preventing re-creation attempts on re-sync. let existingTaskIds: Set try { - const readyTasks = await this.beadsManager.ready() - existingTaskIds = new Set(readyTasks.map(t => t.id)) + const allTasks = await this.beadsManager.list() + existingTaskIds = new Set(allTasks.map(t => t.id)) } catch { - // If ready() fails (e.g., no tasks yet), start with empty set + // If list() fails (e.g., no tasks yet), start with empty set existingTaskIds = new Set() } diff --git a/src/lib/EpicDetector.ts b/src/lib/EpicDetector.ts index b8f8f47..93ec8ae 100644 --- a/src/lib/EpicDetector.ts +++ b/src/lib/EpicDetector.ts @@ -99,17 +99,23 @@ export class EpicDetector { } } - // Fetch dependencies for each child to determine ready vs blocked + // Fetch dependencies for all children in parallel let hasDependencies = false let readyCount = 0 let blockedCount = 0 - for (const child of openChildren) { - try { - const deps: DependenciesResult = await this.issueProvider.getDependencies({ + const depResults = await Promise.allSettled( + openChildren.map(child => + this.issueProvider.getDependencies({ number: child.id, direction: 'blocked_by', }) + ) + ) + + for (const [i, result] of depResults.entries()) { + if (result.status === 'fulfilled') { + const deps: DependenciesResult = result.value // A child has dependencies if it is blocked by at least one issue const openBlockers = deps.blockedBy.filter( @@ -125,10 +131,12 @@ export class EpicDetector { } else { readyCount++ } - } catch (error) { + } else { // If dependencies can't be fetched, treat the child as ready + const childId = openChildren[i]?.id ?? `index-${i}` + const reason = result.reason instanceof Error ? result.reason.message : 'Unknown error' getLogger().debug( - `Failed to fetch dependencies for child ${child.id}: ${error instanceof Error ? error.message : 'Unknown error'}` + `Failed to fetch dependencies for child ${childId}: ${reason}` ) readyCount++ } diff --git a/src/lib/LoomManager.ts b/src/lib/LoomManager.ts index 25d3f0c..ff08f8a 100644 --- a/src/lib/LoomManager.ts +++ b/src/lib/LoomManager.ts @@ -1194,17 +1194,29 @@ export class LoomManager { const description = issueData?.title ?? branchName // Build issue/pr numbers arrays + // For PR workflows, extract issue number from branch name if present let issue_numbers: string[] = [] + let extractedIssueNum: string | null = null if (input.type === 'issue') { issue_numbers = [String(input.identifier)] + } else if (input.type === 'pr') { + extractedIssueNum = extractIssueNumber(branchName) + if (extractedIssueNum) { + issue_numbers = [extractedIssueNum] + } } const pr_numbers: string[] = input.type === 'pr' ? [String(input.identifier)] : [] const sessionId = generateRandomSessionId() + // Build issueUrls/prUrls based on workflow type + // For PR workflows, construct issue URL by replacing /pull/N with /issues/M let issueUrls: Record = {} if (input.type === 'issue' && issueData?.url) { issueUrls = { [String(input.identifier)]: issueData.url } + } else if (input.type === 'pr' && extractedIssueNum && issueData?.url) { + const issueUrl = issueData.url.replace(`/pull/${input.identifier}`, `/issues/${extractedIssueNum}`) + issueUrls = { [extractedIssueNum]: issueUrl } } const prUrls: Record = input.type === 'pr' && issueData?.url ? { [String(input.identifier)]: issueData.url } @@ -1225,6 +1237,8 @@ export class LoomManager { prUrls, capabilities, swarmAgent: true, + ...(input.options?.isEpic && { isEpic: input.options.isEpic }), + ...(input.options?.swarmStatus && { swarmStatus: input.options.swarmStatus }), ...(input.parentLoom && { parentLoom: input.parentLoom }), } await this.metadataManager.writeMetadata(worktreePath, metadataInput) diff --git a/src/lib/SettingsManager.ts b/src/lib/SettingsManager.ts index 0936ad6..56662c2 100644 --- a/src/lib/SettingsManager.ts +++ b/src/lib/SettingsManager.ts @@ -248,7 +248,7 @@ export const SwarmSettingsSchema = z.object({ .min(0) .max(5) .default(1) - .describe('Maximum retries for failed swarm tasks'), + .describe('Maximum total attempts for a failed swarm task (1 = single attempt with no retries, 2 = one retry after initial failure)'), maxConflictRetries: z .number() .min(0) diff --git a/src/lib/SwarmSupervisor.test.ts b/src/lib/SwarmSupervisor.test.ts index 3e3c115..b17000c 100644 --- a/src/lib/SwarmSupervisor.test.ts +++ b/src/lib/SwarmSupervisor.test.ts @@ -25,6 +25,7 @@ vi.mock('fs-extra', () => ({ end: vi.fn(), })), writeJson: vi.fn(), + rename: vi.fn(), }, })) @@ -295,7 +296,7 @@ describe('SwarmSupervisor', () => { // Mock PR search - found a PR, then merge, then issue close vi.mocked(executeGhCommand) - .mockResolvedValueOnce([{ number: 42 }] as never) // PR search + .mockResolvedValueOnce([{ number: 42, headRefName: 'feat/issue-100' }] as never) // PR search .mockResolvedValueOnce(undefined as never) // PR merge .mockResolvedValueOnce(undefined as never) // issue close @@ -472,7 +473,7 @@ describe('SwarmSupervisor', () => { vi.mocked(execa).mockReturnValue(mockProcess as never) vi.mocked(executeGhCommand) - .mockResolvedValueOnce([{ number: 42 }] as never) // PR search + .mockResolvedValueOnce([{ number: 42, headRefName: 'feat/issue-100' }] as never) // PR search .mockResolvedValueOnce(undefined as never) // PR merge .mockResolvedValueOnce(undefined as never) // issue close @@ -665,7 +666,7 @@ describe('SwarmSupervisor', () => { }) vi.mocked(executeGhCommand) - .mockResolvedValueOnce([{ number: 42 }] as never) // PR search + .mockResolvedValueOnce([{ number: 42, headRefName: 'feat/issue-100' }] as never) // PR search .mockRejectedValueOnce(new Error('merge conflict')) // first merge attempt fails .mockResolvedValueOnce(undefined as never) // retry merge succeeds .mockResolvedValueOnce(undefined as never) // issue close @@ -718,7 +719,7 @@ describe('SwarmSupervisor', () => { }) vi.mocked(executeGhCommand) - .mockResolvedValueOnce([{ number: 42 }] as never) // PR search + .mockResolvedValueOnce([{ number: 42, headRefName: 'feat/issue-100' }] as never) // PR search .mockRejectedValueOnce(new Error('merge conflict')) // first merge fails .mockRejectedValueOnce(new Error('merge conflict')) // retry after resolution also fails .mockRejectedValueOnce(new Error('merge conflict')) // conflicts exhausted @@ -759,7 +760,7 @@ describe('SwarmSupervisor', () => { // Test "CONFLICT" pattern vi.mocked(executeGhCommand) - .mockResolvedValueOnce([{ number: 42 }] as never) // PR search + .mockResolvedValueOnce([{ number: 42, headRefName: 'feat/issue-100' }] as never) // PR search .mockRejectedValueOnce(new Error('CONFLICT in file.ts')) // merge with CONFLICT .mockResolvedValueOnce(undefined as never) // retry merge succeeds .mockResolvedValueOnce(undefined as never) // issue close @@ -790,7 +791,7 @@ describe('SwarmSupervisor', () => { // Non-conflict error vi.mocked(executeGhCommand) - .mockResolvedValueOnce([{ number: 42 }] as never) // PR search + .mockResolvedValueOnce([{ number: 42, headRefName: 'feat/issue-100' }] as never) // PR search .mockRejectedValueOnce(new Error('Authentication failed')) // non-conflict error const result = await supervisor.run(epicLoom) @@ -1073,8 +1074,9 @@ describe('SwarmSupervisor', () => { expect(beadsManager.claim).not.toHaveBeenCalled() }) - it('should handle double SIGINT by process.exit', () => { + it('should handle double SIGINT by killing agents and scheduling exit', () => { const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never) + const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout').mockImplementation(() => 0 as unknown as NodeJS.Timeout) const handler = (supervisor as unknown as { handleSignal: () => void }).handleSignal.bind(supervisor) @@ -1082,11 +1084,17 @@ describe('SwarmSupervisor', () => { handler() expect((supervisor as unknown as { shuttingDown: boolean }).shuttingDown).toBe(true) - // Second signal: force exit + // Second signal: schedules force exit after grace period handler() + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 2000) + + // Invoke the scheduled callback to verify it calls process.exit(1) + const scheduledCallback = setTimeoutSpy.mock.calls[0][0] as () => void + scheduledCallback() expect(exitSpy).toHaveBeenCalledWith(1) exitSpy.mockRestore() + setTimeoutSpy.mockRestore() }) }) @@ -1111,7 +1119,7 @@ describe('SwarmSupervisor', () => { vi.mocked(execa).mockReturnValue(mockProcess as never) vi.mocked(executeGhCommand) - .mockResolvedValueOnce([{ number: 42 }] as never) // PR search + .mockResolvedValueOnce([{ number: 42, headRefName: 'feat/issue-100' }] as never) // PR search .mockResolvedValueOnce(undefined as never) // PR merge succeeds .mockRejectedValueOnce(new Error('Cannot close')) // issue close fails @@ -1146,7 +1154,7 @@ describe('SwarmSupervisor', () => { vi.mocked(execa).mockReturnValue(mockProcess as never) vi.mocked(executeGhCommand) - .mockResolvedValueOnce([{ number: 42 }] as never) // PR search + .mockResolvedValueOnce([{ number: 42, headRefName: 'feat/issue-100' }] as never) // PR search .mockResolvedValueOnce(undefined as never) // PR merge .mockResolvedValueOnce(undefined as never) // issue close diff --git a/src/lib/SwarmSupervisor.ts b/src/lib/SwarmSupervisor.ts index ff6d43f..bf68b02 100644 --- a/src/lib/SwarmSupervisor.ts +++ b/src/lib/SwarmSupervisor.ts @@ -32,6 +32,8 @@ export interface ActiveAgent { beadsTaskId: string /** Set when the process exits. Null means still running. */ exitCode: number | null + /** Write stream for agent log file; closed when agent completes */ + logStream: { end: () => void } } /** @@ -41,6 +43,8 @@ export interface MergeQueueEntry { issueId: string prNumber: number beadsTaskId: string + /** Path to the child worktree for this task, used by conflict resolver */ + loomPath: string } /** @@ -152,10 +156,17 @@ export class SwarmSupervisor { private taskCompleteTimes: Map = new Map() /** Tracks which tasks have been permanently failed (exhausted retries) */ private permanentlyFailed: Set = new Set() + /** + * Tracks tasks whose claims have been released but may not yet appear in ready(). + * Prevents premature exit when Beads has internal delay re-surfacing released tasks. + */ + private pendingReleases: number = 0 /** Start time for progress reporting */ private startedAt: string = '' /** Log directory path */ private logDir: string = '' + /** Maps task IDs to their child worktree paths (retained after agent completes for merge queue use) */ + private taskLoomPaths: Map = new Map() constructor( private readonly beadsManager: BeadsManager, @@ -253,7 +264,10 @@ export class SwarmSupervisor { await this.writeProgress(epicLoom, result, 'running') // 5g: Check if we're done - if (readyTasks.length === 0 && this.activeAgents.size === 0 && this.mergeQueue.length === 0) { + // Filter out permanently failed tasks - they will never be actioned and should + // not prevent the supervisor from exiting when all actionable work is complete. + const actionableReadyTasks = readyTasks.filter(t => !this.permanentlyFailed.has(t.id)) + if (actionableReadyTasks.length === 0 && this.activeAgents.size === 0 && this.mergeQueue.length === 0 && this.pendingReleases === 0) { break } @@ -356,6 +370,10 @@ export class SwarmSupervisor { // Atomically claim the task await this.beadsManager.claim(task.id) + // If this task was pending release from a previous failure, clear the counter + if (this.pendingReleases > 0 && attempt > 1) { + this.pendingReleases-- + } logger.info(`Claimed task ${task.id}: ${task.title} (attempt ${attempt})`) // Store title for progress reporting @@ -378,6 +396,9 @@ export class SwarmSupervisor { }, }) + // Store loom path for later use by conflict resolver (after agent is removed from activeAgents) + this.taskLoomPaths.set(task.id, loom.path) + // Set up log file const logFile = path.join(logDir, `${task.id}.log`) const logStream = fs.createWriteStream(logFile, { flags: 'a' }) @@ -408,6 +429,7 @@ export class SwarmSupervisor { process: childProcess, beadsTaskId: task.id, exitCode: null, + logStream, } // Track process completion via callback @@ -435,11 +457,25 @@ export class SwarmSupervisor { this.activeAgents.delete(issueId) + // Close the log file stream to avoid leaking file descriptors + try { + agent.logStream.end() + } catch { + // Ignore errors closing log stream + } + if (agent.exitCode === 0) { logger.info(`Agent for issue ${issueId} completed successfully`) // Find the PR created by the agent - const prNumber = await this.findPRForBranch(issueId, epicLoom) + let prNumber: number | null = null + try { + prNumber = await this.findPRForBranch(issueId, epicLoom) + } catch (prSearchError) { + // findPRForBranch only throws on unexpected errors (network, auth, rate limit). + // Log the error and treat as "no PR found" to avoid losing the agent's work. + logger.error(`Failed to search for PR for issue ${issueId}: ${prSearchError instanceof Error ? prSearchError.message : 'Unknown error'}`) + } if (prNumber) { this.taskPRNumbers.set(issueId, prNumber) @@ -447,6 +483,7 @@ export class SwarmSupervisor { issueId, prNumber, beadsTaskId: agent.beadsTaskId, + loomPath: agent.loomPath, }) logger.info(`Enqueued PR #${prNumber} for merge (issue ${issueId})`) } else { @@ -464,6 +501,10 @@ export class SwarmSupervisor { /** * Handle a failed agent by releasing its claim and retrying if below maxRetries. + * + * Note on maxRetries semantics: maxRetries represents the total number of attempts allowed + * (not additional retries after the first). e.g., maxRetries=1 means 1 total attempt (no retries), + * maxRetries=2 means 2 total attempts (1 retry). */ private async handleAgentFailure( agent: ActiveAgent, @@ -488,6 +529,9 @@ export class SwarmSupervisor { logger.info(`Retrying task ${issueId} (attempt ${attempts + 1} of ${this.settings.maxRetries})...`) // The task's claim was released, so it will appear in ready() on the next loop iteration // and be re-claimed and re-spawned. The attempt counter is already incremented. + // Track as pending release so the supervisor doesn't exit prematurely before Beads + // re-surfaces the task in ready(). + this.pendingReleases++ } else { // Exhausted retries - mark as permanently failed logger.error(`Task ${issueId} failed after ${attempts} attempt(s). Marking as permanently failed.`) @@ -646,9 +690,10 @@ export class SwarmSupervisor { entry: MergeQueueEntry, epicLoom: EpicLoomContext, ): Promise { - // Find the worktree for this issue's agent - const agent = this.activeAgents.get(entry.issueId) - const cwd = agent?.loomPath ?? epicLoom.epicLoomPath + // Use the stored loom path for this task. By the time a task reaches the merge queue, + // the agent has already been removed from activeAgents, so we use the loomPath stored + // directly on the merge queue entry, or fall back to the taskLoomPaths map. + const cwd = entry.loomPath ?? this.taskLoomPaths.get(entry.issueId) ?? epicLoom.epicLoomPath const resolverProcess = execa('il', ['spin', '-p'], { cwd, @@ -713,16 +758,36 @@ export class SwarmSupervisor { */ private async findPRForBranch(issueId: string, _epicLoom: EpicLoomContext): Promise { try { - const prList = await executeGhCommand>( - ['pr', 'list', '--state', 'open', '--json', 'number', '--search', `is:pr is:open ${issueId} in:title`], + // Note: Searching by `issueId in:title` may match unrelated PRs if the issue ID + // appears in other PR titles. A more robust approach would filter by head branch + // pattern, but GitHub's search API doesn't support exact branch matching via `gh pr list --search`. + const prList = await executeGhCommand>( + ['pr', 'list', '--state', 'open', '--json', 'number,headRefName', '--search', `is:pr is:open ${issueId} in:title`], ) + // Prefer PRs whose branch name contains the issue ID for more precise matching + const exactMatch = prList.find(pr => pr.headRefName.includes(issueId)) + if (exactMatch) { + return exactMatch.number + } + if (prList.length > 0 && prList[0]) { return prList[0].number } return null - } catch { - return null + } catch (error: unknown) { + // Only return null for "no PR found" scenarios (empty result from gh pr list). + // gh pr list with --json returns exit code 0 with an empty array when no PRs match, + // so a caught error here indicates an unexpected failure (network, auth, rate limit, etc.). + // Propagate unexpected errors so callers can handle them appropriately. + if (error instanceof Error) { + // gh cli returns exit code 1 with no stderr for genuinely empty results in some edge cases + const msg = error.message.toLowerCase() + if (msg.includes('no pull requests match') || msg.includes('no open pull requests')) { + return null + } + } + throw error } } @@ -734,11 +799,31 @@ export class SwarmSupervisor { result: SwarmResult, epicLoom: EpicLoomContext, ): Promise { + const GRACEFUL_SHUTDOWN_TIMEOUT_MS = 30_000 + const shutdownDeadline = Date.now() + GRACEFUL_SHUTDOWN_TIMEOUT_MS + while (this.activeAgents.size > 0) { await this.checkCompletedAgents(result, epicLoom) await this.processMergeQueue(result, epicLoom) if (this.activeAgents.size > 0) { + if (Date.now() >= shutdownDeadline) { + logger.warn(`Graceful shutdown timed out after ${GRACEFUL_SHUTDOWN_TIMEOUT_MS / 1000}s. Force-killing ${this.activeAgents.size} remaining agent(s).`) + for (const [, agent] of this.activeAgents) { + try { + agent.process.kill('SIGTERM') + } catch { + // Process may have already exited + } + try { + agent.logStream.end() + } catch { + // Ignore log stream close errors + } + } + this.activeAgents.clear() + break + } await sleep(2000) } } @@ -756,8 +841,12 @@ export class SwarmSupervisor { * Task IDs may be numeric (GitHub) or alphanumeric (Linear). */ private parseIssueIdentifier(taskId: string): string | number { - const numericId = parseInt(taskId, 10) - return isNaN(numericId) ? taskId : numericId + // Use strict regex to avoid parseInt truncating mixed-format IDs + // e.g., parseInt("100-fix-login", 10) returns 100, which is wrong + if (/^\d+$/.test(taskId)) { + return parseInt(taskId, 10) + } + return taskId } /** @@ -841,7 +930,11 @@ export class SwarmSupervisor { failures: [...this.failures], } - await fs.writeJson(progressFile, progress, { spaces: 2 }) + // Write atomically: write to a temp file, then rename (atomic on POSIX). + // This prevents readers from seeing partial JSON if they read mid-write. + const tmpFile = progressFile + '.tmp' + await fs.writeJson(tmpFile, progress, { spaces: 2 }) + await fs.rename(tmpFile, progressFile) } catch (error) { // Progress file writing should never fail the swarm logger.debug(`Failed to write progress file: ${error instanceof Error ? error.message : 'Unknown error'}`) @@ -884,8 +977,23 @@ export class SwarmSupervisor { */ private handleSignal(): void { if (this.shuttingDown) { - logger.warn('Forced shutdown requested. Exiting immediately.') - process.exit(1) + logger.warn('Forced shutdown requested. Killing child processes and exiting.') + // Attempt to kill all active agent child processes before force exit + for (const [, agent] of this.activeAgents) { + try { + agent.process.kill('SIGTERM') + } catch { + // Process may have already exited + } + try { + agent.logStream.end() + } catch { + // Ignore log stream close errors + } + } + // Brief grace period then force exit + global.setTimeout(() => process.exit(1), 2000) + return } this.shuttingDown = true diff --git a/templates/prompts/issue-prompt.txt b/templates/prompts/issue-prompt.txt index 6dd8bbc..b9adea6 100644 --- a/templates/prompts/issue-prompt.txt +++ b/templates/prompts/issue-prompt.txt @@ -215,9 +215,13 @@ You are running as an autonomous swarm agent. Follow these specific rules: 5. **Be concise**: Minimize token usage. Focus on implementation, not explanation. {{/if}} -You are orchestrating a set of agents through a development process, with human review at each step. This is referred to as the "iloom workflow". +You are orchestrating a set of agents through a development process{{#unless SWARM_MODE}}, with human review at each step{{/unless}}. This is referred to as the "iloom workflow". +{{#if SWARM_MODE}} +**IMPORTANT: In swarm mode, proceed autonomously without waiting for human approval. Execute all steps in sequence as quickly as possible.** +{{else}} **IMPORTANT: Unless otherwise instructed, each step requires explicit human approval. Do not proceed to any step until explicitly told to do so.** +{{/if}} **Todo List:** 1. Scan issue and determine workflow plan @@ -863,7 +867,15 @@ Only execute if workflow plan determined NEEDS_IMPLEMENTATION: If workflow plan determined SKIP_IMPLEMENTATION: 1. Mark todos #16 and #17 as completed +{{#if SWARM_MODE}} +2. Close issue #{{ISSUE_NUMBER}} via: + ```bash + gh issue close {{ISSUE_NUMBER}} --reason completed --comment "All work was already completed. Closing as part of swarm execution." + ``` +3. Provide final summary noting that all work was already completed and the issue has been closed +{{else}} 2. Provide final summary noting that all work was already completed +{{/if}} --- diff --git a/templates/prompts/plan-prompt.txt b/templates/prompts/plan-prompt.txt index 95f378d..3333c73 100644 --- a/templates/prompts/plan-prompt.txt +++ b/templates/prompts/plan-prompt.txt @@ -280,17 +280,17 @@ Wait for the subagent to complete, then present its summary to the user for plan 1. **Ensure the parent epic has the `iloom-epic` label** - This label enables swarm mode detection for automated parallel execution - If the parent issue does not already have it, the planning tool should note this for the user -1. **Create child issues using the existing issue as parent** +2. **Create child issues using the existing issue as parent** - Use `create_child_issue` with `parentId: {{PARENT_ISSUE_NUMBER}}` - Each child represents one focused unit of work (1 loom = 1 PR) - Do NOT create a new parent epic - use the existing issue #{{PARENT_ISSUE_NUMBER}} -2. **Set up blocking dependencies between children** +3. **Set up blocking dependencies between children** - Use `create_dependency` to define execution order - If Issue B depends on work from Issue A, create a dependency where A blocks B -3. **Post Architectural Decision Record (ADR) as comment on parent issue** +4. **Post Architectural Decision Record (ADR) as comment on parent issue** - Use `create_comment` with `number: "{{PARENT_ISSUE_NUMBER}}"`, `type: "issue"` - Include: Design rationale, key decisions, trade-offs, recommended execution order -4. **Output next steps to the user** +5. **Output next steps to the user** - Tell them what was created: "Created N child issues for #{{PARENT_ISSUE_NUMBER}}." {{#if IS_VSCODE_MODE}} - Recommend where to start: "Exit this session, then use the iloom explorer panel to create a new loom for issue Z to begin with [first task title]." From a3fe3c826fcdea26dfbbf4762ab8ad06795c25bc Mon Sep 17 00:00:00 2001 From: Adam Creeger Date: Sun, 8 Feb 2026 23:48:00 -0500 Subject: [PATCH 9/9] fix(beads): make BeadsManager.init() idempotent on re-run Catch "already initialized" error from `bd init` and treat as success, fixing re-runs on existing epic looms. Also improve execBd() error handling to distinguish CLI failures from unexpected runtime errors. Fixes #571 --- src/lib/BeadsManager.test.ts | 75 ++++++++++++++++++++++++++++++------ src/lib/BeadsManager.ts | 54 ++++++++++++++++++-------- 2 files changed, 101 insertions(+), 28 deletions(-) diff --git a/src/lib/BeadsManager.test.ts b/src/lib/BeadsManager.test.ts index fa583a5..1729161 100644 --- a/src/lib/BeadsManager.test.ts +++ b/src/lib/BeadsManager.test.ts @@ -24,6 +24,7 @@ vi.mock('../utils/logger.js', () => ({ })) import { promptConfirmation, isInteractiveEnvironment } from '../utils/prompt.js' +import { logger } from '../utils/logger.js' describe('BeadsManager', () => { let beadsManager: BeadsManager @@ -208,6 +209,54 @@ describe('BeadsManager', () => { await expect(beadsManager.init()).rejects.toThrow(BeadsError) }) + + it('should succeed silently when beads is already initialized', async () => { + const error = new Error('bd init failed') as Error & { stderr: string; exitCode: number } + error.stderr = 'This workspace is already initialized.' + error.exitCode = 1 + vi.mocked(execa).mockRejectedValueOnce(error) + + await expect(beadsManager.init()).resolves.toBeUndefined() + expect(logger.debug).toHaveBeenCalledWith('Beads already initialized, skipping') + }) + + it('should re-throw non-"already initialized" BeadsErrors', async () => { + const error = new Error('bd init failed') as Error & { stderr: string; exitCode: number } + error.stderr = 'Permission denied: /some/path' + error.exitCode = 1 + vi.mocked(execa).mockRejectedValueOnce(error) + + await expect(beadsManager.init()).rejects.toThrow(BeadsError) + await expect( + // Need to re-mock since the previous call consumed it + (async () => { + const err = new Error('bd init failed') as Error & { stderr: string; exitCode: number } + err.stderr = 'Permission denied: /some/path' + err.exitCode = 1 + vi.mocked(execa).mockRejectedValueOnce(err) + return beadsManager.init() + })() + ).rejects.toThrow('Permission denied') + }) + + it('should re-throw errors that are not BeadsError instances', async () => { + // Simulate an unexpected error type (not an ExecaError with exitCode) + vi.mocked(execa).mockRejectedValueOnce(new TypeError('unexpected type error')) + + // Non-execa errors are re-thrown without wrapping in BeadsError + await expect(beadsManager.init()).rejects.toThrow(TypeError) + vi.mocked(execa).mockRejectedValueOnce(new TypeError('unexpected type error')) + await expect(beadsManager.init()).rejects.toThrow('unexpected type error') + }) + + it('should handle stderr containing "already initialized" anywhere in the message', async () => { + const error = new Error('bd init failed') as Error & { stderr: string; exitCode: number } + error.stderr = 'Found existing database: /path/to/beads.db\n\nThis workspace is already initialized.\n\nTo use the existing database...' + error.exitCode = 1 + vi.mocked(execa).mockRejectedValueOnce(error) + + await expect(beadsManager.init()).resolves.toBeUndefined() + }) }) describe('create', () => { @@ -288,7 +337,11 @@ describe('BeadsManager', () => { }) it('should throw BeadsError when output is not valid JSON', async () => { - vi.mocked(execa).mockResolvedValue({ + vi.mocked(execa).mockResolvedValueOnce({ + stdout: 'not json', + stderr: '', + } as never) + vi.mocked(execa).mockResolvedValueOnce({ stdout: 'not json', stderr: '', } as never) @@ -358,7 +411,7 @@ describe('BeadsManager', () => { await beadsManager.ready() - const callArgs = vi.mocked(execa).mock.calls[0] + const callArgs = vi.mocked(execa).mock.calls[0] as unknown as [string, string[], Record] expect(callArgs[2]).toEqual( expect.objectContaining({ env: expect.objectContaining({ @@ -371,15 +424,15 @@ describe('BeadsManager', () => { it('should only pass allowed environment variables to bd subprocess', async () => { // Set a secret env var that should NOT be passed through - process.env.SECRET_API_KEY = 'super-secret' - process.env.BD_CUSTOM = 'bd-value' + vi.stubEnv('SECRET_API_KEY', 'super-secret') + vi.stubEnv('BD_CUSTOM', 'bd-value') vi.mocked(execa).mockResolvedValueOnce({ stdout: '[]', stderr: '' } as never) await beadsManager.ready() - const callArgs = vi.mocked(execa).mock.calls[0] - const env = (callArgs[2] as { env: Record }).env + const callArgs = vi.mocked(execa).mock.calls[0] as unknown as [string, string[], { env: Record }] + const env = callArgs[2].env // Should NOT include arbitrary env vars expect(env).not.toHaveProperty('SECRET_API_KEY') @@ -388,10 +441,6 @@ describe('BeadsManager', () => { // Should include required vars expect(env.BEADS_DIR).toBe(beadsManager.getBeadsDir()) expect(env.BEADS_NO_DAEMON).toBe('1') - - // Clean up - delete process.env.SECRET_API_KEY - delete process.env.BD_CUSTOM }) }) @@ -417,7 +466,11 @@ describe('BeadsManager', () => { }) it('should throw BeadsError when output is not valid JSON', async () => { - vi.mocked(execa).mockResolvedValue({ + vi.mocked(execa).mockResolvedValueOnce({ + stdout: 'not json', + stderr: '', + } as never) + vi.mocked(execa).mockResolvedValueOnce({ stdout: 'not json', stderr: '', } as never) diff --git a/src/lib/BeadsManager.ts b/src/lib/BeadsManager.ts index 76bc1aa..5746af1 100644 --- a/src/lib/BeadsManager.ts +++ b/src/lib/BeadsManager.ts @@ -4,7 +4,15 @@ import os from 'os' import { execa, type ExecaError } from 'execa' import { logger } from '../utils/logger.js' import { promptConfirmation, isInteractiveEnvironment } from '../utils/prompt.js' -import type { SwarmSettings } from './SettingsManager.js' + +/** + * Subset of SwarmSettings used by BeadsManager. + * Defined locally to avoid coupling to the full SwarmSettings schema + * which lives in SettingsManager and may not be present on all branches. + */ +interface BeadsManagerSettings { + beadsDir?: string +} /** * Error class for Beads CLI failures @@ -57,7 +65,7 @@ export class BeadsManager { constructor( private readonly projectPath: string, - swarmSettings?: Partial, + swarmSettings?: Partial, ) { const baseDir = swarmSettings?.beadsDir ?? '~/.config/iloom-ai/beads' const resolvedBaseDir = baseDir.startsWith('~') @@ -161,19 +169,28 @@ export class BeadsManager { /** * Initialize Beads for this project. - * Idempotent - safe to re-run. + * Idempotent - safe to re-run. If the database already exists, + * the "already initialized" error is caught and treated as success. * - * @throws BeadsError if init fails + * @throws BeadsError if init fails for reasons other than already being initialized */ async init(): Promise { logger.debug('Initializing Beads', { beadsDir: this.beadsDir, projectPath: this.projectPath }) - await this.execBd([ - 'init', - '--quiet', - '--skip-hooks', - '--skip-merge-driver', - ], { cwd: this.projectPath }) + try { + await this.execBd([ + 'init', + '--quiet', + '--skip-hooks', + '--skip-merge-driver', + ], { cwd: this.projectPath }) + } catch (error) { + if (error instanceof BeadsError && error.stderr.includes('already initialized')) { + logger.debug('Beads already initialized, skipping') + return + } + throw error + } logger.debug('Beads initialized successfully') } @@ -329,13 +346,16 @@ export class BeadsManager { }) return { stdout: result.stdout, stderr: result.stderr } } catch (error) { - const execaError = error as ExecaError - const stderr = execaError.stderr ?? execaError.message ?? 'Unknown Beads error' - throw new BeadsError( - `Beads command failed: bd ${args.join(' ')}: ${stderr}`, - execaError.exitCode, - stderr, - ) + if (error instanceof Error && 'exitCode' in error) { + const execaError = error as ExecaError + const stderr = execaError.stderr ?? execaError.message ?? 'Unknown Beads error' + throw new BeadsError( + `Beads command failed: bd ${args.join(' ')}: ${stderr}`, + execaError.exitCode, + stderr, + ) + } + throw error } }