mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
- Stack timers row vertically (timers on top, action log below) - Reduce action log height to 60px since it's now full width - Filter action log to show only important entries: - Commands sent to console - Plan-check with action taken - Step completions - Skip timer starts/cancels, detection updates, ai-check status Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
969 lines
33 KiB
TypeScript
969 lines
33 KiB
TypeScript
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<string, (phrase: string) => 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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mock.calls;
|
|
const warningCalls = writeToSessionCalls.filter(
|
|
(call: [string, string]) => call[1]?.includes('WARNING') && call[1]?.includes('timeout')
|
|
);
|
|
expect(warningCalls.length).toBe(0);
|
|
|
|
warningOrchestrator.removeAllListeners();
|
|
});
|
|
});
|
|
});
|