Files
Codewalkers/apps/server/test/integration/crash-race-condition.test.ts
Lukas May 7e6921f01e feat: enrich listWaitingAgents with task/phase/initiative context via DB joins
Replaces the in-memory filter (agentManager.list() + filter) with a direct
repository query that LEFT JOINs tasks, phases, and initiatives to return
taskName, phaseName, initiativeName, and taskDescription alongside agent fields.

- Adds AgentWithContext interface and findWaitingWithContext() to AgentRepository port
- Implements findWaitingWithContext() in DrizzleAgentRepository using getTableColumns
- Wires agentRepository into TRPCContext, CreateContextOptions, and TrpcAdapterOptions
- Adds requireAgentRepository() helper following existing pattern
- Updates listWaitingAgents to use repository query instead of agentManager
- Adds 5 unit tests for findWaitingWithContext() covering all FK join edge cases
- Updates existing AgentRepository mocks to satisfy updated interface

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 23:29:49 +01:00

235 lines
8.0 KiB
TypeScript

/**
* 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);
});
});