mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
1. Post-phase verify timer leak — setTimeout for verifyCurrentPhase was never stored, so pause() couldn't cancel it. Timer now tracked in postPhaseTimer field and cleared in clearPhasePoll(). 2. Event forwarding flag survives loop replacement — boolean eventForwardingAttached stayed true when a new loop was created, so the new loop never got SSE forwarding. Now tracks the loop instance reference instead of a boolean. 3. Replan stuck when no sessions — replanPhase() returned without setting up task handlers or polling when no idle sessions were available. Now starts polling so the queued task gets picked up when a session becomes idle. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1609 lines
59 KiB
TypeScript
1609 lines
59 KiB
TypeScript
/**
|
||
* @fileoverview Deep integration tests for OrchestratorLoop state machine.
|
||
*
|
||
* Tests the full lifecycle: start → plan → approve → execute → verify → complete.
|
||
* Uses mocked dependencies (SessionManager, TaskQueue, StateStore, Planner, Verifier)
|
||
* to exercise state transitions, event emissions, error handling, and edge cases.
|
||
*
|
||
* Port: N/A (no HTTP)
|
||
*/
|
||
|
||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||
import { EventEmitter } from 'node:events';
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Mock Setup — must be before imports
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
// Mock session
|
||
function createMockSession(id = 'session-1') {
|
||
const session = new EventEmitter() as EventEmitter & {
|
||
id: string;
|
||
sendInput: ReturnType<typeof vi.fn>;
|
||
assignTask: ReturnType<typeof vi.fn>;
|
||
clearTask: ReturnType<typeof vi.fn>;
|
||
writeViaMux: ReturnType<typeof vi.fn>;
|
||
isIdle: ReturnType<typeof vi.fn>;
|
||
isBusy: ReturnType<typeof vi.fn>;
|
||
};
|
||
session.id = id;
|
||
session.sendInput = vi.fn(async () => {});
|
||
session.assignTask = vi.fn();
|
||
session.clearTask = vi.fn();
|
||
session.writeViaMux = vi.fn(async () => true);
|
||
session.isIdle = vi.fn(() => true);
|
||
session.isBusy = vi.fn(() => false);
|
||
return session;
|
||
}
|
||
|
||
// Mock session manager
|
||
const mockSessionManager = new EventEmitter() as EventEmitter & {
|
||
getIdleSessions: ReturnType<typeof vi.fn>;
|
||
getAllSessions: ReturnType<typeof vi.fn>;
|
||
getSession: ReturnType<typeof vi.fn>;
|
||
};
|
||
mockSessionManager.getIdleSessions = vi.fn(() => [createMockSession()]);
|
||
mockSessionManager.getAllSessions = vi.fn(() => [createMockSession()]);
|
||
mockSessionManager.getSession = vi.fn(() => createMockSession());
|
||
|
||
vi.mock('../src/session-manager.js', () => ({
|
||
getSessionManager: () => mockSessionManager,
|
||
SessionManager: vi.fn(),
|
||
}));
|
||
|
||
// Mock task queue with real Task-like objects
|
||
let taskIdCounter = 0;
|
||
function createMockTask(options: { prompt: string; completionPhrase?: string }) {
|
||
const id = `task-${++taskIdCounter}`;
|
||
let status = 'pending' as string;
|
||
let assignedSessionId: string | null = null;
|
||
let error: string | null = null;
|
||
return {
|
||
id,
|
||
prompt: options.prompt,
|
||
completionPhrase: options.completionPhrase,
|
||
status,
|
||
assignedSessionId,
|
||
error,
|
||
assign(sessionId: string) {
|
||
status = 'running';
|
||
assignedSessionId = sessionId;
|
||
this.status = status;
|
||
this.assignedSessionId = assignedSessionId;
|
||
},
|
||
fail(err?: string) {
|
||
status = 'failed';
|
||
error = err || 'Unknown error';
|
||
this.status = status;
|
||
this.error = error;
|
||
},
|
||
complete() {
|
||
status = 'completed';
|
||
this.status = status;
|
||
},
|
||
isCompleted() {
|
||
return this.status === 'completed';
|
||
},
|
||
isFailed() {
|
||
return this.status === 'failed';
|
||
},
|
||
isPending() {
|
||
return this.status === 'pending';
|
||
},
|
||
};
|
||
}
|
||
|
||
const mockTaskQueue = new EventEmitter() as EventEmitter & {
|
||
addTask: ReturnType<typeof vi.fn>;
|
||
getTask: ReturnType<typeof vi.fn>;
|
||
next: ReturnType<typeof vi.fn>;
|
||
updateTask: ReturnType<typeof vi.fn>;
|
||
_tasks: Map<string, ReturnType<typeof createMockTask>>;
|
||
};
|
||
mockTaskQueue._tasks = new Map();
|
||
mockTaskQueue.addTask = vi.fn((options) => {
|
||
const task = createMockTask(options);
|
||
mockTaskQueue._tasks.set(task.id, task);
|
||
return task;
|
||
});
|
||
mockTaskQueue.getTask = vi.fn((id: string) => mockTaskQueue._tasks.get(id));
|
||
mockTaskQueue.next = vi.fn(() => null);
|
||
mockTaskQueue.updateTask = vi.fn();
|
||
|
||
vi.mock('../src/task-queue.js', () => ({
|
||
getTaskQueue: () => mockTaskQueue,
|
||
TaskQueue: vi.fn(),
|
||
}));
|
||
|
||
// Mock state store
|
||
const mockStore = {
|
||
getOrchestratorState: vi.fn(() => null),
|
||
setOrchestratorState: vi.fn(),
|
||
clearOrchestratorState: vi.fn(),
|
||
};
|
||
|
||
vi.mock('../src/state-store.js', () => ({
|
||
getStore: () => mockStore,
|
||
StateStore: vi.fn(),
|
||
}));
|
||
|
||
// Mock planner & verifier — vi.hoisted() so they exist when vi.mock factories run
|
||
const { mockPlannerInstance, mockVerifierInstance } = vi.hoisted(() => ({
|
||
mockPlannerInstance: {
|
||
generatePlan: vi.fn(),
|
||
cancel: vi.fn().mockResolvedValue(undefined),
|
||
},
|
||
mockVerifierInstance: {
|
||
verifyPhase: vi.fn(),
|
||
},
|
||
}));
|
||
|
||
vi.mock('../src/orchestrator-planner.js', () => ({
|
||
OrchestratorPlanner: vi.fn().mockImplementation(function () {
|
||
return mockPlannerInstance;
|
||
}),
|
||
}));
|
||
|
||
vi.mock('../src/orchestrator-verifier.js', () => ({
|
||
OrchestratorVerifier: vi.fn().mockImplementation(function () {
|
||
return mockVerifierInstance;
|
||
}),
|
||
}));
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Import after mocks
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
import { OrchestratorLoop } from '../src/orchestrator-loop.js';
|
||
import type { OrchestratorPlan } from '../src/types.js';
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Test helpers
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
function createTestPlan(overrides?: Partial<OrchestratorPlan>): OrchestratorPlan {
|
||
return {
|
||
id: 'plan-1',
|
||
goal: 'Build a REST API',
|
||
createdAt: Date.now(),
|
||
phases: [
|
||
{
|
||
id: 'phase-1',
|
||
name: 'Phase 1: Setup',
|
||
description: 'Setup the project',
|
||
order: 0,
|
||
status: 'pending',
|
||
tasks: [
|
||
{
|
||
id: 'phase-1-task-1',
|
||
phaseId: 'phase-1',
|
||
prompt: 'Create project structure',
|
||
status: 'pending',
|
||
assignedSessionId: null,
|
||
queueTaskId: null,
|
||
parallel: false,
|
||
completionPhrase: 'ORCH_P1_T1',
|
||
timeoutMs: 600000,
|
||
startedAt: null,
|
||
completedAt: null,
|
||
error: null,
|
||
retries: 0,
|
||
},
|
||
],
|
||
verificationCriteria: [],
|
||
testCommands: [],
|
||
maxAttempts: 3,
|
||
attempts: 0,
|
||
startedAt: null,
|
||
completedAt: null,
|
||
durationMs: null,
|
||
teamStrategy: { type: 'single' },
|
||
},
|
||
{
|
||
id: 'phase-2',
|
||
name: 'Phase 2: Implementation',
|
||
description: 'Implement the API',
|
||
order: 1,
|
||
status: 'pending',
|
||
tasks: [
|
||
{
|
||
id: 'phase-2-task-1',
|
||
phaseId: 'phase-2',
|
||
prompt: 'Implement endpoints',
|
||
status: 'pending',
|
||
assignedSessionId: null,
|
||
queueTaskId: null,
|
||
parallel: false,
|
||
completionPhrase: 'ORCH_P2_T1',
|
||
timeoutMs: 600000,
|
||
startedAt: null,
|
||
completedAt: null,
|
||
error: null,
|
||
retries: 0,
|
||
},
|
||
],
|
||
verificationCriteria: ['All endpoints respond with 200'],
|
||
testCommands: ['npm test'],
|
||
maxAttempts: 3,
|
||
attempts: 0,
|
||
startedAt: null,
|
||
completedAt: null,
|
||
durationMs: null,
|
||
teamStrategy: { type: 'single' },
|
||
},
|
||
],
|
||
metadata: {
|
||
totalTasks: 2,
|
||
estimatedComplexity: 'low',
|
||
modelUsed: 'opus',
|
||
planDurationMs: 1000,
|
||
},
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function createMockMux() {
|
||
return {
|
||
createSession: vi.fn(),
|
||
killSession: vi.fn(),
|
||
listSessions: vi.fn(() => []),
|
||
sendInput: vi.fn(async () => true),
|
||
} as never;
|
||
}
|
||
|
||
// Wait for a specific event to be emitted
|
||
function waitForEvent(emitter: EventEmitter, event: string, timeout = 5000): Promise<unknown[]> {
|
||
return new Promise((resolve, reject) => {
|
||
const timer = setTimeout(() => reject(new Error(`Timeout waiting for event "${event}"`)), timeout);
|
||
emitter.once(event, (...args: unknown[]) => {
|
||
clearTimeout(timer);
|
||
resolve(args);
|
||
});
|
||
});
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Tests
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
describe('OrchestratorLoop', () => {
|
||
let loop: OrchestratorLoop;
|
||
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
taskIdCounter = 0;
|
||
mockTaskQueue._tasks.clear();
|
||
mockStore.getOrchestratorState.mockReturnValue(null);
|
||
mockSessionManager.getIdleSessions.mockReturnValue([createMockSession()]);
|
||
loop = new OrchestratorLoop(createMockMux(), '/test/dir');
|
||
});
|
||
|
||
afterEach(() => {
|
||
loop.destroy();
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Initial State
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
describe('initial state', () => {
|
||
it('starts in idle state', () => {
|
||
expect(loop.state).toBe('idle');
|
||
expect(loop.isRunning()).toBe(false);
|
||
});
|
||
|
||
it('has no plan', () => {
|
||
expect(loop.getPlan()).toBeNull();
|
||
expect(loop.getCurrentPhase()).toBeNull();
|
||
});
|
||
|
||
it('has zeroed stats', () => {
|
||
const stats = loop.getStats();
|
||
expect(stats.phasesCompleted).toBe(0);
|
||
expect(stats.phasesFailed).toBe(0);
|
||
expect(stats.totalTasksCompleted).toBe(0);
|
||
expect(stats.totalTasksFailed).toBe(0);
|
||
});
|
||
|
||
it('reports full status', () => {
|
||
const status = loop.getStatus();
|
||
expect(status.state).toBe('idle');
|
||
expect(status.plan).toBeNull();
|
||
expect(status.currentPhaseIndex).toBe(0);
|
||
expect(status.startedAt).toBeNull();
|
||
});
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// start() — Planning
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
describe('start()', () => {
|
||
it('transitions to planning state', async () => {
|
||
const plan = createTestPlan();
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
|
||
const stateChanges: string[] = [];
|
||
loop.on('stateChanged', (state) => stateChanges.push(state));
|
||
|
||
await loop.start('Build a REST API');
|
||
|
||
expect(stateChanges).toContain('planning');
|
||
expect(stateChanges).toContain('approval');
|
||
expect(loop.state).toBe('approval');
|
||
});
|
||
|
||
it('stores the generated plan', async () => {
|
||
const plan = createTestPlan();
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
|
||
await loop.start('Build a REST API');
|
||
|
||
expect(loop.getPlan()).toBeTruthy();
|
||
expect(loop.getPlan()!.goal).toBe('Build a REST API');
|
||
expect(loop.getPlan()!.phases).toHaveLength(2);
|
||
});
|
||
|
||
it('emits planReady event', async () => {
|
||
const plan = createTestPlan();
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
|
||
const planReadyPromise = waitForEvent(loop, 'planReady');
|
||
await loop.start('Build a REST API');
|
||
const [emittedPlan] = await planReadyPromise;
|
||
|
||
expect(emittedPlan).toBeTruthy();
|
||
});
|
||
|
||
it('persists state during planning', async () => {
|
||
const plan = createTestPlan();
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
|
||
await loop.start('Build a REST API');
|
||
|
||
expect(mockStore.setOrchestratorState).toHaveBeenCalled();
|
||
});
|
||
|
||
it('auto-approves when configured', async () => {
|
||
loop.destroy();
|
||
loop = new OrchestratorLoop(createMockMux(), '/test/dir', { autoApprove: true });
|
||
|
||
const plan = createTestPlan({ phases: [] }); // Empty phases = immediate completion
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
|
||
await loop.start('Quick task');
|
||
|
||
// Should skip approval and go straight to executing then completed
|
||
expect(loop.state).toBe('completed');
|
||
});
|
||
|
||
it('transitions to failed on planner error', async () => {
|
||
mockPlannerInstance.generatePlan.mockRejectedValue(new Error('Planner crashed'));
|
||
|
||
// Must listen for 'error' events — Node throws unhandled 'error' events on EventEmitters
|
||
const errors: Error[] = [];
|
||
loop.on('error', (err) => errors.push(err));
|
||
|
||
await loop.start('Bad goal');
|
||
|
||
expect(loop.state).toBe('failed');
|
||
expect(errors).toHaveLength(1);
|
||
expect(errors[0].message).toBe('Planner crashed');
|
||
});
|
||
|
||
it('rejects start from invalid states', async () => {
|
||
const plan = createTestPlan();
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
|
||
await loop.start('Goal');
|
||
// Now in 'approval' state
|
||
|
||
await expect(loop.start('Another goal')).rejects.toThrow('Cannot start from state');
|
||
});
|
||
|
||
it('allows restart from failed state', async () => {
|
||
mockPlannerInstance.generatePlan.mockRejectedValue(new Error('Failed'));
|
||
loop.on('error', () => {}); // Suppress unhandled error throw
|
||
await loop.start('Fail');
|
||
expect(loop.state).toBe('failed');
|
||
|
||
const plan = createTestPlan();
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
await loop.start('Retry');
|
||
expect(loop.state).toBe('approval');
|
||
});
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// approve() / reject()
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
describe('approve()', () => {
|
||
it('transitions from approval to executing', async () => {
|
||
const plan = createTestPlan();
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
|
||
await loop.start('Goal');
|
||
expect(loop.state).toBe('approval');
|
||
|
||
// approve starts execution asynchronously
|
||
const approvePromise = loop.approve();
|
||
|
||
// Need to wait for the first phase to start executing
|
||
await approvePromise;
|
||
|
||
expect(loop.state).toBe('executing');
|
||
expect(loop.isRunning()).toBe(true);
|
||
});
|
||
|
||
it('rejects approve from non-approval state', async () => {
|
||
await expect(loop.approve()).rejects.toThrow('Expected state "approval"');
|
||
});
|
||
});
|
||
|
||
describe('reject()', () => {
|
||
it('transitions back to planning with feedback', async () => {
|
||
const plan1 = createTestPlan({ id: 'plan-1' });
|
||
const plan2 = createTestPlan({ id: 'plan-2' });
|
||
mockPlannerInstance.generatePlan.mockResolvedValueOnce(plan1).mockResolvedValueOnce(plan2);
|
||
|
||
await loop.start('Goal');
|
||
expect(loop.state).toBe('approval');
|
||
|
||
await loop.reject('Add more tests');
|
||
|
||
expect(loop.state).toBe('approval');
|
||
// Planner should be called with goal + feedback
|
||
expect(mockPlannerInstance.generatePlan).toHaveBeenCalledTimes(2);
|
||
const secondCallGoal = mockPlannerInstance.generatePlan.mock.calls[1][0];
|
||
expect(secondCallGoal).toContain('Add more tests');
|
||
});
|
||
|
||
it('rejects reject from non-approval state', async () => {
|
||
await expect(loop.reject('feedback')).rejects.toThrow('Expected state "approval"');
|
||
});
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Phase Execution
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
describe('phase execution', () => {
|
||
it('creates task queue entries for phase tasks', async () => {
|
||
const plan = createTestPlan();
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
|
||
await loop.start('Goal');
|
||
await loop.approve();
|
||
|
||
// addTask should have been called for the first phase's task
|
||
expect(mockTaskQueue.addTask).toHaveBeenCalled();
|
||
const addTaskCall = mockTaskQueue.addTask.mock.calls[0][0];
|
||
expect(addTaskCall.completionPhrase).toBe('ORCH_P1_T1');
|
||
});
|
||
|
||
it('emits phaseStarted event', async () => {
|
||
const plan = createTestPlan();
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
|
||
await loop.start('Goal');
|
||
|
||
const phaseStartedPromise = waitForEvent(loop, 'phaseStarted');
|
||
await loop.approve();
|
||
const [phase] = await phaseStartedPromise;
|
||
|
||
expect((phase as { id: string }).id).toBe('phase-1');
|
||
});
|
||
|
||
it('increments phase attempts', async () => {
|
||
const plan = createTestPlan();
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
|
||
await loop.start('Goal');
|
||
await loop.approve();
|
||
|
||
const currentPhase = loop.getCurrentPhase();
|
||
expect(currentPhase).toBeTruthy();
|
||
expect(currentPhase!.attempts).toBe(1);
|
||
expect(currentPhase!.status).toBe('executing');
|
||
});
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// pause() / resume()
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
describe('pause()', () => {
|
||
it('transitions to paused state', async () => {
|
||
const plan = createTestPlan();
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
|
||
await loop.start('Goal');
|
||
await loop.approve();
|
||
expect(loop.state).toBe('executing');
|
||
|
||
loop.pause();
|
||
expect(loop.state).toBe('paused');
|
||
expect(loop.isRunning()).toBe(true); // Still "running" in broader sense
|
||
});
|
||
|
||
it('is a no-op from idle/completed/failed', () => {
|
||
loop.pause(); // Should not throw
|
||
expect(loop.state).toBe('idle');
|
||
});
|
||
|
||
it('cleans up listeners on pause', async () => {
|
||
const plan = createTestPlan();
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
|
||
await loop.start('Goal');
|
||
await loop.approve();
|
||
|
||
// Listeners should be active
|
||
const listenerCount = mockSessionManager.listenerCount('sessionCompletion');
|
||
|
||
loop.pause();
|
||
|
||
// After pause, listener should be removed
|
||
expect(mockSessionManager.listenerCount('sessionCompletion')).toBeLessThan(listenerCount);
|
||
});
|
||
|
||
it('cancels pending verify timer on pause', async () => {
|
||
const plan = createTestPlan();
|
||
plan.phases = [plan.phases[0]]; // No verification criteria
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
|
||
// Tasks complete immediately — triggers post-phase delay timer
|
||
mockTaskQueue.addTask.mockImplementation((options) => {
|
||
const task = createMockTask(options);
|
||
task.complete();
|
||
mockTaskQueue._tasks.set(task.id, task);
|
||
return task;
|
||
});
|
||
|
||
await loop.start('Goal');
|
||
await loop.approve();
|
||
|
||
// Wait for poll to detect completion (2s) but pause before verify runs (1s delay)
|
||
await new Promise((resolve) => setTimeout(resolve, 2500));
|
||
loop.pause();
|
||
expect(loop.state).toBe('paused');
|
||
|
||
// Wait past where verify would have fired — state should still be paused
|
||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||
expect(loop.state).toBe('paused');
|
||
});
|
||
});
|
||
|
||
describe('resume()', () => {
|
||
it('resumes executing state', async () => {
|
||
const plan = createTestPlan();
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
|
||
await loop.start('Goal');
|
||
await loop.approve();
|
||
loop.pause();
|
||
expect(loop.state).toBe('paused');
|
||
|
||
await loop.resume();
|
||
expect(loop.state).toBe('executing');
|
||
});
|
||
|
||
it('rejects resume when not paused', async () => {
|
||
await expect(loop.resume()).rejects.toThrow('Not paused');
|
||
});
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// stop()
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
describe('stop()', () => {
|
||
it('returns to idle state', async () => {
|
||
const plan = createTestPlan();
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
|
||
await loop.start('Goal');
|
||
await loop.stop();
|
||
|
||
expect(loop.state).toBe('idle');
|
||
expect(loop.isRunning()).toBe(false);
|
||
});
|
||
|
||
it('cancels planner', async () => {
|
||
await loop.stop();
|
||
expect(mockPlannerInstance.cancel).toHaveBeenCalled();
|
||
});
|
||
|
||
it('clears persisted state', async () => {
|
||
await loop.stop();
|
||
expect(mockStore.clearOrchestratorState).toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// skipPhase() / retryPhase()
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
describe('skipPhase()', () => {
|
||
it('marks phase as skipped', async () => {
|
||
const plan = createTestPlan();
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
|
||
await loop.start('Goal');
|
||
await loop.approve();
|
||
|
||
await loop.skipPhase('phase-1');
|
||
|
||
const phase = loop.getPlan()!.phases[0];
|
||
expect(phase.status).toBe('skipped');
|
||
expect(phase.completedAt).toBeTruthy();
|
||
});
|
||
|
||
it('throws for unknown phase', async () => {
|
||
const plan = createTestPlan();
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
|
||
await loop.start('Goal');
|
||
|
||
await expect(loop.skipPhase('nonexistent')).rejects.toThrow('not found');
|
||
});
|
||
});
|
||
|
||
describe('retryPhase()', () => {
|
||
it('resets phase tasks and re-executes', async () => {
|
||
const plan = createTestPlan();
|
||
plan.phases[0].status = 'failed';
|
||
plan.phases[0].tasks[0].status = 'failed';
|
||
plan.phases[0].tasks[0].error = 'Something broke';
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
|
||
await loop.start('Goal');
|
||
await loop.approve();
|
||
|
||
// The phase was pre-failed, so retrying should work
|
||
await loop.retryPhase('phase-1');
|
||
|
||
const phase = loop.getPlan()!.phases[0];
|
||
expect(phase.tasks[0].status).not.toBe('failed');
|
||
expect(phase.tasks[0].error).toBeNull();
|
||
});
|
||
|
||
it('rejects from invalid states', async () => {
|
||
const plan = createTestPlan();
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
|
||
await loop.start('Goal');
|
||
// In approval state — can't retry
|
||
|
||
await expect(loop.retryPhase('phase-1')).rejects.toThrow('Cannot retry from state');
|
||
});
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Verification Flow
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
describe('verification', () => {
|
||
it('skips verification for phases with no criteria', async () => {
|
||
// Phase 1 has no verificationCriteria, so it should pass automatically
|
||
const plan = createTestPlan();
|
||
plan.phases = [plan.phases[0]]; // Only phase 1 (no criteria)
|
||
|
||
// Mock immediate task completion via pollPhaseStatus
|
||
mockTaskQueue.addTask.mockImplementation((options) => {
|
||
const task = createMockTask(options);
|
||
task.complete(); // Immediately complete
|
||
mockTaskQueue._tasks.set(task.id, task);
|
||
return task;
|
||
});
|
||
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
|
||
// Create a fresh auto-approve loop
|
||
loop.destroy();
|
||
loop = new OrchestratorLoop(createMockMux(), '/test/dir', { autoApprove: true });
|
||
|
||
await loop.start('Goal');
|
||
|
||
// Wait for poll (2s) + post-phase delay (1s) + buffer
|
||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||
|
||
// Phase 1 has no criteria → should pass without verification
|
||
expect(loop.state).toBe('completed');
|
||
});
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// State Persistence & Recovery
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
describe('persistence', () => {
|
||
it('restores failed state on crash recovery', () => {
|
||
mockStore.getOrchestratorState.mockReturnValue({
|
||
state: 'executing',
|
||
plan: createTestPlan(),
|
||
currentPhaseIndex: 0,
|
||
startedAt: Date.now() - 10000,
|
||
completedAt: null,
|
||
config: {
|
||
plannerModel: 'opus',
|
||
autoApprove: false,
|
||
maxPhaseRetries: 3,
|
||
phaseTimeoutMs: 1800000,
|
||
enableTeamAgents: true,
|
||
maxParallelSessions: 3,
|
||
verificationMode: 'moderate',
|
||
compactBetweenPhases: true,
|
||
researchEnabled: true,
|
||
},
|
||
stats: {
|
||
phasesCompleted: 0,
|
||
phasesFailed: 0,
|
||
totalTasksCompleted: 0,
|
||
totalTasksFailed: 0,
|
||
totalDurationMs: 0,
|
||
replanCount: 0,
|
||
},
|
||
});
|
||
|
||
const recovered = new OrchestratorLoop(createMockMux(), '/test/dir');
|
||
expect(recovered.state).toBe('failed'); // Executing at crash → failed
|
||
expect(recovered.getPlan()).toBeTruthy();
|
||
recovered.destroy();
|
||
});
|
||
|
||
it('clears planning state on crash recovery', () => {
|
||
mockStore.getOrchestratorState.mockReturnValue({
|
||
state: 'planning',
|
||
plan: null,
|
||
currentPhaseIndex: 0,
|
||
startedAt: Date.now(),
|
||
completedAt: null,
|
||
config: {
|
||
plannerModel: 'opus',
|
||
autoApprove: false,
|
||
maxPhaseRetries: 3,
|
||
phaseTimeoutMs: 1800000,
|
||
enableTeamAgents: true,
|
||
maxParallelSessions: 3,
|
||
verificationMode: 'moderate',
|
||
compactBetweenPhases: true,
|
||
researchEnabled: true,
|
||
},
|
||
stats: {
|
||
phasesCompleted: 0,
|
||
phasesFailed: 0,
|
||
totalTasksCompleted: 0,
|
||
totalTasksFailed: 0,
|
||
totalDurationMs: 0,
|
||
replanCount: 0,
|
||
},
|
||
});
|
||
|
||
const recovered = new OrchestratorLoop(createMockMux(), '/test/dir');
|
||
expect(recovered.state).toBe('idle'); // Planning at crash → idle
|
||
expect(mockStore.clearOrchestratorState).toHaveBeenCalled();
|
||
recovered.destroy();
|
||
});
|
||
|
||
it('preserves completed state on recovery', () => {
|
||
mockStore.getOrchestratorState.mockReturnValue({
|
||
state: 'completed',
|
||
plan: createTestPlan(),
|
||
currentPhaseIndex: 2,
|
||
startedAt: Date.now() - 60000,
|
||
completedAt: Date.now(),
|
||
config: {
|
||
plannerModel: 'opus',
|
||
autoApprove: false,
|
||
maxPhaseRetries: 3,
|
||
phaseTimeoutMs: 1800000,
|
||
enableTeamAgents: true,
|
||
maxParallelSessions: 3,
|
||
verificationMode: 'moderate',
|
||
compactBetweenPhases: true,
|
||
researchEnabled: true,
|
||
},
|
||
stats: {
|
||
phasesCompleted: 2,
|
||
phasesFailed: 0,
|
||
totalTasksCompleted: 2,
|
||
totalTasksFailed: 0,
|
||
totalDurationMs: 60000,
|
||
replanCount: 0,
|
||
},
|
||
});
|
||
|
||
const recovered = new OrchestratorLoop(createMockMux(), '/test/dir');
|
||
expect(recovered.state).toBe('completed');
|
||
expect(recovered.getPlan()).toBeTruthy();
|
||
expect(recovered.getStats().phasesCompleted).toBe(2);
|
||
recovered.destroy();
|
||
});
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Event Emissions
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
describe('event emissions', () => {
|
||
it('emits stateChanged for every transition', async () => {
|
||
const plan = createTestPlan();
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
|
||
const transitions: Array<{ state: string; prevState: string }> = [];
|
||
loop.on('stateChanged', (state, prevState) => {
|
||
transitions.push({ state, prevState });
|
||
});
|
||
|
||
await loop.start('Goal');
|
||
|
||
// idle → planning → approval
|
||
expect(transitions).toEqual([
|
||
{ state: 'planning', prevState: 'idle' },
|
||
{ state: 'approval', prevState: 'planning' },
|
||
]);
|
||
});
|
||
|
||
it('emits error event on failure', async () => {
|
||
mockPlannerInstance.generatePlan.mockRejectedValue(new Error('Boom'));
|
||
|
||
const errorPromise = waitForEvent(loop, 'error');
|
||
await loop.start('Fail');
|
||
const [error] = await errorPromise;
|
||
|
||
expect((error as Error).message).toBe('Boom');
|
||
});
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Team Phase Execution
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
describe('team phase execution', () => {
|
||
it('sends team lead prompt for team strategy phases', async () => {
|
||
const plan = createTestPlan();
|
||
plan.phases[0].teamStrategy = {
|
||
type: 'team',
|
||
config: {
|
||
leadPrompt: 'Lead prompt',
|
||
suggestedTeammates: ['Specialist 1', 'Specialist 2'],
|
||
maxTeammates: 2,
|
||
},
|
||
};
|
||
plan.phases[0].tasks.push({
|
||
id: 'phase-1-task-2',
|
||
phaseId: 'phase-1',
|
||
prompt: 'Second task',
|
||
status: 'pending',
|
||
assignedSessionId: null,
|
||
queueTaskId: null,
|
||
parallel: true,
|
||
completionPhrase: 'ORCH_P1_T2',
|
||
timeoutMs: 600000,
|
||
startedAt: null,
|
||
completedAt: null,
|
||
error: null,
|
||
retries: 0,
|
||
});
|
||
|
||
const mockSession = createMockSession();
|
||
mockSessionManager.getIdleSessions.mockReturnValue([mockSession]);
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
|
||
await loop.start('Goal');
|
||
await loop.approve();
|
||
|
||
// Should have sent a team lead prompt to the session
|
||
expect(mockSession.sendInput).toHaveBeenCalled();
|
||
const sentPrompt = mockSession.sendInput.mock.calls[0][0];
|
||
expect(sentPrompt).toContain('team lead');
|
||
});
|
||
|
||
it('throws when no idle sessions for team phase', async () => {
|
||
const plan = createTestPlan();
|
||
plan.phases[0].teamStrategy = {
|
||
type: 'team',
|
||
config: {
|
||
leadPrompt: 'Lead prompt',
|
||
suggestedTeammates: ['Specialist'],
|
||
maxTeammates: 1,
|
||
},
|
||
};
|
||
|
||
mockSessionManager.getIdleSessions.mockReturnValue([]);
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
|
||
await loop.start('Goal');
|
||
await loop.approve();
|
||
|
||
// Should fail because no sessions available
|
||
// Wait for state to settle
|
||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||
expect(loop.state).toBe('failed');
|
||
});
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Complete Lifecycle
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
describe('complete lifecycle (auto-approve, single phase, no verification)', () => {
|
||
it('runs start → plan → execute → complete', async () => {
|
||
const plan = createTestPlan();
|
||
// Single phase with no verification criteria
|
||
plan.phases = [plan.phases[0]];
|
||
|
||
loop.destroy();
|
||
loop = new OrchestratorLoop(createMockMux(), '/test/dir', { autoApprove: true });
|
||
|
||
// Make tasks complete immediately
|
||
mockTaskQueue.addTask.mockImplementation((options) => {
|
||
const task = createMockTask(options);
|
||
task.complete();
|
||
mockTaskQueue._tasks.set(task.id, task);
|
||
return task;
|
||
});
|
||
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
|
||
const completedPromise = waitForEvent(loop, 'completed', 10000);
|
||
await loop.start('Simple goal');
|
||
|
||
const [stats] = await completedPromise;
|
||
expect(loop.state).toBe('completed');
|
||
expect((stats as { phasesCompleted: number }).phasesCompleted).toBe(1);
|
||
});
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Edge Cases
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
describe('edge cases', () => {
|
||
it('handles empty plan (no phases)', async () => {
|
||
const plan = createTestPlan({ phases: [] });
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
|
||
loop.destroy();
|
||
loop = new OrchestratorLoop(createMockMux(), '/test/dir', { autoApprove: true });
|
||
|
||
await loop.start('Empty');
|
||
|
||
expect(loop.state).toBe('completed');
|
||
});
|
||
|
||
it('destroy cleans up timers', async () => {
|
||
const plan = createTestPlan();
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
|
||
await loop.start('Goal');
|
||
await loop.approve();
|
||
|
||
// Should not throw
|
||
loop.destroy();
|
||
});
|
||
|
||
it('multiple stop calls are safe', async () => {
|
||
await loop.stop();
|
||
await loop.stop();
|
||
await loop.stop();
|
||
expect(loop.state).toBe('idle');
|
||
});
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Task Failure & Retry (handleTaskFailed)
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
describe('task failure & retry', () => {
|
||
it('retries a failed task when retries < 2', async () => {
|
||
const plan = createTestPlan();
|
||
plan.phases = [plan.phases[0]]; // Single phase
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
|
||
// First addTask returns a task that will "fail" on the first poll
|
||
let callCount = 0;
|
||
mockTaskQueue.addTask.mockImplementation((options) => {
|
||
callCount++;
|
||
const task = createMockTask(options);
|
||
if (callCount === 1) {
|
||
// First task: mark as failed
|
||
task.fail('Transient error');
|
||
} else {
|
||
// Retry: mark as completed
|
||
task.complete();
|
||
}
|
||
mockTaskQueue._tasks.set(task.id, task);
|
||
return task;
|
||
});
|
||
|
||
loop.destroy();
|
||
loop = new OrchestratorLoop(createMockMux(), '/test/dir', { autoApprove: true });
|
||
loop.on('error', () => {}); // Suppress unhandled errors
|
||
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
await loop.start('Goal');
|
||
|
||
// Wait for poll to detect failure, increment retries, re-queue, and detect completion
|
||
await new Promise((resolve) => setTimeout(resolve, 8000));
|
||
|
||
// The task should have been retried (addTask called more than once)
|
||
expect(callCount).toBeGreaterThan(1);
|
||
});
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Phase Error Retry (handlePhaseError auto-retry)
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
describe('phase error auto-retry', () => {
|
||
it('retries phase when task fails and retries exhaust within a phase', async () => {
|
||
const plan = createTestPlan();
|
||
plan.phases = [plan.phases[0]];
|
||
plan.phases[0].maxAttempts = 3;
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
|
||
// Task always fails — this triggers task retries (up to 2), then phase error
|
||
// Phase error handler sees attempts(1) < maxAttempts(3), resets tasks, re-executes
|
||
let addTaskCallCount = 0;
|
||
mockTaskQueue.addTask.mockImplementation((options) => {
|
||
addTaskCallCount++;
|
||
const task = createMockTask(options);
|
||
// First few calls: always fail. After enough calls: succeed (so test eventually ends)
|
||
if (addTaskCallCount <= 4) {
|
||
task.fail('Persistent error');
|
||
} else {
|
||
task.complete();
|
||
}
|
||
mockTaskQueue._tasks.set(task.id, task);
|
||
return task;
|
||
});
|
||
|
||
loop.destroy();
|
||
loop = new OrchestratorLoop(createMockMux(), '/test/dir', { autoApprove: true });
|
||
loop.on('error', () => {});
|
||
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
await loop.start('Goal');
|
||
|
||
// Wait for: task fail × 3 (retry twice) → phase error → phase retry → task fail × 1 → finally succeed
|
||
await new Promise((resolve) => setTimeout(resolve, 20000));
|
||
|
||
// addTask should have been called multiple times (initial + retries + phase retry)
|
||
expect(addTaskCallCount).toBeGreaterThan(2);
|
||
});
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Verification with Criteria
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
describe('verification with criteria', () => {
|
||
it('runs verifier when phase has criteria and passes', async () => {
|
||
const plan = createTestPlan();
|
||
// Use phase 2 which has verification criteria
|
||
plan.phases = [plan.phases[1]];
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
|
||
// Tasks complete immediately
|
||
mockTaskQueue.addTask.mockImplementation((options) => {
|
||
const task = createMockTask(options);
|
||
task.complete();
|
||
mockTaskQueue._tasks.set(task.id, task);
|
||
return task;
|
||
});
|
||
|
||
// Verifier passes
|
||
mockVerifierInstance.verifyPhase.mockResolvedValue({
|
||
passed: true,
|
||
summary: 'All checks passed',
|
||
checks: [],
|
||
suggestions: [],
|
||
});
|
||
|
||
loop.destroy();
|
||
loop = new OrchestratorLoop(createMockMux(), '/test/dir', { autoApprove: true });
|
||
|
||
const events: string[] = [];
|
||
loop.on('stateChanged', (state: string) => events.push(state));
|
||
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
await loop.start('Goal');
|
||
|
||
// Wait for poll + verification
|
||
await new Promise((resolve) => setTimeout(resolve, 8000));
|
||
|
||
expect(mockVerifierInstance.verifyPhase).toHaveBeenCalled();
|
||
expect(events).toContain('verifying');
|
||
expect(loop.state).toBe('completed');
|
||
});
|
||
|
||
it('replans on verification failure', async () => {
|
||
const plan = createTestPlan();
|
||
plan.phases = [plan.phases[1]]; // Phase with criteria
|
||
plan.phases[0].maxAttempts = 3;
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
|
||
let addTaskCalls = 0;
|
||
mockTaskQueue.addTask.mockImplementation((options) => {
|
||
addTaskCalls++;
|
||
const task = createMockTask(options);
|
||
task.complete();
|
||
mockTaskQueue._tasks.set(task.id, task);
|
||
return task;
|
||
});
|
||
|
||
// First verification fails, second passes
|
||
mockVerifierInstance.verifyPhase
|
||
.mockResolvedValueOnce({
|
||
passed: false,
|
||
summary: 'Tests failing',
|
||
checks: [{ name: 'npm test', passed: false, output: 'FAIL' }],
|
||
suggestions: ['Fix the failing test'],
|
||
})
|
||
.mockResolvedValueOnce({
|
||
passed: true,
|
||
summary: 'All passed',
|
||
checks: [],
|
||
suggestions: [],
|
||
});
|
||
|
||
loop.destroy();
|
||
loop = new OrchestratorLoop(createMockMux(), '/test/dir', { autoApprove: true });
|
||
loop.on('error', () => {});
|
||
|
||
const states: string[] = [];
|
||
loop.on('stateChanged', (state: string) => states.push(state));
|
||
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
await loop.start('Goal');
|
||
|
||
// Wait for: execute → verify → fail → replan → re-execute → verify → pass
|
||
await new Promise((resolve) => setTimeout(resolve, 15000));
|
||
|
||
expect(states).toContain('replanning');
|
||
expect(mockVerifierInstance.verifyPhase).toHaveBeenCalledTimes(2);
|
||
expect(loop.getStats().replanCount).toBeGreaterThanOrEqual(1);
|
||
});
|
||
|
||
it('fails phase after max verification attempts', async () => {
|
||
const plan = createTestPlan();
|
||
plan.phases = [plan.phases[1]];
|
||
plan.phases[0].maxAttempts = 1; // Only 1 attempt allowed
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
|
||
mockTaskQueue.addTask.mockImplementation((options) => {
|
||
const task = createMockTask(options);
|
||
task.complete();
|
||
mockTaskQueue._tasks.set(task.id, task);
|
||
return task;
|
||
});
|
||
|
||
mockVerifierInstance.verifyPhase.mockResolvedValue({
|
||
passed: false,
|
||
summary: 'Permanently broken',
|
||
checks: [],
|
||
suggestions: [],
|
||
});
|
||
|
||
loop.destroy();
|
||
loop = new OrchestratorLoop(createMockMux(), '/test/dir', { autoApprove: true });
|
||
loop.on('error', () => {});
|
||
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
await loop.start('Goal');
|
||
|
||
await new Promise((resolve) => setTimeout(resolve, 8000));
|
||
|
||
expect(loop.state).toBe('failed');
|
||
expect(loop.getStats().phasesFailed).toBeGreaterThanOrEqual(1);
|
||
});
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Multi-Phase Advancement & Compact
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
describe('multi-phase advancement', () => {
|
||
it('advances through multiple phases sequentially', async () => {
|
||
const plan = createTestPlan();
|
||
// Both phases have no verification criteria
|
||
plan.phases[1].verificationCriteria = [];
|
||
plan.phases[1].testCommands = [];
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
|
||
mockTaskQueue.addTask.mockImplementation((options) => {
|
||
const task = createMockTask(options);
|
||
task.complete();
|
||
mockTaskQueue._tasks.set(task.id, task);
|
||
return task;
|
||
});
|
||
|
||
loop.destroy();
|
||
loop = new OrchestratorLoop(createMockMux(), '/test/dir', {
|
||
autoApprove: true,
|
||
compactBetweenPhases: false,
|
||
});
|
||
|
||
const phasesStarted: string[] = [];
|
||
loop.on('phaseStarted', (phase: { id: string }) => phasesStarted.push(phase.id));
|
||
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
await loop.start('Goal');
|
||
|
||
// Wait for both phases: 2 × (2s poll + 1s post-phase delay) + buffer
|
||
await new Promise((resolve) => setTimeout(resolve, 12000));
|
||
|
||
expect(phasesStarted).toContain('phase-1');
|
||
expect(phasesStarted).toContain('phase-2');
|
||
expect(loop.state).toBe('completed');
|
||
expect(loop.getStats().phasesCompleted).toBe(2);
|
||
});
|
||
|
||
it('compacts between phases when configured', async () => {
|
||
const plan = createTestPlan();
|
||
plan.phases[1].verificationCriteria = [];
|
||
plan.phases[1].testCommands = [];
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
|
||
mockTaskQueue.addTask.mockImplementation((options) => {
|
||
const task = createMockTask(options);
|
||
task.complete();
|
||
mockTaskQueue._tasks.set(task.id, task);
|
||
return task;
|
||
});
|
||
|
||
const mockSession = createMockSession();
|
||
mockSessionManager.getIdleSessions.mockReturnValue([mockSession]);
|
||
|
||
loop.destroy();
|
||
loop = new OrchestratorLoop(createMockMux(), '/test/dir', {
|
||
autoApprove: true,
|
||
compactBetweenPhases: true,
|
||
});
|
||
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
await loop.start('Goal');
|
||
|
||
// Wait for both phases + compact delay
|
||
await new Promise((resolve) => setTimeout(resolve, 14000));
|
||
|
||
// writeViaMux('/compact') should have been called between phases
|
||
expect(mockSession.writeViaMux).toHaveBeenCalledWith('/compact');
|
||
expect(loop.state).toBe('completed');
|
||
});
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Persistence Recovery Edge Cases
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
describe('persistence recovery edge cases', () => {
|
||
const fullConfig = {
|
||
plannerModel: 'opus' as const,
|
||
autoApprove: false,
|
||
maxPhaseRetries: 3,
|
||
phaseTimeoutMs: 1800000,
|
||
enableTeamAgents: true,
|
||
maxParallelSessions: 3,
|
||
verificationMode: 'moderate' as const,
|
||
compactBetweenPhases: true,
|
||
researchEnabled: true,
|
||
};
|
||
const zeroStats = {
|
||
phasesCompleted: 0,
|
||
phasesFailed: 0,
|
||
totalTasksCompleted: 0,
|
||
totalTasksFailed: 0,
|
||
totalDurationMs: 0,
|
||
replanCount: 0,
|
||
};
|
||
|
||
it('recovers verifying state as failed', () => {
|
||
mockStore.getOrchestratorState.mockReturnValue({
|
||
state: 'verifying',
|
||
plan: createTestPlan(),
|
||
currentPhaseIndex: 0,
|
||
startedAt: Date.now() - 5000,
|
||
completedAt: null,
|
||
config: fullConfig,
|
||
stats: zeroStats,
|
||
});
|
||
|
||
const recovered = new OrchestratorLoop(createMockMux(), '/test/dir');
|
||
expect(recovered.state).toBe('failed');
|
||
expect(recovered.getPlan()).toBeTruthy();
|
||
recovered.destroy();
|
||
});
|
||
|
||
it('recovers replanning state as failed', () => {
|
||
mockStore.getOrchestratorState.mockReturnValue({
|
||
state: 'replanning',
|
||
plan: createTestPlan(),
|
||
currentPhaseIndex: 1,
|
||
startedAt: Date.now() - 30000,
|
||
completedAt: null,
|
||
config: fullConfig,
|
||
stats: { ...zeroStats, replanCount: 1 },
|
||
});
|
||
|
||
const recovered = new OrchestratorLoop(createMockMux(), '/test/dir');
|
||
expect(recovered.state).toBe('failed');
|
||
expect(recovered.getStats().replanCount).toBe(1);
|
||
recovered.destroy();
|
||
});
|
||
|
||
it('recovers paused state as idle (paused is transient)', () => {
|
||
mockStore.getOrchestratorState.mockReturnValue({
|
||
state: 'paused',
|
||
plan: createTestPlan(),
|
||
currentPhaseIndex: 0,
|
||
startedAt: Date.now() - 10000,
|
||
completedAt: null,
|
||
config: fullConfig,
|
||
stats: zeroStats,
|
||
});
|
||
|
||
const recovered = new OrchestratorLoop(createMockMux(), '/test/dir');
|
||
// 'paused' is not handled by restore — falls through, stays idle
|
||
expect(recovered.state).toBe('idle');
|
||
recovered.destroy();
|
||
});
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Prompt Generation
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
describe('prompt generation', () => {
|
||
it('uses single-task prompt for single-task phases', async () => {
|
||
const plan = createTestPlan();
|
||
plan.phases = [plan.phases[0]]; // Phase 1 has 1 task
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
|
||
const mockSession = createMockSession();
|
||
mockSessionManager.getIdleSessions.mockReturnValue([mockSession]);
|
||
|
||
// Capture the prompt from addTask
|
||
let capturedPrompt = '';
|
||
mockTaskQueue.addTask.mockImplementation((options) => {
|
||
capturedPrompt = options.prompt;
|
||
const task = createMockTask(options);
|
||
mockTaskQueue._tasks.set(task.id, task);
|
||
return task;
|
||
});
|
||
mockTaskQueue.next.mockImplementation(() => {
|
||
const tasks = Array.from(mockTaskQueue._tasks.values());
|
||
return tasks.find((t) => t.isPending()) || null;
|
||
});
|
||
|
||
await loop.start('Goal');
|
||
await loop.approve();
|
||
|
||
// Single-task prompt should contain the task description and completion phrase
|
||
expect(capturedPrompt).toContain('Create project structure');
|
||
expect(capturedPrompt).toContain('ORCH_P1_T1');
|
||
expect(capturedPrompt).toContain('<promise>');
|
||
// Should NOT contain multi-task markers
|
||
expect(capturedPrompt).not.toContain('YOUR TASKS FOR THIS PHASE');
|
||
});
|
||
|
||
it('uses multi-task prompt for multi-task phases', async () => {
|
||
const plan = createTestPlan();
|
||
plan.phases[0].tasks.push({
|
||
id: 'phase-1-task-2',
|
||
phaseId: 'phase-1',
|
||
prompt: 'Write unit tests',
|
||
status: 'pending',
|
||
assignedSessionId: null,
|
||
queueTaskId: null,
|
||
parallel: false,
|
||
completionPhrase: 'ORCH_P1_T2',
|
||
timeoutMs: 600000,
|
||
startedAt: null,
|
||
completedAt: null,
|
||
error: null,
|
||
retries: 0,
|
||
});
|
||
plan.phases = [plan.phases[0]]; // Only phase 1 (now with 2 tasks)
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
|
||
const capturedPrompts: string[] = [];
|
||
mockTaskQueue.addTask.mockImplementation((options) => {
|
||
capturedPrompts.push(options.prompt);
|
||
const task = createMockTask(options);
|
||
mockTaskQueue._tasks.set(task.id, task);
|
||
return task;
|
||
});
|
||
|
||
await loop.start('Goal');
|
||
await loop.approve();
|
||
|
||
// Multi-task prompt should contain the phase execution template markers
|
||
expect(capturedPrompts.length).toBeGreaterThanOrEqual(2);
|
||
expect(capturedPrompts[0]).toContain('YOUR TASKS FOR THIS PHASE');
|
||
expect(capturedPrompts[0]).toContain('VERIFICATION');
|
||
});
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Team Phase Error Handling
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
describe('team phase error handling', () => {
|
||
it('fails phase when session.sendInput throws', async () => {
|
||
const plan = createTestPlan();
|
||
plan.phases[0].teamStrategy = {
|
||
type: 'team',
|
||
config: {
|
||
leadPrompt: 'Lead prompt',
|
||
suggestedTeammates: ['Specialist'],
|
||
maxTeammates: 1,
|
||
},
|
||
};
|
||
plan.phases = [plan.phases[0]];
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
|
||
const mockSession = createMockSession();
|
||
mockSession.sendInput.mockRejectedValue(new Error('Session write failed'));
|
||
mockSessionManager.getIdleSessions.mockReturnValue([mockSession]);
|
||
|
||
loop.destroy();
|
||
loop = new OrchestratorLoop(createMockMux(), '/test/dir', { autoApprove: true });
|
||
loop.on('error', () => {});
|
||
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
await loop.start('Goal');
|
||
|
||
// Wait for the error to propagate
|
||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||
|
||
// Phase should have failed or be retrying
|
||
const phase = loop.getPlan()!.phases[0];
|
||
expect(phase.attempts).toBeGreaterThanOrEqual(1);
|
||
});
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Verification Session Retrieval
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
describe('verification session handling', () => {
|
||
it('skips verification when no sessions become available', async () => {
|
||
const plan = createTestPlan();
|
||
plan.phases = [plan.phases[1]]; // Phase with criteria
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
|
||
mockTaskQueue.addTask.mockImplementation((options) => {
|
||
const task = createMockTask(options);
|
||
task.complete();
|
||
mockTaskQueue._tasks.set(task.id, task);
|
||
return task;
|
||
});
|
||
|
||
// Track state: once we enter 'verifying', stop returning sessions
|
||
let inVerification = false;
|
||
const mockSession = createMockSession();
|
||
mockSessionManager.getIdleSessions.mockImplementation(() => {
|
||
if (inVerification) return []; // No sessions for verification
|
||
return [mockSession];
|
||
});
|
||
|
||
loop.destroy();
|
||
loop = new OrchestratorLoop(createMockMux(), '/test/dir', { autoApprove: true });
|
||
loop.on('error', () => {});
|
||
loop.on('stateChanged', (state: string) => {
|
||
if (state === 'verifying') inVerification = true;
|
||
});
|
||
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
await loop.start('Goal');
|
||
|
||
// Wait for poll + verification attempt + 10s session wait + fallback
|
||
await new Promise((resolve) => setTimeout(resolve, 18000));
|
||
|
||
// Should still complete (skip verification when no sessions)
|
||
expect(loop.state).toBe('completed');
|
||
// Verifier should NOT have been called (no sessions to verify with)
|
||
expect(mockVerifierInstance.verifyPhase).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Event Coverage
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
describe('event coverage', () => {
|
||
it('emits taskAssigned when task is assigned to session', async () => {
|
||
const plan = createTestPlan();
|
||
plan.phases = [plan.phases[0]];
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
|
||
const mockSession = createMockSession();
|
||
mockSessionManager.getIdleSessions.mockReturnValue([mockSession]);
|
||
|
||
mockTaskQueue.addTask.mockImplementation((options) => {
|
||
const task = createMockTask(options);
|
||
mockTaskQueue._tasks.set(task.id, task);
|
||
return task;
|
||
});
|
||
mockTaskQueue.next.mockImplementation(() => {
|
||
const tasks = Array.from(mockTaskQueue._tasks.values());
|
||
return tasks.find((t) => t.isPending()) || null;
|
||
});
|
||
|
||
const assigned: Array<{ task: unknown; sessionId: string }> = [];
|
||
loop.on('taskAssigned', (task: unknown, sessionId: string) => {
|
||
assigned.push({ task, sessionId });
|
||
});
|
||
|
||
await loop.start('Goal');
|
||
await loop.approve();
|
||
|
||
// Task assignment happens during assignQueuedTasksToSessions
|
||
expect(assigned.length).toBeGreaterThanOrEqual(1);
|
||
expect(assigned[0].sessionId).toBe('session-1');
|
||
});
|
||
|
||
it('emits phaseCompleted on successful phase', async () => {
|
||
const plan = createTestPlan();
|
||
plan.phases = [plan.phases[0]]; // No verification criteria
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
|
||
mockTaskQueue.addTask.mockImplementation((options) => {
|
||
const task = createMockTask(options);
|
||
task.complete();
|
||
mockTaskQueue._tasks.set(task.id, task);
|
||
return task;
|
||
});
|
||
|
||
loop.destroy();
|
||
loop = new OrchestratorLoop(createMockMux(), '/test/dir', { autoApprove: true });
|
||
|
||
const completedPhases: string[] = [];
|
||
loop.on('phaseCompleted', (phase: { id: string }) => completedPhases.push(phase.id));
|
||
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
await loop.start('Goal');
|
||
|
||
await new Promise((resolve) => setTimeout(resolve, 6000));
|
||
|
||
expect(completedPhases).toContain('phase-1');
|
||
});
|
||
|
||
it('emits phaseFailed when phase exhausts retries', async () => {
|
||
const plan = createTestPlan();
|
||
plan.phases = [plan.phases[0]];
|
||
plan.phases[0].maxAttempts = 1;
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
|
||
// All tasks always fail
|
||
mockTaskQueue.addTask.mockImplementation((options) => {
|
||
const task = createMockTask(options);
|
||
task.fail('Permanent failure');
|
||
mockTaskQueue._tasks.set(task.id, task);
|
||
return task;
|
||
});
|
||
|
||
loop.destroy();
|
||
loop = new OrchestratorLoop(createMockMux(), '/test/dir', { autoApprove: true });
|
||
loop.on('error', () => {});
|
||
|
||
const failedPhases: Array<{ id: string; reason: string }> = [];
|
||
loop.on('phaseFailed', (phase: { id: string }, reason: string) => {
|
||
failedPhases.push({ id: phase.id, reason });
|
||
});
|
||
|
||
mockPlannerInstance.generatePlan.mockResolvedValue(plan);
|
||
await loop.start('Goal');
|
||
|
||
await new Promise((resolve) => setTimeout(resolve, 8000));
|
||
|
||
expect(loop.state).toBe('failed');
|
||
expect(failedPhases.length).toBeGreaterThanOrEqual(1);
|
||
});
|
||
});
|
||
});
|