/** * Integration test to reproduce and fix the crash marking race condition. * * This test simulates the exact scenario where agents complete successfully * but get marked as crashed due to timing issues in the output handler. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { writeFile, mkdir, rm } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { randomBytes } from 'node:crypto'; import { OutputHandler } from '../../agent/output-handler.js'; import type { AgentRepository } from '../../db/repositories/agent-repository.js'; interface TestAgent { id: string; name: string; status: 'idle' | 'running' | 'waiting_for_input' | 'stopped' | 'crashed'; mode: 'execute' | 'discuss' | 'plan' | 'detail' | 'refine'; taskId: string | null; sessionId: string | null; worktreeId: string; createdAt: Date; updatedAt: Date; provider: string; accountId: string | null; pid: number | null; outputFilePath: string | null; result: string | null; pendingQuestions: string | null; initiativeId: string | null; userDismissedAt: Date | null; exitCode: number | null; prompt: string | null; } describe('Crash marking race condition', () => { let outputHandler: OutputHandler; let testAgent: TestAgent; let testDir: string; let mockRepo: AgentRepository; // Track all repository calls let updateCalls: Array<{ id: string; data: any }> = []; let finalAgentStatus: string | null = null; beforeEach(async () => { updateCalls = []; finalAgentStatus = null; // Create test directory structure testDir = join(tmpdir(), `crash-test-${randomBytes(8).toString('hex')}`); const outputDir = join(testDir, '.cw/output'); await mkdir(outputDir, { recursive: true }); // Create test agent testAgent = { id: 'test-agent-id', name: 'test-agent', status: 'running', mode: 'refine', taskId: 'task-1', sessionId: 'session-1', worktreeId: 'worktree-1', createdAt: new Date(), updatedAt: new Date(), provider: 'claude', accountId: null, pid: 12345, outputFilePath: join(testDir, 'output.jsonl'), result: null, pendingQuestions: null, initiativeId: 'init-1', userDismissedAt: null, exitCode: null, prompt: null, }; // Mock repository that tracks all update calls mockRepo = { async findById(id: string) { return id === testAgent.id ? { ...testAgent } : null; }, async update(id: string, data: any) { updateCalls.push({ id, data }); if (data.status) { finalAgentStatus = data.status; testAgent.status = data.status; } return { ...testAgent, ...data }; }, async create() { throw new Error('Not implemented'); }, async findAll() { throw new Error('Not implemented'); }, async findByStatus() { throw new Error('Not implemented'); }, async findByTaskId() { throw new Error('Not implemented'); }, async findByName() { throw new Error('Not implemented'); }, async findBySessionId() { throw new Error('Not implemented'); }, async delete() { throw new Error('Not implemented'); }, async findWaitingWithContext() { throw new Error('Not implemented'); } }; outputHandler = new OutputHandler(mockRepo); }); afterEach(async () => { try { await rm(testDir, { recursive: true }); } catch { // Ignore cleanup errors } }); it('should NOT mark agent as crashed when signal.json indicates completion', async () => { // SETUP: Create a valid completion signal that should prevent crash marking const signalPath = join(testDir, '.cw/output/signal.json'); const signalContent = { status: 'questions', questions: [ { id: 'q1', question: 'Test question?' } ] }; await writeFile(signalPath, JSON.stringify(signalContent, null, 2)); // SETUP: Create empty output file to simulate "no new output detected" scenario const outputFilePath = join(testDir, 'output.jsonl'); await writeFile(outputFilePath, ''); // Empty file simulates the race condition // Mock active agent with output file path const mockActive = { outputFilePath, streamSessionId: 'session-1' }; // Mock getAgentWorkdir function — receives worktreeId, not agentId const getAgentWorkdir = (worktreeId: string) => { expect(worktreeId).toBe(testAgent.worktreeId); return testDir; }; // EXECUTE: Call handleCompletion which should trigger the race condition scenario // This simulates: no stream text + no new file content + valid signal.json await (outputHandler as any).handleCompletion( testAgent.id, mockActive, getAgentWorkdir ); // VERIFY: Agent should NOT be marked as crashed console.log('Update calls:', updateCalls); console.log('Final agent status:', finalAgentStatus); expect(updateCalls.length).toBeGreaterThan(0); expect(finalAgentStatus).not.toBe('crashed'); // Should be marked with the appropriate completion status expect(['idle', 'waiting_for_input', 'stopped']).toContain(finalAgentStatus); }); it('should mark agent as crashed when no completion signal exists', async () => { // SETUP: No signal.json file exists - agent should be marked as crashed const outputFilePath = join(testDir, 'output.jsonl'); await writeFile(outputFilePath, ''); // Empty file const mockActive = { outputFilePath, streamSessionId: 'session-1' }; const getAgentWorkdir = (agentId: string) => testDir; // EXECUTE: This should mark agent as crashed since no completion signal exists await (outputHandler as any).handleCompletion( testAgent.id, mockActive, getAgentWorkdir ); // VERIFY: Agent SHOULD be marked as crashed expect(finalAgentStatus).toBe('crashed'); }); it('should handle the exact slim-wildebeest scenario', async () => { // SETUP: Reproduce the exact conditions that slim-wildebeest had const signalPath = join(testDir, '.cw/output/signal.json'); const exactSignalContent = { "status": "questions", "questions": [ { "id": "q1", "question": "What UI framework/styling system is the admin UI currently using that needs to be replaced?" }, { "id": "q2", "question": "What specific problems with the current admin UI are we solving? (e.g., poor developer experience, design inconsistency, performance issues, lack of accessibility)" } ] }; await writeFile(signalPath, JSON.stringify(exactSignalContent, null, 2)); // Create SUMMARY.md like slim-wildebeest had const summaryPath = join(testDir, '.cw/output/SUMMARY.md'); const summaryContent = `--- files_modified: [] --- Initiative page is essentially empty — lacks context, scope, goals, and technical approach. Requested clarification on current state, problems being solved, scope boundaries, and success criteria before proposing meaningful improvements.`; await writeFile(summaryPath, summaryContent); // Simulate the output file scenario const outputFilePath = join(testDir, 'output.jsonl'); await writeFile(outputFilePath, 'some initial content\n'); // Some content but no new lines const mockActive = { outputFilePath, streamSessionId: 'session-1' }; const getAgentWorkdir = (agentId: string) => testDir; // EXECUTE: This is the exact scenario that caused slim-wildebeest to be marked as crashed await (outputHandler as any).handleCompletion( testAgent.id, mockActive, getAgentWorkdir ); // VERIFY: This should NOT be marked as crashed console.log('slim-wildebeest scenario - Final status:', finalAgentStatus); console.log('slim-wildebeest scenario - Update calls:', updateCalls); expect(finalAgentStatus).not.toBe('crashed'); expect(['idle', 'waiting_for_input', 'stopped']).toContain(finalAgentStatus); }); });