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
491 lines
18 KiB
TypeScript
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');
|
|
});
|
|
});
|
|
});
|