takt: consolidate-tasks-yaml (#187)

This commit is contained in:
nrs 2026-02-09 23:29:24 +09:00 committed by GitHub
parent 222560a96a
commit 4ca414be6b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 1562 additions and 2700 deletions

View File

@ -1,21 +1,13 @@
/**
* Tests for addTask command
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import * as path from 'node:path'; import * as path from 'node:path';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { parse as parseYaml } from 'yaml';
// Mock dependencies before importing the module under test
vi.mock('../features/interactive/index.js', () => ({ vi.mock('../features/interactive/index.js', () => ({
interactiveMode: vi.fn(), interactiveMode: vi.fn(),
})); }));
vi.mock('../infra/providers/index.js', () => ({
getProvider: vi.fn(),
}));
vi.mock('../infra/config/global/globalConfig.js', () => ({ vi.mock('../infra/config/global/globalConfig.js', () => ({
loadGlobalConfig: vi.fn(() => ({ provider: 'claude' })), loadGlobalConfig: vi.fn(() => ({ provider: 'claude' })),
getBuiltinPiecesEnabled: vi.fn().mockReturnValue(true), getBuiltinPiecesEnabled: vi.fn().mockReturnValue(true),
@ -26,14 +18,11 @@ vi.mock('../shared/prompt/index.js', () => ({
confirm: vi.fn(), confirm: vi.fn(),
})); }));
vi.mock('../infra/task/summarize.js', () => ({
summarizeTaskName: vi.fn(),
}));
vi.mock('../shared/ui/index.js', () => ({ vi.mock('../shared/ui/index.js', () => ({
success: vi.fn(), success: vi.fn(),
info: vi.fn(), info: vi.fn(),
blankLine: vi.fn(), blankLine: vi.fn(),
error: vi.fn(),
})); }));
vi.mock('../shared/utils/index.js', async (importOriginal) => ({ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
@ -76,42 +65,27 @@ vi.mock('../infra/github/issue.js', () => ({
import { interactiveMode } from '../features/interactive/index.js'; import { interactiveMode } from '../features/interactive/index.js';
import { promptInput, confirm } from '../shared/prompt/index.js'; import { promptInput, confirm } from '../shared/prompt/index.js';
import { summarizeTaskName } from '../infra/task/summarize.js';
import { determinePiece } from '../features/tasks/execute/selectAndExecute.js'; import { determinePiece } from '../features/tasks/execute/selectAndExecute.js';
import { getPieceDescription } from '../infra/config/loaders/pieceResolver.js'; import { resolveIssueTask } from '../infra/github/issue.js';
import { resolveIssueTask, createIssue } from '../infra/github/issue.js';
import { addTask } from '../features/tasks/index.js'; import { addTask } from '../features/tasks/index.js';
const mockResolveIssueTask = vi.mocked(resolveIssueTask); const mockResolveIssueTask = vi.mocked(resolveIssueTask);
const mockCreateIssue = vi.mocked(createIssue);
const mockInteractiveMode = vi.mocked(interactiveMode); const mockInteractiveMode = vi.mocked(interactiveMode);
const mockPromptInput = vi.mocked(promptInput); const mockPromptInput = vi.mocked(promptInput);
const mockConfirm = vi.mocked(confirm); const mockConfirm = vi.mocked(confirm);
const mockSummarizeTaskName = vi.mocked(summarizeTaskName);
const mockDeterminePiece = vi.mocked(determinePiece); const mockDeterminePiece = vi.mocked(determinePiece);
const mockGetPieceDescription = vi.mocked(getPieceDescription);
function setupFullFlowMocks(overrides?: {
task?: string;
slug?: string;
}) {
const task = overrides?.task ?? '# 認証機能追加\nJWT認証を実装する';
const slug = overrides?.slug ?? 'add-auth';
mockDeterminePiece.mockResolvedValue('default');
mockGetPieceDescription.mockReturnValue({ name: 'default', description: '', pieceStructure: '' });
mockInteractiveMode.mockResolvedValue({ action: 'execute', task });
mockSummarizeTaskName.mockResolvedValue(slug);
mockConfirm.mockResolvedValue(false);
}
let testDir: string; let testDir: string;
function loadTasks(dir: string): { tasks: Array<Record<string, unknown>> } {
const raw = fs.readFileSync(path.join(dir, '.takt', 'tasks.yaml'), 'utf-8');
return parseYaml(raw) as { tasks: Array<Record<string, unknown>> };
}
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
testDir = fs.mkdtempSync(path.join(tmpdir(), 'takt-test-')); testDir = fs.mkdtempSync(path.join(tmpdir(), 'takt-test-'));
mockDeterminePiece.mockResolvedValue('default'); mockDeterminePiece.mockResolvedValue('default');
mockGetPieceDescription.mockReturnValue({ name: 'default', description: '', pieceStructure: '' });
mockConfirm.mockResolvedValue(false); mockConfirm.mockResolvedValue(false);
}); });
@ -122,332 +96,46 @@ afterEach(() => {
}); });
describe('addTask', () => { describe('addTask', () => {
it('should cancel when interactive mode is not confirmed', async () => { it('should create task entry from interactive result', async () => {
// Given: user cancels interactive mode mockInteractiveMode.mockResolvedValue({ action: 'execute', task: '# 認証機能追加\nJWT認証を実装する' });
mockDeterminePiece.mockResolvedValue('default');
mockInteractiveMode.mockResolvedValue({ action: 'cancel', task: '' });
// When
await addTask(testDir); await addTask(testDir);
const tasksDir = path.join(testDir, '.takt', 'tasks'); const tasks = loadTasks(testDir).tasks;
const files = fs.existsSync(tasksDir) ? fs.readdirSync(tasksDir) : []; expect(tasks).toHaveLength(1);
expect(files.length).toBe(0); expect(tasks[0]?.content).toContain('JWT認証を実装する');
expect(mockSummarizeTaskName).not.toHaveBeenCalled(); expect(tasks[0]?.piece).toBe('default');
}); });
it('should create task file with AI-summarized content', async () => { it('should include worktree settings when enabled', async () => {
// Given: full flow setup mockInteractiveMode.mockResolvedValue({ action: 'execute', task: 'Task content' });
setupFullFlowMocks();
// When
await addTask(testDir);
// Then: task file created with summarized content
const tasksDir = path.join(testDir, '.takt', 'tasks');
const taskFile = path.join(tasksDir, 'add-auth.yaml');
expect(fs.existsSync(taskFile)).toBe(true);
const content = fs.readFileSync(taskFile, 'utf-8');
expect(content).toContain('# 認証機能追加');
expect(content).toContain('JWT認証を実装する');
});
it('should use first line of task for filename generation', async () => {
setupFullFlowMocks({
task: 'First line summary\nSecond line details',
slug: 'first-line',
});
await addTask(testDir);
expect(mockSummarizeTaskName).toHaveBeenCalledWith('First line summary', { cwd: testDir });
});
it('should append counter for duplicate filenames', async () => {
// Given: first task creates 'my-task.yaml'
setupFullFlowMocks({ slug: 'my-task' });
await addTask(testDir);
// When: create second task with same slug
setupFullFlowMocks({ slug: 'my-task' });
await addTask(testDir);
// Then: second file has counter
const tasksDir = path.join(testDir, '.takt', 'tasks');
expect(fs.existsSync(path.join(tasksDir, 'my-task.yaml'))).toBe(true);
expect(fs.existsSync(path.join(tasksDir, 'my-task-1.yaml'))).toBe(true);
});
it('should include worktree option when confirmed', async () => {
// Given: user confirms worktree
setupFullFlowMocks({ slug: 'with-worktree' });
mockConfirm.mockResolvedValue(true); mockConfirm.mockResolvedValue(true);
mockPromptInput.mockResolvedValue(''); mockPromptInput.mockResolvedValueOnce('/custom/path').mockResolvedValueOnce('feat/branch');
// When
await addTask(testDir); await addTask(testDir);
// Then const task = loadTasks(testDir).tasks[0]!;
const taskFile = path.join(testDir, '.takt', 'tasks', 'with-worktree.yaml'); expect(task.worktree).toBe('/custom/path');
const content = fs.readFileSync(taskFile, 'utf-8'); expect(task.branch).toBe('feat/branch');
expect(content).toContain('worktree: true');
}); });
it('should include custom worktree path when provided', async () => { it('should create task from issue reference without interactive mode', async () => {
// Given: user provides custom worktree path mockResolveIssueTask.mockReturnValue('Issue #99: Fix login timeout');
setupFullFlowMocks({ slug: 'custom-path' });
mockConfirm.mockResolvedValue(true);
mockPromptInput
.mockResolvedValueOnce('/custom/path')
.mockResolvedValueOnce('');
// When
await addTask(testDir);
// Then
const taskFile = path.join(testDir, '.takt', 'tasks', 'custom-path.yaml');
const content = fs.readFileSync(taskFile, 'utf-8');
expect(content).toContain('worktree: /custom/path');
});
it('should include branch when provided', async () => {
// Given: user provides custom branch
setupFullFlowMocks({ slug: 'with-branch' });
mockConfirm.mockResolvedValue(true);
mockPromptInput
.mockResolvedValueOnce('')
.mockResolvedValueOnce('feat/my-branch');
// When
await addTask(testDir);
// Then
const taskFile = path.join(testDir, '.takt', 'tasks', 'with-branch.yaml');
const content = fs.readFileSync(taskFile, 'utf-8');
expect(content).toContain('branch: feat/my-branch');
});
it('should include piece selection in task file', async () => {
// Given: determinePiece returns a non-default piece
setupFullFlowMocks({ slug: 'with-piece' });
mockDeterminePiece.mockResolvedValue('review');
mockGetPieceDescription.mockReturnValue({ name: 'review', description: 'Code review piece', pieceStructure: '' });
mockConfirm.mockResolvedValue(false); mockConfirm.mockResolvedValue(false);
// When
await addTask(testDir);
// Then
const taskFile = path.join(testDir, '.takt', 'tasks', 'with-piece.yaml');
const content = fs.readFileSync(taskFile, 'utf-8');
expect(content).toContain('piece: review');
});
it('should cancel when piece selection returns null', async () => {
// Given: user cancels piece selection
mockDeterminePiece.mockResolvedValue(null);
// When
await addTask(testDir);
// Then: no task file created (cancelled at piece selection)
const tasksDir = path.join(testDir, '.takt', 'tasks');
const files = fs.readdirSync(tasksDir);
expect(files.length).toBe(0);
});
it('should always include piece from determinePiece', async () => {
// Given: determinePiece returns 'default'
setupFullFlowMocks({ slug: 'default-wf' });
mockDeterminePiece.mockResolvedValue('default');
mockConfirm.mockResolvedValue(false);
// When
await addTask(testDir);
// Then: piece field is included
const taskFile = path.join(testDir, '.takt', 'tasks', 'default-wf.yaml');
const content = fs.readFileSync(taskFile, 'utf-8');
expect(content).toContain('piece: default');
});
it('should fetch issue and use directly as task content when given issue reference', async () => {
// Given: issue reference "#99"
const issueText = 'Issue #99: Fix login timeout\n\nThe login page times out after 30 seconds.';
mockResolveIssueTask.mockReturnValue(issueText);
mockDeterminePiece.mockResolvedValue('default');
mockSummarizeTaskName.mockResolvedValue('fix-login-timeout');
mockConfirm.mockResolvedValue(false);
// When
await addTask(testDir, '#99'); await addTask(testDir, '#99');
// Then: interactiveMode should NOT be called
expect(mockInteractiveMode).not.toHaveBeenCalled(); expect(mockInteractiveMode).not.toHaveBeenCalled();
const task = loadTasks(testDir).tasks[0]!;
// Then: resolveIssueTask was called expect(task.content).toContain('Fix login timeout');
expect(mockResolveIssueTask).toHaveBeenCalledWith('#99'); expect(task.issue).toBe(99);
// Then: determinePiece was called for piece selection
expect(mockDeterminePiece).toHaveBeenCalledWith(testDir);
// Then: task file created with issue text directly (no AI summarization)
const taskFile = path.join(testDir, '.takt', 'tasks', 'fix-login-timeout.yaml');
expect(fs.existsSync(taskFile)).toBe(true);
const content = fs.readFileSync(taskFile, 'utf-8');
expect(content).toContain('Fix login timeout');
}); });
it('should proceed to worktree settings after issue fetch', async () => { it('should not create task when piece selection is cancelled', async () => {
// Given: issue with worktree enabled
mockResolveIssueTask.mockReturnValue('Issue text');
mockDeterminePiece.mockResolvedValue('default');
mockSummarizeTaskName.mockResolvedValue('issue-task');
mockConfirm.mockResolvedValue(true);
mockPromptInput
.mockResolvedValueOnce('') // worktree path (auto)
.mockResolvedValueOnce(''); // branch name (auto)
// When
await addTask(testDir, '#42');
// Then: worktree settings applied
const taskFile = path.join(testDir, '.takt', 'tasks', 'issue-task.yaml');
const content = fs.readFileSync(taskFile, 'utf-8');
expect(content).toContain('worktree: true');
});
it('should handle GitHub API failure gracefully for issue reference', async () => {
// Given: resolveIssueTask throws
mockResolveIssueTask.mockImplementation(() => {
throw new Error('GitHub API rate limit exceeded');
});
// When
await addTask(testDir, '#99');
const tasksDir = path.join(testDir, '.takt', 'tasks');
const files = fs.readdirSync(tasksDir);
expect(files.length).toBe(0);
});
it('should include issue number in task file when issue reference is used', async () => {
// Given: issue reference "#99"
const issueText = 'Issue #99: Fix login timeout';
mockResolveIssueTask.mockReturnValue(issueText);
mockDeterminePiece.mockResolvedValue('default');
mockSummarizeTaskName.mockResolvedValue('fix-login-timeout');
mockConfirm.mockResolvedValue(false);
// When
await addTask(testDir, '#99');
// Then: task file contains issue field
const taskFile = path.join(testDir, '.takt', 'tasks', 'fix-login-timeout.yaml');
expect(fs.existsSync(taskFile)).toBe(true);
const content = fs.readFileSync(taskFile, 'utf-8');
expect(content).toContain('issue: 99');
});
it('should include piece selection in task file when issue reference is used', async () => {
// Given: issue reference "#99" with non-default piece selection
const issueText = 'Issue #99: Fix login timeout';
mockResolveIssueTask.mockReturnValue(issueText);
mockDeterminePiece.mockResolvedValue('review');
mockSummarizeTaskName.mockResolvedValue('fix-login-timeout');
mockConfirm.mockResolvedValue(false);
// When
await addTask(testDir, '#99');
// Then: task file contains piece field
const taskFile = path.join(testDir, '.takt', 'tasks', 'fix-login-timeout.yaml');
expect(fs.existsSync(taskFile)).toBe(true);
const content = fs.readFileSync(taskFile, 'utf-8');
expect(content).toContain('piece: review');
});
it('should cancel when piece selection returns null for issue reference', async () => {
// Given: issue fetched successfully but user cancels piece selection
const issueText = 'Issue #99: Fix login timeout';
mockResolveIssueTask.mockReturnValue(issueText);
mockDeterminePiece.mockResolvedValue(null); mockDeterminePiece.mockResolvedValue(null);
// When
await addTask(testDir, '#99');
// Then: no task file created (cancelled at piece selection)
const tasksDir = path.join(testDir, '.takt', 'tasks');
const files = fs.readdirSync(tasksDir);
expect(files.length).toBe(0);
// Then: issue was fetched before cancellation
expect(mockResolveIssueTask).toHaveBeenCalledWith('#99');
});
it('should call auto-PR confirm with default true', async () => {
// Given: worktree is confirmed so auto-PR prompt is reached
setupFullFlowMocks({ slug: 'auto-pr-default' });
mockConfirm.mockResolvedValue(true);
mockPromptInput.mockResolvedValue('');
// When
await addTask(testDir); await addTask(testDir);
// Then: second confirm call (Auto-create PR?) has defaultYes=true expect(fs.existsSync(path.join(testDir, '.takt', 'tasks.yaml'))).toBe(false);
const autoPrCall = mockConfirm.mock.calls.find(
(call) => call[0] === 'Auto-create PR?',
);
expect(autoPrCall).toBeDefined();
expect(autoPrCall![1]).toBe(true);
});
describe('create_issue action', () => {
it('should call createIssue when create_issue action is selected', async () => {
// Given: interactive mode returns create_issue action
const task = 'Create a new feature\nWith detailed description';
mockDeterminePiece.mockResolvedValue('default');
mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task });
mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/1' });
// When
await addTask(testDir);
// Then: createIssue is called via createIssueFromTask
expect(mockCreateIssue).toHaveBeenCalledWith({
title: 'Create a new feature',
body: task,
});
});
it('should not create task file when create_issue action is selected', async () => {
// Given: interactive mode returns create_issue action
mockDeterminePiece.mockResolvedValue('default');
mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'Some task' });
mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/1' });
// When
await addTask(testDir);
// Then: no task file created
const tasksDir = path.join(testDir, '.takt', 'tasks');
const files = fs.existsSync(tasksDir) ? fs.readdirSync(tasksDir) : [];
expect(files.length).toBe(0);
});
it('should not prompt for worktree settings when create_issue action is selected', async () => {
// Given: interactive mode returns create_issue action
mockDeterminePiece.mockResolvedValue('default');
mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'Some task' });
mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/1' });
// When
await addTask(testDir);
// Then: confirm (worktree prompt) is never called
expect(mockConfirm).not.toHaveBeenCalled();
});
}); });
}); });

View File

@ -1,189 +1,80 @@
/** import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
* Tests for listNonInteractive non-interactive list output and branch actions.
*/
import { execFileSync } from 'node:child_process';
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import * as path from 'node:path'; import * as path from 'node:path';
import * as os from 'node:os'; import * as os from 'node:os';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { stringify as stringifyYaml } from 'yaml';
import { listTasks } from '../features/tasks/list/index.js'; import { listTasksNonInteractive } from '../features/tasks/list/listNonInteractive.js';
describe('listTasks non-interactive text output', () => { const mockInfo = vi.fn();
let tmpDir: string; vi.mock('../shared/ui/index.js', () => ({
info: (...args: unknown[]) => mockInfo(...args),
}));
beforeEach(() => { vi.mock('../infra/task/branchList.js', async (importOriginal) => ({
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-test-ni-')); ...(await importOriginal<Record<string, unknown>>()),
execFileSync('git', ['init', '--initial-branch', 'main'], { cwd: tmpDir, stdio: 'pipe' }); detectDefaultBranch: vi.fn(() => 'main'),
execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: tmpDir, stdio: 'pipe' }); listTaktBranches: vi.fn(() => []),
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: tmpDir, stdio: 'pipe' }); buildListItems: vi.fn(() => []),
execFileSync('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: tmpDir, stdio: 'pipe' }); }));
let tmpDir: string;
beforeEach(() => {
vi.clearAllMocks();
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-list-non-interactive-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function writeTasksFile(projectDir: string): void {
const tasksFile = path.join(projectDir, '.takt', 'tasks.yaml');
fs.mkdirSync(path.dirname(tasksFile), { recursive: true });
fs.writeFileSync(tasksFile, stringifyYaml({
tasks: [
{
name: 'pending-task',
status: 'pending',
content: 'Pending content',
created_at: '2026-02-09T00:00:00.000Z',
started_at: null,
completed_at: null,
},
{
name: 'failed-task',
status: 'failed',
content: 'Failed content',
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: 'Boom' },
},
],
}), 'utf-8');
}
describe('listTasksNonInteractive', () => {
it('should output pending and failed tasks in text format', async () => {
writeTasksFile(tmpDir);
await listTasksNonInteractive(tmpDir, { enabled: true, format: 'text' });
expect(mockInfo).toHaveBeenCalledWith(expect.stringContaining('[running] pending-task'));
expect(mockInfo).toHaveBeenCalledWith(expect.stringContaining('[failed] failed-task'));
}); });
afterEach(() => { it('should output JSON when format=json', async () => {
fs.rmSync(tmpDir, { recursive: true, force: true }); writeTasksFile(tmpDir);
}); const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
it('should output pending tasks in text format', async () => { await listTasksNonInteractive(tmpDir, { enabled: true, format: 'json' });
// Given
const tasksDir = path.join(tmpDir, '.takt', 'tasks');
fs.mkdirSync(tasksDir, { recursive: true });
fs.writeFileSync(path.join(tasksDir, 'my-task.md'), 'Fix the login bug');
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); expect(logSpy).toHaveBeenCalledTimes(1);
const payload = JSON.parse(logSpy.mock.calls[0]![0] as string) as { pendingTasks: Array<{ name: string }>; failedTasks: Array<{ name: string }> };
expect(payload.pendingTasks[0]?.name).toBe('pending-task');
expect(payload.failedTasks[0]?.name).toBe('failed-task');
// When
await listTasks(tmpDir, undefined, { enabled: true });
// Then
const calls = logSpy.mock.calls.map((c) => c[0] as string);
expect(calls).toContainEqual(expect.stringContaining('[running] my-task'));
expect(calls).not.toContainEqual(expect.stringContaining('[pending] my-task'));
expect(calls).not.toContainEqual(expect.stringContaining('[pendig] my-task'));
expect(calls).toContainEqual(expect.stringContaining('Fix the login bug'));
logSpy.mockRestore();
});
it('should output failed tasks in text format', async () => {
// Given
const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_failed-task');
fs.mkdirSync(failedDir, { recursive: true });
fs.writeFileSync(path.join(failedDir, 'failed-task.md'), 'This failed');
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
// When
await listTasks(tmpDir, undefined, { enabled: true });
// Then
const calls = logSpy.mock.calls.map((c) => c[0] as string);
expect(calls).toContainEqual(expect.stringContaining('[failed] failed-task'));
expect(calls).toContainEqual(expect.stringContaining('This failed'));
logSpy.mockRestore();
});
it('should output both pending and failed tasks in text format', async () => {
// Given
const tasksDir = path.join(tmpDir, '.takt', 'tasks');
fs.mkdirSync(tasksDir, { recursive: true });
fs.writeFileSync(path.join(tasksDir, 'pending-one.md'), 'Pending task');
const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_failed-one');
fs.mkdirSync(failedDir, { recursive: true });
fs.writeFileSync(path.join(failedDir, 'failed-one.md'), 'Failed task');
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
// When
await listTasks(tmpDir, undefined, { enabled: true });
// Then
const calls = logSpy.mock.calls.map((c) => c[0] as string);
expect(calls).toContainEqual(expect.stringContaining('[running] pending-one'));
expect(calls).not.toContainEqual(expect.stringContaining('[pending] pending-one'));
expect(calls).not.toContainEqual(expect.stringContaining('[pendig] pending-one'));
expect(calls).toContainEqual(expect.stringContaining('[failed] failed-one'));
logSpy.mockRestore();
});
it('should show info message when no tasks exist', async () => {
// Given: no tasks, no branches
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
// When
await listTasks(tmpDir, undefined, { enabled: true });
// Then
const calls = logSpy.mock.calls.map((c) => c[0] as string);
expect(calls.some((c) => c.includes('No tasks to list'))).toBe(true);
logSpy.mockRestore();
});
});
describe('listTasks non-interactive action errors', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-test-ni-err-'));
execFileSync('git', ['init', '--initial-branch', 'main'], { cwd: tmpDir, stdio: 'pipe' });
execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: tmpDir, stdio: 'pipe' });
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: tmpDir, stdio: 'pipe' });
execFileSync('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: tmpDir, stdio: 'pipe' });
// Create a pending task so the "no tasks" early return is not triggered
const tasksDir = path.join(tmpDir, '.takt', 'tasks');
fs.mkdirSync(tasksDir, { recursive: true });
fs.writeFileSync(path.join(tasksDir, 'dummy.md'), 'dummy');
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('should exit with code 1 when --action specified without --branch', async () => {
// Given
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit'); });
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
// When / Then
await expect(
listTasks(tmpDir, undefined, { enabled: true, action: 'diff' }),
).rejects.toThrow('process.exit');
expect(exitSpy).toHaveBeenCalledWith(1);
exitSpy.mockRestore();
logSpy.mockRestore();
});
it('should exit with code 1 for invalid action', async () => {
// Given
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit'); });
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
// When / Then
await expect(
listTasks(tmpDir, undefined, { enabled: true, action: 'invalid', branch: 'some-branch' }),
).rejects.toThrow('process.exit');
expect(exitSpy).toHaveBeenCalledWith(1);
exitSpy.mockRestore();
logSpy.mockRestore();
});
it('should exit with code 1 when branch not found', async () => {
// Given
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit'); });
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
// When / Then
await expect(
listTasks(tmpDir, undefined, { enabled: true, action: 'diff', branch: 'takt/nonexistent' }),
).rejects.toThrow('process.exit');
expect(exitSpy).toHaveBeenCalledWith(1);
exitSpy.mockRestore();
logSpy.mockRestore();
});
it('should exit with code 1 for delete without --yes', async () => {
// Given: create a branch so it's found
execFileSync('git', ['checkout', '-b', 'takt/20250115-test-branch'], { cwd: tmpDir, stdio: 'pipe' });
execFileSync('git', ['checkout', 'main'], { cwd: tmpDir, stdio: 'pipe' });
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit'); });
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
// When / Then
await expect(
listTasks(tmpDir, undefined, {
enabled: true,
action: 'delete',
branch: 'takt/20250115-test-branch',
}),
).rejects.toThrow('process.exit');
expect(exitSpy).toHaveBeenCalledWith(1);
exitSpy.mockRestore();
logSpy.mockRestore(); logSpy.mockRestore();
}); });
}); });

