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:
nrs 2026-02-12 11:52:07 +09:00 committed by GitHub
parent 39c587d67b
commit 680f0a6df5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 587 additions and 50 deletions

View File

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

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

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

View File

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

View 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');
});
});

View File

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

View File

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

View File

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

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

View File

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

View File

@ -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 = () => {
const shutdownManager = new ShutdownManager({
callbacks: {
onGraceful: () => {
blankLine();
info('Stopping watch...');
watcher.stop();
};
process.on('SIGINT', onSigInt);
},
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

View File

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

View File

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

View File

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

View File

@ -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,

View File

@ -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:

View File

@ -65,6 +65,7 @@ piece:
notifyComplete: "ピース完了 ({iteration} iterations)"
notifyAbort: "中断: {reason}"
sigintGraceful: "Ctrl+C: ピースを中断しています..."
sigintTimeout: "graceful停止がタイムアウトしました ({timeoutMs}ms)"
sigintForce: "Ctrl+C: 強制終了します"
run: