[#366] implement-exceeded-requeue (#374)

* takt: implement-exceeded-requeue

* takt: implement-exceeded-requeue

* takt: implement-exceeded-requeue

* ci: trigger CI

* fix: 未使用インポート削除と --create-worktree e2e テスト修正

InteractiveModeAction の不要な import を削除して lint エラーを解消する。
--create-worktree オプション削除に合わせ e2e の期待メッセージを更新する。

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
nrs 2026-03-02 23:30:53 +09:00 committed by GitHub
parent dfbc455807
commit 4a92ba2012
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 2106 additions and 153 deletions

View File

@ -108,7 +108,7 @@ describe('E2E: Error handling edge cases (mock)', () => {
// Then: exits with migration error
const combined = result.stdout + result.stderr;
expect(result.exitCode).not.toBe(0);
expect(combined).toContain('--create-worktree has been removed');
expect(combined).toContain("unknown option '--create-worktree'");
}, 240_000);
it('should error when piece file contains invalid YAML', () => {

View File

@ -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<Record<string, unknown>>()),
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);
});
});

View File

@ -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');
});
});

View File

@ -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', () => ({

View File

@ -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();
});
});
});

View File

@ -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<Record<string, unknown>> } {
const raw = readFileSync(join(testDir, '.takt', 'tasks.yaml'), 'utf-8');
return parseYaml(raw) as { tasks: Array<Record<string, unknown>> };
}
function writeRecord(testDir: string, record: Record<string, unknown>): 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<string, unknown>).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<string, unknown>).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<string, unknown>).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);
});
});

View File

@ -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<Record<string, unknown>> } {
const raw = readFileSync(join(testDir, '.takt', 'tasks.yaml'), 'utf-8');
return parseYaml(raw) as { tasks: Array<Record<string, unknown>> };
}
function writeExceededRecord(testDir: string, overrides: Record<string, unknown> = {}): 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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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);
});
});

View File

@ -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<string, unknown> = {}): Record<string, unknown> {
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();
});
});
});

View File

@ -308,7 +308,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');
@ -321,7 +321,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);
});
});

View File

@ -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 = {

View File

@ -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>): 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)');
});
});

View File

@ -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<void>) => {

View File

@ -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<typeof import('../infra/config/index.js')>();
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<typeof import('../infra/task/index.js')>();
return {
...actual,
createSharedClone: vi.fn(),
detectDefaultBranch: vi.fn(),
summarizeTaskName: vi.fn(),
};
});
vi.mock('../shared/ui/index.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
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<unknown>) => 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<Record<string, unknown>> } {
const raw = readFileSync(join(testDir, '.takt', 'tasks.yaml'), 'utf-8');
return parseYaml(raw) as { tasks: Array<Record<string, unknown>> };
}
function writeExceededRecord(testDir: string, overrides: Record<string, unknown> = {}): 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<unknown>) => 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');
});
});

View File

@ -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,

View File

@ -180,6 +180,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 */

View File

