diff --git a/src/__tests__/runAllTasks-concurrency.test.ts b/src/__tests__/runAllTasks-concurrency.test.ts index b33c98a..77e10fe 100644 --- a/src/__tests__/runAllTasks-concurrency.test.ts +++ b/src/__tests__/runAllTasks-concurrency.test.ts @@ -49,6 +49,7 @@ const { mockCompleteTask, mockFailTask, mockRecoverInterruptedRunningTasks, + mockListAllTaskItems, mockNotifySuccess, mockNotifyError, mockSendSlackNotification, @@ -58,6 +59,7 @@ const { mockCompleteTask: vi.fn(), mockFailTask: vi.fn(), mockRecoverInterruptedRunningTasks: vi.fn(), + mockListAllTaskItems: vi.fn().mockReturnValue([]), mockNotifySuccess: vi.fn(), mockNotifyError: vi.fn(), mockSendSlackNotification: vi.fn(), @@ -71,6 +73,7 @@ vi.mock('../infra/task/index.js', async (importOriginal) => ({ completeTask: mockCompleteTask, failTask: mockFailTask, recoverInterruptedRunningTasks: mockRecoverInterruptedRunningTasks, + listAllTaskItems: mockListAllTaskItems, })), })); @@ -711,16 +714,37 @@ describe('runAllTasks concurrency', () => { 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(); - expect(mockSendSlackNotification).toHaveBeenCalledWith( - webhookUrl, - 'TAKT Run complete: 1 tasks succeeded', - ); + 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 () => { @@ -731,16 +755,36 @@ describe('runAllTasks concurrency', () => { 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(); - expect(mockSendSlackNotification).toHaveBeenCalledWith( - webhookUrl, - 'TAKT Run finished with errors: 1 failed out of 1 tasks', - ); + 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 () => { @@ -753,14 +797,28 @@ describe('runAllTasks concurrency', () => { .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(); - expect(mockSendSlackNotification).toHaveBeenCalledWith( - webhookUrl, - 'TAKT Run error: worker pool crashed', - ); + 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 () => { diff --git a/src/__tests__/slackWebhook.test.ts b/src/__tests__/slackWebhook.test.ts index 5946eb3..be3448b 100644 --- a/src/__tests__/slackWebhook.test.ts +++ b/src/__tests__/slackWebhook.test.ts @@ -3,7 +3,8 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { sendSlackNotification, getSlackWebhookUrl } from '../shared/utils/slackWebhook.js'; +import { sendSlackNotification, getSlackWebhookUrl, buildSlackRunSummary } from '../shared/utils/slackWebhook.js'; +import type { SlackRunSummaryParams, SlackTaskDetail } from '../shared/utils/slackWebhook.js'; describe('sendSlackNotification', () => { const webhookUrl = 'https://hooks.slack.com/services/T00/B00/xxx'; @@ -133,3 +134,240 @@ describe('getSlackWebhookUrl', () => { expect(url).toBeUndefined(); }); }); + +describe('buildSlackRunSummary', () => { + function makeTask(overrides: Partial & { name: string }): SlackTaskDetail { + return { + success: true, + piece: 'default', + durationSec: 30, + ...overrides, + }; + } + + function makeParams(overrides?: Partial): SlackRunSummaryParams { + return { + runId: 'run-20260219-105815', + total: 3, + success: 2, + failed: 1, + durationSec: 120, + concurrency: 2, + tasks: [], + ...overrides, + }; + } + + it('should include summary header with runId, counts, duration, and concurrency', () => { + // Given + const params = makeParams({ tasks: [] }); + + // When + const result = buildSlackRunSummary(params); + + // Then + expect(result).toContain('\uD83C\uDFC3 TAKT Run run-20260219-105815'); + expect(result).toContain('total=3'); + expect(result).toContain('success=2'); + expect(result).toContain('failed=1'); + expect(result).toContain('duration=120s'); + expect(result).toContain('concurrency=2'); + }); + + it('should display successful task with piece and issue', () => { + // Given + const task = makeTask({ + name: 'task-a', + piece: 'default', + issueNumber: 42, + durationSec: 30, + branch: 'feat/task-a', + worktreePath: '.worktrees/task-a', + prUrl: 'https://github.com/org/repo/pull/10', + }); + const params = makeParams({ total: 1, success: 1, failed: 0, tasks: [task] }); + + // When + const result = buildSlackRunSummary(params); + + // Then + expect(result).toContain('\u2705 task-a | piece=default | issue=#42 | duration=30s'); + expect(result).toContain('branch=feat/task-a'); + expect(result).toContain('worktree=.worktrees/task-a'); + expect(result).toContain('pr=https://github.com/org/repo/pull/10'); + }); + + it('should display failed task with error details', () => { + // Given + const task = makeTask({ + name: 'task-b', + success: false, + piece: 'review', + durationSec: 45, + branch: 'feat/task-b', + failureMovement: 'ai_review', + failureError: 'Lint failed', + failureLastMessage: 'Fix attempt timed out', + }); + const params = makeParams({ total: 1, success: 0, failed: 1, tasks: [task] }); + + // When + const result = buildSlackRunSummary(params); + + // Then + expect(result).toContain('\u274C task-b | piece=review | duration=45s'); + expect(result).toContain('movement=ai_review'); + expect(result).toContain('error=Lint failed'); + expect(result).toContain('last=Fix attempt timed out'); + expect(result).toContain('branch=feat/task-b'); + }); + + it('should omit issue when issueNumber is undefined', () => { + // Given + const task = makeTask({ name: 'task-no-issue', piece: 'default', durationSec: 10 }); + const params = makeParams({ total: 1, success: 1, failed: 0, tasks: [task] }); + + // When + const result = buildSlackRunSummary(params); + + // Then + expect(result).not.toContain('issue='); + }); + + it('should omit second line when no detail fields exist for success task', () => { + // Given + const task = makeTask({ name: 'task-minimal', piece: 'default', durationSec: 5 }); + const params = makeParams({ total: 1, success: 1, failed: 0, tasks: [task] }); + + // When + const result = buildSlackRunSummary(params); + + // Then + const taskLines = result.split('\n').filter((line) => line.includes('task-minimal')); + expect(taskLines).toHaveLength(1); + }); + + it('should preserve task submission order', () => { + // Given + const tasks = [ + makeTask({ name: 'first', durationSec: 10 }), + makeTask({ name: 'second', success: false, durationSec: 20, failureError: 'err' }), + makeTask({ name: 'third', durationSec: 30 }), + ]; + const params = makeParams({ total: 3, success: 2, failed: 1, tasks }); + + // When + const result = buildSlackRunSummary(params); + + // Then + const firstIdx = result.indexOf('first'); + const secondIdx = result.indexOf('second'); + const thirdIdx = result.indexOf('third'); + expect(firstIdx).toBeLessThan(secondIdx); + expect(secondIdx).toBeLessThan(thirdIdx); + }); + + it('should truncate and add "...and N more" when exceeding character limit', () => { + // Given + const tasks: SlackTaskDetail[] = []; + for (let i = 0; i < 50; i++) { + tasks.push(makeTask({ + name: `long-task-name-number-${String(i).padStart(3, '0')}`, + piece: 'default', + durationSec: 60, + branch: `feat/long-branch-name-for-testing-purposes-${String(i)}`, + worktreePath: `.worktrees/long-task-name-number-${String(i).padStart(3, '0')}`, + prUrl: `https://github.com/organization/repository/pull/${String(i + 100)}`, + })); + } + const params = makeParams({ total: 50, success: 50, failed: 0, tasks }); + + // When + const result = buildSlackRunSummary(params); + + // Then + expect(result.length).toBeLessThanOrEqual(3800); + expect(result).toMatch(/\.\.\.and \d+ more$/); + }); + + it('should normalize newlines in error messages', () => { + // Given + const task = makeTask({ + name: 'task-err', + success: false, + failureError: 'Line one\nLine two\r\nLine three', + }); + const params = makeParams({ total: 1, success: 0, failed: 1, tasks: [task] }); + + // When + const result = buildSlackRunSummary(params); + + // Then + expect(result).toContain('error=Line one Line two Line three'); + expect(result).not.toContain('\n error=Line one\n'); + }); + + it('should truncate long error text at 120 characters', () => { + // Given + const longError = 'A'.repeat(200); + const task = makeTask({ + name: 'task-long-err', + success: false, + failureError: longError, + }); + const params = makeParams({ total: 1, success: 0, failed: 1, tasks: [task] }); + + // When + const result = buildSlackRunSummary(params); + + // Then + expect(result).toContain(`error=${'A'.repeat(117)}...`); + expect(result).not.toContain('A'.repeat(200)); + }); + + it('should handle mixed success and failure tasks with PR present only on some', () => { + // Given + const tasks = [ + makeTask({ + name: 'with-pr', + prUrl: 'https://github.com/org/repo/pull/1', + branch: 'feat/with-pr', + }), + makeTask({ + name: 'no-pr', + branch: 'feat/no-pr', + }), + makeTask({ + name: 'failed-with-pr', + success: false, + branch: 'feat/failed', + prUrl: 'https://github.com/org/repo/pull/2', + failureError: 'build failed', + }), + ]; + const params = makeParams({ total: 3, success: 2, failed: 1, tasks }); + + // When + const result = buildSlackRunSummary(params); + + // Then + expect(result).toContain('pr=https://github.com/org/repo/pull/1'); + expect(result).toContain('pr=https://github.com/org/repo/pull/2'); + const lines = result.split('\n'); + const noPrLine = lines.find((l) => l.includes('no-pr')); + expect(noPrLine).not.toContain('pr='); + }); + + it('should handle empty tasks list', () => { + // Given + const params = makeParams({ total: 0, success: 0, failed: 0, tasks: [] }); + + // When + const result = buildSlackRunSummary(params); + + // Then + expect(result).toContain('\uD83C\uDFC3 TAKT Run'); + expect(result).toContain('total=0'); + expect(result).not.toContain('...and'); + }); +}); diff --git a/src/__tests__/taskExecution.test.ts b/src/__tests__/taskExecution.test.ts index 7e17aeb..0e26514 100644 --- a/src/__tests__/taskExecution.test.ts +++ b/src/__tests__/taskExecution.test.ts @@ -19,6 +19,7 @@ const { mockResolveTaskExecution, mockExecutePiece, mockLoadPieceByIdentifier, m vi.mock('../features/tasks/execute/resolveTask.js', () => ({ resolveTaskExecution: (...args: unknown[]) => mockResolveTaskExecution(...args), + resolveTaskIssue: vi.fn(), })); vi.mock('../features/tasks/execute/pieceExecution.js', () => ({ diff --git a/src/features/tasks/execute/postExecution.ts b/src/features/tasks/execute/postExecution.ts index 6df53cb..ce0d72d 100644 --- a/src/features/tasks/execute/postExecution.ts +++ b/src/features/tasks/execute/postExecution.ts @@ -42,10 +42,14 @@ export interface PostExecutionOptions { repo?: string; } +export interface PostExecutionResult { + prUrl?: string; +} + /** * Auto-commit, push, and optionally create a PR after successful task execution. */ -export async function postExecutionFlow(options: PostExecutionOptions): Promise { +export async function postExecutionFlow(options: PostExecutionOptions): Promise { const { execCwd, projectCwd, task, branch, baseBranch, shouldCreatePr, pieceIdentifier, issues, repo } = options; const commitResult = autoCommitAndPush(execCwd, task, projectCwd); @@ -69,6 +73,7 @@ export async function postExecutionFlow(options: PostExecutionOptions): Promise< const commentResult = commentOnPr(projectCwd, existingPr.number, commentBody); if (commentResult.success) { success(`PR updated with comment: ${existingPr.url}`); + return { prUrl: existingPr.url }; } else { error(`PR comment failed: ${commentResult.error}`); } @@ -84,9 +89,12 @@ export async function postExecutionFlow(options: PostExecutionOptions): Promise< }); if (prResult.success) { success(`PR created: ${prResult.url}`); + return { prUrl: prResult.url }; } else { error(`PR creation failed: ${prResult.error}`); } } } + + return {}; } diff --git a/src/features/tasks/execute/resolveTask.ts b/src/features/tasks/execute/resolveTask.ts index 60adb6d..7b475a3 100644 --- a/src/features/tasks/execute/resolveTask.ts +++ b/src/features/tasks/execute/resolveTask.ts @@ -6,9 +6,13 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { resolvePieceConfigValue } from '../../../infra/config/index.js'; import { type TaskInfo, createSharedClone, summarizeTaskName, getCurrentBranch } from '../../../infra/task/index.js'; +import { fetchIssue, checkGhCli } from '../../../infra/github/index.js'; import { withProgress } from '../../../shared/ui/index.js'; +import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; import { getTaskSlugFromTaskDir } from '../../../shared/utils/taskPaths.js'; +const log = createLogger('task'); + export interface ResolvedTaskExecution { execCwd: string; execPiece: string; @@ -60,6 +64,30 @@ function throwIfAborted(signal?: AbortSignal): void { } } +/** + * Resolve a GitHub issue from task data's issue number. + * Returns issue array for buildPrBody, or undefined if no issue or gh CLI unavailable. + */ +export function resolveTaskIssue(issueNumber: number | undefined): ReturnType[] | undefined { + if (issueNumber === undefined) { + return undefined; + } + + const ghStatus = checkGhCli(); + if (!ghStatus.available) { + log.info('gh CLI unavailable, skipping issue resolution for PR body', { issueNumber }); + return undefined; + } + + try { + const issue = fetchIssue(issueNumber); + return [issue]; + } catch (e) { + log.info('Failed to fetch issue for PR body, continuing without issue info', { issueNumber, error: getErrorMessage(e) }); + return undefined; + } +} + /** * Resolve execution directory and piece from task data. * If the task has worktree settings, create a shared clone and use it as cwd. diff --git a/src/features/tasks/execute/slackSummaryAdapter.ts b/src/features/tasks/execute/slackSummaryAdapter.ts new file mode 100644 index 0000000..691e642 --- /dev/null +++ b/src/features/tasks/execute/slackSummaryAdapter.ts @@ -0,0 +1,36 @@ +/** + * Adapts TaskListItem to SlackTaskDetail for Slack run summary notifications. + */ + +import type { TaskListItem } from '../../../infra/task/index.js'; +import type { SlackTaskDetail } from '../../../shared/utils/index.js'; +import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js'; + +export function generateRunId(): string { + const now = new Date(); + const pad = (n: number, len: number): string => String(n).padStart(len, '0'); + return `run-${pad(now.getFullYear(), 4)}${pad(now.getMonth() + 1, 2)}${pad(now.getDate(), 2)}-${pad(now.getHours(), 2)}${pad(now.getMinutes(), 2)}${pad(now.getSeconds(), 2)}`; +} + +function computeTaskDurationSec(item: TaskListItem): number { + if (!item.startedAt || !item.completedAt) { + return 0; + } + return Math.round((new Date(item.completedAt).getTime() - new Date(item.startedAt).getTime()) / 1000); +} + +export function toSlackTaskDetail(item: TaskListItem): SlackTaskDetail { + return { + name: item.name, + success: item.kind === 'completed', + piece: item.data?.piece ?? DEFAULT_PIECE_NAME, + issueNumber: item.data?.issue, + durationSec: computeTaskDurationSec(item), + branch: item.branch, + worktreePath: item.worktreePath, + prUrl: item.prUrl, + failureMovement: item.failure?.movement, + failureError: item.failure?.error, + failureLastMessage: item.failure?.last_message, + }; +} diff --git a/src/features/tasks/execute/taskExecution.ts b/src/features/tasks/execute/taskExecution.ts index 98c7af3..1339394 100644 --- a/src/features/tasks/execute/taskExecution.ts +++ b/src/features/tasks/execute/taskExecution.ts @@ -11,45 +11,21 @@ import { status, blankLine, } from '../../../shared/ui/index.js'; -import { createLogger, getErrorMessage, getSlackWebhookUrl, notifyError, notifySuccess, sendSlackNotification } from '../../../shared/utils/index.js'; +import { createLogger, getErrorMessage, getSlackWebhookUrl, notifyError, notifySuccess, sendSlackNotification, buildSlackRunSummary } from '../../../shared/utils/index.js'; import { getLabel } from '../../../shared/i18n/index.js'; import { executePiece } from './pieceExecution.js'; import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js'; import type { TaskExecutionOptions, ExecuteTaskOptions, PieceExecutionResult } from './types.js'; -import { fetchIssue, checkGhCli } from '../../../infra/github/index.js'; import { runWithWorkerPool } from './parallelExecution.js'; -import { resolveTaskExecution } from './resolveTask.js'; +import { resolveTaskExecution, resolveTaskIssue } from './resolveTask.js'; import { postExecutionFlow } from './postExecution.js'; import { buildTaskResult, persistTaskError, persistTaskResult } from './taskResultHandler.js'; +import { generateRunId, toSlackTaskDetail } from './slackSummaryAdapter.js'; export type { TaskExecutionOptions, ExecuteTaskOptions }; const log = createLogger('task'); -/** - * Resolve a GitHub issue from task data's issue number. - * Returns issue array for buildPrBody, or undefined if no issue or gh CLI unavailable. - */ -function resolveTaskIssue(issueNumber: number | undefined): ReturnType[] | undefined { - if (issueNumber === undefined) { - return undefined; - } - - const ghStatus = checkGhCli(); - if (!ghStatus.available) { - log.info('gh CLI unavailable, skipping issue resolution for PR body', { issueNumber }); - return undefined; - } - - try { - const issue = fetchIssue(issueNumber); - return [issue]; - } catch (e) { - log.info('Failed to fetch issue for PR body, continuing without issue info', { issueNumber, error: getErrorMessage(e) }); - return undefined; - } -} - async function executeTaskWithResult(options: ExecuteTaskOptions): Promise { const { task, @@ -190,9 +166,10 @@ export async function executeAndCompleteTask( const taskSuccess = taskRunResult.success; const completedAt = new Date().toISOString(); + let prUrl: string | undefined; if (taskSuccess && isWorktree) { const issues = resolveTaskIssue(issueNumber); - await postExecutionFlow({ + const postResult = await postExecutionFlow({ execCwd, projectCwd: cwd, task: task.name, @@ -202,6 +179,7 @@ export async function executeAndCompleteTask( pieceIdentifier: execPiece, issues, }); + prUrl = postResult.prUrl; } const taskResult = buildTaskResult({ @@ -211,6 +189,7 @@ export async function executeAndCompleteTask( completedAt, branch, worktreePath, + prUrl, }); persistTaskResult(taskRunner, taskResult); @@ -261,11 +240,31 @@ export async function runAllTasks( return; } + const runId = generateRunId(); + const startTime = Date.now(); + header('Running tasks'); if (concurrency > 1) { info(`Concurrency: ${concurrency}`); } + const sendSlackSummary = async (): Promise => { + if (!slackWebhookUrl) return; + const durationSec = Math.round((Date.now() - startTime) / 1000); + const tasks = taskRunner.listAllTaskItems().map(toSlackTaskDetail); + const successCount = tasks.filter((t) => t.success).length; + const message = buildSlackRunSummary({ + runId, + total: tasks.length, + success: successCount, + failed: tasks.length - successCount, + durationSec, + concurrency, + tasks, + }); + await sendSlackNotification(slackWebhookUrl, message); + }; + try { const result = await runWithWorkerPool(taskRunner, initialTasks, concurrency, cwd, pieceName, options, globalConfig.taskPollIntervalMs); @@ -279,28 +278,19 @@ export async function runAllTasks( if (shouldNotifyRunAbort) { notifyError('TAKT', getLabel('run.notifyAbort', undefined, { failed: String(result.fail) })); } - if (slackWebhookUrl) { - await sendSlackNotification(slackWebhookUrl, `TAKT Run finished with errors: ${String(result.fail)} failed out of ${String(totalCount)} tasks`); - } + await sendSlackSummary(); return; } if (shouldNotifyRunComplete) { notifySuccess('TAKT', getLabel('run.notifyComplete', undefined, { total: String(totalCount) })); } - if (slackWebhookUrl) { - await sendSlackNotification(slackWebhookUrl, `TAKT Run complete: ${String(totalCount)} tasks succeeded`); - } + await sendSlackSummary(); } catch (e) { if (shouldNotifyRunAbort) { notifyError('TAKT', getLabel('run.notifyAbort', undefined, { failed: getErrorMessage(e) })); } - if (slackWebhookUrl) { - await sendSlackNotification(slackWebhookUrl, `TAKT Run error: ${getErrorMessage(e)}`); - } + await sendSlackSummary(); throw e; } } - -// Re-export for backward compatibility with existing consumers -export { resolveTaskExecution } from './resolveTask.js'; diff --git a/src/features/tasks/execute/taskResultHandler.ts b/src/features/tasks/execute/taskResultHandler.ts index 30fcec8..5707837 100644 --- a/src/features/tasks/execute/taskResultHandler.ts +++ b/src/features/tasks/execute/taskResultHandler.ts @@ -10,6 +10,7 @@ interface BuildTaskResultParams { completedAt: string; branch?: string; worktreePath?: string; + prUrl?: string; } interface BuildBooleanTaskResultParams { @@ -33,7 +34,7 @@ interface PersistTaskErrorOptions { } export function buildTaskResult(params: BuildTaskResultParams): TaskResult { - const { task, runResult, startedAt, completedAt, branch, worktreePath } = params; + const { task, runResult, startedAt, completedAt, branch, worktreePath, prUrl } = params; const taskSuccess = runResult.success; if (!taskSuccess && !runResult.reason) { @@ -51,6 +52,7 @@ export function buildTaskResult(params: BuildTaskResultParams): TaskResult { completedAt, ...(branch ? { branch } : {}), ...(worktreePath ? { worktreePath } : {}), + ...(prUrl ? { prUrl } : {}), }; } diff --git a/src/features/tasks/index.ts b/src/features/tasks/index.ts index 41e2b5a..90ec8f8 100644 --- a/src/features/tasks/index.ts +++ b/src/features/tasks/index.ts @@ -4,7 +4,8 @@ export { executePiece, type PieceExecutionResult, type PieceExecutionOptions } from './execute/pieceExecution.js'; export { executeTask, runAllTasks, type TaskExecutionOptions } from './execute/taskExecution.js'; -export { executeAndCompleteTask, resolveTaskExecution } from './execute/taskExecution.js'; +export { executeAndCompleteTask } from './execute/taskExecution.js'; +export { resolveTaskExecution } from './execute/resolveTask.js'; export { withPersonaSession } from './execute/session.js'; export type { PipelineExecutionOptions } from './execute/types.js'; export { diff --git a/src/infra/task/mapper.ts b/src/infra/task/mapper.ts index ae561a4..58e7d45 100644 --- a/src/infra/task/mapper.ts +++ b/src/infra/task/mapper.ts @@ -120,6 +120,7 @@ function toBaseTaskListItem(projectDir: string, tasksFile: string, task: TaskRec summary: task.summary, branch: task.branch, worktreePath: task.worktree_path, + prUrl: task.pr_url, startedAt: task.started_at ?? undefined, completedAt: task.completed_at ?? undefined, ownerPid: task.owner_pid ?? undefined, diff --git a/src/infra/task/schema.ts b/src/infra/task/schema.ts index 7f9c607..3f9b9d9 100644 --- a/src/infra/task/schema.ts +++ b/src/infra/task/schema.ts @@ -44,6 +44,7 @@ export const TaskRecordSchema = TaskExecutionConfigSchema.extend({ slug: z.string().optional(), summary: z.string().optional(), worktree_path: z.string().optional(), + pr_url: z.string().optional(), content: z.string().min(1).optional(), content_file: z.string().min(1).optional(), task_dir: z.string().optional(), diff --git a/src/infra/task/taskLifecycleService.ts b/src/infra/task/taskLifecycleService.ts index c236103..8f3f4ee 100644 --- a/src/infra/task/taskLifecycleService.ts +++ b/src/infra/task/taskLifecycleService.ts @@ -121,6 +121,7 @@ export class TaskLifecycleService { failure: undefined, branch: result.branch ?? target.branch, worktree_path: result.worktreePath ?? target.worktree_path, + pr_url: result.prUrl ?? target.pr_url, }; const tasks = [...current.tasks]; tasks[index] = updated; diff --git a/src/infra/task/types.ts b/src/infra/task/types.ts index 42d0f57..573b047 100644 --- a/src/infra/task/types.ts +++ b/src/infra/task/types.ts @@ -30,6 +30,7 @@ export interface TaskResult { completedAt: string; branch?: string; worktreePath?: string; + prUrl?: string; } export interface WorktreeOptions { @@ -85,6 +86,7 @@ export interface TaskListItem { summary?: string; branch?: string; worktreePath?: string; + prUrl?: string; data?: TaskFileData; failure?: TaskFailure; startedAt?: string; diff --git a/src/shared/utils/slackWebhook.ts b/src/shared/utils/slackWebhook.ts index 6d208f9..5d7e493 100644 --- a/src/shared/utils/slackWebhook.ts +++ b/src/shared/utils/slackWebhook.ts @@ -41,3 +41,107 @@ export async function sendSlackNotification(webhookUrl: string, message: string) export function getSlackWebhookUrl(): string | undefined { return process.env[WEBHOOK_ENV_KEY]; } + +export interface SlackTaskDetail { + name: string; + success: boolean; + piece: string; + issueNumber?: number; + durationSec: number; + branch?: string; + worktreePath?: string; + prUrl?: string; + failureMovement?: string; + failureError?: string; + failureLastMessage?: string; +} + +export interface SlackRunSummaryParams { + runId: string; + total: number; + success: number; + failed: number; + durationSec: number; + concurrency: number; + tasks: SlackTaskDetail[]; +} + +const CHAR_LIMIT = 3_800; +const TRUNCATE_LENGTH = 120; + +function normalizeText(text: string): string { + return text.replace(/[\r\n]+/g, ' '); +} + +function truncateText(text: string, maxLength: number): string { + if (text.length <= maxLength) { + return text; + } + return `${text.slice(0, maxLength - 3)}...`; +} + +function formatTaskLines(task: SlackTaskDetail): string { + const icon = task.success ? '\u2705' : '\u274C'; + const parts = [ + `${icon} ${task.name}`, + `piece=${task.piece}`, + ]; + if (task.issueNumber !== undefined) { + parts.push(`issue=#${String(task.issueNumber)}`); + } + parts.push(`duration=${String(task.durationSec)}s`); + const line1 = parts.join(' | '); + + const line2Parts: string[] = []; + if (task.success) { + if (task.branch) line2Parts.push(`branch=${task.branch}`); + if (task.worktreePath) line2Parts.push(`worktree=${task.worktreePath}`); + if (task.prUrl) line2Parts.push(`pr=${task.prUrl}`); + } else { + if (task.failureMovement) line2Parts.push(`movement=${task.failureMovement}`); + if (task.failureError) { + line2Parts.push(`error=${truncateText(normalizeText(task.failureError), TRUNCATE_LENGTH)}`); + } + if (task.failureLastMessage) { + line2Parts.push(`last=${truncateText(normalizeText(task.failureLastMessage), TRUNCATE_LENGTH)}`); + } + if (task.branch) line2Parts.push(`branch=${task.branch}`); + if (task.prUrl) line2Parts.push(`pr=${task.prUrl}`); + } + + if (line2Parts.length === 0) { + return line1; + } + return `${line1}\n ${line2Parts.join(' | ')}`; +} + +export function buildSlackRunSummary(params: SlackRunSummaryParams): string { + const headerLine = `\uD83C\uDFC3 TAKT Run ${params.runId}`; + const statsLine = `total=${String(params.total)} | success=${String(params.success)} | failed=${String(params.failed)} | duration=${String(params.durationSec)}s | concurrency=${String(params.concurrency)}`; + const summaryBlock = `${headerLine}\n${statsLine}`; + + let result = summaryBlock; + let includedCount = 0; + + for (const task of params.tasks) { + const taskBlock = formatTaskLines(task); + const candidate = `${result}\n\n${taskBlock}`; + + const remaining = params.tasks.length - includedCount - 1; + const suffixLength = remaining > 0 ? `\n...and ${String(remaining)} more`.length : 0; + + if (candidate.length + suffixLength > CHAR_LIMIT) { + break; + } + + result = candidate; + includedCount++; + } + + const omitted = params.tasks.length - includedCount; + if (omitted > 0) { + result = `${result}\n...and ${String(omitted)} more`; + } + + return result; +}