diff --git a/e2e/specs/run-sigint-graceful.e2e.ts b/e2e/specs/run-sigint-graceful.e2e.ts index 79c2d85..041ce7c 100644 --- a/e2e/specs/run-sigint-graceful.e2e.ts +++ b/e2e/specs/run-sigint-graceful.e2e.ts @@ -171,4 +171,85 @@ describe('E2E: Run tasks graceful shutdown on SIGINT (parallel)', () => { expect(stderr).not.toContain('UnhandledPromiseRejection'); } }, 120_000); + + it('should force exit immediately on second SIGINT', async () => { + const binPath = resolve(__dirname, '../../bin/takt'); + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-slow-multi-step.yaml'); + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/run-sigint-parallel.json'); + + const tasksFile = join(testRepo.path, '.takt', 'tasks.yaml'); + mkdirSync(join(testRepo.path, '.takt'), { recursive: true }); + + const now = new Date().toISOString(); + writeFileSync( + tasksFile, + [ + 'tasks:', + ' - name: sigint-a', + ' status: pending', + ' content: "E2E SIGINT task A"', + ` piece: "${piecePath}"`, + ' worktree: true', + ` created_at: "${now}"`, + ' started_at: null', + ' completed_at: null', + ' owner_pid: null', + ' - name: sigint-b', + ' status: pending', + ' content: "E2E SIGINT task B"', + ` piece: "${piecePath}"`, + ' worktree: true', + ` created_at: "${now}"`, + ' started_at: null', + ' completed_at: null', + ' owner_pid: null', + ' - name: sigint-c', + ' status: pending', + ' content: "E2E SIGINT task C"', + ` piece: "${piecePath}"`, + ' worktree: true', + ` created_at: "${now}"`, + ' started_at: null', + ' completed_at: null', + ' owner_pid: null', + ].join('\n'), + 'utf-8', + ); + + const child = spawn('node', [binPath, 'run', '--provider', 'mock'], { + cwd: testRepo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + TAKT_E2E_SELF_SIGINT_TWICE: '1', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + child.stdout?.on('data', (chunk) => { + stdout += chunk.toString(); + }); + child.stderr?.on('data', (chunk) => { + stderr += chunk.toString(); + }); + + const workersFilled = await waitFor( + () => stdout.includes('=== Task: sigint-b ==='), + 30_000, + 20, + ); + expect(workersFilled, `stdout:\n${stdout}\n\nstderr:\n${stderr}`).toBe(true); + + const exit = await waitForClose(child, 60_000); + expect( + exit.signal === 'SIGINT' || exit.code === 130, + `unexpected exit: code=${exit.code}, signal=${exit.signal}`, + ).toBe(true); + + if (stderr.trim().length > 0) { + expect(stderr).not.toContain('UnhandledPromiseRejection'); + } + }, 120_000); }); diff --git a/src/__tests__/claude-executor-abort-signal.test.ts b/src/__tests__/claude-executor-abort-signal.test.ts new file mode 100644 index 0000000..dca6c24 --- /dev/null +++ b/src/__tests__/claude-executor-abort-signal.test.ts @@ -0,0 +1,89 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + queryMock, + interruptMock, + AbortErrorMock, +} = vi.hoisted(() => { + const interruptMock = vi.fn(async () => {}); + class AbortErrorMock extends Error {} + const queryMock = vi.fn(() => { + let interrupted = false; + interruptMock.mockImplementation(async () => { + interrupted = true; + }); + + return { + interrupt: interruptMock, + async *[Symbol.asyncIterator](): AsyncGenerator { + while (!interrupted) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new AbortErrorMock('aborted'); + }, + }; + }); + + return { + queryMock, + interruptMock, + AbortErrorMock, + }; +}); + +vi.mock('@anthropic-ai/claude-agent-sdk', () => ({ + query: queryMock, + AbortError: AbortErrorMock, +})); + +vi.mock('../shared/utils/index.js', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + createLogger: vi.fn().mockReturnValue({ + debug: vi.fn(), + info: vi.fn(), + error: vi.fn(), + }), + }; +}); + +import { QueryExecutor } from '../infra/claude/executor.js'; + +describe('QueryExecutor abortSignal wiring', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('abortSignal 発火時に query.interrupt() を呼ぶ', async () => { + const controller = new AbortController(); + const executor = new QueryExecutor(); + + const promise = executor.execute('test', { + cwd: '/tmp/project', + abortSignal: controller.signal, + }); + + await new Promise((resolve) => setTimeout(resolve, 20)); + controller.abort(); + + const result = await promise; + + expect(interruptMock).toHaveBeenCalledTimes(1); + expect(result.interrupted).toBe(true); + }); + + it('開始前に中断済みの signal でも query.interrupt() を呼ぶ', async () => { + const controller = new AbortController(); + controller.abort(); + + const executor = new QueryExecutor(); + const result = await executor.execute('test', { + cwd: '/tmp/project', + abortSignal: controller.signal, + }); + + expect(interruptMock).toHaveBeenCalledTimes(1); + expect(result.interrupted).toBe(true); + }); +}); diff --git a/src/__tests__/claude-provider-abort-signal.test.ts b/src/__tests__/claude-provider-abort-signal.test.ts new file mode 100644 index 0000000..16fcb73 --- /dev/null +++ b/src/__tests__/claude-provider-abort-signal.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AgentSetup } from '../infra/providers/types.js'; + +const { + mockCallClaude, + mockResolveAnthropicApiKey, +} = vi.hoisted(() => ({ + mockCallClaude: vi.fn(), + mockResolveAnthropicApiKey: vi.fn(), +})); + +vi.mock('../infra/claude/index.js', () => ({ + callClaude: mockCallClaude, + callClaudeCustom: vi.fn(), + callClaudeAgent: vi.fn(), + callClaudeSkill: vi.fn(), +})); + +vi.mock('../infra/config/index.js', () => ({ + resolveAnthropicApiKey: mockResolveAnthropicApiKey, +})); + +import { ClaudeProvider } from '../infra/providers/claude.js'; + +describe('ClaudeProvider abortSignal wiring', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockResolveAnthropicApiKey.mockReturnValue(undefined); + mockCallClaude.mockResolvedValue({ + persona: 'coder', + status: 'done', + content: 'ok', + timestamp: new Date(), + }); + }); + + it('ProviderCallOptions.abortSignal を Claude call options に渡す', async () => { + const provider = new ClaudeProvider(); + const setup: AgentSetup = { name: 'coder' }; + const agent = provider.setup(setup); + const controller = new AbortController(); + + await agent.call('test prompt', { + cwd: '/tmp/project', + abortSignal: controller.signal, + }); + + expect(mockCallClaude).toHaveBeenCalledTimes(1); + const callOptions = mockCallClaude.mock.calls[0]?.[2]; + expect(callOptions).toHaveProperty('abortSignal', controller.signal); + }); +}); diff --git a/src/__tests__/i18n.test.ts b/src/__tests__/i18n.test.ts index bed0d12..5e962b7 100644 --- a/src/__tests__/i18n.test.ts +++ b/src/__tests__/i18n.test.ts @@ -114,6 +114,7 @@ describe('label integrity', () => { expect(() => getLabel('piece.notifyComplete')).not.toThrow(); expect(() => getLabel('piece.notifyAbort')).not.toThrow(); expect(() => getLabel('piece.sigintGraceful')).not.toThrow(); + expect(() => getLabel('piece.sigintTimeout')).not.toThrow(); expect(() => getLabel('piece.sigintForce')).not.toThrow(); }); diff --git a/src/__tests__/shutdownManager.test.ts b/src/__tests__/shutdownManager.test.ts new file mode 100644 index 0000000..0a0f1fb --- /dev/null +++ b/src/__tests__/shutdownManager.test.ts @@ -0,0 +1,173 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockWarn, + mockError, + mockBlankLine, + mockGetLabel, +} = vi.hoisted(() => ({ + mockWarn: vi.fn(), + mockError: vi.fn(), + mockBlankLine: vi.fn(), + mockGetLabel: vi.fn((key: string) => key), +})); + +vi.mock('../shared/ui/index.js', () => ({ + warn: mockWarn, + error: mockError, + blankLine: mockBlankLine, +})); + +vi.mock('../shared/i18n/index.js', () => ({ + getLabel: mockGetLabel, +})); + +import { ShutdownManager } from '../features/tasks/execute/shutdownManager.js'; + +describe('ShutdownManager', () => { + let savedSigintListeners: ((...args: unknown[]) => void)[]; + let originalShutdownTimeoutEnv: string | undefined; + + beforeEach(() => { + vi.clearAllMocks(); + savedSigintListeners = process.rawListeners('SIGINT') as ((...args: unknown[]) => void)[]; + originalShutdownTimeoutEnv = process.env.TAKT_SHUTDOWN_TIMEOUT_MS; + delete process.env.TAKT_SHUTDOWN_TIMEOUT_MS; + }); + + afterEach(() => { + vi.useRealTimers(); + process.removeAllListeners('SIGINT'); + for (const listener of savedSigintListeners) { + process.on('SIGINT', listener as NodeJS.SignalsListener); + } + if (originalShutdownTimeoutEnv === undefined) { + delete process.env.TAKT_SHUTDOWN_TIMEOUT_MS; + } else { + process.env.TAKT_SHUTDOWN_TIMEOUT_MS = originalShutdownTimeoutEnv; + } + }); + + it('1回目SIGINTでgracefulコールバックを呼ぶ', () => { + const onGraceful = vi.fn(); + const onForceKill = vi.fn(); + + const manager = new ShutdownManager({ + callbacks: { onGraceful, onForceKill }, + gracefulTimeoutMs: 1_000, + }); + manager.install(); + + const listeners = process.rawListeners('SIGINT') as Array<() => void>; + listeners[listeners.length - 1]!(); + + expect(onGraceful).toHaveBeenCalledTimes(1); + expect(onForceKill).not.toHaveBeenCalled(); + expect(mockWarn).toHaveBeenCalledWith('piece.sigintGraceful'); + + manager.cleanup(); + }); + + it('graceful timeoutでforceコールバックを呼ぶ', () => { + vi.useFakeTimers(); + const onGraceful = vi.fn(); + const onForceKill = vi.fn(); + + const manager = new ShutdownManager({ + callbacks: { onGraceful, onForceKill }, + gracefulTimeoutMs: 50, + }); + manager.install(); + + const listeners = process.rawListeners('SIGINT') as Array<() => void>; + listeners[listeners.length - 1]!(); + vi.advanceTimersByTime(50); + + expect(onGraceful).toHaveBeenCalledTimes(1); + expect(onForceKill).toHaveBeenCalledTimes(1); + expect(mockError).toHaveBeenCalledWith('piece.sigintTimeout'); + expect(mockError).toHaveBeenCalledWith('piece.sigintForce'); + + manager.cleanup(); + }); + + it('2回目SIGINTで即時forceコールバックを呼び、timeoutを待たない', () => { + vi.useFakeTimers(); + const onGraceful = vi.fn(); + const onForceKill = vi.fn(); + + const manager = new ShutdownManager({ + callbacks: { onGraceful, onForceKill }, + gracefulTimeoutMs: 10_000, + }); + manager.install(); + + const listeners = process.rawListeners('SIGINT') as Array<() => void>; + const handler = listeners[listeners.length - 1]!; + handler(); + handler(); + vi.advanceTimersByTime(10_000); + + expect(onGraceful).toHaveBeenCalledTimes(1); + expect(onForceKill).toHaveBeenCalledTimes(1); + expect(mockError).toHaveBeenCalledWith('piece.sigintForce'); + + manager.cleanup(); + }); + + it('環境変数未設定時はデフォルト10_000msを使う', () => { + vi.useFakeTimers(); + const onGraceful = vi.fn(); + const onForceKill = vi.fn(); + + const manager = new ShutdownManager({ + callbacks: { onGraceful, onForceKill }, + }); + manager.install(); + + const listeners = process.rawListeners('SIGINT') as Array<() => void>; + listeners[listeners.length - 1]!(); + + vi.advanceTimersByTime(9_999); + expect(onForceKill).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + expect(onForceKill).toHaveBeenCalledTimes(1); + + manager.cleanup(); + }); + + it('環境変数設定時はその値をtimeoutとして使う', () => { + vi.useFakeTimers(); + process.env.TAKT_SHUTDOWN_TIMEOUT_MS = '25'; + const onGraceful = vi.fn(); + const onForceKill = vi.fn(); + + const manager = new ShutdownManager({ + callbacks: { onGraceful, onForceKill }, + }); + manager.install(); + + const listeners = process.rawListeners('SIGINT') as Array<() => void>; + listeners[listeners.length - 1]!(); + + vi.advanceTimersByTime(24); + expect(onForceKill).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + expect(onForceKill).toHaveBeenCalledTimes(1); + + manager.cleanup(); + }); + + it('不正な環境変数値ではエラーをthrowする', () => { + process.env.TAKT_SHUTDOWN_TIMEOUT_MS = '0'; + + expect( + () => + new ShutdownManager({ + callbacks: { onGraceful: vi.fn(), onForceKill: vi.fn() }, + }), + ).toThrowError('TAKT_SHUTDOWN_TIMEOUT_MS must be a positive integer'); + }); +}); diff --git a/src/__tests__/watchTasks.test.ts b/src/__tests__/watchTasks.test.ts index 2012287..81991ee 100644 --- a/src/__tests__/watchTasks.test.ts +++ b/src/__tests__/watchTasks.test.ts @@ -12,6 +12,8 @@ const { mockBlankLine, mockStatus, mockSuccess, + mockWarn, + mockError, mockGetCurrentPiece, } = vi.hoisted(() => ({ mockRecoverInterruptedRunningTasks: vi.fn(), @@ -24,6 +26,8 @@ const { mockBlankLine: vi.fn(), mockStatus: vi.fn(), mockSuccess: vi.fn(), + mockWarn: vi.fn(), + mockError: vi.fn(), mockGetCurrentPiece: vi.fn(), })); @@ -45,11 +49,17 @@ vi.mock('../features/tasks/execute/taskExecution.js', () => ({ vi.mock('../shared/ui/index.js', () => ({ header: mockHeader, info: mockInfo, + warn: mockWarn, + error: mockError, success: mockSuccess, status: mockStatus, blankLine: mockBlankLine, })); +vi.mock('../shared/i18n/index.js', () => ({ + getLabel: vi.fn((key: string) => key), +})); + vi.mock('../infra/config/index.js', () => ({ getCurrentPiece: mockGetCurrentPiece, })); diff --git a/src/features/tasks/execute/parallelExecution.ts b/src/features/tasks/execute/parallelExecution.ts index 2967c56..ff46711 100644 --- a/src/features/tasks/execute/parallelExecution.ts +++ b/src/features/tasks/execute/parallelExecution.ts @@ -14,9 +14,10 @@ import type { TaskRunner, TaskInfo } from '../../../infra/task/index.js'; import { info, blankLine } from '../../../shared/ui/index.js'; import { TaskPrefixWriter } from '../../../shared/ui/TaskPrefixWriter.js'; +import { EXIT_SIGINT } from '../../../shared/exitCodes.js'; import { createLogger } from '../../../shared/utils/index.js'; import { executeAndCompleteTask } from './taskExecution.js'; -import { installSigIntHandler } from './sigintHandler.js'; +import { ShutdownManager } from './shutdownManager.js'; import type { TaskExecutionOptions } from './types.js'; const log = createLogger('worker-pool'); @@ -96,8 +97,15 @@ export async function runWithWorkerPool( pollIntervalMs: number, ): Promise { const abortController = new AbortController(); - const { cleanup } = installSigIntHandler(() => abortController.abort()); + const shutdownManager = new ShutdownManager({ + callbacks: { + onGraceful: () => abortController.abort(), + onForceKill: () => process.exit(EXIT_SIGINT), + }, + }); + shutdownManager.install(); const selfSigintOnce = process.env.TAKT_E2E_SELF_SIGINT_ONCE === '1'; + const selfSigintTwice = process.env.TAKT_E2E_SELF_SIGINT_TWICE === '1'; let selfSigintInjected = false; let successCount = 0; @@ -111,9 +119,12 @@ export async function runWithWorkerPool( while (queue.length > 0 || active.size > 0) { if (!abortController.signal.aborted) { fillSlots(queue, active, concurrency, taskRunner, cwd, pieceName, options, abortController, colorCounter); - if (selfSigintOnce && !selfSigintInjected && active.size > 0) { + if ((selfSigintOnce || selfSigintTwice) && !selfSigintInjected && active.size > 0) { selfSigintInjected = true; process.emit('SIGINT'); + if (selfSigintTwice) { + process.emit('SIGINT'); + } } } @@ -169,7 +180,7 @@ export async function runWithWorkerPool( } } } finally { - cleanup(); + shutdownManager.cleanup(); } return { success: successCount, fail: failCount }; diff --git a/src/features/tasks/execute/pieceExecution.ts b/src/features/tasks/execute/pieceExecution.ts index 01bd805..0135225 100644 --- a/src/features/tasks/execute/pieceExecution.ts +++ b/src/features/tasks/execute/pieceExecution.ts @@ -65,7 +65,8 @@ import { } from '../../../shared/utils/providerEventLogger.js'; import { selectOption, promptInput } from '../../../shared/prompt/index.js'; import { getLabel } from '../../../shared/i18n/index.js'; -import { installSigIntHandler } from './sigintHandler.js'; +import { EXIT_SIGINT } from '../../../shared/exitCodes.js'; +import { ShutdownManager } from './shutdownManager.js'; import { buildRunPaths } from '../../../core/piece/run/run-paths.js'; import { resolveMovementProviderModel } from '../../../core/piece/provider-resolution.js'; import { writeFileAtomic, ensureDir } from '../../../infra/config/index.js'; @@ -407,7 +408,7 @@ export async function executePiece( const movementIterations = new Map(); let engine: PieceEngine | null = null; let onAbortSignal: (() => void) | undefined; - let sigintCleanup: (() => void) | undefined; + let shutdownManager: ShutdownManager | undefined; let onEpipe: ((err: NodeJS.ErrnoException) => void) | undefined; const runAbortController = new AbortController(); @@ -730,8 +731,13 @@ export async function executePiece( options.abortSignal!.addEventListener('abort', onAbortSignal, { once: true }); } } else { - const handler = installSigIntHandler(abortEngine); - sigintCleanup = handler.cleanup; + shutdownManager = new ShutdownManager({ + callbacks: { + onGraceful: abortEngine, + onForceKill: () => process.exit(EXIT_SIGINT), + }, + }); + shutdownManager.install(); } const finalState = await engine.run(); @@ -749,7 +755,7 @@ export async function executePiece( throw error; } finally { prefixWriter?.flush(); - sigintCleanup?.(); + shutdownManager?.cleanup(); if (onAbortSignal && options.abortSignal) { options.abortSignal.removeEventListener('abort', onAbortSignal); } diff --git a/src/features/tasks/execute/shutdownManager.ts b/src/features/tasks/execute/shutdownManager.ts new file mode 100644 index 0000000..1ad509e --- /dev/null +++ b/src/features/tasks/execute/shutdownManager.ts @@ -0,0 +1,108 @@ +import { blankLine, warn, error } from '../../../shared/ui/index.js'; +import { getLabel } from '../../../shared/i18n/index.js'; + +export interface ShutdownCallbacks { + onGraceful: () => void; + onForceKill: () => void; +} + +export interface ShutdownManagerOptions { + callbacks: ShutdownCallbacks; + gracefulTimeoutMs?: number; +} + +type ShutdownState = 'idle' | 'graceful' | 'forcing'; + +const DEFAULT_SHUTDOWN_TIMEOUT_MS = 10_000; + +function parseTimeoutMs(raw: string | undefined): number | undefined { + if (!raw) { + return undefined; + } + + const value = Number.parseInt(raw, 10); + if (!Number.isFinite(value) || value <= 0) { + throw new Error('TAKT_SHUTDOWN_TIMEOUT_MS must be a positive integer'); + } + + return value; +} + +function resolveShutdownTimeoutMs(): number { + return parseTimeoutMs(process.env.TAKT_SHUTDOWN_TIMEOUT_MS) ?? DEFAULT_SHUTDOWN_TIMEOUT_MS; +} + +export class ShutdownManager { + private readonly callbacks: ShutdownCallbacks; + private readonly gracefulTimeoutMs: number; + private state: ShutdownState = 'idle'; + private timeoutId: ReturnType | undefined; + private readonly sigintHandler: () => void; + + constructor(options: ShutdownManagerOptions) { + this.callbacks = options.callbacks; + this.gracefulTimeoutMs = options.gracefulTimeoutMs ?? resolveShutdownTimeoutMs(); + this.sigintHandler = () => this.handleSigint(); + } + + install(): void { + process.on('SIGINT', this.sigintHandler); + } + + cleanup(): void { + process.removeListener('SIGINT', this.sigintHandler); + this.clearTimeout(); + } + + private handleSigint(): void { + if (this.state === 'idle') { + this.beginGracefulShutdown(); + return; + } + + if (this.state === 'graceful') { + this.forceShutdown(); + } + } + + private beginGracefulShutdown(): void { + this.state = 'graceful'; + + blankLine(); + warn(getLabel('piece.sigintGraceful')); + this.callbacks.onGraceful(); + + this.timeoutId = setTimeout(() => { + this.timeoutId = undefined; + if (this.state !== 'graceful') { + return; + } + + blankLine(); + error(getLabel('piece.sigintTimeout', undefined, { + timeoutMs: String(this.gracefulTimeoutMs), + })); + this.forceShutdown(); + }, this.gracefulTimeoutMs); + } + + private forceShutdown(): void { + if (this.state === 'forcing') { + return; + } + + this.state = 'forcing'; + this.clearTimeout(); + + blankLine(); + error(getLabel('piece.sigintForce')); + this.callbacks.onForceKill(); + } + + private clearTimeout(): void { + if (this.timeoutId !== undefined) { + clearTimeout(this.timeoutId); + this.timeoutId = undefined; + } + } +} diff --git a/src/features/tasks/execute/sigintHandler.ts b/src/features/tasks/execute/sigintHandler.ts deleted file mode 100644 index 1b8f028..0000000 --- a/src/features/tasks/execute/sigintHandler.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Shared SIGINT handler for graceful/force shutdown pattern. - * - * 1st Ctrl+C = graceful abort via onAbort callback - * 2nd Ctrl+C = force exit - */ - -import { blankLine, warn, error } from '../../../shared/ui/index.js'; -import { EXIT_SIGINT } from '../../../shared/exitCodes.js'; -import { getLabel } from '../../../shared/i18n/index.js'; - -interface SigIntHandler { - cleanup: () => void; -} - -export function installSigIntHandler(onAbort: () => void): SigIntHandler { - let sigintCount = 0; - const handler = () => { - sigintCount++; - if (sigintCount === 1) { - blankLine(); - warn(getLabel('piece.sigintGraceful')); - onAbort(); - } else { - blankLine(); - error(getLabel('piece.sigintForce')); - process.exit(EXIT_SIGINT); - } - }; - process.on('SIGINT', handler); - return { cleanup: () => process.removeListener('SIGINT', handler) }; -} diff --git a/src/features/tasks/watch/index.ts b/src/features/tasks/watch/index.ts index b450515..d2131fa 100644 --- a/src/features/tasks/watch/index.ts +++ b/src/features/tasks/watch/index.ts @@ -16,6 +16,8 @@ import { } from '../../../shared/ui/index.js'; import { executeAndCompleteTask } from '../execute/taskExecution.js'; import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js'; +import { EXIT_SIGINT } from '../../../shared/exitCodes.js'; +import { ShutdownManager } from '../execute/shutdownManager.js'; import type { TaskExecutionOptions } from '../execute/types.js'; /** @@ -41,13 +43,20 @@ export async function watchTasks(cwd: string, options?: TaskExecutionOptions): P info('Waiting for tasks... (Ctrl+C to stop)'); blankLine(); - // Graceful shutdown on SIGINT - const onSigInt = () => { - blankLine(); - info('Stopping watch...'); - watcher.stop(); - }; - process.on('SIGINT', onSigInt); + const shutdownManager = new ShutdownManager({ + callbacks: { + onGraceful: () => { + blankLine(); + info('Stopping watch...'); + watcher.stop(); + }, + onForceKill: () => { + watcher.stop(); + process.exit(EXIT_SIGINT); + }, + }, + }); + shutdownManager.install(); try { await watcher.watch(async (task: TaskInfo) => { @@ -68,7 +77,7 @@ export async function watchTasks(cwd: string, options?: TaskExecutionOptions): P info('Waiting for tasks... (Ctrl+C to stop)'); }); } finally { - process.removeListener('SIGINT', onSigInt); + shutdownManager.cleanup(); } // Summary on exit diff --git a/src/infra/claude/client.ts b/src/infra/claude/client.ts index be473d1..497e29f 100644 --- a/src/infra/claude/client.ts +++ b/src/infra/claude/client.ts @@ -38,6 +38,7 @@ export class ClaudeClient { private static toSpawnOptions(options: ClaudeCallOptions): ClaudeSpawnOptions { return { cwd: options.cwd, + abortSignal: options.abortSignal, sessionId: options.sessionId, allowedTools: options.allowedTools, mcpServers: options.mcpServers, @@ -125,6 +126,7 @@ export class ClaudeClient { const fullPrompt = `/${skillName}\n\n${prompt}`; const spawnOptions: ClaudeSpawnOptions = { cwd: options.cwd, + abortSignal: options.abortSignal, sessionId: options.sessionId, allowedTools: options.allowedTools, mcpServers: options.mcpServers, @@ -192,4 +194,3 @@ export async function callClaudeSkill( ): Promise { return defaultClient.callSkill(skillName, prompt, options); } - diff --git a/src/infra/claude/executor.ts b/src/infra/claude/executor.ts index 254749c..23f8269 100644 --- a/src/infra/claude/executor.ts +++ b/src/infra/claude/executor.ts @@ -94,10 +94,27 @@ export class QueryExecutor { let resultContent: string | undefined; let hasResultMessage = false; let accumulatedAssistantText = ''; + let onExternalAbort: (() => void) | undefined; try { const q = query({ prompt, options: sdkOptions }); registerQuery(queryId, q); + if (options.abortSignal) { + const interruptQuery = () => { + void q.interrupt().catch((interruptError: unknown) => { + log.debug('Failed to interrupt Claude query', { + queryId, + error: getErrorMessage(interruptError), + }); + }); + }; + if (options.abortSignal.aborted) { + interruptQuery(); + } else { + onExternalAbort = interruptQuery; + options.abortSignal.addEventListener('abort', onExternalAbort, { once: true }); + } + } for await (const message of q) { if ('session_id' in message) { @@ -133,6 +150,9 @@ export class QueryExecutor { } unregisterQuery(queryId); + if (onExternalAbort && options.abortSignal) { + options.abortSignal.removeEventListener('abort', onExternalAbort); + } const finalContent = resultContent || accumulatedAssistantText; @@ -151,6 +171,9 @@ export class QueryExecutor { fullContent: accumulatedAssistantText.trim(), }; } catch (error) { + if (onExternalAbort && options.abortSignal) { + options.abortSignal.removeEventListener('abort', onExternalAbort); + } unregisterQuery(queryId); return QueryExecutor.handleQueryError(error, queryId, sessionId, hasResultMessage, success, resultContent, stderrChunks); } diff --git a/src/infra/claude/types.ts b/src/infra/claude/types.ts index cf33daa..aa4d080 100644 --- a/src/infra/claude/types.ts +++ b/src/infra/claude/types.ts @@ -119,6 +119,7 @@ export interface ClaudeResultWithQueryId extends ClaudeResult { /** Options for calling Claude (high-level, used by client/providers/agents) */ export interface ClaudeCallOptions { cwd: string; + abortSignal?: AbortSignal; sessionId?: string; allowedTools?: string[]; /** MCP servers configuration */ @@ -145,6 +146,7 @@ export interface ClaudeCallOptions { /** Options for spawning a Claude SDK query (low-level, used by executor/process) */ export interface ClaudeSpawnOptions { cwd: string; + abortSignal?: AbortSignal; sessionId?: string; allowedTools?: string[]; /** MCP servers configuration */ diff --git a/src/infra/providers/claude.ts b/src/infra/providers/claude.ts index 2ce14a2..c59fe8f 100644 --- a/src/infra/providers/claude.ts +++ b/src/infra/providers/claude.ts @@ -10,6 +10,7 @@ import type { AgentSetup, Provider, ProviderAgent, ProviderCallOptions } from '. function toClaudeOptions(options: ProviderCallOptions): ClaudeCallOptions { return { cwd: options.cwd, + abortSignal: options.abortSignal, sessionId: options.sessionId, allowedTools: options.allowedTools, mcpServers: options.mcpServers, diff --git a/src/shared/i18n/labels_en.yaml b/src/shared/i18n/labels_en.yaml index c5fea74..0d76f2f 100644 --- a/src/shared/i18n/labels_en.yaml +++ b/src/shared/i18n/labels_en.yaml @@ -65,6 +65,7 @@ piece: notifyComplete: "Piece complete ({iteration} iterations)" notifyAbort: "Aborted: {reason}" sigintGraceful: "Ctrl+C: Aborting piece..." + sigintTimeout: "Graceful shutdown timed out after {timeoutMs}ms" sigintForce: "Ctrl+C: Force exit" run: diff --git a/src/shared/i18n/labels_ja.yaml b/src/shared/i18n/labels_ja.yaml index bbd4e38..2bc9ed0 100644 --- a/src/shared/i18n/labels_ja.yaml +++ b/src/shared/i18n/labels_ja.yaml @@ -65,6 +65,7 @@ piece: notifyComplete: "ピース完了 ({iteration} iterations)" notifyAbort: "中断: {reason}" sigintGraceful: "Ctrl+C: ピースを中断しています..." + sigintTimeout: "graceful停止がタイムアウトしました ({timeoutMs}ms)" sigintForce: "Ctrl+C: 強制終了します" run: