takt: github-issue-215-issue (#294)

This commit is contained in:
nrs 2026-02-18 22:48:50 +09:00 committed by GitHub
parent 16d7f9f979
commit 3de574e81b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 291 additions and 598 deletions

View File

@ -0,0 +1,86 @@
/**
* Tests for task execution resolution.
*/
import { describe, it, expect, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import type { TaskInfo } from '../infra/task/index.js';
import { resolveTaskExecution } from '../features/tasks/execute/resolveTask.js';
const tempRoots = new Set<string>();
afterEach(() => {
for (const root of tempRoots) {
fs.rmSync(root, { recursive: true, force: true });
}
tempRoots.clear();
});
function createTempProjectDir(): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-resolve-task-test-'));
tempRoots.add(root);
return root;
}
function createTask(overrides: Partial<TaskInfo>): TaskInfo {
return {
filePath: '/tasks/task.yaml',
name: 'task-name',
content: 'Run task',
createdAt: '2026-01-01T00:00:00.000Z',
status: 'pending',
data: { task: 'Run task' },
...overrides,
};
}
describe('resolveTaskExecution', () => {
it('should return defaults when task data is null', async () => {
const root = createTempProjectDir();
const task = createTask({ data: null });
const result = await resolveTaskExecution(task, root, 'default');
expect(result).toEqual({
execCwd: root,
execPiece: 'default',
isWorktree: false,
autoPr: false,
});
});
it('should generate report context and copy issue-bearing task spec', async () => {
const root = createTempProjectDir();
const taskDir = '.takt/tasks/issue-task-123';
const sourceTaskDir = path.join(root, taskDir);
const sourceOrderPath = path.join(sourceTaskDir, 'order.md');
fs.mkdirSync(sourceTaskDir, { recursive: true });
fs.writeFileSync(sourceOrderPath, '# task instruction');
const task = createTask({
taskDir,
data: {
task: 'Run issue task',
issue: 12345,
auto_pr: true,
},
});
const result = await resolveTaskExecution(task, root, 'default');
const expectedReportOrderPath = path.join(root, '.takt', 'runs', 'issue-task-123', 'context', 'task', 'order.md');
expect(result).toMatchObject({
execCwd: root,
execPiece: 'default',
isWorktree: false,
autoPr: true,
reportDirName: 'issue-task-123',
issueNumber: 12345,
taskPrompt: expect.stringContaining('Primary spec: `.takt/runs/issue-task-123/context/task/order.md`'),
});
expect(fs.existsSync(expectedReportOrderPath)).toBe(true);
expect(fs.readFileSync(expectedReportOrderPath, 'utf-8')).toBe('# task instruction');
});
});

View File

@ -15,6 +15,16 @@ describe('TaskPrefixWriter', () => {
}); });
describe('constructor', () => { describe('constructor', () => {
it('should use issue number when provided', () => {
const writer = new TaskPrefixWriter({ taskName: 'my-task', colorIndex: 0, issue: 123, writeFn });
writer.writeLine('Issue task');
expect(output).toHaveLength(1);
expect(output[0]).toContain('[#123]');
expect(output[0]).not.toContain('[my-t]');
});
it('should cycle colors for different colorIndex values', () => { it('should cycle colors for different colorIndex values', () => {
const writer0 = new TaskPrefixWriter({ taskName: 'task-a', colorIndex: 0, writeFn }); const writer0 = new TaskPrefixWriter({ taskName: 'task-a', colorIndex: 0, writeFn });
const writer4 = new TaskPrefixWriter({ taskName: 'task-a', colorIndex: 4, writeFn }); const writer4 = new TaskPrefixWriter({ taskName: 'task-a', colorIndex: 4, writeFn });
@ -27,6 +37,16 @@ describe('TaskPrefixWriter', () => {
expect(output[1]).toContain('\x1b[36m'); expect(output[1]).toContain('\x1b[36m');
}); });
it('should use display label when provided', () => {
const writer = new TaskPrefixWriter({ taskName: 'my-task', colorIndex: 0, displayLabel: '#12345', writeFn });
writer.writeLine('Hello World');
expect(output).toHaveLength(1);
expect(output[0]).toContain('[#12345]');
expect(output[0]).not.toContain('[my-t]');
});
it('should assign correct colors in order', () => { it('should assign correct colors in order', () => {
const writers = [0, 1, 2, 3].map( const writers = [0, 1, 2, 3].map(
(i) => new TaskPrefixWriter({ taskName: `t${i}`, colorIndex: i, writeFn }), (i) => new TaskPrefixWriter({ taskName: `t${i}`, colorIndex: i, writeFn }),

View File

@ -1,66 +1,55 @@
/** /**
* Tests for resolveTaskExecution * Tests for execute task option propagation.
*/ */
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { TaskInfo } from '../infra/task/index.js';
const { mockResolveTaskExecution, mockExecutePiece, mockLoadPieceByIdentifier, mockLoadGlobalConfig, mockLoadProjectConfig, mockBuildTaskResult, mockPersistTaskResult, mockPostExecutionFlow } =
vi.hoisted(() => ({
mockResolveTaskExecution: vi.fn(),
mockExecutePiece: vi.fn(),
mockLoadPieceByIdentifier: vi.fn(),
mockLoadGlobalConfig: vi.fn(),
mockLoadProjectConfig: vi.fn(),
mockBuildTaskResult: vi.fn(),
mockPersistTaskResult: vi.fn(),
mockPostExecutionFlow: vi.fn(),
}));
vi.mock('../features/tasks/execute/resolveTask.js', () => ({
resolveTaskExecution: (...args: unknown[]) => mockResolveTaskExecution(...args),
}));
vi.mock('../features/tasks/execute/pieceExecution.js', () => ({
executePiece: (...args: unknown[]) => mockExecutePiece(...args),
}));
vi.mock('../features/tasks/execute/taskResultHandler.js', () => ({
buildTaskResult: (...args: unknown[]) => mockBuildTaskResult(...args),
persistTaskResult: (...args: unknown[]) => mockPersistTaskResult(...args),
}));
vi.mock('../features/tasks/execute/postExecution.js', () => ({
postExecutionFlow: (...args: unknown[]) => mockPostExecutionFlow(...args),
}));
// Mock dependencies before importing the module under test
vi.mock('../infra/config/index.js', () => ({ vi.mock('../infra/config/index.js', () => ({
loadPieceByIdentifier: vi.fn(), loadPieceByIdentifier: (...args: unknown[]) => mockLoadPieceByIdentifier(...args),
isPiecePath: vi.fn(() => false), isPiecePath: () => false,
loadGlobalConfig: vi.fn(() => ({})), loadGlobalConfig: () => mockLoadGlobalConfig(),
loadProjectConfig: () => mockLoadProjectConfig(),
})); }));
import { loadGlobalConfig } from '../infra/config/index.js'; vi.mock('../shared/ui/index.js', () => ({
const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig); header: vi.fn(),
info: vi.fn(),
vi.mock('../infra/task/index.js', async (importOriginal) => ({ error: vi.fn(),
...(await importOriginal<Record<string, unknown>>()), status: vi.fn(),
TaskRunner: vi.fn(), success: vi.fn(),
blankLine: vi.fn(),
})); }));
vi.mock('../infra/task/clone.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
createSharedClone: vi.fn(),
removeClone: vi.fn(),
}));
vi.mock('../infra/task/git.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
getCurrentBranch: vi.fn(() => 'main'),
}));
vi.mock('../infra/task/autoCommit.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
autoCommitAndPush: vi.fn(),
}));
vi.mock('../infra/task/summarize.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
summarizeTaskName: vi.fn(),
}));
vi.mock('../shared/ui/index.js', () => {
const info = vi.fn();
return {
header: vi.fn(),
info,
error: vi.fn(),
success: vi.fn(),
status: vi.fn(),
blankLine: vi.fn(),
withProgress: vi.fn(async (start, done, operation) => {
info(start);
const result = await operation();
info(typeof done === 'function' ? done(result) : done);
return result;
}),
};
});
vi.mock('../shared/utils/index.js', async (importOriginal) => ({ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()), ...(await importOriginal<Record<string, unknown>>()),
createLogger: () => ({ createLogger: () => ({
@ -68,560 +57,81 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
debug: vi.fn(), debug: vi.fn(),
error: vi.fn(), error: vi.fn(),
}), }),
getErrorMessage: vi.fn((e) => e.message), getErrorMessage: vi.fn((error: unknown) => String(error)),
})); }));
vi.mock('../features/tasks/execute/pieceExecution.js', () => ({ vi.mock('../shared/i18n/index.js', () => ({
executePiece: vi.fn(), getLabel: vi.fn((key: string) => key),
})); }));
vi.mock('../shared/context.js', () => ({ import { executeAndCompleteTask } from '../features/tasks/execute/taskExecution.js';
isQuietMode: vi.fn(() => false),
}));
vi.mock('../shared/constants.js', () => ({ const createTask = (name: string): TaskInfo => ({
DEFAULT_PIECE_NAME: 'default', name,
DEFAULT_LANGUAGE: 'en', content: `Task: ${name}`,
})); filePath: `/tasks/${name}.yaml`,
createdAt: '2026-02-16T00:00:00.000Z',
import { createSharedClone } from '../infra/task/clone.js'; status: 'pending',
import { getCurrentBranch } from '../infra/task/git.js'; data: { task: `Task: ${name}` },
import { summarizeTaskName } from '../infra/task/summarize.js';
import { info } from '../shared/ui/index.js';
import { resolveTaskExecution } from '../features/tasks/index.js';
import type { TaskInfo } from '../infra/task/index.js';
const mockCreateSharedClone = vi.mocked(createSharedClone);
const mockGetCurrentBranch = vi.mocked(getCurrentBranch);
const mockSummarizeTaskName = vi.mocked(summarizeTaskName);
const mockInfo = vi.mocked(info);
beforeEach(() => {
vi.clearAllMocks();
}); });
describe('resolveTaskExecution', () => { describe('executeAndCompleteTask', () => {
it('should return defaults when task has no data', async () => { beforeEach(() => {
// Given: Task without structured data vi.clearAllMocks();
const task: TaskInfo = {
name: 'simple-task',
content: 'Simple task content',
filePath: '/tasks/simple-task.yaml',
createdAt: '2026-02-09T00:00:00.000Z',
status: 'pending',
data: null,
};
// When mockLoadPieceByIdentifier.mockReturnValue({
const result = await resolveTaskExecution(task, '/project', 'default'); name: 'default',
movements: [],
// Then });
expect(result).toEqual({ mockLoadGlobalConfig.mockReturnValue({
language: 'en',
provider: 'claude',
personaProviders: {},
providerProfiles: {},
});
mockLoadProjectConfig.mockReturnValue({
provider: 'claude',
providerProfiles: {},
});
mockBuildTaskResult.mockReturnValue({ success: true });
mockResolveTaskExecution.mockResolvedValue({
execCwd: '/project', execCwd: '/project',
execPiece: 'default', execPiece: 'default',
isWorktree: false, isWorktree: false,
autoPr: false, autoPr: false,
}); taskPrompt: undefined,
expect(mockSummarizeTaskName).not.toHaveBeenCalled(); reportDirName: undefined,
expect(mockCreateSharedClone).not.toHaveBeenCalled();
});
it('should return defaults when data has no worktree option', async () => {
// Given: Task with data but no worktree
const task: TaskInfo = {
name: 'task-with-data',
content: 'Task content',
filePath: '/tasks/task.yaml',
data: {
task: 'Task content',
},
};
// When
const result = await resolveTaskExecution(task, '/project', 'default');
// Then
expect(result.isWorktree).toBe(false);
expect(mockSummarizeTaskName).not.toHaveBeenCalled();
});
it('should create shared clone with AI-summarized slug when worktree option is true', async () => {
// Given: Task with worktree option
const task: TaskInfo = {
name: 'japanese-task',
content: '認証機能を追加する',
filePath: '/tasks/japanese-task.yaml',
data: {
task: '認証機能を追加する',
worktree: true,
},
};
mockSummarizeTaskName.mockResolvedValue('add-auth');
mockCreateSharedClone.mockReturnValue({
path: '/project/../20260128T0504-add-auth',
branch: 'takt/20260128T0504-add-auth',
});
// When
const result = await resolveTaskExecution(task, '/project', 'default');
// Then
expect(mockSummarizeTaskName).toHaveBeenCalledWith('認証機能を追加する', { cwd: '/project' });
expect(mockCreateSharedClone).toHaveBeenCalledWith('/project', {
worktree: true,
branch: undefined, branch: undefined,
taskSlug: 'add-auth', worktreePath: undefined,
}); baseBranch: undefined,
expect(mockGetCurrentBranch).toHaveBeenCalledWith('/project'); startMovement: undefined,
expect(result).toEqual({ retryNote: undefined,
execCwd: '/project/../20260128T0504-add-auth', issueNumber: undefined,
execPiece: 'default',
isWorktree: true,
autoPr: false,
branch: 'takt/20260128T0504-add-auth',
worktreePath: '/project/../20260128T0504-add-auth',
baseBranch: 'main',
}); });
mockExecutePiece.mockResolvedValue({ success: true });
}); });
it('should display generating message before AI call', async () => { it('should pass taskDisplayLabel from parallel options into executePiece', async () => {
// Given: Task with worktree // Given: Parallel execution passes an issue-style taskDisplayLabel.
const task: TaskInfo = { const task = createTask('task-with-issue');
name: 'test-task', const taskDisplayLabel = '#12345';
content: 'Test task', const abortController = new AbortController();
filePath: '/tasks/test.yaml',
data: {
task: 'Test task',
worktree: true,
},
};
mockSummarizeTaskName.mockResolvedValue('test-task'); // When
mockCreateSharedClone.mockReturnValue({ await executeAndCompleteTask(task, {} as never, '/project', 'default', undefined, {
path: '/project/../test-task', abortSignal: abortController.signal,
branch: 'takt/test-task', taskPrefix: taskDisplayLabel,
taskColorIndex: 0,
taskDisplayLabel,
}); });
// When // Then: executePiece receives the propagated display label.
await resolveTaskExecution(task, '/project', 'default'); expect(mockExecutePiece).toHaveBeenCalledTimes(1);
const pieceExecutionOptions = mockExecutePiece.mock.calls[0]?.[3] as {
// Then taskDisplayLabel?: string;
expect(mockInfo).toHaveBeenCalledWith('Generating branch name...'); taskPrefix?: string;
expect(mockInfo).toHaveBeenCalledWith('Branch name generated: test-task');
});
it('should use task content (not name) for AI summarization', async () => {
// Given: Task where name differs from content
const task: TaskInfo = {
name: 'old-file-name',
content: 'New feature implementation details',
filePath: '/tasks/old-file-name.yaml',
data: {
task: 'New feature implementation details',
worktree: true,
},
}; };
expect(pieceExecutionOptions?.taskDisplayLabel).toBe(taskDisplayLabel);
mockSummarizeTaskName.mockResolvedValue('new-feature'); expect(pieceExecutionOptions?.taskPrefix).toBe(taskDisplayLabel);
mockCreateSharedClone.mockReturnValue({
path: '/project/../new-feature',
branch: 'takt/new-feature',
});
// When
await resolveTaskExecution(task, '/project', 'default');
// Then: Should use content, not file name
expect(mockSummarizeTaskName).toHaveBeenCalledWith('New feature implementation details', { cwd: '/project' });
});
it('should use piece override from task data', async () => {
// Given: Task with piece override
const task: TaskInfo = {
name: 'task-with-piece',
content: 'Task content',
filePath: '/tasks/task.yaml',
data: {
task: 'Task content',
piece: 'custom-piece',
},
};
// When
const result = await resolveTaskExecution(task, '/project', 'default');
// Then
expect(result.execPiece).toBe('custom-piece');
});
it('should pass branch option to createSharedClone when specified', async () => {
// Given: Task with custom branch
const task: TaskInfo = {
name: 'task-with-branch',
content: 'Task content',
filePath: '/tasks/task.yaml',
data: {
task: 'Task content',
worktree: true,
branch: 'feature/custom-branch',
},
};
mockSummarizeTaskName.mockResolvedValue('custom-task');
mockCreateSharedClone.mockReturnValue({
path: '/project/../custom-task',
branch: 'feature/custom-branch',
});
// When
await resolveTaskExecution(task, '/project', 'default');
// Then
expect(mockCreateSharedClone).toHaveBeenCalledWith('/project', {
worktree: true,
branch: 'feature/custom-branch',
taskSlug: 'custom-task',
});
});
it('should display clone creation info', async () => {
// Given: Task with worktree
const task: TaskInfo = {
name: 'info-task',
content: 'Info task',
filePath: '/tasks/info.yaml',
data: {
task: 'Info task',
worktree: true,
},
};
mockSummarizeTaskName.mockResolvedValue('info-task');
mockCreateSharedClone.mockReturnValue({
path: '/project/../20260128-info-task',
branch: 'takt/20260128-info-task',
});
// When
await resolveTaskExecution(task, '/project', 'default');
// Then
expect(mockInfo).toHaveBeenCalledWith(
'Clone created: /project/../20260128-info-task (branch: takt/20260128-info-task)'
);
});
it('should return autoPr from task YAML when specified', async () => {
// Given: Task with auto_pr option
const task: TaskInfo = {
name: 'task-with-auto-pr',
content: 'Task content',
filePath: '/tasks/task.yaml',
data: {
task: 'Task content',
auto_pr: true,
},
};
// When
const result = await resolveTaskExecution(task, '/project', 'default');
// Then
expect(result.autoPr).toBe(true);
});
it('should return autoPr: false from task YAML when specified as false', async () => {
// Given: Task with auto_pr: false
const task: TaskInfo = {
name: 'task-no-auto-pr',
content: 'Task content',
filePath: '/tasks/task.yaml',
data: {
task: 'Task content',
auto_pr: false,
},
};
// When
const result = await resolveTaskExecution(task, '/project', 'default');
// Then
expect(result.autoPr).toBe(false);
});
it('should fall back to global config autoPr when task YAML does not specify', async () => {
// Given: Task without auto_pr, global config has autoPr
mockLoadGlobalConfig.mockReturnValue({
language: 'en',
defaultPiece: 'default',
logLevel: 'info',
autoPr: true,
});
const task: TaskInfo = {
name: 'task-no-auto-pr-setting',
content: 'Task content',
filePath: '/tasks/task.yaml',
data: {
task: 'Task content',
},
};
// When
const result = await resolveTaskExecution(task, '/project', 'default');
// Then
expect(result.autoPr).toBe(true);
});
it('should return false autoPr when neither task nor config specifies', async () => {
// Given: Neither task nor config has autoPr
mockLoadGlobalConfig.mockReturnValue({
language: 'en',
defaultPiece: 'default',
logLevel: 'info',
});
const task: TaskInfo = {
name: 'task-default',
content: 'Task content',
filePath: '/tasks/task.yaml',
data: {
task: 'Task content',
},
};
// When
const result = await resolveTaskExecution(task, '/project', 'default');
// Then
expect(result.autoPr).toBe(false);
});
it('should prioritize task YAML auto_pr over global config', async () => {
// Given: Task has auto_pr: false, global config has autoPr: true
mockLoadGlobalConfig.mockReturnValue({
language: 'en',
defaultPiece: 'default',
logLevel: 'info',
autoPr: true,
});
const task: TaskInfo = {
name: 'task-override',
content: 'Task content',
filePath: '/tasks/task.yaml',
data: {
task: 'Task content',
auto_pr: false,
},
};
// When
const result = await resolveTaskExecution(task, '/project', 'default');
// Then
expect(result.autoPr).toBe(false);
});
it('should capture baseBranch from getCurrentBranch when worktree is used', async () => {
// Given: Task with worktree, on 'develop' branch
mockGetCurrentBranch.mockReturnValue('develop');
const task: TaskInfo = {
name: 'task-on-develop',
content: 'Task on develop branch',
filePath: '/tasks/task.yaml',
data: {
task: 'Task on develop branch',
worktree: true,
},
};
mockSummarizeTaskName.mockResolvedValue('task-develop');
mockCreateSharedClone.mockReturnValue({
path: '/project/../task-develop',
branch: 'takt/task-develop',
});
// When
const result = await resolveTaskExecution(task, '/project', 'default');
// Then
expect(mockGetCurrentBranch).toHaveBeenCalledWith('/project');
expect(result.baseBranch).toBe('develop');
});
it('should not set baseBranch when worktree is not used', async () => {
// Given: Task without worktree
const task: TaskInfo = {
name: 'task-no-worktree',
content: 'Task without worktree',
filePath: '/tasks/task.yaml',
data: {
task: 'Task without worktree',
},
};
// When
const result = await resolveTaskExecution(task, '/project', 'default');
// Then
expect(mockGetCurrentBranch).not.toHaveBeenCalled();
expect(result.baseBranch).toBeUndefined();
});
it('should return issueNumber from task data when specified', async () => {
// Given: Task with issue number
const task: TaskInfo = {
name: 'task-with-issue',
content: 'Fix authentication bug',
filePath: '/tasks/task.yaml',
data: {
task: 'Fix authentication bug',
issue: 131,
},
};
// When
const result = await resolveTaskExecution(task, '/project', 'default');
// Then
expect(result.issueNumber).toBe(131);
});
it('should return undefined issueNumber when task data has no issue', async () => {
// Given: Task without issue
const task: TaskInfo = {
name: 'task-no-issue',
content: 'Task content',
filePath: '/tasks/task.yaml',
data: {
task: 'Task content',
},
};
// When
const result = await resolveTaskExecution(task, '/project', 'default');
// Then
expect(result.issueNumber).toBeUndefined();
});
it('should not start clone creation when abortSignal is already aborted', async () => {
// Given: Worktree task with pre-aborted signal
const task: TaskInfo = {
name: 'aborted-before-clone',
content: 'Task content',
filePath: '/tasks/task.yaml',
data: {
task: 'Task content',
worktree: true,
},
};
const controller = new AbortController();
controller.abort();
// When / Then
await expect(resolveTaskExecution(task, '/project', 'default', controller.signal)).rejects.toThrow('Task execution aborted');
expect(mockSummarizeTaskName).not.toHaveBeenCalled();
expect(mockCreateSharedClone).not.toHaveBeenCalled();
});
it('should stage task_dir spec into run context and return reportDirName', async () => {
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-taskdir-normal-'));
const projectDir = path.join(tmpRoot, 'project');
fs.mkdirSync(path.join(projectDir, '.takt', 'tasks', '20260201-015714-foptng'), { recursive: true });
const sourceOrder = path.join(projectDir, '.takt', 'tasks', '20260201-015714-foptng', 'order.md');
fs.writeFileSync(sourceOrder, '# normal task spec\n', 'utf-8');
const task: TaskInfo = {
name: 'task-with-dir',
content: 'Task content',
taskDir: '.takt/tasks/20260201-015714-foptng',
filePath: '/tasks/task.yaml',
data: {
task: 'Task content',
},
};
const result = await resolveTaskExecution(task, projectDir, 'default');
expect(result.reportDirName).toBe('20260201-015714-foptng');
expect(result.execCwd).toBe(projectDir);
const stagedOrder = path.join(projectDir, '.takt', 'runs', '20260201-015714-foptng', 'context', 'task', 'order.md');
expect(fs.existsSync(stagedOrder)).toBe(true);
expect(fs.readFileSync(stagedOrder, 'utf-8')).toContain('normal task spec');
expect(result.taskPrompt).toContain('Primary spec: `.takt/runs/20260201-015714-foptng/context/task/order.md`.');
expect(result.taskPrompt).not.toContain(projectDir);
});
it('should throw when taskDir format is invalid', async () => {
const task: TaskInfo = {
name: 'task-with-invalid-dir',
content: 'Task content',
taskDir: '.takt/reports/20260201-015714-foptng',
filePath: '/tasks/task.yaml',
data: {
task: 'Task content',
},
};
await expect(resolveTaskExecution(task, '/project', 'default')).rejects.toThrow(
'Invalid task_dir format: .takt/reports/20260201-015714-foptng',
);
});
it('should throw when taskDir contains parent directory segment', async () => {
const task: TaskInfo = {
name: 'task-with-parent-dir',
content: 'Task content',
taskDir: '.takt/tasks/..',
filePath: '/tasks/task.yaml',
data: {
task: 'Task content',
},
};
await expect(resolveTaskExecution(task, '/project', 'default')).rejects.toThrow(
'Invalid task_dir format: .takt/tasks/..',
);
});
it('should stage task_dir spec into worktree run context and return run-scoped task prompt', async () => {
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-taskdir-'));
const projectDir = path.join(tmpRoot, 'project');
const cloneDir = path.join(tmpRoot, 'clone');
fs.mkdirSync(path.join(projectDir, '.takt', 'tasks', '20260201-015714-foptng'), { recursive: true });
fs.mkdirSync(cloneDir, { recursive: true });
const sourceOrder = path.join(projectDir, '.takt', 'tasks', '20260201-015714-foptng', 'order.md');
fs.writeFileSync(sourceOrder, '# webhook task\n', 'utf-8');
const task: TaskInfo = {
name: 'task-with-taskdir-worktree',
content: 'Task content',
taskDir: '.takt/tasks/20260201-015714-foptng',
filePath: '/tasks/task.yaml',
data: {
task: 'Task content',
worktree: true,
},
};
mockSummarizeTaskName.mockResolvedValue('webhook-task');
mockCreateSharedClone.mockReturnValue({
path: cloneDir,
branch: 'takt/webhook-task',
});
const result = await resolveTaskExecution(task, projectDir, 'default');
const stagedOrder = path.join(cloneDir, '.takt', 'runs', '20260201-015714-foptng', 'context', 'task', 'order.md');
expect(fs.existsSync(stagedOrder)).toBe(true);
expect(fs.readFileSync(stagedOrder, 'utf-8')).toContain('webhook task');
expect(result.taskPrompt).toContain('Implement using only the files in `.takt/runs/20260201-015714-foptng/context/task`.');
expect(result.taskPrompt).toContain('Primary spec: `.takt/runs/20260201-015714-foptng/context/task/order.md`.');
expect(result.taskPrompt).not.toContain(projectDir);
}); });
}); });

View File

@ -45,11 +45,17 @@ const mockInfo = vi.mocked(info);
const TEST_POLL_INTERVAL_MS = 50; const TEST_POLL_INTERVAL_MS = 50;
function createTask(name: string): TaskInfo { function createTask(name: string, options?: { issue?: number }): TaskInfo {
return { return {
name, name,
content: `Task: ${name}`, content: `Task: ${name}`,
filePath: `/tasks/${name}.yaml`, filePath: `/tasks/${name}.yaml`,
createdAt: '2026-01-01T00:00:00.000Z',
status: 'pending',
data: {
task: `Task: ${name}`,
...(options?.issue !== undefined ? { issue: options.issue } : {}),
},
}; };
} }
@ -135,10 +141,41 @@ describe('runWithWorkerPool', () => {
// Then // Then
expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1); expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1);
const parallelOpts = mockExecuteAndCompleteTask.mock.calls[0]?.[5]; const parallelOpts = mockExecuteAndCompleteTask.mock.calls[0]?.[5];
expect(parallelOpts).toEqual({ expect(parallelOpts).toMatchObject({
abortSignal: expect.any(AbortSignal), abortSignal: expect.any(AbortSignal),
taskPrefix: 'my-task', taskPrefix: 'my-task',
taskColorIndex: 0, taskColorIndex: 0,
taskDisplayLabel: undefined,
});
});
it('should use full issue number as taskPrefix label when task has issue in parallel execution', async () => {
// Given: task with 5-digit issue number should not be truncated
const issueNumber = 12345;
const tasks = [createTask('issue-task', { issue: issueNumber })];
const runner = createMockTaskRunner([]);
const stdoutChunks: string[] = [];
const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => {
stdoutChunks.push(String(chunk));
return true;
});
// When
await runWithWorkerPool(runner as never, tasks, 2, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS);
// Then: Issue label is used instead of truncated task name
writeSpy.mockRestore();
const allOutput = stdoutChunks.join('');
expect(allOutput).toContain('[#12345]');
expect(allOutput).not.toContain('[#123]');
expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1);
const parallelOpts = mockExecuteAndCompleteTask.mock.calls[0]?.[5];
expect(parallelOpts).toEqual({
abortSignal: expect.any(AbortSignal),
taskPrefix: `#${issueNumber}`,
taskDisplayLabel: `#${issueNumber}`,
taskColorIndex: 0,
}); });
}); });
@ -153,10 +190,11 @@ describe('runWithWorkerPool', () => {
// Then // Then
expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1); expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1);
const parallelOpts = mockExecuteAndCompleteTask.mock.calls[0]?.[5]; const parallelOpts = mockExecuteAndCompleteTask.mock.calls[0]?.[5];
expect(parallelOpts).toEqual({ expect(parallelOpts).toMatchObject({
abortSignal: expect.any(AbortSignal), abortSignal: expect.any(AbortSignal),
taskPrefix: undefined, taskPrefix: undefined,
taskColorIndex: undefined, taskColorIndex: undefined,
taskDisplayLabel: undefined,
}); });
}); });

