import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { SpawnOrchestrator, type SessionCreator } from '../src/spawn-orchestrator.js'; import { mkdirSync, writeFileSync, existsSync, rmSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; /** * SpawnOrchestrator Tests * * Tests the full lifecycle management of spawned agents. * Uses a temporary directory and mock session creator. */ describe('SpawnOrchestrator', () => { let orchestrator: SpawnOrchestrator; let testDir: string; let mockSessionCreator: SessionCreator; let completionHandlers: Map void>; beforeEach(() => { testDir = join(tmpdir(), `spawn-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); mkdirSync(testDir, { recursive: true }); completionHandlers = new Map(); mockSessionCreator = { createAgentSession: vi.fn().mockResolvedValue({ sessionId: `session-${Date.now()}` }), writeToSession: vi.fn(), getSessionTokens: vi.fn().mockReturnValue(0), getSessionCost: vi.fn().mockReturnValue(0), stopSession: vi.fn().mockResolvedValue(undefined), onSessionCompletion: vi.fn().mockImplementation((sessionId, handler) => { completionHandlers.set(sessionId, handler); }), removeSessionCompletionHandler: vi.fn().mockImplementation((sessionId) => { completionHandlers.delete(sessionId); }), }; orchestrator = new SpawnOrchestrator({ casesDir: testDir, maxConcurrentAgents: 3, maxSpawnDepth: 2, defaultTimeoutMinutes: 5, maxTimeoutMinutes: 10, progressPollIntervalMs: 60000, // Long interval to avoid interference }); orchestrator.setSessionCreator(mockSessionCreator); }); afterEach(() => { // Stop all agents and clear timers orchestrator.stopAll().catch(() => {}); orchestrator.removeAllListeners(); // Clean up test directory if (existsSync(testDir)) { rmSync(testDir, { recursive: true, force: true }); } }); function createTaskFile(dir: string, filename: string, content: string): string { const filePath = join(dir, filename); mkdirSync(dir, { recursive: true }); writeFileSync(filePath, content); return filePath; } const basicTaskContent = `--- agentId: test-agent-001 name: Test Agent type: explore priority: normal timeoutMinutes: 5 completionPhrase: TEST_DONE canModifyParentFiles: false --- # Test Task Do a simple test.`; describe('Configuration', () => { it('should use provided config', () => { expect(orchestrator.config.maxConcurrentAgents).toBe(3); expect(orchestrator.config.maxSpawnDepth).toBe(2); }); it('should update config', () => { orchestrator.updateConfig({ maxConcurrentAgents: 10 }); expect(orchestrator.config.maxConcurrentAgents).toBe(10); }); }); describe('handleSpawnRequest', () => { it('should reject when no session creator is set', async () => { const noCreator = new SpawnOrchestrator({ casesDir: testDir }); const failHandler = vi.fn(); noCreator.on('failed', failHandler); const parentDir = join(testDir, 'parent'); createTaskFile(parentDir, 'task.md', basicTaskContent); await noCreator.handleSpawnRequest('task.md', 'parent-session', parentDir); // Should not crash, just log error expect(failHandler).not.toHaveBeenCalled(); // Silent failure with console.error }); it('should fail when task file does not exist', async () => { const failHandler = vi.fn(); orchestrator.on('failed', failHandler); await orchestrator.handleSpawnRequest('nonexistent.md', 'parent-session', testDir); expect(failHandler).toHaveBeenCalledWith( expect.objectContaining({ error: expect.stringContaining('not found') }) ); }); it('should fail when task file cannot be parsed', async () => { const parentDir = join(testDir, 'parent'); createTaskFile(parentDir, 'bad.md', 'No frontmatter here'); const failHandler = vi.fn(); orchestrator.on('failed', failHandler); await orchestrator.handleSpawnRequest('bad.md', 'parent-session', parentDir); expect(failHandler).toHaveBeenCalledWith( expect.objectContaining({ error: expect.stringContaining('parse') }) ); }); it('should reject when max depth exceeded', async () => { const parentDir = join(testDir, 'parent'); createTaskFile(parentDir, 'task.md', basicTaskContent); const failHandler = vi.fn(); orchestrator.on('failed', failHandler); // Max depth is 2, so parentDepth=2 means child would be 3 (exceeds) await orchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir, 2); expect(failHandler).toHaveBeenCalledWith( expect.objectContaining({ error: expect.stringContaining('depth') }) ); }); it('should spawn agent and create directory structure', async () => { const parentDir = join(testDir, 'parent'); createTaskFile(parentDir, 'task.md', basicTaskContent); await orchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir); // Wait for async initialization await vi.waitFor(() => { expect(mockSessionCreator.createAgentSession).toHaveBeenCalled(); }); // Check directory was created const agentDir = join(testDir, 'spawn-test-agent-001'); expect(existsSync(agentDir)).toBe(true); expect(existsSync(join(agentDir, 'CLAUDE.md'))).toBe(true); expect(existsSync(join(agentDir, 'spawn-comms'))).toBe(true); expect(existsSync(join(agentDir, 'spawn-comms', 'task.md'))).toBe(true); expect(existsSync(join(agentDir, 'spawn-comms', 'progress.json'))).toBe(true); expect(existsSync(join(agentDir, 'spawn-comms', 'messages'))).toBe(true); }); it('should generate proper CLAUDE.md for agent', async () => { const parentDir = join(testDir, 'parent'); createTaskFile(parentDir, 'task.md', basicTaskContent); await orchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir); await vi.waitFor(() => { expect(mockSessionCreator.createAgentSession).toHaveBeenCalled(); }); const agentDir = join(testDir, 'spawn-test-agent-001'); const claudeMd = readFileSync(join(agentDir, 'CLAUDE.md'), 'utf-8'); expect(claudeMd).toContain('Agent: Test Agent'); expect(claudeMd).toContain('test-agent-001'); expect(claudeMd).toContain('TEST_DONE'); expect(claudeMd).toContain('# Test Task'); }); it('should emit initializing and started events', async () => { const parentDir = join(testDir, 'parent'); createTaskFile(parentDir, 'task.md', basicTaskContent); const initHandler = vi.fn(); const startHandler = vi.fn(); orchestrator.on('initializing', initHandler); orchestrator.on('started', startHandler); await orchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir); await vi.waitFor(() => { expect(startHandler).toHaveBeenCalled(); }); expect(initHandler).toHaveBeenCalledWith( expect.objectContaining({ agentId: 'test-agent-001', name: 'Test Agent' }) ); expect(startHandler).toHaveBeenCalledWith( expect.objectContaining({ agentId: 'test-agent-001', name: 'Test Agent' }) ); }); it('should enforce timeout limits', async () => { const parentDir = join(testDir, 'parent'); const content = basicTaskContent.replace('timeoutMinutes: 5', 'timeoutMinutes: 999'); createTaskFile(parentDir, 'task.md', content); await orchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir); await vi.waitFor(() => { const status = orchestrator.getAgentStatus('test-agent-001'); expect(status).not.toBeNull(); expect(status!.timeoutMinutes).toBe(10); // Capped at maxTimeoutMinutes }); }); }); describe('Queue Management', () => { it('should queue agents when concurrency limit reached', async () => { const parentDir = join(testDir, 'parent'); // Create 4 tasks (limit is 3) for (let i = 1; i <= 4; i++) { const content = basicTaskContent .replace('test-agent-001', `agent-${i}`) .replace('Test Agent', `Agent ${i}`); createTaskFile(parentDir, `task${i}.md`, content); } const queueHandler = vi.fn(); orchestrator.on('queued', queueHandler); // Spawn 4 agents for (let i = 1; i <= 4; i++) { await orchestrator.handleSpawnRequest(`task${i}.md`, 'parent', parentDir); } // Wait for first 3 to start await vi.waitFor(() => { expect(mockSessionCreator.createAgentSession).toHaveBeenCalledTimes(3); }); // 4th should be queued expect(queueHandler).toHaveBeenCalledWith( expect.objectContaining({ agentId: 'agent-4' }) ); }); it('should order queue by priority', async () => { const parentDir = join(testDir, 'parent'); // Fill concurrency first for (let i = 1; i <= 3; i++) { const content = basicTaskContent .replace('test-agent-001', `filler-${i}`) .replace('Test Agent', `Filler ${i}`); createTaskFile(parentDir, `filler${i}.md`, content); await orchestrator.handleSpawnRequest(`filler${i}.md`, 'parent', parentDir); } await vi.waitFor(() => { expect(mockSessionCreator.createAgentSession).toHaveBeenCalledTimes(3); }); // Now add low and high priority const lowContent = basicTaskContent .replace('test-agent-001', 'low-agent') .replace('priority: normal', 'priority: low'); createTaskFile(parentDir, 'low.md', lowContent); const highContent = basicTaskContent .replace('test-agent-001', 'high-agent') .replace('priority: normal', 'priority: critical'); createTaskFile(parentDir, 'high.md', highContent); await orchestrator.handleSpawnRequest('low.md', 'parent', parentDir); await orchestrator.handleSpawnRequest('high.md', 'parent', parentDir); // State should show high priority first in queue const state = orchestrator.getState(); expect(state.queuedCount).toBe(2); }); }); describe('cancelAgent', () => { it('should cancel a running agent', async () => { const parentDir = join(testDir, 'parent'); createTaskFile(parentDir, 'task.md', basicTaskContent); await orchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir); await vi.waitFor(() => { expect(orchestrator.getAgentStatus('test-agent-001')).not.toBeNull(); }); const cancelHandler = vi.fn(); orchestrator.on('cancelled', cancelHandler); await orchestrator.cancelAgent('test-agent-001', 'User cancelled'); expect(cancelHandler).toHaveBeenCalledWith( expect.objectContaining({ agentId: 'test-agent-001', reason: 'User cancelled' }) ); }); it('should cancel a queued agent', async () => { const parentDir = join(testDir, 'parent'); // Fill concurrency for (let i = 1; i <= 3; i++) { const content = basicTaskContent .replace('test-agent-001', `filler-${i}`) .replace('Test Agent', `Filler ${i}`); createTaskFile(parentDir, `filler${i}.md`, content); await orchestrator.handleSpawnRequest(`filler${i}.md`, 'parent', parentDir); } // Add one more (queued) const queuedContent = basicTaskContent.replace('test-agent-001', 'queued-agent'); createTaskFile(parentDir, 'queued.md', queuedContent); await orchestrator.handleSpawnRequest('queued.md', 'parent', parentDir); const cancelHandler = vi.fn(); orchestrator.on('cancelled', cancelHandler); await orchestrator.cancelAgent('queued-agent'); expect(cancelHandler).toHaveBeenCalledWith( expect.objectContaining({ agentId: 'queued-agent' }) ); }); }); describe('sendMessageToAgent', () => { it('should write message file to comms directory', async () => { const parentDir = join(testDir, 'parent'); createTaskFile(parentDir, 'task.md', basicTaskContent); await orchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir); await vi.waitFor(() => { expect(orchestrator.getAgentStatus('test-agent-001')).not.toBeNull(); }); await orchestrator.sendMessageToAgent('test-agent-001', 'Focus on JWT'); const messagesDir = join(testDir, 'spawn-test-agent-001', 'spawn-comms', 'messages'); expect(existsSync(join(messagesDir, '001-parent.md'))).toBe(true); const content = readFileSync(join(messagesDir, '001-parent.md'), 'utf-8'); expect(content).toBe('Focus on JWT'); }); }); describe('getAgentStatus', () => { it('should return null for unknown agent', () => { expect(orchestrator.getAgentStatus('nonexistent')).toBeNull(); }); it('should return status for active agent', async () => { const parentDir = join(testDir, 'parent'); createTaskFile(parentDir, 'task.md', basicTaskContent); await orchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir); await vi.waitFor(() => { const status = orchestrator.getAgentStatus('test-agent-001'); expect(status).not.toBeNull(); expect(status!.status).toBe('running'); expect(status!.name).toBe('Test Agent'); expect(status!.completionPhrase).toBe('TEST_DONE'); }); }); }); describe('getState', () => { it('should return complete orchestrator state', () => { const state = orchestrator.getState(); expect(state.enabled).toBe(true); expect(state.activeCount).toBe(0); expect(state.queuedCount).toBe(0); expect(state.totalSpawned).toBe(0); expect(state.agents).toEqual([]); }); it('should update counts after spawn', async () => { const parentDir = join(testDir, 'parent'); createTaskFile(parentDir, 'task.md', basicTaskContent); await orchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir); await vi.waitFor(() => { const state = orchestrator.getState(); expect(state.activeCount).toBe(1); expect(state.totalSpawned).toBe(1); }); }); }); describe('readAgentMessages', () => { it('should return empty for unknown agent', () => { expect(orchestrator.readAgentMessages('nonexistent')).toEqual([]); }); it('should read messages from comms directory', async () => { const parentDir = join(testDir, 'parent'); createTaskFile(parentDir, 'task.md', basicTaskContent); await orchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir); await vi.waitFor(() => { expect(orchestrator.getAgentStatus('test-agent-001')).not.toBeNull(); }); // Write a message await orchestrator.sendMessageToAgent('test-agent-001', 'Hello agent'); const messages = orchestrator.readAgentMessages('test-agent-001'); expect(messages).toHaveLength(1); expect(messages[0].sender).toBe('parent'); expect(messages[0].content).toBe('Hello agent'); expect(messages[0].sequence).toBe(1); }); }); describe('triggerSpawn', () => { it('should spawn from content string', async () => { const agentId = await orchestrator.triggerSpawn( basicTaskContent, 'parent-session', testDir ); expect(agentId).toBe('test-agent-001'); await vi.waitFor(() => { expect(mockSessionCreator.createAgentSession).toHaveBeenCalled(); }); }); it('should return null for unparseable content', async () => { const agentId = await orchestrator.triggerSpawn( 'Not valid YAML frontmatter', 'parent-session', testDir ); expect(agentId).toBeNull(); }); }); describe('stopAll', () => { it('should stop all active agents', async () => { const parentDir = join(testDir, 'parent'); createTaskFile(parentDir, 'task.md', basicTaskContent); await orchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir); await vi.waitFor(() => { expect(orchestrator.getState().activeCount).toBe(1); }); await orchestrator.stopAll(); expect(orchestrator.getState().activeCount).toBe(0); }); }); describe('getPersistedState', () => { it('should return serializable state', async () => { const parentDir = join(testDir, 'parent'); createTaskFile(parentDir, 'task.md', basicTaskContent); await orchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir); await vi.waitFor(() => { expect(orchestrator.getState().activeCount).toBe(1); }); const persisted = orchestrator.getPersistedState(); expect(persisted.config).toBeDefined(); expect(persisted.agents['test-agent-001']).toBeDefined(); expect(persisted.agents['test-agent-001'].completionPhrase).toBe('TEST_DONE'); // Should be JSON-serializable expect(() => JSON.stringify(persisted)).not.toThrow(); }); }); // ========== Issue Coverage Tests ========== describe('Cascading Cancellation', () => { it('should cancel child agents when parent is cancelled', async () => { const parentDir = join(testDir, 'parent'); // Create parent agent const parentContent = basicTaskContent .replace('test-agent-001', 'parent-agent') .replace('Test Agent', 'Parent Agent'); createTaskFile(parentDir, 'parent.md', parentContent); await orchestrator.handleSpawnRequest('parent.md', 'user-session', parentDir); await vi.waitFor(() => { expect(orchestrator.getAgentStatus('parent-agent')).not.toBeNull(); }); // Create child agent that depends on parent const childContent = `--- agentId: child-agent name: Child Agent type: explore priority: normal timeoutMinutes: 5 completionPhrase: CHILD_DONE canModifyParentFiles: false --- # Child Task Child agent work.`; createTaskFile(parentDir, 'child.md', childContent); // Spawn child with parent-agent's session as parent // Note: In current implementation, we simulate the parent relationship via parentSessionId await orchestrator.handleSpawnRequest('child.md', 'parent-agent-session', parentDir); await vi.waitFor(() => { expect(orchestrator.getAgentStatus('child-agent')).not.toBeNull(); }); const cancelHandler = vi.fn(); orchestrator.on('cancelled', cancelHandler); // Cancel parent - this SHOULD also cancel child (if cascading is implemented) await orchestrator.cancelAgent('parent-agent', 'User cancelled parent'); // Currently, this test documents the EXPECTED behavior. // The current implementation does NOT cascade cancellations. // If cascading is implemented, uncomment the assertion below: // expect(cancelHandler).toHaveBeenCalledTimes(2); // Current behavior: only parent is cancelled expect(cancelHandler).toHaveBeenCalledWith( expect.objectContaining({ agentId: 'parent-agent', reason: 'User cancelled parent' }) ); // Verify child is still running (documents current buggy behavior) const childStatus = orchestrator.getAgentStatus('child-agent'); // When cascading is fixed, this should be 'cancelled' instead of 'running' expect(childStatus?.status).toBe('running'); }); it('should handle cancellation when no children exist', async () => { const parentDir = join(testDir, 'parent'); createTaskFile(parentDir, 'task.md', basicTaskContent); await orchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir); await vi.waitFor(() => { expect(orchestrator.getAgentStatus('test-agent-001')).not.toBeNull(); }); const cancelHandler = vi.fn(); orchestrator.on('cancelled', cancelHandler); // Cancel agent with no children - should work normally await orchestrator.cancelAgent('test-agent-001', 'Normal cancellation'); expect(cancelHandler).toHaveBeenCalledTimes(1); expect(cancelHandler).toHaveBeenCalledWith( expect.objectContaining({ agentId: 'test-agent-001', reason: 'Normal cancellation' }) ); // Agent should be cleaned up expect(orchestrator.getState().activeCount).toBe(0); }); it('should recursively cancel grandchildren when parent is cancelled', async () => { const parentDir = join(testDir, 'parent-grandchild'); // Create orchestrator with higher concurrency and depth for this test const deepOrchestrator = new SpawnOrchestrator({ casesDir: testDir, maxConcurrentAgents: 5, maxSpawnDepth: 3, defaultTimeoutMinutes: 5, maxTimeoutMinutes: 10, progressPollIntervalMs: 60000, }); deepOrchestrator.setSessionCreator(mockSessionCreator); // Create grandparent agent const grandparentContent = basicTaskContent .replace('test-agent-001', 'grandparent-agent') .replace('Test Agent', 'Grandparent Agent'); createTaskFile(parentDir, 'grandparent.md', grandparentContent); await deepOrchestrator.handleSpawnRequest('grandparent.md', 'user-session', parentDir); await vi.waitFor(() => { expect(deepOrchestrator.getAgentStatus('grandparent-agent')).not.toBeNull(); }); // Create parent agent (child of grandparent) const parentContent = basicTaskContent .replace('test-agent-001', 'parent-agent') .replace('Test Agent', 'Parent Agent'); createTaskFile(parentDir, 'parent.md', parentContent); await deepOrchestrator.handleSpawnRequest('parent.md', 'grandparent-agent-session', parentDir, 1); await vi.waitFor(() => { expect(deepOrchestrator.getAgentStatus('parent-agent')).not.toBeNull(); }); // Create child agent (grandchild of grandparent) const childContent = basicTaskContent .replace('test-agent-001', 'child-agent') .replace('Test Agent', 'Child Agent'); createTaskFile(parentDir, 'child.md', childContent); await deepOrchestrator.handleSpawnRequest('child.md', 'parent-agent-session', parentDir, 2); await vi.waitFor(() => { expect(deepOrchestrator.getAgentStatus('child-agent')).not.toBeNull(); }); // Verify all three agents are running expect(deepOrchestrator.getState().activeCount).toBe(3); const cancelHandler = vi.fn(); deepOrchestrator.on('cancelled', cancelHandler); // Cancel grandparent - this SHOULD cascade to parent and child await deepOrchestrator.cancelAgent('grandparent-agent', 'User cancelled grandparent'); // Document current behavior: only grandparent is cancelled (no cascade) expect(cancelHandler).toHaveBeenCalledWith( expect.objectContaining({ agentId: 'grandparent-agent' }) ); // Current behavior: parent and child are still running (documents the bug) const parentStatus = deepOrchestrator.getAgentStatus('parent-agent'); const childStatus = deepOrchestrator.getAgentStatus('child-agent'); // When cascading is implemented: // expect(parentStatus?.status).toBe('cancelled'); // expect(childStatus?.status).toBe('cancelled'); // expect(cancelHandler).toHaveBeenCalledTimes(3); // Current buggy behavior: expect(parentStatus?.status).toBe('running'); expect(childStatus?.status).toBe('running'); expect(cancelHandler).toHaveBeenCalledTimes(1); // Cleanup await deepOrchestrator.stopAll(); deepOrchestrator.removeAllListeners(); }); }); describe('Resource Budget Validation', () => { it('should handle negative maxTokens in task spec', async () => { const parentDir = join(testDir, 'parent'); // Task with negative maxTokens const content = `--- agentId: negative-tokens-agent name: Negative Tokens Agent type: explore priority: normal timeoutMinutes: 5 completionPhrase: NEG_DONE canModifyParentFiles: false maxTokens: -1000 --- # Test negative tokens`; createTaskFile(parentDir, 'negative.md', content); await orchestrator.handleSpawnRequest('negative.md', 'parent-session', parentDir); await vi.waitFor(() => { const status = orchestrator.getAgentStatus('negative-tokens-agent'); // Current behavior: negative values are accepted (documents the issue) // When validation is added, this should either fail or clamp to 0/null expect(status).not.toBeNull(); }); }); it('should handle zero maxCost in task spec', async () => { const parentDir = join(testDir, 'parent'); // Task with zero maxCost const content = `--- agentId: zero-cost-agent name: Zero Cost Agent type: explore priority: normal timeoutMinutes: 5 completionPhrase: ZERO_DONE canModifyParentFiles: false maxCost: 0 --- # Test zero cost`; createTaskFile(parentDir, 'zero.md', content); await orchestrator.handleSpawnRequest('zero.md', 'parent-session', parentDir); await vi.waitFor(() => { const status = orchestrator.getAgentStatus('zero-cost-agent'); expect(status).not.toBeNull(); // Zero cost budget would immediately trigger 110% threshold check // on first budget check, causing immediate termination // This documents potentially problematic behavior expect(status!.costBudget).toBe(0); }); }); it('should accept valid budget values', async () => { const parentDir = join(testDir, 'parent'); const content = `--- agentId: valid-budget-agent name: Valid Budget Agent type: explore priority: normal timeoutMinutes: 5 completionPhrase: VALID_DONE canModifyParentFiles: false maxTokens: 100000 maxCost: 1.50 --- # Test valid budget`; createTaskFile(parentDir, 'valid.md', content); await orchestrator.handleSpawnRequest('valid.md', 'parent-session', parentDir); await vi.waitFor(() => { const status = orchestrator.getAgentStatus('valid-budget-agent'); expect(status).not.toBeNull(); expect(status!.tokenBudget).toBe(100000); expect(status!.costBudget).toBe(1.50); }); }); }); describe('Queue Dependency Handling', () => { it('should not block independent tasks when one has unmet deps', async () => { const parentDir = join(testDir, 'parent'); // Fill concurrency first for (let i = 1; i <= 3; i++) { const content = basicTaskContent .replace('test-agent-001', `filler-${i}`) .replace('Test Agent', `Filler ${i}`); createTaskFile(parentDir, `filler${i}.md`, content); await orchestrator.handleSpawnRequest(`filler${i}.md`, 'parent', parentDir); } await vi.waitFor(() => { expect(mockSessionCreator.createAgentSession).toHaveBeenCalledTimes(3); }); // Add task A that depends on non-existent Task X const dependentContent = `--- agentId: dependent-agent name: Dependent Agent type: explore priority: normal timeoutMinutes: 5 completionPhrase: DEP_DONE canModifyParentFiles: false dependsOn: - nonexistent-task-x --- # Dependent task`; createTaskFile(parentDir, 'dependent.md', dependentContent); await orchestrator.handleSpawnRequest('dependent.md', 'parent', parentDir); // Add Task B with no dependencies const independentContent = basicTaskContent .replace('test-agent-001', 'independent-agent') .replace('Test Agent', 'Independent Agent'); createTaskFile(parentDir, 'independent.md', independentContent); await orchestrator.handleSpawnRequest('independent.md', 'parent', parentDir); // Both should be queued expect(orchestrator.getState().queuedCount).toBe(2); // Complete one of the filler agents to free up a slot const completionHandler = completionHandlers.get( (mockSessionCreator.createAgentSession as ReturnType).mock.results[0].value.sessionId ); // Simulate completion by triggering cleanup directly await orchestrator.cancelAgent('filler-1', 'Test cleanup'); // Wait for queue processing await vi.waitFor(() => { // Check if independent-agent started // Current buggy behavior: dependent-agent blocks the queue // The test documents this - when fixed, independent-agent should run const state = orchestrator.getState(); // With the bug: queuedCount stays at 2 or decreases but independent doesn't start // When fixed: independent-agent should be running expect(state.activeCount).toBeGreaterThanOrEqual(2); }, { timeout: 1000 }).catch(() => { // Expected to fail with current implementation - documents the bug const state = orchestrator.getState(); // Document current behavior: queue might be stuck console.log('Queue state (documents starvation bug):', { activeCount: state.activeCount, queuedCount: state.queuedCount, }); }); }); }); describe('Timer Cleanup', () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); it('should clear timeout timer on agent completion', async () => { const parentDir = join(testDir, 'parent-timer1'); mkdirSync(parentDir, { recursive: true }); // Use short timeout for testing const content = basicTaskContent.replace('timeoutMinutes: 5', 'timeoutMinutes: 1'); createTaskFile(parentDir, 'task.md', content); // Create a new orchestrator for this test to avoid timer conflicts const timerOrchestrator = new SpawnOrchestrator({ casesDir: testDir, maxConcurrentAgents: 3, maxSpawnDepth: 2, defaultTimeoutMinutes: 5, maxTimeoutMinutes: 10, progressPollIntervalMs: 60000, }); timerOrchestrator.setSessionCreator(mockSessionCreator); // Start the spawn request (this sets up timers) const spawnPromise = timerOrchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir); // Run pending timers and promises await vi.runAllTimersAsync(); await spawnPromise; // Track that timeout event does NOT fire after cancellation const timeoutHandler = vi.fn(); timerOrchestrator.on('timeout', timeoutHandler); // Cancel the agent (which triggers cleanup) await timerOrchestrator.cancelAgent('test-agent-001', 'Test cleanup'); // Advance timers past the timeout period await vi.advanceTimersByTimeAsync(2 * 60 * 1000); // 2 minutes // Timeout should NOT have fired because timer was cleared expect(timeoutHandler).not.toHaveBeenCalled(); timerOrchestrator.removeAllListeners(); }); it('should clear progress timer on cancellation', async () => { const parentDir = join(testDir, 'parent-timer2'); mkdirSync(parentDir, { recursive: true }); // Create orchestrator with fast progress polling const fastPollOrchestrator = new SpawnOrchestrator({ casesDir: testDir, maxConcurrentAgents: 3, maxSpawnDepth: 2, defaultTimeoutMinutes: 5, maxTimeoutMinutes: 10, progressPollIntervalMs: 100, // Fast polling }); fastPollOrchestrator.setSessionCreator(mockSessionCreator); createTaskFile(parentDir, 'task.md', basicTaskContent); const spawnPromise = fastPollOrchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir); await vi.runAllTimersAsync(); await spawnPromise; const progressHandler = vi.fn(); fastPollOrchestrator.on('progress', progressHandler); // Cancel the agent await fastPollOrchestrator.cancelAgent('test-agent-001', 'Test cleanup'); // Clear current call count progressHandler.mockClear(); // Advance time past several poll intervals await vi.advanceTimersByTimeAsync(500); // Progress events should NOT fire after cancellation expect(progressHandler).not.toHaveBeenCalled(); // Cleanup fastPollOrchestrator.removeAllListeners(); }); it('should clear warning timer on early completion', async () => { const parentDir = join(testDir, 'parent-timer3'); mkdirSync(parentDir, { recursive: true }); // Short timeout so warning would fire at ~54 seconds (90% of 1 min) const content = basicTaskContent.replace('timeoutMinutes: 5', 'timeoutMinutes: 1'); createTaskFile(parentDir, 'task.md', content); // Create a fresh orchestrator for this test const warningOrchestrator = new SpawnOrchestrator({ casesDir: testDir, maxConcurrentAgents: 3, maxSpawnDepth: 2, defaultTimeoutMinutes: 5, maxTimeoutMinutes: 10, progressPollIntervalMs: 60000, }); warningOrchestrator.setSessionCreator(mockSessionCreator); const spawnPromise = warningOrchestrator.handleSpawnRequest('task.md', 'parent-session', parentDir); await vi.runAllTimersAsync(); await spawnPromise; // Cancel before warning would fire await warningOrchestrator.cancelAgent('test-agent-001', 'Early completion'); // Clear the mock (mockSessionCreator.writeToSession as ReturnType).mockClear(); // Advance past warning time (54 seconds) await vi.advanceTimersByTimeAsync(60 * 1000); // Warning message should NOT have been sent const writeToSessionCalls = (mockSessionCreator.writeToSession as ReturnType).mock.calls; const warningCalls = writeToSessionCalls.filter( (call: [string, string]) => call[1]?.includes('WARNING') && call[1]?.includes('timeout') ); expect(warningCalls.length).toBe(0); warningOrchestrator.removeAllListeners(); }); }); });