* refactor: provider/modelの解決ロジックをAgentRunnerに集約 OptionsBuilderでCLIレベルとstepレベルを事前マージしていたのをやめ、 stepProvider/stepModelとして分離して渡す形に変更。 AgentRunnerが全レイヤーの優先度を一括で解決する。 * takt: takt-list
369 lines
12 KiB
TypeScript
369 lines
12 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } 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 take over stale lock file with invalid pid', () => {
|
|
mkdirSync(join(testDir, '.takt'), { recursive: true });
|
|
writeFileSync(join(testDir, '.takt', 'tasks.yaml.lock'), 'invalid-pid', 'utf-8');
|
|
|
|
const task = runner.addTask('Task with stale lock');
|
|
|
|
expect(task.name).toContain('task-with-stale-lock');
|
|
expect(existsSync(join(testDir, '.takt', 'tasks.yaml.lock'))).toBe(false);
|
|
});
|
|
|
|
it('should timeout when lock file is held by a live process', () => {
|
|
mkdirSync(join(testDir, '.takt'), { recursive: true });
|
|
writeFileSync(join(testDir, '.takt', 'tasks.yaml.lock'), String(process.pid), 'utf-8');
|
|
|
|
const dateNowSpy = vi.spyOn(Date, 'now');
|
|
dateNowSpy.mockReturnValueOnce(0);
|
|
dateNowSpy.mockReturnValue(5_000);
|
|
|
|
try {
|
|
expect(() => runner.listTasks()).toThrow('Failed to acquire tasks lock within 5000ms');
|
|
} finally {
|
|
dateNowSpy.mockRestore();
|
|
rmSync(join(testDir, '.takt', 'tasks.yaml.lock'), { force: true });
|
|
}
|
|
});
|
|
|
|
it('should recover from corrupted tasks.yaml and allow adding tasks again', () => {
|
|
mkdirSync(join(testDir, '.takt'), { recursive: true });
|
|
writeFileSync(join(testDir, '.takt', 'tasks.yaml'), 'tasks:\n - name: [broken', 'utf-8');
|
|
|
|
expect(() => runner.listTasks()).not.toThrow();
|
|
expect(runner.listTasks()).toEqual([]);
|
|
expect(existsSync(join(testDir, '.takt', 'tasks.yaml'))).toBe(false);
|
|
|
|
const task = runner.addTask('Task after recovery');
|
|
expect(task.name).toContain('task-after-recovery');
|
|
expect(existsSync(join(testDir, '.takt', 'tasks.yaml'))).toBe(true);
|
|
expect(runner.listTasks()).toHaveLength(1);
|
|
});
|
|
|
|
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 reset tasks file 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,
|
|
}]);
|
|
|
|
expect(runner.listTasks()).toEqual([]);
|
|
expect(existsSync(join(testDir, '.takt', 'tasks.yaml'))).toBe(false);
|
|
});
|
|
|
|
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.deletePendingTask(pending.name);
|
|
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.deleteFailedTask(failed.name);
|
|
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();
|
|
});
|
|
});
|