Files
Codewalkers/src/test/e2e/phase-dispatch.test.ts
Lukas May fab7706f5c feat: Phase schema refactor, agent lifecycle module, and log chunks
Phase model changes:
- Drop `number` column (ordering now by createdAt + dependency DAG)
- Replace `description` (plain text) with `content` (Tiptap JSON)
- Add `approved` status as dispatch gate
- Add phase dependency management (list, remove, dependents)
- Approval gate in PhaseDispatchManager.queuePhase()

Agent log chunks:
- New `agent_log_chunks` table for DB-first output persistence
- LogChunkRepository port + DrizzleLogChunkRepository adapter
- FileTailer onRawContent callback streams chunks to DB
- getAgentOutput reads from DB first, falls back to file

Agent lifecycle module (src/agent/lifecycle/):
- SignalManager: atomic signal.json read/write/wait operations
- RetryPolicy: exponential backoff with error-specific strategies
- ErrorAnalyzer: pattern-based error classification
- CleanupStrategy: debug archival vs production cleanup
- AgentLifecycleController: orchestrates retry/recovery flow
- Missing signal recovery with instruction injection

Completion detection fixes:
- Read signal.json file instead of parsing stdout as JSON
- Cancellable pollForCompletion with { cancel } handle
- Centralized state cleanup via cleanupAgentState()
- Credential handler consolidation (prepareProcessEnv)

Prompts refactor:
- Split monolithic prompts.ts into per-mode modules
- Add workspace layout section to agent prompts
- Fix markdown-to-tiptap double-serialization

Server/tRPC:
- Subscription heartbeat (30s) and bounded queue (1000 max)
- Phase CRUD: approvePhase, deletePhase, dependency queries
- Page: findByIds, getPageUpdatedAtMap
- Wire new repositories through container and context
2026-02-09 22:33:28 +01:00

481 lines
18 KiB
TypeScript