View File

@ -1,391 +1,94 @@
/** import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
* Tests for list-tasks command
*/
import { execFileSync } from 'node:child_process';
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import * as path from 'node:path'; import * as path from 'node:path';
import * as os from 'node:os'; import * as os from 'node:os';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { stringify as stringifyYaml } from 'yaml';
import {
parseTaktBranches, vi.mock('../shared/ui/index.js', () => ({
extractTaskSlug, info: vi.fn(),
buildListItems, header: vi.fn(),
type BranchInfo, blankLine: vi.fn(),
} from '../infra/task/branchList.js'; }));
vi.mock('../infra/task/branchList.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
listTaktBranches: vi.fn(() => []),
buildListItems: vi.fn(() => []),
detectDefaultBranch: vi.fn(() => 'main'),
}));
import { TaskRunner } from '../infra/task/runner.js'; import { TaskRunner } from '../infra/task/runner.js';
import type { TaskListItem } from '../infra/task/types.js'; import { listTasksNonInteractive } from '../features/tasks/list/listNonInteractive.js';
import { isBranchMerged, showFullDiff, type ListAction } from '../features/tasks/index.js';
import { listTasks } from '../features/tasks/list/index.js';
describe('parseTaktBranches', () => { let tmpDir: string;
it('should parse takt/ branches from git branch output', () => {
const output = [
'takt/20260128-fix-auth def4567',
'takt/20260128-add-search 789abcd',
].join('\n');
const result = parseTaktBranches(output); beforeEach(() => {
expect(result).toHaveLength(2); vi.clearAllMocks();
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-list-test-'));
expect(result[0]).toEqual({
branch: 'takt/20260128-fix-auth',
commit: 'def4567',
});
expect(result[1]).toEqual({
branch: 'takt/20260128-add-search',
commit: '789abcd',
});
});
it('should handle empty output', () => {
const result = parseTaktBranches('');
expect(result).toHaveLength(0);
});
it('should handle output with only whitespace lines', () => {
const result = parseTaktBranches(' \n \n');
expect(result).toHaveLength(0);
});
it('should handle single branch', () => {
const output = 'takt/20260128-fix-auth abc1234';
const result = parseTaktBranches(output);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
branch: 'takt/20260128-fix-auth',
commit: 'abc1234',
});
});
it('should skip lines without space separator', () => {
const output = [
'takt/20260128-fix-auth abc1234',
'malformed-line',
].join('\n');
const result = parseTaktBranches(output);
expect(result).toHaveLength(1);
});
}); });
describe('extractTaskSlug', () => { afterEach(() => {
it('should extract slug from timestamped branch name', () => { fs.rmSync(tmpDir, { recursive: true, force: true });
expect(extractTaskSlug('takt/20260128T032800-fix-auth')).toBe('fix-auth');
});
it('should extract slug from date-only timestamp', () => {
expect(extractTaskSlug('takt/20260128-add-search')).toBe('add-search');
});
it('should extract slug with long timestamp format', () => {
expect(extractTaskSlug('takt/20260128T032800-refactor-api')).toBe('refactor-api');
});
it('should handle branch without timestamp', () => {
expect(extractTaskSlug('takt/my-task')).toBe('my-task');
});
it('should handle branch with only timestamp', () => {
const result = extractTaskSlug('takt/20260128T032800');
// Timestamp is stripped, nothing left, falls back to original name
expect(result).toBe('20260128T032800');
});
it('should handle slug with multiple dashes', () => {
expect(extractTaskSlug('takt/20260128-fix-auth-bug-in-login')).toBe('fix-auth-bug-in-login');
});
}); });
describe('buildListItems', () => { function writeTasksFile(projectDir: string): void {
it('should build items with correct task slug and originalInstruction', () => { const tasksFile = path.join(projectDir, '.takt', 'tasks.yaml');
const branches: BranchInfo[] = [ fs.mkdirSync(path.dirname(tasksFile), { recursive: true });
fs.writeFileSync(tasksFile, stringifyYaml({
tasks: [
{ {
branch: 'takt/20260128-fix-auth', name: 'pending-one',
commit: 'abc123', status: 'pending',
}, content: 'Pending task',
]; created_at: '2026-02-09T00:00:00.000Z',
started_at: null,
const items = buildListItems('/project', branches, 'main'); completed_at: null,
expect(items).toHaveLength(1);
expect(items[0]!.taskSlug).toBe('fix-auth');
expect(items[0]!.info).toBe(branches[0]);
// filesChanged will be 0 since we don't have a real git repo
expect(items[0]!.filesChanged).toBe(0);
// originalInstruction will be empty since git command fails on non-existent repo
expect(items[0]!.originalInstruction).toBe('');
});
it('should handle multiple branches', () => {
const branches: BranchInfo[] = [
{
branch: 'takt/20260128-fix-auth',
commit: 'abc123',
}, },
{ {
branch: 'takt/20260128-add-search', name: 'failed-one',
commit: 'def456', status: 'failed',
content: 'Failed task',
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: 'boom' },
}, },
]; ],
}), 'utf-8');
}
const items = buildListItems('/project', branches, 'main'); describe('TaskRunner list APIs', () => {
expect(items).toHaveLength(2); it('should read pending and failed tasks from tasks.yaml', () => {
expect(items[0]!.taskSlug).toBe('fix-auth'); writeTasksFile(tmpDir);
expect(items[1]!.taskSlug).toBe('add-search');
});
it('should handle empty branch list', () => {
const items = buildListItems('/project', [], 'main');
expect(items).toHaveLength(0);
});
});
describe('ListAction type', () => {
it('should include diff, instruct, try, merge, delete (no skip)', () => {
const actions: ListAction[] = ['diff', 'instruct', 'try', 'merge', 'delete'];
expect(actions).toHaveLength(5);
expect(actions).toContain('diff');
expect(actions).toContain('instruct');
expect(actions).toContain('try');
expect(actions).toContain('merge');
expect(actions).toContain('delete');
expect(actions).not.toContain('skip');
});
});
describe('showFullDiff', () => {
it('should not throw for non-existent project dir', () => {
// spawnSync will fail gracefully; showFullDiff catches errors
expect(() => showFullDiff('/non-existent-dir', 'main', 'some-branch')).not.toThrow();
});
it('should not throw for non-existent branch', () => {
expect(() => showFullDiff('/tmp', 'main', 'non-existent-branch-xyz')).not.toThrow();
});
it('should warn when diff fails', () => {
const warnSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
showFullDiff('/non-existent-dir', 'main', 'some-branch');
warnSpy.mockRestore();
// No assertion needed — the test verifies it doesn't throw
});
});
describe('isBranchMerged', () => {
it('should return false for non-existent project dir', () => {
// git merge-base will fail on non-existent dir
const result = isBranchMerged('/non-existent-dir', 'some-branch');
expect(result).toBe(false);
});
it('should return false for non-existent branch', () => {
const result = isBranchMerged('/tmp', 'non-existent-branch-xyz');
expect(result).toBe(false);
});
});
describe('TaskRunner.listFailedTasks', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-test-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('should return empty array for empty failed directory', () => {
const runner = new TaskRunner(tmpDir); const runner = new TaskRunner(tmpDir);
const result = runner.listFailedTasks();
expect(result).toEqual([]);
});
it('should parse failed task directories correctly', () => { const pending = runner.listPendingTaskItems();
const failedDir = path.join(tmpDir, '.takt', 'failed'); const failed = runner.listFailedTasks();
const taskDir = path.join(failedDir, '2025-01-15T12-34-56_my-task');
fs.mkdirSync(taskDir, { recursive: true });
fs.writeFileSync(path.join(taskDir, 'my-task.md'), 'Fix the login bug\nMore details here');
const runner = new TaskRunner(tmpDir); expect(pending).toHaveLength(1);
const result = runner.listFailedTasks(); expect(pending[0]?.name).toBe('pending-one');
expect(failed).toHaveLength(1);
expect(result).toHaveLength(1); expect(failed[0]?.name).toBe('failed-one');
expect(result[0]).toEqual({ expect(failed[0]?.failure?.error).toBe('boom');
kind: 'failed',
name: 'my-task',
createdAt: '2025-01-15T12:34:56',
filePath: taskDir,
content: 'Fix the login bug',
});
});
it('should skip malformed directory names', () => {
const failedDir = path.join(tmpDir, '.takt', 'failed');
// No underscore → malformed, should be skipped
fs.mkdirSync(path.join(failedDir, 'malformed-name'), { recursive: true });
// Valid one
const validDir = path.join(failedDir, '2025-01-15T12-34-56_valid-task');
fs.mkdirSync(validDir, { recursive: true });
fs.writeFileSync(path.join(validDir, 'valid-task.md'), 'Content');
const runner = new TaskRunner(tmpDir);
const result = runner.listFailedTasks();
expect(result).toHaveLength(1);
expect(result[0]!.name).toBe('valid-task');
});
it('should extract task content from task file in directory', () => {
const failedDir = path.join(tmpDir, '.takt', 'failed');
const taskDir = path.join(failedDir, '2025-02-01T00-00-00_content-test');
fs.mkdirSync(taskDir, { recursive: true });
// report.md and log.json should be skipped; the actual task file should be read
fs.writeFileSync(path.join(taskDir, 'report.md'), 'Report content');
fs.writeFileSync(path.join(taskDir, 'log.json'), '{}');
fs.writeFileSync(path.join(taskDir, 'content-test.yaml'), 'task: Do something important');
const runner = new TaskRunner(tmpDir);
const result = runner.listFailedTasks();
expect(result).toHaveLength(1);
expect(result[0]!.content).toBe('task: Do something important');
});
it('should return empty content when no task file exists', () => {
const failedDir = path.join(tmpDir, '.takt', 'failed');
const taskDir = path.join(failedDir, '2025-02-01T00-00-00_no-task-file');
fs.mkdirSync(taskDir, { recursive: true });
// Only report.md and log.json, no actual task file
fs.writeFileSync(path.join(taskDir, 'report.md'), 'Report content');
fs.writeFileSync(path.join(taskDir, 'log.json'), '{}');
const runner = new TaskRunner(tmpDir);
const result = runner.listFailedTasks();
expect(result).toHaveLength(1);
expect(result[0]!.content).toBe('');
});
it('should handle task name with underscores', () => {
const failedDir = path.join(tmpDir, '.takt', 'failed');
const taskDir = path.join(failedDir, '2025-01-15T12-34-56_my_task_name');
fs.mkdirSync(taskDir, { recursive: true });
const runner = new TaskRunner(tmpDir);
const result = runner.listFailedTasks();
expect(result).toHaveLength(1);
expect(result[0]!.name).toBe('my_task_name');
});
it('should skip non-directory entries', () => {
const failedDir = path.join(tmpDir, '.takt', 'failed');
fs.mkdirSync(failedDir, { recursive: true });
// Create a file (not a directory) in the failed dir
fs.writeFileSync(path.join(failedDir, '2025-01-15T12-34-56_file-task'), 'content');
const runner = new TaskRunner(tmpDir);
const result = runner.listFailedTasks();
expect(result).toHaveLength(0);
});
});
describe('TaskRunner.listPendingTaskItems', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-test-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('should return empty array when no pending tasks', () => {
const runner = new TaskRunner(tmpDir);
const result = runner.listPendingTaskItems();
expect(result).toEqual([]);
});
it('should convert TaskInfo to TaskListItem with kind=pending', () => {
const tasksDir = path.join(tmpDir, '.takt', 'tasks');
fs.mkdirSync(tasksDir, { recursive: true });
fs.writeFileSync(path.join(tasksDir, 'my-task.md'), 'Fix the login bug\nMore details here');
const runner = new TaskRunner(tmpDir);
const result = runner.listPendingTaskItems();
expect(result).toHaveLength(1);
expect(result[0]!.kind).toBe('pending');
expect(result[0]!.name).toBe('my-task');
expect(result[0]!.content).toBe('Fix the login bug');
});
it('should truncate content to first line (max 80 chars)', () => {
const tasksDir = path.join(tmpDir, '.takt', 'tasks');
fs.mkdirSync(tasksDir, { recursive: true });
const longLine = 'A'.repeat(120) + '\nSecond line';
fs.writeFileSync(path.join(tasksDir, 'long-task.md'), longLine);
const runner = new TaskRunner(tmpDir);
const result = runner.listPendingTaskItems();
expect(result).toHaveLength(1);
expect(result[0]!.content).toBe('A'.repeat(80));
}); });
}); });
describe('listTasks non-interactive JSON output', () => { describe('listTasks non-interactive JSON output', () => {
let tmpDir: string; it('should output JSON object with branches, pendingTasks, and failedTasks', async () => {
writeTasksFile(tmpDir);
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
beforeEach(() => { await listTasksNonInteractive(tmpDir, { enabled: true, format: 'json' });
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-test-json-'));
// Initialize as a git repo so detectDefaultBranch works
execFileSync('git', ['init', '--initial-branch', 'main'], { cwd: tmpDir, stdio: 'pipe' });
execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: tmpDir, stdio: 'pipe' });
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: tmpDir, stdio: 'pipe' });
execFileSync('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: tmpDir, stdio: 'pipe' });
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('should output JSON as object with branches, pendingTasks, and failedTasks keys', async () => {
// Given: a pending task and a failed task
const tasksDir = path.join(tmpDir, '.takt', 'tasks');
fs.mkdirSync(tasksDir, { recursive: true });
fs.writeFileSync(path.join(tasksDir, 'my-task.md'), 'Do something');
const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_failed-task');
fs.mkdirSync(failedDir, { recursive: true });
fs.writeFileSync(path.join(failedDir, 'failed-task.md'), 'This failed');
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
// When: listTasks is called in non-interactive JSON mode
await listTasks(tmpDir, undefined, {
enabled: true,
format: 'json',
});
// Then: output is an object with branches, pendingTasks, failedTasks
expect(logSpy).toHaveBeenCalledTimes(1); expect(logSpy).toHaveBeenCalledTimes(1);
const output = JSON.parse(logSpy.mock.calls[0]![0] as string); const payload = JSON.parse(logSpy.mock.calls[0]![0] as string) as {
expect(output).toHaveProperty('branches'); branches: unknown[];
expect(output).toHaveProperty('pendingTasks'); pendingTasks: Array<{ name: string }>;
expect(output).toHaveProperty('failedTasks'); failedTasks: Array<{ name: string }>;
expect(Array.isArray(output.branches)).toBe(true); };
expect(Array.isArray(output.pendingTasks)).toBe(true); expect(Array.isArray(payload.branches)).toBe(true);
expect(Array.isArray(output.failedTasks)).toBe(true); expect(payload.pendingTasks[0]?.name).toBe('pending-one');
expect(output.pendingTasks).toHaveLength(1); expect(payload.failedTasks[0]?.name).toBe('failed-one');
expect(output.pendingTasks[0].name).toBe('my-task');
expect(output.failedTasks).toHaveLength(1);
expect(output.failedTasks[0].name).toBe('failed-task');
logSpy.mockRestore(); logSpy.mockRestore();
}); });

View File

@ -21,18 +21,18 @@ vi.mock('../infra/config/index.js', () => ({
import { loadGlobalConfig } from '../infra/config/index.js'; import { loadGlobalConfig } from '../infra/config/index.js';
const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig); const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig);
const mockGetNextTask = vi.fn();
const mockClaimNextTasks = vi.fn(); const mockClaimNextTasks = vi.fn();
const mockCompleteTask = vi.fn(); const mockCompleteTask = vi.fn();
const mockFailTask = vi.fn(); const mockFailTask = vi.fn();
const mockRecoverInterruptedRunningTasks = vi.fn();
vi.mock('../infra/task/index.js', async (importOriginal) => ({ vi.mock('../infra/task/index.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()), ...(await importOriginal<Record<string, unknown>>()),
TaskRunner: vi.fn().mockImplementation(() => ({ TaskRunner: vi.fn().mockImplementation(() => ({
getNextTask: mockGetNextTask,
claimNextTasks: mockClaimNextTasks, claimNextTasks: mockClaimNextTasks,
completeTask: mockCompleteTask, completeTask: mockCompleteTask,
failTask: mockFailTask, failTask: mockFailTask,
recoverInterruptedRunningTasks: mockRecoverInterruptedRunningTasks,
})), })),
})); }));
@ -128,11 +128,15 @@ function createTask(name: string): TaskInfo {
name, name,
content: `Task: ${name}`, content: `Task: ${name}`,
filePath: `/tasks/${name}.yaml`, filePath: `/tasks/${name}.yaml`,
createdAt: '2026-02-09T00:00:00.000Z',
status: 'pending',
data: null,
}; };
} }
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockRecoverInterruptedRunningTasks.mockReturnValue(0);
}); });
describe('runAllTasks concurrency', () => { describe('runAllTasks concurrency', () => {
@ -155,7 +159,7 @@ describe('runAllTasks concurrency', () => {
await runAllTasks('/project'); await runAllTasks('/project');
// Then // Then
expect(mockInfo).toHaveBeenCalledWith('No pending tasks in .takt/tasks/'); expect(mockInfo).toHaveBeenCalledWith('No pending tasks in .takt/tasks.yaml');
}); });
it('should execute tasks sequentially via worker pool when concurrency is 1', async () => { it('should execute tasks sequentially via worker pool when concurrency is 1', async () => {
@ -401,6 +405,28 @@ describe('runAllTasks concurrency', () => {
expect(mockStatus).toHaveBeenCalledWith('Failed', '1', 'red'); expect(mockStatus).toHaveBeenCalledWith('Failed', '1', 'red');
}); });
it('should persist failure reason and movement when piece aborts', async () => {
const task1 = createTask('fail-with-detail');
mockExecutePiece.mockResolvedValue({
success: false,
reason: 'blocked_by_review',
lastMovement: 'review',
lastMessage: 'security check failed',
});
mockClaimNextTasks
.mockReturnValueOnce([task1])
.mockReturnValueOnce([]);
await runAllTasks('/project');
expect(mockFailTask).toHaveBeenCalledWith(expect.objectContaining({
response: 'blocked_by_review',
failureMovement: 'review',
failureLastMessage: 'security check failed',
}));
});
it('should pass abortSignal and taskPrefix to executePiece in parallel mode', async () => { it('should pass abortSignal and taskPrefix to executePiece in parallel mode', async () => {
// Given: One task in parallel mode // Given: One task in parallel mode
const task1 = createTask('parallel-task'); const task1 = createTask('parallel-task');

View File

@ -1,15 +1,8 @@
/**
* Tests for saveTaskFile and saveTaskFromInteractive
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import * as path from 'node:path'; import * as path from 'node:path';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { parse as parseYaml } from 'yaml';
vi.mock('../infra/task/summarize.js', () => ({
summarizeTaskName: vi.fn(),
}));
vi.mock('../shared/ui/index.js', () => ({ vi.mock('../shared/ui/index.js', () => ({
success: vi.fn(), success: vi.fn(),
@ -31,12 +24,10 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
}), }),
})); }));
import { summarizeTaskName } from '../infra/task/summarize.js';
import { success, info } from '../shared/ui/index.js'; import { success, info } from '../shared/ui/index.js';
import { confirm, promptInput } from '../shared/prompt/index.js'; import { confirm, promptInput } from '../shared/prompt/index.js';
import { saveTaskFile, saveTaskFromInteractive } from '../features/tasks/add/index.js'; import { saveTaskFile, saveTaskFromInteractive } from '../features/tasks/add/index.js';
const mockSummarizeTaskName = vi.mocked(summarizeTaskName);
const mockSuccess = vi.mocked(success); const mockSuccess = vi.mocked(success);
const mockInfo = vi.mocked(info); const mockInfo = vi.mocked(info);
const mockConfirm = vi.mocked(confirm); const mockConfirm = vi.mocked(confirm);
@ -44,10 +35,14 @@ const mockPromptInput = vi.mocked(promptInput);
let testDir: string; let testDir: string;
function loadTasks(testDir: string): { tasks: Array<Record<string, unknown>> } {
const raw = fs.readFileSync(path.join(testDir, '.takt', 'tasks.yaml'), 'utf-8');
return parseYaml(raw) as { tasks: Array<Record<string, unknown>> };
}
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
testDir = fs.mkdtempSync(path.join(tmpdir(), 'takt-test-save-')); testDir = fs.mkdtempSync(path.join(tmpdir(), 'takt-test-save-'));
mockSummarizeTaskName.mockResolvedValue('test-task');
}); });
afterEach(() => { afterEach(() => {
@ -57,243 +52,74 @@ afterEach(() => {
}); });
describe('saveTaskFile', () => { describe('saveTaskFile', () => {
it('should create task file with correct YAML content', async () => { it('should append task to tasks.yaml', async () => {
// Given const created = await saveTaskFile(testDir, 'Implement feature X\nDetails here');
const taskContent = 'Implement feature X\nDetails here';
// When expect(created.taskName).toContain('implement-feature-x');
const filePath = await saveTaskFile(testDir, taskContent); expect(created.tasksFile).toBe(path.join(testDir, '.takt', 'tasks.yaml'));
expect(fs.existsSync(created.tasksFile)).toBe(true);
// Then const tasks = loadTasks(testDir).tasks;
expect(fs.existsSync(filePath)).toBe(true); expect(tasks).toHaveLength(1);
const content = fs.readFileSync(filePath, 'utf-8'); expect(tasks[0]?.content).toContain('Implement feature X');
expect(content).toContain('Implement feature X');
expect(content).toContain('Details here');
}); });
it('should create .takt/tasks directory if it does not exist', async () => { it('should include optional fields', async () => {
// Given await saveTaskFile(testDir, 'Task', {
const tasksDir = path.join(testDir, '.takt', 'tasks'); piece: 'review',
expect(fs.existsSync(tasksDir)).toBe(false); issue: 42,
worktree: true,
branch: 'feat/my-branch',
autoPr: false,
});
// When const task = loadTasks(testDir).tasks[0]!;
await saveTaskFile(testDir, 'Task content'); expect(task.piece).toBe('review');
expect(task.issue).toBe(42);
// Then expect(task.worktree).toBe(true);
expect(fs.existsSync(tasksDir)).toBe(true); expect(task.branch).toBe('feat/my-branch');
expect(task.auto_pr).toBe(false);
}); });
it('should include piece in YAML when specified', async () => { it('should generate unique names on duplicates', async () => {
// When const first = await saveTaskFile(testDir, 'Same title');
const filePath = await saveTaskFile(testDir, 'Task', { piece: 'review' }); const second = await saveTaskFile(testDir, 'Same title');
// Then expect(first.taskName).not.toBe(second.taskName);
const content = fs.readFileSync(filePath, 'utf-8');
expect(content).toContain('piece: review');
});
it('should include issue number in YAML when specified', async () => {
// When
const filePath = await saveTaskFile(testDir, 'Task', { issue: 42 });
// Then
const content = fs.readFileSync(filePath, 'utf-8');
expect(content).toContain('issue: 42');
});
it('should include worktree in YAML when specified', async () => {
// When
const filePath = await saveTaskFile(testDir, 'Task', { worktree: true });
// Then
const content = fs.readFileSync(filePath, 'utf-8');
expect(content).toContain('worktree: true');
});
it('should include branch in YAML when specified', async () => {
// When
const filePath = await saveTaskFile(testDir, 'Task', { branch: 'feat/my-branch' });
// Then
const content = fs.readFileSync(filePath, 'utf-8');
expect(content).toContain('branch: feat/my-branch');
});
it('should not include optional fields when not specified', async () => {
// When
const filePath = await saveTaskFile(testDir, 'Simple task');
// Then
const content = fs.readFileSync(filePath, 'utf-8');
expect(content).not.toContain('piece:');
expect(content).not.toContain('issue:');
expect(content).not.toContain('worktree:');
expect(content).not.toContain('branch:');
expect(content).not.toContain('auto_pr:');
});
it('should include auto_pr in YAML when specified', async () => {
// When
const filePath = await saveTaskFile(testDir, 'Task', { autoPr: true });
// Then
const content = fs.readFileSync(filePath, 'utf-8');
expect(content).toContain('auto_pr: true');
});
it('should include auto_pr: false in YAML when specified as false', async () => {
// When
const filePath = await saveTaskFile(testDir, 'Task', { autoPr: false });
// Then
const content = fs.readFileSync(filePath, 'utf-8');
expect(content).toContain('auto_pr: false');
});
it('should use first line for filename generation', async () => {
// When
await saveTaskFile(testDir, 'First line\nSecond line');
// Then
expect(mockSummarizeTaskName).toHaveBeenCalledWith('First line', { cwd: testDir });
});
it('should handle duplicate filenames with counter', async () => {
// Given: first file already exists
await saveTaskFile(testDir, 'Task 1');
// When: second file with same slug
const filePath = await saveTaskFile(testDir, 'Task 2');
// Then
expect(path.basename(filePath)).toBe('test-task-1.yaml');
}); });
}); });
describe('saveTaskFromInteractive', () => { describe('saveTaskFromInteractive', () => {
it('should save task with worktree settings when user confirms worktree', async () => { it('should save task with worktree settings when user confirms', async () => {
// Given: user confirms worktree, accepts defaults, confirms auto-PR mockConfirm.mockResolvedValueOnce(true);
mockConfirm.mockResolvedValueOnce(true); // Create worktree? → Yes mockPromptInput.mockResolvedValueOnce('');
mockPromptInput.mockResolvedValueOnce(''); // Worktree path → auto mockPromptInput.mockResolvedValueOnce('');
mockPromptInput.mockResolvedValueOnce(''); // Branch name → auto mockConfirm.mockResolvedValueOnce(true);
mockConfirm.mockResolvedValueOnce(true); // Auto-create PR? → Yes
// When
await saveTaskFromInteractive(testDir, 'Task content'); await saveTaskFromInteractive(testDir, 'Task content');
// Then expect(mockSuccess).toHaveBeenCalledWith(expect.stringContaining('Task created:'));
expect(mockSuccess).toHaveBeenCalledWith('Task created: test-task.yaml'); const task = loadTasks(testDir).tasks[0]!;
expect(mockInfo).toHaveBeenCalledWith(expect.stringContaining('Path:')); expect(task.worktree).toBe(true);
const tasksDir = path.join(testDir, '.takt', 'tasks'); expect(task.auto_pr).toBe(true);
const files = fs.readdirSync(tasksDir);
const content = fs.readFileSync(path.join(tasksDir, files[0]!), 'utf-8');
expect(content).toContain('worktree: true');
expect(content).toContain('auto_pr: true');
}); });
it('should save task without worktree settings when user declines worktree', async () => { it('should save task without worktree settings when declined', async () => {
// Given: user declines worktree mockConfirm.mockResolvedValueOnce(false);
mockConfirm.mockResolvedValueOnce(false); // Create worktree? → No
// When
await saveTaskFromInteractive(testDir, 'Task content'); await saveTaskFromInteractive(testDir, 'Task content');
// Then const task = loadTasks(testDir).tasks[0]!;
expect(mockSuccess).toHaveBeenCalledWith('Task created: test-task.yaml'); expect(task.worktree).toBeUndefined();
const tasksDir = path.join(testDir, '.takt', 'tasks'); expect(task.branch).toBeUndefined();
const files = fs.readdirSync(tasksDir); expect(task.auto_pr).toBeUndefined();
const content = fs.readFileSync(path.join(tasksDir, files[0]!), 'utf-8');
expect(content).not.toContain('worktree:');
expect(content).not.toContain('branch:');
expect(content).not.toContain('auto_pr:');
});
it('should save custom worktree path and branch when specified', async () => {
// Given: user specifies custom path and branch
mockConfirm.mockResolvedValueOnce(true); // Create worktree? → Yes
mockPromptInput.mockResolvedValueOnce('/custom/path'); // Worktree path
mockPromptInput.mockResolvedValueOnce('feat/branch'); // Branch name
mockConfirm.mockResolvedValueOnce(false); // Auto-create PR? → No
// When
await saveTaskFromInteractive(testDir, 'Task content');
// Then
const tasksDir = path.join(testDir, '.takt', 'tasks');
const files = fs.readdirSync(tasksDir);
const content = fs.readFileSync(path.join(tasksDir, files[0]!), 'utf-8');
expect(content).toContain('worktree: /custom/path');
expect(content).toContain('branch: feat/branch');
expect(content).toContain('auto_pr: false');
});
it('should display worktree/branch/auto-PR info when settings are provided', async () => {
// Given
mockConfirm.mockResolvedValueOnce(true); // Create worktree? → Yes
mockPromptInput.mockResolvedValueOnce('/my/path'); // Worktree path
mockPromptInput.mockResolvedValueOnce('my-branch'); // Branch name
mockConfirm.mockResolvedValueOnce(true); // Auto-create PR? → Yes
// When
await saveTaskFromInteractive(testDir, 'Task content');
// Then
expect(mockInfo).toHaveBeenCalledWith(' Worktree: /my/path');
expect(mockInfo).toHaveBeenCalledWith(' Branch: my-branch');
expect(mockInfo).toHaveBeenCalledWith(' Auto-PR: yes');
}); });
it('should display piece info when specified', async () => { it('should display piece info when specified', async () => {
// Given mockConfirm.mockResolvedValueOnce(false);
mockConfirm.mockResolvedValueOnce(false); // Create worktree? → No
// When
await saveTaskFromInteractive(testDir, 'Task content', 'review'); await saveTaskFromInteractive(testDir, 'Task content', 'review');
// Then
expect(mockInfo).toHaveBeenCalledWith(' Piece: review'); expect(mockInfo).toHaveBeenCalledWith(' Piece: review');
}); });
it('should include piece in saved YAML', async () => {
// Given
mockConfirm.mockResolvedValueOnce(false); // Create worktree? → No
// When
await saveTaskFromInteractive(testDir, 'Task content', 'custom');
// Then
const tasksDir = path.join(testDir, '.takt', 'tasks');
const files = fs.readdirSync(tasksDir);
expect(files.length).toBe(1);
const content = fs.readFileSync(path.join(tasksDir, files[0]!), 'utf-8');
expect(content).toContain('piece: custom');
});
it('should not display piece info when not specified', async () => {
// Given
mockConfirm.mockResolvedValueOnce(false); // Create worktree? → No
// When
await saveTaskFromInteractive(testDir, 'Task content');
// Then
const pieceInfoCalls = mockInfo.mock.calls.filter(
(call) => typeof call[0] === 'string' && call[0].includes('Piece:')
);
expect(pieceInfoCalls.length).toBe(0);
});
it('should display auto worktree info when no custom path', async () => {
// Given
mockConfirm.mockResolvedValueOnce(true); // Create worktree? → Yes
mockPromptInput.mockResolvedValueOnce(''); // Worktree path → auto
mockPromptInput.mockResolvedValueOnce(''); // Branch name → auto
mockConfirm.mockResolvedValueOnce(true); // Auto-create PR? → Yes
// When
await saveTaskFromInteractive(testDir, 'Task content');
// Then
expect(mockInfo).toHaveBeenCalledWith(' Worktree: auto');
});
}); });

View File

@ -1,59 +1,34 @@
/** import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
* Task runner tests import { mkdirSync, writeFileSync, existsSync, rmSync, readFileSync } from 'node:fs';
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdirSync, writeFileSync, existsSync, rmSync, readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
import { TaskRunner } from '../infra/task/runner.js'; import { TaskRunner } from '../infra/task/runner.js';
import { isTaskFile, parseTaskFiles } from '../infra/task/parser.js'; import { TaskRecordSchema } from '../infra/task/schema.js';
describe('isTaskFile', () => { function loadTasksFile(testDir: string): { tasks: Array<Record<string, unknown>> } {
it('should accept .yaml files', () => { const raw = readFileSync(join(testDir, '.takt', 'tasks.yaml'), 'utf-8');
expect(isTaskFile('task.yaml')).toBe(true); return parseYaml(raw) as { tasks: Array<Record<string, unknown>> };
}); }
it('should accept .yml files', () => { function writeTasksFile(testDir: string, tasks: Array<Record<string, unknown>>): void {
expect(isTaskFile('task.yml')).toBe(true); mkdirSync(join(testDir, '.takt'), { recursive: true });
}); writeFileSync(join(testDir, '.takt', 'tasks.yaml'), stringifyYaml({ tasks }), 'utf-8');
}
it('should accept .md files', () => { function createPendingRecord(overrides: Record<string, unknown>): Record<string, unknown> {
expect(isTaskFile('task.md')).toBe(true); 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>;
}
it('should reject extensionless files like TASK-FORMAT', () => { describe('TaskRunner (tasks.yaml)', () => {
expect(isTaskFile('TASK-FORMAT')).toBe(false);
});
it('should reject .txt files', () => {
expect(isTaskFile('readme.txt')).toBe(false);
});
});
describe('parseTaskFiles', () => {
const testDir = `/tmp/takt-parse-test-${Date.now()}`;
beforeEach(() => {
mkdirSync(testDir, { recursive: true });
});
afterEach(() => {
if (existsSync(testDir)) {
rmSync(testDir, { recursive: true, force: true });
}
});
it('should ignore extensionless files like TASK-FORMAT', () => {
writeFileSync(join(testDir, 'TASK-FORMAT'), 'Format documentation');
writeFileSync(join(testDir, 'real-task.md'), 'Real task');
const tasks = parseTaskFiles(testDir);
expect(tasks).toHaveLength(1);
expect(tasks[0]?.name).toBe('real-task');
});
});
describe('TaskRunner', () => {
const testDir = `/tmp/takt-task-test-${Date.now()}`; const testDir = `/tmp/takt-task-test-${Date.now()}`;
let runner: TaskRunner; let runner: TaskRunner;
@ -68,465 +43,245 @@ describe('TaskRunner', () => {
} }
}); });
describe('ensureDirs', () => { it('should add tasks to .takt/tasks.yaml', () => {
it('should create tasks, completed, and failed directories', () => { const task = runner.addTask('Fix login flow', { piece: 'default' });
runner.ensureDirs(); expect(task.name).toContain('fix-login-flow');
expect(existsSync(join(testDir, '.takt', 'tasks'))).toBe(true); expect(existsSync(join(testDir, '.takt', 'tasks.yaml'))).toBe(true);
expect(existsSync(join(testDir, '.takt', 'completed'))).toBe(true);
expect(existsSync(join(testDir, '.takt', 'failed'))).toBe(true);
});
}); });
describe('listTasks', () => { it('should list only pending tasks', () => {
it('should return empty array when no tasks', () => { runner.addTask('Task A');
const tasks = runner.listTasks(); runner.addTask('Task B');
expect(tasks).toEqual([]);
});
it('should list tasks sorted by name', () => { const tasks = runner.listTasks();
const tasksDir = join(testDir, '.takt', 'tasks'); expect(tasks).toHaveLength(2);
mkdirSync(tasksDir, { recursive: true }); expect(tasks.every((task) => task.status === 'pending')).toBe(true);
writeFileSync(join(tasksDir, '02-second.md'), 'Second task');
writeFileSync(join(tasksDir, '01-first.md'), 'First task');
writeFileSync(join(tasksDir, '03-third.md'), 'Third task');
const tasks = runner.listTasks();
expect(tasks).toHaveLength(3);
expect(tasks[0]?.name).toBe('01-first');
expect(tasks[1]?.name).toBe('02-second');
expect(tasks[2]?.name).toBe('03-third');
});
it('should only list .md files', () => {
const tasksDir = join(testDir, '.takt', 'tasks');
mkdirSync(tasksDir, { recursive: true });
writeFileSync(join(tasksDir, 'task.md'), 'Task content');
writeFileSync(join(tasksDir, 'readme.txt'), 'Not a task');
const tasks = runner.listTasks();
expect(tasks).toHaveLength(1);
expect(tasks[0]?.name).toBe('task');
});
}); });
describe('getTask', () => { it('should claim tasks and mark them running', () => {
it('should return null for non-existent task', () => { runner.addTask('Task A');
const task = runner.getTask('non-existent'); runner.addTask('Task B');
expect(task).toBeNull();
});
it('should return task info for existing task', () => { const claimed = runner.claimNextTasks(1);
const tasksDir = join(testDir, '.takt', 'tasks'); expect(claimed).toHaveLength(1);
mkdirSync(tasksDir, { recursive: true }); expect(claimed[0]?.status).toBe('running');
writeFileSync(join(tasksDir, 'my-task.md'), 'Task content');
const task = runner.getTask('my-task'); const file = loadTasksFile(testDir);
expect(task).not.toBeNull(); expect(file.tasks.some((task) => task.status === 'running')).toBe(true);
expect(task?.name).toBe('my-task');
expect(task?.content).toBe('Task content');
});
}); });
describe('getNextTask', () => { it('should recover interrupted running tasks to pending', () => {
it('should return null when no tasks', () => { runner.addTask('Task A');
const task = runner.getNextTask(); runner.claimNextTasks(1);
expect(task).toBeNull(); 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');
it('should return first task (alphabetically)', () => { const recovered = runner.recoverInterruptedRunningTasks();
const tasksDir = join(testDir, '.takt', 'tasks'); expect(recovered).toBe(1);
mkdirSync(tasksDir, { recursive: true });
writeFileSync(join(tasksDir, 'b-task.md'), 'B');
writeFileSync(join(tasksDir, 'a-task.md'), 'A');
const task = runner.getNextTask(); const tasks = runner.listTasks();
expect(task?.name).toBe('a-task'); expect(tasks).toHaveLength(1);
}); expect(tasks[0]?.status).toBe('pending');
}); });
describe('claimNextTasks', () => { it('should keep running tasks owned by a live process', () => {
it('should return empty array when no tasks', () => { runner.addTask('Task A');
const tasks = runner.claimNextTasks(3); runner.claimNextTasks(1);
expect(tasks).toEqual([]);
});
it('should return tasks up to the requested count', () => { const recovered = runner.recoverInterruptedRunningTasks();
const tasksDir = join(testDir, '.takt', 'tasks'); expect(recovered).toBe(0);
mkdirSync(tasksDir, { recursive: true });
writeFileSync(join(tasksDir, 'a-task.md'), 'A');
writeFileSync(join(tasksDir, 'b-task.md'), 'B');
writeFileSync(join(tasksDir, 'c-task.md'), 'C');
const tasks = runner.claimNextTasks(2);
expect(tasks).toHaveLength(2);
expect(tasks[0]?.name).toBe('a-task');
expect(tasks[1]?.name).toBe('b-task');
});
it('should not return already claimed tasks on subsequent calls', () => {
const tasksDir = join(testDir, '.takt', 'tasks');
mkdirSync(tasksDir, { recursive: true });
writeFileSync(join(tasksDir, 'a-task.md'), 'A');
writeFileSync(join(tasksDir, 'b-task.md'), 'B');
writeFileSync(join(tasksDir, 'c-task.md'), 'C');
// Given: first call claims a-task
const first = runner.claimNextTasks(1);
expect(first).toHaveLength(1);
expect(first[0]?.name).toBe('a-task');
// When: second call should skip a-task
const second = runner.claimNextTasks(1);
expect(second).toHaveLength(1);
expect(second[0]?.name).toBe('b-task');
// When: third call should skip a-task and b-task
const third = runner.claimNextTasks(1);
expect(third).toHaveLength(1);
expect(third[0]?.name).toBe('c-task');
// When: fourth call should return empty (all claimed)
const fourth = runner.claimNextTasks(1);
expect(fourth).toEqual([]);
});
it('should release claim after completeTask', () => {
const tasksDir = join(testDir, '.takt', 'tasks');
mkdirSync(tasksDir, { recursive: true });
writeFileSync(join(tasksDir, 'task-a.md'), 'Task A content');
// Given: claim the task
const claimed = runner.claimNextTasks(1);
expect(claimed).toHaveLength(1);
// When: complete the task (file is moved away)
runner.completeTask({
task: claimed[0]!,
success: true,
response: 'Done',
executionLog: [],
startedAt: '2024-01-01T00:00:00.000Z',
completedAt: '2024-01-01T00:01:00.000Z',
});
// Then: claim set no longer blocks (but file is moved, so no tasks anyway)
const next = runner.claimNextTasks(1);
expect(next).toEqual([]);
});
it('should release claim after failTask', () => {
const tasksDir = join(testDir, '.takt', 'tasks');
mkdirSync(tasksDir, { recursive: true });
writeFileSync(join(tasksDir, 'task-a.md'), 'Task A content');
// Given: claim the task
const claimed = runner.claimNextTasks(1);
expect(claimed).toHaveLength(1);
// When: fail the task (file is moved away)
runner.failTask({
task: claimed[0]!,
success: false,
response: 'Error',
executionLog: [],
startedAt: '2024-01-01T00:00:00.000Z',
completedAt: '2024-01-01T00:01:00.000Z',
});
// Then: claim set no longer blocks
const next = runner.claimNextTasks(1);
expect(next).toEqual([]);
});
it('should not affect getNextTask (unclaimed access)', () => {
const tasksDir = join(testDir, '.takt', 'tasks');
mkdirSync(tasksDir, { recursive: true });
writeFileSync(join(tasksDir, 'a-task.md'), 'A');
writeFileSync(join(tasksDir, 'b-task.md'), 'B');
// Given: claim a-task via claimNextTasks
runner.claimNextTasks(1);
// When: getNextTask is called (no claim filtering)
const task = runner.getNextTask();
// Then: getNextTask still returns first task (including claimed)
expect(task?.name).toBe('a-task');
});
}); });
describe('completeTask', () => { it('should take over stale lock file with invalid pid', () => {
it('should move task to completed directory', () => { mkdirSync(join(testDir, '.takt'), { recursive: true });
const tasksDir = join(testDir, '.takt', 'tasks'); writeFileSync(join(testDir, '.takt', 'tasks.yaml.lock'), 'invalid-pid', 'utf-8');
mkdirSync(tasksDir, { recursive: true });
const taskFile = join(tasksDir, 'test-task.md');
writeFileSync(taskFile, 'Test task content');
const task = runner.getTask('test-task')!; const task = runner.addTask('Task with stale lock');
const result = {
task,
success: true,
response: 'Task completed successfully',
executionLog: ['Started', 'Done'],
startedAt: '2024-01-01T00:00:00.000Z',
completedAt: '2024-01-01T00:01:00.000Z',
};
const reportFile = runner.completeTask(result); expect(task.name).toContain('task-with-stale-lock');
expect(existsSync(join(testDir, '.takt', 'tasks.yaml.lock'))).toBe(false);
// Original task file should be moved
expect(existsSync(taskFile)).toBe(false);
// Report should be created
expect(existsSync(reportFile)).toBe(true);
const reportContent = readFileSync(reportFile, 'utf-8');
expect(reportContent).toContain('# タスク実行レポート');
expect(reportContent).toContain('test-task');
expect(reportContent).toContain('成功');
// Log file should be created
const logFile = reportFile.replace('report.md', 'log.json');
expect(existsSync(logFile)).toBe(true);
const logData = JSON.parse(readFileSync(logFile, 'utf-8'));
expect(logData.taskName).toBe('test-task');
expect(logData.success).toBe(true);
});
it('should throw error when called with a failed result', () => {
const tasksDir = join(testDir, '.takt', 'tasks');
mkdirSync(tasksDir, { recursive: true });
writeFileSync(join(tasksDir, 'fail-task.md'), 'Will fail');
const task = runner.getTask('fail-task')!;
const result = {
task,
success: false,
response: 'Error occurred',
executionLog: ['Error'],
startedAt: '2024-01-01T00:00:00.000Z',
completedAt: '2024-01-01T00:01:00.000Z',
};
expect(() => runner.completeTask(result)).toThrow(
'Cannot complete a failed task. Use failTask() instead.'
);
});
}); });
describe('failTask', () => { it('should timeout when lock file is held by a live process', () => {
it('should move task to failed directory', () => { mkdirSync(join(testDir, '.takt'), { recursive: true });
const tasksDir = join(testDir, '.takt', 'tasks'); writeFileSync(join(testDir, '.takt', 'tasks.yaml.lock'), String(process.pid), 'utf-8');
mkdirSync(tasksDir, { recursive: true });
const taskFile = join(tasksDir, 'fail-task.md');
writeFileSync(taskFile, 'Task that will fail');
const task = runner.getTask('fail-task')!; const dateNowSpy = vi.spyOn(Date, 'now');
const result = { dateNowSpy.mockReturnValueOnce(0);
task, dateNowSpy.mockReturnValue(5_000);
success: false,
response: 'Error occurred',
executionLog: ['Started', 'Error'],
startedAt: '2024-01-01T00:00:00.000Z',
completedAt: '2024-01-01T00:01:00.000Z',
};
const reportFile = runner.failTask(result); try {
expect(() => runner.listTasks()).toThrow('Failed to acquire tasks lock within 5000ms');
// Original task file should be removed from tasks dir } finally {
expect(existsSync(taskFile)).toBe(false); dateNowSpy.mockRestore();
rmSync(join(testDir, '.takt', 'tasks.yaml.lock'), { force: true });
// Report should be in .takt/failed/ (not .takt/completed/) }
expect(reportFile).toContain(join('.takt', 'failed'));
expect(reportFile).not.toContain(join('.takt', 'completed'));
expect(existsSync(reportFile)).toBe(true);
const reportContent = readFileSync(reportFile, 'utf-8');
expect(reportContent).toContain('# タスク実行レポート');
expect(reportContent).toContain('fail-task');
expect(reportContent).toContain('失敗');
// Log file should be created in failed dir
const logFile = reportFile.replace('report.md', 'log.json');
expect(existsSync(logFile)).toBe(true);
const logData = JSON.parse(readFileSync(logFile, 'utf-8'));
expect(logData.taskName).toBe('fail-task');
expect(logData.success).toBe(false);
});
it('should not move failed task to completed directory', () => {
const tasksDir = join(testDir, '.takt', 'tasks');
const completedDir = join(testDir, '.takt', 'completed');
mkdirSync(tasksDir, { recursive: true });
const taskFile = join(tasksDir, 'another-fail.md');
writeFileSync(taskFile, 'Another failing task');
const task = runner.getTask('another-fail')!;
const result = {
task,
success: false,
response: 'Something went wrong',
executionLog: [],
startedAt: '2024-01-01T00:00:00.000Z',
completedAt: '2024-01-01T00:01:00.000Z',
};
runner.failTask(result);
// completed directory should be empty (only the dir itself exists)
mkdirSync(completedDir, { recursive: true });
const completedContents = readdirSync(completedDir);
expect(completedContents).toHaveLength(0);
});
}); });
describe('getTasksDir', () => { it('should recover from corrupted tasks.yaml and allow adding tasks again', () => {
it('should return tasks directory path', () => { mkdirSync(join(testDir, '.takt'), { recursive: true });
expect(runner.getTasksDir()).toBe(join(testDir, '.takt', 'tasks')); 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);
}); });
describe('requeueFailedTask', () => { it('should load pending content from relative content_file', () => {
it('should copy task file from failed to tasks directory', () => { mkdirSync(join(testDir, 'fixtures'), { recursive: true });
runner.ensureDirs(); writeFileSync(join(testDir, 'fixtures', 'task.txt'), 'Task from file\nsecond line', 'utf-8');
writeTasksFile(testDir, [createPendingRecord({
content: undefined,
content_file: 'fixtures/task.txt',
})]);
// Create a failed task directory const tasks = runner.listTasks();
const failedDir = join(testDir, '.takt', 'failed', '2026-01-31T12-00-00_my-task'); const pendingItems = runner.listPendingTaskItems();
mkdirSync(failedDir, { recursive: true });
writeFileSync(join(failedDir, 'my-task.yaml'), 'task: Do something\n');
writeFileSync(join(failedDir, 'report.md'), '# Report');
writeFileSync(join(failedDir, 'log.json'), '{}');
const result = runner.requeueFailedTask(failedDir); expect(tasks[0]?.content).toBe('Task from file\nsecond line');
expect(pendingItems[0]?.content).toBe('Task from file');
});
// Task file should be copied to tasks dir it('should load pending content from absolute content_file', () => {
expect(existsSync(result)).toBe(true); const contentPath = join(testDir, 'absolute-task.txt');
expect(result).toBe(join(testDir, '.takt', 'tasks', 'my-task.yaml')); writeFileSync(contentPath, 'Absolute task content', 'utf-8');
writeTasksFile(testDir, [createPendingRecord({
content: undefined,
content_file: contentPath,
})]);
// Original failed directory should still exist const tasks = runner.listTasks();
expect(existsSync(failedDir)).toBe(true); expect(tasks[0]?.content).toBe('Absolute task content');
});
// Task content should be preserved it('should prefer inline content over content_file', () => {
const content = readFileSync(result, 'utf-8'); writeTasksFile(testDir, [createPendingRecord({
expect(content).toBe('task: Do something\n'); content: 'Inline content',
content_file: 'missing-content-file.txt',
})]);
const tasks = runner.listTasks();
expect(tasks[0]?.content).toBe('Inline content');
});
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 mark claimed task as completed', () => {
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(),
}); });
it('should add start_movement to YAML task file when specified', () => { const file = loadTasksFile(testDir);
runner.ensureDirs(); expect(file.tasks[0]?.status).toBe('completed');
});
const failedDir = join(testDir, '.takt', 'failed', '2026-01-31T12-00-00_retry-task'); it('should mark claimed task as failed with failure detail', () => {
mkdirSync(failedDir, { recursive: true }); runner.addTask('Task A');
writeFileSync(join(failedDir, 'retry-task.yaml'), 'task: Retry me\npiece: default\n'); const task = runner.claimNextTasks(1)[0]!;
const result = runner.requeueFailedTask(failedDir, 'implement'); runner.failTask({
task,
const content = readFileSync(result, 'utf-8'); success: false,
expect(content).toContain('start_movement: implement'); response: 'Boom',
expect(content).toContain('task: Retry me'); executionLog: ['last message'],
expect(content).toContain('piece: default'); failureMovement: 'review',
failureLastMessage: 'last message',
startedAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
}); });
it('should replace existing start_movement in YAML task file', () => { const failed = runner.listFailedTasks();
runner.ensureDirs(); 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');
});
const failedDir = join(testDir, '.takt', 'failed', '2026-01-31T12-00-00_replace-task'); it('should requeue failed task to pending with retry metadata', () => {
mkdirSync(failedDir, { recursive: true }); runner.addTask('Task A');
writeFileSync(join(failedDir, 'replace-task.yaml'), 'task: Replace me\nstart_movement: plan\n'); const task = runner.claimNextTasks(1)[0]!;
runner.failTask({
const result = runner.requeueFailedTask(failedDir, 'ai_review'); task,
success: false,
const content = readFileSync(result, 'utf-8'); response: 'Boom',
expect(content).toContain('start_movement: ai_review'); executionLog: [],
expect(content).not.toContain('start_movement: plan'); startedAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
}); });
it('should not modify markdown task files even with startMovement', () => { runner.requeueFailedTask(task.name, 'implement', 'retry note');
runner.ensureDirs();
const failedDir = join(testDir, '.takt', 'failed', '2026-01-31T12-00-00_md-task'); const pending = runner.listTasks();
mkdirSync(failedDir, { recursive: true }); expect(pending).toHaveLength(1);
writeFileSync(join(failedDir, 'md-task.md'), '# Task\nDo something'); expect(pending[0]?.data?.start_movement).toBe('implement');
expect(pending[0]?.data?.retry_note).toBe('retry note');
});
const result = runner.requeueFailedTask(failedDir, 'implement'); it('should delete pending and failed tasks', () => {
const pending = runner.addTask('Task A');
runner.deletePendingTask(pending.name);
expect(runner.listTasks()).toHaveLength(0);
const content = readFileSync(result, 'utf-8'); const failed = runner.addTask('Task B');
// Markdown files should not have start_movement added const running = runner.claimNextTasks(1)[0]!;
expect(content).toBe('# Task\nDo something'); runner.failTask({
expect(content).not.toContain('start_movement'); task: running,
}); success: false,
response: 'Boom',
it('should throw error when no task file found', () => { executionLog: [],
runner.ensureDirs(); startedAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
const failedDir = join(testDir, '.takt', 'failed', '2026-01-31T12-00-00_no-task');
mkdirSync(failedDir, { recursive: true });
writeFileSync(join(failedDir, 'report.md'), '# Report');
expect(() => runner.requeueFailedTask(failedDir)).toThrow(
/No task file found in failed directory/
);
});
it('should throw error when failed directory does not exist', () => {
runner.ensureDirs();
expect(() => runner.requeueFailedTask('/nonexistent/path')).toThrow(
/Failed to read failed task directory/
);
});
it('should add retry_note to YAML task file when specified', () => {
runner.ensureDirs();
const failedDir = join(testDir, '.takt', 'failed', '2026-01-31T12-00-00_note-task');
mkdirSync(failedDir, { recursive: true });
writeFileSync(join(failedDir, 'note-task.yaml'), 'task: Task with note\n');
const result = runner.requeueFailedTask(failedDir, undefined, 'Fixed the ENOENT error');
const content = readFileSync(result, 'utf-8');
expect(content).toContain('retry_note: "Fixed the ENOENT error"');
expect(content).toContain('task: Task with note');
});
it('should escape double quotes in retry_note', () => {
runner.ensureDirs();
const failedDir = join(testDir, '.takt', 'failed', '2026-01-31T12-00-00_quote-task');
mkdirSync(failedDir, { recursive: true });
writeFileSync(join(failedDir, 'quote-task.yaml'), 'task: Task with quotes\n');
const result = runner.requeueFailedTask(failedDir, undefined, 'Fixed "spawn node ENOENT" error');
const content = readFileSync(result, 'utf-8');
expect(content).toContain('retry_note: "Fixed \\"spawn node ENOENT\\" error"');
});
it('should add both start_movement and retry_note when both specified', () => {
runner.ensureDirs();
const failedDir = join(testDir, '.takt', 'failed', '2026-01-31T12-00-00_both-task');
mkdirSync(failedDir, { recursive: true });
writeFileSync(join(failedDir, 'both-task.yaml'), 'task: Task with both\n');
const result = runner.requeueFailedTask(failedDir, 'implement', 'Retrying from implement');
const content = readFileSync(result, 'utf-8');
expect(content).toContain('start_movement: implement');
expect(content).toContain('retry_note: "Retrying from implement"');
});
it('should not add retry_note to markdown task files', () => {
runner.ensureDirs();
const failedDir = join(testDir, '.takt', 'failed', '2026-01-31T12-00-00_md-note-task');
mkdirSync(failedDir, { recursive: true });
writeFileSync(join(failedDir, 'md-note-task.md'), '# Task\nDo something');
const result = runner.requeueFailedTask(failedDir, undefined, 'Should be ignored');
const content = readFileSync(result, 'utf-8');
expect(content).toBe('# Task\nDo something');
expect(content).not.toContain('retry_note');
}); });
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();
}); });
}); });

View File

@ -1,10 +1,7 @@
/**
* Tests for taskDeleteActions pending/failed task deletion
*/
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import * as path from 'node:path'; import * as path from 'node:path';
import * as os from 'node:os'; import * as os from 'node:os';
import { stringify as stringifyYaml } from 'yaml';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
vi.mock('../shared/prompt/index.js', () => ({ vi.mock('../shared/prompt/index.js', () => ({
@ -35,6 +32,33 @@ const mockLogError = vi.mocked(logError);
let tmpDir: string; let tmpDir: string;
function setupTasksFile(projectDir: string): string {
const tasksFile = path.join(projectDir, '.takt', 'tasks.yaml');
fs.mkdirSync(path.dirname(tasksFile), { recursive: true });
fs.writeFileSync(tasksFile, stringifyYaml({
tasks: [
{
name: 'pending-task',
status: 'pending',
content: 'pending',
created_at: '2025-01-15T00:00:00.000Z',
started_at: null,
completed_at: null,
},
{
name: 'failed-task',
status: 'failed',
content: 'failed',
created_at: '2025-01-15T00:00:00.000Z',
started_at: '2025-01-15T00:01:00.000Z',
completed_at: '2025-01-15T00:02:00.000Z',
failure: { error: 'boom' },
},
],
}), 'utf-8');
return tasksFile;
}
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-test-delete-')); tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-test-delete-'));
@ -44,137 +68,59 @@ afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true }); fs.rmSync(tmpDir, { recursive: true, force: true });
}); });
describe('deletePendingTask', () => { describe('taskDeleteActions', () => {
it('should delete pending task file when confirmed', async () => { it('should delete pending task when confirmed', async () => {
// Given const tasksFile = setupTasksFile(tmpDir);
const filePath = path.join(tmpDir, 'my-task.md');
fs.writeFileSync(filePath, 'task content');
const task: TaskListItem = { const task: TaskListItem = {
kind: 'pending', kind: 'pending',
name: 'my-task', name: 'pending-task',
createdAt: '2025-01-15', createdAt: '2025-01-15',
filePath, filePath: tasksFile,
content: 'task content', content: 'pending',
}; };
mockConfirm.mockResolvedValue(true); mockConfirm.mockResolvedValue(true);
// When
const result = await deletePendingTask(task); const result = await deletePendingTask(task);
// Then
expect(result).toBe(true); expect(result).toBe(true);
expect(fs.existsSync(filePath)).toBe(false); const raw = fs.readFileSync(tasksFile, 'utf-8');
expect(mockSuccess).toHaveBeenCalledWith('Deleted pending task: my-task'); expect(raw).not.toContain('pending-task');
expect(mockSuccess).toHaveBeenCalledWith('Deleted pending task: pending-task');
}); });
it('should not delete when user declines confirmation', async () => { it('should delete failed task when confirmed', async () => {
// Given const tasksFile = setupTasksFile(tmpDir);
const filePath = path.join(tmpDir, 'my-task.md');
fs.writeFileSync(filePath, 'task content');
const task: TaskListItem = { const task: TaskListItem = {
kind: 'pending', kind: 'failed',
name: 'my-task', name: 'failed-task',
createdAt: '2025-01-15', createdAt: '2025-01-15T12:34:56',
filePath, filePath: tasksFile,
content: 'task content', content: 'failed',
}; };
mockConfirm.mockResolvedValue(false); mockConfirm.mockResolvedValue(true);
// When const result = await deleteFailedTask(task);
const result = await deletePendingTask(task);
// Then expect(result).toBe(true);
expect(result).toBe(false); const raw = fs.readFileSync(tasksFile, 'utf-8');
expect(fs.existsSync(filePath)).toBe(true); expect(raw).not.toContain('failed-task');
expect(mockSuccess).not.toHaveBeenCalled(); expect(mockSuccess).toHaveBeenCalledWith('Deleted failed task: failed-task');
}); });
it('should return false and show error when file does not exist', async () => { it('should return false when target task is missing', async () => {
// Given const tasksFile = setupTasksFile(tmpDir);
const filePath = path.join(tmpDir, 'non-existent.md');
const task: TaskListItem = { const task: TaskListItem = {
kind: 'pending', kind: 'failed',
name: 'non-existent', name: 'not-found',
createdAt: '2025-01-15', createdAt: '2025-01-15T12:34:56',
filePath, filePath: tasksFile,
content: '', content: '',
}; };
mockConfirm.mockResolvedValue(true); mockConfirm.mockResolvedValue(true);
// When const result = await deleteFailedTask(task);
const result = await deletePendingTask(task);
// Then
expect(result).toBe(false); expect(result).toBe(false);
expect(mockLogError).toHaveBeenCalled(); expect(mockLogError).toHaveBeenCalled();
expect(mockSuccess).not.toHaveBeenCalled();
});
});
describe('deleteFailedTask', () => {
it('should delete failed task directory when confirmed', async () => {
// Given
const dirPath = path.join(tmpDir, '2025-01-15T12-34-56_my-task');
fs.mkdirSync(dirPath, { recursive: true });
fs.writeFileSync(path.join(dirPath, 'my-task.md'), 'content');
const task: TaskListItem = {
kind: 'failed',
name: 'my-task',
createdAt: '2025-01-15T12:34:56',
filePath: dirPath,
content: 'content',
};
mockConfirm.mockResolvedValue(true);
// When
const result = await deleteFailedTask(task);
// Then
expect(result).toBe(true);
expect(fs.existsSync(dirPath)).toBe(false);
expect(mockSuccess).toHaveBeenCalledWith('Deleted failed task: my-task');
});
it('should not delete when user declines confirmation', async () => {
// Given
const dirPath = path.join(tmpDir, '2025-01-15T12-34-56_my-task');
fs.mkdirSync(dirPath, { recursive: true });
const task: TaskListItem = {
kind: 'failed',
name: 'my-task',
createdAt: '2025-01-15T12:34:56',
filePath: dirPath,
content: '',
};
mockConfirm.mockResolvedValue(false);
// When
const result = await deleteFailedTask(task);
// Then
expect(result).toBe(false);
expect(fs.existsSync(dirPath)).toBe(true);
expect(mockSuccess).not.toHaveBeenCalled();
});
it('should return false and show error when directory does not exist', async () => {
// Given
const dirPath = path.join(tmpDir, 'non-existent-dir');
const task: TaskListItem = {
kind: 'failed',
name: 'non-existent',
createdAt: '2025-01-15T12:34:56',
filePath: dirPath,
content: '',
};
mockConfirm.mockResolvedValue(true);
// When
const result = await deleteFailedTask(task);
// Then
expect(result).toBe(false);
expect(mockLogError).toHaveBeenCalled();
expect(mockSuccess).not.toHaveBeenCalled();
}); });
}); });

View File

@ -95,6 +95,9 @@ describe('resolveTaskExecution', () => {
name: 'simple-task', name: 'simple-task',
content: 'Simple task content', content: 'Simple task content',
filePath: '/tasks/simple-task.yaml', filePath: '/tasks/simple-task.yaml',
createdAt: '2026-02-09T00:00:00.000Z',
status: 'pending',
data: null,
}; };
// When // When

View File

@ -1,10 +1,7 @@
/**
* Tests for taskRetryActions failed task retry functionality
*/
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import * as path from 'node:path'; import * as path from 'node:path';
import * as os from 'node:os'; import * as os from 'node:os';
import { stringify as stringifyYaml } from 'yaml';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
vi.mock('../shared/prompt/index.js', () => ({ vi.mock('../shared/prompt/index.js', () => ({
@ -29,10 +26,6 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
}), }),
})); }));
vi.mock('../infra/fs/session.js', () => ({
extractFailureInfo: vi.fn(),
}));
vi.mock('../infra/config/index.js', () => ({ vi.mock('../infra/config/index.js', () => ({
loadGlobalConfig: vi.fn(), loadGlobalConfig: vi.fn(),
loadPieceByIdentifier: vi.fn(), loadPieceByIdentifier: vi.fn(),
@ -66,16 +59,37 @@ const defaultPieceConfig: PieceConfig = {
], ],
}; };
const customPieceConfig: PieceConfig = { function writeFailedTask(projectDir: string, name: string): TaskListItem {
name: 'custom', const tasksFile = path.join(projectDir, '.takt', 'tasks.yaml');
description: 'Custom piece', fs.mkdirSync(path.dirname(tasksFile), { recursive: true });
initialMovement: 'step1', fs.writeFileSync(tasksFile, stringifyYaml({
maxIterations: 10, tasks: [
movements: [ {
{ name: 'step1', persona: 'coder', instruction: '' }, name,
{ name: 'step2', persona: 'reviewer', instruction: '' }, status: 'failed',
], content: 'Do something',
}; created_at: '2025-01-15T12:00:00.000Z',
started_at: '2025-01-15T12:01:00.000Z',
completed_at: '2025-01-15T12:02:00.000Z',
piece: 'default',
failure: {
movement: 'review',
error: 'Boom',
},
},
],
}), 'utf-8');
return {
kind: 'failed',
name,
createdAt: '2025-01-15T12:02:00.000Z',
filePath: tasksFile,
content: 'Do something',
data: { task: 'Do something', piece: 'default' },
failure: { movement: 'review', error: 'Boom' },
};
}
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@ -88,264 +102,49 @@ afterEach(() => {
describe('retryFailedTask', () => { describe('retryFailedTask', () => {
it('should requeue task with selected movement', async () => { it('should requeue task with selected movement', async () => {
// Given: a failed task directory with a task file const task = writeFailedTask(tmpDir, 'my-task');
const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_my-task');
const tasksDir = path.join(tmpDir, '.takt', 'tasks');
fs.mkdirSync(failedDir, { recursive: true });
fs.mkdirSync(tasksDir, { recursive: true });
fs.writeFileSync(path.join(failedDir, 'my-task.yaml'), 'task: Do something\n');
const task: TaskListItem = {
kind: 'failed',
name: 'my-task',
createdAt: '2025-01-15T12:34:56',
filePath: failedDir,
content: 'Do something',
};
mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' }); mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' });
mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig); mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig);
mockSelectOption.mockResolvedValue('implement'); mockSelectOption.mockResolvedValue('implement');
mockPromptInput.mockResolvedValue(''); // Empty retry note mockPromptInput.mockResolvedValue('');
// When
const result = await retryFailedTask(task, tmpDir); const result = await retryFailedTask(task, tmpDir);
// Then
expect(result).toBe(true); expect(result).toBe(true);
expect(mockSuccess).toHaveBeenCalledWith('Task requeued: my-task'); expect(mockSuccess).toHaveBeenCalledWith('Task requeued: my-task');
// Verify requeued file const tasksYaml = fs.readFileSync(path.join(tmpDir, '.takt', 'tasks.yaml'), 'utf-8');
const requeuedFile = path.join(tasksDir, 'my-task.yaml'); expect(tasksYaml).toContain('status: pending');
expect(fs.existsSync(requeuedFile)).toBe(true); expect(tasksYaml).toContain('start_movement: implement');
const content = fs.readFileSync(requeuedFile, 'utf-8');
expect(content).toContain('start_movement: implement');
});
it('should use piece field from task file instead of defaultPiece', async () => {
// Given: a failed task with piece: custom in YAML
const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_custom-task');
const tasksDir = path.join(tmpDir, '.takt', 'tasks');
fs.mkdirSync(failedDir, { recursive: true });
fs.mkdirSync(tasksDir, { recursive: true });
fs.writeFileSync(
path.join(failedDir, 'custom-task.yaml'),
'task: Do something\npiece: custom\n',
);
const task: TaskListItem = {
kind: 'failed',
name: 'custom-task',
createdAt: '2025-01-15T12:34:56',
filePath: failedDir,
content: 'Do something',
};
mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' });
// Should be called with 'custom', not 'default'
mockLoadPieceByIdentifier.mockImplementation((name: string) => {
if (name === 'custom') return customPieceConfig;
if (name === 'default') return defaultPieceConfig;
return null;
});
mockSelectOption.mockResolvedValue('step2');
mockPromptInput.mockResolvedValue('');
// When
const result = await retryFailedTask(task, tmpDir);
// Then
expect(result).toBe(true);
expect(mockLoadPieceByIdentifier).toHaveBeenCalledWith('custom', tmpDir);
expect(mockSuccess).toHaveBeenCalledWith('Task requeued: custom-task');
});
it('should return false when user cancels movement selection', async () => {
// Given
const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_my-task');
fs.mkdirSync(failedDir, { recursive: true });
fs.writeFileSync(path.join(failedDir, 'my-task.yaml'), 'task: Do something\n');
const task: TaskListItem = {
kind: 'failed',
name: 'my-task',
createdAt: '2025-01-15T12:34:56',
filePath: failedDir,
content: 'Do something',
};
mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' });
mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig);
mockSelectOption.mockResolvedValue(null); // User cancelled
// No need to mock promptInput since user cancelled before reaching it
// When
const result = await retryFailedTask(task, tmpDir);
// Then
expect(result).toBe(false);
expect(mockSuccess).not.toHaveBeenCalled();
expect(mockPromptInput).not.toHaveBeenCalled();
});
it('should return false and show error when piece not found', async () => {
// Given
const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_my-task');
fs.mkdirSync(failedDir, { recursive: true });
fs.writeFileSync(path.join(failedDir, 'my-task.yaml'), 'task: Do something\n');
const task: TaskListItem = {
kind: 'failed',
name: 'my-task',
createdAt: '2025-01-15T12:34:56',
filePath: failedDir,
content: 'Do something',
};
mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'nonexistent' });
mockLoadPieceByIdentifier.mockReturnValue(null);
// When
const result = await retryFailedTask(task, tmpDir);
// Then
expect(result).toBe(false);
expect(mockLogError).toHaveBeenCalledWith(
'Piece "nonexistent" not found. Cannot determine available movements.',
);
});
it('should fallback to defaultPiece when task file has no piece field', async () => {
// Given: a failed task without piece field in YAML
const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_plain-task');
const tasksDir = path.join(tmpDir, '.takt', 'tasks');
fs.mkdirSync(failedDir, { recursive: true });
fs.mkdirSync(tasksDir, { recursive: true });
fs.writeFileSync(
path.join(failedDir, 'plain-task.yaml'),
'task: Do something without piece\n',
);
const task: TaskListItem = {
kind: 'failed',
name: 'plain-task',
createdAt: '2025-01-15T12:34:56',
filePath: failedDir,
content: 'Do something without piece',
};
mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' });
mockLoadPieceByIdentifier.mockImplementation((name: string) => {
if (name === 'default') return defaultPieceConfig;
return null;
});
mockSelectOption.mockResolvedValue('plan');
mockPromptInput.mockResolvedValue('');
// When
const result = await retryFailedTask(task, tmpDir);
// Then
expect(result).toBe(true);
expect(mockLoadPieceByIdentifier).toHaveBeenCalledWith('default', tmpDir);
}); });
it('should not add start_movement when initial movement is selected', async () => { it('should not add start_movement when initial movement is selected', async () => {
// Given const task = writeFailedTask(tmpDir, 'my-task');
const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_my-task');
const tasksDir = path.join(tmpDir, '.takt', 'tasks');
fs.mkdirSync(failedDir, { recursive: true });
fs.mkdirSync(tasksDir, { recursive: true });
fs.writeFileSync(path.join(failedDir, 'my-task.yaml'), 'task: Do something\n');
const task: TaskListItem = {
kind: 'failed',
name: 'my-task',
createdAt: '2025-01-15T12:34:56',
filePath: failedDir,
content: 'Do something',
};
mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' }); mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' });
mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig); mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig);
mockSelectOption.mockResolvedValue('plan'); // Initial movement mockSelectOption.mockResolvedValue('plan');
mockPromptInput.mockResolvedValue(''); // Empty retry note mockPromptInput.mockResolvedValue('');
// When
const result = await retryFailedTask(task, tmpDir); const result = await retryFailedTask(task, tmpDir);
// Then
expect(result).toBe(true); expect(result).toBe(true);
const tasksYaml = fs.readFileSync(path.join(tmpDir, '.takt', 'tasks.yaml'), 'utf-8');
// Verify requeued file does not have start_movement expect(tasksYaml).not.toContain('start_movement');
const requeuedFile = path.join(tasksDir, 'my-task.yaml');
const content = fs.readFileSync(requeuedFile, 'utf-8');
expect(content).not.toContain('start_movement');
}); });
it('should add retry_note when user provides one', async () => { it('should return false and show error when piece not found', async () => {
// Given const task = writeFailedTask(tmpDir, 'my-task');
const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_retry-note-task');
const tasksDir = path.join(tmpDir, '.takt', 'tasks');
fs.mkdirSync(failedDir, { recursive: true });
fs.mkdirSync(tasksDir, { recursive: true });
fs.writeFileSync(path.join(failedDir, 'retry-note-task.yaml'), 'task: Do something\n');
const task: TaskListItem = {
kind: 'failed',
name: 'retry-note-task',
createdAt: '2025-01-15T12:34:56',
filePath: failedDir,
content: 'Do something',
};
mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' }); mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' });
mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig); mockLoadPieceByIdentifier.mockReturnValue(null);
mockSelectOption.mockResolvedValue('implement');
mockPromptInput.mockResolvedValue('Fixed spawn node ENOENT error');
// When
const result = await retryFailedTask(task, tmpDir); const result = await retryFailedTask(task, tmpDir);
// Then expect(result).toBe(false);
expect(result).toBe(true); expect(mockLogError).toHaveBeenCalledWith(
'Piece "default" not found. Cannot determine available movements.',
const requeuedFile = path.join(tasksDir, 'retry-note-task.yaml'); );
const content = fs.readFileSync(requeuedFile, 'utf-8');
expect(content).toContain('start_movement: implement');
expect(content).toContain('retry_note: "Fixed spawn node ENOENT error"');
});
it('should not add retry_note when user skips it', async () => {
// Given
const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_no-note-task');
const tasksDir = path.join(tmpDir, '.takt', 'tasks');
fs.mkdirSync(failedDir, { recursive: true });
fs.mkdirSync(tasksDir, { recursive: true });
fs.writeFileSync(path.join(failedDir, 'no-note-task.yaml'), 'task: Do something\n');
const task: TaskListItem = {
kind: 'failed',
name: 'no-note-task',
createdAt: '2025-01-15T12:34:56',
filePath: failedDir,
content: 'Do something',
};
mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' });
mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig);
mockSelectOption.mockResolvedValue('implement');
mockPromptInput.mockResolvedValue(''); // Empty string - user skipped
// When
const result = await retryFailedTask(task, tmpDir);
// Then
expect(result).toBe(true);
const requeuedFile = path.join(tasksDir, 'no-note-task.yaml');
const content = fs.readFileSync(requeuedFile, 'utf-8');
expect(content).toContain('start_movement: implement');
expect(content).not.toContain('retry_note');
}); });
}); });

