diff --git a/src/__tests__/exceeded-requeue.test.ts b/src/__tests__/exceeded-requeue.test.ts new file mode 100644 index 00000000..db21e550 --- /dev/null +++ b/src/__tests__/exceeded-requeue.test.ts @@ -0,0 +1,453 @@ +/** + * Integration tests for exceeded status and requeue flow + * + * Covers: + * - PieceEngine: onIterationLimit returning null causes engine to stop (exceeded behavior) + * - PieceEngine: onIterationLimit returning a number allows continuation + * - PieceEngine: onIterationLimit receives correct request (currentMovement, maxMovements, currentIteration) + * - StateManager: initialIteration option sets the starting iteration counter + * - PieceEngineOptions: initialIteration passed down to StateManager + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { existsSync, rmSync } from 'node:fs'; +import type { PieceConfig } from '../core/models/index.js'; + +// --- Mock setup (must be before imports that use these modules) --- + +vi.mock('../agents/runner.js', () => ({ + runAgent: vi.fn(), +})); + +vi.mock('../core/piece/evaluation/index.js', () => ({ + detectMatchedRule: vi.fn(), +})); + +vi.mock('../core/piece/phase-runner.js', () => ({ + needsStatusJudgmentPhase: vi.fn().mockReturnValue(false), + runReportPhase: vi.fn().mockResolvedValue(undefined), + runStatusJudgmentPhase: vi.fn().mockResolvedValue({ tag: '', ruleIndex: 0, method: 'auto_select' }), +})); + +vi.mock('../shared/utils/index.js', async (importOriginal) => ({ + ...(await importOriginal>()), + generateReportDir: vi.fn().mockReturnValue('test-report-dir'), +})); + +// --- Imports (after mocks) --- + +import { PieceEngine } from '../core/piece/index.js'; +import { + makeResponse, + makeMovement, + makeRule, + mockRunAgentSequence, + mockDetectMatchedRuleSequence, + createTestTmpDir, + applyDefaultMocks, + cleanupPieceEngine, +} from './engine-test-helpers.js'; + +// --- Tests --- + +describe('PieceEngine: onIterationLimit - exceeded behavior', () => { + let tmpDir: string; + let engine: PieceEngine | null = null; + + beforeEach(() => { + vi.resetAllMocks(); + applyDefaultMocks(); + tmpDir = createTestTmpDir(); + }); + + afterEach(() => { + if (engine) { + cleanupPieceEngine(engine); + engine = null; + } + if (existsSync(tmpDir)) { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('should abort engine when onIterationLimit returns null (non-interactive mode)', async () => { + // Given: a piece with maxMovements=1 and onIterationLimit returning null. + // plan → implement (not COMPLETE) so the limit check fires between plan and implement. + const config: PieceConfig = { + name: 'test', + maxMovements: 1, + initialMovement: 'plan', + movements: [ + makeMovement('plan', { + rules: [makeRule('done', 'implement')], + }), + makeMovement('implement', { + rules: [makeRule('done', 'COMPLETE')], + }), + ], + }; + + const onIterationLimit = vi.fn().mockResolvedValue(null); + + mockRunAgentSequence([ + makeResponse({ persona: 'plan', content: 'Plan complete' }), + ]); + mockDetectMatchedRuleSequence([ + { index: 0, method: 'phase1_tag' }, // plan → implement + ]); + + engine = new PieceEngine(config, tmpDir, 'test task', { + projectCwd: tmpDir, + onIterationLimit, + }); + + // When: engine runs and hits the iteration limit after plan + const state = await engine.run(); + + // Then: engine is aborted (plan ran → iteration=1 >= maxMovements=1, null returned) + expect(state.status).toBe('aborted'); + expect(onIterationLimit).toHaveBeenCalledOnce(); + }); + + it('should continue when onIterationLimit returns a positive number', async () => { + // Given: a piece with maxMovements=1 and onIterationLimit granting more iterations. + // plan → implement so the limit fires between plan and implement. + const config: PieceConfig = { + name: 'test', + maxMovements: 1, + initialMovement: 'plan', + movements: [ + makeMovement('plan', { + rules: [makeRule('done', 'implement')], + }), + makeMovement('implement', { + rules: [makeRule('done', 'COMPLETE')], + }), + ], + }; + + // onIterationLimit called once (at iteration=1), grants 5 more iterations → maxMovements=6 + const onIterationLimit = vi.fn().mockResolvedValueOnce(5); + + mockRunAgentSequence([ + makeResponse({ persona: 'plan', content: 'Plan complete' }), + makeResponse({ persona: 'implement', content: 'Impl done' }), + ]); + mockDetectMatchedRuleSequence([ + { index: 0, method: 'phase1_tag' }, // plan → implement + { index: 0, method: 'phase1_tag' }, // implement → COMPLETE + ]); + + engine = new PieceEngine(config, tmpDir, 'test task', { + projectCwd: tmpDir, + onIterationLimit, + }); + + // When: engine runs + const state = await engine.run(); + + // Then: engine completed because limit was extended (plan+limit check+implement → COMPLETE) + expect(state.status).toBe('completed'); + expect(onIterationLimit).toHaveBeenCalledOnce(); + }); + + it('should pass correct request data to onIterationLimit', async () => { + // Given: a piece with maxMovements=1 + const config: PieceConfig = { + name: 'test', + maxMovements: 1, + initialMovement: 'plan', + movements: [ + makeMovement('plan', { + rules: [makeRule('done', 'implement')], + }), + makeMovement('implement', { + rules: [makeRule('done', 'COMPLETE')], + }), + ], + }; + + const capturedRequest = { currentIteration: 0, maxMovements: 0, currentMovement: '' }; + const onIterationLimit = vi.fn().mockImplementation(async (request: typeof capturedRequest) => { + Object.assign(capturedRequest, request); + return null; + }); + + mockRunAgentSequence([ + makeResponse({ persona: 'plan', content: 'Plan complete' }), + ]); + mockDetectMatchedRuleSequence([ + { index: 0, method: 'phase1_tag' }, // plan → implement + ]); + + engine = new PieceEngine(config, tmpDir, 'test task', { + projectCwd: tmpDir, + onIterationLimit, + }); + + // When: engine runs and hits the iteration limit + await engine.run(); + + // Then: onIterationLimit received correct request data + expect(capturedRequest.currentIteration).toBe(1); + expect(capturedRequest.maxMovements).toBe(1); + // currentMovement is the next movement to run (implement) since plan already ran + expect(capturedRequest.currentMovement).toBe('implement'); + }); + + it('should update maxMovements in engine config when onIterationLimit returns additionalIterations', async () => { + // Given: a piece with maxMovements=2 + const config: PieceConfig = { + name: 'test', + maxMovements: 2, + initialMovement: 'plan', + movements: [ + makeMovement('plan', { + rules: [makeRule('done', 'implement')], + }), + makeMovement('implement', { + rules: [makeRule('done', 'COMPLETE')], + }), + ], + }; + + // Grant 1 more iteration when limit is reached at iteration=2 + const onIterationLimit = vi.fn().mockResolvedValueOnce(1); + + mockRunAgentSequence([ + makeResponse({ persona: 'plan', content: 'Plan' }), + makeResponse({ persona: 'implement', content: 'Impl' }), + // Third movement needed after extension + makeResponse({ persona: 'implement', content: 'Impl done' }), + ]); + mockDetectMatchedRuleSequence([ + { index: 0, method: 'phase1_tag' }, // plan → implement + { index: 0, method: 'phase1_tag' }, // implement → COMPLETE + // This never runs because we complete on the second implement + ]); + + engine = new PieceEngine(config, tmpDir, 'test task', { + projectCwd: tmpDir, + onIterationLimit, + }); + + // When: engine runs + const state = await engine.run(); + + // Then: completed since limit was extended by 1 (2 → 3) + expect(state.status).toBe('completed'); + expect(state.iteration).toBe(2); + }); + + it('should emit iteration:limit event before calling onIterationLimit', async () => { + // Given: a piece with maxMovements=1 and plan → implement so the limit fires. + const config: PieceConfig = { + name: 'test', + maxMovements: 1, + initialMovement: 'plan', + movements: [ + makeMovement('plan', { + rules: [makeRule('done', 'implement')], + }), + makeMovement('implement', { + rules: [makeRule('done', 'COMPLETE')], + }), + ], + }; + + const onIterationLimit = vi.fn().mockResolvedValue(null); + const eventOrder: string[] = []; + + mockRunAgentSequence([ + makeResponse({ persona: 'plan', content: 'Plan complete' }), + ]); + mockDetectMatchedRuleSequence([ + { index: 0, method: 'phase1_tag' }, // plan → implement + ]); + + engine = new PieceEngine(config, tmpDir, 'test task', { + projectCwd: tmpDir, + onIterationLimit: async (request) => { + eventOrder.push('onIterationLimit'); + return onIterationLimit(request); + }, + }); + + engine.on('iteration:limit', () => { + eventOrder.push('iteration:limit'); + }); + + // When: engine runs + await engine.run(); + + // Then: iteration:limit event emitted before onIterationLimit callback + expect(eventOrder).toEqual(['iteration:limit', 'onIterationLimit']); + }); +}); + +describe('PieceEngine: initialIteration option', () => { + let tmpDir: string; + let engine: PieceEngine | null = null; + + beforeEach(() => { + vi.resetAllMocks(); + applyDefaultMocks(); + tmpDir = createTestTmpDir(); + }); + + afterEach(() => { + if (engine) { + cleanupPieceEngine(engine); + engine = null; + } + if (existsSync(tmpDir)) { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('should start iteration counter from initialIteration value', async () => { + // Given: a piece with maxMovements=60 and initialIteration=30 + const config: PieceConfig = { + name: 'test', + maxMovements: 60, + initialMovement: 'plan', + movements: [ + makeMovement('plan', { + rules: [makeRule('done', 'COMPLETE')], + }), + ], + }; + + mockRunAgentSequence([ + makeResponse({ persona: 'plan', content: 'Plan complete' }), + ]); + mockDetectMatchedRuleSequence([ + { index: 0, method: 'phase1_tag' }, + ]); + + engine = new PieceEngine(config, tmpDir, 'test task', { + projectCwd: tmpDir, + initialIteration: 30, + }); + + // When: engine runs one step + const state = await engine.run(); + + // Then: iteration is 31 (30 + 1 step) + expect(state.status).toBe('completed'); + expect(state.iteration).toBe(31); + }); + + it('should start from 0 when initialIteration is not provided', async () => { + // Given: a piece without initialIteration + const config: PieceConfig = { + name: 'test', + maxMovements: 60, + initialMovement: 'plan', + movements: [ + makeMovement('plan', { + rules: [makeRule('done', 'COMPLETE')], + }), + ], + }; + + mockRunAgentSequence([ + makeResponse({ persona: 'plan', content: 'Plan complete' }), + ]); + mockDetectMatchedRuleSequence([ + { index: 0, method: 'phase1_tag' }, + ]); + + engine = new PieceEngine(config, tmpDir, 'test task', { + projectCwd: tmpDir, + }); + + // When: engine runs one step + const state = await engine.run(); + + // Then: iteration is 1 (0 + 1 step) + expect(state.status).toBe('completed'); + expect(state.iteration).toBe(1); + }); + + it('should trigger iteration limit immediately when initialIteration >= maxMovements', async () => { + // Given: initialIteration=30, maxMovements=30 (already at limit on first check) + const config: PieceConfig = { + name: 'test', + maxMovements: 30, + initialMovement: 'plan', + movements: [ + makeMovement('plan', { + rules: [makeRule('done', 'COMPLETE')], + }), + ], + }; + + const onIterationLimit = vi.fn().mockResolvedValue(null); + + engine = new PieceEngine(config, tmpDir, 'test task', { + projectCwd: tmpDir, + initialIteration: 30, + onIterationLimit, + }); + + // When: engine runs + const state = await engine.run(); + + // Then: iteration limit handler is called immediately (no movements executed) + expect(onIterationLimit).toHaveBeenCalledOnce(); + expect(onIterationLimit).toHaveBeenCalledWith(expect.objectContaining({ + currentIteration: 30, + maxMovements: 30, + currentMovement: 'plan', + })); + expect(state.status).toBe('aborted'); + }); + + it('should emit iteration:limit with correct count when initialIteration is set', async () => { + // Given: initialIteration=30, maxMovements=31 (one step before limit) + const config: PieceConfig = { + name: 'test', + maxMovements: 31, + initialMovement: 'plan', + movements: [ + makeMovement('plan', { + rules: [makeRule('done', 'implement')], + }), + makeMovement('implement', { + rules: [makeRule('done', 'COMPLETE')], + }), + ], + }; + + const limitEvents: { iteration: number; maxMovements: number }[] = []; + + const onIterationLimit = vi.fn().mockResolvedValue(null); + + mockRunAgentSequence([ + makeResponse({ persona: 'plan', content: 'Plan' }), + ]); + mockDetectMatchedRuleSequence([ + { index: 0, method: 'phase1_tag' }, // plan → implement + ]); + + engine = new PieceEngine(config, tmpDir, 'test task', { + projectCwd: tmpDir, + initialIteration: 30, + onIterationLimit, + }); + + engine.on('iteration:limit', (iteration, maxMovements) => { + limitEvents.push({ iteration, maxMovements }); + }); + + // When: engine runs + await engine.run(); + + // Then: limit event emitted with correct counts + // After plan runs, iteration = 31 >= maxMovements=31, so limit is reached + expect(limitEvents).toHaveLength(1); + expect(limitEvents[0]!.iteration).toBe(31); + expect(limitEvents[0]!.maxMovements).toBe(31); + }); +}); diff --git a/src/__tests__/listNonInteractive-completedActions.test.ts b/src/__tests__/listNonInteractive-completedActions.test.ts index 78603408..ef45223d 100644 --- a/src/__tests__/listNonInteractive-completedActions.test.ts +++ b/src/__tests__/listNonInteractive-completedActions.test.ts @@ -1,13 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const { - mockDeleteCompletedTask, + mockDeleteTask, mockListAllTaskItems, mockMergeBranch, mockDeleteBranch, mockInfo, } = vi.hoisted(() => ({ - mockDeleteCompletedTask: vi.fn(), + mockDeleteTask: vi.fn(), mockListAllTaskItems: vi.fn(), mockMergeBranch: vi.fn(), mockDeleteBranch: vi.fn(), @@ -20,8 +20,8 @@ vi.mock('../infra/task/index.js', () => ({ listAllTaskItems() { return mockListAllTaskItems(); } - deleteCompletedTask(name: string) { - mockDeleteCompletedTask(name); + deleteTask(name: string, kind: string) { + mockDeleteTask(name, kind); } }, })); @@ -64,7 +64,7 @@ describe('listTasksNonInteractive completed actions', () => { }); expect(mockMergeBranch).toHaveBeenCalled(); - expect(mockDeleteCompletedTask).toHaveBeenCalledWith('completed-task'); + expect(mockDeleteTask).toHaveBeenCalledWith('completed-task', 'completed'); }); it('should delete completed record after delete action', async () => { @@ -78,6 +78,6 @@ describe('listTasksNonInteractive completed actions', () => { }); expect(mockDeleteBranch).toHaveBeenCalled(); - expect(mockDeleteCompletedTask).toHaveBeenCalledWith('completed-task'); + expect(mockDeleteTask).toHaveBeenCalledWith('completed-task', 'completed'); }); }); diff --git a/src/__tests__/listTasksInteractivePendingLabel.test.ts b/src/__tests__/listTasksInteractivePendingLabel.test.ts index 5de745b2..b1f549ae 100644 --- a/src/__tests__/listTasksInteractivePendingLabel.test.ts +++ b/src/__tests__/listTasksInteractivePendingLabel.test.ts @@ -45,9 +45,8 @@ vi.mock('../features/tasks/list/taskActions.js', () => ({ })); vi.mock('../features/tasks/list/taskDeleteActions.js', () => ({ - deletePendingTask: mockDeletePendingTask, - deleteFailedTask: vi.fn(), - deleteCompletedTask: vi.fn(), + deleteTaskByKind: mockDeletePendingTask, + deleteAllTasks: vi.fn(), })); vi.mock('../features/tasks/list/taskRetryActions.js', () => ({ diff --git a/src/__tests__/listTasksInteractiveStatusActions.test.ts b/src/__tests__/listTasksInteractiveStatusActions.test.ts index 98b27307..be8dc544 100644 --- a/src/__tests__/listTasksInteractiveStatusActions.test.ts +++ b/src/__tests__/listTasksInteractiveStatusActions.test.ts @@ -7,20 +7,22 @@ const { mockInfo, mockBlankLine, mockListAllTaskItems, - mockDeleteCompletedRecord, + mockDeleteTask, mockShowDiffAndPromptActionForTask, mockMergeBranch, mockDeleteCompletedTask, + mockRequeueExceededTask, } = vi.hoisted(() => ({ mockSelectOption: vi.fn(), mockHeader: vi.fn(), mockInfo: vi.fn(), mockBlankLine: vi.fn(), mockListAllTaskItems: vi.fn(), - mockDeleteCompletedRecord: vi.fn(), + mockDeleteTask: vi.fn(), mockShowDiffAndPromptActionForTask: vi.fn(), mockMergeBranch: vi.fn(), mockDeleteCompletedTask: vi.fn(), + mockRequeueExceededTask: vi.fn(), })); vi.mock('../infra/task/index.js', () => ({ @@ -28,8 +30,11 @@ vi.mock('../infra/task/index.js', () => ({ listAllTaskItems() { return mockListAllTaskItems(); } - deleteCompletedTask(name: string) { - mockDeleteCompletedRecord(name); + deleteTask(name: string, kind: string) { + mockDeleteTask(name, kind); + } + requeueExceededTask(name: string) { + mockRequeueExceededTask(name); } }, })); @@ -54,9 +59,8 @@ vi.mock('../features/tasks/list/taskActions.js', () => ({ })); vi.mock('../features/tasks/list/taskDeleteActions.js', () => ({ - deletePendingTask: vi.fn(), - deleteFailedTask: vi.fn(), - deleteCompletedTask: mockDeleteCompletedTask, + deleteTaskByKind: mockDeleteCompletedTask, + deleteAllTasks: vi.fn(), })); vi.mock('../features/tasks/list/taskRetryActions.js', () => ({ @@ -88,6 +92,16 @@ const completedTaskWithoutBranch: TaskListItem = { name: 'completed-without-branch', }; +const exceededTask: TaskListItem = { + kind: 'exceeded', + name: 'exceeded-task', + createdAt: '2026-02-14T00:00:00.000Z', + filePath: '/project/.takt/tasks.yaml', + content: 'iteration limit reached', + exceededMaxMovements: 60, + exceededCurrentIteration: 30, +}; + describe('listTasks interactive status actions', () => { beforeEach(() => { vi.clearAllMocks(); @@ -129,7 +143,7 @@ describe('listTasks interactive status actions', () => { await listTasks('/project'); expect(mockMergeBranch).toHaveBeenCalledWith('/project', completedTaskWithBranch); - expect(mockDeleteCompletedRecord).toHaveBeenCalledWith('completed-task'); + expect(mockDeleteTask).toHaveBeenCalledWith('completed-task', 'completed'); }); it('completed delete 選択時は deleteCompletedTask を呼ぶ', async () => { @@ -142,6 +156,47 @@ describe('listTasks interactive status actions', () => { await listTasks('/project'); expect(mockDeleteCompletedTask).toHaveBeenCalledWith(completedTaskWithBranch); - expect(mockDeleteCompletedRecord).not.toHaveBeenCalled(); + expect(mockDeleteTask).not.toHaveBeenCalled(); + }); + + describe('exceeded status action handling', () => { + it('exceeded requeue 選択時は requeueExceededTask を呼ぶ', async () => { + mockListAllTaskItems.mockReturnValue([exceededTask]); + mockSelectOption + .mockResolvedValueOnce('exceeded:0') + .mockResolvedValueOnce('requeue') + .mockResolvedValueOnce(null); + + await listTasks('/project'); + + expect(mockRequeueExceededTask).toHaveBeenCalledWith('exceeded-task'); + expect(mockDeleteCompletedTask).not.toHaveBeenCalled(); + }); + + it('exceeded delete 選択時は deleteTaskByKind を呼ぶ', async () => { + mockListAllTaskItems.mockReturnValue([exceededTask]); + mockSelectOption + .mockResolvedValueOnce('exceeded:0') + .mockResolvedValueOnce('delete') + .mockResolvedValueOnce(null); + + await listTasks('/project'); + + expect(mockDeleteCompletedTask).toHaveBeenCalledWith(exceededTask); + expect(mockRequeueExceededTask).not.toHaveBeenCalled(); + }); + + it('exceeded でキャンセル選択時は何も呼ばれない', async () => { + mockListAllTaskItems.mockReturnValue([exceededTask]); + mockSelectOption + .mockResolvedValueOnce('exceeded:0') + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null); + + await listTasks('/project'); + + expect(mockRequeueExceededTask).not.toHaveBeenCalled(); + expect(mockDeleteCompletedTask).not.toHaveBeenCalled(); + }); }); }); diff --git a/src/__tests__/task-delete-task.test.ts b/src/__tests__/task-delete-task.test.ts new file mode 100644 index 00000000..bc99af3f --- /dev/null +++ b/src/__tests__/task-delete-task.test.ts @@ -0,0 +1,150 @@ +/** + * Unit tests for TaskRunner.deleteTask (generic delete by kind) + * + * Covers: + * - deleteTask('name', 'pending') → pending task removed + * - deleteTask('name', 'failed') → failed task removed + * - deleteTask('name', 'completed') → completed task removed + * - deleteTask('name', 'exceeded') → exceeded task removed + * - Error when task does not exist + * - Error when kind does not match actual task status + * - Sibling tasks are not affected + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, existsSync, rmSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; +import { TaskRunner } from '../infra/task/runner.js'; + +function loadTasksFile(testDir: string): { tasks: Array> } { + const raw = readFileSync(join(testDir, '.takt', 'tasks.yaml'), 'utf-8'); + return parseYaml(raw) as { tasks: Array> }; +} + +function writeRecord(testDir: string, record: Record): void { + mkdirSync(join(testDir, '.takt'), { recursive: true }); + writeFileSync( + join(testDir, '.takt', 'tasks.yaml'), + stringifyYaml({ tasks: [record] }), + 'utf-8', + ); +} + +describe('TaskRunner - deleteTask', () => { + const testDir = `/tmp/takt-delete-task-test-${Date.now()}`; + let runner: TaskRunner; + + beforeEach(() => { + mkdirSync(testDir, { recursive: true }); + runner = new TaskRunner(testDir); + }); + + afterEach(() => { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + it('should delete a pending task by kind', () => { + // Given: a pending task + runner.addTask('Task A'); + const taskName = (loadTasksFile(testDir).tasks[0] as Record).name as string; + + // When: deleteTask is called with kind 'pending' + runner.deleteTask(taskName, 'pending'); + + // Then: task is removed from the store + expect(loadTasksFile(testDir).tasks).toHaveLength(0); + }); + + it('should delete a failed task by kind', () => { + // Given: a failed task written directly to YAML + writeRecord(testDir, { + name: 'task-a', + status: 'failed', + content: 'Do work', + created_at: '2026-01-01T00:00:00.000Z', + started_at: '2026-01-01T00:01:00.000Z', + completed_at: '2026-01-01T00:05:00.000Z', + owner_pid: null, + failure: { error: 'Something went wrong' }, + }); + + // When: deleteTask is called with kind 'failed' + runner.deleteTask('task-a', 'failed'); + + // Then: task is removed from the store + expect(loadTasksFile(testDir).tasks).toHaveLength(0); + }); + + it('should delete a completed task by kind', () => { + // Given: a completed task written directly to YAML + writeRecord(testDir, { + name: 'task-a', + status: 'completed', + content: 'Do work', + created_at: '2026-01-01T00:00:00.000Z', + started_at: '2026-01-01T00:01:00.000Z', + completed_at: '2026-01-01T00:05:00.000Z', + owner_pid: null, + }); + + // When: deleteTask is called with kind 'completed' + runner.deleteTask('task-a', 'completed'); + + // Then: task is removed from the store + expect(loadTasksFile(testDir).tasks).toHaveLength(0); + }); + + it('should delete an exceeded task by kind', () => { + // Given: an exceeded task written directly to YAML + writeRecord(testDir, { + name: 'task-a', + status: 'exceeded', + content: 'Do work', + created_at: '2026-01-01T00:00:00.000Z', + started_at: '2026-01-01T00:01:00.000Z', + completed_at: '2026-01-01T00:05:00.000Z', + owner_pid: null, + start_movement: 'implement', + exceeded_max_movements: 60, + exceeded_current_iteration: 30, + }); + + // When: deleteTask is called with kind 'exceeded' + runner.deleteTask('task-a', 'exceeded'); + + // Then: task is removed from the store + expect(loadTasksFile(testDir).tasks).toHaveLength(0); + }); + + it('should throw when task does not exist', () => { + // Given: no tasks in the store + // When/Then: deleteTask throws with not-found error + expect(() => runner.deleteTask('nonexistent', 'pending')).toThrow(/not found/i); + }); + + it('should throw when kind does not match the actual task status', () => { + // Given: a pending task + runner.addTask('Task A'); + const taskName = (loadTasksFile(testDir).tasks[0] as Record).name as string; + + // When: deleteTask is called with wrong kind ('failed' instead of 'pending') + // Then: throws because no running task with that name exists under 'failed' status + expect(() => runner.deleteTask(taskName, 'failed')).toThrow(/not found/i); + }); + + it('should not affect sibling tasks when deleting one task', () => { + // Given: two pending tasks + runner.addTask('Task A'); + runner.addTask('Task B'); + const taskName = (loadTasksFile(testDir).tasks[0] as Record).name as string; + + // When: deleteTask is called for the first task + runner.deleteTask(taskName, 'pending'); + + // Then: only the targeted task is removed; sibling remains + expect(loadTasksFile(testDir).tasks).toHaveLength(1); + }); +}); diff --git a/src/__tests__/task-exceed-service.test.ts b/src/__tests__/task-exceed-service.test.ts new file mode 100644 index 00000000..b16ca8e4 --- /dev/null +++ b/src/__tests__/task-exceed-service.test.ts @@ -0,0 +1,482 @@ +/** + * Unit tests for task exceed/requeue operations + * + * Covers: + * - exceedTask: transitions running task to exceeded status with metadata + * - requeueExceededTask: transitions exceeded task back to pending, preserving metadata + * - deleteTask('exceeded'): removes exceeded task from the store + * - listExceededTasks: returns exceeded tasks as TaskListItem list + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, existsSync, rmSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; +import { TaskRunner } from '../infra/task/runner.js'; + +function loadTasksFile(testDir: string): { tasks: Array> } { + const raw = readFileSync(join(testDir, '.takt', 'tasks.yaml'), 'utf-8'); + return parseYaml(raw) as { tasks: Array> }; +} + +function writeExceededRecord(testDir: string, overrides: Record = {}): void { + mkdirSync(join(testDir, '.takt'), { recursive: true }); + const record = { + name: 'task-a', + status: 'exceeded', + content: 'Do work', + created_at: '2026-02-09T00:00:00.000Z', + started_at: '2026-02-09T00:01:00.000Z', + completed_at: '2026-02-09T00:05:00.000Z', + owner_pid: null, + start_movement: 'implement', + exceeded_max_movements: 60, + exceeded_current_iteration: 30, + ...overrides, + }; + writeFileSync( + join(testDir, '.takt', 'tasks.yaml'), + stringifyYaml({ tasks: [record] }), + 'utf-8', + ); +} + +describe('TaskRunner - exceedTask', () => { + const testDir = `/tmp/takt-exceed-test-${Date.now()}`; + let runner: TaskRunner; + + beforeEach(() => { + mkdirSync(testDir, { recursive: true }); + runner = new TaskRunner(testDir); + }); + + afterEach(() => { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + it('should transition a running task to exceeded status', () => { + // Given: a running task + runner.addTask('Task A'); + runner.claimNextTasks(1); + + const beforeFile = loadTasksFile(testDir); + const runningTask = beforeFile.tasks[0]!; + const taskName = runningTask.name as string; + + // When: exceedTask is called + runner.exceedTask(taskName, { + currentMovement: 'implement', + newMaxMovements: 60, + currentIteration: 30, + }); + + // Then: task is now exceeded + const afterFile = loadTasksFile(testDir); + const exceededTask = afterFile.tasks[0]!; + expect(exceededTask.status).toBe('exceeded'); + }); + + it('should preserve started_at from the running state', () => { + // Given: a running task + runner.addTask('Task A'); + runner.claimNextTasks(1); + + const beforeFile = loadTasksFile(testDir); + const runningTask = beforeFile.tasks[0]!; + const taskName = runningTask.name as string; + const originalStartedAt = runningTask.started_at as string; + + // When: exceedTask is called + runner.exceedTask(taskName, { + currentMovement: 'plan', + newMaxMovements: 60, + currentIteration: 30, + }); + + // Then: started_at is preserved from running state + const afterFile = loadTasksFile(testDir); + const exceededTask = afterFile.tasks[0]!; + expect(exceededTask.started_at).toBe(originalStartedAt); + }); + + it('should set completed_at to a non-null timestamp', () => { + // Given: a running task + runner.addTask('Task A'); + runner.claimNextTasks(1); + const taskName = (loadTasksFile(testDir).tasks[0] as Record).name as string; + + // When: exceedTask is called + runner.exceedTask(taskName, { + currentMovement: 'plan', + newMaxMovements: 60, + currentIteration: 30, + }); + + // Then: completed_at is set + const afterFile = loadTasksFile(testDir); + const exceededTask = afterFile.tasks[0]!; + expect(exceededTask.completed_at).toBeTruthy(); + expect(typeof exceededTask.completed_at).toBe('string'); + }); + + it('should clear owner_pid', () => { + // Given: a running task (has owner_pid) + runner.addTask('Task A'); + runner.claimNextTasks(1); + const taskName = (loadTasksFile(testDir).tasks[0] as Record).name as string; + + // When: exceedTask is called + runner.exceedTask(taskName, { + currentMovement: 'plan', + newMaxMovements: 60, + currentIteration: 30, + }); + + // Then: owner_pid is null + const afterFile = loadTasksFile(testDir); + const exceededTask = afterFile.tasks[0]!; + expect(exceededTask.owner_pid).toBeNull(); + }); + + it('should record the current movement as start_movement', () => { + // Given: a running task + runner.addTask('Task A'); + runner.claimNextTasks(1); + const taskName = (loadTasksFile(testDir).tasks[0] as Record).name as string; + + // When: exceedTask is called with currentMovement = 'reviewers' + runner.exceedTask(taskName, { + currentMovement: 'reviewers', + newMaxMovements: 60, + currentIteration: 30, + }); + + // Then: start_movement is set to 'reviewers' + const afterFile = loadTasksFile(testDir); + const exceededTask = afterFile.tasks[0]!; + expect(exceededTask.start_movement).toBe('reviewers'); + }); + + it('should record exceeded_max_movements', () => { + // Given: a running task + runner.addTask('Task A'); + runner.claimNextTasks(1); + const taskName = (loadTasksFile(testDir).tasks[0] as Record).name as string; + + // When: exceedTask is called with newMaxMovements = 60 + runner.exceedTask(taskName, { + currentMovement: 'plan', + newMaxMovements: 60, + currentIteration: 30, + }); + + // Then: exceeded_max_movements is 60 + const afterFile = loadTasksFile(testDir); + const exceededTask = afterFile.tasks[0]!; + expect(exceededTask.exceeded_max_movements).toBe(60); + }); + + it('should record exceeded_current_iteration', () => { + // Given: a running task + runner.addTask('Task A'); + runner.claimNextTasks(1); + const taskName = (loadTasksFile(testDir).tasks[0] as Record).name as string; + + // When: exceedTask is called with currentIteration = 30 + runner.exceedTask(taskName, { + currentMovement: 'plan', + newMaxMovements: 60, + currentIteration: 30, + }); + + // Then: exceeded_current_iteration is 30 + const afterFile = loadTasksFile(testDir); + const exceededTask = afterFile.tasks[0]!; + expect(exceededTask.exceeded_current_iteration).toBe(30); + }); + + it('should throw when task is not found', () => { + // Given: no task exists + // When/Then: exceedTask throws + expect(() => runner.exceedTask('nonexistent-task', { + currentMovement: 'plan', + newMaxMovements: 60, + currentIteration: 30, + })).toThrow(/not found/i); + }); + + it('should throw when task is pending (not running)', () => { + // Given: a pending task (not yet claimed) + runner.addTask('Task A'); + const taskName = (loadTasksFile(testDir).tasks[0] as Record).name as string; + + // When/Then: exceedTask throws for pending task + expect(() => runner.exceedTask(taskName, { + currentMovement: 'plan', + newMaxMovements: 60, + currentIteration: 0, + })).toThrow(/not found/i); + }); +}); + +describe('TaskRunner - requeueExceededTask', () => { + const testDir = `/tmp/takt-requeue-exceeded-test-${Date.now()}`; + let runner: TaskRunner; + + beforeEach(() => { + mkdirSync(testDir, { recursive: true }); + runner = new TaskRunner(testDir); + }); + + afterEach(() => { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + it('should transition exceeded task to pending', () => { + // Given: an exceeded task in the store + writeExceededRecord(testDir, { name: 'task-a' }); + + // When: requeueExceededTask is called + runner.requeueExceededTask('task-a'); + + // Then: task is now pending + const file = loadTasksFile(testDir); + expect(file.tasks[0]?.status).toBe('pending'); + }); + + it('should clear started_at after requeue', () => { + // Given: an exceeded task (has started_at from execution) + writeExceededRecord(testDir, { name: 'task-a' }); + + // When: requeueExceededTask is called + runner.requeueExceededTask('task-a'); + + // Then: started_at is null + const file = loadTasksFile(testDir); + expect(file.tasks[0]?.started_at).toBeNull(); + }); + + it('should clear completed_at after requeue', () => { + // Given: an exceeded task (has completed_at from exceed time) + writeExceededRecord(testDir, { name: 'task-a' }); + + // When: requeueExceededTask is called + runner.requeueExceededTask('task-a'); + + // Then: completed_at is null + const file = loadTasksFile(testDir); + expect(file.tasks[0]?.completed_at).toBeNull(); + }); + + it('should clear owner_pid after requeue', () => { + // Given: an exceeded task + writeExceededRecord(testDir, { name: 'task-a' }); + + // When: requeueExceededTask is called + runner.requeueExceededTask('task-a'); + + // Then: owner_pid is null + const file = loadTasksFile(testDir); + expect(file.tasks[0]?.owner_pid).toBeNull(); + }); + + it('should preserve exceeded_max_movements for continuation', () => { + // Given: an exceeded task with exceeded_max_movements = 60 + writeExceededRecord(testDir, { + name: 'task-a', + exceeded_max_movements: 60, + exceeded_current_iteration: 30, + }); + + // When: requeueExceededTask is called + runner.requeueExceededTask('task-a'); + + // Then: exceeded_max_movements is preserved (used by resolveTaskExecution) + const file = loadTasksFile(testDir); + expect(file.tasks[0]?.exceeded_max_movements).toBe(60); + }); + + it('should preserve exceeded_current_iteration for continuation', () => { + // Given: an exceeded task with exceeded_current_iteration = 30 + writeExceededRecord(testDir, { + name: 'task-a', + exceeded_current_iteration: 30, + }); + + // When: requeueExceededTask is called + runner.requeueExceededTask('task-a'); + + // Then: exceeded_current_iteration is preserved + const file = loadTasksFile(testDir); + expect(file.tasks[0]?.exceeded_current_iteration).toBe(30); + }); + + it('should preserve start_movement for re-entry point', () => { + // Given: an exceeded task with start_movement = 'reviewers' + writeExceededRecord(testDir, { + name: 'task-a', + start_movement: 'reviewers', + }); + + // When: requeueExceededTask is called + runner.requeueExceededTask('task-a'); + + // Then: start_movement is preserved + const file = loadTasksFile(testDir); + expect(file.tasks[0]?.start_movement).toBe('reviewers'); + }); + + it('should throw when task is not in exceeded status', () => { + // Given: a pending task (not exceeded) + runner.addTask('Task A'); + const taskName = (loadTasksFile(testDir).tasks[0] as Record).name as string; + + // When/Then: requeueExceededTask throws + expect(() => runner.requeueExceededTask(taskName)).toThrow(/not found/i); + }); + + it('should throw when task does not exist', () => { + // Given: no task exists + // When/Then: requeueExceededTask throws + expect(() => runner.requeueExceededTask('nonexistent-task')).toThrow(/not found/i); + }); + + it('should not affect other tasks in the store', () => { + // Given: one exceeded and one pending task + // writeExceededRecord must come first because it overwrites tasks.yaml; + // addTask then reads and appends to the file. + writeExceededRecord(testDir, { name: 'task-a' }); + runner.addTask('Task B'); + + const initialFile = loadTasksFile(testDir); + const pendingTask = initialFile.tasks.find((t) => t.status === 'pending'); + expect(pendingTask).toBeDefined(); + + // When: requeueExceededTask is called for task-a + runner.requeueExceededTask('task-a'); + + // Then: the other task is unaffected + const afterFile = loadTasksFile(testDir); + const stillPending = afterFile.tasks.find((t) => (t.name as string).includes('task-b')); + expect(stillPending?.status).toBe('pending'); + }); +}); + +describe('TaskRunner - deleteTask (exceeded)', () => { + const testDir = `/tmp/takt-delete-exceeded-test-${Date.now()}`; + let runner: TaskRunner; + + beforeEach(() => { + mkdirSync(testDir, { recursive: true }); + runner = new TaskRunner(testDir); + }); + + afterEach(() => { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + it('should delete an exceeded task', () => { + // Given: an exceeded task + writeExceededRecord(testDir, { name: 'task-a' }); + + // When: deleteTask is called + runner.deleteTask('task-a', 'exceeded'); + + // Then: task is removed + const file = loadTasksFile(testDir); + expect(file.tasks).toHaveLength(0); + }); + + it('should throw when task is not in exceeded status', () => { + // Given: a pending task + runner.addTask('Task A'); + const taskName = (loadTasksFile(testDir).tasks[0] as Record).name as string; + + // When/Then: deleteTask throws + expect(() => runner.deleteTask(taskName, 'exceeded')).toThrow(/not found/i); + }); + + it('should throw when task does not exist', () => { + // Given: no task exists + // When/Then: deleteTask throws + expect(() => runner.deleteTask('nonexistent-task', 'exceeded')).toThrow(/not found/i); + }); +}); + +describe('TaskRunner - listExceededTasks', () => { + const testDir = `/tmp/takt-list-exceeded-test-${Date.now()}`; + let runner: TaskRunner; + + beforeEach(() => { + mkdirSync(testDir, { recursive: true }); + runner = new TaskRunner(testDir); + }); + + afterEach(() => { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + it('should return exceeded tasks as TaskListItems with exceeded kind', () => { + // Given: an exceeded task + writeExceededRecord(testDir, { name: 'task-a' }); + + // When: listExceededTasks is called + const exceeded = runner.listExceededTasks(); + + // Then: one item with kind 'exceeded' + expect(exceeded).toHaveLength(1); + expect(exceeded[0]?.kind).toBe('exceeded'); + expect(exceeded[0]?.name).toBe('task-a'); + }); + + it('should return empty array when no exceeded tasks exist', () => { + // Given: only pending tasks + runner.addTask('Task A'); + + // When: listExceededTasks is called + const exceeded = runner.listExceededTasks(); + + // Then: empty array + expect(exceeded).toHaveLength(0); + }); + + it('should not include non-exceeded tasks', () => { + // Given: one exceeded and one pending task + // writeExceededRecord must come first because it overwrites tasks.yaml; + // addTask then reads and appends to the file. + writeExceededRecord(testDir, { name: 'task-a' }); + runner.addTask('Task B'); + + // When: listExceededTasks is called + const exceeded = runner.listExceededTasks(); + + // Then: only the exceeded task + expect(exceeded).toHaveLength(1); + expect(exceeded[0]?.name).toBe('task-a'); + }); + + it('should expose exceeded metadata in data field', () => { + // Given: an exceeded task with metadata + writeExceededRecord(testDir, { + name: 'task-a', + exceeded_max_movements: 60, + exceeded_current_iteration: 30, + }); + + // When: listExceededTasks is called + const exceeded = runner.listExceededTasks(); + + // Then: metadata is accessible via data + const task = exceeded[0]!; + expect(task.data?.exceeded_max_movements).toBe(60); + expect(task.data?.exceeded_current_iteration).toBe(30); + }); +}); diff --git a/src/__tests__/task-schema-exceeded.test.ts b/src/__tests__/task-schema-exceeded.test.ts new file mode 100644 index 00000000..7be133f8 --- /dev/null +++ b/src/__tests__/task-schema-exceeded.test.ts @@ -0,0 +1,179 @@ +/** + * Unit tests for `exceeded` status schema validation + * + * Covers: + * - TaskRecordSchema cross-field validation for `exceeded` status + * - TaskExecutionConfigSchema new fields: exceeded_max_movements, exceeded_current_iteration + */ + +import { describe, it, expect } from 'vitest'; +import { + TaskRecordSchema, + TaskExecutionConfigSchema, + TaskStatusSchema, +} from '../infra/task/schema.js'; + +function makeExceededRecord(overrides: Record = {}): Record { + return { + name: 'test-task', + status: 'exceeded', + content: 'task content', + created_at: '2025-01-01T00:00:00.000Z', + started_at: '2025-01-01T01:00:00.000Z', + completed_at: '2025-01-01T02:00:00.000Z', + start_movement: 'plan', + exceeded_max_movements: 60, + exceeded_current_iteration: 30, + ...overrides, + }; +} + +describe('TaskStatusSchema', () => { + it('should accept exceeded as a valid status', () => { + expect(() => TaskStatusSchema.parse('exceeded')).not.toThrow(); + }); + + it('should still accept all existing statuses', () => { + expect(() => TaskStatusSchema.parse('pending')).not.toThrow(); + expect(() => TaskStatusSchema.parse('running')).not.toThrow(); + expect(() => TaskStatusSchema.parse('completed')).not.toThrow(); + expect(() => TaskStatusSchema.parse('failed')).not.toThrow(); + }); + + it('should reject unknown status', () => { + expect(() => TaskStatusSchema.parse('unknown')).toThrow(); + }); +}); + +describe('TaskExecutionConfigSchema - exceeded fields', () => { + it('should accept exceeded_max_movements as a positive integer', () => { + expect(() => TaskExecutionConfigSchema.parse({ exceeded_max_movements: 60 })).not.toThrow(); + }); + + it('should accept exceeded_current_iteration as a non-negative integer', () => { + expect(() => TaskExecutionConfigSchema.parse({ exceeded_current_iteration: 30 })).not.toThrow(); + }); + + it('should accept exceeded_current_iteration as zero', () => { + expect(() => TaskExecutionConfigSchema.parse({ exceeded_current_iteration: 0 })).not.toThrow(); + }); + + it('should accept both fields together', () => { + expect(() => TaskExecutionConfigSchema.parse({ + exceeded_max_movements: 60, + exceeded_current_iteration: 30, + })).not.toThrow(); + }); + + it('should accept config without exceeded fields (optional)', () => { + expect(() => TaskExecutionConfigSchema.parse({})).not.toThrow(); + }); + + it('should reject exceeded_max_movements as zero', () => { + expect(() => TaskExecutionConfigSchema.parse({ exceeded_max_movements: 0 })).toThrow(); + }); + + it('should reject exceeded_max_movements as negative', () => { + expect(() => TaskExecutionConfigSchema.parse({ exceeded_max_movements: -1 })).toThrow(); + }); + + it('should reject exceeded_max_movements as non-integer', () => { + expect(() => TaskExecutionConfigSchema.parse({ exceeded_max_movements: 1.5 })).toThrow(); + }); + + it('should reject exceeded_current_iteration as negative', () => { + expect(() => TaskExecutionConfigSchema.parse({ exceeded_current_iteration: -1 })).toThrow(); + }); + + it('should reject exceeded_current_iteration as non-integer', () => { + expect(() => TaskExecutionConfigSchema.parse({ exceeded_current_iteration: 0.5 })).toThrow(); + }); +}); + +describe('TaskRecordSchema - exceeded status', () => { + describe('valid exceeded record', () => { + it('should accept a valid exceeded record with all required fields', () => { + expect(() => TaskRecordSchema.parse(makeExceededRecord())).not.toThrow(); + }); + + it('should accept exceeded record without start_movement (optional)', () => { + const record = makeExceededRecord({ start_movement: undefined }); + expect(() => TaskRecordSchema.parse(record)).not.toThrow(); + }); + + it('should reject exceeded record with only exceeded_current_iteration set (exceeded_max_movements missing)', () => { + const record = makeExceededRecord({ exceeded_max_movements: undefined }); + expect(() => TaskRecordSchema.parse(record)).toThrow(); + }); + + it('should reject exceeded record with only exceeded_max_movements set (exceeded_current_iteration missing)', () => { + const record = makeExceededRecord({ exceeded_current_iteration: undefined }); + expect(() => TaskRecordSchema.parse(record)).toThrow(); + }); + + it('should accept exceeded record when both exceeded fields are absent (neither field set)', () => { + const record = makeExceededRecord({ exceeded_max_movements: undefined, exceeded_current_iteration: undefined }); + expect(() => TaskRecordSchema.parse(record)).not.toThrow(); + }); + }); + + describe('started_at requirement', () => { + it('should reject exceeded record without started_at (null)', () => { + const record = makeExceededRecord({ started_at: null }); + expect(() => TaskRecordSchema.parse(record)).toThrow(); + }); + }); + + describe('completed_at requirement', () => { + it('should reject exceeded record without completed_at (null)', () => { + const record = makeExceededRecord({ completed_at: null }); + expect(() => TaskRecordSchema.parse(record)).toThrow(); + }); + }); + + describe('failure prohibition', () => { + it('should reject exceeded record with failure field', () => { + const record = makeExceededRecord({ failure: { error: 'something' } }); + expect(() => TaskRecordSchema.parse(record)).toThrow(); + }); + }); + + describe('owner_pid prohibition', () => { + it('should reject exceeded record with owner_pid set to a process ID', () => { + const record = makeExceededRecord({ owner_pid: 12345 }); + expect(() => TaskRecordSchema.parse(record)).toThrow(); + }); + + it('should accept exceeded record with owner_pid explicitly null', () => { + const record = makeExceededRecord({ owner_pid: null }); + expect(() => TaskRecordSchema.parse(record)).not.toThrow(); + }); + }); + + describe('independence from other statuses', () => { + it('should not affect pending status validation', () => { + // pending: started_at must be null + expect(() => TaskRecordSchema.parse({ + name: 'test-task', + status: 'pending', + content: 'task content', + created_at: '2025-01-01T00:00:00.000Z', + started_at: null, + completed_at: null, + })).not.toThrow(); + }); + + it('should not affect failed status validation', () => { + // failed: requires failure field + expect(() => TaskRecordSchema.parse({ + name: 'test-task', + status: 'failed', + content: 'task content', + created_at: '2025-01-01T00:00:00.000Z', + started_at: '2025-01-01T01:00:00.000Z', + completed_at: '2025-01-01T02:00:00.000Z', + failure: { error: 'something went wrong' }, + })).not.toThrow(); + }); + }); +}); diff --git a/src/__tests__/task.test.ts b/src/__tests__/task.test.ts index 1005d726..5cd88daa 100644 --- a/src/__tests__/task.test.ts +++ b/src/__tests__/task.test.ts @@ -296,7 +296,7 @@ describe('TaskRunner (tasks.yaml)', () => { it('should delete pending and failed tasks', () => { const pending = runner.addTask('Task A'); - runner.deletePendingTask(pending.name); + runner.deleteTask(pending.name, 'pending'); expect(runner.listTasks()).toHaveLength(0); const failed = runner.addTask('Task B'); @@ -309,7 +309,7 @@ describe('TaskRunner (tasks.yaml)', () => { startedAt: new Date().toISOString(), completedAt: new Date().toISOString(), }); - runner.deleteFailedTask(failed.name); + runner.deleteTask(failed.name, 'failed'); expect(runner.listFailedTasks()).toHaveLength(0); }); }); diff --git a/src/__tests__/taskDeleteActions.test.ts b/src/__tests__/taskDeleteActions.test.ts index cca1c490..ac1f8581 100644 --- a/src/__tests__/taskDeleteActions.test.ts +++ b/src/__tests__/taskDeleteActions.test.ts @@ -28,7 +28,7 @@ vi.mock('../features/tasks/list/taskActions.js', () => ({ import { confirm } from '../shared/prompt/index.js'; import { success, error as logError } from '../shared/ui/index.js'; -import { deletePendingTask, deleteFailedTask, deleteCompletedTask, deleteAllTasks } from '../features/tasks/list/taskDeleteActions.js'; +import { deleteTaskByKind, deleteAllTasks } from '../features/tasks/list/taskDeleteActions.js'; import type { TaskListItem } from '../infra/task/types.js'; const mockConfirm = vi.mocked(confirm); @@ -69,6 +69,16 @@ function setupTasksFile(projectDir: string): string { started_at: '2025-01-15T00:01:00.000Z', completed_at: '2025-01-15T00:02:00.000Z', }, + { + name: 'exceeded-task', + status: 'exceeded', + content: 'exceeded', + exceeded_max_movements: 60, + exceeded_current_iteration: 30, + 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; @@ -96,7 +106,7 @@ describe('taskDeleteActions', () => { }; mockConfirm.mockResolvedValue(true); - const result = await deletePendingTask(task); + const result = await deleteTaskByKind(task); expect(result).toBe(true); const raw = fs.readFileSync(tasksFile, 'utf-8'); @@ -115,7 +125,7 @@ describe('taskDeleteActions', () => { }; mockConfirm.mockResolvedValue(true); - const result = await deleteFailedTask(task); + const result = await deleteTaskByKind(task); expect(result).toBe(true); const raw = fs.readFileSync(tasksFile, 'utf-8'); @@ -136,7 +146,7 @@ describe('taskDeleteActions', () => { }; mockConfirm.mockResolvedValue(true); - const result = await deleteFailedTask(task); + const result = await deleteTaskByKind(task); expect(result).toBe(true); expect(mockDeleteBranch).toHaveBeenCalledWith(tmpDir, task); @@ -159,7 +169,7 @@ describe('taskDeleteActions', () => { mockConfirm.mockResolvedValue(true); mockDeleteBranch.mockReturnValue(false); - const result = await deleteFailedTask(task); + const result = await deleteTaskByKind(task); expect(result).toBe(false); expect(mockDeleteBranch).toHaveBeenCalledWith(tmpDir, task); @@ -178,12 +188,35 @@ describe('taskDeleteActions', () => { }; mockConfirm.mockResolvedValue(true); - const result = await deleteFailedTask(task); + const result = await deleteTaskByKind(task); expect(result).toBe(false); expect(mockLogError).toHaveBeenCalled(); }); + it('should confirm with message containing "exceeded" and delete exceeded task when confirmed', async () => { + const tasksFile = setupTasksFile(tmpDir); + const task: TaskListItem = { + kind: 'exceeded', + name: 'exceeded-task', + createdAt: '2025-01-15T12:34:56', + filePath: tasksFile, + content: 'exceeded', + branch: 'takt/exceeded-task', + worktreePath: '/tmp/takt/exceeded-task', + }; + mockConfirm.mockResolvedValue(true); + + const result = await deleteTaskByKind(task); + + expect(result).toBe(true); + expect(mockConfirm).toHaveBeenCalledWith(expect.stringContaining('exceeded'), false); + expect(mockDeleteBranch).toHaveBeenCalledWith(tmpDir, task); + const raw = fs.readFileSync(tasksFile, 'utf-8'); + expect(raw).not.toContain('exceeded-task'); + expect(mockSuccess).toHaveBeenCalledWith('Deleted exceeded task: exceeded-task'); + }); + it('should delete completed task and cleanup worktree when confirmed', async () => { const tasksFile = setupTasksFile(tmpDir); const task: TaskListItem = { @@ -197,7 +230,7 @@ describe('taskDeleteActions', () => { }; mockConfirm.mockResolvedValue(true); - const result = await deleteCompletedTask(task); + const result = await deleteTaskByKind(task); expect(result).toBe(true); expect(mockDeleteBranch).toHaveBeenCalledWith(tmpDir, task); @@ -311,6 +344,29 @@ describe('deleteAllTasks', () => { expect(mockSuccess).not.toHaveBeenCalled(); }); + it('should include exceeded task in deleteAllTasks (not filtered like running)', async () => { + const tasksFile = setupTasksFile(tmpDir); + const task: TaskListItem = { + kind: 'exceeded', + name: 'exceeded-task', + createdAt: '2025-01-15', + filePath: tasksFile, + content: 'exceeded', + branch: 'takt/exceeded-task', + worktreePath: '/tmp/takt/exceeded-task', + }; + mockConfirm.mockResolvedValue(true); + + const result = await deleteAllTasks([task]); + + expect(result).toBe(true); + expect(mockConfirm).toHaveBeenCalledWith('Delete all 1 tasks?', false); + expect(mockDeleteBranch).toHaveBeenCalledWith(tmpDir, task); + const raw = fs.readFileSync(tasksFile, 'utf-8'); + expect(raw).not.toContain('exceeded-task'); + expect(mockSuccess).toHaveBeenCalledWith('Deleted 1 of 1 tasks.'); + }); + it('should cleanup branches for completed and failed tasks', async () => { const tasksFile = setupTasksFile(tmpDir); const completedTask: TaskListItem = { diff --git a/src/__tests__/taskStatusLabel-exceeded.test.ts b/src/__tests__/taskStatusLabel-exceeded.test.ts new file mode 100644 index 00000000..286df01c --- /dev/null +++ b/src/__tests__/taskStatusLabel-exceeded.test.ts @@ -0,0 +1,90 @@ +/** + * Unit tests for formatTaskStatusLabel with exceeded status + * + * Covers: + * - exceeded kind formats as '[exceeded] name' + * - exceeded with branch + * - exceeded with issue number + */ + +import { describe, it, expect } from 'vitest'; +import { formatTaskStatusLabel } from '../features/tasks/list/taskStatusLabel.js'; +import type { TaskListItem } from '../infra/task/types.js'; + +function makeExceededTask(overrides: Partial): TaskListItem { + return { + kind: 'exceeded', + name: 'test-task', + createdAt: '2026-02-11T00:00:00.000Z', + filePath: '/tmp/task.md', + content: 'content', + ...overrides, + }; +} + +describe('formatTaskStatusLabel - exceeded', () => { + it("should format exceeded task as '[exceeded] name'", () => { + // Given: an exceeded task + const task = makeExceededTask({ name: 'implement-feature' }); + + // When: formatTaskStatusLabel is called + const label = formatTaskStatusLabel(task); + + // Then: label shows exceeded status + expect(label).toBe('[exceeded] implement-feature'); + }); + + it('should include branch when present', () => { + // Given: an exceeded task with a branch + const task = makeExceededTask({ + name: 'fix-login-bug', + branch: 'takt/366/fix-login-bug', + }); + + // When: formatTaskStatusLabel is called + const label = formatTaskStatusLabel(task); + + // Then: label includes branch + expect(label).toBe('[exceeded] fix-login-bug (takt/366/fix-login-bug)'); + }); + + it('should not include branch when absent', () => { + // Given: an exceeded task without branch + const task = makeExceededTask({ name: 'my-task' }); + + // When: formatTaskStatusLabel is called + const label = formatTaskStatusLabel(task); + + // Then: no branch in label + expect(label).toBe('[exceeded] my-task'); + }); + + it('should include issue number when present', () => { + // Given: an exceeded task with issue number + const task = makeExceededTask({ + name: 'implement-feature', + issueNumber: 42, + }); + + // When: formatTaskStatusLabel is called + const label = formatTaskStatusLabel(task); + + // Then: label includes issue number + expect(label).toBe('[exceeded] implement-feature #42'); + }); + + it('should include both issue number and branch when both present', () => { + // Given: an exceeded task with both issue and branch + const task = makeExceededTask({ + name: 'fix-bug', + issueNumber: 366, + branch: 'takt/366/fix-bug', + }); + + // When: formatTaskStatusLabel is called + const label = formatTaskStatusLabel(task); + + // Then: label includes both + expect(label).toBe('[exceeded] fix-bug #366 (takt/366/fix-bug)'); + }); +}); diff --git a/src/__tests__/watchTasks.test.ts b/src/__tests__/watchTasks.test.ts index 48a74d32..20f6a7b2 100644 --- a/src/__tests__/watchTasks.test.ts +++ b/src/__tests__/watchTasks.test.ts @@ -3,7 +3,7 @@ import type { TaskInfo } from '../infra/task/index.js'; const { mockRecoverInterruptedRunningTasks, - mockGetTasksDir, + mockGetTasksFilePath, mockWatch, mockStop, mockExecuteAndCompleteTask, @@ -17,7 +17,7 @@ const { mockResolveConfigValue, } = vi.hoisted(() => ({ mockRecoverInterruptedRunningTasks: vi.fn(), - mockGetTasksDir: vi.fn(), + mockGetTasksFilePath: vi.fn(), mockWatch: vi.fn(), mockStop: vi.fn(), mockExecuteAndCompleteTask: vi.fn(), @@ -34,7 +34,7 @@ const { vi.mock('../infra/task/index.js', () => ({ TaskRunner: vi.fn().mockImplementation(() => ({ recoverInterruptedRunningTasks: mockRecoverInterruptedRunningTasks, - getTasksDir: mockGetTasksDir, + getTasksFilePath: mockGetTasksFilePath, })), TaskWatcher: vi.fn().mockImplementation(() => ({ watch: mockWatch, @@ -71,7 +71,7 @@ describe('watchTasks', () => { vi.clearAllMocks(); mockResolveConfigValue.mockReturnValue('default'); mockRecoverInterruptedRunningTasks.mockReturnValue(0); - mockGetTasksDir.mockReturnValue('/project/.takt/tasks.yaml'); + mockGetTasksFilePath.mockReturnValue('/project/.takt/tasks.yaml'); mockExecuteAndCompleteTask.mockResolvedValue(true); mockWatch.mockImplementation(async (onTask: (task: TaskInfo) => Promise) => { diff --git a/src/__tests__/worktree-exceeded-requeue.test.ts b/src/__tests__/worktree-exceeded-requeue.test.ts new file mode 100644 index 00000000..7cbebce2 --- /dev/null +++ b/src/__tests__/worktree-exceeded-requeue.test.ts @@ -0,0 +1,307 @@ +/** + * Integration tests for worktree exceeded → requeue → re-execution flow. + * + * Scenarios: + * 1. Worktree task reaches iteration limit → transitions to 'exceeded' status + * 2. Exceeded task stores start_movement / exceeded_max_movements / exceeded_current_iteration + * 3. After requeue, re-execution passes maxMovementsOverride and initialIterationOverride + * 4. After requeue, re-execution starts from start_movement (re-entry point) + * + * Integration boundary: + * TaskRunner (real file I/O) → + * executeAndCompleteTask → + * resolveTaskExecution → + * executeTaskWithResult → + * executePiece (mocked, args captured) + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdirSync, existsSync, rmSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { randomUUID } from 'node:crypto'; +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; + +// --- Mock setup (must be before imports that use these modules) --- + +vi.mock('../infra/config/index.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadPieceByIdentifier: vi.fn(), + isPiecePath: vi.fn().mockReturnValue(false), + resolvePieceConfigValues: vi.fn().mockReturnValue({}), + resolveConfigValueWithSource: vi.fn().mockReturnValue({ value: undefined, source: 'global' }), + resolvePieceConfigValue: vi.fn().mockReturnValue(undefined), + }; +}); + +vi.mock('../features/tasks/execute/pieceExecution.js', () => ({ + executePiece: vi.fn(), +})); + +vi.mock('../features/tasks/execute/postExecution.js', () => ({ + postExecutionFlow: vi.fn(), +})); + +vi.mock('../infra/task/index.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createSharedClone: vi.fn(), + detectDefaultBranch: vi.fn(), + summarizeTaskName: vi.fn(), + }; +}); + +vi.mock('../shared/ui/index.js', async (importOriginal) => ({ + ...(await importOriginal>()), + header: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + success: vi.fn(), + status: vi.fn(), + blankLine: vi.fn(), + withProgress: vi.fn().mockImplementation( + async (_startMsg: string, _successFn: unknown, fn: () => Promise) => fn(), + ), +})); + +// --- Imports (after mocks) --- + +import { executePiece } from '../features/tasks/execute/pieceExecution.js'; +import { postExecutionFlow } from '../features/tasks/execute/postExecution.js'; +import { loadPieceByIdentifier } from '../infra/config/index.js'; +import { detectDefaultBranch } from '../infra/task/index.js'; +import { withProgress } from '../shared/ui/index.js'; +import { executeAndCompleteTask } from '../features/tasks/execute/taskExecution.js'; +import { TaskRunner } from '../infra/task/runner.js'; +import type { PieceConfig } from '../core/models/index.js'; +import type { PieceExecutionOptions } from '../features/tasks/execute/types.js'; + +// --- Helpers --- + +function createTestDir(): string { + const dir = join(tmpdir(), `takt-worktree-requeue-test-${randomUUID()}`); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function loadTasksFile(testDir: string): { tasks: Array> } { + const raw = readFileSync(join(testDir, '.takt', 'tasks.yaml'), 'utf-8'); + return parseYaml(raw) as { tasks: Array> }; +} + +function writeExceededRecord(testDir: string, overrides: Record = {}): void { + mkdirSync(join(testDir, '.takt'), { recursive: true }); + const record = { + name: 'task-a', + status: 'exceeded', + content: 'Do work', + created_at: '2026-02-09T00:00:00.000Z', + started_at: '2026-02-09T00:01:00.000Z', + completed_at: '2026-02-09T00:05:00.000Z', + owner_pid: null, + start_movement: 'implement', + exceeded_max_movements: 60, + exceeded_current_iteration: 30, + ...overrides, + }; + writeFileSync( + join(testDir, '.takt', 'tasks.yaml'), + stringifyYaml({ tasks: [record] }), + 'utf-8', + ); +} + +function buildTestPieceConfig(): PieceConfig { + return { + name: 'test-piece', + maxMovements: 30, + initialMovement: 'plan', + movements: [ + { + name: 'plan', + persona: '../personas/plan.md', + personaDisplayName: 'plan', + instructionTemplate: 'Run plan', + passPreviousResponse: true, + rules: [], + }, + ], + }; +} + +function applyDefaultMocks(): void { + // Re-apply mocks that are not set by the vi.mock factory + // (vi.clearAllMocks preserves factory implementations, but these are set per-suite) + vi.mocked(loadPieceByIdentifier).mockReturnValue(buildTestPieceConfig()); + vi.mocked(detectDefaultBranch).mockReturnValue('main'); + vi.mocked(postExecutionFlow).mockResolvedValue({ prUrl: undefined, prFailed: false }); + vi.mocked(withProgress).mockImplementation( + async (_startMsg: string, _successFn: unknown, fn: () => Promise) => fn(), + ); +} + +// --- Tests --- + +describe('シナリオ1・2: exceeded status transition via executeAndCompleteTask', () => { + let testDir: string; + let runner: TaskRunner; + + beforeEach(() => { + // clearAllMocks clears call history but preserves factory implementations + vi.clearAllMocks(); + applyDefaultMocks(); + testDir = createTestDir(); + runner = new TaskRunner(testDir); + }); + + afterEach(() => { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + it('scenario 1: task transitions to exceeded status when executePiece returns exceeded result', async () => { + // Given: a pending task + runner.addTask('Do work'); + const [task] = runner.claimNextTasks(1); + if (!task) throw new Error('No task claimed'); + + // executePiece simulates hitting iteration limit + vi.mocked(executePiece).mockResolvedValueOnce({ + success: false, + exceeded: true, + exceededInfo: { + currentMovement: 'implement', + newMaxMovements: 60, + currentIteration: 30, + }, + }); + + // When: executeAndCompleteTask processes the exceeded result + const result = await executeAndCompleteTask(task, runner, testDir, 'test-piece'); + + // Then: returns false (task did not succeed) + expect(result).toBe(false); + + // Then: task is now in exceeded status + const exceededTasks = runner.listExceededTasks(); + expect(exceededTasks).toHaveLength(1); + expect(exceededTasks[0]?.kind).toBe('exceeded'); + expect(exceededTasks[0]?.name).toBe(task.name); + }); + + it('scenario 2: exceeded metadata is recorded in tasks.yaml for resumption', async () => { + // Given: a pending task + runner.addTask('Do work'); + const [task] = runner.claimNextTasks(1); + if (!task) throw new Error('No task claimed'); + + // executePiece simulates hitting limit at 'implement' movement, producing 30/60 iterations + vi.mocked(executePiece).mockResolvedValueOnce({ + success: false, + exceeded: true, + exceededInfo: { + currentMovement: 'implement', + newMaxMovements: 60, + currentIteration: 30, + }, + }); + + // When: executeAndCompleteTask records the exceeded result + await executeAndCompleteTask(task, runner, testDir, 'test-piece'); + + // Then: YAML contains the three resumption fields + const file = loadTasksFile(testDir); + const exceededRecord = file.tasks[0]; + expect(exceededRecord?.status).toBe('exceeded'); + expect(exceededRecord?.start_movement).toBe('implement'); + expect(exceededRecord?.exceeded_max_movements).toBe(60); + expect(exceededRecord?.exceeded_current_iteration).toBe(30); + }); +}); + +describe('シナリオ3・4: requeue → re-execution passes exceeded metadata to executePiece', () => { + let testDir: string; + let cloneDir: string; + let runner: TaskRunner; + + beforeEach(() => { + // clearAllMocks clears call history but preserves factory implementations + vi.clearAllMocks(); + applyDefaultMocks(); + testDir = createTestDir(); + // cloneDir simulates a pre-existing worktree clone (fs.existsSync check will pass) + cloneDir = createTestDir(); + runner = new TaskRunner(testDir); + }); + + afterEach(() => { + for (const dir of [testDir, cloneDir]) { + if (existsSync(dir)) { + rmSync(dir, { recursive: true, force: true }); + } + } + }); + + it('scenario 3: maxMovementsOverride and initialIterationOverride are passed to executePiece after requeue', async () => { + // Given: an exceeded worktree task with pre-existing clone on disk + writeExceededRecord(testDir, { + worktree: true, + worktree_path: cloneDir, + exceeded_max_movements: 60, + exceeded_current_iteration: 30, + }); + + // Requeue → status back to pending, exceeded metadata and worktree_path preserved + runner.requeueExceededTask('task-a'); + + // Claim the requeued task as running + const [task] = runner.claimNextTasks(1); + if (!task) throw new Error('No task claimed'); + + // executePiece returns success so we can capture args without side effects + vi.mocked(executePiece).mockResolvedValueOnce({ success: true }); + + // When: executeAndCompleteTask runs the requeued task + await executeAndCompleteTask(task, runner, testDir, 'test-piece'); + + // Then: executePiece received the correct exceeded override options + expect(vi.mocked(executePiece)).toHaveBeenCalledOnce(); + const capturedOptions = vi.mocked(executePiece).mock.calls[0]![3] as PieceExecutionOptions; + expect(capturedOptions.maxMovementsOverride).toBe(60); + expect(capturedOptions.initialIterationOverride).toBe(30); + }); + + it('scenario 4: startMovement is passed so re-execution resumes from the exceeded movement', async () => { + // Given: an exceeded worktree task with start_movement='implement' + writeExceededRecord(testDir, { + worktree: true, + worktree_path: cloneDir, + exceeded_max_movements: 60, + exceeded_current_iteration: 30, + start_movement: 'implement', + }); + + // Requeue → pending, start_movement preserved + runner.requeueExceededTask('task-a'); + + // Claim the requeued task as running + const [task] = runner.claimNextTasks(1); + if (!task) throw new Error('No task claimed'); + + // executePiece returns success so we can capture args without side effects + vi.mocked(executePiece).mockResolvedValueOnce({ success: true }); + + // When: executeAndCompleteTask runs the requeued task + await executeAndCompleteTask(task, runner, testDir, 'test-piece'); + + // Then: executePiece received startMovement='implement' to resume from where it stopped + expect(vi.mocked(executePiece)).toHaveBeenCalledOnce(); + const capturedOptions = vi.mocked(executePiece).mock.calls[0]![3] as PieceExecutionOptions; + expect(capturedOptions.startMovement).toBe('implement'); + }); +}); diff --git a/src/core/piece/engine/state-manager.ts b/src/core/piece/engine/state-manager.ts index fc593557..42ada3d7 100644 --- a/src/core/piece/engine/state-manager.ts +++ b/src/core/piece/engine/state-manager.ts @@ -37,7 +37,7 @@ export class StateManager { this.state = { pieceName: config.name, currentMovement: options.startMovement ?? config.initialMovement, - iteration: 0, + iteration: options.initialIteration ?? 0, movementOutputs: new Map(), lastOutput: undefined, previousResponseSourcePath: undefined, diff --git a/src/core/piece/types.ts b/src/core/piece/types.ts index 1cd26397..00c94a04 100644 --- a/src/core/piece/types.ts +++ b/src/core/piece/types.ts @@ -206,6 +206,8 @@ export interface PieceEngineOptions { taskPrefix?: string; /** Color index for task prefix (cycled across tasks) */ taskColorIndex?: number; + /** Initial iteration count (for resuming exceeded tasks) */ + initialIteration?: number; } /** Loop detection result */ diff --git a/src/features/tasks/execute/pieceExecution.ts b/src/features/tasks/execute/pieceExecution.ts index 59331e96..069315bd 100644 --- a/src/features/tasks/execute/pieceExecution.ts +++ b/src/features/tasks/execute/pieceExecution.ts @@ -6,7 +6,7 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { PieceEngine, createDenyAskUserQuestionHandler, type IterationLimitRequest, type UserInputRequest } from '../../../core/piece/index.js'; import type { PieceConfig } from '../../../core/models/index.js'; -import type { PieceExecutionResult, PieceExecutionOptions } from './types.js'; +import type { PieceExecutionResult, PieceExecutionOptions, ExceededInfo } from './types.js'; import { detectRuleIndex } from '../../../shared/utils/ruleIndex.js'; import { interruptAllQueries } from '../../../infra/claude/query-manager.js'; import { callAiJudge } from '../../../agents/ai-judge.js'; @@ -343,6 +343,7 @@ export async function executePiece( const effectivePieceConfig: PieceConfig = { ...pieceConfig, runtime: resolveRuntimeConfig(globalConfig.runtime, pieceConfig.runtime), + ...(options.maxMovementsOverride !== undefined ? { maxMovements: options.maxMovementsOverride } : {}), }; const providerEventLogger = createProviderEventLogger({ logsDir: runPaths.logsAbs, @@ -399,6 +400,15 @@ export async function executePiece( playWarningSound(); } + if (!interactiveUserInput) { + exceededInfo = { + currentMovement: request.currentMovement, + newMaxMovements: request.maxMovements + pieceConfig.maxMovements, + currentIteration: request.currentIteration, + }; + return null; + } + enterInputWait(); try { const action = await selectOption(getLabel('piece.iterationLimit.continueQuestion'), [ @@ -447,6 +457,7 @@ export async function executePiece( : undefined; let abortReason: string | undefined; + let exceededInfo: ExceededInfo | undefined; let lastMovementContent: string | undefined; let lastMovementName: string | undefined; let currentIteration = 0; @@ -485,6 +496,7 @@ export async function executePiece( reportDirName: runSlug, taskPrefix: options.taskPrefix, taskColorIndex: options.taskColorIndex, + initialIteration: options.initialIterationOverride, }); engine.on('phase:start', (step, phase, phaseName, instruction) => { @@ -545,10 +557,10 @@ export async function executePiece( prefixWriter?.setMovementContext({ movementName: step.name, iteration, - maxMovements: pieceConfig.maxMovements, + maxMovements: effectivePieceConfig.maxMovements, movementIteration, }); - out.info(`[${iteration}/${pieceConfig.maxMovements}] ${step.name} (${step.personaDisplayName})`); + out.info(`[${iteration}/${effectivePieceConfig.maxMovements}] ${step.name} (${step.personaDisplayName})`); const resolved = resolveMovementProviderModel({ step, provider: options.provider, @@ -581,7 +593,7 @@ export async function executePiece( const agentLabel = step.personaDisplayName; displayRef.current = new StreamDisplay(agentLabel, quiet, { iteration, - maxMovements: pieceConfig.maxMovements, + maxMovements: effectivePieceConfig.maxMovements, movementIndex: movementIndex >= 0 ? movementIndex : 0, totalMovements, }); @@ -849,6 +861,8 @@ export async function executePiece( reason: abortReason, lastMovement: lastMovementName, lastMessage: lastMovementContent, + exceeded: exceededInfo != null, + ...(exceededInfo ? { exceededInfo } : {}), }; } catch (error) { if (!isMetaFinalized) { diff --git a/src/features/tasks/execute/resolveTask.ts b/src/features/tasks/execute/resolveTask.ts index 10461582..1541def0 100644 --- a/src/features/tasks/execute/resolveTask.ts +++ b/src/features/tasks/execute/resolveTask.ts @@ -27,6 +27,8 @@ export interface ResolvedTaskExecution { autoPr: boolean; draftPr: boolean; issueNumber?: number; + maxMovementsOverride?: number; + initialIterationOverride?: number; } function buildRunTaskDirInstruction(reportDirName: string): string { @@ -166,6 +168,8 @@ export async function resolveTaskExecution( const execPiece = data.piece || defaultPiece; const startMovement = data.start_movement; const retryNote = data.retry_note; + const maxMovementsOverride = data.exceeded_max_movements; + const initialIterationOverride = data.exceeded_current_iteration; const autoPr = data.auto_pr ?? resolvePieceConfigValue(defaultCwd, 'autoPr') ?? false; const draftPr = data.draft_pr ?? resolvePieceConfigValue(defaultCwd, 'draftPr') ?? false; @@ -184,5 +188,7 @@ export async function resolveTaskExecution( ...(startMovement ? { startMovement } : {}), ...(retryNote ? { retryNote } : {}), ...(data.issue !== undefined ? { issueNumber: data.issue } : {}), + ...(maxMovementsOverride !== undefined ? { maxMovementsOverride } : {}), + ...(initialIterationOverride !== undefined ? { initialIterationOverride } : {}), }; } diff --git a/src/features/tasks/execute/taskExecution.ts b/src/features/tasks/execute/taskExecution.ts index d21cf900..31f23ab9 100644 --- a/src/features/tasks/execute/taskExecution.ts +++ b/src/features/tasks/execute/taskExecution.ts @@ -19,7 +19,7 @@ import type { TaskExecutionOptions, ExecuteTaskOptions, PieceExecutionResult } f import { runWithWorkerPool } from './parallelExecution.js'; import { resolveTaskExecution, resolveTaskIssue } from './resolveTask.js'; import { postExecutionFlow } from './postExecution.js'; -import { buildTaskResult, persistTaskError, persistTaskResult } from './taskResultHandler.js'; +import { buildTaskResult, persistExceededTaskResult, persistTaskError, persistTaskResult } from './taskResultHandler.js'; import { generateRunId, toSlackTaskDetail } from './slackSummaryAdapter.js'; export type { TaskExecutionOptions, ExecuteTaskOptions }; @@ -42,6 +42,8 @@ async function executeTaskWithResult(options: ExecuteTaskOptions): Promise { + header(formatTaskStatusLabel(task)); + info(` Created: ${task.createdAt}`); + if (task.content) { + info(` ${task.content}`); + } + if (task.exceededCurrentIteration !== undefined && task.exceededMaxMovements !== undefined) { + info(` Iteration: ${task.exceededCurrentIteration}/${task.exceededMaxMovements}`); + } + blankLine(); + + return await selectOption( + `Action for ${task.name}:`, + [ + { label: 'Requeue', value: 'requeue', description: 'Resume execution from where it stopped' }, + { label: 'Delete', value: 'delete', description: 'Remove this task permanently' }, + ], + ); +} + async function showPendingTaskAndPromptAction(task: TaskListItem): Promise { header(formatTaskStatusLabel(task)); info(` Created: ${task.createdAt}`); @@ -138,7 +159,7 @@ export async function listTasks( if (!task) continue; const taskAction = await showPendingTaskAndPromptAction(task); if (taskAction === 'delete') { - await deletePendingTask(task); + await deleteTaskByKind(task); } } else if (type === 'running') { const task = tasks[idx]; @@ -176,11 +197,11 @@ export async function listTasks( break; case 'merge': if (mergeBranch(cwd, task)) { - runner.deleteCompletedTask(task.name); + runner.deleteTask(task.name, 'completed'); } break; case 'delete': - await deleteCompletedTask(task); + await deleteTaskByKind(task); break; } } else if (type === 'failed') { @@ -190,7 +211,16 @@ export async function listTasks( if (taskAction === 'retry') { await retryFailedTask(task, cwd); } else if (taskAction === 'delete') { - await deleteFailedTask(task); + await deleteTaskByKind(task); + } + } else if (type === 'exceeded') { + const task = tasks[idx]; + if (!task) continue; + const taskAction = await showExceededTaskAndPromptAction(task); + if (taskAction === 'requeue') { + runner.requeueExceededTask(task.name); + } else if (taskAction === 'delete') { + await deleteTaskByKind(task); } } } diff --git a/src/features/tasks/list/listNonInteractive.ts b/src/features/tasks/list/listNonInteractive.ts index f8c271b9..d3920d54 100644 --- a/src/features/tasks/list/listNonInteractive.ts +++ b/src/features/tasks/list/listNonInteractive.ts @@ -106,7 +106,7 @@ export async function listTasksNonInteractive( return; case 'merge': if (mergeBranch(cwd, task)) { - runner.deleteCompletedTask(task.name); + runner.deleteTask(task.name, 'completed'); } return; case 'delete': @@ -115,7 +115,7 @@ export async function listTasksNonInteractive( process.exit(1); } if (deleteBranch(cwd, task)) { - runner.deleteCompletedTask(task.name); + runner.deleteTask(task.name, 'completed'); } return; } diff --git a/src/features/tasks/list/taskDeleteActions.ts b/src/features/tasks/list/taskDeleteActions.ts index 8ca85161..66e55bb6 100644 --- a/src/features/tasks/list/taskDeleteActions.ts +++ b/src/features/tasks/list/taskDeleteActions.ts @@ -20,71 +20,30 @@ function cleanupBranchIfPresent(task: TaskListItem, projectDir: string): boolean return deleteBranch(projectDir, task); } -export async function deletePendingTask(task: TaskListItem): Promise { - const confirmed = await confirm(`Delete pending task "${task.name}"?`, false); - if (!confirmed) return false; - try { - const runner = new TaskRunner(getProjectDir(task)); - runner.deletePendingTask(task.name); - } catch (err) { - const msg = getErrorMessage(err); - logError(`Failed to delete pending task "${task.name}": ${msg}`); - log.error('Failed to delete pending task', { name: task.name, filePath: task.filePath, error: msg }); - return false; - } - success(`Deleted pending task: ${task.name}`); - log.info('Deleted pending task', { name: task.name, filePath: task.filePath }); - return true; -} - -export async function deleteFailedTask(task: TaskListItem): Promise { - const confirmed = await confirm(`Delete failed task "${task.name}"?`, false); +export async function deleteTaskByKind(task: TaskListItem): Promise { + if (task.kind === 'running') throw new Error(`Cannot delete running task "${task.name}"`); + const confirmed = await confirm(`Delete ${task.kind} task "${task.name}"?`, false); if (!confirmed) return false; const projectDir = getProjectDir(task); try { - if (!cleanupBranchIfPresent(task, projectDir)) { - return false; - } - + if (!cleanupBranchIfPresent(task, projectDir)) return false; const runner = new TaskRunner(projectDir); - runner.deleteFailedTask(task.name); + runner.deleteTask(task.name, task.kind); } catch (err) { const msg = getErrorMessage(err); - logError(`Failed to delete failed task "${task.name}": ${msg}`); - log.error('Failed to delete failed task', { name: task.name, filePath: task.filePath, error: msg }); + logError(`Failed to delete ${task.kind} task "${task.name}": ${msg}`); + log.error('Failed to delete task', { name: task.name, kind: task.kind, filePath: task.filePath, error: msg }); return false; } - success(`Deleted failed task: ${task.name}`); - log.info('Deleted failed task', { name: task.name, filePath: task.filePath }); + success(`Deleted ${task.kind} task: ${task.name}`); + log.info('Deleted task', { name: task.name, kind: task.kind, filePath: task.filePath }); return true; } -export async function deleteCompletedTask(task: TaskListItem): Promise { - const confirmed = await confirm(`Delete completed task "${task.name}"?`, false); - if (!confirmed) return false; - - const projectDir = getProjectDir(task); - try { - if (!cleanupBranchIfPresent(task, projectDir)) { - return false; - } - - const runner = new TaskRunner(projectDir); - runner.deleteCompletedTask(task.name); - } catch (err) { - const msg = getErrorMessage(err); - logError(`Failed to delete completed task "${task.name}": ${msg}`); - log.error('Failed to delete completed task', { name: task.name, filePath: task.filePath, error: msg }); - return false; - } - - success(`Deleted completed task: ${task.name}`); - log.info('Deleted completed task', { name: task.name, filePath: task.filePath }); - return true; -} +type DeletableTask = TaskListItem & { kind: 'pending' | 'failed' | 'completed' | 'exceeded' }; export async function deleteAllTasks(tasks: TaskListItem[]): Promise { - const deletable = tasks.filter(t => t.kind !== 'running'); + const deletable = tasks.filter((t): t is DeletableTask => t.kind !== 'running'); if (deletable.length === 0) return false; const confirmed = await confirm(`Delete all ${deletable.length} tasks?`, false); @@ -100,13 +59,7 @@ export async function deleteAllTasks(tasks: TaskListItem[]): Promise { continue; } const runner = new TaskRunner(projectDir); - if (task.kind === 'pending') { - runner.deletePendingTask(task.name); - } else if (task.kind === 'failed') { - runner.deleteFailedTask(task.name); - } else if (task.kind === 'completed') { - runner.deleteCompletedTask(task.name); - } + runner.deleteTask(task.name, task.kind); deletedCount++; log.info('Deleted task in bulk delete', { name: task.name, kind: task.kind }); } catch (err) { diff --git a/src/features/tasks/list/taskStatusLabel.ts b/src/features/tasks/list/taskStatusLabel.ts index 32b9a50e..f4646f01 100644 --- a/src/features/tasks/list/taskStatusLabel.ts +++ b/src/features/tasks/list/taskStatusLabel.ts @@ -5,6 +5,7 @@ const TASK_STATUS_BY_KIND: Record = { running: 'running', completed: 'completed', failed: 'failed', + exceeded: 'exceeded', }; export function formatTaskStatusLabel(task: TaskListItem): string { diff --git a/src/features/tasks/watch/index.ts b/src/features/tasks/watch/index.ts index 91feaaab..5a8018d6 100644 --- a/src/features/tasks/watch/index.ts +++ b/src/features/tasks/watch/index.ts @@ -35,7 +35,7 @@ export async function watchTasks(cwd: string, options?: TaskExecutionOptions): P header('TAKT Watch Mode'); info(`Piece: ${pieceName}`); - info(`Watching: ${taskRunner.getTasksDir()}`); + info(`Watching: ${taskRunner.getTasksFilePath()}`); if (recovered > 0) { info(`Recovered ${recovered} interrupted running task(s) to pending.`); } diff --git a/src/infra/task/display.ts b/src/infra/task/display.ts index 8b958f7a..beee098e 100644 --- a/src/infra/task/display.ts +++ b/src/infra/task/display.ts @@ -18,13 +18,13 @@ export function showTaskList(runner: TaskRunner): void { divider('=', 60); header('TAKT タスク一覧'); divider('=', 60); - console.log(chalk.gray(`タスクディレクトリ: ${runner.getTasksDir()}`)); + console.log(chalk.gray(`タスクファイル: ${runner.getTasksFilePath()}`)); divider('-', 60); if (tasks.length === 0) { console.log(); info('実行待ちのタスクはありません。'); - console.log(chalk.gray(`\n${runner.getTasksDir()} を確認してください。`)); + console.log(chalk.gray(`\nタスクファイル: ${runner.getTasksFilePath()} を確認してください。`)); console.log(chalk.gray('takt add でタスクを追加できます。')); return; } @@ -34,7 +34,6 @@ export function showTaskList(runner: TaskRunner): void { for (let i = 0; i < tasks.length; i++) { const task = tasks[i]; if (task) { - // タスク内容の最初の行を取得 const firstLine = task.content.trim().split('\n')[0]?.slice(0, 60) ?? ''; console.log(chalk.cyan.bold(` [${i + 1}] ${task.name}`)); console.log(chalk.gray(` ${firstLine}...`)); diff --git a/src/infra/task/mapper.ts b/src/infra/task/mapper.ts index 3f69c000..babc38fb 100644 --- a/src/infra/task/mapper.ts +++ b/src/infra/task/mapper.ts @@ -45,9 +45,9 @@ export function resolveTaskContent(projectDir: string, task: TaskRecord): string return fs.readFileSync(contentPath, 'utf-8'); } -export function toTaskData(projectDir: string, task: TaskRecord): TaskFileData { +function buildTaskFileData(task: TaskRecord, content: string): TaskFileData { return TaskFileSchema.parse({ - task: resolveTaskContent(projectDir, task), + task: content, worktree: task.worktree, branch: task.branch, piece: task.piece, @@ -56,9 +56,15 @@ export function toTaskData(projectDir: string, task: TaskRecord): TaskFileData { retry_note: task.retry_note, auto_pr: task.auto_pr, draft_pr: task.draft_pr, + exceeded_max_movements: task.exceeded_max_movements, + exceeded_current_iteration: task.exceeded_current_iteration, }); } +export function toTaskData(projectDir: string, task: TaskRecord): TaskFileData { + return buildTaskFileData(task, resolveTaskContent(projectDir, task)); +} + export function toTaskInfo(projectDir: string, tasksFile: string, task: TaskRecord): TaskInfo { const content = resolveTaskContent(projectDir, task); return { @@ -70,17 +76,7 @@ export function toTaskInfo(projectDir: string, tasksFile: string, task: TaskReco createdAt: task.created_at, status: task.status, worktreePath: task.worktree_path, - data: TaskFileSchema.parse({ - task: content, - worktree: task.worktree, - branch: task.branch, - piece: task.piece, - issue: task.issue, - start_movement: task.start_movement, - retry_note: task.retry_note, - auto_pr: task.auto_pr, - draft_pr: task.draft_pr, - }), + data: buildTaskFileData(task, content), }; } @@ -99,6 +95,15 @@ export function toFailedTaskItem(projectDir: string, tasksFile: string, task: Ta }; } +export function toExceededTaskItem(projectDir: string, tasksFile: string, task: TaskRecord): TaskListItem { + return { + kind: 'exceeded', + ...toBaseTaskListItem(projectDir, tasksFile, task), + exceededMaxMovements: task.exceeded_max_movements, + exceededCurrentIteration: task.exceeded_current_iteration, + }; +} + function toRunningTaskItem(projectDir: string, tasksFile: string, task: TaskRecord): TaskListItem { return { kind: 'running', @@ -113,7 +118,7 @@ function toCompletedTaskItem(projectDir: string, tasksFile: string, task: TaskRe }; } -function toBaseTaskListItem(projectDir: string, tasksFile: string, task: TaskRecord): Omit { +function toBaseTaskListItem(projectDir: string, tasksFile: string, task: TaskRecord): Omit { return { name: task.name, createdAt: task.created_at, @@ -141,5 +146,7 @@ export function toTaskListItem(projectDir: string, tasksFile: string, task: Task return toCompletedTaskItem(projectDir, tasksFile, task); case 'failed': return toFailedTaskItem(projectDir, tasksFile, task); + case 'exceeded': + return toExceededTaskItem(projectDir, tasksFile, task); } } diff --git a/src/infra/task/runner.ts b/src/infra/task/runner.ts index b8266d11..cb3baee7 100644 --- a/src/infra/task/runner.ts +++ b/src/infra/task/runner.ts @@ -5,6 +5,7 @@ import { TaskStore } from './store.js'; import { TaskLifecycleService } from './taskLifecycleService.js'; import { TaskQueryService } from './taskQueryService.js'; import { TaskDeletionService } from './taskDeletionService.js'; +import { TaskExceedService, type ExceedTaskOptions } from './taskExceedService.js'; export type { TaskInfo, TaskResult, TaskListItem }; @@ -14,6 +15,7 @@ export class TaskRunner { private readonly lifecycle: TaskLifecycleService; private readonly query: TaskQueryService; private readonly deletion: TaskDeletionService; + private readonly exceed: TaskExceedService; constructor(private readonly projectDir: string) { this.store = new TaskStore(projectDir); @@ -21,13 +23,14 @@ export class TaskRunner { this.lifecycle = new TaskLifecycleService(projectDir, this.tasksFile, this.store); this.query = new TaskQueryService(projectDir, this.tasksFile, this.store); this.deletion = new TaskDeletionService(this.store); + this.exceed = new TaskExceedService(this.store); } ensureDirs(): void { this.store.ensureDirs(); } - getTasksDir(): string { + getTasksFilePath(): string { return this.tasksFile; } @@ -76,6 +79,10 @@ export class TaskRunner { return this.query.listFailedTasks(); } + listExceededTasks(): TaskListItem[] { + return this.query.listExceededTasks(); + } + requeueFailedTask(taskRef: string, startMovement?: string, retryNote?: string): string { return this.lifecycle.requeueFailedTask(taskRef, startMovement, retryNote); } @@ -98,15 +105,15 @@ export class TaskRunner { return this.lifecycle.startReExecution(taskRef, allowedStatuses, startMovement, retryNote); } - deletePendingTask(name: string): void { - this.deletion.deletePendingTask(name); + deleteTask(name: string, kind: 'pending' | 'failed' | 'completed' | 'exceeded'): void { + this.deletion.deleteTaskByNameAndStatus(name, kind); } - deleteFailedTask(name: string): void { - this.deletion.deleteFailedTask(name); + exceedTask(taskName: string, options: ExceedTaskOptions): void { + this.exceed.exceedTask(taskName, options); } - deleteCompletedTask(name: string): void { - this.deletion.deleteCompletedTask(name); + requeueExceededTask(taskName: string): void { + this.exceed.requeueExceededTask(taskName); } } diff --git a/src/infra/task/schema.ts b/src/infra/task/schema.ts index 9ccfdb5a..985e0dca 100644 --- a/src/infra/task/schema.ts +++ b/src/infra/task/schema.ts @@ -18,6 +18,8 @@ export const TaskExecutionConfigSchema = z.object({ retry_note: z.string().optional(), auto_pr: z.boolean().optional(), draft_pr: z.boolean().optional(), + exceeded_max_movements: z.number().int().positive().optional(), + exceeded_current_iteration: z.number().int().min(0).optional(), }); /** @@ -29,7 +31,7 @@ export const TaskFileSchema = TaskExecutionConfigSchema.extend({ export type TaskFileData = z.infer; -export const TaskStatusSchema = z.enum(['pending', 'running', 'completed', 'failed']); +export const TaskStatusSchema = z.enum(['pending', 'running', 'completed', 'failed', 'exceeded']); export type TaskStatus = z.infer; export const TaskFailureSchema = z.object({ @@ -197,6 +199,46 @@ export const TaskRecordSchema = TaskExecutionConfigSchema.extend({ }); } } + + if (value.status === 'exceeded') { + if (value.started_at === null) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['started_at'], + message: 'Exceeded task requires started_at.', + }); + } + if (value.completed_at === null) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['completed_at'], + message: 'Exceeded task requires completed_at.', + }); + } + if (hasFailure) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['failure'], + message: 'Exceeded task must not have failure.', + }); + } + if (hasOwnerPid) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['owner_pid'], + message: 'Exceeded task must not have owner_pid.', + }); + } + const hasExceededMax = value.exceeded_max_movements !== undefined; + const hasExceededIter = value.exceeded_current_iteration !== undefined; + if (hasExceededMax !== hasExceededIter) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['exceeded_max_movements'], + message: 'exceeded_max_movements and exceeded_current_iteration must both be set or both be absent.', + }); + } + } }); export type TaskRecord = z.infer; diff --git a/src/infra/task/taskDeletionService.ts b/src/infra/task/taskDeletionService.ts index d1da4e9c..cc099a33 100644 --- a/src/infra/task/taskDeletionService.ts +++ b/src/infra/task/taskDeletionService.ts @@ -3,19 +3,7 @@ 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 { + deleteTaskByNameAndStatus(name: string, status: 'pending' | 'failed' | 'completed' | 'exceeded'): void { this.store.update((current) => { const exists = current.tasks.some((task) => task.name === name && task.status === status); if (!exists) { diff --git a/src/infra/task/taskExceedService.ts b/src/infra/task/taskExceedService.ts new file mode 100644 index 00000000..2f76c2c3 --- /dev/null +++ b/src/infra/task/taskExceedService.ts @@ -0,0 +1,65 @@ +import type { TaskRecord } from './schema.js'; +import { TaskStore } from './store.js'; +import { nowIso } from './naming.js'; + +export interface ExceedTaskOptions { + currentMovement: string; + newMaxMovements: number; + currentIteration: number; +} + +export class TaskExceedService { + constructor(private readonly store: TaskStore) {} + + exceedTask(taskName: string, options: ExceedTaskOptions): void { + this.store.update((current) => { + const index = current.tasks.findIndex( + (task) => task.name === taskName && task.status === 'running', + ); + if (index === -1) { + throw new Error(`Task not found: ${taskName} (running)`); + } + + const target = current.tasks[index]!; + const updated: TaskRecord = { + ...target, + status: 'exceeded', + completed_at: nowIso(), + owner_pid: null, + failure: undefined, + start_movement: options.currentMovement, + exceeded_max_movements: options.newMaxMovements, + exceeded_current_iteration: options.currentIteration, + }; + + const tasks = [...current.tasks]; + tasks[index] = updated; + return { tasks }; + }); + } + + requeueExceededTask(taskName: string): void { + this.store.update((current) => { + const index = current.tasks.findIndex( + (task) => task.name === taskName && task.status === 'exceeded', + ); + if (index === -1) { + throw new Error(`Task not found: ${taskName} (exceeded)`); + } + + const target = current.tasks[index]!; + const updated: TaskRecord = { + ...target, + status: 'pending', + started_at: null, + completed_at: null, + owner_pid: null, + failure: undefined, + }; + + const tasks = [...current.tasks]; + tasks[index] = updated; + return { tasks }; + }); + } +} diff --git a/src/infra/task/taskQueryService.ts b/src/infra/task/taskQueryService.ts index 5632011d..b523a6a9 100644 --- a/src/infra/task/taskQueryService.ts +++ b/src/infra/task/taskQueryService.ts @@ -1,5 +1,5 @@ import type { TaskInfo, TaskListItem } from './types.js'; -import { toFailedTaskItem, toPendingTaskItem, toTaskInfo, toTaskListItem } from './mapper.js'; +import { toExceededTaskItem, toFailedTaskItem, toPendingTaskItem, toTaskInfo, toTaskListItem } from './mapper.js'; import { TaskStore } from './store.js'; export class TaskQueryService { @@ -34,4 +34,11 @@ export class TaskQueryService { .filter((task) => task.status === 'failed') .map((task) => toFailedTaskItem(this.projectDir, this.tasksFile, task)); } + + listExceededTasks(): TaskListItem[] { + const state = this.store.read(); + return state.tasks + .filter((task) => task.status === 'exceeded') + .map((task) => toExceededTaskItem(this.projectDir, this.tasksFile, task)); + } } diff --git a/src/infra/task/types.ts b/src/infra/task/types.ts index 5556536d..4f165bcc 100644 --- a/src/infra/task/types.ts +++ b/src/infra/task/types.ts @@ -78,7 +78,7 @@ export interface SummarizeOptions { /** pending/failedタスクのリストアイテム */ export interface TaskListItem { - kind: 'pending' | 'running' | 'completed' | 'failed'; + kind: 'pending' | 'running' | 'completed' | 'failed' | 'exceeded'; name: string; createdAt: string; filePath: string; @@ -93,4 +93,6 @@ export interface TaskListItem { completedAt?: string; ownerPid?: number; issueNumber?: number; + exceededMaxMovements?: number; + exceededCurrentIteration?: number; }