From 6e67f864f58f4c0b55f79e1e98f58c4b836ee501 Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Tue, 10 Feb 2026 20:03:17 +0900 Subject: [PATCH] takt: github-issue-198-e2e-config-yaml (#208) --- README.md | 6 + docs/README.ja.md | 6 + docs/testing/e2e.md | 7 + e2e/fixtures/config.e2e.yaml | 11 ++ e2e/helpers/isolated-env.ts | 95 +++++++-- e2e/specs/add.e2e.ts | 22 +-- e2e/specs/provider-error.e2e.ts | 18 +- e2e/specs/run-multiple-tasks.e2e.ts | 18 +- e2e/specs/run-sigint-graceful.e2e.ts | 24 ++- e2e/specs/task-content-file.e2e.ts | 18 +- src/__tests__/e2e-helpers.test.ts | 115 ++++++++++- src/__tests__/globalConfig-defaults.test.ts | 53 +++++ src/__tests__/it-notification-sound.test.ts | 49 +++++ src/__tests__/runAllTasks-concurrency.test.ts | 182 +++++++++++++++++- src/core/models/global-config.ts | 16 ++ src/core/models/schemas.ts | 8 + src/features/tasks/execute/pieceExecution.ts | 10 +- src/features/tasks/execute/taskExecution.ts | 38 +++- src/infra/config/global/globalConfig.ts | 28 +++ src/shared/i18n/labels_en.yaml | 4 + src/shared/i18n/labels_ja.yaml | 4 + 21 files changed, 642 insertions(+), 90 deletions(-) create mode 100644 e2e/fixtures/config.e2e.yaml diff --git a/README.md b/README.md index 71160a7..397b534 100644 --- a/README.md +++ b/README.md @@ -565,6 +565,12 @@ model: sonnet # Default model (optional) branch_name_strategy: romaji # Branch name generation: 'romaji' (fast) or 'ai' (slow) prevent_sleep: false # Prevent macOS idle sleep during execution (caffeinate) notification_sound: true # Enable/disable notification sounds +notification_sound_events: # Optional per-event toggles + iteration_limit: false + piece_complete: true + piece_abort: true + run_complete: true # Enabled by default; set false to disable + run_abort: true # Enabled by default; set false to disable concurrency: 1 # Parallel task count for takt run (1-10, default: 1 = sequential) task_poll_interval_ms: 500 # Polling interval for new tasks during takt run (100-5000, default: 500) interactive_preview_movements: 3 # Movement previews in interactive mode (0-10, default: 3) diff --git a/docs/README.ja.md b/docs/README.ja.md index 6877a16..9165267 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -565,6 +565,12 @@ model: sonnet # デフォルトモデル(オプション) branch_name_strategy: romaji # ブランチ名生成: 'romaji'(高速)または 'ai'(低速) prevent_sleep: false # macOS の実行中スリープ防止(caffeinate) notification_sound: true # 通知音の有効/無効 +notification_sound_events: # タイミング別の通知音制御 + iteration_limit: false + piece_complete: true + piece_abort: true + run_complete: true # 未設定時は有効。false を指定すると無効 + run_abort: true # 未設定時は有効。false を指定すると無効 concurrency: 1 # takt run の並列タスク数(1-10、デフォルト: 1 = 逐次実行) task_poll_interval_ms: 500 # takt run 中の新タスク検出ポーリング間隔(100-5000、デフォルト: 500) interactive_preview_movements: 3 # 対話モードでのムーブメントプレビュー数(0-10、デフォルト: 3) diff --git a/docs/testing/e2e.md b/docs/testing/e2e.md index 1ebd72c..1f91ed7 100644 --- a/docs/testing/e2e.md +++ b/docs/testing/e2e.md @@ -13,6 +13,13 @@ E2Eテストを追加・変更した場合は、このドキュメントも更 - リポジトリクローン: `$(os.tmpdir())/takt-e2e-repo-/` - 実行環境: `$(os.tmpdir())/takt-e2e--/` +## E2E用config.yaml +- E2Eのグローバル設定は `e2e/fixtures/config.e2e.yaml` を基準に生成する。 +- `createIsolatedEnv()` は毎回一時ディレクトリ配下(`$TAKT_CONFIG_DIR/config.yaml`)にこの基準設定を書き出す。 +- 通知音は `notification_sound_events` でタイミング別に制御し、E2E既定では道中(`iteration_limit` / `piece_complete` / `piece_abort`)をOFF、全体終了時(`run_complete` / `run_abort`)のみONにする。 +- 各スペックで `provider` や `concurrency` を変更する場合は、`updateIsolatedConfig()` を使って差分のみ上書きする。 +- `~/.takt/config.yaml` はE2Eでは参照されないため、通常実行の設定には影響しない。 + ## 実行コマンド - `npm run test:e2e`: E2E全体を実行。 - `npm run test:e2e:mock`: mock固定のE2Eのみ実行。 diff --git a/e2e/fixtures/config.e2e.yaml b/e2e/fixtures/config.e2e.yaml new file mode 100644 index 0000000..6eea1b8 --- /dev/null +++ b/e2e/fixtures/config.e2e.yaml @@ -0,0 +1,11 @@ +provider: claude +language: en +log_level: info +default_piece: default +notification_sound: true +notification_sound_events: + iteration_limit: false + piece_complete: false + piece_abort: false + run_complete: true + run_abort: true diff --git a/e2e/helpers/isolated-env.ts b/e2e/helpers/isolated-env.ts index 5f08be4..2aea4a7 100644 --- a/e2e/helpers/isolated-env.ts +++ b/e2e/helpers/isolated-env.ts @@ -1,6 +1,8 @@ -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; -import { join } from 'node:path'; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; export interface IsolatedEnv { runId: string; @@ -9,6 +11,73 @@ export interface IsolatedEnv { cleanup: () => void; } +type E2EConfig = Record; +type NotificationSoundEvents = Record; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const E2E_CONFIG_FIXTURE_PATH = resolve(__dirname, '../fixtures/config.e2e.yaml'); + +function readE2EFixtureConfig(): E2EConfig { + const raw = readFileSync(E2E_CONFIG_FIXTURE_PATH, 'utf-8'); + const parsed = parseYaml(raw); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`Invalid E2E config fixture: ${E2E_CONFIG_FIXTURE_PATH}`); + } + return parsed as E2EConfig; +} + +function writeConfigFile(taktDir: string, config: E2EConfig): void { + writeFileSync(join(taktDir, 'config.yaml'), `${stringifyYaml(config)}`); +} + +function parseNotificationSoundEvents( + source: E2EConfig, + sourceName: string, +): NotificationSoundEvents | undefined { + const value = source.notification_sound_events; + if (value === undefined) { + return undefined; + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error( + `Invalid notification_sound_events in ${sourceName}: expected object`, + ); + } + return value as NotificationSoundEvents; +} + +function mergeIsolatedConfig( + fixture: E2EConfig, + current: E2EConfig, + patch: E2EConfig, +): E2EConfig { + const merged: E2EConfig = { ...fixture, ...current, ...patch }; + const fixtureEvents = parseNotificationSoundEvents(fixture, 'fixture'); + const currentEvents = parseNotificationSoundEvents(current, 'current config'); + const patchEvents = parseNotificationSoundEvents(patch, 'patch'); + if (!fixtureEvents && !currentEvents && !patchEvents) { + return merged; + } + merged.notification_sound_events = { + ...(fixtureEvents ?? {}), + ...(currentEvents ?? {}), + ...(patchEvents ?? {}), + }; + return merged; +} + +export function updateIsolatedConfig(taktDir: string, patch: E2EConfig): void { + const current = readE2EFixtureConfig(); + const configPath = join(taktDir, 'config.yaml'); + const raw = readFileSync(configPath, 'utf-8'); + const parsed = parseYaml(raw); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`Invalid isolated config: ${configPath}`); + } + writeConfigFile(taktDir, mergeIsolatedConfig(current, parsed as E2EConfig, patch)); +} + /** * Create an isolated environment for E2E testing. * @@ -24,18 +93,12 @@ export function createIsolatedEnv(): IsolatedEnv { const gitConfigPath = join(baseDir, '.gitconfig'); // Create TAKT config directory and config.yaml - // Use TAKT_E2E_PROVIDER to match config provider with the actual provider being tested - const configProvider = process.env.TAKT_E2E_PROVIDER ?? 'claude'; mkdirSync(taktDir, { recursive: true }); - writeFileSync( - join(taktDir, 'config.yaml'), - [ - `provider: ${configProvider}`, - 'language: en', - 'log_level: info', - 'default_piece: default', - ].join('\n'), - ); + const baseConfig = readE2EFixtureConfig(); + const config = process.env.TAKT_E2E_PROVIDER + ? { ...baseConfig, provider: process.env.TAKT_E2E_PROVIDER } + : baseConfig; + writeConfigFile(taktDir, config); // Create isolated Git config file writeFileSync( @@ -58,11 +121,7 @@ export function createIsolatedEnv(): IsolatedEnv { taktDir, env, cleanup: () => { - try { - rmSync(baseDir, { recursive: true, force: true }); - } catch { - // Best-effort cleanup; ignore errors (e.g., already deleted) - } + rmSync(baseDir, { recursive: true, force: true }); }, }; } diff --git a/e2e/specs/add.e2e.ts b/e2e/specs/add.e2e.ts index f2f26f5..bc7979c 100644 --- a/e2e/specs/add.e2e.ts +++ b/e2e/specs/add.e2e.ts @@ -1,10 +1,14 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { execFileSync } from 'node:child_process'; -import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { readFileSync, existsSync } from 'node:fs'; import { join, dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { parse as parseYaml } from 'yaml'; -import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { + createIsolatedEnv, + updateIsolatedConfig, + type IsolatedEnv, +} from '../helpers/isolated-env'; import { createTestRepo, type TestRepo } from '../helpers/test-repo'; import { runTakt } from '../helpers/takt-runner'; @@ -22,16 +26,10 @@ describe('E2E: Add task from GitHub issue (takt add)', () => { testRepo = createTestRepo(); // Use mock provider to stabilize summarizer - writeFileSync( - join(isolatedEnv.taktDir, 'config.yaml'), - [ - 'provider: mock', - 'model: mock-model', - 'language: en', - 'log_level: info', - 'default_piece: default', - ].join('\n'), - ); + updateIsolatedConfig(isolatedEnv.taktDir, { + provider: 'mock', + model: 'mock-model', + }); const createOutput = execFileSync( 'gh', diff --git a/e2e/specs/provider-error.e2e.ts b/e2e/specs/provider-error.e2e.ts index 0f14542..e2e6978 100644 --- a/e2e/specs/provider-error.e2e.ts +++ b/e2e/specs/provider-error.e2e.ts @@ -5,7 +5,11 @@ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { execFileSync } from 'node:child_process'; -import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { + createIsolatedEnv, + updateIsolatedConfig, + type IsolatedEnv, +} from '../helpers/isolated-env'; import { runTakt } from '../helpers/takt-runner'; const __filename = fileURLToPath(import.meta.url); @@ -44,15 +48,9 @@ describe('E2E: Provider error handling (mock)', () => { it('should override config provider with --provider flag', () => { // Given: config.yaml has provider: claude, but CLI flag specifies mock - writeFileSync( - join(isolatedEnv.taktDir, 'config.yaml'), - [ - 'provider: claude', - 'language: en', - 'log_level: info', - 'default_piece: default', - ].join('\n'), - ); + updateIsolatedConfig(isolatedEnv.taktDir, { + provider: 'claude', + }); const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml'); const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json'); diff --git a/e2e/specs/run-multiple-tasks.e2e.ts b/e2e/specs/run-multiple-tasks.e2e.ts index 14b1e7b..518db71 100644 --- a/e2e/specs/run-multiple-tasks.e2e.ts +++ b/e2e/specs/run-multiple-tasks.e2e.ts @@ -5,7 +5,11 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { execFileSync } from 'node:child_process'; -import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { + createIsolatedEnv, + updateIsolatedConfig, + type IsolatedEnv, +} from '../helpers/isolated-env'; import { runTakt } from '../helpers/takt-runner'; const __filename = fileURLToPath(import.meta.url); @@ -39,15 +43,9 @@ describe('E2E: Run multiple tasks (takt run)', () => { repo = createLocalRepo(); // Override config to use mock provider - writeFileSync( - join(isolatedEnv.taktDir, 'config.yaml'), - [ - 'provider: mock', - 'language: en', - 'log_level: info', - 'default_piece: default', - ].join('\n'), - ); + updateIsolatedConfig(isolatedEnv.taktDir, { + provider: 'mock', + }); }); afterEach(() => { diff --git a/e2e/specs/run-sigint-graceful.e2e.ts b/e2e/specs/run-sigint-graceful.e2e.ts index 941baea..79c2d85 100644 --- a/e2e/specs/run-sigint-graceful.e2e.ts +++ b/e2e/specs/run-sigint-graceful.e2e.ts @@ -3,7 +3,11 @@ import { spawn } from 'node:child_process'; import { mkdirSync, writeFileSync, readFileSync } from 'node:fs'; import { join, resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { + createIsolatedEnv, + updateIsolatedConfig, + type IsolatedEnv, +} from '../helpers/isolated-env'; import { createTestRepo, type TestRepo } from '../helpers/test-repo'; const __filename = fileURLToPath(import.meta.url); @@ -50,18 +54,12 @@ describe('E2E: Run tasks graceful shutdown on SIGINT (parallel)', () => { isolatedEnv = createIsolatedEnv(); testRepo = createTestRepo(); - writeFileSync( - join(isolatedEnv.taktDir, 'config.yaml'), - [ - 'provider: mock', - 'model: mock-model', - 'language: en', - 'log_level: info', - 'default_piece: default', - 'concurrency: 2', - 'task_poll_interval_ms: 100', - ].join('\n'), - ); + updateIsolatedConfig(isolatedEnv.taktDir, { + provider: 'mock', + model: 'mock-model', + concurrency: 2, + task_poll_interval_ms: 100, + }); }); afterEach(() => { diff --git a/e2e/specs/task-content-file.e2e.ts b/e2e/specs/task-content-file.e2e.ts index d826d86..4e79acb 100644 --- a/e2e/specs/task-content-file.e2e.ts +++ b/e2e/specs/task-content-file.e2e.ts @@ -5,7 +5,11 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { execFileSync } from 'node:child_process'; -import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { + createIsolatedEnv, + updateIsolatedConfig, + type IsolatedEnv, +} from '../helpers/isolated-env'; import { runTakt } from '../helpers/takt-runner'; const __filename = fileURLToPath(import.meta.url); @@ -38,15 +42,9 @@ describe('E2E: Task content_file reference (mock)', () => { isolatedEnv = createIsolatedEnv(); repo = createLocalRepo(); - writeFileSync( - join(isolatedEnv.taktDir, 'config.yaml'), - [ - 'provider: mock', - 'language: en', - 'log_level: info', - 'default_piece: default', - ].join('\n'), - ); + updateIsolatedConfig(isolatedEnv.taktDir, { + provider: 'mock', + }); }); afterEach(() => { diff --git a/src/__tests__/e2e-helpers.test.ts b/src/__tests__/e2e-helpers.test.ts index c94124e..63b395d 100644 --- a/src/__tests__/e2e-helpers.test.ts +++ b/src/__tests__/e2e-helpers.test.ts @@ -1,6 +1,11 @@ import { describe, it, expect, afterEach } from 'vitest'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { parse as parseYaml } from 'yaml'; import { injectProviderArgs } from '../../e2e/helpers/takt-runner.js'; -import { createIsolatedEnv } from '../../e2e/helpers/isolated-env.js'; +import { + createIsolatedEnv, + updateIsolatedConfig, +} from '../../e2e/helpers/isolated-env.js'; describe('injectProviderArgs', () => { it('should prepend --provider when provider is specified', () => { @@ -70,4 +75,112 @@ describe('createIsolatedEnv', () => { expect(isolated.env.GIT_CONFIG_GLOBAL).toBeDefined(); expect(isolated.env.GIT_CONFIG_GLOBAL).toContain('takt-e2e-'); }); + + it('should create config.yaml from E2E fixture with notification_sound timing controls', () => { + const isolated = createIsolatedEnv(); + cleanups.push(isolated.cleanup); + + const configRaw = readFileSync(`${isolated.taktDir}/config.yaml`, 'utf-8'); + const config = parseYaml(configRaw) as Record; + + expect(config.language).toBe('en'); + expect(config.log_level).toBe('info'); + expect(config.default_piece).toBe('default'); + expect(config.notification_sound).toBe(true); + expect(config.notification_sound_events).toEqual({ + iteration_limit: false, + piece_complete: false, + piece_abort: false, + run_complete: true, + run_abort: true, + }); + }); + + it('should override provider in config.yaml when TAKT_E2E_PROVIDER is set', () => { + process.env = { ...originalEnv, TAKT_E2E_PROVIDER: 'mock' }; + const isolated = createIsolatedEnv(); + cleanups.push(isolated.cleanup); + + const configRaw = readFileSync(`${isolated.taktDir}/config.yaml`, 'utf-8'); + const config = parseYaml(configRaw) as Record; + expect(config.provider).toBe('mock'); + }); + + it('should preserve base settings when updateIsolatedConfig applies patch', () => { + const isolated = createIsolatedEnv(); + cleanups.push(isolated.cleanup); + + updateIsolatedConfig(isolated.taktDir, { + provider: 'mock', + concurrency: 2, + }); + + const configRaw = readFileSync(`${isolated.taktDir}/config.yaml`, 'utf-8'); + const config = parseYaml(configRaw) as Record; + + expect(config.provider).toBe('mock'); + expect(config.concurrency).toBe(2); + expect(config.notification_sound).toBe(true); + expect(config.notification_sound_events).toEqual({ + iteration_limit: false, + piece_complete: false, + piece_abort: false, + run_complete: true, + run_abort: true, + }); + expect(config.language).toBe('en'); + }); + + it('should deep-merge notification_sound_events patch and preserve unspecified keys', () => { + const isolated = createIsolatedEnv(); + cleanups.push(isolated.cleanup); + + updateIsolatedConfig(isolated.taktDir, { + notification_sound_events: { + run_complete: false, + }, + }); + + const configRaw = readFileSync(`${isolated.taktDir}/config.yaml`, 'utf-8'); + const config = parseYaml(configRaw) as Record; + + expect(config.notification_sound_events).toEqual({ + iteration_limit: false, + piece_complete: false, + piece_abort: false, + run_complete: false, + run_abort: true, + }); + }); + + it('should throw when patch.notification_sound_events is not an object', () => { + const isolated = createIsolatedEnv(); + cleanups.push(isolated.cleanup); + + expect(() => { + updateIsolatedConfig(isolated.taktDir, { + notification_sound_events: true, + }); + }).toThrow('Invalid notification_sound_events in patch: expected object'); + }); + + it('should throw when current config notification_sound_events is invalid', () => { + const isolated = createIsolatedEnv(); + cleanups.push(isolated.cleanup); + + writeFileSync( + `${isolated.taktDir}/config.yaml`, + [ + 'language: en', + 'log_level: info', + 'default_piece: default', + 'notification_sound: true', + 'notification_sound_events: true', + ].join('\n'), + ); + + expect(() => { + updateIsolatedConfig(isolated.taktDir, { provider: 'mock' }); + }).toThrow('Invalid notification_sound_events in current config: expected object'); + }); }); diff --git a/src/__tests__/globalConfig-defaults.test.ts b/src/__tests__/globalConfig-defaults.test.ts index 3c62adf..ec4ec51 100644 --- a/src/__tests__/globalConfig-defaults.test.ts +++ b/src/__tests__/globalConfig-defaults.test.ts @@ -287,6 +287,59 @@ describe('loadGlobalConfig', () => { expect(config.notificationSound).toBeUndefined(); }); + it('should load notification_sound_events config from config.yaml', () => { + const taktDir = join(testHomeDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + writeFileSync( + getGlobalConfigPath(), + [ + 'language: en', + 'notification_sound_events:', + ' iteration_limit: false', + ' piece_complete: true', + ' piece_abort: true', + ' run_complete: true', + ' run_abort: false', + ].join('\n'), + 'utf-8', + ); + + const config = loadGlobalConfig(); + expect(config.notificationSoundEvents).toEqual({ + iterationLimit: false, + pieceComplete: true, + pieceAbort: true, + runComplete: true, + runAbort: false, + }); + }); + + it('should save and reload notification_sound_events config', () => { + const taktDir = join(testHomeDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8'); + + const config = loadGlobalConfig(); + config.notificationSoundEvents = { + iterationLimit: false, + pieceComplete: true, + pieceAbort: false, + runComplete: true, + runAbort: true, + }; + saveGlobalConfig(config); + invalidateGlobalConfigCache(); + + const reloaded = loadGlobalConfig(); + expect(reloaded.notificationSoundEvents).toEqual({ + iterationLimit: false, + pieceComplete: true, + pieceAbort: false, + runComplete: true, + runAbort: true, + }); + }); + it('should load interactive_preview_movements config from config.yaml', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); diff --git a/src/__tests__/it-notification-sound.test.ts b/src/__tests__/it-notification-sound.test.ts index 3ba80d2..b23ac93 100644 --- a/src/__tests__/it-notification-sound.test.ts +++ b/src/__tests__/it-notification-sound.test.ts @@ -282,6 +282,22 @@ describe('executePiece: notification sound behavior', () => { expect(mockNotifySuccess).not.toHaveBeenCalled(); }); + + it('should NOT call notifySuccess when piece_complete event is disabled', async () => { + mockLoadGlobalConfig.mockReturnValue({ + provider: 'claude', + notificationSound: true, + notificationSoundEvents: { pieceComplete: false }, + }); + + const resultPromise = executePiece(makeConfig(), 'test task', tmpDir, { projectCwd: tmpDir }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + MockPieceEngine.latestInstance!.complete(); + await resultPromise; + + expect(mockNotifySuccess).not.toHaveBeenCalled(); + }); }); describe('notifyError on piece:abort', () => { @@ -320,6 +336,22 @@ describe('executePiece: notification sound behavior', () => { expect(mockNotifyError).not.toHaveBeenCalled(); }); + + it('should NOT call notifyError when piece_abort event is disabled', async () => { + mockLoadGlobalConfig.mockReturnValue({ + provider: 'claude', + notificationSound: true, + notificationSoundEvents: { pieceAbort: false }, + }); + + const resultPromise = executePiece(makeConfig(), 'test task', tmpDir, { projectCwd: tmpDir }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + MockPieceEngine.latestInstance!.abort(); + await resultPromise; + + expect(mockNotifyError).not.toHaveBeenCalled(); + }); }); describe('playWarningSound on iteration limit', () => { @@ -361,5 +393,22 @@ describe('executePiece: notification sound behavior', () => { expect(mockPlayWarningSound).not.toHaveBeenCalled(); }); + + it('should NOT call playWarningSound when iteration_limit event is disabled', async () => { + mockLoadGlobalConfig.mockReturnValue({ + provider: 'claude', + notificationSound: true, + notificationSoundEvents: { iterationLimit: false }, + }); + + const resultPromise = executePiece(makeConfig(), 'test task', tmpDir, { projectCwd: tmpDir }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + await MockPieceEngine.latestInstance!.triggerIterationLimit(); + MockPieceEngine.latestInstance!.abort(); + await resultPromise; + + expect(mockPlayWarningSound).not.toHaveBeenCalled(); + }); }); }); diff --git a/src/__tests__/runAllTasks-concurrency.test.ts b/src/__tests__/runAllTasks-concurrency.test.ts index e7ec26c..b8dca60 100644 --- a/src/__tests__/runAllTasks-concurrency.test.ts +++ b/src/__tests__/runAllTasks-concurrency.test.ts @@ -21,10 +21,21 @@ vi.mock('../infra/config/index.js', () => ({ import { loadGlobalConfig } from '../infra/config/index.js'; const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig); -const mockClaimNextTasks = vi.fn(); -const mockCompleteTask = vi.fn(); -const mockFailTask = vi.fn(); -const mockRecoverInterruptedRunningTasks = vi.fn(); +const { + mockClaimNextTasks, + mockCompleteTask, + mockFailTask, + mockRecoverInterruptedRunningTasks, + mockNotifySuccess, + mockNotifyError, +} = vi.hoisted(() => ({ + mockClaimNextTasks: vi.fn(), + mockCompleteTask: vi.fn(), + mockFailTask: vi.fn(), + mockRecoverInterruptedRunningTasks: vi.fn(), + mockNotifySuccess: vi.fn(), + mockNotifyError: vi.fn(), +})); vi.mock('../infra/task/index.js', async (importOriginal) => ({ ...(await importOriginal>()), @@ -75,6 +86,8 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({ error: vi.fn(), }), getErrorMessage: vi.fn((e) => e.message), + notifySuccess: mockNotifySuccess, + notifyError: mockNotifyError, })); vi.mock('../features/tasks/execute/pieceExecution.js', () => ({ @@ -149,6 +162,8 @@ describe('runAllTasks concurrency', () => { language: 'en', defaultPiece: 'default', logLevel: 'info', + notificationSound: true, + notificationSoundEvents: { runComplete: true, runAbort: true }, concurrency: 1, taskPollIntervalMs: 500, }); @@ -190,6 +205,8 @@ describe('runAllTasks concurrency', () => { language: 'en', defaultPiece: 'default', logLevel: 'info', + notificationSound: true, + notificationSoundEvents: { runComplete: true, runAbort: true }, concurrency: 3, taskPollIntervalMs: 500, }); @@ -266,6 +283,7 @@ describe('runAllTasks concurrency', () => { language: 'en', defaultPiece: 'default', logLevel: 'info', + notificationSound: false, concurrency: 1, taskPollIntervalMs: 500, }); @@ -283,6 +301,8 @@ describe('runAllTasks concurrency', () => { (call) => typeof call[0] === 'string' && call[0].startsWith('Concurrency:') ); expect(concurrencyInfoCalls).toHaveLength(0); + expect(mockNotifySuccess).not.toHaveBeenCalled(); + expect(mockNotifyError).not.toHaveBeenCalled(); }); }); @@ -384,6 +404,16 @@ describe('runAllTasks concurrency', () => { it('should count partial failures correctly', async () => { // Given: 3 tasks, 1 fails, 2 succeed + mockLoadGlobalConfig.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'); @@ -406,6 +436,8 @@ describe('runAllTasks concurrency', () => { 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 () => { @@ -458,6 +490,8 @@ describe('runAllTasks concurrency', () => { language: 'en', defaultPiece: 'default', logLevel: 'info', + notificationSound: true, + notificationSoundEvents: { runComplete: true, runAbort: true }, concurrency: 1, taskPollIntervalMs: 500, }); @@ -480,5 +514,145 @@ describe('runAllTasks concurrency', () => { expect(pieceOptions?.abortSignal).toBeInstanceOf(AbortSignal); expect(pieceOptions?.taskPrefix).toBeUndefined(); }); + + it('should only notify once at run completion when multiple tasks succeed', async () => { + mockLoadGlobalConfig.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 () => { + mockLoadGlobalConfig.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 () => { + mockLoadGlobalConfig.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 () => { + mockLoadGlobalConfig.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 () => { + mockLoadGlobalConfig.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 () => { + mockLoadGlobalConfig.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'); + }); }); }); diff --git a/src/core/models/global-config.ts b/src/core/models/global-config.ts index 26a7b43..4f7e168 100644 --- a/src/core/models/global-config.ts +++ b/src/core/models/global-config.ts @@ -33,6 +33,20 @@ export interface PipelineConfig { prBodyTemplate?: string; } +/** Notification sound toggles per event timing */ +export interface NotificationSoundEventsConfig { + /** Warning when iteration limit is reached */ + iterationLimit?: boolean; + /** Success notification when piece execution completes */ + pieceComplete?: boolean; + /** Error notification when piece execution aborts */ + pieceAbort?: boolean; + /** Success notification when runAllTasks finishes without failures */ + runComplete?: boolean; + /** Error notification when runAllTasks finishes with failures or aborts */ + runAbort?: boolean; +} + /** Global configuration for takt */ export interface GlobalConfig { language: Language; @@ -69,6 +83,8 @@ export interface GlobalConfig { preventSleep?: boolean; /** Enable notification sounds (default: true when undefined) */ notificationSound?: boolean; + /** Notification sound toggles per event timing */ + notificationSoundEvents?: NotificationSoundEventsConfig; /** Number of movement previews to inject into interactive mode (0 to disable, max 10) */ interactivePreviewMovements?: number; /** Number of tasks to run concurrently in takt run (default: 1 = sequential) */ diff --git a/src/core/models/schemas.ts b/src/core/models/schemas.ts index 8345a47..1d4ce4e 100644 --- a/src/core/models/schemas.ts +++ b/src/core/models/schemas.ts @@ -369,6 +369,14 @@ export const GlobalConfigSchema = z.object({ prevent_sleep: z.boolean().optional(), /** Enable notification sounds (default: true when undefined) */ notification_sound: z.boolean().optional(), + /** Notification sound toggles per event timing */ + notification_sound_events: z.object({ + iteration_limit: z.boolean().optional(), + piece_complete: z.boolean().optional(), + piece_abort: z.boolean().optional(), + run_complete: z.boolean().optional(), + run_abort: z.boolean().optional(), + }).optional(), /** Number of movement previews to inject into interactive mode (0 to disable, max 10) */ interactive_preview_movements: z.number().int().min(0).max(10).optional().default(3), /** Number of tasks to run concurrently in takt run (default: 1 = sequential, max: 10) */ diff --git a/src/features/tasks/execute/pieceExecution.ts b/src/features/tasks/execute/pieceExecution.ts index 2b75e59..f87e020 100644 --- a/src/features/tasks/execute/pieceExecution.ts +++ b/src/features/tasks/execute/pieceExecution.ts @@ -296,6 +296,10 @@ export async function executePiece( const isWorktree = cwd !== projectCwd; const globalConfig = loadGlobalConfig(); const shouldNotify = globalConfig.notificationSound !== false; + const notificationSoundEvents = globalConfig.notificationSoundEvents; + const shouldNotifyIterationLimit = shouldNotify && notificationSoundEvents?.iterationLimit !== false; + const shouldNotifyPieceComplete = shouldNotify && notificationSoundEvents?.pieceComplete !== false; + const shouldNotifyPieceAbort = shouldNotify && notificationSoundEvents?.pieceAbort !== false; const currentProvider = globalConfig.provider ?? 'claude'; // Prevent macOS idle sleep if configured @@ -333,7 +337,7 @@ export async function executePiece( ); out.info(getLabel('piece.iterationLimit.currentMovement', undefined, { currentMovement: request.currentMovement })); - if (shouldNotify) { + if (shouldNotifyIterationLimit) { playWarningSound(); } @@ -613,7 +617,7 @@ export async function executePiece( out.success(`Piece completed (${state.iteration} iterations${elapsedDisplay})`); out.info(`Session log: ${ndjsonLogPath}`); - if (shouldNotify) { + if (shouldNotifyPieceComplete) { notifySuccess('TAKT', getLabel('piece.notifyComplete', undefined, { iteration: String(state.iteration) })); } }); @@ -661,7 +665,7 @@ export async function executePiece( out.error(`Piece aborted after ${state.iteration} iterations${elapsedDisplay}: ${reason}`); out.info(`Session log: ${ndjsonLogPath}`); - if (shouldNotify) { + if (shouldNotifyPieceAbort) { notifyError('TAKT', getLabel('piece.notifyAbort', undefined, { reason })); } }); diff --git a/src/features/tasks/execute/taskExecution.ts b/src/features/tasks/execute/taskExecution.ts index b0da2ae..5c17e3a 100644 --- a/src/features/tasks/execute/taskExecution.ts +++ b/src/features/tasks/execute/taskExecution.ts @@ -12,7 +12,8 @@ import { status, blankLine, } from '../../../shared/ui/index.js'; -import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; +import { createLogger, getErrorMessage, notifyError, notifySuccess } 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'; @@ -241,6 +242,10 @@ export async function runAllTasks( ): Promise { const taskRunner = new TaskRunner(cwd); const globalConfig = loadGlobalConfig(); + const shouldNotifyRunComplete = globalConfig.notificationSound !== false + && globalConfig.notificationSoundEvents?.runComplete !== false; + const shouldNotifyRunAbort = globalConfig.notificationSound !== false + && globalConfig.notificationSoundEvents?.runAbort !== false; const concurrency = globalConfig.concurrency; const recovered = taskRunner.recoverInterruptedRunningTasks(); if (recovered > 0) { @@ -260,15 +265,30 @@ export async function runAllTasks( info(`Concurrency: ${concurrency}`); } - const result = await runWithWorkerPool(taskRunner, initialTasks, concurrency, cwd, pieceName, options, globalConfig.taskPollIntervalMs); + try { + const result = await runWithWorkerPool(taskRunner, initialTasks, concurrency, cwd, pieceName, options, globalConfig.taskPollIntervalMs); - const totalCount = result.success + result.fail; - blankLine(); - header('Tasks Summary'); - status('Total', String(totalCount)); - status('Success', String(result.success), result.success === totalCount ? 'green' : undefined); - if (result.fail > 0) { - status('Failed', String(result.fail), 'red'); + const totalCount = result.success + result.fail; + blankLine(); + header('Tasks Summary'); + status('Total', String(totalCount)); + status('Success', String(result.success), result.success === totalCount ? 'green' : undefined); + if (result.fail > 0) { + status('Failed', String(result.fail), 'red'); + if (shouldNotifyRunAbort) { + notifyError('TAKT', getLabel('run.notifyAbort', undefined, { failed: String(result.fail) })); + } + return; + } + + if (shouldNotifyRunComplete) { + notifySuccess('TAKT', getLabel('run.notifyComplete', undefined, { total: String(totalCount) })); + } + } catch (e) { + if (shouldNotifyRunAbort) { + notifyError('TAKT', getLabel('run.notifyAbort', undefined, { failed: getErrorMessage(e) })); + } + throw e; } } diff --git a/src/infra/config/global/globalConfig.ts b/src/infra/config/global/globalConfig.ts index 32f4280..169853b 100644 --- a/src/infra/config/global/globalConfig.ts +++ b/src/infra/config/global/globalConfig.ts @@ -110,6 +110,13 @@ export class GlobalConfigManager { branchNameStrategy: parsed.branch_name_strategy, preventSleep: parsed.prevent_sleep, notificationSound: parsed.notification_sound, + notificationSoundEvents: parsed.notification_sound_events ? { + iterationLimit: parsed.notification_sound_events.iteration_limit, + pieceComplete: parsed.notification_sound_events.piece_complete, + pieceAbort: parsed.notification_sound_events.piece_abort, + runComplete: parsed.notification_sound_events.run_complete, + runAbort: parsed.notification_sound_events.run_abort, + } : undefined, interactivePreviewMovements: parsed.interactive_preview_movements, concurrency: parsed.concurrency, taskPollIntervalMs: parsed.task_poll_interval_ms, @@ -185,6 +192,27 @@ export class GlobalConfigManager { if (config.notificationSound !== undefined) { raw.notification_sound = config.notificationSound; } + if (config.notificationSoundEvents) { + const eventRaw: Record = {}; + if (config.notificationSoundEvents.iterationLimit !== undefined) { + eventRaw.iteration_limit = config.notificationSoundEvents.iterationLimit; + } + if (config.notificationSoundEvents.pieceComplete !== undefined) { + eventRaw.piece_complete = config.notificationSoundEvents.pieceComplete; + } + if (config.notificationSoundEvents.pieceAbort !== undefined) { + eventRaw.piece_abort = config.notificationSoundEvents.pieceAbort; + } + if (config.notificationSoundEvents.runComplete !== undefined) { + eventRaw.run_complete = config.notificationSoundEvents.runComplete; + } + if (config.notificationSoundEvents.runAbort !== undefined) { + eventRaw.run_abort = config.notificationSoundEvents.runAbort; + } + if (Object.keys(eventRaw).length > 0) { + raw.notification_sound_events = eventRaw; + } + } if (config.interactivePreviewMovements !== undefined) { raw.interactive_preview_movements = config.interactivePreviewMovements; } diff --git a/src/shared/i18n/labels_en.yaml b/src/shared/i18n/labels_en.yaml index 1fdbba1..ffbd475 100644 --- a/src/shared/i18n/labels_en.yaml +++ b/src/shared/i18n/labels_en.yaml @@ -58,3 +58,7 @@ piece: notifyAbort: "Aborted: {reason}" sigintGraceful: "Ctrl+C: Aborting piece..." sigintForce: "Ctrl+C: Force exit" + +run: + notifyComplete: "Run complete ({total} tasks)" + notifyAbort: "Run finished with errors ({failed})" diff --git a/src/shared/i18n/labels_ja.yaml b/src/shared/i18n/labels_ja.yaml index 21af472..0c50890 100644 --- a/src/shared/i18n/labels_ja.yaml +++ b/src/shared/i18n/labels_ja.yaml @@ -58,3 +58,7 @@ piece: notifyAbort: "中断: {reason}" sigintGraceful: "Ctrl+C: ピースを中断しています..." sigintForce: "Ctrl+C: 強制終了します" + +run: + notifyComplete: "run完了 ({total} tasks)" + notifyAbort: "runはエラー終了 ({failed})"