View File

@ -0,0 +1,89 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { TaskInfo } from '../infra/task/index.js';
const {
mockRecoverInterruptedRunningTasks,
mockGetTasksDir,
mockWatch,
mockStop,
mockExecuteAndCompleteTask,
mockInfo,
mockHeader,
mockBlankLine,
mockStatus,
mockSuccess,
mockGetCurrentPiece,
} = vi.hoisted(() => ({
mockRecoverInterruptedRunningTasks: vi.fn(),
mockGetTasksDir: vi.fn(),
mockWatch: vi.fn(),
mockStop: vi.fn(),
mockExecuteAndCompleteTask: vi.fn(),
mockInfo: vi.fn(),
mockHeader: vi.fn(),
mockBlankLine: vi.fn(),
mockStatus: vi.fn(),
mockSuccess: vi.fn(),
mockGetCurrentPiece: vi.fn(),
}));
vi.mock('../infra/task/index.js', () => ({
TaskRunner: vi.fn().mockImplementation(() => ({
recoverInterruptedRunningTasks: mockRecoverInterruptedRunningTasks,
getTasksDir: mockGetTasksDir,
})),
TaskWatcher: vi.fn().mockImplementation(() => ({
watch: mockWatch,
stop: mockStop,
})),
}));
vi.mock('../features/tasks/execute/taskExecution.js', () => ({
executeAndCompleteTask: mockExecuteAndCompleteTask,
}));
vi.mock('../shared/ui/index.js', () => ({
header: mockHeader,
info: mockInfo,
success: mockSuccess,
status: mockStatus,
blankLine: mockBlankLine,
}));
vi.mock('../infra/config/index.js', () => ({
getCurrentPiece: mockGetCurrentPiece,
}));
import { watchTasks } from '../features/tasks/watch/index.js';
describe('watchTasks', () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetCurrentPiece.mockReturnValue('default');
mockRecoverInterruptedRunningTasks.mockReturnValue(0);
mockGetTasksDir.mockReturnValue('/project/.takt/tasks.yaml');
mockExecuteAndCompleteTask.mockResolvedValue(true);
mockWatch.mockImplementation(async (onTask: (task: TaskInfo) => Promise<void>) => {
await onTask({
name: 'task-1',
content: 'Task 1',
filePath: '/project/.takt/tasks.yaml',
createdAt: '2026-02-09T00:00:00.000Z',
status: 'running',
data: null,
});
});
});
it('watch開始時に中断されたrunningタスクをpendingへ復旧する', async () => {
mockRecoverInterruptedRunningTasks.mockReturnValue(1);
await watchTasks('/project');
expect(mockRecoverInterruptedRunningTasks).toHaveBeenCalledTimes(1);
expect(mockInfo).toHaveBeenCalledWith('Recovered 1 interrupted running task(s) to pending.');
expect(mockWatch).toHaveBeenCalledTimes(1);
expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1);
});
});

