diff --git a/src/__tests__/addTask.test.ts b/src/__tests__/addTask.test.ts index e00f40f..8c645a3 100644 --- a/src/__tests__/addTask.test.ts +++ b/src/__tests__/addTask.test.ts @@ -1,21 +1,13 @@ -/** - * Tests for addTask command - */ - import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; 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', () => ({ interactiveMode: vi.fn(), })); -vi.mock('../infra/providers/index.js', () => ({ - getProvider: vi.fn(), -})); - vi.mock('../infra/config/global/globalConfig.js', () => ({ loadGlobalConfig: vi.fn(() => ({ provider: 'claude' })), getBuiltinPiecesEnabled: vi.fn().mockReturnValue(true), @@ -26,14 +18,11 @@ vi.mock('../shared/prompt/index.js', () => ({ confirm: vi.fn(), })); -vi.mock('../infra/task/summarize.js', () => ({ - summarizeTaskName: vi.fn(), -})); - vi.mock('../shared/ui/index.js', () => ({ success: vi.fn(), info: vi.fn(), blankLine: vi.fn(), + error: vi.fn(), })); 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 { promptInput, confirm } from '../shared/prompt/index.js'; -import { summarizeTaskName } from '../infra/task/summarize.js'; import { determinePiece } from '../features/tasks/execute/selectAndExecute.js'; -import { getPieceDescription } from '../infra/config/loaders/pieceResolver.js'; -import { resolveIssueTask, createIssue } from '../infra/github/issue.js'; +import { resolveIssueTask } from '../infra/github/issue.js'; import { addTask } from '../features/tasks/index.js'; const mockResolveIssueTask = vi.mocked(resolveIssueTask); -const mockCreateIssue = vi.mocked(createIssue); const mockInteractiveMode = vi.mocked(interactiveMode); const mockPromptInput = vi.mocked(promptInput); const mockConfirm = vi.mocked(confirm); -const mockSummarizeTaskName = vi.mocked(summarizeTaskName); 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; +function loadTasks(dir: string): { tasks: Array> } { + const raw = fs.readFileSync(path.join(dir, '.takt', 'tasks.yaml'), 'utf-8'); + return parseYaml(raw) as { tasks: Array> }; +} + beforeEach(() => { vi.clearAllMocks(); testDir = fs.mkdtempSync(path.join(tmpdir(), 'takt-test-')); mockDeterminePiece.mockResolvedValue('default'); - mockGetPieceDescription.mockReturnValue({ name: 'default', description: '', pieceStructure: '' }); mockConfirm.mockResolvedValue(false); }); @@ -122,332 +96,46 @@ afterEach(() => { }); describe('addTask', () => { - it('should cancel when interactive mode is not confirmed', async () => { - // Given: user cancels interactive mode - mockDeterminePiece.mockResolvedValue('default'); - mockInteractiveMode.mockResolvedValue({ action: 'cancel', task: '' }); + it('should create task entry from interactive result', async () => { + mockInteractiveMode.mockResolvedValue({ action: 'execute', task: '# 認証機能追加\nJWT認証を実装する' }); - // When await addTask(testDir); - const tasksDir = path.join(testDir, '.takt', 'tasks'); - const files = fs.existsSync(tasksDir) ? fs.readdirSync(tasksDir) : []; - expect(files.length).toBe(0); - expect(mockSummarizeTaskName).not.toHaveBeenCalled(); + const tasks = loadTasks(testDir).tasks; + expect(tasks).toHaveLength(1); + expect(tasks[0]?.content).toContain('JWT認証を実装する'); + expect(tasks[0]?.piece).toBe('default'); }); - it('should create task file with AI-summarized content', async () => { - // Given: full flow setup - 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' }); + it('should include worktree settings when enabled', async () => { + mockInteractiveMode.mockResolvedValue({ action: 'execute', task: 'Task content' }); mockConfirm.mockResolvedValue(true); - mockPromptInput.mockResolvedValue(''); + mockPromptInput.mockResolvedValueOnce('/custom/path').mockResolvedValueOnce('feat/branch'); - // When await addTask(testDir); - // Then - const taskFile = path.join(testDir, '.takt', 'tasks', 'with-worktree.yaml'); - const content = fs.readFileSync(taskFile, 'utf-8'); - expect(content).toContain('worktree: true'); + const task = loadTasks(testDir).tasks[0]!; + expect(task.worktree).toBe('/custom/path'); + expect(task.branch).toBe('feat/branch'); }); - it('should include custom worktree path when provided', async () => { - // Given: user provides custom worktree path - 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: '' }); + it('should create task from issue reference without interactive mode', async () => { + mockResolveIssueTask.mockReturnValue('Issue #99: Fix login timeout'); 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'); - // Then: interactiveMode should NOT be called expect(mockInteractiveMode).not.toHaveBeenCalled(); - - // Then: resolveIssueTask was called - expect(mockResolveIssueTask).toHaveBeenCalledWith('#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'); + const task = loadTasks(testDir).tasks[0]!; + expect(task.content).toContain('Fix login timeout'); + expect(task.issue).toBe(99); }); - it('should proceed to worktree settings after issue fetch', 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); + it('should not create task when piece selection is cancelled', async () => { 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); - // Then: second confirm call (Auto-create PR?) has defaultYes=true - 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(); - }); + expect(fs.existsSync(path.join(testDir, '.takt', 'tasks.yaml'))).toBe(false); }); }); diff --git a/src/__tests__/listNonInteractive.test.ts b/src/__tests__/listNonInteractive.test.ts index 6d38f41..85b66f2 100644 --- a/src/__tests__/listNonInteractive.test.ts +++ b/src/__tests__/listNonInteractive.test.ts @@ -1,189 +1,80 @@ -/** - * Tests for listNonInteractive — non-interactive list output and branch actions. - */ - -import { execFileSync } from 'node:child_process'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { listTasks } from '../features/tasks/list/index.js'; +import { stringify as stringifyYaml } from 'yaml'; +import { listTasksNonInteractive } from '../features/tasks/list/listNonInteractive.js'; -describe('listTasks non-interactive text output', () => { - let tmpDir: string; +const mockInfo = vi.fn(); +vi.mock('../shared/ui/index.js', () => ({ + info: (...args: unknown[]) => mockInfo(...args), +})); - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-test-ni-')); - 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' }); +vi.mock('../infra/task/branchList.js', async (importOriginal) => ({ + ...(await importOriginal>()), + detectDefaultBranch: vi.fn(() => 'main'), + listTaktBranches: vi.fn(() => []), + buildListItems: vi.fn(() => []), +})); + +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(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); + it('should output JSON when format=json', async () => { + writeTasksFile(tmpDir); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); - it('should output pending tasks in text format', async () => { - // 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'); + await listTasksNonInteractive(tmpDir, { enabled: true, format: 'json' }); - 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(); }); }); diff --git a/src/__tests__/listTasks.test.ts b/src/__tests__/listTasks.test.ts index 8395422..704b749 100644 --- a/src/__tests__/listTasks.test.ts +++ b/src/__tests__/listTasks.test.ts @@ -1,391 +1,94 @@ -/** - * Tests for list-tasks command - */ - -import { execFileSync } from 'node:child_process'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { - parseTaktBranches, - extractTaskSlug, - buildListItems, - type BranchInfo, -} from '../infra/task/branchList.js'; +import { stringify as stringifyYaml } from 'yaml'; + +vi.mock('../shared/ui/index.js', () => ({ + info: vi.fn(), + header: vi.fn(), + blankLine: vi.fn(), +})); + +vi.mock('../infra/task/branchList.js', async (importOriginal) => ({ + ...(await importOriginal>()), + listTaktBranches: vi.fn(() => []), + buildListItems: vi.fn(() => []), + detectDefaultBranch: vi.fn(() => 'main'), +})); + import { TaskRunner } from '../infra/task/runner.js'; -import type { TaskListItem } from '../infra/task/types.js'; -import { isBranchMerged, showFullDiff, type ListAction } from '../features/tasks/index.js'; -import { listTasks } from '../features/tasks/list/index.js'; +import { listTasksNonInteractive } from '../features/tasks/list/listNonInteractive.js'; -describe('parseTaktBranches', () => { - it('should parse takt/ branches from git branch output', () => { - const output = [ - 'takt/20260128-fix-auth def4567', - 'takt/20260128-add-search 789abcd', - ].join('\n'); +let tmpDir: string; - const result = parseTaktBranches(output); - expect(result).toHaveLength(2); - - 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); - }); +beforeEach(() => { + vi.clearAllMocks(); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-list-test-')); }); -describe('extractTaskSlug', () => { - it('should extract slug from timestamped branch name', () => { - 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'); - }); +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); }); -describe('buildListItems', () => { - it('should build items with correct task slug and originalInstruction', () => { - const branches: BranchInfo[] = [ +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: [ { - branch: 'takt/20260128-fix-auth', - commit: 'abc123', - }, - ]; - - const items = buildListItems('/project', branches, 'main'); - 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', + name: 'pending-one', + status: 'pending', + content: 'Pending task', + created_at: '2026-02-09T00:00:00.000Z', + started_at: null, + completed_at: null, }, { - branch: 'takt/20260128-add-search', - commit: 'def456', + name: 'failed-one', + 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'); - expect(items).toHaveLength(2); - expect(items[0]!.taskSlug).toBe('fix-auth'); - 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', () => { +describe('TaskRunner list APIs', () => { + it('should read pending and failed tasks from tasks.yaml', () => { + writeTasksFile(tmpDir); const runner = new TaskRunner(tmpDir); - const result = runner.listFailedTasks(); - expect(result).toEqual([]); - }); - it('should parse failed task directories correctly', () => { - const failedDir = path.join(tmpDir, '.takt', 'failed'); - 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 pending = runner.listPendingTaskItems(); + const failed = runner.listFailedTasks(); - const runner = new TaskRunner(tmpDir); - const result = runner.listFailedTasks(); - - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ - 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)); + expect(pending).toHaveLength(1); + expect(pending[0]?.name).toBe('pending-one'); + expect(failed).toHaveLength(1); + expect(failed[0]?.name).toBe('failed-one'); + expect(failed[0]?.failure?.error).toBe('boom'); }); }); 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(() => { - 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' }); - }); + await listTasksNonInteractive(tmpDir, { enabled: true, format: 'json' }); - 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); - const output = JSON.parse(logSpy.mock.calls[0]![0] as string); - expect(output).toHaveProperty('branches'); - expect(output).toHaveProperty('pendingTasks'); - expect(output).toHaveProperty('failedTasks'); - expect(Array.isArray(output.branches)).toBe(true); - expect(Array.isArray(output.pendingTasks)).toBe(true); - expect(Array.isArray(output.failedTasks)).toBe(true); - expect(output.pendingTasks).toHaveLength(1); - expect(output.pendingTasks[0].name).toBe('my-task'); - expect(output.failedTasks).toHaveLength(1); - expect(output.failedTasks[0].name).toBe('failed-task'); + const payload = JSON.parse(logSpy.mock.calls[0]![0] as string) as { + branches: unknown[]; + pendingTasks: Array<{ name: string }>; + failedTasks: Array<{ name: string }>; + }; + expect(Array.isArray(payload.branches)).toBe(true); + expect(payload.pendingTasks[0]?.name).toBe('pending-one'); + expect(payload.failedTasks[0]?.name).toBe('failed-one'); logSpy.mockRestore(); }); diff --git a/src/__tests__/runAllTasks-concurrency.test.ts b/src/__tests__/runAllTasks-concurrency.test.ts index 1bc7ccd..2ea5f30 100644 --- a/src/__tests__/runAllTasks-concurrency.test.ts +++ b/src/__tests__/runAllTasks-concurrency.test.ts @@ -21,18 +21,18 @@ vi.mock('../infra/config/index.js', () => ({ import { loadGlobalConfig } from '../infra/config/index.js'; const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig); -const mockGetNextTask = vi.fn(); const mockClaimNextTasks = vi.fn(); const mockCompleteTask = vi.fn(); const mockFailTask = vi.fn(); +const mockRecoverInterruptedRunningTasks = vi.fn(); vi.mock('../infra/task/index.js', async (importOriginal) => ({ ...(await importOriginal>()), TaskRunner: vi.fn().mockImplementation(() => ({ - getNextTask: mockGetNextTask, claimNextTasks: mockClaimNextTasks, completeTask: mockCompleteTask, failTask: mockFailTask, + recoverInterruptedRunningTasks: mockRecoverInterruptedRunningTasks, })), })); @@ -128,11 +128,15 @@ function createTask(name: string): TaskInfo { name, content: `Task: ${name}`, filePath: `/tasks/${name}.yaml`, + createdAt: '2026-02-09T00:00:00.000Z', + status: 'pending', + data: null, }; } beforeEach(() => { vi.clearAllMocks(); + mockRecoverInterruptedRunningTasks.mockReturnValue(0); }); describe('runAllTasks concurrency', () => { @@ -155,7 +159,7 @@ describe('runAllTasks concurrency', () => { await runAllTasks('/project'); // 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 () => { @@ -401,6 +405,28 @@ describe('runAllTasks concurrency', () => { 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 () => { // Given: One task in parallel mode const task1 = createTask('parallel-task'); diff --git a/src/__tests__/saveTaskFile.test.ts b/src/__tests__/saveTaskFile.test.ts index 9d69a29..51078ae 100644 --- a/src/__tests__/saveTaskFile.test.ts +++ b/src/__tests__/saveTaskFile.test.ts @@ -1,15 +1,8 @@ -/** - * Tests for saveTaskFile and saveTaskFromInteractive - */ - import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { tmpdir } from 'node:os'; - -vi.mock('../infra/task/summarize.js', () => ({ - summarizeTaskName: vi.fn(), -})); +import { parse as parseYaml } from 'yaml'; vi.mock('../shared/ui/index.js', () => ({ 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 { confirm, promptInput } from '../shared/prompt/index.js'; import { saveTaskFile, saveTaskFromInteractive } from '../features/tasks/add/index.js'; -const mockSummarizeTaskName = vi.mocked(summarizeTaskName); const mockSuccess = vi.mocked(success); const mockInfo = vi.mocked(info); const mockConfirm = vi.mocked(confirm); @@ -44,10 +35,14 @@ const mockPromptInput = vi.mocked(promptInput); let testDir: string; +function loadTasks(testDir: string): { tasks: Array> } { + const raw = fs.readFileSync(path.join(testDir, '.takt', 'tasks.yaml'), 'utf-8'); + return parseYaml(raw) as { tasks: Array> }; +} + beforeEach(() => { vi.clearAllMocks(); testDir = fs.mkdtempSync(path.join(tmpdir(), 'takt-test-save-')); - mockSummarizeTaskName.mockResolvedValue('test-task'); }); afterEach(() => { @@ -57,243 +52,74 @@ afterEach(() => { }); describe('saveTaskFile', () => { - it('should create task file with correct YAML content', async () => { - // Given - const taskContent = 'Implement feature X\nDetails here'; + it('should append task to tasks.yaml', async () => { + const created = await saveTaskFile(testDir, 'Implement feature X\nDetails here'); - // When - const filePath = await saveTaskFile(testDir, taskContent); + expect(created.taskName).toContain('implement-feature-x'); + expect(created.tasksFile).toBe(path.join(testDir, '.takt', 'tasks.yaml')); + expect(fs.existsSync(created.tasksFile)).toBe(true); - // Then - expect(fs.existsSync(filePath)).toBe(true); - const content = fs.readFileSync(filePath, 'utf-8'); - expect(content).toContain('Implement feature X'); - expect(content).toContain('Details here'); + const tasks = loadTasks(testDir).tasks; + expect(tasks).toHaveLength(1); + expect(tasks[0]?.content).toContain('Implement feature X'); }); - it('should create .takt/tasks directory if it does not exist', async () => { - // Given - const tasksDir = path.join(testDir, '.takt', 'tasks'); - expect(fs.existsSync(tasksDir)).toBe(false); + it('should include optional fields', async () => { + await saveTaskFile(testDir, 'Task', { + piece: 'review', + issue: 42, + worktree: true, + branch: 'feat/my-branch', + autoPr: false, + }); - // When - await saveTaskFile(testDir, 'Task content'); - - // Then - expect(fs.existsSync(tasksDir)).toBe(true); + const task = loadTasks(testDir).tasks[0]!; + expect(task.piece).toBe('review'); + expect(task.issue).toBe(42); + expect(task.worktree).toBe(true); + expect(task.branch).toBe('feat/my-branch'); + expect(task.auto_pr).toBe(false); }); - it('should include piece in YAML when specified', async () => { - // When - const filePath = await saveTaskFile(testDir, 'Task', { piece: 'review' }); + it('should generate unique names on duplicates', async () => { + const first = await saveTaskFile(testDir, 'Same title'); + const second = await saveTaskFile(testDir, 'Same title'); - // Then - 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'); + expect(first.taskName).not.toBe(second.taskName); }); }); describe('saveTaskFromInteractive', () => { - it('should save task with worktree settings when user confirms worktree', async () => { - // Given: user confirms worktree, accepts defaults, confirms auto-PR - mockConfirm.mockResolvedValueOnce(true); // Create worktree? → Yes - mockPromptInput.mockResolvedValueOnce(''); // Worktree path → auto - mockPromptInput.mockResolvedValueOnce(''); // Branch name → auto - mockConfirm.mockResolvedValueOnce(true); // Auto-create PR? → Yes + it('should save task with worktree settings when user confirms', async () => { + mockConfirm.mockResolvedValueOnce(true); + mockPromptInput.mockResolvedValueOnce(''); + mockPromptInput.mockResolvedValueOnce(''); + mockConfirm.mockResolvedValueOnce(true); - // When await saveTaskFromInteractive(testDir, 'Task content'); - // Then - expect(mockSuccess).toHaveBeenCalledWith('Task created: test-task.yaml'); - expect(mockInfo).toHaveBeenCalledWith(expect.stringContaining('Path:')); - 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: true'); - expect(content).toContain('auto_pr: true'); + expect(mockSuccess).toHaveBeenCalledWith(expect.stringContaining('Task created:')); + const task = loadTasks(testDir).tasks[0]!; + expect(task.worktree).toBe(true); + expect(task.auto_pr).toBe(true); }); - it('should save task without worktree settings when user declines worktree', async () => { - // Given: user declines worktree - mockConfirm.mockResolvedValueOnce(false); // Create worktree? → No + it('should save task without worktree settings when declined', async () => { + mockConfirm.mockResolvedValueOnce(false); - // When await saveTaskFromInteractive(testDir, 'Task content'); - // Then - expect(mockSuccess).toHaveBeenCalledWith('Task created: test-task.yaml'); - 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).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'); + const task = loadTasks(testDir).tasks[0]!; + expect(task.worktree).toBeUndefined(); + expect(task.branch).toBeUndefined(); + expect(task.auto_pr).toBeUndefined(); }); it('should display piece info when specified', async () => { - // Given - mockConfirm.mockResolvedValueOnce(false); // Create worktree? → No + mockConfirm.mockResolvedValueOnce(false); - // When await saveTaskFromInteractive(testDir, 'Task content', 'review'); - // Then 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'); - }); }); diff --git a/src/__tests__/task.test.ts b/src/__tests__/task.test.ts index 6e9af16..d5dfe14 100644 --- a/src/__tests__/task.test.ts +++ b/src/__tests__/task.test.ts @@ -1,59 +1,34 @@ -/** - * Task runner tests - */ - -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdirSync, writeFileSync, existsSync, rmSync, readFileSync, readdirSync } from 'node:fs'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdirSync, writeFileSync, existsSync, rmSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; import { TaskRunner } from '../infra/task/runner.js'; -import { isTaskFile, parseTaskFiles } from '../infra/task/parser.js'; +import { TaskRecordSchema } from '../infra/task/schema.js'; -describe('isTaskFile', () => { - it('should accept .yaml files', () => { - expect(isTaskFile('task.yaml')).toBe(true); - }); +function loadTasksFile(testDir: string): { tasks: Array> } { + const raw = readFileSync(join(testDir, '.takt', 'tasks.yaml'), 'utf-8'); + return parseYaml(raw) as { tasks: Array> }; +} - it('should accept .yml files', () => { - expect(isTaskFile('task.yml')).toBe(true); - }); +function writeTasksFile(testDir: string, tasks: Array>): void { + mkdirSync(join(testDir, '.takt'), { recursive: true }); + writeFileSync(join(testDir, '.takt', 'tasks.yaml'), stringifyYaml({ tasks }), 'utf-8'); +} - it('should accept .md files', () => { - expect(isTaskFile('task.md')).toBe(true); - }); +function createPendingRecord(overrides: Record): Record { + 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; +} - it('should reject extensionless files like TASK-FORMAT', () => { - 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', () => { +describe('TaskRunner (tasks.yaml)', () => { const testDir = `/tmp/takt-task-test-${Date.now()}`; let runner: TaskRunner; @@ -68,465 +43,245 @@ describe('TaskRunner', () => { } }); - describe('ensureDirs', () => { - it('should create tasks, completed, and failed directories', () => { - runner.ensureDirs(); - expect(existsSync(join(testDir, '.takt', 'tasks'))).toBe(true); - expect(existsSync(join(testDir, '.takt', 'completed'))).toBe(true); - expect(existsSync(join(testDir, '.takt', 'failed'))).toBe(true); - }); + it('should add tasks to .takt/tasks.yaml', () => { + const task = runner.addTask('Fix login flow', { piece: 'default' }); + expect(task.name).toContain('fix-login-flow'); + expect(existsSync(join(testDir, '.takt', 'tasks.yaml'))).toBe(true); }); - describe('listTasks', () => { - it('should return empty array when no tasks', () => { - const tasks = runner.listTasks(); - expect(tasks).toEqual([]); - }); + it('should list only pending tasks', () => { + runner.addTask('Task A'); + runner.addTask('Task B'); - it('should list tasks sorted by name', () => { - const tasksDir = join(testDir, '.takt', 'tasks'); - mkdirSync(tasksDir, { recursive: 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'); - }); + const tasks = runner.listTasks(); + expect(tasks).toHaveLength(2); + expect(tasks.every((task) => task.status === 'pending')).toBe(true); }); - describe('getTask', () => { - it('should return null for non-existent task', () => { - const task = runner.getTask('non-existent'); - expect(task).toBeNull(); - }); + it('should claim tasks and mark them running', () => { + runner.addTask('Task A'); + runner.addTask('Task B'); - it('should return task info for existing task', () => { - const tasksDir = join(testDir, '.takt', 'tasks'); - mkdirSync(tasksDir, { recursive: true }); - writeFileSync(join(tasksDir, 'my-task.md'), 'Task content'); + const claimed = runner.claimNextTasks(1); + expect(claimed).toHaveLength(1); + expect(claimed[0]?.status).toBe('running'); - const task = runner.getTask('my-task'); - expect(task).not.toBeNull(); - expect(task?.name).toBe('my-task'); - expect(task?.content).toBe('Task content'); - }); + const file = loadTasksFile(testDir); + expect(file.tasks.some((task) => task.status === 'running')).toBe(true); }); - describe('getNextTask', () => { - it('should return null when no tasks', () => { - const task = runner.getNextTask(); - expect(task).toBeNull(); - }); + it('should recover interrupted running tasks to pending', () => { + runner.addTask('Task A'); + runner.claimNextTasks(1); + const current = loadTasksFile(testDir); + const running = current.tasks[0] as Record; + running.owner_pid = 999999999; + writeFileSync(join(testDir, '.takt', 'tasks.yaml'), stringifyYaml(current), 'utf-8'); - it('should return first task (alphabetically)', () => { - const tasksDir = join(testDir, '.takt', 'tasks'); - mkdirSync(tasksDir, { recursive: true }); - writeFileSync(join(tasksDir, 'b-task.md'), 'B'); - writeFileSync(join(tasksDir, 'a-task.md'), 'A'); + const recovered = runner.recoverInterruptedRunningTasks(); + expect(recovered).toBe(1); - const task = runner.getNextTask(); - expect(task?.name).toBe('a-task'); - }); + const tasks = runner.listTasks(); + expect(tasks).toHaveLength(1); + expect(tasks[0]?.status).toBe('pending'); }); - describe('claimNextTasks', () => { - it('should return empty array when no tasks', () => { - const tasks = runner.claimNextTasks(3); - expect(tasks).toEqual([]); - }); + it('should keep running tasks owned by a live process', () => { + runner.addTask('Task A'); + runner.claimNextTasks(1); - it('should return tasks up to the requested count', () => { - 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'); - - 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'); - }); + const recovered = runner.recoverInterruptedRunningTasks(); + expect(recovered).toBe(0); }); - describe('completeTask', () => { - it('should move task to completed directory', () => { - const tasksDir = join(testDir, '.takt', 'tasks'); - mkdirSync(tasksDir, { recursive: true }); - const taskFile = join(tasksDir, 'test-task.md'); - writeFileSync(taskFile, 'Test task content'); + it('should take over stale lock file with invalid pid', () => { + mkdirSync(join(testDir, '.takt'), { recursive: true }); + writeFileSync(join(testDir, '.takt', 'tasks.yaml.lock'), 'invalid-pid', 'utf-8'); - const task = runner.getTask('test-task')!; - 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 task = runner.addTask('Task with stale lock'); - const reportFile = runner.completeTask(result); - - // 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.' - ); - }); + expect(task.name).toContain('task-with-stale-lock'); + expect(existsSync(join(testDir, '.takt', 'tasks.yaml.lock'))).toBe(false); }); - describe('failTask', () => { - it('should move task to failed directory', () => { - const tasksDir = join(testDir, '.takt', 'tasks'); - mkdirSync(tasksDir, { recursive: true }); - const taskFile = join(tasksDir, 'fail-task.md'); - writeFileSync(taskFile, 'Task that will fail'); + it('should timeout when lock file is held by a live process', () => { + mkdirSync(join(testDir, '.takt'), { recursive: true }); + writeFileSync(join(testDir, '.takt', 'tasks.yaml.lock'), String(process.pid), 'utf-8'); - const task = runner.getTask('fail-task')!; - const result = { - task, - success: false, - response: 'Error occurred', - executionLog: ['Started', 'Error'], - startedAt: '2024-01-01T00:00:00.000Z', - completedAt: '2024-01-01T00:01:00.000Z', - }; + const dateNowSpy = vi.spyOn(Date, 'now'); + dateNowSpy.mockReturnValueOnce(0); + dateNowSpy.mockReturnValue(5_000); - const reportFile = runner.failTask(result); - - // Original task file should be removed from tasks dir - expect(existsSync(taskFile)).toBe(false); - - // 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); - }); + try { + expect(() => runner.listTasks()).toThrow('Failed to acquire tasks lock within 5000ms'); + } finally { + dateNowSpy.mockRestore(); + rmSync(join(testDir, '.takt', 'tasks.yaml.lock'), { force: true }); + } }); - describe('getTasksDir', () => { - it('should return tasks directory path', () => { - expect(runner.getTasksDir()).toBe(join(testDir, '.takt', 'tasks')); - }); + it('should recover from corrupted tasks.yaml and allow adding tasks again', () => { + mkdirSync(join(testDir, '.takt'), { recursive: true }); + writeFileSync(join(testDir, '.takt', 'tasks.yaml'), 'tasks:\n - name: [broken', 'utf-8'); + + expect(() => runner.listTasks()).not.toThrow(); + expect(runner.listTasks()).toEqual([]); + expect(existsSync(join(testDir, '.takt', 'tasks.yaml'))).toBe(false); + + const task = runner.addTask('Task after recovery'); + expect(task.name).toContain('task-after-recovery'); + expect(existsSync(join(testDir, '.takt', 'tasks.yaml'))).toBe(true); + expect(runner.listTasks()).toHaveLength(1); }); - describe('requeueFailedTask', () => { - it('should copy task file from failed to tasks directory', () => { - runner.ensureDirs(); + it('should load pending content from relative content_file', () => { + mkdirSync(join(testDir, 'fixtures'), { recursive: true }); + writeFileSync(join(testDir, 'fixtures', 'task.txt'), 'Task from file\nsecond line', 'utf-8'); + writeTasksFile(testDir, [createPendingRecord({ + content: undefined, + content_file: 'fixtures/task.txt', + })]); - // Create a failed task directory - const failedDir = join(testDir, '.takt', 'failed', '2026-01-31T12-00-00_my-task'); - 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 tasks = runner.listTasks(); + const pendingItems = runner.listPendingTaskItems(); - 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 - expect(existsSync(result)).toBe(true); - expect(result).toBe(join(testDir, '.takt', 'tasks', 'my-task.yaml')); + it('should load pending content from absolute content_file', () => { + const contentPath = join(testDir, 'absolute-task.txt'); + writeFileSync(contentPath, 'Absolute task content', 'utf-8'); + writeTasksFile(testDir, [createPendingRecord({ + content: undefined, + content_file: contentPath, + })]); - // Original failed directory should still exist - expect(existsSync(failedDir)).toBe(true); + const tasks = runner.listTasks(); + expect(tasks[0]?.content).toBe('Absolute task content'); + }); - // Task content should be preserved - const content = readFileSync(result, 'utf-8'); - expect(content).toBe('task: Do something\n'); + it('should prefer inline content over content_file', () => { + writeTasksFile(testDir, [createPendingRecord({ + 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', () => { - runner.ensureDirs(); + const file = loadTasksFile(testDir); + expect(file.tasks[0]?.status).toBe('completed'); + }); - const failedDir = join(testDir, '.takt', 'failed', '2026-01-31T12-00-00_retry-task'); - mkdirSync(failedDir, { recursive: true }); - writeFileSync(join(failedDir, 'retry-task.yaml'), 'task: Retry me\npiece: default\n'); + it('should mark claimed task as failed with failure detail', () => { + runner.addTask('Task A'); + const task = runner.claimNextTasks(1)[0]!; - const result = runner.requeueFailedTask(failedDir, 'implement'); - - const content = readFileSync(result, 'utf-8'); - expect(content).toContain('start_movement: implement'); - expect(content).toContain('task: Retry me'); - expect(content).toContain('piece: default'); + runner.failTask({ + task, + success: false, + response: 'Boom', + executionLog: ['last message'], + failureMovement: 'review', + failureLastMessage: 'last message', + startedAt: new Date().toISOString(), + completedAt: new Date().toISOString(), }); - it('should replace existing start_movement in YAML task file', () => { - runner.ensureDirs(); + const failed = runner.listFailedTasks(); + expect(failed).toHaveLength(1); + expect(failed[0]?.failure?.error).toBe('Boom'); + expect(failed[0]?.failure?.movement).toBe('review'); + expect(failed[0]?.failure?.last_message).toBe('last message'); + }); - const failedDir = join(testDir, '.takt', 'failed', '2026-01-31T12-00-00_replace-task'); - mkdirSync(failedDir, { recursive: true }); - writeFileSync(join(failedDir, 'replace-task.yaml'), 'task: Replace me\nstart_movement: plan\n'); - - const result = runner.requeueFailedTask(failedDir, 'ai_review'); - - const content = readFileSync(result, 'utf-8'); - expect(content).toContain('start_movement: ai_review'); - expect(content).not.toContain('start_movement: plan'); + it('should requeue failed task to pending with retry metadata', () => { + runner.addTask('Task A'); + const task = runner.claimNextTasks(1)[0]!; + runner.failTask({ + task, + success: false, + response: 'Boom', + executionLog: [], + startedAt: new Date().toISOString(), + completedAt: new Date().toISOString(), }); - it('should not modify markdown task files even with startMovement', () => { - runner.ensureDirs(); + runner.requeueFailedTask(task.name, 'implement', 'retry note'); - const failedDir = join(testDir, '.takt', 'failed', '2026-01-31T12-00-00_md-task'); - mkdirSync(failedDir, { recursive: true }); - writeFileSync(join(failedDir, 'md-task.md'), '# Task\nDo something'); + const pending = runner.listTasks(); + expect(pending).toHaveLength(1); + expect(pending[0]?.data?.start_movement).toBe('implement'); + expect(pending[0]?.data?.retry_note).toBe('retry note'); + }); - 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'); - // Markdown files should not have start_movement added - expect(content).toBe('# Task\nDo something'); - expect(content).not.toContain('start_movement'); - }); - - it('should throw error when no task file found', () => { - runner.ensureDirs(); - - 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'); + const failed = runner.addTask('Task B'); + const running = runner.claimNextTasks(1)[0]!; + runner.failTask({ + task: running, + success: false, + response: 'Boom', + executionLog: [], + startedAt: new Date().toISOString(), + completedAt: new Date().toISOString(), }); + runner.deleteFailedTask(failed.name); + expect(runner.listFailedTasks()).toHaveLength(0); + }); +}); + +describe('TaskRecordSchema', () => { + it('should reject failed record without failure details', () => { + expect(() => TaskRecordSchema.parse({ + name: 'task-a', + status: 'failed', + content: 'Do work', + created_at: '2026-02-09T00:00:00.000Z', + started_at: '2026-02-09T00:01:00.000Z', + completed_at: '2026-02-09T00:02:00.000Z', + })).toThrow(); + }); + + it('should reject completed record with failure details', () => { + expect(() => TaskRecordSchema.parse({ + name: 'task-a', + status: 'completed', + content: 'Do work', + created_at: '2026-02-09T00:00:00.000Z', + started_at: '2026-02-09T00:01:00.000Z', + completed_at: '2026-02-09T00:02:00.000Z', + failure: { + error: 'unexpected', + }, + })).toThrow(); }); }); diff --git a/src/__tests__/taskDeleteActions.test.ts b/src/__tests__/taskDeleteActions.test.ts index 47cecdb..912591f 100644 --- a/src/__tests__/taskDeleteActions.test.ts +++ b/src/__tests__/taskDeleteActions.test.ts @@ -1,10 +1,7 @@ -/** - * Tests for taskDeleteActions — pending/failed task deletion - */ - import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; +import { stringify as stringifyYaml } from 'yaml'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; vi.mock('../shared/prompt/index.js', () => ({ @@ -35,6 +32,33 @@ const mockLogError = vi.mocked(logError); 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(() => { vi.clearAllMocks(); tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-test-delete-')); @@ -44,137 +68,59 @@ afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); -describe('deletePendingTask', () => { - it('should delete pending task file when confirmed', async () => { - // Given - const filePath = path.join(tmpDir, 'my-task.md'); - fs.writeFileSync(filePath, 'task content'); +describe('taskDeleteActions', () => { + it('should delete pending task when confirmed', async () => { + const tasksFile = setupTasksFile(tmpDir); const task: TaskListItem = { kind: 'pending', - name: 'my-task', + name: 'pending-task', createdAt: '2025-01-15', - filePath, - content: 'task content', + filePath: tasksFile, + content: 'pending', }; mockConfirm.mockResolvedValue(true); - // When const result = await deletePendingTask(task); - // Then expect(result).toBe(true); - expect(fs.existsSync(filePath)).toBe(false); - expect(mockSuccess).toHaveBeenCalledWith('Deleted pending task: my-task'); + const raw = fs.readFileSync(tasksFile, 'utf-8'); + expect(raw).not.toContain('pending-task'); + expect(mockSuccess).toHaveBeenCalledWith('Deleted pending task: pending-task'); }); - it('should not delete when user declines confirmation', async () => { - // Given - const filePath = path.join(tmpDir, 'my-task.md'); - fs.writeFileSync(filePath, 'task content'); + it('should delete failed task when confirmed', async () => { + const tasksFile = setupTasksFile(tmpDir); const task: TaskListItem = { - kind: 'pending', - name: 'my-task', - createdAt: '2025-01-15', - filePath, - content: 'task content', + kind: 'failed', + name: 'failed-task', + createdAt: '2025-01-15T12:34:56', + filePath: tasksFile, + content: 'failed', }; - mockConfirm.mockResolvedValue(false); + mockConfirm.mockResolvedValue(true); - // When - const result = await deletePendingTask(task); + const result = await deleteFailedTask(task); - // Then - expect(result).toBe(false); - expect(fs.existsSync(filePath)).toBe(true); - expect(mockSuccess).not.toHaveBeenCalled(); + expect(result).toBe(true); + const raw = fs.readFileSync(tasksFile, 'utf-8'); + expect(raw).not.toContain('failed-task'); + expect(mockSuccess).toHaveBeenCalledWith('Deleted failed task: failed-task'); }); - it('should return false and show error when file does not exist', async () => { - // Given - const filePath = path.join(tmpDir, 'non-existent.md'); + it('should return false when target task is missing', async () => { + const tasksFile = setupTasksFile(tmpDir); const task: TaskListItem = { - kind: 'pending', - name: 'non-existent', - createdAt: '2025-01-15', - filePath, + kind: 'failed', + name: 'not-found', + createdAt: '2025-01-15T12:34:56', + filePath: tasksFile, content: '', }; mockConfirm.mockResolvedValue(true); - // When - const result = await deletePendingTask(task); + const result = await deleteFailedTask(task); - // Then expect(result).toBe(false); 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(); }); }); diff --git a/src/__tests__/taskExecution.test.ts b/src/__tests__/taskExecution.test.ts index 6734c06..023e317 100644 --- a/src/__tests__/taskExecution.test.ts +++ b/src/__tests__/taskExecution.test.ts @@ -95,6 +95,9 @@ describe('resolveTaskExecution', () => { name: 'simple-task', content: 'Simple task content', filePath: '/tasks/simple-task.yaml', + createdAt: '2026-02-09T00:00:00.000Z', + status: 'pending', + data: null, }; // When diff --git a/src/__tests__/taskRetryActions.test.ts b/src/__tests__/taskRetryActions.test.ts index 7a917fe..5035dde 100644 --- a/src/__tests__/taskRetryActions.test.ts +++ b/src/__tests__/taskRetryActions.test.ts @@ -1,10 +1,7 @@ -/** - * Tests for taskRetryActions — failed task retry functionality - */ - import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; +import { stringify as stringifyYaml } from 'yaml'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; 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', () => ({ loadGlobalConfig: vi.fn(), loadPieceByIdentifier: vi.fn(), @@ -66,16 +59,37 @@ const defaultPieceConfig: PieceConfig = { ], }; -const customPieceConfig: PieceConfig = { - name: 'custom', - description: 'Custom piece', - initialMovement: 'step1', - maxIterations: 10, - movements: [ - { name: 'step1', persona: 'coder', instruction: '' }, - { name: 'step2', persona: 'reviewer', instruction: '' }, - ], -}; +function writeFailedTask(projectDir: string, name: string): TaskListItem { + const tasksFile = path.join(projectDir, '.takt', 'tasks.yaml'); + fs.mkdirSync(path.dirname(tasksFile), { recursive: true }); + fs.writeFileSync(tasksFile, stringifyYaml({ + tasks: [ + { + name, + 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(() => { vi.clearAllMocks(); @@ -88,264 +102,49 @@ afterEach(() => { describe('retryFailedTask', () => { it('should requeue task with selected movement', async () => { - // Given: a failed task directory with a task file - 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', - }; + const task = writeFailedTask(tmpDir, 'my-task'); mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' }); mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig); mockSelectOption.mockResolvedValue('implement'); - mockPromptInput.mockResolvedValue(''); // Empty retry note + mockPromptInput.mockResolvedValue(''); - // When const result = await retryFailedTask(task, tmpDir); - // Then expect(result).toBe(true); expect(mockSuccess).toHaveBeenCalledWith('Task requeued: my-task'); - // Verify requeued file - const requeuedFile = path.join(tasksDir, 'my-task.yaml'); - expect(fs.existsSync(requeuedFile)).toBe(true); - 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); + const tasksYaml = fs.readFileSync(path.join(tmpDir, '.takt', 'tasks.yaml'), 'utf-8'); + expect(tasksYaml).toContain('status: pending'); + expect(tasksYaml).toContain('start_movement: implement'); }); it('should not add start_movement when initial movement is selected', async () => { - // Given - 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', - }; + const task = writeFailedTask(tmpDir, 'my-task'); mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' }); mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig); - mockSelectOption.mockResolvedValue('plan'); // Initial movement - mockPromptInput.mockResolvedValue(''); // Empty retry note + mockSelectOption.mockResolvedValue('plan'); + mockPromptInput.mockResolvedValue(''); - // When const result = await retryFailedTask(task, tmpDir); - // Then expect(result).toBe(true); - - // Verify requeued file does not have start_movement - const requeuedFile = path.join(tasksDir, 'my-task.yaml'); - const content = fs.readFileSync(requeuedFile, 'utf-8'); - expect(content).not.toContain('start_movement'); + const tasksYaml = fs.readFileSync(path.join(tmpDir, '.takt', 'tasks.yaml'), 'utf-8'); + expect(tasksYaml).not.toContain('start_movement'); }); - it('should add retry_note when user provides one', async () => { - // Given - 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', - }; + it('should return false and show error when piece not found', async () => { + const task = writeFailedTask(tmpDir, 'my-task'); mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' }); - mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig); - mockSelectOption.mockResolvedValue('implement'); - mockPromptInput.mockResolvedValue('Fixed spawn node ENOENT error'); + mockLoadPieceByIdentifier.mockReturnValue(null); - // When const result = await retryFailedTask(task, tmpDir); - // Then - expect(result).toBe(true); - - 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'); + expect(result).toBe(false); + expect(mockLogError).toHaveBeenCalledWith( + 'Piece "default" not found. Cannot determine available movements.', + ); }); }); diff --git a/src/__tests__/watchTasks.test.ts b/src/__tests__/watchTasks.test.ts new file mode 100644 index 0000000..2012287 --- /dev/null +++ b/src/__tests__/watchTasks.test.ts @@ -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) => { + 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); + }); +}); diff --git a/src/__tests__/watcher.test.ts b/src/__tests__/watcher.test.ts index 601e33c..051f73a 100644 --- a/src/__tests__/watcher.test.ts +++ b/src/__tests__/watcher.test.ts @@ -1,24 +1,26 @@ -/** - * TaskWatcher tests - */ - import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdirSync, writeFileSync, existsSync, rmSync } from 'node:fs'; import { join } from 'node:path'; +import { stringify as stringifyYaml } from 'yaml'; import { TaskWatcher } from '../infra/task/watcher.js'; +import { TaskRunner } from '../infra/task/runner.js'; import type { TaskInfo } from '../infra/task/types.js'; describe('TaskWatcher', () => { const testDir = `/tmp/takt-watcher-test-${Date.now()}`; let watcher: TaskWatcher | null = null; + function writeTasksYaml(tasks: Array>): void { + const tasksFile = join(testDir, '.takt', 'tasks.yaml'); + mkdirSync(join(testDir, '.takt'), { recursive: true }); + writeFileSync(tasksFile, stringifyYaml({ tasks }), 'utf-8'); + } + beforeEach(() => { - mkdirSync(join(testDir, '.takt', 'tasks'), { recursive: true }); - mkdirSync(join(testDir, '.takt', 'completed'), { recursive: true }); + mkdirSync(join(testDir, '.takt'), { recursive: true }); }); afterEach(() => { - // Ensure watcher is stopped before cleanup if (watcher) { watcher.stop(); watcher = null; @@ -41,21 +43,24 @@ describe('TaskWatcher', () => { }); 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 }); 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) => { processed.push(task.name); - // Stop after processing to avoid infinite loop in test - watcher.stop(); + watcher?.stop(); }); await watchPromise; @@ -64,48 +69,61 @@ describe('TaskWatcher', () => { 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 }); - let pollCount = 0; + const runner = new TaskRunner(testDir); + let processed = 0; - // Start watching, add a task after a delay - const watchPromise = watcher.watch(async (task: TaskInfo) => { - pollCount++; - watcher.stop(); + const watchPromise = watcher.watch(async () => { + processed++; + watcher?.stop(); }); - // Add task after short delay (after at least one empty poll) await new Promise((resolve) => setTimeout(resolve, 100)); - writeFileSync( - join(testDir, '.takt', 'tasks', 'delayed-task.md'), - 'Delayed task' - ); + runner.addTask('Delayed task'); await watchPromise; - expect(pollCount).toBe(1); + expect(processed).toBe(1); }); 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 }); 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) => { processed.push(task.name); - // Remove the task file to simulate completion - rmSync(task.filePath); + runner.completeTask({ + task, + success: true, + response: 'Done', + executionLog: [], + startedAt: new Date().toISOString(), + completedAt: new Date().toISOString(), + }); if (processed.length >= 2) { - watcher.stop(); + watcher?.stop(); } }); @@ -117,15 +135,13 @@ describe('TaskWatcher', () => { describe('stop', () => { it('should stop the watcher gracefully', async () => { + writeTasksYaml([]); watcher = new TaskWatcher(testDir, { pollInterval: 50 }); - // Start watching, stop after a short delay 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; @@ -133,18 +149,17 @@ describe('TaskWatcher', () => { }); it('should abort sleep immediately when stopped', async () => { + writeTasksYaml([]); watcher = new TaskWatcher(testDir, { pollInterval: 10000 }); const start = Date.now(); const watchPromise = watcher.watch(async () => {}); - // Stop after 50ms, should not wait the full 10s - setTimeout(() => watcher.stop(), 50); + setTimeout(() => watcher?.stop(), 50); await watchPromise; const elapsed = Date.now() - start; - // Should complete well under the 10s poll interval expect(elapsed).toBeLessThan(1000); }); }); diff --git a/src/features/tasks/add/index.ts b/src/features/tasks/add/index.ts index 6e9e37a..e7a4c94 100644 --- a/src/features/tasks/add/index.ts +++ b/src/features/tasks/add/index.ts @@ -2,15 +2,13 @@ * add command implementation * * 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 { stringify as stringifyYaml } from 'yaml'; import { promptInput, confirm } from '../../../shared/prompt/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 { determinePiece } from '../execute/selectAndExecute.js'; import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; @@ -19,23 +17,8 @@ import { interactiveMode } from '../../interactive/index.js'; const log = createLogger('add-task'); -async function generateFilename(tasksDir: string, taskContent: string, cwd: string): Promise { - 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() * and saveTaskFromInteractive(). @@ -44,29 +27,19 @@ export async function saveTaskFile( cwd: string, taskContent: string, options?: { piece?: string; issue?: number; worktree?: boolean | string; branch?: string; autoPr?: boolean }, -): Promise { - const tasksDir = path.join(cwd, '.takt', 'tasks'); - fs.mkdirSync(tasksDir, { recursive: true }); - - const firstLine = taskContent.split('\n')[0] || taskContent; - const filename = await generateFilename(tasksDir, firstLine, cwd); - - const taskData: TaskFileData = { - task: taskContent, +): Promise<{ taskName: string; tasksFile: string }> { + const runner = new TaskRunner(cwd); + const config: Omit = { ...(options?.worktree !== undefined && { worktree: options.worktree }), ...(options?.branch && { branch: options.branch }), ...(options?.piece && { piece: options.piece }), ...(options?.issue !== undefined && { issue: options.issue }), ...(options?.autoPr !== undefined && { auto_pr: options.autoPr }), }; - - const filePath = path.join(tasksDir, filename); - const yamlContent = stringifyYaml(taskData); - fs.writeFileSync(filePath, yamlContent, 'utf-8'); - - log.info('Task created', { filePath, taskData }); - - return filePath; + const created = runner.addTask(taskContent, config); + const tasksFile = path.join(cwd, '.takt', 'tasks.yaml'); + log.info('Task created', { taskName: created.name, tasksFile, config }); + return { taskName: created.name, tasksFile }; } /** @@ -120,10 +93,9 @@ export async function saveTaskFromInteractive( piece?: string, ): Promise { const settings = await promptWorktreeSettings(); - const filePath = await saveTaskFile(cwd, task, { piece, ...settings }); - const filename = path.basename(filePath); - success(`Task created: ${filename}`); - info(` Path: ${filePath}`); + const created = await saveTaskFile(cwd, task, { piece, ...settings }); + success(`Task created: ${created.taskName}`); + info(` File: ${created.tasksFile}`); if (settings.worktree) { info(` Worktree: ${typeof settings.worktree === 'string' ? settings.worktree : 'auto'}`); } @@ -144,9 +116,6 @@ export async function saveTaskFromInteractive( * B) それ以外: ピース選択 → AI対話モード → ワークツリー設定 → YAML作成 */ export async function addTask(cwd: string, task?: string): Promise { - const tasksDir = path.join(cwd, '.takt', 'tasks'); - fs.mkdirSync(tasksDir, { recursive: true }); - // ピース選択とタスク内容の決定 let taskContent: string; let issueNumber: number | undefined; @@ -209,15 +178,14 @@ export async function addTask(cwd: string, task?: string): Promise { const settings = await promptWorktreeSettings(); // YAMLファイル作成 - const filePath = await saveTaskFile(cwd, taskContent, { + const created = await saveTaskFile(cwd, taskContent, { piece, issue: issueNumber, ...settings, }); - const filename = path.basename(filePath); - success(`Task created: ${filename}`); - info(` Path: ${filePath}`); + success(`Task created: ${created.taskName}`); + info(` File: ${created.tasksFile}`); if (settings.worktree) { info(` Worktree: ${typeof settings.worktree === 'string' ? settings.worktree : 'auto'}`); } diff --git a/src/features/tasks/execute/pieceExecution.ts b/src/features/tasks/execute/pieceExecution.ts index b0defab..99ece9f 100644 --- a/src/features/tasks/execute/pieceExecution.ts +++ b/src/features/tasks/execute/pieceExecution.ts @@ -647,6 +647,8 @@ export async function executePiece( return { success: finalState.status === 'completed', reason: abortReason, + lastMovement: lastMovementName, + lastMessage: lastMovementContent, }; } finally { prefixWriter?.flush(); diff --git a/src/features/tasks/execute/resolveTask.ts b/src/features/tasks/execute/resolveTask.ts index db88636..9f4ed82 100644 --- a/src/features/tasks/execute/resolveTask.ts +++ b/src/features/tasks/execute/resolveTask.ts @@ -29,7 +29,6 @@ export async function resolveTaskExecution( defaultPiece: string, ): Promise { const data = task.data; - if (!data) { return { execCwd: defaultCwd, execPiece: defaultPiece, isWorktree: false }; } diff --git a/src/features/tasks/execute/taskExecution.ts b/src/features/tasks/execute/taskExecution.ts index 219711c..4798cc9 100644 --- a/src/features/tasks/execute/taskExecution.ts +++ b/src/features/tasks/execute/taskExecution.ts @@ -15,7 +15,7 @@ import { import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; import { executePiece } from './pieceExecution.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 { runWithWorkerPool } from './parallelExecution.js'; import { resolveTaskExecution } from './resolveTask.js'; @@ -48,22 +48,20 @@ function resolveTaskIssue(issueNumber: number | undefined): ReturnType { +async function executeTaskWithResult(options: ExecuteTaskOptions): Promise { const { task, cwd, pieceIdentifier, projectCwd, agentOverrides, interactiveUserInput, interactiveMetadata, startMovement, retryNote, abortSignal, taskPrefix, taskColorIndex } = options; const pieceConfig = loadPieceByIdentifier(pieceIdentifier, projectCwd); if (!pieceConfig) { if (isPiecePath(pieceIdentifier)) { error(`Piece file not found: ${pieceIdentifier}`); + return { success: false, reason: `Piece file not found: ${pieceIdentifier}` }; } else { error(`Piece "${pieceIdentifier}" not found.`); info('Available pieces are in ~/.takt/pieces/ or .takt/pieces/'); info('Use "takt switch" to select a piece.'); + return { success: false, reason: `Piece "${pieceIdentifier}" not found.` }; } - return false; } log.debug('Running piece', { @@ -72,7 +70,7 @@ export async function executeTask(options: ExecuteTaskOptions): Promise }); const globalConfig = loadGlobalConfig(); - const result = await executePiece(pieceConfig, task, cwd, { + return await executePiece(pieceConfig, task, cwd, { projectCwd, language: globalConfig.language, provider: agentOverrides?.provider, @@ -86,6 +84,13 @@ export async function executeTask(options: ExecuteTaskOptions): Promise taskPrefix, taskColorIndex, }); +} + +/** + * Execute a single task with piece. + */ +export async function executeTask(options: ExecuteTaskOptions): Promise { + const result = await executeTaskWithResult(options); return result.success; } @@ -106,7 +111,6 @@ export async function executeAndCompleteTask( parallelOptions?: { abortSignal?: AbortSignal; taskPrefix?: string; taskColorIndex?: number }, ): Promise { const startedAt = new Date().toISOString(); - const executionLog: string[] = []; const taskAbortController = new AbortController(); const externalAbortSignal = parallelOptions?.abortSignal; 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); // 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, cwd: execCwd, pieceIdentifier: execPiece, @@ -140,7 +144,11 @@ export async function executeAndCompleteTask( 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(); if (taskSuccess && isWorktree) { @@ -180,8 +188,10 @@ export async function executeAndCompleteTask( const taskResult = { task, success: taskSuccess, - response: taskSuccess ? 'Task completed successfully' : 'Task failed', - executionLog, + response: taskSuccess ? 'Task completed successfully' : taskRunResult.reason!, + executionLog: taskRunResult.lastMessage ? [taskRunResult.lastMessage] : [], + failureMovement: taskRunResult.lastMovement, + failureLastMessage: taskRunResult.lastMessage, startedAt, completedAt, }; @@ -202,7 +212,7 @@ export async function executeAndCompleteTask( task, success: false, response: getErrorMessage(err), - executionLog, + executionLog: [], startedAt, completedAt, }); @@ -230,12 +240,16 @@ export async function runAllTasks( const taskRunner = new TaskRunner(cwd); const globalConfig = loadGlobalConfig(); 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); if (initialTasks.length === 0) { - info('No pending tasks in .takt/tasks/'); - info('Create task files as .takt/tasks/*.yaml or use takt add'); + info('No pending tasks in .takt/tasks.yaml'); + info('Use takt add to append tasks.'); return; } diff --git a/src/features/tasks/execute/types.ts b/src/features/tasks/execute/types.ts index 55cf547..5a8d946 100644 --- a/src/features/tasks/execute/types.ts +++ b/src/features/tasks/execute/types.ts @@ -10,6 +10,8 @@ import type { GitHubIssue } from '../../../infra/github/index.js'; export interface PieceExecutionResult { success: boolean; reason?: string; + lastMovement?: string; + lastMessage?: string; } /** Metadata from interactive mode, passed through to NDJSON logging */ diff --git a/src/features/tasks/list/taskActions.ts b/src/features/tasks/list/taskActions.ts index 59a32c1..2315529 100644 --- a/src/features/tasks/list/taskActions.ts +++ b/src/features/tasks/list/taskActions.ts @@ -37,16 +37,21 @@ export type ListAction = 'diff' | 'instruct' | 'try' | 'merge' | 'delete'; * Check if a branch has already been merged into HEAD. */ export function isBranchMerged(projectDir: string, branch: string): boolean { - try { - execFileSync('git', ['merge-base', '--is-ancestor', branch, 'HEAD'], { - cwd: projectDir, - encoding: 'utf-8', - stdio: 'pipe', + const result = spawnSync('git', ['merge-base', '--is-ancestor', branch, 'HEAD'], { + cwd: projectDir, + encoding: 'utf-8', + 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 result.status === 0; } /** @@ -70,8 +75,13 @@ export function showFullDiff( if (result.status !== 0) { warn('Could not display diff'); } - } catch { + } catch (err) { 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' }, ); info(stat); - } catch { + } catch (err) { 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( @@ -168,8 +183,12 @@ export function mergeBranch(projectDir: string, item: BranchListItem): boolean { encoding: 'utf-8', stdio: 'pipe', }); - } catch { + } catch (err) { 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); @@ -276,8 +295,12 @@ function getBranchContext(projectDir: string, branch: string): string { lines.push(diffStat); lines.push('```'); } - } catch { - // Ignore errors + } catch (err) { + log.debug('Failed to collect branch diff stat for instruction context', { + branch, + defaultBranch, + error: getErrorMessage(err), + }); } try { @@ -292,8 +315,12 @@ function getBranchContext(projectDir: string, branch: string): string { lines.push(commitLog); lines.push('```'); } - } catch { - // Ignore errors + } catch (err) { + 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' : ''; @@ -361,4 +388,3 @@ export async function instructBranch( removeCloneMeta(projectDir, branch); } } - diff --git a/src/features/tasks/list/taskDeleteActions.ts b/src/features/tasks/list/taskDeleteActions.ts index 128dfe1..89eee61 100644 --- a/src/features/tasks/list/taskDeleteActions.ts +++ b/src/features/tasks/list/taskDeleteActions.ts @@ -2,17 +2,22 @@ * Delete actions for pending and failed tasks. * * 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 { TaskRunner } from '../../../infra/task/index.js'; import { confirm } from '../../../shared/prompt/index.js'; import { success, error as logError } from '../../../shared/ui/index.js'; import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; const log = createLogger('list-tasks'); +function getProjectDir(task: TaskListItem): string { + return dirname(dirname(task.filePath)); +} + /** * Delete a pending task file. * Prompts user for confirmation first. @@ -21,7 +26,8 @@ export async function deletePendingTask(task: TaskListItem): Promise { const confirmed = await confirm(`Delete pending task "${task.name}"?`, false); if (!confirmed) return false; try { - unlinkSync(task.filePath); + const runner = new TaskRunner(getProjectDir(task)); + runner.deletePendingTask(task.name); } catch (err) { const msg = getErrorMessage(err); logError(`Failed to delete pending task "${task.name}": ${msg}`); @@ -38,10 +44,11 @@ export async function deletePendingTask(task: TaskListItem): Promise { * Prompts user for confirmation first. */ export async function deleteFailedTask(task: TaskListItem): Promise { - 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; try { - rmSync(task.filePath, { recursive: true }); + const runner = new TaskRunner(getProjectDir(task)); + runner.deleteFailedTask(task.name); } catch (err) { const msg = getErrorMessage(err); logError(`Failed to delete failed task "${task.name}": ${msg}`); diff --git a/src/features/tasks/list/taskRetryActions.ts b/src/features/tasks/list/taskRetryActions.ts index 615e0b0..d840787 100644 --- a/src/features/tasks/list/taskRetryActions.ts +++ b/src/features/tasks/list/taskRetryActions.ts @@ -5,11 +5,8 @@ * 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 { TaskRunner, parseTaskFile, type TaskFileData } from '../../../infra/task/index.js'; -import { extractFailureInfo, type FailureInfo } from '../../../infra/fs/session.js'; +import { TaskRunner } from '../../../infra/task/index.js'; import { loadPieceByIdentifier, loadGlobalConfig } from '../../../infra/config/index.js'; import { selectOption, promptInput } from '../../../shared/prompt/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'); -/** - * 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 { +function displayFailureInfo(task: TaskListItem): void { header(`Failed Task: ${task.name}`); info(` Failed at: ${task.createdAt}`); - if (failureInfo) { + if (task.failure) { blankLine(); - if (failureInfo.lastCompletedMovement) { - status('Last completed', failureInfo.lastCompletedMovement); + if (task.failure.movement) { + status('Failed at', task.failure.movement, 'red'); } - if (failureInfo.failedMovement) { - status('Failed at', failureInfo.failedMovement, 'red'); + status('Error', task.failure.error, '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(); } -/** - * Prompt user to select a movement to start from. - * Returns the selected movement name, or null if cancelled. - */ async function selectStartMovement( pieceConfig: PieceConfig, defaultMovement: string | null, ): Promise { const movements = pieceConfig.movements.map((m) => m.name); - // Determine default selection const defaultIdx = defaultMovement ? movements.indexOf(defaultMovement) : 0; @@ -149,7 +55,6 @@ async function selectStartMovement( /** * Retry a failed task. - * Shows failure info, prompts for movement selection, and requeues the task. * * @returns true if task was requeued, false if cancelled */ @@ -157,19 +62,9 @@ export async function retryFailedTask( task: TaskListItem, projectDir: string, ): Promise { - // Find session log and extract failure info - const sessionLogPath = findSessionLogPath(task.filePath, projectDir); - const failureInfo = sessionLogPath ? extractFailureInfo(sessionLogPath) : null; + displayFailureInfo(task); - // Display failure information - 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 pieceName = task.data?.piece ?? loadGlobalConfig().defaultPiece ?? 'default'; const pieceConfig = loadPieceByIdentifier(pieceName, projectDir); if (!pieceConfig) { @@ -177,42 +72,22 @@ export async function retryFailedTask( return false; } - // Prompt for movement selection - // 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); + const selectedMovement = await selectStartMovement(pieceConfig, task.failure?.movement ?? null); if (selectedMovement === null) { - return false; // User cancelled + return false; } - // Prompt for retry note (optional) blankLine(); const retryNote = await promptInput('Retry note (optional, press Enter to skip):'); const trimmedNote = retryNote?.trim(); - // Requeue the task try { const runner = new TaskRunner(projectDir); - // Only pass startMovement if it's different from the initial movement const startMovement = selectedMovement !== pieceConfig.initialMovement ? selectedMovement : undefined; - const requeuedPath = runner.requeueFailedTask( - task.filePath, - startMovement, - trimmedNote || undefined - ); + + runner.requeueFailedTask(task.name, startMovement, trimmedNote || undefined); success(`Task requeued: ${task.name}`); if (startMovement) { @@ -221,12 +96,11 @@ export async function retryFailedTask( if (trimmedNote) { info(` Retry note: ${trimmedNote}`); } - info(` Task file: ${requeuedPath}`); + info(` File: ${task.filePath}`); log.info('Requeued failed task', { name: task.name, - from: task.filePath, - to: requeuedPath, + tasksFile: task.filePath, startMovement, retryNote: trimmedNote, }); diff --git a/src/features/tasks/watch/index.ts b/src/features/tasks/watch/index.ts index dd57dd2..b450515 100644 --- a/src/features/tasks/watch/index.ts +++ b/src/features/tasks/watch/index.ts @@ -1,7 +1,7 @@ /** * /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). */ @@ -26,6 +26,7 @@ export async function watchTasks(cwd: string, options?: TaskExecutionOptions): P const pieceName = getCurrentPiece(cwd) || DEFAULT_PIECE_NAME; const taskRunner = new TaskRunner(cwd); const watcher = new TaskWatcher(cwd); + const recovered = taskRunner.recoverInterruptedRunningTasks(); let taskCount = 0; let successCount = 0; @@ -34,6 +35,9 @@ export async function watchTasks(cwd: string, options?: TaskExecutionOptions): P header('TAKT Watch Mode'); info(`Piece: ${pieceName}`); info(`Watching: ${taskRunner.getTasksDir()}`); + if (recovered > 0) { + info(`Recovered ${recovered} interrupted running task(s) to pending.`); + } info('Waiting for tasks... (Ctrl+C to stop)'); blankLine(); diff --git a/src/infra/task/display.ts b/src/infra/task/display.ts index 76e6751..8b958f7 100644 --- a/src/infra/task/display.ts +++ b/src/infra/task/display.ts @@ -24,8 +24,8 @@ export function showTaskList(runner: TaskRunner): void { if (tasks.length === 0) { console.log(); info('実行待ちのタスクはありません。'); - console.log(chalk.gray(`\n${runner.getTasksDir()}/ にタスクファイル(.yaml/.md)を配置してください。`)); - console.log(chalk.gray(`または takt add でタスクを追加できます。`)); + console.log(chalk.gray(`\n${runner.getTasksDir()} を確認してください。`)); + console.log(chalk.gray('takt add でタスクを追加できます。')); return; } @@ -39,7 +39,6 @@ export function showTaskList(runner: TaskRunner): void { console.log(chalk.cyan.bold(` [${i + 1}] ${task.name}`)); console.log(chalk.gray(` ${firstLine}...`)); - // Show worktree/branch info for YAML tasks if (task.data) { const extras: string[] = []; if (task.data.worktree) { diff --git a/src/infra/task/index.ts b/src/infra/task/index.ts index 3340cae..b511650 100644 --- a/src/infra/task/index.ts +++ b/src/infra/task/index.ts @@ -24,8 +24,19 @@ export { TaskRunner } from './runner.js'; export { showTaskList } from './display.js'; -export { TaskFileSchema, type TaskFileData } from './schema.js'; -export { parseTaskFile, parseTaskFiles, type ParsedTask } from './parser.js'; +export { + TaskFileSchema, + type TaskFileData, + TaskExecutionConfigSchema, + TaskStatusSchema, + type TaskStatus, + TaskFailureSchema, + type TaskFailure, + TaskRecordSchema, + type TaskRecord, + TasksFileSchema, + type TasksFileData, +} from './schema.js'; export { createSharedClone, removeClone, diff --git a/src/infra/task/mapper.ts b/src/infra/task/mapper.ts new file mode 100644 index 0000000..ded75cf --- /dev/null +++ b/src/infra/task/mapper.ts @@ -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, + }; +} diff --git a/src/infra/task/naming.ts b/src/infra/task/naming.ts new file mode 100644 index 0000000..649fe8e --- /dev/null +++ b/src/infra/task/naming.ts @@ -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; +} diff --git a/src/infra/task/parser.ts b/src/infra/task/parser.ts deleted file mode 100644 index 9964054..0000000 --- a/src/infra/task/parser.ts +++ /dev/null @@ -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; -} diff --git a/src/infra/task/runner.ts b/src/infra/task/runner.ts index 0eb58b7..6c04996 100644 --- a/src/infra/task/runner.ts +++ b/src/infra/task/runner.ts @@ -1,393 +1,281 @@ -/** - * TAKT タスク実行モード - * - * .takt/tasks/ ディレクトリ内のタスクファイルを読み込み、 - * 順番に実行してレポートを生成する。 - * - * Supports both .md (plain text) and .yaml/.yml (structured) task files. - * - * 使用方法: - * /task # タスク一覧を表示 - * /task run # 次のタスクを実行 - * /task run # 指定したタスクを実行 - * /task list # タスク一覧を表示 - */ - -import * as fs from 'node:fs'; 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 { 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 }; -const log = createLogger('task-runner'); - -/** - * タスク実行管理クラス - */ export class TaskRunner { - private projectDir: string; - private tasksDir: string; - private completedDir: string; - private failedDir: string; - private claimedPaths = new Set(); + private readonly store: TaskStore; + private readonly tasksFile: string; - constructor(projectDir: string) { - this.projectDir = projectDir; - this.tasksDir = path.join(projectDir, '.takt', 'tasks'); - this.completedDir = path.join(projectDir, '.takt', 'completed'); - this.failedDir = path.join(projectDir, '.takt', 'failed'); + constructor(private readonly projectDir: string) { + this.store = new TaskStore(projectDir); + this.tasksFile = this.store.getTasksFilePath(); } - /** ディレクトリ構造を作成 */ ensureDirs(): void { - fs.mkdirSync(this.tasksDir, { recursive: true }); - fs.mkdirSync(this.completedDir, { recursive: true }); - fs.mkdirSync(this.failedDir, { recursive: true }); + this.store.ensureDirs(); } - /** タスクディレクトリのパスを取得 */ getTasksDir(): string { - return this.tasksDir; + return this.tasksFile; } - /** - * タスク一覧を取得 - * @returns タスク情報のリスト(ファイル名順) - */ - listTasks(): TaskInfo[] { - this.ensureDirs(); + addTask(content: string, options?: Omit): TaskInfo { + const state = this.store.update((current) => { + const name = this.generateTaskName(content, current.tasks.map((task) => task.name)); + const record: TaskRecord = TaskRecordSchema.parse({ + name, + status: 'pending', + content, + created_at: nowIso(), + started_at: null, + completed_at: null, + owner_pid: null, + ...options, + }); + return { tasks: [...current.tasks, record] }; + }); - try { - const parsed = parseTaskFiles(this.tasksDir); - return parsed.map(toTaskInfo); - } catch (err) { - const nodeErr = err as NodeJS.ErrnoException; - if (nodeErr.code !== 'ENOENT') { - throw err; // 予期しないエラーは再スロー - } - // ENOENT は許容(ディレクトリ未作成) + const created = state.tasks[state.tasks.length - 1]; + if (!created) { + throw new Error('Failed to create task.'); + } + return toTaskInfo(this.projectDir, this.tasksFile, created); + } + + listTasks(): TaskInfo[] { + 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 []; } + + 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)); } - /** - * 指定した名前のタスクを取得 - * Searches for .yaml, .yml, and .md files in that order. - */ - getTask(name: string): TaskInfo | null { - this.ensureDirs(); - - const extensions = ['.yaml', '.yml', '.md']; - - for (const ext of extensions) { - const filePath = path.join(this.tasksDir, `${name}${ext}`); - if (!fs.existsSync(filePath)) { - continue; - } - - try { - const parsed = parseTaskFile(filePath); - return toTaskInfo(parsed); - } catch { - // Parse error: skip this extension - } - } - - return null; + recoverInterruptedRunningTasks(): number { + let recovered = 0; + this.store.update((current) => { + const tasks = current.tasks.map((task) => { + if (task.status !== 'running' || !this.isRunningTaskStale(task)) { + return task; + } + recovered++; + return { + ...task, + status: 'pending', + started_at: null, + owner_pid: null, + } as TaskRecord; + }); + return { tasks }; + }); + return recovered; } - /** - * 次に実行すべきタスクを取得(最初のタスク) - */ - 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 { if (!result.success) { 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 { - return this.moveTask(result, this.failedDir); - } - - /** - * 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, + const failure: TaskFailure = { + movement: result.failureMovement, + error: result.response, + last_message: result.failureLastMessage ?? result.executionLog[result.executionLog.length - 1], }; - 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; } - /** - * レポートを生成 - */ - private generateReport(result: TaskResult): string { - const status = result.success ? '成功' : '失敗'; - - return `# タスク実行レポート - -## 基本情報 - -- タスク名: ${result.task.name} -- ステータス: ${status} -- 開始時刻: ${result.startedAt} -- 完了時刻: ${result.completedAt} - -## 元のタスク - -\`\`\`markdown -${result.task.content} -\`\`\` - -## 実行結果 - -${result.response} - ---- - -*Generated by TAKT Task Runner* -`; + listPendingTaskItems(): TaskListItem[] { + const state = this.store.read(); + return state.tasks + .filter((task) => task.status === 'pending') + .map((task) => toPendingTaskItem(this.projectDir, this.tasksFile, task)); + } + + listFailedTasks(): TaskListItem[] { + const state = this.store.read(); + return state.tasks + .filter((task) => task.status === 'failed') + .map((task) => toFailedTaskItem(this.projectDir, this.tasksFile, task)); + } + + requeueFailedTask(taskRef: string, startMovement?: string, retryNote?: string): string { + const taskName = this.normalizeTaskRef(taskRef); + + this.store.update((current) => { + const index = current.tasks.findIndex((task) => task.name === taskName && task.status === 'failed'); + if (index === -1) { + throw new Error(`Failed task not found: ${taskRef}`); + } + + const target = current.tasks[index]!; + const updated: TaskRecord = { + ...target, + status: 'pending', + 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, - }; } diff --git a/src/infra/task/schema.ts b/src/infra/task/schema.ts index 051d189..a884f0f 100644 --- a/src/infra/task/schema.ts +++ b/src/infra/task/schema.ts @@ -1,39 +1,184 @@ /** - * Task YAML schema definition - * - * Zod schema for structured task files (.yaml/.yml) + * Task schema definitions */ import { z } from 'zod/v4'; /** - * YAML task file schema - * - * 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} + * Per-task execution config schema. + * Used by `takt add` input and in-memory TaskInfo.data. */ -export const TaskFileSchema = z.object({ - task: z.string().min(1), +export const TaskExecutionConfigSchema = z.object({ worktree: z.union([z.boolean(), z.string()]).optional(), branch: z.string().optional(), piece: z.string().optional(), issue: z.number().int().positive().optional(), start_movement: z.string().optional(), retry_note: z.string().optional(), - /** Auto-create PR after worktree execution (default: prompt in interactive mode) */ 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; + +export const TaskStatusSchema = z.enum(['pending', 'running', 'completed', 'failed']); +export type TaskStatus = z.infer; + +export const TaskFailureSchema = z.object({ + movement: z.string().optional(), + error: z.string().min(1), + last_message: z.string().optional(), +}); +export type TaskFailure = z.infer; + +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; + +export const TasksFileSchema = z.object({ + tasks: z.array(TaskRecordSchema), +}); +export type TasksFileData = z.infer; diff --git a/src/infra/task/store.ts b/src/infra/task/store.ts new file mode 100644 index 0000000..ea6329c --- /dev/null +++ b/src/infra/task/store.ts @@ -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(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; + } + } +} diff --git a/src/infra/task/types.ts b/src/infra/task/types.ts index 523413a..2968a7c 100644 --- a/src/infra/task/types.ts +++ b/src/infra/task/types.ts @@ -3,6 +3,7 @@ */ import type { TaskFileData } from './schema.js'; +import type { TaskFailure, TaskStatus } from './schema.js'; /** タスク情報 */ export interface TaskInfo { @@ -10,7 +11,7 @@ export interface TaskInfo { name: string; content: string; createdAt: string; - /** Structured data from YAML files (null for .md files) */ + status: TaskStatus; data: TaskFileData | null; } @@ -20,6 +21,8 @@ export interface TaskResult { success: boolean; response: string; executionLog: string[]; + failureMovement?: string; + failureLastMessage?: string; startedAt: string; completedAt: string; } @@ -74,4 +77,6 @@ export interface TaskListItem { createdAt: string; filePath: string; content: string; + data?: TaskFileData; + failure?: TaskFailure; } diff --git a/src/infra/task/watcher.ts b/src/infra/task/watcher.ts index 5cb1b7f..f3cefa9 100644 --- a/src/infra/task/watcher.ts +++ b/src/infra/task/watcher.ts @@ -1,7 +1,7 @@ /** * 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. */ @@ -40,7 +40,8 @@ export class TaskWatcher { log.info('Watch started', { pollInterval: this.pollInterval }); while (this.running) { - const task = this.runner.getNextTask(); + const claimed = this.runner.claimNextTasks(1); + const task = claimed[0]; if (task) { log.info('Task found', { name: task.name });