mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39:42 +02:00
test: add 43 deep integration tests for OrchestratorLoop state machine
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 <noreply@anthropic.com>
This commit is contained in:
+75
-15
@@ -100,6 +100,9 @@ export class OrchestratorLoop extends EventEmitter {
|
|||||||
/** Phase poll timer for checking task completion */
|
/** Phase poll timer for checking task completion */
|
||||||
private phasePollTimer: NodeJS.Timeout | null = null;
|
private phasePollTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
/** Phase-level timeout timer */
|
||||||
|
private phaseTimeoutTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
/** Session completion listener (bound for cleanup) */
|
/** Session completion listener (bound for cleanup) */
|
||||||
private sessionCompletionListener: ((sessionId: string, phrase: string) => void) | null = null;
|
private sessionCompletionListener: ((sessionId: string, phrase: string) => void) | null = null;
|
||||||
|
|
||||||
@@ -141,7 +144,7 @@ export class OrchestratorLoop extends EventEmitter {
|
|||||||
console.log(`[Orchestrator] Planning: ${phase} — ${detail}`);
|
console.log(`[Orchestrator] Planning: ${phase} — ${detail}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
if ((this._state as OrchestratorState) !== 'planning') {
|
if (this.currentState() !== 'planning') {
|
||||||
// Cancelled during planning
|
// Cancelled during planning
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -163,9 +166,7 @@ export class OrchestratorLoop extends EventEmitter {
|
|||||||
|
|
||||||
/** Approve the generated plan. Transitions: approval → executing */
|
/** Approve the generated plan. Transitions: approval → executing */
|
||||||
async approve(): Promise<void> {
|
async approve(): Promise<void> {
|
||||||
if ((this._state as OrchestratorState) !== 'approval') {
|
this.requireState('approval');
|
||||||
throw new Error(`Cannot approve from state "${this._state}"`);
|
|
||||||
}
|
|
||||||
if (!this.plan) {
|
if (!this.plan) {
|
||||||
throw new Error('No plan to approve');
|
throw new Error('No plan to approve');
|
||||||
}
|
}
|
||||||
@@ -176,9 +177,7 @@ export class OrchestratorLoop extends EventEmitter {
|
|||||||
|
|
||||||
/** Reject plan with feedback. Transitions: approval → planning (regenerate) */
|
/** Reject plan with feedback. Transitions: approval → planning (regenerate) */
|
||||||
async reject(feedback: string): Promise<void> {
|
async reject(feedback: string): Promise<void> {
|
||||||
if ((this._state as OrchestratorState) !== 'approval') {
|
this.requireState('approval');
|
||||||
throw new Error(`Cannot reject from state "${this._state}"`);
|
|
||||||
}
|
|
||||||
if (!this.plan) {
|
if (!this.plan) {
|
||||||
throw new Error('No plan to reject');
|
throw new Error('No plan to reject');
|
||||||
}
|
}
|
||||||
@@ -208,6 +207,7 @@ export class OrchestratorLoop extends EventEmitter {
|
|||||||
}
|
}
|
||||||
this.pausedState = this._state;
|
this.pausedState = this._state;
|
||||||
this.clearPhasePoll();
|
this.clearPhasePoll();
|
||||||
|
this.cleanupTaskHandlers();
|
||||||
this.setState('paused');
|
this.setState('paused');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,6 +540,14 @@ export class OrchestratorLoop extends EventEmitter {
|
|||||||
}
|
}
|
||||||
this.pollPhaseStatus(phase);
|
this.pollPhaseStatus(phase);
|
||||||
}, PHASE_POLL_INTERVAL_MS);
|
}, 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 {
|
private clearPhasePoll(): void {
|
||||||
@@ -547,6 +555,10 @@ export class OrchestratorLoop extends EventEmitter {
|
|||||||
clearInterval(this.phasePollTimer);
|
clearInterval(this.phasePollTimer);
|
||||||
this.phasePollTimer = null;
|
this.phasePollTimer = null;
|
||||||
}
|
}
|
||||||
|
if (this.phaseTimeoutTimer) {
|
||||||
|
clearTimeout(this.phaseTimeoutTimer);
|
||||||
|
this.phaseTimeoutTimer = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private pollPhaseStatus(phase: OrchestratorPhase): void {
|
private pollPhaseStatus(phase: OrchestratorPhase): void {
|
||||||
@@ -634,10 +646,16 @@ export class OrchestratorLoop extends EventEmitter {
|
|||||||
|
|
||||||
this.setState('verifying');
|
this.setState('verifying');
|
||||||
|
|
||||||
// Get a session for verification
|
// Get a session for verification — wait briefly for sessions to become idle
|
||||||
const sessions = this.sessionManager.getIdleSessions();
|
let sessions = this.sessionManager.getIdleSessions();
|
||||||
if (sessions.length === 0) {
|
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.status = 'passed';
|
||||||
phase.completedAt = Date.now();
|
phase.completedAt = Date.now();
|
||||||
phase.durationMs = phase.startedAt ? Date.now() - phase.startedAt : null;
|
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<void> {
|
private async replanPhase(phase: OrchestratorPhase, result: VerificationResult): Promise<void> {
|
||||||
const sessions = this.sessionManager.getIdleSessions();
|
const completionPhrase = phase.tasks[0]?.completionPhrase || `${phase.id.toUpperCase()}_FIXED`;
|
||||||
if (sessions.length === 0) return;
|
|
||||||
|
|
||||||
const prompt = REPLAN_PROMPT.replace('{PHASE_NAME}', phase.name)
|
const prompt = REPLAN_PROMPT.replace('{PHASE_NAME}', phase.name)
|
||||||
.replace('{ATTEMPT_NUMBER}', String(phase.attempts))
|
.replace('{ATTEMPT_NUMBER}', String(phase.attempts))
|
||||||
@@ -729,16 +746,59 @@ export class OrchestratorLoop extends EventEmitter {
|
|||||||
.replace('{FAILURE_SUMMARY}', result.summary)
|
.replace('{FAILURE_SUMMARY}', result.summary)
|
||||||
.replace('{SUGGESTIONS}', result.suggestions.join('\n'))
|
.replace('{SUGGESTIONS}', result.suggestions.join('\n'))
|
||||||
.replace('{ORIGINAL_TASKS}', phase.tasks.map((t, i) => `${i + 1}. ${t.prompt}`).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
|
// Create a tracked queue task for the replan (so completion is detected)
|
||||||
await sessions[0].sendInput(prompt);
|
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
|
// 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 {
|
private setState(newState: OrchestratorState): void {
|
||||||
const prev = this._state;
|
const prev = this._state;
|
||||||
if (prev === newState) return;
|
if (prev === newState) return;
|
||||||
|
|||||||
@@ -38,7 +38,10 @@ export function registerOrchestratorRoutes(app: FastifyInstance, ctx: Orchestrat
|
|||||||
return loop;
|
return loop;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let eventForwardingAttached = false;
|
||||||
function setupEventForwarding(loop: import('../../orchestrator-loop.js').OrchestratorLoop) {
|
function setupEventForwarding(loop: import('../../orchestrator-loop.js').OrchestratorLoop) {
|
||||||
|
if (eventForwardingAttached) return;
|
||||||
|
eventForwardingAttached = true;
|
||||||
loop.on('stateChanged', (state, prevState) => {
|
loop.on('stateChanged', (state, prevState) => {
|
||||||
ctx.broadcast(SseEvent.OrchestratorStateChanged, { state, prevState });
|
ctx.broadcast(SseEvent.OrchestratorStateChanged, { state, prevState });
|
||||||
});
|
});
|
||||||
|
|||||||
+6
-2
@@ -488,6 +488,8 @@ export class WebServer extends EventEmitter {
|
|||||||
* Single object with zero runtime cost — ISP enforced at the type level.
|
* Single object with zero runtime cost — ISP enforced at the type level.
|
||||||
*/
|
*/
|
||||||
private createRouteContext() {
|
private createRouteContext() {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||||
|
const self = this;
|
||||||
return {
|
return {
|
||||||
// SessionPort
|
// SessionPort
|
||||||
sessions: this.sessions as ReadonlyMap<string, Session>,
|
sessions: this.sessions as ReadonlyMap<string, Session>,
|
||||||
@@ -540,8 +542,10 @@ export class WebServer extends EventEmitter {
|
|||||||
// AuthPort
|
// AuthPort
|
||||||
authSessions: this.authSessions,
|
authSessions: this.authSessions,
|
||||||
qrAuthFailures: this.qrAuthFailures,
|
qrAuthFailures: this.qrAuthFailures,
|
||||||
// OrchestratorPort
|
// OrchestratorPort — use getter so routes always see current value (not a null snapshot)
|
||||||
orchestratorLoop: this._orchestratorLoop,
|
get orchestratorLoop() {
|
||||||
|
return self._orchestratorLoop;
|
||||||
|
},
|
||||||
initOrchestratorLoop: () => this.initOrchestratorLoop(),
|
initOrchestratorLoop: () => this.initOrchestratorLoop(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user