View File

@ -1,24 +1,26 @@
/**
* TaskWatcher tests
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdirSync, writeFileSync, existsSync, rmSync } from 'node:fs'; import { mkdirSync, writeFileSync, existsSync, rmSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { stringify as stringifyYaml } from 'yaml';
import { TaskWatcher } from '../infra/task/watcher.js'; import { TaskWatcher } from '../infra/task/watcher.js';
import { TaskRunner } from '../infra/task/runner.js';
import type { TaskInfo } from '../infra/task/types.js'; import type { TaskInfo } from '../infra/task/types.js';
describe('TaskWatcher', () => { describe('TaskWatcher', () => {
const testDir = `/tmp/takt-watcher-test-${Date.now()}`; const testDir = `/tmp/takt-watcher-test-${Date.now()}`;
let watcher: TaskWatcher | null = null; let watcher: TaskWatcher | null = null;
function writeTasksYaml(tasks: Array<Record<string, unknown>>): void {
const tasksFile = join(testDir, '.takt', 'tasks.yaml');
mkdirSync(join(testDir, '.takt'), { recursive: true });
writeFileSync(tasksFile, stringifyYaml({ tasks }), 'utf-8');
}
beforeEach(() => { beforeEach(() => {
mkdirSync(join(testDir, '.takt', 'tasks'), { recursive: true }); mkdirSync(join(testDir, '.takt'), { recursive: true });
mkdirSync(join(testDir, '.takt', 'completed'), { recursive: true });
}); });
afterEach(() => { afterEach(() => {
// Ensure watcher is stopped before cleanup
if (watcher) { if (watcher) {
watcher.stop(); watcher.stop();
watcher = null; watcher = null;
@ -41,21 +43,24 @@ describe('TaskWatcher', () => {
}); });
describe('watch', () => { describe('watch', () => {
it('should detect and process a task file', async () => { it('should detect and process a pending task from tasks.yaml', async () => {
writeTasksYaml([
{
name: 'test-task',
status: 'pending',
content: 'Test task content',
created_at: '2026-02-09T00:00:00.000Z',
started_at: null,
completed_at: null,
},
]);
watcher = new TaskWatcher(testDir, { pollInterval: 50 }); watcher = new TaskWatcher(testDir, { pollInterval: 50 });
const processed: string[] = []; const processed: string[] = [];
// Pre-create a task file
writeFileSync(
join(testDir, '.takt', 'tasks', 'test-task.md'),
'Test task content'
);
// Start watching, stop after first task
const watchPromise = watcher.watch(async (task: TaskInfo) => { const watchPromise = watcher.watch(async (task: TaskInfo) => {
processed.push(task.name); processed.push(task.name);
// Stop after processing to avoid infinite loop in test watcher?.stop();
watcher.stop();
}); });
await watchPromise; await watchPromise;
@ -64,48 +69,61 @@ describe('TaskWatcher', () => {
expect(watcher.isRunning()).toBe(false); expect(watcher.isRunning()).toBe(false);
}); });
it('should wait when no tasks are available', async () => { it('should wait when no tasks are available and then process added task', async () => {
writeTasksYaml([]);
watcher = new TaskWatcher(testDir, { pollInterval: 50 }); watcher = new TaskWatcher(testDir, { pollInterval: 50 });
let pollCount = 0; const runner = new TaskRunner(testDir);
let processed = 0;
// Start watching, add a task after a delay const watchPromise = watcher.watch(async () => {
const watchPromise = watcher.watch(async (task: TaskInfo) => { processed++;
pollCount++; watcher?.stop();
watcher.stop();
}); });
// Add task after short delay (after at least one empty poll)
await new Promise((resolve) => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
writeFileSync( runner.addTask('Delayed task');
join(testDir, '.takt', 'tasks', 'delayed-task.md'),
'Delayed task'
);
await watchPromise; await watchPromise;
expect(pollCount).toBe(1); expect(processed).toBe(1);
}); });
it('should process multiple tasks sequentially', async () => { it('should process multiple tasks sequentially', async () => {
writeTasksYaml([
{
name: 'a-task',
status: 'pending',
content: 'First task',
created_at: '2026-02-09T00:00:00.000Z',
started_at: null,
completed_at: null,
},
{
name: 'b-task',
status: 'pending',
content: 'Second task',
created_at: '2026-02-09T00:01:00.000Z',
started_at: null,
completed_at: null,
},
]);
const runner = new TaskRunner(testDir);
watcher = new TaskWatcher(testDir, { pollInterval: 50 }); watcher = new TaskWatcher(testDir, { pollInterval: 50 });
const processed: string[] = []; const processed: string[] = [];
// Pre-create two task files
writeFileSync(
join(testDir, '.takt', 'tasks', 'a-task.md'),
'First task'
);
writeFileSync(
join(testDir, '.takt', 'tasks', 'b-task.md'),
'Second task'
);
const watchPromise = watcher.watch(async (task: TaskInfo) => { const watchPromise = watcher.watch(async (task: TaskInfo) => {
processed.push(task.name); processed.push(task.name);
// Remove the task file to simulate completion runner.completeTask({
rmSync(task.filePath); task,
success: true,
response: 'Done',
executionLog: [],
startedAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
});
if (processed.length >= 2) { if (processed.length >= 2) {
watcher.stop(); watcher?.stop();
} }
}); });
@ -117,15 +135,13 @@ describe('TaskWatcher', () => {
describe('stop', () => { describe('stop', () => {
it('should stop the watcher gracefully', async () => { it('should stop the watcher gracefully', async () => {
writeTasksYaml([]);
watcher = new TaskWatcher(testDir, { pollInterval: 50 }); watcher = new TaskWatcher(testDir, { pollInterval: 50 });
// Start watching, stop after a short delay
const watchPromise = watcher.watch(async () => { const watchPromise = watcher.watch(async () => {
// Should not be called since no tasks
}); });
// Stop after short delay setTimeout(() => watcher?.stop(), 100);
setTimeout(() => watcher.stop(), 100);
await watchPromise; await watchPromise;
@ -133,18 +149,17 @@ describe('TaskWatcher', () => {
}); });
it('should abort sleep immediately when stopped', async () => { it('should abort sleep immediately when stopped', async () => {
writeTasksYaml([]);
watcher = new TaskWatcher(testDir, { pollInterval: 10000 }); watcher = new TaskWatcher(testDir, { pollInterval: 10000 });
const start = Date.now(); const start = Date.now();
const watchPromise = watcher.watch(async () => {}); const watchPromise = watcher.watch(async () => {});
// Stop after 50ms, should not wait the full 10s setTimeout(() => watcher?.stop(), 50);
setTimeout(() => watcher.stop(), 50);
await watchPromise; await watchPromise;
const elapsed = Date.now() - start; const elapsed = Date.now() - start;
// Should complete well under the 10s poll interval
expect(elapsed).toBeLessThan(1000); expect(elapsed).toBeLessThan(1000);
}); });
}); });

View File

@ -2,15 +2,13 @@
* add command implementation * add command implementation
* *
* Starts an AI conversation to refine task requirements, * Starts an AI conversation to refine task requirements,
* then creates a task file in .takt/tasks/ with YAML format. * then appends a task record to .takt/tasks.yaml.
*/ */
import * as fs from 'node:fs';
import * as path from 'node:path'; import * as path from 'node:path';
import { stringify as stringifyYaml } from 'yaml';
import { promptInput, confirm } from '../../../shared/prompt/index.js'; import { promptInput, confirm } from '../../../shared/prompt/index.js';
import { success, info, error } from '../../../shared/ui/index.js'; import { success, info, error } from '../../../shared/ui/index.js';
import { summarizeTaskName, type TaskFileData } from '../../../infra/task/index.js'; import { TaskRunner, type TaskFileData } from '../../../infra/task/index.js';
import { getPieceDescription, loadGlobalConfig } from '../../../infra/config/index.js'; import { getPieceDescription, loadGlobalConfig } from '../../../infra/config/index.js';
import { determinePiece } from '../execute/selectAndExecute.js'; import { determinePiece } from '../execute/selectAndExecute.js';
import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; import { createLogger, getErrorMessage } from '../../../shared/utils/index.js';
@ -19,23 +17,8 @@ import { interactiveMode } from '../../interactive/index.js';
const log = createLogger('add-task'); const log = createLogger('add-task');
async function generateFilename(tasksDir: string, taskContent: string, cwd: string): Promise<string> {
info('Generating task filename...');
const slug = await summarizeTaskName(taskContent, { cwd });
const base = slug || 'task';
let filename = `${base}.yaml`;
let counter = 1;
while (fs.existsSync(path.join(tasksDir, filename))) {
filename = `${base}-${counter}.yaml`;
counter++;
}
return filename;
}
/** /**
* Save a task file to .takt/tasks/ with YAML format. * Save a task entry to .takt/tasks.yaml.
* *
* Common logic extracted from addTask(). Used by both addTask() * Common logic extracted from addTask(). Used by both addTask()
* and saveTaskFromInteractive(). * and saveTaskFromInteractive().
@ -44,29 +27,19 @@ export async function saveTaskFile(
cwd: string, cwd: string,
taskContent: string, taskContent: string,
options?: { piece?: string; issue?: number; worktree?: boolean | string; branch?: string; autoPr?: boolean }, options?: { piece?: string; issue?: number; worktree?: boolean | string; branch?: string; autoPr?: boolean },
): Promise<string> { ): Promise<{ taskName: string; tasksFile: string }> {
const tasksDir = path.join(cwd, '.takt', 'tasks'); const runner = new TaskRunner(cwd);
fs.mkdirSync(tasksDir, { recursive: true }); const config: Omit<TaskFileData, 'task'> = {
const firstLine = taskContent.split('\n')[0] || taskContent;
const filename = await generateFilename(tasksDir, firstLine, cwd);
const taskData: TaskFileData = {
task: taskContent,
...(options?.worktree !== undefined && { worktree: options.worktree }), ...(options?.worktree !== undefined && { worktree: options.worktree }),
...(options?.branch && { branch: options.branch }), ...(options?.branch && { branch: options.branch }),
...(options?.piece && { piece: options.piece }), ...(options?.piece && { piece: options.piece }),
...(options?.issue !== undefined && { issue: options.issue }), ...(options?.issue !== undefined && { issue: options.issue }),
...(options?.autoPr !== undefined && { auto_pr: options.autoPr }), ...(options?.autoPr !== undefined && { auto_pr: options.autoPr }),
}; };
const created = runner.addTask(taskContent, config);
const filePath = path.join(tasksDir, filename); const tasksFile = path.join(cwd, '.takt', 'tasks.yaml');
const yamlContent = stringifyYaml(taskData); log.info('Task created', { taskName: created.name, tasksFile, config });
fs.writeFileSync(filePath, yamlContent, 'utf-8'); return { taskName: created.name, tasksFile };
log.info('Task created', { filePath, taskData });
return filePath;
} }
/** /**
@ -120,10 +93,9 @@ export async function saveTaskFromInteractive(
piece?: string, piece?: string,
): Promise<void> { ): Promise<void> {
const settings = await promptWorktreeSettings(); const settings = await promptWorktreeSettings();
const filePath = await saveTaskFile(cwd, task, { piece, ...settings }); const created = await saveTaskFile(cwd, task, { piece, ...settings });
const filename = path.basename(filePath); success(`Task created: ${created.taskName}`);
success(`Task created: ${filename}`); info(` File: ${created.tasksFile}`);
info(` Path: ${filePath}`);
if (settings.worktree) { if (settings.worktree) {
info(` Worktree: ${typeof settings.worktree === 'string' ? settings.worktree : 'auto'}`); info(` Worktree: ${typeof settings.worktree === 'string' ? settings.worktree : 'auto'}`);
} }
@ -144,9 +116,6 @@ export async function saveTaskFromInteractive(
* B) それ以外: ピース選択 AI対話モード YAML作成 * B) それ以外: ピース選択 AI対話モード YAML作成
*/ */
export async function addTask(cwd: string, task?: string): Promise<void> { export async function addTask(cwd: string, task?: string): Promise<void> {
const tasksDir = path.join(cwd, '.takt', 'tasks');
fs.mkdirSync(tasksDir, { recursive: true });
// ピース選択とタスク内容の決定 // ピース選択とタスク内容の決定
let taskContent: string; let taskContent: string;
let issueNumber: number | undefined; let issueNumber: number | undefined;
@ -209,15 +178,14 @@ export async function addTask(cwd: string, task?: string): Promise<void> {
const settings = await promptWorktreeSettings(); const settings = await promptWorktreeSettings();
// YAMLファイル作成 // YAMLファイル作成
const filePath = await saveTaskFile(cwd, taskContent, { const created = await saveTaskFile(cwd, taskContent, {
piece, piece,
issue: issueNumber, issue: issueNumber,
...settings, ...settings,
}); });
const filename = path.basename(filePath); success(`Task created: ${created.taskName}`);
success(`Task created: ${filename}`); info(` File: ${created.tasksFile}`);
info(` Path: ${filePath}`);
if (settings.worktree) { if (settings.worktree) {
info(` Worktree: ${typeof settings.worktree === 'string' ? settings.worktree : 'auto'}`); info(` Worktree: ${typeof settings.worktree === 'string' ? settings.worktree : 'auto'}`);
} }

View File

@ -647,6 +647,8 @@ export async function executePiece(
return { return {
success: finalState.status === 'completed', success: finalState.status === 'completed',
reason: abortReason, reason: abortReason,
lastMovement: lastMovementName,
lastMessage: lastMovementContent,
}; };
} finally { } finally {
prefixWriter?.flush(); prefixWriter?.flush();

View File

@ -29,7 +29,6 @@ export async function resolveTaskExecution(
defaultPiece: string, defaultPiece: string,
): Promise<ResolvedTaskExecution> { ): Promise<ResolvedTaskExecution> {
const data = task.data; const data = task.data;
if (!data) { if (!data) {
return { execCwd: defaultCwd, execPiece: defaultPiece, isWorktree: false }; return { execCwd: defaultCwd, execPiece: defaultPiece, isWorktree: false };
} }

View File

@ -15,7 +15,7 @@ import {
import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; import { createLogger, getErrorMessage } from '../../../shared/utils/index.js';
import { executePiece } from './pieceExecution.js'; import { executePiece } from './pieceExecution.js';
import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js'; import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js';
import type { TaskExecutionOptions, ExecuteTaskOptions } from './types.js'; import type { TaskExecutionOptions, ExecuteTaskOptions, PieceExecutionResult } from './types.js';
import { createPullRequest, buildPrBody, pushBranch, fetchIssue, checkGhCli } from '../../../infra/github/index.js'; import { createPullRequest, buildPrBody, pushBranch, fetchIssue, checkGhCli } from '../../../infra/github/index.js';
import { runWithWorkerPool } from './parallelExecution.js'; import { runWithWorkerPool } from './parallelExecution.js';
import { resolveTaskExecution } from './resolveTask.js'; import { resolveTaskExecution } from './resolveTask.js';
@ -48,22 +48,20 @@ function resolveTaskIssue(issueNumber: number | undefined): ReturnType<typeof fe
} }
} }
/** async function executeTaskWithResult(options: ExecuteTaskOptions): Promise<PieceExecutionResult> {
* Execute a single task with piece.
*/
export async function executeTask(options: ExecuteTaskOptions): Promise<boolean> {
const { task, cwd, pieceIdentifier, projectCwd, agentOverrides, interactiveUserInput, interactiveMetadata, startMovement, retryNote, abortSignal, taskPrefix, taskColorIndex } = options; const { task, cwd, pieceIdentifier, projectCwd, agentOverrides, interactiveUserInput, interactiveMetadata, startMovement, retryNote, abortSignal, taskPrefix, taskColorIndex } = options;
const pieceConfig = loadPieceByIdentifier(pieceIdentifier, projectCwd); const pieceConfig = loadPieceByIdentifier(pieceIdentifier, projectCwd);
if (!pieceConfig) { if (!pieceConfig) {
if (isPiecePath(pieceIdentifier)) { if (isPiecePath(pieceIdentifier)) {
error(`Piece file not found: ${pieceIdentifier}`); error(`Piece file not found: ${pieceIdentifier}`);
return { success: false, reason: `Piece file not found: ${pieceIdentifier}` };
} else { } else {
error(`Piece "${pieceIdentifier}" not found.`); error(`Piece "${pieceIdentifier}" not found.`);
info('Available pieces are in ~/.takt/pieces/ or .takt/pieces/'); info('Available pieces are in ~/.takt/pieces/ or .takt/pieces/');
info('Use "takt switch" to select a piece.'); info('Use "takt switch" to select a piece.');
return { success: false, reason: `Piece "${pieceIdentifier}" not found.` };
} }
return false;
} }
log.debug('Running piece', { log.debug('Running piece', {
@ -72,7 +70,7 @@ export async function executeTask(options: ExecuteTaskOptions): Promise<boolean>
}); });
const globalConfig = loadGlobalConfig(); const globalConfig = loadGlobalConfig();
const result = await executePiece(pieceConfig, task, cwd, { return await executePiece(pieceConfig, task, cwd, {
projectCwd, projectCwd,
language: globalConfig.language, language: globalConfig.language,
provider: agentOverrides?.provider, provider: agentOverrides?.provider,
@ -86,6 +84,13 @@ export async function executeTask(options: ExecuteTaskOptions): Promise<boolean>
taskPrefix, taskPrefix,
taskColorIndex, taskColorIndex,
}); });
}
/**
* Execute a single task with piece.
*/
export async function executeTask(options: ExecuteTaskOptions): Promise<boolean> {
const result = await executeTaskWithResult(options);
return result.success; return result.success;
} }
@ -106,7 +111,6 @@ export async function executeAndCompleteTask(
parallelOptions?: { abortSignal?: AbortSignal; taskPrefix?: string; taskColorIndex?: number }, parallelOptions?: { abortSignal?: AbortSignal; taskPrefix?: string; taskColorIndex?: number },
): Promise<boolean> { ): Promise<boolean> {
const startedAt = new Date().toISOString(); const startedAt = new Date().toISOString();
const executionLog: string[] = [];
const taskAbortController = new AbortController(); const taskAbortController = new AbortController();
const externalAbortSignal = parallelOptions?.abortSignal; const externalAbortSignal = parallelOptions?.abortSignal;
const taskAbortSignal = externalAbortSignal ? taskAbortController.signal : undefined; const taskAbortSignal = externalAbortSignal ? taskAbortController.signal : undefined;
@ -127,7 +131,7 @@ export async function executeAndCompleteTask(
const { execCwd, execPiece, isWorktree, branch, baseBranch, startMovement, retryNote, autoPr, issueNumber } = await resolveTaskExecution(task, cwd, pieceName); const { execCwd, execPiece, isWorktree, branch, baseBranch, startMovement, retryNote, autoPr, issueNumber } = await resolveTaskExecution(task, cwd, pieceName);
// cwd is always the project root; pass it as projectCwd so reports/sessions go there // cwd is always the project root; pass it as projectCwd so reports/sessions go there
const taskRunPromise = executeTask({ const taskRunResult = await executeTaskWithResult({
task: task.content, task: task.content,
cwd: execCwd, cwd: execCwd,
pieceIdentifier: execPiece, pieceIdentifier: execPiece,
@ -140,7 +144,11 @@ export async function executeAndCompleteTask(
taskColorIndex: parallelOptions?.taskColorIndex, taskColorIndex: parallelOptions?.taskColorIndex,
}); });
const taskSuccess = await taskRunPromise; if (!taskRunResult.success && !taskRunResult.reason) {
throw new Error('Task failed without reason');
}
const taskSuccess = taskRunResult.success;
const completedAt = new Date().toISOString(); const completedAt = new Date().toISOString();
if (taskSuccess && isWorktree) { if (taskSuccess && isWorktree) {
@ -180,8 +188,10 @@ export async function executeAndCompleteTask(
const taskResult = { const taskResult = {
task, task,
success: taskSuccess, success: taskSuccess,
response: taskSuccess ? 'Task completed successfully' : 'Task failed', response: taskSuccess ? 'Task completed successfully' : taskRunResult.reason!,
executionLog, executionLog: taskRunResult.lastMessage ? [taskRunResult.lastMessage] : [],
failureMovement: taskRunResult.lastMovement,
failureLastMessage: taskRunResult.lastMessage,
startedAt, startedAt,
completedAt, completedAt,
}; };
@ -202,7 +212,7 @@ export async function executeAndCompleteTask(
task, task,
success: false, success: false,
response: getErrorMessage(err), response: getErrorMessage(err),
executionLog, executionLog: [],
startedAt, startedAt,
completedAt, completedAt,
}); });
@ -230,12 +240,16 @@ export async function runAllTasks(
const taskRunner = new TaskRunner(cwd); const taskRunner = new TaskRunner(cwd);
const globalConfig = loadGlobalConfig(); const globalConfig = loadGlobalConfig();
const concurrency = globalConfig.concurrency; const concurrency = globalConfig.concurrency;
const recovered = taskRunner.recoverInterruptedRunningTasks();
if (recovered > 0) {
info(`Recovered ${recovered} interrupted running task(s) to pending.`);
}
const initialTasks = taskRunner.claimNextTasks(concurrency); const initialTasks = taskRunner.claimNextTasks(concurrency);
if (initialTasks.length === 0) { if (initialTasks.length === 0) {
info('No pending tasks in .takt/tasks/'); info('No pending tasks in .takt/tasks.yaml');
info('Create task files as .takt/tasks/*.yaml or use takt add'); info('Use takt add to append tasks.');
return; return;
} }

View File

@ -10,6 +10,8 @@ import type { GitHubIssue } from '../../../infra/github/index.js';
export interface PieceExecutionResult { export interface PieceExecutionResult {
success: boolean; success: boolean;
reason?: string; reason?: string;
lastMovement?: string;
lastMessage?: string;
} }
/** Metadata from interactive mode, passed through to NDJSON logging */ /** Metadata from interactive mode, passed through to NDJSON logging */

View File

@ -37,16 +37,21 @@ export type ListAction = 'diff' | 'instruct' | 'try' | 'merge' | 'delete';
* Check if a branch has already been merged into HEAD. * Check if a branch has already been merged into HEAD.
*/ */
export function isBranchMerged(projectDir: string, branch: string): boolean { export function isBranchMerged(projectDir: string, branch: string): boolean {
try { const result = spawnSync('git', ['merge-base', '--is-ancestor', branch, 'HEAD'], {
execFileSync('git', ['merge-base', '--is-ancestor', branch, 'HEAD'], { cwd: projectDir,
cwd: projectDir, encoding: 'utf-8',
encoding: 'utf-8', stdio: 'pipe',
stdio: 'pipe', });
if (result.error) {
log.error('Failed to check if branch is merged', {
branch,
error: getErrorMessage(result.error),
}); });
return true;
} catch {
return false; return false;
} }
return result.status === 0;
} }
/** /**
@ -70,8 +75,13 @@ export function showFullDiff(
if (result.status !== 0) { if (result.status !== 0) {
warn('Could not display diff'); warn('Could not display diff');
} }
} catch { } catch (err) {
warn('Could not display diff'); warn('Could not display diff');
log.error('Failed to display full diff', {
branch,
defaultBranch,
error: getErrorMessage(err),
});
} }
} }
@ -95,8 +105,13 @@ export async function showDiffAndPromptAction(
{ cwd, encoding: 'utf-8', stdio: 'pipe' }, { cwd, encoding: 'utf-8', stdio: 'pipe' },
); );
info(stat); info(stat);
} catch { } catch (err) {
warn('Could not generate diff stat'); warn('Could not generate diff stat');
log.error('Failed to generate diff stat', {
branch: item.info.branch,
defaultBranch,
error: getErrorMessage(err),
});
} }
const action = await selectOption<ListAction>( const action = await selectOption<ListAction>(
@ -168,8 +183,12 @@ export function mergeBranch(projectDir: string, item: BranchListItem): boolean {
encoding: 'utf-8', encoding: 'utf-8',
stdio: 'pipe', stdio: 'pipe',
}); });
} catch { } catch (err) {
warn(`Could not delete branch ${branch}. You may delete it manually.`); warn(`Could not delete branch ${branch}. You may delete it manually.`);
log.error('Failed to delete merged branch', {
branch,
error: getErrorMessage(err),
});
} }
cleanupOrphanedClone(projectDir, branch); cleanupOrphanedClone(projectDir, branch);
@ -276,8 +295,12 @@ function getBranchContext(projectDir: string, branch: string): string {
lines.push(diffStat); lines.push(diffStat);
lines.push('```'); lines.push('```');
} }
} catch { } catch (err) {
// Ignore errors log.debug('Failed to collect branch diff stat for instruction context', {
branch,
defaultBranch,
error: getErrorMessage(err),
});
} }
try { try {
@ -292,8 +315,12 @@ function getBranchContext(projectDir: string, branch: string): string {
lines.push(commitLog); lines.push(commitLog);
lines.push('```'); lines.push('```');
} }
} catch { } catch (err) {
// Ignore errors log.debug('Failed to collect branch commit log for instruction context', {
branch,
defaultBranch,
error: getErrorMessage(err),
});
} }
return lines.length > 0 ? lines.join('\n') + '\n\n' : ''; return lines.length > 0 ? lines.join('\n') + '\n\n' : '';
@ -361,4 +388,3 @@ export async function instructBranch(
removeCloneMeta(projectDir, branch); removeCloneMeta(projectDir, branch);
} }
} }

View File

@ -2,17 +2,22 @@
* Delete actions for pending and failed tasks. * Delete actions for pending and failed tasks.
* *
* Provides interactive deletion (with confirm prompt) * Provides interactive deletion (with confirm prompt)
* for pending task files and failed task directories. * for pending/failed tasks in .takt/tasks.yaml.
*/ */
import { rmSync, unlinkSync } from 'node:fs'; import { dirname } from 'node:path';
import type { TaskListItem } from '../../../infra/task/index.js'; import type { TaskListItem } from '../../../infra/task/index.js';
import { TaskRunner } from '../../../infra/task/index.js';
import { confirm } from '../../../shared/prompt/index.js'; import { confirm } from '../../../shared/prompt/index.js';
import { success, error as logError } from '../../../shared/ui/index.js'; import { success, error as logError } from '../../../shared/ui/index.js';
import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; import { createLogger, getErrorMessage } from '../../../shared/utils/index.js';
const log = createLogger('list-tasks'); const log = createLogger('list-tasks');
function getProjectDir(task: TaskListItem): string {
return dirname(dirname(task.filePath));
}
/** /**
* Delete a pending task file. * Delete a pending task file.
* Prompts user for confirmation first. * Prompts user for confirmation first.
@ -21,7 +26,8 @@ export async function deletePendingTask(task: TaskListItem): Promise<boolean> {
const confirmed = await confirm(`Delete pending task "${task.name}"?`, false); const confirmed = await confirm(`Delete pending task "${task.name}"?`, false);
if (!confirmed) return false; if (!confirmed) return false;
try { try {
unlinkSync(task.filePath); const runner = new TaskRunner(getProjectDir(task));
runner.deletePendingTask(task.name);
} catch (err) { } catch (err) {
const msg = getErrorMessage(err); const msg = getErrorMessage(err);
logError(`Failed to delete pending task "${task.name}": ${msg}`); logError(`Failed to delete pending task "${task.name}": ${msg}`);
@ -38,10 +44,11 @@ export async function deletePendingTask(task: TaskListItem): Promise<boolean> {
* Prompts user for confirmation first. * Prompts user for confirmation first.
*/ */
export async function deleteFailedTask(task: TaskListItem): Promise<boolean> { export async function deleteFailedTask(task: TaskListItem): Promise<boolean> {
const confirmed = await confirm(`Delete failed task "${task.name}" and its logs?`, false); const confirmed = await confirm(`Delete failed task "${task.name}"?`, false);
if (!confirmed) return false; if (!confirmed) return false;
try { try {
rmSync(task.filePath, { recursive: true }); const runner = new TaskRunner(getProjectDir(task));
runner.deleteFailedTask(task.name);
} catch (err) { } catch (err) {
const msg = getErrorMessage(err); const msg = getErrorMessage(err);
logError(`Failed to delete failed task "${task.name}": ${msg}`); logError(`Failed to delete failed task "${task.name}": ${msg}`);

View File

@ -5,11 +5,8 @@
* failure info display and movement selection. * failure info display and movement selection.
*/ */
import { join } from 'node:path';
import { existsSync, readdirSync } from 'node:fs';
import type { TaskListItem } from '../../../infra/task/index.js'; import type { TaskListItem } from '../../../infra/task/index.js';
import { TaskRunner, parseTaskFile, type TaskFileData } from '../../../infra/task/index.js'; import { TaskRunner } from '../../../infra/task/index.js';
import { extractFailureInfo, type FailureInfo } from '../../../infra/fs/session.js';
import { loadPieceByIdentifier, loadGlobalConfig } from '../../../infra/config/index.js'; import { loadPieceByIdentifier, loadGlobalConfig } from '../../../infra/config/index.js';
import { selectOption, promptInput } from '../../../shared/prompt/index.js'; import { selectOption, promptInput } from '../../../shared/prompt/index.js';
import { success, error as logError, info, header, blankLine, status } from '../../../shared/ui/index.js'; import { success, error as logError, info, header, blankLine, status } from '../../../shared/ui/index.js';
@ -18,121 +15,30 @@ import type { PieceConfig } from '../../../core/models/index.js';
const log = createLogger('list-tasks'); const log = createLogger('list-tasks');
/** function displayFailureInfo(task: TaskListItem): void {
* Find the session log file path from a failed task directory.
* Looks in .takt/logs/ for a matching session ID from log.json.
*/
function findSessionLogPath(failedTaskDir: string, projectDir: string): string | null {
const logsDir = join(projectDir, '.takt', 'logs');
if (!existsSync(logsDir)) return null;
// Try to find the log file
// Failed tasks don't have sessionId in log.json by default,
// so we look for the most recent log file that matches the failure time
const logJsonPath = join(failedTaskDir, 'log.json');
if (!existsSync(logJsonPath)) return null;
try {
// List all .jsonl files in logs dir
const logFiles = readdirSync(logsDir).filter((f) => f.endsWith('.jsonl'));
if (logFiles.length === 0) return null;
// Get the failed task timestamp from directory name
const dirName = failedTaskDir.split('/').pop();
if (!dirName) return null;
const underscoreIdx = dirName.indexOf('_');
if (underscoreIdx === -1) return null;
const timestampRaw = dirName.slice(0, underscoreIdx);
// Convert format: 2026-01-31T12-00-00 -> 20260131-120000
const normalizedTimestamp = timestampRaw
.replace(/-/g, '')
.replace('T', '-');
// Find logs that match the date (first 8 chars of normalized timestamp)
const datePrefix = normalizedTimestamp.slice(0, 8);
const matchingLogs = logFiles
.filter((f) => f.startsWith(datePrefix))
.sort()
.reverse(); // Most recent first
// Return the most recent matching log
if (matchingLogs.length > 0) {
return join(logsDir, matchingLogs[0]!);
}
} catch {
// Ignore errors
}
return null;
}
/**
* Find and parse the task file from a failed task directory.
* Returns the parsed TaskFileData if found, null otherwise.
*/
function parseFailedTaskFile(failedTaskDir: string): TaskFileData | null {
const taskExtensions = ['.yaml', '.yml', '.md'];
let files: string[];
try {
files = readdirSync(failedTaskDir);
} catch {
return null;
}
for (const file of files) {
const ext = file.slice(file.lastIndexOf('.'));
if (file === 'report.md' || file === 'log.json') continue;
if (!taskExtensions.includes(ext)) continue;
try {
const taskFilePath = join(failedTaskDir, file);
const parsed = parseTaskFile(taskFilePath);
return parsed.data;
} catch {
continue;
}
}
return null;
}
/**
* Display failure information for a failed task.
*/
function displayFailureInfo(task: TaskListItem, failureInfo: FailureInfo | null): void {
header(`Failed Task: ${task.name}`); header(`Failed Task: ${task.name}`);
info(` Failed at: ${task.createdAt}`); info(` Failed at: ${task.createdAt}`);
if (failureInfo) { if (task.failure) {
blankLine(); blankLine();
if (failureInfo.lastCompletedMovement) { if (task.failure.movement) {
status('Last completed', failureInfo.lastCompletedMovement); status('Failed at', task.failure.movement, 'red');
} }
if (failureInfo.failedMovement) { status('Error', task.failure.error, 'red');
status('Failed at', failureInfo.failedMovement, 'red'); if (task.failure.last_message) {
status('Last message', task.failure.last_message);
} }
status('Iterations', String(failureInfo.iterations));
if (failureInfo.errorMessage) {
status('Error', failureInfo.errorMessage, 'red');
}
} else {
blankLine();
info(' (No session log found - failure details unavailable)');
} }
blankLine(); blankLine();
} }
/**
* Prompt user to select a movement to start from.
* Returns the selected movement name, or null if cancelled.
*/
async function selectStartMovement( async function selectStartMovement(
pieceConfig: PieceConfig, pieceConfig: PieceConfig,
defaultMovement: string | null, defaultMovement: string | null,
): Promise<string | null> { ): Promise<string | null> {
const movements = pieceConfig.movements.map((m) => m.name); const movements = pieceConfig.movements.map((m) => m.name);
// Determine default selection
const defaultIdx = defaultMovement const defaultIdx = defaultMovement
? movements.indexOf(defaultMovement) ? movements.indexOf(defaultMovement)
: 0; : 0;
@ -149,7 +55,6 @@ async function selectStartMovement(
/** /**
* Retry a failed task. * Retry a failed task.
* Shows failure info, prompts for movement selection, and requeues the task.
* *
* @returns true if task was requeued, false if cancelled * @returns true if task was requeued, false if cancelled
*/ */
@ -157,19 +62,9 @@ export async function retryFailedTask(
task: TaskListItem, task: TaskListItem,
projectDir: string, projectDir: string,
): Promise<boolean> { ): Promise<boolean> {
// Find session log and extract failure info displayFailureInfo(task);
const sessionLogPath = findSessionLogPath(task.filePath, projectDir);
const failureInfo = sessionLogPath ? extractFailureInfo(sessionLogPath) : null;
// Display failure information const pieceName = task.data?.piece ?? loadGlobalConfig().defaultPiece ?? 'default';
displayFailureInfo(task, failureInfo);
// Parse the failed task file to get the piece field
const taskFileData = parseFailedTaskFile(task.filePath);
// Determine piece name: task file -> global config -> 'default'
const globalConfig = loadGlobalConfig();
const pieceName = taskFileData?.piece ?? globalConfig.defaultPiece ?? 'default';
const pieceConfig = loadPieceByIdentifier(pieceName, projectDir); const pieceConfig = loadPieceByIdentifier(pieceName, projectDir);
if (!pieceConfig) { if (!pieceConfig) {
@ -177,42 +72,22 @@ export async function retryFailedTask(
return false; return false;
} }
// Prompt for movement selection const selectedMovement = await selectStartMovement(pieceConfig, task.failure?.movement ?? null);
// Default to failed movement, or last completed + 1, or initial movement
let defaultMovement: string | null = null;
if (failureInfo?.failedMovement) {
defaultMovement = failureInfo.failedMovement;
} else if (failureInfo?.lastCompletedMovement) {
// Find the next movement after the last completed one
const movements = pieceConfig.movements.map((m) => m.name);
const lastIdx = movements.indexOf(failureInfo.lastCompletedMovement);
if (lastIdx >= 0 && lastIdx < movements.length - 1) {
defaultMovement = movements[lastIdx + 1] ?? null;
}
}
const selectedMovement = await selectStartMovement(pieceConfig, defaultMovement);
if (selectedMovement === null) { if (selectedMovement === null) {
return false; // User cancelled return false;
} }
// Prompt for retry note (optional)
blankLine(); blankLine();
const retryNote = await promptInput('Retry note (optional, press Enter to skip):'); const retryNote = await promptInput('Retry note (optional, press Enter to skip):');
const trimmedNote = retryNote?.trim(); const trimmedNote = retryNote?.trim();
// Requeue the task
try { try {
const runner = new TaskRunner(projectDir); const runner = new TaskRunner(projectDir);
// Only pass startMovement if it's different from the initial movement
const startMovement = selectedMovement !== pieceConfig.initialMovement const startMovement = selectedMovement !== pieceConfig.initialMovement
? selectedMovement ? selectedMovement
: undefined; : undefined;
const requeuedPath = runner.requeueFailedTask(
task.filePath, runner.requeueFailedTask(task.name, startMovement, trimmedNote || undefined);
startMovement,
trimmedNote || undefined
);
success(`Task requeued: ${task.name}`); success(`Task requeued: ${task.name}`);
if (startMovement) { if (startMovement) {
@ -221,12 +96,11 @@ export async function retryFailedTask(
if (trimmedNote) { if (trimmedNote) {
info(` Retry note: ${trimmedNote}`); info(` Retry note: ${trimmedNote}`);
} }
info(` Task file: ${requeuedPath}`); info(` File: ${task.filePath}`);
log.info('Requeued failed task', { log.info('Requeued failed task', {
name: task.name, name: task.name,
from: task.filePath, tasksFile: task.filePath,
to: requeuedPath,
startMovement, startMovement,
retryNote: trimmedNote, retryNote: trimmedNote,
}); });

View File

@ -1,7 +1,7 @@
/** /**
* /watch command implementation * /watch command implementation
* *
* Watches .takt/tasks/ for new task files and executes them automatically. * Watches .takt/tasks.yaml for pending tasks and executes them automatically.
* Stays resident until Ctrl+C (SIGINT). * Stays resident until Ctrl+C (SIGINT).
*/ */
@ -26,6 +26,7 @@ export async function watchTasks(cwd: string, options?: TaskExecutionOptions): P
const pieceName = getCurrentPiece(cwd) || DEFAULT_PIECE_NAME; const pieceName = getCurrentPiece(cwd) || DEFAULT_PIECE_NAME;
const taskRunner = new TaskRunner(cwd); const taskRunner = new TaskRunner(cwd);
const watcher = new TaskWatcher(cwd); const watcher = new TaskWatcher(cwd);
const recovered = taskRunner.recoverInterruptedRunningTasks();
let taskCount = 0; let taskCount = 0;
let successCount = 0; let successCount = 0;
@ -34,6 +35,9 @@ export async function watchTasks(cwd: string, options?: TaskExecutionOptions): P
header('TAKT Watch Mode'); header('TAKT Watch Mode');
info(`Piece: ${pieceName}`); info(`Piece: ${pieceName}`);
info(`Watching: ${taskRunner.getTasksDir()}`); info(`Watching: ${taskRunner.getTasksDir()}`);
if (recovered > 0) {
info(`Recovered ${recovered} interrupted running task(s) to pending.`);
}
info('Waiting for tasks... (Ctrl+C to stop)'); info('Waiting for tasks... (Ctrl+C to stop)');
blankLine(); blankLine();

View File

@ -24,8 +24,8 @@ export function showTaskList(runner: TaskRunner): void {
if (tasks.length === 0) { if (tasks.length === 0) {
console.log(); console.log();
info('実行待ちのタスクはありません。'); info('実行待ちのタスクはありません。');
console.log(chalk.gray(`\n${runner.getTasksDir()}/ にタスクファイル(.yaml/.mdを配置してください。`)); console.log(chalk.gray(`\n${runner.getTasksDir()} を確認してください。`));
console.log(chalk.gray(`または takt add でタスクを追加できます。`)); console.log(chalk.gray('takt add でタスクを追加できます。'));
return; return;
} }
@ -39,7 +39,6 @@ export function showTaskList(runner: TaskRunner): void {
console.log(chalk.cyan.bold(` [${i + 1}] ${task.name}`)); console.log(chalk.cyan.bold(` [${i + 1}] ${task.name}`));
console.log(chalk.gray(` ${firstLine}...`)); console.log(chalk.gray(` ${firstLine}...`));
// Show worktree/branch info for YAML tasks
if (task.data) { if (task.data) {
const extras: string[] = []; const extras: string[] = [];
if (task.data.worktree) { if (task.data.worktree) {

View File

@ -24,8 +24,19 @@ export { TaskRunner } from './runner.js';
export { showTaskList } from './display.js'; export { showTaskList } from './display.js';
export { TaskFileSchema, type TaskFileData } from './schema.js'; export {
export { parseTaskFile, parseTaskFiles, type ParsedTask } from './parser.js'; TaskFileSchema,
type TaskFileData,
TaskExecutionConfigSchema,
TaskStatusSchema,
type TaskStatus,
TaskFailureSchema,
type TaskFailure,
TaskRecordSchema,
type TaskRecord,
TasksFileSchema,
type TasksFileData,
} from './schema.js';
export { export {
createSharedClone, createSharedClone,
removeClone, removeClone,

79
src/infra/task/mapper.ts Normal file
View File

@ -0,0 +1,79 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { TaskFileSchema, type TaskFileData, type TaskRecord } from './schema.js';
import type { TaskInfo, TaskListItem } from './types.js';
function firstLine(content: string): string {
return content.trim().split('\n')[0]?.slice(0, 80) ?? '';
}
export function resolveTaskContent(projectDir: string, task: TaskRecord): string {
if (task.content) {
return task.content;
}
if (!task.content_file) {
throw new Error(`Task content is missing: ${task.name}`);
}
const contentPath = path.isAbsolute(task.content_file)
? task.content_file
: path.join(projectDir, task.content_file);
return fs.readFileSync(contentPath, 'utf-8');
}
export function toTaskData(projectDir: string, task: TaskRecord): TaskFileData {
return TaskFileSchema.parse({
task: resolveTaskContent(projectDir, task),
worktree: task.worktree,
branch: task.branch,
piece: task.piece,
issue: task.issue,
start_movement: task.start_movement,
retry_note: task.retry_note,
auto_pr: task.auto_pr,
});
}
export function toTaskInfo(projectDir: string, tasksFile: string, task: TaskRecord): TaskInfo {
const content = resolveTaskContent(projectDir, task);
return {
filePath: tasksFile,
name: task.name,
content,
createdAt: task.created_at,
status: task.status,
data: TaskFileSchema.parse({
task: content,
worktree: task.worktree,
branch: task.branch,
piece: task.piece,
issue: task.issue,
start_movement: task.start_movement,
retry_note: task.retry_note,
auto_pr: task.auto_pr,
}),
};
}
export function toPendingTaskItem(projectDir: string, tasksFile: string, task: TaskRecord): TaskListItem {
return {
kind: 'pending',
name: task.name,
createdAt: task.created_at,
filePath: tasksFile,
content: firstLine(resolveTaskContent(projectDir, task)),
data: toTaskData(projectDir, task),
};
}
export function toFailedTaskItem(projectDir: string, tasksFile: string, task: TaskRecord): TaskListItem {
return {
kind: 'failed',
name: task.name,
createdAt: task.completed_at ?? task.created_at,
filePath: tasksFile,
content: firstLine(resolveTaskContent(projectDir, task)),
data: toTaskData(projectDir, task),
failure: task.failure,
};
}

22
src/infra/task/naming.ts Normal file
View File

@ -0,0 +1,22 @@
export function nowIso(): string {
return new Date().toISOString();
}
export function firstLine(content: string): string {
return content.trim().split('\n')[0]?.slice(0, 80) ?? '';
}
export function sanitizeTaskName(base: string): string {
const normalized = base
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, ' ')
.trim()
.replace(/\s+/g, '-')
.replace(/-+/g, '-');
if (!normalized) {
return `task-${Date.now()}`;
}
return normalized;
}

View File

@ -1,106 +0,0 @@
/**
* Task file parser
*
* Supports both YAML (.yaml/.yml) and Markdown (.md) task files.
* YAML files are validated against TaskFileSchema.
* Markdown files are treated as plain text.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { parse as parseYaml } from 'yaml';
import { TaskFileSchema, type TaskFileData } from './schema.js';
/** Supported task file extensions */
const YAML_EXTENSIONS = ['.yaml', '.yml'];
const MD_EXTENSIONS = ['.md'];
export const TASK_EXTENSIONS = [...YAML_EXTENSIONS, ...MD_EXTENSIONS];
/** Parsed task with optional structured data */
export interface ParsedTask {
filePath: string;
name: string;
content: string;
createdAt: string;
/** Structured data from YAML files (null for .md files) */
data: TaskFileData | null;
}
/**
* Check if a file is a supported task file
*/
export function isTaskFile(filename: string): boolean {
const ext = path.extname(filename).toLowerCase();
return TASK_EXTENSIONS.includes(ext);
}
/**
* Check if a file is a YAML task file
*/
function isYamlFile(filename: string): boolean {
const ext = path.extname(filename).toLowerCase();
return YAML_EXTENSIONS.includes(ext);
}
/**
* Get the task name from a filename (without extension)
*/
function getTaskName(filename: string): string {
const ext = path.extname(filename);
return path.basename(filename, ext);
}
/**
* Parse a single task file
*
* @throws Error if YAML parsing or validation fails
*/
export function parseTaskFile(filePath: string): ParsedTask {
const rawContent = fs.readFileSync(filePath, 'utf-8');
const stat = fs.statSync(filePath);
const filename = path.basename(filePath);
const name = getTaskName(filename);
if (isYamlFile(filename)) {
const parsed = parseYaml(rawContent) as unknown;
const validated = TaskFileSchema.parse(parsed);
return {
filePath,
name,
content: validated.task,
createdAt: stat.birthtime.toISOString(),
data: validated,
};
}
// Markdown file: plain text, no structured data
return {
filePath,
name,
content: rawContent,
createdAt: stat.birthtime.toISOString(),
data: null,
};
}
/**
* List and parse all task files in a directory
*/
export function parseTaskFiles(tasksDir: string): ParsedTask[] {
const tasks: ParsedTask[] = [];
const files = fs.readdirSync(tasksDir)
.filter(isTaskFile)
.sort();
for (const file of files) {
const filePath = path.join(tasksDir, file);
try {
tasks.push(parseTaskFile(filePath));
} catch {
// Skip files that fail to parse
}
}
return tasks;
}

View File

@ -1,393 +1,281 @@
/**
* TAKT
*
* .takt/tasks/
*
*
* Supports both .md (plain text) and .yaml/.yml (structured) task files.
*
* 使:
* /task #
* /task run #
* /task run <filename> #
* /task list #
*/
import * as fs from 'node:fs';
import * as path from 'node:path'; import * as path from 'node:path';
import { parseTaskFiles, parseTaskFile, type ParsedTask } from './parser.js'; import {
TaskRecordSchema,
type TaskFileData,
type TaskRecord,
type TaskFailure,
} from './schema.js';
import type { TaskInfo, TaskResult, TaskListItem } from './types.js'; import type { TaskInfo, TaskResult, TaskListItem } from './types.js';
import { createLogger } from '../../shared/utils/index.js'; import { toFailedTaskItem, toPendingTaskItem, toTaskInfo } from './mapper.js';
import { TaskStore } from './store.js';
import { firstLine, nowIso, sanitizeTaskName } from './naming.js';
export type { TaskInfo, TaskResult, TaskListItem }; export type { TaskInfo, TaskResult, TaskListItem };
const log = createLogger('task-runner');
/**
*
*/
export class TaskRunner { export class TaskRunner {
private projectDir: string; private readonly store: TaskStore;
private tasksDir: string; private readonly tasksFile: string;
private completedDir: string;
private failedDir: string;
private claimedPaths = new Set<string>();
constructor(projectDir: string) { constructor(private readonly projectDir: string) {
this.projectDir = projectDir; this.store = new TaskStore(projectDir);
this.tasksDir = path.join(projectDir, '.takt', 'tasks'); this.tasksFile = this.store.getTasksFilePath();
this.completedDir = path.join(projectDir, '.takt', 'completed');
this.failedDir = path.join(projectDir, '.takt', 'failed');
} }
/** ディレクトリ構造を作成 */
ensureDirs(): void { ensureDirs(): void {
fs.mkdirSync(this.tasksDir, { recursive: true }); this.store.ensureDirs();
fs.mkdirSync(this.completedDir, { recursive: true });
fs.mkdirSync(this.failedDir, { recursive: true });
} }
/** タスクディレクトリのパスを取得 */
getTasksDir(): string { getTasksDir(): string {
return this.tasksDir; return this.tasksFile;
} }
/** addTask(content: string, options?: Omit<TaskFileData, 'task'>): TaskInfo {
* const state = this.store.update((current) => {
* @returns const name = this.generateTaskName(content, current.tasks.map((task) => task.name));
*/ const record: TaskRecord = TaskRecordSchema.parse({
listTasks(): TaskInfo[] { name,
this.ensureDirs(); status: 'pending',
content,
created_at: nowIso(),
started_at: null,
completed_at: null,
owner_pid: null,
...options,
});
return { tasks: [...current.tasks, record] };
});
try { const created = state.tasks[state.tasks.length - 1];
const parsed = parseTaskFiles(this.tasksDir); if (!created) {
return parsed.map(toTaskInfo); throw new Error('Failed to create task.');
} catch (err) { }
const nodeErr = err as NodeJS.ErrnoException; return toTaskInfo(this.projectDir, this.tasksFile, created);
if (nodeErr.code !== 'ENOENT') { }
throw err; // 予期しないエラーは再スロー
} listTasks(): TaskInfo[] {
// ENOENT は許容(ディレクトリ未作成) const state = this.store.read();
return state.tasks
.filter((task) => task.status === 'pending')
.map((task) => toTaskInfo(this.projectDir, this.tasksFile, task));
}
claimNextTasks(count: number): TaskInfo[] {
if (count <= 0) {
return []; return [];
} }
const claimed: TaskRecord[] = [];
this.store.update((current) => {
let remaining = count;
const tasks = current.tasks.map((task) => {
if (remaining > 0 && task.status === 'pending') {
const next: TaskRecord = {
...task,
status: 'running',
started_at: nowIso(),
owner_pid: process.pid,
};
claimed.push(next);
remaining--;
return next;
}
return task;
});
return { tasks };
});
return claimed.map((task) => toTaskInfo(this.projectDir, this.tasksFile, task));
} }
/** recoverInterruptedRunningTasks(): number {
* let recovered = 0;
* Searches for .yaml, .yml, and .md files in that order. this.store.update((current) => {
*/ const tasks = current.tasks.map((task) => {
getTask(name: string): TaskInfo | null { if (task.status !== 'running' || !this.isRunningTaskStale(task)) {
this.ensureDirs(); return task;
}
const extensions = ['.yaml', '.yml', '.md']; recovered++;
return {
for (const ext of extensions) { ...task,
const filePath = path.join(this.tasksDir, `${name}${ext}`); status: 'pending',
if (!fs.existsSync(filePath)) { started_at: null,
continue; owner_pid: null,
} } as TaskRecord;
});
try { return { tasks };
const parsed = parseTaskFile(filePath); });
return toTaskInfo(parsed); return recovered;
} catch {
// Parse error: skip this extension
}
}
return null;
} }
/**
*
*/
getNextTask(): TaskInfo | null {
const tasks = this.listTasks();
return tasks[0] ?? null;
}
/**
*
*
* claimed claimed
*
*/
claimNextTasks(count: number): TaskInfo[] {
const allTasks = this.listTasks();
const unclaimed = allTasks.filter((t) => !this.claimedPaths.has(t.filePath));
const claimed = unclaimed.slice(0, count);
for (const task of claimed) {
this.claimedPaths.add(task.filePath);
}
return claimed;
}
/**
*
*
* .takt/completed
*
*
* @returns
*/
completeTask(result: TaskResult): string { completeTask(result: TaskResult): string {
if (!result.success) { if (!result.success) {
throw new Error('Cannot complete a failed task. Use failTask() instead.'); throw new Error('Cannot complete a failed task. Use failTask() instead.');
} }
return this.moveTask(result, this.completedDir);
this.store.update((current) => {
const index = this.findActiveTaskIndex(current.tasks, result.task.name);
if (index === -1) {
throw new Error(`Task not found: ${result.task.name}`);
}
const target = current.tasks[index]!;
const updated: TaskRecord = {
...target,
status: 'completed',
completed_at: result.completedAt,
owner_pid: null,
failure: undefined,
};
const tasks = [...current.tasks];
tasks[index] = updated;
return { tasks };
});
return this.tasksFile;
} }
/**
*
*
* .takt/failed
*
*
* @returns
*/
failTask(result: TaskResult): string { failTask(result: TaskResult): string {
return this.moveTask(result, this.failedDir); const failure: TaskFailure = {
} movement: result.failureMovement,
error: result.response,
/** last_message: result.failureLastMessage ?? result.executionLog[result.executionLog.length - 1],
* pendingタスクを TaskListItem
*/
listPendingTaskItems(): TaskListItem[] {
return this.listTasks().map((task) => ({
kind: 'pending' as const,
name: task.name,
createdAt: task.createdAt,
filePath: task.filePath,
content: task.content.trim().split('\n')[0]?.slice(0, 80) ?? '',
}));
}
/**
* failedタスクの一覧を取得
* .takt/failed/ TaskListItem
*/
listFailedTasks(): TaskListItem[] {
this.ensureDirs();
const entries = fs.readdirSync(this.failedDir);
return entries
.filter((entry) => {
const entryPath = path.join(this.failedDir, entry);
return fs.statSync(entryPath).isDirectory() && entry.includes('_');
})
.map((entry) => {
const entryPath = path.join(this.failedDir, entry);
const underscoreIdx = entry.indexOf('_');
const timestampRaw = entry.slice(0, underscoreIdx);
const name = entry.slice(underscoreIdx + 1);
const createdAt = timestampRaw.replace(
/^(\d{4}-\d{2}-\d{2}T\d{2})-(\d{2})-(\d{2})$/,
'$1:$2:$3',
);
const content = this.readFailedTaskContent(entryPath);
return { kind: 'failed' as const, name, createdAt, filePath: entryPath, content };
})
.filter((item) => item.name !== '');
}
/**
* failedタスクディレクトリ内のタスクファイルから先頭1行を読み取る
*/
private readFailedTaskContent(dirPath: string): string {
const taskExtensions = ['.md', '.yaml', '.yml'];
let files: string[];
try {
files = fs.readdirSync(dirPath);
} catch (err) {
log.error('Failed to read failed task directory', { dirPath, error: String(err) });
return '';
}
for (const file of files) {
const ext = path.extname(file);
if (file === 'report.md' || file === 'log.json') continue;
if (!taskExtensions.includes(ext)) continue;
try {
const raw = fs.readFileSync(path.join(dirPath, file), 'utf-8');
return raw.trim().split('\n')[0]?.slice(0, 80) ?? '';
} catch (err) {
log.error('Failed to read failed task file', { file, dirPath, error: String(err) });
continue;
}
}
return '';
}
/**
* Requeue a failed task back to .takt/tasks/
*
* Copies the task file from failed directory to tasks directory.
* If startMovement is specified and the task is YAML, adds start_movement field.
* If retryNote is specified and the task is YAML, adds retry_note field.
* Original failed directory is preserved for history.
*
* @param failedTaskDir - Path to failed task directory (e.g., .takt/failed/2026-01-31T12-00-00_my-task/)
* @param startMovement - Optional movement to start from (written to task file)
* @param retryNote - Optional note about why task is being retried (written to task file)
* @returns The path to the requeued task file
* @throws Error if task file not found or copy fails
*/
requeueFailedTask(failedTaskDir: string, startMovement?: string, retryNote?: string): string {
this.ensureDirs();
// Find task file in failed directory
const taskExtensions = ['.yaml', '.yml', '.md'];
let files: string[];
try {
files = fs.readdirSync(failedTaskDir);
} catch (err) {
throw new Error(`Failed to read failed task directory: ${failedTaskDir} - ${err}`);
}
let taskFile: string | null = null;
let taskExt: string | null = null;
for (const file of files) {
const ext = path.extname(file);
if (file === 'report.md' || file === 'log.json') continue;
if (!taskExtensions.includes(ext)) continue;
taskFile = path.join(failedTaskDir, file);
taskExt = ext;
break;
}
if (!taskFile || !taskExt) {
throw new Error(`No task file found in failed directory: ${failedTaskDir}`);
}
// Read task content
const taskContent = fs.readFileSync(taskFile, 'utf-8');
const taskName = path.basename(taskFile, taskExt);
// Destination path
const destFile = path.join(this.tasksDir, `${taskName}${taskExt}`);
// For YAML files, add start_movement and retry_note if specified
let finalContent = taskContent;
if (taskExt === '.yaml' || taskExt === '.yml') {
if (startMovement) {
// Check if start_movement already exists
if (!/^start_movement:/m.test(finalContent)) {
// Add start_movement field at the end
finalContent = finalContent.trimEnd() + `\nstart_movement: ${startMovement}\n`;
} else {
// Replace existing start_movement
finalContent = finalContent.replace(/^start_movement:.*$/m, `start_movement: ${startMovement}`);
}
}
if (retryNote) {
// Escape double quotes in retry note for YAML string
const escapedNote = retryNote.replace(/"/g, '\\"');
// Check if retry_note already exists
if (!/^retry_note:/m.test(finalContent)) {
// Add retry_note field at the end
finalContent = finalContent.trimEnd() + `\nretry_note: "${escapedNote}"\n`;
} else {
// Replace existing retry_note
finalContent = finalContent.replace(/^retry_note:.*$/m, `retry_note: "${escapedNote}"`);
}
}
}
// Write to tasks directory
fs.writeFileSync(destFile, finalContent, 'utf-8');
log.info('Requeued failed task', { from: failedTaskDir, to: destFile, startMovement });
return destFile;
}
/**
*
*/
private moveTask(result: TaskResult, targetDir: string): string {
this.ensureDirs();
// タイムスタンプを生成
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
// ターゲットディレクトリにサブディレクトリを作成
const taskTargetDir = path.join(
targetDir,
`${timestamp}_${result.task.name}`
);
fs.mkdirSync(taskTargetDir, { recursive: true });
// 元のタスクファイルを移動(元の拡張子を保持)
const originalExt = path.extname(result.task.filePath);
const movedTaskFile = path.join(taskTargetDir, `${result.task.name}${originalExt}`);
fs.renameSync(result.task.filePath, movedTaskFile);
this.claimedPaths.delete(result.task.filePath);
// レポートを生成
const reportFile = path.join(taskTargetDir, 'report.md');
const reportContent = this.generateReport(result);
fs.writeFileSync(reportFile, reportContent, 'utf-8');
// ログを保存
const logFile = path.join(taskTargetDir, 'log.json');
const logData = {
taskName: result.task.name,
success: result.success,
startedAt: result.startedAt,
completedAt: result.completedAt,
executionLog: result.executionLog,
response: result.response,
}; };
fs.writeFileSync(logFile, JSON.stringify(logData, null, 2), 'utf-8');
return reportFile; this.store.update((current) => {
const index = this.findActiveTaskIndex(current.tasks, result.task.name);
if (index === -1) {
throw new Error(`Task not found: ${result.task.name}`);
}
const target = current.tasks[index]!;
const updated: TaskRecord = {
...target,
status: 'failed',
completed_at: result.completedAt,
owner_pid: null,
failure,
};
const tasks = [...current.tasks];
tasks[index] = updated;
return { tasks };
});
return this.tasksFile;
} }
/** listPendingTaskItems(): TaskListItem[] {
* const state = this.store.read();
*/ return state.tasks
private generateReport(result: TaskResult): string { .filter((task) => task.status === 'pending')
const status = result.success ? '成功' : '失敗'; .map((task) => toPendingTaskItem(this.projectDir, this.tasksFile, task));
}
return `# タスク実行レポート
listFailedTasks(): TaskListItem[] {
## const state = this.store.read();
return state.tasks
- タスク名: ${result.task.name} .filter((task) => task.status === 'failed')
- ステータス: ${status} .map((task) => toFailedTaskItem(this.projectDir, this.tasksFile, task));
- 開始時刻: ${result.startedAt} }
- 完了時刻: ${result.completedAt}
requeueFailedTask(taskRef: string, startMovement?: string, retryNote?: string): string {
## const taskName = this.normalizeTaskRef(taskRef);
\`\`\`markdown this.store.update((current) => {
${result.task.content} const index = current.tasks.findIndex((task) => task.name === taskName && task.status === 'failed');
\`\`\` if (index === -1) {
throw new Error(`Failed task not found: ${taskRef}`);
## }
${result.response} const target = current.tasks[index]!;
const updated: TaskRecord = {
--- ...target,
status: 'pending',
*Generated by TAKT Task Runner* started_at: null,
`; completed_at: null,
owner_pid: null,
failure: undefined,
start_movement: startMovement,
retry_note: retryNote,
};
const tasks = [...current.tasks];
tasks[index] = updated;
return { tasks };
});
return this.tasksFile;
}
deletePendingTask(name: string): void {
this.deleteTaskByNameAndStatus(name, 'pending');
}
deleteFailedTask(name: string): void {
this.deleteTaskByNameAndStatus(name, 'failed');
}
private deleteTaskByNameAndStatus(name: string, status: 'pending' | 'failed'): void {
this.store.update((current) => {
const exists = current.tasks.some((task) => task.name === name && task.status === status);
if (!exists) {
throw new Error(`Task not found: ${name} (${status})`);
}
return {
tasks: current.tasks.filter((task) => !(task.name === name && task.status === status)),
};
});
}
private normalizeTaskRef(taskRef: string): string {
if (!taskRef.includes(path.sep)) {
return taskRef;
}
const base = path.basename(taskRef);
if (base.includes('_')) {
return base.slice(base.indexOf('_') + 1);
}
return base;
}
private findActiveTaskIndex(tasks: TaskRecord[], name: string): number {
return tasks.findIndex((task) => task.name === name && (task.status === 'running' || task.status === 'pending'));
}
private isRunningTaskStale(task: TaskRecord): boolean {
if (task.owner_pid == null) {
return true;
}
return !this.isProcessAlive(task.owner_pid);
}
private isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (err) {
const nodeErr = err as NodeJS.ErrnoException;
if (nodeErr.code === 'ESRCH') {
return false;
}
if (nodeErr.code === 'EPERM') {
return true;
}
throw err;
}
}
private generateTaskName(content: string, existingNames: string[]): string {
const base = sanitizeTaskName(firstLine(content));
let candidate = base;
let counter = 1;
while (existingNames.includes(candidate)) {
candidate = `${base}-${counter}`;
counter++;
}
return candidate;
} }
}
/** Convert ParsedTask to TaskInfo */
function toTaskInfo(parsed: ParsedTask): TaskInfo {
return {
filePath: parsed.filePath,
name: parsed.name,
content: parsed.content,
createdAt: parsed.createdAt,
data: parsed.data,
};
} }

View File

@ -1,39 +1,184 @@
/** /**
* Task YAML schema definition * Task schema definitions
*
* Zod schema for structured task files (.yaml/.yml)
*/ */
import { z } from 'zod/v4'; import { z } from 'zod/v4';
/** /**
* YAML task file schema * Per-task execution config schema.
* * Used by `takt add` input and in-memory TaskInfo.data.
* Examples:
* task: "認証機能を追加する"
* worktree: true #
* branch: "feat/add-auth" #
* piece: "default" # current piece
*
* worktree patterns (uses git clone --shared internally):
* - true: create shared clone in sibling dir or worktree_dir
* - "/path/to/dir": create at specified path
* - omitted: no isolation (run in cwd)
*
* branch patterns:
* - "feat/xxx": use specified branch name
* - omitted: auto-generate as takt/{timestamp}-{task-slug}
*/ */
export const TaskFileSchema = z.object({ export const TaskExecutionConfigSchema = z.object({
task: z.string().min(1),
worktree: z.union([z.boolean(), z.string()]).optional(), worktree: z.union([z.boolean(), z.string()]).optional(),
branch: z.string().optional(), branch: z.string().optional(),
piece: z.string().optional(), piece: z.string().optional(),
issue: z.number().int().positive().optional(), issue: z.number().int().positive().optional(),
start_movement: z.string().optional(), start_movement: z.string().optional(),
retry_note: z.string().optional(), retry_note: z.string().optional(),
/** Auto-create PR after worktree execution (default: prompt in interactive mode) */
auto_pr: z.boolean().optional(), auto_pr: z.boolean().optional(),
}); });
/**
* Single task payload schema used by in-memory TaskInfo.data.
*/
export const TaskFileSchema = TaskExecutionConfigSchema.extend({
task: z.string().min(1),
});
export type TaskFileData = z.infer<typeof TaskFileSchema>; export type TaskFileData = z.infer<typeof TaskFileSchema>;
export const TaskStatusSchema = z.enum(['pending', 'running', 'completed', 'failed']);
export type TaskStatus = z.infer<typeof TaskStatusSchema>;
export const TaskFailureSchema = z.object({
movement: z.string().optional(),
error: z.string().min(1),
last_message: z.string().optional(),
});
export type TaskFailure = z.infer<typeof TaskFailureSchema>;
export const TaskRecordSchema = TaskExecutionConfigSchema.extend({
name: z.string().min(1),
status: TaskStatusSchema,
content: z.string().optional(),
content_file: z.string().optional(),
created_at: z.string().min(1),
started_at: z.string().nullable(),
completed_at: z.string().nullable(),
owner_pid: z.number().int().positive().nullable().optional(),
failure: TaskFailureSchema.optional(),
}).superRefine((value, ctx) => {
if (!value.content && !value.content_file) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['content'],
message: 'Either content or content_file is required.',
});
}
const hasFailure = value.failure !== undefined;
const hasOwnerPid = typeof value.owner_pid === 'number';
if (value.status === 'pending') {
if (value.started_at !== null) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['started_at'],
message: 'Pending task must not have started_at.',
});
}
if (value.completed_at !== null) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['completed_at'],
message: 'Pending task must not have completed_at.',
});
}
if (hasFailure) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['failure'],
message: 'Pending task must not have failure.',
});
}
if (hasOwnerPid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['owner_pid'],
message: 'Pending task must not have owner_pid.',
});
}
}
if (value.status === 'running') {
if (value.started_at === null) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['started_at'],
message: 'Running task requires started_at.',
});
}
if (value.completed_at !== null) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['completed_at'],
message: 'Running task must not have completed_at.',
});
}
if (hasFailure) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['failure'],
message: 'Running task must not have failure.',
});
}
}
if (value.status === 'completed') {
if (value.started_at === null) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['started_at'],
message: 'Completed task requires started_at.',
});
}
if (value.completed_at === null) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['completed_at'],
message: 'Completed task requires completed_at.',
});
}
if (hasFailure) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['failure'],
message: 'Completed task must not have failure.',
});
}
if (hasOwnerPid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['owner_pid'],
message: 'Completed task must not have owner_pid.',
});
}
}
if (value.status === 'failed') {
if (value.started_at === null) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['started_at'],
message: 'Failed task requires started_at.',
});
}
if (value.completed_at === null) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['completed_at'],
message: 'Failed task requires completed_at.',
});
}
if (!hasFailure) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['failure'],
message: 'Failed task requires failure.',
});
}
if (hasOwnerPid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['owner_pid'],
message: 'Failed task must not have owner_pid.',
});
}
}
});
export type TaskRecord = z.infer<typeof TaskRecordSchema>;
export const TasksFileSchema = z.object({
tasks: z.array(TaskRecordSchema),
});
export type TasksFileData = z.infer<typeof TasksFileSchema>;

