From 919215fad30a3a5442b2f4244184a2fb129c793f Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Fri, 6 Feb 2026 17:22:22 +0900 Subject: [PATCH] =?UTF-8?q?resolved=20=E5=A4=B1=E6=95=97=E3=82=BF=E3=82=B9?= =?UTF-8?q?=E3=82=AF=E3=81=AE=E5=86=8D=E6=8A=95=E5=85=A5=E3=81=A8=E3=83=A0?= =?UTF-8?q?=E3=83=BC=E3=83=96=E3=83=A1=E3=83=B3=E3=83=88=E9=96=8B=E5=A7=8B?= =?UTF-8?q?=E4=BD=8D=E7=BD=AE=E3=81=AE=E9=81=B8=E6=8A=9E=E6=A9=9F=E8=83=BD?= =?UTF-8?q?=20#110?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- resources/skill/SKILL.md | 3 + src/__tests__/engine-happy-path.test.ts | 88 +++++ src/__tests__/session.test.ts | 128 +++++++ src/__tests__/task.test.ts | 145 ++++++++ src/__tests__/taskRetryActions.test.ts | 351 ++++++++++++++++++ src/core/piece/engine/MovementExecutor.ts | 2 + src/core/piece/engine/PieceEngine.ts | 9 + src/core/piece/engine/state-manager.ts | 2 +- .../piece/instruction/InstructionBuilder.ts | 6 + .../piece/instruction/instruction-context.ts | 2 + src/core/piece/types.ts | 4 + src/features/tasks/execute/pieceExecution.ts | 2 + src/features/tasks/execute/taskExecution.ts | 18 +- src/features/tasks/execute/types.ts | 8 + src/features/tasks/list/index.ts | 43 ++- src/features/tasks/list/taskRetryActions.ts | 241 ++++++++++++ src/infra/fs/session.ts | 78 ++++ src/infra/task/runner.ts | 85 +++++ src/infra/task/schema.ts | 2 + .../prompts/en/perform_phase1_message.md | 7 +- .../prompts/ja/perform_phase1_message.md | 7 +- 21 files changed, 1216 insertions(+), 15 deletions(-) create mode 100644 src/__tests__/taskRetryActions.test.ts create mode 100644 src/features/tasks/list/taskRetryActions.ts diff --git a/resources/skill/SKILL.md b/resources/skill/SKILL.md index dd5c6b6..e2226e7 100644 --- a/resources/skill/SKILL.md +++ b/resources/skill/SKILL.md @@ -15,6 +15,7 @@ description: TAKT ピースエンジン。Agent Team を使ったマルチエー - **自分で作業するな** — コーディング、レビュー、設計、テスト等は全てチームメイトに委任する - **タスクを自分で分析して1つの Task にまとめるな** — movement を1つずつ順番に実行せよ - **movement をスキップするな** — 必ず initial_movement から開始し、Rule 評価で決まった次の movement に進む +- **"yolo" をピース名と誤解するな** — "yolo" は YOLO(You Only Live Once)の俗語で「無謀・適当・いい加減」という意味。「yolo ではレビューして」= 「適当にやらずにちゃんとレビューして」という意味であり、ピース作成の指示ではない ### あなたの仕事は4つだけ @@ -23,6 +24,8 @@ description: TAKT ピースエンジン。Agent Team を使ったマルチエー 3. **Task tool** でチームメイトを起動して作業を委任する 4. チームメイトの出力から Rule 評価を行い、次の movement を決定する +**重要**: ユーザーが明示的に指示するまで git commit を実行してはならない。実装完了 ≠ コミット許可。 + ### ツールの使い分け(重要) | やること | 使うツール | 説明 | diff --git a/src/__tests__/engine-happy-path.test.ts b/src/__tests__/engine-happy-path.test.ts index 8fa0a8f..329b3bb 100644 --- a/src/__tests__/engine-happy-path.test.ts +++ b/src/__tests__/engine-happy-path.test.ts @@ -622,5 +622,93 @@ describe('PieceEngine Integration: Happy Path', () => { new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); }).toThrow('nonexistent_step'); }); + + it('should throw when startMovement option references nonexistent movement', () => { + const config = buildDefaultPieceConfig(); + + expect(() => { + new PieceEngine(config, tmpDir, 'test task', { + projectCwd: tmpDir, + startMovement: 'nonexistent', + }); + }).toThrow('Unknown movement: nonexistent'); + }); + }); + + // ===================================================== + // 9. startMovement option + // ===================================================== + describe('startMovement option', () => { + it('should start from specified movement instead of initialMovement', async () => { + const config = buildDefaultPieceConfig(); + // Start from ai_review, skipping plan and implement + engine = new PieceEngine(config, tmpDir, 'test task', { + projectCwd: tmpDir, + startMovement: 'ai_review', + }); + + mockRunAgentSequence([ + makeResponse({ agent: 'ai_review', content: 'No issues' }), + makeResponse({ agent: 'arch-review', content: 'Architecture OK' }), + makeResponse({ agent: 'security-review', content: 'Security OK' }), + makeResponse({ agent: 'supervise', content: 'All passed' }), + ]); + + mockDetectMatchedRuleSequence([ + { index: 0, method: 'phase1_tag' }, // ai_review → reviewers + { index: 0, method: 'phase1_tag' }, // arch-review → approved + { index: 0, method: 'phase1_tag' }, // security-review → approved + { index: 0, method: 'aggregate' }, // reviewers(all approved) → supervise + { index: 0, method: 'phase1_tag' }, // supervise → COMPLETE + ]); + + const startFn = vi.fn(); + engine.on('movement:start', startFn); + + const state = await engine.run(); + + expect(state.status).toBe('completed'); + // Should only run 3 movements: ai_review, reviewers, supervise + expect(state.iteration).toBe(3); + + // First movement should be ai_review, not plan + const startedMovements = startFn.mock.calls.map(call => (call[0] as PieceMovement).name); + expect(startedMovements[0]).toBe('ai_review'); + expect(startedMovements).not.toContain('plan'); + expect(startedMovements).not.toContain('implement'); + }); + + it('should use initialMovement when startMovement is not specified', async () => { + const config = buildDefaultPieceConfig(); + engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir }); + + mockRunAgentSequence([ + makeResponse({ agent: 'plan', content: 'Plan complete' }), + makeResponse({ agent: 'implement', content: 'Implementation done' }), + makeResponse({ agent: 'ai_review', content: 'No issues' }), + makeResponse({ agent: 'arch-review', content: 'Architecture OK' }), + makeResponse({ agent: 'security-review', content: 'Security OK' }), + makeResponse({ agent: 'supervise', content: 'All passed' }), + ]); + + mockDetectMatchedRuleSequence([ + { index: 0, method: 'phase1_tag' }, + { index: 0, method: 'phase1_tag' }, + { index: 0, method: 'phase1_tag' }, + { index: 0, method: 'phase1_tag' }, + { index: 0, method: 'phase1_tag' }, + { index: 0, method: 'aggregate' }, + { index: 0, method: 'phase1_tag' }, + ]); + + const startFn = vi.fn(); + engine.on('movement:start', startFn); + + await engine.run(); + + // First movement should be plan (the initialMovement) + const startedMovements = startFn.mock.calls.map(call => (call[0] as PieceMovement).name); + expect(startedMovements[0]).toBe('plan'); + }); }); }); diff --git a/src/__tests__/session.test.ts b/src/__tests__/session.test.ts index fd0bc00..6d583fa 100644 --- a/src/__tests__/session.test.ts +++ b/src/__tests__/session.test.ts @@ -13,6 +13,7 @@ import { appendNdjsonLine, loadNdjsonLog, loadSessionLog, + extractFailureInfo, type LatestLogPointer, type SessionLog, type NdjsonRecord, @@ -638,4 +639,131 @@ describe('NDJSON log', () => { expect(log!.history).toHaveLength(0); }); }); + + describe('extractFailureInfo', () => { + it('should return null for non-existent file', () => { + const result = extractFailureInfo('/nonexistent/path.jsonl'); + expect(result).toBeNull(); + }); + + it('should extract failure info from aborted piece log', () => { + const filepath = initNdjsonLog('20260205-120000-abc123', 'failing task', 'wf', projectDir); + + // Add step_start for plan + appendNdjsonLine(filepath, { + type: 'step_start', + step: 'plan', + agent: 'planner', + iteration: 1, + timestamp: '2025-01-01T00:00:01.000Z', + }); + + // Add step_complete for plan + appendNdjsonLine(filepath, { + type: 'step_complete', + step: 'plan', + agent: 'planner', + status: 'done', + content: 'Plan done', + instruction: 'Plan it', + timestamp: '2025-01-01T00:00:02.000Z', + } satisfies NdjsonStepComplete); + + // Add step_start for implement (fails before completing) + appendNdjsonLine(filepath, { + type: 'step_start', + step: 'implement', + agent: 'coder', + iteration: 2, + timestamp: '2025-01-01T00:00:03.000Z', + }); + + // Add piece_abort + appendNdjsonLine(filepath, { + type: 'piece_abort', + iterations: 1, + reason: 'spawn node ENOENT', + endTime: '2025-01-01T00:00:04.000Z', + } satisfies NdjsonPieceAbort); + + const result = extractFailureInfo(filepath); + expect(result).not.toBeNull(); + expect(result!.lastCompletedMovement).toBe('plan'); + expect(result!.failedMovement).toBe('implement'); + expect(result!.iterations).toBe(1); + expect(result!.errorMessage).toBe('spawn node ENOENT'); + expect(result!.sessionId).toBe('20260205-120000-abc123'); + }); + + it('should handle log with only completed movements (no abort)', () => { + const filepath = initNdjsonLog('sess-success-001', 'task', 'wf', projectDir); + + appendNdjsonLine(filepath, { + type: 'step_start', + step: 'plan', + agent: 'planner', + iteration: 1, + timestamp: '2025-01-01T00:00:01.000Z', + }); + + appendNdjsonLine(filepath, { + type: 'step_complete', + step: 'plan', + agent: 'planner', + status: 'done', + content: 'Plan done', + instruction: 'Plan it', + timestamp: '2025-01-01T00:00:02.000Z', + } satisfies NdjsonStepComplete); + + appendNdjsonLine(filepath, { + type: 'piece_complete', + iterations: 1, + endTime: '2025-01-01T00:00:03.000Z', + }); + + const result = extractFailureInfo(filepath); + expect(result).not.toBeNull(); + expect(result!.lastCompletedMovement).toBe('plan'); + expect(result!.failedMovement).toBeNull(); + expect(result!.iterations).toBe(1); + expect(result!.errorMessage).toBeNull(); + }); + + it('should handle log with no step_complete records', () => { + const filepath = initNdjsonLog('sess-fail-early-001', 'task', 'wf', projectDir); + + appendNdjsonLine(filepath, { + type: 'step_start', + step: 'plan', + agent: 'planner', + iteration: 1, + timestamp: '2025-01-01T00:00:01.000Z', + }); + + appendNdjsonLine(filepath, { + type: 'piece_abort', + iterations: 0, + reason: 'API error', + endTime: '2025-01-01T00:00:02.000Z', + } satisfies NdjsonPieceAbort); + + const result = extractFailureInfo(filepath); + expect(result).not.toBeNull(); + expect(result!.lastCompletedMovement).toBeNull(); + expect(result!.failedMovement).toBe('plan'); + expect(result!.iterations).toBe(0); + expect(result!.errorMessage).toBe('API error'); + }); + + it('should return null for empty file', () => { + const logsDir = join(projectDir, '.takt', 'logs'); + mkdirSync(logsDir, { recursive: true }); + const filepath = join(logsDir, 'empty.jsonl'); + writeFileSync(filepath, '', 'utf-8'); + + const result = extractFailureInfo(filepath); + expect(result).toBeNull(); + }); + }); }); diff --git a/src/__tests__/task.test.ts b/src/__tests__/task.test.ts index 8ec3ba7..20ce2c8 100644 --- a/src/__tests__/task.test.ts +++ b/src/__tests__/task.test.ts @@ -273,4 +273,149 @@ describe('TaskRunner', () => { expect(runner.getTasksDir()).toBe(join(testDir, '.takt', 'tasks')); }); }); + + describe('requeueFailedTask', () => { + it('should copy task file from failed to tasks directory', () => { + runner.ensureDirs(); + + // 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 result = runner.requeueFailedTask(failedDir); + + // Task file should be copied to tasks dir + expect(existsSync(result)).toBe(true); + expect(result).toBe(join(testDir, '.takt', 'tasks', 'my-task.yaml')); + + // Original failed directory should still exist + expect(existsSync(failedDir)).toBe(true); + + // Task content should be preserved + const content = readFileSync(result, 'utf-8'); + expect(content).toBe('task: Do something\n'); + }); + + it('should add start_movement to YAML task file when specified', () => { + runner.ensureDirs(); + + 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'); + + 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'); + }); + + it('should replace existing start_movement in YAML task file', () => { + runner.ensureDirs(); + + 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 not modify markdown task files even with startMovement', () => { + runner.ensureDirs(); + + 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 result = runner.requeueFailedTask(failedDir, 'implement'); + + 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'); + }); + }); }); diff --git a/src/__tests__/taskRetryActions.test.ts b/src/__tests__/taskRetryActions.test.ts new file mode 100644 index 0000000..4032a13 --- /dev/null +++ b/src/__tests__/taskRetryActions.test.ts @@ -0,0 +1,351 @@ +/** + * 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 { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('../shared/prompt/index.js', () => ({ + selectOption: vi.fn(), + promptInput: vi.fn(), +})); + +vi.mock('../shared/ui/index.js', () => ({ + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + header: vi.fn(), + blankLine: vi.fn(), + status: vi.fn(), +})); + +vi.mock('../shared/utils/index.js', async (importOriginal) => ({ + ...(await importOriginal>()), + createLogger: () => ({ + info: vi.fn(), + error: vi.fn(), + }), +})); + +vi.mock('../infra/fs/session.js', () => ({ + extractFailureInfo: vi.fn(), +})); + +vi.mock('../infra/config/index.js', () => ({ + loadGlobalConfig: vi.fn(), + loadPieceByIdentifier: vi.fn(), +})); + +import { selectOption, promptInput } from '../shared/prompt/index.js'; +import { success, error as logError } from '../shared/ui/index.js'; +import { loadGlobalConfig, loadPieceByIdentifier } from '../infra/config/index.js'; +import { retryFailedTask } from '../features/tasks/list/taskRetryActions.js'; +import type { TaskListItem } from '../infra/task/types.js'; +import type { PieceConfig } from '../core/models/index.js'; + +const mockSelectOption = vi.mocked(selectOption); +const mockPromptInput = vi.mocked(promptInput); +const mockSuccess = vi.mocked(success); +const mockLogError = vi.mocked(logError); +const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig); +const mockLoadPieceByIdentifier = vi.mocked(loadPieceByIdentifier); + +let tmpDir: string; + +const defaultPieceConfig: PieceConfig = { + name: 'default', + description: 'Default piece', + initialMovement: 'plan', + maxIterations: 30, + movements: [ + { name: 'plan', agent: 'planner', instruction: '' }, + { name: 'implement', agent: 'coder', instruction: '' }, + { name: 'review', agent: 'reviewer', instruction: '' }, + ], +}; + +const customPieceConfig: PieceConfig = { + name: 'custom', + description: 'Custom piece', + initialMovement: 'step1', + maxIterations: 10, + movements: [ + { name: 'step1', agent: 'coder', instruction: '' }, + { name: 'step2', agent: 'reviewer', instruction: '' }, + ], +}; + +beforeEach(() => { + vi.clearAllMocks(); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-test-retry-')); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +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', + }; + + mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' }); + mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig); + mockSelectOption.mockResolvedValue('implement'); + mockPromptInput.mockResolvedValue(''); // Empty retry note + + // 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); + }); + + 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', + }; + + mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' }); + mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig); + mockSelectOption.mockResolvedValue('plan'); // Initial movement + mockPromptInput.mockResolvedValue(''); // Empty retry note + + // 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'); + }); + + 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', + }; + + mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' }); + mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig); + mockSelectOption.mockResolvedValue('implement'); + mockPromptInput.mockResolvedValue('Fixed spawn node ENOENT error'); + + // 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'); + }); +}); diff --git a/src/core/piece/engine/MovementExecutor.ts b/src/core/piece/engine/MovementExecutor.ts index 648aba4..a9dae0b 100644 --- a/src/core/piece/engine/MovementExecutor.ts +++ b/src/core/piece/engine/MovementExecutor.ts @@ -35,6 +35,7 @@ export interface MovementExecutorDeps { readonly getPieceMovements: () => ReadonlyArray<{ name: string; description?: string }>; readonly getPieceName: () => string; readonly getPieceDescription: () => string | undefined; + readonly getRetryNote: () => string | undefined; readonly detectRuleIndex: (content: string, movementName: string) => number; readonly callAiJudge: ( agentOutput: string, @@ -75,6 +76,7 @@ export class MovementExecutor { currentMovementIndex: pieceMovements.findIndex(s => s.name === step.name), pieceName: this.deps.getPieceName(), pieceDescription: this.deps.getPieceDescription(), + retryNote: this.deps.getRetryNote(), }).build(); } diff --git a/src/core/piece/engine/PieceEngine.ts b/src/core/piece/engine/PieceEngine.ts index dbc202c..5037184 100644 --- a/src/core/piece/engine/PieceEngine.ts +++ b/src/core/piece/engine/PieceEngine.ts @@ -110,6 +110,7 @@ export class PieceEngine extends EventEmitter { getPieceMovements: () => this.config.movements.map(s => ({ name: s.name, description: s.description })), getPieceName: () => this.getPieceName(), getPieceDescription: () => this.getPieceDescription(), + getRetryNote: () => this.options.retryNote, detectRuleIndex: this.detectRuleIndex, callAiJudge: this.callAiJudge, onPhaseStart: (step, phase, phaseName, instruction) => { @@ -160,6 +161,14 @@ export class PieceEngine extends EventEmitter { throw new Error(ERROR_MESSAGES.UNKNOWN_MOVEMENT(this.config.initialMovement)); } + // Validate startMovement option if specified + if (this.options.startMovement) { + const startMovement = this.config.movements.find((s) => s.name === this.options.startMovement); + if (!startMovement) { + throw new Error(ERROR_MESSAGES.UNKNOWN_MOVEMENT(this.options.startMovement)); + } + } + const movementNames = new Set(this.config.movements.map((s) => s.name)); movementNames.add(COMPLETE_MOVEMENT); movementNames.add(ABORT_MOVEMENT); diff --git a/src/core/piece/engine/state-manager.ts b/src/core/piece/engine/state-manager.ts index 60fe1ab..82ca9f2 100644 --- a/src/core/piece/engine/state-manager.ts +++ b/src/core/piece/engine/state-manager.ts @@ -36,7 +36,7 @@ export class StateManager { this.state = { pieceName: config.name, - currentMovement: config.initialMovement, + currentMovement: options.startMovement ?? config.initialMovement, iteration: 0, movementOutputs: new Map(), lastOutput: undefined, diff --git a/src/core/piece/instruction/InstructionBuilder.ts b/src/core/piece/instruction/InstructionBuilder.ts index 5773f01..2fce3aa 100644 --- a/src/core/piece/instruction/InstructionBuilder.ts +++ b/src/core/piece/instruction/InstructionBuilder.ts @@ -94,6 +94,10 @@ export class InstructionBuilder { const pieceDescription = this.context.pieceDescription ?? ''; const hasPieceDescription = !!pieceDescription; + // Retry note + const hasRetryNote = !!this.context.retryNote; + const retryNote = hasRetryNote ? escapeTemplateChars(this.context.retryNote!) : ''; + return loadTemplate('perform_phase1_message', language, { workingDirectory: this.context.cwd, editRule, @@ -113,6 +117,8 @@ export class InstructionBuilder { previousResponse, hasUserInputs, userInputs, + hasRetryNote, + retryNote, instructions, }); } diff --git a/src/core/piece/instruction/instruction-context.ts b/src/core/piece/instruction/instruction-context.ts index c9e4cf4..838de9c 100644 --- a/src/core/piece/instruction/instruction-context.ts +++ b/src/core/piece/instruction/instruction-context.ts @@ -40,6 +40,8 @@ export interface InstructionContext { pieceName?: string; /** Piece description (optional) */ pieceDescription?: string; + /** Retry note explaining why task is being retried */ + retryNote?: string; } /** diff --git a/src/core/piece/types.ts b/src/core/piece/types.ts index 2527a50..9ee3481 100644 --- a/src/core/piece/types.ts +++ b/src/core/piece/types.ts @@ -183,6 +183,10 @@ export interface PieceEngineOptions { detectRuleIndex?: RuleIndexDetector; /** AI judge caller (required for rules evaluation) */ callAiJudge?: AiJudgeCaller; + /** Override initial movement (default: piece config's initialMovement) */ + startMovement?: string; + /** Retry note explaining why task is being retried */ + retryNote?: string; } /** Loop detection result */ diff --git a/src/features/tasks/execute/pieceExecution.ts b/src/features/tasks/execute/pieceExecution.ts index 8ab8efc..d39790a 100644 --- a/src/features/tasks/execute/pieceExecution.ts +++ b/src/features/tasks/execute/pieceExecution.ts @@ -228,6 +228,8 @@ export async function executePiece( interactive: interactiveUserInput, detectRuleIndex, callAiJudge, + startMovement: options.startMovement, + retryNote: options.retryNote, }); let abortReason: string | undefined; diff --git a/src/features/tasks/execute/taskExecution.ts b/src/features/tasks/execute/taskExecution.ts index 312b8cf..e97bde4 100644 --- a/src/features/tasks/execute/taskExecution.ts +++ b/src/features/tasks/execute/taskExecution.ts @@ -25,7 +25,7 @@ const log = createLogger('task'); * Execute a single task with piece. */ export async function executeTask(options: ExecuteTaskOptions): Promise { - const { task, cwd, pieceIdentifier, projectCwd, agentOverrides, interactiveUserInput, interactiveMetadata } = options; + const { task, cwd, pieceIdentifier, projectCwd, agentOverrides, interactiveUserInput, interactiveMetadata, startMovement, retryNote } = options; const pieceConfig = loadPieceByIdentifier(pieceIdentifier, projectCwd); if (!pieceConfig) { @@ -52,6 +52,8 @@ export async function executeTask(options: ExecuteTaskOptions): Promise model: agentOverrides?.model, interactiveUserInput, interactiveMetadata, + startMovement, + retryNote, }); return result.success; } @@ -75,7 +77,7 @@ export async function executeAndCompleteTask( const executionLog: string[] = []; try { - const { execCwd, execPiece, isWorktree } = await resolveTaskExecution(task, cwd, pieceName); + const { execCwd, execPiece, isWorktree, startMovement, retryNote } = await resolveTaskExecution(task, cwd, pieceName); // cwd is always the project root; pass it as projectCwd so reports/sessions go there const taskSuccess = await executeTask({ @@ -84,6 +86,8 @@ export async function executeAndCompleteTask( pieceIdentifier: execPiece, projectCwd: cwd, agentOverrides: options, + startMovement, + retryNote, }); const completedAt = new Date().toISOString(); @@ -194,7 +198,7 @@ export async function resolveTaskExecution( task: TaskInfo, defaultCwd: string, defaultPiece: string -): Promise<{ execCwd: string; execPiece: string; isWorktree: boolean; branch?: string }> { +): Promise<{ execCwd: string; execPiece: string; isWorktree: boolean; branch?: string; startMovement?: string; retryNote?: string }> { const data = task.data; // No structured data: use defaults @@ -227,5 +231,11 @@ export async function resolveTaskExecution( // Handle piece override const execPiece = data.piece || defaultPiece; - return { execCwd, execPiece, isWorktree, branch }; + // Handle start_movement override + const startMovement = data.start_movement; + + // Handle retry_note + const retryNote = data.retry_note; + + return { execCwd, execPiece, isWorktree, branch, startMovement, retryNote }; } diff --git a/src/features/tasks/execute/types.ts b/src/features/tasks/execute/types.ts index 206ca94..04d3850 100644 --- a/src/features/tasks/execute/types.ts +++ b/src/features/tasks/execute/types.ts @@ -34,6 +34,10 @@ export interface PieceExecutionOptions { interactiveUserInput?: boolean; /** Interactive mode result metadata for NDJSON logging */ interactiveMetadata?: InteractiveMetadata; + /** Override initial movement (default: piece config's initialMovement) */ + startMovement?: string; + /** Retry note explaining why task is being retried */ + retryNote?: string; } export interface TaskExecutionOptions { @@ -56,6 +60,10 @@ export interface ExecuteTaskOptions { interactiveUserInput?: boolean; /** Interactive mode result metadata for NDJSON logging */ interactiveMetadata?: InteractiveMetadata; + /** Override initial movement (default: piece config's initialMovement) */ + startMovement?: string; + /** Retry note explaining why task is being retried */ + retryNote?: string; } export interface PipelineExecutionOptions { diff --git a/src/features/tasks/list/index.ts b/src/features/tasks/list/index.ts index 140787a..8dfa0bf 100644 --- a/src/features/tasks/list/index.ts +++ b/src/features/tasks/list/index.ts @@ -28,6 +28,7 @@ import { instructBranch, } from './taskActions.js'; import { deletePendingTask, deleteFailedTask } from './taskDeleteActions.js'; +import { retryFailedTask } from './taskRetryActions.js'; import { listTasksNonInteractive, type ListNonInteractiveOptions } from './listNonInteractive.js'; export type { ListNonInteractiveOptions } from './listNonInteractive.js'; @@ -42,14 +43,17 @@ export { instructBranch, } from './taskActions.js'; -/** Task action type for the task action selection menu */ -type TaskAction = 'delete'; +/** Task action type for pending task action selection menu */ +type PendingTaskAction = 'delete'; + +/** Task action type for failed task action selection menu */ +type FailedTaskAction = 'retry' | 'delete'; /** - * Show task details and prompt for an action. + * Show pending task details and prompt for an action. * Returns the selected action, or null if cancelled. */ -async function showTaskAndPromptAction(task: TaskListItem): Promise { +async function showPendingTaskAndPromptAction(task: TaskListItem): Promise { header(`[${task.kind}] ${task.name}`); info(` Created: ${task.createdAt}`); if (task.content) { @@ -57,12 +61,33 @@ async function showTaskAndPromptAction(task: TaskListItem): Promise( + return await selectOption( `Action for ${task.name}:`, [{ label: 'Delete', value: 'delete', description: 'Remove this task permanently' }], ); } +/** + * Show failed task details and prompt for an action. + * Returns the selected action, or null if cancelled. + */ +async function showFailedTaskAndPromptAction(task: TaskListItem): Promise { + header(`[${task.kind}] ${task.name}`); + info(` Failed at: ${task.createdAt}`); + if (task.content) { + info(` ${task.content}`); + } + blankLine(); + + return await selectOption( + `Action for ${task.name}:`, + [ + { label: 'Retry', value: 'retry', description: 'Requeue task and select start movement' }, + { label: 'Delete', value: 'delete', description: 'Remove this task permanently' }, + ], + ); +} + /** * Main entry point: list branch-based tasks interactively. */ @@ -170,15 +195,17 @@ export async function listTasks( } else if (type === 'pending') { const task = pendingTasks[idx]; if (!task) continue; - const taskAction = await showTaskAndPromptAction(task); + const taskAction = await showPendingTaskAndPromptAction(task); if (taskAction === 'delete') { await deletePendingTask(task); } } else if (type === 'failed') { const task = failedTasks[idx]; if (!task) continue; - const taskAction = await showTaskAndPromptAction(task); - if (taskAction === 'delete') { + const taskAction = await showFailedTaskAndPromptAction(task); + if (taskAction === 'retry') { + await retryFailedTask(task, cwd); + } else if (taskAction === 'delete') { await deleteFailedTask(task); } } diff --git a/src/features/tasks/list/taskRetryActions.ts b/src/features/tasks/list/taskRetryActions.ts new file mode 100644 index 0000000..615e0b0 --- /dev/null +++ b/src/features/tasks/list/taskRetryActions.ts @@ -0,0 +1,241 @@ +/** + * Retry actions for failed tasks. + * + * Provides interactive retry functionality including + * 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 { 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'; +import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; +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 { + header(`Failed Task: ${task.name}`); + info(` Failed at: ${task.createdAt}`); + + if (failureInfo) { + blankLine(); + if (failureInfo.lastCompletedMovement) { + status('Last completed', failureInfo.lastCompletedMovement); + } + if (failureInfo.failedMovement) { + status('Failed at', failureInfo.failedMovement, 'red'); + } + 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; + const effectiveDefault = defaultIdx >= 0 ? movements[defaultIdx] : movements[0]; + + const options = movements.map((name) => ({ + label: name === effectiveDefault ? `${name} (default)` : name, + value: name, + description: name === pieceConfig.initialMovement ? 'Initial movement' : undefined, + })); + + return await selectOption('Start from movement:', options); +} + +/** + * Retry a failed task. + * Shows failure info, prompts for movement selection, and requeues the task. + * + * @returns true if task was requeued, false if cancelled + */ +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; + + // 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 pieceConfig = loadPieceByIdentifier(pieceName, projectDir); + + if (!pieceConfig) { + logError(`Piece "${pieceName}" not found. Cannot determine available movements.`); + 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); + if (selectedMovement === null) { + return false; // User cancelled + } + + // 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 + ); + + success(`Task requeued: ${task.name}`); + if (startMovement) { + info(` Will start from: ${startMovement}`); + } + if (trimmedNote) { + info(` Retry note: ${trimmedNote}`); + } + info(` Task file: ${requeuedPath}`); + + log.info('Requeued failed task', { + name: task.name, + from: task.filePath, + to: requeuedPath, + startMovement, + retryNote: trimmedNote, + }); + + return true; + } catch (err) { + const msg = getErrorMessage(err); + logError(`Failed to requeue task: ${msg}`); + log.error('Failed to requeue task', { name: task.name, error: msg }); + return false; + } +} diff --git a/src/infra/fs/session.ts b/src/infra/fs/session.ts index 2cea5f7..81d4fd4 100644 --- a/src/infra/fs/session.ts +++ b/src/infra/fs/session.ts @@ -28,6 +28,20 @@ export type { LatestLogPointer, } from '../../shared/utils/index.js'; +/** Failure information extracted from session log */ +export interface FailureInfo { + /** Last movement that completed successfully */ + lastCompletedMovement: string | null; + /** Movement that was in progress when failure occurred */ + failedMovement: string | null; + /** Total iterations consumed */ + iterations: number; + /** Error message from piece_abort record */ + errorMessage: string | null; + /** Session ID extracted from log file name */ + sessionId: string | null; +} + /** * Manages session lifecycle: ID generation, NDJSON logging, * session log creation/loading, and latest pointer maintenance. @@ -298,3 +312,67 @@ export function updateLatestPointer( ): void { defaultManager.updateLatestPointer(log, sessionId, projectDir, options); } + +/** + * Extract failure information from an NDJSON session log file. + * + * @param filepath - Path to the .jsonl session log file + * @returns FailureInfo or null if file doesn't exist or is invalid + */ +export function extractFailureInfo(filepath: string): FailureInfo | null { + if (!existsSync(filepath)) { + return null; + } + + const content = readFileSync(filepath, 'utf-8'); + const lines = content.trim().split('\n').filter((line) => line.length > 0); + if (lines.length === 0) return null; + + let lastCompletedMovement: string | null = null; + let failedMovement: string | null = null; + let iterations = 0; + let errorMessage: string | null = null; + let lastStepStartMovement: string | null = null; + + // Extract sessionId from filename (e.g., "20260205-120000-abc123.jsonl" -> "20260205-120000-abc123") + const filename = filepath.split('/').pop(); + const sessionId = filename?.replace(/\.jsonl$/, '') ?? null; + + for (const line of lines) { + try { + const record = JSON.parse(line) as NdjsonRecord; + + switch (record.type) { + case 'step_start': + // Track the movement that started (may fail before completing) + lastStepStartMovement = record.step; + break; + + case 'step_complete': + // Track the last successfully completed movement + lastCompletedMovement = record.step; + iterations++; + // Reset lastStepStartMovement since this movement completed + lastStepStartMovement = null; + break; + + case 'piece_abort': + // If there was a step_start without a step_complete, that's the failed movement + failedMovement = lastStepStartMovement; + errorMessage = record.reason; + break; + } + } catch { + // Skip malformed JSON lines + continue; + } + } + + return { + lastCompletedMovement, + failedMovement, + iterations, + errorMessage, + sessionId, + }; +} diff --git a/src/infra/task/runner.ts b/src/infra/task/runner.ts index a443b8b..6d34c36 100644 --- a/src/infra/task/runner.ts +++ b/src/infra/task/runner.ts @@ -204,6 +204,91 @@ export class TaskRunner { 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; + } + /** * タスクファイルを指定ディレクトリに移動し、レポート・ログを生成する */ diff --git a/src/infra/task/schema.ts b/src/infra/task/schema.ts index eeedf91..32401e2 100644 --- a/src/infra/task/schema.ts +++ b/src/infra/task/schema.ts @@ -30,6 +30,8 @@ export const TaskFileSchema = z.object({ branch: z.string().optional(), piece: z.string().optional(), issue: z.number().int().positive().optional(), + start_movement: z.string().optional(), + retry_note: z.string().optional(), }); export type TaskFileData = z.infer; diff --git a/src/shared/prompts/en/perform_phase1_message.md b/src/shared/prompts/en/perform_phase1_message.md index 90ce1e5..c053e50 100644 --- a/src/shared/prompts/en/perform_phase1_message.md +++ b/src/shared/prompts/en/perform_phase1_message.md @@ -4,7 +4,7 @@ vars: workingDirectory, editRule, pieceName, pieceDescription, hasPieceDescription, pieceStructure, iteration, movementIteration, movement, hasReport, reportInfo, phaseNote, hasTaskSection, userRequest, hasPreviousResponse, previousResponse, - hasUserInputs, userInputs, instructions + hasUserInputs, userInputs, hasRetryNote, retryNote, instructions builder: InstructionBuilder --> ## Execution Context @@ -29,6 +29,11 @@ Note: This section is metadata. Follow the language used in the rest of the prom {{#if hasReport}}{{reportInfo}} {{phaseNote}}{{/if}} +{{#if hasRetryNote}} + +## Retry Note +{{retryNote}} +{{/if}} {{#if hasTaskSection}} ## User Request diff --git a/src/shared/prompts/ja/perform_phase1_message.md b/src/shared/prompts/ja/perform_phase1_message.md index 7756be8..560fee2 100644 --- a/src/shared/prompts/ja/perform_phase1_message.md +++ b/src/shared/prompts/ja/perform_phase1_message.md @@ -4,7 +4,7 @@ vars: workingDirectory, editRule, pieceName, pieceDescription, hasPieceDescription, pieceStructure, iteration, movementIteration, movement, hasReport, reportInfo, phaseNote, hasTaskSection, userRequest, hasPreviousResponse, previousResponse, - hasUserInputs, userInputs, instructions + hasUserInputs, userInputs, hasRetryNote, retryNote, instructions builder: InstructionBuilder --> ## 実行コンテキスト @@ -28,6 +28,11 @@ {{#if hasReport}}{{reportInfo}} {{phaseNote}}{{/if}} +{{#if hasRetryNote}} + +## 再投入メモ +{{retryNote}} +{{/if}} {{#if hasTaskSection}} ## User Request