Files
Codewalkers/apps/server/dispatch/manager.test.ts
Lukas May 28521e1c20 chore: merge main into cw/small-change-flow
Integrates main branch changes (headquarters dashboard, task retry count,
agent prompt persistence, remote sync improvements) with the initiative's
errand agent feature. Both features coexist in the merged result.

Key resolutions:
- Schema: take main's errands table (nullable projectId, no conflictFiles,
  with errandsRelations); migrate to 0035_faulty_human_fly
- Router: keep both errandProcedures and headquartersProcedures
- Errand prompt: take main's simpler version (no question-asking flow)
- Manager: take main's status check (running|idle only, no waiting_for_input)
- Tests: update to match removed conflictFiles field and undefined vs null
2026-03-06 16:48:12 +01:00

555 lines
18 KiB
TypeScript

/**
* 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(),
exitCode: null,
prompt: null,
};
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),
resumeForConversation: vi.fn().mockResolvedValue(false),
sendUserMessage: vi.fn().mockResolvedValue(undefined),
};
}
/**
* 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(),
exitCode: null,
prompt: null,
};
}
// =============================================================================
// 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);
});
});
});