takt/src/__tests__/runAllTasks-concurrency.test.ts
Tomohisa Takaoka a08adadfb3
fix: PR creation failure handling + use default branch for base (#345)
* fix: mark task as failed when PR creation fails

Previously, when PR creation failed (e.g. invalid base branch),
the task was still marked as 'completed' even though the PR was
not created. This fix ensures:

- postExecutionFlow returns prFailed/prError on failure
- executeAndCompleteTask marks the task as failed when PR fails
- selectAndExecuteTask runs postExecution before persisting result

The pipeline path (executePipeline) already handled this correctly
via EXIT_PR_CREATION_FAILED.

* fix: use detectDefaultBranch instead of getCurrentBranch for PR base

Previously, baseBranch for PR creation was set to HEAD's current branch
via getCurrentBranch(). When the user was on a feature branch like
'codex/pr-16-review', PRs were created with --base codex/pr-16-review,
which fails because it doesn't exist on the remote.

Now uses detectDefaultBranch() (via git symbolic-ref refs/remotes/origin/HEAD)
to always use the actual default branch (main/master) as the PR base.

Affected paths:
- resolveTask.ts (takt run)
- selectAndExecute.ts (interactive mode)
- pipeline/execute.ts (takt pipeline)
2026-02-22 20:37:14 +09:00

852 lines
27 KiB
TypeScript

/**
* Tests for runAllTasks concurrency support (worker pool)
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { TaskInfo } from '../infra/task/index.js';
const { mockLoadConfigRaw } = vi.hoisted(() => ({
mockLoadConfigRaw: vi.fn(() => ({
language: 'en',
defaultPiece: 'default',
logLevel: 'info',
concurrency: 1,
taskPollIntervalMs: 500,
})),
}));
// Mock dependencies before importing the module under test
vi.mock('../infra/config/index.js', () => ({
loadPieceByIdentifier: vi.fn(),
isPiecePath: vi.fn(() => false),
loadConfig: (...args: unknown[]) => {
const raw = mockLoadConfigRaw(...args) as Record<string, unknown>;
if ('global' in raw && 'project' in raw) {
return raw;
}
return {
global: raw,
project: { piece: 'default' },
};
},
resolvePieceConfigValues: (_projectDir: string, keys: readonly string[]) => {
const raw = mockLoadConfigRaw() as Record<string, unknown>;
const config = ('global' in raw && 'project' in raw)
? { ...raw.global as Record<string, unknown>, ...raw.project as Record<string, unknown> }
: { ...raw, piece: 'default', provider: 'claude', verbose: false };
const result: Record<string, unknown> = {};
for (const key of keys) {
result[key] = config[key];
}
return result;
},
resolveConfigValueWithSource: (_projectDir: string, key: string) => {
const raw = mockLoadConfigRaw() as Record<string, unknown>;
const config = ('global' in raw && 'project' in raw)
? { ...raw.global as Record<string, unknown>, ...raw.project as Record<string, unknown> }
: { ...raw, piece: 'default', provider: 'claude', verbose: false };
return { value: config[key], source: 'project' };
},
}));
const mockLoadConfig = mockLoadConfigRaw;
const {
mockClaimNextTasks,
mockCompleteTask,
mockFailTask,
mockRecoverInterruptedRunningTasks,
mockListAllTaskItems,
mockNotifySuccess,
mockNotifyError,
mockSendSlackNotification,
mockGetSlackWebhookUrl,
} = vi.hoisted(() => ({
mockClaimNextTasks: vi.fn(),
mockCompleteTask: vi.fn(),
mockFailTask: vi.fn(),
mockRecoverInterruptedRunningTasks: vi.fn(),
mockListAllTaskItems: vi.fn().mockReturnValue([]),
mockNotifySuccess: vi.fn(),
mockNotifyError: vi.fn(),
mockSendSlackNotification: vi.fn(),
mockGetSlackWebhookUrl: vi.fn(),
}));
vi.mock('../infra/task/index.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
TaskRunner: vi.fn().mockImplementation(() => ({
claimNextTasks: mockClaimNextTasks,
completeTask: mockCompleteTask,
failTask: mockFailTask,
recoverInterruptedRunningTasks: mockRecoverInterruptedRunningTasks,
listAllTaskItems: mockListAllTaskItems,
})),
}));
vi.mock('../infra/task/clone.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
createSharedClone: vi.fn(),
removeClone: vi.fn(),
}));
vi.mock('../infra/task/branchList.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
detectDefaultBranch: vi.fn(() => 'main'),
}));
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', () => ({
header: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
status: vi.fn(),
blankLine: vi.fn(),
}));
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
createLogger: () => ({
info: vi.fn(),
debug: vi.fn(),
error: vi.fn(),
}),
getErrorMessage: vi.fn((e) => e.message),
notifySuccess: mockNotifySuccess,
notifyError: mockNotifyError,
sendSlackNotification: mockSendSlackNotification,
getSlackWebhookUrl: mockGetSlackWebhookUrl,
}));
vi.mock('../features/tasks/execute/pieceExecution.js', () => ({
executePiece: vi.fn(() => Promise.resolve({ success: true })),
}));
vi.mock('../shared/context.js', () => ({
isQuietMode: vi.fn(() => false),
}));
vi.mock('../shared/constants.js', () => ({
DEFAULT_PIECE_NAME: 'default',
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../infra/github/index.js', () => ({
createPullRequest: vi.fn(),
buildPrBody: vi.fn(),
pushBranch: vi.fn(),
}));
vi.mock('../infra/claude/query-manager.js', () => ({
interruptAllQueries: vi.fn(),
}));
vi.mock('../agents/ai-judge.js', () => ({
callAiJudge: vi.fn(),
}));
vi.mock('../shared/exitCodes.js', () => ({
EXIT_SIGINT: 130,
}));
vi.mock('../shared/i18n/index.js', () => ({
getLabel: vi.fn((key: string) => key),
}));
import { info, header, status, success, error as errorFn } from '../shared/ui/index.js';
import { runAllTasks } from '../features/tasks/index.js';
import { executePiece } from '../features/tasks/execute/pieceExecution.js';
import { loadPieceByIdentifier } from '../infra/config/index.js';
const mockInfo = vi.mocked(info);
const mockHeader = vi.mocked(header);
const mockStatus = vi.mocked(status);
const mockSuccess = vi.mocked(success);
const mockError = vi.mocked(errorFn);
const mockExecutePiece = vi.mocked(executePiece);
const mockLoadPieceByIdentifier = vi.mocked(loadPieceByIdentifier);
function createTask(name: string): TaskInfo {
return {
name,
content: `Task: ${name}`,
filePath: `/tasks/${name}.yaml`,
createdAt: '2026-02-09T00:00:00.000Z',
status: 'pending',
data: null,
};
}
beforeEach(() => {
vi.clearAllMocks();
mockRecoverInterruptedRunningTasks.mockReturnValue(0);
});
describe('runAllTasks concurrency', () => {
describe('sequential execution (concurrency=1)', () => {
beforeEach(() => {
mockLoadConfig.mockReturnValue({
language: 'en',
defaultPiece: 'default',
logLevel: 'info',
notificationSound: true,
notificationSoundEvents: { runComplete: true, runAbort: true },
concurrency: 1,
taskPollIntervalMs: 500,
});
});
it('should show no-tasks message when no tasks exist', async () => {
// Given: No pending tasks
mockClaimNextTasks.mockReturnValue([]);
// When
await runAllTasks('/project');
// Then
expect(mockInfo).toHaveBeenCalledWith('No pending tasks in .takt/tasks.yaml');
});
it('should execute tasks sequentially via worker pool when concurrency is 1', async () => {
// Given: Two tasks available sequentially
const task1 = createTask('task-1');
const task2 = createTask('task-2');
mockClaimNextTasks
.mockReturnValueOnce([task1])
.mockReturnValueOnce([task2])
.mockReturnValueOnce([]);
// When
await runAllTasks('/project');
// Then: Worker pool uses claimNextTasks for fetching more tasks
expect(mockClaimNextTasks).toHaveBeenCalled();
expect(mockStatus).toHaveBeenCalledWith('Total', '2');
});
});
describe('parallel execution (concurrency>1)', () => {
beforeEach(() => {
mockLoadConfig.mockReturnValue({
language: 'en',
defaultPiece: 'default',
logLevel: 'info',
notificationSound: true,
notificationSoundEvents: { runComplete: true, runAbort: true },
concurrency: 3,
taskPollIntervalMs: 500,
});
});
it('should display concurrency info when concurrency > 1', async () => {
// Given: Tasks available
const task1 = createTask('task-1');
mockClaimNextTasks
.mockReturnValueOnce([task1])
.mockReturnValueOnce([]);
// When
await runAllTasks('/project');
// Then
expect(mockInfo).toHaveBeenCalledWith('Concurrency: 3');
});
it('should execute tasks using worker pool when concurrency > 1', async () => {
// Given: 3 tasks available
const task1 = createTask('task-1');
const task2 = createTask('task-2');
const task3 = createTask('task-3');
mockClaimNextTasks
.mockReturnValueOnce([task1, task2, task3])
.mockReturnValueOnce([]);
// In parallel mode, task start messages go through TaskPrefixWriter → process.stdout.write
const stdoutChunks: string[] = [];
const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => {
stdoutChunks.push(String(chunk));
return true;
});
// When
await runAllTasks('/project');
writeSpy.mockRestore();
// Then: Task names displayed with prefix in stdout
const allOutput = stdoutChunks.join('');
expect(allOutput).toContain('[task]');
expect(allOutput).toContain('=== Task: task-1 ===');
expect(allOutput).toContain('[task]');
expect(allOutput).toContain('=== Task: task-2 ===');
expect(allOutput).toContain('[task]');
expect(allOutput).toContain('=== Task: task-3 ===');
expect(mockStatus).toHaveBeenCalledWith('Total', '3');
});
it('should fill slots as tasks complete (worker pool behavior)', async () => {
// Given: 5 tasks, concurrency=3
// Worker pool should start 3, then fill slots as tasks complete
const tasks = Array.from({ length: 5 }, (_, i) => createTask(`task-${i + 1}`));
mockClaimNextTasks
.mockReturnValueOnce(tasks.slice(0, 3))
.mockReturnValueOnce(tasks.slice(3, 5))
.mockReturnValueOnce([]);
// When
await runAllTasks('/project');
// Then: All 5 tasks executed
expect(mockStatus).toHaveBeenCalledWith('Total', '5');
});
});
describe('default concurrency', () => {
it('should default to sequential when concurrency is not set', async () => {
// Given: Config without explicit concurrency (defaults to 1)
mockLoadConfig.mockReturnValue({
language: 'en',
defaultPiece: 'default',
logLevel: 'info',
notificationSound: false,
concurrency: 1,
taskPollIntervalMs: 500,
});
const task1 = createTask('task-1');
mockClaimNextTasks
.mockReturnValueOnce([task1])
.mockReturnValueOnce([]);
// When
await runAllTasks('/project');
// Then: No concurrency info displayed
const concurrencyInfoCalls = mockInfo.mock.calls.filter(
(call) => typeof call[0] === 'string' && call[0].startsWith('Concurrency:')
);
expect(concurrencyInfoCalls).toHaveLength(0);
expect(mockNotifySuccess).not.toHaveBeenCalled();
expect(mockNotifyError).not.toHaveBeenCalled();
});
});
describe('parallel execution behavior', () => {
const fakePieceConfig = {
name: 'default',
movements: [{ name: 'implement', personaDisplayName: 'coder' }],
initialMovement: 'implement',
maxMovements: 10,
};
beforeEach(() => {
mockLoadConfig.mockReturnValue({
language: 'en',
defaultPiece: 'default',
logLevel: 'info',
concurrency: 3,
taskPollIntervalMs: 500,
});
// Return a valid piece config so executeTask reaches executePiece
mockLoadPieceByIdentifier.mockReturnValue(fakePieceConfig as never);
});
it('should run tasks concurrently, not sequentially', async () => {
// Given: 2 tasks with delayed execution to verify concurrency
const task1 = createTask('slow-1');
const task2 = createTask('slow-2');
const executionOrder: string[] = [];
// Each task takes 50ms — if sequential, total > 100ms; if parallel, total ~50ms
mockExecutePiece.mockImplementation((_config, task) => {
executionOrder.push(`start:${task}`);
return new Promise((resolve) => {
setTimeout(() => {
executionOrder.push(`end:${task}`);
resolve({ success: true });
}, 50);
});
});
mockClaimNextTasks
.mockReturnValueOnce([task1, task2])
.mockReturnValueOnce([]);
// When
const startTime = Date.now();
await runAllTasks('/project');
const elapsed = Date.now() - startTime;
// Then: Both tasks started before either completed (concurrent execution)
expect(executionOrder[0]).toBe('start:Task: slow-1');
expect(executionOrder[1]).toBe('start:Task: slow-2');
// Elapsed time should be closer to 50ms than 100ms (allowing margin for CI)
expect(elapsed).toBeLessThan(150);
});
it('should fill slots immediately when a task completes (no batch waiting)', async () => {
// Given: 3 tasks, concurrency=2, task1 finishes quickly, task2 takes longer
mockLoadConfig.mockReturnValue({
language: 'en',
defaultPiece: 'default',
logLevel: 'info',
concurrency: 2,
taskPollIntervalMs: 500,
});
const task1 = createTask('fast');
const task2 = createTask('slow');
const task3 = createTask('after-fast');
const executionOrder: string[] = [];
mockExecutePiece.mockImplementation((_config, task) => {
executionOrder.push(`start:${task}`);
const delay = (task as string).includes('slow') ? 80 : 20;
return new Promise((resolve) => {
setTimeout(() => {
executionOrder.push(`end:${task}`);
resolve({ success: true });
}, delay);
});
});
mockClaimNextTasks
.mockReturnValueOnce([task1, task2])
.mockReturnValueOnce([task3])
.mockReturnValueOnce([]);
// When
await runAllTasks('/project');
// Then: task3 starts before task2 finishes (slot filled immediately)
const task3StartIdx = executionOrder.indexOf('start:Task: after-fast');
const task2EndIdx = executionOrder.indexOf('end:Task: slow');
expect(task3StartIdx).toBeLessThan(task2EndIdx);
expect(mockStatus).toHaveBeenCalledWith('Total', '3');
});
it('should count partial failures correctly', async () => {
// Given: 3 tasks, 1 fails, 2 succeed
mockLoadConfig.mockReturnValue({
language: 'en',
defaultPiece: 'default',
logLevel: 'info',
notificationSound: true,
notificationSoundEvents: { runAbort: true },
concurrency: 3,
taskPollIntervalMs: 500,
});
const task1 = createTask('pass-1');
const task2 = createTask('fail-1');
const task3 = createTask('pass-2');
let callIndex = 0;
mockExecutePiece.mockImplementation(() => {
callIndex++;
// Second call fails
return Promise.resolve({ success: callIndex !== 2 });
});
mockClaimNextTasks
.mockReturnValueOnce([task1, task2, task3])
.mockReturnValueOnce([]);
// When
await runAllTasks('/project');
// Then: Correct success/fail counts
expect(mockStatus).toHaveBeenCalledWith('Total', '3');
expect(mockStatus).toHaveBeenCalledWith('Success', '2', undefined);
expect(mockStatus).toHaveBeenCalledWith('Failed', '1', 'red');
expect(mockNotifySuccess).not.toHaveBeenCalled();
expect(mockNotifyError).toHaveBeenCalledTimes(1);
});
it('should persist failure reason and movement when piece aborts', async () => {
const task1 = createTask('fail-with-detail');
mockExecutePiece.mockResolvedValue({
success: false,
reason: 'blocked_by_review',
lastMovement: 'review',
lastMessage: 'security check failed',
});
mockClaimNextTasks
.mockReturnValueOnce([task1])
.mockReturnValueOnce([]);
await runAllTasks('/project');
expect(mockFailTask).toHaveBeenCalledWith(expect.objectContaining({
response: 'blocked_by_review',
failureMovement: 'review',
failureLastMessage: 'security check failed',
}));
});
it('should pass abortSignal and taskPrefix to executePiece in parallel mode', async () => {
// Given: One task in parallel mode
const task1 = createTask('parallel-task');
mockExecutePiece.mockResolvedValue({ success: true });
mockClaimNextTasks
.mockReturnValueOnce([task1])
.mockReturnValueOnce([]);
// When
await runAllTasks('/project');
// Then: executePiece received abortSignal and taskPrefix options
expect(mockExecutePiece).toHaveBeenCalledTimes(1);
const callArgs = mockExecutePiece.mock.calls[0];
const pieceOptions = callArgs?.[3]; // 4th argument is options
expect(pieceOptions).toHaveProperty('abortSignal');
expect(pieceOptions?.abortSignal).toBeInstanceOf(AbortSignal);
expect(pieceOptions).toHaveProperty('taskPrefix', 'parallel-task');
});
it('should pass abortSignal but not taskPrefix in sequential mode', async () => {
// Given: Sequential mode
mockLoadConfig.mockReturnValue({
language: 'en',
defaultPiece: 'default',
logLevel: 'info',
notificationSound: true,
notificationSoundEvents: { runComplete: true, runAbort: true },
concurrency: 1,
taskPollIntervalMs: 500,
});
const task1 = createTask('sequential-task');
mockExecutePiece.mockResolvedValue({ success: true });
mockLoadPieceByIdentifier.mockReturnValue(fakePieceConfig as never);
mockClaimNextTasks
.mockReturnValueOnce([task1])
.mockReturnValueOnce([]);
// When
await runAllTasks('/project');
// Then: executePiece should have abortSignal but not taskPrefix
expect(mockExecutePiece).toHaveBeenCalledTimes(1);
const callArgs = mockExecutePiece.mock.calls[0];
const pieceOptions = callArgs?.[3];
expect(pieceOptions?.abortSignal).toBeInstanceOf(AbortSignal);
expect(pieceOptions?.taskPrefix).toBeUndefined();
});
it('should only notify once at run completion when multiple tasks succeed', async () => {
mockLoadConfig.mockReturnValue({
language: 'en',
defaultPiece: 'default',
logLevel: 'info',
notificationSound: true,
notificationSoundEvents: { runComplete: true },
concurrency: 3,
taskPollIntervalMs: 500,
});
const task1 = createTask('task-1');
const task2 = createTask('task-2');
const task3 = createTask('task-3');
mockClaimNextTasks
.mockReturnValueOnce([task1, task2, task3])
.mockReturnValueOnce([]);
await runAllTasks('/project');
expect(mockNotifySuccess).toHaveBeenCalledTimes(1);
expect(mockNotifyError).not.toHaveBeenCalled();
});
it('should not notify run completion when runComplete is explicitly false', async () => {
mockLoadConfig.mockReturnValue({
language: 'en',
defaultPiece: 'default',
logLevel: 'info',
notificationSound: true,
notificationSoundEvents: { runComplete: false },
concurrency: 1,
taskPollIntervalMs: 500,
});
const task1 = createTask('task-1');
mockClaimNextTasks
.mockReturnValueOnce([task1])
.mockReturnValueOnce([]);
await runAllTasks('/project');
expect(mockNotifySuccess).not.toHaveBeenCalled();
expect(mockNotifyError).not.toHaveBeenCalled();
});
it('should notify run completion by default when notification_sound_events is not set', async () => {
mockLoadConfig.mockReturnValue({
language: 'en',
defaultPiece: 'default',
logLevel: 'info',
notificationSound: true,
concurrency: 1,
taskPollIntervalMs: 500,
});
const task1 = createTask('task-1');
mockClaimNextTasks
.mockReturnValueOnce([task1])
.mockReturnValueOnce([]);
await runAllTasks('/project');
expect(mockNotifySuccess).toHaveBeenCalledTimes(1);
expect(mockNotifySuccess).toHaveBeenCalledWith('TAKT', 'run.notifyComplete');
expect(mockNotifyError).not.toHaveBeenCalled();
});
it('should notify run abort by default when notification_sound_events is not set', async () => {
mockLoadConfig.mockReturnValue({
language: 'en',
defaultPiece: 'default',
logLevel: 'info',
notificationSound: true,
concurrency: 1,
taskPollIntervalMs: 500,
});
const task1 = createTask('task-1');
mockExecutePiece.mockResolvedValueOnce({ success: false, reason: 'failed' });
mockClaimNextTasks
.mockReturnValueOnce([task1])
.mockReturnValueOnce([]);
await runAllTasks('/project');
expect(mockNotifySuccess).not.toHaveBeenCalled();
expect(mockNotifyError).toHaveBeenCalledTimes(1);
expect(mockNotifyError).toHaveBeenCalledWith('TAKT', 'run.notifyAbort');
});
it('should not notify run abort when runAbort is explicitly false', async () => {
mockLoadConfig.mockReturnValue({
language: 'en',
defaultPiece: 'default',
logLevel: 'info',
notificationSound: true,
notificationSoundEvents: { runAbort: false },
concurrency: 1,
taskPollIntervalMs: 500,
});
const task1 = createTask('task-1');
mockExecutePiece.mockResolvedValueOnce({ success: false, reason: 'failed' });
mockClaimNextTasks
.mockReturnValueOnce([task1])
.mockReturnValueOnce([]);
await runAllTasks('/project');
expect(mockNotifySuccess).not.toHaveBeenCalled();
expect(mockNotifyError).not.toHaveBeenCalled();
});
it('should notify run abort and rethrow when worker pool throws', async () => {
mockLoadConfig.mockReturnValue({
language: 'en',
defaultPiece: 'default',
logLevel: 'info',
notificationSound: true,
notificationSoundEvents: { runAbort: true },
concurrency: 1,
taskPollIntervalMs: 500,
});
const task1 = createTask('task-1');
const poolError = new Error('worker pool crashed');
mockClaimNextTasks
.mockReturnValueOnce([task1])
.mockImplementationOnce(() => {
throw poolError;
});
await expect(runAllTasks('/project')).rejects.toThrow('worker pool crashed');
expect(mockNotifyError).toHaveBeenCalledTimes(1);
expect(mockNotifyError).toHaveBeenCalledWith('TAKT', 'run.notifyAbort');
});
});
describe('Slack webhook notification', () => {
const webhookUrl = 'https://hooks.slack.com/services/T00/B00/xxx';
const fakePieceConfig = {
name: 'default',
movements: [{ name: 'implement', personaDisplayName: 'coder' }],
initialMovement: 'implement',
maxMovements: 10,
};
beforeEach(() => {
mockLoadConfig.mockReturnValue({
language: 'en',
defaultPiece: 'default',
logLevel: 'info',
concurrency: 1,
taskPollIntervalMs: 500,
});
mockLoadPieceByIdentifier.mockReturnValue(fakePieceConfig as never);
});
it('should send Slack notification on success when webhook URL is set', async () => {
// Given
mockGetSlackWebhookUrl.mockReturnValue(webhookUrl);
const task1 = createTask('task-1');
mockClaimNextTasks
.mockReturnValueOnce([task1])
.mockReturnValueOnce([]);
mockListAllTaskItems.mockReturnValue([
{
kind: 'completed',
name: 'task-1',
createdAt: '2026-02-19T00:00:00.000Z',
filePath: '/tasks/task-1.yaml',
content: 'Task: task-1',
startedAt: '2026-02-19T00:00:00.000Z',
completedAt: '2026-02-19T00:00:30.000Z',
branch: 'feat/task-1',
prUrl: 'https://github.com/org/repo/pull/10',
data: { task: 'task-1', piece: 'default', issue: 42 },
},
]);
// When
await runAllTasks('/project');
// Then
expect(mockSendSlackNotification).toHaveBeenCalledOnce();
const [url, message] = mockSendSlackNotification.mock.calls[0]! as [string, string];
expect(url).toBe(webhookUrl);
expect(message).toContain('TAKT Run');
expect(message).toContain('total=1');
expect(message).toContain('success=1');
expect(message).toContain('failed=0');
expect(message).toContain('task-1');
expect(message).toContain('piece=default');
expect(message).toContain('issue=#42');
expect(message).toContain('duration=30s');
expect(message).toContain('pr=https://github.com/org/repo/pull/10');
});
it('should send Slack notification on failure when webhook URL is set', async () => {
// Given
mockGetSlackWebhookUrl.mockReturnValue(webhookUrl);
const task1 = createTask('task-1');
mockExecutePiece.mockResolvedValueOnce({ success: false, reason: 'failed' });
mockClaimNextTasks
.mockReturnValueOnce([task1])
.mockReturnValueOnce([]);
mockListAllTaskItems.mockReturnValue([
{
kind: 'failed',
name: 'task-1',
createdAt: '2026-02-19T00:00:00.000Z',
filePath: '/tasks/task-1.yaml',
content: 'Task: task-1',
startedAt: '2026-02-19T00:00:00.000Z',
completedAt: '2026-02-19T00:00:45.000Z',
branch: 'feat/task-1',
data: { task: 'task-1', piece: 'review' },
failure: { movement: 'ai_review', error: 'Lint failed', last_message: 'Fix attempt timed out' },
},
]);
// When
await runAllTasks('/project');
// Then
expect(mockSendSlackNotification).toHaveBeenCalledOnce();
const [url, message] = mockSendSlackNotification.mock.calls[0]! as [string, string];
expect(url).toBe(webhookUrl);
expect(message).toContain('TAKT Run');
expect(message).toContain('total=1');
expect(message).toContain('failed=1');
expect(message).toContain('task-1');
expect(message).toContain('piece=review');
expect(message).toContain('duration=45s');
expect(message).toContain('movement=ai_review');
expect(message).toContain('error=Lint failed');
});
it('should send Slack notification on exception when webhook URL is set', async () => {
// Given
mockGetSlackWebhookUrl.mockReturnValue(webhookUrl);
const task1 = createTask('task-1');
const poolError = new Error('worker pool crashed');
mockClaimNextTasks
.mockReturnValueOnce([task1])
.mockImplementationOnce(() => {
throw poolError;
});
mockListAllTaskItems.mockReturnValue([
{
kind: 'completed',
name: 'task-1',
createdAt: '2026-02-19T00:00:00.000Z',
filePath: '/tasks/task-1.yaml',
content: 'Task: task-1',
startedAt: '2026-02-19T00:00:00.000Z',
completedAt: '2026-02-19T00:00:15.000Z',
data: { task: 'task-1', piece: 'default' },
},
]);
// When / Then
await expect(runAllTasks('/project')).rejects.toThrow('worker pool crashed');
expect(mockSendSlackNotification).toHaveBeenCalledOnce();
const [url, message] = mockSendSlackNotification.mock.calls[0]! as [string, string];
expect(url).toBe(webhookUrl);
expect(message).toContain('TAKT Run');
expect(message).toContain('task-1');
expect(message).toContain('piece=default');
expect(message).toContain('duration=15s');
});
it('should not send Slack notification when webhook URL is not set', async () => {
// Given
mockGetSlackWebhookUrl.mockReturnValue(undefined);
const task1 = createTask('task-1');
mockClaimNextTasks
.mockReturnValueOnce([task1])
.mockReturnValueOnce([]);
// When
await runAllTasks('/project');
// Then
expect(mockSendSlackNotification).not.toHaveBeenCalled();
});
});
});