/**
* E2E Tests for Phase Parallel Execution
*
* Tests proving phase dispatch/coordination flow works end-to-end
* using the TestHarness with phaseDispatchManager.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { createTestHarness, type TestHarness } from '../index.js';
import type {
PhaseQueuedEvent,
PhaseStartedEvent,
PhaseCompletedEvent,
PhaseBlockedEvent,
} from '../../events/types.js';
describe('Phase Parallel Execution', () => {
let harness: TestHarness;
beforeEach(() => {
harness = createTestHarness();
});
afterEach(() => {
harness.cleanup();
});
// ===========================================================================
// Test 1: Independent phases dispatch in parallel
// ===========================================================================
describe('Independent phases dispatch in parallel', () => {
it('dispatches multiple independent phases when no dependencies exist', async () => {
// Create initiative with 2 independent phases (no dependencies)
const initiative = await harness.initiativeRepository.create({
name: 'Independent Phases Test',
status: 'active',
});
const phaseA = await harness.phaseRepository.create({
initiativeId: initiative.id,
name: 'Phase A',
content: 'Independent phase A',
status: 'pending',
});
const phaseB = await harness.phaseRepository.create({
initiativeId: initiative.id,
name: 'Phase B',
content: 'Independent phase B',
status: 'pending',
});
// Approve phases before queuing
await harness.phaseRepository.update(phaseA.id, { status: 'approved' as const });
await harness.phaseRepository.update(phaseB.id, { status: 'approved' as const });
// Queue both phases
await harness.phaseDispatchManager.queuePhase(phaseA.id);
await harness.phaseDispatchManager.queuePhase(phaseB.id);
// Verify phase:queued events
const queuedEvents = harness.getEventsByType('phase:queued');
expect(queuedEvents.length).toBe(2);
// Get queue state - both should be ready (no dependencies)
const queueState = await harness.phaseDispatchManager.getPhaseQueueState();
expect(queueState.queued.length).toBe(2);
expect(queueState.ready.length).toBe(2);
expect(queueState.blocked.length).toBe(0);
// Both phases should be dispatchable immediately
const readyPhaseIds = queueState.ready.map((p) => p.phaseId);
expect(readyPhaseIds).toContain(phaseA.id);
expect(readyPhaseIds).toContain(phaseB.id);
harness.clearEvents();
// Dispatch first phase
const result1 = await harness.phaseDispatchManager.dispatchNextPhase();
expect(result1.success).toBe(true);
// Dispatch second phase (parallel)
const result2 = await harness.phaseDispatchManager.dispatchNextPhase();
expect(result2.success).toBe(true);
// Verify both dispatched to different phases
expect(result1.phaseId).not.toBe(result2.phaseId);
// Verify phase:started events
const startedEvents = harness.getEventsByType('phase:started');
expect(startedEvents.length).toBe(2);
// Verify both phases are now in_progress
const updatedPhaseA = await harness.phaseRepository.findById(phaseA.id);
const updatedPhaseB = await harness.phaseRepository.findById(phaseB.id);
expect(updatedPhaseA?.status).toBe('in_progress');
expect(updatedPhaseB?.status).toBe('in_progress');
});
});
// ===========================================================================
// Test 2: Dependent phase waits for prerequisite
// ===========================================================================
describe('Dependent phase waits for prerequisite', () => {
it('only dispatches phase A first, then B after A completes', async () => {
// Create phases: A, B (depends on A)
const initiative = await harness.initiativeRepository.create({
name: 'Sequential Phases Test',
status: 'active',
});
const phaseA = await harness.phaseRepository.create({
initiativeId: initiative.id,
name: 'Phase A',
content: 'First phase',
status: 'pending',
});
const phaseB = await harness.phaseRepository.create({
initiativeId: initiative.id,
name: 'Phase B',
content: 'Second phase, depends on A',
status: 'pending',
});
// Approve phases before queuing
await harness.phaseRepository.update(phaseA.id, { status: 'approved' as const });
await harness.phaseRepository.update(phaseB.id, { status: 'approved' as const });
// Create dependency: B depends on A
await harness.phaseRepository.createDependency(phaseB.id, phaseA.id);
// Queue both phases
await harness.phaseDispatchManager.queuePhase(phaseA.id);
await harness.phaseDispatchManager.queuePhase(phaseB.id);
// Check queue state - only A should be ready
const queueState1 = await harness.phaseDispatchManager.getPhaseQueueState();
expect(queueState1.queued.length).toBe(2);
expect(queueState1.ready.length).toBe(1);
expect(queueState1.ready[0].phaseId).toBe(phaseA.id);
harness.clearEvents();
// Dispatch - should get phase A
const result1 = await harness.phaseDispatchManager.dispatchNextPhase();
expect(result1.success).toBe(true);
expect(result1.phaseId).toBe(phaseA.id);
// Try to dispatch again - should fail (B is blocked by A)
const result2 = await harness.phaseDispatchManager.dispatchNextPhase();
expect(result2.success).toBe(false);
expect(result2.reason).toBe('No dispatchable phases');
// Verify phase B still in queue but not ready
const queueState2 = await harness.phaseDispatchManager.getPhaseQueueState();
expect(queueState2.queued.length).toBe(1);
expect(queueState2.ready.length).toBe(0);
// Complete phase A
await harness.phaseDispatchManager.completePhase(phaseA.id);
// Verify phase:completed event for A
const completedEvents = harness.getEventsByType('phase:completed');
expect(completedEvents.length).toBe(1);
expect((completedEvents[0] as PhaseCompletedEvent).payload.phaseId).toBe(phaseA.id);
// Now B should be ready
const queueState3 = await harness.phaseDispatchManager.getPhaseQueueState();
expect(queueState3.ready.length).toBe(1);
expect(queueState3.ready[0].phaseId).toBe(phaseB.id);
harness.clearEvents();
// Dispatch - should get phase B
const result3 = await harness.phaseDispatchManager.dispatchNextPhase();
expect(result3.success).toBe(true);
expect(result3.phaseId).toBe(phaseB.id);
// Verify phase B is now in_progress
const updatedPhaseB = await harness.phaseRepository.findById(phaseB.id);
expect(updatedPhaseB?.status).toBe('in_progress');
});
});
// ===========================================================================
// Test 3: Diamond dependency pattern
// ===========================================================================
describe('Diamond dependency pattern', () => {
it('handles diamond: A -> B,C -> D correctly', async () => {
// Create phases: A, B (depends on A), C (depends on A), D (depends on B, C)
const initiative = await harness.initiativeRepository.create({
name: 'Diamond Pattern Test',
status: 'active',
});
const phaseA = await harness.phaseRepository.create({
initiativeId: initiative.id,
name: 'Phase A',
content: 'Root phase',
status: 'pending',
});
const phaseB = await harness.phaseRepository.create({
initiativeId: initiative.id,
name: 'Phase B',
content: 'Depends on A',
status: 'pending',
});
const phaseC = await harness.phaseRepository.create({
initiativeId: initiative.id,
name: 'Phase C',
content: 'Depends on A',
status: 'pending',
});
const phaseD = await harness.phaseRepository.create({
initiativeId: initiative.id,
name: 'Phase D',
content: 'Depends on B and C',
status: 'pending',
});
// Approve all phases before queuing
await harness.phaseRepository.update(phaseA.id, { status: 'approved' as const });
await harness.phaseRepository.update(phaseB.id, { status: 'approved' as const });
await harness.phaseRepository.update(phaseC.id, { status: 'approved' as const });
await harness.phaseRepository.update(phaseD.id, { status: 'approved' as const });
// Create dependencies
await harness.phaseRepository.createDependency(phaseB.id, phaseA.id);
await harness.phaseRepository.createDependency(phaseC.id, phaseA.id);
await harness.phaseRepository.createDependency(phaseD.id, phaseB.id);
await harness.phaseRepository.createDependency(phaseD.id, phaseC.id);
// Queue all phases
await harness.phaseDispatchManager.queuePhase(phaseA.id);
await harness.phaseDispatchManager.queuePhase(phaseB.id);
await harness.phaseDispatchManager.queuePhase(phaseC.id);
await harness.phaseDispatchManager.queuePhase(phaseD.id);
// Step 1: Only A should be ready
const state1 = await harness.phaseDispatchManager.getPhaseQueueState();
expect(state1.queued.length).toBe(4);
expect(state1.ready.length).toBe(1);
expect(state1.ready[0].phaseId).toBe(phaseA.id);
// Dispatch A
const resultA = await harness.phaseDispatchManager.dispatchNextPhase();
expect(resultA.success).toBe(true);
expect(resultA.phaseId).toBe(phaseA.id);
// Step 2: After A completes, B and C should be ready (parallel)
await harness.phaseDispatchManager.completePhase(phaseA.id);
const state2 = await harness.phaseDispatchManager.getPhaseQueueState();
expect(state2.queued.length).toBe(3); // B, C, D still queued
expect(state2.ready.length).toBe(2); // B and C ready
const readyIds = state2.ready.map((p) => p.phaseId);
expect(readyIds).toContain(phaseB.id);
expect(readyIds).toContain(phaseC.id);
expect(readyIds).not.toContain(phaseD.id);
// Dispatch B and C in parallel
const resultB = await harness.phaseDispatchManager.dispatchNextPhase();
expect(resultB.success).toBe(true);
const resultC = await harness.phaseDispatchManager.dispatchNextPhase();
expect(resultC.success).toBe(true);
// Verify D is still not ready (needs both B and C complete)
const state3 = await harness.phaseDispatchManager.getPhaseQueueState();
expect(state3.ready.length).toBe(0);
expect(state3.queued.length).toBe(1);
expect(state3.queued[0].phaseId).toBe(phaseD.id);
// Step 3: Complete B only - D still not ready
await harness.phaseDispatchManager.completePhase(resultB.phaseId);
const state4 = await harness.phaseDispatchManager.getPhaseQueueState();
expect(state4.ready.length).toBe(0); // D still blocked by C
// Step 4: Complete C - now D should be ready
await harness.phaseDispatchManager.completePhase(resultC.phaseId);
const state5 = await harness.phaseDispatchManager.getPhaseQueueState();
expect(state5.ready.length).toBe(1);
expect(state5.ready[0].phaseId).toBe(phaseD.id);
// Dispatch D
const resultD = await harness.phaseDispatchManager.dispatchNextPhase();
expect(resultD.success).toBe(true);
expect(resultD.phaseId).toBe(phaseD.id);
// Verify D is now in_progress
const updatedPhaseD = await harness.phaseRepository.findById(phaseD.id);
expect(updatedPhaseD?.status).toBe('in_progress');
});
});
// ===========================================================================
// Test 4: Approval gate rejects non-approved phases
// ===========================================================================
describe('Approval gate rejects non-approved phases', () => {
it('rejects queuePhase for pending phase', async () => {
const initiative = await harness.initiativeRepository.create({
name: 'Approval Gate Test',
status: 'active',
});
const phase = await harness.phaseRepository.create({
initiativeId: initiative.id,
name: 'Unapproved Phase',
status: 'pending',
});
await expect(
harness.phaseDispatchManager.queuePhase(phase.id)
).rejects.toThrow('must be approved before queuing');
});
it('rejects queuePhase for in_progress phase', async () => {
const initiative = await harness.initiativeRepository.create({
name: 'Approval Gate Test 2',
status: 'active',
});
const phase = await harness.phaseRepository.create({
initiativeId: initiative.id,
name: 'In Progress Phase',
status: 'in_progress',
});
await expect(
harness.phaseDispatchManager.queuePhase(phase.id)
).rejects.toThrow('must be approved before queuing');
});
});
// ===========================================================================
// Test 5: Blocked phase doesn't dispatch
// ===========================================================================
describe('Blocked phase does not dispatch', () => {
it('prevents dispatch of blocked phase even if dependencies complete', async () => {
// Create phases: A, B (depends on A)
const initiative = await harness.initiativeRepository.create({
name: 'Blocked Phase Test',
status: 'active',
});
const phaseA = await harness.phaseRepository.create({
initiativeId: initiative.id,
name: 'Phase A',
content: 'First phase that will be blocked',
status: 'pending',
});
const phaseB = await harness.phaseRepository.create({
initiativeId: initiative.id,
name: 'Phase B',
content: 'Second phase, depends on A',
status: 'pending',
});
// Approve phases before queuing
await harness.phaseRepository.update(phaseA.id, { status: 'approved' as const });
await harness.phaseRepository.update(phaseB.id, { status: 'approved' as const });
// Create dependency: B depends on A
await harness.phaseRepository.createDependency(phaseB.id, phaseA.id);
// Queue phase A
await harness.phaseDispatchManager.queuePhase(phaseA.id);
// Block phase A
await harness.phaseDispatchManager.blockPhase(phaseA.id, 'External dependency unavailable');
// Verify phase:blocked event
const blockedEvents = harness.getEventsByType('phase:blocked');
expect(blockedEvents.length).toBe(1);
expect((blockedEvents[0] as PhaseBlockedEvent).payload.phaseId).toBe(phaseA.id);
expect((blockedEvents[0] as PhaseBlockedEvent).payload.reason).toBe(
'External dependency unavailable'
);
// Try to dispatch - should fail
const result = await harness.phaseDispatchManager.dispatchNextPhase();
expect(result.success).toBe(false);
expect(result.reason).toBe('No dispatchable phases');
// Verify queue state shows A as blocked
const queueState = await harness.phaseDispatchManager.getPhaseQueueState();
expect(queueState.blocked.length).toBe(1);
expect(queueState.blocked[0].phaseId).toBe(phaseA.id);
expect(queueState.blocked[0].reason).toBe('External dependency unavailable');
// Queue phase B
await harness.phaseDispatchManager.queuePhase(phaseB.id);
// B should never become ready because A is blocked (not completed)
const queueState2 = await harness.phaseDispatchManager.getPhaseQueueState();
expect(queueState2.ready.length).toBe(0);
expect(queueState2.queued.length).toBe(1); // Only B is queued (A is blocked, not queued)
expect(queueState2.queued[0].phaseId).toBe(phaseB.id);
// Try to dispatch B - should fail
const resultB = await harness.phaseDispatchManager.dispatchNextPhase();
expect(resultB.success).toBe(false);
expect(resultB.reason).toBe('No dispatchable phases');
// Verify phase A status is blocked in database
const updatedPhaseA = await harness.phaseRepository.findById(phaseA.id);
expect(updatedPhaseA?.status).toBe('blocked');
});
it('blocked phase prevents all downstream phases from dispatching', async () => {
// Create chain: A -> B -> C, then block A
const initiative = await harness.initiativeRepository.create({
name: 'Chain Block Test',
status: 'active',
});
const phaseA = await harness.phaseRepository.create({
initiativeId: initiative.id,
name: 'Phase A',
content: 'Root phase',
status: 'pending',
});
const phaseB = await harness.phaseRepository.create({
initiativeId: initiative.id,
name: 'Phase B',
content: 'Depends on A',
status: 'pending',
});
const phaseC = await harness.phaseRepository.create({
initiativeId: initiative.id,
name: 'Phase C',
content: 'Depends on B',
status: 'pending',
});
// Approve all phases before queuing
await harness.phaseRepository.update(phaseA.id, { status: 'approved' as const });
await harness.phaseRepository.update(phaseB.id, { status: 'approved' as const });
await harness.phaseRepository.update(phaseC.id, { status: 'approved' as const });
// Create dependency chain: A -> B -> C
await harness.phaseRepository.createDependency(phaseB.id, phaseA.id);
await harness.phaseRepository.createDependency(phaseC.id, phaseB.id);
// Queue all phases
await harness.phaseDispatchManager.queuePhase(phaseA.id);
await harness.phaseDispatchManager.queuePhase(phaseB.id);
await harness.phaseDispatchManager.queuePhase(phaseC.id);
// Block phase A
await harness.phaseDispatchManager.blockPhase(phaseA.id, 'Resource unavailable');
// Verify only B and C are in queue (A is blocked)
const queueState = await harness.phaseDispatchManager.getPhaseQueueState();
expect(queueState.queued.length).toBe(2);
expect(queueState.ready.length).toBe(0); // Neither B nor C can dispatch
expect(queueState.blocked.length).toBe(1);
// Try to dispatch any phase - should fail for all
const result = await harness.phaseDispatchManager.dispatchNextPhase();
expect(result.success).toBe(false);
expect(result.reason).toBe('No dispatchable phases');
});
});
});