takt: github-issue-215-issue (#294)
This commit is contained in:
parent
16d7f9f979
commit
3de574e81b
86
src/__tests__/resolveTask.test.ts
Normal file
86
src/__tests__/resolveTask.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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 }),
|
||||||
|
|||||||
@ -1,65 +1,54 @@
|
|||||||
/**
|
/**
|
||||||
* 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);
|
|
||||||
|
|
||||||
vi.mock('../infra/task/index.js', async (importOriginal) => ({
|
|
||||||
...(await importOriginal<Record<string, unknown>>()),
|
|
||||||
TaskRunner: 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(),
|
header: vi.fn(),
|
||||||
info,
|
info: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
success: vi.fn(),
|
|
||||||
status: vi.fn(),
|
status: vi.fn(),
|
||||||
|
success: vi.fn(),
|
||||||
blankLine: 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>>()),
|
||||||
@ -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');
|
|
||||||
mockCreateSharedClone.mockReturnValue({
|
|
||||||
path: '/project/../test-task',
|
|
||||||
branch: 'takt/test-task',
|
|
||||||
});
|
|
||||||
|
|
||||||
// When
|
// When
|
||||||
await resolveTaskExecution(task, '/project', 'default');
|
await executeAndCompleteTask(task, {} as never, '/project', 'default', undefined, {
|
||||||
|
abortSignal: abortController.signal,
|
||||||
// Then
|
taskPrefix: taskDisplayLabel,
|
||||||
expect(mockInfo).toHaveBeenCalledWith('Generating branch name...');
|
taskColorIndex: 0,
|
||||||
expect(mockInfo).toHaveBeenCalledWith('Branch name generated: test-task');
|
taskDisplayLabel,
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use task content (not name) for AI summarization', async () => {
|
// Then: executePiece receives the propagated display label.
|
||||||
// Given: Task where name differs from content
|
expect(mockExecutePiece).toHaveBeenCalledTimes(1);
|
||||||
const task: TaskInfo = {
|
const pieceExecutionOptions = mockExecutePiece.mock.calls[0]?.[3] as {
|
||||||
name: 'old-file-name',
|
taskDisplayLabel?: string;
|
||||||
content: 'New feature implementation details',
|
taskPrefix?: string;
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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,6 +106,7 @@ async function executeTaskWithResult(options: ExecuteTaskOptions): Promise<Piece
|
|||||||
abortSignal,
|
abortSignal,
|
||||||
taskPrefix,
|
taskPrefix,
|
||||||
taskColorIndex,
|
taskColorIndex,
|
||||||
|
taskDisplayLabel,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user