takt: extend-slack-task-notification (#316)

This commit is contained in:
nrs 2026-02-19 23:08:17 +09:00 committed by GitHub
parent 64d06f96c0
commit e70bceb4a8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 527 additions and 56 deletions

View File

@ -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 () => {

View File

@ -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<SlackTaskDetail> & { name: string }): SlackTaskDetail {
return {
success: true,
piece: 'default',
durationSec: 30,
...overrides,
};
}
function makeParams(overrides?: Partial<SlackRunSummaryParams>): 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');
});
});

View File

@ -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', () => ({

View File

@ -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<void> {
export async function postExecutionFlow(options: PostExecutionOptions): Promise<PostExecutionResult> {
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 {};
}

View File

@ -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<typeof fetchIssue>[] | 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.

View File

@ -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,
};
}

View File

@ -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<typeof fetchIssue>[] | 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<PieceExecutionResult> {
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<void> => {
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';

View File

@ -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 } : {}),
};
}

View File

@ -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 {

View File

@ -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,

View File

@ -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(),

View File

@ -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;

View File

@ -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;

View File

@ -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;
}