refactor: Restructure monorepo to apps/server/ and apps/web/ layout
Move src/ → apps/server/ and packages/web/ → apps/web/ to adopt standard monorepo conventions (apps/ for runnable apps, packages/ for reusable libraries). Update all config files, shared package imports, test fixtures, and documentation to reflect new paths. Key fixes: - Update workspace config to ["apps/*", "packages/*"] - Update tsconfig.json rootDir/include for apps/server/ - Add apps/web/** to vitest exclude list - Update drizzle.config.ts schema path - Fix ensure-schema.ts migration path detection (3 levels up in dev, 2 levels up in dist) - Fix tests/integration/cli-server.test.ts import paths - Update packages/shared imports to apps/server/ paths - Update all docs/ files with new paths
This commit is contained in:
14
apps/server/dispatch/index.ts
Normal file
14
apps/server/dispatch/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Dispatch Module - Public API
|
||||
*
|
||||
* Exports the DispatchManager port interface and related types.
|
||||
* All modules should import from this index file.
|
||||
*/
|
||||
|
||||
// Port interfaces (what consumers depend on)
|
||||
export type { DispatchManager, QueuedTask, DispatchResult } from './types.js';
|
||||
export type { PhaseDispatchManager, QueuedPhase, PhaseDispatchResult } from './types.js';
|
||||
|
||||
// Adapter implementations
|
||||
export { DefaultDispatchManager } from './manager.js';
|
||||
export { DefaultPhaseDispatchManager } from './phase-manager.js';
|
||||
548
apps/server/dispatch/manager.test.ts
Normal file
548
apps/server/dispatch/manager.test.ts
Normal file
@@ -0,0 +1,548 @@
|
||||
/**
|
||||
* DefaultDispatchManager Tests
|
||||
*
|
||||
* Tests for the DispatchManager adapter with dependency checking
|
||||
* and queue management.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { DefaultDispatchManager } from './manager.js';
|
||||
import { DrizzleTaskRepository } from '../db/repositories/drizzle/task.js';
|
||||
import { DrizzleMessageRepository } from '../db/repositories/drizzle/message.js';
|
||||
|
||||
import { DrizzlePhaseRepository } from '../db/repositories/drizzle/phase.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 { AgentManager, AgentInfo } from '../agent/types.js';
|
||||
import type { TaskRepository } from '../db/repositories/task-repository.js';
|
||||
import type { MessageRepository } from '../db/repositories/message-repository.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(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock AgentManager.
|
||||
*/
|
||||
function createMockAgentManager(
|
||||
agents: AgentInfo[] = []
|
||||
): AgentManager {
|
||||
const mockAgents = [...agents];
|
||||
|
||||
return {
|
||||
list: vi.fn().mockResolvedValue(mockAgents),
|
||||
get: vi.fn().mockImplementation(async (id: string) => {
|
||||
return mockAgents.find((a) => a.id === id) || null;
|
||||
}),
|
||||
getByName: vi.fn().mockImplementation(async (name: string) => {
|
||||
return mockAgents.find((a) => a.name === name) || null;
|
||||
}),
|
||||
spawn: vi.fn().mockImplementation(async (options) => {
|
||||
const newAgent: AgentInfo = {
|
||||
id: `agent-${Date.now()}`,
|
||||
name: options.name ?? `mock-agent-${Date.now()}`,
|
||||
taskId: options.taskId,
|
||||
initiativeId: options.initiativeId ?? null,
|
||||
sessionId: null,
|
||||
worktreeId: 'worktree-test',
|
||||
status: 'running',
|
||||
mode: options.mode ?? 'execute',
|
||||
provider: options.provider ?? 'claude',
|
||||
accountId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
mockAgents.push(newAgent);
|
||||
return newAgent;
|
||||
}),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
dismiss: vi.fn().mockResolvedValue(undefined),
|
||||
resume: vi.fn().mockResolvedValue(undefined),
|
||||
getResult: vi.fn().mockResolvedValue(null),
|
||||
getPendingQuestions: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an idle agent for testing.
|
||||
*/
|
||||
function createIdleAgent(id: string, name: string): AgentInfo {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
taskId: 'task-123',
|
||||
initiativeId: null,
|
||||
sessionId: 'session-abc',
|
||||
worktreeId: 'worktree-xyz',
|
||||
status: 'idle',
|
||||
mode: 'execute',
|
||||
provider: 'claude',
|
||||
accountId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
describe('DefaultDispatchManager', () => {
|
||||
let db: DrizzleDatabase;
|
||||
let taskRepository: TaskRepository;
|
||||
let messageRepository: MessageRepository;
|
||||
let eventBus: EventBus & { emittedEvents: DomainEvent[] };
|
||||
let agentManager: AgentManager;
|
||||
let dispatchManager: DefaultDispatchManager;
|
||||
let testPhaseId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Set up test database
|
||||
db = createTestDatabase();
|
||||
taskRepository = new DrizzleTaskRepository(db);
|
||||
messageRepository = new DrizzleMessageRepository(db);
|
||||
|
||||
// Create required hierarchy for tasks
|
||||
const initiativeRepo = new DrizzleInitiativeRepository(db);
|
||||
const phaseRepo = new DrizzlePhaseRepository(db);
|
||||
|
||||
|
||||
const initiative = await initiativeRepo.create({
|
||||
name: 'Test Initiative',
|
||||
});
|
||||
const phase = await phaseRepo.create({
|
||||
initiativeId: initiative.id,
|
||||
name: 'Test Phase',
|
||||
});
|
||||
testPhaseId = phase.id;
|
||||
|
||||
// Create mocks
|
||||
eventBus = createMockEventBus();
|
||||
agentManager = createMockAgentManager();
|
||||
|
||||
// Create dispatch manager
|
||||
dispatchManager = new DefaultDispatchManager(
|
||||
taskRepository,
|
||||
messageRepository,
|
||||
agentManager,
|
||||
eventBus
|
||||
);
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// queue() Tests
|
||||
// ===========================================================================
|
||||
|
||||
describe('queue', () => {
|
||||
it('should add task to queue and emit TaskQueuedEvent', async () => {
|
||||
const task = await taskRepository.create({
|
||||
phaseId: testPhaseId,
|
||||
name: 'Test Task',
|
||||
priority: 'high',
|
||||
order: 1,
|
||||
});
|
||||
|
||||
await dispatchManager.queue(task.id);
|
||||
|
||||
const state = await dispatchManager.getQueueState();
|
||||
expect(state.queued.length).toBe(1);
|
||||
expect(state.queued[0].taskId).toBe(task.id);
|
||||
expect(state.queued[0].priority).toBe('high');
|
||||
|
||||
// Check event was emitted
|
||||
expect(eventBus.emittedEvents.length).toBe(1);
|
||||
expect(eventBus.emittedEvents[0].type).toBe('task:queued');
|
||||
expect((eventBus.emittedEvents[0] as any).payload.taskId).toBe(task.id);
|
||||
});
|
||||
|
||||
it('should throw error when task not found', async () => {
|
||||
await expect(dispatchManager.queue('non-existent-id')).rejects.toThrow(
|
||||
'Task not found'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// getNextDispatchable() Tests
|
||||
// ===========================================================================
|
||||
|
||||
describe('getNextDispatchable', () => {
|
||||
it('should return null when queue is empty', async () => {
|
||||
const next = await dispatchManager.getNextDispatchable();
|
||||
expect(next).toBeNull();
|
||||
});
|
||||
|
||||
it('should return task when dependencies are complete', async () => {
|
||||
const task = await taskRepository.create({
|
||||
phaseId: testPhaseId,
|
||||
name: 'Dispatchable Task',
|
||||
priority: 'medium',
|
||||
order: 1,
|
||||
});
|
||||
|
||||
await dispatchManager.queue(task.id);
|
||||
|
||||
const next = await dispatchManager.getNextDispatchable();
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.taskId).toBe(task.id);
|
||||
});
|
||||
|
||||
it('should respect priority ordering (high > medium > low)', async () => {
|
||||
// Create tasks in different priority order
|
||||
const lowTask = await taskRepository.create({
|
||||
phaseId: testPhaseId,
|
||||
name: 'Low Priority',
|
||||
priority: 'low',
|
||||
order: 1,
|
||||
});
|
||||
const highTask = await taskRepository.create({
|
||||
phaseId: testPhaseId,
|
||||
name: 'High Priority',
|
||||
priority: 'high',
|
||||
order: 2,
|
||||
});
|
||||
const mediumTask = await taskRepository.create({
|
||||
phaseId: testPhaseId,
|
||||
name: 'Medium Priority',
|
||||
priority: 'medium',
|
||||
order: 3,
|
||||
});
|
||||
|
||||
// Queue in wrong order (low, high, medium)
|
||||
await dispatchManager.queue(lowTask.id);
|
||||
await dispatchManager.queue(highTask.id);
|
||||
await dispatchManager.queue(mediumTask.id);
|
||||
|
||||
// Should get high priority first
|
||||
const next = await dispatchManager.getNextDispatchable();
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.taskId).toBe(highTask.id);
|
||||
expect(next!.priority).toBe('high');
|
||||
});
|
||||
|
||||
it('should order by queuedAt within same priority (oldest first)', async () => {
|
||||
const task1 = await taskRepository.create({
|
||||
phaseId: testPhaseId,
|
||||
name: 'First Task',
|
||||
priority: 'medium',
|
||||
order: 1,
|
||||
});
|
||||
const task2 = await taskRepository.create({
|
||||
phaseId: testPhaseId,
|
||||
name: 'Second Task',
|
||||
priority: 'medium',
|
||||
order: 2,
|
||||
});
|
||||
|
||||
// Queue first task, wait, then queue second
|
||||
await dispatchManager.queue(task1.id);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
await dispatchManager.queue(task2.id);
|
||||
|
||||
// Should get the first queued task
|
||||
const next = await dispatchManager.getNextDispatchable();
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.taskId).toBe(task1.id);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// completeTask() Tests
|
||||
// ===========================================================================
|
||||
|
||||
describe('completeTask', () => {
|
||||
it('should update task status and emit TaskCompletedEvent', async () => {
|
||||
const task = await taskRepository.create({
|
||||
phaseId: testPhaseId,
|
||||
name: 'Task to Complete',
|
||||
priority: 'medium',
|
||||
order: 1,
|
||||
});
|
||||
|
||||
await dispatchManager.queue(task.id);
|
||||
await dispatchManager.completeTask(task.id);
|
||||
|
||||
// Check task status updated
|
||||
const updatedTask = await taskRepository.findById(task.id);
|
||||
expect(updatedTask!.status).toBe('completed');
|
||||
|
||||
// Check removed from queue
|
||||
const state = await dispatchManager.getQueueState();
|
||||
expect(state.queued.length).toBe(0);
|
||||
|
||||
// Check event was emitted (2 events: queued + completed)
|
||||
expect(eventBus.emittedEvents.length).toBe(2);
|
||||
expect(eventBus.emittedEvents[1].type).toBe('task:completed');
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// blockTask() Tests
|
||||
// ===========================================================================
|
||||
|
||||
describe('blockTask', () => {
|
||||
it('should update task status and emit TaskBlockedEvent', async () => {
|
||||
const task = await taskRepository.create({
|
||||
phaseId: testPhaseId,
|
||||
name: 'Task to Block',
|
||||
priority: 'medium',
|
||||
order: 1,
|
||||
});
|
||||
|
||||
await dispatchManager.queue(task.id);
|
||||
await dispatchManager.blockTask(task.id, 'Waiting for user input');
|
||||
|
||||
// Check task status updated
|
||||
const updatedTask = await taskRepository.findById(task.id);
|
||||
expect(updatedTask!.status).toBe('blocked');
|
||||
|
||||
// Check moved to blocked list
|
||||
const state = await dispatchManager.getQueueState();
|
||||
expect(state.queued.length).toBe(0);
|
||||
expect(state.blocked.length).toBe(1);
|
||||
expect(state.blocked[0].taskId).toBe(task.id);
|
||||
expect(state.blocked[0].reason).toBe('Waiting for user input');
|
||||
|
||||
// Check event was emitted (2 events: queued + blocked)
|
||||
expect(eventBus.emittedEvents.length).toBe(2);
|
||||
expect(eventBus.emittedEvents[1].type).toBe('task:blocked');
|
||||
expect((eventBus.emittedEvents[1] as any).payload.reason).toBe(
|
||||
'Waiting for user input'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// dispatchNext() Tests
|
||||
// ===========================================================================
|
||||
|
||||
describe('dispatchNext', () => {
|
||||
it('should return failure when no tasks ready', async () => {
|
||||
const result = await dispatchManager.dispatchNext();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.reason).toBe('No dispatchable tasks');
|
||||
});
|
||||
|
||||
it('should return failure when no agents available', async () => {
|
||||
const task = await taskRepository.create({
|
||||
phaseId: testPhaseId,
|
||||
name: 'Task needing agent',
|
||||
priority: 'high',
|
||||
order: 1,
|
||||
});
|
||||
|
||||
await dispatchManager.queue(task.id);
|
||||
|
||||
// Agent manager returns empty list (no idle agents)
|
||||
const result = await dispatchManager.dispatchNext();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.taskId).toBe(task.id);
|
||||
expect(result.reason).toBe('No available agents');
|
||||
});
|
||||
|
||||
it('should dispatch task to available agent', async () => {
|
||||
// Create task
|
||||
const task = await taskRepository.create({
|
||||
phaseId: testPhaseId,
|
||||
name: 'Task for dispatch',
|
||||
description: 'Do the thing',
|
||||
priority: 'high',
|
||||
order: 1,
|
||||
});
|
||||
|
||||
await dispatchManager.queue(task.id);
|
||||
|
||||
// Set up agent manager with an idle agent
|
||||
const idleAgent = createIdleAgent('agent-1', 'gastown');
|
||||
agentManager = createMockAgentManager([idleAgent]);
|
||||
dispatchManager = new DefaultDispatchManager(
|
||||
taskRepository,
|
||||
messageRepository,
|
||||
agentManager,
|
||||
eventBus
|
||||
);
|
||||
|
||||
// Re-queue since we created a new dispatch manager
|
||||
await dispatchManager.queue(task.id);
|
||||
|
||||
const result = await dispatchManager.dispatchNext();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.taskId).toBe(task.id);
|
||||
expect(result.agentId).toBeDefined();
|
||||
|
||||
// Check task status updated to in_progress
|
||||
const updatedTask = await taskRepository.findById(task.id);
|
||||
expect(updatedTask!.status).toBe('in_progress');
|
||||
|
||||
// Check spawn was called with correct params (prompt is wrapped by buildExecutePrompt)
|
||||
expect(agentManager.spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
taskId: task.id,
|
||||
prompt: expect.stringContaining('Do the thing'),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should emit TaskDispatchedEvent on successful dispatch', async () => {
|
||||
const task = await taskRepository.create({
|
||||
phaseId: testPhaseId,
|
||||
name: 'Dispatch event test',
|
||||
priority: 'medium',
|
||||
order: 1,
|
||||
});
|
||||
|
||||
const idleAgent = createIdleAgent('agent-1', 'yaletown');
|
||||
agentManager = createMockAgentManager([idleAgent]);
|
||||
dispatchManager = new DefaultDispatchManager(
|
||||
taskRepository,
|
||||
messageRepository,
|
||||
agentManager,
|
||||
eventBus
|
||||
);
|
||||
|
||||
await dispatchManager.queue(task.id);
|
||||
await dispatchManager.dispatchNext();
|
||||
|
||||
// Find TaskDispatchedEvent
|
||||
const dispatchedEvent = eventBus.emittedEvents.find(
|
||||
(e) => e.type === 'task:dispatched'
|
||||
);
|
||||
expect(dispatchedEvent).toBeDefined();
|
||||
expect((dispatchedEvent as any).payload.taskId).toBe(task.id);
|
||||
expect((dispatchedEvent as any).payload.agentId).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// getQueueState() Tests
|
||||
// ===========================================================================
|
||||
|
||||
describe('getQueueState', () => {
|
||||
it('should return correct state', async () => {
|
||||
// Create and queue tasks
|
||||
const task1 = await taskRepository.create({
|
||||
phaseId: testPhaseId,
|
||||
name: 'Ready Task',
|
||||
priority: 'high',
|
||||
order: 1,
|
||||
});
|
||||
const task2 = await taskRepository.create({
|
||||
phaseId: testPhaseId,
|
||||
name: 'Another Ready Task',
|
||||
priority: 'low',
|
||||
order: 2,
|
||||
});
|
||||
const task3 = await taskRepository.create({
|
||||
phaseId: testPhaseId,
|
||||
name: 'Blocked Task',
|
||||
priority: 'medium',
|
||||
order: 3,
|
||||
});
|
||||
|
||||
await dispatchManager.queue(task1.id);
|
||||
await dispatchManager.queue(task2.id);
|
||||
await dispatchManager.queue(task3.id);
|
||||
await dispatchManager.blockTask(task3.id, 'Manual block');
|
||||
|
||||
const state = await dispatchManager.getQueueState();
|
||||
|
||||
// Queued should have 2 (task3 was moved to blocked)
|
||||
expect(state.queued.length).toBe(2);
|
||||
expect(state.queued.map((t) => t.taskId)).toContain(task1.id);
|
||||
expect(state.queued.map((t) => t.taskId)).toContain(task2.id);
|
||||
|
||||
// Ready should have same 2 tasks (no dependencies)
|
||||
expect(state.ready.length).toBe(2);
|
||||
|
||||
// Blocked should have 1
|
||||
expect(state.blocked.length).toBe(1);
|
||||
expect(state.blocked[0].taskId).toBe(task3.id);
|
||||
expect(state.blocked[0].reason).toBe('Manual block');
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Dependency Scenario Test
|
||||
// ===========================================================================
|
||||
|
||||
describe('dependency scenario', () => {
|
||||
it('should handle task dependencies correctly', async () => {
|
||||
// This test verifies the basic flow described in the plan:
|
||||
// - Task A (no deps) - should be dispatchable
|
||||
// - Task B (depends on A) - not dispatchable until A completes
|
||||
// - Task C (depends on A) - not dispatchable until A completes
|
||||
|
||||
// For v1, dependencies are empty (we'd need to enhance queue() to fetch from task_dependencies table)
|
||||
// This test verifies the priority and queue ordering work correctly
|
||||
|
||||
const taskA = await taskRepository.create({
|
||||
phaseId: testPhaseId,
|
||||
name: 'Task A - Foundation',
|
||||
priority: 'high',
|
||||
order: 1,
|
||||
});
|
||||
const taskB = await taskRepository.create({
|
||||
phaseId: testPhaseId,
|
||||
name: 'Task B - Build on A',
|
||||
priority: 'medium',
|
||||
order: 2,
|
||||
});
|
||||
const taskC = await taskRepository.create({
|
||||
phaseId: testPhaseId,
|
||||
name: 'Task C - Also build on A',
|
||||
priority: 'medium',
|
||||
order: 3,
|
||||
});
|
||||
|
||||
// Queue all three
|
||||
await dispatchManager.queue(taskA.id);
|
||||
await dispatchManager.queue(taskB.id);
|
||||
await dispatchManager.queue(taskC.id);
|
||||
|
||||
const state = await dispatchManager.getQueueState();
|
||||
expect(state.queued.length).toBe(3);
|
||||
|
||||
// With no dependencies set, all should be ready
|
||||
// (In a full implementation, B and C would have dependsOn: [taskA.id])
|
||||
expect(state.ready.length).toBe(3);
|
||||
|
||||
// A should come first (highest priority)
|
||||
const next = await dispatchManager.getNextDispatchable();
|
||||
expect(next!.taskId).toBe(taskA.id);
|
||||
|
||||
// Complete A
|
||||
await dispatchManager.completeTask(taskA.id);
|
||||
|
||||
// Now B and C should be next (both medium priority, B queued first)
|
||||
const stateAfterA = await dispatchManager.getQueueState();
|
||||
expect(stateAfterA.queued.length).toBe(2);
|
||||
|
||||
// Get next - should be B (queued before C)
|
||||
const nextAfterA = await dispatchManager.getNextDispatchable();
|
||||
expect(nextAfterA!.taskId).toBe(taskB.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
485
apps/server/dispatch/manager.ts
Normal file
485
apps/server/dispatch/manager.ts
Normal file
@@ -0,0 +1,485 @@
|
||||
/**
|
||||
* Default Dispatch Manager - Adapter Implementation
|
||||
*
|
||||
* Implements DispatchManager interface with in-memory queue
|
||||
* and dependency-ordered dispatch.
|
||||
*
|
||||
* This is the ADAPTER for the DispatchManager PORT.
|
||||
*/
|
||||
|
||||
import type {
|
||||
EventBus,
|
||||
TaskQueuedEvent,
|
||||
TaskCompletedEvent,
|
||||
TaskBlockedEvent,
|
||||
TaskDispatchedEvent,
|
||||
TaskPendingApprovalEvent,
|
||||
} from '../events/index.js';
|
||||
import type { AgentManager } from '../agent/types.js';
|
||||
import type { TaskRepository } from '../db/repositories/task-repository.js';
|
||||
import type { MessageRepository } from '../db/repositories/message-repository.js';
|
||||
import type { InitiativeRepository } from '../db/repositories/initiative-repository.js';
|
||||
import type { PhaseRepository } from '../db/repositories/phase-repository.js';
|
||||
import type { Task } from '../db/schema.js';
|
||||
import type { DispatchManager, QueuedTask, DispatchResult } from './types.js';
|
||||
import { phaseBranchName, taskBranchName, isPlanningCategory, generateInitiativeBranch } from '../git/branch-naming.js';
|
||||
import { buildExecutePrompt } from '../agent/prompts/index.js';
|
||||
import { createModuleLogger } from '../logger/index.js';
|
||||
|
||||
const log = createModuleLogger('dispatch');
|
||||
|
||||
// =============================================================================
|
||||
// Internal Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Internal representation of a blocked task.
|
||||
*/
|
||||
interface BlockedTask {
|
||||
taskId: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DefaultDispatchManager Implementation
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* In-memory implementation of DispatchManager.
|
||||
*
|
||||
* Uses Map for queue management and checks task_dependencies table
|
||||
* for dependency resolution.
|
||||
*/
|
||||
export class DefaultDispatchManager implements DispatchManager {
|
||||
/** Internal queue of tasks pending dispatch */
|
||||
private taskQueue: Map<string, QueuedTask> = new Map();
|
||||
|
||||
/** Blocked tasks with their reasons */
|
||||
private blockedTasks: Map<string, BlockedTask> = new Map();
|
||||
|
||||
constructor(
|
||||
private taskRepository: TaskRepository,
|
||||
private messageRepository: MessageRepository,
|
||||
private agentManager: AgentManager,
|
||||
private eventBus: EventBus,
|
||||
private initiativeRepository?: InitiativeRepository,
|
||||
private phaseRepository?: PhaseRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Queue a task for dispatch.
|
||||
* Fetches task dependencies and adds to internal queue.
|
||||
* Checkpoint tasks are queued but won't auto-dispatch.
|
||||
*/
|
||||
async queue(taskId: string): Promise<void> {
|
||||
// Fetch task to verify it exists and get priority
|
||||
const task = await this.taskRepository.findById(taskId);
|
||||
if (!task) {
|
||||
throw new Error(`Task not found: ${taskId}`);
|
||||
}
|
||||
|
||||
// Get dependencies for this task from the repository
|
||||
const dependsOn = await this.taskRepository.getDependencies(taskId);
|
||||
|
||||
const queuedTask: QueuedTask = {
|
||||
taskId,
|
||||
priority: task.priority,
|
||||
queuedAt: new Date(),
|
||||
dependsOn,
|
||||
};
|
||||
|
||||
this.taskQueue.set(taskId, queuedTask);
|
||||
|
||||
log.info({ taskId, priority: task.priority, isCheckpoint: this.isCheckpointTask(task) }, 'task queued');
|
||||
|
||||
// Emit TaskQueuedEvent
|
||||
const event: TaskQueuedEvent = {
|
||||
type: 'task:queued',
|
||||
timestamp: new Date(),
|
||||
payload: {
|
||||
taskId,
|
||||
priority: task.priority,
|
||||
dependsOn,
|
||||
},
|
||||
};
|
||||
this.eventBus.emit(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next dispatchable task.
|
||||
* Returns task with all dependencies complete, highest priority first.
|
||||
* Checkpoint tasks are excluded (require human action).
|
||||
*/
|
||||
async getNextDispatchable(): Promise<QueuedTask | null> {
|
||||
const queuedTasks = Array.from(this.taskQueue.values());
|
||||
|
||||
if (queuedTasks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Filter to only tasks with all dependencies complete and not checkpoint tasks
|
||||
const readyTasks: QueuedTask[] = [];
|
||||
|
||||
log.debug({ queueSize: queuedTasks.length }, 'evaluating dispatchable tasks');
|
||||
|
||||
for (const qt of queuedTasks) {
|
||||
// Check dependencies
|
||||
const allDepsComplete = await this.areAllDependenciesComplete(qt.dependsOn);
|
||||
if (!allDepsComplete) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this is a checkpoint task (requires human action)
|
||||
const task = await this.taskRepository.findById(qt.taskId);
|
||||
if (task && this.isCheckpointTask(task)) {
|
||||
log.debug({ taskId: qt.taskId, type: task.type }, 'skipping checkpoint task');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip planning-category tasks (handled by architect flow)
|
||||
if (task && isPlanningCategory(task.category)) {
|
||||
log.debug({ taskId: qt.taskId, category: task.category }, 'skipping planning-category task');
|
||||
continue;
|
||||
}
|
||||
|
||||
readyTasks.push(qt);
|
||||
}
|
||||
|
||||
log.debug({ queueSize: queuedTasks.length, readyCount: readyTasks.length }, 'dispatchable evaluation complete');
|
||||
|
||||
if (readyTasks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sort by priority (high > medium > low), then by queuedAt (oldest first)
|
||||
const priorityOrder: Record<string, number> = { high: 0, medium: 1, low: 2 };
|
||||
|
||||
readyTasks.sort((a, b) => {
|
||||
const priorityDiff = priorityOrder[a.priority] - priorityOrder[b.priority];
|
||||
if (priorityDiff !== 0) {
|
||||
return priorityDiff;
|
||||
}
|
||||
return a.queuedAt.getTime() - b.queuedAt.getTime();
|
||||
});
|
||||
|
||||
return readyTasks[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a task as complete.
|
||||
* If the task requires approval, sets status to 'pending_approval' instead.
|
||||
* Updates task status and removes from queue.
|
||||
*
|
||||
* @param taskId - ID of the task to complete
|
||||
* @param agentId - Optional ID of the agent that completed the task
|
||||
*/
|
||||
async completeTask(taskId: string, agentId?: string): Promise<void> {
|
||||
const task = await this.taskRepository.findById(taskId);
|
||||
if (!task) {
|
||||
throw new Error(`Task not found: ${taskId}`);
|
||||
}
|
||||
|
||||
// Determine if approval is required
|
||||
const requiresApproval = await this.taskRequiresApproval(task);
|
||||
|
||||
if (requiresApproval) {
|
||||
// Set to pending_approval instead of completed
|
||||
await this.taskRepository.update(taskId, { status: 'pending_approval' });
|
||||
|
||||
// Remove from queue
|
||||
this.taskQueue.delete(taskId);
|
||||
|
||||
log.info({ taskId, category: task.category }, 'task pending approval');
|
||||
|
||||
// Emit TaskPendingApprovalEvent
|
||||
const event: TaskPendingApprovalEvent = {
|
||||
type: 'task:pending_approval',
|
||||
timestamp: new Date(),
|
||||
payload: {
|
||||
taskId,
|
||||
agentId: agentId ?? '',
|
||||
category: task.category,
|
||||
name: task.name,
|
||||
},
|
||||
};
|
||||
this.eventBus.emit(event);
|
||||
} else {
|
||||
// Complete directly
|
||||
await this.taskRepository.update(taskId, { status: 'completed' });
|
||||
|
||||
// Remove from queue
|
||||
this.taskQueue.delete(taskId);
|
||||
|
||||
log.info({ taskId }, 'task completed');
|
||||
|
||||
// Emit TaskCompletedEvent
|
||||
const event: TaskCompletedEvent = {
|
||||
type: 'task:completed',
|
||||
timestamp: new Date(),
|
||||
payload: {
|
||||
taskId,
|
||||
agentId: agentId ?? '',
|
||||
success: true,
|
||||
message: 'Task completed',
|
||||
},
|
||||
};
|
||||
this.eventBus.emit(event);
|
||||
}
|
||||
|
||||
// Also remove from blocked if it was there
|
||||
this.blockedTasks.delete(taskId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve a task that is pending approval.
|
||||
* Sets status to 'completed' and emits completion event.
|
||||
*/
|
||||
async approveTask(taskId: string): Promise<void> {
|
||||
const task = await this.taskRepository.findById(taskId);
|
||||
if (!task) {
|
||||
throw new Error(`Task not found: ${taskId}`);
|
||||
}
|
||||
|
||||
if (task.status !== 'pending_approval') {
|
||||
throw new Error(`Task ${taskId} is not pending approval (status: ${task.status})`);
|
||||
}
|
||||
|
||||
// Complete the task
|
||||
await this.taskRepository.update(taskId, { status: 'completed' });
|
||||
|
||||
log.info({ taskId }, 'task approved and completed');
|
||||
|
||||
// Emit TaskCompletedEvent
|
||||
const event: TaskCompletedEvent = {
|
||||
type: 'task:completed',
|
||||
timestamp: new Date(),
|
||||
payload: {
|
||||
taskId,
|
||||
agentId: '',
|
||||
success: true,
|
||||
message: 'Task approved',
|
||||
},
|
||||
};
|
||||
this.eventBus.emit(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a task as blocked.
|
||||
* Updates task status and records block reason.
|
||||
*/
|
||||
async blockTask(taskId: string, reason: string): Promise<void> {
|
||||
// Update task status to 'blocked'
|
||||
await this.taskRepository.update(taskId, { status: 'blocked' });
|
||||
|
||||
// Record in blocked map
|
||||
this.blockedTasks.set(taskId, { taskId, reason });
|
||||
|
||||
log.warn({ taskId, reason }, 'task blocked');
|
||||
|
||||
// Remove from queue (blocked tasks aren't dispatchable)
|
||||
this.taskQueue.delete(taskId);
|
||||
|
||||
// Emit TaskBlockedEvent
|
||||
const event: TaskBlockedEvent = {
|
||||
type: 'task:blocked',
|
||||
timestamp: new Date(),
|
||||
payload: {
|
||||
taskId,
|
||||
reason,
|
||||
},
|
||||
};
|
||||
this.eventBus.emit(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch next available task to an agent.
|
||||
*/
|
||||
async dispatchNext(): Promise<DispatchResult> {
|
||||
// Get next dispatchable task
|
||||
const nextTask = await this.getNextDispatchable();
|
||||
|
||||
if (!nextTask) {
|
||||
log.debug('no dispatchable tasks');
|
||||
return {
|
||||
success: false,
|
||||
taskId: '',
|
||||
reason: 'No dispatchable tasks',
|
||||
};
|
||||
}
|
||||
|
||||
// Find available agent (status='idle')
|
||||
const agents = await this.agentManager.list();
|
||||
const idleAgent = agents.find((a) => a.status === 'idle');
|
||||
|
||||
if (!idleAgent) {
|
||||
log.debug('no available agents');
|
||||
return {
|
||||
success: false,
|
||||
taskId: nextTask.taskId,
|
||||
reason: 'No available agents',
|
||||
};
|
||||
}
|
||||
|
||||
// Get task details
|
||||
const task = await this.taskRepository.findById(nextTask.taskId);
|
||||
if (!task) {
|
||||
return {
|
||||
success: false,
|
||||
taskId: nextTask.taskId,
|
||||
reason: 'Task not found',
|
||||
};
|
||||
}
|
||||
|
||||
// Compute branch info for branch-aware spawning
|
||||
let baseBranch: string | undefined;
|
||||
let branchName: string | undefined;
|
||||
|
||||
if (task.initiativeId && this.initiativeRepository) {
|
||||
try {
|
||||
if (isPlanningCategory(task.category)) {
|
||||
// Planning tasks run on project default branches — no initiative branch needed.
|
||||
// baseBranch and branchName remain undefined; ProcessManager uses per-project defaults.
|
||||
} else if (task.phaseId && this.phaseRepository) {
|
||||
// Execution task — ensure initiative has a branch
|
||||
const initiative = await this.initiativeRepository.findById(task.initiativeId);
|
||||
if (initiative) {
|
||||
let initBranch = initiative.branch;
|
||||
if (!initBranch) {
|
||||
initBranch = generateInitiativeBranch(initiative.name);
|
||||
await this.initiativeRepository.update(initiative.id, { branch: initBranch });
|
||||
}
|
||||
|
||||
const phase = await this.phaseRepository.findById(task.phaseId);
|
||||
if (phase) {
|
||||
if (task.category === 'merge') {
|
||||
// Merge tasks work directly on the phase branch
|
||||
baseBranch = initBranch;
|
||||
branchName = phaseBranchName(initBranch, phase.name);
|
||||
} else {
|
||||
baseBranch = phaseBranchName(initBranch, phase.name);
|
||||
branchName = taskBranchName(initBranch, task.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal: fall back to default branching
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn agent with task (alias auto-generated by agent manager)
|
||||
const agent = await this.agentManager.spawn({
|
||||
taskId: nextTask.taskId,
|
||||
initiativeId: task.initiativeId ?? undefined,
|
||||
phaseId: task.phaseId ?? undefined,
|
||||
prompt: buildExecutePrompt(task.description || task.name),
|
||||
baseBranch,
|
||||
branchName,
|
||||
});
|
||||
|
||||
log.info({ taskId: nextTask.taskId, agentId: agent.id }, 'task dispatched');
|
||||
|
||||
// Update task status to 'in_progress'
|
||||
await this.taskRepository.update(nextTask.taskId, { status: 'in_progress' });
|
||||
|
||||
// Remove from queue (now being worked on)
|
||||
this.taskQueue.delete(nextTask.taskId);
|
||||
|
||||
// Emit TaskDispatchedEvent
|
||||
const event: TaskDispatchedEvent = {
|
||||
type: 'task:dispatched',
|
||||
timestamp: new Date(),
|
||||
payload: {
|
||||
taskId: nextTask.taskId,
|
||||
agentId: agent.id,
|
||||
agentName: agent.name,
|
||||
},
|
||||
};
|
||||
this.eventBus.emit(event);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
taskId: nextTask.taskId,
|
||||
agentId: agent.id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current queue state.
|
||||
*/
|
||||
async getQueueState(): Promise<{
|
||||
queued: QueuedTask[];
|
||||
ready: QueuedTask[];
|
||||
blocked: Array<{ taskId: string; reason: string }>;
|
||||
}> {
|
||||
const allQueued = Array.from(this.taskQueue.values());
|
||||
|
||||
// Determine which are ready
|
||||
const ready: QueuedTask[] = [];
|
||||
for (const qt of allQueued) {
|
||||
const allDepsComplete = await this.areAllDependenciesComplete(qt.dependsOn);
|
||||
if (allDepsComplete) {
|
||||
ready.push(qt);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
queued: allQueued,
|
||||
ready,
|
||||
blocked: Array.from(this.blockedTasks.values()),
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Private Helpers
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Check if all dependencies are complete.
|
||||
*/
|
||||
private async areAllDependenciesComplete(dependsOn: string[]): Promise<boolean> {
|
||||
if (dependsOn.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const depTaskId of dependsOn) {
|
||||
const depTask = await this.taskRepository.findById(depTaskId);
|
||||
if (!depTask || depTask.status !== 'completed') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a task is a checkpoint task.
|
||||
* Checkpoint tasks require human action and don't auto-dispatch.
|
||||
*/
|
||||
private isCheckpointTask(task: Task): boolean {
|
||||
return task.type.startsWith('checkpoint:');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a task requires approval before being marked complete.
|
||||
* Checks task-level override first, then falls back to initiative setting.
|
||||
*/
|
||||
private async taskRequiresApproval(task: Task): Promise<boolean> {
|
||||
// Task-level override takes precedence
|
||||
if (task.requiresApproval !== null) {
|
||||
return task.requiresApproval;
|
||||
}
|
||||
|
||||
// Fall back to initiative setting if we have initiative access
|
||||
if (this.initiativeRepository && task.initiativeId) {
|
||||
const initiative = await this.initiativeRepository.findById(task.initiativeId);
|
||||
if (initiative) {
|
||||
return initiative.mergeRequiresApproval;
|
||||
}
|
||||
}
|
||||
|
||||
// If task has a phaseId but no initiativeId, we could traverse up but for now default to false
|
||||
// Default: no approval required
|
||||
return false;
|
||||
}
|
||||
}
|
||||
541
apps/server/dispatch/phase-manager.test.ts
Normal file
541
apps/server/dispatch/phase-manager.test.ts
Normal file
@@ -0,0 +1,541 @@
|
||||
/**
|
||||
* 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(),
|
||||
approveTask: vi.fn(),
|
||||
blockTask: 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
326
apps/server/dispatch/phase-manager.ts
Normal file
326
apps/server/dispatch/phase-manager.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* Default Phase Dispatch Manager - Adapter Implementation
|
||||
*
|
||||
* Implements PhaseDispatchManager interface with in-memory queue
|
||||
* and dependency-ordered dispatch.
|
||||
*
|
||||
* This is the ADAPTER for the PhaseDispatchManager PORT.
|
||||
*/
|
||||
|
||||
import type {
|
||||
EventBus,
|
||||
PhaseQueuedEvent,
|
||||
PhaseStartedEvent,
|
||||
PhaseCompletedEvent,
|
||||
PhaseBlockedEvent,
|
||||
} from '../events/index.js';
|
||||
import type { PhaseRepository } from '../db/repositories/phase-repository.js';
|
||||
import type { TaskRepository } from '../db/repositories/task-repository.js';
|
||||
import type { InitiativeRepository } from '../db/repositories/initiative-repository.js';
|
||||
import type { ProjectRepository } from '../db/repositories/project-repository.js';
|
||||
import type { BranchManager } from '../git/branch-manager.js';
|
||||
import type { PhaseDispatchManager, DispatchManager, QueuedPhase, PhaseDispatchResult } from './types.js';
|
||||
import { phaseBranchName, isPlanningCategory } from '../git/branch-naming.js';
|
||||
import { ensureProjectClone } from '../git/project-clones.js';
|
||||
import { createModuleLogger } from '../logger/index.js';
|
||||
|
||||
const log = createModuleLogger('phase-dispatch');
|
||||
|
||||
// =============================================================================
|
||||
// Internal Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Internal representation of a blocked phase.
|
||||
*/
|
||||
interface BlockedPhase {
|
||||
phaseId: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DefaultPhaseDispatchManager Implementation
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* In-memory implementation of PhaseDispatchManager.
|
||||
*
|
||||
* Uses Map for queue management and checks phase_dependencies table
|
||||
* for dependency resolution.
|
||||
*/
|
||||
export class DefaultPhaseDispatchManager implements PhaseDispatchManager {
|
||||
/** Internal queue of phases pending dispatch */
|
||||
private phaseQueue: Map<string, QueuedPhase> = new Map();
|
||||
|
||||
/** Blocked phases with their reasons */
|
||||
private blockedPhases: Map<string, BlockedPhase> = new Map();
|
||||
|
||||
constructor(
|
||||
private phaseRepository: PhaseRepository,
|
||||
private taskRepository: TaskRepository,
|
||||
private dispatchManager: DispatchManager,
|
||||
private eventBus: EventBus,
|
||||
private initiativeRepository?: InitiativeRepository,
|
||||
private projectRepository?: ProjectRepository,
|
||||
private branchManager?: BranchManager,
|
||||
private workspaceRoot?: string,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Queue a phase for dispatch.
|
||||
* Only approved phases can be queued.
|
||||
* Fetches phase dependencies and adds to internal queue.
|
||||
*/
|
||||
async queuePhase(phaseId: string): Promise<void> {
|
||||
// Fetch phase to verify it exists and get initiativeId
|
||||
const phase = await this.phaseRepository.findById(phaseId);
|
||||
if (!phase) {
|
||||
throw new Error(`Phase not found: ${phaseId}`);
|
||||
}
|
||||
|
||||
// Approval gate: only approved phases can be queued
|
||||
if (phase.status !== 'approved') {
|
||||
throw new Error(`Phase '${phaseId}' must be approved before queuing (current status: ${phase.status})`);
|
||||
}
|
||||
|
||||
// Get dependencies for this phase
|
||||
const dependsOn = await this.phaseRepository.getDependencies(phaseId);
|
||||
|
||||
const queuedPhase: QueuedPhase = {
|
||||
phaseId,
|
||||
initiativeId: phase.initiativeId,
|
||||
queuedAt: new Date(),
|
||||
dependsOn,
|
||||
};
|
||||
|
||||
this.phaseQueue.set(phaseId, queuedPhase);
|
||||
|
||||
// Emit PhaseQueuedEvent
|
||||
const event: PhaseQueuedEvent = {
|
||||
type: 'phase:queued',
|
||||
timestamp: new Date(),
|
||||
payload: {
|
||||
phaseId,
|
||||
initiativeId: phase.initiativeId,
|
||||
dependsOn,
|
||||
},
|
||||
};
|
||||
this.eventBus.emit(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next dispatchable phase.
|
||||
* Returns phase with all dependencies complete, sorted by queuedAt (oldest first).
|
||||
*/
|
||||
async getNextDispatchablePhase(): Promise<QueuedPhase | null> {
|
||||
const queuedPhases = Array.from(this.phaseQueue.values());
|
||||
|
||||
if (queuedPhases.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Filter to only phases with all dependencies complete
|
||||
const readyPhases: QueuedPhase[] = [];
|
||||
|
||||
for (const qp of queuedPhases) {
|
||||
const allDepsComplete = await this.areAllPhaseDependenciesComplete(qp.dependsOn);
|
||||
if (allDepsComplete) {
|
||||
readyPhases.push(qp);
|
||||
}
|
||||
}
|
||||
|
||||
if (readyPhases.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sort by queuedAt (oldest first)
|
||||
readyPhases.sort((a, b) => a.queuedAt.getTime() - b.queuedAt.getTime());
|
||||
|
||||
return readyPhases[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch next available phase.
|
||||
* Updates phase status to 'in_progress', queues its tasks, and emits PhaseStartedEvent.
|
||||
*/
|
||||
async dispatchNextPhase(): Promise<PhaseDispatchResult> {
|
||||
// Get next dispatchable phase
|
||||
const nextPhase = await this.getNextDispatchablePhase();
|
||||
|
||||
if (!nextPhase) {
|
||||
return {
|
||||
success: false,
|
||||
phaseId: '',
|
||||
reason: 'No dispatchable phases',
|
||||
};
|
||||
}
|
||||
|
||||
// Get phase details for event
|
||||
const phase = await this.phaseRepository.findById(nextPhase.phaseId);
|
||||
if (!phase) {
|
||||
return {
|
||||
success: false,
|
||||
phaseId: nextPhase.phaseId,
|
||||
reason: 'Phase not found',
|
||||
};
|
||||
}
|
||||
|
||||
// Update phase status to 'in_progress'
|
||||
await this.phaseRepository.update(nextPhase.phaseId, { status: 'in_progress' });
|
||||
|
||||
// Create phase branch in all linked project clones
|
||||
if (this.initiativeRepository && this.projectRepository && this.branchManager && this.workspaceRoot) {
|
||||
try {
|
||||
const initiative = await this.initiativeRepository.findById(phase.initiativeId);
|
||||
if (initiative?.branch) {
|
||||
const initBranch = initiative.branch;
|
||||
const phBranch = phaseBranchName(initBranch, phase.name);
|
||||
const projects = await this.projectRepository.findProjectsByInitiativeId(phase.initiativeId);
|
||||
for (const project of projects) {
|
||||
const clonePath = await ensureProjectClone(project, this.workspaceRoot);
|
||||
await this.branchManager.ensureBranch(clonePath, initBranch, project.defaultBranch);
|
||||
await this.branchManager.ensureBranch(clonePath, phBranch, initBranch);
|
||||
}
|
||||
log.info({ phaseId: nextPhase.phaseId, phBranch, initBranch }, 'phase branch created');
|
||||
}
|
||||
} catch (err) {
|
||||
log.error({ phaseId: nextPhase.phaseId, err: err instanceof Error ? err.message : String(err) }, 'failed to create phase branch');
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from queue (now being worked on)
|
||||
this.phaseQueue.delete(nextPhase.phaseId);
|
||||
|
||||
// Auto-queue pending execution tasks for this phase (skip planning-category tasks)
|
||||
const phaseTasks = await this.taskRepository.findByPhaseId(nextPhase.phaseId);
|
||||
for (const task of phaseTasks) {
|
||||
if (task.status === 'pending' && !isPlanningCategory(task.category)) {
|
||||
await this.dispatchManager.queue(task.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit PhaseStartedEvent
|
||||
const event: PhaseStartedEvent = {
|
||||
type: 'phase:started',
|
||||
timestamp: new Date(),
|
||||
payload: {
|
||||
phaseId: nextPhase.phaseId,
|
||||
initiativeId: phase.initiativeId,
|
||||
},
|
||||
};
|
||||
this.eventBus.emit(event);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
phaseId: nextPhase.phaseId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a phase as complete.
|
||||
* Updates phase status and removes from queue.
|
||||
*/
|
||||
async completePhase(phaseId: string): Promise<void> {
|
||||
// Get phase for event
|
||||
const phase = await this.phaseRepository.findById(phaseId);
|
||||
if (!phase) {
|
||||
throw new Error(`Phase not found: ${phaseId}`);
|
||||
}
|
||||
|
||||
// Update phase status to 'completed'
|
||||
await this.phaseRepository.update(phaseId, { status: 'completed' });
|
||||
|
||||
// Remove from queue
|
||||
this.phaseQueue.delete(phaseId);
|
||||
|
||||
// Also remove from blocked if it was there
|
||||
this.blockedPhases.delete(phaseId);
|
||||
|
||||
// Emit PhaseCompletedEvent
|
||||
const event: PhaseCompletedEvent = {
|
||||
type: 'phase:completed',
|
||||
timestamp: new Date(),
|
||||
payload: {
|
||||
phaseId,
|
||||
initiativeId: phase.initiativeId,
|
||||
success: true,
|
||||
message: 'Phase completed',
|
||||
},
|
||||
};
|
||||
this.eventBus.emit(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a phase as blocked.
|
||||
* Updates phase status and records block reason.
|
||||
*/
|
||||
async blockPhase(phaseId: string, reason: string): Promise<void> {
|
||||
// Update phase status to 'blocked'
|
||||
await this.phaseRepository.update(phaseId, { status: 'blocked' });
|
||||
|
||||
// Record in blocked map
|
||||
this.blockedPhases.set(phaseId, { phaseId, reason });
|
||||
|
||||
// Remove from queue (blocked phases aren't dispatchable)
|
||||
this.phaseQueue.delete(phaseId);
|
||||
|
||||
// Emit PhaseBlockedEvent
|
||||
const event: PhaseBlockedEvent = {
|
||||
type: 'phase:blocked',
|
||||
timestamp: new Date(),
|
||||
payload: {
|
||||
phaseId,
|
||||
reason,
|
||||
},
|
||||
};
|
||||
this.eventBus.emit(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current phase queue state.
|
||||
*/
|
||||
async getPhaseQueueState(): Promise<{
|
||||
queued: QueuedPhase[];
|
||||
ready: QueuedPhase[];
|
||||
blocked: Array<{ phaseId: string; reason: string }>;
|
||||
}> {
|
||||
const allQueued = Array.from(this.phaseQueue.values());
|
||||
|
||||
// Determine which are ready
|
||||
const ready: QueuedPhase[] = [];
|
||||
for (const qp of allQueued) {
|
||||
const allDepsComplete = await this.areAllPhaseDependenciesComplete(qp.dependsOn);
|
||||
if (allDepsComplete) {
|
||||
ready.push(qp);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
queued: allQueued,
|
||||
ready,
|
||||
blocked: Array.from(this.blockedPhases.values()),
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Private Helpers
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Check if all phase dependencies are complete.
|
||||
*/
|
||||
private async areAllPhaseDependenciesComplete(dependsOn: string[]): Promise<boolean> {
|
||||
if (dependsOn.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const depPhaseId of dependsOn) {
|
||||
const depPhase = await this.phaseRepository.findById(depPhaseId);
|
||||
if (!depPhase || depPhase.status !== 'completed') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
232
apps/server/dispatch/types.ts
Normal file
232
apps/server/dispatch/types.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Dispatch Module Types
|
||||
*
|
||||
* Port interfaces for task and phase dispatch management.
|
||||
* DispatchManager and PhaseDispatchManager are PORTS. Implementations are ADAPTERS.
|
||||
*
|
||||
* This follows the same hexagonal architecture pattern as EventBus and AgentManager:
|
||||
* - Interface defines the contract (port)
|
||||
* - Implementations can be swapped without changing consumers
|
||||
* - Enables testing with in-memory/mock implementations
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// Dispatch Domain Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Represents a task queued for dispatch.
|
||||
* Tasks are dispatched when all dependencies complete.
|
||||
*/
|
||||
export interface QueuedTask {
|
||||
/** Unique identifier for the task */
|
||||
taskId: string;
|
||||
/** Task priority level */
|
||||
priority: 'low' | 'medium' | 'high';
|
||||
/** When the task was queued */
|
||||
queuedAt: Date;
|
||||
/** Task IDs that must complete before this task can be dispatched */
|
||||
dependsOn: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a dispatch operation.
|
||||
* Success means task was assigned to an agent; failure means dispatch couldn't happen.
|
||||
*/
|
||||
export interface DispatchResult {
|
||||
/** Whether the task was successfully dispatched */
|
||||
success: boolean;
|
||||
/** ID of the task that was dispatched */
|
||||
taskId: string;
|
||||
/** ID of the agent assigned to the task (only present on success) */
|
||||
agentId?: string;
|
||||
/** Reason why dispatch failed (only present on failure) */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DispatchManager Port Interface
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* DispatchManager Port Interface
|
||||
*
|
||||
* Manages task dispatch queue with dependency ordering.
|
||||
*
|
||||
* Covers requirements:
|
||||
* - TASK-04: Dependency-ordered dispatch
|
||||
* - TASK-05: Work queue for available agents
|
||||
*/
|
||||
export interface DispatchManager {
|
||||
/**
|
||||
* Queue a task for dispatch.
|
||||
* Task will be dispatched when all dependencies complete.
|
||||
*
|
||||
* @param taskId - ID of the task to queue
|
||||
*/
|
||||
queue(taskId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get next dispatchable task.
|
||||
* Returns task with all dependencies complete, highest priority first.
|
||||
* Returns null if no tasks ready.
|
||||
*
|
||||
* @returns Next dispatchable task or null
|
||||
*/
|
||||
getNextDispatchable(): Promise<QueuedTask | null>;
|
||||
|
||||
/**
|
||||
* Dispatch next available task to an agent.
|
||||
* Finds available agent, assigns task, spawns agent.
|
||||
* Returns dispatch result.
|
||||
*
|
||||
* @returns Result of the dispatch operation
|
||||
*/
|
||||
dispatchNext(): Promise<DispatchResult>;
|
||||
|
||||
/**
|
||||
* Mark a task as complete.
|
||||
* If the task requires approval, sets status to 'pending_approval' instead.
|
||||
* Triggers re-evaluation of dependent tasks.
|
||||
*
|
||||
* @param taskId - ID of the completed task
|
||||
* @param agentId - Optional ID of the agent that completed the task
|
||||
*/
|
||||
completeTask(taskId: string, agentId?: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Approve a task that is pending approval.
|
||||
* Sets status to 'completed' and emits completion event.
|
||||
*
|
||||
* @param taskId - ID of the task to approve
|
||||
*/
|
||||
approveTask(taskId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Mark a task as blocked.
|
||||
* Task will not be dispatched until unblocked.
|
||||
*
|
||||
* @param taskId - ID of the task to block
|
||||
* @param reason - Reason for blocking
|
||||
*/
|
||||
blockTask(taskId: string, reason: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get current queue state.
|
||||
* Returns all queued tasks with their dispatch readiness.
|
||||
*
|
||||
* @returns Queue state with queued, ready, and blocked tasks
|
||||
*/
|
||||
getQueueState(): Promise<{
|
||||
/** All queued tasks */
|
||||
queued: QueuedTask[];
|
||||
/** Tasks ready for dispatch (all dependencies complete) */
|
||||
ready: QueuedTask[];
|
||||
/** Tasks that are blocked */
|
||||
blocked: Array<{ taskId: string; reason: string }>;
|
||||
}>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Phase Dispatch Domain Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Represents a phase queued for dispatch.
|
||||
* Phases are dispatched when all dependencies complete.
|
||||
*/
|
||||
export interface QueuedPhase {
|
||||
/** Unique identifier for the phase */
|
||||
phaseId: string;
|
||||
/** Initiative this phase belongs to */
|
||||
initiativeId: string;
|
||||
/** When the phase was queued */
|
||||
queuedAt: Date;
|
||||
/** Phase IDs that must complete before this phase can be dispatched */
|
||||
dependsOn: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a phase dispatch operation.
|
||||
* Success means phase was dispatched; failure means dispatch couldn't happen.
|
||||
*/
|
||||
export interface PhaseDispatchResult {
|
||||
/** Whether the phase was successfully dispatched */
|
||||
success: boolean;
|
||||
/** ID of the phase that was dispatched */
|
||||
phaseId: string;
|
||||
/** Reason why dispatch failed (only present on failure) */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PhaseDispatchManager Port Interface
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* PhaseDispatchManager Port Interface
|
||||
*
|
||||
* Manages phase dispatch queue with dependency ordering.
|
||||
* Enables queuing and dispatching phases based on their dependencies.
|
||||
*
|
||||
* Follows exact patterns from DispatchManager for tasks.
|
||||
*/
|
||||
export interface PhaseDispatchManager {
|
||||
/**
|
||||
* Queue a phase for dispatch.
|
||||
* Phase will be dispatched when all dependencies complete.
|
||||
*
|
||||
* @param phaseId - ID of the phase to queue
|
||||
*/
|
||||
queuePhase(phaseId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get next dispatchable phase.
|
||||
* Returns phase with all dependencies complete.
|
||||
* Returns null if no phases ready.
|
||||
*
|
||||
* @returns Next dispatchable phase or null
|
||||
*/
|
||||
getNextDispatchablePhase(): Promise<QueuedPhase | null>;
|
||||
|
||||
/**
|
||||
* Dispatch next available phase.
|
||||
* Finds next ready phase and dispatches it.
|
||||
* Returns dispatch result.
|
||||
*
|
||||
* @returns Result of the dispatch operation
|
||||
*/
|
||||
dispatchNextPhase(): Promise<PhaseDispatchResult>;
|
||||
|
||||
/**
|
||||
* Mark a phase as complete.
|
||||
* Triggers re-evaluation of dependent phases.
|
||||
*
|
||||
* @param phaseId - ID of the completed phase
|
||||
*/
|
||||
completePhase(phaseId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Mark a phase as blocked.
|
||||
* Phase will not be dispatched until unblocked.
|
||||
*
|
||||
* @param phaseId - ID of the phase to block
|
||||
* @param reason - Reason for blocking
|
||||
*/
|
||||
blockPhase(phaseId: string, reason: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get current phase queue state.
|
||||
* Returns all queued phases with their dispatch readiness.
|
||||
*
|
||||
* @returns Queue state with queued, ready, and blocked phases
|
||||
*/
|
||||
getPhaseQueueState(): Promise<{
|
||||
/** All queued phases */
|
||||
queued: QueuedPhase[];
|
||||
/** Phases ready for dispatch (all dependencies complete) */
|
||||
ready: QueuedPhase[];
|
||||
/** Phases that are blocked */
|
||||
blocked: Array<{ phaseId: string; reason: string }>;
|
||||
}>;
|
||||
}
|
||||
Reference in New Issue
Block a user