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.
542 lines
20 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|