takt/src/__tests__/worktree-exceeded-requeue.test.ts
nrs 4a92ba2012
[#366] implement-exceeded-requeue (#374)
* takt: implement-exceeded-requeue

* takt: implement-exceeded-requeue

* takt: implement-exceeded-requeue

* ci: trigger CI

* fix: 未使用インポート削除と --create-worktree e2e テスト修正

InteractiveModeAction の不要な import を削除して lint エラーを解消する。
--create-worktree オプション削除に合わせ e2e の期待メッセージを更新する。

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-02 23:30:53 +09:00

308 lines
11 KiB
TypeScript

/**
* Integration tests for worktree exceeded → requeue → re-execution flow.
*
* Scenarios:
* 1. Worktree task reaches iteration limit → transitions to 'exceeded' status
* 2. Exceeded task stores start_movement / exceeded_max_movements / exceeded_current_iteration
* 3. After requeue, re-execution passes maxMovementsOverride and initialIterationOverride
* 4. After requeue, re-execution starts from start_movement (re-entry point)
*
* Integration boundary:
* TaskRunner (real file I/O) →
* executeAndCompleteTask →
* resolveTaskExecution →
* executeTaskWithResult →
* executePiece (mocked, args captured)
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdirSync, existsSync, rmSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { randomUUID } from 'node:crypto';
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
// --- Mock setup (must be before imports that use these modules) ---
vi.mock('../infra/config/index.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../infra/config/index.js')>();
return {
...actual,
loadPieceByIdentifier: vi.fn(),
isPiecePath: vi.fn().mockReturnValue(false),
resolvePieceConfigValues: vi.fn().mockReturnValue({}),
resolveConfigValueWithSource: vi.fn().mockReturnValue({ value: undefined, source: 'global' }),
resolvePieceConfigValue: vi.fn().mockReturnValue(undefined),
};
});
vi.mock('../features/tasks/execute/pieceExecution.js', () => ({
executePiece: vi.fn(),
}));
vi.mock('../features/tasks/execute/postExecution.js', () => ({
postExecutionFlow: vi.fn(),
}));
vi.mock('../infra/task/index.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../infra/task/index.js')>();
return {
...actual,
createSharedClone: vi.fn(),
detectDefaultBranch: vi.fn(),
summarizeTaskName: vi.fn(),
};
});
vi.mock('../shared/ui/index.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
header: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
status: vi.fn(),
blankLine: vi.fn(),
withProgress: vi.fn().mockImplementation(
async (_startMsg: string, _successFn: unknown, fn: () => Promise<unknown>) => fn(),
),
}));
// --- Imports (after mocks) ---
import { executePiece } from '../features/tasks/execute/pieceExecution.js';
import { postExecutionFlow } from '../features/tasks/execute/postExecution.js';
import { loadPieceByIdentifier } from '../infra/config/index.js';
import { detectDefaultBranch } from '../infra/task/index.js';
import { withProgress } from '../shared/ui/index.js';
import { executeAndCompleteTask } from '../features/tasks/execute/taskExecution.js';
import { TaskRunner } from '../infra/task/runner.js';
import type { PieceConfig } from '../core/models/index.js';
import type { PieceExecutionOptions } from '../features/tasks/execute/types.js';
// --- Helpers ---
function createTestDir(): string {
const dir = join(tmpdir(), `takt-worktree-requeue-test-${randomUUID()}`);
mkdirSync(dir, { recursive: true });
return dir;
}
function loadTasksFile(testDir: string): { tasks: Array<Record<string, unknown>> } {
const raw = readFileSync(join(testDir, '.takt', 'tasks.yaml'), 'utf-8');
return parseYaml(raw) as { tasks: Array<Record<string, unknown>> };
}
function writeExceededRecord(testDir: string, overrides: Record<string, unknown> = {}): void {
mkdirSync(join(testDir, '.takt'), { recursive: true });
const record = {
name: 'task-a',
status: 'exceeded',
content: 'Do work',
created_at: '2026-02-09T00:00:00.000Z',
started_at: '2026-02-09T00:01:00.000Z',
completed_at: '2026-02-09T00:05:00.000Z',
owner_pid: null,
start_movement: 'implement',
exceeded_max_movements: 60,
exceeded_current_iteration: 30,
...overrides,
};
writeFileSync(
join(testDir, '.takt', 'tasks.yaml'),
stringifyYaml({ tasks: [record] }),
'utf-8',
);
}
function buildTestPieceConfig(): PieceConfig {
return {
name: 'test-piece',
maxMovements: 30,
initialMovement: 'plan',
movements: [
{
name: 'plan',
persona: '../personas/plan.md',
personaDisplayName: 'plan',
instructionTemplate: 'Run plan',
passPreviousResponse: true,
rules: [],
},
],
};
}
function applyDefaultMocks(): void {
// Re-apply mocks that are not set by the vi.mock factory
// (vi.clearAllMocks preserves factory implementations, but these are set per-suite)
vi.mocked(loadPieceByIdentifier).mockReturnValue(buildTestPieceConfig());
vi.mocked(detectDefaultBranch).mockReturnValue('main');
vi.mocked(postExecutionFlow).mockResolvedValue({ prUrl: undefined, prFailed: false });
vi.mocked(withProgress).mockImplementation(
async (_startMsg: string, _successFn: unknown, fn: () => Promise<unknown>) => fn(),
);
}
// --- Tests ---
describe('シナリオ1・2: exceeded status transition via executeAndCompleteTask', () => {
let testDir: string;
let runner: TaskRunner;
beforeEach(() => {
// clearAllMocks clears call history but preserves factory implementations
vi.clearAllMocks();
applyDefaultMocks();
testDir = createTestDir();
runner = new TaskRunner(testDir);
});
afterEach(() => {
if (existsSync(testDir)) {
rmSync(testDir, { recursive: true, force: true });
}
});
it('scenario 1: task transitions to exceeded status when executePiece returns exceeded result', async () => {
// Given: a pending task
runner.addTask('Do work');
const [task] = runner.claimNextTasks(1);
if (!task) throw new Error('No task claimed');
// executePiece simulates hitting iteration limit
vi.mocked(executePiece).mockResolvedValueOnce({
success: false,
exceeded: true,
exceededInfo: {
currentMovement: 'implement',
newMaxMovements: 60,
currentIteration: 30,
},
});
// When: executeAndCompleteTask processes the exceeded result
const result = await executeAndCompleteTask(task, runner, testDir, 'test-piece');
// Then: returns false (task did not succeed)
expect(result).toBe(false);
// Then: task is now in exceeded status
const exceededTasks = runner.listExceededTasks();
expect(exceededTasks).toHaveLength(1);
expect(exceededTasks[0]?.kind).toBe('exceeded');
expect(exceededTasks[0]?.name).toBe(task.name);
});
it('scenario 2: exceeded metadata is recorded in tasks.yaml for resumption', async () => {
// Given: a pending task
runner.addTask('Do work');
const [task] = runner.claimNextTasks(1);
if (!task) throw new Error('No task claimed');
// executePiece simulates hitting limit at 'implement' movement, producing 30/60 iterations
vi.mocked(executePiece).mockResolvedValueOnce({
success: false,
exceeded: true,
exceededInfo: {
currentMovement: 'implement',
newMaxMovements: 60,
currentIteration: 30,
},
});
// When: executeAndCompleteTask records the exceeded result
await executeAndCompleteTask(task, runner, testDir, 'test-piece');
// Then: YAML contains the three resumption fields
const file = loadTasksFile(testDir);
const exceededRecord = file.tasks[0];
expect(exceededRecord?.status).toBe('exceeded');
expect(exceededRecord?.start_movement).toBe('implement');
expect(exceededRecord?.exceeded_max_movements).toBe(60);
expect(exceededRecord?.exceeded_current_iteration).toBe(30);
});
});
describe('シナリオ3・4: requeue → re-execution passes exceeded metadata to executePiece', () => {
let testDir: string;
let cloneDir: string;
let runner: TaskRunner;
beforeEach(() => {
// clearAllMocks clears call history but preserves factory implementations
vi.clearAllMocks();
applyDefaultMocks();
testDir = createTestDir();
// cloneDir simulates a pre-existing worktree clone (fs.existsSync check will pass)
cloneDir = createTestDir();
runner = new TaskRunner(testDir);
});
afterEach(() => {
for (const dir of [testDir, cloneDir]) {
if (existsSync(dir)) {
rmSync(dir, { recursive: true, force: true });
}
}
});
it('scenario 3: maxMovementsOverride and initialIterationOverride are passed to executePiece after requeue', async () => {
// Given: an exceeded worktree task with pre-existing clone on disk
writeExceededRecord(testDir, {
worktree: true,
worktree_path: cloneDir,
exceeded_max_movements: 60,
exceeded_current_iteration: 30,
});
// Requeue → status back to pending, exceeded metadata and worktree_path preserved
runner.requeueExceededTask('task-a');
// Claim the requeued task as running
const [task] = runner.claimNextTasks(1);
if (!task) throw new Error('No task claimed');
// executePiece returns success so we can capture args without side effects
vi.mocked(executePiece).mockResolvedValueOnce({ success: true });
// When: executeAndCompleteTask runs the requeued task
await executeAndCompleteTask(task, runner, testDir, 'test-piece');
// Then: executePiece received the correct exceeded override options
expect(vi.mocked(executePiece)).toHaveBeenCalledOnce();
const capturedOptions = vi.mocked(executePiece).mock.calls[0]![3] as PieceExecutionOptions;
expect(capturedOptions.maxMovementsOverride).toBe(60);
expect(capturedOptions.initialIterationOverride).toBe(30);
});
it('scenario 4: startMovement is passed so re-execution resumes from the exceeded movement', async () => {
// Given: an exceeded worktree task with start_movement='implement'
writeExceededRecord(testDir, {
worktree: true,
worktree_path: cloneDir,
exceeded_max_movements: 60,
exceeded_current_iteration: 30,
start_movement: 'implement',
});
// Requeue → pending, start_movement preserved
runner.requeueExceededTask('task-a');
// Claim the requeued task as running
const [task] = runner.claimNextTasks(1);
if (!task) throw new Error('No task claimed');
// executePiece returns success so we can capture args without side effects
vi.mocked(executePiece).mockResolvedValueOnce({ success: true });
// When: executeAndCompleteTask runs the requeued task
await executeAndCompleteTask(task, runner, testDir, 'test-piece');
// Then: executePiece received startMovement='implement' to resume from where it stopped
expect(vi.mocked(executePiece)).toHaveBeenCalledOnce();
const capturedOptions = vi.mocked(executePiece).mock.calls[0]![3] as PieceExecutionOptions;
expect(capturedOptions.startMovement).toBe('implement');
});
});