Files
Codewalkers/apps/server/dispatch/phase-manager.test.ts
Lukas May d81e0864f7 feat: Add retry mechanism for blocked tasks
Blocked tasks (from spawn failures) were a dead-end with no way to
recover. Add retryBlockedTask to DispatchManager that resets status
to pending and re-queues, a tRPC mutation that also kicks dispatchNext,
and a Retry button in the task slide-over when status is blocked.
2026-03-05 20:41:49 +01:00

542 lines
20 KiB
TypeScript

/**
* DefaultPhaseDispatchManager Tests
*
* Tests for the PhaseDispatchManager adapter with dependency checking
* and queue management.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { DefaultPhaseDispatchManager } from './phase-manager.js';
import { DrizzlePhaseRepository } from '../db/repositories/drizzle/phase.js';
import { DrizzleTaskRepository } from '../db/repositories/drizzle/task.js';
import { DrizzleInitiativeRepository } from '../db/repositories/drizzle/initiative.js';
import { createTestDatabase } from '../db/repositories/drizzle/test-helpers.js';
import type { DrizzleDatabase } from '../db/index.js';
import type { EventBus, DomainEvent } from '../events/types.js';
import type { DispatchManager } from './types.js';
// =============================================================================
// Test Helpers
// =============================================================================
/**
* Create a mock EventBus that captures emitted events.
*/
function createMockEventBus(): EventBus & { emittedEvents: DomainEvent[] } {
const emittedEvents: DomainEvent[] = [];
return {
emittedEvents,
emit<T extends DomainEvent>(event: T): void {
emittedEvents.push(event);
},
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
};
}
// =============================================================================
// Tests
// =============================================================================
/**
* Create a mock DispatchManager (stub, not used in phase dispatch tests).
*/
function createMockDispatchManager(): DispatchManager {
return {
queue: vi.fn(),
getNextDispatchable: vi.fn().mockResolvedValue(null),
dispatchNext: vi.fn().mockResolvedValue({ success: false, reason: 'mock' }),
completeTask: vi.fn(),
blockTask: vi.fn(),
retryBlockedTask: vi.fn(),
getQueueState: vi.fn().mockResolvedValue({ queued: [], ready: [], blocked: [] }),
};
}
describe('DefaultPhaseDispatchManager', () => {
let db: DrizzleDatabase;
let phaseRepository: DrizzlePhaseRepository;
let taskRepository: DrizzleTaskRepository;
let initiativeRepository: DrizzleInitiativeRepository;
let eventBus: EventBus & { emittedEvents: DomainEvent[] };
let dispatchManager: DispatchManager;
let phaseDispatchManager: DefaultPhaseDispatchManager;
let testInitiativeId: string;
beforeEach(async () => {
// Set up test database
db = createTestDatabase();
phaseRepository = new DrizzlePhaseRepository(db);
taskRepository = new DrizzleTaskRepository(db);
initiativeRepository = new DrizzleInitiativeRepository(db);
// Create required initiative for phases
const initiative = await initiativeRepository.create({
name: 'Test Initiative',
});
testInitiativeId = initiative.id;
// Create mock event bus and dispatch manager
eventBus = createMockEventBus();
dispatchManager = createMockDispatchManager();
// Create phase dispatch manager
phaseDispatchManager = new DefaultPhaseDispatchManager(
phaseRepository,
taskRepository,
dispatchManager,
eventBus
);
});
// ===========================================================================
// queuePhase() Tests
// ===========================================================================
describe('queuePhase', () => {
it('should add phase to queue', async () => {
const phase = await phaseRepository.create({
initiativeId: testInitiativeId,
name: 'Test Phase',
});
await phaseRepository.update(phase.id, { status: 'approved' as const });
await phaseDispatchManager.queuePhase(phase.id);
const state = await phaseDispatchManager.getPhaseQueueState();
expect(state.queued.length).toBe(1);
expect(state.queued[0].phaseId).toBe(phase.id);
expect(state.queued[0].initiativeId).toBe(testInitiativeId);
});
it('should emit PhaseQueuedEvent', async () => {
const phase = await phaseRepository.create({
initiativeId: testInitiativeId,
name: 'Test Phase',
});
await phaseRepository.update(phase.id, { status: 'approved' as const });
await phaseDispatchManager.queuePhase(phase.id);
// Check event was emitted
expect(eventBus.emittedEvents.length).toBe(1);
expect(eventBus.emittedEvents[0].type).toBe('phase:queued');
expect((eventBus.emittedEvents[0] as any).payload.phaseId).toBe(phase.id);
expect((eventBus.emittedEvents[0] as any).payload.initiativeId).toBe(testInitiativeId);
});
it('should include dependencies in queued phase', async () => {
const phase1 = await phaseRepository.create({
initiativeId: testInitiativeId,
name: 'Phase 1',
});
const phase2 = await phaseRepository.create({
initiativeId: testInitiativeId,
name: 'Phase 2',
});
await phaseRepository.update(phase2.id, { status: 'approved' as const });
// Phase 2 depends on Phase 1
await phaseRepository.createDependency(phase2.id, phase1.id);
await phaseDispatchManager.queuePhase(phase2.id);
const state = await phaseDispatchManager.getPhaseQueueState();
expect(state.queued[0].dependsOn).toContain(phase1.id);
// Event should also include dependencies
expect((eventBus.emittedEvents[0] as any).payload.dependsOn).toContain(phase1.id);
});
it('should throw error when phase not found', async () => {
await expect(phaseDispatchManager.queuePhase('non-existent-id')).rejects.toThrow(
'Phase not found'
);
});
it('should reject non-approved phase', async () => {
const phase = await phaseRepository.create({
initiativeId: testInitiativeId,
name: 'Pending Phase',
status: 'pending',
});
await expect(phaseDispatchManager.queuePhase(phase.id)).rejects.toThrow(
'must be approved before queuing'
);
});
it('should reject in_progress phase', async () => {
const phase = await phaseRepository.create({
initiativeId: testInitiativeId,
name: 'In Progress Phase',
status: 'in_progress',
});
await expect(phaseDispatchManager.queuePhase(phase.id)).rejects.toThrow(
'must be approved before queuing'
);
});
});
// ===========================================================================
// getNextDispatchablePhase() Tests
// ===========================================================================
describe('getNextDispatchablePhase', () => {
it('should return null when queue empty', async () => {
const next = await phaseDispatchManager.getNextDispatchablePhase();
expect(next).toBeNull();
});
it('should return phase with no dependencies first', async () => {
const phase1 = await phaseRepository.create({
initiativeId: testInitiativeId,
name: 'Phase 1 (no deps)',
});
const phase2 = await phaseRepository.create({
initiativeId: testInitiativeId,
name: 'Phase 2 (depends on 1)',
});
await phaseRepository.update(phase1.id, { status: 'approved' as const });
await phaseRepository.update(phase2.id, { status: 'approved' as const });
// Phase 2 depends on Phase 1
await phaseRepository.createDependency(phase2.id, phase1.id);
// Queue both phases (phase 2 first, then phase 1)
await phaseDispatchManager.queuePhase(phase2.id);
await phaseDispatchManager.queuePhase(phase1.id);
// Should return phase 1 since phase 2 has incomplete dependency
const next = await phaseDispatchManager.getNextDispatchablePhase();
expect(next).not.toBeNull();
expect(next!.phaseId).toBe(phase1.id);
});
it('should skip phases with incomplete dependencies', async () => {
const phase1 = await phaseRepository.create({
initiativeId: testInitiativeId,
name: 'Phase 1',
status: 'pending', // Not completed
});
const phase2 = await phaseRepository.create({
initiativeId: testInitiativeId,
name: 'Phase 2',
});
await phaseRepository.update(phase2.id, { status: 'approved' as const });
// Phase 2 depends on Phase 1
await phaseRepository.createDependency(phase2.id, phase1.id);
// Queue only phase 2 (phase 1 is not queued or completed)
await phaseDispatchManager.queuePhase(phase2.id);
// Should return null since phase 2's dependency (phase 1) is not complete
const next = await phaseDispatchManager.getNextDispatchablePhase();
expect(next).toBeNull();
});
it('should return oldest phase when multiple ready', async () => {
const phase1 = await phaseRepository.create({
initiativeId: testInitiativeId,
name: 'Phase 1',
});
const phase2 = await phaseRepository.create({
initiativeId: testInitiativeId,
name: 'Phase 2',
});
await phaseRepository.update(phase1.id, { status: 'approved' as const });
await phaseRepository.update(phase2.id, { status: 'approved' as const });
// Queue phase1 first, then phase2
await phaseDispatchManager.queuePhase(phase1.id);
await new Promise((resolve) => setTimeout(resolve, 10)); // Small delay
await phaseDispatchManager.queuePhase(phase2.id);
// Should return phase 1 (queued first)
const next = await phaseDispatchManager.getNextDispatchablePhase();
expect(next).not.toBeNull();
expect(next!.phaseId).toBe(phase1.id);
});
});
// ===========================================================================
// dispatchNextPhase() Tests
// ===========================================================================
describe('dispatchNextPhase', () => {
it('should update phase status to in_progress', async () => {
const phase = await phaseRepository.create({
initiativeId: testInitiativeId,
name: 'Test Phase',
});
await phaseRepository.update(phase.id, { status: 'approved' as const });
await phaseDispatchManager.queuePhase(phase.id);
const result = await phaseDispatchManager.dispatchNextPhase();
expect(result.success).toBe(true);
expect(result.phaseId).toBe(phase.id);
// Check phase status updated
const updatedPhase = await phaseRepository.findById(phase.id);
expect(updatedPhase!.status).toBe('in_progress');
});
it('should emit PhaseStartedEvent', async () => {
const phase = await phaseRepository.create({
initiativeId: testInitiativeId,
name: 'Test Phase',
});
await phaseRepository.update(phase.id, { status: 'approved' as const });
await phaseDispatchManager.queuePhase(phase.id);
await phaseDispatchManager.dispatchNextPhase();
// Find PhaseStartedEvent (events include queued + started)
const startedEvent = eventBus.emittedEvents.find(
(e) => e.type === 'phase:started'
);
expect(startedEvent).toBeDefined();
expect((startedEvent as any).payload.phaseId).toBe(phase.id);
expect((startedEvent as any).payload.initiativeId).toBe(testInitiativeId);
});
it('should return failure when no dispatchable phases', async () => {
const result = await phaseDispatchManager.dispatchNextPhase();
expect(result.success).toBe(false);
expect(result.reason).toBe('No dispatchable phases');
});
it('should remove phase from queue after dispatch', async () => {
const phase = await phaseRepository.create({
initiativeId: testInitiativeId,
name: 'Test Phase',
});
await phaseRepository.update(phase.id, { status: 'approved' as const });
await phaseDispatchManager.queuePhase(phase.id);
await phaseDispatchManager.dispatchNextPhase();
const state = await phaseDispatchManager.getPhaseQueueState();
expect(state.queued.length).toBe(0);
});
});
// ===========================================================================
// completePhase() Tests
// ===========================================================================
describe('completePhase', () => {
it('should update phase status to completed', async () => {
const phase = await phaseRepository.create({
initiativeId: testInitiativeId,
name: 'Test Phase',
status: 'in_progress',
});
await phaseDispatchManager.completePhase(phase.id);
const updatedPhase = await phaseRepository.findById(phase.id);
expect(updatedPhase!.status).toBe('completed');
});
it('should remove from queue', async () => {
const phase = await phaseRepository.create({
initiativeId: testInitiativeId,
name: 'Test Phase',
});
await phaseRepository.update(phase.id, { status: 'approved' as const });
await phaseDispatchManager.queuePhase(phase.id);
await phaseDispatchManager.completePhase(phase.id);
const state = await phaseDispatchManager.getPhaseQueueState();
expect(state.queued.length).toBe(0);
});
it('should emit PhaseCompletedEvent', async () => {
const phase = await phaseRepository.create({
initiativeId: testInitiativeId,
name: 'Test Phase',
status: 'in_progress',
});
await phaseDispatchManager.completePhase(phase.id);
// Find PhaseCompletedEvent
const completedEvent = eventBus.emittedEvents.find(
(e) => e.type === 'phase:completed'
);
expect(completedEvent).toBeDefined();
expect((completedEvent as any).payload.phaseId).toBe(phase.id);
expect((completedEvent as any).payload.initiativeId).toBe(testInitiativeId);
expect((completedEvent as any).payload.success).toBe(true);
});
it('should throw error when phase not found', async () => {
await expect(phaseDispatchManager.completePhase('non-existent-id')).rejects.toThrow(
'Phase not found'
);
});
});
// ===========================================================================
// blockPhase() Tests
// ===========================================================================
describe('blockPhase', () => {
it('should update phase status', async () => {
const phase = await phaseRepository.create({
initiativeId: testInitiativeId,
name: 'Test Phase',
});
await phaseRepository.update(phase.id, { status: 'approved' as const });
await phaseDispatchManager.queuePhase(phase.id);
await phaseDispatchManager.blockPhase(phase.id, 'Waiting for user input');
const updatedPhase = await phaseRepository.findById(phase.id);
expect(updatedPhase!.status).toBe('blocked');
});
it('should add to blocked list', async () => {
const phase = await phaseRepository.create({
initiativeId: testInitiativeId,
name: 'Test Phase',
});
await phaseRepository.update(phase.id, { status: 'approved' as const });
await phaseDispatchManager.queuePhase(phase.id);
await phaseDispatchManager.blockPhase(phase.id, 'Waiting for user input');
const state = await phaseDispatchManager.getPhaseQueueState();
expect(state.blocked.length).toBe(1);
expect(state.blocked[0].phaseId).toBe(phase.id);
expect(state.blocked[0].reason).toBe('Waiting for user input');
});
it('should emit PhaseBlockedEvent', async () => {
const phase = await phaseRepository.create({
initiativeId: testInitiativeId,
name: 'Test Phase',
});
await phaseRepository.update(phase.id, { status: 'approved' as const });
await phaseDispatchManager.queuePhase(phase.id);
await phaseDispatchManager.blockPhase(phase.id, 'External dependency');
// Find PhaseBlockedEvent (events include queued + blocked)
const blockedEvent = eventBus.emittedEvents.find(
(e) => e.type === 'phase:blocked'
);
expect(blockedEvent).toBeDefined();
expect((blockedEvent as any).payload.phaseId).toBe(phase.id);
expect((blockedEvent as any).payload.reason).toBe('External dependency');
});
it('should remove from queue when blocked', async () => {
const phase = await phaseRepository.create({
initiativeId: testInitiativeId,
name: 'Test Phase',
});
await phaseRepository.update(phase.id, { status: 'approved' as const });
await phaseDispatchManager.queuePhase(phase.id);
await phaseDispatchManager.blockPhase(phase.id, 'Some reason');
const state = await phaseDispatchManager.getPhaseQueueState();
expect(state.queued.length).toBe(0);
});
});
// ===========================================================================
// Dependency Scenario Test
// ===========================================================================
describe('dependency scenario', () => {
it('should dispatch phases in correct dependency order', async () => {
// Create a diamond dependency:
// Phase A (no deps) -> Phase B & C -> Phase D (depends on B and C)
const phaseA = await phaseRepository.create({
initiativeId: testInitiativeId,
name: 'Phase A - Foundation',
});
const phaseB = await phaseRepository.create({
initiativeId: testInitiativeId,
name: 'Phase B - Build on A',
});
const phaseC = await phaseRepository.create({
initiativeId: testInitiativeId,
name: 'Phase C - Also build on A',
});
const phaseD = await phaseRepository.create({
initiativeId: testInitiativeId,
name: 'Phase D - Needs B and C',
});
await phaseRepository.update(phaseA.id, { status: 'approved' as const });
await phaseRepository.update(phaseB.id, { status: 'approved' as const });
await phaseRepository.update(phaseC.id, { status: 'approved' as const });
await phaseRepository.update(phaseD.id, { status: 'approved' as const });
// Set up dependencies
await phaseRepository.createDependency(phaseB.id, phaseA.id);
await phaseRepository.createDependency(phaseC.id, phaseA.id);
await phaseRepository.createDependency(phaseD.id, phaseB.id);
await phaseRepository.createDependency(phaseD.id, phaseC.id);
// Queue all four phases
await phaseDispatchManager.queuePhase(phaseA.id);
await phaseDispatchManager.queuePhase(phaseB.id);
await phaseDispatchManager.queuePhase(phaseC.id);
await phaseDispatchManager.queuePhase(phaseD.id);
// Initially only A should be dispatchable
let state = await phaseDispatchManager.getPhaseQueueState();
expect(state.ready.length).toBe(1);
expect(state.ready[0].phaseId).toBe(phaseA.id);
// Dispatch A
let result = await phaseDispatchManager.dispatchNextPhase();
expect(result.success).toBe(true);
expect(result.phaseId).toBe(phaseA.id);
// Complete A
await phaseDispatchManager.completePhase(phaseA.id);
// Now B and C should be dispatchable (both have A completed)
state = await phaseDispatchManager.getPhaseQueueState();
expect(state.ready.length).toBe(2);
const readyIds = state.ready.map((p) => p.phaseId);
expect(readyIds).toContain(phaseB.id);
expect(readyIds).toContain(phaseC.id);
// D should still not be ready (needs both B and C completed)
expect(readyIds).not.toContain(phaseD.id);
// Dispatch and complete B
result = await phaseDispatchManager.dispatchNextPhase();
expect(result.success).toBe(true);
await phaseDispatchManager.completePhase(result.phaseId!);
// D still not ready (needs C completed too)
state = await phaseDispatchManager.getPhaseQueueState();
expect(state.ready.length).toBe(1); // Only C is ready now
// Dispatch and complete C
result = await phaseDispatchManager.dispatchNextPhase();
expect(result.success).toBe(true);
await phaseDispatchManager.completePhase(result.phaseId!);
// Now D should be ready
state = await phaseDispatchManager.getPhaseQueueState();
expect(state.ready.length).toBe(1);
expect(state.ready[0].phaseId).toBe(phaseD.id);
});
});
});