181
src/infra/task/store.ts Normal file
View File

@ -0,0 +1,181 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
import { TasksFileSchema, type TasksFileData } from './schema.js';
import { createLogger } from '../../shared/utils/index.js';
const log = createLogger('task-store');
const LOCK_WAIT_MS = 5_000;
const LOCK_POLL_MS = 50;
function sleepSync(ms: number): void {
const arr = new Int32Array(new SharedArrayBuffer(4));
Atomics.wait(arr, 0, 0, ms);
}
export class TaskStore {
private readonly tasksFile: string;
private readonly lockFile: string;
private readonly taktDir: string;
constructor(private readonly projectDir: string) {
this.taktDir = path.join(projectDir, '.takt');
this.tasksFile = path.join(this.taktDir, 'tasks.yaml');
this.lockFile = path.join(this.taktDir, 'tasks.yaml.lock');
}
getTasksFilePath(): string {
return this.tasksFile;
}
ensureDirs(): void {
fs.mkdirSync(this.taktDir, { recursive: true });
}
read(): TasksFileData {
return this.withLock(() => this.readUnsafe());
}
update(mutator: (current: TasksFileData) => TasksFileData): TasksFileData {
return this.withLock(() => {
const current = this.readUnsafe();
const updated = TasksFileSchema.parse(mutator(current));
this.writeUnsafe(updated);
return updated;
});
}
private readUnsafe(): TasksFileData {
this.ensureDirs();
if (!fs.existsSync(this.tasksFile)) {
return { tasks: [] };
}
let raw: string;
try {
raw = fs.readFileSync(this.tasksFile, 'utf-8');
} catch (err) {
log.error('Failed to read tasks file', { file: this.tasksFile, error: String(err) });
throw err;
}
try {
const parsed = parseYaml(raw) as unknown;
return TasksFileSchema.parse(parsed);
} catch (err) {
log.error('tasks.yaml is broken. Resetting file.', { file: this.tasksFile, error: String(err) });
fs.unlinkSync(this.tasksFile);
return { tasks: [] };
}
}
private writeUnsafe(state: TasksFileData): void {
this.ensureDirs();
const tempPath = `${this.tasksFile}.tmp-${process.pid}-${Date.now()}`;
const yaml = stringifyYaml(state);
fs.writeFileSync(tempPath, yaml, 'utf-8');
fs.renameSync(tempPath, this.tasksFile);
}
private withLock<T>(fn: () => T): T {
this.acquireLock();
try {
return fn();
} finally {
this.releaseLock();
}
}
private acquireLock(): void {
this.ensureDirs();
const start = Date.now();
while (true) {
try {
fs.writeFileSync(this.lockFile, String(process.pid), { encoding: 'utf-8', flag: 'wx' });
return;
} catch (err) {
const nodeErr = err as NodeJS.ErrnoException;
if (nodeErr.code !== 'EEXIST') {
throw err;
}
}
if (this.isStaleLock()) {
this.removeStaleLock();
continue;
}
if (Date.now() - start >= LOCK_WAIT_MS) {
throw new Error(`Failed to acquire tasks lock within ${LOCK_WAIT_MS}ms`);
}
sleepSync(LOCK_POLL_MS);
}
}
private isStaleLock(): boolean {
let pidRaw: string;
try {
pidRaw = fs.readFileSync(this.lockFile, 'utf-8').trim();
} catch (err) {
const nodeErr = err as NodeJS.ErrnoException;
if (nodeErr.code === 'ENOENT') {
return false;
}
throw err;
}
const pid = Number.parseInt(pidRaw, 10);
if (!Number.isInteger(pid) || pid <= 0) {
return true;
}
return !this.isProcessAlive(pid);
}
private removeStaleLock(): void {
try {
fs.unlinkSync(this.lockFile);
} catch (err) {
const nodeErr = err as NodeJS.ErrnoException;
if (nodeErr.code !== 'ENOENT') {
log.debug('Failed to remove stale lock, retrying.', { lockFile: this.lockFile, error: String(err) });
}
}
}
private isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (err) {
const nodeErr = err as NodeJS.ErrnoException;
if (nodeErr.code === 'ESRCH') {
return false;
}
if (nodeErr.code === 'EPERM') {
return true;
}
throw err;
}
}
private releaseLock(): void {
try {
const holder = fs.readFileSync(this.lockFile, 'utf-8').trim();
if (holder !== String(process.pid)) {
return;
}
fs.unlinkSync(this.lockFile);
} catch (err) {
const nodeErr = err as NodeJS.ErrnoException;
if (nodeErr.code === 'ENOENT') {
return;
}
log.debug('Failed to release tasks lock.', { lockFile: this.lockFile, error: String(err) });
throw err;
}
}
}

