resolved 失敗タスクの再投入とムーブメント開始位置の選択機能 #110
This commit is contained in:
parent
163561a5b3
commit
919215fad3
@ -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 を実行してはならない。実装完了 ≠ コミット許可。
|
||||
|
||||
### ツールの使い分け(重要)
|
||||
|
||||
| やること | 使うツール | 説明 |
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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 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();
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -40,6 +40,8 @@ export interface InstructionContext {
|
||||
pieceName?: string;
|
||||
/** Piece description (optional) */
|
||||
pieceDescription?: string;
|
||||
/** Retry note explaining why task is being retried */
|
||||
retryNote?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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 */
|
||||
|
||||
@ -228,6 +228,8 @@ export async function executePiece(
|
||||
interactive: interactiveUserInput,
|
||||
detectRuleIndex,
|
||||
callAiJudge,
|
||||
startMovement: options.startMovement,
|
||||
retryNote: options.retryNote,
|
||||
});
|
||||
|
||||
let abortReason: string | undefined;
|
||||
|
||||
@ -25,7 +25,7 @@ const log = createLogger('task');
|
||||
* Execute a single task with piece.
|
||||
*/
|
||||
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);
|
||||
|
||||
if (!pieceConfig) {
|
||||
@ -52,6 +52,8 @@ export async function executeTask(options: ExecuteTaskOptions): Promise<boolean>
|
||||
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 };
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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<TaskAction | null> {
|
||||
async function showPendingTaskAndPromptAction(task: TaskListItem): Promise<PendingTaskAction | null> {
|
||||
header(`[${task.kind}] ${task.name}`);
|
||||
info(` Created: ${task.createdAt}`);
|
||||
if (task.content) {
|
||||
@ -57,12 +61,33 @@ async function showTaskAndPromptAction(task: TaskListItem): Promise<TaskAction |
|
||||
}
|
||||
blankLine();
|
||||
|
||||
return await selectOption<TaskAction>(
|
||||
return await selectOption<PendingTaskAction>(
|
||||
`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<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.
|
||||
*/
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
} 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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* タスクファイルを指定ディレクトリに移動し、レポート・ログを生成する
|
||||
*/
|
||||
|
||||
@ -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<typeof TaskFileSchema>;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user