From b9c47d29a8ed4ac4a5218e9abe620e2f8264244c Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Fri, 6 Feb 2026 20:33:55 +0900 Subject: [PATCH] takt: github-issue-100-macosdesuriip --- src/__tests__/globalConfig-defaults.test.ts | 47 ++++++++ src/__tests__/sleep.test.ts | 114 +++++++++++++++++++ src/core/models/global-config.ts | 2 + src/core/models/schemas.ts | 2 + src/features/tasks/execute/pieceExecution.ts | 10 +- src/infra/config/global/globalConfig.ts | 4 + src/shared/utils/index.ts | 1 + src/shared/utils/sleep.ts | 46 ++++++++ 8 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/sleep.test.ts create mode 100644 src/shared/utils/sleep.ts diff --git a/src/__tests__/globalConfig-defaults.test.ts b/src/__tests__/globalConfig-defaults.test.ts index 9854205..9718d1e 100644 --- a/src/__tests__/globalConfig-defaults.test.ts +++ b/src/__tests__/globalConfig-defaults.test.ts @@ -193,4 +193,51 @@ describe('loadGlobalConfig', () => { expect(config3.language).toBe('en'); expect(config3).not.toBe(config1); }); + + it('should load prevent_sleep config from config.yaml', () => { + const taktDir = join(testHomeDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + writeFileSync( + getGlobalConfigPath(), + 'language: en\nprevent_sleep: true\n', + 'utf-8', + ); + + const config = loadGlobalConfig(); + + expect(config.preventSleep).toBe(true); + }); + + it('should save and reload prevent_sleep config', () => { + const taktDir = join(testHomeDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8'); + + const config = loadGlobalConfig(); + config.preventSleep = true; + saveGlobalConfig(config); + invalidateGlobalConfigCache(); + + const reloaded = loadGlobalConfig(); + expect(reloaded.preventSleep).toBe(true); + }); + + it('should save prevent_sleep: false when explicitly set', () => { + const taktDir = join(testHomeDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8'); + + const config = loadGlobalConfig(); + config.preventSleep = false; + saveGlobalConfig(config); + invalidateGlobalConfigCache(); + + const reloaded = loadGlobalConfig(); + expect(reloaded.preventSleep).toBe(false); + }); + + it('should have undefined preventSleep by default', () => { + const config = loadGlobalConfig(); + expect(config.preventSleep).toBeUndefined(); + }); }); diff --git a/src/__tests__/sleep.test.ts b/src/__tests__/sleep.test.ts new file mode 100644 index 0000000..e7e821b --- /dev/null +++ b/src/__tests__/sleep.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { ChildProcess } from 'node:child_process'; + +// Mock modules before importing the module under test +vi.mock('node:child_process', () => ({ + spawn: vi.fn(), +})); + +vi.mock('node:os', () => ({ + platform: vi.fn(), +})); + +vi.mock('node:fs', () => ({ + existsSync: vi.fn(), +})); + +// Mock the debug logger +vi.mock('../shared/utils/debug.js', () => ({ + createLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + error: vi.fn(), + enter: vi.fn(), + exit: vi.fn(), + }), +})); + +// Import after mocks are set up +const { spawn } = await import('node:child_process'); +const { platform } = await import('node:os'); +const { existsSync } = await import('node:fs'); +const { preventSleep, resetPreventSleepState } = await import('../shared/utils/sleep.js'); + +describe('preventSleep', () => { + beforeEach(() => { + vi.clearAllMocks(); + resetPreventSleepState(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should do nothing on non-darwin platforms', () => { + vi.mocked(platform).mockReturnValue('linux'); + + preventSleep(); + + expect(spawn).not.toHaveBeenCalled(); + }); + + it('should do nothing on Windows', () => { + vi.mocked(platform).mockReturnValue('win32'); + + preventSleep(); + + expect(spawn).not.toHaveBeenCalled(); + }); + + it('should spawn caffeinate on macOS when available', () => { + vi.mocked(platform).mockReturnValue('darwin'); + vi.mocked(existsSync).mockReturnValue(true); + + const mockChild = { + unref: vi.fn(), + pid: 12345, + } as unknown as ChildProcess; + vi.mocked(spawn).mockReturnValue(mockChild); + + preventSleep(); + + expect(spawn).toHaveBeenCalledWith( + '/usr/bin/caffeinate', + ['-i', '-w', String(process.pid)], + { stdio: 'ignore', detached: true } + ); + expect(mockChild.unref).toHaveBeenCalled(); + }); + + it('should not spawn caffeinate if not found', () => { + vi.mocked(platform).mockReturnValue('darwin'); + vi.mocked(existsSync).mockReturnValue(false); + + preventSleep(); + + expect(spawn).not.toHaveBeenCalled(); + }); + + it('should check for caffeinate at /usr/bin/caffeinate', () => { + vi.mocked(platform).mockReturnValue('darwin'); + vi.mocked(existsSync).mockReturnValue(false); + + preventSleep(); + + expect(existsSync).toHaveBeenCalledWith('/usr/bin/caffeinate'); + }); + + it('should only spawn caffeinate once even when called multiple times', () => { + vi.mocked(platform).mockReturnValue('darwin'); + vi.mocked(existsSync).mockReturnValue(true); + + const mockChild = { + unref: vi.fn(), + pid: 12345, + } as unknown as ChildProcess; + vi.mocked(spawn).mockReturnValue(mockChild); + + preventSleep(); + preventSleep(); + preventSleep(); + + expect(spawn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/core/models/global-config.ts b/src/core/models/global-config.ts index bd1976f..172f182 100644 --- a/src/core/models/global-config.ts +++ b/src/core/models/global-config.ts @@ -63,6 +63,8 @@ export interface GlobalConfig { pieceCategoriesFile?: string; /** Branch name generation strategy: 'romaji' (fast, default) or 'ai' (slow) */ branchNameStrategy?: 'romaji' | 'ai'; + /** Prevent macOS idle sleep during takt execution using caffeinate (default: false) */ + preventSleep?: boolean; } /** Project-level configuration */ diff --git a/src/core/models/schemas.ts b/src/core/models/schemas.ts index c3645b0..d9ccda5 100644 --- a/src/core/models/schemas.ts +++ b/src/core/models/schemas.ts @@ -272,6 +272,8 @@ export const GlobalConfigSchema = z.object({ piece_categories_file: z.string().optional(), /** Branch name generation strategy: 'romaji' (fast, default) or 'ai' (slow) */ branch_name_strategy: z.enum(['romaji', 'ai']).optional(), + /** Prevent macOS idle sleep during takt execution using caffeinate (default: false) */ + prevent_sleep: z.boolean().optional(), }); /** Project config schema */ diff --git a/src/features/tasks/execute/pieceExecution.ts b/src/features/tasks/execute/pieceExecution.ts index d39790a..0ff8119 100644 --- a/src/features/tasks/execute/pieceExecution.ts +++ b/src/features/tasks/execute/pieceExecution.ts @@ -46,7 +46,7 @@ import { type NdjsonInteractiveStart, type NdjsonInteractiveEnd, } from '../../../infra/fs/index.js'; -import { createLogger, notifySuccess, notifyError } from '../../../shared/utils/index.js'; +import { createLogger, notifySuccess, notifyError, preventSleep } from '../../../shared/utils/index.js'; import { selectOption, promptInput } from '../../../shared/prompt/index.js'; import { EXIT_SIGINT } from '../../../shared/exitCodes.js'; import { getLabel } from '../../../shared/i18n/index.js'; @@ -141,7 +141,13 @@ export async function executePiece( // Load saved agent sessions for continuity (from project root or clone-specific storage) const isWorktree = cwd !== projectCwd; - const currentProvider = loadGlobalConfig().provider ?? 'claude'; + const globalConfig = loadGlobalConfig(); + const currentProvider = globalConfig.provider ?? 'claude'; + + // Prevent macOS idle sleep if configured + if (globalConfig.preventSleep) { + preventSleep(); + } const savedSessions = isWorktree ? loadWorktreeSessions(projectCwd, cwd, currentProvider) : loadAgentSessions(projectCwd, currentProvider); diff --git a/src/infra/config/global/globalConfig.ts b/src/infra/config/global/globalConfig.ts index 6597066..d6d4cdc 100644 --- a/src/infra/config/global/globalConfig.ts +++ b/src/infra/config/global/globalConfig.ts @@ -89,6 +89,7 @@ export class GlobalConfigManager { bookmarksFile: parsed.bookmarks_file, pieceCategoriesFile: parsed.piece_categories_file, branchNameStrategy: parsed.branch_name_strategy, + preventSleep: parsed.prevent_sleep, }; this.cachedConfig = config; return config; @@ -151,6 +152,9 @@ export class GlobalConfigManager { if (config.branchNameStrategy) { raw.branch_name_strategy = config.branchNameStrategy; } + if (config.preventSleep !== undefined) { + raw.prevent_sleep = config.preventSleep; + } writeFileSync(configPath, stringifyYaml(raw), 'utf-8'); this.invalidateCache(); } diff --git a/src/shared/utils/index.ts b/src/shared/utils/index.ts index 1f49ef9..1eb7ab4 100644 --- a/src/shared/utils/index.ts +++ b/src/shared/utils/index.ts @@ -6,6 +6,7 @@ export * from './debug.js'; export * from './error.js'; export * from './notification.js'; export * from './reportDir.js'; +export * from './sleep.js'; export * from './slug.js'; export * from './text.js'; export * from './types.js'; diff --git a/src/shared/utils/sleep.ts b/src/shared/utils/sleep.ts new file mode 100644 index 0000000..0c2ec6d --- /dev/null +++ b/src/shared/utils/sleep.ts @@ -0,0 +1,46 @@ +import { spawn } from 'node:child_process'; +import { platform } from 'node:os'; +import { existsSync } from 'node:fs'; +import { createLogger } from './debug.js'; + +const log = createLogger('sleep'); + +let caffeinateStarted = false; + +/** + * takt実行中のmacOSアイドルスリープを防止する。 + * 蓋を閉じた場合のスリープは防げない(-s はAC電源が必要なため)。 + */ +export function preventSleep(): void { + if (caffeinateStarted) { + return; + } + + if (platform() !== 'darwin') { + return; + } + + const caffeinatePath = '/usr/bin/caffeinate'; + if (!existsSync(caffeinatePath)) { + log.info('caffeinate not found, sleep prevention disabled'); + return; + } + + const child = spawn(caffeinatePath, ['-i', '-w', String(process.pid)], { + stdio: 'ignore', + detached: true, + }); + + child.unref(); + + caffeinateStarted = true; + + log.debug('Started caffeinate for sleep prevention', { pid: child.pid }); +} + +/** + * テスト用: caffeinateStarted フラグをリセットする + */ +export function resetPreventSleepState(): void { + caffeinateStarted = false; +}