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');
|
expect(stderr).not.toContain('UnhandledPromiseRejection');
|
||||||
}
|
}
|
||||||
}, 120_000);
|
}, 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.notifyComplete')).not.toThrow();
|
||||||
expect(() => getLabel('piece.notifyAbort')).not.toThrow();
|
expect(() => getLabel('piece.notifyAbort')).not.toThrow();
|
||||||
expect(() => getLabel('piece.sigintGraceful')).not.toThrow();
|
expect(() => getLabel('piece.sigintGraceful')).not.toThrow();
|
||||||
|
expect(() => getLabel('piece.sigintTimeout')).not.toThrow();
|
||||||
expect(() => getLabel('piece.sigintForce')).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,
|
mockBlankLine,
|
||||||
mockStatus,
|
mockStatus,
|
||||||
mockSuccess,
|
mockSuccess,
|
||||||
|
mockWarn,
|
||||||
|
mockError,
|
||||||
mockGetCurrentPiece,
|
mockGetCurrentPiece,
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
mockRecoverInterruptedRunningTasks: vi.fn(),
|
mockRecoverInterruptedRunningTasks: vi.fn(),
|
||||||
@ -24,6 +26,8 @@ const {
|
|||||||
mockBlankLine: vi.fn(),
|
mockBlankLine: vi.fn(),
|
||||||
mockStatus: vi.fn(),
|
mockStatus: vi.fn(),
|
||||||
mockSuccess: vi.fn(),
|
mockSuccess: vi.fn(),
|
||||||
|
mockWarn: vi.fn(),
|
||||||
|
mockError: vi.fn(),
|
||||||
mockGetCurrentPiece: vi.fn(),
|
mockGetCurrentPiece: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -45,11 +49,17 @@ vi.mock('../features/tasks/execute/taskExecution.js', () => ({
|
|||||||
vi.mock('../shared/ui/index.js', () => ({
|
vi.mock('../shared/ui/index.js', () => ({
|
||||||
header: mockHeader,
|
header: mockHeader,
|
||||||
info: mockInfo,
|
info: mockInfo,
|
||||||
|
warn: mockWarn,
|
||||||
|
error: mockError,
|
||||||
success: mockSuccess,
|
success: mockSuccess,
|
||||||
status: mockStatus,
|
status: mockStatus,
|
||||||
blankLine: mockBlankLine,
|
blankLine: mockBlankLine,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../shared/i18n/index.js', () => ({
|
||||||
|
getLabel: vi.fn((key: string) => key),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../infra/config/index.js', () => ({
|
vi.mock('../infra/config/index.js', () => ({
|
||||||
getCurrentPiece: mockGetCurrentPiece,
|
getCurrentPiece: mockGetCurrentPiece,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@ -14,9 +14,10 @@
|
|||||||
import type { TaskRunner, TaskInfo } from '../../../infra/task/index.js';
|
import type { TaskRunner, TaskInfo } from '../../../infra/task/index.js';
|
||||||
import { info, blankLine } from '../../../shared/ui/index.js';
|
import { info, blankLine } from '../../../shared/ui/index.js';
|
||||||
import { TaskPrefixWriter } from '../../../shared/ui/TaskPrefixWriter.js';
|
import { TaskPrefixWriter } from '../../../shared/ui/TaskPrefixWriter.js';
|
||||||
|
import { EXIT_SIGINT } from '../../../shared/exitCodes.js';
|
||||||
import { createLogger } from '../../../shared/utils/index.js';
|
import { createLogger } from '../../../shared/utils/index.js';
|
||||||
import { executeAndCompleteTask } from './taskExecution.js';
|
import { executeAndCompleteTask } from './taskExecution.js';
|
||||||
import { installSigIntHandler } from './sigintHandler.js';
|
import { ShutdownManager } from './shutdownManager.js';
|
||||||
import type { TaskExecutionOptions } from './types.js';
|
import type { TaskExecutionOptions } from './types.js';
|
||||||
|
|
||||||
const log = createLogger('worker-pool');
|
const log = createLogger('worker-pool');
|
||||||
@ -96,8 +97,15 @@ export async function runWithWorkerPool(
|
|||||||
pollIntervalMs: number,
|
pollIntervalMs: number,
|
||||||
): Promise<WorkerPoolResult> {
|
): Promise<WorkerPoolResult> {
|
||||||
const abortController = new AbortController();
|
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 selfSigintOnce = process.env.TAKT_E2E_SELF_SIGINT_ONCE === '1';
|
||||||
|
const selfSigintTwice = process.env.TAKT_E2E_SELF_SIGINT_TWICE === '1';
|
||||||
let selfSigintInjected = false;
|
let selfSigintInjected = false;
|
||||||
|
|
||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
@ -111,9 +119,12 @@ export async function runWithWorkerPool(
|
|||||||
while (queue.length > 0 || active.size > 0) {
|
while (queue.length > 0 || active.size > 0) {
|
||||||
if (!abortController.signal.aborted) {
|
if (!abortController.signal.aborted) {
|
||||||
fillSlots(queue, active, concurrency, taskRunner, cwd, pieceName, options, abortController, colorCounter);
|
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;
|
selfSigintInjected = true;
|
||||||
process.emit('SIGINT');
|
process.emit('SIGINT');
|
||||||
|
if (selfSigintTwice) {
|
||||||
|
process.emit('SIGINT');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -169,7 +180,7 @@ export async function runWithWorkerPool(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
cleanup();
|
shutdownManager.cleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: successCount, fail: failCount };
|
return { success: successCount, fail: failCount };
|
||||||
|
|||||||
@ -65,7 +65,8 @@ import {
|
|||||||
} from '../../../shared/utils/providerEventLogger.js';
|
} from '../../../shared/utils/providerEventLogger.js';
|
||||||
import { selectOption, promptInput } from '../../../shared/prompt/index.js';
|
import { selectOption, promptInput } from '../../../shared/prompt/index.js';
|
||||||
import { getLabel } from '../../../shared/i18n/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 { buildRunPaths } from '../../../core/piece/run/run-paths.js';
|
||||||
import { resolveMovementProviderModel } from '../../../core/piece/provider-resolution.js';
|
import { resolveMovementProviderModel } from '../../../core/piece/provider-resolution.js';
|
||||||
import { writeFileAtomic, ensureDir } from '../../../infra/config/index.js';
|
import { writeFileAtomic, ensureDir } from '../../../infra/config/index.js';
|
||||||
@ -407,7 +408,7 @@ export async function executePiece(
|
|||||||
const movementIterations = new Map<string, number>();
|
const movementIterations = new Map<string, number>();
|
||||||
let engine: PieceEngine | null = null;
|
let engine: PieceEngine | null = null;
|
||||||
let onAbortSignal: (() => void) | undefined;
|
let onAbortSignal: (() => void) | undefined;
|
||||||
let sigintCleanup: (() => void) | undefined;
|
let shutdownManager: ShutdownManager | undefined;
|
||||||
let onEpipe: ((err: NodeJS.ErrnoException) => void) | undefined;
|
let onEpipe: ((err: NodeJS.ErrnoException) => void) | undefined;
|
||||||
const runAbortController = new AbortController();
|
const runAbortController = new AbortController();
|
||||||
|
|
||||||
@ -730,8 +731,13 @@ export async function executePiece(
|
|||||||
options.abortSignal!.addEventListener('abort', onAbortSignal, { once: true });
|
options.abortSignal!.addEventListener('abort', onAbortSignal, { once: true });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const handler = installSigIntHandler(abortEngine);
|
shutdownManager = new ShutdownManager({
|
||||||
sigintCleanup = handler.cleanup;
|
callbacks: {
|
||||||
|
onGraceful: abortEngine,
|
||||||
|
onForceKill: () => process.exit(EXIT_SIGINT),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
shutdownManager.install();
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalState = await engine.run();
|
const finalState = await engine.run();
|
||||||
@ -749,7 +755,7 @@ export async function executePiece(
|
|||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
prefixWriter?.flush();
|
prefixWriter?.flush();
|
||||||
sigintCleanup?.();
|
shutdownManager?.cleanup();
|
||||||
if (onAbortSignal && options.abortSignal) {
|
if (onAbortSignal && options.abortSignal) {
|
||||||
options.abortSignal.removeEventListener('abort', onAbortSignal);
|
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';
|
} from '../../../shared/ui/index.js';
|
||||||
import { executeAndCompleteTask } from '../execute/taskExecution.js';
|
import { executeAndCompleteTask } from '../execute/taskExecution.js';
|
||||||
import { DEFAULT_PIECE_NAME } from '../../../shared/constants.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';
|
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)');
|
info('Waiting for tasks... (Ctrl+C to stop)');
|
||||||
blankLine();
|
blankLine();
|
||||||
|
|
||||||
// Graceful shutdown on SIGINT
|
const shutdownManager = new ShutdownManager({
|
||||||
const onSigInt = () => {
|
callbacks: {
|
||||||
blankLine();
|
onGraceful: () => {
|
||||||
info('Stopping watch...');
|
blankLine();
|
||||||
watcher.stop();
|
info('Stopping watch...');
|
||||||
};
|
watcher.stop();
|
||||||
process.on('SIGINT', onSigInt);
|
},
|
||||||
|
onForceKill: () => {
|
||||||
|
watcher.stop();
|
||||||
|
process.exit(EXIT_SIGINT);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
shutdownManager.install();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await watcher.watch(async (task: TaskInfo) => {
|
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)');
|
info('Waiting for tasks... (Ctrl+C to stop)');
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
process.removeListener('SIGINT', onSigInt);
|
shutdownManager.cleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Summary on exit
|
// Summary on exit
|
||||||
|
|||||||
@ -38,6 +38,7 @@ export class ClaudeClient {
|
|||||||
private static toSpawnOptions(options: ClaudeCallOptions): ClaudeSpawnOptions {
|
private static toSpawnOptions(options: ClaudeCallOptions): ClaudeSpawnOptions {
|
||||||
return {
|
return {
|
||||||
cwd: options.cwd,
|
cwd: options.cwd,
|
||||||
|
abortSignal: options.abortSignal,
|
||||||
sessionId: options.sessionId,
|
sessionId: options.sessionId,
|
||||||
allowedTools: options.allowedTools,
|
allowedTools: options.allowedTools,
|
||||||
mcpServers: options.mcpServers,
|
mcpServers: options.mcpServers,
|
||||||
@ -125,6 +126,7 @@ export class ClaudeClient {
|
|||||||
const fullPrompt = `/${skillName}\n\n${prompt}`;
|
const fullPrompt = `/${skillName}\n\n${prompt}`;
|
||||||
const spawnOptions: ClaudeSpawnOptions = {
|
const spawnOptions: ClaudeSpawnOptions = {
|
||||||
cwd: options.cwd,
|
cwd: options.cwd,
|
||||||
|
abortSignal: options.abortSignal,
|
||||||
sessionId: options.sessionId,
|
sessionId: options.sessionId,
|
||||||
allowedTools: options.allowedTools,
|
allowedTools: options.allowedTools,
|
||||||
mcpServers: options.mcpServers,
|
mcpServers: options.mcpServers,
|
||||||
@ -192,4 +194,3 @@ export async function callClaudeSkill(
|
|||||||
): Promise<AgentResponse> {
|
): Promise<AgentResponse> {
|
||||||
return defaultClient.callSkill(skillName, prompt, options);
|
return defaultClient.callSkill(skillName, prompt, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -94,10 +94,27 @@ export class QueryExecutor {
|
|||||||
let resultContent: string | undefined;
|
let resultContent: string | undefined;
|
||||||
let hasResultMessage = false;
|
let hasResultMessage = false;
|
||||||
let accumulatedAssistantText = '';
|
let accumulatedAssistantText = '';
|
||||||
|
let onExternalAbort: (() => void) | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const q = query({ prompt, options: sdkOptions });
|
const q = query({ prompt, options: sdkOptions });
|
||||||
registerQuery(queryId, q);
|
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) {
|
for await (const message of q) {
|
||||||
if ('session_id' in message) {
|
if ('session_id' in message) {
|
||||||
@ -133,6 +150,9 @@ export class QueryExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
unregisterQuery(queryId);
|
unregisterQuery(queryId);
|
||||||
|
if (onExternalAbort && options.abortSignal) {
|
||||||
|
options.abortSignal.removeEventListener('abort', onExternalAbort);
|
||||||
|
}
|
||||||
|
|
||||||
const finalContent = resultContent || accumulatedAssistantText;
|
const finalContent = resultContent || accumulatedAssistantText;
|
||||||
|
|
||||||
@ -151,6 +171,9 @@ export class QueryExecutor {
|
|||||||
fullContent: accumulatedAssistantText.trim(),
|
fullContent: accumulatedAssistantText.trim(),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (onExternalAbort && options.abortSignal) {
|
||||||
|
options.abortSignal.removeEventListener('abort', onExternalAbort);
|
||||||
|
}
|
||||||
unregisterQuery(queryId);
|
unregisterQuery(queryId);
|
||||||
return QueryExecutor.handleQueryError(error, queryId, sessionId, hasResultMessage, success, resultContent, stderrChunks);
|
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) */
|
/** Options for calling Claude (high-level, used by client/providers/agents) */
|
||||||
export interface ClaudeCallOptions {
|
export interface ClaudeCallOptions {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
|
abortSignal?: AbortSignal;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
allowedTools?: string[];
|
allowedTools?: string[];
|
||||||
/** MCP servers configuration */
|
/** MCP servers configuration */
|
||||||
@ -145,6 +146,7 @@ export interface ClaudeCallOptions {
|
|||||||
/** Options for spawning a Claude SDK query (low-level, used by executor/process) */
|
/** Options for spawning a Claude SDK query (low-level, used by executor/process) */
|
||||||
export interface ClaudeSpawnOptions {
|
export interface ClaudeSpawnOptions {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
|
abortSignal?: AbortSignal;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
allowedTools?: string[];
|
allowedTools?: string[];
|
||||||
/** MCP servers configuration */
|
/** MCP servers configuration */
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import type { AgentSetup, Provider, ProviderAgent, ProviderCallOptions } from '.
|
|||||||
function toClaudeOptions(options: ProviderCallOptions): ClaudeCallOptions {
|
function toClaudeOptions(options: ProviderCallOptions): ClaudeCallOptions {
|
||||||
return {
|
return {
|
||||||
cwd: options.cwd,
|
cwd: options.cwd,
|
||||||
|
abortSignal: options.abortSignal,
|
||||||
sessionId: options.sessionId,
|
sessionId: options.sessionId,
|
||||||
allowedTools: options.allowedTools,
|
allowedTools: options.allowedTools,
|
||||||
mcpServers: options.mcpServers,
|
mcpServers: options.mcpServers,
|
||||||
|
|||||||
@ -65,6 +65,7 @@ piece:
|
|||||||
notifyComplete: "Piece complete ({iteration} iterations)"
|
notifyComplete: "Piece complete ({iteration} iterations)"
|
||||||
notifyAbort: "Aborted: {reason}"
|
notifyAbort: "Aborted: {reason}"
|
||||||
sigintGraceful: "Ctrl+C: Aborting piece..."
|
sigintGraceful: "Ctrl+C: Aborting piece..."
|
||||||
|
sigintTimeout: "Graceful shutdown timed out after {timeoutMs}ms"
|
||||||
sigintForce: "Ctrl+C: Force exit"
|
sigintForce: "Ctrl+C: Force exit"
|
||||||
|
|
||||||
run:
|
run:
|
||||||
|
|||||||
@ -65,6 +65,7 @@ piece:
|
|||||||
notifyComplete: "ピース完了 ({iteration} iterations)"
|
notifyComplete: "ピース完了 ({iteration} iterations)"
|
||||||
notifyAbort: "中断: {reason}"
|
notifyAbort: "中断: {reason}"
|
||||||
sigintGraceful: "Ctrl+C: ピースを中断しています..."
|
sigintGraceful: "Ctrl+C: ピースを中断しています..."
|
||||||
|
sigintTimeout: "graceful停止がタイムアウトしました ({timeoutMs}ms)"
|
||||||
sigintForce: "Ctrl+C: 強制終了します"
|
sigintForce: "Ctrl+C: 強制終了します"
|
||||||
|
|
||||||
run:
|
run:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user