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>
235 lines
8.0 KiB
TypeScript
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);
|
|
});
|
|
|
|
}); |