From d322f17f735725d65ea39309a9abb4cb1eb3c17d Mon Sep 17 00:00:00 2001 From: arkon Date: Sat, 21 Mar 2026 15:33:02 +0100 Subject: [PATCH] test: add 43 deep integration tests for OrchestratorLoop state machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers full lifecycle: start → plan → approve → execute → verify → complete. Tests state transitions, event emissions, persistence/recovery, pause/resume, skip/retry, team phase execution, error handling, and edge cases. Also fixes bugs found during review: - Route context snapshot: use getter for orchestratorLoop (was null forever) - Event listener stacking: guard setupEventForwarding with boolean flag - Replan completion: create tracked TaskQueue task instead of raw sendInput - Pause cleanup: call cleanupTaskHandlers() on pause - Phase timeout: add phaseTimeoutTimer enforcement Co-Authored-By: Claude Opus 4.6 --- src/orchestrator-loop.ts | 90 ++- src/web/routes/orchestrator-routes.ts | 3 + src/web/server.ts | 8 +- test/orchestrator-loop.test.ts | 969 ++++++++++++++++++++++++++ 4 files changed, 1053 insertions(+), 17 deletions(-) create mode 100644 test/orchestrator-loop.test.ts diff --git a/src/orchestrator-loop.ts b/src/orchestrator-loop.ts index f5113780..526ad75f 100644 --- a/src/orchestrator-loop.ts +++ b/src/orchestrator-loop.ts @@ -100,6 +100,9 @@ export class OrchestratorLoop extends EventEmitter { /** Phase poll timer for checking task completion */ private phasePollTimer: NodeJS.Timeout | null = null; + /** Phase-level timeout timer */ + private phaseTimeoutTimer: NodeJS.Timeout | null = null; + /** Session completion listener (bound for cleanup) */ private sessionCompletionListener: ((sessionId: string, phrase: string) => void) | null = null; @@ -141,7 +144,7 @@ export class OrchestratorLoop extends EventEmitter { console.log(`[Orchestrator] Planning: ${phase} — ${detail}`); }); - if ((this._state as OrchestratorState) !== 'planning') { + if (this.currentState() !== 'planning') { // Cancelled during planning return; } @@ -163,9 +166,7 @@ export class OrchestratorLoop extends EventEmitter { /** Approve the generated plan. Transitions: approval → executing */ async approve(): Promise { - if ((this._state as OrchestratorState) !== 'approval') { - throw new Error(`Cannot approve from state "${this._state}"`); - } + this.requireState('approval'); if (!this.plan) { throw new Error('No plan to approve'); } @@ -176,9 +177,7 @@ export class OrchestratorLoop extends EventEmitter { /** Reject plan with feedback. Transitions: approval → planning (regenerate) */ async reject(feedback: string): Promise { - if ((this._state as OrchestratorState) !== 'approval') { - throw new Error(`Cannot reject from state "${this._state}"`); - } + this.requireState('approval'); if (!this.plan) { throw new Error('No plan to reject'); } @@ -208,6 +207,7 @@ export class OrchestratorLoop extends EventEmitter { } this.pausedState = this._state; this.clearPhasePoll(); + this.cleanupTaskHandlers(); this.setState('paused'); } @@ -540,6 +540,14 @@ export class OrchestratorLoop extends EventEmitter { } this.pollPhaseStatus(phase); }, PHASE_POLL_INTERVAL_MS); + + // Phase-level timeout — fail the phase if it exceeds the configured timeout + this.phaseTimeoutTimer = setTimeout(() => { + if (this._state === 'executing' && phase.status === 'executing') { + console.warn(`[Orchestrator] Phase "${phase.name}" timed out after ${this.config.phaseTimeoutMs}ms`); + this.handlePhaseError(phase, `Phase timed out after ${Math.round(this.config.phaseTimeoutMs / 60000)} minutes`); + } + }, this.config.phaseTimeoutMs); } private clearPhasePoll(): void { @@ -547,6 +555,10 @@ export class OrchestratorLoop extends EventEmitter { clearInterval(this.phasePollTimer); this.phasePollTimer = null; } + if (this.phaseTimeoutTimer) { + clearTimeout(this.phaseTimeoutTimer); + this.phaseTimeoutTimer = null; + } } private pollPhaseStatus(phase: OrchestratorPhase): void { @@ -634,10 +646,16 @@ export class OrchestratorLoop extends EventEmitter { this.setState('verifying'); - // Get a session for verification - const sessions = this.sessionManager.getIdleSessions(); + // Get a session for verification — wait briefly for sessions to become idle + let sessions = this.sessionManager.getIdleSessions(); if (sessions.length === 0) { - // No idle sessions — mark as passed (can't verify) + // Wait up to 10s for a session to become idle + await new Promise((resolve) => setTimeout(resolve, 10_000)); + sessions = this.sessionManager.getIdleSessions(); + } + if (sessions.length === 0) { + // Still no sessions — log warning and skip verification (don't silently pass) + console.warn('[Orchestrator] No idle sessions for verification — skipping (marking passed with warning)'); phase.status = 'passed'; phase.completedAt = Date.now(); phase.durationMs = phase.startedAt ? Date.now() - phase.startedAt : null; @@ -720,8 +738,7 @@ export class OrchestratorLoop extends EventEmitter { } private async replanPhase(phase: OrchestratorPhase, result: VerificationResult): Promise { - const sessions = this.sessionManager.getIdleSessions(); - if (sessions.length === 0) return; + const completionPhrase = phase.tasks[0]?.completionPhrase || `${phase.id.toUpperCase()}_FIXED`; const prompt = REPLAN_PROMPT.replace('{PHASE_NAME}', phase.name) .replace('{ATTEMPT_NUMBER}', String(phase.attempts)) @@ -729,16 +746,59 @@ export class OrchestratorLoop extends EventEmitter { .replace('{FAILURE_SUMMARY}', result.summary) .replace('{SUGGESTIONS}', result.suggestions.join('\n')) .replace('{ORIGINAL_TASKS}', phase.tasks.map((t, i) => `${i + 1}. ${t.prompt}`).join('\n')) - .replace('{COMPLETION_PHRASE}', phase.tasks[0]?.completionPhrase || `${phase.id.toUpperCase()}_FIXED`); + .replace('{COMPLETION_PHRASE}', completionPhrase); - // Send the replan prompt to an idle session - await sessions[0].sendInput(prompt); + // Create a tracked queue task for the replan (so completion is detected) + const queueTask = this.taskQueue.addTask({ + prompt, + workingDir: this.workingDir, + priority: 100, + completionPhrase, + timeoutMs: this.config.phaseTimeoutMs, + }); + + // Link to first phase task for tracking + if (phase.tasks[0]) { + phase.tasks[0].queueTaskId = queueTask.id; + phase.tasks[0].status = 'running'; + } + + this.persist(); + + // Assign to a session + const sessions = this.sessionManager.getIdleSessions(); + if (sessions.length === 0) { + console.warn('[Orchestrator] No idle sessions for replan — task queued, waiting'); + return; + } + + try { + queueTask.assign(sessions[0].id); + sessions[0].assignTask(queueTask.id); + this.taskQueue.updateTask(queueTask); + await sessions[0].sendInput(prompt); + } catch (err) { + queueTask.fail(getErrorMessage(err)); + this.taskQueue.updateTask(queueTask); + } } // ═══════════════════════════════════════════════════════════════ // Internal — State Machine // ═══════════════════════════════════════════════════════════════ + /** Read current state (bypasses TypeScript narrowing from guards) */ + private currentState(): OrchestratorState { + return this._state; + } + + /** Assert state matches expected or throw */ + private requireState(...expected: OrchestratorState[]): void { + if (!expected.includes(this._state)) { + throw new Error(`Expected state "${expected.join('|')}", got "${this._state}"`); + } + } + private setState(newState: OrchestratorState): void { const prev = this._state; if (prev === newState) return; diff --git a/src/web/routes/orchestrator-routes.ts b/src/web/routes/orchestrator-routes.ts index 9acc0879..b7bfe5ed 100644 --- a/src/web/routes/orchestrator-routes.ts +++ b/src/web/routes/orchestrator-routes.ts @@ -38,7 +38,10 @@ export function registerOrchestratorRoutes(app: FastifyInstance, ctx: Orchestrat return loop; } + let eventForwardingAttached = false; function setupEventForwarding(loop: import('../../orchestrator-loop.js').OrchestratorLoop) { + if (eventForwardingAttached) return; + eventForwardingAttached = true; loop.on('stateChanged', (state, prevState) => { ctx.broadcast(SseEvent.OrchestratorStateChanged, { state, prevState }); }); diff --git a/src/web/server.ts b/src/web/server.ts index bd5cc788..917fa29d 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -488,6 +488,8 @@ export class WebServer extends EventEmitter { * Single object with zero runtime cost — ISP enforced at the type level. */ private createRouteContext() { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self = this; return { // SessionPort sessions: this.sessions as ReadonlyMap, @@ -540,8 +542,10 @@ export class WebServer extends EventEmitter { // AuthPort authSessions: this.authSessions, qrAuthFailures: this.qrAuthFailures, - // OrchestratorPort - orchestratorLoop: this._orchestratorLoop, + // OrchestratorPort — use getter so routes always see current value (not a null snapshot) + get orchestratorLoop() { + return self._orchestratorLoop; + }, initOrchestratorLoop: () => this.initOrchestratorLoop(), }; } diff --git a/test/orchestrator-loop.test.ts b/test/orchestrator-loop.test.ts new file mode 100644 index 00000000..bae897ba --- /dev/null +++ b/test/orchestrator-loop.test.ts @@ -0,0 +1,969 @@ +/** + * @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; + assignTask: ReturnType; + clearTask: ReturnType; + writeViaMux: ReturnType; + isIdle: ReturnType; + isBusy: ReturnType; + }; + 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; + getAllSessions: ReturnType; + getSession: ReturnType; +}; +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; + getTask: ReturnType; + next: ReturnType; + updateTask: ReturnType; + _tasks: Map>; +}; +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 { + 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 { + 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); + }); + }); + + 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'); + }); + }); +});