takt-list (#271)
* refactor: provider/modelの解決ロジックをAgentRunnerに集約 OptionsBuilderでCLIレベルとstepレベルを事前マージしていたのをやめ、 stepProvider/stepModelとして分離して渡す形に変更。 AgentRunnerが全レイヤーの優先度を一括で解決する。 * takt: takt-list
This commit is contained in:
parent
eb593e3829
commit
e52e1da6bf
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
83
src/__tests__/listNonInteractive-completedActions.test.ts
Normal file
83
src/__tests__/listNonInteractive-completedActions.test.ts
Normal file
@ -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');
|
||||
});
|
||||
});
|
||||
@ -13,8 +13,6 @@ vi.mock('../shared/ui/index.js', () => ({
|
||||
vi.mock('../infra/task/branchList.js', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
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();
|
||||
});
|
||||
|
||||
@ -12,8 +12,6 @@ vi.mock('../shared/ui/index.js', () => ({
|
||||
|
||||
vi.mock('../infra/task/branchList.js', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
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();
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
147
src/__tests__/listTasksInteractiveStatusActions.test.ts
Normal file
147
src/__tests__/listTasksInteractiveStatusActions.test.ts
Normal file
@ -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();
|
||||
});
|
||||
});
|
||||
210
src/__tests__/option-resolution-order.test.ts
Normal file
210
src/__tests__/option-resolution-order.test.ts
Normal file
@ -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' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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]!;
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -180,6 +180,7 @@ describe('resolveTaskExecution', () => {
|
||||
isWorktree: true,
|
||||
autoPr: false,
|
||||
branch: 'takt/20260128T0504-add-auth',
|
||||
worktreePath: '/project/../20260128T0504-add-auth',
|
||||
baseBranch: 'main',
|
||||
});
|
||||
});
|
||||
|
||||
157
src/__tests__/taskInstructionActions.test.ts
Normal file
157
src/__tests__/taskInstructionActions.test.ts
Normal file
@ -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<Record<string, unknown>>()),
|
||||
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();
|
||||
});
|
||||
});
|
||||
@ -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'", () => {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<string, McpServerConfig>;
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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 } : {}),
|
||||
|
||||
@ -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,7 +105,19 @@ export async function selectAndExecuteTask(
|
||||
}
|
||||
|
||||
log.info('Starting task execution', { piece: pieceIdentifier, worktree: isWorktree, autoPr: shouldCreatePr });
|
||||
const taskSuccess = await executeTask({
|
||||
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,
|
||||
@ -113,6 +126,27 @@ export async function selectAndExecuteTask(
|
||||
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({
|
||||
|
||||
@ -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) {
|
||||
|
||||
125
src/features/tasks/execute/taskResultHandler.ts
Normal file
125
src/features/tasks/execute/taskResultHandler.ts
Normal file
@ -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)}`);
|
||||
}
|
||||
}
|
||||
@ -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<Pendi
|
||||
*/
|
||||
async function showFailedTaskAndPromptAction(task: TaskListItem): Promise<FailedTaskAction | null> {
|
||||
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<Failed
|
||||
);
|
||||
}
|
||||
|
||||
async function showCompletedTaskAndPromptAction(cwd: string, task: TaskListItem): Promise<CompletedTaskAction | null> {
|
||||
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) => ({
|
||||
const menuOptions = tasks.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,
|
||||
})),
|
||||
];
|
||||
value: `${task.kind}:${idx}`,
|
||||
description: `${task.content} | ${task.createdAt}`,
|
||||
}));
|
||||
|
||||
const selected = await selectOption<string>(
|
||||
'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') {
|
||||
|
||||
@ -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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
29
src/features/tasks/list/taskActionTarget.ts
Normal file
29
src/features/tasks/list/taskActionTarget.ts
Normal file
@ -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<BranchListItem, 'info' | 'originalInstruction'>;
|
||||
|
||||
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;
|
||||
}
|
||||
@ -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<ListAction | null> {
|
||||
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<ListAction>(
|
||||
`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<boolean> {
|
||||
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';
|
||||
|
||||
141
src/features/tasks/list/taskBranchLifecycleActions.ts
Normal file
141
src/features/tasks/list/taskBranchLifecycleActions.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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<boolean> {
|
||||
export async function deleteFailedTask(task: TaskListItem): Promise<boolean> {
|
||||
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<boolean> {
|
||||
log.info('Deleted failed task', { name: task.name, filePath: task.filePath });
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function deleteCompletedTask(task: TaskListItem): Promise<boolean> {
|
||||
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;
|
||||
}
|
||||
|
||||
74
src/features/tasks/list/taskDiffActions.ts
Normal file
74
src/features/tasks/list/taskDiffActions.ts
Normal file
@ -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<ListAction | null> {
|
||||
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<ListAction>(
|
||||
`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' },
|
||||
],
|
||||
);
|
||||
}
|
||||
185
src/features/tasks/list/taskInstructionActions.ts
Normal file
185
src/features/tasks/list/taskInstructionActions.ts
Normal file
@ -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<boolean> {
|
||||
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);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -1,7 +1,9 @@
|
||||
import type { TaskListItem } from '../../../infra/task/index.js';
|
||||
|
||||
const TASK_STATUS_BY_KIND: Record<TaskListItem['kind'], string> = {
|
||||
pending: 'running',
|
||||
pending: 'pending',
|
||||
running: 'running',
|
||||
completed: 'completed',
|
||||
failed: 'failed',
|
||||
};
|
||||
|
||||
|
||||
@ -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<TaskListItem, 'kind' | 'failure'> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<TaskFileData, 'task'> & { content_file?: string; task_dir?: string },
|
||||
options?: Omit<TaskFileData, 'task'> & { 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})`);
|
||||
deleteCompletedTask(name: string): void {
|
||||
this.deletion.deleteCompletedTask(name);
|
||||
}
|
||||
return {
|
||||
tasks: current.tasks.filter((task) => !(task.name === name && task.status === status)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -41,6 +41,7 @@ export type TaskFailure = z.infer<typeof TaskFailureSchema>;
|
||||
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(),
|
||||
|
||||
29
src/infra/task/taskDeletionService.ts
Normal file
29
src/infra/task/taskDeletionService.ts
Normal file
@ -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)),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
232
src/infra/task/taskLifecycleService.ts
Normal file
232
src/infra/task/taskLifecycleService.ts
Normal file
@ -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<TaskFileData, 'task'> & { 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;
|
||||
}
|
||||
}
|
||||
37
src/infra/task/taskQueryService.ts
Normal file
37
src/infra/task/taskQueryService.ts
Normal file
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user