Files
Codewalkers/apps/server/test/integration/crash-race-condition.test.ts
Lukas May 28521e1c20 chore: merge main into cw/small-change-flow
Integrates main branch changes (headquarters dashboard, task retry count,
agent prompt persistence, remote sync improvements) with the initiative's
errand agent feature. Both features coexist in the merged result.

Key resolutions:
- Schema: take main's errands table (nullable projectId, no conflictFiles,
  with errandsRelations); migrate to 0035_faulty_human_fly
- Router: keep both errandProcedures and headquartersProcedures
- Errand prompt: take main's simpler version (no question-asking flow)
- Manager: take main's status check (running|idle only, no waiting_for_input)
- Tests: update to match removed conflictFiles field and undefined vs null
2026-03-06 16:48:12 +01:00

234 lines
7.9 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'); }
};
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);
});
});