* 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:
parent
dfbc455807
commit
4a92ba2012
@ -108,7 +108,7 @@ describe('E2E: Error handling edge cases (mock)', () => {
|
|||||||
// Then: exits with migration error
|
// Then: exits with migration error
|
||||||
const combined = result.stdout + result.stderr;
|
const combined = result.stdout + result.stderr;
|
||||||
expect(result.exitCode).not.toBe(0);
|
expect(result.exitCode).not.toBe(0);
|
||||||
expect(combined).toContain('--create-worktree has been removed');
|
expect(combined).toContain("unknown option '--create-worktree'");
|
||||||
}, 240_000);
|
}, 240_000);
|
||||||
|
|
||||||
it('should error when piece file contains invalid YAML', () => {
|
it('should error when piece file contains invalid YAML', () => {
|
||||||
|
|||||||
453
src/__tests__/exceeded-requeue.test.ts
Normal file
453
src/__tests__/exceeded-requeue.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,13 +1,13 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mockDeleteCompletedTask,
|
mockDeleteTask,
|
||||||
mockListAllTaskItems,
|
mockListAllTaskItems,
|
||||||
mockMergeBranch,
|
mockMergeBranch,
|
||||||
mockDeleteBranch,
|
mockDeleteBranch,
|
||||||
mockInfo,
|
mockInfo,
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
mockDeleteCompletedTask: vi.fn(),
|
mockDeleteTask: vi.fn(),
|
||||||
mockListAllTaskItems: vi.fn(),
|
mockListAllTaskItems: vi.fn(),
|
||||||
mockMergeBranch: vi.fn(),
|
mockMergeBranch: vi.fn(),
|
||||||
mockDeleteBranch: vi.fn(),
|
mockDeleteBranch: vi.fn(),
|
||||||
@ -20,8 +20,8 @@ vi.mock('../infra/task/index.js', () => ({
|
|||||||
listAllTaskItems() {
|
listAllTaskItems() {
|
||||||
return mockListAllTaskItems();
|
return mockListAllTaskItems();
|
||||||
}
|
}
|
||||||
deleteCompletedTask(name: string) {
|
deleteTask(name: string, kind: string) {
|
||||||
mockDeleteCompletedTask(name);
|
mockDeleteTask(name, kind);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@ -64,7 +64,7 @@ describe('listTasksNonInteractive completed actions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(mockMergeBranch).toHaveBeenCalled();
|
expect(mockMergeBranch).toHaveBeenCalled();
|
||||||
expect(mockDeleteCompletedTask).toHaveBeenCalledWith('completed-task');
|
expect(mockDeleteTask).toHaveBeenCalledWith('completed-task', 'completed');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should delete completed record after delete action', async () => {
|
it('should delete completed record after delete action', async () => {
|
||||||
@ -78,6 +78,6 @@ describe('listTasksNonInteractive completed actions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(mockDeleteBranch).toHaveBeenCalled();
|
expect(mockDeleteBranch).toHaveBeenCalled();
|
||||||
expect(mockDeleteCompletedTask).toHaveBeenCalledWith('completed-task');
|
expect(mockDeleteTask).toHaveBeenCalledWith('completed-task', 'completed');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -45,9 +45,8 @@ vi.mock('../features/tasks/list/taskActions.js', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../features/tasks/list/taskDeleteActions.js', () => ({
|
vi.mock('../features/tasks/list/taskDeleteActions.js', () => ({
|
||||||
deletePendingTask: mockDeletePendingTask,
|
deleteTaskByKind: mockDeletePendingTask,
|
||||||
deleteFailedTask: vi.fn(),
|
deleteAllTasks: vi.fn(),
|
||||||
deleteCompletedTask: vi.fn(),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../features/tasks/list/taskRetryActions.js', () => ({
|
vi.mock('../features/tasks/list/taskRetryActions.js', () => ({
|
||||||
|
|||||||
@ -7,20 +7,22 @@ const {
|
|||||||
mockInfo,
|
mockInfo,
|
||||||
mockBlankLine,
|
mockBlankLine,
|
||||||
mockListAllTaskItems,
|
mockListAllTaskItems,
|
||||||
mockDeleteCompletedRecord,
|
mockDeleteTask,
|
||||||
mockShowDiffAndPromptActionForTask,
|
mockShowDiffAndPromptActionForTask,
|
||||||
mockMergeBranch,
|
mockMergeBranch,
|
||||||
mockDeleteCompletedTask,
|
mockDeleteCompletedTask,
|
||||||
|
mockRequeueExceededTask,
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
mockSelectOption: vi.fn(),
|
mockSelectOption: vi.fn(),
|
||||||
mockHeader: vi.fn(),
|
mockHeader: vi.fn(),
|
||||||
mockInfo: vi.fn(),
|
mockInfo: vi.fn(),
|
||||||
mockBlankLine: vi.fn(),
|
mockBlankLine: vi.fn(),
|
||||||
mockListAllTaskItems: vi.fn(),
|
mockListAllTaskItems: vi.fn(),
|
||||||
mockDeleteCompletedRecord: vi.fn(),
|
mockDeleteTask: vi.fn(),
|
||||||
mockShowDiffAndPromptActionForTask: vi.fn(),
|
mockShowDiffAndPromptActionForTask: vi.fn(),
|
||||||
mockMergeBranch: vi.fn(),
|
mockMergeBranch: vi.fn(),
|
||||||
mockDeleteCompletedTask: vi.fn(),
|
mockDeleteCompletedTask: vi.fn(),
|
||||||
|
mockRequeueExceededTask: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../infra/task/index.js', () => ({
|
vi.mock('../infra/task/index.js', () => ({
|
||||||
@ -28,8 +30,11 @@ vi.mock('../infra/task/index.js', () => ({
|
|||||||
listAllTaskItems() {
|
listAllTaskItems() {
|
||||||
return mockListAllTaskItems();
|
return mockListAllTaskItems();
|
||||||
}
|
}
|
||||||
deleteCompletedTask(name: string) {
|
deleteTask(name: string, kind: string) {
|
||||||
mockDeleteCompletedRecord(name);
|
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', () => ({
|
vi.mock('../features/tasks/list/taskDeleteActions.js', () => ({
|
||||||
deletePendingTask: vi.fn(),
|
deleteTaskByKind: mockDeleteCompletedTask,
|
||||||
deleteFailedTask: vi.fn(),
|
deleteAllTasks: vi.fn(),
|
||||||
deleteCompletedTask: mockDeleteCompletedTask,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../features/tasks/list/taskRetryActions.js', () => ({
|
vi.mock('../features/tasks/list/taskRetryActions.js', () => ({
|
||||||
@ -88,6 +92,16 @@ const completedTaskWithoutBranch: TaskListItem = {
|
|||||||
name: 'completed-without-branch',
|
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', () => {
|
describe('listTasks interactive status actions', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@ -129,7 +143,7 @@ describe('listTasks interactive status actions', () => {
|
|||||||
await listTasks('/project');
|
await listTasks('/project');
|
||||||
|
|
||||||
expect(mockMergeBranch).toHaveBeenCalledWith('/project', completedTaskWithBranch);
|
expect(mockMergeBranch).toHaveBeenCalledWith('/project', completedTaskWithBranch);
|
||||||
expect(mockDeleteCompletedRecord).toHaveBeenCalledWith('completed-task');
|
expect(mockDeleteTask).toHaveBeenCalledWith('completed-task', 'completed');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('completed delete 選択時は deleteCompletedTask を呼ぶ', async () => {
|
it('completed delete 選択時は deleteCompletedTask を呼ぶ', async () => {
|
||||||
@ -142,6 +156,47 @@ describe('listTasks interactive status actions', () => {
|
|||||||
await listTasks('/project');
|
await listTasks('/project');
|
||||||
|
|
||||||
expect(mockDeleteCompletedTask).toHaveBeenCalledWith(completedTaskWithBranch);
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
150
src/__tests__/task-delete-task.test.ts
Normal file
150
src/__tests__/task-delete-task.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
482
src/__tests__/task-exceed-service.test.ts
Normal file
482
src/__tests__/task-exceed-service.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
179
src/__tests__/task-schema-exceeded.test.ts
Normal file
179
src/__tests__/task-schema-exceeded.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -308,7 +308,7 @@ describe('TaskRunner (tasks.yaml)', () => {
|
|||||||
|
|
||||||
it('should delete pending and failed tasks', () => {
|
it('should delete pending and failed tasks', () => {
|
||||||
const pending = runner.addTask('Task A');
|
const pending = runner.addTask('Task A');
|
||||||
runner.deletePendingTask(pending.name);
|
runner.deleteTask(pending.name, 'pending');
|
||||||
expect(runner.listTasks()).toHaveLength(0);
|
expect(runner.listTasks()).toHaveLength(0);
|
||||||
|
|
||||||
const failed = runner.addTask('Task B');
|
const failed = runner.addTask('Task B');
|
||||||
@ -321,7 +321,7 @@ describe('TaskRunner (tasks.yaml)', () => {
|
|||||||
startedAt: new Date().toISOString(),
|
startedAt: new Date().toISOString(),
|
||||||
completedAt: new Date().toISOString(),
|
completedAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
runner.deleteFailedTask(failed.name);
|
runner.deleteTask(failed.name, 'failed');
|
||||||
expect(runner.listFailedTasks()).toHaveLength(0);
|
expect(runner.listFailedTasks()).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -28,7 +28,7 @@ vi.mock('../features/tasks/list/taskActions.js', () => ({
|
|||||||
|
|
||||||
import { confirm } from '../shared/prompt/index.js';
|
import { confirm } from '../shared/prompt/index.js';
|
||||||
import { success, error as logError } from '../shared/ui/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';
|
import type { TaskListItem } from '../infra/task/types.js';
|
||||||
|
|
||||||
const mockConfirm = vi.mocked(confirm);
|
const mockConfirm = vi.mocked(confirm);
|
||||||
@ -69,6 +69,16 @@ function setupTasksFile(projectDir: string): string {
|
|||||||
started_at: '2025-01-15T00:01:00.000Z',
|
started_at: '2025-01-15T00:01:00.000Z',
|
||||||
completed_at: '2025-01-15T00:02: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');
|
}), 'utf-8');
|
||||||
return tasksFile;
|
return tasksFile;
|
||||||
@ -96,7 +106,7 @@ describe('taskDeleteActions', () => {
|
|||||||
};
|
};
|
||||||
mockConfirm.mockResolvedValue(true);
|
mockConfirm.mockResolvedValue(true);
|
||||||
|
|
||||||
const result = await deletePendingTask(task);
|
const result = await deleteTaskByKind(task);
|
||||||
|
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
const raw = fs.readFileSync(tasksFile, 'utf-8');
|
const raw = fs.readFileSync(tasksFile, 'utf-8');
|
||||||
@ -115,7 +125,7 @@ describe('taskDeleteActions', () => {
|
|||||||
};
|
};
|
||||||
mockConfirm.mockResolvedValue(true);
|
mockConfirm.mockResolvedValue(true);
|
||||||
|
|
||||||
const result = await deleteFailedTask(task);
|
const result = await deleteTaskByKind(task);
|
||||||
|
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
const raw = fs.readFileSync(tasksFile, 'utf-8');
|
const raw = fs.readFileSync(tasksFile, 'utf-8');
|
||||||
@ -136,7 +146,7 @@ describe('taskDeleteActions', () => {
|
|||||||
};
|
};
|
||||||
mockConfirm.mockResolvedValue(true);
|
mockConfirm.mockResolvedValue(true);
|
||||||
|
|
||||||
const result = await deleteFailedTask(task);
|
const result = await deleteTaskByKind(task);
|
||||||
|
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
expect(mockDeleteBranch).toHaveBeenCalledWith(tmpDir, task);
|
expect(mockDeleteBranch).toHaveBeenCalledWith(tmpDir, task);
|
||||||
@ -159,7 +169,7 @@ describe('taskDeleteActions', () => {
|
|||||||
mockConfirm.mockResolvedValue(true);
|
mockConfirm.mockResolvedValue(true);
|
||||||
mockDeleteBranch.mockReturnValue(false);
|
mockDeleteBranch.mockReturnValue(false);
|
||||||
|
|
||||||
const result = await deleteFailedTask(task);
|
const result = await deleteTaskByKind(task);
|
||||||
|
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
expect(mockDeleteBranch).toHaveBeenCalledWith(tmpDir, task);
|
expect(mockDeleteBranch).toHaveBeenCalledWith(tmpDir, task);
|
||||||
@ -178,12 +188,35 @@ describe('taskDeleteActions', () => {
|
|||||||
};
|
};
|
||||||
mockConfirm.mockResolvedValue(true);
|
mockConfirm.mockResolvedValue(true);
|
||||||
|
|
||||||
const result = await deleteFailedTask(task);
|
const result = await deleteTaskByKind(task);
|
||||||
|
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
expect(mockLogError).toHaveBeenCalled();
|
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 () => {
|
it('should delete completed task and cleanup worktree when confirmed', async () => {
|
||||||
const tasksFile = setupTasksFile(tmpDir);
|
const tasksFile = setupTasksFile(tmpDir);
|
||||||
const task: TaskListItem = {
|
const task: TaskListItem = {
|
||||||
@ -197,7 +230,7 @@ describe('taskDeleteActions', () => {
|
|||||||
};
|
};
|
||||||
mockConfirm.mockResolvedValue(true);
|
mockConfirm.mockResolvedValue(true);
|
||||||
|
|
||||||
const result = await deleteCompletedTask(task);
|
const result = await deleteTaskByKind(task);
|
||||||
|
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
expect(mockDeleteBranch).toHaveBeenCalledWith(tmpDir, task);
|
expect(mockDeleteBranch).toHaveBeenCalledWith(tmpDir, task);
|
||||||
@ -311,6 +344,29 @@ describe('deleteAllTasks', () => {
|
|||||||
expect(mockSuccess).not.toHaveBeenCalled();
|
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 () => {
|
it('should cleanup branches for completed and failed tasks', async () => {
|
||||||
const tasksFile = setupTasksFile(tmpDir);
|
const tasksFile = setupTasksFile(tmpDir);
|
||||||
const completedTask: TaskListItem = {
|
const completedTask: TaskListItem = {
|
||||||
|
|||||||
90
src/__tests__/taskStatusLabel-exceeded.test.ts
Normal file
90
src/__tests__/taskStatusLabel-exceeded.test.ts
Normal 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)');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -3,7 +3,7 @@ import type { TaskInfo } from '../infra/task/index.js';
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
mockRecoverInterruptedRunningTasks,
|
mockRecoverInterruptedRunningTasks,
|
||||||
mockGetTasksDir,
|
mockGetTasksFilePath,
|
||||||
mockWatch,
|
mockWatch,
|
||||||
mockStop,
|
mockStop,
|
||||||
mockExecuteAndCompleteTask,
|
mockExecuteAndCompleteTask,
|
||||||
@ -17,7 +17,7 @@ const {
|
|||||||
mockResolveConfigValue,
|
mockResolveConfigValue,
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
mockRecoverInterruptedRunningTasks: vi.fn(),
|
mockRecoverInterruptedRunningTasks: vi.fn(),
|
||||||
mockGetTasksDir: vi.fn(),
|
mockGetTasksFilePath: vi.fn(),
|
||||||
mockWatch: vi.fn(),
|
mockWatch: vi.fn(),
|
||||||
mockStop: vi.fn(),
|
mockStop: vi.fn(),
|
||||||
mockExecuteAndCompleteTask: vi.fn(),
|
mockExecuteAndCompleteTask: vi.fn(),
|
||||||
@ -34,7 +34,7 @@ const {
|
|||||||
vi.mock('../infra/task/index.js', () => ({
|
vi.mock('../infra/task/index.js', () => ({
|
||||||
TaskRunner: vi.fn().mockImplementation(() => ({
|
TaskRunner: vi.fn().mockImplementation(() => ({
|
||||||
recoverInterruptedRunningTasks: mockRecoverInterruptedRunningTasks,
|
recoverInterruptedRunningTasks: mockRecoverInterruptedRunningTasks,
|
||||||
getTasksDir: mockGetTasksDir,
|
getTasksFilePath: mockGetTasksFilePath,
|
||||||
})),
|
})),
|
||||||
TaskWatcher: vi.fn().mockImplementation(() => ({
|
TaskWatcher: vi.fn().mockImplementation(() => ({
|
||||||
watch: mockWatch,
|
watch: mockWatch,
|
||||||
@ -71,7 +71,7 @@ describe('watchTasks', () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockResolveConfigValue.mockReturnValue('default');
|
mockResolveConfigValue.mockReturnValue('default');
|
||||||
mockRecoverInterruptedRunningTasks.mockReturnValue(0);
|
mockRecoverInterruptedRunningTasks.mockReturnValue(0);
|
||||||
mockGetTasksDir.mockReturnValue('/project/.takt/tasks.yaml');
|
mockGetTasksFilePath.mockReturnValue('/project/.takt/tasks.yaml');
|
||||||
mockExecuteAndCompleteTask.mockResolvedValue(true);
|
mockExecuteAndCompleteTask.mockResolvedValue(true);
|
||||||
|
|
||||||
mockWatch.mockImplementation(async (onTask: (task: TaskInfo) => Promise<void>) => {
|
mockWatch.mockImplementation(async (onTask: (task: TaskInfo) => Promise<void>) => {
|
||||||
|
|||||||
307
src/__tests__/worktree-exceeded-requeue.test.ts
Normal file
307
src/__tests__/worktree-exceeded-requeue.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -37,7 +37,7 @@ export class StateManager {
|
|||||||
this.state = {
|
this.state = {
|
||||||
pieceName: config.name,
|
pieceName: config.name,
|
||||||
currentMovement: options.startMovement ?? config.initialMovement,
|
currentMovement: options.startMovement ?? config.initialMovement,
|
||||||
iteration: 0,
|
iteration: options.initialIteration ?? 0,
|
||||||
movementOutputs: new Map(),
|
movementOutputs: new Map(),
|
||||||
lastOutput: undefined,
|
lastOutput: undefined,
|
||||||
previousResponseSourcePath: undefined,
|
previousResponseSourcePath: undefined,
|
||||||
|
|||||||
@ -180,6 +180,8 @@ export interface PieceEngineOptions {
|
|||||||
taskPrefix?: string;
|
taskPrefix?: string;
|
||||||
/** Color index for task prefix (cycled across tasks) */
|
/** Color index for task prefix (cycled across tasks) */
|
||||||
taskColorIndex?: number;
|
taskColorIndex?: number;
|
||||||
|
/** Initial iteration count (for resuming exceeded tasks) */
|
||||||
|
initialIteration?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Loop detection result */
|
/** Loop detection result */
|
||||||
|
|||||||
@ -11,7 +11,6 @@ import {
|
|||||||
type ConversationMessage,
|
type ConversationMessage,
|
||||||
type TaskHistorySummaryItem,
|
type TaskHistorySummaryItem,
|
||||||
type PieceContext,
|
type PieceContext,
|
||||||
type InteractiveModeAction,
|
|
||||||
type PostSummaryAction,
|
type PostSummaryAction,
|
||||||
type SummaryActionValue,
|
type SummaryActionValue,
|
||||||
type SummaryActionOption,
|
type SummaryActionOption,
|
||||||
|
|||||||
@ -17,6 +17,7 @@ export function createIterationLimitHandler(
|
|||||||
out: OutputFns,
|
out: OutputFns,
|
||||||
displayRef: { current: StreamDisplay | null },
|
displayRef: { current: StreamDisplay | null },
|
||||||
shouldNotify: boolean,
|
shouldNotify: boolean,
|
||||||
|
onExceeded?: (request: IterationLimitRequest) => void,
|
||||||
): (request: IterationLimitRequest) => Promise<number | null> {
|
): (request: IterationLimitRequest) => Promise<number | null> {
|
||||||
return async (request: IterationLimitRequest): Promise<number | null> => {
|
return async (request: IterationLimitRequest): Promise<number | null> => {
|
||||||
if (displayRef.current) { displayRef.current.flush(); displayRef.current = 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 }));
|
out.info(getLabel('piece.iterationLimit.currentMovement', undefined, { currentMovement: request.currentMovement }));
|
||||||
if (shouldNotify) playWarningSound();
|
if (shouldNotify) playWarningSound();
|
||||||
|
if (onExceeded) {
|
||||||
|
onExceeded(request);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
enterInputWait();
|
enterInputWait();
|
||||||
try {
|
try {
|
||||||
const action = await selectOption(getLabel('piece.iterationLimit.continueQuestion'), [
|
const action = await selectOption(getLabel('piece.iterationLimit.continueQuestion'), [
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { readFileSync } from 'node:fs';
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { PieceEngine, createDenyAskUserQuestionHandler } from '../../../core/piece/index.js';
|
import { PieceEngine, createDenyAskUserQuestionHandler } from '../../../core/piece/index.js';
|
||||||
import type { PieceConfig } from '../../../core/models/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 { detectRuleIndex } from '../../../shared/utils/ruleIndex.js';
|
||||||
import { interruptAllQueries } from '../../../infra/claude/query-manager.js';
|
import { interruptAllQueries } from '../../../infra/claude/query-manager.js';
|
||||||
import { callAiJudge } from '../../../agents/ai-judge.js';
|
import { callAiJudge } from '../../../agents/ai-judge.js';
|
||||||
@ -110,8 +110,11 @@ export async function executePiece(
|
|||||||
const currentProvider = globalConfig.provider;
|
const currentProvider = globalConfig.provider;
|
||||||
if (!currentProvider) throw new Error('No provider configured. Set "provider" in ~/.takt/config.yaml');
|
if (!currentProvider) throw new Error('No provider configured. Set "provider" in ~/.takt/config.yaml');
|
||||||
const configuredModel = options.model ?? globalConfig.model;
|
const configuredModel = options.model ?? globalConfig.model;
|
||||||
|
const effectivePieceConfig: PieceConfig = {
|
||||||
const effectivePieceConfig: PieceConfig = { ...pieceConfig, runtime: resolveRuntimeConfig(globalConfig.runtime, pieceConfig.runtime) };
|
...pieceConfig,
|
||||||
|
runtime: resolveRuntimeConfig(globalConfig.runtime, pieceConfig.runtime),
|
||||||
|
...(options.maxMovementsOverride !== undefined ? { maxMovements: options.maxMovementsOverride } : {}),
|
||||||
|
};
|
||||||
const providerEventLogger = createProviderEventLogger({
|
const providerEventLogger = createProviderEventLogger({
|
||||||
logsDir: runPaths.logsAbs,
|
logsDir: runPaths.logsAbs,
|
||||||
sessionId: pieceSessionId,
|
sessionId: pieceSessionId,
|
||||||
@ -132,10 +135,24 @@ export async function executePiece(
|
|||||||
? (personaName: string, personaSessionId: string) => updateWorktreeSession(projectCwd, cwd, personaName, personaSessionId, currentProvider)
|
? (personaName: string, personaSessionId: string) => updateWorktreeSession(projectCwd, cwd, personaName, personaSessionId, currentProvider)
|
||||||
: (persona: string, personaSessionId: string) => updatePersonaSession(projectCwd, persona, 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;
|
const onUserInput = interactiveUserInput ? createUserInputHandler(out, displayRef) : undefined;
|
||||||
|
|
||||||
let abortReason: string | undefined;
|
let abortReason: string | undefined;
|
||||||
|
let exceededInfo: ExceededInfo | undefined;
|
||||||
let lastMovementContent: string | undefined;
|
let lastMovementContent: string | undefined;
|
||||||
let lastMovementName: string | undefined;
|
let lastMovementName: string | undefined;
|
||||||
let currentIteration = 0;
|
let currentIteration = 0;
|
||||||
@ -169,6 +186,7 @@ export async function executePiece(
|
|||||||
reportDirName: runSlug,
|
reportDirName: runSlug,
|
||||||
taskPrefix: options.taskPrefix,
|
taskPrefix: options.taskPrefix,
|
||||||
taskColorIndex: options.taskColorIndex,
|
taskColorIndex: options.taskColorIndex,
|
||||||
|
initialIteration: options.initialIterationOverride,
|
||||||
});
|
});
|
||||||
|
|
||||||
abortHandler.install();
|
abortHandler.install();
|
||||||
@ -189,8 +207,8 @@ export async function executePiece(
|
|||||||
currentIteration = iteration;
|
currentIteration = iteration;
|
||||||
const movementIteration = (movementIterations.get(step.name) ?? 0) + 1;
|
const movementIteration = (movementIterations.get(step.name) ?? 0) + 1;
|
||||||
movementIterations.set(step.name, movementIteration);
|
movementIterations.set(step.name, movementIteration);
|
||||||
prefixWriter?.setMovementContext({ movementName: step.name, iteration, maxMovements: pieceConfig.maxMovements, movementIteration });
|
prefixWriter?.setMovementContext({ movementName: step.name, iteration, maxMovements: effectivePieceConfig.maxMovements, movementIteration });
|
||||||
out.info(`[${iteration}/${pieceConfig.maxMovements}] ${step.name} (${step.personaDisplayName})`);
|
out.info(`[${iteration}/${effectivePieceConfig.maxMovements}] ${step.name} (${step.personaDisplayName})`);
|
||||||
const movementProvider = providerInfo.provider ?? currentProvider;
|
const movementProvider = providerInfo.provider ?? currentProvider;
|
||||||
const movementModel = providerInfo.model ?? (movementProvider === currentProvider ? configuredModel : undefined) ?? '(default)';
|
const movementModel = providerInfo.model ?? (movementProvider === currentProvider ? configuredModel : undefined) ?? '(default)';
|
||||||
providerEventLogger.setMovement(step.name);
|
providerEventLogger.setMovement(step.name);
|
||||||
@ -203,7 +221,7 @@ export async function executePiece(
|
|||||||
const movementIndex = pieceConfig.movements.findIndex((m) => m.name === step.name);
|
const movementIndex = pieceConfig.movements.findIndex((m) => m.name === step.name);
|
||||||
displayRef.current = new StreamDisplay(step.personaDisplayName, isQuietMode(), {
|
displayRef.current = new StreamDisplay(step.personaDisplayName, isQuietMode(), {
|
||||||
iteration,
|
iteration,
|
||||||
maxMovements: pieceConfig.maxMovements,
|
maxMovements: effectivePieceConfig.maxMovements,
|
||||||
movementIndex: movementIndex >= 0 ? movementIndex : 0,
|
movementIndex: movementIndex >= 0 ? movementIndex : 0,
|
||||||
totalMovements: pieceConfig.movements.length,
|
totalMovements: pieceConfig.movements.length,
|
||||||
});
|
});
|
||||||
@ -271,7 +289,14 @@ export async function executePiece(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const finalState = await engine.run();
|
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) {
|
} catch (error) {
|
||||||
if (!runMetaManager.isFinalized) runMetaManager.finalize('aborted');
|
if (!runMetaManager.isFinalized) runMetaManager.finalize('aborted');
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@ -27,6 +27,8 @@ export interface ResolvedTaskExecution {
|
|||||||
autoPr: boolean;
|
autoPr: boolean;
|
||||||
draftPr: boolean;
|
draftPr: boolean;
|
||||||
issueNumber?: number;
|
issueNumber?: number;
|
||||||
|
maxMovementsOverride?: number;
|
||||||
|
initialIterationOverride?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRunTaskDirInstruction(reportDirName: string): string {
|
function buildRunTaskDirInstruction(reportDirName: string): string {
|
||||||
@ -166,6 +168,8 @@ export async function resolveTaskExecution(
|
|||||||
const execPiece = data.piece || defaultPiece;
|
const execPiece = data.piece || defaultPiece;
|
||||||
const startMovement = data.start_movement;
|
const startMovement = data.start_movement;
|
||||||
const retryNote = data.retry_note;
|
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 autoPr = data.auto_pr ?? resolvePieceConfigValue(defaultCwd, 'autoPr') ?? false;
|
||||||
const draftPr = data.draft_pr ?? resolvePieceConfigValue(defaultCwd, 'draftPr') ?? false;
|
const draftPr = data.draft_pr ?? resolvePieceConfigValue(defaultCwd, 'draftPr') ?? false;
|
||||||
@ -184,5 +188,7 @@ export async function resolveTaskExecution(
|
|||||||
...(startMovement ? { startMovement } : {}),
|
...(startMovement ? { startMovement } : {}),
|
||||||
...(retryNote ? { retryNote } : {}),
|
...(retryNote ? { retryNote } : {}),
|
||||||
...(data.issue !== undefined ? { issueNumber: data.issue } : {}),
|
...(data.issue !== undefined ? { issueNumber: data.issue } : {}),
|
||||||
|
...(maxMovementsOverride !== undefined ? { maxMovementsOverride } : {}),
|
||||||
|
...(initialIterationOverride !== undefined ? { initialIterationOverride } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,7 +19,7 @@ import type { TaskExecutionOptions, ExecuteTaskOptions, PieceExecutionResult } f
|
|||||||
import { runWithWorkerPool } from './parallelExecution.js';
|
import { runWithWorkerPool } from './parallelExecution.js';
|
||||||
import { resolveTaskExecution, resolveTaskIssue } from './resolveTask.js';
|
import { resolveTaskExecution, resolveTaskIssue } from './resolveTask.js';
|
||||||
import { postExecutionFlow } from './postExecution.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';
|
import { generateRunId, toSlackTaskDetail } from './slackSummaryAdapter.js';
|
||||||
|
|
||||||
export type { TaskExecutionOptions, ExecuteTaskOptions };
|
export type { TaskExecutionOptions, ExecuteTaskOptions };
|
||||||
@ -42,6 +42,8 @@ async function executeTaskWithResult(options: ExecuteTaskOptions): Promise<Piece
|
|||||||
taskPrefix,
|
taskPrefix,
|
||||||
taskColorIndex,
|
taskColorIndex,
|
||||||
taskDisplayLabel,
|
taskDisplayLabel,
|
||||||
|
maxMovementsOverride,
|
||||||
|
initialIterationOverride,
|
||||||
} = options;
|
} = options;
|
||||||
const pieceConfig = loadPieceByIdentifier(pieceIdentifier, projectCwd);
|
const pieceConfig = loadPieceByIdentifier(pieceIdentifier, projectCwd);
|
||||||
|
|
||||||
@ -82,8 +84,10 @@ async function executeTaskWithResult(options: ExecuteTaskOptions): Promise<Piece
|
|||||||
taskPrefix,
|
taskPrefix,
|
||||||
taskColorIndex,
|
taskColorIndex,
|
||||||
taskDisplayLabel,
|
taskDisplayLabel,
|
||||||
|
maxMovementsOverride,
|
||||||
|
initialIterationOverride,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute a single task with piece.
|
* Execute a single task with piece.
|
||||||
@ -141,6 +145,8 @@ export async function executeAndCompleteTask(
|
|||||||
autoPr,
|
autoPr,
|
||||||
draftPr,
|
draftPr,
|
||||||
issueNumber,
|
issueNumber,
|
||||||
|
maxMovementsOverride,
|
||||||
|
initialIterationOverride,
|
||||||
} = await resolveTaskExecution(task, cwd, pieceName, taskAbortSignal);
|
} = await resolveTaskExecution(task, cwd, pieceName, taskAbortSignal);
|
||||||
|
|
||||||
// cwd is always the project root; pass it as projectCwd so reports/sessions go there
|
// 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,
|
taskPrefix: parallelOptions?.taskPrefix,
|
||||||
taskColorIndex: parallelOptions?.taskColorIndex,
|
taskColorIndex: parallelOptions?.taskColorIndex,
|
||||||
taskDisplayLabel: parallelOptions?.taskDisplayLabel,
|
taskDisplayLabel: parallelOptions?.taskDisplayLabel,
|
||||||
|
maxMovementsOverride,
|
||||||
|
initialIterationOverride,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (taskRunResult.exceeded && taskRunResult.exceededInfo) {
|
||||||
|
persistExceededTaskResult(taskRunner, task, taskRunResult.exceededInfo);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const taskSuccess = taskRunResult.success;
|
const taskSuccess = taskRunResult.success;
|
||||||
const completedAt = new Date().toISOString();
|
const completedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { type TaskInfo, type TaskResult, TaskRunner } from '../../../infra/task/index.js';
|
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 { getErrorMessage } from '../../../shared/utils/index.js';
|
||||||
import type { PieceExecutionResult } from './types.js';
|
import type { ExceededInfo, PieceExecutionResult } from './types.js';
|
||||||
|
|
||||||
interface BuildTaskResultParams {
|
interface BuildTaskResultParams {
|
||||||
task: TaskInfo;
|
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(
|
export function persistTaskError(
|
||||||
taskRunner: TaskRunner,
|
taskRunner: TaskRunner,
|
||||||
task: TaskInfo,
|
task: TaskInfo,
|
||||||
|
|||||||
@ -9,12 +9,22 @@ import type { MovementProviderOptions } from '../../../core/models/piece-types.j
|
|||||||
import type { ProviderType } from '../../../infra/providers/index.js';
|
import type { ProviderType } from '../../../infra/providers/index.js';
|
||||||
import type { ProviderOptionsSource } from '../../../core/piece/types.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 */
|
/** Result of piece execution */
|
||||||
export interface PieceExecutionResult {
|
export interface PieceExecutionResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
lastMovement?: string;
|
lastMovement?: string;
|
||||||
lastMessage?: 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 */
|
/** Metadata from interactive mode, passed through to NDJSON logging */
|
||||||
@ -31,6 +41,10 @@ export interface PieceExecutionOptions {
|
|||||||
headerPrefix?: string;
|
headerPrefix?: string;
|
||||||
/** Project root directory (where .takt/ lives). */
|
/** Project root directory (where .takt/ lives). */
|
||||||
projectCwd: string;
|
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 for instruction metadata */
|
||||||
language?: Language;
|
language?: Language;
|
||||||
provider?: ProviderType;
|
provider?: ProviderType;
|
||||||
@ -79,6 +93,10 @@ export interface ExecuteTaskOptions {
|
|||||||
projectCwd: string;
|
projectCwd: string;
|
||||||
/** Agent provider/model overrides */
|
/** Agent provider/model overrides */
|
||||||
agentOverrides?: TaskExecutionOptions;
|
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 */
|
/** Enable interactive user input during step transitions */
|
||||||
interactiveUserInput?: boolean;
|
interactiveUserInput?: boolean;
|
||||||
/** Interactive mode result metadata for NDJSON logging */
|
/** Interactive mode result metadata for NDJSON logging */
|
||||||
|
|||||||
@ -15,7 +15,7 @@ import {
|
|||||||
syncBranchWithRoot,
|
syncBranchWithRoot,
|
||||||
pullFromRemote,
|
pullFromRemote,
|
||||||
} from './taskActions.js';
|
} from './taskActions.js';
|
||||||
import { deletePendingTask, deleteFailedTask, deleteCompletedTask, deleteAllTasks } from './taskDeleteActions.js';
|
import { deleteTaskByKind, deleteAllTasks } from './taskDeleteActions.js';
|
||||||
import { retryFailedTask } from './taskRetryActions.js';
|
import { retryFailedTask } from './taskRetryActions.js';
|
||||||
import { listTasksNonInteractive, type ListNonInteractiveOptions } from './listNonInteractive.js';
|
import { listTasksNonInteractive, type ListNonInteractiveOptions } from './listNonInteractive.js';
|
||||||
import { formatTaskStatusLabel, formatShortDate } from './taskStatusLabel.js';
|
import { formatTaskStatusLabel, formatShortDate } from './taskStatusLabel.js';
|
||||||
@ -39,10 +39,31 @@ export {
|
|||||||
} from './instructMode.js';
|
} from './instructMode.js';
|
||||||
|
|
||||||
type PendingTaskAction = 'delete';
|
type PendingTaskAction = 'delete';
|
||||||
|
type ExceededTaskAction = 'requeue' | 'delete';
|
||||||
|
|
||||||
type FailedTaskAction = 'retry' | 'delete';
|
type FailedTaskAction = 'retry' | 'delete';
|
||||||
type CompletedTaskAction = ListAction;
|
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> {
|
async function showPendingTaskAndPromptAction(task: TaskListItem): Promise<PendingTaskAction | null> {
|
||||||
header(formatTaskStatusLabel(task));
|
header(formatTaskStatusLabel(task));
|
||||||
info(` Created: ${task.createdAt}`);
|
info(` Created: ${task.createdAt}`);
|
||||||
@ -139,7 +160,7 @@ export async function listTasks(
|
|||||||
if (!task) continue;
|
if (!task) continue;
|
||||||
const taskAction = await showPendingTaskAndPromptAction(task);
|
const taskAction = await showPendingTaskAndPromptAction(task);
|
||||||
if (taskAction === 'delete') {
|
if (taskAction === 'delete') {
|
||||||
await deletePendingTask(task);
|
await deleteTaskByKind(task);
|
||||||
}
|
}
|
||||||
} else if (type === 'running') {
|
} else if (type === 'running') {
|
||||||
const task = tasks[idx];
|
const task = tasks[idx];
|
||||||
@ -180,11 +201,11 @@ export async function listTasks(
|
|||||||
break;
|
break;
|
||||||
case 'merge':
|
case 'merge':
|
||||||
if (mergeBranch(cwd, task)) {
|
if (mergeBranch(cwd, task)) {
|
||||||
runner.deleteCompletedTask(task.name);
|
runner.deleteTask(task.name, 'completed');
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'delete':
|
case 'delete':
|
||||||
await deleteCompletedTask(task);
|
await deleteTaskByKind(task);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} else if (type === 'failed') {
|
} else if (type === 'failed') {
|
||||||
@ -194,7 +215,16 @@ export async function listTasks(
|
|||||||
if (taskAction === 'retry') {
|
if (taskAction === 'retry') {
|
||||||
await retryFailedTask(task, cwd);
|
await retryFailedTask(task, cwd);
|
||||||
} else if (taskAction === 'delete') {
|
} 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -106,7 +106,7 @@ export async function listTasksNonInteractive(
|
|||||||
return;
|
return;
|
||||||
case 'merge':
|
case 'merge':
|
||||||
if (mergeBranch(cwd, task)) {
|
if (mergeBranch(cwd, task)) {
|
||||||
runner.deleteCompletedTask(task.name);
|
runner.deleteTask(task.name, 'completed');
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
case 'delete':
|
case 'delete':
|
||||||
@ -115,7 +115,7 @@ export async function listTasksNonInteractive(
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
if (deleteBranch(cwd, task)) {
|
if (deleteBranch(cwd, task)) {
|
||||||
runner.deleteCompletedTask(task.name);
|
runner.deleteTask(task.name, 'completed');
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,71 +20,30 @@ function cleanupBranchIfPresent(task: TaskListItem, projectDir: string): boolean
|
|||||||
return deleteBranch(projectDir, task);
|
return deleteBranch(projectDir, task);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deletePendingTask(task: TaskListItem): Promise<boolean> {
|
export async function deleteTaskByKind(task: TaskListItem): Promise<boolean> {
|
||||||
const confirmed = await confirm(`Delete pending task "${task.name}"?`, false);
|
if (task.kind === 'running') throw new Error(`Cannot delete running task "${task.name}"`);
|
||||||
if (!confirmed) return false;
|
const confirmed = await confirm(`Delete ${task.kind} task "${task.name}"?`, 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);
|
|
||||||
if (!confirmed) return false;
|
if (!confirmed) return false;
|
||||||
const projectDir = getProjectDir(task);
|
const projectDir = getProjectDir(task);
|
||||||
try {
|
try {
|
||||||
if (!cleanupBranchIfPresent(task, projectDir)) {
|
if (!cleanupBranchIfPresent(task, projectDir)) return false;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const runner = new TaskRunner(projectDir);
|
const runner = new TaskRunner(projectDir);
|
||||||
runner.deleteFailedTask(task.name);
|
runner.deleteTask(task.name, task.kind);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = getErrorMessage(err);
|
const msg = getErrorMessage(err);
|
||||||
logError(`Failed to delete failed task "${task.name}": ${msg}`);
|
logError(`Failed to delete ${task.kind} task "${task.name}": ${msg}`);
|
||||||
log.error('Failed to delete failed task', { name: task.name, filePath: task.filePath, error: msg });
|
log.error('Failed to delete task', { name: task.name, kind: task.kind, filePath: task.filePath, error: msg });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
success(`Deleted failed task: ${task.name}`);
|
success(`Deleted ${task.kind} task: ${task.name}`);
|
||||||
log.info('Deleted failed task', { name: task.name, filePath: task.filePath });
|
log.info('Deleted task', { name: task.name, kind: task.kind, filePath: task.filePath });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteCompletedTask(task: TaskListItem): Promise<boolean> {
|
type DeletableTask = TaskListItem & { kind: 'pending' | 'failed' | 'completed' | 'exceeded' };
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteAllTasks(tasks: TaskListItem[]): Promise<boolean> {
|
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;
|
if (deletable.length === 0) return false;
|
||||||
|
|
||||||
const confirmed = await confirm(`Delete all ${deletable.length} tasks?`, false);
|
const confirmed = await confirm(`Delete all ${deletable.length} tasks?`, false);
|
||||||
@ -100,13 +59,7 @@ export async function deleteAllTasks(tasks: TaskListItem[]): Promise<boolean> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const runner = new TaskRunner(projectDir);
|
const runner = new TaskRunner(projectDir);
|
||||||
if (task.kind === 'pending') {
|
runner.deleteTask(task.name, task.kind);
|
||||||
runner.deletePendingTask(task.name);
|
|
||||||
} else if (task.kind === 'failed') {
|
|
||||||
runner.deleteFailedTask(task.name);
|
|
||||||
} else if (task.kind === 'completed') {
|
|
||||||
runner.deleteCompletedTask(task.name);
|
|
||||||
}
|
|
||||||
deletedCount++;
|
deletedCount++;
|
||||||
log.info('Deleted task in bulk delete', { name: task.name, kind: task.kind });
|
log.info('Deleted task in bulk delete', { name: task.name, kind: task.kind });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@ -5,6 +5,7 @@ const TASK_STATUS_BY_KIND: Record<TaskListItem['kind'], string> = {
|
|||||||
running: 'running',
|
running: 'running',
|
||||||
completed: 'completed',
|
completed: 'completed',
|
||||||
failed: 'failed',
|
failed: 'failed',
|
||||||
|
exceeded: 'exceeded',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function formatTaskStatusLabel(task: TaskListItem): string {
|
export function formatTaskStatusLabel(task: TaskListItem): string {
|
||||||
|
|||||||
@ -35,7 +35,7 @@ export async function watchTasks(cwd: string, options?: TaskExecutionOptions): P
|
|||||||
|
|
||||||
header('TAKT Watch Mode');
|
header('TAKT Watch Mode');
|
||||||
info(`Piece: ${pieceName}`);
|
info(`Piece: ${pieceName}`);
|
||||||
info(`Watching: ${taskRunner.getTasksDir()}`);
|
info(`Watching: ${taskRunner.getTasksFilePath()}`);
|
||||||
if (recovered > 0) {
|
if (recovered > 0) {
|
||||||
info(`Recovered ${recovered} interrupted running task(s) to pending.`);
|
info(`Recovered ${recovered} interrupted running task(s) to pending.`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,13 +18,13 @@ export function showTaskList(runner: TaskRunner): void {
|
|||||||
divider('=', 60);
|
divider('=', 60);
|
||||||
header('TAKT タスク一覧');
|
header('TAKT タスク一覧');
|
||||||
divider('=', 60);
|
divider('=', 60);
|
||||||
console.log(chalk.gray(`タスクディレクトリ: ${runner.getTasksDir()}`));
|
console.log(chalk.gray(`タスクファイル: ${runner.getTasksFilePath()}`));
|
||||||
divider('-', 60);
|
divider('-', 60);
|
||||||
|
|
||||||
if (tasks.length === 0) {
|
if (tasks.length === 0) {
|
||||||
console.log();
|
console.log();
|
||||||
info('実行待ちのタスクはありません。');
|
info('実行待ちのタスクはありません。');
|
||||||
console.log(chalk.gray(`\n${runner.getTasksDir()} を確認してください。`));
|
console.log(chalk.gray(`\nタスクファイル: ${runner.getTasksFilePath()} を確認してください。`));
|
||||||
console.log(chalk.gray('takt add でタスクを追加できます。'));
|
console.log(chalk.gray('takt add でタスクを追加できます。'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -34,7 +34,6 @@ export function showTaskList(runner: TaskRunner): void {
|
|||||||
for (let i = 0; i < tasks.length; i++) {
|
for (let i = 0; i < tasks.length; i++) {
|
||||||
const task = tasks[i];
|
const task = tasks[i];
|
||||||
if (task) {
|
if (task) {
|
||||||
// タスク内容の最初の行を取得
|
|
||||||
const firstLine = task.content.trim().split('\n')[0]?.slice(0, 60) ?? '';
|
const firstLine = task.content.trim().split('\n')[0]?.slice(0, 60) ?? '';
|
||||||
console.log(chalk.cyan.bold(` [${i + 1}] ${task.name}`));
|
console.log(chalk.cyan.bold(` [${i + 1}] ${task.name}`));
|
||||||
console.log(chalk.gray(` ${firstLine}...`));
|
console.log(chalk.gray(` ${firstLine}...`));
|
||||||
|
|||||||
@ -45,9 +45,9 @@ export function resolveTaskContent(projectDir: string, task: TaskRecord): string
|
|||||||
return fs.readFileSync(contentPath, 'utf-8');
|
return fs.readFileSync(contentPath, 'utf-8');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toTaskData(projectDir: string, task: TaskRecord): TaskFileData {
|
function buildTaskFileData(task: TaskRecord, content: string): TaskFileData {
|
||||||
return TaskFileSchema.parse({
|
return TaskFileSchema.parse({
|
||||||
task: resolveTaskContent(projectDir, task),
|
task: content,
|
||||||
worktree: task.worktree,
|
worktree: task.worktree,
|
||||||
branch: task.branch,
|
branch: task.branch,
|
||||||
piece: task.piece,
|
piece: task.piece,
|
||||||
@ -56,9 +56,15 @@ export function toTaskData(projectDir: string, task: TaskRecord): TaskFileData {
|
|||||||
retry_note: task.retry_note,
|
retry_note: task.retry_note,
|
||||||
auto_pr: task.auto_pr,
|
auto_pr: task.auto_pr,
|
||||||
draft_pr: task.draft_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 {
|
export function toTaskInfo(projectDir: string, tasksFile: string, task: TaskRecord): TaskInfo {
|
||||||
const content = resolveTaskContent(projectDir, task);
|
const content = resolveTaskContent(projectDir, task);
|
||||||
return {
|
return {
|
||||||
@ -70,17 +76,7 @@ export function toTaskInfo(projectDir: string, tasksFile: string, task: TaskReco
|
|||||||
createdAt: task.created_at,
|
createdAt: task.created_at,
|
||||||
status: task.status,
|
status: task.status,
|
||||||
worktreePath: task.worktree_path,
|
worktreePath: task.worktree_path,
|
||||||
data: TaskFileSchema.parse({
|
data: buildTaskFileData(task, content),
|
||||||
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,
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -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 {
|
function toRunningTaskItem(projectDir: string, tasksFile: string, task: TaskRecord): TaskListItem {
|
||||||
return {
|
return {
|
||||||
kind: 'running',
|
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 {
|
return {
|
||||||
name: task.name,
|
name: task.name,
|
||||||
createdAt: task.created_at,
|
createdAt: task.created_at,
|
||||||
@ -141,5 +146,7 @@ export function toTaskListItem(projectDir: string, tasksFile: string, task: Task
|
|||||||
return toCompletedTaskItem(projectDir, tasksFile, task);
|
return toCompletedTaskItem(projectDir, tasksFile, task);
|
||||||
case 'failed':
|
case 'failed':
|
||||||
return toFailedTaskItem(projectDir, tasksFile, task);
|
return toFailedTaskItem(projectDir, tasksFile, task);
|
||||||
|
case 'exceeded':
|
||||||
|
return toExceededTaskItem(projectDir, tasksFile, task);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { TaskStore } from './store.js';
|
|||||||
import { TaskLifecycleService } from './taskLifecycleService.js';
|
import { TaskLifecycleService } from './taskLifecycleService.js';
|
||||||
import { TaskQueryService } from './taskQueryService.js';
|
import { TaskQueryService } from './taskQueryService.js';
|
||||||
import { TaskDeletionService } from './taskDeletionService.js';
|
import { TaskDeletionService } from './taskDeletionService.js';
|
||||||
|
import { TaskExceedService, type ExceedTaskOptions } from './taskExceedService.js';
|
||||||
|
|
||||||
export type { TaskInfo, TaskResult, TaskListItem };
|
export type { TaskInfo, TaskResult, TaskListItem };
|
||||||
|
|
||||||
@ -14,6 +15,7 @@ export class TaskRunner {
|
|||||||
private readonly lifecycle: TaskLifecycleService;
|
private readonly lifecycle: TaskLifecycleService;
|
||||||
private readonly query: TaskQueryService;
|
private readonly query: TaskQueryService;
|
||||||
private readonly deletion: TaskDeletionService;
|
private readonly deletion: TaskDeletionService;
|
||||||
|
private readonly exceed: TaskExceedService;
|
||||||
|
|
||||||
constructor(private readonly projectDir: string) {
|
constructor(private readonly projectDir: string) {
|
||||||
this.store = new TaskStore(projectDir);
|
this.store = new TaskStore(projectDir);
|
||||||
@ -21,13 +23,14 @@ export class TaskRunner {
|
|||||||
this.lifecycle = new TaskLifecycleService(projectDir, this.tasksFile, this.store);
|
this.lifecycle = new TaskLifecycleService(projectDir, this.tasksFile, this.store);
|
||||||
this.query = new TaskQueryService(projectDir, this.tasksFile, this.store);
|
this.query = new TaskQueryService(projectDir, this.tasksFile, this.store);
|
||||||
this.deletion = new TaskDeletionService(this.store);
|
this.deletion = new TaskDeletionService(this.store);
|
||||||
|
this.exceed = new TaskExceedService(this.store);
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureDirs(): void {
|
ensureDirs(): void {
|
||||||
this.store.ensureDirs();
|
this.store.ensureDirs();
|
||||||
}
|
}
|
||||||
|
|
||||||
getTasksDir(): string {
|
getTasksFilePath(): string {
|
||||||
return this.tasksFile;
|
return this.tasksFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -76,6 +79,10 @@ export class TaskRunner {
|
|||||||
return this.query.listFailedTasks();
|
return this.query.listFailedTasks();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
listExceededTasks(): TaskListItem[] {
|
||||||
|
return this.query.listExceededTasks();
|
||||||
|
}
|
||||||
|
|
||||||
requeueFailedTask(taskRef: string, startMovement?: string, retryNote?: string): string {
|
requeueFailedTask(taskRef: string, startMovement?: string, retryNote?: string): string {
|
||||||
return this.lifecycle.requeueFailedTask(taskRef, startMovement, retryNote);
|
return this.lifecycle.requeueFailedTask(taskRef, startMovement, retryNote);
|
||||||
}
|
}
|
||||||
@ -98,15 +105,15 @@ export class TaskRunner {
|
|||||||
return this.lifecycle.startReExecution(taskRef, allowedStatuses, startMovement, retryNote);
|
return this.lifecycle.startReExecution(taskRef, allowedStatuses, startMovement, retryNote);
|
||||||
}
|
}
|
||||||
|
|
||||||
deletePendingTask(name: string): void {
|
deleteTask(name: string, kind: 'pending' | 'failed' | 'completed' | 'exceeded'): void {
|
||||||
this.deletion.deletePendingTask(name);
|
this.deletion.deleteTaskByNameAndStatus(name, kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteFailedTask(name: string): void {
|
exceedTask(taskName: string, options: ExceedTaskOptions): void {
|
||||||
this.deletion.deleteFailedTask(name);
|
this.exceed.exceedTask(taskName, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteCompletedTask(name: string): void {
|
requeueExceededTask(taskName: string): void {
|
||||||
this.deletion.deleteCompletedTask(name);
|
this.exceed.requeueExceededTask(taskName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,8 @@ export const TaskExecutionConfigSchema = z.object({
|
|||||||
retry_note: z.string().optional(),
|
retry_note: z.string().optional(),
|
||||||
auto_pr: z.boolean().optional(),
|
auto_pr: z.boolean().optional(),
|
||||||
draft_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 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 type TaskStatus = z.infer<typeof TaskStatusSchema>;
|
||||||
|
|
||||||
export const TaskFailureSchema = z.object({
|
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>;
|
export type TaskRecord = z.infer<typeof TaskRecordSchema>;
|
||||||
|
|
||||||
|
|||||||
@ -3,19 +3,7 @@ import { TaskStore } from './store.js';
|
|||||||
export class TaskDeletionService {
|
export class TaskDeletionService {
|
||||||
constructor(private readonly store: TaskStore) {}
|
constructor(private readonly store: TaskStore) {}
|
||||||
|
|
||||||
deletePendingTask(name: string): void {
|
deleteTaskByNameAndStatus(name: string, status: 'pending' | 'failed' | 'completed' | 'exceeded'): void {
|
||||||
this.deleteTaskByNameAndStatus(name, 'pending');
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteFailedTask(name: string): void {
|
|
||||||
this.deleteTaskByNameAndStatus(name, 'failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteCompletedTask(name: string): void {
|
|
||||||
this.deleteTaskByNameAndStatus(name, 'completed');
|
|
||||||
}
|
|
||||||
|
|
||||||
private deleteTaskByNameAndStatus(name: string, status: 'pending' | 'failed' | 'completed'): void {
|
|
||||||
this.store.update((current) => {
|
this.store.update((current) => {
|
||||||
const exists = current.tasks.some((task) => task.name === name && task.status === status);
|
const exists = current.tasks.some((task) => task.name === name && task.status === status);
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
|
|||||||
65
src/infra/task/taskExceedService.ts
Normal file
65
src/infra/task/taskExceedService.ts
Normal 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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import type { TaskInfo, TaskListItem } from './types.js';
|
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';
|
import { TaskStore } from './store.js';
|
||||||
|
|
||||||
export class TaskQueryService {
|
export class TaskQueryService {
|
||||||
@ -34,4 +34,11 @@ export class TaskQueryService {
|
|||||||
.filter((task) => task.status === 'failed')
|
.filter((task) => task.status === 'failed')
|
||||||
.map((task) => toFailedTaskItem(this.projectDir, this.tasksFile, task));
|
.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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -78,7 +78,7 @@ export interface SummarizeOptions {
|
|||||||
|
|
||||||
/** pending/failedタスクのリストアイテム */
|
/** pending/failedタスクのリストアイテム */
|
||||||
export interface TaskListItem {
|
export interface TaskListItem {
|
||||||
kind: 'pending' | 'running' | 'completed' | 'failed';
|
kind: 'pending' | 'running' | 'completed' | 'failed' | 'exceeded';
|
||||||
name: string;
|
name: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
filePath: string;
|
filePath: string;
|
||||||
@ -93,4 +93,6 @@ export interface TaskListItem {
|
|||||||
completedAt?: string;
|
completedAt?: string;
|
||||||
ownerPid?: number;
|
ownerPid?: number;
|
||||||
issueNumber?: number;
|
issueNumber?: number;
|
||||||
|
exceededMaxMovements?: number;
|
||||||
|
exceededCurrentIteration?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user