Files
Codewalkers/apps/server/test/e2e/recovery-scenarios.test.ts
Lukas May 34578d39c6 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
2026-03-03 11:22:53 +01:00

491 lines
18 KiB
TypeScript

/**
* E2E Tests for Recovery and Extended Scenarios
*
* Tests recovery/resume after interruption scenarios:
* - Queue state survives harness recreation (DB is source of truth)
* - In-progress task recoverable after agent crash
* - Blocked task state persists and can be unblocked
* - Merge queue state recoverable
*
* Tests extended agent Q&A scenarios:
* - Multiple questions in sequence
* - Question surfaces in message queue
* - Agent resumes with answer in context
* - Waiting agent blocks task completion
*
* Uses TestHarness from src/test/ for full system wiring.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
createTestHarness,
SIMPLE_FIXTURE,
type TestHarness,
} from '../index.js';
import type {
AgentWaitingEvent,
AgentResumedEvent,
AgentStoppedEvent,
} from '../../events/types.js';
describe('E2E Recovery Scenarios', () => {
describe('Recovery after interruption', () => {
let harness: TestHarness;
beforeEach(() => {
harness = createTestHarness();
});
afterEach(() => {
harness.cleanup();
vi.useRealTimers();
});
it('queue state survives in database (source of truth)', async () => {
// Seed fixture, queue tasks
const seeded = await harness.seedFixture(SIMPLE_FIXTURE);
const taskAId = seeded.tasks.get('Task A')!;
// Queue task
await harness.dispatchManager.queue(taskAId);
// Verify queue state shows task (queued, not pending)
const queueState1 = await harness.dispatchManager.getQueueState();
expect(queueState1.queued.length).toBe(1);
expect(queueState1.queued[0].taskId).toBe(taskAId);
// The queue state is in memory, but task status is in DB.
// Verify task status in database directly
const task = await harness.taskRepository.findById(taskAId);
expect(task?.status).toBe('pending');
// Verify: even after clearing in-memory queue state,
// we can still find pending tasks from database
const allTasks = await harness.taskRepository.findByParentTaskId(
seeded.taskGroups.get('Task Group 1')!
);
const pendingTasks = allTasks.filter((t) => t.status === 'pending');
// Task A is pending (not queued, but status is pending)
// Task B and C are also pending but depend on Task A
expect(pendingTasks.length).toBeGreaterThanOrEqual(1);
});
it('in-progress task recoverable after agent crash', async () => {
vi.useFakeTimers();
const seeded = await harness.seedFixture(SIMPLE_FIXTURE);
const taskAId = seeded.tasks.get('Task A')!;
// Pre-seed required idle agent
await harness.agentManager.spawn({
name: 'pool-agent',
taskId: 'placeholder',
prompt: 'placeholder',
});
await harness.advanceTimers();
// Set crash scenario
harness.setAgentError(`agent-${taskAId.slice(0, 6)}`, 'Token limit exceeded');
// Queue and dispatch
await harness.dispatchManager.queue(taskAId);
await harness.dispatchManager.dispatchNext();
await harness.advanceTimers();
// Verify task status is 'in_progress' (not completed, not lost)
let task = await harness.taskRepository.findById(taskAId);
expect(task?.status).toBe('in_progress');
// Task can be re-queued and dispatched to a new agent
// First, clear agent manager and create new pool agent
harness.agentManager.clear();
await harness.agentManager.spawn({
name: 'new-pool-agent',
taskId: 'placeholder',
prompt: 'placeholder',
});
await harness.advanceTimers();
// Re-queue the task (it's still in_progress but we can retry)
await harness.dispatchManager.queue(taskAId);
// Set success scenario for the new agent
harness.setAgentDone(`agent-${taskAId.slice(0, 6)}`, 'Task completed after retry');
// Clear events and dispatch again
harness.clearEvents();
const dispatchResult = await harness.dispatchManager.dispatchNext();
await harness.advanceTimers();
// Verify: agent completed successfully
expect(dispatchResult.agentId).toBeDefined();
const agentResult = await harness.agentManager.getResult(dispatchResult.agentId!);
expect(agentResult?.success).toBe(true);
});
it('blocked task state persists in database', async () => {
const seeded = await harness.seedFixture(SIMPLE_FIXTURE);
const taskAId = seeded.tasks.get('Task A')!;
// Queue task and block it
await harness.dispatchManager.queue(taskAId);
await harness.dispatchManager.blockTask(taskAId, 'Waiting for user decision');
// Verify task in blocked state in DB
const task = await harness.taskRepository.findById(taskAId);
expect(task?.status).toBe('blocked');
// Query blocked tasks from queue state
const queueState = await harness.dispatchManager.getQueueState();
expect(queueState.blocked.length).toBe(1);
expect(queueState.blocked[0].taskId).toBe(taskAId);
expect(queueState.blocked[0].reason).toBe('Waiting for user decision');
// Re-queue task to unblock (set status back to pending via repository)
await harness.taskRepository.update(taskAId, { status: 'pending' });
await harness.dispatchManager.queue(taskAId);
// Verify: task now in pending state in database
const unblocked = await harness.taskRepository.findById(taskAId);
expect(unblocked?.status).toBe('pending');
// Task should be in queued list
const queueState2 = await harness.dispatchManager.getQueueState();
expect(queueState2.queued.some((t) => t.taskId === taskAId)).toBe(true);
});
it('merge queue state recoverable', async () => {
const seeded = await harness.seedFixture(SIMPLE_FIXTURE);
const taskAId = seeded.tasks.get('Task A')!;
// Mark task as completed (required for merge)
await harness.taskRepository.update(taskAId, { status: 'completed' });
// Create worktree for task
const worktreeId = `wt-${taskAId.slice(0, 6)}`;
await harness.worktreeManager.create(worktreeId, 'feature-task-a');
// Create agent in agentRepository (required for merge lookup)
await harness.agentRepository.create({
name: `agent-${taskAId.slice(0, 6)}`,
worktreeId,
taskId: taskAId,
status: 'idle',
});
// Queue for merge
await harness.coordinationManager.queueMerge(taskAId);
// Verify merge queue has queued item
const queueState1 = await harness.coordinationManager.getQueueState();
expect(queueState1.queued.some((item) => item.taskId === taskAId)).toBe(true);
// Process merge
const results = await harness.coordinationManager.processMerges('main');
// Verify: merge completed correctly
expect(results.length).toBe(1);
expect(results[0].taskId).toBe(taskAId);
expect(results[0].success).toBe(true);
// Verify: task in merged list
const queueState2 = await harness.coordinationManager.getQueueState();
expect(queueState2.merged.includes(taskAId)).toBe(true);
});
});
describe('Agent Q&A extended scenarios', () => {
let harness: TestHarness;
beforeEach(() => {
harness = createTestHarness();
});
afterEach(() => {
harness.cleanup();
vi.useRealTimers();
});
it('question enters waiting state and completes after resume', async () => {
vi.useFakeTimers();
const seeded = await harness.seedFixture(SIMPLE_FIXTURE);
const taskAId = seeded.tasks.get('Task A')!;
// Pre-seed required idle agent
await harness.agentManager.spawn({
name: 'pool-agent',
taskId: 'placeholder',
prompt: 'placeholder',
});
await harness.advanceTimers();
// Set questions scenario with options
harness.setAgentQuestions(`agent-${taskAId.slice(0, 6)}`, [
{
id: 'q1',
question: 'Which database should I use?',
options: [
{ label: 'PostgreSQL', description: 'Relational, ACID compliant' },
{ label: 'SQLite', description: 'Lightweight, file-based' },
],
},
]);
// Queue and dispatch
await harness.dispatchManager.queue(taskAId);
harness.clearEvents();
const dispatchResult = await harness.dispatchManager.dispatchNext();
await harness.advanceTimers();
// Verify: agent:waiting event emitted
const waitingEvents = harness.getEventsByType('agent:waiting');
expect(waitingEvents.length).toBe(1);
const waitingPayload = (waitingEvents[0] as AgentWaitingEvent).payload;
expect(waitingPayload.taskId).toBe(taskAId);
expect(waitingPayload.questions[0].question).toBe('Which database should I use?');
// Clear and resume with answers map
harness.clearEvents();
await harness.agentManager.resume(dispatchResult.agentId!, { q1: 'PostgreSQL' });
await harness.advanceTimers();
// Verify: resumed and stopped events
const resumedEvents = harness.getEventsByType('agent:resumed');
expect(resumedEvents.length).toBe(1);
const resumedPayload = (resumedEvents[0] as AgentResumedEvent).payload;
expect(resumedPayload.taskId).toBe(taskAId);
const stoppedEvents = harness.getEventsByType('agent:stopped');
expect(stoppedEvents.length).toBe(1);
const stoppedPayload = (stoppedEvents[0] as AgentStoppedEvent).payload;
expect(stoppedPayload.reason).toBe('task_complete');
});
it('questions surface as structured PendingQuestions', async () => {
vi.useFakeTimers();
const seeded = await harness.seedFixture(SIMPLE_FIXTURE);
const taskAId = seeded.tasks.get('Task A')!;
// Pre-seed required idle agent
await harness.agentManager.spawn({
name: 'pool-agent',
taskId: 'placeholder',
prompt: 'placeholder',
});
await harness.advanceTimers();
// Set questions scenario with options
harness.setAgentQuestions(`agent-${taskAId.slice(0, 6)}`, [
{
id: 'q1',
question: 'Select your framework',
options: [
{ label: 'React' },
{ label: 'Vue' },
{ label: 'Svelte' },
],
},
]);
// Queue and dispatch
await harness.dispatchManager.queue(taskAId);
const dispatchResult = await harness.dispatchManager.dispatchNext();
await harness.advanceTimers();
// Verify: agent:waiting event has questions
const waitingEvents = harness.getEventsByType('agent:waiting');
expect(waitingEvents.length).toBe(1);
const waitingPayload = (waitingEvents[0] as AgentWaitingEvent).payload;
expect(waitingPayload.questions[0].question).toBe('Select your framework');
expect(waitingPayload.questions[0].options).toEqual([
{ label: 'React' },
{ label: 'Vue' },
{ label: 'Svelte' },
]);
// Verify: getPendingQuestions returns structured data
const pendingQuestions = await harness.getPendingQuestions(dispatchResult.agentId!);
expect(pendingQuestions).not.toBeNull();
expect(pendingQuestions?.questions[0].question).toBe('Select your framework');
expect(pendingQuestions?.questions[0].options).toEqual([
{ label: 'React' },
{ label: 'Vue' },
{ label: 'Svelte' },
]);
});
it('agent resumes with answer and completes successfully', async () => {
vi.useFakeTimers();
const seeded = await harness.seedFixture(SIMPLE_FIXTURE);
const taskAId = seeded.tasks.get('Task A')!;
// Pre-seed required idle agent
await harness.agentManager.spawn({
name: 'pool-agent',
taskId: 'placeholder',
prompt: 'placeholder',
});
await harness.advanceTimers();
// Set questions scenario
harness.setAgentQuestions(`agent-${taskAId.slice(0, 6)}`, [
{ id: 'q1', question: 'Choose database type' },
]);
// Queue and dispatch
await harness.dispatchManager.queue(taskAId);
const dispatchResult = await harness.dispatchManager.dispatchNext();
await harness.advanceTimers();
// Verify agent is waiting
const agent = await harness.agentManager.get(dispatchResult.agentId!);
expect(agent?.status).toBe('waiting_for_input');
// Resume with answers map
await harness.agentManager.resume(dispatchResult.agentId!, { q1: 'PostgreSQL' });
await harness.advanceTimers();
// Verify: agent completed successfully
const agentResult = await harness.agentManager.getResult(dispatchResult.agentId!);
expect(agentResult).not.toBeNull();
expect(agentResult?.success).toBe(true);
expect(agentResult?.message).toBe('Resumed and completed successfully');
// Verify: agent status is now idle
const finalAgent = await harness.agentManager.get(dispatchResult.agentId!);
expect(finalAgent?.status).toBe('idle');
});
it('waiting agent status transitions correctly through full cycle', async () => {
vi.useFakeTimers();
const seeded = await harness.seedFixture(SIMPLE_FIXTURE);
const taskAId = seeded.tasks.get('Task A')!;
// Pre-seed required idle agent
await harness.agentManager.spawn({
name: 'pool-agent',
taskId: 'placeholder',
prompt: 'placeholder',
});
await harness.advanceTimers();
// Set questions scenario
harness.setAgentQuestions(`agent-${taskAId.slice(0, 6)}`, [
{ id: 'q1', question: 'API key format?' },
]);
// Queue and dispatch
await harness.dispatchManager.queue(taskAId);
const dispatchResult = await harness.dispatchManager.dispatchNext();
// Phase 1: Initially running
let agent = await harness.agentManager.get(dispatchResult.agentId!);
expect(agent?.status).toBe('running');
await harness.advanceTimers();
// Phase 2: After scenario completes, waiting_for_input
agent = await harness.agentManager.get(dispatchResult.agentId!);
expect(agent?.status).toBe('waiting_for_input');
// Verify pending questions exist
const pendingQuestions = await harness.getPendingQuestions(dispatchResult.agentId!);
expect(pendingQuestions?.questions[0].question).toBe('API key format?');
// Phase 3: Resume with answers map
await harness.agentManager.resume(dispatchResult.agentId!, { q1: 'Bearer token' });
// After resume: running again briefly
agent = await harness.agentManager.get(dispatchResult.agentId!);
expect(agent?.status).toBe('running');
await harness.advanceTimers();
// Phase 4: After completion, idle
agent = await harness.agentManager.get(dispatchResult.agentId!);
expect(agent?.status).toBe('idle');
// Verify pending questions is cleared after resume
const clearedQuestions = await harness.getPendingQuestions(dispatchResult.agentId!);
expect(clearedQuestions).toBeNull();
});
it('should handle agent asking multiple questions at once', async () => {
vi.useFakeTimers();
const seeded = await harness.seedFixture(SIMPLE_FIXTURE);
const taskAId = seeded.tasks.get('Task A')!;
// Pre-seed required idle agent
await harness.agentManager.spawn({
name: 'pool-agent',
taskId: 'placeholder',
prompt: 'placeholder',
});
await harness.advanceTimers();
// Setup: agent asks two questions
harness.setAgentQuestions(`agent-${taskAId.slice(0, 6)}`, [
{
id: 'q1',
question: 'Which database?',
options: [{ label: 'SQLite' }, { label: 'Postgres' }],
},
{
id: 'q2',
question: 'Include tests?',
options: [{ label: 'Yes' }, { label: 'No' }],
},
]);
// Queue and dispatch task
await harness.dispatchManager.queue(taskAId);
harness.clearEvents();
const dispatchResult = await harness.dispatchManager.dispatchNext();
await harness.advanceTimers();
// Verify: agent:waiting event emitted
const waitingEvents = harness.getEventsByType('agent:waiting');
expect(waitingEvents.length).toBe(1);
const waitingPayload = (waitingEvents[0] as AgentWaitingEvent).payload;
expect(waitingPayload.taskId).toBe(taskAId);
// Verify both questions present
const pending = await harness.getPendingQuestions(dispatchResult.agentId!);
expect(pending?.questions).toHaveLength(2);
expect(pending?.questions[0].id).toBe('q1');
expect(pending?.questions[0].question).toBe('Which database?');
expect(pending?.questions[1].id).toBe('q2');
expect(pending?.questions[1].question).toBe('Include tests?');
// Resume with answers for both questions
harness.clearEvents();
await harness.agentManager.resume(dispatchResult.agentId!, {
q1: 'SQLite',
q2: 'Yes',
});
await harness.advanceTimers();
// Verify: agent:resumed event emitted
const resumedEvents = harness.getEventsByType('agent:resumed');
expect(resumedEvents.length).toBe(1);
// Verify: agent:stopped event emitted (after resume completes)
const stoppedEvents = harness.getEventsByType('agent:stopped');
expect(stoppedEvents.length).toBe(1);
const stoppedPayload = (stoppedEvents[0] as AgentStoppedEvent).payload;
expect(stoppedPayload.taskId).toBe(taskAId);
expect(stoppedPayload.reason).toBe('task_complete');
// Verify task completed (agent result)
const agentResult = await harness.agentManager.getResult(dispatchResult.agentId!);
expect(agentResult?.success).toBe(true);
// Verify agent is now idle
const finalAgent = await harness.agentManager.get(dispatchResult.agentId!);
expect(finalAgent?.status).toBe('idle');
});
});
});