Phase model changes:
- Drop `number` column (ordering now by createdAt + dependency DAG)
- Replace `description` (plain text) with `content` (Tiptap JSON)
- Add `approved` status as dispatch gate
- Add phase dependency management (list, remove, dependents)
- Approval gate in PhaseDispatchManager.queuePhase()
Agent log chunks:
- New `agent_log_chunks` table for DB-first output persistence
- LogChunkRepository port + DrizzleLogChunkRepository adapter
- FileTailer onRawContent callback streams chunks to DB
- getAgentOutput reads from DB first, falls back to file
Agent lifecycle module (src/agent/lifecycle/):
- SignalManager: atomic signal.json read/write/wait operations
- RetryPolicy: exponential backoff with error-specific strategies
- ErrorAnalyzer: pattern-based error classification
- CleanupStrategy: debug archival vs production cleanup
- AgentLifecycleController: orchestrates retry/recovery flow
- Missing signal recovery with instruction injection
Completion detection fixes:
- Read signal.json file instead of parsing stdout as JSON
- Cancellable pollForCompletion with { cancel } handle
- Centralized state cleanup via cleanupAgentState()
- Credential handler consolidation (prepareProcessEnv)
Prompts refactor:
- Split monolithic prompts.ts into per-mode modules
- Add workspace layout section to agent prompts
- Fix markdown-to-tiptap double-serialization
Server/tRPC:
- Subscription heartbeat (30s) and bounded queue (1000 max)
- Phase CRUD: approvePhase, deletePhase, dependency queries
- Page: findByIds, getPageUpdatedAtMap
- Wire new repositories through container and context
133 lines
4.5 KiB
TypeScript
133 lines
4.5 KiB
TypeScript
/**
|
|
* Test for completion detection via readSignalCompletion
|
|
*/
|
|
|
|
import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import { mkdtemp, writeFile, mkdir } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { rmSync } from 'node:fs';
|
|
import { OutputHandler } from './output-handler.js';
|
|
import type { AgentRepository } from '../db/repositories/agent-repository.js';
|
|
import type { ProposalRepository } from '../db/repositories/proposal-repository.js';
|
|
|
|
describe('Completion Detection Fix', () => {
|
|
let tempDir: string;
|
|
let outputHandler: OutputHandler;
|
|
let mockAgentRepo: AgentRepository;
|
|
let mockProposalRepo: ProposalRepository;
|
|
|
|
beforeEach(async () => {
|
|
tempDir = await mkdtemp(join(tmpdir(), 'completion-test-'));
|
|
|
|
// Mock repositories
|
|
mockAgentRepo = {
|
|
update: vi.fn(),
|
|
findById: vi.fn().mockResolvedValue({ id: 'test-agent', mode: 'refine' }),
|
|
} as any;
|
|
|
|
mockProposalRepo = {
|
|
create: vi.fn(),
|
|
} as any;
|
|
|
|
outputHandler = new OutputHandler(mockAgentRepo, undefined, mockProposalRepo);
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
test('detects completion from signal.json with "questions" status', async () => {
|
|
const agentWorkdir = join(tempDir, 'test-agent');
|
|
const cwDir = join(agentWorkdir, '.cw/output');
|
|
await mkdir(cwDir, { recursive: true });
|
|
|
|
const signalContent = JSON.stringify({
|
|
status: 'questions',
|
|
questions: [{ id: 'q1', text: 'Do you want to proceed?' }]
|
|
});
|
|
await writeFile(join(cwDir, 'signal.json'), signalContent);
|
|
|
|
const readSignalCompletion = (outputHandler as any).readSignalCompletion.bind(outputHandler);
|
|
const result = await readSignalCompletion(agentWorkdir);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(JSON.parse(result).status).toBe('questions');
|
|
});
|
|
|
|
test('detects completion from signal.json with "done" status', async () => {
|
|
const agentWorkdir = join(tempDir, 'test-agent');
|
|
const cwDir = join(agentWorkdir, '.cw/output');
|
|
await mkdir(cwDir, { recursive: true });
|
|
|
|
const signalContent = JSON.stringify({
|
|
status: 'done',
|
|
result: 'Task completed successfully'
|
|
});
|
|
await writeFile(join(cwDir, 'signal.json'), signalContent);
|
|
|
|
const readSignalCompletion = (outputHandler as any).readSignalCompletion.bind(outputHandler);
|
|
const result = await readSignalCompletion(agentWorkdir);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(JSON.parse(result).status).toBe('done');
|
|
});
|
|
|
|
test('detects completion from signal.json with "error" status', async () => {
|
|
const agentWorkdir = join(tempDir, 'test-agent');
|
|
const cwDir = join(agentWorkdir, '.cw/output');
|
|
await mkdir(cwDir, { recursive: true });
|
|
|
|
const signalContent = JSON.stringify({
|
|
status: 'error',
|
|
error: 'Something went wrong'
|
|
});
|
|
await writeFile(join(cwDir, 'signal.json'), signalContent);
|
|
|
|
const readSignalCompletion = (outputHandler as any).readSignalCompletion.bind(outputHandler);
|
|
const result = await readSignalCompletion(agentWorkdir);
|
|
|
|
expect(result).not.toBeNull();
|
|
expect(JSON.parse(result).status).toBe('error');
|
|
});
|
|
|
|
test('returns null when signal.json does not exist', async () => {
|
|
const agentWorkdir = join(tempDir, 'test-agent');
|
|
|
|
const readSignalCompletion = (outputHandler as any).readSignalCompletion.bind(outputHandler);
|
|
const result = await readSignalCompletion(agentWorkdir);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
test('returns null for incomplete status', async () => {
|
|
const agentWorkdir = join(tempDir, 'test-agent');
|
|
const cwDir = join(agentWorkdir, '.cw/output');
|
|
await mkdir(cwDir, { recursive: true });
|
|
|
|
const signalContent = JSON.stringify({
|
|
status: 'running',
|
|
progress: 'Still working...'
|
|
});
|
|
await writeFile(join(cwDir, 'signal.json'), signalContent);
|
|
|
|
const readSignalCompletion = (outputHandler as any).readSignalCompletion.bind(outputHandler);
|
|
const result = await readSignalCompletion(agentWorkdir);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
test('handles malformed signal.json gracefully', async () => {
|
|
const agentWorkdir = join(tempDir, 'test-agent');
|
|
const cwDir = join(agentWorkdir, '.cw/output');
|
|
await mkdir(cwDir, { recursive: true });
|
|
|
|
await writeFile(join(cwDir, 'signal.json'), '{ invalid json }');
|
|
|
|
const readSignalCompletion = (outputHandler as any).readSignalCompletion.bind(outputHandler);
|
|
const result = await readSignalCompletion(agentWorkdir);
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|