* 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>
355 lines
12 KiB
TypeScript
355 lines
12 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import { mkdirSync, writeFileSync, existsSync, rmSync, readFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
import { TaskRunner } from '../infra/task/runner.js';
|
|
import { TaskRecordSchema } from '../infra/task/schema.js';
|
|
|
|
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 writeTasksFile(testDir: string, tasks: Array<Record<string, unknown>>): void {
|
|
mkdirSync(join(testDir, '.takt'), { recursive: true });
|
|
writeFileSync(join(testDir, '.takt', 'tasks.yaml'), stringifyYaml({ tasks }), 'utf-8');
|
|
}
|
|
|
|
function createPendingRecord(overrides: Record<string, unknown>): Record<string, unknown> {
|
|
return TaskRecordSchema.parse({
|
|
name: 'task-a',
|
|
status: 'pending',
|
|
content: 'Do work',
|
|
created_at: '2026-02-09T00:00:00.000Z',
|
|
started_at: null,
|
|
completed_at: null,
|
|
owner_pid: null,
|
|
...overrides,
|
|
}) as unknown as Record<string, unknown>;
|
|
}
|
|
|
|
describe('TaskRunner (tasks.yaml)', () => {
|
|
const testDir = `/tmp/takt-task-test-${Date.now()}`;
|
|
let runner: TaskRunner;
|
|
|
|
beforeEach(() => {
|
|
mkdirSync(testDir, { recursive: true });
|
|
runner = new TaskRunner(testDir);
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (existsSync(testDir)) {
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('should add tasks to .takt/tasks.yaml', () => {
|
|
const task = runner.addTask('Fix login flow', { piece: 'default' });
|
|
expect(task.name).toContain('fix-login-flow');
|
|
expect(existsSync(join(testDir, '.takt', 'tasks.yaml'))).toBe(true);
|
|
});
|
|
|
|
it('should list only pending tasks', () => {
|
|
runner.addTask('Task A');
|
|
runner.addTask('Task B');
|
|
|
|
const tasks = runner.listTasks();
|
|
expect(tasks).toHaveLength(2);
|
|
expect(tasks.every((task) => task.status === 'pending')).toBe(true);
|
|
});
|
|
|
|
it('should claim tasks and mark them running', () => {
|
|
runner.addTask('Task A');
|
|
runner.addTask('Task B');
|
|
|
|
const claimed = runner.claimNextTasks(1);
|
|
expect(claimed).toHaveLength(1);
|
|
expect(claimed[0]?.status).toBe('running');
|
|
|
|
const file = loadTasksFile(testDir);
|
|
expect(file.tasks.some((task) => task.status === 'running')).toBe(true);
|
|
});
|
|
|
|
it('should recover interrupted running tasks to pending', () => {
|
|
runner.addTask('Task A');
|
|
runner.claimNextTasks(1);
|
|
const current = loadTasksFile(testDir);
|
|
const running = current.tasks[0] as Record<string, unknown>;
|
|
running.owner_pid = 999999999;
|
|
writeFileSync(join(testDir, '.takt', 'tasks.yaml'), stringifyYaml(current), 'utf-8');
|
|
|
|
const recovered = runner.recoverInterruptedRunningTasks();
|
|
expect(recovered).toBe(1);
|
|
|
|
const tasks = runner.listTasks();
|
|
expect(tasks).toHaveLength(1);
|
|
expect(tasks[0]?.status).toBe('pending');
|
|
});
|
|
|
|
it('should keep running tasks owned by a live process', () => {
|
|
runner.addTask('Task A');
|
|
runner.claimNextTasks(1);
|
|
|
|
const recovered = runner.recoverInterruptedRunningTasks();
|
|
expect(recovered).toBe(0);
|
|
});
|
|
|
|
it('should preserve corrupted tasks.yaml and throw', () => {
|
|
mkdirSync(join(testDir, '.takt'), { recursive: true });
|
|
writeFileSync(join(testDir, '.takt', 'tasks.yaml'), 'tasks:\n - name: [broken', 'utf-8');
|
|
|
|
const tasksFilePath = join(testDir, '.takt', 'tasks.yaml');
|
|
expect(() => runner.listTasks()).toThrow(/Invalid tasks\.yaml/);
|
|
expect(existsSync(tasksFilePath)).toBe(true);
|
|
});
|
|
|
|
it('should preserve tasks.yaml and throw when pending record has started_at', () => {
|
|
writeTasksFile(testDir, [{
|
|
name: 'broken-pending-task',
|
|
status: 'pending',
|
|
content: 'Broken pending',
|
|
created_at: '2026-02-09T00:00:00.000Z',
|
|
started_at: '2026-02-09T00:01:00.000Z',
|
|
completed_at: null,
|
|
owner_pid: null,
|
|
}]);
|
|
|
|
const tasksFilePath = join(testDir, '.takt', 'tasks.yaml');
|
|
expect(() => runner.claimNextTasks(1)).toThrow();
|
|
expect(existsSync(tasksFilePath)).toBe(true);
|
|
});
|
|
|
|
it('should load pending content from relative content_file', () => {
|
|
mkdirSync(join(testDir, 'fixtures'), { recursive: true });
|
|
writeFileSync(join(testDir, 'fixtures', 'task.txt'), 'Task from file\nsecond line', 'utf-8');
|
|
writeTasksFile(testDir, [createPendingRecord({
|
|
content: undefined,
|
|
content_file: 'fixtures/task.txt',
|
|
})]);
|
|
|
|
const tasks = runner.listTasks();
|
|
const pendingItems = runner.listPendingTaskItems();
|
|
|
|
expect(tasks[0]?.content).toBe('Task from file\nsecond line');
|
|
expect(pendingItems[0]?.content).toBe('Task from file');
|
|
});
|
|
|
|
it('should load pending content from absolute content_file', () => {
|
|
const contentPath = join(testDir, 'absolute-task.txt');
|
|
writeFileSync(contentPath, 'Absolute task content', 'utf-8');
|
|
writeTasksFile(testDir, [createPendingRecord({
|
|
content: undefined,
|
|
content_file: contentPath,
|
|
})]);
|
|
|
|
const tasks = runner.listTasks();
|
|
expect(tasks[0]?.content).toBe('Absolute task content');
|
|
});
|
|
|
|
it('should build task instruction from task_dir and expose taskDir on TaskInfo', () => {
|
|
mkdirSync(join(testDir, '.takt', 'tasks', '20260201-000000-demo'), { recursive: true });
|
|
writeFileSync(
|
|
join(testDir, '.takt', 'tasks', '20260201-000000-demo', 'order.md'),
|
|
'Detailed long spec',
|
|
'utf-8',
|
|
);
|
|
writeTasksFile(testDir, [createPendingRecord({
|
|
content: undefined,
|
|
task_dir: '.takt/tasks/20260201-000000-demo',
|
|
})]);
|
|
|
|
const tasks = runner.listTasks();
|
|
expect(tasks[0]?.taskDir).toBe('.takt/tasks/20260201-000000-demo');
|
|
expect(tasks[0]?.content).toContain('Implement using only the files');
|
|
expect(tasks[0]?.content).toContain('.takt/tasks/20260201-000000-demo');
|
|
expect(tasks[0]?.content).toContain('.takt/tasks/20260201-000000-demo/order.md');
|
|
});
|
|
|
|
it('should throw when task_dir order.md is missing', () => {
|
|
mkdirSync(join(testDir, '.takt', 'tasks', '20260201-000000-missing'), { recursive: true });
|
|
writeTasksFile(testDir, [createPendingRecord({
|
|
content: undefined,
|
|
task_dir: '.takt/tasks/20260201-000000-missing',
|
|
})]);
|
|
|
|
expect(() => runner.listTasks()).toThrow(/Task spec file is missing/i);
|
|
});
|
|
|
|
it('should preserve tasks file and throw when both content and content_file are set', () => {
|
|
writeTasksFile(testDir, [{
|
|
name: 'task-a',
|
|
status: 'pending',
|
|
content: 'Inline content',
|
|
content_file: 'missing-content-file.txt',
|
|
created_at: '2026-02-09T00:00:00.000Z',
|
|
started_at: null,
|
|
completed_at: null,
|
|
owner_pid: null,
|
|
}]);
|
|
|
|
const tasksFilePath = join(testDir, '.takt', 'tasks.yaml');
|
|
expect(() => runner.listTasks()).toThrow(/Invalid tasks\.yaml/);
|
|
expect(existsSync(tasksFilePath)).toBe(true);
|
|
});
|
|
|
|
it('should throw when content_file target is missing', () => {
|
|
writeTasksFile(testDir, [createPendingRecord({
|
|
content: undefined,
|
|
content_file: 'missing-content-file.txt',
|
|
})]);
|
|
|
|
expect(() => runner.listTasks()).toThrow(/ENOENT|no such file/i);
|
|
});
|
|
|
|
it('should keep completed task record in tasks.yaml', () => {
|
|
runner.addTask('Task A');
|
|
const task = runner.claimNextTasks(1)[0]!;
|
|
|
|
runner.completeTask({
|
|
task,
|
|
success: true,
|
|
response: 'Done',
|
|
executionLog: [],
|
|
startedAt: new Date().toISOString(),
|
|
completedAt: new Date().toISOString(),
|
|
});
|
|
|
|
const file = loadTasksFile(testDir);
|
|
expect(file.tasks).toHaveLength(1);
|
|
expect(file.tasks[0]?.status).toBe('completed');
|
|
});
|
|
|
|
it('should update only target task to completed when multiple tasks exist', () => {
|
|
runner.addTask('Task A');
|
|
runner.addTask('Task B');
|
|
const task = runner.claimNextTasks(1)[0]!;
|
|
|
|
runner.completeTask({
|
|
task,
|
|
success: true,
|
|
response: 'Done',
|
|
executionLog: [],
|
|
startedAt: new Date().toISOString(),
|
|
completedAt: new Date().toISOString(),
|
|
});
|
|
|
|
const file = loadTasksFile(testDir);
|
|
expect(file.tasks).toHaveLength(2);
|
|
expect(file.tasks[0]?.name).toContain('task-a');
|
|
expect(file.tasks[0]?.status).toBe('completed');
|
|
expect(file.tasks[1]?.name).toContain('task-b');
|
|
expect(file.tasks[1]?.status).toBe('pending');
|
|
});
|
|
|
|
it('should mark claimed task as failed with failure detail', () => {
|
|
runner.addTask('Task A');
|
|
const task = runner.claimNextTasks(1)[0]!;
|
|
|
|
runner.failTask({
|
|
task,
|
|
success: false,
|
|
response: 'Boom',
|
|
executionLog: ['last message'],
|
|
failureMovement: 'review',
|
|
failureLastMessage: 'last message',
|
|
startedAt: new Date().toISOString(),
|
|
completedAt: new Date().toISOString(),
|
|
});
|
|
|
|
const failed = runner.listFailedTasks();
|
|
expect(failed).toHaveLength(1);
|
|
expect(failed[0]?.failure?.error).toBe('Boom');
|
|
expect(failed[0]?.failure?.movement).toBe('review');
|
|
expect(failed[0]?.failure?.last_message).toBe('last message');
|
|
});
|
|
|
|
it('should mark pending task as failed with started_at and branch', () => {
|
|
const task = runner.addTask('Task C', { branch: 'takt/task-c' });
|
|
const startedAt = new Date().toISOString();
|
|
const completedAt = new Date().toISOString();
|
|
|
|
runner.failTask({
|
|
task,
|
|
success: false,
|
|
response: 'Boom',
|
|
executionLog: [],
|
|
startedAt,
|
|
completedAt,
|
|
branch: 'takt/task-c-updated',
|
|
});
|
|
|
|
const file = loadTasksFile(testDir);
|
|
const failed = file.tasks[0];
|
|
expect(failed?.status).toBe('failed');
|
|
expect(failed?.started_at).toBe(startedAt);
|
|
expect(failed?.completed_at).toBe(completedAt);
|
|
expect(failed?.branch).toBe('takt/task-c-updated');
|
|
});
|
|
|
|
it('should requeue failed task to pending with retry metadata', () => {
|
|
runner.addTask('Task A');
|
|
const task = runner.claimNextTasks(1)[0]!;
|
|
runner.failTask({
|
|
task,
|
|
success: false,
|
|
response: 'Boom',
|
|
executionLog: [],
|
|
startedAt: new Date().toISOString(),
|
|
completedAt: new Date().toISOString(),
|
|
});
|
|
|
|
runner.requeueFailedTask(task.name, 'implement', 'retry note');
|
|
|
|
const pending = runner.listTasks();
|
|
expect(pending).toHaveLength(1);
|
|
expect(pending[0]?.data?.start_movement).toBe('implement');
|
|
expect(pending[0]?.data?.retry_note).toBe('retry note');
|
|
});
|
|
|
|
it('should delete pending and failed tasks', () => {
|
|
const pending = runner.addTask('Task A');
|
|
runner.deleteTask(pending.name, 'pending');
|
|
expect(runner.listTasks()).toHaveLength(0);
|
|
|
|
const failed = runner.addTask('Task B');
|
|
const running = runner.claimNextTasks(1)[0]!;
|
|
runner.failTask({
|
|
task: running,
|
|
success: false,
|
|
response: 'Boom',
|
|
executionLog: [],
|
|
startedAt: new Date().toISOString(),
|
|
completedAt: new Date().toISOString(),
|
|
});
|
|
runner.deleteTask(failed.name, 'failed');
|
|
expect(runner.listFailedTasks()).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('TaskRecordSchema', () => {
|
|
it('should reject failed record without failure details', () => {
|
|
expect(() => TaskRecordSchema.parse({
|
|
name: 'task-a',
|
|
status: 'failed',
|
|
content: 'Do work',
|
|
created_at: '2026-02-09T00:00:00.000Z',
|
|
started_at: '2026-02-09T00:01:00.000Z',
|
|
completed_at: '2026-02-09T00:02:00.000Z',
|
|
})).toThrow();
|
|
});
|
|
|
|
it('should reject completed record with failure details', () => {
|
|
expect(() => TaskRecordSchema.parse({
|
|
name: 'task-a',
|
|
status: 'completed',
|
|
content: 'Do work',
|
|
created_at: '2026-02-09T00:00:00.000Z',
|
|
started_at: '2026-02-09T00:01:00.000Z',
|
|
completed_at: '2026-02-09T00:02:00.000Z',
|
|
failure: {
|
|
error: 'unexpected',
|
|
},
|
|
})).toThrow();
|
|
});
|
|
});
|