resolved 失敗タスクの再投入とムーブメント開始位置の選択機能 #110
This commit is contained in:
parent
163561a5b3
commit
919215fad3
@ -15,6 +15,7 @@ description: TAKT ピースエンジン。Agent Team を使ったマルチエー
|
|||||||
- **自分で作業するな** — コーディング、レビュー、設計、テスト等は全てチームメイトに委任する
|
- **自分で作業するな** — コーディング、レビュー、設計、テスト等は全てチームメイトに委任する
|
||||||
- **タスクを自分で分析して1つの Task にまとめるな** — movement を1つずつ順番に実行せよ
|
- **タスクを自分で分析して1つの Task にまとめるな** — movement を1つずつ順番に実行せよ
|
||||||
- **movement をスキップするな** — 必ず initial_movement から開始し、Rule 評価で決まった次の movement に進む
|
- **movement をスキップするな** — 必ず initial_movement から開始し、Rule 評価で決まった次の movement に進む
|
||||||
|
- **"yolo" をピース名と誤解するな** — "yolo" は YOLO(You Only Live Once)の俗語で「無謀・適当・いい加減」という意味。「yolo ではレビューして」= 「適当にやらずにちゃんとレビューして」という意味であり、ピース作成の指示ではない
|
||||||
|
|
||||||
### あなたの仕事は4つだけ
|
### あなたの仕事は4つだけ
|
||||||
|
|
||||||
@ -23,6 +24,8 @@ description: TAKT ピースエンジン。Agent Team を使ったマルチエー
|
|||||||
3. **Task tool** でチームメイトを起動して作業を委任する
|
3. **Task tool** でチームメイトを起動して作業を委任する
|
||||||
4. チームメイトの出力から Rule 評価を行い、次の movement を決定する
|
4. チームメイトの出力から Rule 評価を行い、次の movement を決定する
|
||||||
|
|
||||||
|
**重要**: ユーザーが明示的に指示するまで git commit を実行してはならない。実装完了 ≠ コミット許可。
|
||||||
|
|
||||||
### ツールの使い分け(重要)
|
### ツールの使い分け(重要)
|
||||||
|
|
||||||
| やること | 使うツール | 説明 |
|
| やること | 使うツール | 説明 |
|
||||||
|
|||||||
@ -622,5 +622,93 @@ describe('PieceEngine Integration: Happy Path', () => {
|
|||||||
new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
|
new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
|
||||||
}).toThrow('nonexistent_step');
|
}).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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
appendNdjsonLine,
|
appendNdjsonLine,
|
||||||
loadNdjsonLog,
|
loadNdjsonLog,
|
||||||
loadSessionLog,
|
loadSessionLog,
|
||||||
|
extractFailureInfo,
|
||||||
type LatestLogPointer,
|
type LatestLogPointer,
|
||||||
type SessionLog,
|
type SessionLog,
|
||||||
type NdjsonRecord,
|
type NdjsonRecord,
|
||||||
@ -638,4 +639,131 @@ describe('NDJSON log', () => {
|
|||||||
expect(log!.history).toHaveLength(0);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -273,4 +273,149 @@ describe('TaskRunner', () => {
|
|||||||
expect(runner.getTasksDir()).toBe(join(testDir, '.takt', 'tasks'));
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
351
src/__tests__/taskRetryActions.test.ts
Normal file
351
src/__tests__/taskRetryActions.test.ts
Normal file
@ -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<Record<string, unknown>>()),
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -35,6 +35,7 @@ export interface MovementExecutorDeps {
|
|||||||
readonly getPieceMovements: () => ReadonlyArray<{ name: string; description?: string }>;
|
readonly getPieceMovements: () => ReadonlyArray<{ name: string; description?: string }>;
|
||||||
readonly getPieceName: () => string;
|
readonly getPieceName: () => string;
|
||||||
readonly getPieceDescription: () => string | undefined;
|
readonly getPieceDescription: () => string | undefined;
|
||||||
|
readonly getRetryNote: () => string | undefined;
|
||||||
readonly detectRuleIndex: (content: string, movementName: string) => number;
|
readonly detectRuleIndex: (content: string, movementName: string) => number;
|
||||||
readonly callAiJudge: (
|
readonly callAiJudge: (
|
||||||
agentOutput: string,
|
agentOutput: string,
|
||||||
@ -75,6 +76,7 @@ export class MovementExecutor {
|
|||||||
currentMovementIndex: pieceMovements.findIndex(s => s.name === step.name),
|
currentMovementIndex: pieceMovements.findIndex(s => s.name === step.name),
|
||||||
pieceName: this.deps.getPieceName(),
|
pieceName: this.deps.getPieceName(),
|
||||||
pieceDescription: this.deps.getPieceDescription(),
|
pieceDescription: this.deps.getPieceDescription(),
|
||||||
|
retryNote: this.deps.getRetryNote(),
|
||||||
}).build();
|
}).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -110,6 +110,7 @@ export class PieceEngine extends EventEmitter {
|
|||||||
getPieceMovements: () => this.config.movements.map(s => ({ name: s.name, description: s.description })),
|
getPieceMovements: () => this.config.movements.map(s => ({ name: s.name, description: s.description })),
|
||||||
getPieceName: () => this.getPieceName(),
|
getPieceName: () => this.getPieceName(),
|
||||||
getPieceDescription: () => this.getPieceDescription(),
|
getPieceDescription: () => this.getPieceDescription(),
|
||||||
|
getRetryNote: () => this.options.retryNote,
|
||||||
detectRuleIndex: this.detectRuleIndex,
|
detectRuleIndex: this.detectRuleIndex,
|
||||||
callAiJudge: this.callAiJudge,
|
callAiJudge: this.callAiJudge,
|
||||||
onPhaseStart: (step, phase, phaseName, instruction) => {
|
onPhaseStart: (step, phase, phaseName, instruction) => {
|
||||||
@ -160,6 +161,14 @@ export class PieceEngine extends EventEmitter {
|
|||||||
throw new Error(ERROR_MESSAGES.UNKNOWN_MOVEMENT(this.config.initialMovement));
|
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));
|
const movementNames = new Set(this.config.movements.map((s) => s.name));
|
||||||
movementNames.add(COMPLETE_MOVEMENT);
|
movementNames.add(COMPLETE_MOVEMENT);
|
||||||
movementNames.add(ABORT_MOVEMENT);
|
movementNames.add(ABORT_MOVEMENT);
|
||||||
|
|||||||
@ -36,7 +36,7 @@ export class StateManager {
|
|||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
pieceName: config.name,
|
pieceName: config.name,
|
||||||
currentMovement: config.initialMovement,
|
currentMovement: options.startMovement ?? config.initialMovement,
|
||||||
iteration: 0,
|
iteration: 0,
|
||||||
movementOutputs: new Map(),
|
movementOutputs: new Map(),
|
||||||
lastOutput: undefined,
|
lastOutput: undefined,
|
||||||
|
|||||||
@ -94,6 +94,10 @@ export class InstructionBuilder {
|
|||||||
const pieceDescription = this.context.pieceDescription ?? '';
|
const pieceDescription = this.context.pieceDescription ?? '';
|
||||||
const hasPieceDescription = !!pieceDescription;
|
const hasPieceDescription = !!pieceDescription;
|
||||||
|
|
||||||
|
// Retry note
|
||||||
|
const hasRetryNote = !!this.context.retryNote;
|
||||||
|
const retryNote = hasRetryNote ? escapeTemplateChars(this.context.retryNote!) : '';
|
||||||
|
|
||||||
return loadTemplate('perform_phase1_message', language, {
|
return loadTemplate('perform_phase1_message', language, {
|
||||||
workingDirectory: this.context.cwd,
|
workingDirectory: this.context.cwd,
|
||||||
editRule,
|
editRule,
|
||||||
@ -113,6 +117,8 @@ export class InstructionBuilder {
|
|||||||
previousResponse,
|
previousResponse,
|
||||||
hasUserInputs,
|
hasUserInputs,
|
||||||
userInputs,
|
userInputs,
|
||||||
|
hasRetryNote,
|
||||||
|
retryNote,
|
||||||
instructions,
|
instructions,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,6 +40,8 @@ export interface InstructionContext {
|
|||||||
pieceName?: string;
|
pieceName?: string;
|
||||||
/** Piece description (optional) */
|
/** Piece description (optional) */
|
||||||
pieceDescription?: string;
|
pieceDescription?: string;
|
||||||
|
/** Retry note explaining why task is being retried */
|
||||||
|
retryNote?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -183,6 +183,10 @@ export interface PieceEngineOptions {
|
|||||||
detectRuleIndex?: RuleIndexDetector;
|
detectRuleIndex?: RuleIndexDetector;
|
||||||
/** AI judge caller (required for rules evaluation) */
|
/** AI judge caller (required for rules evaluation) */
|
||||||
callAiJudge?: AiJudgeCaller;
|
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 */
|
/** Loop detection result */
|
||||||
|
|||||||
@ -228,6 +228,8 @@ export async function executePiece(
|
|||||||
interactive: interactiveUserInput,
|
interactive: interactiveUserInput,
|
||||||
detectRuleIndex,
|
detectRuleIndex,
|
||||||
callAiJudge,
|
callAiJudge,
|
||||||
|
startMovement: options.startMovement,
|
||||||
|
retryNote: options.retryNote,
|
||||||
});
|
});
|
||||||
|
|
||||||
let abortReason: string | undefined;
|
let abortReason: string | undefined;
|
||||||
|
|||||||
@ -25,7 +25,7 @@ const log = createLogger('task');
|
|||||||
* Execute a single task with piece.
|
* Execute a single task with piece.
|
||||||
*/
|
*/
|
||||||
export async function executeTask(options: ExecuteTaskOptions): Promise<boolean> {
|
export async function executeTask(options: ExecuteTaskOptions): Promise<boolean> {
|
||||||
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);
|
const pieceConfig = loadPieceByIdentifier(pieceIdentifier, projectCwd);
|
||||||
|
|
||||||
if (!pieceConfig) {
|
if (!pieceConfig) {
|
||||||
@ -52,6 +52,8 @@ export async function executeTask(options: ExecuteTaskOptions): Promise<boolean>
|
|||||||
model: agentOverrides?.model,
|
model: agentOverrides?.model,
|
||||||
interactiveUserInput,
|
interactiveUserInput,
|
||||||
interactiveMetadata,
|
interactiveMetadata,
|
||||||
|
startMovement,
|
||||||
|
retryNote,
|
||||||
});
|
});
|
||||||
return result.success;
|
return result.success;
|
||||||
}
|
}
|
||||||
@ -75,7 +77,7 @@ export async function executeAndCompleteTask(
|
|||||||
const executionLog: string[] = [];
|
const executionLog: string[] = [];
|
||||||
|
|
||||||
try {
|
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
|
// cwd is always the project root; pass it as projectCwd so reports/sessions go there
|
||||||
const taskSuccess = await executeTask({
|
const taskSuccess = await executeTask({
|
||||||
@ -84,6 +86,8 @@ export async function executeAndCompleteTask(
|
|||||||
pieceIdentifier: execPiece,
|
pieceIdentifier: execPiece,
|
||||||
projectCwd: cwd,
|
projectCwd: cwd,
|
||||||
agentOverrides: options,
|
agentOverrides: options,
|
||||||
|
startMovement,
|
||||||
|
retryNote,
|
||||||
});
|
});
|
||||||
const completedAt = new Date().toISOString();
|
const completedAt = new Date().toISOString();
|
||||||
|
|
||||||
@ -194,7 +198,7 @@ export async function resolveTaskExecution(
|
|||||||
task: TaskInfo,
|
task: TaskInfo,
|
||||||
defaultCwd: string,
|
defaultCwd: string,
|
||||||
defaultPiece: 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;
|
const data = task.data;
|
||||||
|
|
||||||
// No structured data: use defaults
|
// No structured data: use defaults
|
||||||
@ -227,5 +231,11 @@ export async function resolveTaskExecution(
|
|||||||
// Handle piece override
|
// Handle piece override
|
||||||
const execPiece = data.piece || defaultPiece;
|
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,6 +34,10 @@ export interface PieceExecutionOptions {
|
|||||||
interactiveUserInput?: boolean;
|
interactiveUserInput?: boolean;
|
||||||
/** Interactive mode result metadata for NDJSON logging */
|
/** Interactive mode result metadata for NDJSON logging */
|
||||||
interactiveMetadata?: InteractiveMetadata;
|
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 {
|
export interface TaskExecutionOptions {
|
||||||
@ -56,6 +60,10 @@ export interface ExecuteTaskOptions {
|
|||||||
interactiveUserInput?: boolean;
|
interactiveUserInput?: boolean;
|
||||||
/** Interactive mode result metadata for NDJSON logging */
|
/** Interactive mode result metadata for NDJSON logging */
|
||||||
interactiveMetadata?: InteractiveMetadata;
|
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 {
|
export interface PipelineExecutionOptions {
|
||||||
|
|||||||
@ -28,6 +28,7 @@ import {
|
|||||||
instructBranch,
|
instructBranch,
|
||||||
} from './taskActions.js';
|
} from './taskActions.js';
|
||||||
import { deletePendingTask, deleteFailedTask } from './taskDeleteActions.js';
|
import { deletePendingTask, deleteFailedTask } from './taskDeleteActions.js';
|
||||||
|
import { retryFailedTask } from './taskRetryActions.js';
|
||||||
import { listTasksNonInteractive, type ListNonInteractiveOptions } from './listNonInteractive.js';
|
import { listTasksNonInteractive, type ListNonInteractiveOptions } from './listNonInteractive.js';
|
||||||
|
|
||||||
export type { ListNonInteractiveOptions } from './listNonInteractive.js';
|
export type { ListNonInteractiveOptions } from './listNonInteractive.js';
|
||||||
@ -42,14 +43,17 @@ export {
|
|||||||
instructBranch,
|
instructBranch,
|
||||||
} from './taskActions.js';
|
} from './taskActions.js';
|
||||||
|
|
||||||
/** Task action type for the task action selection menu */
|
/** Task action type for pending task action selection menu */
|
||||||
type TaskAction = 'delete';
|
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.
|
* Returns the selected action, or null if cancelled.
|
||||||
*/
|
*/
|
||||||
async function showTaskAndPromptAction(task: TaskListItem): Promise<TaskAction | null> {
|
async function showPendingTaskAndPromptAction(task: TaskListItem): Promise<PendingTaskAction | null> {
|
||||||
header(`[${task.kind}] ${task.name}`);
|
header(`[${task.kind}] ${task.name}`);
|
||||||
info(` Created: ${task.createdAt}`);
|
info(` Created: ${task.createdAt}`);
|
||||||
if (task.content) {
|
if (task.content) {
|
||||||
@ -57,12 +61,33 @@ async function showTaskAndPromptAction(task: TaskListItem): Promise<TaskAction |
|
|||||||
}
|
}
|
||||||
blankLine();
|
blankLine();
|
||||||
|
|
||||||
return await selectOption<TaskAction>(
|
return await selectOption<PendingTaskAction>(
|
||||||
`Action for ${task.name}:`,
|
`Action for ${task.name}:`,
|
||||||
[{ label: 'Delete', value: 'delete', description: 'Remove this task permanently' }],
|
[{ 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<FailedTaskAction | null> {
|
||||||
|
header(`[${task.kind}] ${task.name}`);
|
||||||
|
info(` Failed at: ${task.createdAt}`);
|
||||||
|
if (task.content) {
|
||||||
|
info(` ${task.content}`);
|
||||||
|
}
|
||||||
|
blankLine();
|
||||||
|
|
||||||
|
return await selectOption<FailedTaskAction>(
|
||||||
|
`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.
|
* Main entry point: list branch-based tasks interactively.
|
||||||
*/
|
*/
|
||||||
@ -170,15 +195,17 @@ export async function listTasks(
|
|||||||
} else if (type === 'pending') {
|
} else if (type === 'pending') {
|
||||||
const task = pendingTasks[idx];
|
const task = pendingTasks[idx];
|
||||||
if (!task) continue;
|
if (!task) continue;
|
||||||
const taskAction = await showTaskAndPromptAction(task);
|
const taskAction = await showPendingTaskAndPromptAction(task);
|
||||||
if (taskAction === 'delete') {
|
if (taskAction === 'delete') {
|
||||||
await deletePendingTask(task);
|
await deletePendingTask(task);
|
||||||
}
|
}
|
||||||
} else if (type === 'failed') {
|
} else if (type === 'failed') {
|
||||||
const task = failedTasks[idx];
|
const task = failedTasks[idx];
|
||||||
if (!task) continue;
|
if (!task) continue;
|
||||||
const taskAction = await showTaskAndPromptAction(task);
|
const taskAction = await showFailedTaskAndPromptAction(task);
|
||||||
if (taskAction === 'delete') {
|
if (taskAction === 'retry') {
|
||||||
|
await retryFailedTask(task, cwd);
|
||||||
|
} else if (taskAction === 'delete') {
|
||||||
await deleteFailedTask(task);
|
await deleteFailedTask(task);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
241
src/features/tasks/list/taskRetryActions.ts
Normal file
241
src/features/tasks/list/taskRetryActions.ts
Normal file
@ -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<string | null> {
|
||||||
|
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<string>('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<boolean> {
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -28,6 +28,20 @@ export type {
|
|||||||
LatestLogPointer,
|
LatestLogPointer,
|
||||||
} from '../../shared/utils/index.js';
|
} 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,
|
* Manages session lifecycle: ID generation, NDJSON logging,
|
||||||
* session log creation/loading, and latest pointer maintenance.
|
* session log creation/loading, and latest pointer maintenance.
|
||||||
@ -298,3 +312,67 @@ export function updateLatestPointer(
|
|||||||
): void {
|
): void {
|
||||||
defaultManager.updateLatestPointer(log, sessionId, projectDir, options);
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -204,6 +204,91 @@ export class TaskRunner {
|
|||||||
return '';
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* タスクファイルを指定ディレクトリに移動し、レポート・ログを生成する
|
* タスクファイルを指定ディレクトリに移動し、レポート・ログを生成する
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -30,6 +30,8 @@ export const TaskFileSchema = z.object({
|
|||||||
branch: z.string().optional(),
|
branch: z.string().optional(),
|
||||||
piece: z.string().optional(),
|
piece: z.string().optional(),
|
||||||
issue: z.number().int().positive().optional(),
|
issue: z.number().int().positive().optional(),
|
||||||
|
start_movement: z.string().optional(),
|
||||||
|
retry_note: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TaskFileData = z.infer<typeof TaskFileSchema>;
|
export type TaskFileData = z.infer<typeof TaskFileSchema>;
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
vars: workingDirectory, editRule, pieceName, pieceDescription, hasPieceDescription,
|
vars: workingDirectory, editRule, pieceName, pieceDescription, hasPieceDescription,
|
||||||
pieceStructure, iteration, movementIteration, movement, hasReport, reportInfo,
|
pieceStructure, iteration, movementIteration, movement, hasReport, reportInfo,
|
||||||
phaseNote, hasTaskSection, userRequest, hasPreviousResponse, previousResponse,
|
phaseNote, hasTaskSection, userRequest, hasPreviousResponse, previousResponse,
|
||||||
hasUserInputs, userInputs, instructions
|
hasUserInputs, userInputs, hasRetryNote, retryNote, instructions
|
||||||
builder: InstructionBuilder
|
builder: InstructionBuilder
|
||||||
-->
|
-->
|
||||||
## Execution Context
|
## Execution Context
|
||||||
@ -29,6 +29,11 @@ Note: This section is metadata. Follow the language used in the rest of the prom
|
|||||||
{{#if hasReport}}{{reportInfo}}
|
{{#if hasReport}}{{reportInfo}}
|
||||||
|
|
||||||
{{phaseNote}}{{/if}}
|
{{phaseNote}}{{/if}}
|
||||||
|
{{#if hasRetryNote}}
|
||||||
|
|
||||||
|
## Retry Note
|
||||||
|
{{retryNote}}
|
||||||
|
{{/if}}
|
||||||
{{#if hasTaskSection}}
|
{{#if hasTaskSection}}
|
||||||
|
|
||||||
## User Request
|
## User Request
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
vars: workingDirectory, editRule, pieceName, pieceDescription, hasPieceDescription,
|
vars: workingDirectory, editRule, pieceName, pieceDescription, hasPieceDescription,
|
||||||
pieceStructure, iteration, movementIteration, movement, hasReport, reportInfo,
|
pieceStructure, iteration, movementIteration, movement, hasReport, reportInfo,
|
||||||
phaseNote, hasTaskSection, userRequest, hasPreviousResponse, previousResponse,
|
phaseNote, hasTaskSection, userRequest, hasPreviousResponse, previousResponse,
|
||||||
hasUserInputs, userInputs, instructions
|
hasUserInputs, userInputs, hasRetryNote, retryNote, instructions
|
||||||
builder: InstructionBuilder
|
builder: InstructionBuilder
|
||||||
-->
|
-->
|
||||||
## 実行コンテキスト
|
## 実行コンテキスト
|
||||||
@ -28,6 +28,11 @@
|
|||||||
{{#if hasReport}}{{reportInfo}}
|
{{#if hasReport}}{{reportInfo}}
|
||||||
|
|
||||||
{{phaseNote}}{{/if}}
|
{{phaseNote}}{{/if}}
|
||||||
|
{{#if hasRetryNote}}
|
||||||
|
|
||||||
|
## 再投入メモ
|
||||||
|
{{retryNote}}
|
||||||
|
{{/if}}
|
||||||
{{#if hasTaskSection}}
|
{{#if hasTaskSection}}
|
||||||
|
|
||||||
## User Request
|
## User Request
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user