@ -11,7 +11,6 @@ import {
type ConversationMessage,
type TaskHistorySummaryItem,
type PieceContext,
type InteractiveModeAction,
type PostSummaryAction,
type SummaryActionValue,
type SummaryActionOption,

View File

@ -17,6 +17,7 @@ export function createIterationLimitHandler(
out: OutputFns,
displayRef: { current: StreamDisplay | null },
shouldNotify: boolean,
onExceeded?: (request: IterationLimitRequest) => void,
): (request: IterationLimitRequest) => Promise<number | null> {
return async (request: IterationLimitRequest): Promise<number | null> => {
if (displayRef.current) { displayRef.current.flush(); displayRef.current = null; }
@ -27,6 +28,10 @@ export function createIterationLimitHandler(
}));
out.info(getLabel('piece.iterationLimit.currentMovement', undefined, { currentMovement: request.currentMovement }));
if (shouldNotify) playWarningSound();
if (onExceeded) {
onExceeded(request);
return null;
}
enterInputWait();
try {
const action = await selectOption(getLabel('piece.iterationLimit.continueQuestion'), [

View File

@ -6,7 +6,7 @@ import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { PieceEngine, createDenyAskUserQuestionHandler } 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';
@ -110,8 +110,11 @@ export async function executePiece(
const currentProvider = globalConfig.provider;
if (!currentProvider) throw new Error('No provider configured. Set "provider" in ~/.takt/config.yaml');
const configuredModel = options.model ?? globalConfig.model;
const effectivePieceConfig: PieceConfig = { ...pieceConfig, runtime: resolveRuntimeConfig(globalConfig.runtime, pieceConfig.runtime) };
const effectivePieceConfig: PieceConfig = {
...pieceConfig,
runtime: resolveRuntimeConfig(globalConfig.runtime, pieceConfig.runtime),
...(options.maxMovementsOverride !== undefined ? { maxMovements: options.maxMovementsOverride } : {}),
};
const providerEventLogger = createProviderEventLogger({
logsDir: runPaths.logsAbs,
sessionId: pieceSessionId,
@ -132,10 +135,24 @@ export async function executePiece(
? (personaName: string, personaSessionId: string) => updateWorktreeSession(projectCwd, cwd, personaName, personaSessionId, currentProvider)
: (persona: string, personaSessionId: string) => updatePersonaSession(projectCwd, persona, personaSessionId, currentProvider);
const iterationLimitHandler = createIterationLimitHandler(out, displayRef, shouldNotifyIterationLimit);
const iterationLimitHandler = createIterationLimitHandler(
out,
displayRef,
shouldNotifyIterationLimit,
!interactiveUserInput
? (request) => {
exceededInfo = {
currentMovement: request.currentMovement,
newMaxMovements: request.maxMovements + pieceConfig.maxMovements,
currentIteration: request.currentIteration,
};
}
: undefined,
);
const onUserInput = interactiveUserInput ? createUserInputHandler(out, displayRef) : undefined;
let abortReason: string | undefined;
let exceededInfo: ExceededInfo | undefined;
let lastMovementContent: string | undefined;
let lastMovementName: string | undefined;
let currentIteration = 0;
@ -169,6 +186,7 @@ export async function executePiece(
reportDirName: runSlug,
taskPrefix: options.taskPrefix,
taskColorIndex: options.taskColorIndex,
initialIteration: options.initialIterationOverride,
});
abortHandler.install();
@ -189,8 +207,8 @@ export async function executePiece(
currentIteration = iteration;
const movementIteration = (movementIterations.get(step.name) ?? 0) + 1;
movementIterations.set(step.name, movementIteration);
prefixWriter?.setMovementContext({ movementName: step.name, iteration, maxMovements: pieceConfig.maxMovements, movementIteration });
out.info(`[${iteration}/${pieceConfig.maxMovements}] ${step.name} (${step.personaDisplayName})`);
prefixWriter?.setMovementContext({ movementName: step.name, iteration, maxMovements: effectivePieceConfig.maxMovements, movementIteration });
out.info(`[${iteration}/${effectivePieceConfig.maxMovements}] ${step.name} (${step.personaDisplayName})`);
const movementProvider = providerInfo.provider ?? currentProvider;
const movementModel = providerInfo.model ?? (movementProvider === currentProvider ? configuredModel : undefined) ?? '(default)';
providerEventLogger.setMovement(step.name);
@ -203,7 +221,7 @@ export async function executePiece(
const movementIndex = pieceConfig.movements.findIndex((m) => m.name === step.name);
displayRef.current = new StreamDisplay(step.personaDisplayName, isQuietMode(), {
iteration,
maxMovements: pieceConfig.maxMovements,
maxMovements: effectivePieceConfig.maxMovements,
movementIndex: movementIndex >= 0 ? movementIndex : 0,
totalMovements: pieceConfig.movements.length,
});
@ -271,7 +289,14 @@ export async function executePiece(
});
const finalState = await engine.run();
return { success: finalState.status === 'completed', reason: abortReason, lastMovement: lastMovementName, lastMessage: lastMovementContent };
return {
success: finalState.status === 'completed',
reason: abortReason,
lastMovement: lastMovementName,
lastMessage: lastMovementContent,
exceeded: exceededInfo != null,
...(exceededInfo ? { exceededInfo } : {}),
};
} catch (error) {
if (!runMetaManager.isFinalized) runMetaManager.finalize('aborted');
throw error;

View File

@ -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 } : {}),
};
}

View File

@ -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<Piece
taskPrefix,
taskColorIndex,
taskDisplayLabel,
maxMovementsOverride,
initialIterationOverride,
} = options;
const pieceConfig = loadPieceByIdentifier(pieceIdentifier, projectCwd);
@ -82,8 +84,10 @@ async function executeTaskWithResult(options: ExecuteTaskOptions): Promise<Piece
taskPrefix,
taskColorIndex,
taskDisplayLabel,
maxMovementsOverride,
initialIterationOverride,
});
}
}
/**
* Execute a single task with piece.
@ -141,6 +145,8 @@ export async function executeAndCompleteTask(
autoPr,
draftPr,
issueNumber,
maxMovementsOverride,
initialIterationOverride,
} = await resolveTaskExecution(task, cwd, pieceName, taskAbortSignal);
// cwd is always the project root; pass it as projectCwd so reports/sessions go there
@ -157,8 +163,15 @@ export async function executeAndCompleteTask(
taskPrefix: parallelOptions?.taskPrefix,
taskColorIndex: parallelOptions?.taskColorIndex,
taskDisplayLabel: parallelOptions?.taskDisplayLabel,
maxMovementsOverride,
initialIterationOverride,
});
if (taskRunResult.exceeded && taskRunResult.exceededInfo) {
persistExceededTaskResult(taskRunner, task, taskRunResult.exceededInfo);
return false;
}
const taskSuccess = taskRunResult.success;
const completedAt = new Date().toISOString();

View File

@ -1,7 +1,7 @@
import { type TaskInfo, type TaskResult, TaskRunner } from '../../../infra/task/index.js';
import { error, success } from '../../../shared/ui/index.js';
import { error, info, success } from '../../../shared/ui/index.js';
import { getErrorMessage } from '../../../shared/utils/index.js';
import type { PieceExecutionResult } from './types.js';
import type { ExceededInfo, PieceExecutionResult } from './types.js';
interface BuildTaskResultParams {
task: TaskInfo;
@ -100,6 +100,19 @@ export function persistTaskResult(
}
}
export function persistExceededTaskResult(
taskRunner: TaskRunner,
task: TaskInfo,
exceeded: ExceededInfo,
): void {
taskRunner.exceedTask(task.name, {
currentMovement: exceeded.currentMovement,
newMaxMovements: exceeded.newMaxMovements,
currentIteration: exceeded.currentIteration,
});
info(`Task "${task.name}" exceeded iteration limit at movement "${exceeded.currentMovement}"`);
}
export function persistTaskError(
taskRunner: TaskRunner,
task: TaskInfo,

View File

@ -9,12 +9,22 @@ import type { MovementProviderOptions } from '../../../core/models/piece-types.j
import type { ProviderType } from '../../../infra/providers/index.js';
import type { ProviderOptionsSource } from '../../../core/piece/types.js';
/** Info captured when iteration limit is hit in non-interactive mode */
export interface ExceededInfo {
currentMovement: string;
newMaxMovements: number;
currentIteration: number;
}
/** Result of piece execution */
export interface PieceExecutionResult {
success: boolean;
reason?: string;
lastMovement?: string;
lastMessage?: string;
/** True when iteration limit was hit in non-interactive mode */
exceeded?: boolean;
exceededInfo?: ExceededInfo;
}
/** Metadata from interactive mode, passed through to NDJSON logging */
@ -31,6 +41,10 @@ export interface PieceExecutionOptions {
headerPrefix?: string;
/** Project root directory (where .takt/ lives). */
projectCwd: string;
/** Override maxMovements from piece config (used when resuming exceeded tasks) */
maxMovementsOverride?: number;
/** Override initial iteration count (used when resuming exceeded tasks) */
initialIterationOverride?: number;
/** Language for instruction metadata */
language?: Language;
provider?: ProviderType;
@ -79,6 +93,10 @@ export interface ExecuteTaskOptions {
projectCwd: string;
/** Agent provider/model overrides */
agentOverrides?: TaskExecutionOptions;
/** Override maxMovements from piece config (used when resuming exceeded tasks) */
maxMovementsOverride?: number;
/** Override initial iteration count (used when resuming exceeded tasks) */
initialIterationOverride?: number;
/** Enable interactive user input during step transitions */
interactiveUserInput?: boolean;
/** Interactive mode result metadata for NDJSON logging */

View File

@ -15,7 +15,7 @@ import {
syncBranchWithRoot,
pullFromRemote,
} from './taskActions.js';
import { deletePendingTask, deleteFailedTask, deleteCompletedTask, deleteAllTasks } from './taskDeleteActions.js';
import { deleteTaskByKind, deleteAllTasks } from './taskDeleteActions.js';
import { retryFailedTask } from './taskRetryActions.js';
import { listTasksNonInteractive, type ListNonInteractiveOptions } from './listNonInteractive.js';
import { formatTaskStatusLabel, formatShortDate } from './taskStatusLabel.js';
@ -39,10 +39,31 @@ export {
} from './instructMode.js';
type PendingTaskAction = 'delete';
type ExceededTaskAction = 'requeue' | 'delete';
type FailedTaskAction = 'retry' | 'delete';
type CompletedTaskAction = ListAction;
async function showExceededTaskAndPromptAction(task: TaskListItem): Promise<ExceededTaskAction | null> {
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<ExceededTaskAction>(
`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<PendingTaskAction | null> {
header(formatTaskStatusLabel(task));
info(` Created: ${task.createdAt}`);
@ -139,7 +160,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];
@ -180,11 +201,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') {
@ -194,7 +215,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);
}
}
}

View File

@ -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;
}

View File

@ -20,71 +20,30 @@ function cleanupBranchIfPresent(task: TaskListItem, projectDir: string): boolean
return deleteBranch(projectDir, task);
}
export async function deletePendingTask(task: TaskListItem): Promise<boolean> {
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<boolean> {
const confirmed = await confirm(`Delete failed task "${task.name}"?`, false);
export async function deleteTaskByKind(task: TaskListItem): Promise<boolean> {
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<boolean> {
const confirmed = await confirm(`Delete completed task "${task.name}"?`, false);
if (!confirmed) return false;
const projectDir = getProjectDir(task);
try {
if (!cleanupBranchIfPresent(task, projectDir)) {
return false;
}
const runner = new TaskRunner(projectDir);
runner.deleteCompletedTask(task.name);
} catch (err) {
const msg = getErrorMessage(err);
logError(`Failed to delete completed task "${task.name}": ${msg}`);
log.error('Failed to delete completed task', { name: task.name, filePath: task.filePath, error: msg });
return false;
}
success(`Deleted completed task: ${task.name}`);
log.info('Deleted completed task', { name: task.name, filePath: task.filePath });
return true;
}
type DeletableTask = TaskListItem & { kind: 'pending' | 'failed' | 'completed' | 'exceeded' };
export async function deleteAllTasks(tasks: TaskListItem[]): Promise<boolean> {
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<boolean> {
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) {

View File

@ -5,6 +5,7 @@ const TASK_STATUS_BY_KIND: Record<TaskListItem['kind'], string> = {
running: 'running',
completed: 'completed',
failed: 'failed',
exceeded: 'exceeded',
};
export function formatTaskStatusLabel(task: TaskListItem): string {

View File

@ -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.`);
}

View File

@ -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}...`));

View File

@ -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<TaskListItem, 'kind' | 'failure'> {
function toBaseTaskListItem(projectDir: string, tasksFile: string, task: TaskRecord): Omit<TaskListItem, 'kind' | 'failure' | 'exceededMaxMovements' | 'exceededCurrentIteration'> {
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);
}
}

View File

@ -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);
}
}

View File

@ -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<typeof TaskFileSchema>;
export const TaskStatusSchema = z.enum(['pending', 'running', 'completed', 'failed']);
export const TaskStatusSchema = z.enum(['pending', 'running', 'completed', 'failed', 'exceeded']);
export type TaskStatus = z.infer<typeof TaskStatusSchema>;
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<typeof TaskRecordSchema>;

View File

@ -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) {

View File

@ -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 };
});
}
}

View File

@ -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));
}
}

View File

@ -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;
}