View File

@ -3,6 +3,7 @@
*/ */
import type { TaskFileData } from './schema.js'; import type { TaskFileData } from './schema.js';
import type { TaskFailure, TaskStatus } from './schema.js';
/** タスク情報 */ /** タスク情報 */
export interface TaskInfo { export interface TaskInfo {
@ -10,7 +11,7 @@ export interface TaskInfo {
name: string; name: string;
content: string; content: string;
createdAt: string; createdAt: string;
/** Structured data from YAML files (null for .md files) */ status: TaskStatus;
data: TaskFileData | null; data: TaskFileData | null;
} }
@ -20,6 +21,8 @@ export interface TaskResult {
success: boolean; success: boolean;
response: string; response: string;
executionLog: string[]; executionLog: string[];
failureMovement?: string;
failureLastMessage?: string;
startedAt: string; startedAt: string;
completedAt: string; completedAt: string;
} }
@ -74,4 +77,6 @@ export interface TaskListItem {
createdAt: string; createdAt: string;
filePath: string; filePath: string;
content: string; content: string;
data?: TaskFileData;
failure?: TaskFailure;
} }

View File

@ -1,7 +1,7 @@
/** /**
* Task directory watcher * Task directory watcher
* *
* Polls .takt/tasks/ for new task files and invokes a callback when found. * Polls .takt/tasks.yaml for pending tasks and invokes a callback when found.
* Uses polling (not fs.watch) for cross-platform reliability. * Uses polling (not fs.watch) for cross-platform reliability.
*/ */
@ -40,7 +40,8 @@ export class TaskWatcher {
log.info('Watch started', { pollInterval: this.pollInterval }); log.info('Watch started', { pollInterval: this.pollInterval });
while (this.running) { while (this.running) {
const task = this.runner.getNextTask(); const claimed = this.runner.claimNextTasks(1);
const task = claimed[0];
if (task) { if (task) {
log.info('Task found', { name: task.name }); log.info('Task found', { name: task.name });