View File

@ -205,9 +205,17 @@ function fillSlots(
const task = queue.shift()!; const task = queue.shift()!;
const isParallel = concurrency > 1; const isParallel = concurrency > 1;
const colorIndex = colorCounter.value++; const colorIndex = colorCounter.value++;
const issueNumber = task.data?.issue;
const taskPrefix = issueNumber === undefined ? task.name : `#${issueNumber}`;
const taskDisplayLabel = issueNumber === undefined ? undefined : taskPrefix;
if (isParallel) { if (isParallel) {
const writer = new TaskPrefixWriter({ taskName: task.name, colorIndex }); const writer = new TaskPrefixWriter({
taskName: task.name,
colorIndex,
issue: issueNumber,
displayLabel: taskDisplayLabel,
});
writer.writeLine(`=== Task: ${task.name} ===`); writer.writeLine(`=== Task: ${task.name} ===`);
} else { } else {
blankLine(); blankLine();
@ -216,8 +224,9 @@ function fillSlots(
const promise = executeAndCompleteTask(task, taskRunner, cwd, pieceName, options, { const promise = executeAndCompleteTask(task, taskRunner, cwd, pieceName, options, {
abortSignal: abortController.signal, abortSignal: abortController.signal,
taskPrefix: isParallel ? task.name : undefined, taskPrefix: isParallel ? taskPrefix : undefined,
taskColorIndex: isParallel ? colorIndex : undefined, taskColorIndex: isParallel ? colorIndex : undefined,
taskDisplayLabel: isParallel ? taskDisplayLabel : undefined,
}); });
active.set(promise, task); active.set(promise, task);
} }

View File

@ -232,7 +232,11 @@ export async function executePiece(
// When taskPrefix is set (parallel execution), route all output through TaskPrefixWriter // When taskPrefix is set (parallel execution), route all output through TaskPrefixWriter
const prefixWriter = options.taskPrefix != null const prefixWriter = options.taskPrefix != null
? new TaskPrefixWriter({ taskName: options.taskPrefix, colorIndex: options.taskColorIndex! }) ? new TaskPrefixWriter({
taskName: options.taskPrefix,
colorIndex: options.taskColorIndex!,
displayLabel: options.taskDisplayLabel,
})
: undefined; : undefined;
const out = createOutputFns(prefixWriter); const out = createOutputFns(prefixWriter);

View File

@ -51,7 +51,22 @@ function resolveTaskIssue(issueNumber: number | undefined): ReturnType<typeof fe
} }
async function executeTaskWithResult(options: ExecuteTaskOptions): Promise<PieceExecutionResult> { async function executeTaskWithResult(options: ExecuteTaskOptions): Promise<PieceExecutionResult> {
const { task, cwd, pieceIdentifier, projectCwd, agentOverrides, interactiveUserInput, interactiveMetadata, startMovement, retryNote, reportDirName, abortSignal, taskPrefix, taskColorIndex } = options; const {
task,
cwd,
pieceIdentifier,
projectCwd,
agentOverrides,
interactiveUserInput,
interactiveMetadata,
startMovement,
retryNote,
reportDirName,
abortSignal,
taskPrefix,
taskColorIndex,
taskDisplayLabel,
} = options;
const pieceConfig = loadPieceByIdentifier(pieceIdentifier, projectCwd); const pieceConfig = loadPieceByIdentifier(pieceIdentifier, projectCwd);
if (!pieceConfig) { if (!pieceConfig) {
@ -91,8 +106,9 @@ async function executeTaskWithResult(options: ExecuteTaskOptions): Promise<Piece
abortSignal, abortSignal,
taskPrefix, taskPrefix,
taskColorIndex, taskColorIndex,
taskDisplayLabel,
}); });
} }
/** /**
* Execute a single task with piece. * Execute a single task with piece.
@ -116,7 +132,7 @@ export async function executeAndCompleteTask(
cwd: string, cwd: string,
pieceName: string, pieceName: string,
options?: TaskExecutionOptions, options?: TaskExecutionOptions,
parallelOptions?: { abortSignal?: AbortSignal; taskPrefix?: string; taskColorIndex?: number }, parallelOptions?: { abortSignal?: AbortSignal; taskPrefix?: string; taskColorIndex?: number; taskDisplayLabel?: string },
): Promise<boolean> { ): Promise<boolean> {
const startedAt = new Date().toISOString(); const startedAt = new Date().toISOString();
const taskAbortController = new AbortController(); const taskAbortController = new AbortController();
@ -164,6 +180,7 @@ export async function executeAndCompleteTask(
abortSignal: taskAbortSignal, abortSignal: taskAbortSignal,
taskPrefix: parallelOptions?.taskPrefix, taskPrefix: parallelOptions?.taskPrefix,
taskColorIndex: parallelOptions?.taskColorIndex, taskColorIndex: parallelOptions?.taskColorIndex,
taskDisplayLabel: parallelOptions?.taskDisplayLabel,
}); });
const taskSuccess = taskRunResult.success; const taskSuccess = taskRunResult.success;

View File

@ -57,6 +57,8 @@ export interface PieceExecutionOptions {
abortSignal?: AbortSignal; abortSignal?: AbortSignal;
/** Task name prefix for parallel execution output (e.g. "[task-name] output...") */ /** Task name prefix for parallel execution output (e.g. "[task-name] output...") */
taskPrefix?: string; taskPrefix?: string;
/** Optional full task label used instead of taskName truncation when prefixed output is rendered */
taskDisplayLabel?: string;
/** Color index for task prefix (cycled mod 4 across concurrent tasks) */ /** Color index for task prefix (cycled mod 4 across concurrent tasks) */
taskColorIndex?: number; taskColorIndex?: number;
} }
@ -91,6 +93,8 @@ export interface ExecuteTaskOptions {
abortSignal?: AbortSignal; abortSignal?: AbortSignal;
/** Task name prefix for parallel execution output (e.g. "[task-name] output...") */ /** Task name prefix for parallel execution output (e.g. "[task-name] output...") */
taskPrefix?: string; taskPrefix?: string;
/** Optional full task label used instead of taskName truncation when prefixed output is rendered */
taskDisplayLabel?: string;
/** Color index for task prefix (cycled mod 4 across concurrent tasks) */ /** Color index for task prefix (cycled mod 4 across concurrent tasks) */
taskColorIndex?: number; taskColorIndex?: number;
} }

