From e52e1da6bf0bfdfb6a4e37add52a8935b6e76399 Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Sat, 14 Feb 2026 11:44:01 +0900 Subject: [PATCH] takt-list (#271) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: provider/modelの解決ロジックをAgentRunnerに集約 OptionsBuilderでCLIレベルとstepレベルを事前マージしていたのをやめ、 stepProvider/stepModelとして分離して渡す形に変更。 AgentRunnerが全レイヤーの優先度を一括で解決する。 * takt: takt-list --- src/__tests__/engine-agent-overrides.test.ts | 24 +- .../engine-persona-providers.test.ts | 20 +- ...istNonInteractive-completedActions.test.ts | 83 ++++ src/__tests__/listNonInteractive.test.ts | 12 +- src/__tests__/listTasks.test.ts | 16 +- .../listTasksInteractivePendingLabel.test.ts | 37 +- .../listTasksInteractiveStatusActions.test.ts | 147 +++++++ src/__tests__/option-resolution-order.test.ts | 210 +++++++++ src/__tests__/selectAndExecute-autoPr.test.ts | 111 ++++- src/__tests__/task.test.ts | 38 +- src/__tests__/taskDeleteActions.test.ts | 84 +++- src/__tests__/taskExecution.test.ts | 1 + src/__tests__/taskInstructionActions.test.ts | 157 +++++++ src/__tests__/taskStatusLabel.test.ts | 6 +- src/agents/runner.ts | 35 +- src/agents/types.ts | 2 + src/core/piece/engine/OptionsBuilder.ts | 6 +- src/features/tasks/execute/resolveTask.ts | 4 + .../tasks/execute/selectAndExecute.ts | 52 ++- src/features/tasks/execute/taskExecution.ts | 40 +- .../tasks/execute/taskResultHandler.ts | 125 ++++++ src/features/tasks/list/index.ts | 143 +++---- src/features/tasks/list/listNonInteractive.ts | 56 +-- src/features/tasks/list/taskActionTarget.ts | 29 ++ src/features/tasks/list/taskActions.ts | 402 +----------------- .../tasks/list/taskBranchLifecycleActions.ts | 141 ++++++ src/features/tasks/list/taskDeleteActions.ts | 40 +- src/features/tasks/list/taskDiffActions.ts | 74 ++++ .../tasks/list/taskInstructionActions.ts | 185 ++++++++ src/features/tasks/list/taskStatusLabel.ts | 4 +- src/infra/task/mapper.ts | 52 ++- src/infra/task/runner.ts | 246 ++--------- src/infra/task/schema.ts | 1 + src/infra/task/taskDeletionService.ts | 29 ++ src/infra/task/taskLifecycleService.ts | 232 ++++++++++ src/infra/task/taskQueryService.ts | 37 ++ src/infra/task/types.ts | 7 +- 37 files changed, 2010 insertions(+), 878 deletions(-) create mode 100644 src/__tests__/listNonInteractive-completedActions.test.ts create mode 100644 src/__tests__/listTasksInteractiveStatusActions.test.ts create mode 100644 src/__tests__/option-resolution-order.test.ts create mode 100644 src/__tests__/taskInstructionActions.test.ts create mode 100644 src/features/tasks/execute/taskResultHandler.ts create mode 100644 src/features/tasks/list/taskActionTarget.ts create mode 100644 src/features/tasks/list/taskBranchLifecycleActions.ts create mode 100644 src/features/tasks/list/taskDiffActions.ts create mode 100644 src/features/tasks/list/taskInstructionActions.ts create mode 100644 src/infra/task/taskDeletionService.ts create mode 100644 src/infra/task/taskLifecycleService.ts create mode 100644 src/infra/task/taskQueryService.ts diff --git a/src/__tests__/engine-agent-overrides.test.ts b/src/__tests__/engine-agent-overrides.test.ts index 4ba823c..38b2804 100644 --- a/src/__tests__/engine-agent-overrides.test.ts +++ b/src/__tests__/engine-agent-overrides.test.ts @@ -1,8 +1,8 @@ /** * Tests for PieceEngine provider/model overrides. * - * Verifies that CLI-specified overrides take precedence over piece movement defaults, - * and that movement-specific values are used when no overrides are present. + * Verifies that PieceEngine passes CLI-level and movement-level provider/model + * as separate fields to AgentRunner. */ import { describe, it, expect, beforeEach, vi } from 'vitest'; @@ -44,7 +44,7 @@ describe('PieceEngine agent overrides', () => { applyDefaultMocks(); }); - it('respects piece movement provider/model even when CLI overrides are provided', async () => { + it('passes CLI provider/model and movement provider/model separately', async () => { const movement = makeMovement('plan', { provider: 'claude', model: 'claude-movement', @@ -71,11 +71,13 @@ describe('PieceEngine agent overrides', () => { await engine.run(); const options = vi.mocked(runAgent).mock.calls[0][2]; - expect(options.provider).toBe('claude'); - expect(options.model).toBe('claude-movement'); + expect(options.provider).toBe('codex'); + expect(options.model).toBe('cli-model'); + expect(options.stepProvider).toBe('claude'); + expect(options.stepModel).toBe('claude-movement'); }); - it('allows CLI overrides when piece movement leaves provider/model undefined', async () => { + it('uses CLI provider/model when movement provider/model is undefined', async () => { const movement = makeMovement('plan', { rules: [makeRule('done', 'COMPLETE')], }); @@ -102,9 +104,11 @@ describe('PieceEngine agent overrides', () => { const options = vi.mocked(runAgent).mock.calls[0][2]; expect(options.provider).toBe('codex'); expect(options.model).toBe('cli-model'); + expect(options.stepProvider).toBe('codex'); + expect(options.stepModel).toBe('cli-model'); }); - it('falls back to piece movement provider/model when no overrides supplied', async () => { + it('sets movement provider/model to step fields when no CLI overrides are supplied', async () => { const movement = makeMovement('plan', { provider: 'claude', model: 'movement-model', @@ -126,7 +130,9 @@ describe('PieceEngine agent overrides', () => { await engine.run(); const options = vi.mocked(runAgent).mock.calls[0][2]; - expect(options.provider).toBe('claude'); - expect(options.model).toBe('movement-model'); + expect(options.provider).toBeUndefined(); + expect(options.model).toBeUndefined(); + expect(options.stepProvider).toBe('claude'); + expect(options.stepModel).toBe('movement-model'); }); }); diff --git a/src/__tests__/engine-persona-providers.test.ts b/src/__tests__/engine-persona-providers.test.ts index 4dc533c..2766186 100644 --- a/src/__tests__/engine-persona-providers.test.ts +++ b/src/__tests__/engine-persona-providers.test.ts @@ -1,10 +1,10 @@ /** * Tests for persona_providers config-level provider override. * - * Verifies the provider resolution priority: + * Verifies movement-level provider resolution for stepProvider: * 1. Movement YAML provider (highest) * 2. persona_providers[personaDisplayName] - * 3. CLI/global provider (lowest) + * 3. CLI provider (lowest) */ import { describe, it, expect, beforeEach, vi } from 'vitest'; @@ -72,7 +72,8 @@ describe('PieceEngine persona_providers override', () => { await engine.run(); const options = vi.mocked(runAgent).mock.calls[0][2]; - expect(options.provider).toBe('codex'); + expect(options.provider).toBe('claude'); + expect(options.stepProvider).toBe('codex'); }); it('should use global provider when persona is not in persona_providers', async () => { @@ -102,6 +103,7 @@ describe('PieceEngine persona_providers override', () => { const options = vi.mocked(runAgent).mock.calls[0][2]; expect(options.provider).toBe('claude'); + expect(options.stepProvider).toBe('claude'); }); it('should prioritize movement provider over persona_providers', async () => { @@ -131,7 +133,8 @@ describe('PieceEngine persona_providers override', () => { await engine.run(); const options = vi.mocked(runAgent).mock.calls[0][2]; - expect(options.provider).toBe('claude'); + expect(options.provider).toBe('mock'); + expect(options.stepProvider).toBe('claude'); }); it('should work without persona_providers (undefined)', async () => { @@ -160,6 +163,7 @@ describe('PieceEngine persona_providers override', () => { const options = vi.mocked(runAgent).mock.calls[0][2]; expect(options.provider).toBe('claude'); + expect(options.stepProvider).toBe('claude'); }); it('should apply different providers to different personas in a multi-movement piece', async () => { @@ -196,9 +200,11 @@ describe('PieceEngine persona_providers override', () => { await engine.run(); const calls = vi.mocked(runAgent).mock.calls; - // Plan movement: planner not in persona_providers → claude + // Plan movement: planner not in persona_providers → stepProvider は claude expect(calls[0][2].provider).toBe('claude'); - // Implement movement: coder in persona_providers → codex - expect(calls[1][2].provider).toBe('codex'); + expect(calls[0][2].stepProvider).toBe('claude'); + // Implement movement: coder in persona_providers → stepProvider は codex + expect(calls[1][2].provider).toBe('claude'); + expect(calls[1][2].stepProvider).toBe('codex'); }); }); diff --git a/src/__tests__/listNonInteractive-completedActions.test.ts b/src/__tests__/listNonInteractive-completedActions.test.ts new file mode 100644 index 0000000..7860340 --- /dev/null +++ b/src/__tests__/listNonInteractive-completedActions.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockDeleteCompletedTask, + mockListAllTaskItems, + mockMergeBranch, + mockDeleteBranch, + mockInfo, +} = vi.hoisted(() => ({ + mockDeleteCompletedTask: vi.fn(), + mockListAllTaskItems: vi.fn(), + mockMergeBranch: vi.fn(), + mockDeleteBranch: vi.fn(), + mockInfo: vi.fn(), +})); + +vi.mock('../infra/task/index.js', () => ({ + detectDefaultBranch: vi.fn(() => 'main'), + TaskRunner: class { + listAllTaskItems() { + return mockListAllTaskItems(); + } + deleteCompletedTask(name: string) { + mockDeleteCompletedTask(name); + } + }, +})); + +vi.mock('../shared/ui/index.js', () => ({ + info: (...args: unknown[]) => mockInfo(...args), +})); + +vi.mock('../features/tasks/list/taskActions.js', () => ({ + tryMergeBranch: vi.fn(), + mergeBranch: (...args: unknown[]) => mockMergeBranch(...args), + deleteBranch: (...args: unknown[]) => mockDeleteBranch(...args), +})); + +import { listTasksNonInteractive } from '../features/tasks/list/listNonInteractive.js'; + +describe('listTasksNonInteractive completed actions', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockListAllTaskItems.mockReturnValue([ + { + kind: 'completed', + name: 'completed-task', + createdAt: '2026-02-14T00:00:00.000Z', + filePath: '/project/.takt/tasks.yaml', + content: 'done', + branch: 'takt/completed-task', + }, + ]); + }); + + it('should delete completed record after merge action', async () => { + mockMergeBranch.mockReturnValue(true); + + await listTasksNonInteractive('/project', { + enabled: true, + action: 'merge', + branch: 'takt/completed-task', + yes: true, + }); + + expect(mockMergeBranch).toHaveBeenCalled(); + expect(mockDeleteCompletedTask).toHaveBeenCalledWith('completed-task'); + }); + + it('should delete completed record after delete action', async () => { + mockDeleteBranch.mockReturnValue(true); + + await listTasksNonInteractive('/project', { + enabled: true, + action: 'delete', + branch: 'takt/completed-task', + yes: true, + }); + + expect(mockDeleteBranch).toHaveBeenCalled(); + expect(mockDeleteCompletedTask).toHaveBeenCalledWith('completed-task'); + }); +}); diff --git a/src/__tests__/listNonInteractive.test.ts b/src/__tests__/listNonInteractive.test.ts index 85b66f2..71f8ae8 100644 --- a/src/__tests__/listNonInteractive.test.ts +++ b/src/__tests__/listNonInteractive.test.ts @@ -13,8 +13,6 @@ vi.mock('../shared/ui/index.js', () => ({ vi.mock('../infra/task/branchList.js', async (importOriginal) => ({ ...(await importOriginal>()), detectDefaultBranch: vi.fn(() => 'main'), - listTaktBranches: vi.fn(() => []), - buildListItems: vi.fn(() => []), })); let tmpDir: string; @@ -60,7 +58,7 @@ describe('listTasksNonInteractive', () => { await listTasksNonInteractive(tmpDir, { enabled: true, format: 'text' }); - expect(mockInfo).toHaveBeenCalledWith(expect.stringContaining('[running] pending-task')); + expect(mockInfo).toHaveBeenCalledWith(expect.stringContaining('[pending] pending-task')); expect(mockInfo).toHaveBeenCalledWith(expect.stringContaining('[failed] failed-task')); }); @@ -71,9 +69,11 @@ describe('listTasksNonInteractive', () => { await listTasksNonInteractive(tmpDir, { enabled: true, format: 'json' }); expect(logSpy).toHaveBeenCalledTimes(1); - const payload = JSON.parse(logSpy.mock.calls[0]![0] as string) as { pendingTasks: Array<{ name: string }>; failedTasks: Array<{ name: string }> }; - expect(payload.pendingTasks[0]?.name).toBe('pending-task'); - expect(payload.failedTasks[0]?.name).toBe('failed-task'); + const payload = JSON.parse(logSpy.mock.calls[0]![0] as string) as { tasks: Array<{ name: string; kind: string }> }; + expect(payload.tasks[0]?.name).toBe('pending-task'); + expect(payload.tasks[0]?.kind).toBe('pending'); + expect(payload.tasks[1]?.name).toBe('failed-task'); + expect(payload.tasks[1]?.kind).toBe('failed'); logSpy.mockRestore(); }); diff --git a/src/__tests__/listTasks.test.ts b/src/__tests__/listTasks.test.ts index 704b749..ba74935 100644 --- a/src/__tests__/listTasks.test.ts +++ b/src/__tests__/listTasks.test.ts @@ -12,8 +12,6 @@ vi.mock('../shared/ui/index.js', () => ({ vi.mock('../infra/task/branchList.js', async (importOriginal) => ({ ...(await importOriginal>()), - listTaktBranches: vi.fn(() => []), - buildListItems: vi.fn(() => []), detectDefaultBranch: vi.fn(() => 'main'), })); @@ -74,7 +72,7 @@ describe('TaskRunner list APIs', () => { }); describe('listTasks non-interactive JSON output', () => { - it('should output JSON object with branches, pendingTasks, and failedTasks', async () => { + it('should output JSON object with tasks', async () => { writeTasksFile(tmpDir); const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); @@ -82,13 +80,13 @@ describe('listTasks non-interactive JSON output', () => { expect(logSpy).toHaveBeenCalledTimes(1); const payload = JSON.parse(logSpy.mock.calls[0]![0] as string) as { - branches: unknown[]; - pendingTasks: Array<{ name: string }>; - failedTasks: Array<{ name: string }>; + tasks: Array<{ name: string; kind: string }>; }; - expect(Array.isArray(payload.branches)).toBe(true); - expect(payload.pendingTasks[0]?.name).toBe('pending-one'); - expect(payload.failedTasks[0]?.name).toBe('failed-one'); + expect(Array.isArray(payload.tasks)).toBe(true); + expect(payload.tasks[0]?.name).toBe('pending-one'); + expect(payload.tasks[0]?.kind).toBe('pending'); + expect(payload.tasks[1]?.name).toBe('failed-one'); + expect(payload.tasks[1]?.kind).toBe('failed'); logSpy.mockRestore(); }); diff --git a/src/__tests__/listTasksInteractivePendingLabel.test.ts b/src/__tests__/listTasksInteractivePendingLabel.test.ts index f6e3196..5de745b 100644 --- a/src/__tests__/listTasksInteractivePendingLabel.test.ts +++ b/src/__tests__/listTasksInteractivePendingLabel.test.ts @@ -6,38 +6,27 @@ const { mockHeader, mockInfo, mockBlankLine, - mockConfirm, - mockListPendingTaskItems, - mockListFailedTasks, + mockListAllTaskItems, mockDeletePendingTask, } = vi.hoisted(() => ({ mockSelectOption: vi.fn(), mockHeader: vi.fn(), mockInfo: vi.fn(), mockBlankLine: vi.fn(), - mockConfirm: vi.fn(), - mockListPendingTaskItems: vi.fn(), - mockListFailedTasks: vi.fn(), + mockListAllTaskItems: vi.fn(), mockDeletePendingTask: vi.fn(), })); vi.mock('../infra/task/index.js', () => ({ - listTaktBranches: vi.fn(() => []), - buildListItems: vi.fn(() => []), - detectDefaultBranch: vi.fn(() => 'main'), TaskRunner: class { - listPendingTaskItems() { - return mockListPendingTaskItems(); - } - listFailedTasks() { - return mockListFailedTasks(); + listAllTaskItems() { + return mockListAllTaskItems(); } }, })); vi.mock('../shared/prompt/index.js', () => ({ selectOption: mockSelectOption, - confirm: mockConfirm, })); vi.mock('../shared/ui/index.js', () => ({ @@ -48,7 +37,7 @@ vi.mock('../shared/ui/index.js', () => ({ vi.mock('../features/tasks/list/taskActions.js', () => ({ showFullDiff: vi.fn(), - showDiffAndPromptAction: vi.fn(), + showDiffAndPromptActionForTask: vi.fn(), tryMergeBranch: vi.fn(), mergeBranch: vi.fn(), deleteBranch: vi.fn(), @@ -58,6 +47,7 @@ vi.mock('../features/tasks/list/taskActions.js', () => ({ vi.mock('../features/tasks/list/taskDeleteActions.js', () => ({ deletePendingTask: mockDeletePendingTask, deleteFailedTask: vi.fn(), + deleteCompletedTask: vi.fn(), })); vi.mock('../features/tasks/list/taskRetryActions.js', () => ({ @@ -77,23 +67,22 @@ describe('listTasks interactive pending label regression', () => { beforeEach(() => { vi.clearAllMocks(); - mockListPendingTaskItems.mockReturnValue([pendingTask]); - mockListFailedTasks.mockReturnValue([]); + mockListAllTaskItems.mockReturnValue([pendingTask]); }); - it('should show [running] in interactive menu for pending tasks', async () => { + it('should show [pending] in interactive menu for pending tasks', async () => { mockSelectOption.mockResolvedValueOnce(null); await listTasks('/project'); expect(mockSelectOption).toHaveBeenCalledTimes(1); const menuOptions = mockSelectOption.mock.calls[0]![1] as Array<{ label: string; value: string }>; - expect(menuOptions).toContainEqual(expect.objectContaining({ label: '[running] my-task', value: 'pending:0' })); - expect(menuOptions.some((opt) => opt.label.includes('[pending]'))).toBe(false); + expect(menuOptions).toContainEqual(expect.objectContaining({ label: '[pending] my-task', value: 'pending:0' })); + expect(menuOptions.some((opt) => opt.label.includes('[running]'))).toBe(false); expect(menuOptions.some((opt) => opt.label.includes('[pendig]'))).toBe(false); }); - it('should show [running] header when pending task is selected', async () => { + it('should show [pending] header when pending task is selected', async () => { mockSelectOption .mockResolvedValueOnce('pending:0') .mockResolvedValueOnce(null) @@ -101,9 +90,9 @@ describe('listTasks interactive pending label regression', () => { await listTasks('/project'); - expect(mockHeader).toHaveBeenCalledWith('[running] my-task'); + expect(mockHeader).toHaveBeenCalledWith('[pending] my-task'); const headerTexts = mockHeader.mock.calls.map(([text]) => String(text)); - expect(headerTexts.some((text) => text.includes('[pending]'))).toBe(false); + expect(headerTexts.some((text) => text.includes('[running]'))).toBe(false); expect(headerTexts.some((text) => text.includes('[pendig]'))).toBe(false); }); }); diff --git a/src/__tests__/listTasksInteractiveStatusActions.test.ts b/src/__tests__/listTasksInteractiveStatusActions.test.ts new file mode 100644 index 0000000..98b2730 --- /dev/null +++ b/src/__tests__/listTasksInteractiveStatusActions.test.ts @@ -0,0 +1,147 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { TaskListItem } from '../infra/task/types.js'; + +const { + mockSelectOption, + mockHeader, + mockInfo, + mockBlankLine, + mockListAllTaskItems, + mockDeleteCompletedRecord, + mockShowDiffAndPromptActionForTask, + mockMergeBranch, + mockDeleteCompletedTask, +} = vi.hoisted(() => ({ + mockSelectOption: vi.fn(), + mockHeader: vi.fn(), + mockInfo: vi.fn(), + mockBlankLine: vi.fn(), + mockListAllTaskItems: vi.fn(), + mockDeleteCompletedRecord: vi.fn(), + mockShowDiffAndPromptActionForTask: vi.fn(), + mockMergeBranch: vi.fn(), + mockDeleteCompletedTask: vi.fn(), +})); + +vi.mock('../infra/task/index.js', () => ({ + TaskRunner: class { + listAllTaskItems() { + return mockListAllTaskItems(); + } + deleteCompletedTask(name: string) { + mockDeleteCompletedRecord(name); + } + }, +})); + +vi.mock('../shared/prompt/index.js', () => ({ + selectOption: mockSelectOption, +})); + +vi.mock('../shared/ui/index.js', () => ({ + info: mockInfo, + header: mockHeader, + blankLine: mockBlankLine, +})); + +vi.mock('../features/tasks/list/taskActions.js', () => ({ + showFullDiff: vi.fn(), + showDiffAndPromptActionForTask: mockShowDiffAndPromptActionForTask, + tryMergeBranch: vi.fn(), + mergeBranch: mockMergeBranch, + deleteBranch: vi.fn(), + instructBranch: vi.fn(), +})); + +vi.mock('../features/tasks/list/taskDeleteActions.js', () => ({ + deletePendingTask: vi.fn(), + deleteFailedTask: vi.fn(), + deleteCompletedTask: mockDeleteCompletedTask, +})); + +vi.mock('../features/tasks/list/taskRetryActions.js', () => ({ + retryFailedTask: vi.fn(), +})); + +import { listTasks } from '../features/tasks/list/index.js'; + +const runningTask: TaskListItem = { + kind: 'running', + name: 'running-task', + createdAt: '2026-02-14T00:00:00.000Z', + filePath: '/project/.takt/tasks.yaml', + content: 'in progress', +}; + +const completedTaskWithBranch: TaskListItem = { + kind: 'completed', + name: 'completed-task', + createdAt: '2026-02-14T00:00:00.000Z', + filePath: '/project/.takt/tasks.yaml', + content: 'done', + branch: 'takt/completed-task', +}; + +const completedTaskWithoutBranch: TaskListItem = { + ...completedTaskWithBranch, + branch: undefined, + name: 'completed-without-branch', +}; + +describe('listTasks interactive status actions', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('running タスク選択時は read-only メッセージを表示する', async () => { + mockListAllTaskItems.mockReturnValue([runningTask]); + mockSelectOption + .mockResolvedValueOnce('running:0') + .mockResolvedValueOnce(null); + + await listTasks('/project'); + + expect(mockHeader).toHaveBeenCalledWith('[running] running-task'); + expect(mockInfo).toHaveBeenCalledWith('Running task is read-only.'); + expect(mockShowDiffAndPromptActionForTask).not.toHaveBeenCalled(); + }); + + it('completed タスクで branch が無い場合はアクションに進まない', async () => { + mockListAllTaskItems.mockReturnValue([completedTaskWithoutBranch]); + mockSelectOption + .mockResolvedValueOnce('completed:0') + .mockResolvedValueOnce(null); + + await listTasks('/project'); + + expect(mockInfo).toHaveBeenCalledWith('Branch is missing for completed task: completed-without-branch'); + expect(mockShowDiffAndPromptActionForTask).not.toHaveBeenCalled(); + }); + + it('completed merge 成功時は tasks.yaml から completed レコードを削除する', async () => { + mockListAllTaskItems.mockReturnValue([completedTaskWithBranch]); + mockShowDiffAndPromptActionForTask.mockResolvedValueOnce('merge'); + mockMergeBranch.mockReturnValue(true); + mockSelectOption + .mockResolvedValueOnce('completed:0') + .mockResolvedValueOnce(null); + + await listTasks('/project'); + + expect(mockMergeBranch).toHaveBeenCalledWith('/project', completedTaskWithBranch); + expect(mockDeleteCompletedRecord).toHaveBeenCalledWith('completed-task'); + }); + + it('completed delete 選択時は deleteCompletedTask を呼ぶ', async () => { + mockListAllTaskItems.mockReturnValue([completedTaskWithBranch]); + mockShowDiffAndPromptActionForTask.mockResolvedValueOnce('delete'); + mockSelectOption + .mockResolvedValueOnce('completed:0') + .mockResolvedValueOnce(null); + + await listTasks('/project'); + + expect(mockDeleteCompletedTask).toHaveBeenCalledWith(completedTaskWithBranch); + expect(mockDeleteCompletedRecord).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/option-resolution-order.test.ts b/src/__tests__/option-resolution-order.test.ts new file mode 100644 index 0000000..fc71099 --- /dev/null +++ b/src/__tests__/option-resolution-order.test.ts @@ -0,0 +1,210 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + getProviderMock, + loadProjectConfigMock, + loadGlobalConfigMock, + loadCustomAgentsMock, + loadAgentPromptMock, + loadTemplateMock, + providerSetupMock, + providerCallMock, +} = vi.hoisted(() => { + const providerCall = vi.fn(); + const providerSetup = vi.fn(() => ({ call: providerCall })); + + return { + getProviderMock: vi.fn(() => ({ setup: providerSetup })), + loadProjectConfigMock: vi.fn(), + loadGlobalConfigMock: vi.fn(), + loadCustomAgentsMock: vi.fn(), + loadAgentPromptMock: vi.fn(), + loadTemplateMock: vi.fn(), + providerSetupMock: providerSetup, + providerCallMock: providerCall, + }; +}); + +vi.mock('../infra/providers/index.js', () => ({ + getProvider: getProviderMock, +})); + +vi.mock('../infra/config/index.js', () => ({ + loadProjectConfig: loadProjectConfigMock, + loadGlobalConfig: loadGlobalConfigMock, + loadCustomAgents: loadCustomAgentsMock, + loadAgentPrompt: loadAgentPromptMock, +})); + +vi.mock('../shared/prompts/index.js', () => ({ + loadTemplate: loadTemplateMock, +})); + +import { runAgent } from '../agents/runner.js'; + +describe('option resolution order', () => { + beforeEach(() => { + vi.clearAllMocks(); + + providerCallMock.mockResolvedValue({ content: 'ok' }); + loadProjectConfigMock.mockReturnValue({}); + loadGlobalConfigMock.mockReturnValue({}); + loadCustomAgentsMock.mockReturnValue(new Map()); + loadAgentPromptMock.mockReturnValue('prompt'); + loadTemplateMock.mockReturnValue('template'); + }); + + it('should resolve provider in order: CLI > Local > Piece(step) > Global', async () => { + // Given + loadProjectConfigMock.mockReturnValue({ provider: 'opencode' }); + loadGlobalConfigMock.mockReturnValue({ provider: 'mock' }); + + // When: CLI provider が指定される + await runAgent(undefined, 'task', { + cwd: '/repo', + provider: 'codex', + stepProvider: 'claude', + }); + + // Then + expect(getProviderMock).toHaveBeenLastCalledWith('codex'); + + // When: CLI 指定なし(Local が有効) + await runAgent(undefined, 'task', { + cwd: '/repo', + stepProvider: 'claude', + }); + + // Then + expect(getProviderMock).toHaveBeenLastCalledWith('opencode'); + + // When: Local なし(Piece が有効) + loadProjectConfigMock.mockReturnValue({}); + await runAgent(undefined, 'task', { + cwd: '/repo', + stepProvider: 'claude', + }); + + // Then + expect(getProviderMock).toHaveBeenLastCalledWith('claude'); + + // When: Piece なし(Global が有効) + await runAgent(undefined, 'task', { cwd: '/repo' }); + + // Then + expect(getProviderMock).toHaveBeenLastCalledWith('mock'); + }); + + it('should resolve model in order: CLI > Piece(step) > Global(matching provider)', async () => { + // Given + loadProjectConfigMock.mockReturnValue({ provider: 'claude' }); + loadGlobalConfigMock.mockReturnValue({ provider: 'claude', model: 'global-model' }); + + // When: CLI model あり + await runAgent(undefined, 'task', { + cwd: '/repo', + model: 'cli-model', + stepModel: 'step-model', + }); + + // Then + expect(providerCallMock).toHaveBeenLastCalledWith( + 'task', + expect.objectContaining({ model: 'cli-model' }), + ); + + // When: CLI model なし + await runAgent(undefined, 'task', { + cwd: '/repo', + stepModel: 'step-model', + }); + + // Then + expect(providerCallMock).toHaveBeenLastCalledWith( + 'task', + expect.objectContaining({ model: 'step-model' }), + ); + + // When: stepModel なし + await runAgent(undefined, 'task', { cwd: '/repo' }); + + // Then + expect(providerCallMock).toHaveBeenLastCalledWith( + 'task', + expect.objectContaining({ model: 'global-model' }), + ); + }); + + it('should ignore global model when global provider does not match resolved provider', async () => { + // Given + loadProjectConfigMock.mockReturnValue({ provider: 'codex' }); + loadGlobalConfigMock.mockReturnValue({ provider: 'claude', model: 'global-model' }); + + // When + await runAgent(undefined, 'task', { cwd: '/repo' }); + + // Then + expect(providerCallMock).toHaveBeenLastCalledWith( + 'task', + expect.objectContaining({ model: undefined }), + ); + }); + + it('should use providerOptions from piece(step) only', async () => { + // Given + const stepProviderOptions = { + claude: { + sandbox: { + allowUnsandboxedCommands: false, + }, + }, + }; + + loadProjectConfigMock.mockReturnValue({ + provider: 'claude', + provider_options: { + claude: { sandbox: { allow_unsandboxed_commands: true } }, + }, + }); + loadGlobalConfigMock.mockReturnValue({ + provider: 'claude', + providerOptions: { + claude: { sandbox: { allowUnsandboxedCommands: true } }, + }, + }); + + // When + await runAgent(undefined, 'task', { + cwd: '/repo', + provider: 'claude', + providerOptions: stepProviderOptions, + }); + + // Then + expect(providerCallMock).toHaveBeenLastCalledWith( + 'task', + expect.objectContaining({ providerOptions: stepProviderOptions }), + ); + }); + + it('should use custom agent provider/model when higher-priority values are absent', async () => { + // Given + const customAgents = new Map([ + ['custom', { name: 'custom', prompt: 'agent prompt', provider: 'opencode', model: 'agent-model' }], + ]); + loadCustomAgentsMock.mockReturnValue(customAgents); + + // When + await runAgent('custom', 'task', { cwd: '/repo' }); + + // Then + expect(getProviderMock).toHaveBeenLastCalledWith('opencode'); + expect(providerCallMock).toHaveBeenLastCalledWith( + 'task', + expect.objectContaining({ model: 'agent-model' }), + ); + expect(providerSetupMock).toHaveBeenLastCalledWith( + expect.objectContaining({ systemPrompt: 'prompt' }), + ); + }); +}); diff --git a/src/__tests__/selectAndExecute-autoPr.test.ts b/src/__tests__/selectAndExecute-autoPr.test.ts index b483e04..2224f78 100644 --- a/src/__tests__/selectAndExecute-autoPr.test.ts +++ b/src/__tests__/selectAndExecute-autoPr.test.ts @@ -4,6 +4,25 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +const { + mockAddTask, + mockCompleteTask, + mockFailTask, + mockExecuteTask, +} = vi.hoisted(() => ({ + mockAddTask: vi.fn(() => ({ + name: 'test-task', + content: 'test task', + filePath: '/project/.takt/tasks.yaml', + createdAt: '2026-02-14T00:00:00.000Z', + status: 'pending', + data: { task: 'test task' }, + })), + mockCompleteTask: vi.fn(), + mockFailTask: vi.fn(), + mockExecuteTask: vi.fn(), +})); + vi.mock('../shared/prompt/index.js', () => ({ confirm: vi.fn(), })); @@ -21,6 +40,11 @@ vi.mock('../infra/task/index.js', () => ({ autoCommitAndPush: vi.fn(), summarizeTaskName: vi.fn(), getCurrentBranch: vi.fn(() => 'main'), + TaskRunner: vi.fn(() => ({ + addTask: (...args: unknown[]) => mockAddTask(...args), + completeTask: (...args: unknown[]) => mockCompleteTask(...args), + failTask: (...args: unknown[]) => mockFailTask(...args), + })), })); vi.mock('../shared/ui/index.js', () => ({ @@ -50,7 +74,7 @@ vi.mock('../infra/github/index.js', () => ({ })); vi.mock('../features/tasks/execute/taskExecution.js', () => ({ - executeTask: vi.fn(), + executeTask: (...args: unknown[]) => mockExecuteTask(...args), })); vi.mock('../features/pieceSelection/index.js', () => ({ @@ -61,17 +85,11 @@ vi.mock('../features/pieceSelection/index.js', () => ({ })); import { confirm } from '../shared/prompt/index.js'; -import { - getCurrentPiece, - listPieces, -} from '../infra/config/index.js'; import { createSharedClone, autoCommitAndPush, summarizeTaskName } from '../infra/task/index.js'; import { selectPiece } from '../features/pieceSelection/index.js'; import { selectAndExecuteTask, determinePiece } from '../features/tasks/execute/selectAndExecute.js'; const mockConfirm = vi.mocked(confirm); -const mockGetCurrentPiece = vi.mocked(getCurrentPiece); -const mockListPieces = vi.mocked(listPieces); const mockCreateSharedClone = vi.mocked(createSharedClone); const mockAutoCommitAndPush = vi.mocked(autoCommitAndPush); const mockSummarizeTaskName = vi.mocked(summarizeTaskName); @@ -79,6 +97,7 @@ const mockSelectPiece = vi.mocked(selectPiece); beforeEach(() => { vi.clearAllMocks(); + mockExecuteTask.mockResolvedValue(true); }); describe('resolveAutoPr default in selectAndExecuteTask', () => { @@ -91,10 +110,6 @@ describe('resolveAutoPr default in selectAndExecuteTask', () => { branch: 'takt/test-task', }); - const { executeTask } = await import( - '../features/tasks/execute/taskExecution.js' - ); - vi.mocked(executeTask).mockResolvedValue(true); mockAutoCommitAndPush.mockReturnValue({ success: false, message: 'no changes', @@ -122,4 +137,78 @@ describe('resolveAutoPr default in selectAndExecuteTask', () => { expect(selected).toBe('selected-piece'); expect(mockSelectPiece).toHaveBeenCalledWith('/project'); }); + + it('should fail task record when executeTask throws', async () => { + mockConfirm.mockResolvedValue(true); + mockSummarizeTaskName.mockResolvedValue('test-task'); + mockCreateSharedClone.mockReturnValue({ + path: '/project/../clone', + branch: 'takt/test-task', + }); + mockExecuteTask.mockRejectedValue(new Error('boom')); + + await expect(selectAndExecuteTask('/project', 'test task', { + piece: 'default', + createWorktree: true, + })).rejects.toThrow('boom'); + + expect(mockAddTask).toHaveBeenCalledTimes(1); + expect(mockFailTask).toHaveBeenCalledTimes(1); + expect(mockCompleteTask).not.toHaveBeenCalled(); + }); + + it('should record task and complete when executeTask returns true', async () => { + mockConfirm.mockResolvedValue(true); + mockSummarizeTaskName.mockResolvedValue('test-task'); + mockCreateSharedClone.mockReturnValue({ + path: '/project/../clone', + branch: 'takt/test-task', + }); + mockExecuteTask.mockResolvedValue(true); + + await selectAndExecuteTask('/project', 'test task', { + piece: 'default', + createWorktree: true, + }); + + expect(mockAddTask).toHaveBeenCalledWith('test task', expect.objectContaining({ + piece: 'default', + worktree: true, + branch: 'takt/test-task', + worktree_path: '/project/../clone', + auto_pr: true, + })); + expect(mockCompleteTask).toHaveBeenCalledTimes(1); + expect(mockFailTask).not.toHaveBeenCalled(); + }); + + it('should record task and fail when executeTask returns false', async () => { + mockConfirm.mockResolvedValue(false); + mockSummarizeTaskName.mockResolvedValue('test-task'); + mockCreateSharedClone.mockReturnValue({ + path: '/project/../clone', + branch: 'takt/test-task', + }); + mockExecuteTask.mockResolvedValue(false); + + const processExitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process exit'); + }) as (code?: string | number | null | undefined) => never); + + await expect(selectAndExecuteTask('/project', 'test task', { + piece: 'default', + createWorktree: true, + })).rejects.toThrow('process exit'); + + expect(mockAddTask).toHaveBeenCalledWith('test task', expect.objectContaining({ + piece: 'default', + worktree: true, + branch: 'takt/test-task', + worktree_path: '/project/../clone', + auto_pr: false, + })); + expect(mockFailTask).toHaveBeenCalledTimes(1); + expect(mockCompleteTask).not.toHaveBeenCalled(); + processExitSpy.mockRestore(); + }); }); diff --git a/src/__tests__/task.test.ts b/src/__tests__/task.test.ts index d715390..1cf5e99 100644 --- a/src/__tests__/task.test.ts +++ b/src/__tests__/task.test.ts @@ -215,7 +215,7 @@ describe('TaskRunner (tasks.yaml)', () => { expect(() => runner.listTasks()).toThrow(/ENOENT|no such file/i); }); - it('should remove completed task record from tasks.yaml', () => { + it('should keep completed task record in tasks.yaml', () => { runner.addTask('Task A'); const task = runner.claimNextTasks(1)[0]!; @@ -229,10 +229,11 @@ describe('TaskRunner (tasks.yaml)', () => { }); const file = loadTasksFile(testDir); - expect(file.tasks).toHaveLength(0); + expect(file.tasks).toHaveLength(1); + expect(file.tasks[0]?.status).toBe('completed'); }); - it('should remove only the completed task when multiple tasks exist', () => { + it('should update only target task to completed when multiple tasks exist', () => { runner.addTask('Task A'); runner.addTask('Task B'); const task = runner.claimNextTasks(1)[0]!; @@ -247,9 +248,11 @@ describe('TaskRunner (tasks.yaml)', () => { }); const file = loadTasksFile(testDir); - expect(file.tasks).toHaveLength(1); - expect(file.tasks[0]?.name).toContain('task-b'); - expect(file.tasks[0]?.status).toBe('pending'); + expect(file.tasks).toHaveLength(2); + expect(file.tasks[0]?.name).toContain('task-a'); + expect(file.tasks[0]?.status).toBe('completed'); + expect(file.tasks[1]?.name).toContain('task-b'); + expect(file.tasks[1]?.status).toBe('pending'); }); it('should mark claimed task as failed with failure detail', () => { @@ -274,6 +277,29 @@ describe('TaskRunner (tasks.yaml)', () => { expect(failed[0]?.failure?.last_message).toBe('last message'); }); + it('should mark pending task as failed with started_at and branch', () => { + const task = runner.addTask('Task C', { branch: 'takt/task-c' }); + const startedAt = new Date().toISOString(); + const completedAt = new Date().toISOString(); + + runner.failTask({ + task, + success: false, + response: 'Boom', + executionLog: [], + startedAt, + completedAt, + branch: 'takt/task-c-updated', + }); + + const file = loadTasksFile(testDir); + const failed = file.tasks[0]; + expect(failed?.status).toBe('failed'); + expect(failed?.started_at).toBe(startedAt); + expect(failed?.completed_at).toBe(completedAt); + expect(failed?.branch).toBe('takt/task-c-updated'); + }); + it('should requeue failed task to pending with retry metadata', () => { runner.addTask('Task A'); const task = runner.claimNextTasks(1)[0]!; diff --git a/src/__tests__/taskDeleteActions.test.ts b/src/__tests__/taskDeleteActions.test.ts index 912591f..0a81403 100644 --- a/src/__tests__/taskDeleteActions.test.ts +++ b/src/__tests__/taskDeleteActions.test.ts @@ -21,9 +21,14 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({ }), })); +const mockDeleteBranch = vi.fn(); +vi.mock('../features/tasks/list/taskActions.js', () => ({ + deleteBranch: (...args: unknown[]) => mockDeleteBranch(...args), +})); + import { confirm } from '../shared/prompt/index.js'; import { success, error as logError } from '../shared/ui/index.js'; -import { deletePendingTask, deleteFailedTask } from '../features/tasks/list/taskDeleteActions.js'; +import { deletePendingTask, deleteFailedTask, deleteCompletedTask } from '../features/tasks/list/taskDeleteActions.js'; import type { TaskListItem } from '../infra/task/types.js'; const mockConfirm = vi.mocked(confirm); @@ -54,6 +59,16 @@ function setupTasksFile(projectDir: string): string { completed_at: '2025-01-15T00:02:00.000Z', failure: { error: 'boom' }, }, + { + name: 'completed-task', + status: 'completed', + content: 'completed', + branch: 'takt/completed-task', + worktree_path: '/tmp/takt/completed-task', + created_at: '2025-01-15T00:00:00.000Z', + started_at: '2025-01-15T00:01:00.000Z', + completed_at: '2025-01-15T00:02:00.000Z', + }, ], }), 'utf-8'); return tasksFile; @@ -61,6 +76,7 @@ function setupTasksFile(projectDir: string): string { beforeEach(() => { vi.clearAllMocks(); + mockDeleteBranch.mockReturnValue(true); tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-test-delete-')); }); @@ -107,6 +123,50 @@ describe('taskDeleteActions', () => { expect(mockSuccess).toHaveBeenCalledWith('Deleted failed task: failed-task'); }); + it('should cleanup branch before deleting failed task when branch exists', async () => { + const tasksFile = setupTasksFile(tmpDir); + const task: TaskListItem = { + kind: 'failed', + name: 'failed-task', + createdAt: '2025-01-15T12:34:56', + filePath: tasksFile, + content: 'failed', + branch: 'takt/failed-task', + worktreePath: '/tmp/takt/failed-task', + }; + mockConfirm.mockResolvedValue(true); + + const result = await deleteFailedTask(task); + + expect(result).toBe(true); + expect(mockDeleteBranch).toHaveBeenCalledWith(tmpDir, task); + const raw = fs.readFileSync(tasksFile, 'utf-8'); + expect(raw).not.toContain('failed-task'); + expect(mockSuccess).toHaveBeenCalledWith('Deleted failed task: failed-task'); + }); + + it('should keep failed task record when branch cleanup fails', async () => { + const tasksFile = setupTasksFile(tmpDir); + const task: TaskListItem = { + kind: 'failed', + name: 'failed-task', + createdAt: '2025-01-15T12:34:56', + filePath: tasksFile, + content: 'failed', + branch: 'takt/failed-task', + worktreePath: '/tmp/takt/failed-task', + }; + mockConfirm.mockResolvedValue(true); + mockDeleteBranch.mockReturnValue(false); + + const result = await deleteFailedTask(task); + + expect(result).toBe(false); + expect(mockDeleteBranch).toHaveBeenCalledWith(tmpDir, task); + const raw = fs.readFileSync(tasksFile, 'utf-8'); + expect(raw).toContain('failed-task'); + }); + it('should return false when target task is missing', async () => { const tasksFile = setupTasksFile(tmpDir); const task: TaskListItem = { @@ -123,4 +183,26 @@ describe('taskDeleteActions', () => { expect(result).toBe(false); expect(mockLogError).toHaveBeenCalled(); }); + + it('should delete completed task and cleanup worktree when confirmed', async () => { + const tasksFile = setupTasksFile(tmpDir); + const task: TaskListItem = { + kind: 'completed', + name: 'completed-task', + createdAt: '2025-01-15T12:34:56', + filePath: tasksFile, + content: 'completed', + branch: 'takt/completed-task', + worktreePath: '/tmp/takt/completed-task', + }; + mockConfirm.mockResolvedValue(true); + + const result = await deleteCompletedTask(task); + + expect(result).toBe(true); + expect(mockDeleteBranch).toHaveBeenCalledWith(tmpDir, task); + const raw = fs.readFileSync(tasksFile, 'utf-8'); + expect(raw).not.toContain('completed-task'); + expect(mockSuccess).toHaveBeenCalledWith('Deleted completed task: completed-task'); + }); }); diff --git a/src/__tests__/taskExecution.test.ts b/src/__tests__/taskExecution.test.ts index 0b05dea..2ce647e 100644 --- a/src/__tests__/taskExecution.test.ts +++ b/src/__tests__/taskExecution.test.ts @@ -180,6 +180,7 @@ describe('resolveTaskExecution', () => { isWorktree: true, autoPr: false, branch: 'takt/20260128T0504-add-auth', + worktreePath: '/project/../20260128T0504-add-auth', baseBranch: 'main', }); }); diff --git a/src/__tests__/taskInstructionActions.test.ts b/src/__tests__/taskInstructionActions.test.ts new file mode 100644 index 0000000..d880ae2 --- /dev/null +++ b/src/__tests__/taskInstructionActions.test.ts @@ -0,0 +1,157 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockAddTask, + mockCompleteTask, + mockFailTask, + mockExecuteTask, + mockRunInstructMode, + mockDispatchConversationAction, + mockSelectPiece, +} = vi.hoisted(() => ({ + mockAddTask: vi.fn(() => ({ + name: 'instruction-task', + content: 'instruction', + filePath: '/project/.takt/tasks.yaml', + createdAt: '2026-02-14T00:00:00.000Z', + status: 'pending', + data: { task: 'instruction' }, + })), + mockCompleteTask: vi.fn(), + mockFailTask: vi.fn(), + mockExecuteTask: vi.fn(), + mockRunInstructMode: vi.fn(), + mockDispatchConversationAction: vi.fn(), + mockSelectPiece: vi.fn(), +})); + +vi.mock('../infra/task/index.js', () => ({ + createTempCloneForBranch: vi.fn(() => ({ path: '/tmp/clone', branch: 'takt/sample' })), + removeClone: vi.fn(), + removeCloneMeta: vi.fn(), + detectDefaultBranch: vi.fn(() => 'main'), + autoCommitAndPush: vi.fn(() => ({ success: false, message: 'no changes' })), + TaskRunner: class { + addTask(...args: unknown[]) { + return mockAddTask(...args); + } + completeTask(...args: unknown[]) { + return mockCompleteTask(...args); + } + failTask(...args: unknown[]) { + return mockFailTask(...args); + } + }, +})); + +vi.mock('../infra/config/index.js', () => ({ + loadGlobalConfig: vi.fn(() => ({ interactivePreviewMovements: false })), + getPieceDescription: vi.fn(() => ({ + name: 'default', + description: 'desc', + pieceStructure: [], + movementPreviews: [], + })), +})); + +vi.mock('../features/tasks/execute/taskExecution.js', () => ({ + executeTask: (...args: unknown[]) => mockExecuteTask(...args), +})); + +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), +})); + +vi.mock('../features/interactive/actionDispatcher.js', () => ({ + dispatchConversationAction: (...args: unknown[]) => mockDispatchConversationAction(...args), +})); + +vi.mock('../shared/ui/index.js', () => ({ + info: vi.fn(), + success: vi.fn(), + error: vi.fn(), +})); + +vi.mock('../shared/utils/index.js', async (importOriginal) => ({ + ...(await importOriginal>()), + createLogger: () => ({ + info: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})); + +import { instructBranch } from '../features/tasks/list/taskActions.js'; + +describe('instructBranch execute flow', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockSelectPiece.mockResolvedValue('default'); + mockRunInstructMode.mockResolvedValue({ type: 'execute', task: '追加して' }); + mockDispatchConversationAction.mockImplementation(async (_result, handlers) => handlers.execute({ task: '追加して' })); + }); + + it('should record addTask and completeTask on success', async () => { + mockExecuteTask.mockResolvedValue(true); + + 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', + }); + + expect(result).toBe(true); + expect(mockAddTask).toHaveBeenCalledTimes(1); + expect(mockCompleteTask).toHaveBeenCalledTimes(1); + expect(mockFailTask).not.toHaveBeenCalled(); + }); + + it('should record addTask and failTask on failure', async () => { + mockExecuteTask.mockResolvedValue(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', + }); + + expect(result).toBe(false); + expect(mockAddTask).toHaveBeenCalledTimes(1); + expect(mockFailTask).toHaveBeenCalledTimes(1); + expect(mockCompleteTask).not.toHaveBeenCalled(); + }); + + it('should record failTask when executeTask throws', async () => { + mockExecuteTask.mockRejectedValue(new Error('crashed')); + + await expect(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', + })).rejects.toThrow('crashed'); + + expect(mockAddTask).toHaveBeenCalledTimes(1); + expect(mockFailTask).toHaveBeenCalledTimes(1); + expect(mockCompleteTask).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/taskStatusLabel.test.ts b/src/__tests__/taskStatusLabel.test.ts index 54ba349..7efb53f 100644 --- a/src/__tests__/taskStatusLabel.test.ts +++ b/src/__tests__/taskStatusLabel.test.ts @@ -3,7 +3,7 @@ import { formatTaskStatusLabel } from '../features/tasks/list/taskStatusLabel.js import type { TaskListItem } from '../infra/task/types.js'; describe('formatTaskStatusLabel', () => { - it("should format pending task as '[running] name'", () => { + it("should format pending task as '[pending] name'", () => { // Given: pending タスク const task: TaskListItem = { kind: 'pending', @@ -16,8 +16,8 @@ describe('formatTaskStatusLabel', () => { // When: ステータスラベルを生成する const result = formatTaskStatusLabel(task); - // Then: pending は running 表示になる - expect(result).toBe('[running] implement test'); + // Then: pending は pending 表示になる + expect(result).toBe('[pending] implement test'); }); it("should format failed task as '[failed] name'", () => { diff --git a/src/agents/runner.ts b/src/agents/runner.ts index 75691a6..560742e 100644 --- a/src/agents/runner.ts +++ b/src/agents/runner.ts @@ -5,9 +5,8 @@ import { existsSync, readFileSync } from 'node:fs'; import { basename, dirname } from 'node:path'; import { loadCustomAgents, loadAgentPrompt, loadGlobalConfig, loadProjectConfig } from '../infra/config/index.js'; -import { mergeProviderOptions } from '../infra/config/loaders/pieceParser.js'; import { getProvider, type ProviderType, type ProviderCallOptions } from '../infra/providers/index.js'; -import type { AgentResponse, CustomAgentConfig, MovementProviderOptions } from '../core/models/index.js'; +import type { AgentResponse, CustomAgentConfig } from '../core/models/index.js'; import { createLogger } from '../shared/utils/index.js'; import { loadTemplate } from '../shared/prompts/index.js'; import type { RunAgentOptions } from './types.js'; @@ -30,14 +29,15 @@ export class AgentRunner { agentConfig?: CustomAgentConfig, ): ProviderType { if (options?.provider) return options.provider; - if (agentConfig?.provider) return agentConfig.provider; const projectConfig = loadProjectConfig(cwd); if (projectConfig.provider) return projectConfig.provider; + if (options?.stepProvider) return options.stepProvider; + if (agentConfig?.provider) return agentConfig.provider; try { const globalConfig = loadGlobalConfig(); if (globalConfig.provider) return globalConfig.provider; - } catch { - // Ignore missing global config; fallback below + } catch (error) { + log.debug('Global config not available for provider resolution', { error }); } return 'claude'; } @@ -53,6 +53,7 @@ export class AgentRunner { agentConfig?: CustomAgentConfig, ): string | undefined { if (options?.model) return options.model; + if (options?.stepModel) return options.stepModel; if (agentConfig?.model) return agentConfig.model; try { const globalConfig = loadGlobalConfig(); @@ -60,8 +61,8 @@ export class AgentRunner { const globalProvider = globalConfig.provider ?? 'claude'; if (globalProvider === resolvedProvider) return globalConfig.model; } - } catch { - // Ignore missing global config + } catch (error) { + log.debug('Global config not available for model resolution', { error }); } return undefined; } @@ -93,24 +94,6 @@ export class AgentRunner { return `${dir}/${name}`; } - /** - * Resolve provider options with 4-layer priority: Global < Local < Step (piece+movement merged). - * Step already contains the piece+movement merge result from pieceParser. - */ - private static resolveProviderOptions( - cwd: string, - stepOptions?: MovementProviderOptions, - ): MovementProviderOptions | undefined { - let globalOptions: MovementProviderOptions | undefined; - try { - globalOptions = loadGlobalConfig().providerOptions; - } catch { /* ignore */ } - - const localOptions = loadProjectConfig(cwd).provider_options; - - return mergeProviderOptions(globalOptions, localOptions, stepOptions); - } - /** Build ProviderCallOptions from RunAgentOptions */ private static buildCallOptions( resolvedProvider: ProviderType, @@ -126,7 +109,7 @@ export class AgentRunner { maxTurns: options.maxTurns, model: AgentRunner.resolveModel(resolvedProvider, options, agentConfig), permissionMode: options.permissionMode, - providerOptions: AgentRunner.resolveProviderOptions(options.cwd, options.providerOptions), + providerOptions: options.providerOptions, onStream: options.onStream, onPermissionRequest: options.onPermissionRequest, onAskUserQuestion: options.onAskUserQuestion, diff --git a/src/agents/types.ts b/src/agents/types.ts index d2b6e32..497fd08 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -14,6 +14,8 @@ export interface RunAgentOptions { sessionId?: string; model?: string; provider?: 'claude' | 'codex' | 'opencode' | 'mock'; + stepModel?: string; + stepProvider?: 'claude' | 'codex' | 'opencode' | 'mock'; personaPath?: string; allowedTools?: string[]; mcpServers?: Record; diff --git a/src/core/piece/engine/OptionsBuilder.ts b/src/core/piece/engine/OptionsBuilder.ts index 535cacf..a847a22 100644 --- a/src/core/piece/engine/OptionsBuilder.ts +++ b/src/core/piece/engine/OptionsBuilder.ts @@ -35,8 +35,10 @@ export class OptionsBuilder { cwd: this.getCwd(), abortSignal: this.engineOptions.abortSignal, personaPath: step.personaPath, - provider: resolved.provider, - model: resolved.model, + provider: this.engineOptions.provider, + model: this.engineOptions.model, + stepProvider: resolved.provider, + stepModel: resolved.model, permissionMode: step.permissionMode, providerOptions: step.providerOptions, language: this.getLanguage(), diff --git a/src/features/tasks/execute/resolveTask.ts b/src/features/tasks/execute/resolveTask.ts index 5e4d1ed..ebf2cc2 100644 --- a/src/features/tasks/execute/resolveTask.ts +++ b/src/features/tasks/execute/resolveTask.ts @@ -16,6 +16,7 @@ export interface ResolvedTaskExecution { taskPrompt?: string; reportDirName?: string; branch?: string; + worktreePath?: string; baseBranch?: string; startMovement?: string; retryNote?: string; @@ -82,6 +83,7 @@ export async function resolveTaskExecution( let reportDirName: string | undefined; let taskPrompt: string | undefined; let branch: string | undefined; + let worktreePath: string | undefined; let baseBranch: string | undefined; if (task.taskDir) { const taskSlug = getTaskSlugFromTaskDir(task.taskDir); @@ -114,6 +116,7 @@ export async function resolveTaskExecution( throwIfAborted(abortSignal); execCwd = result.path; branch = result.branch; + worktreePath = result.path; isWorktree = true; } @@ -141,6 +144,7 @@ export async function resolveTaskExecution( ...(taskPrompt ? { taskPrompt } : {}), ...(reportDirName ? { reportDirName } : {}), ...(branch ? { branch } : {}), + ...(worktreePath ? { worktreePath } : {}), ...(baseBranch ? { baseBranch } : {}), ...(startMovement ? { startMovement } : {}), ...(retryNote ? { retryNote } : {}), diff --git a/src/features/tasks/execute/selectAndExecute.ts b/src/features/tasks/execute/selectAndExecute.ts index 32aedb0..78e5fe7 100644 --- a/src/features/tasks/execute/selectAndExecute.ts +++ b/src/features/tasks/execute/selectAndExecute.ts @@ -11,7 +11,7 @@ import { isPiecePath, } from '../../../infra/config/index.js'; import { confirm } from '../../../shared/prompt/index.js'; -import { createSharedClone, summarizeTaskName, getCurrentBranch } from '../../../infra/task/index.js'; +import { createSharedClone, summarizeTaskName, getCurrentBranch, TaskRunner } from '../../../infra/task/index.js'; import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js'; import { info, error, withProgress } from '../../../shared/ui/index.js'; import { createLogger } from '../../../shared/utils/index.js'; @@ -19,6 +19,7 @@ import { executeTask } from './taskExecution.js'; import { resolveAutoPr, postExecutionFlow } from './postExecution.js'; import type { TaskExecutionOptions, WorktreeConfirmationResult, SelectAndExecuteOptions } from './types.js'; import { selectPiece } from '../../pieceSelection/index.js'; +import { buildBooleanTaskResult, persistTaskError, persistTaskResult } from './taskResultHandler.js'; export type { WorktreeConfirmationResult, SelectAndExecuteOptions }; @@ -104,15 +105,48 @@ export async function selectAndExecuteTask( } log.info('Starting task execution', { piece: pieceIdentifier, worktree: isWorktree, autoPr: shouldCreatePr }); - const taskSuccess = await executeTask({ - task, - cwd: execCwd, - pieceIdentifier, - projectCwd: cwd, - agentOverrides, - interactiveUserInput: options?.interactiveUserInput === true, - interactiveMetadata: options?.interactiveMetadata, + const taskRunner = new TaskRunner(cwd); + const taskRecord = taskRunner.addTask(task, { + piece: pieceIdentifier, + ...(isWorktree ? { worktree: true } : {}), + ...(branch ? { branch } : {}), + ...(isWorktree ? { worktree_path: execCwd } : {}), + auto_pr: shouldCreatePr, }); + const startedAt = new Date().toISOString(); + + let taskSuccess: boolean; + try { + taskSuccess = await executeTask({ + task, + cwd: execCwd, + pieceIdentifier, + projectCwd: cwd, + agentOverrides, + interactiveUserInput: options?.interactiveUserInput === true, + interactiveMetadata: options?.interactiveMetadata, + }); + } catch (err) { + const completedAt = new Date().toISOString(); + persistTaskError(taskRunner, taskRecord, startedAt, completedAt, err, { + responsePrefix: 'Task failed: ', + }); + throw err; + } + + const completedAt = new Date().toISOString(); + + const taskResult = buildBooleanTaskResult({ + task: taskRecord, + taskSuccess, + successResponse: 'Task completed successfully', + failureResponse: 'Task failed', + startedAt, + completedAt, + branch, + ...(isWorktree ? { worktreePath: execCwd } : {}), + }); + persistTaskResult(taskRunner, taskResult); if (taskSuccess && isWorktree) { await postExecutionFlow({ diff --git a/src/features/tasks/execute/taskExecution.ts b/src/features/tasks/execute/taskExecution.ts index f0b4751..2e65882 100644 --- a/src/features/tasks/execute/taskExecution.ts +++ b/src/features/tasks/execute/taskExecution.ts @@ -8,7 +8,6 @@ import { header, info, error, - success, status, blankLine, } from '../../../shared/ui/index.js'; @@ -21,6 +20,7 @@ import { fetchIssue, checkGhCli } from '../../../infra/github/index.js'; import { runWithWorkerPool } from './parallelExecution.js'; import { resolveTaskExecution } from './resolveTask.js'; import { postExecutionFlow } from './postExecution.js'; +import { buildTaskResult, persistTaskError, persistTaskResult } from './taskResultHandler.js'; export type { TaskExecutionOptions, ExecuteTaskOptions }; @@ -138,6 +138,7 @@ export async function executeAndCompleteTask( taskPrompt, reportDirName, branch, + worktreePath, baseBranch, startMovement, retryNote, @@ -160,10 +161,6 @@ export async function executeAndCompleteTask( taskColorIndex: parallelOptions?.taskColorIndex, }); - if (!taskRunResult.success && !taskRunResult.reason) { - throw new Error('Task failed without reason'); - } - const taskSuccess = taskRunResult.success; const completedAt = new Date().toISOString(); @@ -181,39 +178,20 @@ export async function executeAndCompleteTask( }); } - const taskResult = { + const taskResult = buildTaskResult({ task, - success: taskSuccess, - response: taskSuccess ? 'Task completed successfully' : taskRunResult.reason!, - executionLog: taskRunResult.lastMessage ? [taskRunResult.lastMessage] : [], - failureMovement: taskRunResult.lastMovement, - failureLastMessage: taskRunResult.lastMessage, + runResult: taskRunResult, startedAt, completedAt, - }; - - if (taskSuccess) { - taskRunner.completeTask(taskResult); - success(`Task "${task.name}" completed`); - } else { - taskRunner.failTask(taskResult); - error(`Task "${task.name}" failed`); - } + branch, + worktreePath, + }); + persistTaskResult(taskRunner, taskResult); return taskSuccess; } catch (err) { const completedAt = new Date().toISOString(); - - taskRunner.failTask({ - task, - success: false, - response: getErrorMessage(err), - executionLog: [], - startedAt, - completedAt, - }); - - error(`Task "${task.name}" error: ${getErrorMessage(err)}`); + persistTaskError(taskRunner, task, startedAt, completedAt, err); return false; } finally { if (externalAbortSignal) { diff --git a/src/features/tasks/execute/taskResultHandler.ts b/src/features/tasks/execute/taskResultHandler.ts new file mode 100644 index 0000000..30fcec8 --- /dev/null +++ b/src/features/tasks/execute/taskResultHandler.ts @@ -0,0 +1,125 @@ +import { type TaskInfo, type TaskResult, TaskRunner } from '../../../infra/task/index.js'; +import { error, success } from '../../../shared/ui/index.js'; +import { getErrorMessage } from '../../../shared/utils/index.js'; +import type { PieceExecutionResult } from './types.js'; + +interface BuildTaskResultParams { + task: TaskInfo; + runResult: PieceExecutionResult; + startedAt: string; + completedAt: string; + branch?: string; + worktreePath?: string; +} + +interface BuildBooleanTaskResultParams { + task: TaskInfo; + taskSuccess: boolean; + startedAt: string; + completedAt: string; + successResponse: string; + failureResponse: string; + branch?: string; + worktreePath?: string; +} + +interface PersistTaskResultOptions { + emitStatusLog?: boolean; +} + +interface PersistTaskErrorOptions { + emitStatusLog?: boolean; + responsePrefix?: string; +} + +export function buildTaskResult(params: BuildTaskResultParams): TaskResult { + const { task, runResult, startedAt, completedAt, branch, worktreePath } = params; + const taskSuccess = runResult.success; + + if (!taskSuccess && !runResult.reason) { + throw new Error('Task failed without reason'); + } + + return { + task, + success: taskSuccess, + response: taskSuccess ? 'Task completed successfully' : runResult.reason!, + executionLog: runResult.lastMessage ? [runResult.lastMessage] : [], + failureMovement: runResult.lastMovement, + failureLastMessage: runResult.lastMessage, + startedAt, + completedAt, + ...(branch ? { branch } : {}), + ...(worktreePath ? { worktreePath } : {}), + }; +} + +export function buildBooleanTaskResult(params: BuildBooleanTaskResultParams): TaskResult { + const { + task, + taskSuccess, + startedAt, + completedAt, + successResponse, + failureResponse, + branch, + worktreePath, + } = params; + + return { + task, + success: taskSuccess, + response: taskSuccess ? successResponse : failureResponse, + executionLog: [], + startedAt, + completedAt, + ...(branch ? { branch } : {}), + ...(worktreePath ? { worktreePath } : {}), + }; +} + +export function persistTaskResult( + taskRunner: TaskRunner, + taskResult: TaskResult, + options?: PersistTaskResultOptions, +): void { + const emitStatusLog = options?.emitStatusLog !== false; + if (taskResult.success) { + taskRunner.completeTask(taskResult); + if (emitStatusLog) { + success(`Task "${taskResult.task.name}" completed`); + } + return; + } + + taskRunner.failTask(taskResult); + if (emitStatusLog) { + error(`Task "${taskResult.task.name}" failed`); + } +} + +export function persistTaskError( + taskRunner: TaskRunner, + task: TaskInfo, + startedAt: string, + completedAt: string, + err: unknown, + options?: PersistTaskErrorOptions, +): void { + const emitStatusLog = options?.emitStatusLog !== false; + const responsePrefix = options?.responsePrefix ?? ''; + taskRunner.failTask({ + task, + success: false, + response: `${responsePrefix}${getErrorMessage(err)}`, + executionLog: [], + startedAt, + completedAt, + ...(task.data?.branch ? { branch: task.data.branch } : {}), + ...(task.worktreePath ? { worktreePath: task.worktreePath } : {}), + }); + + if (emitStatusLog) { + error(`Task "${task.name}" error: ${getErrorMessage(err)}`); + } +} diff --git a/src/features/tasks/list/index.ts b/src/features/tasks/list/index.ts index 287962b..6016630 100644 --- a/src/features/tasks/list/index.ts +++ b/src/features/tasks/list/index.ts @@ -9,25 +9,21 @@ */ import { - listTaktBranches, - buildListItems, - detectDefaultBranch, TaskRunner, } from '../../../infra/task/index.js'; import type { TaskListItem } from '../../../infra/task/index.js'; -import { selectOption, confirm } from '../../../shared/prompt/index.js'; +import { selectOption } from '../../../shared/prompt/index.js'; import { info, header, blankLine } from '../../../shared/ui/index.js'; import type { TaskExecutionOptions } from '../execute/types.js'; import { type ListAction, showFullDiff, - showDiffAndPromptAction, + showDiffAndPromptActionForTask, tryMergeBranch, mergeBranch, - deleteBranch, instructBranch, } from './taskActions.js'; -import { deletePendingTask, deleteFailedTask } from './taskDeleteActions.js'; +import { deletePendingTask, deleteFailedTask, deleteCompletedTask } from './taskDeleteActions.js'; import { retryFailedTask } from './taskRetryActions.js'; import { listTasksNonInteractive, type ListNonInteractiveOptions } from './listNonInteractive.js'; import { formatTaskStatusLabel } from './taskStatusLabel.js'; @@ -55,6 +51,7 @@ type PendingTaskAction = 'delete'; /** Task action type for failed task action selection menu */ type FailedTaskAction = 'retry' | 'delete'; +type CompletedTaskAction = ListAction; /** * Show pending task details and prompt for an action. @@ -80,7 +77,7 @@ async function showPendingTaskAndPromptAction(task: TaskListItem): Promise { header(formatTaskStatusLabel(task)); - info(` Failed at: ${task.createdAt}`); + info(` Created: ${task.createdAt}`); if (task.content) { info(` ${task.content}`); } @@ -95,6 +92,17 @@ async function showFailedTaskAndPromptAction(task: TaskListItem): Promise { + header(formatTaskStatusLabel(task)); + info(` Created: ${task.createdAt}`); + if (task.content) { + info(` ${task.content}`); + } + blankLine(); + + return await showDiffAndPromptActionForTask(cwd, task); +} + /** * Main entry point: list branch-based tasks interactively. */ @@ -108,44 +116,22 @@ export async function listTasks( return; } - const defaultBranch = detectDefaultBranch(cwd); const runner = new TaskRunner(cwd); // Interactive loop while (true) { - const branches = listTaktBranches(cwd); - const items = buildListItems(cwd, branches, defaultBranch); - const pendingTasks = runner.listPendingTaskItems(); - const failedTasks = runner.listFailedTasks(); + const tasks = runner.listAllTaskItems(); - if (items.length === 0 && pendingTasks.length === 0 && failedTasks.length === 0) { + if (tasks.length === 0) { info('No tasks to list.'); return; } - const menuOptions = [ - ...items.map((item, idx) => { - const filesSummary = `${item.filesChanged} file${item.filesChanged !== 1 ? 's' : ''} changed`; - const description = item.originalInstruction - ? `${filesSummary} | ${item.originalInstruction}` - : filesSummary; - return { - label: item.info.branch, - value: `branch:${idx}`, - description, - }; - }), - ...pendingTasks.map((task, idx) => ({ - label: formatTaskStatusLabel(task), - value: `pending:${idx}`, - description: task.content, - })), - ...failedTasks.map((task, idx) => ({ - label: formatTaskStatusLabel(task), - value: `failed:${idx}`, - description: task.content, - })), - ]; + const menuOptions = tasks.map((task, idx) => ({ + label: formatTaskStatusLabel(task), + value: `${task.kind}:${idx}`, + description: `${task.content} | ${task.createdAt}`, + })); const selected = await selectOption( 'List Tasks', @@ -162,52 +148,55 @@ export async function listTasks( const idx = parseInt(selected.slice(colonIdx + 1), 10); if (Number.isNaN(idx)) continue; - if (type === 'branch') { - const item = items[idx]; - if (!item) continue; - - // Action loop: re-show menu after viewing diff - let action: ListAction | null; - do { - action = await showDiffAndPromptAction(cwd, defaultBranch, item); - - if (action === 'diff') { - showFullDiff(cwd, defaultBranch, item.info.branch); - } - } while (action === 'diff'); - - if (action === null) continue; - - switch (action) { - case 'instruct': - await instructBranch(cwd, item, options); - break; - case 'try': - tryMergeBranch(cwd, item); - break; - case 'merge': - mergeBranch(cwd, item); - break; - case 'delete': { - const confirmed = await confirm( - `Delete ${item.info.branch}? This will discard all changes.`, - false, - ); - if (confirmed) { - deleteBranch(cwd, item); - } - break; - } - } - } else if (type === 'pending') { - const task = pendingTasks[idx]; + if (type === 'pending') { + const task = tasks[idx]; if (!task) continue; const taskAction = await showPendingTaskAndPromptAction(task); if (taskAction === 'delete') { await deletePendingTask(task); } + } else if (type === 'running') { + const task = tasks[idx]; + if (!task) continue; + header(formatTaskStatusLabel(task)); + info(` Created: ${task.createdAt}`); + if (task.content) { + info(` ${task.content}`); + } + blankLine(); + info('Running task is read-only.'); + blankLine(); + } else if (type === 'completed') { + const task = tasks[idx]; + if (!task) continue; + if (!task.branch) { + info(`Branch is missing for completed task: ${task.name}`); + continue; + } + const taskAction = await showCompletedTaskAndPromptAction(cwd, task); + if (taskAction === null) continue; + + switch (taskAction) { + case 'diff': + showFullDiff(cwd, task.branch); + break; + case 'instruct': + await instructBranch(cwd, task, options); + break; + case 'try': + tryMergeBranch(cwd, task); + break; + case 'merge': + if (mergeBranch(cwd, task)) { + runner.deleteCompletedTask(task.name); + } + break; + case 'delete': + await deleteCompletedTask(task); + break; + } } else if (type === 'failed') { - const task = failedTasks[idx]; + const task = tasks[idx]; if (!task) continue; const taskAction = await showFailedTaskAndPromptAction(task); if (taskAction === 'retry') { diff --git a/src/features/tasks/list/listNonInteractive.ts b/src/features/tasks/list/listNonInteractive.ts index 2eb7609..3c11cad 100644 --- a/src/features/tasks/list/listNonInteractive.ts +++ b/src/features/tasks/list/listNonInteractive.ts @@ -6,11 +6,9 @@ */ import { execFileSync } from 'node:child_process'; -import type { TaskListItem, BranchListItem } from '../../../infra/task/index.js'; +import type { TaskListItem } from '../../../infra/task/index.js'; import { detectDefaultBranch, - listTaktBranches, - buildListItems, TaskRunner, } from '../../../infra/task/index.js'; import { info } from '../../../shared/ui/index.js'; @@ -34,34 +32,18 @@ function isValidAction(action: string): action is ListAction { return action === 'diff' || action === 'try' || action === 'merge' || action === 'delete'; } -function printNonInteractiveList( - items: BranchListItem[], - pendingTasks: TaskListItem[], - failedTasks: TaskListItem[], - format?: string, -): void { +function printNonInteractiveList(tasks: TaskListItem[], format?: string): void { const outputFormat = format ?? 'text'; if (outputFormat === 'json') { // stdout に直接出力(JSON パース用途のため UI ヘルパーを経由しない) console.log(JSON.stringify({ - branches: items, - pendingTasks, - failedTasks, + tasks, }, null, 2)); return; } - for (const item of items) { - const instruction = item.originalInstruction ? ` - ${item.originalInstruction}` : ''; - info(`${item.info.branch} (${item.filesChanged} files)${instruction}`); - } - - for (const task of pendingTasks) { - info(`${formatTaskStatusLabel(task)} - ${task.content}`); - } - - for (const task of failedTasks) { - info(`${formatTaskStatusLabel(task)} - ${task.content}`); + for (const task of tasks) { + info(`${formatTaskStatusLabel(task)} - ${task.content} (${task.createdAt})`); } } @@ -85,24 +67,20 @@ export async function listTasksNonInteractive( nonInteractive: ListNonInteractiveOptions, ): Promise { const defaultBranch = detectDefaultBranch(cwd); - const branches = listTaktBranches(cwd); const runner = new TaskRunner(cwd); - const pendingTasks = runner.listPendingTaskItems(); - const failedTasks = runner.listFailedTasks(); + const tasks = runner.listAllTaskItems(); - const items = buildListItems(cwd, branches, defaultBranch); - - if (items.length === 0 && pendingTasks.length === 0 && failedTasks.length === 0) { + if (tasks.length === 0) { info('No tasks to list.'); return; } if (!nonInteractive.action) { - printNonInteractiveList(items, pendingTasks, failedTasks, nonInteractive.format); + printNonInteractiveList(tasks, nonInteractive.format); return; } - // Branch-targeted action (--branch) + // Completed-task branch-targeted action (--branch) if (!nonInteractive.branch) { info('Missing --branch for non-interactive action.'); process.exit(1); @@ -113,28 +91,32 @@ export async function listTasksNonInteractive( process.exit(1); } - const item = items.find((entry) => entry.info.branch === nonInteractive.branch); - if (!item) { + const task = tasks.find((entry) => entry.kind === 'completed' && entry.branch === nonInteractive.branch); + if (!task) { info(`Branch not found: ${nonInteractive.branch}`); process.exit(1); } switch (nonInteractive.action) { case 'diff': - showDiffStat(cwd, defaultBranch, item.info.branch); + showDiffStat(cwd, defaultBranch, nonInteractive.branch); return; case 'try': - tryMergeBranch(cwd, item); + tryMergeBranch(cwd, task); return; case 'merge': - mergeBranch(cwd, item); + if (mergeBranch(cwd, task)) { + runner.deleteCompletedTask(task.name); + } return; case 'delete': if (!nonInteractive.yes) { info('Delete requires --yes in non-interactive mode.'); process.exit(1); } - deleteBranch(cwd, item); + if (deleteBranch(cwd, task)) { + runner.deleteCompletedTask(task.name); + } return; } } diff --git a/src/features/tasks/list/taskActionTarget.ts b/src/features/tasks/list/taskActionTarget.ts new file mode 100644 index 0000000..2e2f0c4 --- /dev/null +++ b/src/features/tasks/list/taskActionTarget.ts @@ -0,0 +1,29 @@ +import type { BranchListItem, TaskListItem } from '../../../infra/task/index.js'; + +export type ListAction = 'diff' | 'instruct' | 'try' | 'merge' | 'delete'; + +export type BranchActionTarget = TaskListItem | Pick; + +export function resolveTargetBranch(target: BranchActionTarget): string { + if ('kind' in target) { + if (!target.branch) { + throw new Error(`Branch is required for task action: ${target.name}`); + } + return target.branch; + } + return target.info.branch; +} + +export function resolveTargetWorktreePath(target: BranchActionTarget): string | undefined { + if ('kind' in target) { + return target.worktreePath; + } + return target.info.worktreePath; +} + +export function resolveTargetInstruction(target: BranchActionTarget): string { + if ('kind' in target) { + return target.content; + } + return target.originalInstruction; +} diff --git a/src/features/tasks/list/taskActions.ts b/src/features/tasks/list/taskActions.ts index 223d56d..ef94343 100644 --- a/src/features/tasks/list/taskActions.ts +++ b/src/features/tasks/list/taskActions.ts @@ -1,395 +1,19 @@ /** - * Individual actions for branch-based tasks. - * - * Provides merge, delete, try-merge, instruct, and diff operations - * for branches listed by the listTasks command. + * Individual actions for task-centric list items. */ -import { execFileSync, spawnSync } from 'node:child_process'; -import { rmSync, existsSync, unlinkSync } from 'node:fs'; -import { join } from 'node:path'; +export type { ListAction } from './taskActionTarget.js'; -import chalk from 'chalk'; -import { - createTempCloneForBranch, - removeClone, - removeCloneMeta, - cleanupOrphanedClone, - detectDefaultBranch, - autoCommitAndPush, - type BranchListItem, -} from '../../../infra/task/index.js'; -import { loadGlobalConfig, getPieceDescription } from '../../../infra/config/index.js'; -import { selectOption } from '../../../shared/prompt/index.js'; -import { info, success, error as logError, warn, header, blankLine } from '../../../shared/ui/index.js'; -import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; -import { executeTask } from '../execute/taskExecution.js'; -import type { TaskExecutionOptions } from '../execute/types.js'; -import { encodeWorktreePath } from '../../../infra/config/project/sessionStore.js'; -import { runInstructMode } from './instructMode.js'; -import { saveTaskFile } from '../add/index.js'; -import { selectPiece } from '../../pieceSelection/index.js'; -import { dispatchConversationAction } from '../../interactive/actionDispatcher.js'; -import type { PieceContext } from '../../interactive/interactive.js'; +export { + showFullDiff, + showDiffAndPromptActionForTask, +} from './taskDiffActions.js'; -const log = createLogger('list-tasks'); +export { + isBranchMerged, + tryMergeBranch, + mergeBranch, + deleteBranch, +} from './taskBranchLifecycleActions.js'; -export type ListAction = 'diff' | 'instruct' | 'try' | 'merge' | 'delete'; - -/** - * Check if a branch has already been merged into HEAD. - */ -export function isBranchMerged(projectDir: string, branch: string): boolean { - const result = spawnSync('git', ['merge-base', '--is-ancestor', branch, 'HEAD'], { - cwd: projectDir, - encoding: 'utf-8', - stdio: 'pipe', - }); - - if (result.error) { - log.error('Failed to check if branch is merged', { - branch, - error: getErrorMessage(result.error), - }); - return false; - } - - return result.status === 0; -} - -/** - * Show full diff in an interactive pager (less). - * Falls back to direct output if pager is unavailable. - */ -export function showFullDiff( - cwd: string, - defaultBranch: string, - branch: string, -): void { - try { - const result = spawnSync( - 'git', ['diff', '--color=always', `${defaultBranch}...${branch}`], - { - cwd, - stdio: 'inherit', - env: { ...process.env, GIT_PAGER: 'less -R' }, - }, - ); - if (result.status !== 0) { - warn('Could not display diff'); - } - } catch (err) { - warn('Could not display diff'); - log.error('Failed to display full diff', { - branch, - defaultBranch, - error: getErrorMessage(err), - }); - } -} - -/** - * Show diff stat for a branch and prompt for an action. - */ -export async function showDiffAndPromptAction( - cwd: string, - defaultBranch: string, - item: BranchListItem, -): Promise { - header(item.info.branch); - if (item.originalInstruction) { - info(chalk.dim(` ${item.originalInstruction}`)); - } - blankLine(); - - try { - const stat = execFileSync( - 'git', ['diff', '--stat', `${defaultBranch}...${item.info.branch}`], - { cwd, encoding: 'utf-8', stdio: 'pipe' }, - ); - info(stat); - } catch (err) { - warn('Could not generate diff stat'); - log.error('Failed to generate diff stat', { - branch: item.info.branch, - defaultBranch, - error: getErrorMessage(err), - }); - } - - const action = await selectOption( - `Action for ${item.info.branch}:`, - [ - { label: 'View diff', value: 'diff', description: 'Show full diff in pager' }, - { label: 'Instruct', value: 'instruct', description: 'Give additional instructions via temp clone' }, - { label: 'Try merge', value: 'try', description: 'Squash merge (stage changes without commit)' }, - { label: 'Merge & cleanup', value: 'merge', description: 'Merge and delete branch' }, - { label: 'Delete', value: 'delete', description: 'Discard changes, delete branch' }, - ], - ); - - return action; -} - -/** - * Try-merge (squash): stage changes from branch without committing. - */ -export function tryMergeBranch(projectDir: string, item: BranchListItem): boolean { - const { branch } = item.info; - - try { - execFileSync('git', ['merge', '--squash', branch], { - cwd: projectDir, - encoding: 'utf-8', - stdio: 'pipe', - }); - - success(`Squash-merged ${branch} (changes staged, not committed)`); - info('Run `git status` to see staged changes, `git commit` to finalize, or `git reset` to undo.'); - log.info('Try-merge (squash) completed', { branch }); - return true; - } catch (err) { - const msg = getErrorMessage(err); - logError(`Squash merge failed: ${msg}`); - logError('You may need to resolve conflicts manually.'); - log.error('Try-merge (squash) failed', { branch, error: msg }); - return false; - } -} - -/** - * Merge & cleanup: if already merged, skip merge and just delete the branch. - */ -export function mergeBranch(projectDir: string, item: BranchListItem): boolean { - const { branch } = item.info; - const alreadyMerged = isBranchMerged(projectDir, branch); - - try { - if (alreadyMerged) { - info(`${branch} is already merged, skipping merge.`); - log.info('Branch already merged, cleanup only', { branch }); - } else { - execFileSync('git', ['merge', '--no-edit', branch], { - cwd: projectDir, - encoding: 'utf-8', - stdio: 'pipe', - env: { - ...process.env, - GIT_MERGE_AUTOEDIT: 'no', - }, - }); - } - - try { - execFileSync('git', ['branch', '-d', branch], { - cwd: projectDir, - encoding: 'utf-8', - stdio: 'pipe', - }); - } catch (err) { - warn(`Could not delete branch ${branch}. You may delete it manually.`); - log.error('Failed to delete merged branch', { - branch, - error: getErrorMessage(err), - }); - } - - cleanupOrphanedClone(projectDir, branch); - - success(`Merged & cleaned up ${branch}`); - log.info('Branch merged & cleaned up', { branch, alreadyMerged }); - return true; - } catch (err) { - const msg = getErrorMessage(err); - logError(`Merge failed: ${msg}`); - logError('You may need to resolve conflicts manually.'); - log.error('Merge & cleanup failed', { branch, error: msg }); - return false; - } -} - -/** - * Delete a branch (discard changes). - * For worktree branches, removes the worktree directory and session file. - */ -export function deleteBranch(projectDir: string, item: BranchListItem): boolean { - const { branch, worktreePath } = item.info; - - try { - // If this is a worktree branch, remove the worktree directory and session file - if (worktreePath) { - // Remove worktree directory if it exists - if (existsSync(worktreePath)) { - rmSync(worktreePath, { recursive: true, force: true }); - log.info('Removed worktree directory', { worktreePath }); - } - - // Remove worktree-session file - const encodedPath = encodeWorktreePath(worktreePath); - const sessionFile = join(projectDir, '.takt', 'worktree-sessions', `${encodedPath}.json`); - if (existsSync(sessionFile)) { - unlinkSync(sessionFile); - log.info('Removed worktree-session file', { sessionFile }); - } - - success(`Deleted worktree ${branch}`); - log.info('Worktree branch deleted', { branch, worktreePath }); - return true; - } - - // For regular branches, use git branch -D - execFileSync('git', ['branch', '-D', branch], { - cwd: projectDir, - encoding: 'utf-8', - stdio: 'pipe', - }); - - cleanupOrphanedClone(projectDir, branch); - - success(`Deleted ${branch}`); - log.info('Branch deleted', { branch }); - return true; - } catch (err) { - const msg = getErrorMessage(err); - logError(`Delete failed: ${msg}`); - log.error('Delete failed', { branch, error: msg }); - return false; - } -} - -/** - * Get branch context: diff stat and commit log from main branch. - */ -function getBranchContext(projectDir: string, branch: string): string { - const defaultBranch = detectDefaultBranch(projectDir); - const lines: string[] = []; - - try { - const diffStat = execFileSync( - 'git', ['diff', '--stat', `${defaultBranch}...${branch}`], - { cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' }, - ).trim(); - if (diffStat) { - lines.push('## 現在の変更内容(mainからの差分)'); - lines.push('```'); - lines.push(diffStat); - lines.push('```'); - } - } catch (err) { - log.debug('Failed to collect branch diff stat for instruction context', { - branch, - defaultBranch, - error: getErrorMessage(err), - }); - } - - try { - const commitLog = execFileSync( - 'git', ['log', '--oneline', `${defaultBranch}..${branch}`], - { cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' }, - ).trim(); - if (commitLog) { - lines.push(''); - lines.push('## コミット履歴'); - lines.push('```'); - lines.push(commitLog); - lines.push('```'); - } - } catch (err) { - log.debug('Failed to collect branch commit log for instruction context', { - branch, - defaultBranch, - error: getErrorMessage(err), - }); - } - - return lines.length > 0 ? lines.join('\n') + '\n\n' : ''; -} - -/** - * Instruct branch: create a temp clone, give additional instructions via - * interactive conversation, then auto-commit+push+PR or save as task file. - */ -export async function instructBranch( - projectDir: string, - item: BranchListItem, - options?: TaskExecutionOptions, -): Promise { - const { branch } = item.info; - - const selectedPiece = await selectPiece(projectDir); - if (!selectedPiece) { - info('Cancelled'); - return false; - } - - const globalConfig = loadGlobalConfig(); - const pieceDesc = getPieceDescription(selectedPiece, projectDir, globalConfig.interactivePreviewMovements); - const pieceContext: PieceContext = { - name: pieceDesc.name, - description: pieceDesc.description, - pieceStructure: pieceDesc.pieceStructure, - movementPreviews: pieceDesc.movementPreviews, - }; - - const branchContext = getBranchContext(projectDir, branch); - const result = await runInstructMode(projectDir, branchContext, branch, pieceContext); - - return dispatchConversationAction(result, { - cancel: () => { - info('Cancelled'); - return false; - }, - save_task: async ({ task }) => { - const created = await saveTaskFile(projectDir, task, { - piece: selectedPiece, - worktree: true, - branch, - autoPr: false, - }); - success(`Task saved: ${created.taskName}`); - info(` Branch: ${branch}`); - log.info('Task saved from instruct mode', { branch, piece: selectedPiece }); - return true; - }, - execute: async ({ task }) => { - log.info('Instructing branch via temp clone', { branch, piece: selectedPiece }); - info(`Running instruction on ${branch}...`); - - const clone = createTempCloneForBranch(projectDir, branch); - - try { - const fullInstruction = branchContext - ? `${branchContext}## 追加指示\n${task}` - : task; - - const taskSuccess = await executeTask({ - task: fullInstruction, - cwd: clone.path, - pieceIdentifier: selectedPiece, - projectCwd: projectDir, - agentOverrides: options, - }); - - if (taskSuccess) { - const commitResult = autoCommitAndPush(clone.path, task, projectDir); - if (commitResult.success && commitResult.commitHash) { - success(`Auto-committed & pushed: ${commitResult.commitHash}`); - } else if (!commitResult.success) { - logError(`Auto-commit failed: ${commitResult.message}`); - } - success(`Instruction completed on ${branch}`); - log.info('Instruction completed', { branch }); - } else { - logError(`Instruction failed on ${branch}`); - log.error('Instruction failed', { branch }); - } - - return taskSuccess; - } finally { - removeClone(clone.path); - removeCloneMeta(projectDir, branch); - } - }, - }); -} +export { instructBranch } from './taskInstructionActions.js'; diff --git a/src/features/tasks/list/taskBranchLifecycleActions.ts b/src/features/tasks/list/taskBranchLifecycleActions.ts new file mode 100644 index 0000000..2d8fabe --- /dev/null +++ b/src/features/tasks/list/taskBranchLifecycleActions.ts @@ -0,0 +1,141 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +import { rmSync, existsSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import { cleanupOrphanedClone } from '../../../infra/task/index.js'; +import { encodeWorktreePath } from '../../../infra/config/project/sessionStore.js'; +import { info, success, error as logError, warn } from '../../../shared/ui/index.js'; +import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; +import { type BranchActionTarget, resolveTargetBranch, resolveTargetWorktreePath } from './taskActionTarget.js'; + +const log = createLogger('list-tasks'); + +export function isBranchMerged(projectDir: string, branch: string): boolean { + const result = spawnSync('git', ['merge-base', '--is-ancestor', branch, 'HEAD'], { + cwd: projectDir, + encoding: 'utf-8', + stdio: 'pipe', + }); + + if (result.error) { + log.error('Failed to check if branch is merged', { + branch, + error: getErrorMessage(result.error), + }); + return false; + } + + return result.status === 0; +} + +export function tryMergeBranch(projectDir: string, target: BranchActionTarget): boolean { + const branch = resolveTargetBranch(target); + + try { + execFileSync('git', ['merge', '--squash', branch], { + cwd: projectDir, + encoding: 'utf-8', + stdio: 'pipe', + }); + + success(`Squash-merged ${branch} (changes staged, not committed)`); + info('Run `git status` to see staged changes, `git commit` to finalize, or `git reset` to undo.'); + log.info('Try-merge (squash) completed', { branch }); + return true; + } catch (err) { + const msg = getErrorMessage(err); + logError(`Squash merge failed: ${msg}`); + logError('You may need to resolve conflicts manually.'); + log.error('Try-merge (squash) failed', { branch, error: msg }); + return false; + } +} + +export function mergeBranch(projectDir: string, target: BranchActionTarget): boolean { + const branch = resolveTargetBranch(target); + const alreadyMerged = isBranchMerged(projectDir, branch); + + try { + if (alreadyMerged) { + info(`${branch} is already merged, skipping merge.`); + log.info('Branch already merged, cleanup only', { branch }); + } else { + execFileSync('git', ['merge', '--no-edit', branch], { + cwd: projectDir, + encoding: 'utf-8', + stdio: 'pipe', + env: { + ...process.env, + GIT_MERGE_AUTOEDIT: 'no', + }, + }); + } + + try { + execFileSync('git', ['branch', '-d', branch], { + cwd: projectDir, + encoding: 'utf-8', + stdio: 'pipe', + }); + } catch (err) { + warn(`Could not delete branch ${branch}. You may delete it manually.`); + log.error('Failed to delete merged branch', { + branch, + error: getErrorMessage(err), + }); + } + + cleanupOrphanedClone(projectDir, branch); + + success(`Merged & cleaned up ${branch}`); + log.info('Branch merged & cleaned up', { branch, alreadyMerged }); + return true; + } catch (err) { + const msg = getErrorMessage(err); + logError(`Merge failed: ${msg}`); + logError('You may need to resolve conflicts manually.'); + log.error('Merge & cleanup failed', { branch, error: msg }); + return false; + } +} + +export function deleteBranch(projectDir: string, target: BranchActionTarget): boolean { + const branch = resolveTargetBranch(target); + const worktreePath = resolveTargetWorktreePath(target); + + try { + if (worktreePath) { + if (existsSync(worktreePath)) { + rmSync(worktreePath, { recursive: true, force: true }); + log.info('Removed worktree directory', { worktreePath }); + } + + const encodedPath = encodeWorktreePath(worktreePath); + const sessionFile = join(projectDir, '.takt', 'worktree-sessions', `${encodedPath}.json`); + if (existsSync(sessionFile)) { + unlinkSync(sessionFile); + log.info('Removed worktree-session file', { sessionFile }); + } + + success(`Deleted worktree ${branch}`); + log.info('Worktree branch deleted', { branch, worktreePath }); + return true; + } + + execFileSync('git', ['branch', '-D', branch], { + cwd: projectDir, + encoding: 'utf-8', + stdio: 'pipe', + }); + + cleanupOrphanedClone(projectDir, branch); + + success(`Deleted ${branch}`); + log.info('Branch deleted', { branch }); + return true; + } catch (err) { + const msg = getErrorMessage(err); + logError(`Delete failed: ${msg}`); + log.error('Delete failed', { branch, error: msg }); + return false; + } +} diff --git a/src/features/tasks/list/taskDeleteActions.ts b/src/features/tasks/list/taskDeleteActions.ts index 89eee61..c3219fb 100644 --- a/src/features/tasks/list/taskDeleteActions.ts +++ b/src/features/tasks/list/taskDeleteActions.ts @@ -11,6 +11,7 @@ import { TaskRunner } from '../../../infra/task/index.js'; import { confirm } from '../../../shared/prompt/index.js'; import { success, error as logError } from '../../../shared/ui/index.js'; import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; +import { deleteBranch } from './taskActions.js'; const log = createLogger('list-tasks'); @@ -18,6 +19,14 @@ function getProjectDir(task: TaskListItem): string { return dirname(dirname(task.filePath)); } +function cleanupBranchIfPresent(task: TaskListItem, projectDir: string): boolean { + if (!task.branch) { + return true; + } + + return deleteBranch(projectDir, task); +} + /** * Delete a pending task file. * Prompts user for confirmation first. @@ -46,8 +55,13 @@ export async function deletePendingTask(task: TaskListItem): Promise { export async function deleteFailedTask(task: TaskListItem): Promise { const confirmed = await confirm(`Delete failed task "${task.name}"?`, false); if (!confirmed) return false; + const projectDir = getProjectDir(task); try { - const runner = new TaskRunner(getProjectDir(task)); + if (!cleanupBranchIfPresent(task, projectDir)) { + return false; + } + + const runner = new TaskRunner(projectDir); runner.deleteFailedTask(task.name); } catch (err) { const msg = getErrorMessage(err); @@ -59,3 +73,27 @@ export async function deleteFailedTask(task: TaskListItem): Promise { log.info('Deleted failed task', { name: task.name, filePath: task.filePath }); return true; } + +export async function deleteCompletedTask(task: TaskListItem): Promise { + const confirmed = await confirm(`Delete completed task "${task.name}"?`, false); + if (!confirmed) return false; + + const projectDir = getProjectDir(task); + try { + if (!cleanupBranchIfPresent(task, projectDir)) { + return false; + } + + const runner = new TaskRunner(projectDir); + runner.deleteCompletedTask(task.name); + } catch (err) { + const msg = getErrorMessage(err); + logError(`Failed to delete completed task "${task.name}": ${msg}`); + log.error('Failed to delete completed task', { name: task.name, filePath: task.filePath, error: msg }); + return false; + } + + success(`Deleted completed task: ${task.name}`); + log.info('Deleted completed task', { name: task.name, filePath: task.filePath }); + return true; +} diff --git a/src/features/tasks/list/taskDiffActions.ts b/src/features/tasks/list/taskDiffActions.ts new file mode 100644 index 0000000..787fa8b --- /dev/null +++ b/src/features/tasks/list/taskDiffActions.ts @@ -0,0 +1,74 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +import chalk from 'chalk'; +import { detectDefaultBranch } from '../../../infra/task/index.js'; +import { selectOption } from '../../../shared/prompt/index.js'; +import { info, warn, header, blankLine } from '../../../shared/ui/index.js'; +import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; +import { type BranchActionTarget, type ListAction, resolveTargetBranch, resolveTargetInstruction } from './taskActionTarget.js'; + +const log = createLogger('list-tasks'); + +export function showFullDiff(cwd: string, branch: string): void { + const defaultBranch = detectDefaultBranch(cwd); + try { + const result = spawnSync( + 'git', ['diff', '--color=always', `${defaultBranch}...${branch}`], + { + cwd, + stdio: 'inherit', + env: { ...process.env, GIT_PAGER: 'less -R' }, + }, + ); + if (result.status !== 0) { + warn('Could not display diff'); + } + } catch (err) { + warn('Could not display diff'); + log.error('Failed to display full diff', { + branch, + defaultBranch, + error: getErrorMessage(err), + }); + } +} + +export async function showDiffAndPromptActionForTask( + cwd: string, + target: BranchActionTarget, +): Promise { + const branch = resolveTargetBranch(target); + const instruction = resolveTargetInstruction(target); + const defaultBranch = detectDefaultBranch(cwd); + + header(branch); + if (instruction) { + info(chalk.dim(` ${instruction}`)); + } + blankLine(); + + try { + const stat = execFileSync( + 'git', ['diff', '--stat', `${defaultBranch}...${branch}`], + { cwd, encoding: 'utf-8', stdio: 'pipe' }, + ); + info(stat); + } catch (err) { + warn('Could not generate diff stat'); + log.error('Failed to generate diff stat', { + branch, + defaultBranch, + error: getErrorMessage(err), + }); + } + + return await selectOption( + `Action for ${branch}:`, + [ + { label: 'View diff', value: 'diff', description: 'Show full diff in pager' }, + { label: 'Instruct', value: 'instruct', description: 'Give additional instructions via temp clone' }, + { label: 'Try merge', value: 'try', description: 'Squash merge (stage changes without commit)' }, + { label: 'Merge & cleanup', value: 'merge', description: 'Merge and delete branch' }, + { label: 'Delete', value: 'delete', description: 'Discard changes, delete branch' }, + ], + ); +} diff --git a/src/features/tasks/list/taskInstructionActions.ts b/src/features/tasks/list/taskInstructionActions.ts new file mode 100644 index 0000000..59431a7 --- /dev/null +++ b/src/features/tasks/list/taskInstructionActions.ts @@ -0,0 +1,185 @@ +import { execFileSync } from 'node:child_process'; +import { + createTempCloneForBranch, + removeClone, + removeCloneMeta, + TaskRunner, +} from '../../../infra/task/index.js'; +import { loadGlobalConfig, getPieceDescription } from '../../../infra/config/index.js'; +import { info, success, error as logError } from '../../../shared/ui/index.js'; +import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; +import { executeTask } from '../execute/taskExecution.js'; +import type { TaskExecutionOptions } from '../execute/types.js'; +import { buildBooleanTaskResult, persistTaskError, persistTaskResult } from '../execute/taskResultHandler.js'; +import { runInstructMode } from './instructMode.js'; +import { saveTaskFile } from '../add/index.js'; +import { selectPiece } from '../../pieceSelection/index.js'; +import { dispatchConversationAction } from '../../interactive/actionDispatcher.js'; +import type { PieceContext } from '../../interactive/interactive.js'; +import { type BranchActionTarget, resolveTargetBranch, resolveTargetWorktreePath } from './taskActionTarget.js'; +import { detectDefaultBranch, autoCommitAndPush } from '../../../infra/task/index.js'; + +const log = createLogger('list-tasks'); + +function getBranchContext(projectDir: string, branch: string): string { + const defaultBranch = detectDefaultBranch(projectDir); + const lines: string[] = []; + + try { + const diffStat = execFileSync( + 'git', ['diff', '--stat', `${defaultBranch}...${branch}`], + { cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' }, + ).trim(); + if (diffStat) { + lines.push('## 現在の変更内容(mainからの差分)'); + lines.push('```'); + lines.push(diffStat); + lines.push('```'); + } + } catch (err) { + log.debug('Failed to collect branch diff stat for instruction context', { + branch, + defaultBranch, + error: getErrorMessage(err), + }); + } + + try { + const commitLog = execFileSync( + 'git', ['log', '--oneline', `${defaultBranch}..${branch}`], + { cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' }, + ).trim(); + if (commitLog) { + lines.push(''); + lines.push('## コミット履歴'); + lines.push('```'); + lines.push(commitLog); + lines.push('```'); + } + } catch (err) { + log.debug('Failed to collect branch commit log for instruction context', { + branch, + defaultBranch, + error: getErrorMessage(err), + }); + } + + return lines.length > 0 ? `${lines.join('\n')}\n\n` : ''; +} + +export async function instructBranch( + projectDir: string, + target: BranchActionTarget, + options?: TaskExecutionOptions, +): Promise { + const branch = resolveTargetBranch(target); + const worktreePath = resolveTargetWorktreePath(target); + + const selectedPiece = await selectPiece(projectDir); + if (!selectedPiece) { + info('Cancelled'); + return false; + } + + const globalConfig = loadGlobalConfig(); + const pieceDesc = getPieceDescription(selectedPiece, projectDir, globalConfig.interactivePreviewMovements); + const pieceContext: PieceContext = { + name: pieceDesc.name, + description: pieceDesc.description, + pieceStructure: pieceDesc.pieceStructure, + movementPreviews: pieceDesc.movementPreviews, + }; + + const branchContext = getBranchContext(projectDir, branch); + const result = await runInstructMode(projectDir, branchContext, branch, pieceContext); + + return dispatchConversationAction(result, { + cancel: () => { + info('Cancelled'); + return false; + }, + save_task: async ({ task }) => { + const created = await saveTaskFile(projectDir, task, { + piece: selectedPiece, + worktree: true, + branch, + autoPr: false, + }); + success(`Task saved: ${created.taskName}`); + info(` Branch: ${branch}`); + log.info('Task saved from instruct mode', { branch, piece: selectedPiece }); + return true; + }, + execute: async ({ task }) => { + log.info('Instructing branch via temp clone', { branch, piece: selectedPiece }); + info(`Running instruction on ${branch}...`); + + const clone = createTempCloneForBranch(projectDir, branch); + const fullInstruction = branchContext + ? `${branchContext}## 追加指示\n${task}` + : task; + + const runner = new TaskRunner(projectDir); + const taskRecord = runner.addTask(fullInstruction, { + piece: selectedPiece, + worktree: true, + branch, + auto_pr: false, + ...(worktreePath ? { worktree_path: worktreePath } : {}), + }); + const startedAt = new Date().toISOString(); + + try { + const taskSuccess = await executeTask({ + task: fullInstruction, + cwd: clone.path, + pieceIdentifier: selectedPiece, + projectCwd: projectDir, + agentOverrides: options, + }); + + const completedAt = new Date().toISOString(); + const taskResult = buildBooleanTaskResult({ + task: taskRecord, + taskSuccess, + successResponse: 'Instruction completed', + failureResponse: 'Instruction failed', + startedAt, + completedAt, + branch, + ...(worktreePath ? { worktreePath } : {}), + }); + persistTaskResult(runner, taskResult, { emitStatusLog: false }); + + if (taskSuccess) { + const commitResult = autoCommitAndPush(clone.path, task, projectDir); + if (commitResult.success && commitResult.commitHash) { + success(`Auto-committed & pushed: ${commitResult.commitHash}`); + } else if (!commitResult.success) { + logError(`Auto-commit failed: ${commitResult.message}`); + } + + success(`Instruction completed on ${branch}`); + log.info('Instruction completed', { branch }); + } else { + logError(`Instruction failed on ${branch}`); + log.error('Instruction failed', { branch }); + } + + return taskSuccess; + } catch (err) { + const completedAt = new Date().toISOString(); + persistTaskError(runner, taskRecord, startedAt, completedAt, err, { + emitStatusLog: false, + responsePrefix: 'Instruction failed: ', + }); + logError(`Instruction failed on ${branch}`); + log.error('Instruction crashed', { branch, error: getErrorMessage(err) }); + throw err; + } finally { + removeClone(clone.path); + removeCloneMeta(projectDir, branch); + } + }, + }); +} diff --git a/src/features/tasks/list/taskStatusLabel.ts b/src/features/tasks/list/taskStatusLabel.ts index 05bd1ae..4a891b1 100644 --- a/src/features/tasks/list/taskStatusLabel.ts +++ b/src/features/tasks/list/taskStatusLabel.ts @@ -1,7 +1,9 @@ import type { TaskListItem } from '../../../infra/task/index.js'; const TASK_STATUS_BY_KIND: Record = { - pending: 'running', + pending: 'pending', + running: 'running', + completed: 'completed', failed: 'failed', }; diff --git a/src/infra/task/mapper.ts b/src/infra/task/mapper.ts index 87762b6..a7594f7 100644 --- a/src/infra/task/mapper.ts +++ b/src/infra/task/mapper.ts @@ -70,6 +70,7 @@ export function toTaskInfo(projectDir: string, tasksFile: string, task: TaskReco taskDir: task.task_dir, createdAt: task.created_at, status: task.status, + worktreePath: task.worktree_path, data: TaskFileSchema.parse({ task: content, worktree: task.worktree, @@ -86,22 +87,53 @@ export function toTaskInfo(projectDir: string, tasksFile: string, task: TaskReco export function toPendingTaskItem(projectDir: string, tasksFile: string, task: TaskRecord): TaskListItem { return { kind: 'pending', - name: task.name, - createdAt: task.created_at, - filePath: tasksFile, - content: firstLine(resolveTaskContent(projectDir, task)), - data: toTaskData(projectDir, task), + ...toBaseTaskListItem(projectDir, tasksFile, task), }; } export function toFailedTaskItem(projectDir: string, tasksFile: string, task: TaskRecord): TaskListItem { return { kind: 'failed', - name: task.name, - createdAt: task.completed_at ?? task.created_at, - filePath: tasksFile, - content: firstLine(resolveTaskContent(projectDir, task)), - data: toTaskData(projectDir, task), + ...toBaseTaskListItem(projectDir, tasksFile, task), failure: task.failure, }; } + +function toRunningTaskItem(projectDir: string, tasksFile: string, task: TaskRecord): TaskListItem { + return { + kind: 'running', + ...toBaseTaskListItem(projectDir, tasksFile, task), + }; +} + +function toCompletedTaskItem(projectDir: string, tasksFile: string, task: TaskRecord): TaskListItem { + return { + kind: 'completed', + ...toBaseTaskListItem(projectDir, tasksFile, task), + }; +} + +function toBaseTaskListItem(projectDir: string, tasksFile: string, task: TaskRecord): Omit { + return { + name: task.name, + createdAt: task.created_at, + filePath: tasksFile, + content: firstLine(resolveTaskContent(projectDir, task)), + branch: task.branch, + worktreePath: task.worktree_path, + data: toTaskData(projectDir, task), + }; +} + +export function toTaskListItem(projectDir: string, tasksFile: string, task: TaskRecord): TaskListItem { + switch (task.status) { + case 'pending': + return toPendingTaskItem(projectDir, tasksFile, task); + case 'running': + return toRunningTaskItem(projectDir, tasksFile, task); + case 'completed': + return toCompletedTaskItem(projectDir, tasksFile, task); + case 'failed': + return toFailedTaskItem(projectDir, tasksFile, task); + } +} diff --git a/src/infra/task/runner.ts b/src/infra/task/runner.ts index e7e9957..f1f0ef0 100644 --- a/src/infra/task/runner.ts +++ b/src/infra/task/runner.ts @@ -1,24 +1,25 @@ -import * as path from 'node:path'; -import { - TaskRecordSchema, - type TaskFileData, - type TaskRecord, - type TaskFailure, -} from './schema.js'; +import type { TaskFileData } from './schema.js'; import type { TaskInfo, TaskResult, TaskListItem } from './types.js'; -import { toFailedTaskItem, toPendingTaskItem, toTaskInfo } from './mapper.js'; import { TaskStore } from './store.js'; -import { firstLine, nowIso, sanitizeTaskName } from './naming.js'; +import { TaskLifecycleService } from './taskLifecycleService.js'; +import { TaskQueryService } from './taskQueryService.js'; +import { TaskDeletionService } from './taskDeletionService.js'; export type { TaskInfo, TaskResult, TaskListItem }; export class TaskRunner { private readonly store: TaskStore; private readonly tasksFile: string; + private readonly lifecycle: TaskLifecycleService; + private readonly query: TaskQueryService; + private readonly deletion: TaskDeletionService; constructor(private readonly projectDir: string) { this.store = new TaskStore(projectDir); this.tasksFile = this.store.getTasksFilePath(); + this.lifecycle = new TaskLifecycleService(projectDir, this.tasksFile, this.store); + this.query = new TaskQueryService(projectDir, this.tasksFile, this.store); + this.deletion = new TaskDeletionService(this.store); } ensureDirs(): void { @@ -31,247 +32,56 @@ export class TaskRunner { addTask( content: string, - options?: Omit & { content_file?: string; task_dir?: string }, + options?: Omit & { content_file?: string; task_dir?: string; worktree_path?: string }, ): TaskInfo { - const state = this.store.update((current) => { - const name = this.generateTaskName(content, current.tasks.map((task) => task.name)); - const contentValue = options?.task_dir ? undefined : content; - const record: TaskRecord = TaskRecordSchema.parse({ - name, - status: 'pending', - content: contentValue, - created_at: nowIso(), - started_at: null, - completed_at: null, - owner_pid: null, - ...options, - }); - return { tasks: [...current.tasks, record] }; - }); - - const created = state.tasks[state.tasks.length - 1]; - if (!created) { - throw new Error('Failed to create task.'); - } - return toTaskInfo(this.projectDir, this.tasksFile, created); + return this.lifecycle.addTask(content, options); } listTasks(): TaskInfo[] { - const state = this.store.read(); - return state.tasks - .filter((task) => task.status === 'pending') - .map((task) => toTaskInfo(this.projectDir, this.tasksFile, task)); + return this.query.listTasks(); } claimNextTasks(count: number): TaskInfo[] { - if (count <= 0) { - return []; - } - - const claimed: TaskRecord[] = []; - - this.store.update((current) => { - let remaining = count; - const tasks = current.tasks.map((task) => { - if (remaining > 0 && task.status === 'pending') { - const next: TaskRecord = { - ...task, - status: 'running', - started_at: nowIso(), - owner_pid: process.pid, - }; - claimed.push(next); - remaining--; - return next; - } - return task; - }); - return { tasks }; - }); - - return claimed.map((task) => toTaskInfo(this.projectDir, this.tasksFile, task)); + return this.lifecycle.claimNextTasks(count); } recoverInterruptedRunningTasks(): number { - let recovered = 0; - this.store.update((current) => { - const tasks = current.tasks.map((task) => { - if (task.status !== 'running' || !this.isRunningTaskStale(task)) { - return task; - } - recovered++; - return { - ...task, - status: 'pending', - started_at: null, - owner_pid: null, - } as TaskRecord; - }); - return { tasks }; - }); - return recovered; + return this.lifecycle.recoverInterruptedRunningTasks(); } completeTask(result: TaskResult): string { - if (!result.success) { - throw new Error('Cannot complete a failed task. Use failTask() instead.'); - } - - this.store.update((current) => { - const index = this.findActiveTaskIndex(current.tasks, result.task.name); - if (index === -1) { - throw new Error(`Task not found: ${result.task.name}`); - } - - return { - tasks: current.tasks.filter((_, i) => i !== index), - }; - }); - - return this.tasksFile; + return this.lifecycle.completeTask(result); } failTask(result: TaskResult): string { - const failure: TaskFailure = { - movement: result.failureMovement, - error: result.response, - last_message: result.failureLastMessage ?? result.executionLog[result.executionLog.length - 1], - }; - - this.store.update((current) => { - const index = this.findActiveTaskIndex(current.tasks, result.task.name); - if (index === -1) { - throw new Error(`Task not found: ${result.task.name}`); - } - - const target = current.tasks[index]!; - const updated: TaskRecord = { - ...target, - status: 'failed', - completed_at: result.completedAt, - owner_pid: null, - failure, - }; - const tasks = [...current.tasks]; - tasks[index] = updated; - return { tasks }; - }); - - return this.tasksFile; + return this.lifecycle.failTask(result); } listPendingTaskItems(): TaskListItem[] { - const state = this.store.read(); - return state.tasks - .filter((task) => task.status === 'pending') - .map((task) => toPendingTaskItem(this.projectDir, this.tasksFile, task)); + return this.query.listPendingTaskItems(); + } + + listAllTaskItems(): TaskListItem[] { + return this.query.listAllTaskItems(); } listFailedTasks(): TaskListItem[] { - const state = this.store.read(); - return state.tasks - .filter((task) => task.status === 'failed') - .map((task) => toFailedTaskItem(this.projectDir, this.tasksFile, task)); + return this.query.listFailedTasks(); } requeueFailedTask(taskRef: string, startMovement?: string, retryNote?: string): string { - const taskName = this.normalizeTaskRef(taskRef); - - this.store.update((current) => { - const index = current.tasks.findIndex((task) => task.name === taskName && task.status === 'failed'); - if (index === -1) { - throw new Error(`Failed task not found: ${taskRef}`); - } - - const target = current.tasks[index]!; - const updated: TaskRecord = { - ...target, - status: 'pending', - started_at: null, - completed_at: null, - owner_pid: null, - failure: undefined, - start_movement: startMovement, - retry_note: retryNote, - }; - - const tasks = [...current.tasks]; - tasks[index] = updated; - return { tasks }; - }); - - return this.tasksFile; + return this.lifecycle.requeueFailedTask(taskRef, startMovement, retryNote); } deletePendingTask(name: string): void { - this.deleteTaskByNameAndStatus(name, 'pending'); + this.deletion.deletePendingTask(name); } deleteFailedTask(name: string): void { - this.deleteTaskByNameAndStatus(name, 'failed'); + this.deletion.deleteFailedTask(name); } - private deleteTaskByNameAndStatus(name: string, status: 'pending' | 'failed'): void { - this.store.update((current) => { - const exists = current.tasks.some((task) => task.name === name && task.status === status); - if (!exists) { - throw new Error(`Task not found: ${name} (${status})`); - } - return { - tasks: current.tasks.filter((task) => !(task.name === name && task.status === status)), - }; - }); + deleteCompletedTask(name: string): void { + this.deletion.deleteCompletedTask(name); } - - private normalizeTaskRef(taskRef: string): string { - if (!taskRef.includes(path.sep)) { - return taskRef; - } - - const base = path.basename(taskRef); - if (base.includes('_')) { - return base.slice(base.indexOf('_') + 1); - } - - return base; - } - - private findActiveTaskIndex(tasks: TaskRecord[], name: string): number { - return tasks.findIndex((task) => task.name === name && (task.status === 'running' || task.status === 'pending')); - } - - private isRunningTaskStale(task: TaskRecord): boolean { - if (task.owner_pid == null) { - return true; - } - return !this.isProcessAlive(task.owner_pid); - } - - private isProcessAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch (err) { - const nodeErr = err as NodeJS.ErrnoException; - if (nodeErr.code === 'ESRCH') { - return false; - } - if (nodeErr.code === 'EPERM') { - return true; - } - throw err; - } - } - - private generateTaskName(content: string, existingNames: string[]): string { - const base = sanitizeTaskName(firstLine(content)); - let candidate = base; - let counter = 1; - while (existingNames.includes(candidate)) { - candidate = `${base}-${counter}`; - counter++; - } - return candidate; - } - } diff --git a/src/infra/task/schema.ts b/src/infra/task/schema.ts index f84df16..3f5cc52 100644 --- a/src/infra/task/schema.ts +++ b/src/infra/task/schema.ts @@ -41,6 +41,7 @@ export type TaskFailure = z.infer; export const TaskRecordSchema = TaskExecutionConfigSchema.extend({ name: z.string().min(1), status: TaskStatusSchema, + worktree_path: z.string().optional(), content: z.string().min(1).optional(), content_file: z.string().min(1).optional(), task_dir: z.string().optional(), diff --git a/src/infra/task/taskDeletionService.ts b/src/infra/task/taskDeletionService.ts new file mode 100644 index 0000000..d1da4e9 --- /dev/null +++ b/src/infra/task/taskDeletionService.ts @@ -0,0 +1,29 @@ +import { TaskStore } from './store.js'; + +export class TaskDeletionService { + constructor(private readonly store: TaskStore) {} + + deletePendingTask(name: string): void { + this.deleteTaskByNameAndStatus(name, 'pending'); + } + + deleteFailedTask(name: string): void { + this.deleteTaskByNameAndStatus(name, 'failed'); + } + + deleteCompletedTask(name: string): void { + this.deleteTaskByNameAndStatus(name, 'completed'); + } + + private deleteTaskByNameAndStatus(name: string, status: 'pending' | 'failed' | 'completed'): void { + this.store.update((current) => { + const exists = current.tasks.some((task) => task.name === name && task.status === status); + if (!exists) { + throw new Error(`Task not found: ${name} (${status})`); + } + return { + tasks: current.tasks.filter((task) => !(task.name === name && task.status === status)), + }; + }); + } +} diff --git a/src/infra/task/taskLifecycleService.ts b/src/infra/task/taskLifecycleService.ts new file mode 100644 index 0000000..ea3a128 --- /dev/null +++ b/src/infra/task/taskLifecycleService.ts @@ -0,0 +1,232 @@ +import * as path from 'node:path'; +import { TaskRecordSchema, type TaskFileData, type TaskRecord, type TaskFailure } from './schema.js'; +import type { TaskInfo, TaskResult } from './types.js'; +import { toTaskInfo } from './mapper.js'; +import { TaskStore } from './store.js'; +import { firstLine, nowIso, sanitizeTaskName } from './naming.js'; + +export class TaskLifecycleService { + constructor( + private readonly projectDir: string, + private readonly tasksFile: string, + private readonly store: TaskStore, + ) {} + + addTask( + content: string, + options?: Omit & { content_file?: string; task_dir?: string; worktree_path?: string }, + ): TaskInfo { + const state = this.store.update((current) => { + const name = this.generateTaskName(content, current.tasks.map((task) => task.name)); + const contentValue = options?.task_dir ? undefined : content; + const record: TaskRecord = TaskRecordSchema.parse({ + name, + status: 'pending', + content: contentValue, + created_at: nowIso(), + started_at: null, + completed_at: null, + owner_pid: null, + ...options, + }); + return { tasks: [...current.tasks, record] }; + }); + + const created = state.tasks[state.tasks.length - 1]; + if (!created) { + throw new Error('Failed to create task.'); + } + return toTaskInfo(this.projectDir, this.tasksFile, created); + } + + claimNextTasks(count: number): TaskInfo[] { + if (count <= 0) { + return []; + } + + const claimed: TaskRecord[] = []; + + this.store.update((current) => { + let remaining = count; + const tasks = current.tasks.map((task) => { + if (remaining > 0 && task.status === 'pending') { + const next: TaskRecord = { + ...task, + status: 'running', + started_at: nowIso(), + owner_pid: process.pid, + }; + claimed.push(next); + remaining--; + return next; + } + return task; + }); + return { tasks }; + }); + + return claimed.map((task) => toTaskInfo(this.projectDir, this.tasksFile, task)); + } + + recoverInterruptedRunningTasks(): number { + let recovered = 0; + this.store.update((current) => { + const tasks = current.tasks.map((task) => { + if (task.status !== 'running' || !this.isRunningTaskStale(task)) { + return task; + } + recovered++; + return { + ...task, + status: 'pending', + started_at: null, + owner_pid: null, + } as TaskRecord; + }); + return { tasks }; + }); + return recovered; + } + + completeTask(result: TaskResult): string { + if (!result.success) { + throw new Error('Cannot complete a failed task. Use failTask() instead.'); + } + + this.store.update((current) => { + const index = this.findActiveTaskIndex(current.tasks, result.task.name); + if (index === -1) { + throw new Error(`Task not found: ${result.task.name}`); + } + + const target = current.tasks[index]!; + const updated: TaskRecord = { + ...target, + status: 'completed', + started_at: result.startedAt, + completed_at: result.completedAt, + owner_pid: null, + failure: undefined, + branch: result.branch ?? target.branch, + worktree_path: result.worktreePath ?? target.worktree_path, + }; + const tasks = [...current.tasks]; + tasks[index] = updated; + return { tasks }; + }); + + return this.tasksFile; + } + + failTask(result: TaskResult): string { + const failure: TaskFailure = { + movement: result.failureMovement, + error: result.response, + last_message: result.failureLastMessage ?? result.executionLog[result.executionLog.length - 1], + }; + + this.store.update((current) => { + const index = this.findActiveTaskIndex(current.tasks, result.task.name); + if (index === -1) { + throw new Error(`Task not found: ${result.task.name}`); + } + + const target = current.tasks[index]!; + const updated: TaskRecord = { + ...target, + status: 'failed', + started_at: result.startedAt, + completed_at: result.completedAt, + owner_pid: null, + failure, + branch: result.branch ?? target.branch, + worktree_path: result.worktreePath ?? target.worktree_path, + }; + const tasks = [...current.tasks]; + tasks[index] = updated; + return { tasks }; + }); + + return this.tasksFile; + } + + requeueFailedTask(taskRef: string, startMovement?: string, retryNote?: string): string { + const taskName = this.normalizeTaskRef(taskRef); + + this.store.update((current) => { + const index = current.tasks.findIndex((task) => task.name === taskName && task.status === 'failed'); + if (index === -1) { + throw new Error(`Failed task not found: ${taskRef}`); + } + + const target = current.tasks[index]!; + const updated: TaskRecord = { + ...target, + status: 'pending', + started_at: null, + completed_at: null, + owner_pid: null, + failure: undefined, + start_movement: startMovement, + retry_note: retryNote, + }; + + const tasks = [...current.tasks]; + tasks[index] = updated; + return { tasks }; + }); + + return this.tasksFile; + } + + private normalizeTaskRef(taskRef: string): string { + if (!taskRef.includes(path.sep)) { + return taskRef; + } + + const base = path.basename(taskRef); + if (base.includes('_')) { + return base.slice(base.indexOf('_') + 1); + } + + return base; + } + + private findActiveTaskIndex(tasks: TaskRecord[], name: string): number { + return tasks.findIndex((task) => task.name === name && (task.status === 'running' || task.status === 'pending')); + } + + private isRunningTaskStale(task: TaskRecord): boolean { + if (task.owner_pid == null) { + return true; + } + return !this.isProcessAlive(task.owner_pid); + } + + private isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (err) { + const nodeErr = err as NodeJS.ErrnoException; + if (nodeErr.code === 'ESRCH') { + return false; + } + if (nodeErr.code === 'EPERM') { + return true; + } + throw err; + } + } + + private generateTaskName(content: string, existingNames: string[]): string { + const base = sanitizeTaskName(firstLine(content)); + let candidate = base; + let counter = 1; + while (existingNames.includes(candidate)) { + candidate = `${base}-${counter}`; + counter++; + } + return candidate; + } +} diff --git a/src/infra/task/taskQueryService.ts b/src/infra/task/taskQueryService.ts new file mode 100644 index 0000000..5632011 --- /dev/null +++ b/src/infra/task/taskQueryService.ts @@ -0,0 +1,37 @@ +import type { TaskInfo, TaskListItem } from './types.js'; +import { toFailedTaskItem, toPendingTaskItem, toTaskInfo, toTaskListItem } from './mapper.js'; +import { TaskStore } from './store.js'; + +export class TaskQueryService { + constructor( + private readonly projectDir: string, + private readonly tasksFile: string, + private readonly store: TaskStore, + ) {} + + listTasks(): TaskInfo[] { + const state = this.store.read(); + return state.tasks + .filter((task) => task.status === 'pending') + .map((task) => toTaskInfo(this.projectDir, this.tasksFile, task)); + } + + listPendingTaskItems(): TaskListItem[] { + const state = this.store.read(); + return state.tasks + .filter((task) => task.status === 'pending') + .map((task) => toPendingTaskItem(this.projectDir, this.tasksFile, task)); + } + + listAllTaskItems(): TaskListItem[] { + const state = this.store.read(); + return state.tasks.map((task) => toTaskListItem(this.projectDir, this.tasksFile, task)); + } + + listFailedTasks(): TaskListItem[] { + const state = this.store.read(); + return state.tasks + .filter((task) => task.status === 'failed') + .map((task) => toFailedTaskItem(this.projectDir, this.tasksFile, task)); + } +} diff --git a/src/infra/task/types.ts b/src/infra/task/types.ts index 303ccf6..d8f3295 100644 --- a/src/infra/task/types.ts +++ b/src/infra/task/types.ts @@ -13,6 +13,7 @@ export interface TaskInfo { taskDir?: string; createdAt: string; status: TaskStatus; + worktreePath?: string; data: TaskFileData | null; } @@ -26,6 +27,8 @@ export interface TaskResult { failureLastMessage?: string; startedAt: string; completedAt: string; + branch?: string; + worktreePath?: string; } export interface WorktreeOptions { @@ -73,11 +76,13 @@ export interface SummarizeOptions { /** pending/failedタスクのリストアイテム */ export interface TaskListItem { - kind: 'pending' | 'failed'; + kind: 'pending' | 'running' | 'completed' | 'failed'; name: string; createdAt: string; filePath: string; content: string; + branch?: string; + worktreePath?: string; data?: TaskFileData; failure?: TaskFailure; }