リトライモード新設と instruct/retry の直接再実行対応

失敗タスク専用のリトライモード(retryMode.ts)を追加し、失敗情報・実行ログ・
レポートをシステムプロンプトに注入する方式に変更。instruct モードもタスク情報を
プロンプトに含める専用テンプレートへ移行。requeue のみだった再実行を
startReExecution による即時実行に対応し、既存ワークツリーの再利用も実装。
不要になった DebugConfig を削除。
This commit is contained in:
nrslib 2026-02-18 22:35:31 +09:00
parent 85c845057e
commit 16d7f9f979
33 changed files with 1819 additions and 395 deletions

View File

@ -25,6 +25,7 @@
"test:e2e:codex": "npm run test:e2e:provider:codex",
"test:e2e:opencode": "npm run test:e2e:provider:opencode",
"lint": "eslint src/",
"check:release": "npm run build && npm run lint && npm run test && npm run test:e2e",
"prepublishOnly": "npm run lint && npm run build && npm run test"
},
"keywords": [

View File

@ -62,7 +62,6 @@ vi.mock('../infra/config/index.js', () => ({
initGlobalDirs: vi.fn(),
initProjectDirs: vi.fn(),
loadGlobalConfig: vi.fn(() => ({ logLevel: 'info' })),
getEffectiveDebugConfig: vi.fn(),
}));
vi.mock('../infra/config/paths.js', () => ({

View File

@ -103,29 +103,17 @@ describe('runInstructMode', () => {
setupRawStdin(toRawInputs(['/cancel']));
setupMockProvider([]);
const result = await runInstructMode('/project', 'branch context', 'feature-branch');
const result = await runInstructMode('/project', 'branch context', 'feature-branch', 'my-task', 'Do something', '');
expect(result.action).toBe('cancel');
expect(result.task).toBe('');
});
it('should include branch name in intro message', async () => {
setupRawStdin(toRawInputs(['/cancel']));
setupMockProvider([]);
await runInstructMode('/project', 'diff stats', 'my-feature-branch');
const introCall = mockInfo.mock.calls.find((call) =>
call[0]?.includes('my-feature-branch')
);
expect(introCall).toBeDefined();
});
it('should return action=execute with task on /go after conversation', async () => {
setupRawStdin(toRawInputs(['add more tests', '/go']));
setupMockProvider(['What kind of tests?', 'Add unit tests for the feature.']);
const result = await runInstructMode('/project', 'branch context', 'feature-branch');
const result = await runInstructMode('/project', 'branch context', 'feature-branch', 'my-task', 'Do something', '');
expect(result.action).toBe('execute');
expect(result.task).toBe('Add unit tests for the feature.');
@ -136,7 +124,7 @@ describe('runInstructMode', () => {
setupMockProvider(['response', 'Summarized task.']);
mockSelectOption.mockResolvedValue('save_task');
const result = await runInstructMode('/project', 'branch context', 'feature-branch');
const result = await runInstructMode('/project', 'branch context', 'feature-branch', 'my-task', 'Do something', '');
expect(result.action).toBe('save_task');
expect(result.task).toBe('Summarized task.');
@ -147,7 +135,7 @@ describe('runInstructMode', () => {
setupMockProvider(['response', 'Summarized task.']);
mockSelectOption.mockResolvedValueOnce('continue');
const result = await runInstructMode('/project', 'branch context', 'feature-branch');
const result = await runInstructMode('/project', 'branch context', 'feature-branch', 'my-task', 'Do something', '');
expect(result.action).toBe('cancel');
});
@ -156,7 +144,7 @@ describe('runInstructMode', () => {
setupRawStdin(toRawInputs(['/go', '/cancel']));
setupMockProvider([]);
const result = await runInstructMode('/project', 'branch context', 'feature-branch');
const result = await runInstructMode('/project', 'branch context', 'feature-branch', 'my-task', 'Do something', '');
expect(result.action).toBe('cancel');
});
@ -165,7 +153,7 @@ describe('runInstructMode', () => {
setupRawStdin(toRawInputs(['task', '/go']));
setupMockProvider(['response', 'Task summary.']);
await runInstructMode('/project', 'branch context', 'feature-branch');
await runInstructMode('/project', 'branch context', 'feature-branch', 'my-task', 'Do something', '');
const selectCall = mockSelectOption.mock.calls.find((call) =>
Array.isArray(call[1])
@ -179,6 +167,25 @@ describe('runInstructMode', () => {
expect(values).not.toContain('create_issue');
});
it('should use dedicated instruct system prompt with task context', async () => {
setupRawStdin(toRawInputs(['/cancel']));
setupMockProvider([]);
await runInstructMode('/project', 'branch context', 'feature-branch', 'my-task', 'Do something', 'existing note');
expect(mockLoadTemplate).toHaveBeenCalledWith(
'score_instruct_system_prompt',
'en',
expect.objectContaining({
taskName: 'my-task',
taskContent: 'Do something',
branchName: 'feature-branch',
branchContext: 'branch context',
retryNote: 'existing note',
}),
);
});
it('should inject selected run context into system prompt variables', async () => {
setupRawStdin(toRawInputs(['/cancel']));
setupMockProvider([]);
@ -195,10 +202,10 @@ describe('runInstructMode', () => {
],
};
await runInstructMode('/project', 'branch context', 'feature-branch', undefined, runSessionContext);
await runInstructMode('/project', 'branch context', 'feature-branch', 'my-task', 'Do something', '', undefined, runSessionContext);
expect(mockLoadTemplate).toHaveBeenCalledWith(
'score_interactive_system_prompt',
'score_instruct_system_prompt',
'en',
expect.objectContaining({
hasRunSession: true,

View File

@ -104,7 +104,7 @@ function setupScenarioProvider(...scenarios: Parameters<typeof createScenarioPro
}
async function runInstruct() {
return runInstructMode('/test', '', 'takt/test-branch');
return runInstructMode('/test', '', 'takt/test-branch', 'test-branch', '', '');
}
beforeEach(() => {
@ -394,7 +394,7 @@ describe('policy injection', () => {
setupRawStdin(toRawInputs(['fix the bug', '/cancel']));
const capture = setupProvider(['OK.']);
await runInstructMode('/test', '', 'takt/test');
await runInstructMode('/test', '', 'takt/test', 'test', '', '');
// The prompt sent to AI should contain Policy section
expect(capture.prompts[0]).toContain('Policy');
@ -407,21 +407,22 @@ describe('policy injection', () => {
// System prompt: branch name appears in intro
// =================================================================
describe('branch context', () => {
it('should include branch name and context in intro', async () => {
setupRawStdin(toRawInputs(['/cancel']));
setupProvider([]);
const { info: mockInfo } = await import('../shared/ui/index.js');
it('should include branch name and context in system prompt', async () => {
setupRawStdin(toRawInputs(['check changes', '/cancel']));
const capture = setupProvider(['Looks good.']);
await runInstructMode(
'/test',
'## Changes\n```\nsrc/auth.ts | 50 +++\n```',
'takt/feature-auth',
'feature-auth',
'Do something',
'',
);
const introCall = vi.mocked(mockInfo).mock.calls.find((call) =>
call[0]?.includes('takt/feature-auth'),
);
expect(introCall).toBeDefined();
expect(capture.systemPrompts.length).toBeGreaterThan(0);
const systemPrompt = capture.systemPrompts[0]!;
expect(systemPrompt).toContain('takt/feature-auth');
expect(systemPrompt).toContain('src/auth.ts | 50 +++');
});
});

View File

@ -0,0 +1,410 @@
/**
* E2E test: Retry mode with failure context and run session injection.
*
* Simulates the retry assistant flow:
* 1. Create .takt/runs/ fixtures (logs, reports)
* 2. Build RetryContext with failure info + run session
* 3. Run retry mode with stdin simulation (user types message /go)
* 4. Mock provider captures the system prompt sent to AI
* 5. Verify failure info AND run session data appear in the system prompt
*
* Real: buildRetryTemplateVars, loadTemplate, runConversationLoop,
* loadRunSessionContext, formatRunSessionForPrompt, getRunPaths
* Mocked: provider (captures system prompt), config, UI, session persistence
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import {
setupRawStdin,
restoreStdin,
toRawInputs,
createMockProvider,
type MockProviderCapture,
} from './helpers/stdinSimulator.js';
// --- Mocks (infrastructure only) ---
vi.mock('../infra/fs/session.js', () => ({
loadNdjsonLog: vi.fn(),
}));
vi.mock('../infra/config/global/globalConfig.js', () => ({
loadGlobalConfig: vi.fn(() => ({ provider: 'mock', language: 'en' })),
getBuiltinPiecesEnabled: vi.fn().mockReturnValue(true),
}));
vi.mock('../infra/providers/index.js', () => ({
getProvider: vi.fn(),
}));
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
createLogger: () => ({
info: vi.fn(),
debug: vi.fn(),
error: vi.fn(),
}),
}));
vi.mock('../shared/context.js', () => ({
isQuietMode: vi.fn(() => false),
}));
vi.mock('../infra/config/paths.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
loadPersonaSessions: vi.fn(() => ({})),
updatePersonaSession: vi.fn(),
getProjectConfigDir: vi.fn(() => '/tmp'),
loadSessionState: vi.fn(() => null),
clearSessionState: vi.fn(),
}));
vi.mock('../shared/ui/index.js', () => ({
info: vi.fn(),
error: vi.fn(),
blankLine: vi.fn(),
StreamDisplay: vi.fn().mockImplementation(() => ({
createHandler: vi.fn(() => vi.fn()),
flush: vi.fn(),
})),
}));
vi.mock('../shared/prompt/index.js', () => ({
selectOption: vi.fn().mockResolvedValue('execute'),
}));
vi.mock('../shared/i18n/index.js', () => ({
getLabel: vi.fn((_key: string, _lang: string) => 'Mock label'),
getLabelObject: vi.fn(() => ({
intro: 'Retry intro',
resume: 'Resume',
noConversation: 'No conversation',
summarizeFailed: 'Summarize failed',
continuePrompt: 'Continue?',
proposed: 'Proposed:',
actionPrompt: 'What next?',
playNoTask: 'No task',
cancelled: 'Cancelled',
actions: { execute: 'Execute', saveTask: 'Save', continue: 'Continue' },
})),
}));
// --- Imports (after mocks) ---
import { getProvider } from '../infra/providers/index.js';
import { loadNdjsonLog } from '../infra/fs/session.js';
import {
loadRunSessionContext,
formatRunSessionForPrompt,
getRunPaths,
} from '../features/interactive/runSessionReader.js';
import { runRetryMode, type RetryContext } from '../features/interactive/retryMode.js';
const mockGetProvider = vi.mocked(getProvider);
const mockLoadNdjsonLog = vi.mocked(loadNdjsonLog);
// --- Fixture helpers ---
function createTmpDir(): string {
const dir = join(tmpdir(), `takt-retry-e2e-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(dir, { recursive: true });
return dir;
}
function createRunFixture(
cwd: string,
slug: string,
overrides?: {
meta?: Record<string, unknown>;
reports?: Array<{ name: string; content: string }>;
},
): void {
const runDir = join(cwd, '.takt', 'runs', slug);
mkdirSync(join(runDir, 'logs'), { recursive: true });
mkdirSync(join(runDir, 'reports'), { recursive: true });
const meta = {
task: `Task for ${slug}`,
piece: 'default',
status: 'completed',
startTime: '2026-02-01T00:00:00.000Z',
logsDirectory: `.takt/runs/${slug}/logs`,
reportDirectory: `.takt/runs/${slug}/reports`,
runSlug: slug,
...overrides?.meta,
};
writeFileSync(join(runDir, 'meta.json'), JSON.stringify(meta), 'utf-8');
writeFileSync(join(runDir, 'logs', 'session-001.jsonl'), '{}', 'utf-8');
for (const report of overrides?.reports ?? []) {
writeFileSync(join(runDir, 'reports', report.name), report.content, 'utf-8');
}
}
function setupMockNdjsonLog(history: Array<{ step: string; persona: string; status: string; content: string }>): void {
mockLoadNdjsonLog.mockReturnValue({
task: 'mock',
projectDir: '',
pieceName: 'default',
iterations: history.length,
startTime: '2026-02-01T00:00:00.000Z',
status: 'completed',
history: history.map((h) => ({
...h,
instruction: '',
timestamp: '2026-02-01T00:00:00.000Z',
})),
});
}
function setupProvider(responses: string[]): MockProviderCapture {
const { provider, capture } = createMockProvider(responses);
mockGetProvider.mockReturnValue(provider);
return capture;
}
// --- Tests ---
describe('E2E: Retry mode with failure context injection', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = createTmpDir();
vi.clearAllMocks();
});
afterEach(() => {
restoreStdin();
rmSync(tmpDir, { recursive: true, force: true });
});
it('should inject failure info into system prompt', async () => {
setupRawStdin(toRawInputs(['what went wrong?', '/go']));
const capture = setupProvider([
'The review step failed due to a timeout.',
'Fix review timeout by increasing the limit.',
]);
const retryContext: RetryContext = {
failure: {
taskName: 'implement-auth',
createdAt: '2026-02-15T10:00:00Z',
failedMovement: 'review',
error: 'Timeout after 300s',
lastMessage: 'Agent stopped responding',
retryNote: '',
},
branchName: 'takt/implement-auth',
pieceContext: {
name: 'default',
description: '',
pieceStructure: '',
movementPreviews: [],
},
run: null,
};
const result = await runRetryMode(tmpDir, retryContext);
// Verify: system prompt contains failure information
expect(capture.systemPrompts.length).toBeGreaterThan(0);
const systemPrompt = capture.systemPrompts[0]!;
expect(systemPrompt).toContain('Retry Assistant');
expect(systemPrompt).toContain('implement-auth');
expect(systemPrompt).toContain('takt/implement-auth');
expect(systemPrompt).toContain('review');
expect(systemPrompt).toContain('Timeout after 300s');
expect(systemPrompt).toContain('Agent stopped responding');
// Verify: flow completed
expect(result.action).toBe('execute');
expect(result.task).toBe('Fix review timeout by increasing the limit.');
expect(capture.callCount).toBe(2);
});
it('should inject failure info AND run session data into system prompt', async () => {
// Create run fixture with logs and reports
createRunFixture(tmpDir, 'run-failed', {
meta: { task: 'Build login page', status: 'failed' },
reports: [
{ name: '00-plan.md', content: '# Plan\n\nLogin form with OAuth2.' },
],
});
setupMockNdjsonLog([
{ step: 'plan', persona: 'architect', status: 'completed', content: 'Planned OAuth2 login flow' },
{ step: 'implement', persona: 'coder', status: 'failed', content: 'Failed at CSS compilation' },
]);
// Load real run session data
const sessionContext = loadRunSessionContext(tmpDir, 'run-failed');
const formatted = formatRunSessionForPrompt(sessionContext);
const paths = getRunPaths(tmpDir, 'run-failed');
setupRawStdin(toRawInputs(['fix the CSS issue', '/go']));
const capture = setupProvider([
'The CSS compilation error is likely due to missing imports.',
'Fix CSS imports in login component.',
]);
const retryContext: RetryContext = {
failure: {
taskName: 'build-login',
createdAt: '2026-02-15T14:00:00Z',
failedMovement: 'implement',
error: 'CSS compilation failed',
lastMessage: 'PostCSS error: unknown property',
retryNote: '',
},
branchName: 'takt/build-login',
pieceContext: {
name: 'default',
description: '',
pieceStructure: '',
movementPreviews: [],
},
run: {
logsDir: paths.logsDir,
reportsDir: paths.reportsDir,
task: formatted.runTask,
piece: formatted.runPiece,
status: formatted.runStatus,
movementLogs: formatted.runMovementLogs,
reports: formatted.runReports,
},
};
const result = await runRetryMode(tmpDir, retryContext);
// Verify: system prompt contains BOTH failure info and run session data
const systemPrompt = capture.systemPrompts[0]!;
// Failure info
expect(systemPrompt).toContain('build-login');
expect(systemPrompt).toContain('CSS compilation failed');
expect(systemPrompt).toContain('PostCSS error: unknown property');
expect(systemPrompt).toContain('implement');
// Run session data
expect(systemPrompt).toContain('Previous Run Data');
expect(systemPrompt).toContain('Build login page');
expect(systemPrompt).toContain('Planned OAuth2 login flow');
expect(systemPrompt).toContain('Failed at CSS compilation');
expect(systemPrompt).toContain('00-plan.md');
expect(systemPrompt).toContain('Login form with OAuth2');
// Run paths (AI can use Read tool)
expect(systemPrompt).toContain(paths.logsDir);
expect(systemPrompt).toContain(paths.reportsDir);
// Flow completed
expect(result.action).toBe('execute');
expect(result.task).toBe('Fix CSS imports in login component.');
});
it('should include existing retry note in system prompt', async () => {
setupRawStdin(toRawInputs(['what should I do?', '/go']));
const capture = setupProvider([
'Based on the previous attempt, the mocks are still incomplete.',
'Add complete mocks for all API endpoints.',
]);
const retryContext: RetryContext = {
failure: {
taskName: 'fix-tests',
createdAt: '2026-02-15T16:00:00Z',
failedMovement: '',
error: 'Test suite failed',
lastMessage: '',
retryNote: 'Previous attempt: added missing mocks but still failing',
},
branchName: 'takt/fix-tests',
pieceContext: {
name: 'default',
description: '',
pieceStructure: '',
movementPreviews: [],
},
run: null,
};
await runRetryMode(tmpDir, retryContext);
const systemPrompt = capture.systemPrompts[0]!;
expect(systemPrompt).toContain('Existing Retry Note');
expect(systemPrompt).toContain('Previous attempt: added missing mocks but still failing');
// absent fields should NOT appear as sections
expect(systemPrompt).not.toContain('Failed movement');
expect(systemPrompt).not.toContain('Last Message');
});
it('should cancel cleanly and not crash', async () => {
setupRawStdin(toRawInputs(['/cancel']));
setupProvider([]);
const retryContext: RetryContext = {
failure: {
taskName: 'some-task',
createdAt: '2026-02-15T12:00:00Z',
failedMovement: 'plan',
error: 'Unknown error',
lastMessage: '',
retryNote: '',
},
branchName: 'takt/some-task',
pieceContext: {
name: 'default',
description: '',
pieceStructure: '',
movementPreviews: [],
},
run: null,
};
const result = await runRetryMode(tmpDir, retryContext);
expect(result.action).toBe('cancel');
expect(result.task).toBe('');
});
it('should handle conversation before /go with failure context', async () => {
setupRawStdin(toRawInputs([
'what was the error?',
'can you suggest a fix?',
'/go',
]));
const capture = setupProvider([
'The error was a timeout in the review step.',
'You could increase the timeout limit or optimize the review.',
'Increase review timeout to 600s and add retry logic.',
]);
const retryContext: RetryContext = {
failure: {
taskName: 'optimize-review',
createdAt: '2026-02-15T18:00:00Z',
failedMovement: 'review',
error: 'Timeout',
lastMessage: '',
retryNote: '',
},
branchName: 'takt/optimize-review',
pieceContext: {
name: 'default',
description: '',
pieceStructure: '',
movementPreviews: [],
},
run: null,
};
const result = await runRetryMode(tmpDir, retryContext);
expect(result.action).toBe('execute');
expect(result.task).toBe('Increase review timeout to 600s and add retry logic.');
expect(capture.callCount).toBe(3);
});
});

View File

@ -213,6 +213,9 @@ describe('E2E: Run session → instruct mode with interactive flow', () => {
tmpDir,
'## Branch: takt/fix-auth\n',
'takt/fix-auth',
'fix-auth',
'Implement JWT auth',
'',
{ name: 'default', description: '', pieceStructure: '', movementPreviews: [] },
context,
);
@ -239,7 +242,7 @@ describe('E2E: Run session → instruct mode with interactive flow', () => {
setupRawStdin(toRawInputs(['/cancel']));
setupProvider([]);
const result = await runInstructMode(tmpDir, '', 'takt/fix', undefined, undefined);
const result = await runInstructMode(tmpDir, '', 'takt/fix', 'fix', '', '', undefined, undefined);
expect(result.action).toBe('cancel');
});
@ -254,7 +257,7 @@ describe('E2E: Run session → instruct mode with interactive flow', () => {
const capture = setupProvider(['I understand.']);
const result = await runInstructMode(
tmpDir, '', 'takt/branch', undefined, context,
tmpDir, '', 'takt/branch', 'branch', '', '', undefined, context,
);
expect(result.action).toBe('cancel');

View File

@ -35,6 +35,16 @@ describe('loadTemplate', () => {
expect(result).toContain('対話モードポリシー');
});
it('loads an English retry system prompt template', () => {
const result = loadTemplate('score_retry_system_prompt', 'en');
expect(result).toContain('Retry Assistant');
});
it('loads a Japanese retry system prompt template', () => {
const result = loadTemplate('score_retry_system_prompt', 'ja');
expect(result).toContain('リトライアシスタント');
});
it('loads score_slug_system_prompt with explicit lang', () => {
const result = loadTemplate('score_slug_system_prompt', 'en');
expect(result).toContain('You are a slug generator');

View File

@ -0,0 +1,147 @@
/**
* Unit tests for retryMode: buildRetryTemplateVars
*/
import { describe, it, expect } from 'vitest';
import { buildRetryTemplateVars, type RetryContext } from '../features/interactive/retryMode.js';
function createRetryContext(overrides?: Partial<RetryContext>): RetryContext {
return {
failure: {
taskName: 'my-task',
createdAt: '2026-02-15T10:00:00Z',
failedMovement: 'review',
error: 'Timeout',
lastMessage: 'Agent stopped',
retryNote: '',
},
branchName: 'takt/my-task',
pieceContext: {
name: 'default',
description: '',
pieceStructure: '1. plan → 2. implement → 3. review',
movementPreviews: [],
},
run: null,
...overrides,
};
}
describe('buildRetryTemplateVars', () => {
it('should map failure info to template variables', () => {
const ctx = createRetryContext();
const vars = buildRetryTemplateVars(ctx, 'en');
expect(vars.taskName).toBe('my-task');
expect(vars.branchName).toBe('takt/my-task');
expect(vars.createdAt).toBe('2026-02-15T10:00:00Z');
expect(vars.failedMovement).toBe('review');
expect(vars.failureError).toBe('Timeout');
expect(vars.failureLastMessage).toBe('Agent stopped');
});
it('should set empty string for absent optional fields', () => {
const ctx = createRetryContext({
failure: {
taskName: 'task',
createdAt: '2026-01-01T00:00:00Z',
failedMovement: '',
error: 'Error',
lastMessage: '',
retryNote: '',
},
});
const vars = buildRetryTemplateVars(ctx, 'en');
expect(vars.failedMovement).toBe('');
expect(vars.failureLastMessage).toBe('');
expect(vars.retryNote).toBe('');
});
it('should set hasRun=false and empty run vars when run is null', () => {
const ctx = createRetryContext({ run: null });
const vars = buildRetryTemplateVars(ctx, 'en');
expect(vars.hasRun).toBe(false);
expect(vars.runLogsDir).toBe('');
expect(vars.runReportsDir).toBe('');
expect(vars.runTask).toBe('');
expect(vars.runPiece).toBe('');
expect(vars.runStatus).toBe('');
expect(vars.runMovementLogs).toBe('');
expect(vars.runReports).toBe('');
});
it('should set hasRun=true and populate run vars when run is provided', () => {
const ctx = createRetryContext({
run: {
logsDir: '/project/.takt/runs/slug/logs',
reportsDir: '/project/.takt/runs/slug/reports',
task: 'Build feature',
piece: 'default',
status: 'failed',
movementLogs: '### plan\nPlanned.',
reports: '### 00-plan.md\n# Plan',
},
});
const vars = buildRetryTemplateVars(ctx, 'en');
expect(vars.hasRun).toBe(true);
expect(vars.runLogsDir).toBe('/project/.takt/runs/slug/logs');
expect(vars.runReportsDir).toBe('/project/.takt/runs/slug/reports');
expect(vars.runTask).toBe('Build feature');
expect(vars.runPiece).toBe('default');
expect(vars.runStatus).toBe('failed');
expect(vars.runMovementLogs).toBe('### plan\nPlanned.');
expect(vars.runReports).toBe('### 00-plan.md\n# Plan');
});
it('should set hasPiecePreview=false when no movement previews', () => {
const ctx = createRetryContext();
const vars = buildRetryTemplateVars(ctx, 'en');
expect(vars.hasPiecePreview).toBe(false);
expect(vars.movementDetails).toBe('');
});
it('should set hasPiecePreview=true and format movement details when previews exist', () => {
const ctx = createRetryContext({
pieceContext: {
name: 'default',
description: '',
pieceStructure: '1. plan',
movementPreviews: [
{
name: 'plan',
personaDisplayName: 'Architect',
personaContent: 'You are an architect.',
instructionContent: 'Plan the feature.',
allowedTools: ['Read', 'Grep'],
canEdit: false,
},
],
},
});
const vars = buildRetryTemplateVars(ctx, 'en');
expect(vars.hasPiecePreview).toBe(true);
expect(vars.movementDetails).toContain('plan');
expect(vars.movementDetails).toContain('Architect');
});
it('should include retryNote when present', () => {
const ctx = createRetryContext({
failure: {
taskName: 'task',
createdAt: '2026-01-01T00:00:00Z',
failedMovement: '',
error: 'Error',
lastMessage: '',
retryNote: 'Added more specific error handling',
},
});
const vars = buildRetryTemplateVars(ctx, 'en');
expect(vars.retryNote).toBe('Added more specific error handling');
});
});

View File

@ -14,6 +14,7 @@ vi.mock('../infra/fs/session.js', () => ({
import { loadNdjsonLog } from '../infra/fs/session.js';
import {
listRecentRuns,
findRunForTask,
loadRunSessionContext,
formatRunSessionForPrompt,
type RunSessionContext,
@ -107,6 +108,78 @@ describe('listRecentRuns', () => {
});
});
describe('findRunForTask', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = createTmpDir();
vi.clearAllMocks();
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
it('should return null when no runs exist', () => {
const result = findRunForTask(tmpDir, 'Some task');
expect(result).toBeNull();
});
it('should return null when no runs match the task content', () => {
createRunDir(tmpDir, 'run-other', {
task: 'Different task',
piece: 'default',
status: 'completed',
startTime: '2026-02-01T00:00:00.000Z',
logsDirectory: '.takt/runs/run-other/logs',
reportDirectory: '.takt/runs/run-other/reports',
runSlug: 'run-other',
});
const result = findRunForTask(tmpDir, 'My specific task');
expect(result).toBeNull();
});
it('should return the matching run slug', () => {
createRunDir(tmpDir, 'run-match', {
task: 'Build login page',
piece: 'default',
status: 'failed',
startTime: '2026-02-01T00:00:00.000Z',
logsDirectory: '.takt/runs/run-match/logs',
reportDirectory: '.takt/runs/run-match/reports',
runSlug: 'run-match',
});
const result = findRunForTask(tmpDir, 'Build login page');
expect(result).toBe('run-match');
});
it('should return the most recent matching run when multiple exist', () => {
createRunDir(tmpDir, 'run-old', {
task: 'Build login page',
piece: 'default',
status: 'failed',
startTime: '2026-01-01T00:00:00.000Z',
logsDirectory: '.takt/runs/run-old/logs',
reportDirectory: '.takt/runs/run-old/reports',
runSlug: 'run-old',
});
createRunDir(tmpDir, 'run-new', {
task: 'Build login page',
piece: 'default',
status: 'failed',
startTime: '2026-02-01T00:00:00.000Z',
logsDirectory: '.takt/runs/run-new/logs',
reportDirectory: '.takt/runs/run-new/reports',
runSlug: 'run-new',
});
const result = findRunForTask(tmpDir, 'Build login page');
expect(result).toBe('run-new');
});
});
describe('loadRunSessionContext', () => {
let tmpDir: string;

View File

@ -1,7 +1,10 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const {
mockExistsSync,
mockStartReExecution,
mockRequeueTask,
mockExecuteAndCompleteTask,
mockRunInstructMode,
mockDispatchConversationAction,
mockSelectPiece,
@ -12,7 +15,10 @@ const {
mockSelectRun,
mockLoadRunSessionContext,
} = vi.hoisted(() => ({
mockExistsSync: vi.fn(() => true),
mockStartReExecution: vi.fn(),
mockRequeueTask: vi.fn(),
mockExecuteAndCompleteTask: vi.fn(),
mockRunInstructMode: vi.fn(),
mockDispatchConversationAction: vi.fn(),
mockSelectPiece: vi.fn(),
@ -24,9 +30,17 @@ const {
mockLoadRunSessionContext: vi.fn(),
}));
vi.mock('node:fs', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
existsSync: (...args: unknown[]) => mockExistsSync(...args),
}));
vi.mock('../infra/task/index.js', () => ({
detectDefaultBranch: vi.fn(() => 'main'),
TaskRunner: class {
startReExecution(...args: unknown[]) {
return mockStartReExecution(...args);
}
requeueTask(...args: unknown[]) {
return mockRequeueTask(...args);
}
@ -47,10 +61,6 @@ vi.mock('../features/tasks/list/instructMode.js', () => ({
runInstructMode: (...args: unknown[]) => mockRunInstructMode(...args),
}));
vi.mock('../features/tasks/add/index.js', () => ({
saveTaskFile: vi.fn(),
}));
vi.mock('../features/pieceSelection/index.js', () => ({
selectPiece: (...args: unknown[]) => mockSelectPiece(...args),
}));
@ -74,9 +84,12 @@ vi.mock('../features/interactive/index.js', () => ({
loadRunSessionContext: (...args: unknown[]) => mockLoadRunSessionContext(...args),
}));
vi.mock('../features/tasks/execute/taskExecution.js', () => ({
executeAndCompleteTask: (...args: unknown[]) => mockExecuteAndCompleteTask(...args),
}));
vi.mock('../shared/ui/index.js', () => ({
info: vi.fn(),
success: vi.fn(),
error: vi.fn(),
}));
@ -90,10 +103,15 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
}));
import { instructBranch } from '../features/tasks/list/taskActions.js';
import { error as logError } from '../shared/ui/index.js';
describe('instructBranch requeue flow', () => {
const mockLogError = vi.mocked(logError);
describe('instructBranch direct execution flow', () => {
beforeEach(() => {
vi.clearAllMocks();
mockExistsSync.mockReturnValue(true);
mockSelectPiece.mockResolvedValue('default');
mockRunInstructMode.mockResolvedValue({ action: 'execute', task: '追加指示A' });
mockDispatchConversationAction.mockImplementation(async (_result, handlers) => handlers.execute({ task: '追加指示A' }));
@ -102,9 +120,15 @@ describe('instructBranch requeue flow', () => {
mockResolveLanguage.mockReturnValue('en');
mockListRecentRuns.mockReturnValue([]);
mockSelectRun.mockResolvedValue(null);
mockStartReExecution.mockReturnValue({
name: 'done-task',
content: 'done',
data: { task: 'done' },
});
mockExecuteAndCompleteTask.mockResolvedValue(true);
});
it('should requeue the same completed task instead of creating another task', async () => {
it('should execute directly via startReExecution instead of requeuing', async () => {
const result = await instructBranch('/project', {
kind: 'completed',
name: 'done-task',
@ -117,12 +141,13 @@ describe('instructBranch requeue flow', () => {
});
expect(result).toBe(true);
expect(mockRequeueTask).toHaveBeenCalledWith(
expect(mockStartReExecution).toHaveBeenCalledWith(
'done-task',
['completed', 'failed'],
undefined,
'既存ノート\n\n追加指示A',
);
expect(mockExecuteAndCompleteTask).toHaveBeenCalled();
});
it('should set generated instruction as retry note when no existing note', async () => {
@ -137,7 +162,7 @@ describe('instructBranch requeue flow', () => {
data: { task: 'done' },
});
expect(mockRequeueTask).toHaveBeenCalledWith(
expect(mockStartReExecution).toHaveBeenCalledWith(
'done-task',
['completed', 'failed'],
undefined,
@ -145,7 +170,31 @@ describe('instructBranch requeue flow', () => {
);
});
it('should load selected run context and pass it to instruct mode', async () => {
it('should run instruct mode in existing worktree', async () => {
await instructBranch('/project', {
kind: 'completed',
name: 'done-task',
createdAt: '2026-02-14T00:00:00.000Z',
filePath: '/project/.takt/tasks.yaml',
content: 'done',
branch: 'takt/done-task',
worktreePath: '/project/.takt/worktrees/done-task',
data: { task: 'done' },
});
expect(mockRunInstructMode).toHaveBeenCalledWith(
'/project/.takt/worktrees/done-task',
expect.any(String),
'takt/done-task',
'done-task',
'done',
'',
expect.anything(),
undefined,
);
});
it('should search runs in worktree for run session context', async () => {
mockListRecentRuns.mockReturnValue([
{ slug: 'run-1', task: 'fix', piece: 'default', status: 'completed', startTime: '2026-02-18T00:00:00Z' },
]);
@ -165,14 +214,76 @@ describe('instructBranch requeue flow', () => {
});
expect(mockConfirm).toHaveBeenCalledWith("Reference a previous run's results?", false);
expect(mockSelectRun).toHaveBeenCalledWith('/project', 'en');
expect(mockLoadRunSessionContext).toHaveBeenCalledWith('/project', 'run-1');
// selectRunSessionContext uses worktreePath for run data
expect(mockListRecentRuns).toHaveBeenCalledWith('/project/.takt/worktrees/done-task');
expect(mockSelectRun).toHaveBeenCalledWith('/project/.takt/worktrees/done-task', 'en');
expect(mockLoadRunSessionContext).toHaveBeenCalledWith('/project/.takt/worktrees/done-task', 'run-1');
expect(mockRunInstructMode).toHaveBeenCalledWith(
'/project',
'/project/.takt/worktrees/done-task',
expect.any(String),
'takt/done-task',
'done-task',
'done',
'',
expect.anything(),
runContext,
);
});
it('should return false when worktree does not exist', async () => {
mockExistsSync.mockReturnValue(false);
const result = await instructBranch('/project', {
kind: 'completed',
name: 'done-task',
createdAt: '2026-02-14T00:00:00.000Z',
filePath: '/project/.takt/tasks.yaml',
content: 'done',
branch: 'takt/done-task',
worktreePath: '/project/.takt/worktrees/done-task',
data: { task: 'done' },
});
expect(result).toBe(false);
expect(mockLogError).toHaveBeenCalledWith('Worktree directory does not exist for task: done-task');
expect(mockStartReExecution).not.toHaveBeenCalled();
});
it('should requeue task via requeueTask when save_task action', async () => {
mockDispatchConversationAction.mockImplementation(async (_result, handlers) => handlers.save_task({ task: '追加指示A' }));
const result = await instructBranch('/project', {
kind: 'completed',
name: 'done-task',
createdAt: '2026-02-14T00:00:00.000Z',
filePath: '/project/.takt/tasks.yaml',
content: 'done',
branch: 'takt/done-task',
worktreePath: '/project/.takt/worktrees/done-task',
data: { task: 'done' },
});
expect(result).toBe(true);
expect(mockRequeueTask).toHaveBeenCalledWith('done-task', ['completed', 'failed'], undefined, '追加指示A');
expect(mockStartReExecution).not.toHaveBeenCalled();
expect(mockExecuteAndCompleteTask).not.toHaveBeenCalled();
});
it('should requeue task with existing retry note appended when save_task', async () => {
mockDispatchConversationAction.mockImplementation(async (_result, handlers) => handlers.save_task({ task: '追加指示A' }));
const result = await instructBranch('/project', {
kind: 'completed',
name: 'done-task',
createdAt: '2026-02-14T00:00:00.000Z',
filePath: '/project/.takt/tasks.yaml',
content: 'done',
branch: 'takt/done-task',
worktreePath: '/project/.takt/worktrees/done-task',
data: { task: 'done', retry_note: '既存ノート' },
});
expect(result).toBe(true);
expect(mockRequeueTask).toHaveBeenCalledWith('done-task', ['completed', 'failed'], undefined, '既存ノート\n\n追加指示A');
});
});

View File

@ -1,17 +1,50 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { stringify as stringifyYaml } from 'yaml';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
const {
mockExistsSync,
mockSelectPiece,
mockSelectOption,
mockLoadGlobalConfig,
mockLoadPieceByIdentifier,
mockGetPieceDescription,
mockRunRetryMode,
mockFindRunForTask,
mockStartReExecution,
mockRequeueTask,
mockExecuteAndCompleteTask,
} = vi.hoisted(() => ({
mockExistsSync: vi.fn(() => true),
mockSelectPiece: vi.fn(),
mockSelectOption: vi.fn(),
mockLoadGlobalConfig: vi.fn(),
mockLoadPieceByIdentifier: vi.fn(),
mockGetPieceDescription: vi.fn(() => ({
name: 'default',
description: 'desc',
pieceStructure: '',
movementPreviews: [],
})),
mockRunRetryMode: vi.fn(),
mockFindRunForTask: vi.fn(() => null),
mockStartReExecution: vi.fn(),
mockRequeueTask: vi.fn(),
mockExecuteAndCompleteTask: vi.fn(),
}));
vi.mock('node:fs', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
existsSync: (...args: unknown[]) => mockExistsSync(...args),
}));
vi.mock('../features/pieceSelection/index.js', () => ({
selectPiece: (...args: unknown[]) => mockSelectPiece(...args),
}));
vi.mock('../shared/prompt/index.js', () => ({
selectOption: vi.fn(),
confirm: vi.fn(),
selectOption: (...args: unknown[]) => mockSelectOption(...args),
}));
vi.mock('../shared/ui/index.js', () => ({
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
header: vi.fn(),
blankLine: vi.fn(),
@ -27,48 +60,39 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
}));
vi.mock('../infra/config/index.js', () => ({
loadGlobalConfig: vi.fn(),
loadPieceByIdentifier: vi.fn(),
getPieceDescription: vi.fn(() => ({
name: 'default',
description: 'desc',
pieceStructure: '',
movementPreviews: [],
})),
}));
vi.mock('../features/tasks/list/instructMode.js', () => ({
runInstructMode: vi.fn(),
loadGlobalConfig: (...args: unknown[]) => mockLoadGlobalConfig(...args),
loadPieceByIdentifier: (...args: unknown[]) => mockLoadPieceByIdentifier(...args),
getPieceDescription: (...args: unknown[]) => mockGetPieceDescription(...args),
}));
vi.mock('../features/interactive/index.js', () => ({
resolveLanguage: vi.fn(() => 'en'),
listRecentRuns: vi.fn(() => []),
selectRun: vi.fn(() => null),
findRunForTask: (...args: unknown[]) => mockFindRunForTask(...args),
loadRunSessionContext: vi.fn(),
getRunPaths: vi.fn(() => ({ logsDir: '/tmp/logs', reportsDir: '/tmp/reports' })),
formatRunSessionForPrompt: vi.fn(() => ({
runTask: '', runPiece: '', runStatus: '', runMovementLogs: '', runReports: '',
})),
runRetryMode: (...args: unknown[]) => mockRunRetryMode(...args),
}));
vi.mock('../shared/i18n/index.js', () => ({
getLabel: vi.fn(() => "Reference a previous run's results?"),
vi.mock('../infra/task/index.js', () => ({
TaskRunner: class {
startReExecution(...args: unknown[]) {
return mockStartReExecution(...args);
}
requeueTask(...args: unknown[]) {
return mockRequeueTask(...args);
}
},
}));
vi.mock('../features/tasks/execute/taskExecution.js', () => ({
executeAndCompleteTask: (...args: unknown[]) => mockExecuteAndCompleteTask(...args),
}));
import { selectOption, confirm } 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';
import { runInstructMode } from '../features/tasks/list/instructMode.js';
const mockSelectOption = vi.mocked(selectOption);
const mockConfirm = vi.mocked(confirm);
const mockSuccess = vi.mocked(success);
const mockLogError = vi.mocked(logError);
const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig);
const mockLoadPieceByIdentifier = vi.mocked(loadPieceByIdentifier);
const mockRunInstructMode = vi.mocked(runInstructMode);
let tmpDir: string;
const defaultPieceConfig: PieceConfig = {
name: 'default',
@ -82,115 +106,142 @@ const defaultPieceConfig: PieceConfig = {
],
};
function writeFailedTask(projectDir: string, name: string): TaskListItem {
const tasksFile = path.join(projectDir, '.takt', 'tasks.yaml');
fs.mkdirSync(path.dirname(tasksFile), { recursive: true });
fs.writeFileSync(tasksFile, stringifyYaml({
tasks: [
{
name,
status: 'failed',
content: 'Do something',
created_at: '2025-01-15T12:00:00.000Z',
started_at: '2025-01-15T12:01:00.000Z',
completed_at: '2025-01-15T12:02:00.000Z',
piece: 'default',
failure: {
movement: 'review',
error: 'Boom',
},
},
],
}), 'utf-8');
function makeFailedTask(overrides?: Partial<TaskListItem>): TaskListItem {
return {
kind: 'failed',
name,
name: 'my-task',
createdAt: '2025-01-15T12:02:00.000Z',
filePath: tasksFile,
filePath: '/project/.takt/tasks.yaml',
content: 'Do something',
branch: 'takt/my-task',
worktreePath: '/project/.takt/worktrees/my-task',
data: { task: 'Do something', piece: 'default' },
failure: { movement: 'review', error: 'Boom' },
...overrides,
};
}
beforeEach(() => {
vi.clearAllMocks();
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-test-retry-'));
});
mockExistsSync.mockReturnValue(true);
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
mockSelectPiece.mockResolvedValue('default');
mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' });
mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig);
mockSelectOption.mockResolvedValue('plan');
mockRunRetryMode.mockResolvedValue({ action: 'execute', task: '追加指示A' });
mockStartReExecution.mockReturnValue({
name: 'my-task',
content: 'Do something',
data: { task: 'Do something', piece: 'default' },
});
mockExecuteAndCompleteTask.mockResolvedValue(true);
});
describe('retryFailedTask', () => {
it('should requeue task with selected movement', async () => {
const task = writeFailedTask(tmpDir, 'my-task');
it('should run retry mode in existing worktree and execute directly', async () => {
const task = makeFailedTask();
mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' });
mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig);
const result = await retryFailedTask(task, '/project');
expect(result).toBe(true);
expect(mockSelectPiece).toHaveBeenCalledWith('/project');
expect(mockRunRetryMode).toHaveBeenCalledWith(
'/project/.takt/worktrees/my-task',
expect.objectContaining({
failure: expect.objectContaining({ taskName: 'my-task', taskContent: 'Do something' }),
}),
);
expect(mockStartReExecution).toHaveBeenCalledWith('my-task', ['failed'], undefined, '追加指示A');
expect(mockExecuteAndCompleteTask).toHaveBeenCalled();
});
it('should pass non-initial movement as startMovement', async () => {
const task = makeFailedTask();
mockSelectOption.mockResolvedValue('implement');
mockConfirm.mockResolvedValue(false);
mockRunInstructMode.mockResolvedValue({ action: 'execute', task: '追加指示A' });
const result = await retryFailedTask(task, tmpDir);
await retryFailedTask(task, '/project');
expect(result).toBe(true);
expect(mockSuccess).toHaveBeenCalledWith('Task requeued: my-task');
const tasksYaml = fs.readFileSync(path.join(tmpDir, '.takt', 'tasks.yaml'), 'utf-8');
expect(tasksYaml).toContain('status: pending');
expect(tasksYaml).toContain('start_movement: implement');
expect(tasksYaml).toContain('retry_note: 追加指示A');
expect(mockStartReExecution).toHaveBeenCalledWith('my-task', ['failed'], 'implement', '追加指示A');
});
it('should not add start_movement when initial movement is selected', async () => {
const task = writeFailedTask(tmpDir, 'my-task');
it('should not pass startMovement when initial movement is selected', async () => {
const task = makeFailedTask();
mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' });
mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig);
mockSelectOption.mockResolvedValue('plan');
mockConfirm.mockResolvedValue(false);
mockRunInstructMode.mockResolvedValue({ action: 'execute', task: '追加指示A' });
await retryFailedTask(task, '/project');
const result = await retryFailedTask(task, tmpDir);
expect(result).toBe(true);
const tasksYaml = fs.readFileSync(path.join(tmpDir, '.takt', 'tasks.yaml'), 'utf-8');
expect(tasksYaml).not.toContain('start_movement');
expect(tasksYaml).toContain('retry_note: 追加指示A');
expect(mockStartReExecution).toHaveBeenCalledWith('my-task', ['failed'], undefined, '追加指示A');
});
it('should append generated instruction to existing retry note', async () => {
const task = writeFailedTask(tmpDir, 'my-task');
task.data = { task: 'Do something', piece: 'default', retry_note: '既存ノート' };
it('should append instruction to existing retry note', async () => {
const task = makeFailedTask({ data: { task: 'Do something', piece: 'default', retry_note: '既存ノート' } });
mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' });
mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig);
mockSelectOption.mockResolvedValue('plan');
mockConfirm.mockResolvedValue(false);
mockRunInstructMode.mockResolvedValue({ action: 'execute', task: '追加指示B' });
await retryFailedTask(task, '/project');
const result = await retryFailedTask(task, tmpDir);
expect(result).toBe(true);
const tasksYaml = fs.readFileSync(path.join(tmpDir, '.takt', 'tasks.yaml'), 'utf-8');
expect(tasksYaml).toContain('retry_note: |');
expect(tasksYaml).toContain('既存ノート');
expect(tasksYaml).toContain('追加指示B');
});
it('should return false and show error when piece not found', async () => {
const task = writeFailedTask(tmpDir, 'my-task');
mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' });
mockLoadPieceByIdentifier.mockReturnValue(null);
const result = await retryFailedTask(task, tmpDir);
expect(result).toBe(false);
expect(mockLogError).toHaveBeenCalledWith(
'Piece "default" not found. Cannot determine available movements.',
expect(mockStartReExecution).toHaveBeenCalledWith(
'my-task', ['failed'], undefined, '既存ノート\n\n追加指示A',
);
});
it('should search runs in worktree, not projectDir', async () => {
const task = makeFailedTask();
await retryFailedTask(task, '/project');
expect(mockFindRunForTask).toHaveBeenCalledWith('/project/.takt/worktrees/my-task', 'Do something');
});
it('should throw when worktree path is not set', async () => {
const task = makeFailedTask({ worktreePath: undefined });
await expect(retryFailedTask(task, '/project')).rejects.toThrow('Worktree path is not set');
});
it('should throw when worktree directory does not exist', async () => {
mockExistsSync.mockReturnValue(false);
const task = makeFailedTask();
await expect(retryFailedTask(task, '/project')).rejects.toThrow('Worktree directory does not exist');
});
it('should return false when piece selection is cancelled', async () => {
const task = makeFailedTask();
mockSelectPiece.mockResolvedValue(null);
const result = await retryFailedTask(task, '/project');
expect(result).toBe(false);
expect(mockLoadPieceByIdentifier).not.toHaveBeenCalled();
});
it('should return false when retry mode is cancelled', async () => {
const task = makeFailedTask();
mockRunRetryMode.mockResolvedValue({ action: 'cancel', task: '' });
const result = await retryFailedTask(task, '/project');
expect(result).toBe(false);
expect(mockStartReExecution).not.toHaveBeenCalled();
});
it('should requeue task via requeueTask when save_task action', async () => {
const task = makeFailedTask();
mockRunRetryMode.mockResolvedValue({ action: 'save_task', task: '追加指示A' });
const result = await retryFailedTask(task, '/project');
expect(result).toBe(true);
expect(mockRequeueTask).toHaveBeenCalledWith('my-task', ['failed'], undefined, '追加指示A');
expect(mockStartReExecution).not.toHaveBeenCalled();
expect(mockExecuteAndCompleteTask).not.toHaveBeenCalled();
});
it('should requeue task with existing retry note appended when save_task', async () => {
const task = makeFailedTask({ data: { task: 'Do something', piece: 'default', retry_note: '既存ノート' } });
mockRunRetryMode.mockResolvedValue({ action: 'save_task', task: '追加指示A' });
await retryFailedTask(task, '/project');
expect(mockRequeueTask).toHaveBeenCalledWith('my-task', ['failed'], undefined, '既存ノート\n\n追加指示A');
});
});

View File

@ -12,7 +12,6 @@ import {
initGlobalDirs,
initProjectDirs,
loadGlobalConfig,
getEffectiveDebugConfig,
isVerboseMode,
} from '../../infra/config/index.js';
import { setQuietMode } from '../../shared/context.js';
@ -68,13 +67,7 @@ export async function runPreActionHook(): Promise<void> {
initProjectDirs(resolvedCwd);
const verbose = isVerboseMode(resolvedCwd);
let debugConfig = getEffectiveDebugConfig(resolvedCwd);
if (verbose && (!debugConfig || !debugConfig.enabled)) {
debugConfig = { enabled: true };
}
initDebugLogger(debugConfig, resolvedCwd);
initDebugLogger(verbose ? { enabled: true } : undefined, resolvedCwd);
const config = loadGlobalConfig();

View File

@ -17,12 +17,6 @@ export interface CustomAgentConfig {
model?: string;
}
/** Debug configuration for takt */
export interface DebugConfig {
enabled: boolean;
logFile?: string;
}
/** Observability configuration for runtime event logs */
export interface ObservabilityConfig {
/** Enable provider stream event logging (default: false when undefined) */
@ -63,7 +57,6 @@ export interface GlobalConfig {
logLevel: 'debug' | 'info' | 'warn' | 'error';
provider?: 'claude' | 'codex' | 'opencode' | 'mock';
model?: string;
debug?: DebugConfig;
observability?: ObservabilityConfig;
/** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */
worktreeDir?: string;

View File

@ -27,7 +27,6 @@ export type {
PieceConfig,
PieceState,
CustomAgentConfig,
DebugConfig,
ObservabilityConfig,
Language,
PipelineConfig,

View File

@ -374,12 +374,6 @@ export const CustomAgentConfigSchema = z.object({
{ message: 'Agent must have prompt_file, prompt, claude_agent, or claude_skill' }
);
/** Debug config schema */
export const DebugConfigSchema = z.object({
enabled: z.boolean().optional().default(false),
log_file: z.string().optional(),
});
export const ObservabilityConfigSchema = z.object({
provider_events: z.boolean().optional(),
});
@ -415,7 +409,6 @@ export const GlobalConfigSchema = z.object({
log_level: z.enum(['debug', 'info', 'warn', 'error']).optional().default('info'),
provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional().default('claude'),
model: z.string().optional(),
debug: DebugConfigSchema.optional(),
observability: ObservabilityConfigSchema.optional(),
/** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */
worktree_dir: z.string().optional(),

View File

@ -62,7 +62,6 @@ export type {
// Configuration types (global and project)
export type {
CustomAgentConfig,
DebugConfig,
ObservabilityConfig,
Language,
PipelineConfig,

View File

@ -22,5 +22,6 @@ export { passthroughMode } from './passthroughMode.js';
export { quietMode } from './quietMode.js';
export { personaMode } from './personaMode.js';
export { selectRun } from './runSelector.js';
export { listRecentRuns, loadRunSessionContext, type RunSessionContext } from './runSessionReader.js';
export { listRecentRuns, findRunForTask, loadRunSessionContext, formatRunSessionForPrompt, getRunPaths, type RunSessionContext, type RunPaths } from './runSessionReader.js';
export { runRetryMode, buildRetryTemplateVars, type RetryContext, type RetryFailureInfo, type RetryRunInfo } from './retryMode.js';
export { dispatchConversationAction, type ConversationActionResult } from './actionDispatcher.js';

View File

@ -0,0 +1,167 @@
/**
* Retry mode for failed tasks.
*
* Provides a dedicated conversation loop with failure context,
* run session data, and piece structure injected into the system prompt.
*/
import {
initializeSession,
displayAndClearSessionState,
runConversationLoop,
type SessionContext,
type ConversationStrategy,
type PostSummaryAction,
} from './conversationLoop.js';
import {
buildSummaryActionOptions,
selectSummaryAction,
formatMovementPreviews,
type PieceContext,
} from './interactive-summary.js';
import { resolveLanguage } from './interactive.js';
import { loadTemplate } from '../../shared/prompts/index.js';
import { getLabelObject } from '../../shared/i18n/index.js';
import { loadGlobalConfig } from '../../infra/config/index.js';
import type { InstructModeResult, InstructUIText } from '../tasks/list/instructMode.js';
/** Failure information for a retry task */
export interface RetryFailureInfo {
readonly taskName: string;
readonly taskContent: string;
readonly createdAt: string;
readonly failedMovement: string;
readonly error: string;
readonly lastMessage: string;
readonly retryNote: string;
}
/** Run session reference data for retry prompt */
export interface RetryRunInfo {
readonly logsDir: string;
readonly reportsDir: string;
readonly task: string;
readonly piece: string;
readonly status: string;
readonly movementLogs: string;
readonly reports: string;
}
/** Full retry context assembled by the caller */
export interface RetryContext {
readonly failure: RetryFailureInfo;
readonly branchName: string;
readonly pieceContext: PieceContext;
readonly run: RetryRunInfo | null;
}
const RETRY_TOOLS = ['Read', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'];
/**
* Convert RetryContext into template variable map.
*/
export function buildRetryTemplateVars(ctx: RetryContext, lang: 'en' | 'ja'): Record<string, string | boolean> {
const hasPiecePreview = !!ctx.pieceContext.movementPreviews?.length;
const movementDetails = hasPiecePreview
? formatMovementPreviews(ctx.pieceContext.movementPreviews!, lang)
: '';
const hasRun = ctx.run !== null;
return {
taskName: ctx.failure.taskName,
taskContent: ctx.failure.taskContent,
branchName: ctx.branchName,
createdAt: ctx.failure.createdAt,
failedMovement: ctx.failure.failedMovement,
failureError: ctx.failure.error,
failureLastMessage: ctx.failure.lastMessage,
retryNote: ctx.failure.retryNote,
hasPiecePreview,
pieceStructure: ctx.pieceContext.pieceStructure,
movementDetails,
hasRun,
runLogsDir: hasRun ? ctx.run!.logsDir : '',
runReportsDir: hasRun ? ctx.run!.reportsDir : '',
runTask: hasRun ? ctx.run!.task : '',
runPiece: hasRun ? ctx.run!.piece : '',
runStatus: hasRun ? ctx.run!.status : '',
runMovementLogs: hasRun ? ctx.run!.movementLogs : '',
runReports: hasRun ? ctx.run!.reports : '',
};
}
function createSelectRetryAction(ui: InstructUIText): (task: string, lang: 'en' | 'ja') => Promise<PostSummaryAction | null> {
return async (task: string, _lang: 'en' | 'ja'): Promise<PostSummaryAction | null> => {
return selectSummaryAction(
task,
ui.proposed,
ui.actionPrompt,
buildSummaryActionOptions({
execute: ui.actions.execute,
saveTask: ui.actions.saveTask,
continue: ui.actions.continue,
}),
);
};
}
/**
* Run retry mode conversation loop.
*
* Uses a dedicated system prompt with failure context, run session data,
* and piece structure injected for the AI assistant.
*/
export async function runRetryMode(
cwd: string,
retryContext: RetryContext,
): Promise<InstructModeResult> {
const globalConfig = loadGlobalConfig();
const lang = resolveLanguage(globalConfig.language);
if (!globalConfig.provider) {
throw new Error('Provider is not configured.');
}
const baseCtx = initializeSession(cwd, 'retry');
const ctx: SessionContext = { ...baseCtx, lang, personaName: 'retry' };
displayAndClearSessionState(cwd, ctx.lang);
const ui = getLabelObject<InstructUIText>('instruct.ui', ctx.lang);
const templateVars = buildRetryTemplateVars(retryContext, lang);
const systemPrompt = loadTemplate('score_retry_system_prompt', ctx.lang, templateVars);
const introLabel = ctx.lang === 'ja'
? `## リトライ: ${retryContext.failure.taskName}\n\nブランチ: ${retryContext.branchName}\n\n${ui.intro}`
: `## Retry: ${retryContext.failure.taskName}\n\nBranch: ${retryContext.branchName}\n\n${ui.intro}`;
const policyContent = loadTemplate('score_interactive_policy', ctx.lang, {});
function injectPolicy(userMessage: string): string {
const policyIntro = ctx.lang === 'ja'
? '以下のポリシーは行動規範です。必ず遵守してください。'
: 'The following policy defines behavioral guidelines. Please follow them.';
const reminderLabel = ctx.lang === 'ja'
? '上記の Policy セクションで定義されたポリシー規範を遵守してください。'
: 'Please follow the policy guidelines defined in the Policy section above.';
return `## Policy\n${policyIntro}\n\n${policyContent}\n\n---\n\n${userMessage}\n\n---\n**Policy Reminder:** ${reminderLabel}`;
}
const strategy: ConversationStrategy = {
systemPrompt,
allowedTools: RETRY_TOOLS,
transformPrompt: injectPolicy,
introMessage: introLabel,
selectAction: createSelectRetryAction(ui),
};
const result = await runConversationLoop(cwd, ctx, strategy, retryContext.pieceContext, undefined);
if (result.action === 'cancel') {
return { action: 'cancel', task: '' };
}
return { action: result.action as InstructModeResult['action'], task: result.task };
}

View File

@ -48,6 +48,12 @@ export interface RunSessionContext {
readonly reports: readonly ReportEntry[];
}
/** Absolute paths to a run's logs and reports directories */
export interface RunPaths {
readonly logsDir: string;
readonly reportsDir: string;
}
interface MetaJson {
readonly task: string;
readonly piece: string;
@ -150,6 +156,33 @@ export function listRecentRuns(cwd: string): RunSummary[] {
return summaries.slice(0, MAX_RUNS);
}
/**
* Find the most recent run matching the given task content.
*
* @returns The run slug if found, null otherwise.
*/
export function findRunForTask(cwd: string, taskContent: string): string | null {
const runs = listRecentRuns(cwd);
const match = runs.find((r) => r.task === taskContent);
return match?.slug ?? null;
}
/**
* Get absolute paths to a run's logs and reports directories.
*/
export function getRunPaths(cwd: string, slug: string): RunPaths {
const metaPath = join(cwd, '.takt', 'runs', slug, 'meta.json');
const meta = parseMetaJson(metaPath);
if (!meta) {
throw new Error(`Run not found: ${slug}`);
}
return {
logsDir: join(cwd, meta.logsDirectory),
reportsDir: join(cwd, meta.reportDirectory),
};
}
/**
* Load full run session context for prompt injection.
*/

View File

@ -96,6 +96,14 @@ export async function resolveTaskExecution(
if (data.worktree) {
throwIfAborted(abortSignal);
baseBranch = getCurrentBranch(defaultCwd);
if (task.worktreePath && fs.existsSync(task.worktreePath)) {
// Reuse existing worktree (clone still on disk from previous execution)
execCwd = task.worktreePath;
branch = data.branch;
worktreePath = task.worktreePath;
isWorktree = true;
} else {
const taskSlug = await withProgress(
'Generating branch name...',
(slug) => `Branch name generated: ${slug}`,
@ -119,6 +127,7 @@ export async function resolveTaskExecution(
worktreePath = result.path;
isWorktree = true;
}
}
if (task.taskDir && reportDirName) {
taskPrompt = stageTaskSpecForExecution(defaultCwd, execCwd, task.taskDir, reportDirName);

View File

@ -181,7 +181,7 @@ export async function listTasks(
showFullDiff(cwd, task.branch);
break;
case 'instruct':
await instructBranch(cwd, task, options);
await instructBranch(cwd, task);
break;
case 'try':
tryMergeBranch(cwd, task);

View File

@ -17,6 +17,7 @@ import {
resolveLanguage,
buildSummaryActionOptions,
selectSummaryAction,
formatMovementPreviews,
type PieceContext,
} from '../../interactive/interactive.js';
import { type RunSessionContext, formatRunSessionForPrompt } from '../../interactive/runSessionReader.js';
@ -64,10 +65,47 @@ function createSelectInstructAction(ui: InstructUIText): (task: string, lang: 'e
};
}
function buildInstructTemplateVars(
branchContext: string,
branchName: string,
taskName: string,
taskContent: string,
retryNote: string,
lang: 'en' | 'ja',
pieceContext?: PieceContext,
runSessionContext?: RunSessionContext,
): Record<string, string | boolean> {
const hasPiecePreview = !!pieceContext?.movementPreviews?.length;
const movementDetails = hasPiecePreview
? formatMovementPreviews(pieceContext!.movementPreviews!, lang)
: '';
const hasRunSession = !!runSessionContext;
const runPromptVars = hasRunSession
? formatRunSessionForPrompt(runSessionContext)
: { runTask: '', runPiece: '', runStatus: '', runMovementLogs: '', runReports: '' };
return {
taskName,
taskContent,
branchName,
branchContext,
retryNote,
hasPiecePreview,
pieceStructure: pieceContext?.pieceStructure ?? '',
movementDetails,
hasRunSession,
...runPromptVars,
};
}
export async function runInstructMode(
cwd: string,
branchContext: string,
branchName: string,
taskName: string,
taskContent: string,
retryNote: string,
pieceContext?: PieceContext,
runSessionContext?: RunSessionContext,
): Promise<InstructModeResult> {
@ -85,24 +123,11 @@ export async function runInstructMode(
const ui = getLabelObject<InstructUIText>('instruct.ui', ctx.lang);
const hasRunSession = !!runSessionContext;
const runPromptVars = hasRunSession
? formatRunSessionForPrompt(runSessionContext)
: { runTask: '', runPiece: '', runStatus: '', runMovementLogs: '', runReports: '' };
const systemPrompt = loadTemplate('score_interactive_system_prompt', ctx.lang, {
hasPiecePreview: false,
pieceStructure: '',
movementDetails: '',
hasRunSession,
...runPromptVars,
});
const branchIntro = ctx.lang === 'ja'
? `## ブランチ: ${branchName}\n\n${branchContext}`
: `## Branch: ${branchName}\n\n${branchContext}`;
const introMessage = `${branchIntro}\n\n${ui.intro}`;
const templateVars = buildInstructTemplateVars(
branchContext, branchName, taskName, taskContent, retryNote, lang,
pieceContext, runSessionContext,
);
const systemPrompt = loadTemplate('score_instruct_system_prompt', ctx.lang, templateVars);
const policyContent = loadTemplate('score_interactive_policy', ctx.lang, {});
@ -120,7 +145,7 @@ export async function runInstructMode(
systemPrompt,
allowedTools: INSTRUCT_TOOLS,
transformPrompt: injectPolicy,
introMessage,
introMessage: ui.intro,
selectAction: createSelectInstructAction(ui),
};

View File

@ -1,19 +1,27 @@
/**
* Instruction actions for completed/failed tasks.
*
* Uses the existing worktree (clone) for conversation and direct re-execution.
* The worktree is preserved after initial execution, so no clone creation is needed.
*/
import * as fs from 'node:fs';
import { execFileSync } from 'node:child_process';
import {
TaskRunner,
detectDefaultBranch,
} from '../../../infra/task/index.js';
import { loadGlobalConfig, getPieceDescription } from '../../../infra/config/index.js';
import { info, success } from '../../../shared/ui/index.js';
import { info, error as logError } from '../../../shared/ui/index.js';
import { createLogger, getErrorMessage } from '../../../shared/utils/index.js';
import type { TaskExecutionOptions } from '../execute/types.js';
import { runInstructMode } from './instructMode.js';
import { selectPiece } from '../../pieceSelection/index.js';
import { dispatchConversationAction } from '../../interactive/actionDispatcher.js';
import type { PieceContext } from '../../interactive/interactive.js';
import { resolveLanguage } from '../../interactive/index.js';
import { type BranchActionTarget, resolveTargetBranch } from './taskActionTarget.js';
import { detectDefaultBranch } from '../../../infra/task/index.js';
import { appendRetryNote, selectRunSessionContext } from './requeueHelpers.js';
import { executeAndCompleteTask } from '../execute/taskExecution.js';
const log = createLogger('list-tasks');
@ -66,12 +74,17 @@ function getBranchContext(projectDir: string, branch: string): string {
export async function instructBranch(
projectDir: string,
target: BranchActionTarget,
_options?: TaskExecutionOptions,
): Promise<boolean> {
if (!('kind' in target)) {
throw new Error('Instruct requeue requires a task target.');
}
if (!target.worktreePath || !fs.existsSync(target.worktreePath)) {
logError(`Worktree directory does not exist for task: ${target.name}`);
return false;
}
const worktreePath = target.worktreePath;
const branch = resolveTargetBranch(target);
const selectedPiece = await selectPiece(projectDir);
@ -90,23 +103,30 @@ export async function instructBranch(
};
const lang = resolveLanguage(globalConfig.language);
const runSessionContext = await selectRunSessionContext(projectDir, lang);
// Runs data lives in the worktree (written during previous execution)
const runSessionContext = await selectRunSessionContext(worktreePath, lang);
const branchContext = getBranchContext(projectDir, branch);
const result = await runInstructMode(projectDir, branchContext, branch, pieceContext, runSessionContext);
const requeueWithInstruction = async (instruction: string): Promise<boolean> => {
const runner = new TaskRunner(projectDir);
const result = await runInstructMode(
worktreePath, branchContext, branch,
target.name, target.content, target.data?.retry_note ?? '',
pieceContext, runSessionContext,
);
const executeWithInstruction = async (instruction: string): Promise<boolean> => {
const retryNote = appendRetryNote(target.data?.retry_note, instruction);
runner.requeueTask(target.name, ['completed', 'failed'], undefined, retryNote);
success(`Task requeued with additional instructions: ${target.name}`);
info(` Branch: ${branch}`);
log.info('Requeued task from instruct mode', {
const runner = new TaskRunner(projectDir);
const taskInfo = runner.startReExecution(target.name, ['completed', 'failed'], undefined, retryNote);
log.info('Starting re-execution of instructed task', {
name: target.name,
worktreePath,
branch,
piece: selectedPiece,
});
return true;
return executeAndCompleteTask(taskInfo, runner, projectDir, selectedPiece);
};
return dispatchConversationAction(result, {
@ -114,7 +134,13 @@ export async function instructBranch(
info('Cancelled');
return false;
},
execute: async ({ task }) => requeueWithInstruction(task),
save_task: async ({ task }) => requeueWithInstruction(task),
execute: async ({ task }) => executeWithInstruction(task),
save_task: async ({ task }) => {
const retryNote = appendRetryNote(target.data?.retry_note, task);
const runner = new TaskRunner(projectDir);
runner.requeueTask(target.name, ['completed', 'failed'], undefined, retryNote);
info(`Task "${target.name}" has been requeued.`);
return true;
},
});
}

View File

@ -1,22 +1,31 @@
/**
* Retry actions for failed tasks.
*
* Provides interactive retry functionality including
* failure info display and movement selection.
* Uses the existing worktree (clone) for conversation and direct re-execution.
* The worktree is preserved after initial execution, so no clone creation is needed.
*/
import * as fs from 'node:fs';
import type { TaskListItem } from '../../../infra/task/index.js';
import { TaskRunner } from '../../../infra/task/index.js';
import { loadPieceByIdentifier, loadGlobalConfig, getPieceDescription } from '../../../infra/config/index.js';
import { selectPiece } from '../../pieceSelection/index.js';
import { selectOption } 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 { info, header, blankLine, status } from '../../../shared/ui/index.js';
import { createLogger } from '../../../shared/utils/index.js';
import type { PieceConfig } from '../../../core/models/index.js';
import { runInstructMode } from './instructMode.js';
import type { PieceContext } from '../../interactive/interactive.js';
import { resolveLanguage, selectRun, loadRunSessionContext, listRecentRuns, type RunSessionContext } from '../../interactive/index.js';
import { getLabel } from '../../../shared/i18n/index.js';
import { confirm } from '../../../shared/prompt/index.js';
import {
findRunForTask,
loadRunSessionContext,
getRunPaths,
formatRunSessionForPrompt,
runRetryMode,
type RetryContext,
type RetryFailureInfo,
type RetryRunInfo,
} from '../../interactive/index.js';
import { executeAndCompleteTask } from '../execute/taskExecution.js';
import { appendRetryNote } from './requeueHelpers.js';
const log = createLogger('list-tasks');
@ -58,42 +67,53 @@ async function selectStartMovement(
return await selectOption<string>('Start from movement:', options);
}
function appendRetryNote(existing: string | undefined, additional: string): string {
const trimmedAdditional = additional.trim();
if (trimmedAdditional === '') {
throw new Error('Additional instruction is empty.');
}
if (!existing || existing.trim() === '') {
return trimmedAdditional;
}
return `${existing}\n\n${trimmedAdditional}`;
function buildRetryFailureInfo(task: TaskListItem): RetryFailureInfo {
return {
taskName: task.name,
taskContent: task.content,
createdAt: task.createdAt,
failedMovement: task.failure?.movement ?? '',
error: task.failure?.error ?? '',
lastMessage: task.failure?.last_message ?? '',
retryNote: task.data?.retry_note ?? '',
};
}
function buildRetryBranchContext(task: TaskListItem): string {
const lines = [
'## 失敗情報',
`- タスク名: ${task.name}`,
`- 失敗日時: ${task.createdAt}`,
];
if (task.failure?.movement) {
lines.push(`- 失敗ムーブメント: ${task.failure.movement}`);
function buildRetryRunInfo(
runsBaseDir: string,
slug: string,
): RetryRunInfo {
const paths = getRunPaths(runsBaseDir, slug);
const sessionContext = loadRunSessionContext(runsBaseDir, slug);
const formatted = formatRunSessionForPrompt(sessionContext);
return {
logsDir: paths.logsDir,
reportsDir: paths.reportsDir,
task: formatted.runTask,
piece: formatted.runPiece,
status: formatted.runStatus,
movementLogs: formatted.runMovementLogs,
reports: formatted.runReports,
};
}
if (task.failure?.error) {
lines.push(`- エラー: ${task.failure.error}`);
function resolveWorktreePath(task: TaskListItem): string {
if (!task.worktreePath) {
throw new Error(`Worktree path is not set for task: ${task.name}`);
}
if (task.failure?.last_message) {
lines.push(`- 最終メッセージ: ${task.failure.last_message}`);
if (!fs.existsSync(task.worktreePath)) {
throw new Error(`Worktree directory does not exist: ${task.worktreePath}`);
}
if (task.data?.retry_note) {
lines.push('', '## 既存の再投入メモ', task.data.retry_note);
}
return `${lines.join('\n')}\n`;
return task.worktreePath;
}
/**
* Retry a failed task.
*
* @returns true if task was requeued, false if cancelled
* Runs the retry conversation in the existing worktree, then directly
* re-executes the task there (auto-commit + push + status update).
*
* @returns true if task was re-executed successfully, false if cancelled or failed
*/
export async function retryFailedTask(
task: TaskListItem,
@ -103,15 +123,21 @@ export async function retryFailedTask(
throw new Error(`retryFailedTask requires failed task. received: ${task.kind}`);
}
const worktreePath = resolveWorktreePath(task);
displayFailureInfo(task);
const selectedPiece = await selectPiece(projectDir);
if (!selectedPiece) {
info('Cancelled');
return false;
}
const globalConfig = loadGlobalConfig();
const pieceName = task.data?.piece ?? globalConfig.defaultPiece ?? 'default';
const pieceConfig = loadPieceByIdentifier(pieceName, projectDir);
const pieceConfig = loadPieceByIdentifier(selectedPiece, projectDir);
if (!pieceConfig) {
logError(`Piece "${pieceName}" not found. Cannot determine available movements.`);
return false;
throw new Error(`Piece "${selectedPiece}" not found after selection.`);
}
const selectedMovement = await selectStartMovement(pieceConfig, task.failure?.movement ?? null);
@ -119,72 +145,51 @@ export async function retryFailedTask(
return false;
}
const pieceDesc = getPieceDescription(pieceName, projectDir, globalConfig.interactivePreviewMovements);
const pieceContext: PieceContext = {
const pieceDesc = getPieceDescription(selectedPiece, projectDir, globalConfig.interactivePreviewMovements);
const pieceContext = {
name: pieceDesc.name,
description: pieceDesc.description,
pieceStructure: pieceDesc.pieceStructure,
movementPreviews: pieceDesc.movementPreviews,
};
const lang = resolveLanguage(globalConfig.language);
let runSessionContext: RunSessionContext | undefined;
const hasRuns = listRecentRuns(projectDir).length > 0;
if (hasRuns) {
const shouldReferenceRun = await confirm(
getLabel('interactive.runSelector.confirm', lang),
false,
);
if (shouldReferenceRun) {
const selectedSlug = await selectRun(projectDir, lang);
if (selectedSlug) {
runSessionContext = loadRunSessionContext(projectDir, selectedSlug);
}
}
}
// Runs data lives in the worktree (written during previous execution)
const matchedSlug = findRunForTask(worktreePath, task.content);
const runInfo = matchedSlug ? buildRetryRunInfo(worktreePath, matchedSlug) : null;
blankLine();
const branchContext = buildRetryBranchContext(task);
const branchName = task.branch ?? task.name;
const instructResult = await runInstructMode(
projectDir,
branchContext,
const retryContext: RetryContext = {
failure: buildRetryFailureInfo(task),
branchName,
pieceContext,
runSessionContext,
);
if (instructResult.action !== 'execute') {
run: runInfo,
};
const retryResult = await runRetryMode(worktreePath, retryContext);
if (retryResult.action === 'cancel') {
return false;
}
try {
const runner = new TaskRunner(projectDir);
const startMovement = selectedMovement !== pieceConfig.initialMovement
? selectedMovement
: undefined;
const retryNote = appendRetryNote(task.data?.retry_note, instructResult.task);
const retryNote = appendRetryNote(task.data?.retry_note, retryResult.task);
const runner = new TaskRunner(projectDir);
if (retryResult.action === 'save_task') {
runner.requeueTask(task.name, ['failed'], startMovement, retryNote);
success(`Task requeued: ${task.name}`);
if (startMovement) {
info(` Will start from: ${startMovement}`);
info(`Task "${task.name}" has been requeued.`);
return true;
}
info(' Retry note: updated');
info(` File: ${task.filePath}`);
log.info('Requeued failed task', {
const taskInfo = runner.startReExecution(task.name, ['failed'], startMovement, retryNote);
log.info('Starting re-execution of failed task', {
name: task.name,
tasksFile: task.filePath,
worktreePath,
startMovement,
retryNote,
});
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;
}
return executeAndCompleteTask(taskInfo, runner, projectDir, selectedPiece);
}

View File

@ -1,7 +1,7 @@
/**
* Global configuration loader
*
* Manages ~/.takt/config.yaml and project-level debug settings.
* Manages ~/.takt/config.yaml.
* GlobalConfigManager encapsulates the config cache as a singleton.
*/
@ -9,10 +9,10 @@ import { readFileSync, existsSync, writeFileSync, statSync, accessSync, constant
import { isAbsolute } from 'node:path';
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
import { GlobalConfigSchema } from '../../../core/models/index.js';
import type { GlobalConfig, DebugConfig, Language } from '../../../core/models/index.js';
import type { GlobalConfig, Language } from '../../../core/models/index.js';
import type { ProviderPermissionProfiles } from '../../../core/models/provider-profiles.js';
import { normalizeProviderOptions } from '../loaders/pieceParser.js';
import { getGlobalConfigPath, getProjectConfigPath } from '../paths.js';
import { getGlobalConfigPath } from '../paths.js';
import { DEFAULT_LANGUAGE } from '../../../shared/constants.js';
import { parseProviderModel } from '../../../shared/utils/providerModel.js';
@ -168,10 +168,6 @@ export class GlobalConfigManager {
logLevel: parsed.log_level,
provider: parsed.provider,
model: parsed.model,
debug: parsed.debug ? {
enabled: parsed.debug.enabled,
logFile: parsed.debug.log_file,
} : undefined,
observability: parsed.observability ? {
providerEvents: parsed.observability.provider_events,
} : undefined,
@ -228,12 +224,6 @@ export class GlobalConfigManager {
if (config.model) {
raw.model = config.model;
}
if (config.debug) {
raw.debug = {
enabled: config.debug.enabled,
log_file: config.debug.logFile,
};
}
if (config.observability && config.observability.providerEvents !== undefined) {
raw.observability = {
provider_events: config.observability.providerEvents,
@ -458,41 +448,3 @@ export function resolveOpencodeApiKey(): string | undefined {
}
}
/** Load project-level debug configuration (from .takt/config.yaml) */
export function loadProjectDebugConfig(projectDir: string): DebugConfig | undefined {
const configPath = getProjectConfigPath(projectDir);
if (!existsSync(configPath)) {
return undefined;
}
try {
const content = readFileSync(configPath, 'utf-8');
const raw = parseYaml(content);
if (raw && typeof raw === 'object' && 'debug' in raw) {
const debug = raw.debug;
if (debug && typeof debug === 'object') {
return {
enabled: Boolean(debug.enabled),
logFile: typeof debug.log_file === 'string' ? debug.log_file : undefined,
};
}
}
} catch {
// Ignore parse errors
}
return undefined;
}
/** Get effective debug config (project overrides global) */
export function getEffectiveDebugConfig(projectDir?: string): DebugConfig | undefined {
const globalConfig = loadGlobalConfig();
let debugConfig = globalConfig.debug;
if (projectDir) {
const projectDebugConfig = loadProjectDebugConfig(projectDir);
if (projectDebugConfig) {
debugConfig = projectDebugConfig;
}
}
return debugConfig;
}

View File

@ -16,8 +16,6 @@ export {
resolveOpenaiApiKey,
resolveCodexCliPath,
resolveOpencodeApiKey,
loadProjectDebugConfig,
getEffectiveDebugConfig,
} from './globalConfig.js';
export {

View File

@ -28,6 +28,4 @@ export {
loadGlobalConfig,
saveGlobalConfig,
invalidateGlobalConfigCache,
loadProjectDebugConfig,
getEffectiveDebugConfig,
} from '../global/globalConfig.js';

View File

@ -83,6 +83,15 @@ export class TaskRunner {
return this.lifecycle.requeueTask(taskRef, allowedStatuses, startMovement, retryNote);
}
startReExecution(
taskRef: string,
allowedStatuses: readonly TaskStatus[],
startMovement?: string,
retryNote?: string,
): TaskInfo {
return this.lifecycle.startReExecution(taskRef, allowedStatuses, startMovement, retryNote);
}
deletePendingTask(name: string): void {
this.deletion.deletePendingTask(name);
}

View File

@ -156,6 +156,49 @@ export class TaskLifecycleService {
return this.requeueTask(taskRef, ['failed'], startMovement, retryNote);
}
/**
* Atomically transition a completed/failed task to running for re-execution.
* Avoids the race condition of requeueTask( pending) + claimNextTasks( running).
*/
startReExecution(
taskRef: string,
allowedStatuses: readonly TaskStatus[],
startMovement?: string,
retryNote?: string,
): TaskInfo {
const taskName = this.normalizeTaskRef(taskRef);
let found: TaskRecord | undefined;
this.store.update((current) => {
const index = current.tasks.findIndex((task) => (
task.name === taskName
&& allowedStatuses.includes(task.status)
));
if (index === -1) {
const expectedStatuses = allowedStatuses.join(', ');
throw new Error(`Task not found for re-execution: ${taskRef} (expected status: ${expectedStatuses})`);
}
const target = current.tasks[index]!;
const updated: TaskRecord = {
...target,
status: 'running',
started_at: nowIso(),
owner_pid: process.pid,
failure: undefined,
start_movement: startMovement,
retry_note: retryNote,
};
found = updated;
const tasks = [...current.tasks];
tasks[index] = updated;
return { tasks };
});
return toTaskInfo(this.projectDir, this.tasksFile, found!);
}
requeueTask(
taskRef: string,
allowedStatuses: readonly TaskStatus[],

View File

@ -0,0 +1,87 @@
<!--
template: score_instruct_system_prompt
role: system prompt for instruct assistant mode (completed/failed tasks)
vars: taskName, taskContent, branchName, branchContext, retryNote, hasPiecePreview, pieceStructure, movementDetails, hasRunSession, runTask, runPiece, runStatus, runMovementLogs, runReports
caller: features/tasks/list/instructMode
-->
# Additional Instruction Assistant
Reviews completed task artifacts and creates additional instructions for re-execution.
## How TAKT Works
1. **Additional Instruction Assistant (your role)**: Review branch changes and execution results, then converse with users to create additional instructions for re-execution
2. **Piece Execution**: Pass the created instructions to the piece, where multiple AI agents execute sequentially
## Role Boundaries
**Do:**
- Explain the current situation based on branch changes (diffs, commit history)
- Answer user questions with awareness of the change context
- Create concrete additional instructions for the work that still needs to be done
**Don't:**
- Fix code (piece's job)
- Execute tasks directly (piece's job)
- Mention slash commands
## Task Information
**Task name:** {{taskName}}
**Original instruction:** {{taskContent}}
**Branch:** {{branchName}}
## Branch Changes
{{branchContext}}
{{#if retryNote}}
## Existing Retry Note
Instructions added from previous attempts.
{{retryNote}}
{{/if}}
{{#if hasPiecePreview}}
## Piece Structure
This task will be processed through the following workflow:
{{pieceStructure}}
### Agent Details
The following agents will process the task sequentially. Understand each agent's capabilities and instructions to improve the quality of your task instructions.
{{movementDetails}}
### Delegation Guidance
- Do not include excessive detail in instructions for things the agents above can investigate and determine on their own
- Clearly include information that agents cannot resolve on their own (user intent, priorities, constraints, etc.)
- Delegate codebase investigation, implementation details, and dependency analysis to the agents
{{/if}}
{{#if hasRunSession}}
## Previous Run Reference
The user has selected a previous run for reference. Use this information to help them understand what happened and craft follow-up instructions.
**Task:** {{runTask}}
**Piece:** {{runPiece}}
**Status:** {{runStatus}}
### Movement Logs
{{runMovementLogs}}
### Reports
{{runReports}}
### Guidance
- Reference specific movement results when discussing issues or improvements
- Help the user identify what went wrong or what needs additional work
- Suggest concrete follow-up instructions based on the run results
{{/if}}

View File

@ -0,0 +1,97 @@
<!--
template: score_retry_system_prompt
role: system prompt for retry assistant mode
vars: taskName, taskContent, branchName, createdAt, failedMovement, failureError, failureLastMessage, retryNote, hasPiecePreview, pieceStructure, movementDetails, hasRun, runLogsDir, runReportsDir, runTask, runPiece, runStatus, runMovementLogs, runReports
caller: features/interactive/retryMode
-->
# Retry Assistant
Diagnoses failed tasks and creates additional instructions for re-execution.
## How TAKT Works
1. **Retry Assistant (your role)**: Analyze failure causes and converse with users to create instructions for re-execution
2. **Piece Execution**: Pass the created instructions to the piece, where multiple AI agents execute sequentially
## Role Boundaries
**Do:**
- Analyze failure information and explain possible causes to the user
- Answer user questions with awareness of the failure context
- Create concrete additional instructions that will help the re-execution succeed
**Don't:**
- Fix code (piece's job)
- Execute tasks directly (piece's job)
- Mention slash commands
## Failure Information
**Task name:** {{taskName}}
**Original instruction:** {{taskContent}}
**Branch:** {{branchName}}
**Failed at:** {{createdAt}}
{{#if failedMovement}}
**Failed movement:** {{failedMovement}}
{{/if}}
**Error:** {{failureError}}
{{#if failureLastMessage}}
### Last Message
{{failureLastMessage}}
{{/if}}
{{#if retryNote}}
## Existing Retry Note
Instructions added from previous retry attempts.
{{retryNote}}
{{/if}}
{{#if hasPiecePreview}}
## Piece Structure
This task will be processed through the following workflow:
{{pieceStructure}}
### Agent Details
The following agents will process the task sequentially. Understand each agent's capabilities and instructions to improve the quality of your task instructions.
{{movementDetails}}
### Delegation Guidance
- Do not include excessive detail in instructions for things the agents above can investigate and determine on their own
- Clearly include information that agents cannot resolve on their own (user intent, priorities, constraints, etc.)
- Delegate codebase investigation, implementation details, and dependency analysis to the agents
{{/if}}
{{#if hasRun}}
## Previous Run Data
Logs and reports from the previous execution are available for reference. Use them to identify the failure cause.
**Logs directory:** {{runLogsDir}}
**Reports directory:** {{runReportsDir}}
**Task:** {{runTask}}
**Piece:** {{runPiece}}
**Status:** {{runStatus}}
### Movement Logs
{{runMovementLogs}}
### Reports
{{runReports}}
### Analysis Guidance
- Focus on the movement logs where the error occurred
- Cross-reference the plans and implementation recorded in reports with the actual failure point
- If the user wants more details, files in the directories above can be read using the Read tool
{{/if}}

View File

@ -0,0 +1,87 @@
<!--
template: score_instruct_system_prompt
role: system prompt for instruct assistant mode (completed/failed tasks)
vars: taskName, taskContent, branchName, branchContext, retryNote, hasPiecePreview, pieceStructure, movementDetails, hasRunSession, runTask, runPiece, runStatus, runMovementLogs, runReports
caller: features/tasks/list/instructMode
-->
# 追加指示アシスタント
完了済みタスクの成果物を確認し、再実行のための追加指示を作成する。
## TAKTの仕組み
1. **追加指示アシスタント(あなたの役割)**: ブランチの変更内容と実行結果を確認し、ユーザーと対話して再実行用の追加指示を作成する
2. **ピース実行**: 作成した指示書をピースに渡し、複数のAIエージェントが順次実行する
## 役割の境界
**やること:**
- ブランチの変更内容(差分・コミット履歴)を踏まえて状況を説明する
- ユーザーの質問に変更コンテキストを踏まえて回答する
- 追加で必要な作業を具体的な指示として作成する
**やらないこと:**
- コードの修正(ピースの仕事)
- タスクの直接実行(ピースの仕事)
- スラッシュコマンドへの言及
## タスク情報
**タスク名:** {{taskName}}
**元の指示:** {{taskContent}}
**ブランチ:** {{branchName}}
## ブランチの変更内容
{{branchContext}}
{{#if retryNote}}
## 既存の再投入メモ
以前の追加指示で設定された内容です。
{{retryNote}}
{{/if}}
{{#if hasPiecePreview}}
## ピース構成
このタスクは以下のワークフローで処理されます:
{{pieceStructure}}
### エージェント詳細
以下のエージェントが順次タスクを処理します。各エージェントの能力と指示内容を理解し、指示書の質を高めてください。
{{movementDetails}}
### 委譲ガイダンス
- 上記エージェントが自ら調査・判断できる内容は、指示書に過度な詳細を含める必要はありません
- エージェントが自力で解決できない情報(ユーザーの意図、優先度、制約条件など)を指示書に明確に含めてください
- コードベースの調査、実装詳細の特定、依存関係の解析はエージェントに委ねてください
{{/if}}
{{#if hasRunSession}}
## 前回実行の参照
ユーザーが前回の実行結果を参照として選択しました。この情報を使って、何が起きたかを理解し、追加指示の作成を支援してください。
**タスク:** {{runTask}}
**ピース:** {{runPiece}}
**ステータス:** {{runStatus}}
### ムーブメントログ
{{runMovementLogs}}
### レポート
{{runReports}}
### ガイダンス
- 問題点や改善点を議論する際は、具体的なムーブメントの結果を参照してください
- 何がうまくいかなかったか、追加作業が必要な箇所をユーザーが特定できるよう支援してください
- 実行結果に基づいて、具体的なフォローアップ指示を提案してください
{{/if}}

View File

@ -0,0 +1,97 @@
<!--
template: score_retry_system_prompt
role: system prompt for retry assistant mode
vars: taskName, taskContent, branchName, createdAt, failedMovement, failureError, failureLastMessage, retryNote, hasPiecePreview, pieceStructure, movementDetails, hasRun, runLogsDir, runReportsDir, runTask, runPiece, runStatus, runMovementLogs, runReports
caller: features/interactive/retryMode
-->
# リトライアシスタント
失敗したタスクの診断と、再実行のための追加指示作成を担当する。
## TAKTの仕組み
1. **リトライアシスタント(あなたの役割)**: 失敗原因を分析し、ユーザーと対話して再実行用の指示書を作成する
2. **ピース実行**: 作成した指示書をピースに渡し、複数のAIエージェントが順次実行する
## 役割の境界
**やること:**
- 失敗情報を分析し、考えられる原因をユーザーに説明する
- ユーザーの質問に失敗コンテキストを踏まえて回答する
- 再実行時に成功するための具体的な追加指示を作成する
**やらないこと:**
- コードの修正(ピースの仕事)
- タスクの直接実行(ピースの仕事)
- スラッシュコマンドへの言及
## 失敗情報
**タスク名:** {{taskName}}
**元の指示:** {{taskContent}}
**ブランチ:** {{branchName}}
**失敗日時:** {{createdAt}}
{{#if failedMovement}}
**失敗ムーブメント:** {{failedMovement}}
{{/if}}
**エラー:** {{failureError}}
{{#if failureLastMessage}}
### 最終メッセージ
{{failureLastMessage}}
{{/if}}
{{#if retryNote}}
## 既存の再投入メモ
以前のリトライで追加された指示です。
{{retryNote}}
{{/if}}
{{#if hasPiecePreview}}
## ピース構成
このタスクは以下のワークフローで処理されます:
{{pieceStructure}}
### エージェント詳細
以下のエージェントが順次タスクを処理します。各エージェントの能力と指示内容を理解し、指示書の質を高めてください。
{{movementDetails}}
### 委譲ガイダンス
- 上記エージェントが自ら調査・判断できる内容は、指示書に過度な詳細を含める必要はありません
- エージェントが自力で解決できない情報(ユーザーの意図、優先度、制約条件など)を指示書に明確に含めてください
- コードベースの調査、実装詳細の特定、依存関係の解析はエージェントに委ねてください
{{/if}}
{{#if hasRun}}
## 前回実行データ
前回の実行ログとレポートを参照できます。失敗原因の特定に活用してください。
**ログディレクトリ:** {{runLogsDir}}
**レポートディレクトリ:** {{runReportsDir}}
**タスク:** {{runTask}}
**ピース:** {{runPiece}}
**ステータス:** {{runStatus}}
### ムーブメントログ
{{runMovementLogs}}
### レポート
{{runReports}}
### 分析ガイダンス
- エラーが発生したムーブメントのログを重点的に確認してください
- レポートに記録された計画や実装内容と、実際の失敗箇所を照合してください
- ユーザーが詳細を知りたい場合は、上記ディレクトリのファイルを Read ツールで参照できます
{{/if}}