View File

@ -21,6 +21,10 @@ const RESET = '\x1b[0m';
export interface TaskPrefixWriterOptions { export interface TaskPrefixWriterOptions {
/** Task name used in the prefix */ /** Task name used in the prefix */
taskName: string; taskName: string;
/** Optional pre-computed label used in the prefix (overrides taskName truncation). */
displayLabel?: string;
/** Optional issue number used in the prefix (overrides taskName and display label). */
issue?: number;
/** Color index for the prefix (cycled mod 4) */ /** Color index for the prefix (cycled mod 4) */
colorIndex: number; colorIndex: number;
/** Override process.stdout.write for testing */ /** Override process.stdout.write for testing */
@ -49,7 +53,8 @@ export class TaskPrefixWriter {
constructor(options: TaskPrefixWriterOptions) { constructor(options: TaskPrefixWriterOptions) {
const color = TASK_COLORS[options.colorIndex % TASK_COLORS.length]; const color = TASK_COLORS[options.colorIndex % TASK_COLORS.length];
const taskLabel = options.taskName.slice(0, 4); const issueLabel = options.issue == null ? undefined : `#${options.issue}`;
const taskLabel = issueLabel ?? options.displayLabel ?? options.taskName.slice(0, 4);
this.taskPrefix = `${color}[${taskLabel}]${RESET}`; this.taskPrefix = `${color}[${taskLabel}]${RESET}`;
this.writeFn = options.writeFn ?? ((text: string) => process.stdout.write(text)); this.writeFn = options.writeFn ?? ((text: string) => process.stdout.write(text));
} }