takt/src/__tests__/engine-parallel-failure.test.ts
nrslib 222560a96a プロバイダーエラーを blocked から error ステータスに分離し、Codex にリトライ機構を追加
blocked はユーザー入力で解決可能な状態、error はプロバイダー障害として意味を明確化。
PieceEngine で error ステータスを検知して即座に abort する。
Codex クライアントにトランジェントエラー(stream disconnected, transport error 等)の
指数バックオフリトライ(最大3回)を追加。
2026-02-09 22:04:52 +09:00

203 lines
6.4 KiB
TypeScript

/**
* PieceEngine integration tests: parallel movement partial failure handling.
*
* Covers:
* - One sub-movement fails while another succeeds → piece continues
* - All sub-movements fail → piece aborts
* - Failed sub-movement is recorded as error with error message
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { existsSync, rmSync } from 'node:fs';
// --- Mock setup (must be before imports that use these modules) ---
vi.mock('../agents/runner.js', () => ({
runAgent: vi.fn(),
}));
vi.mock('../core/piece/evaluation/index.js', () => ({
detectMatchedRule: vi.fn(),
}));
vi.mock('../core/piece/phase-runner.js', () => ({
needsStatusJudgmentPhase: vi.fn().mockReturnValue(false),
runReportPhase: vi.fn().mockResolvedValue(undefined),
runStatusJudgmentPhase: vi.fn().mockResolvedValue(''),
}));
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
generateReportDir: vi.fn().mockReturnValue('test-report-dir'),
}));
// --- Imports (after mocks) ---
import { PieceEngine } from '../core/piece/index.js';
import { runAgent } from '../agents/runner.js';
import { detectMatchedRule } from '../core/piece/index.js';
import {
makeResponse,
makeMovement,
makeRule,
mockDetectMatchedRuleSequence,
createTestTmpDir,
applyDefaultMocks,
} from './engine-test-helpers.js';
import type { PieceConfig } from '../core/models/index.js';
/**
* Build a piece config that goes directly to a parallel step:
* parallel-step (arch-review + security-review) → done
*/
function buildParallelOnlyConfig(): PieceConfig {
return {
name: 'test-parallel-failure',
description: 'Test parallel failure handling',
maxIterations: 10,
initialMovement: 'reviewers',
movements: [
makeMovement('reviewers', {
parallel: [
makeMovement('arch-review', {
rules: [
makeRule('done', 'COMPLETE'),
makeRule('needs_fix', 'fix'),
],
}),
makeMovement('security-review', {
rules: [
makeRule('done', 'COMPLETE'),
makeRule('needs_fix', 'fix'),
],
}),
],
rules: [
makeRule('any("done")', 'done', {
isAggregateCondition: true,
aggregateType: 'any',
aggregateConditionText: 'done',
}),
makeRule('all("needs_fix")', 'fix', {
isAggregateCondition: true,
aggregateType: 'all',
aggregateConditionText: 'needs_fix',
}),
],
}),
makeMovement('done', {
rules: [
makeRule('completed', 'COMPLETE'),
],
}),
makeMovement('fix', {
rules: [
makeRule('fixed', 'reviewers'),
],
}),
],
};
}
describe('PieceEngine Integration: Parallel Movement Partial Failure', () => {
let tmpDir: string;
beforeEach(() => {
vi.resetAllMocks();
applyDefaultMocks();
tmpDir = createTestTmpDir();
});
afterEach(() => {
if (existsSync(tmpDir)) {
rmSync(tmpDir, { recursive: true, force: true });
}
});
it('should continue when one sub-movement fails but another succeeds', async () => {
const config = buildParallelOnlyConfig();
const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
const mock = vi.mocked(runAgent);
// arch-review fails (exit code 1)
mock.mockRejectedValueOnce(new Error('Claude Code process exited with code 1'));
// security-review succeeds
mock.mockResolvedValueOnce(
makeResponse({ persona: 'security-review', content: 'Security review passed' }),
);
// done step
mock.mockResolvedValueOnce(
makeResponse({ persona: 'done', content: 'Completed' }),
);
mockDetectMatchedRuleSequence([
// security-review sub-movement rule match (arch-review has no match — it failed)
{ index: 0, method: 'phase1_tag' }, // security-review → done
{ index: 0, method: 'aggregate' }, // reviewers → any("done") matches
{ index: 0, method: 'phase1_tag' }, // done → COMPLETE
]);
const state = await engine.run();
expect(state.status).toBe('completed');
// arch-review should be recorded as error
const archReviewOutput = state.movementOutputs.get('arch-review');
expect(archReviewOutput).toBeDefined();
expect(archReviewOutput!.status).toBe('error');
expect(archReviewOutput!.error).toContain('exit');
// security-review should be recorded as done
const securityReviewOutput = state.movementOutputs.get('security-review');
expect(securityReviewOutput).toBeDefined();
expect(securityReviewOutput!.status).toBe('done');
});
it('should abort when all sub-movements fail', async () => {
const config = buildParallelOnlyConfig();
const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
const mock = vi.mocked(runAgent);
// Both fail
mock.mockRejectedValueOnce(new Error('Claude Code process exited with code 1'));
mock.mockRejectedValueOnce(new Error('Claude Code process exited with code 1'));
const abortFn = vi.fn();
engine.on('piece:abort', abortFn);
const state = await engine.run();
expect(state.status).toBe('aborted');
expect(abortFn).toHaveBeenCalledOnce();
const reason = abortFn.mock.calls[0]![1] as string;
expect(reason).toContain('All parallel sub-movements failed');
});
it('should record failed sub-movement error message in movementOutputs', async () => {
const config = buildParallelOnlyConfig();
const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
const mock = vi.mocked(runAgent);
mock.mockRejectedValueOnce(new Error('Session resume failed'));
mock.mockResolvedValueOnce(
makeResponse({ persona: 'security-review', content: 'OK' }),
);
mock.mockResolvedValueOnce(
makeResponse({ persona: 'done', content: 'Done' }),
);
mockDetectMatchedRuleSequence([
{ index: 0, method: 'phase1_tag' },
{ index: 0, method: 'aggregate' },
{ index: 0, method: 'phase1_tag' },
]);
const state = await engine.run();
const archReviewOutput = state.movementOutputs.get('arch-review');
expect(archReviewOutput).toBeDefined();
expect(archReviewOutput!.error).toBe('Session resume failed');
expect(archReviewOutput!.content).toBe('');
});
});