github-issue-237-fix-ctrl-c-provider-graceful-timeout-force (#253)
* dist-tag 検証をリトライ付きに変更(npm レジストリの結果整合性対策) * takt run 実行時に蓋閉じスリープを抑制 * takt: github-issue-237-fix-ctrl-c-provider-graceful-timeout-force
This commit is contained in:
parent
39c587d67b
commit
680f0a6df5
@ -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);
|
||||
});
|
||||
|
||||
89
src/__tests__/claude-executor-abort-signal.test.ts
Normal file
89
src/__tests__/claude-executor-abort-signal.test.ts
Normal file
@ -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<never, void, unknown> {
|
||||
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<typeof import('../shared/utils/index.js')>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
52
src/__tests__/claude-provider-abort-signal.test.ts
Normal file
52
src/__tests__/claude-provider-abort-signal.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
|
||||
|
||||
173
src/__tests__/shutdownManager.test.ts
Normal file
173
src/__tests__/shutdownManager.test.ts
Normal file
@ -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');
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
}));
|
||||
|
||||
@ -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<WorkerPoolResult> {
|
||||
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 };
|
||||
|
||||
@ -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<string, number>();
|
||||
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);
|
||||
}
|
||||
|
||||
108
src/features/tasks/execute/shutdownManager.ts
Normal file
108
src/features/tasks/execute/shutdownManager.ts
Normal file
@ -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<typeof setTimeout> | 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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) };
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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<AgentResponse> {
|
||||
return defaultClient.callSkill(skillName, prompt, options);
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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 */
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -65,6 +65,7 @@ piece:
|
||||
notifyComplete: "ピース完了 ({iteration} iterations)"
|
||||
notifyAbort: "中断: {reason}"
|
||||
sigintGraceful: "Ctrl+C: ピースを中断しています..."
|
||||
sigintTimeout: "graceful停止がタイムアウトしました ({timeoutMs}ms)"
|
||||
sigintForce: "Ctrl+C: 強制終了します"
|
||||
|
||||
run:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user