From 69bd77ab620a2c2dfbca663e83bfe3d5af97202f Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Wed, 11 Feb 2026 10:38:03 +0900 Subject: [PATCH] =?UTF-8?q?Provider=20=E3=81=8A=E3=82=88=E3=81=B3=E3=83=A2?= =?UTF-8?q?=E3=83=87=E3=83=AB=E5=90=8D=E3=82=92=E5=87=BA=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/it-sigint-interrupt.test.ts | 31 ++++++++++++++++++- .../pieceExecution-session-loading.test.ts | 30 ++++++++++++++++++ src/core/piece/engine/OptionsBuilder.ts | 11 +++++-- src/core/piece/provider-resolution.ts | 24 ++++++++++++++ src/features/tasks/execute/pieceExecution.ts | 23 ++++++++++++-- 5 files changed, 114 insertions(+), 5 deletions(-) create mode 100644 src/core/piece/provider-resolution.ts diff --git a/src/__tests__/it-sigint-interrupt.test.ts b/src/__tests__/it-sigint-interrupt.test.ts index 576600a..28abafe 100644 --- a/src/__tests__/it-sigint-interrupt.test.ts +++ b/src/__tests__/it-sigint-interrupt.test.ts @@ -27,14 +27,18 @@ const { mockInterruptAllQueries, MockPieceEngine } = vi.hoisted(() => { class MockPieceEngine extends EE { private abortRequested = false; private runResolve: ((value: { status: string; iteration: number }) => void) | null = null; + static lastOptions: { abortSignal?: AbortSignal } | null = null; constructor( _config: unknown, _cwd: string, _task: string, - _options: unknown, + options: unknown, ) { super(); + if (options && typeof options === 'object') { + MockPieceEngine.lastOptions = options as { abortSignal?: AbortSignal }; + } } abort(): void { @@ -170,6 +174,7 @@ describe('executePiece: SIGINT handler integration', () => { beforeEach(() => { vi.clearAllMocks(); + MockPieceEngine.lastOptions = null; tmpDir = join(tmpdir(), `takt-sigint-it-${randomUUID()}`); mkdirSync(tmpDir, { recursive: true }); mkdirSync(join(tmpDir, '.takt', 'reports'), { recursive: true }); @@ -243,6 +248,30 @@ describe('executePiece: SIGINT handler integration', () => { expect(result.success).toBe(false); }); + it('should abort provider signal on first SIGINT', async () => { + const config = makeConfig(); + + const resultPromise = executePiece(config, 'test task', tmpDir, { + projectCwd: tmpDir, + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const signal = MockPieceEngine.lastOptions?.abortSignal; + expect(signal).toBeDefined(); + expect(signal!.aborted).toBe(false); + + const allListeners = process.rawListeners('SIGINT') as ((...args: unknown[]) => void)[]; + const newListener = allListeners.find((l) => !savedSigintListeners.includes(l)); + expect(newListener).toBeDefined(); + newListener!(); + + expect(signal!.aborted).toBe(true); + + const result = await resultPromise; + expect(result.success).toBe(false); + }); + it('should register EPIPE handler before calling interruptAllQueries', async () => { const config = makeConfig(); diff --git a/src/__tests__/pieceExecution-session-loading.test.ts b/src/__tests__/pieceExecution-session-loading.test.ts index e09d0d8..92ff51e 100644 --- a/src/__tests__/pieceExecution-session-loading.test.ts +++ b/src/__tests__/pieceExecution-session-loading.test.ts @@ -18,9 +18,11 @@ const { MockPieceEngine, mockLoadPersonaSessions, mockLoadWorktreeSessions } = v class MockPieceEngine extends EE { static lastInstance: MockPieceEngine; readonly receivedOptions: Record; + private readonly config: PieceConfig; constructor(config: PieceConfig, _cwd: string, _task: string, options: Record) { super(); + this.config = config; this.receivedOptions = options; MockPieceEngine.lastInstance = this; } @@ -28,6 +30,10 @@ const { MockPieceEngine, mockLoadPersonaSessions, mockLoadWorktreeSessions } = v abort(): void {} async run(): Promise<{ status: string; iteration: number }> { + const firstStep = this.config.movements[0]; + if (firstStep) { + this.emit('movement:start', firstStep, 1, firstStep.instructionTemplate); + } this.emit('piece:complete', { status: 'completed', iteration: 1 }); return { status: 'completed', iteration: 1 }; } @@ -124,6 +130,7 @@ vi.mock('../shared/exitCodes.js', () => ({ })); import { executePiece } from '../features/tasks/execute/pieceExecution.js'; +import { info } from '../shared/ui/index.js'; function makeConfig(): PieceConfig { return { @@ -218,4 +225,27 @@ describe('executePiece session loading', () => { // Then: sessions are loaded expect(mockLoadPersonaSessions).toHaveBeenCalledWith('/tmp/project', 'claude'); }); + + it('should log provider and model per movement with global defaults', async () => { + await executePiece(makeConfig(), 'task', '/tmp/project', { + projectCwd: '/tmp/project', + }); + + const mockInfo = vi.mocked(info); + expect(mockInfo).toHaveBeenCalledWith('Provider: claude'); + expect(mockInfo).toHaveBeenCalledWith('Model: (default)'); + }); + + it('should log provider and model per movement with overrides', async () => { + await executePiece(makeConfig(), 'task', '/tmp/project', { + projectCwd: '/tmp/project', + provider: 'codex', + model: 'gpt-5', + personaProviders: { coder: 'opencode' }, + }); + + const mockInfo = vi.mocked(info); + expect(mockInfo).toHaveBeenCalledWith('Provider: opencode'); + expect(mockInfo).toHaveBeenCalledWith('Model: gpt-5'); + }); }); diff --git a/src/core/piece/engine/OptionsBuilder.ts b/src/core/piece/engine/OptionsBuilder.ts index bec67a4..8fe68c3 100644 --- a/src/core/piece/engine/OptionsBuilder.ts +++ b/src/core/piece/engine/OptionsBuilder.ts @@ -11,6 +11,7 @@ import type { RunAgentOptions } from '../../../agents/runner.js'; import type { PhaseRunnerContext } from '../phase-runner.js'; import type { PieceEngineOptions, PhaseName } from '../types.js'; import { buildSessionKey } from '../session-key.js'; +import { resolveMovementProviderModel } from '../provider-resolution.js'; export class OptionsBuilder { constructor( @@ -30,13 +31,19 @@ export class OptionsBuilder { const movements = this.getPieceMovements(); const currentIndex = movements.findIndex((m) => m.name === step.name); const currentPosition = currentIndex >= 0 ? `${currentIndex + 1}/${movements.length}` : '?/?'; + const resolved = resolveMovementProviderModel({ + step, + provider: this.engineOptions.provider, + model: this.engineOptions.model, + personaProviders: this.engineOptions.personaProviders, + }); return { cwd: this.getCwd(), abortSignal: this.engineOptions.abortSignal, personaPath: step.personaPath, - provider: step.provider ?? this.engineOptions.personaProviders?.[step.personaDisplayName] ?? this.engineOptions.provider, - model: step.model ?? this.engineOptions.model, + provider: resolved.provider, + model: resolved.model, permissionMode: step.permissionMode, language: this.getLanguage(), onStream: this.engineOptions.onStream, diff --git a/src/core/piece/provider-resolution.ts b/src/core/piece/provider-resolution.ts new file mode 100644 index 0000000..6561c80 --- /dev/null +++ b/src/core/piece/provider-resolution.ts @@ -0,0 +1,24 @@ +import type { PieceMovement } from '../models/types.js'; + +export type ProviderType = 'claude' | 'codex' | 'opencode' | 'mock'; + +export interface MovementProviderModelInput { + step: Pick; + provider?: ProviderType; + model?: string; + personaProviders?: Record; +} + +export interface MovementProviderModelOutput { + provider?: ProviderType; + model?: string; +} + +export function resolveMovementProviderModel(input: MovementProviderModelInput): MovementProviderModelOutput { + return { + provider: input.step.provider + ?? input.personaProviders?.[input.step.personaDisplayName] + ?? input.provider, + model: input.step.model ?? input.model, + }; +} diff --git a/src/features/tasks/execute/pieceExecution.ts b/src/features/tasks/execute/pieceExecution.ts index ba3d19c..4b58272 100644 --- a/src/features/tasks/execute/pieceExecution.ts +++ b/src/features/tasks/execute/pieceExecution.ts @@ -63,6 +63,7 @@ import { selectOption, promptInput } from '../../../shared/prompt/index.js'; import { getLabel } from '../../../shared/i18n/index.js'; import { installSigIntHandler } from './sigintHandler.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'; const log = createLogger('piece'); @@ -396,10 +397,11 @@ export async function executePiece( let onAbortSignal: (() => void) | undefined; let sigintCleanup: (() => void) | undefined; let onEpipe: ((err: NodeJS.ErrnoException) => void) | undefined; + const runAbortController = new AbortController(); try { engine = new PieceEngine(pieceConfig, cwd, task, { - abortSignal: options.abortSignal, + abortSignal: runAbortController.signal, onStream: streamHandler, onUserInput, initialSessions: savedSessions, @@ -482,6 +484,16 @@ export async function executePiece( movementIteration, }); out.info(`[${iteration}/${pieceConfig.maxMovements}] ${step.name} (${step.personaDisplayName})`); + const resolved = resolveMovementProviderModel({ + step, + provider: options.provider, + model: options.model, + personaProviders: options.personaProviders, + }); + const movementProvider = resolved.provider ?? currentProvider; + const movementModel = resolved.model ?? globalConfig.model ?? '(default)'; + out.info(`Provider: ${movementProvider}`); + out.info(`Model: ${movementModel}`); // Log prompt content for debugging if (instruction) { @@ -686,6 +698,9 @@ export async function executePiece( if (!engine || !onEpipe) { throw new Error('Abort handler invoked before PieceEngine initialization'); } + if (!runAbortController.signal.aborted) { + runAbortController.abort(); + } process.on('uncaughtException', onEpipe); interruptAllQueries(); engine.abort(); @@ -695,7 +710,11 @@ export async function executePiece( const useExternalAbort = Boolean(options.abortSignal); if (useExternalAbort) { onAbortSignal = abortEngine; - options.abortSignal!.addEventListener('abort', onAbortSignal, { once: true }); + if (options.abortSignal!.aborted) { + abortEngine(); + } else { + options.abortSignal!.addEventListener('abort', onAbortSignal, { once: true }); + } } else { const handler = installSigIntHandler(abortEngine); sigintCleanup = handler.cleanup;