takt: github-issue-100-macosdesuriip

This commit is contained in:
nrslib 2026-02-06 20:33:55 +09:00
parent 24361b34e3
commit b9c47d29a8
8 changed files with 224 additions and 2 deletions

View File

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

114
src/__tests__/sleep.test.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

46
src/shared/utils/sleep.ts Normal file
View File

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