takt: github-issue-198-e2e-config-yaml (#208)

This commit is contained in:
nrs 2026-02-10 20:03:17 +09:00 committed by GitHub
parent 194610018a
commit 6e67f864f5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 642 additions and 90 deletions

View File

@ -565,6 +565,12 @@ model: sonnet # Default model (optional)
branch_name_strategy: romaji # Branch name generation: 'romaji' (fast) or 'ai' (slow) branch_name_strategy: romaji # Branch name generation: 'romaji' (fast) or 'ai' (slow)
prevent_sleep: false # Prevent macOS idle sleep during execution (caffeinate) prevent_sleep: false # Prevent macOS idle sleep during execution (caffeinate)
notification_sound: true # Enable/disable notification sounds 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) 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) 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) interactive_preview_movements: 3 # Movement previews in interactive mode (0-10, default: 3)

View File

@ -565,6 +565,12 @@ model: sonnet # デフォルトモデル(オプション)
branch_name_strategy: romaji # ブランチ名生成: 'romaji'(高速)または 'ai'(低速) branch_name_strategy: romaji # ブランチ名生成: 'romaji'(高速)または 'ai'(低速)
prevent_sleep: false # macOS の実行中スリープ防止caffeinate prevent_sleep: false # macOS の実行中スリープ防止caffeinate
notification_sound: true # 通知音の有効/無効 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 = 逐次実行) concurrency: 1 # takt run の並列タスク数1-10、デフォルト: 1 = 逐次実行)
task_poll_interval_ms: 500 # takt run 中の新タスク検出ポーリング間隔100-5000、デフォルト: 500 task_poll_interval_ms: 500 # takt run 中の新タスク検出ポーリング間隔100-5000、デフォルト: 500
interactive_preview_movements: 3 # 対話モードでのムーブメントプレビュー数0-10、デフォルト: 3 interactive_preview_movements: 3 # 対話モードでのムーブメントプレビュー数0-10、デフォルト: 3

View File

@ -13,6 +13,13 @@ E2Eテストを追加・変更した場合は、このドキュメントも更
- リポジトリクローン: `$(os.tmpdir())/takt-e2e-repo-<random>/` - リポジトリクローン: `$(os.tmpdir())/takt-e2e-repo-<random>/`
- 実行環境: `$(os.tmpdir())/takt-e2e-<runId>-<random>/` - 実行環境: `$(os.tmpdir())/takt-e2e-<runId>-<random>/`
## 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`: E2E全体を実行。
- `npm run test:e2e:mock`: mock固定のE2Eのみ実行。 - `npm run test:e2e:mock`: mock固定のE2Eのみ実行。

View File

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

View File

@ -1,6 +1,8 @@
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path'; import { dirname, join, resolve } from 'node:path';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
export interface IsolatedEnv { export interface IsolatedEnv {
runId: string; runId: string;
@ -9,6 +11,73 @@ export interface IsolatedEnv {
cleanup: () => void; cleanup: () => void;
} }
type E2EConfig = Record<string, unknown>;
type NotificationSoundEvents = Record<string, unknown>;
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. * Create an isolated environment for E2E testing.
* *
@ -24,18 +93,12 @@ export function createIsolatedEnv(): IsolatedEnv {
const gitConfigPath = join(baseDir, '.gitconfig'); const gitConfigPath = join(baseDir, '.gitconfig');
// Create TAKT config directory and config.yaml // 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 }); mkdirSync(taktDir, { recursive: true });
writeFileSync( const baseConfig = readE2EFixtureConfig();
join(taktDir, 'config.yaml'), const config = process.env.TAKT_E2E_PROVIDER
[ ? { ...baseConfig, provider: process.env.TAKT_E2E_PROVIDER }
`provider: ${configProvider}`, : baseConfig;
'language: en', writeConfigFile(taktDir, config);
'log_level: info',
'default_piece: default',
].join('\n'),
);
// Create isolated Git config file // Create isolated Git config file
writeFileSync( writeFileSync(
@ -58,11 +121,7 @@ export function createIsolatedEnv(): IsolatedEnv {
taktDir, taktDir,
env, env,
cleanup: () => { cleanup: () => {
try {
rmSync(baseDir, { recursive: true, force: true }); rmSync(baseDir, { recursive: true, force: true });
} catch {
// Best-effort cleanup; ignore errors (e.g., already deleted)
}
}, },
}; };
} }

View File

@ -1,10 +1,14 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { execFileSync } from 'node:child_process'; 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 { join, dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { parse as parseYaml } from 'yaml'; 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 { createTestRepo, type TestRepo } from '../helpers/test-repo';
import { runTakt } from '../helpers/takt-runner'; import { runTakt } from '../helpers/takt-runner';
@ -22,16 +26,10 @@ describe('E2E: Add task from GitHub issue (takt add)', () => {
testRepo = createTestRepo(); testRepo = createTestRepo();
// Use mock provider to stabilize summarizer // Use mock provider to stabilize summarizer
writeFileSync( updateIsolatedConfig(isolatedEnv.taktDir, {
join(isolatedEnv.taktDir, 'config.yaml'), provider: 'mock',
[ model: 'mock-model',
'provider: mock', });
'model: mock-model',
'language: en',
'log_level: info',
'default_piece: default',
].join('\n'),
);
const createOutput = execFileSync( const createOutput = execFileSync(
'gh', 'gh',

View File

@ -5,7 +5,11 @@ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { execFileSync } from 'node:child_process'; 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'; import { runTakt } from '../helpers/takt-runner';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
@ -44,15 +48,9 @@ describe('E2E: Provider error handling (mock)', () => {
it('should override config provider with --provider flag', () => { it('should override config provider with --provider flag', () => {
// Given: config.yaml has provider: claude, but CLI flag specifies mock // Given: config.yaml has provider: claude, but CLI flag specifies mock
writeFileSync( updateIsolatedConfig(isolatedEnv.taktDir, {
join(isolatedEnv.taktDir, 'config.yaml'), provider: 'claude',
[ });
'provider: claude',
'language: en',
'log_level: info',
'default_piece: default',
].join('\n'),
);
const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml'); const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml');
const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json'); const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json');

View File

@ -5,7 +5,11 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { execFileSync } from 'node:child_process'; 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'; import { runTakt } from '../helpers/takt-runner';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
@ -39,15 +43,9 @@ describe('E2E: Run multiple tasks (takt run)', () => {
repo = createLocalRepo(); repo = createLocalRepo();
// Override config to use mock provider // Override config to use mock provider
writeFileSync( updateIsolatedConfig(isolatedEnv.taktDir, {
join(isolatedEnv.taktDir, 'config.yaml'), provider: 'mock',
[ });
'provider: mock',
'language: en',
'log_level: info',
'default_piece: default',
].join('\n'),
);
}); });
afterEach(() => { afterEach(() => {

View File

@ -3,7 +3,11 @@ import { spawn } from 'node:child_process';
import { mkdirSync, writeFileSync, readFileSync } from 'node:fs'; import { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
import { join, resolve, dirname } from 'node:path'; import { join, resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url'; 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'; import { createTestRepo, type TestRepo } from '../helpers/test-repo';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
@ -50,18 +54,12 @@ describe('E2E: Run tasks graceful shutdown on SIGINT (parallel)', () => {
isolatedEnv = createIsolatedEnv(); isolatedEnv = createIsolatedEnv();
testRepo = createTestRepo(); testRepo = createTestRepo();
writeFileSync( updateIsolatedConfig(isolatedEnv.taktDir, {
join(isolatedEnv.taktDir, 'config.yaml'), provider: 'mock',
[ model: 'mock-model',
'provider: mock', concurrency: 2,
'model: mock-model', task_poll_interval_ms: 100,
'language: en', });
'log_level: info',
'default_piece: default',
'concurrency: 2',
'task_poll_interval_ms: 100',
].join('\n'),
);
}); });
afterEach(() => { afterEach(() => {

View File

@ -5,7 +5,11 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { execFileSync } from 'node:child_process'; 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'; import { runTakt } from '../helpers/takt-runner';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
@ -38,15 +42,9 @@ describe('E2E: Task content_file reference (mock)', () => {
isolatedEnv = createIsolatedEnv(); isolatedEnv = createIsolatedEnv();
repo = createLocalRepo(); repo = createLocalRepo();
writeFileSync( updateIsolatedConfig(isolatedEnv.taktDir, {
join(isolatedEnv.taktDir, 'config.yaml'), provider: 'mock',
[ });
'provider: mock',
'language: en',
'log_level: info',
'default_piece: default',
].join('\n'),
);
}); });
afterEach(() => { afterEach(() => {

View File

@ -1,6 +1,11 @@
import { describe, it, expect, afterEach } from 'vitest'; 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 { 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', () => { describe('injectProviderArgs', () => {
it('should prepend --provider when provider is specified', () => { 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).toBeDefined();
expect(isolated.env.GIT_CONFIG_GLOBAL).toContain('takt-e2e-'); 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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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');
});
}); });

View File

@ -287,6 +287,59 @@ describe('loadGlobalConfig', () => {
expect(config.notificationSound).toBeUndefined(); 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', () => { it('should load interactive_preview_movements config from config.yaml', () => {
const taktDir = join(testHomeDir, '.takt'); const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true }); mkdirSync(taktDir, { recursive: true });

View File

@ -282,6 +282,22 @@ describe('executePiece: notification sound behavior', () => {
expect(mockNotifySuccess).not.toHaveBeenCalled(); 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', () => { describe('notifyError on piece:abort', () => {
@ -320,6 +336,22 @@ describe('executePiece: notification sound behavior', () => {
expect(mockNotifyError).not.toHaveBeenCalled(); 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', () => { describe('playWarningSound on iteration limit', () => {
@ -361,5 +393,22 @@ describe('executePiece: notification sound behavior', () => {
expect(mockPlayWarningSound).not.toHaveBeenCalled(); 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();
});
}); });
}); });

View File

@ -21,10 +21,21 @@ vi.mock('../infra/config/index.js', () => ({
import { loadGlobalConfig } from '../infra/config/index.js'; import { loadGlobalConfig } from '../infra/config/index.js';
const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig); const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig);
const mockClaimNextTasks = vi.fn(); const {
const mockCompleteTask = vi.fn(); mockClaimNextTasks,
const mockFailTask = vi.fn(); mockCompleteTask,
const mockRecoverInterruptedRunningTasks = vi.fn(); 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) => ({ vi.mock('../infra/task/index.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()), ...(await importOriginal<Record<string, unknown>>()),
@ -75,6 +86,8 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
error: vi.fn(), error: vi.fn(),
}), }),
getErrorMessage: vi.fn((e) => e.message), getErrorMessage: vi.fn((e) => e.message),
notifySuccess: mockNotifySuccess,
notifyError: mockNotifyError,
})); }));
vi.mock('../features/tasks/execute/pieceExecution.js', () => ({ vi.mock('../features/tasks/execute/pieceExecution.js', () => ({
@ -149,6 +162,8 @@ describe('runAllTasks concurrency', () => {
language: 'en', language: 'en',
defaultPiece: 'default', defaultPiece: 'default',
logLevel: 'info', logLevel: 'info',
notificationSound: true,
notificationSoundEvents: { runComplete: true, runAbort: true },
concurrency: 1, concurrency: 1,
taskPollIntervalMs: 500, taskPollIntervalMs: 500,
}); });
@ -190,6 +205,8 @@ describe('runAllTasks concurrency', () => {
language: 'en', language: 'en',
defaultPiece: 'default', defaultPiece: 'default',
logLevel: 'info', logLevel: 'info',
notificationSound: true,
notificationSoundEvents: { runComplete: true, runAbort: true },
concurrency: 3, concurrency: 3,
taskPollIntervalMs: 500, taskPollIntervalMs: 500,
}); });
@ -266,6 +283,7 @@ describe('runAllTasks concurrency', () => {
language: 'en', language: 'en',
defaultPiece: 'default', defaultPiece: 'default',
logLevel: 'info', logLevel: 'info',
notificationSound: false,
concurrency: 1, concurrency: 1,
taskPollIntervalMs: 500, taskPollIntervalMs: 500,
}); });
@ -283,6 +301,8 @@ describe('runAllTasks concurrency', () => {
(call) => typeof call[0] === 'string' && call[0].startsWith('Concurrency:') (call) => typeof call[0] === 'string' && call[0].startsWith('Concurrency:')
); );
expect(concurrencyInfoCalls).toHaveLength(0); 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 () => { it('should count partial failures correctly', async () => {
// Given: 3 tasks, 1 fails, 2 succeed // 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 task1 = createTask('pass-1');
const task2 = createTask('fail-1'); const task2 = createTask('fail-1');
const task3 = createTask('pass-2'); const task3 = createTask('pass-2');
@ -406,6 +436,8 @@ describe('runAllTasks concurrency', () => {
expect(mockStatus).toHaveBeenCalledWith('Total', '3'); expect(mockStatus).toHaveBeenCalledWith('Total', '3');
expect(mockStatus).toHaveBeenCalledWith('Success', '2', undefined); expect(mockStatus).toHaveBeenCalledWith('Success', '2', undefined);
expect(mockStatus).toHaveBeenCalledWith('Failed', '1', 'red'); 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 () => { it('should persist failure reason and movement when piece aborts', async () => {
@ -458,6 +490,8 @@ describe('runAllTasks concurrency', () => {
language: 'en', language: 'en',
defaultPiece: 'default', defaultPiece: 'default',
logLevel: 'info', logLevel: 'info',
notificationSound: true,
notificationSoundEvents: { runComplete: true, runAbort: true },
concurrency: 1, concurrency: 1,
taskPollIntervalMs: 500, taskPollIntervalMs: 500,
}); });
@ -480,5 +514,145 @@ describe('runAllTasks concurrency', () => {
expect(pieceOptions?.abortSignal).toBeInstanceOf(AbortSignal); expect(pieceOptions?.abortSignal).toBeInstanceOf(AbortSignal);
expect(pieceOptions?.taskPrefix).toBeUndefined(); 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');
});
}); });
}); });

View File

@ -33,6 +33,20 @@ export interface PipelineConfig {
prBodyTemplate?: string; 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 */ /** Global configuration for takt */
export interface GlobalConfig { export interface GlobalConfig {
language: Language; language: Language;
@ -69,6 +83,8 @@ export interface GlobalConfig {
preventSleep?: boolean; preventSleep?: boolean;
/** Enable notification sounds (default: true when undefined) */ /** Enable notification sounds (default: true when undefined) */
notificationSound?: boolean; notificationSound?: boolean;
/** Notification sound toggles per event timing */
notificationSoundEvents?: NotificationSoundEventsConfig;
/** Number of movement previews to inject into interactive mode (0 to disable, max 10) */ /** Number of movement previews to inject into interactive mode (0 to disable, max 10) */
interactivePreviewMovements?: number; interactivePreviewMovements?: number;
/** Number of tasks to run concurrently in takt run (default: 1 = sequential) */ /** Number of tasks to run concurrently in takt run (default: 1 = sequential) */

View File

@ -369,6 +369,14 @@ export const GlobalConfigSchema = z.object({
prevent_sleep: z.boolean().optional(), prevent_sleep: z.boolean().optional(),
/** Enable notification sounds (default: true when undefined) */ /** Enable notification sounds (default: true when undefined) */
notification_sound: z.boolean().optional(), 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) */ /** 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), 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) */ /** Number of tasks to run concurrently in takt run (default: 1 = sequential, max: 10) */

View File

@ -296,6 +296,10 @@ export async function executePiece(
const isWorktree = cwd !== projectCwd; const isWorktree = cwd !== projectCwd;
const globalConfig = loadGlobalConfig(); const globalConfig = loadGlobalConfig();
const shouldNotify = globalConfig.notificationSound !== false; 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'; const currentProvider = globalConfig.provider ?? 'claude';
// Prevent macOS idle sleep if configured // Prevent macOS idle sleep if configured
@ -333,7 +337,7 @@ export async function executePiece(
); );
out.info(getLabel('piece.iterationLimit.currentMovement', undefined, { currentMovement: request.currentMovement })); out.info(getLabel('piece.iterationLimit.currentMovement', undefined, { currentMovement: request.currentMovement }));
if (shouldNotify) { if (shouldNotifyIterationLimit) {
playWarningSound(); playWarningSound();
} }
@ -613,7 +617,7 @@ export async function executePiece(
out.success(`Piece completed (${state.iteration} iterations${elapsedDisplay})`); out.success(`Piece completed (${state.iteration} iterations${elapsedDisplay})`);
out.info(`Session log: ${ndjsonLogPath}`); out.info(`Session log: ${ndjsonLogPath}`);
if (shouldNotify) { if (shouldNotifyPieceComplete) {
notifySuccess('TAKT', getLabel('piece.notifyComplete', undefined, { iteration: String(state.iteration) })); 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.error(`Piece aborted after ${state.iteration} iterations${elapsedDisplay}: ${reason}`);
out.info(`Session log: ${ndjsonLogPath}`); out.info(`Session log: ${ndjsonLogPath}`);
if (shouldNotify) { if (shouldNotifyPieceAbort) {
notifyError('TAKT', getLabel('piece.notifyAbort', undefined, { reason })); notifyError('TAKT', getLabel('piece.notifyAbort', undefined, { reason }));
} }
}); });

View File

@ -12,7 +12,8 @@ import {
status, status,
blankLine, blankLine,
} from '../../../shared/ui/index.js'; } 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 { executePiece } from './pieceExecution.js';
import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js'; import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js';
import type { TaskExecutionOptions, ExecuteTaskOptions, PieceExecutionResult } from './types.js'; import type { TaskExecutionOptions, ExecuteTaskOptions, PieceExecutionResult } from './types.js';
@ -241,6 +242,10 @@ export async function runAllTasks(
): Promise<void> { ): Promise<void> {
const taskRunner = new TaskRunner(cwd); const taskRunner = new TaskRunner(cwd);
const globalConfig = loadGlobalConfig(); 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 concurrency = globalConfig.concurrency;
const recovered = taskRunner.recoverInterruptedRunningTasks(); const recovered = taskRunner.recoverInterruptedRunningTasks();
if (recovered > 0) { if (recovered > 0) {
@ -260,6 +265,7 @@ export async function runAllTasks(
info(`Concurrency: ${concurrency}`); info(`Concurrency: ${concurrency}`);
} }
try {
const result = await runWithWorkerPool(taskRunner, initialTasks, concurrency, cwd, pieceName, options, globalConfig.taskPollIntervalMs); const result = await runWithWorkerPool(taskRunner, initialTasks, concurrency, cwd, pieceName, options, globalConfig.taskPollIntervalMs);
const totalCount = result.success + result.fail; const totalCount = result.success + result.fail;
@ -269,6 +275,20 @@ export async function runAllTasks(
status('Success', String(result.success), result.success === totalCount ? 'green' : undefined); status('Success', String(result.success), result.success === totalCount ? 'green' : undefined);
if (result.fail > 0) { if (result.fail > 0) {
status('Failed', String(result.fail), 'red'); 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;
} }
} }

View File

@ -110,6 +110,13 @@ export class GlobalConfigManager {
branchNameStrategy: parsed.branch_name_strategy, branchNameStrategy: parsed.branch_name_strategy,
preventSleep: parsed.prevent_sleep, preventSleep: parsed.prevent_sleep,
notificationSound: parsed.notification_sound, 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, interactivePreviewMovements: parsed.interactive_preview_movements,
concurrency: parsed.concurrency, concurrency: parsed.concurrency,
taskPollIntervalMs: parsed.task_poll_interval_ms, taskPollIntervalMs: parsed.task_poll_interval_ms,
@ -185,6 +192,27 @@ export class GlobalConfigManager {
if (config.notificationSound !== undefined) { if (config.notificationSound !== undefined) {
raw.notification_sound = config.notificationSound; raw.notification_sound = config.notificationSound;
} }
if (config.notificationSoundEvents) {
const eventRaw: Record<string, unknown> = {};
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) { if (config.interactivePreviewMovements !== undefined) {
raw.interactive_preview_movements = config.interactivePreviewMovements; raw.interactive_preview_movements = config.interactivePreviewMovements;
} }

View File

@ -58,3 +58,7 @@ piece:
notifyAbort: "Aborted: {reason}" notifyAbort: "Aborted: {reason}"
sigintGraceful: "Ctrl+C: Aborting piece..." sigintGraceful: "Ctrl+C: Aborting piece..."
sigintForce: "Ctrl+C: Force exit" sigintForce: "Ctrl+C: Force exit"
run:
notifyComplete: "Run complete ({total} tasks)"
notifyAbort: "Run finished with errors ({failed})"

View File

@ -58,3 +58,7 @@ piece:
notifyAbort: "中断: {reason}" notifyAbort: "中断: {reason}"
sigintGraceful: "Ctrl+C: ピースを中断しています..." sigintGraceful: "Ctrl+C: ピースを中断しています..."
sigintForce: "Ctrl+C: 強制終了します" sigintForce: "Ctrl+C: 強制終了します"
run:
notifyComplete: "run完了 ({total} tasks)"
notifyAbort: "runはエラー終了 ({failed})"