diff --git a/apps/desktop/src/main/__tests__/makerSendToSessionOrdering.test.ts b/apps/desktop/src/main/__tests__/makerSendToSessionOrdering.test.ts index dcd9c9f808..86683012f6 100644 --- a/apps/desktop/src/main/__tests__/makerSendToSessionOrdering.test.ts +++ b/apps/desktop/src/main/__tests__/makerSendToSessionOrdering.test.ts @@ -521,6 +521,10 @@ describe('sendToSession ordering', () => { ); expect(setModelBlock).toContain('patch.effort = atomicSelection.effort;'); expect(setModelBlock).toContain('patch.fastMode = atomicSelection.fastMode;'); + expect(setModelBlock).toContain( + "result.status === 'deferred' && result.preservePersistedRoute === true", + ); + expect(setModelBlock).toContain('if (!preservePersistedRoute) {'); expect(setModelBlock).toContain('await persistSessionFields(sessionId, patch);'); expect(setModelBlock).toContain('markRemoteSettingPersistedInsideHandler(response);'); expectOrder( @@ -548,6 +552,8 @@ describe('sendToSession ordering', () => { ); expect(setModelBlock).toContain('pendingCredentialSwitchHolder?.clear(sessionId);'); expect(setModelBlock).toContain('restoreControlStores();'); + expect(setModelBlock).toContain('const hadRelink = appliedCodexThreadRelink !== undefined;'); + expect(setModelBlock).toContain('await recoverRuntimeAfterSelectionRollback({'); expect(setModelBlock).toContain('previousRuntime.pendingCredentialSwitch'); expect(setModelBlock).toContain('withRehydrateCloseSuppressed(sessionId'); expect(setModelBlock).toContain('recordRecoveredSessionRuntimeMutation(sessionId'); diff --git a/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts b/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts index 06b7682f8e..9c8226271c 100644 --- a/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts +++ b/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts @@ -11,6 +11,10 @@ const registerSource = readFileSync(resolve(mainRoot, 'maker-ipc/register.ts'), /\r\n?/g, '\n', ); +const pendingCredentialSwitchSource = readFileSync( + resolve(mainRoot, 'maker-ipc/pendingCredentialSwitch.ts'), + 'utf8', +).replace(/\r\n?/g, '\n'); function handlerBody(source: string, channel: string, nextChannel: string): string { const start = source.indexOf(channel); @@ -186,6 +190,410 @@ describe('session runtime control wiring', () => { expect(axisValidation).toBeLessThan(setModel.indexOf('persistSessionFields(sessionId')); }); + it('commits immediate user, fallback, and Agent Codex relinks in one SQLite CAS', () => { + const setModel = handlerBody( + registerSource, + 'const handleSetModel = async (', + 'const recoverRemoteRuntimeAxisPersistence', + ); + const fallback = handlerBody( + registerSource, + 'const maybeApplySessionRuntimeFallback = async (', + 'const sessionControlService = createSessionControlService({', + ); + const agent = handlerBody( + registerSource, + 'setSessionRuntime: async ({ targetSessionId, expectedGeneration, patch }) => {', + 'assertExternalInputAllowed: assertReviewExternalInputAllowed', + ); + const sourceTuple = setModel.indexOf('const persistedRuntimeSourceRoute:'); + const targetTuple = setModel.indexOf('const persistedRuntimeTargetRoute:', sourceTuple); + const wrapper = setModel.indexOf('const relinkCodexThreadForRuntimeSelection = ('); + const transition = setModel.indexOf('persistedRouteTransition: {', wrapper); + const injected = setModel.indexOf( + 'relinkCodexThreadForProviderSwitch: relinkCodexThreadForRuntimeSelection', + transition, + ); + const appliedReceipt = setModel.indexOf( + 'appliedCodexThreadRelink = result.codexThreadRelink;', + injected, + ); + const markAtomicRoute = setModel.indexOf( + 'appliedPersistedRuntimeRoute = persistedRuntimeTargetRoute;', + appliedReceipt, + ); + const skipSecondWrite = setModel.indexOf( + 'if (!preservePersistedRoute && !appliedPersistedRuntimeRoute) {', + markAtomicRoute, + ); + const persist = setModel.indexOf('await persistSessionFields(sessionId, patch)'); + + expect(sourceTuple).toBeGreaterThan(-1); + expect(setModel.slice(sourceTuple, targetTuple)).toContain('model: runtimeStatus.model'); + expect(setModel.slice(sourceTuple, targetTuple)).toContain( + 'providerId: runtimeStatus.providerId ?? null', + ); + expect(setModel.slice(sourceTuple, targetTuple)).toContain('effort: runtimeStatus.effort'); + expect(setModel.slice(sourceTuple, targetTuple)).toContain('fastMode: runtimeStatus.fastMode'); + expect(targetTuple).toBeGreaterThan(sourceTuple); + expect(setModel.slice(targetTuple, wrapper)).toContain('model,'); + expect(setModel.slice(targetTuple, wrapper)).toContain( + 'effort: atomicSelection?.effort ?? runtimeStatus.effort', + ); + expect(setModel.slice(targetTuple, wrapper)).toContain( + 'fastMode: atomicSelection?.fastMode ?? runtimeStatus.fastMode', + ); + expect(wrapper).toBeGreaterThan(targetTuple); + expect(setModel.slice(wrapper, injected)).not.toContain( + "internalOptions.source === 'user'", + ); + expect(setModel.slice(transition, injected)).toContain( + 'previous: persistedRuntimeSourceRoute', + ); + expect(setModel.slice(transition, injected)).toContain('next: persistedRuntimeTargetRoute'); + expect(injected).toBeGreaterThan(transition); + expect(markAtomicRoute).toBeGreaterThan(appliedReceipt); + expect(setModel.slice(appliedReceipt, markAtomicRoute)).not.toContain( + "internalOptions.source === 'user'", + ); + expect(skipSecondWrite).toBeGreaterThan(markAtomicRoute); + expect(persist).toBeGreaterThan(-1); + expect(persist).toBeGreaterThan(skipSecondWrite); + for (const [body, source] of [ + [fallback, 'fallback'], + [agent, 'agent'], + ] as const) { + expect(body).toContain('applySessionRuntimeSelection('); + expect(body).toContain(`source: '${source}'`); + } + }); + + it('commits a deferred Codex relink with the final route in the same SQLite CAS', () => { + const finalizer = handlerBody( + pendingCredentialSwitchSource, + 'private async finalizeApply(', + 'private scheduleRetry(', + ); + const transition = finalizer.indexOf('const persistedRouteTransition = {'); + const relink = finalizer.indexOf('relinkReceipt = await relink({', transition); + const transitionArgument = finalizer.indexOf('persistedRouteTransition,', relink); + const committedRoute = finalizer.indexOf( + 'if (relinkReceipt) persistedResolvedRoute = resolvedRoute;', + transitionArgument, + ); + const skipSecondWrite = finalizer.indexOf( + 'const routeCommittedWithRelink = relinkReceipt !== null;', + committedRoute, + ); + const guardedPersist = finalizer.indexOf('!routeCommittedWithRelink', skipSecondWrite); + + expect(transition).toBeGreaterThan(-1); + expect(finalizer.slice(transition, relink)).toContain('previous: {'); + expect(finalizer.slice(transition, relink)).toContain('next: {'); + expect(relink).toBeGreaterThan(transition); + expect(transitionArgument).toBeGreaterThan(relink); + expect(committedRoute).toBeGreaterThan(transitionArgument); + expect(skipSecondWrite).toBeGreaterThan(committedRoute); + expect(guardedPersist).toBeGreaterThan(skipSecondWrite); + expect(registerSource).toContain( + 'relinkCodexThreadForProviderSwitch: relinkCodexThreadForCredentialSwitch', + ); + expect(registerSource).toContain('...(transition ? transition.next : {})'); + expect(registerSource).toContain('...(transition ? transition.previous : {})'); + }); + + it('rolls back the complete persisted route before an owner-boundary request exits', () => { + const setModel = handlerBody( + registerSource, + 'const handleSetModel = async (', + 'const recoverRemoteRuntimeAxisPersistence', + ); + const guard = setModel.indexOf( + 'const rollbackRuntimeSelectionForSupersededOwner = async (): Promise => {', + ); + const rollback = setModel.indexOf('await rollbackAppliedRuntimeSelection();', guard); + const ownerFailure = setModel.indexOf('assertRuntimeOwnerCurrent();', guard); + + expect(setModel).toContain('!isAppSessionBoundaryPending()'); + expect(setModel).toContain('const rollbackAppliedRuntimeSelection = async ()'); + expect(setModel).toContain('const runtimeDbSnapshot = getCurrentDbClientSnapshot();'); + expect(setModel).toContain('effort: sessions.effort'); + expect(setModel).toContain('fastMode: sessions.fastMode'); + expect(setModel).toContain('rollbackPersistedCodexRuntimeSelection({'); + expect(setModel).toContain('sdkSessionId: runtimeStatus.sdkSessionId'); + expect(setModel).toContain('model: runtimeStatus.model'); + expect(setModel).toContain('providerId: runtimeStatus.providerId'); + expect(setModel).toContain('effort: runtimeStatus.effort'); + expect(setModel).toContain('fastMode: runtimeStatus.fastMode'); + expect(setModel).toContain('eq(sessions.model, expected.model)'); + expect(setModel).toContain('eq(sessions.sdkSessionId, expected.sdkSessionId)'); + expect(setModel).toContain('eq(sessions.providerId, expected.providerId)'); + expect(setModel).toContain('eq(sessions.effort, expected.effort'); + expect(setModel).toContain('eq(sessions.fastMode, expected.fastMode)'); + expect(guard).toBeGreaterThan(-1); + expect(rollback).toBeGreaterThan(guard); + expect(ownerFailure).toBeGreaterThan(rollback); + expect(setModel.match(/await rollbackRuntimeSelectionForSupersededOwner\(\)/g)).toHaveLength(3); + const outerCatch = setModel.lastIndexOf('} catch (err) {'); + expect(setModel.indexOf('await rollbackAppliedRuntimeSelection();', outerCatch)).toBeGreaterThan( + outerCatch, + ); + }); + + it('restores host stores and retires the live session after a post-selection failure', () => { + const setModel = handlerBody( + registerSource, + 'const handleSetModel = async (', + 'const recoverRemoteRuntimeAxisPersistence', + ); + const runtimeRecovery = setModel.indexOf( + 'const recoverRuntimeAfterSelectionRollback = async (', + ); + const ownerGuard = setModel.indexOf('const runtimeRecoveryCurrent =', runtimeRecovery); + const restoreStores = setModel.indexOf('restoreControlStores();', ownerGuard); + const retireLiveSession = setModel.indexOf( + 'await withRehydrateCloseSuppressed(sessionId, () => maker.closeSession(sessionId));', + restoreStores, + ); + const restoredRouteBroadcast = setModel.indexOf( + 'broadcastSessionPatched(\n sessionId,\n {\n ...restoredState,', + retireLiveSession, + ); + const reconcileRetainedLive = setModel.indexOf( + 'await reconcileRetainedLiveProfile();', + restoredRouteBroadcast, + ); + const rollbackFailure = setModel.indexOf( + "'runtime selection rollback could not retire the live session'", + reconcileRetainedLive, + ); + const rollback = setModel.indexOf( + 'const rollbackAppliedRuntimeSelection = async (): Promise => {', + rollbackFailure, + ); + const sqliteRestore = setModel.indexOf('rollbackPersistedCodexRuntimeSelection({', rollback); + const projectionRead = setModel.indexOf('const projectionMeta = await maker.getSessionMeta(sessionId);'); + const outerCatch = setModel.lastIndexOf('} catch (err) {'); + + expect(ownerGuard).toBeGreaterThan(runtimeRecovery); + expect(restoreStores).toBeGreaterThan(ownerGuard); + expect(retireLiveSession).toBeGreaterThan(restoreStores); + expect(restoredRouteBroadcast).toBeGreaterThan(retireLiveSession); + expect(reconcileRetainedLive).toBeGreaterThan(restoredRouteBroadcast); + expect(rollbackFailure).toBeGreaterThan(reconcileRetainedLive); + expect(rollback).toBeGreaterThan(rollbackFailure); + expect(sqliteRestore).toBeGreaterThan(rollback); + expect(setModel.slice(retireLiveSession, restoredRouteBroadcast)).toContain( + 'if (recoveryErrors.length === 0)', + ); + expect(setModel.slice(ownerGuard, restoreStores)).toContain('runtimeOwnerBoundaryCurrent()'); + expect(setModel.slice(ownerGuard, restoreStores)).toContain( + 'getCurrentDbClientSnapshot()?.clientEpoch === runtimeDbSnapshot.clientEpoch', + ); + expect(projectionRead).toBeGreaterThan(retireLiveSession); + expect(outerCatch).toBeGreaterThan(projectionRead); + expect(setModel.indexOf('await rollbackAppliedRuntimeSelection();', outerCatch)).toBeGreaterThan( + outerCatch, + ); + }); + + it('cancels a deferred runtime selection when a later metadata read fails', () => { + const setModel = handlerBody( + registerSource, + 'const handleSetModel = async (', + 'const recoverRemoteRuntimeAxisPersistence', + ); + const rollback = setModel.indexOf( + 'const rollbackAppliedRuntimeSelection = async (): Promise => {', + ); + const noPersistedRoute = setModel.indexOf('if (!persistedRoute) {', rollback); + const captureRelink = setModel.indexOf( + 'const hadRelink = appliedCodexThreadRelink !== undefined;', + noPersistedRoute, + ); + const rollbackRelink = setModel.indexOf( + 'const restored = await rollbackAppliedCodexThreadRelink();', + captureRelink, + ); + const deferredRecoveryGuard = setModel.indexOf( + 'if (hadRelink || appliedRuntimeSelectionWasDeferred) {', + rollbackRelink, + ); + const runtimeRecovery = setModel.indexOf( + 'await recoverRuntimeAfterSelectionRollback({', + deferredRecoveryGuard, + ); + const recoveryDefinition = setModel.indexOf( + 'const recoverRuntimeAfterSelectionRollback = async (', + ); + const clearDeferredTarget = setModel.indexOf( + 'pendingCredentialSwitchHolder?.clear(sessionId);', + recoveryDefinition, + ); + const restoreControlStores = setModel.indexOf( + 'restoreControlStores();', + clearDeferredTarget, + ); + const restorePreviousPending = setModel.indexOf( + 'if (previousRuntime.pendingCredentialSwitch) {', + restoreControlStores, + ); + const wakeWithoutPreviousPending = setModel.indexOf( + 'wakeSessionInputAfterCredentialSwitch(sessionId);', + restorePreviousPending, + ); + const projectionRead = setModel.indexOf( + 'const projectionMeta = await maker.getSessionMeta(sessionId);', + ); + const outerCatch = setModel.lastIndexOf('} catch (err) {'); + const outerRollback = setModel.indexOf( + 'await rollbackAppliedRuntimeSelection();', + outerCatch, + ); + + expect(noPersistedRoute).toBeGreaterThan(rollback); + expect(captureRelink).toBeGreaterThan(noPersistedRoute); + expect(rollbackRelink).toBeGreaterThan(captureRelink); + expect(deferredRecoveryGuard).toBeGreaterThan(rollbackRelink); + expect(runtimeRecovery).toBeGreaterThan(deferredRecoveryGuard); + expect(clearDeferredTarget).toBeGreaterThan(recoveryDefinition); + expect(restoreControlStores).toBeGreaterThan(clearDeferredTarget); + expect(restorePreviousPending).toBeGreaterThan(restoreControlStores); + expect(wakeWithoutPreviousPending).toBeGreaterThan(restorePreviousPending); + expect(setModel.slice(runtimeRecovery, runtimeRecovery + 500)).toContain( + 'sdkSessionId: runtimeStatus.sdkSessionId', + ); + expect(setModel.slice(runtimeRecovery, runtimeRecovery + 500)).toContain( + 'providerId: runtimeStatus.providerId', + ); + expect(projectionRead).toBeGreaterThan(runtimeRecovery); + expect(outerRollback).toBeGreaterThan(outerCatch); + }); + + it('treats teardown pending as stale at the final relink commit boundary', () => { + const relink = handlerBody( + registerSource, + 'export async function relinkCodexThreadForCredentialSwitch(', + 'export function clearPendingCredentialSwitchForSession(', + ); + const guardedCommit = relink.indexOf( + 'commitCodexProviderThreadRelinkWithBoundaryGuard({', + ); + const pendingGuard = relink.lastIndexOf('!isAppSessionBoundaryPending()', guardedCommit); + const broadcast = relink.indexOf('broadcastSessionPatched(', guardedCommit); + + expect(pendingGuard).toBeGreaterThan(-1); + expect(pendingGuard).toBeLessThan(guardedCommit); + expect(broadcast).toBeGreaterThan(guardedCommit); + expect(relink).toContain('rollbackCapturedRelink({'); + }); + + it('passes the locked persistent thread identity into runtime model switching', () => { + const setModel = handlerBody( + registerSource, + 'const handleSetModel = async (', + 'const recoverRemoteRuntimeAxisPersistence', + ); + + expect(setModel).toContain('agentKind: sessions.agentKind'); + expect(setModel).toContain('sdkSessionId: sessions.sdkSessionId'); + expect(setModel).toContain('persistedSession: {'); + expect(setModel).toContain('model: runtimeStatus.model'); + expect(setModel).toContain('providerId: runtimeStatus.providerId ?? null'); + }); + + it('keeps the Codex rebuild marker in the production pending-switch projection', () => { + const getter = handlerBody( + registerSource, + 'export function getPendingCredentialSwitchTarget(', + '// ── Scheduler 撞忙排队桥', + ); + + expect(getter).toContain('pending.rebuildCodexThread'); + expect(getter).toContain('{ rebuildCodexThread: true }'); + expect(getter).toContain('pending.codexThreadRelinkCommitted'); + expect(getter).toContain('{ codexThreadRelinkCommitted: true }'); + expect(getter).toContain('pending.ownerScope'); + expect(getter).toContain('pending.sourceCodexThreadModelProviderId'); + expect(getter).toContain('pending.previousRoute'); + expect(getter).toContain('pending.restoreStaleOwnerRoute'); + }); + + it('restores the source route before an in-memory relink pending can be lost on restart', () => { + const registration = handlerBody( + registerSource, + 'export async function registerPendingCredentialSwitchForSession(', + '/**\n * 跨订阅远端压缩身份边界时', + ); + const restartSafeRestore = registration.indexOf( + 'const restored = await restoreStaleOwnerRoute({', + ); + const registrationAttempt = registration.indexOf('const registered = await service.register('); + + expect(registration).toContain('model: capturedPrevRow.model'); + expect(registration).toContain('providerId: capturedPrevRow.providerId'); + expect(registration).toContain('effort: capturedPrevRow.effort'); + expect(registration).toContain('fastMode: capturedPrevRow.fastMode'); + expect(registration).toContain( + 'Codex pending credential switch could not preserve the source route', + ); + expect(restartSafeRestore).toBeGreaterThan(-1); + expect(registrationAttempt).toBeGreaterThan(restartSafeRestore); + }); + + it('binds deferred credential switches to the captured owner scope and runtime epoch', () => { + const registration = handlerBody( + registerSource, + 'export async function registerPendingCredentialSwitchForSession(', + '/**\n * 跨订阅远端压缩身份边界时', + ); + const service = handlerBody( + registerSource, + 'const pendingCredentialSwitchService = new PendingCredentialSwitchService({', + 'pendingCredentialSwitchHolder = pendingCredentialSwitchService;', + ); + + expect(registration).toContain('ownerScopeKey: activeOwnerScopeKey()'); + expect(registration).toContain( + 'runtimeOwnerEpoch: captureSessionRuntimeControlOwnerEpoch()', + ); + expect(registration).toContain('target.ownerScope ??'); + expect(registration).toContain('const dbSnapshot = getCurrentDbClientSnapshot();'); + expect(registration).toContain('const sourcePersistedSession = target.sourcePersistedSession;'); + expect(registration).toContain('sourcePersistedSession.sdkSessionId'); + expect(registration).toContain('sourcePersistedSession.fastMode'); + expect(registration).toContain('restoreCapturedProfileRoute'); + expect(registration).toContain('const appliedRoute = persistedRoute ??'); + expect(registration).toContain('eq(sessions.model, appliedRoute.model)'); + expect(registration).toContain('sdkSessionId: capturedPrevRow.sdkSessionId'); + expect(registration).toContain('model: capturedPrevRow.model'); + expect(registration).toContain('providerId: capturedPrevRow.providerId'); + expect(registration).toContain('effort: capturedPrevRow.effort'); + expect(registration).toContain('fastMode: capturedPrevRow.fastMode'); + expect(registration).toContain( + 'Codex pending credential switch requires an old-profile rollback snapshot', + ); + const registrationAttempt = registration.indexOf('const registered = await service.register('); + const registrationFailure = registration.indexOf('if (!registered) {', registrationAttempt); + const failureReturn = registration.indexOf( + "throw new Error('Pending credential switch owner changed during registration')", + registrationFailure, + ); + expect(registrationAttempt).toBeGreaterThan(-1); + expect(registrationFailure).toBeGreaterThan(registrationAttempt); + expect(failureReturn).toBeGreaterThan(registrationFailure); + expect(pendingCredentialSwitchSource).toContain('restoreStaleOwnerRoute'); + expect(pendingCredentialSwitchSource).toContain( + 'await this.compensateStaleOwnerRoute(sessionId, pending);', + ); + expect(pendingCredentialSwitchSource).toContain('this.staleDiscards.has(target)'); + expect(service).toContain('!isAppSessionBoundaryPending()'); + expect(service).toContain('activeOwnerScopeKey() === scope.ownerScopeKey'); + expect(service).toContain( + 'sessionRuntimeControlOwnerEpochMatches(scope.runtimeOwnerEpoch)', + ); + }); + it('commits user effort and Fast state only after the live runtime call succeeds', () => { const effort = handlerBody( registerSource, diff --git a/apps/desktop/src/main/__tests__/windowsPackagedInstanceBarrier.test.ts b/apps/desktop/src/main/__tests__/windowsPackagedInstanceBarrier.test.ts index e5e7bb0ee8..3c5baea8ac 100644 --- a/apps/desktop/src/main/__tests__/windowsPackagedInstanceBarrier.test.ts +++ b/apps/desktop/src/main/__tests__/windowsPackagedInstanceBarrier.test.ts @@ -1,6 +1,7 @@ import os from 'node:os'; import path from 'node:path'; -import { describe, expect, it } from 'vitest'; +import { EventEmitter } from 'node:events'; +import { describe, expect, it, vi } from 'vitest'; import { acquireWindowsPackagedInstanceBarrier, @@ -9,6 +10,12 @@ import { describe('windowsPackagedInstanceBarrier', () => { it('parses only complete helper statuses', () => { + expect(__testing.parseBarrierStatus('{"status":"started"}')).toEqual({ + status: 'started', + }); + expect(__testing.parseBarrierStatus('{"status":"locked"}')).toEqual({ + status: 'locked', + }); expect(__testing.parseBarrierStatus('{"status":"acquired"}')).toEqual({ status: 'acquired', }); @@ -34,6 +41,36 @@ describe('windowsPackagedInstanceBarrier', () => { expect(__testing.WINDOWS_PACKAGED_INSTANCE_BARRIER_SCRIPT).toContain( 'CINDY_SINGLETON_WINDOW_TITLE', ); + expect( + __testing.WINDOWS_PACKAGED_INSTANCE_BARRIER_SCRIPT.indexOf('{"status":"locked"}'), + ).toBeLessThan(__testing.WINDOWS_PACKAGED_INSTANCE_BARRIER_SCRIPT.indexOf('Add-Type')); + }); + + it('waits for the helper exit after forced termination', async () => { + const child = new EventEmitter() as EventEmitter & { + exitCode: number | null; + signalCode: NodeJS.Signals | null; + kill: ReturnType; + }; + child.exitCode = null; + child.signalCode = null; + child.kill = vi.fn(() => true); + let finished = false; + const waiting = __testing + .waitForExit(child as never, 20) + .then(() => { + finished = true; + }); + + await vi.waitFor(() => expect(child.kill).toHaveBeenCalledOnce(), { + timeout: 30, + interval: 1, + }); + expect(finished).toBe(false); + child.exitCode = 1; + child.emit('exit', 1, null); + await waiting; + expect(finished).toBe(true); }); it.runIf(process.platform === 'win32')( diff --git a/apps/desktop/src/main/im/shared/__tests__/cardActionTakeoverReplace.test.ts b/apps/desktop/src/main/im/shared/__tests__/cardActionTakeoverReplace.test.ts index a850c2ea55..c594b7ecfd 100644 --- a/apps/desktop/src/main/im/shared/__tests__/cardActionTakeoverReplace.test.ts +++ b/apps/desktop/src/main/im/shared/__tests__/cardActionTakeoverReplace.test.ts @@ -22,13 +22,40 @@ const mocks = vi.hoisted(() => ({ getDesktopCcPrefs: vi.fn<() => DesktopCcPrefs | null>(() => null), resolveLenientSessionRoute: vi.fn(), applyRuntimeSetModelChange: - vi.fn<(input: unknown) => Promise<{ status: 'applied' | 'deferred' }>>(), + vi.fn< + (input: unknown) => Promise< + | { + status: 'applied'; + codexThreadRelink?: { + previousSdkSessionId: string; + newSdkSessionId: string; + rollback: () => Promise; + }; + } + | { status: 'deferred'; preservePersistedRoute?: true } + > + >(), + relinkCodexThreadForCredentialSwitch: vi.fn< + (input: unknown) => Promise<{ + previousSdkSessionId: string; + newSdkSessionId: string; + rollback: () => Promise; + } | null> + >(async () => null), registerPendingCredentialSwitchForSession: vi.fn(), clearPendingCredentialSwitchForSession: vi.fn(), wakeSessionInputAfterCredentialSwitch: vi.fn(), getPendingCredentialSwitchTarget: vi.fn(() => undefined), readModelRouteSnapshot: vi.fn( - async (): Promise<{ model: string; effort: string; providerId: string | null } | null> => null, + async (): Promise<{ + agentKind: 'codex' | 'pi' | 'claude-code'; + remoteHostId: string | null; + sdkSessionId: string | null; + model: string; + effort: string; + fastMode?: boolean; + providerId: string | null; + } | null> => null, ), readPermissionMode: vi.fn(async () => 'auto'), updatePermissionMode: vi.fn(async () => {}), @@ -97,6 +124,7 @@ vi.mock('../../../maker-ipc/runtimeSetModel', () => ({ vi.mock('../../../maker-ipc/register', () => ({ cancelPendingAgentSwitchForSession: mocks.cancelPendingAgentSwitchForSession, isSessionInTurn: mocks.isSessionInTurn, + relinkCodexThreadForCredentialSwitch: mocks.relinkCodexThreadForCredentialSwitch, registerPendingCredentialSwitchForSession: mocks.registerPendingCredentialSwitchForSession, clearPendingCredentialSwitchForSession: mocks.clearPendingCredentialSwitchForSession, wakeSessionInputAfterCredentialSwitch: mocks.wakeSessionInputAfterCredentialSwitch, @@ -646,6 +674,16 @@ describe('model:pick 持久化失败', () => { }); it('busy provider 切换注入 pending hooks,并在 deferred 时不 mid-turn 改 effort', async () => { + const sourceSnapshot = { + agentKind: 'codex' as const, + remoteHostId: null, + sdkSessionId: 'thread-xd', + model: 'gpt-5.4', + effort: 'high', + fastMode: false, + providerId: 'xd', + }; + mocks.readModelRouteSnapshot.mockResolvedValueOnce(sourceSnapshot); const live = { agentKind: 'codex', remoteHostId: null, @@ -658,12 +696,21 @@ describe('model:pick 持久化失败', () => { const hooks = input as { registerPendingCredentialSwitch?: ( sessionId: string, - target: { model: string; providerId: string | null }, + target: { + model: string; + providerId: string | null; + effort?: string; + sourcePersistedSession?: unknown; + }, ) => void; + persistedSession?: unknown; + effort?: string; }; hooks.registerPendingCredentialSwitch?.('sess-target', { model: 'claude-opus-4-7', providerId: 'anthropic', + effort: hooks.effort, + sourcePersistedSession: hooks.persistedSession, }); return { status: 'deferred' as const }; }); @@ -679,10 +726,15 @@ describe('model:pick 持久化失败', () => { getPendingCredentialSwitch: mocks.getPendingCredentialSwitchTarget, }), ); - expect(mocks.registerPendingCredentialSwitchForSession).toHaveBeenCalledWith('sess-target', { - model: 'claude-opus-4-7', - providerId: 'anthropic', - }); + expect(mocks.registerPendingCredentialSwitchForSession).toHaveBeenCalledWith( + 'sess-target', + expect.objectContaining({ + model: 'claude-opus-4-7', + providerId: 'anthropic', + effort: 'high', + sourcePersistedSession: sourceSnapshot, + }), + ); expect(live.setEffort).not.toHaveBeenCalled(); expect(im.updateInteractiveCard).toHaveBeenCalledWith( 'model-card', @@ -690,7 +742,72 @@ describe('model:pick 持久化失败', () => { ); }); - it('DB 未落盘时不触碰 runtime,并收口失败卡', async () => { + it('空闲 Codex 跨来源切换用同一 CAS 成套提交 thread 与 route', async () => { + const sourceSnapshot = { + agentKind: 'codex' as const, + remoteHostId: null, + sdkSessionId: 'thread-xd', + model: 'codex/gpt-5.6-sol', + effort: 'medium', + fastMode: false, + providerId: 'xd', + }; + mocks.readModelRouteSnapshot.mockResolvedValueOnce(sourceSnapshot); + const receipt = { + previousSdkSessionId: 'thread-xd', + newSdkSessionId: 'thread-openai', + rollback: vi.fn(async () => true), + }; + mocks.relinkCodexThreadForCredentialSwitch.mockResolvedValueOnce(receipt); + mocks.applyRuntimeSetModelChange.mockImplementationOnce(async (input: unknown) => { + expect(mocks.updateModelEffort).not.toHaveBeenCalled(); + const hooks = input as { + relinkCodexThreadForProviderSwitch: (relinkInput: { + sessionId: string; + sourceModel: string; + sourceProviderId: string | null; + targetModel: string; + targetProviderId: string | null; + }) => Promise; + }; + const committed = await hooks.relinkCodexThreadForProviderSwitch({ + sessionId: 'sess-target', + sourceModel: sourceSnapshot.model, + sourceProviderId: sourceSnapshot.providerId, + targetModel: 'claude-opus-4-7', + targetProviderId: 'anthropic', + }); + return { status: 'applied' as const, codexThreadRelink: committed ?? undefined }; + }); + const im = makeIm(); + + await pressModelPick(im); + + expect(mocks.relinkCodexThreadForCredentialSwitch).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'sess-target', + persistedRouteTransition: { + previous: { + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + effort: 'medium', + fastMode: false, + }, + next: { + model: 'claude-opus-4-7', + providerId: 'anthropic', + effort: 'high', + fastMode: false, + }, + }, + }), + ); + // The relink CAS already persisted the complete target tuple; no second route write window. + expect(mocks.updateModelEffort).not.toHaveBeenCalled(); + expect(mocks.cancelPendingAgentSwitchForSession).toHaveBeenCalledWith('sess-target'); + }); + + it('DB 落盘失败时回滚已接受的 runtime,并收口失败卡', async () => { const live = { agentKind: 'claude-code', remoteHostId: null, @@ -700,6 +817,7 @@ describe('model:pick 持久化失败', () => { }; (turnRunner.getMakerSessionById as ReturnType).mockReturnValue(live); mocks.readModelRouteSnapshot.mockResolvedValueOnce({ + agentKind: 'claude-code', remoteHostId: null, sdkSessionId: null, model: 'claude-sonnet-4-6', effort: 'medium', providerId: 'openrouter', @@ -715,19 +833,23 @@ describe('model:pick 持久化失败', () => { 'high', 'anthropic', ); - expect(mocks.applyRuntimeSetModelChange).not.toHaveBeenCalled(); + expect(mocks.applyRuntimeSetModelChange).toHaveBeenCalledOnce(); expect(mocks.cancelPendingAgentSwitchForSession).not.toHaveBeenCalled(); - expect(mocks.setSessionProvider).not.toHaveBeenCalled(); - expect(live.setModel).not.toHaveBeenCalled(); - expect(live.setEffort).not.toHaveBeenCalled(); + expect(mocks.clearPendingCredentialSwitchForSession).toHaveBeenCalledWith('sess-target'); + expect(mocks.setSessionProvider).toHaveBeenCalledWith('sess-target', 'openrouter'); + expect(live.setModel).toHaveBeenCalledWith('claude-sonnet-4-6', { + providerId: 'openrouter', + }); + expect(live.setEffort).toHaveBeenCalledWith('medium'); expect(im.updateInteractiveCard).toHaveBeenCalledWith( 'model-card', expect.objectContaining({ body: slackUi.cards.model.failed('db locked') }), ); }); - it('runtime setModel 失败时恢复已落盘的旧路由', async () => { + it('runtime setModel 失败时不预写目标路由', async () => { mocks.readModelRouteSnapshot.mockResolvedValueOnce({ + agentKind: 'claude-code', remoteHostId: null, sdkSessionId: null, model: 'claude-sonnet-4-6', effort: 'medium', providerId: 'openrouter', @@ -737,19 +859,18 @@ describe('model:pick 持久化失败', () => { await pressModelPick(im); - expect(mocks.updateModelEffort).toHaveBeenNthCalledWith( - 1, - 'sess-target', - 'claude-opus-4-7', - 'high', - 'anthropic', - ); - expect(mocks.updateModelEffort).toHaveBeenNthCalledWith( - 2, - 'sess-target', - 'claude-sonnet-4-6', - 'medium', - 'openrouter', + expect(mocks.updateModelEffort).not.toHaveBeenCalled(); + expect(mocks.applyRuntimeSetModelChange).toHaveBeenCalledWith( + expect.objectContaining({ + persistedSession: { + agentKind: 'claude-code', + remoteHostId: null, + sdkSessionId: null, + model: 'claude-sonnet-4-6', + effort: 'medium', + providerId: 'openrouter', + }, + }), ); expect(im.updateInteractiveCard).toHaveBeenCalledWith( 'model-card', @@ -771,6 +892,7 @@ describe('model:pick 持久化失败', () => { }; (turnRunner.getMakerSessionById as ReturnType).mockReturnValue(live); mocks.readModelRouteSnapshot.mockResolvedValueOnce({ + agentKind: 'claude-code', remoteHostId: null, sdkSessionId: null, model: 'claude-sonnet-4-6', effort: 'medium', providerId: 'openrouter', @@ -819,6 +941,7 @@ describe('model:pick 持久化失败', () => { }; (turnRunner.getMakerSessionById as ReturnType).mockReturnValue(live); mocks.readModelRouteSnapshot.mockResolvedValueOnce({ + agentKind: 'claude-code', remoteHostId: null, sdkSessionId: null, model: 'claude-sonnet-4-6', effort: 'medium', providerId: null, diff --git a/apps/desktop/src/main/im/shared/cardActionHandler.ts b/apps/desktop/src/main/im/shared/cardActionHandler.ts index 036d609bd2..1b4f879dca 100644 --- a/apps/desktop/src/main/im/shared/cardActionHandler.ts +++ b/apps/desktop/src/main/im/shared/cardActionHandler.ts @@ -38,6 +38,7 @@ import { clearPendingCredentialSwitchForSession, getPendingCredentialSwitchTarget, isSessionInTurn, + relinkCodexThreadForCredentialSwitch, registerPendingCredentialSwitchForSession, withSendToSessionLock, wakeSessionInputAfterCredentialSwitch, @@ -373,29 +374,69 @@ export function createCardActionHandler( } }; - // 持久化与运行态切换必须和 send / agent switch 共用 session 锁,保证后选覆盖先选。 - try { - await updateModelEffort(sessionId, modelId, effort ?? 'high', providerId); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - log.error(`model:pick DB update failed: ${msg}`); - return msg; - } - try { + const relinkWithPersistedRoute = async ( + relinkInput: Parameters[0], + ) => { + if (!previousRoute) { + throw new Error('IM Codex thread relink requires a persisted source route snapshot'); + } + return relinkCodexThreadForCredentialSwitch({ + ...relinkInput, + persistedRouteTransition: { + previous: { + model: previousRoute.model, + providerId: previousRoute.providerId, + effort: previousRoute.effort, + fastMode: previousRoute.fastMode, + }, + next: { + model: relinkInput.targetModel, + providerId: relinkInput.targetProviderId, + effort: effort ?? 'high', + fastMode: previousRoute.fastMode, + }, + }, + }); + }; const runtimeChange = await applyRuntimeSetModelChange({ maker: getMaker(), sessionId, model: modelId, providerId, + ...(effort !== null ? { effort } : {}), + ...(previousRoute ? { persistedSession: previousRoute } : {}), isSessionInTurn, registerPendingCredentialSwitch: registerPendingCredentialSwitchForSession, clearPendingCredentialSwitch: clearPendingCredentialSwitchForSession, wakeSessionInputQueue: wakeSessionInputAfterCredentialSwitch, getPendingCredentialSwitch: getPendingCredentialSwitchTarget, + relinkCodexThreadForProviderSwitch: relinkWithPersistedRoute, logger: log, }); + const preservePersistedRoute = + runtimeChange.status === 'deferred' && + runtimeChange.preservePersistedRoute === true; + const routePersistedWithRelink = + runtimeChange.status === 'applied' && runtimeChange.codexThreadRelink !== undefined; + if (!preservePersistedRoute && !routePersistedWithRelink) { + try { + // Non-relink selections persist only after runtime acceptance. Cross-family Codex + // relinks use the single SQLite CAS above, so a crash observes either the complete + // source tuple or the complete target tuple, never a mixed route/thread pair. + await updateModelEffort(sessionId, modelId, effort ?? 'high', providerId); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + log.error(`model:pick DB update failed: ${msg}`); + clearPendingCredentialSwitchForSession(sessionId); + if (runtimeChange.status !== 'deferred') { + await rollbackRuntimeChange('DB persistence'); + } + return msg; + } + } + const liveAfterModel = turnRunner.getMakerSessionById(sessionId); if (runtimeChange.status !== 'deferred' && liveAfterModel && effort) { try { @@ -411,7 +452,6 @@ export function createCardActionHandler( } catch (err) { const msg = err instanceof Error ? err.message : String(err); log.warn(`model:pick runtime setModel failed: ${msg}`); - await restorePersistentRoute('runtime setModel'); return msg; } diff --git a/apps/desktop/src/main/im/shared/sessionRepo.ts b/apps/desktop/src/main/im/shared/sessionRepo.ts index d8775ddb0e..c10899faca 100644 --- a/apps/desktop/src/main/im/shared/sessionRepo.ts +++ b/apps/desktop/src/main/im/shared/sessionRepo.ts @@ -70,8 +70,12 @@ export interface ImSessionRow { } export interface SessionModelRouteSnapshot { + agentKind: AgentKind; + remoteHostId: string | null; + sdkSessionId: string | null; model: string; effort: Effort; + fastMode: boolean; providerId: string | null; } @@ -585,8 +589,12 @@ export async function readModelRouteSnapshot( const db = getDbClient().drizzle; const rows = await db .select({ + agentKind: sessions.agentKind, + remoteHostId: sessions.remoteHostId, + sdkSessionId: sessions.sdkSessionId, model: sessions.model, effort: sessions.effort, + fastMode: sessions.fastMode, providerId: sessions.providerId, }) .from(sessions) @@ -595,8 +603,12 @@ export async function readModelRouteSnapshot( const row = rows[0]; if (!row) return null; return { + agentKind: toCoreAgentKind(row.agentKind), + remoteHostId: row.remoteHostId ?? null, + sdkSessionId: row.sdkSessionId ?? null, model: row.model, effort: row.effort as Effort, + fastMode: row.fastMode, providerId: row.providerId ?? null, }; } diff --git a/apps/desktop/src/main/maker-host/__tests__/codexProxyHost.test.ts b/apps/desktop/src/main/maker-host/__tests__/codexProxyHost.test.ts index 3544b12876..f4b8b6afa2 100644 --- a/apps/desktop/src/main/maker-host/__tests__/codexProxyHost.test.ts +++ b/apps/desktop/src/main/maker-host/__tests__/codexProxyHost.test.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { TEST_XD_GATEWAY_BASE_URL as XD_GATEWAY_BASE_URL } from '../../../test/vitest/clientEndpointsFixture'; type Registry = { @@ -128,8 +128,11 @@ vi.mock('@cindy/responses-anthropic-bridge', () => ({ createResponsesAnthropicHandler: mockState.createResponsesAnthropicHandler, })); -async function freshCodexProxyHost() { - vi.resetModules(); +type CodexProxyHostModule = typeof import('../codex-proxy-host.js'); + +let preloadedCodexProxyHost: CodexProxyHostModule | null = null; + +function resetCodexProxyHostMocks(): void { mockState.createAnthropicCompatProxy.mockReset(); mockState.createResponsesChatHandler.mockClear(); mockState.createResponsesAnthropicHandler.mockClear(); @@ -139,6 +142,24 @@ async function freshCodexProxyHost() { mockState.stripNonAnthropicFields.mockReset(); mockState.stripNonAnthropicFields.mockReturnValue(null); mockState.resetCapturedRegistry(); +} + +beforeAll(async () => { + // This module has a large dependency graph. Loading it inside the first 5s test body makes + // that assertion inherit unrelated shard contention (the Linux shard collects 1k+ files). + // Preload once as suite setup; later tests keep their existing resetModules isolation. + preloadedCodexProxyHost = await import('../codex-proxy-host.js'); +}); + +async function freshCodexProxyHost(): Promise { + const preloaded = preloadedCodexProxyHost; + preloadedCodexProxyHost = null; + if (preloaded) { + resetCodexProxyHostMocks(); + return preloaded; + } + vi.resetModules(); + resetCodexProxyHostMocks(); return import('../codex-proxy-host.js'); } diff --git a/apps/desktop/src/main/maker-host/codex-credential-switch.ts b/apps/desktop/src/main/maker-host/codex-credential-switch.ts index 372f2d9fe3..26b258916f 100644 --- a/apps/desktop/src/main/maker-host/codex-credential-switch.ts +++ b/apps/desktop/src/main/maker-host/codex-credential-switch.ts @@ -259,8 +259,9 @@ export function shouldCloseSessionForCredentialSwitch( // oauth spawn 的订阅直连 thread 以 OpenAI 身份 provider 创建(codex 据此走 // OpenAI 远端压缩),而 provider 身份是 thread 级冻结、settings/update 改不了; // 网关 / xAI 等上游不支持远端压缩且失败无本地回退。因此凡切换跨过 - // oauth-bearer(订阅直连)家族边界,必须关会话、由下一次发送按新路由 resume - // 重建 thread(resume 会按新家族重新决定 provider 身份)。这有意收窄了 + // oauth-bearer(订阅直连)家族边界,必须关会话,并由 host 把旧 rollout 安全 fork + // 成新 thread 后再按新路由 resume(直接 resume 同一 thread 会把供应商私有历史 + // 带到另一条路由)。这有意收窄了 // 方案 A 的「oauth 超集 host 热切 gateway-key / provider-oauth 会话」范围: // host 仍复用不重建,只是该会话自身要走关闭重建。隐式来源解析不出家族且 // 未提供 codexAuthInjection 时按未知处理 → 与另一侧不同即保守关闭。 @@ -271,7 +272,7 @@ export function shouldCloseSessionForCredentialSwitch( // provider store 可能先于运行时切换被 UI/持久层覆盖。此时仅比较 currentMode/nextMode // 会把两边误判为同一家族,并在仍绑定 cindy_openai 的 thread 上热切 DeepSeek/xAI/XD。 - // start/resume 响应才是 thread 身份的事实源;与目标身份不一致就必须 close + resume。 + // start/resume 响应才是 thread 身份的事实源;与目标身份不一致就必须 close + fork。 if (isCodexThreadModelProviderIdentityMismatch(input)) { return true; } diff --git a/apps/desktop/src/main/maker-ipc/__tests__/codexProviderThreadRelink.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/codexProviderThreadRelink.test.ts new file mode 100644 index 0000000000..0e573ed377 --- /dev/null +++ b/apps/desktop/src/main/maker-ipc/__tests__/codexProviderThreadRelink.test.ts @@ -0,0 +1,384 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + CodexProviderThreadRelinkCompensationRequiredError, + commitCodexProviderThreadRelinkWithBoundaryGuard, + relinkCodexProviderThread, + rollbackPersistedCodexRuntimeSelection, +} from '../codexProviderThreadRelink.js'; + +describe('relinkCodexProviderThread', () => { + it('atomically restores thread and route when teardown follows route persistence', async () => { + let persisted = { + sdkSessionId: 'thread-new' as string | null, + model: 'gpt-5.6-sol', + providerId: 'openai' as string | null, + effort: 'xhigh', + fastMode: true, + }; + const restore = vi.fn(async ({ expected, previous }) => { + if (JSON.stringify(persisted) !== JSON.stringify(expected)) return false; + persisted = previous; + return true; + }); + + // Simulate teardown beginning during a later projection await, after both writes landed. + await Promise.resolve(); + await expect( + rollbackPersistedCodexRuntimeSelection({ + previous: { + sdkSessionId: 'thread-old', + model: 'deepseek/deepseek-v4-pro', + providerId: 'xd', + effort: 'high', + fastMode: false, + }, + appliedRoute: { + model: 'gpt-5.6-sol', + providerId: 'openai', + effort: 'xhigh', + fastMode: true, + }, + relinkReceipt: { + previousSdkSessionId: 'thread-old', + newSdkSessionId: 'thread-new', + }, + restore, + }), + ).resolves.toBe(true); + + expect(restore).toHaveBeenCalledOnce(); + expect(persisted).toEqual({ + sdkSessionId: 'thread-old', + model: 'deepseek/deepseek-v4-pro', + providerId: 'xd', + effort: 'high', + fastMode: false, + }); + }); + + it.each(['account switch', 'teardown', 'client epoch change'])( + 'rolls back a CAS when %s happens while the write is awaiting', + async () => { + let boundaryCurrent = true; + let sdkSessionId = 'thread-old'; + const rollback = vi.fn(async () => { + if (sdkSessionId !== 'thread-new') return false; + sdkSessionId = 'thread-old'; + return true; + }); + + await expect( + commitCodexProviderThreadRelinkWithBoundaryGuard({ + isBoundaryCurrent: () => boundaryCurrent, + commit: async () => { + sdkSessionId = 'thread-new'; + boundaryCurrent = false; + return true; + }, + rollback, + }), + ).resolves.toBe(false); + + expect(rollback).toHaveBeenCalledOnce(); + expect(sdkSessionId).toBe('thread-old'); + }, + ); + + it('hands off a retryable receipt when post-commit boundary rollback fails', async () => { + let boundaryCurrent = true; + let sdkSessionId = 'thread-old'; + const rollback = vi + .fn(async () => false) + .mockRejectedValueOnce(new Error('sqlite temporarily unavailable')) + .mockImplementationOnce(async () => { + if (sdkSessionId !== 'thread-new') return false; + sdkSessionId = 'thread-old'; + return true; + }); + let caught: unknown; + + try { + await relinkCodexProviderThread( + { + readSource: vi.fn(async () => ({ sdkSessionId: 'thread-old', workingDir: null })), + fork: vi.fn(async () => ({ newSdkSessionId: 'thread-new' })), + commit: () => + commitCodexProviderThreadRelinkWithBoundaryGuard({ + isBoundaryCurrent: () => boundaryCurrent, + commit: async () => { + sdkSessionId = 'thread-new'; + boundaryCurrent = false; + return true; + }, + rollback, + }), + }, + { + sessionId: 'session-1', + sourceModel: 'codex/gpt-5.6-sol', + sourceProviderId: 'xd', + isCurrent: () => boundaryCurrent, + }, + ); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(CodexProviderThreadRelinkCompensationRequiredError); + const receipt = (caught as CodexProviderThreadRelinkCompensationRequiredError).receipt; + expect(receipt).toMatchObject({ + previousSdkSessionId: 'thread-old', + newSdkSessionId: 'thread-new', + }); + expect(sdkSessionId).toBe('thread-new'); + await expect(receipt.rollback()).resolves.toBe(true); + expect(sdkSessionId).toBe('thread-old'); + expect(rollback).toHaveBeenCalledTimes(2); + }); + + it('treats a post-commit rollback CAS miss as a newer winner without a handoff', async () => { + let boundaryCurrent = true; + const rollback = vi.fn(async () => false); + let caught: unknown; + + try { + await relinkCodexProviderThread( + { + readSource: vi.fn(async () => ({ sdkSessionId: 'thread-old', workingDir: null })), + fork: vi.fn(async () => ({ newSdkSessionId: 'thread-new' })), + commit: () => + commitCodexProviderThreadRelinkWithBoundaryGuard({ + isBoundaryCurrent: () => boundaryCurrent, + commit: async () => { + boundaryCurrent = false; + return true; + }, + rollback, + }), + }, + { + sessionId: 'session-1', + sourceModel: 'codex/gpt-5.6-sol', + sourceProviderId: 'xd', + isCurrent: () => boundaryCurrent, + }, + ); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(Error); + expect(caught).not.toBeInstanceOf(CodexProviderThreadRelinkCompensationRequiredError); + expect((caught as Error).message).toMatch(/superseded/); + expect(rollback).toHaveBeenCalledOnce(); + }); + + it('forks a provider-neutral history and CAS-relinks the Cindy session', async () => { + const fork = vi.fn(async () => ({ newSdkSessionId: 'thread-openai' })); + const commit = vi.fn(async () => true); + const onCommitted = vi.fn(); + + await expect( + relinkCodexProviderThread( + { + readSource: vi.fn(async () => ({ + sdkSessionId: 'thread-xd', + workingDir: '/work', + })), + fork, + commit, + onCommitted, + }, + { + sessionId: 'session-1', + sourceModel: 'codex/gpt-5.6-sol', + sourceProviderId: 'xd', + }, + ), + ).resolves.toEqual({ + previousSdkSessionId: 'thread-xd', + newSdkSessionId: 'thread-openai', + }); + + expect(fork).toHaveBeenCalledWith({ + sourceSdkSessionId: 'thread-xd', + sourceModel: 'codex/gpt-5.6-sol', + sourceProviderId: 'xd', + workingDir: '/work', + }); + expect(commit).toHaveBeenCalledWith({ + sessionId: 'session-1', + expectedSdkSessionId: 'thread-xd', + newSdkSessionId: 'thread-openai', + }); + expect(onCommitted).toHaveBeenCalledWith({ + sessionId: 'session-1', + previousSdkSessionId: 'thread-xd', + newSdkSessionId: 'thread-openai', + }); + }); + + it('keeps the restart tuple on the source route until fork and atomic commit complete', async () => { + let persisted = { + sdkSessionId: 'thread-xd', + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + effort: 'medium', + fastMode: false, + }; + let releaseFork!: () => void; + const forkGate = new Promise((resolve) => { + releaseFork = resolve; + }); + const fork = vi.fn(async () => { + await forkGate; + return { newSdkSessionId: 'thread-openai' }; + }); + const relinking = relinkCodexProviderThread( + { + readSource: vi.fn(async () => ({ + sdkSessionId: persisted.sdkSessionId, + workingDir: null, + })), + fork, + commit: vi.fn(async ({ expectedSdkSessionId, newSdkSessionId }) => { + if ( + persisted.sdkSessionId !== expectedSdkSessionId || + persisted.model !== 'codex/gpt-5.6-sol' || + persisted.providerId !== 'xd' || + persisted.effort !== 'medium' || + persisted.fastMode !== false + ) { + return false; + } + persisted = { + sdkSessionId: newSdkSessionId, + model: 'gpt-5.6-sol', + providerId: 'openai', + effort: 'xhigh', + fastMode: true, + }; + return true; + }), + }, + { + sessionId: 'session-1', + sourceModel: 'codex/gpt-5.6-sol', + sourceProviderId: 'xd', + }, + ); + + await vi.waitFor(() => expect(fork).toHaveBeenCalledOnce()); + // Simulated process exit here restarts with a source provider and its own source thread. + expect(persisted).toEqual({ + sdkSessionId: 'thread-xd', + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + effort: 'medium', + fastMode: false, + }); + + releaseFork(); + await relinking; + expect(persisted).toEqual({ + sdkSessionId: 'thread-openai', + model: 'gpt-5.6-sol', + providerId: 'openai', + effort: 'xhigh', + fastMode: true, + }); + }); + + it('does nothing when the Cindy session has no native thread yet', async () => { + const fork = vi.fn(); + const commit = vi.fn(); + + await expect( + relinkCodexProviderThread( + { + readSource: vi.fn(async () => ({ sdkSessionId: null, workingDir: '/work' })), + fork, + commit, + }, + { + sessionId: 'session-1', + sourceModel: 'codex/gpt-5.6-sol', + sourceProviderId: 'xd', + }, + ), + ).resolves.toBeNull(); + expect(fork).not.toHaveBeenCalled(); + expect(commit).not.toHaveBeenCalled(); + }); + + it('fails closed when another owner replaces the thread before CAS commit', async () => { + await expect( + relinkCodexProviderThread( + { + readSource: vi.fn(async () => ({ + sdkSessionId: 'thread-xd', + workingDir: null, + })), + fork: vi.fn(async () => ({ newSdkSessionId: 'thread-openai' })), + commit: vi.fn(async () => false), + }, + { + sessionId: 'session-1', + sourceModel: 'codex/gpt-5.6-sol', + sourceProviderId: 'xd', + }, + ), + ).rejects.toThrow(/superseded/); + }); + + it('passes the pending generation guard into the CAS commit', async () => { + const isCurrent = vi.fn(() => true); + const commit = vi.fn(async () => true); + + await relinkCodexProviderThread( + { + readSource: vi.fn(async () => ({ sdkSessionId: 'thread-old', workingDir: null })), + fork: vi.fn(async () => ({ newSdkSessionId: 'thread-new' })), + commit, + }, + { + sessionId: 'session-1', + sourceModel: 'gpt-5.6-sol', + sourceProviderId: 'openai', + isCurrent, + }, + ); + + expect(commit).toHaveBeenCalledWith({ + sessionId: 'session-1', + expectedSdkSessionId: 'thread-old', + newSdkSessionId: 'thread-new', + isCurrent, + }); + }); + + it('uses the source thread identity when the route store already contains the target', async () => { + const fork = vi.fn(async () => ({ newSdkSessionId: 'thread-gateway' })); + + await relinkCodexProviderThread( + { + readSource: vi.fn(async () => ({ sdkSessionId: 'thread-openai', workingDir: null })), + fork, + commit: vi.fn(async () => true), + }, + { + sessionId: 'session-1', + sourceModel: 'deepseek/deepseek-v4-pro', + sourceProviderId: 'deepseek', + sourceThreadModelProviderId: 'cindy_openai', + }, + ); + + expect(fork).toHaveBeenCalledWith({ + sourceSdkSessionId: 'thread-openai', + sourceModel: 'deepseek/deepseek-v4-pro', + sourceProviderId: 'openai', + }); + }); +}); diff --git a/apps/desktop/src/main/maker-ipc/__tests__/messageDeleteHandler.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/messageDeleteHandler.test.ts index 1287971284..9d5529c7ae 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/messageDeleteHandler.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/messageDeleteHandler.test.ts @@ -87,8 +87,10 @@ describe('performMessageDeletion', () => { resolve(process.cwd(), 'src/main/maker-ipc/register.ts'), 'utf8', ); - const onCommittedStart = registerSource.indexOf('onCommitted:'); - expect(onCommittedStart).toBeGreaterThan(-1); + const deleteHandlerStart = registerSource.indexOf('registerMakerMessageDeleteHandler('); + expect(deleteHandlerStart).toBeGreaterThan(-1); + const onCommittedStart = registerSource.indexOf('onCommitted:', deleteHandlerStart); + expect(onCommittedStart).toBeGreaterThan(deleteHandlerStart); const patchStart = registerSource.indexOf( 'broadcastSessionPatched(sessionId, {', onCommittedStart, diff --git a/apps/desktop/src/main/maker-ipc/__tests__/pendingCredentialSwitch.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/pendingCredentialSwitch.test.ts index 0b51f22c64..2bfb383bf9 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/pendingCredentialSwitch.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/pendingCredentialSwitch.test.ts @@ -5,16 +5,25 @@ import { getSessionProvider, setSessionProvider, } from '../../maker-host/session-provider-store.js'; +import { + getSessionEffort, + getSessionFastMode, + setSessionEffort, + setSessionFastMode, +} from '../../maker-host/session-effort-store.js'; import { PendingCredentialSwitchService, type PendingCredentialSwitchDeps, } from '../pendingCredentialSwitch.js'; +import { CodexProviderThreadRelinkCompensationRequiredError } from '../codexProviderThreadRelink.js'; const touchedSessions = new Set(); afterEach(() => { for (const sessionId of touchedSessions) { clearSessionProvider(sessionId); + setSessionEffort(sessionId, null); + setSessionFastMode(sessionId, false); } touchedSessions.clear(); }); @@ -33,14 +42,23 @@ interface HarnessSession { function createHarness( sessions: HarnessSession[], - opts?: { retryDelayMs?: number; resolveRoute?: PendingCredentialSwitchDeps['resolveRoute'] }, + opts?: { + retryDelayMs?: number; + resolveRoute?: PendingCredentialSwitchDeps['resolveRoute']; + isOwnerScopeCurrent?: PendingCredentialSwitchDeps['isOwnerScopeCurrent']; + }, ) { const closeSession = vi.fn(async (_sessionId: string) => {}); const broadcastApplied = vi.fn>(); const onApplied = vi.fn>(); + const onCancellationCompensated = + vi.fn>(); const persistRoute = vi.fn>( async () => {}, ); + const relinkCodexThreadForProviderSwitch = vi.fn< + NonNullable + >(async () => null); const service = new PendingCredentialSwitchService({ maker: { listActiveSessions: () => sessions, @@ -48,11 +66,25 @@ function createHarness( }, broadcastApplied, onApplied, + onCancellationCompensated, persistRoute, + relinkCodexThreadForProviderSwitch, + ...(opts?.isOwnerScopeCurrent + ? { isOwnerScopeCurrent: opts.isOwnerScopeCurrent } + : {}), ...(opts?.resolveRoute ? { resolveRoute: opts.resolveRoute } : {}), ...(opts?.retryDelayMs !== undefined ? { retryDelayMs: opts.retryDelayMs } : {}), }); - return { service, closeSession, broadcastApplied, onApplied, persistRoute, sessions }; + return { + service, + closeSession, + broadcastApplied, + onApplied, + onCancellationCompensated, + persistRoute, + relinkCodexThreadForProviderSwitch, + sessions, + }; } describe('PendingCredentialSwitchService', () => { @@ -93,6 +125,1301 @@ describe('PendingCredentialSwitchService', () => { expect(h.onApplied).toHaveBeenCalledWith(sessionId); }); + it('atomically commits a deferred Codex thread with its final route before queue wake', async () => { + const sessionId = rememberSession('pending-switch-relink-codex-thread'); + setSessionProvider(sessionId, 'xd'); + let persistedProfile = { + sdkSessionId: 'thread-xd', + model: 'codex/gpt-5.6-sol', + providerId: 'xd' as string | null, + effort: 'high', + fastMode: false, + }; + const h = createHarness([ + { id: sessionId, agentKind: 'codex', remoteHostId: null, isTurnRunning: () => false }, + ]); + h.relinkCodexThreadForProviderSwitch.mockImplementationOnce(async (input) => { + // A forced exit before the relink CAS can only restart the complete source tuple. + expect(persistedProfile).toEqual({ + sdkSessionId: 'thread-xd', + ...input.persistedRouteTransition.previous, + }); + persistedProfile = { + sdkSessionId: 'thread-openai', + ...input.persistedRouteTransition.next, + }; + return { + previousSdkSessionId: 'thread-xd', + newSdkSessionId: 'thread-openai', + rollback: vi.fn(async () => true), + }; + }); + + h.service.register(sessionId, { + model: 'gpt-5.6-sol', + providerId: 'openai', + effort: 'xhigh', + fastMode: true, + rebuildCodexThread: true, + previousRoute: { + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + effort: 'high', + fastMode: false, + }, + }); + // A crash before settlement cannot have persisted the target route. The production + // registration bridge has already restored SQLite to previousRoute at this point. + expect(h.persistRoute).not.toHaveBeenCalled(); + await h.service.onTurnSettled(sessionId); + + expect(h.closeSession).toHaveBeenCalledWith(sessionId); + expect(h.relinkCodexThreadForProviderSwitch).toHaveBeenCalledWith({ + sessionId, + sourceModel: 'codex/gpt-5.6-sol', + sourceProviderId: 'xd', + targetModel: 'gpt-5.6-sol', + targetProviderId: 'openai', + isCurrent: expect.any(Function), + persistedRouteTransition: { + previous: { + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + effort: 'high', + fastMode: false, + }, + next: { + model: 'gpt-5.6-sol', + providerId: 'openai', + effort: 'xhigh', + fastMode: true, + }, + }, + }); + expect(h.relinkCodexThreadForProviderSwitch.mock.invocationCallOrder[0]).toBeLessThan( + h.onApplied.mock.invocationCallOrder[0]!, + ); + expect(h.persistRoute).not.toHaveBeenCalled(); + expect(persistedProfile).toEqual({ + sdkSessionId: 'thread-openai', + model: 'gpt-5.6-sol', + providerId: 'openai', + effort: 'xhigh', + fastMode: true, + }); + }); + + it('recomputes relink from the final route when revalidation crosses thread families', async () => { + const sessionId = rememberSession('pending-switch-reroute-crosses-thread-family'); + setSessionProvider(sessionId, 'xd'); + const h = createHarness( + [{ id: sessionId, agentKind: 'codex', remoteHostId: null, isTurnRunning: () => false }], + { + resolveRoute: async () => ({ + model: 'gpt-5.6-sol', + providerId: 'openai', + degraded: true, + }), + }, + ); + h.relinkCodexThreadForProviderSwitch.mockResolvedValueOnce({ + previousSdkSessionId: 'thread-xd', + newSdkSessionId: 'thread-openai', + rollback: vi.fn(async () => true), + }); + + h.service.register(sessionId, { + model: 'xai/grok-4.3', + providerId: 'xai', + agentKind: 'codex', + sourceCodexThreadModelProviderId: 'cindy_gateway', + previousRoute: { + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + effort: 'high', + fastMode: false, + }, + }); + await h.service.onTurnSettled(sessionId); + + expect(h.relinkCodexThreadForProviderSwitch).toHaveBeenCalledWith({ + sessionId, + sourceModel: 'codex/gpt-5.6-sol', + sourceProviderId: 'xd', + sourceThreadModelProviderId: 'cindy_gateway', + targetModel: 'gpt-5.6-sol', + targetProviderId: 'openai', + isCurrent: expect.any(Function), + persistedRouteTransition: { + previous: { + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + effort: 'high', + fastMode: false, + }, + next: { + model: 'gpt-5.6-sol', + providerId: 'openai', + effort: 'high', + fastMode: false, + }, + }, + }); + expect(h.persistRoute).not.toHaveBeenCalled(); + }); + + it('skips a stale relink marker when revalidation returns to the source thread family', async () => { + const sessionId = rememberSession('pending-switch-reroute-returns-to-source-family'); + setSessionProvider(sessionId, 'xd'); + const h = createHarness( + [{ id: sessionId, agentKind: 'codex', remoteHostId: null, isTurnRunning: () => false }], + { + resolveRoute: async () => ({ + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + degraded: true, + }), + }, + ); + + h.service.register(sessionId, { + model: 'gpt-5.6-sol', + providerId: 'openai', + rebuildCodexThread: true, + agentKind: 'codex', + sourceCodexThreadModelProviderId: 'cindy_gateway', + previousRoute: { model: 'codex/gpt-5.6-sol', providerId: 'xd' }, + }); + await h.service.onTurnSettled(sessionId); + + expect(h.relinkCodexThreadForProviderSwitch).not.toHaveBeenCalled(); + expect(h.persistRoute).toHaveBeenCalledWith(sessionId, { + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + }); + }); + + it('keeps a deferred switch gated when the Codex thread relink fails', async () => { + const sessionId = rememberSession('pending-switch-relink-failed'); + setSessionProvider(sessionId, 'xd'); + const h = createHarness([ + { id: sessionId, agentKind: 'codex', remoteHostId: null, isTurnRunning: () => false }, + ]); + h.relinkCodexThreadForProviderSwitch.mockRejectedValueOnce(new Error('fork failed')); + + h.service.register(sessionId, { + model: 'gpt-5.6-sol', + providerId: 'openai', + rebuildCodexThread: true, + }); + await h.service.onTurnSettled(sessionId); + + expect(h.service.has(sessionId)).toBe(true); + expect(getSessionProvider(sessionId)).toBe('xd'); + expect(h.persistRoute).not.toHaveBeenCalled(); + expect(h.onApplied).not.toHaveBeenCalled(); + h.service.clear(sessionId); + }); + + it('keeps an old-owner relink gated until post-commit rollback compensation succeeds', async () => { + const sessionId = rememberSession('pending-switch-owner-teardown'); + setSessionProvider(sessionId, 'xd'); + let currentOwner = { ownerScopeKey: 'owner-a:1', runtimeOwnerEpoch: '7' }; + let persistedProfile = { + sdkSessionId: 'thread-xd', + model: 'codex/gpt-5.6-sol', + providerId: 'xd' as string | null, + effort: 'high', + fastMode: false, + }; + const restoreStaleOwnerRoute = vi + .fn() + .mockRejectedValueOnce(new Error('old profile temporarily unavailable')) + .mockImplementationOnce(async (_route, expectedSdkSessionId) => { + if ( + expectedSdkSessionId !== 'thread-openai' || + persistedProfile.sdkSessionId !== 'thread-openai' + ) { + return false; + } + persistedProfile.sdkSessionId = 'thread-xd'; + return true; + }); + const h = createHarness( + [{ id: sessionId, agentKind: 'codex', remoteHostId: null, isTurnRunning: () => false }], + { + retryDelayMs: 50, + isOwnerScopeCurrent: (scope) => + scope.ownerScopeKey === currentOwner.ownerScopeKey && + scope.runtimeOwnerEpoch === currentOwner.runtimeOwnerEpoch, + }, + ); + const rollback = vi.fn(async () => false); + h.relinkCodexThreadForProviderSwitch.mockImplementationOnce(async (input) => { + persistedProfile.sdkSessionId = 'thread-openai'; + currentOwner = { ownerScopeKey: 'owner-boundary', runtimeOwnerEpoch: '7' }; + expect(input.isCurrent()).toBe(false); + throw new CodexProviderThreadRelinkCompensationRequiredError({ + previousSdkSessionId: 'thread-xd', + newSdkSessionId: 'thread-openai', + rollback, + }); + }); + + h.service.register(sessionId, { + model: 'gpt-5.6-sol', + providerId: 'openai', + rebuildCodexThread: true, + ownerScope: { ownerScopeKey: 'owner-a:1', runtimeOwnerEpoch: '7' }, + previousRoute: { + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + effort: 'high', + fastMode: false, + }, + restoreStaleOwnerRoute, + }); + await h.service.onTurnSettled(sessionId); + + await vi.waitFor(() => expect(restoreStaleOwnerRoute).toHaveBeenCalledOnce()); + expect(h.service.has(sessionId)).toBe(true); + expect(h.onCancellationCompensated).not.toHaveBeenCalled(); + await vi.waitFor(() => expect(restoreStaleOwnerRoute).toHaveBeenCalledTimes(2)); + await vi.waitFor(() => expect(h.service.has(sessionId)).toBe(false)); + expect(persistedProfile.sdkSessionId).toBe('thread-xd'); + expect(h.relinkCodexThreadForProviderSwitch).toHaveBeenCalledOnce(); + expect(h.persistRoute).not.toHaveBeenCalled(); + expect(h.onApplied).not.toHaveBeenCalled(); + expect(h.onCancellationCompensated).toHaveBeenCalledOnce(); + expect(rollback).not.toHaveBeenCalled(); + }); + + it('keeps a same-owner cancellation gated while post-commit compensation retries', async () => { + const sessionId = rememberSession('pending-switch-same-owner-post-commit-compensation'); + setSessionProvider(sessionId, 'xd'); + let persistedSdkSessionId = 'thread-xd'; + const restoreStaleOwnerRoute = vi + .fn() + .mockRejectedValueOnce(new Error('sqlite temporarily unavailable')) + .mockImplementationOnce(async (_route, expectedSdkSessionId) => { + if (expectedSdkSessionId !== 'thread-openai') return false; + persistedSdkSessionId = 'thread-xd'; + return true; + }); + const receipt = { + previousSdkSessionId: 'thread-xd', + newSdkSessionId: 'thread-openai', + rollback: vi.fn(async () => false), + }; + const h = createHarness( + [{ id: sessionId, agentKind: 'codex', remoteHostId: null, isTurnRunning: () => false }], + { retryDelayMs: 50 }, + ); + h.relinkCodexThreadForProviderSwitch.mockImplementationOnce(async () => { + persistedSdkSessionId = 'thread-openai'; + h.service.clear(sessionId); + throw new CodexProviderThreadRelinkCompensationRequiredError(receipt); + }); + + await h.service.register(sessionId, { + model: 'gpt-5.6-sol', + providerId: 'openai', + rebuildCodexThread: true, + previousRoute: { + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + effort: 'high', + fastMode: false, + }, + restoreStaleOwnerRoute, + }); + await h.service.onTurnSettled(sessionId); + + expect(h.service.has(sessionId)).toBe(true); + expect(h.onCancellationCompensated).not.toHaveBeenCalled(); + expect(persistedSdkSessionId).toBe('thread-openai'); + await vi.waitFor(() => expect(restoreStaleOwnerRoute).toHaveBeenCalledTimes(2)); + await vi.waitFor(() => expect(h.service.has(sessionId)).toBe(false)); + expect(persistedSdkSessionId).toBe('thread-xd'); + expect(h.onCancellationCompensated).toHaveBeenCalledOnce(); + }); + + it('restores the captured old Profile before completing an owner-stale discard', async () => { + const sessionId = rememberSession('pending-switch-owner-route-restore'); + let currentOwner = { ownerScopeKey: 'owner-a:1', runtimeOwnerEpoch: '7' }; + let persistedProfile = { + sdkSessionId: 'thread-xd', + model: 'gpt-5.6-sol', + providerId: 'openai', + effort: 'xhigh', + fastMode: true, + }; + const restoreStaleOwnerRoute = vi.fn(async () => { + if ( + persistedProfile.sdkSessionId !== 'thread-xd' || + persistedProfile.model !== 'gpt-5.6-sol' || + persistedProfile.providerId !== 'openai' + ) { + return false; + } + persistedProfile = { + sdkSessionId: 'thread-xd', + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + effort: 'high', + fastMode: false, + }; + return true; + }); + const h = createHarness([], { + retryDelayMs: 10, + isOwnerScopeCurrent: (scope) => + scope.ownerScopeKey === currentOwner.ownerScopeKey && + scope.runtimeOwnerEpoch === currentOwner.runtimeOwnerEpoch, + }); + + h.service.register(sessionId, { + model: 'gpt-5.6-sol', + providerId: 'openai', + ownerScope: { ownerScopeKey: 'owner-a:1', runtimeOwnerEpoch: '7' }, + previousRoute: { + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + effort: 'high', + fastMode: false, + }, + restoreStaleOwnerRoute, + }); + + currentOwner = { ownerScopeKey: 'owner-boundary', runtimeOwnerEpoch: '7' }; + expect(h.service.has(sessionId)).toBe(true); + expect(h.service.get(sessionId)).toBeUndefined(); + await vi.waitFor(() => expect(restoreStaleOwnerRoute).toHaveBeenCalledOnce()); + await vi.waitFor(() => expect(h.service.has(sessionId)).toBe(false)); + + currentOwner = { ownerScopeKey: 'owner-a:2', runtimeOwnerEpoch: '8' }; + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(persistedProfile).toEqual({ + sdkSessionId: 'thread-xd', + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + effort: 'high', + fastMode: false, + }); + expect(h.service.get(sessionId)).toBeUndefined(); + expect(h.persistRoute).not.toHaveBeenCalled(); + }); + + it('compensates a failed-fork target rejected across an owner boundary before returning', async () => { + const sessionId = rememberSession('pending-switch-registration-owner-stale'); + let persistedProfile = { + sdkSessionId: 'thread-xd', + model: 'gpt-5.6-sol', + providerId: 'openai', + effort: 'xhigh', + fastMode: true, + }; + const restoreStaleOwnerRoute = vi.fn(async () => { + persistedProfile = { + sdkSessionId: 'thread-xd', + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + effort: 'high', + fastMode: false, + }; + return true; + }); + const h = createHarness([], { + isOwnerScopeCurrent: () => false, + }); + + const registered = await h.service.register(sessionId, { + model: 'gpt-5.6-sol', + providerId: 'openai', + rebuildCodexThread: true, + codexThreadRelinkCommitted: true, + ownerScope: { ownerScopeKey: 'owner-a:1', runtimeOwnerEpoch: '7' }, + previousRoute: { + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + effort: 'high', + fastMode: false, + }, + restoreStaleOwnerRoute, + }); + + expect(registered).toBe(false); + expect(restoreStaleOwnerRoute).toHaveBeenCalledOnce(); + expect(persistedProfile).toEqual({ + sdkSessionId: 'thread-xd', + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + effort: 'high', + fastMode: false, + }); + expect(h.service.get(sessionId)).toBeUndefined(); + expect(h.persistRoute).not.toHaveBeenCalled(); + }); + + it('does not let a stale restored target erase the new owner pending for the same session id', () => { + const sessionId = rememberSession('pending-switch-owner-reused-session-id'); + const currentOwner = { ownerScopeKey: 'owner-b:2', runtimeOwnerEpoch: '8' }; + const h = createHarness([], { + retryDelayMs: 60_000, + isOwnerScopeCurrent: (scope) => + scope.ownerScopeKey === currentOwner.ownerScopeKey && + scope.runtimeOwnerEpoch === currentOwner.runtimeOwnerEpoch, + }); + + h.service.register(sessionId, { + model: 'gpt-5.6-sol', + providerId: 'openai', + ownerScope: currentOwner, + }); + h.service.register(sessionId, { + model: 'deepseek/deepseek-v4-pro', + providerId: 'deepseek', + ownerScope: { ownerScopeKey: 'owner-a:1', runtimeOwnerEpoch: '7' }, + }); + + expect(h.service.get(sessionId)).toMatchObject({ + model: 'gpt-5.6-sol', + providerId: 'openai', + ownerScope: currentOwner, + }); + h.service.clear(sessionId); + }); + + it('releases only the stale target barrier when its compensation CAS misses a newer tuple', async () => { + const sessionId = rememberSession('pending-switch-stale-compensation-newer-winner'); + let currentOwner = { ownerScopeKey: 'owner-a:1', runtimeOwnerEpoch: '7' }; + const h = createHarness([], { + isOwnerScopeCurrent: (scope) => + scope.ownerScopeKey === currentOwner.ownerScopeKey && + scope.runtimeOwnerEpoch === currentOwner.runtimeOwnerEpoch, + }); + let releaseRestore!: () => void; + const restoreGate = new Promise((resolve) => { + releaseRestore = resolve; + }); + const restoreStaleOwnerRoute = vi.fn(async () => { + await restoreGate; + return false; + }); + + await h.service.register(sessionId, { + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + ownerScope: currentOwner, + restoreStaleOwnerRoute, + }); + currentOwner = { ownerScopeKey: 'owner-b:2', runtimeOwnerEpoch: '8' }; + expect(h.service.has(sessionId)).toBe(true); + await vi.waitFor(() => expect(restoreStaleOwnerRoute).toHaveBeenCalledOnce()); + await h.service.register(sessionId, { + model: 'gpt-5.6-sol', + providerId: 'openai', + ownerScope: currentOwner, + }); + + releaseRestore(); + await vi.waitFor(() => expect(h.onCancellationCompensated).toHaveBeenCalledOnce()); + expect(h.service.get(sessionId)).toMatchObject({ + model: 'gpt-5.6-sol', + providerId: 'openai', + ownerScope: currentOwner, + }); + expect(h.service.has(sessionId)).toBe(true); + h.service.clear(sessionId); + expect(h.service.has(sessionId)).toBe(false); + }); + + it('keeps the pending gated while an atomic relink compensation receipt rolls back', async () => { + const sessionId = rememberSession('pending-switch-persist-rollback'); + setSessionProvider(sessionId, 'xd'); + const rollback = vi.fn(async () => true); + const h = createHarness( + [{ id: sessionId, agentKind: 'codex', remoteHostId: null, isTurnRunning: () => false }], + { + resolveRoute: async (_agent, model, providerId) => ({ + model, + providerId, + degraded: false, + }), + }, + ); + h.relinkCodexThreadForProviderSwitch.mockRejectedValueOnce( + new CodexProviderThreadRelinkCompensationRequiredError({ + previousSdkSessionId: 'thread-xd', + newSdkSessionId: 'thread-openai', + rollback, + }), + ); + + h.service.register(sessionId, { + model: 'gpt-5.6-sol', + providerId: 'openai', + rebuildCodexThread: true, + previousRoute: { + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + effort: 'high', + fastMode: false, + }, + }); + await h.service.onTurnSettled(sessionId); + + expect(rollback).toHaveBeenCalledOnce(); + expect(h.service.get(sessionId)?.rebuildCodexThread).toBe(true); + expect(h.persistRoute).not.toHaveBeenCalled(); + expect(h.onApplied).not.toHaveBeenCalled(); + h.service.clear(sessionId); + }); + + it('restores runtime axes before waking a cancellation during atomic relink rollback', async () => { + const sessionId = rememberSession('pending-switch-persist-failure-cancelled'); + setSessionProvider(sessionId, 'xd'); + setSessionEffort(sessionId, 'xhigh'); + setSessionFastMode(sessionId, true); + const rollback = vi.fn(async () => true); + const h = createHarness( + [{ id: sessionId, agentKind: 'codex', remoteHostId: null, isTurnRunning: () => false }], + { + resolveRoute: async (_agent, model, providerId) => ({ + model, + providerId, + degraded: false, + }), + }, + ); + h.relinkCodexThreadForProviderSwitch.mockImplementationOnce(async () => { + h.service.clear(sessionId); + throw new CodexProviderThreadRelinkCompensationRequiredError({ + previousSdkSessionId: 'thread-xd', + newSdkSessionId: 'thread-openai', + rollback, + }); + }); + + await h.service.register(sessionId, { + model: 'gpt-5.6-sol', + providerId: 'openai', + effort: 'xhigh', + fastMode: true, + rebuildCodexThread: true, + previousRoute: { + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + effort: 'high', + fastMode: false, + }, + }); + await h.service.onTurnSettled(sessionId); + + expect(rollback).toHaveBeenCalledOnce(); + expect(h.closeSession).toHaveBeenCalledWith(sessionId); + expect(getSessionProvider(sessionId)).toBe('xd'); + expect(getSessionEffort(sessionId)).toBe('high'); + expect(getSessionFastMode(sessionId)).toBe(false); + expect(h.service.has(sessionId)).toBe(false); + expect(h.onApplied).not.toHaveBeenCalled(); + expect(h.onCancellationCompensated).toHaveBeenCalledOnce(); + expect(h.broadcastApplied).not.toHaveBeenCalled(); + expect(h.persistRoute).not.toHaveBeenCalled(); + }); + + it('retries an atomic relink compensation receipt without reforking while rollback is failing', async () => { + const sessionId = rememberSession('pending-switch-retry-atomic-compensation'); + setSessionProvider(sessionId, 'xd'); + const rollback = vi + .fn() + .mockRejectedValueOnce(new Error('rollback failed')) + .mockResolvedValueOnce(true); + const h = createHarness( + [{ id: sessionId, agentKind: 'codex', remoteHostId: null, isTurnRunning: () => false }], + { + resolveRoute: async (_agent, model, providerId) => ({ + model, + providerId, + degraded: false, + }), + retryDelayMs: 5, + }, + ); + h.relinkCodexThreadForProviderSwitch.mockImplementationOnce(async () => { + h.service.clear(sessionId); + throw new CodexProviderThreadRelinkCompensationRequiredError({ + previousSdkSessionId: 'thread-xd', + newSdkSessionId: 'thread-openai', + rollback, + }); + }); + + h.service.register(sessionId, { + model: 'gpt-5.6-sol', + providerId: 'openai', + rebuildCodexThread: true, + agentKind: 'codex', + sourceCodexThreadModelProviderId: 'cindy_gateway', + previousRoute: { + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + effort: 'high', + fastMode: false, + }, + }); + await h.service.onTurnSettled(sessionId); + + expect(rollback).toHaveBeenCalledOnce(); + expect(h.service.has(sessionId)).toBe(true); + expect(h.relinkCodexThreadForProviderSwitch).toHaveBeenCalledOnce(); + await vi.waitFor(() => expect(rollback).toHaveBeenCalledTimes(2)); + expect(h.relinkCodexThreadForProviderSwitch).toHaveBeenCalledOnce(); + expect(h.persistRoute).not.toHaveBeenCalled(); + h.service.clear(sessionId); + }); + + it('atomically rolls back an abandoned relink when its generation is superseded', async () => { + const sessionId = rememberSession('pending-switch-superseded-after-persist'); + setSessionProvider(sessionId, 'xd'); + let persistedProfile = { + sdkSessionId: 'thread-openai', + model: 'codex/gpt-5.6-sol', + providerId: 'xd' as string | null, + effort: 'high', + fastMode: false, + }; + const rollback = vi.fn(async () => { + if (persistedProfile.sdkSessionId !== 'thread-openai') return false; + persistedProfile = { + sdkSessionId: 'thread-xd', + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + effort: 'high', + fastMode: false, + }; + return true; + }); + const restoreStaleOwnerRoute = vi.fn( + async ( + route?: { model: string; providerId: string | null; effort?: string; fastMode?: boolean }, + expectedSdkSessionId?: string, + ) => { + if ( + persistedProfile.sdkSessionId !== expectedSdkSessionId || + persistedProfile.model !== route?.model || + persistedProfile.providerId !== route?.providerId || + persistedProfile.effort !== route?.effort || + persistedProfile.fastMode !== route?.fastMode + ) { + return false; + } + persistedProfile = { + sdkSessionId: 'thread-xd', + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + effort: 'high', + fastMode: false, + }; + return true; + }, + ); + const h = createHarness( + [{ id: sessionId, agentKind: 'codex', remoteHostId: null, isTurnRunning: () => false }], + { + resolveRoute: async (_agent, model, providerId) => ({ + model, + providerId, + degraded: false, + }), + }, + ); + h.relinkCodexThreadForProviderSwitch.mockImplementationOnce(async (input) => { + persistedProfile = { + sdkSessionId: 'thread-openai', + ...input.persistedRouteTransition.next, + }; + h.service.clear(sessionId, { wake: false }); + await h.service.register(sessionId, { + model: 'gpt-5.6-sol', + providerId: 'xd', + }); + return { + previousSdkSessionId: 'thread-xd', + newSdkSessionId: 'thread-openai', + rollback, + }; + }); + + await h.service.register(sessionId, { + model: 'gpt-5.6-sol', + providerId: 'openai', + effort: 'xhigh', + fastMode: true, + rebuildCodexThread: true, + previousRoute: { + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + effort: 'high', + fastMode: false, + }, + restoreStaleOwnerRoute, + }); + await h.service.onTurnSettled(sessionId); + + expect(rollback).toHaveBeenCalledOnce(); + expect(restoreStaleOwnerRoute).not.toHaveBeenCalled(); + expect(persistedProfile).toEqual({ + sdkSessionId: 'thread-xd', + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + effort: 'high', + fastMode: false, + }); + expect(h.relinkCodexThreadForProviderSwitch).toHaveBeenCalledOnce(); + expect(h.persistRoute).not.toHaveBeenCalled(); + expect(h.service.get(sessionId)).toMatchObject({ + model: 'gpt-5.6-sol', + providerId: 'xd', + }); + expect(h.onApplied).not.toHaveBeenCalled(); + expect(h.onCancellationCompensated).not.toHaveBeenCalled(); + expect(h.broadcastApplied).not.toHaveBeenCalled(); + h.service.clear(sessionId); + }); + + it('keeps cancellation gated until a late atomic relink is compensated', async () => { + const sessionId = rememberSession('pending-switch-cancel-during-persist'); + setSessionProvider(sessionId, 'xd'); + setSessionEffort(sessionId, 'xhigh'); + setSessionFastMode(sessionId, true); + let persistedProfile = { + sdkSessionId: 'thread-xd', + model: 'codex/gpt-5.6-sol', + providerId: 'xd' as string | null, + effort: 'high', + fastMode: false, + }; + let markCommitStarted!: () => void; + const commitStarted = new Promise((resolve) => { + markCommitStarted = resolve; + }); + let releaseCommit!: () => void; + const commitGate = new Promise((resolve) => { + releaseCommit = resolve; + }); + const restoreStaleOwnerRoute = vi.fn( + async ( + route?: { model: string; providerId: string | null; effort?: string; fastMode?: boolean }, + expectedSdkSessionId?: string, + ) => { + if ( + persistedProfile.sdkSessionId !== expectedSdkSessionId || + persistedProfile.model !== route?.model || + persistedProfile.providerId !== route?.providerId || + persistedProfile.effort !== route?.effort || + persistedProfile.fastMode !== route?.fastMode + ) { + return false; + } + persistedProfile = { + sdkSessionId: 'thread-xd', + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + effort: 'high', + fastMode: false, + }; + return true; + }, + ); + const h = createHarness( + [{ id: sessionId, agentKind: 'codex', remoteHostId: null, isTurnRunning: () => false }], + { retryDelayMs: 60_000 }, + ); + h.relinkCodexThreadForProviderSwitch.mockImplementationOnce(async (input) => { + persistedProfile = { + sdkSessionId: 'thread-openai', + ...input.persistedRouteTransition.next, + }; + markCommitStarted(); + await commitGate; + return { + previousSdkSessionId: 'thread-xd', + newSdkSessionId: 'thread-openai', + rollback: vi.fn(async () => false), + }; + }); + + await h.service.register(sessionId, { + model: 'gpt-5.6-sol', + providerId: 'openai', + effort: 'xhigh', + fastMode: true, + rebuildCodexThread: true, + previousRoute: { + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + effort: 'high', + fastMode: false, + }, + restoreStaleOwnerRoute, + }); + const settling = h.service.onTurnSettled(sessionId); + await commitStarted; + + h.service.clear(sessionId); + expect(h.service.has(sessionId)).toBe(true); + expect(h.onApplied).not.toHaveBeenCalled(); + releaseCommit(); + await settling; + + expect(restoreStaleOwnerRoute).toHaveBeenCalledWith( + { + model: 'gpt-5.6-sol', + providerId: 'openai', + effort: 'xhigh', + fastMode: true, + }, + 'thread-openai', + ); + expect(persistedProfile).toEqual({ + sdkSessionId: 'thread-xd', + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + effort: 'high', + fastMode: false, + }); + expect(getSessionProvider(sessionId)).toBe('xd'); + expect(getSessionEffort(sessionId)).toBe('high'); + expect(getSessionFastMode(sessionId)).toBe(false); + expect(h.service.has(sessionId)).toBe(false); + expect(h.onApplied).not.toHaveBeenCalled(); + expect(h.onCancellationCompensated).toHaveBeenCalledOnce(); + expect(h.relinkCodexThreadForProviderSwitch).toHaveBeenCalledOnce(); + expect(h.persistRoute).not.toHaveBeenCalled(); + expect(h.broadcastApplied).not.toHaveBeenCalled(); + }); + + it('keeps cancellation gated and retries when atomic relink compensation fails', async () => { + const sessionId = rememberSession('pending-switch-cancel-compensation-retry'); + setSessionProvider(sessionId, 'openai'); + const restoreStaleOwnerRoute = vi + .fn() + .mockRejectedValueOnce(new Error('sqlite temporarily unavailable')) + .mockResolvedValueOnce(true); + const h = createHarness( + [{ id: sessionId, agentKind: 'codex', remoteHostId: null, isTurnRunning: () => false }], + { retryDelayMs: 5 }, + ); + h.relinkCodexThreadForProviderSwitch.mockImplementationOnce(async () => { + h.service.clear(sessionId); + return { + previousSdkSessionId: 'thread-xd', + newSdkSessionId: 'thread-openai', + rollback: vi.fn(async () => false), + }; + }); + + await h.service.register(sessionId, { + model: 'gpt-5.6-sol', + providerId: 'openai', + effort: 'xhigh', + fastMode: true, + rebuildCodexThread: true, + previousRoute: { + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + effort: 'high', + fastMode: false, + }, + restoreStaleOwnerRoute, + }); + await h.service.onTurnSettled(sessionId); + + expect(h.service.has(sessionId)).toBe(true); + expect(h.onCancellationCompensated).not.toHaveBeenCalled(); + await vi.waitFor(() => expect(restoreStaleOwnerRoute).toHaveBeenCalledTimes(2)); + await vi.waitFor(() => expect(h.service.has(sessionId)).toBe(false)); + expect(h.onCancellationCompensated).toHaveBeenCalledOnce(); + expect(getSessionProvider(sessionId)).toBe('xd'); + expect(h.persistRoute).not.toHaveBeenCalled(); + }); + + it.each(['older-first', 'newer-first'] as const)( + 'isolates overlapping compensation retries by target generation (%s)', + async (completionOrder) => { + const sessionId = rememberSession(`pending-switch-compensation-${completionOrder}`); + setSessionProvider(sessionId, 'xd'); + let persistedProfile = { + sdkSessionId: 'thread-xd', + model: 'codex/gpt-5.6-sol', + providerId: 'xd' as string | null, + effort: 'high', + fastMode: false, + }; + let releaseOlderRetry!: () => void; + const olderRetryGate = new Promise((resolve) => { + releaseOlderRetry = resolve; + }); + let markOlderRetryDone!: () => void; + const olderRetryDone = new Promise((resolve) => { + markOlderRetryDone = resolve; + }); + let releaseNewerRetry!: () => void; + const newerRetryGate = new Promise((resolve) => { + releaseNewerRetry = resolve; + }); + let markNewerRetryDone!: () => void; + const newerRetryDone = new Promise((resolve) => { + markNewerRetryDone = resolve; + }); + const restoreOlderRoute = vi + .fn() + .mockRejectedValueOnce(new Error('older compensation temporarily failed')) + .mockImplementationOnce(async () => { + await olderRetryGate; + markOlderRetryDone(); + // The newer target owns SQLite now; the old CAS correctly proves superseded. + return false; + }); + const restoreNewerRoute = vi + .fn() + .mockRejectedValueOnce(new Error('newer compensation temporarily failed')) + .mockImplementationOnce(async (_route, expectedSdkSessionId) => { + await newerRetryGate; + if ( + expectedSdkSessionId !== 'thread-newer' || + persistedProfile.sdkSessionId !== 'thread-newer' + ) { + return false; + } + persistedProfile = { + sdkSessionId: 'thread-xd', + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + effort: 'high', + fastMode: false, + }; + markNewerRetryDone(); + return true; + }); + const h = createHarness( + [{ id: sessionId, agentKind: 'codex', remoteHostId: null, isTurnRunning: () => false }], + { retryDelayMs: 5 }, + ); + h.relinkCodexThreadForProviderSwitch + .mockImplementationOnce(async (input) => { + persistedProfile = { + sdkSessionId: 'thread-older', + ...input.persistedRouteTransition.next, + }; + h.service.clear(sessionId); + return { + previousSdkSessionId: 'thread-xd', + newSdkSessionId: 'thread-older', + rollback: vi.fn(async () => false), + }; + }) + .mockImplementationOnce(async (input) => { + persistedProfile = { + sdkSessionId: 'thread-newer', + ...input.persistedRouteTransition.next, + }; + h.service.clear(sessionId); + return { + previousSdkSessionId: 'thread-older', + newSdkSessionId: 'thread-newer', + rollback: vi.fn(async () => false), + }; + }); + + await h.service.register(sessionId, { + model: 'gpt-5.6-sol', + providerId: 'openai', + effort: 'xhigh', + fastMode: true, + rebuildCodexThread: true, + previousRoute: { + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + effort: 'high', + fastMode: false, + }, + restoreStaleOwnerRoute: restoreOlderRoute, + }); + await h.service.onTurnSettled(sessionId); + await h.service.register(sessionId, { + model: 'deepseek/deepseek-v4-pro', + providerId: 'deepseek', + effort: 'max', + fastMode: true, + rebuildCodexThread: true, + previousRoute: { + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + effort: 'high', + fastMode: false, + }, + restoreStaleOwnerRoute: restoreNewerRoute, + }); + await h.service.onTurnSettled(sessionId); + + await vi.waitFor(() => expect(restoreOlderRoute).toHaveBeenCalledTimes(2)); + await vi.waitFor(() => expect(restoreNewerRoute).toHaveBeenCalledTimes(2)); + if (completionOrder === 'older-first') { + releaseOlderRetry(); + await olderRetryDone; + } else { + releaseNewerRetry(); + await newerRetryDone; + } + await Promise.resolve(); + await Promise.resolve(); + expect(h.service.has(sessionId)).toBe(true); + expect(h.onCancellationCompensated).not.toHaveBeenCalled(); + + if (completionOrder === 'older-first') { + releaseNewerRetry(); + await newerRetryDone; + } else { + releaseOlderRetry(); + await olderRetryDone; + } + await vi.waitFor(() => expect(h.service.has(sessionId)).toBe(false)); + expect(persistedProfile).toEqual({ + sdkSessionId: 'thread-xd', + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + effort: 'high', + fastMode: false, + }); + expect(h.onCancellationCompensated).toHaveBeenCalledOnce(); + expect(h.relinkCodexThreadForProviderSwitch).toHaveBeenCalledTimes(2); + expect(h.persistRoute).not.toHaveBeenCalled(); + expect(h.onApplied).not.toHaveBeenCalled(); + }, + ); + + it('compensates the actual fallback route when the owner changes during atomic relink', async () => { + const sessionId = rememberSession('pending-switch-owner-stale-during-fallback-persist'); + setSessionProvider(sessionId, 'xd'); + let ownerCurrent = true; + const rollback = vi.fn(async () => false); + const restoreStaleOwnerRoute = vi.fn(async () => true); + const h = createHarness( + [{ id: sessionId, agentKind: 'codex', remoteHostId: null, isTurnRunning: () => false }], + { + isOwnerScopeCurrent: () => ownerCurrent, + resolveRoute: async () => ({ + model: 'gpt-5.6-sol', + providerId: 'openai', + degraded: true, + effort: 'xhigh', + fastMode: true, + }), + }, + ); + h.relinkCodexThreadForProviderSwitch.mockImplementationOnce(async () => { + ownerCurrent = false; + return { + previousSdkSessionId: 'thread-xd', + newSdkSessionId: 'thread-openai', + rollback, + }; + }); + + h.service.register(sessionId, { + model: 'xai/grok-4.3', + providerId: 'xai', + agentKind: 'codex', + ownerScope: { ownerScopeKey: 'owner-a', runtimeOwnerEpoch: '7' }, + sourceCodexThreadModelProviderId: 'cindy_gateway', + previousRoute: { + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + effort: 'high', + fastMode: false, + }, + restoreStaleOwnerRoute, + }); + await h.service.onTurnSettled(sessionId); + await vi.waitFor(() => expect(restoreStaleOwnerRoute).toHaveBeenCalledOnce()); + + const persistedFallback = { + model: 'gpt-5.6-sol', + providerId: 'openai', + effort: 'xhigh', + fastMode: true, + }; + expect(h.persistRoute).not.toHaveBeenCalled(); + expect(rollback).toHaveBeenCalledOnce(); + expect(restoreStaleOwnerRoute).toHaveBeenCalledWith( + persistedFallback, + 'thread-openai', + ); + expect(h.service.get(sessionId)).toBeUndefined(); + expect(h.onApplied).not.toHaveBeenCalled(); + }); + + it('keeps an owner-stale atomic relink gated until Profile compensation retry succeeds', async () => { + const sessionId = rememberSession('pending-switch-owner-stale-compensation-retry'); + setSessionProvider(sessionId, 'xd'); + let ownerCurrent = true; + let persistedProfile = { + sdkSessionId: 'thread-xd', + model: 'codex/gpt-5.6-sol', + providerId: 'xd' as string | null, + effort: 'high', + fastMode: false, + }; + const rollback = vi.fn(async () => false); + const restoreStaleOwnerRoute = vi + .fn() + .mockRejectedValueOnce(new Error('sqlite temporarily unavailable')) + .mockRejectedValueOnce(new Error('sqlite still temporarily unavailable')) + .mockImplementationOnce(async (route, expectedSdkSessionId) => { + if ( + persistedProfile.sdkSessionId !== expectedSdkSessionId || + persistedProfile.model !== route?.model || + persistedProfile.providerId !== route.providerId || + persistedProfile.effort !== route.effort || + persistedProfile.fastMode !== route.fastMode + ) { + return false; + } + persistedProfile = { + sdkSessionId: 'thread-xd', + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + effort: 'high', + fastMode: false, + }; + return true; + }); + const h = createHarness( + [{ id: sessionId, agentKind: 'codex', remoteHostId: null, isTurnRunning: () => false }], + { + retryDelayMs: 50, + isOwnerScopeCurrent: () => ownerCurrent, + }, + ); + h.relinkCodexThreadForProviderSwitch.mockImplementationOnce(async (input) => { + persistedProfile = { + sdkSessionId: 'thread-openai', + ...input.persistedRouteTransition.next, + }; + ownerCurrent = false; + // A concurrent queue probe can begin stale cleanup before the relink returns. The + // finalizer must upgrade that in-flight cleanup with the committed tuple + replacement id. + expect(h.service.has(sessionId)).toBe(true); + return { + previousSdkSessionId: 'thread-xd', + newSdkSessionId: 'thread-openai', + rollback, + }; + }); + + await h.service.register(sessionId, { + model: 'gpt-5.6-sol', + providerId: 'openai', + effort: 'xhigh', + fastMode: true, + rebuildCodexThread: true, + ownerScope: { ownerScopeKey: 'owner-a', runtimeOwnerEpoch: '7' }, + previousRoute: { + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + effort: 'high', + fastMode: false, + }, + restoreStaleOwnerRoute, + }); + await h.service.onTurnSettled(sessionId); + + expect(rollback).toHaveBeenCalledOnce(); + expect(restoreStaleOwnerRoute).toHaveBeenCalledTimes(2); + expect(persistedProfile).toEqual({ + sdkSessionId: 'thread-openai', + model: 'gpt-5.6-sol', + providerId: 'openai', + effort: 'xhigh', + fastMode: true, + }); + expect(h.service.has(sessionId)).toBe(true); + expect(h.service.get(sessionId)).toBeUndefined(); + expect(h.onCancellationCompensated).not.toHaveBeenCalled(); + expect(h.onApplied).not.toHaveBeenCalled(); + expect(h.broadcastApplied).not.toHaveBeenCalled(); + + expect(h.service.has(sessionId)).toBe(true); + expect(persistedProfile.sdkSessionId).toBe('thread-openai'); + await vi.waitFor(() => expect(restoreStaleOwnerRoute).toHaveBeenCalledTimes(3)); + await vi.waitFor(() => expect(h.service.has(sessionId)).toBe(false)); + expect(restoreStaleOwnerRoute).toHaveBeenNthCalledWith(1, undefined, undefined); + expect(restoreStaleOwnerRoute).toHaveBeenNthCalledWith( + 2, + { + model: 'gpt-5.6-sol', + providerId: 'openai', + effort: 'xhigh', + fastMode: true, + }, + 'thread-openai', + ); + expect(restoreStaleOwnerRoute).toHaveBeenNthCalledWith( + 3, + { + model: 'gpt-5.6-sol', + providerId: 'openai', + effort: 'xhigh', + fastMode: true, + }, + 'thread-openai', + ); + expect(persistedProfile).toEqual({ + sdkSessionId: 'thread-xd', + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + effort: 'high', + fastMode: false, + }); + expect(h.onCancellationCompensated).toHaveBeenCalledOnce(); + expect(h.relinkCodexThreadForProviderSwitch).toHaveBeenCalledOnce(); + expect(h.persistRoute).not.toHaveBeenCalled(); + expect(h.onApplied).not.toHaveBeenCalled(); + expect(h.broadcastApplied).not.toHaveBeenCalled(); + }); + + it('does not commit an abandoned relink generation after the user cancels it', async () => { + const sessionId = rememberSession('pending-switch-cancel-during-relink'); + setSessionProvider(sessionId, 'xd'); + const h = createHarness([ + { id: sessionId, agentKind: 'codex', remoteHostId: null, isTurnRunning: () => false }, + ]); + h.relinkCodexThreadForProviderSwitch.mockImplementationOnce(async (input) => { + h.service.clear(sessionId); + expect(input.isCurrent()).toBe(false); + throw new Error('superseded'); + }); + + h.service.register(sessionId, { + model: 'gpt-5.6-sol', + providerId: 'openai', + rebuildCodexThread: true, + previousRoute: { + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + effort: 'high', + fastMode: false, + }, + }); + await h.service.onTurnSettled(sessionId); + + expect(h.service.has(sessionId)).toBe(false); + expect(getSessionProvider(sessionId)).toBe('xd'); + expect(h.onApplied).not.toHaveBeenCalled(); + }); + it('re-registration overwrites the previous pending target (last click wins)', async () => { const sessionId = rememberSession('pending-switch-overwrite'); setSessionProvider(sessionId, 'openai'); @@ -126,21 +1453,30 @@ describe('PendingCredentialSwitchService', () => { expect(h.onApplied).toHaveBeenCalledWith(sessionId); }); - it('still applies the route when closing the session fails hard', async () => { - // close 失败不能让用户的选择静默蒸发:route 照写,下一次发送由 getHost 仲裁兜底。 + it('keeps the queue gated and never relinks while the live-session close is unconfirmed', async () => { const sessionId = rememberSession('pending-switch-close-failed'); - setSessionProvider(sessionId, 'openai'); - const h = createHarness([ - { id: sessionId, agentKind: 'codex', remoteHostId: null, isTurnRunning: () => false }, - ]); + setSessionProvider(sessionId, 'xd'); + const h = createHarness( + [{ id: sessionId, agentKind: 'codex', remoteHostId: null, isTurnRunning: () => false }], + { retryDelayMs: 60_000 }, + ); h.closeSession.mockRejectedValueOnce(new Error('close blew up')); - h.service.register(sessionId, { model: 'gpt-5.5', providerId: 'xd' }); + h.service.register(sessionId, { + model: 'gpt-5.6-sol', + providerId: 'openai', + rebuildCodexThread: true, + previousRoute: { model: 'codex/gpt-5.6-sol', providerId: 'xd' }, + }); await h.service.onTurnSettled(sessionId); - expect(h.service.has(sessionId)).toBe(false); + expect(h.service.has(sessionId)).toBe(true); expect(getSessionProvider(sessionId)).toBe('xd'); - expect(h.broadcastApplied).toHaveBeenCalledTimes(1); + expect(h.relinkCodexThreadForProviderSwitch).not.toHaveBeenCalled(); + expect(h.persistRoute).not.toHaveBeenCalled(); + expect(h.onApplied).not.toHaveBeenCalled(); + expect(h.broadcastApplied).not.toHaveBeenCalled(); + h.service.clear(sessionId); }); it('applies the latest registration when the user re-selects during the async close (last click wins)', async () => { diff --git a/apps/desktop/src/main/maker-ipc/__tests__/runtimeSetModel.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/runtimeSetModel.test.ts index 98c1b8a87e..cc536fc62e 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/runtimeSetModel.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/runtimeSetModel.test.ts @@ -235,6 +235,7 @@ describe('applyRuntimeSetModelChange', () => { sessionId, model: 'codex/gpt-5.5', providerId: 'xd', + relinkCodexThreadForProviderSwitch: vi.fn(async () => null), }); expect(closeSession).toHaveBeenCalledWith(sessionId); @@ -307,6 +308,7 @@ describe('applyRuntimeSetModelChange', () => { sessionId, model: 'xai/grok-4.3', providerId: 'xai', + relinkCodexThreadForProviderSwitch: vi.fn(async () => null), }); expect(closeSession).toHaveBeenCalledWith(sessionId); @@ -373,13 +375,32 @@ describe('applyRuntimeSetModelChange', () => { sessionId, model: 'codex/gpt-5.5', providerId: 'xd', + persistedSession: { + agentKind: 'codex', + remoteHostId: null, + sdkSessionId: 'thread-openai', + model: 'gpt-5.4', + providerId: 'openai', + effort: 'high', + fastMode: false, + }, registerPendingCredentialSwitch, }); - expect(result).toEqual({ status: 'deferred' }); + expect(result).toEqual({ status: 'deferred', preservePersistedRoute: true }); expect(registerPendingCredentialSwitch).toHaveBeenCalledWith(sessionId, { model: 'codex/gpt-5.5', providerId: 'xd', + rebuildCodexThread: true, + sourcePersistedSession: { + agentKind: 'codex', + remoteHostId: null, + sdkSessionId: 'thread-openai', + model: 'gpt-5.4', + providerId: 'openai', + effort: 'high', + fastMode: false, + }, }); expect(closeSession).not.toHaveBeenCalled(); expect(setModel).not.toHaveBeenCalled(); @@ -394,6 +415,7 @@ describe('applyRuntimeSetModelChange', () => { const setModel = vi.fn(async () => {}); const closeSession = vi.fn(async () => {}); const registerPendingCredentialSwitch = vi.fn(); + const relinkCodexThreadForProviderSwitch = vi.fn(async () => null); const maker: RuntimeSetModelMaker = { getSession: () => ({ agentKind: 'codex', remoteHostId: null, codexProxyActive: true, @@ -413,15 +435,328 @@ describe('applyRuntimeSetModelChange', () => { providerId: 'xd', registerPendingCredentialSwitch, codexAuthInjection: 'oauth-bearer', + relinkCodexThreadForProviderSwitch, }); expect(result).toEqual({ status: 'applied' }); expect(registerPendingCredentialSwitch).not.toHaveBeenCalled(); expect(closeSession).toHaveBeenCalledWith(sessionId); + expect(relinkCodexThreadForProviderSwitch).toHaveBeenCalledWith({ + sessionId, + sourceModel: 'gpt-5.4', + sourceProviderId: 'openai', + targetModel: 'codex/gpt-5.5', + targetProviderId: 'xd', + }); expect(setModel).not.toHaveBeenCalled(); expect(getSessionProvider(sessionId)).toBe('xd'); }); + it('relinks an XD Codex thread before switching the same Cindy session to subscription', async () => { + const sessionId = rememberSession('runtime-set-model-xd-to-subscription-relink'); + setSessionProvider(sessionId, 'xd'); + const closeSession = vi.fn(async () => {}); + const relinkCodexThreadForProviderSwitch = vi.fn(async () => null); + const maker: RuntimeSetModelMaker = { + getSession: () => ({ + agentKind: 'codex', + remoteHostId: null, + codexProxyActive: true, + codexThreadModelProviderId: 'cindy_gateway', + model: 'codex/gpt-5.6-sol', + setModel: vi.fn(async () => {}), + }), + listActiveSessions: () => [{ + id: sessionId, + agentKind: 'codex', + remoteHostId: null, + isTurnRunning: () => false, + }], + closeSession, + }; + + await expect( + applyRuntimeSetModelChange({ + maker, + sessionId, + model: 'gpt-5.6-sol', + providerId: 'openai', + codexAuthInjection: 'oauth-bearer', + relinkCodexThreadForProviderSwitch, + }), + ).resolves.toEqual({ status: 'applied' }); + + expect(closeSession).toHaveBeenCalledWith(sessionId); + expect(relinkCodexThreadForProviderSwitch).toHaveBeenCalledWith({ + sessionId, + sourceModel: 'codex/gpt-5.6-sol', + sourceProviderId: 'xd', + sourceThreadModelProviderId: 'cindy_gateway', + targetModel: 'gpt-5.6-sol', + targetProviderId: 'openai', + }); + expect(getSessionProvider(sessionId)).toBe('openai'); + }); + + it('relinks a persisted Codex thread before a no-live lazy resume crosses provider families', async () => { + const sessionId = rememberSession('runtime-set-model-no-live-persisted-relink'); + // 模拟调用方/store 已看到目标来源;旧 thread 的来源必须仍以锁内 DB 快照为准。 + setSessionProvider(sessionId, 'openai'); + const rollback = vi.fn(async () => true); + const receipt = { + previousSdkSessionId: 'thread-xd', + newSdkSessionId: 'thread-openai', + rollback, + }; + const relinkCodexThreadForProviderSwitch = vi.fn(async () => receipt); + const closeSession = vi.fn(async () => {}); + const maker: RuntimeSetModelMaker = { + getSession: () => undefined, + listActiveSessions: () => [], + closeSession, + }; + + await expect( + applyRuntimeSetModelChange({ + maker, + sessionId, + model: 'gpt-5.6-sol', + providerId: 'openai', + persistedSession: { + agentKind: 'codex', + remoteHostId: null, + sdkSessionId: 'thread-xd', + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + }, + codexAuthInjection: 'oauth-bearer', + relinkCodexThreadForProviderSwitch, + }), + ).resolves.toEqual({ status: 'applied', codexThreadRelink: receipt }); + + expect(closeSession).not.toHaveBeenCalled(); + expect(relinkCodexThreadForProviderSwitch).toHaveBeenCalledWith({ + sessionId, + sourceModel: 'codex/gpt-5.6-sol', + sourceProviderId: 'xd', + targetModel: 'gpt-5.6-sol', + targetProviderId: 'openai', + }); + expect(getSessionProvider(sessionId)).toBe('openai'); + expect(rollback).not.toHaveBeenCalled(); + }); + + it('keeps the old route when a no-live persisted Codex thread cannot be relinked', async () => { + const sessionId = rememberSession('runtime-set-model-no-live-relink-failure'); + setSessionProvider(sessionId, 'xd'); + const maker: RuntimeSetModelMaker = { + getSession: () => undefined, + listActiveSessions: () => [], + closeSession: vi.fn(async () => {}), + }; + + await expect( + applyRuntimeSetModelChange({ + maker, + sessionId, + model: 'gpt-5.6-sol', + providerId: 'openai', + persistedSession: { + agentKind: 'codex', + remoteHostId: null, + sdkSessionId: 'thread-xd', + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + }, + codexAuthInjection: 'oauth-bearer', + relinkCodexThreadForProviderSwitch: vi.fn(async () => { + throw new Error('fork failed'); + }), + }), + ).rejects.toThrow('fork failed'); + + expect(getSessionProvider(sessionId)).toBe('xd'); + }); + + it('fails closed when a persisted no-live Codex thread disappears before relink', async () => { + const sessionId = rememberSession('runtime-set-model-no-live-relink-missing-source'); + setSessionProvider(sessionId, 'xd'); + const maker: RuntimeSetModelMaker = { + getSession: () => undefined, + listActiveSessions: () => [], + closeSession: vi.fn(async () => {}), + }; + + await expect( + applyRuntimeSetModelChange({ + maker, + sessionId, + model: 'gpt-5.6-sol', + providerId: 'openai', + persistedSession: { + agentKind: 'codex', + remoteHostId: null, + sdkSessionId: 'thread-xd', + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + }, + codexAuthInjection: 'oauth-bearer', + relinkCodexThreadForProviderSwitch: vi.fn(async () => null), + }), + ).rejects.toThrow(/disappeared before credential-family relink/); + + expect(getSessionProvider(sessionId)).toBe('xd'); + }); + + it('fails closed when a cross-provider switch has no thread relink dependency', async () => { + const sessionId = rememberSession('runtime-set-model-missing-thread-relink'); + setSessionProvider(sessionId, 'xd'); + const maker: RuntimeSetModelMaker = { + getSession: () => ({ + agentKind: 'codex', remoteHostId: null, codexProxyActive: true, + codexThreadModelProviderId: 'cindy_gateway', model: 'codex/gpt-5.6-sol', + setModel: vi.fn(async () => {}), + }), + listActiveSessions: () => [{ + id: sessionId, agentKind: 'codex', remoteHostId: null, + isTurnRunning: () => false, + }], + closeSession: vi.fn(async () => {}), + }; + + await expect(applyRuntimeSetModelChange({ + maker, + sessionId, + model: 'gpt-5.6-sol', + providerId: 'openai', + codexAuthInjection: 'oauth-bearer', + })).rejects.toThrow(/thread relink is required/); + expect(getSessionProvider(sessionId)).toBe('xd'); + }); + + it('restores a cleared pending switch with its Codex rebuild marker after relink fails', async () => { + const sessionId = rememberSession('runtime-set-model-restore-pending-rebuild-marker'); + setSessionProvider(sessionId, 'xd'); + let releaseRegistration!: () => void; + const registrationGate = new Promise((resolve) => { + releaseRegistration = resolve; + }); + let restorationComplete = false; + const registerPendingCredentialSwitch = vi.fn(async () => { + await registrationGate; + restorationComplete = true; + }); + const maker: RuntimeSetModelMaker = { + getSession: () => ({ + agentKind: 'codex', + remoteHostId: null, + codexProxyActive: true, + codexThreadModelProviderId: 'cindy_gateway', + model: 'codex/gpt-5.6-sol', + setModel: vi.fn(async () => {}), + }), + listActiveSessions: () => [{ + id: sessionId, + agentKind: 'codex', + remoteHostId: null, + isTurnRunning: () => false, + }], + closeSession: vi.fn(async () => {}), + }; + + let settled = false; + const result = applyRuntimeSetModelChange({ + maker, + sessionId, + model: 'gpt-5.6-sol', + providerId: 'openai', + getPendingCredentialSwitch: () => ({ + model: 'gpt-5.6-sol', + providerId: 'openai', + rebuildCodexThread: true, + codexThreadRelinkCommitted: true, + sourceCodexThreadModelProviderId: 'cindy_gateway', + previousRoute: { model: 'codex/gpt-5.6-sol', providerId: 'xd' }, + }), + clearPendingCredentialSwitch: vi.fn(), + registerPendingCredentialSwitch, + relinkCodexThreadForProviderSwitch: vi.fn(async () => { + throw new Error('fork failed'); + }), + }).finally(() => { + settled = true; + }); + + await vi.waitFor(() => expect(registerPendingCredentialSwitch).toHaveBeenCalledOnce()); + await Promise.resolve(); + expect(settled).toBe(false); + expect(restorationComplete).toBe(false); + + releaseRegistration(); + await expect(result).rejects.toThrow('fork failed'); + expect(restorationComplete).toBe(true); + + expect(registerPendingCredentialSwitch).toHaveBeenCalledWith(sessionId, { + model: 'gpt-5.6-sol', + providerId: 'openai', + rebuildCodexThread: true, + codexThreadRelinkCommitted: true, + sourceCodexThreadModelProviderId: 'cindy_gateway', + previousRoute: { model: 'codex/gpt-5.6-sol', providerId: 'xd' }, + }); + }); + + it('fails closed when restoring a cleared pending switch rejects after close fails', async () => { + const sessionId = rememberSession('runtime-set-model-restore-pending-close-reject'); + setSessionProvider(sessionId, 'xd'); + const closeSession = vi.fn(async () => { + throw new Error('close failed'); + }); + const registerPendingCredentialSwitch = vi.fn(async () => { + throw new Error('pending restoration rejected'); + }); + const wakeSessionInputQueue = vi.fn(); + const maker: RuntimeSetModelMaker = { + getSession: () => ({ + agentKind: 'codex', + remoteHostId: null, + codexProxyActive: true, + codexThreadModelProviderId: 'cindy_gateway', + model: 'codex/gpt-5.6-sol', + setModel: vi.fn(async () => {}), + }), + listActiveSessions: () => [{ + id: sessionId, + agentKind: 'codex', + remoteHostId: null, + isTurnRunning: () => false, + }], + closeSession, + }; + const clearedPending = { + model: 'gpt-5.6-sol', + providerId: 'openai', + rebuildCodexThread: true, + previousRoute: { model: 'codex/gpt-5.6-sol', providerId: 'xd' }, + } as const; + + await expect(applyRuntimeSetModelChange({ + maker, + sessionId, + model: 'gpt-5.6-sol', + providerId: 'openai', + getPendingCredentialSwitch: () => clearedPending, + clearPendingCredentialSwitch: vi.fn(), + registerPendingCredentialSwitch, + wakeSessionInputQueue, + })).rejects.toThrow('pending restoration rejected'); + + expect(closeSession).toHaveBeenCalledWith(sessionId); + expect(registerPendingCredentialSwitch).toHaveBeenCalledWith(sessionId, clearedPending); + expect(wakeSessionInputQueue).not.toHaveBeenCalled(); + expect(getSessionProvider(sessionId)).toBe('xd'); + }); + it('closes an idle OpenAI thread when the provider store was already overwritten with DeepSeek', async () => { const sessionId = rememberSession('runtime-set-model-stale-openai-thread-to-deepseek'); setSessionProvider(sessionId, 'deepseek'); @@ -450,6 +785,7 @@ describe('applyRuntimeSetModelChange', () => { sessionId, model: 'deepseek/deepseek-v4-pro', providerId: 'deepseek', + relinkCodexThreadForProviderSwitch: vi.fn(async () => null), }); expect(result).toEqual({ status: 'applied' }); @@ -484,15 +820,108 @@ describe('applyRuntimeSetModelChange', () => { codexAuthInjection: 'oauth-bearer', }); - expect(result).toEqual({ status: 'deferred' }); + expect(result).toEqual({ status: 'deferred', preservePersistedRoute: true }); expect(registerPendingCredentialSwitch).toHaveBeenCalledWith(sessionId, { model: 'codex/gpt-5.5', providerId: 'xd', + rebuildCodexThread: true, }); expect(setModel).not.toHaveBeenCalled(); expect(getSessionProvider(sessionId)).toBe('openai'); }); + it('waits for pending registration before reporting an idle-to-busy race as deferred', async () => { + const sessionId = rememberSession('runtime-set-model-idle-to-busy-await-registration'); + setSessionProvider(sessionId, 'openai'); + let busyProbe = 0; + let releaseRegistration!: () => void; + const registrationGate = new Promise((resolve) => { + releaseRegistration = resolve; + }); + const registerPendingCredentialSwitch = vi.fn(async () => { + await registrationGate; + }); + const maker: RuntimeSetModelMaker = { + getSession: () => ({ + agentKind: 'codex', + remoteHostId: null, + codexProxyActive: true, + model: 'gpt-5.4', + setModel: vi.fn(async () => {}), + }), + listActiveSessions: () => [{ + id: sessionId, + agentKind: 'codex', + remoteHostId: null, + isTurnRunning: () => busyProbe++ > 0, + }], + closeSession: vi.fn(async () => {}), + }; + + let settled = false; + const result = applyRuntimeSetModelChange({ + maker, + sessionId, + model: 'codex/gpt-5.5', + providerId: 'xd', + codexAuthInjection: 'oauth-bearer', + registerPendingCredentialSwitch, + }).finally(() => { + settled = true; + }); + + await vi.waitFor(() => expect(registerPendingCredentialSwitch).toHaveBeenCalledOnce()); + await Promise.resolve(); + expect(settled).toBe(false); + expect(getSessionProvider(sessionId)).toBe('openai'); + + releaseRegistration(); + await expect(result).resolves.toEqual({ + status: 'deferred', + preservePersistedRoute: true, + }); + expect(getSessionProvider(sessionId)).toBe('openai'); + }); + + it('fails closed when pending registration rejects during an idle-to-busy race', async () => { + const sessionId = rememberSession('runtime-set-model-idle-to-busy-registration-reject'); + setSessionProvider(sessionId, 'openai'); + let busyProbe = 0; + const closeSession = vi.fn(async () => {}); + const registerPendingCredentialSwitch = vi.fn(async () => { + throw new Error('pending registration rejected'); + }); + const maker: RuntimeSetModelMaker = { + getSession: () => ({ + agentKind: 'codex', + remoteHostId: null, + codexProxyActive: true, + model: 'gpt-5.4', + setModel: vi.fn(async () => {}), + }), + listActiveSessions: () => [{ + id: sessionId, + agentKind: 'codex', + remoteHostId: null, + isTurnRunning: () => busyProbe++ > 0, + }], + closeSession, + }; + + await expect(applyRuntimeSetModelChange({ + maker, + sessionId, + model: 'codex/gpt-5.5', + providerId: 'xd', + codexAuthInjection: 'oauth-bearer', + registerPendingCredentialSwitch, + })).rejects.toThrow('pending registration rejected'); + + expect(registerPendingCredentialSwitch).toHaveBeenCalledOnce(); + expect(closeSession).not.toHaveBeenCalled(); + expect(getSessionProvider(sessionId)).toBe('openai'); + }); + it('fails closed on a busy subscription-to-XD Codex switch without a pending channel', async () => { const sessionId = rememberSession('runtime-set-model-busy-boundary-no-channel'); setSessionProvider(sessionId, 'openai'); @@ -621,6 +1050,7 @@ describe('applyRuntimeSetModelChange', () => { model: 'xai/grok-4.3', providerId: 'xai', codexAuthInjection: 'oauth-bearer', + relinkCodexThreadForProviderSwitch: vi.fn(async () => null), }); expect(closeSession).toHaveBeenCalledWith(sessionId); @@ -655,6 +1085,7 @@ describe('applyRuntimeSetModelChange', () => { model: 'codex/gpt-5.5', providerId: 'xd', registerPendingCredentialSwitch: vi.fn(), + relinkCodexThreadForProviderSwitch: vi.fn(async () => null), }); expect(result).toEqual({ status: 'applied' }); @@ -691,10 +1122,11 @@ describe('applyRuntimeSetModelChange', () => { registerPendingCredentialSwitch, }); - expect(result).toEqual({ status: 'deferred' }); + expect(result).toEqual({ status: 'deferred', preservePersistedRoute: true }); expect(registerPendingCredentialSwitch).toHaveBeenCalledWith(sessionId, { model: 'gpt-5.5', providerId: 'xd', + rebuildCodexThread: true, }); expect(closeSession).not.toHaveBeenCalled(); // route 保持旧值:运行中的 turn 继续用原来源,pending 兑现时才写新值。 @@ -770,6 +1202,92 @@ describe('applyRuntimeSetModelChange', () => { expect(setModel).toHaveBeenCalledWith('gpt-5.5', { providerId: null }); }); + it('waits for pending restoration before returning a failed model-only hot switch', async () => { + const sessionId = rememberSession('runtime-set-model-hot-failure-await-pending-restore'); + setSessionProvider(sessionId, null); + let releaseRestoration!: () => void; + const restorationGate = new Promise((resolve) => { + releaseRestoration = resolve; + }); + let restorationComplete = false; + const registerPendingCredentialSwitch = vi.fn(async () => { + await restorationGate; + restorationComplete = true; + }); + const pendingTarget = { + model: 'codex/gpt-5.5', + providerId: null, + rebuildCodexThread: true, + previousRoute: { model: 'gpt-5.5', providerId: null }, + } as const; + const maker: RuntimeSetModelMaker = { + getSession: () => ({ + agentKind: 'codex', + remoteHostId: null, + model: 'gpt-5.5', + setModel: vi.fn(async () => { + throw new Error('hot switch failed'); + }), + }), + listActiveSessions: () => [], + closeSession: vi.fn(async () => {}), + }; + + let settled = false; + const result = applyRuntimeSetModelChange({ + maker, + sessionId, + model: 'gpt-5.5', + getPendingCredentialSwitch: () => pendingTarget, + clearPendingCredentialSwitch: vi.fn(), + registerPendingCredentialSwitch, + }).finally(() => { + settled = true; + }); + + await vi.waitFor(() => expect(registerPendingCredentialSwitch).toHaveBeenCalledOnce()); + await Promise.resolve(); + expect(settled).toBe(false); + expect(restorationComplete).toBe(false); + + releaseRestoration(); + await expect(result).rejects.toThrow('hot switch failed'); + expect(restorationComplete).toBe(true); + expect(registerPendingCredentialSwitch).toHaveBeenCalledWith(sessionId, pendingTarget); + }); + + it('fails closed when pending restoration rejects after a model-only hot switch failure', async () => { + const sessionId = rememberSession('runtime-set-model-hot-failure-pending-restore-reject'); + setSessionProvider(sessionId, null); + const pendingTarget = { model: 'codex/gpt-5.5', providerId: null } as const; + const registerPendingCredentialSwitch = vi.fn(async () => { + throw new Error('pending restoration rejected'); + }); + const maker: RuntimeSetModelMaker = { + getSession: () => ({ + agentKind: 'codex', + remoteHostId: null, + model: 'gpt-5.5', + setModel: vi.fn(async () => { + throw new Error('hot switch failed'); + }), + }), + listActiveSessions: () => [], + closeSession: vi.fn(async () => {}), + }; + + await expect(applyRuntimeSetModelChange({ + maker, + sessionId, + model: 'gpt-5.5', + getPendingCredentialSwitch: () => pendingTarget, + clearPendingCredentialSwitch: vi.fn(), + registerPendingCredentialSwitch, + })).rejects.toThrow('pending restoration rejected'); + + expect(registerPendingCredentialSwitch).toHaveBeenCalledWith(sessionId, pendingTarget); + }); + it('updates the pending model and keeps its provider on a model-only change under a deferred source', async () => { // 用户先 deferred 选了 xd 来源,turn 未结束又换了个模型:pending 的来源意图必须 // 保留,只把目标模型更新为最新选择(model-only 调用回落 store 旧值会把 xd 丢掉)。 @@ -797,10 +1315,11 @@ describe('applyRuntimeSetModelChange', () => { getPendingCredentialSwitch: () => ({ model: 'gpt-5.5', providerId: 'xd' }), }); - expect(result).toEqual({ status: 'deferred' }); + expect(result).toEqual({ status: 'deferred', preservePersistedRoute: true }); expect(registerPendingCredentialSwitch).toHaveBeenCalledWith(sessionId, { model: 'gpt-5.4', providerId: 'xd', + rebuildCodexThread: true, }); // route 保持旧值,等 pending 兑现。 expect(getSessionProvider(sessionId)).toBe('openai'); @@ -817,6 +1336,10 @@ describe('applyRuntimeSetModelChange', () => { }); const wakeSessionInputQueue = vi.fn(() => { order.push('wake'); }); const closeSession = vi.fn(async () => { order.push('close'); }); + const relinkCodexThreadForProviderSwitch = vi.fn(async () => { + order.push('relink'); + return null; + }); const maker: RuntimeSetModelMaker = { getSession: () => ({ agentKind: 'codex', @@ -838,12 +1361,13 @@ describe('applyRuntimeSetModelChange', () => { registerPendingCredentialSwitch: vi.fn(), clearPendingCredentialSwitch, wakeSessionInputQueue, + relinkCodexThreadForProviderSwitch, }); expect(result).toEqual({ status: 'applied' }); // clear 必须不带唤醒(否则 drain 趁 close 窗口把队首派发到旧会话), // 唤醒在 close + 写路由完成之后。 - expect(order).toEqual(['clear-no-wake', 'close', 'wake']); + expect(order).toEqual(['clear-no-wake', 'close', 'relink', 'wake']); expect(getSessionProvider(sessionId)).toBe('xd'); }); diff --git a/apps/desktop/src/main/maker-ipc/codexProviderThreadRelink.ts b/apps/desktop/src/main/maker-ipc/codexProviderThreadRelink.ts new file mode 100644 index 0000000000..702a3e2857 --- /dev/null +++ b/apps/desktop/src/main/maker-ipc/codexProviderThreadRelink.ts @@ -0,0 +1,215 @@ +/** + * Codex 跨凭证家族切换时不能 resume 原 thread。 + * + * 旧 rollout 先经 Codex 的安全 fork 清掉供应商私有 reasoning payload,再用 CAS + * 把同一个 Cindy session 指向新 thread。这样既保留任务上下文,也不会把 XD 网关 + * 创建的 thread 直接交给 ChatGPT 订阅(反向同理)。 + */ + +export interface CodexProviderThreadRelinkSource { + sdkSessionId: string | null; + workingDir: string | null; +} + +export interface CodexProviderThreadRelinkReceipt { + previousSdkSessionId: string; + newSdkSessionId: string; + /** Restore the source thread only while this relink still owns sdk_session_id. */ + rollback(): Promise; +} + +/** + * The replacement thread reached SQLite, but a boundary-triggered rollback did not. + * Callers must keep their dispatch gate closed and retry this receipt against the captured DB. + */ +export class CodexProviderThreadRelinkCompensationRequiredError extends Error { + readonly name = 'CodexProviderThreadRelinkCompensationRequiredError'; + + constructor( + readonly receipt: CodexProviderThreadRelinkReceipt, + readonly rollbackError?: unknown, + ) { + super('Codex provider thread relink requires post-commit compensation'); + } +} + +export function isCodexProviderThreadRelinkCompensationRequiredError( + error: unknown, +): error is CodexProviderThreadRelinkCompensationRequiredError { + return error instanceof CodexProviderThreadRelinkCompensationRequiredError; +} + +class CodexProviderThreadRelinkRollbackHandoffError extends Error { + readonly name = 'CodexProviderThreadRelinkRollbackHandoffError'; + + constructor( + readonly retryRollback: () => Promise, + readonly rollbackError?: unknown, + ) { + super('Codex provider thread relink rollback requires a retry handoff'); + } +} + +/** Preserve the retry closure whenever a committed replacement cannot be rolled back now. */ +export async function rollbackCommittedCodexProviderThreadRelink( + rollback: () => Promise, +): Promise { + try { + // false means the replacement tuple no longer owns the CAS target; a newer selection won. + // Only an actual SQLite rejection needs a retry handoff. + await rollback(); + } catch (error) { + throw new CodexProviderThreadRelinkRollbackHandoffError(rollback, error); + } +} + +export interface PersistedCodexRuntimeSelectionState { + sdkSessionId: string | null; + model: string; + providerId: string | null; + effort: 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'ultra'; + fastMode: boolean; +} + +/** + * Turn a route write plus an optional Codex relink into one compensation record. + * The injected restore must compare the complete applied tuple and replace it with + * the complete previous tuple atomically against the captured profile database. + */ +export async function rollbackPersistedCodexRuntimeSelection(input: { + previous: PersistedCodexRuntimeSelectionState; + appliedRoute: Omit; + relinkReceipt?: Pick< + CodexProviderThreadRelinkReceipt, + 'previousSdkSessionId' | 'newSdkSessionId' + >; + restore: (states: { + expected: PersistedCodexRuntimeSelectionState; + previous: PersistedCodexRuntimeSelectionState; + }) => Promise; +}): Promise { + return input.restore({ + expected: { + sdkSessionId: input.relinkReceipt?.newSdkSessionId ?? input.previous.sdkSessionId, + ...input.appliedRoute, + }, + previous: { + ...input.previous, + sdkSessionId: + input.relinkReceipt?.previousSdkSessionId ?? input.previous.sdkSessionId, + }, + }); +} + +/** + * Commit a relink only while its owner/generation boundary remains current. + * The write itself may yield, so a boundary change after the preflight check + * must be compensated against the same captured database before returning. + */ +export async function commitCodexProviderThreadRelinkWithBoundaryGuard(input: { + isBoundaryCurrent: () => boolean; + commit: () => Promise; + rollback: () => Promise; +}): Promise { + if (!input.isBoundaryCurrent()) return false; + const committed = await input.commit(); + if (!committed) return false; + if (input.isBoundaryCurrent()) return true; + await rollbackCommittedCodexProviderThreadRelink(input.rollback); + return false; +} + +function resolveForkSourceProviderId( + sourceProviderId: string | null, + sourceThreadModelProviderId: string | null | undefined, +): string | null { + // thread/start|resume is the fact source when a control plane already overwrote the + // route store. Feed forkSdkSession a public provider id that resolves to the same + // credential family as the source thread, never the target selection. + if (sourceThreadModelProviderId === 'cindy_openai') return 'openai'; + if (sourceThreadModelProviderId === 'cindy_gateway' && sourceProviderId === 'openai') { + return 'xd'; + } + return sourceProviderId; +} + +export interface CodexProviderThreadRelinkDeps { + readSource(sessionId: string): Promise; + fork(input: { + sourceSdkSessionId: string; + sourceModel: string; + sourceProviderId: string | null; + workingDir?: string; + }): Promise<{ newSdkSessionId: string }>; + commit(input: { + sessionId: string; + expectedSdkSessionId: string; + newSdkSessionId: string; + isCurrent?: () => boolean; + }): Promise; + onCommitted?(input: { + sessionId: string; + previousSdkSessionId: string; + newSdkSessionId: string; + }): void; +} + +export async function relinkCodexProviderThread( + deps: CodexProviderThreadRelinkDeps, + input: { + sessionId: string; + sourceModel: string; + sourceProviderId: string | null; + sourceThreadModelProviderId?: string | null; + isCurrent?: () => boolean; + }, +): Promise<{ previousSdkSessionId: string; newSdkSessionId: string } | null> { + const source = await deps.readSource(input.sessionId); + if (!source?.sdkSessionId) return null; + + const forked = await deps.fork({ + sourceSdkSessionId: source.sdkSessionId, + sourceModel: input.sourceModel, + sourceProviderId: resolveForkSourceProviderId( + input.sourceProviderId, + input.sourceThreadModelProviderId, + ), + ...(source.workingDir ? { workingDir: source.workingDir } : {}), + }); + let committed: boolean; + try { + committed = await deps.commit({ + sessionId: input.sessionId, + expectedSdkSessionId: source.sdkSessionId, + newSdkSessionId: forked.newSdkSessionId, + ...(input.isCurrent ? { isCurrent: input.isCurrent } : {}), + }); + } catch (error) { + if (error instanceof CodexProviderThreadRelinkRollbackHandoffError && input.isCurrent) { + throw new CodexProviderThreadRelinkCompensationRequiredError( + { + previousSdkSessionId: source.sdkSessionId, + newSdkSessionId: forked.newSdkSessionId, + rollback: error.retryRollback, + }, + error.rollbackError, + ); + } + if (error instanceof CodexProviderThreadRelinkRollbackHandoffError) { + throw error.rollbackError ?? error; + } + throw error; + } + if (!committed) { + throw new Error(`Codex provider thread relink was superseded for session ${input.sessionId}`); + } + deps.onCommitted?.({ + sessionId: input.sessionId, + previousSdkSessionId: source.sdkSessionId, + newSdkSessionId: forked.newSdkSessionId, + }); + return { + previousSdkSessionId: source.sdkSessionId, + newSdkSessionId: forked.newSdkSessionId, + }; +} diff --git a/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts b/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts index d2ec4026b2..a251811656 100644 --- a/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts +++ b/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts @@ -1,11 +1,24 @@ -import type { AgentKind } from '@cindy/maker-core'; +import type { AgentKind, Effort } from '@cindy/maker-core'; import { isCredentialModeSwitchBusyError, isLocalSessionBusy, prepareLocalSessionCredentialModeSwitch, + shouldCloseSessionForCredentialSwitch, } from '../maker-host/codex-credential-switch.js'; +import { + CODEX_CINDY_COMPACT_PROVIDER_ID, + CODEX_GATEWAY_PROVIDER_ID, +} from '../maker-host/codex-gateway-config.js'; +import { + setSessionEffort, + setSessionFastMode, +} from '../maker-host/session-effort-store.js'; import { setSessionProvider } from '../maker-host/session-provider-store.js'; +import { + isCodexProviderThreadRelinkCompensationRequiredError, + type CodexProviderThreadRelinkReceipt, +} from './codexProviderThreadRelink.js'; /** * PendingCredentialSwitchService —— 会话凭证形态切换的「延迟生效」登记表。 @@ -28,16 +41,57 @@ import { setSessionProvider } from '../maker-host/session-provider-store.js'; * coordinator 的 hasPendingCredentialSwitch 门冻结 —— register 起周期定时器 * 重试 onTurnSettled,杜绝"事件丢失 → 永久冻结"。 * - * DB 持久化不在本模块:renderer 在 set-model 返回 deferred 后照常 - * sessionService.update 落盘 sessions.{model,provider_id},重启后 hydrate 生效。 + * 跨 Codex thread family 的 deferred 登记期间,SQLite 必须保留源 route/thread; + * finalizer 确认 close 后由 relink CAS 一次提交新 thread + 最终目标四轴。进程在 + * 任意边界退出都只会恢复完整源 tuple 或完整目标 tuple,不会跨来源 resume。 */ /** 自愈兜底重试间隔(事件路径正常时用户感知不到它)。 */ const PENDING_APPLY_RETRY_DELAY_MS = 10_000; +function isAtomicPersistedRouteEffort(effort: string | undefined): effort is Effort { + return ( + effort === 'minimal' || + effort === 'low' || + effort === 'medium' || + effort === 'high' || + effort === 'xhigh' || + effort === 'max' || + effort === 'ultra' + ); +} + +export interface PendingCredentialSwitchOwnerScope { + ownerScopeKey: string; + runtimeOwnerEpoch: string; +} + +export interface PendingCredentialSwitchPersistedRoute { + model: string; + providerId: string | null; + effort?: string; + fastMode?: boolean; +} + +interface PendingCredentialSwitchAtomicPersistedRoute { + model: string; + providerId: string | null; + effort: Effort; + fastMode: boolean; +} + export interface PendingCredentialSwitch { model: string; providerId: string | null; + /** relink 成功后随 route 一次提交的目标运行轴;deferred 期间 SQLite 保持旧 route。 */ + effort?: string; + fastMode?: boolean; + /** 跨 cindy_gateway / cindy_openai 身份边界,收口前必须换成新的 native thread。 */ + rebuildCodexThread?: boolean; + /** relink 已提交且回滚失败时,重试只补 route persist,不能再次 fork 新 thread。 */ + codexThreadRelinkCommitted?: boolean; + /** app-server 确认的源 thread provider 身份,路由 store 被提前覆盖时仍是事实源。 */ + sourceCodexThreadModelProviderId?: string | null; /** 目标会话的 agent(register 时由调用方捕获,收口前的停用重裁决用;可缺席 = 不裁决)。 */ agentKind?: AgentKind; /** @@ -46,7 +100,19 @@ export interface PendingCredentialSwitch { * 预写进 DB,不回滚的话懒 resume 会经停用隐式来源重建(PR #744 review 第十六轮)。 * 缺席 = 无从回滚,退化为只清显式来源。 */ - previousRoute?: { model: string; providerId: string | null; effort?: string; fastMode?: boolean }; + previousRoute?: { + model: string; + providerId: string | null; + effort?: string; + fastMode?: boolean; + }; + /** 登记时的 data owner + runtime epoch;跨账号/teardown 后旧 target 必须终态丢弃。 */ + ownerScope?: PendingCredentialSwitchOwnerScope; + /** 针对登记时捕获的旧 Profile 成套恢复 thread + route;第二参数用于迟到提交 CAS。 */ + restoreStaleOwnerRoute?: ( + persistedRoute?: PendingCredentialSwitchPersistedRoute, + expectedSdkSessionId?: string, + ) => Promise; requestedAt: number; } @@ -63,6 +129,8 @@ export interface PendingCredentialSwitchDeps { closeSession: (sessionId: string) => Promise; }; isSessionInTurn?: (sessionId: string) => boolean; + /** 缺席仅供旧调用方/最小测试;生产必须校验 pending 登记时捕获的 owner。 */ + isOwnerScopeCurrent?: (scope: PendingCredentialSwitchOwnerScope) => boolean; /** 生效后广播给 renderer(清「任务结束后生效」标记 / 会话内 toast)。 */ broadcastApplied?: (payload: { sessionId: string; @@ -71,6 +139,8 @@ export interface PendingCredentialSwitchDeps { }) => void; /** 生效后唤醒该会话的输入队列(排队消息此前被 pending 门挡住)。 */ onApplied?: (sessionId: string) => void; + /** clear 撞上 finalizer 时,待补偿/关闭完成后再次唤醒此前被临时门挡住的队列。 */ + onCancellationCompensated?: (sessionId: string) => void; /** * 停用轴裁决(生产 = model-route-guard-live 的 resolveLenientSessionRoute)。 * SET_MODEL 请求时刻已裁决过,但 deferred 切换的**生效**可能在数分钟后 —— 期间 @@ -93,15 +163,36 @@ export interface PendingCredentialSwitchDeps { }>; /** * 把收口裁决后的实际 route 持久化(生产 = 直写 sessions 行 + 广播 - * sessions:patched)。renderer 在 deferred 被接受那一刻已按**请求值**落盘 DB; - * 收口裁决改道 / 丢弃被停用的显式来源 / 全停换模型时必须回写实际值 —— 否则下 - * 一次懒 resume 按 DB 里的停用路由重建(resume 免裁决,PR #744 review 第十、 - * 十四轮)。缺席 = 只写内存 store(测试最小 harness)。 + * sessions:patched)。无 native thread 的切换及其它 deferred 会走这里;跨 family + * relink 的 route 已与 sdkSessionId 在 relink CAS 中提交,不再二次写。若收口裁决 + * 改道仍必须回写实际值。缺席 = 只写内存 store(测试最小 harness)。 */ persistRoute?: ( sessionId: string, - route: { providerId: string | null; model?: string; effort?: string; fastMode?: boolean }, + route: { + providerId: string | null; + model?: string; + effort?: string; + fastMode?: boolean; + }, ) => Promise; + /** + * 把旧 Codex rollout 安全 fork 到目标来源使用的新 thread,并用同一 CAS 替换持久化 + * sdkSessionId + 完整最终 route。失败时 pending 保留、输入队列继续 gated。 + */ + relinkCodexThreadForProviderSwitch?: (input: { + sessionId: string; + sourceModel: string; + sourceProviderId: string | null; + sourceThreadModelProviderId?: string | null; + targetModel: string; + targetProviderId: string | null; + isCurrent: () => boolean; + persistedRouteTransition: { + previous: PendingCredentialSwitchAtomicPersistedRoute; + next: PendingCredentialSwitchAtomicPersistedRoute; + }; + }) => Promise; /** 自愈兜底重试间隔覆写(测试用)。 */ retryDelayMs?: number; logger?: { @@ -117,44 +208,140 @@ export class PendingCredentialSwitchService { private readonly applying = new Set(); /** 自愈兜底定时器(per session,pending 收口即清)。 */ private readonly retryTimers = new Map>(); + /** owner-stale 补偿中的 target;同步 has/get 重入不得重复写旧 Profile。 */ + private readonly staleDiscards = new Set(); + /** owner-stale Profile 补偿未完成时按 session 保持输入 gate,且不让新 owner 重跑 apply。 */ + private readonly staleCompensations = new Map>(); + /** owner-stale 只重试捕获的 Profile CAS;绝不重跑 close / relink / route persist。 */ + private readonly staleCompensationRetryTimers = new Map< + PendingCredentialSwitch, + ReturnType + >(); + /** A finalizer may learn the persisted tuple after has()/get() already began stale cleanup. */ + private readonly staleCompensationContexts = new Map< + PendingCredentialSwitch, + { + persistedRoute?: PendingCredentialSwitchPersistedRoute; + expectedSdkSessionId?: string; + } + >(); + /** Serialize captured-Profile CAS attempts while allowing a later finalizer to upgrade context. */ + private readonly staleCompensationRunning = new Set(); + /** clear() / owner-stale 不能在迟到 finalizer 完成 Profile 补偿前解除输入队列门。 */ + private readonly clearedDuringApply = new Set(); + /** wake:false 的显式替换由新选择收尾,旧 finalizer 不得代为唤醒。 */ + private readonly suppressedCancellationWakes = new Set(); + /** 新选择已完成但唤醒曾被旧 finalizer barrier 挡住,补偿结束后补发。 */ + private readonly deferredCancellationWakes = new Set(); + /** SQLite 暂时失败时保持 gate,并用捕获的旧 Profile 继续补偿。 */ + private readonly compensationFailures = new Map< + string, + Set + >(); + private readonly compensationRetryTimers = new Map< + PendingCredentialSwitch, + ReturnType + >(); constructor(private readonly deps: PendingCredentialSwitchDeps) {} - register( + async register( sessionId: string, target: { model: string; providerId: string | null; + effort?: string; + fastMode?: boolean; + rebuildCodexThread?: boolean; + codexThreadRelinkCommitted?: boolean; + sourceCodexThreadModelProviderId?: string | null; agentKind?: AgentKind; - previousRoute?: { model: string; providerId: string | null; effort?: string; fastMode?: boolean }; + previousRoute?: { + model: string; + providerId: string | null; + effort?: string; + fastMode?: boolean; + }; + ownerScope?: PendingCredentialSwitchOwnerScope; + restoreStaleOwnerRoute?: ( + persistedRoute?: PendingCredentialSwitchPersistedRoute, + expectedSdkSessionId?: string, + ) => Promise; }, - ): void { - this.pending.set(sessionId, { + ): Promise { + const pending: PendingCredentialSwitch = { model: target.model, providerId: target.providerId, + ...(target.effort !== undefined ? { effort: target.effort } : {}), + ...(target.fastMode !== undefined ? { fastMode: target.fastMode } : {}), + ...(target.rebuildCodexThread ? { rebuildCodexThread: true } : {}), + ...(target.codexThreadRelinkCommitted ? { codexThreadRelinkCommitted: true } : {}), + ...(target.sourceCodexThreadModelProviderId !== undefined + ? { sourceCodexThreadModelProviderId: target.sourceCodexThreadModelProviderId } + : {}), ...(target.agentKind ? { agentKind: target.agentKind } : {}), ...(target.previousRoute ? { previousRoute: target.previousRoute } : {}), + ...(target.ownerScope ? { ownerScope: target.ownerScope } : {}), + ...(target.restoreStaleOwnerRoute + ? { restoreStaleOwnerRoute: target.restoreStaleOwnerRoute } + : {}), requestedAt: Date.now(), - }); + }; + if (!this.ownerScopeCurrent(pending)) { + const existing = this.pending.get(sessionId); + if (existing && !this.ownerScopeCurrent(existing)) { + this.discardIfOwnerStale(sessionId, existing); + } + // This target never enters the map, so discardIfOwnerStale cannot compensate it later. + // Finish its captured old-Profile restore before the caller receives registration failure. + const compensation = await this.compensateStaleOwnerRoute(sessionId, pending); + if (compensation === 'retry') { + throw new Error('Stale pending credential switch route compensation failed'); + } + this.deps.logger?.info('stale pending credential switch discarded at registration', { + sessionId, + }); + return false; + } + this.pending.set(sessionId, pending); this.scheduleRetry(sessionId); this.deps.logger?.info('pending credential switch registered', { sessionId, model: target.model, providerId: target.providerId, }); + return true; } has(sessionId: string): boolean { - return this.pending.has(sessionId); + if (this.clearedDuringApply.has(sessionId) || this.staleCompensations.has(sessionId)) { + return true; + } + const target = this.pending.get(sessionId); + if (!target) return false; + if (!this.discardIfOwnerStale(sessionId, target)) return true; + return this.clearedDuringApply.has(sessionId) || this.staleCompensations.has(sessionId); } get(sessionId: string): PendingCredentialSwitch | undefined { - return this.pending.get(sessionId); + const target = this.pending.get(sessionId); + if (!target || this.discardIfOwnerStale(sessionId, target)) return undefined; + return target; } - clear(sessionId: string): void { + clear(sessionId: string, opts?: { wake?: boolean }): void { this.pending.delete(sessionId); this.clearRetry(sessionId); + if (this.applying.has(sessionId)) { + this.clearedDuringApply.add(sessionId); + if (opts?.wake === false) this.suppressedCancellationWakes.add(sessionId); + } + } + + requestWakeAfterCancellationBarrier(sessionId: string): boolean { + if (!this.clearedDuringApply.has(sessionId)) return true; + this.deferredCancellationWakes.add(sessionId); + return false; } /** @@ -163,8 +350,10 @@ export class PendingCredentialSwitchService { * 让下一次发送按新来源重建。任何路径都不允许向外抛错(register 侧 fire-and-forget)。 */ async onTurnSettled(sessionId: string): Promise { + if (this.staleCompensations.has(sessionId)) return; const target = this.pending.get(sessionId); if (!target) return; + if (this.discardIfOwnerStale(sessionId, target)) return; if (this.applying.has(sessionId)) return; const session = this.deps.maker .listActiveSessions() @@ -185,21 +374,31 @@ export class PendingCredentialSwitchService { // turn done 与新 turn start 竞态:保留 pending,等下一个边界。 return; } - // 关闭失败也要落 route:下一次发送的 getHost 仲裁仍会按新来源协调, - // 不能让用户的选择因一次 close 失败而静默蒸发。 - this.deps.logger?.warn('pending credential switch: close session failed; applying route anyway', { - sessionId, - error: err instanceof Error ? err.message : String(err), - }); + // A rejected close leaves the live Session state unknown. Keep the pending gate and + // retry from the close boundary; relinking or persisting a target route here could + // pair the still-live source thread with the target provider/model. + this.deps.logger?.error?.( + 'pending credential switch: close session failed; keeping queue gated for retry', + { + sessionId, + error: err instanceof Error ? err.message : String(err), + }, + ); + const latest = this.pending.get(sessionId); + if (latest && !this.discardIfOwnerStale(sessionId, latest)) { + this.scheduleRetry(sessionId); + } + return; } } // await close 期间用户可能又 register(改选)或 clear(取消):以**当前**登记为准 // 收口,不能用进入函数时捕获的 stale target 覆盖后选(后选覆盖先选)。 const latest = this.pending.get(sessionId); if (!latest) return; + if (this.discardIfOwnerStale(sessionId, latest)) return; await this.finalizeApplyChecked(sessionId, latest, 'turn end'); } finally { - this.applying.delete(sessionId); + this.finishApplying(sessionId); } } @@ -211,12 +410,14 @@ export class PendingCredentialSwitchService { * 让完成权归 turn-settled 路径,避免双写 route / 双广播(renderer 双 toast)。 */ onSessionClosed(sessionId: string): void { + if (this.staleCompensations.has(sessionId)) return; if (this.applying.has(sessionId)) return; const target = this.pending.get(sessionId); if (!target) return; + if (this.discardIfOwnerStale(sessionId, target)) return; this.applying.add(sessionId); void this.finalizeApplyChecked(sessionId, target, 'session close').finally(() => { - this.applying.delete(sessionId); + this.finishApplying(sessionId); }); } @@ -233,9 +434,19 @@ export class PendingCredentialSwitchService { */ private async resolveApplyRoute( target: PendingCredentialSwitch, - ): Promise<{ providerId: string | null; model?: string; effort?: string; fastMode?: boolean; apply: boolean }> { + ): Promise<{ + providerId: string | null; + model?: string; + effort?: string; + fastMode?: boolean; + apply: boolean; + }> { const { resolveRoute } = this.deps; - if (!resolveRoute) return { providerId: target.providerId, apply: true }; + const targetAxes = { + ...(target.effort !== undefined ? { effort: target.effort } : {}), + ...(target.fastMode !== undefined ? { fastMode: target.fastMode } : {}), + }; + if (!resolveRoute) return { providerId: target.providerId, ...targetAxes, apply: true }; try { if (!target.agentKind) { for (const agent of ['claude-code', 'codex', 'pi'] as AgentKind[]) { @@ -245,10 +456,10 @@ export class PendingCredentialSwitchService { resolved.providerId !== target.providerId || resolved.degraded ) { - return { providerId: null, apply: true }; + return { providerId: null, ...targetAxes, apply: true }; } } - return { providerId: target.providerId, apply: true }; + return { providerId: target.providerId, ...targetAxes, apply: true }; } // desiredFastMode = 会话当前 fast(previousRoute 捕获;renderer 在 set-model 时 // 不动 fast,DB 现值即它):目标全停换兜底模型时按落地拷贝 reconcile,不支持 @@ -272,7 +483,7 @@ export class PendingCredentialSwitchService { ...(prev.model !== target.model ? { model: prev.model, - ...(prev.effort ? { effort: prev.effort } : {}), + ...(prev.effort !== undefined ? { effort: prev.effort } : {}), ...(prev.fastMode !== undefined ? { fastMode: prev.fastMode } : {}), } : {}), @@ -290,7 +501,7 @@ export class PendingCredentialSwitchService { apply: true, }; } - return { providerId: resolved.providerId, apply: true }; + return { providerId: resolved.providerId, ...targetAxes, apply: true }; } catch (err) { // 复核异常按「不写 route」保守处理:目标可能恰在等待期间被停用,异常放行会把 // 停用来源写回会话(PR #744 review 第八轮)。生产 resolveRoute @@ -318,13 +529,21 @@ export class PendingCredentialSwitchService { await this.finalizeApply( sessionId, target, - { providerId: target.providerId, apply: true }, + { + providerId: target.providerId, + ...(target.effort !== undefined ? { effort: target.effort } : {}), + ...(target.fastMode !== undefined ? { fastMode: target.fastMode } : {}), + apply: true, + }, reason, ); return; } const resolved = await this.resolveApplyRoute(target); - if (this.pending.get(sessionId) !== target) return; + if (!this.targetCurrent(sessionId, target)) { + this.discardIfOwnerStale(sessionId, target); + return; + } await this.finalizeApply(sessionId, target, resolved, reason); } @@ -337,57 +556,309 @@ export class PendingCredentialSwitchService { private async finalizeApply( sessionId: string, target: PendingCredentialSwitch, - resolved: { providerId: string | null; model?: string; effort?: string; fastMode?: boolean; apply: boolean }, + resolved: { + providerId: string | null; + model?: string; + effort?: string; + fastMode?: boolean; + apply: boolean; + }, reason: string, ): Promise { + let relinkReceipt: CodexProviderThreadRelinkReceipt | null = null; if (resolved.apply) { + const modelChanged = !!resolved.model && resolved.model !== target.model; + const routeChanged = resolved.providerId !== target.providerId || modelChanged; + const resolvedEffort = resolved.effort ?? target.effort ?? target.previousRoute?.effort; + const resolvedFastMode = + resolved.fastMode ?? target.fastMode ?? target.previousRoute?.fastMode; + const resolvedRoute: PendingCredentialSwitchPersistedRoute = { + providerId: resolved.providerId, + model: resolved.model ?? target.model, + ...(resolvedEffort !== undefined ? { effort: resolvedEffort } : {}), + ...(resolvedFastMode !== undefined ? { fastMode: resolvedFastMode } : {}), + }; + // A successful relink CAS can make the complete target tuple durable before this + // finalizer resumes. Keep that fact for cancellation/owner compensation and skip the + // legacy second route write; otherwise a crash could expose a target thread on source route. + let persistedResolvedRoute: PendingCredentialSwitchPersistedRoute | undefined; + // 等待期间停用裁决可能把最终 provider/model 改到另一 thread 身份家族。 + // source thread 身份完整时必须按最终 route 重算,不能沿用登记时冻结的 marker; + // 身份不完整时保留旧 marker 的 fail-closed 兼容语义。 + const shouldRelinkCodexThread = + target.codexThreadRelinkCommitted !== true && + (target.agentKind === 'codex' && + target.previousRoute && + target.sourceCodexThreadModelProviderId !== undefined + ? shouldCloseSessionForCredentialSwitch({ + agentKind: 'codex', + remoteHostId: null, + currentProviderId: target.previousRoute.providerId, + nextProviderId: resolved.providerId, + currentModel: target.previousRoute.model, + nextModel: resolved.model ?? target.model, + currentCodexProxyActive: true, + currentCodexThreadModelProviderId: target.sourceCodexThreadModelProviderId, + currentCodexCindyRemoteCompactionCompatible: + target.sourceCodexThreadModelProviderId === CODEX_GATEWAY_PROVIDER_ID + ? false + : target.sourceCodexThreadModelProviderId === CODEX_CINDY_COMPACT_PROVIDER_ID + ? true + : undefined, + }) + : target.rebuildCodexThread === true); + if (shouldRelinkCodexThread) { + const relink = this.deps.relinkCodexThreadForProviderSwitch; + if (!relink) { + this.deps.logger?.error?.( + 'pending credential switch: Codex thread relink dependency is unavailable; keeping queue gated', + { sessionId, model: resolved.model ?? target.model, providerId: resolved.providerId }, + ); + if (this.targetCurrent(sessionId, target)) this.scheduleRetry(sessionId); + else this.discardIfOwnerStale(sessionId, target); + return; + } + const previousRoute = target.previousRoute; + if ( + !previousRoute || + !isAtomicPersistedRouteEffort(previousRoute.effort) || + previousRoute.fastMode === undefined || + !isAtomicPersistedRouteEffort(resolvedRoute.effort) || + resolvedRoute.fastMode === undefined + ) { + this.deps.logger?.error?.( + 'pending credential switch: complete persisted route is unavailable; keeping queue gated', + { sessionId, model: resolvedRoute.model, providerId: resolvedRoute.providerId }, + ); + if (this.targetCurrent(sessionId, target)) this.scheduleRetry(sessionId); + else this.discardIfOwnerStale(sessionId, target); + return; + } + const persistedRouteTransition = { + previous: { + model: previousRoute.model, + providerId: previousRoute.providerId, + effort: previousRoute.effort, + fastMode: previousRoute.fastMode, + }, + next: { + model: resolvedRoute.model, + providerId: resolvedRoute.providerId, + effort: resolvedRoute.effort, + fastMode: resolvedRoute.fastMode, + }, + }; + try { + relinkReceipt = await relink({ + sessionId, + sourceModel: target.previousRoute?.model ?? target.model, + sourceProviderId: target.previousRoute?.providerId ?? target.providerId, + ...(target.sourceCodexThreadModelProviderId !== undefined + ? { + sourceThreadModelProviderId: target.sourceCodexThreadModelProviderId, + } + : {}), + targetModel: resolved.model ?? target.model, + targetProviderId: resolved.providerId, + isCurrent: () => this.targetCurrent(sessionId, target), + persistedRouteTransition, + }); + if (relinkReceipt) persistedResolvedRoute = resolvedRoute; + } catch (err) { + this.deps.logger?.error?.( + 'pending credential switch: Codex thread relink failed; keeping queue gated for retry', + { + sessionId, + model: resolved.model ?? target.model, + providerId: resolved.providerId, + error: err instanceof Error ? err.message : String(err), + }, + ); + if (isCodexProviderThreadRelinkCompensationRequiredError(err)) { + const receipt = err.receipt; + if (!this.ownerScopeCurrent(target)) { + this.discardIfOwnerStale( + sessionId, + target, + resolvedRoute, + receipt.newSdkSessionId, + ); + } else { + await this.compensateSupersededPersistedRoute( + sessionId, + target, + resolvedRoute, + receipt, + ); + if (this.targetCurrent(sessionId, target)) this.scheduleRetry(sessionId); + } + } else if (this.targetCurrent(sessionId, target)) { + this.scheduleRetry(sessionId); + } else { + this.discardIfOwnerStale(sessionId, target); + } + return; + } + if (!this.targetCurrent(sessionId, target)) { + let rollbackRestored = relinkReceipt === null; + if (relinkReceipt) { + try { + rollbackRestored = await relinkReceipt.rollback(); + } catch (rollbackError) { + this.deps.logger?.error?.( + 'pending credential switch: superseded Codex relink rollback failed', + { + sessionId, + error: + rollbackError instanceof Error + ? rollbackError.message + : String(rollbackError), + }, + ); + } + } + if (!rollbackRestored && relinkReceipt) { + if (!this.ownerScopeCurrent(target)) { + this.discardIfOwnerStale( + sessionId, + target, + persistedResolvedRoute, + relinkReceipt.newSdkSessionId, + ); + } else { + await this.compensateSupersededPersistedRoute( + sessionId, + target, + persistedResolvedRoute, + relinkReceipt, + ); + } + } else { + this.discardIfOwnerStale(sessionId, target); + } + return; + } + } setSessionProvider(sessionId, resolved.providerId); - // 裁决改了落地路由(reroute / 丢弃停用显式来源 / 全停换模型或清空):回写 DB - // —— renderer 在 deferred 接受时已按请求值落盘 sessions 行,不纠正的话下一次 - // 懒 resume 按停用路由重建(resume 免裁决)。回写必须 **await 且先于**删登记 / + // 跨 family deferred 在登记时刻刻意让 DB 保持源 route;有 native thread 时 + // relink CAS 已成套提交目标 route,无 thread 或裁决不跨 family 才在这里回写。 + // 回写必须 **await 且先于**删登记 / // 唤醒队列:排队消息在唤醒后立刻按 DB 懒 resume,fire-and-forget 会让它抢在 // 替换模型落库前用停用目标重建(PR #744 review 第十五轮);await 期间 pending // 仍在,coordinator 的 pending 门保持关闭。失败留痕后仍收口(队列不能永久 // 冻结;内存 store 已是权威路由,DB 差异在下次显式切换收敛)。 - const modelChanged = !!resolved.model && resolved.model !== target.model; - const routeChanged = resolved.providerId !== target.providerId || modelChanged; + // When relink returned null there was no native thread to replace, so the route still + // needs the ordinary persistence write. A non-null receipt already committed this exact + // tuple atomically with the replacement sdk_session_id and broadcast it. + const routeCommittedWithRelink = relinkReceipt !== null; // 恒写幂等回正(最后收口者赢,PR #744 review 第二十三轮):旧登记的 finalizer 在 - // await persistRoute 期间被新选择替换时,旧写可能晚于 renderer 为新选择落的盘; + // await persistRoute 期间被新选择替换时,旧写可能晚于新选择的登记/落盘; // 若新登记收口时因「裁决未改路由」跳过回写,DB 会留着旧 finalizer 的迟到值, // 下次懒 resume 用错路由。收口时恒写 (providerId, model) —— 正常路径是对 - // renderer 已写值的幂等重写,零语义差;不做 per-session 写锁(产品口径:先可用, + // 已写值的幂等重写,零语义差;不做 per-session 写锁(产品口径:先可用, // 低概率竞态由幂等回写收敛,见 model-route-guard.ts 头注)。 // 仅在裁决接线存在时恒写:该竞态面来自 resolveRoute/persist 的 await 窗口, // 无接线的同步快路径(onSessionClosed 不 await,「关闭即生效」须同 tick 完成) // 既无竞态也不能引入异步,保持只在路由改动时回写。 - if (this.deps.persistRoute && (routeChanged || this.deps.resolveRoute)) { + if ( + this.deps.persistRoute && + !routeCommittedWithRelink && + (routeChanged || + this.deps.resolveRoute || + shouldRelinkCodexThread || + target.codexThreadRelinkCommitted === true) + ) { try { - await this.deps.persistRoute(sessionId, { - providerId: resolved.providerId, - model: resolved.model ?? target.model, - ...(modelChanged - ? { - ...(resolved.effort ? { effort: resolved.effort } : {}), - ...(resolved.fastMode !== undefined ? { fastMode: resolved.fastMode } : {}), - } - : {}), - }); + await this.deps.persistRoute(sessionId, resolvedRoute); + persistedResolvedRoute = resolvedRoute; } catch (err) { + let relinkRollbackError: unknown; + let relinkRestored = relinkReceipt === null; + if (relinkReceipt) { + try { + const restored = await relinkReceipt.rollback(); + if (!restored) { + throw new Error('Codex thread relink rollback was superseded'); + } + relinkRestored = true; + } catch (rollbackError) { + relinkRollbackError = rollbackError; + // The replacement thread still owns sdk_session_id. Avoid forking it again on + // retry; the pending gate remains closed until route persistence succeeds. + target.rebuildCodexThread = false; + target.codexThreadRelinkCommitted = true; + } + } // fail-closed(PR #744 review 第十六轮):DB 里躺着 renderer 预写的停用 // 目标,回写失败就唤醒队列 = 排队消息立刻按停用路由懒 resume。保留登记 // (pending 门继续挡住派发)+ 自愈定时器重试整个收口(重新裁决 + 回写); // 用户改选 / 取消随时接管。error 级留痕 —— 这是会冻结该会话队列的状态。 - this.deps.logger?.error?.('pending credential switch: persist resolved route failed; keeping queue gated for retry', { + this.deps.logger?.error?.( + 'pending credential switch: persist resolved route failed; keeping queue gated for retry', + { sessionId, providerId: resolved.providerId, model: resolved.model, error: err instanceof Error ? err.message : String(err), - }); - if (this.pending.get(sessionId) === target) this.scheduleRetry(sessionId); + ...(relinkRollbackError + ? { + relinkRollbackError: + relinkRollbackError instanceof Error + ? relinkRollbackError.message + : String(relinkRollbackError), + } + : {}), + }, + ); + if (this.targetCurrent(sessionId, target)) { + this.scheduleRetry(sessionId); + } else if (this.ownerScopeCurrent(target)) { + // persistRoute did not prove that any target tuple reached SQLite. A same-owner + // cancellation may nevertheless have deleted pending after setSessionProvider() + // switched the in-memory route. Only release its cancellation barrier after the + // thread is back on the source and all captured runtime axes are restored. + if (relinkRestored) { + this.restoreCancelledRuntimeRoute(sessionId, target); + } else { + // The failed persist may have written nothing or may have committed before a + // later hook rejected. The captured Profile callback accepts either the resolved + // tuple or previousRoute and retries fail-closed until thread + route are safe. + await this.compensateSupersededPersistedRoute( + sessionId, + target, + resolvedRoute, + relinkReceipt, + ); + } + } else { + this.discardIfOwnerStale(sessionId, target); + } return; } // await 期间用户可能改选 / 取消:本次让位,新登记有自己的收口路径。 - if (this.pending.get(sessionId) !== target) return; + // 同 owner 的迟到写必须把实际 route + replacement thread 作为一个 CAS + // 成套补偿;先单独回滚 thread 会留下可被唤醒队列/重启观察到的裂分状态。 + if (!this.targetCurrent(sessionId, target)) { + if (!this.ownerScopeCurrent(target)) { + // The captured old-Profile callback restores sdk_session_id and the complete source + // route in one CAS. Never call receipt.rollback() first: a crash between that write + // and route compensation would persist target provider/model with the source thread. + this.discardIfOwnerStale( + sessionId, + target, + persistedResolvedRoute, + relinkReceipt?.newSdkSessionId, + ); + } else { + await this.compensateSupersededPersistedRoute( + sessionId, + target, + persistedResolvedRoute, + relinkReceipt, + ); + } + return; + } } this.deps.logger?.info(`pending credential switch applied on ${reason}`, { sessionId, @@ -400,17 +871,22 @@ export class PendingCredentialSwitchService { // 会让排队消息按未经复核的路由懒 resume(PR #744 review 第二十轮)。保留 // 登记(pending 门继续挡派发)+ 自愈定时器重试整个收口;生产 resolveRoute // 自带降级不抛,此分支纯防御,fail-closed 零日常代价。 - this.deps.logger?.error?.('pending credential switch revalidation failed; keeping queue gated for retry', { + this.deps.logger?.error?.( + 'pending credential switch revalidation failed; keeping queue gated for retry', + { sessionId, model: target.model, providerId: target.providerId, - }); - if (this.pending.get(sessionId) === target) this.scheduleRetry(sessionId); + }, + ); + if (this.targetCurrent(sessionId, target)) this.scheduleRetry(sessionId); + else this.discardIfOwnerStale(sessionId, target); return; } // 删登记(解除 coordinator 的 pending 门)必须在 route 写入 + DB 回写之后。 this.pending.delete(sessionId); this.clearRetry(sessionId); + this.requestWakeAfterCancellationBarrier(sessionId); try { this.deps.onApplied?.(sessionId); } catch (err) { @@ -435,12 +911,15 @@ export class PendingCredentialSwitchService { private scheduleRetry(sessionId: string): void { this.clearRetry(sessionId); + const target = this.pending.get(sessionId); + if (!target || this.discardIfOwnerStale(sessionId, target)) return; const timer = setTimeout(() => { this.retryTimers.delete(sessionId); - if (!this.pending.has(sessionId)) return; + const current = this.pending.get(sessionId); + if (!current || this.discardIfOwnerStale(sessionId, current)) return; void this.onTurnSettled(sessionId).finally(() => { // 仍未收口(还在跑 / busy 竞态)→ 继续兜底。收口路径已 clearRetry。 - if (this.pending.has(sessionId) && !this.retryTimers.has(sessionId)) { + if (this.has(sessionId) && !this.retryTimers.has(sessionId)) { this.scheduleRetry(sessionId); } }); @@ -453,4 +932,313 @@ export class PendingCredentialSwitchService { if (timer) clearTimeout(timer); this.retryTimers.delete(sessionId); } + + private finishApplying(sessionId: string): void { + this.applying.delete(sessionId); + this.maybeReleaseCancellationBarrier(sessionId); + } + + private hasCompensationFailures(sessionId: string): boolean { + return (this.compensationFailures.get(sessionId)?.size ?? 0) > 0; + } + + private maybeReleaseCancellationBarrier(sessionId: string): void { + if ( + this.applying.has(sessionId) || + this.staleCompensations.has(sessionId) || + this.hasCompensationFailures(sessionId) + ) { + return; + } + this.releaseClearedDuringApply(sessionId); + } + + private releaseClearedDuringApply(sessionId: string): void { + if (!this.clearedDuringApply.delete(sessionId)) return; + const wakeSuppressed = this.suppressedCancellationWakes.delete(sessionId); + const deferredWake = this.deferredCancellationWakes.delete(sessionId); + // clearPendingCredentialSwitchForSession() may already have tried to wake the queue, but + // has() kept it gated until the late finalizer finished compensating the captured Profile. + // A replacement target remains gated by pending.has(); a pure cancellation may now drain. + if (wakeSuppressed && !deferredWake) return; + try { + this.deps.onCancellationCompensated?.(sessionId); + } catch (err) { + this.deps.logger?.warn('pending credential switch: compensated cancellation wake failed', { + sessionId, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + private ownerScopeCurrent(target: PendingCredentialSwitch): boolean { + return !target.ownerScope || !this.deps.isOwnerScopeCurrent + ? true + : this.deps.isOwnerScopeCurrent(target.ownerScope); + } + + private targetCurrent(sessionId: string, target: PendingCredentialSwitch): boolean { + return this.pending.get(sessionId) === target && this.ownerScopeCurrent(target); + } + + private async compensateStaleOwnerRoute( + sessionId: string, + target: PendingCredentialSwitch, + persistedRoute?: PendingCredentialSwitchPersistedRoute, + expectedSdkSessionId?: string, + ): Promise<'safe' | 'retry'> { + try { + if (target.restoreStaleOwnerRoute) { + const restored = await target.restoreStaleOwnerRoute( + persistedRoute, + expectedSdkSessionId, + ); + if (!restored) { + this.deps.logger?.warn( + 'stale pending credential switch route compensation was superseded', + { sessionId }, + ); + return 'safe'; + } + } + return 'safe'; + } catch (error) { + this.deps.logger?.error?.('stale pending credential switch route compensation failed', { + sessionId, + error: error instanceof Error ? error.message : String(error), + }); + return 'retry'; + } + } + + private async retryStaleOwnerCompensation( + sessionId: string, + target: PendingCredentialSwitch, + ): Promise { + if (this.staleCompensationRunning.has(target)) return; + this.staleCompensationRunning.add(target); + const context = this.staleCompensationContexts.get(target); + const compensation = await this.compensateStaleOwnerRoute( + sessionId, + target, + context?.persistedRoute, + context?.expectedSdkSessionId, + ); + this.staleCompensationRunning.delete(target); + // persistRoute may have completed while an earlier no-context cleanup was awaiting SQLite. + // Ignore that obsolete result and immediately retry with the exact tuple + replacement id. + if (this.staleCompensationContexts.get(target) !== context) { + this.requestStaleOwnerCompensation(sessionId, target); + return; + } + if (compensation === 'retry') { + this.scheduleStaleOwnerCompensationRetry(sessionId, target); + return; + } + this.completeStaleOwnerCompensation(sessionId, target); + } + + private scheduleStaleOwnerCompensationRetry( + sessionId: string, + target: PendingCredentialSwitch, + ): void { + if (this.staleCompensationRetryTimers.has(target)) return; + const timer = setTimeout(() => { + this.staleCompensationRetryTimers.delete(target); + this.requestStaleOwnerCompensation(sessionId, target); + }, this.deps.retryDelayMs ?? PENDING_APPLY_RETRY_DELAY_MS); + this.staleCompensationRetryTimers.set(target, timer); + } + + private requestStaleOwnerCompensation( + sessionId: string, + target: PendingCredentialSwitch, + ): void { + const timer = this.staleCompensationRetryTimers.get(target); + if (timer) clearTimeout(timer); + this.staleCompensationRetryTimers.delete(target); + if (this.staleCompensationRunning.has(target)) return; + void this.retryStaleOwnerCompensation(sessionId, target); + } + + private completeStaleOwnerCompensation( + sessionId: string, + target: PendingCredentialSwitch, + ): void { + const timer = this.staleCompensationRetryTimers.get(target); + if (timer) clearTimeout(timer); + this.staleCompensationRetryTimers.delete(target); + this.staleCompensationContexts.delete(target); + this.staleCompensationRunning.delete(target); + this.staleDiscards.delete(target); + const targets = this.staleCompensations.get(sessionId); + targets?.delete(target); + if (targets?.size === 0) this.staleCompensations.delete(sessionId); + if (this.pending.get(sessionId) === target) { + this.pending.delete(sessionId); + } + this.deps.logger?.info('stale pending credential switch discarded after owner boundary', { + sessionId, + }); + this.maybeReleaseCancellationBarrier(sessionId); + } + + /** + * persistRoute() may finish after this generation was cancelled or replaced while the data + * owner is still valid. Owner-stale cleanup intentionally ignores that case, so compensate + * the exact tuple and replacement thread this finalizer wrote in one captured-Profile CAS. + * A newer route/thread therefore wins without being overwritten. + */ + private async compensateSupersededPersistedRoute( + sessionId: string, + target: PendingCredentialSwitch, + persistedRoute: PendingCredentialSwitchPersistedRoute | undefined, + relinkReceipt: CodexProviderThreadRelinkReceipt | null, + ): Promise { + const safe = await this.tryCompensateSupersededPersistedRoute( + sessionId, + target, + persistedRoute, + relinkReceipt, + ); + if (safe) { + this.completeSupersededCompensation(sessionId, target); + return; + } + const failures = this.compensationFailures.get(sessionId) ?? new Set(); + failures.add(target); + this.compensationFailures.set(sessionId, failures); + this.scheduleCompensationRetry(sessionId, target, persistedRoute, relinkReceipt); + } + + private async tryCompensateSupersededPersistedRoute( + sessionId: string, + target: PendingCredentialSwitch, + persistedRoute: PendingCredentialSwitchPersistedRoute | undefined, + relinkReceipt: CodexProviderThreadRelinkReceipt | null, + ): Promise { + try { + const restored = target.restoreStaleOwnerRoute + ? await target.restoreStaleOwnerRoute(persistedRoute, relinkReceipt?.newSdkSessionId) + : relinkReceipt + ? await relinkReceipt.rollback() + : false; + if (!restored) { + this.deps.logger?.warn( + 'superseded pending credential switch route compensation was superseded', + { sessionId }, + ); + return true; + } + // A pure cancellation has no newer runtime selection to preserve. Re-align the provider + // store only after the captured Profile CAS succeeded; replacement targets own their store. + this.restoreCancelledRuntimeRoute(sessionId, target); + return true; + } catch (error) { + this.deps.logger?.error?.('superseded pending credential switch route compensation failed', { + sessionId, + error: error instanceof Error ? error.message : String(error), + }); + return false; + } + } + + private restoreCancelledRuntimeRoute( + sessionId: string, + target: PendingCredentialSwitch, + ): void { + if (this.pending.has(sessionId) || !target.previousRoute) return; + // The finalizer already closed the old live Session before relink/persist. The next lazy + // creation reads model + sdk_session_id from the unchanged/restored SQLite source tuple; + // these three owner-scoped stores are the remaining runtime axes that must match it. + setSessionProvider(sessionId, target.previousRoute.providerId); + setSessionEffort(sessionId, target.previousRoute.effort); + if (target.previousRoute.fastMode !== undefined) { + setSessionFastMode(sessionId, target.previousRoute.fastMode); + } + } + + private scheduleCompensationRetry( + sessionId: string, + target: PendingCredentialSwitch, + persistedRoute: PendingCredentialSwitchPersistedRoute | undefined, + relinkReceipt: CodexProviderThreadRelinkReceipt | null, + ): void { + if (this.compensationRetryTimers.has(target)) return; + const timer = setTimeout(() => { + this.compensationRetryTimers.delete(target); + void this.tryCompensateSupersededPersistedRoute( + sessionId, + target, + persistedRoute, + relinkReceipt, + ).then((safe) => { + if (!safe) { + this.scheduleCompensationRetry(sessionId, target, persistedRoute, relinkReceipt); + return; + } + this.completeSupersededCompensation(sessionId, target); + }); + }, this.deps.retryDelayMs ?? PENDING_APPLY_RETRY_DELAY_MS); + this.compensationRetryTimers.set(target, timer); + } + + private completeSupersededCompensation( + sessionId: string, + target: PendingCredentialSwitch, + ): void { + const timer = this.compensationRetryTimers.get(target); + if (timer) clearTimeout(timer); + this.compensationRetryTimers.delete(target); + const failures = this.compensationFailures.get(sessionId); + failures?.delete(target); + if (failures?.size === 0) this.compensationFailures.delete(sessionId); + this.maybeReleaseCancellationBarrier(sessionId); + } + + private discardIfOwnerStale( + sessionId: string, + target: PendingCredentialSwitch, + persistedRoute?: PendingCredentialSwitchPersistedRoute, + expectedSdkSessionId?: string, + ): boolean { + if (this.ownerScopeCurrent(target)) return false; + const existingContext = this.staleCompensationContexts.get(target); + const nextContext = { + ...(persistedRoute !== undefined + ? { persistedRoute } + : existingContext?.persistedRoute !== undefined + ? { persistedRoute: existingContext.persistedRoute } + : {}), + ...(expectedSdkSessionId !== undefined + ? { expectedSdkSessionId } + : existingContext?.expectedSdkSessionId !== undefined + ? { expectedSdkSessionId: existingContext.expectedSdkSessionId } + : {}), + }; + const contextChanged = + !existingContext || + nextContext.persistedRoute !== existingContext.persistedRoute || + nextContext.expectedSdkSessionId !== existingContext.expectedSdkSessionId; + if (contextChanged) this.staleCompensationContexts.set(target, nextContext); + if (!this.staleDiscards.has(target)) { + // Stop normal apply retries immediately, but keep a compensation barrier until the exact + // old Profile CAS succeeds (or proves a newer selection won). A new owner may register the + // same session id meanwhile; target identity keeps the old cleanup from deleting it. + if (this.pending.get(sessionId) === target) this.clearRetry(sessionId); + this.staleDiscards.add(target); + const targets = this.staleCompensations.get(sessionId) ?? new Set(); + targets.add(target); + this.staleCompensations.set(sessionId, targets); + // Reuse the cancellation barrier lifecycle so finishApplying cannot release the queue + // while SQLite is temporarily unavailable; successful compensation performs one wake. + this.clearedDuringApply.add(sessionId); + this.requestStaleOwnerCompensation(sessionId, target); + } else if (contextChanged) { + // A post-persist finalizer upgrades an earlier has()/get()-triggered cleanup with the + // replacement sdk id; wake the retry loop now instead of keeping the obsolete closure. + this.requestStaleOwnerCompensation(sessionId, target); + } + return true; + } } diff --git a/apps/desktop/src/main/maker-ipc/register.ts b/apps/desktop/src/main/maker-ipc/register.ts index 1925552b8d..6c20af0d6e 100644 --- a/apps/desktop/src/main/maker-ipc/register.ts +++ b/apps/desktop/src/main/maker-ipc/register.ts @@ -56,9 +56,10 @@ import { CONTROLLER_CAPABILITY_SET_MODEL_EXPLICIT_PROVIDER_NULL_V1, DL_SESSION_REFERENCE_CAPABILITY_CHANNEL, } from '@cindy/device-link'; -import { and, desc, eq, gte, inArray, isNull, lt, sql } from 'drizzle-orm'; +import { and, desc, eq, gte, inArray, isNull, lt, or, sql } from 'drizzle-orm'; import { app, BrowserWindow, dialog, ipcMain, shell, type IpcMainInvokeEvent } from 'electron'; import { + activeOwnerScopeKey, getActiveAppSession, getActiveDataOwnerPushStamp, isAppSessionBoundaryPending, @@ -880,7 +881,16 @@ import { import { applyRuntimeSetModelChange, isRemoteModelSwitchRouteChangeError, + type RuntimeSetModelPersistedSession, } from './runtimeSetModel.js'; +import { + commitCodexProviderThreadRelinkWithBoundaryGuard, + relinkCodexProviderThread, + rollbackCommittedCodexProviderThreadRelink, + rollbackPersistedCodexRuntimeSelection, + type CodexProviderThreadRelinkReceipt, + type PersistedCodexRuntimeSelectionState, +} from './codexProviderThreadRelink.js'; import { applyRuntimeSelectionAxesWithRecovery, commitRuntimeAxisAfterPersistence, @@ -915,7 +925,10 @@ import { } from './sessionRuntimeControl.js'; import { applyRuntimeEffortWithRecovery } from './runtimeSetEffort.js'; import { normalizeDeviceLinkSetModelWireArgs } from './setModelWireArgs.js'; -import { PendingCredentialSwitchService } from './pendingCredentialSwitch.js'; +import { + PendingCredentialSwitchService, + type PendingCredentialSwitchPersistedRoute, +} from './pendingCredentialSwitch.js'; import { DeferredCodexRestartService, runMemoryChangeWithCodexRestart, @@ -3053,65 +3066,428 @@ export function cancelPendingAgentSwitchForSession(sessionId: string): void { */ export async function registerPendingCredentialSwitchForSession( sessionId: string, - target: { model: string; providerId: string | null }, + target: { + model: string; + providerId: string | null; + effort?: string; + fastMode?: boolean; + rebuildCodexThread?: boolean; + codexThreadRelinkCommitted?: boolean; + ownerScope?: { ownerScopeKey: string; runtimeOwnerEpoch: string }; + sourceCodexThreadModelProviderId?: string | null; + previousRoute?: { + model: string; + providerId: string | null; + effort?: string; + fastMode?: boolean; + }; + sourcePersistedSession?: RuntimeSetModelPersistedSession; + restoreStaleOwnerRoute?: ( + persistedRoute?: PendingCredentialSwitchPersistedRoute, + expectedSdkSessionId?: string, + ) => Promise; + }, ): Promise { const service = pendingCredentialSwitchHolder; if (!service) { throw new Error('Pending credential switch service is not initialized'); } + // Capture before any DB await. Restoring a cleared pending passes its original scope through; + // never relabel an old owner's target with the newly active owner. + const ownerScope = target.ownerScope ?? { + ownerScopeKey: activeOwnerScopeKey(), + runtimeOwnerEpoch: captureSessionRuntimeControlOwnerEpoch(), + }; + const dbSnapshot = getCurrentDbClientSnapshot(); // 捕获会话 agent:deferred 切换在收口时刻要重过停用裁决(期间目标可能被停用, // PR #744 review 第七轮);读不到(会话行缺失)则登记不带 agentKind = 收口不裁决。 const dbAgentKind = getSessionDbAgentKind(sessionId); // 捕获切换前的运行路由:model 用 live handle(热切过未落库时比 DB 权威),来源用 - // provider store;effort / fast 无 live getter,从 DB 行读 —— renderer 在收到 - // deferred 结果**之后**才落盘请求值,本函数在 IPC 回包之前执行,此刻行里仍是 - // 切换前值,无竞态。目标在等待期间被全停且目录无启用兜底时,收口按整套 - // previousRoute 回滚(model/provider/effort/fast 一致成对,第十六、十八轮)。 + // provider store;effort / fast 无 live getter,从写前快照或 DB 行读。renderer 在 + // deferred 回包后才落盘,可安全回读 DB;IM model:pick 则先落盘目标再调用这里, + // 必须由 runtimeSetModel 把 input.persistedSession 原样带成 sourcePersistedSession。 + // 目标在等待期间被全停且目录无启用兜底时,收口按整套 previousRoute 回滚 + // (model/provider/effort/fast 一致成对,第十六、十八轮)。 // deferred 场景会话正在跑 turn,live handle 必在;取不到就不带 = 无从回滚。 const live = getMaker().getSession(sessionId); + const sourcePersistedSession = target.sourcePersistedSession; let prevRow: { model: string | null; effort: string | null; fastMode: boolean | null; providerId: string | null; + sdkSessionId: string | null; } | null = null; - try { - const [row] = await getDbClient() - .drizzle.select({ - model: sessions.model, - effort: sessions.effort, - fastMode: sessions.fastMode, - providerId: sessions.providerId, - }) - .from(sessions) - .where(eq(sessions.id, sessionId)) - .limit(1); - prevRow = row ?? null; - } catch { - prevRow = null; + if ( + sourcePersistedSession?.effort !== undefined && + sourcePersistedSession.fastMode !== undefined + ) { + prevRow = { + model: sourcePersistedSession.model, + effort: sourcePersistedSession.effort, + fastMode: sourcePersistedSession.fastMode, + providerId: sourcePersistedSession.providerId, + sdkSessionId: sourcePersistedSession.sdkSessionId, + }; + } else { + try { + if (dbSnapshot) { + const [row] = await dbSnapshot.client.drizzle + .select({ + model: sessions.model, + effort: sessions.effort, + fastMode: sessions.fastMode, + providerId: sessions.providerId, + sdkSessionId: sessions.sdkSessionId, + }) + .from(sessions) + .where(eq(sessions.id, sessionId)) + .limit(1); + prevRow = row ?? null; + } + } catch { + prevRow = null; + } } - const prevModel = live?.model ?? prevRow?.model ?? null; - service.register(sessionId, { + const prevModel = sourcePersistedSession?.model ?? live?.model ?? prevRow?.model ?? null; + let restoreCapturedProfileRoute: + | (( + persistedRoute?: PendingCredentialSwitchPersistedRoute, + expectedSdkSessionId?: string, + ) => Promise) + | undefined; + if (dbSnapshot && prevRow?.model && prevRow.effort && prevRow.fastMode !== null) { + const capturedPrevRow = { + ...prevRow, + model: prevRow.model, + effort: prevRow.effort as typeof sessions.$inferSelect.effort, + fastMode: prevRow.fastMode, + }; + restoreCapturedProfileRoute = async ( + persistedRoute?: PendingCredentialSwitchPersistedRoute, + expectedSdkSessionId?: string, + ): Promise => { + const appliedRoute = persistedRoute ?? { + model: target.model, + providerId: target.providerId, + }; + const expectedSdk = expectedSdkSessionId ?? capturedPrevRow.sdkSessionId; + const sdkMatches = + expectedSdk === null + ? isNull(sessions.sdkSessionId) + : eq(sessions.sdkSessionId, expectedSdk); + const appliedProviderMatches = + appliedRoute.providerId === null + ? isNull(sessions.providerId) + : eq(sessions.providerId, appliedRoute.providerId); + const previousProviderMatches = + capturedPrevRow.providerId === null + ? isNull(sessions.providerId) + : eq(sessions.providerId, capturedPrevRow.providerId); + const write = await dbSnapshot.client.drizzle + .update(sessions) + .set({ + sdkSessionId: capturedPrevRow.sdkSessionId, + model: capturedPrevRow.model, + providerId: capturedPrevRow.providerId, + ...(appliedRoute.effort !== undefined + ? { effort: capturedPrevRow.effort } + : {}), + ...(appliedRoute.fastMode !== undefined + ? { fastMode: capturedPrevRow.fastMode } + : {}), + updatedAt: Date.now(), + }) + .where( + and( + eq(sessions.id, sessionId), + sdkMatches, + or( + and( + eq(sessions.model, appliedRoute.model), + appliedProviderMatches, + ...(appliedRoute.effort !== undefined + ? [ + eq( + sessions.effort, + appliedRoute.effort as typeof sessions.$inferSelect.effort, + ), + ] + : []), + ...(appliedRoute.fastMode !== undefined + ? [eq(sessions.fastMode, appliedRoute.fastMode)] + : []), + ), + and( + eq(sessions.model, capturedPrevRow.model), + previousProviderMatches, + eq(sessions.effort, capturedPrevRow.effort), + eq(sessions.fastMode, capturedPrevRow.fastMode), + ), + ), + ), + ) + .run(); + return write.changes > 0; + }; + } + if ( + target.rebuildCodexThread && + !target.restoreStaleOwnerRoute && + !restoreCapturedProfileRoute + ) { + throw new Error('Codex pending credential switch requires an old-profile rollback snapshot'); + } + const restoreStaleOwnerRoute = + target.restoreStaleOwnerRoute ?? restoreCapturedProfileRoute; + if (target.rebuildCodexThread && restoreStaleOwnerRoute) { + // A deferred relink exists only in memory. Before registering it, make SQLite provably + // restart-safe: the source thread must remain paired with the complete source route until + // the finalizer has closed the live Session and committed the replacement thread. + const restored = await restoreStaleOwnerRoute({ + model: target.model, + providerId: target.providerId, + ...(target.effort !== undefined ? { effort: target.effort } : {}), + ...(target.fastMode !== undefined ? { fastMode: target.fastMode } : {}), + }); + if (!restored) { + throw new Error('Codex pending credential switch could not preserve the source route'); + } + } + const registered = await service.register(sessionId, { ...target, + ownerScope, + ...(restoreStaleOwnerRoute ? { restoreStaleOwnerRoute } : {}), + ...(live?.codexThreadModelProviderId !== undefined + ? { sourceCodexThreadModelProviderId: live.codexThreadModelProviderId } + : {}), ...(dbAgentKind ? { agentKind: dbToMakerAgentKind(dbAgentKind) } : {}), - ...(prevModel + ...(!target.previousRoute && prevModel ? { previousRoute: { model: prevModel, - providerId: getSessionProvider(sessionId) ?? prevRow?.providerId ?? null, + providerId: + sourcePersistedSession?.providerId ?? + getSessionProvider(sessionId) ?? + prevRow?.providerId ?? + null, ...(prevRow?.effort ? { effort: prevRow.effort } : {}), ...(prevRow?.fastMode != null ? { fastMode: prevRow.fastMode } : {}), }, } : {}), }); + if (!registered) { + throw new Error('Pending credential switch owner changed during registration'); + } +} + +/** + * 跨订阅远端压缩身份边界时,把旧 rollout 安全 fork 到新 thread,再以 CAS + * 替换同一个 Cindy session 的 sdkSessionId。该函数同时供 Desktop 与 IM 模型选择 + * 使用;调用方必须先关闭 live Session,并保持输入队列 gated。 + */ +export async function relinkCodexThreadForCredentialSwitch(input: { + sessionId: string; + sourceModel: string; + sourceProviderId: string | null; + sourceThreadModelProviderId?: string | null; + targetModel: string; + targetProviderId: string | null; + isCurrent?: () => boolean; + /** IM 等先于通用持久化调用 relink 的入口,用单条 CAS 成套提交 thread + route。 */ + persistedRouteTransition?: { + previous: Omit; + next: Omit; + }; +}): Promise { + const ownerScope = captureDataOwnerBroadcastScope(); + const dbSnapshot = getCurrentDbClientSnapshot(); + if (!dbSnapshot) throw new Error('Codex provider thread relink requires an active database'); + + const rollbackCapturedRelink = async (params: { + sessionId: string; + previousSdkSessionId: string; + newSdkSessionId: string; + }): Promise => { + const transition = input.persistedRouteTransition; + const write = await dbSnapshot.client.drizzle + .update(sessions) + .set({ + sdkSessionId: params.previousSdkSessionId, + ...(transition ? transition.previous : {}), + updatedAt: Date.now(), + }) + .where( + and( + eq(sessions.id, params.sessionId), + eq(sessions.sdkSessionId, params.newSdkSessionId), + ...(transition + ? [ + eq(sessions.model, transition.next.model), + transition.next.providerId === null + ? isNull(sessions.providerId) + : eq(sessions.providerId, transition.next.providerId), + eq(sessions.effort, transition.next.effort), + eq(sessions.fastMode, transition.next.fastMode), + ] + : []), + ), + ) + .run(); + return write.changes > 0; + }; + + const relinked = await relinkCodexProviderThread( + { + readSource: async (sessionId) => { + const [row] = await dbSnapshot.client.drizzle + .select({ + sdkSessionId: sessions.sdkSessionId, + workingDir: sessions.workingDir, + }) + .from(sessions) + .where(eq(sessions.id, sessionId)) + .limit(1); + return row ?? null; + }, + fork: async ({ sourceSdkSessionId, sourceModel, sourceProviderId, workingDir }) => { + const forked = await getMaker().forkSdkSession('codex', { + sourceSdkSessionId, + model: sourceModel, + providerId: sourceProviderId, + upToMessageId: undefined, + ...(workingDir ? { workingDir } : {}), + stripEncryptedReasoning: true, + remoteHostId: null, + }); + return { newSdkSessionId: forked.newSdkSessionId }; + }, + commit: async ({ sessionId, expectedSdkSessionId, newSdkSessionId, isCurrent }) => { + const transition = input.persistedRouteTransition; + const isBoundaryCurrent = (): boolean => + !isAppSessionBoundaryPending() && + isCurrent?.() !== false && + isDataOwnerBroadcastScopeCurrent(ownerScope) && + getCurrentDbClientSnapshot()?.clientEpoch === dbSnapshot.clientEpoch; + const now = Date.now(); + const committed = await commitCodexProviderThreadRelinkWithBoundaryGuard({ + isBoundaryCurrent, + commit: async () => { + const write = await dbSnapshot.client.drizzle + .update(sessions) + .set({ + sdkSessionId: newSdkSessionId, + ...(transition ? transition.next : {}), + updatedAt: now, + }) + .where( + and( + eq(sessions.id, sessionId), + eq(sessions.sdkSessionId, expectedSdkSessionId), + ...(transition + ? [ + eq(sessions.model, transition.previous.model), + transition.previous.providerId === null + ? isNull(sessions.providerId) + : eq(sessions.providerId, transition.previous.providerId), + eq(sessions.effort, transition.previous.effort), + eq(sessions.fastMode, transition.previous.fastMode), + ] + : []), + ), + ) + .run(); + return write.changes > 0; + }, + rollback: () => + rollbackCapturedRelink({ + sessionId, + previousSdkSessionId: expectedSdkSessionId, + newSdkSessionId, + }), + }); + if (!committed) return false; + // The post-write guard above covers pending generation, owner scope, and client epoch. + // Broadcast only after all three still match the initiating selection. + if (!isBoundaryCurrent()) { + await rollbackCommittedCodexProviderThreadRelink(() => + rollbackCapturedRelink({ + sessionId, + previousSdkSessionId: expectedSdkSessionId, + newSdkSessionId, + }), + ); + return false; + } + broadcastSessionPatched( + sessionId, + { + sdkSessionId: newSdkSessionId, + ...(transition ? transition.next : {}), + updatedAt: new Date(now).toISOString(), + }, + ownerScope, + ); + return true; + }, + onCommitted: ({ sessionId, previousSdkSessionId, newSdkSessionId }) => { + log.info('Codex provider thread relinked before credential switch', { + sessionId, + fromThreadId: previousSdkSessionId, + toThreadId: newSdkSessionId, + sourceModel: input.sourceModel, + sourceProviderId: input.sourceProviderId, + targetModel: input.targetModel, + targetProviderId: input.targetProviderId, + }); + }, + }, + input, + ); + if (!relinked) return null; + + return { + ...relinked, + rollback: async () => { + // Always compensate against the captured DB. Owner teardown/account switch may make + // the current client differ, but that must not strand the relink in the old profile. + const restored = await rollbackCapturedRelink({ + sessionId: input.sessionId, + previousSdkSessionId: relinked.previousSdkSessionId, + newSdkSessionId: relinked.newSdkSessionId, + }); + if (!restored) return false; + if ( + !isAppSessionBoundaryPending() && + isDataOwnerBroadcastScopeCurrent(ownerScope) && + getCurrentDbClientSnapshot()?.clientEpoch === dbSnapshot.clientEpoch + ) { + const now = Date.now(); + broadcastSessionPatched( + input.sessionId, + { + sdkSessionId: relinked.previousSdkSessionId, + updatedAt: new Date(now).toISOString(), + }, + ownerScope, + ); + } + log.info('Codex provider thread relink rolled back', { + sessionId: input.sessionId, + fromThreadId: relinked.newSdkSessionId, + toThreadId: relinked.previousSdkSessionId, + }); + return true; + }, + }; } export function clearPendingCredentialSwitchForSession( sessionId: string, opts?: { wake?: boolean }, ): void { - pendingCredentialSwitchHolder?.clear(sessionId); + pendingCredentialSwitchHolder?.clear(sessionId, opts); // pending 门解除后 coordinator 没有其它唤醒源;wake:false 由调用方在 // close + route 写入完成后显式唤醒,避免队首趁窗口派发到旧凭证。 if (opts?.wake !== false) { @@ -3120,14 +3496,55 @@ export function clearPendingCredentialSwitchForSession( } export function wakeSessionInputAfterCredentialSwitch(sessionId: string): void { + if (pendingCredentialSwitchHolder?.requestWakeAfterCancellationBarrier(sessionId) === false) { + return; + } agentInputCoordinatorHolder?.wakeSession(sessionId, 'credential-switch-applied-inline'); } -export function getPendingCredentialSwitchTarget( - sessionId: string, -): { model: string; providerId: string | null } | undefined { +export function getPendingCredentialSwitchTarget(sessionId: string): + | { + model: string; + providerId: string | null; + effort?: string; + fastMode?: boolean; + rebuildCodexThread?: boolean; + codexThreadRelinkCommitted?: boolean; + ownerScope?: { ownerScopeKey: string; runtimeOwnerEpoch: string }; + sourceCodexThreadModelProviderId?: string | null; + previousRoute?: { + model: string; + providerId: string | null; + effort?: string; + fastMode?: boolean; + }; + restoreStaleOwnerRoute?: ( + persistedRoute?: PendingCredentialSwitchPersistedRoute, + expectedSdkSessionId?: string, + ) => Promise; + } + | undefined { const pending = pendingCredentialSwitchHolder?.get(sessionId); - return pending ? { model: pending.model, providerId: pending.providerId } : undefined; + return pending + ? { + model: pending.model, + providerId: pending.providerId, + ...(pending.effort !== undefined ? { effort: pending.effort } : {}), + ...(pending.fastMode !== undefined ? { fastMode: pending.fastMode } : {}), + ...(pending.rebuildCodexThread ? { rebuildCodexThread: true } : {}), + ...(pending.codexThreadRelinkCommitted + ? { codexThreadRelinkCommitted: true } + : {}), + ...(pending.ownerScope ? { ownerScope: pending.ownerScope } : {}), + ...(pending.sourceCodexThreadModelProviderId !== undefined + ? { sourceCodexThreadModelProviderId: pending.sourceCodexThreadModelProviderId } + : {}), + ...(pending.previousRoute ? { previousRoute: pending.previousRoute } : {}), + ...(pending.restoreStaleOwnerRoute + ? { restoreStaleOwnerRoute: pending.restoreStaleOwnerRoute } + : {}), + } + : undefined; } // ── Scheduler 撞忙排队桥(scheduler-host runner 消费)──────────────────────── @@ -4455,8 +4872,7 @@ export function wireSessionToIpc(session: ReturnType): void !session.remoteHostId && event.type === 'error' && isTerminalTurnErrorEvent(event) && - (isContextOverflowErrorData(event.data) || - isOversizedHistoryErrorData(event.data)); + (isContextOverflowErrorData(event.data) || isOversizedHistoryErrorData(event.data)); if (suppressOverflowBroadcast) { overflowSuppressedBroadcasts.set(session.id, { sessionId: session.id, @@ -5909,11 +6325,9 @@ async function confirmReviewExternalArtifacts( ): Promise { const parent = BrowserWindow.fromWebContents(event.sender); if (!parent || parent.isDestroyed()) return false; - return showReviewArtifactConfirmWindow( - parent, - buildReviewArtifactConfirmationDialog(items, t), - { log }, - ); + return showReviewArtifactConfirmWindow(parent, buildReviewArtifactConfirmationDialog(items, t), { + log, + }); } export interface RegisterMakerIpcOptions { @@ -6048,8 +6462,9 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) }); // submit_github_issue 工具的 main 侧提交服务(确认桥 → serverApiFetch)。 - initGithubIssueSubmit(issueConfirmBridge, (sessionId) => - turnModelPromiseBySession.get(sessionId) ?? readSessionModelForUsage(sessionId), + initGithubIssueSubmit( + issueConfirmBridge, + (sessionId) => turnModelPromiseBySession.get(sessionId) ?? readSessionModelForUsage(sessionId), ); initRenameSessionsConfirm(renameSessionsConfirmBridge); @@ -11970,7 +12385,13 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) .limit(1); return row ?? null; }, - tryStripOversizedCodexHistory: async ({ sessionId, threadId, model, providerId, workingDir }) => { + tryStripOversizedCodexHistory: async ({ + sessionId, + threadId, + model, + providerId, + workingDir, + }) => { const ownerScope = captureDataOwnerBroadcastScope(); const dbSnapshot = getCurrentDbClientSnapshot(); let committed = false; @@ -12004,8 +12425,8 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) return 'stale'; } const now = Date.now(); - const write = await dbSnapshot.client - .drizzle.update(sessions) + const write = await dbSnapshot.client.drizzle + .update(sessions) .set({ sdkSessionId: forked.newSdkSessionId, updatedAt: now }) .where(and(eq(sessions.id, sessionId), eq(sessions.sdkSessionId, threadId))) .run(); @@ -13420,12 +13841,20 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) const pendingCredentialSwitchService = new PendingCredentialSwitchService({ maker, isSessionInTurn, + isOwnerScopeCurrent: (scope) => + !isAppSessionBoundaryPending() && + activeOwnerScopeKey() === scope.ownerScopeKey && + sessionRuntimeControlOwnerEpochMatches(scope.runtimeOwnerEpoch), broadcastApplied: (payload) => { broadcastToAllWindows(MAKER_PUSH.SESSION_CREDENTIAL_SWITCH_APPLIED, payload); }, onApplied: (sessionId) => { inputCoordinator.wakeSession(sessionId, 'pending-credential-switch-applied'); }, + onCancellationCompensated: (sessionId) => { + inputCoordinator.wakeSession(sessionId, 'pending-credential-switch-cancellation-compensated'); + }, + relinkCodexThreadForProviderSwitch: relinkCodexThreadForCredentialSwitch, // 停用轴:deferred 切换收口前重裁决(SET_MODEL 时刻裁决过,但生效可能在数分钟 // 后,期间目标可能被停用;PR #744 review 第七轮,第十四轮换宽松降级形态 —— // 目标全停时连模型一起换到启用兜底)。 @@ -14889,8 +15318,11 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) let atomicSelection = selection as { effort: SessionRuntimeProfile['effort']; fastMode: boolean } | undefined; const runtimeOwnerEpoch = captureSessionRuntimeControlOwnerEpoch(); + const runtimeOwnerBoundaryCurrent = (): boolean => + !isAppSessionBoundaryPending() && + sessionRuntimeControlOwnerEpochMatches(runtimeOwnerEpoch); const assertRuntimeOwnerCurrent = (): void => { - if (!sessionRuntimeControlOwnerEpochMatches(runtimeOwnerEpoch)) { + if (!runtimeOwnerBoundaryCurrent()) { throwIpcError( 'PRECONDITION_FAILED', 'app session changed during runtime selection; request dropped', @@ -14898,7 +15330,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) } }; const supersededByOwnerBoundary = (): boolean => { - if (sessionRuntimeControlOwnerEpochMatches(runtimeOwnerEpoch)) return false; + if (runtimeOwnerBoundaryCurrent()) return false; if (internalOptions.source === 'user') { assertRuntimeOwnerCurrent(); } @@ -14912,11 +15344,26 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) if (supersededByOwnerBoundary()) { return { deferred: false, superseded: true }; } + const runtimeOwnerScope = captureDataOwnerBroadcastScope(); + const runtimeDbSnapshot = getCurrentDbClientSnapshot(); + if (!runtimeDbSnapshot) { + return { deferred: false, superseded: true }; + } // 归档/删除写入与本 handler 共用同一把 session route lock。拿锁后重读持久 // 状态可封住两种次序:本请求先拿锁时终态写等待并在之后清理 override;终态 // 先拿锁时旧请求看到非 active,不能在 cleanup 后重新建立 pending/override。 - const [runtimeStatus] = await getDbClient() - .drizzle.select({ status: sessions.status, orcaRole: sessions.orcaRole }) + const [runtimeStatus] = await runtimeDbSnapshot.client.drizzle + .select({ + status: sessions.status, + orcaRole: sessions.orcaRole, + agentKind: sessions.agentKind, + remoteHostId: sessions.remoteHostId, + sdkSessionId: sessions.sdkSessionId, + model: sessions.model, + providerId: sessions.providerId, + effort: sessions.effort, + fastMode: sessions.fastMode, + }) .from(sessions) .where(eq(sessions.id, sessionId)) .limit(1); @@ -15230,6 +15677,183 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) }); }); }; + const persistedRuntimeSourceRoute: Omit< + PersistedCodexRuntimeSelectionState, + 'sdkSessionId' + > = { + model: runtimeStatus.model, + providerId: runtimeStatus.providerId ?? null, + effort: runtimeStatus.effort, + fastMode: runtimeStatus.fastMode, + }; + const persistedRuntimeTargetRoute: Omit< + PersistedCodexRuntimeSelectionState, + 'sdkSessionId' + > = { + model, + providerId: + effectiveProviderId === undefined + ? (runtimeStatus.providerId ?? null) + : (normalizeSessionProviderId(effectiveProviderId) ?? null), + effort: atomicSelection?.effort ?? runtimeStatus.effort, + fastMode: atomicSelection?.fastMode ?? runtimeStatus.fastMode, + }; + const relinkCodexThreadForRuntimeSelection = ( + relinkInput: Parameters[0], + ) => + relinkCodexThreadForCredentialSwitch({ + ...relinkInput, + persistedRouteTransition: { + previous: persistedRuntimeSourceRoute, + next: persistedRuntimeTargetRoute, + }, + }); + let appliedCodexThreadRelink: CodexProviderThreadRelinkReceipt | undefined; + let appliedPersistedRuntimeRoute: + | { + model: string; + providerId: string | null; + effort: typeof runtimeStatus.effort; + fastMode: boolean; + } + | undefined; + let appliedRuntimeSelectionWasDeferred = false; + type RestoredRuntimeSelectionState = { + sdkSessionId: string | null; + model: string; + providerId: string | null; + effort: typeof runtimeStatus.effort; + fastMode: boolean; + }; + const rollbackAppliedCodexThreadRelink = async (): Promise => { + const receipt = appliedCodexThreadRelink; + if (!receipt) return true; + const restored = await receipt.rollback(); + appliedCodexThreadRelink = undefined; + return restored; + }; + const recoverRuntimeAfterSelectionRollback = async ( + restoredState: RestoredRuntimeSelectionState, + ): Promise => { + const runtimeRecoveryCurrent = + runtimeOwnerBoundaryCurrent() && + isDataOwnerBroadcastScopeCurrent(runtimeOwnerScope) && + getCurrentDbClientSnapshot()?.clientEpoch === runtimeDbSnapshot.clientEpoch; + if (!runtimeRecoveryCurrent) return; + + // SQLite may have needed no route rollback (ordinary renderer persists only after this + // handler returns), but applyRuntimeSetModelChange has already switched the provider + // store and may have left a replacement live Session. Compensate those runtime effects + // in the same unit as the thread receipt before surfacing the original failure. + pendingCredentialSwitchHolder?.clear(sessionId); + restoreControlStores(); + const recoveryErrors: unknown[] = []; + let retainedLiveSession = false; + if (!appliedRuntimeSelectionWasDeferred && maker.getSession(sessionId)) { + try { + await withRehydrateCloseSuppressed(sessionId, () => maker.closeSession(sessionId)); + } catch (error) { + recoveryErrors.push(error); + retainedLiveSession = true; + } + } + if (recoveryErrors.length === 0) { + if (previousRuntime.pendingCredentialSwitch) { + await pendingCredentialSwitchHolder?.register( + sessionId, + previousRuntime.pendingCredentialSwitch, + ); + } else { + wakeSessionInputAfterCredentialSwitch(sessionId); + } + } + broadcastSessionPatched( + sessionId, + { + ...restoredState, + }, + runtimeOwnerScope, + ); + if (retainedLiveSession) { + try { + // A live Session that cannot be retired remains the immediate dispatch authority. + // Re-adopt its actual profile so host stores/projection do not mix it with the + // restored durable source route; a later restart still hydrates that source route. + await reconcileRetainedLiveProfile(); + } catch (error) { + recoveryErrors.push(error); + } + } + if (recoveryErrors.length > 0) { + throw new AggregateError( + recoveryErrors, + 'runtime selection rollback could not retire the live session', + ); + } + }; + const rollbackAppliedRuntimeSelection = async (): Promise => { + const persistedRoute = appliedPersistedRuntimeRoute; + if (!persistedRoute) { + const hadRelink = appliedCodexThreadRelink !== undefined; + const restored = await rollbackAppliedCodexThreadRelink(); + if (!restored) return false; + if (hadRelink || appliedRuntimeSelectionWasDeferred) { + await recoverRuntimeAfterSelectionRollback({ + sdkSessionId: runtimeStatus.sdkSessionId, + model: runtimeStatus.model, + providerId: runtimeStatus.providerId, + effort: runtimeStatus.effort, + fastMode: runtimeStatus.fastMode, + }); + } + return true; + } + + // Route persistence and thread relink are one compensation unit. Match every value + // written by this selection so a later choice is never overwritten, then restore the + // complete old-profile tuple in one SQLite statement even after the active DB changed. + let restoredState: RestoredRuntimeSelectionState | undefined; + const restored = await rollbackPersistedCodexRuntimeSelection({ + previous: { + sdkSessionId: runtimeStatus.sdkSessionId, + model: runtimeStatus.model, + providerId: runtimeStatus.providerId, + effort: runtimeStatus.effort, + fastMode: runtimeStatus.fastMode, + }, + appliedRoute: persistedRoute, + ...(appliedCodexThreadRelink + ? { relinkReceipt: appliedCodexThreadRelink } + : {}), + restore: async ({ expected, previous }) => { + const write = await runtimeDbSnapshot.client.drizzle + .update(sessions) + .set({ ...previous, updatedAt: Date.now() }) + .where( + and( + eq(sessions.id, sessionId), + expected.sdkSessionId === null + ? isNull(sessions.sdkSessionId) + : eq(sessions.sdkSessionId, expected.sdkSessionId), + eq(sessions.model, expected.model), + expected.providerId === null + ? isNull(sessions.providerId) + : eq(sessions.providerId, expected.providerId), + eq(sessions.effort, expected.effort as typeof runtimeStatus.effort), + eq(sessions.fastMode, expected.fastMode), + ), + ) + .run(); + if (write.changes > 0) restoredState = previous as typeof restoredState; + return write.changes > 0; + }, + }); + appliedPersistedRuntimeRoute = undefined; + appliedCodexThreadRelink = undefined; + if (!restored || !restoredState) return false; + await recoverRuntimeAfterSelectionRollback(restoredState); + return true; + }; try { const result = routeExplicit ? await applyRuntimeSetModelChange({ @@ -15237,12 +15861,30 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) sessionId, model, providerId: effectiveProviderId, + persistedSession: { + agentKind: dbToMakerAgentKind(runtimeStatus.agentKind), + remoteHostId: runtimeStatus.remoteHostId, + sdkSessionId: runtimeStatus.sdkSessionId, + model: runtimeStatus.model, + providerId: runtimeStatus.providerId ?? null, + effort: runtimeStatus.effort, + fastMode: runtimeStatus.fastMode, + }, ...(atomicSelection?.effort ? { effort: atomicSelection.effort as - 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'ultra', + | 'minimal' + | 'low' + | 'medium' + | 'high' + | 'xhigh' + | 'max' + | 'ultra', + fastMode: atomicSelection.fastMode, } - : {}), + : atomicSelection + ? { fastMode: atomicSelection.fastMode } + : {}), forceSessionRebuild: rebuildLiveOrcaWorker, isSessionInTurn, registerPendingCredentialSwitch: registerPendingCredentialSwitchForSession, @@ -15256,16 +15898,39 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) // 解析隐式来源的凭证家族,精确判定是否跨远端压缩身份边界(见 // shouldCloseSessionForCredentialSwitch.codexAuthInjection)。 codexAuthInjection: getCodexProxyAuthInjectionState(), + relinkCodexThreadForProviderSwitch: relinkCodexThreadForRuntimeSelection, logger: log, }) : { status: 'applied' as const }; + if (result.status === 'applied') { + appliedCodexThreadRelink = result.codexThreadRelink; + if (appliedCodexThreadRelink) { + // Every route-explicit runtime source uses the same relink CAS, so fallback and + // Agent selections are restart-safe and share the complete-tuple rollback path. + appliedPersistedRuntimeRoute = persistedRuntimeTargetRoute; + } + } // deferred = 会话自己在跑,选择已登记、turn 结束自动生效。renderer 据此提示 // "任务结束后生效"而不是当成已即时切换。 const response = { deferred: result.status === 'deferred', superseded: false, }; - if (supersededByOwnerBoundary()) { + const preservePersistedRoute = + result.status === 'deferred' && result.preservePersistedRoute === true; + appliedRuntimeSelectionWasDeferred = response.deferred; + const rollbackRuntimeSelectionForSupersededOwner = async (): Promise => { + if (runtimeOwnerBoundaryCurrent()) return false; + const restored = await rollbackAppliedRuntimeSelection(); + if (!restored) { + throw new Error('runtime selection rollback was superseded'); + } + if (internalOptions.source === 'user') { + assertRuntimeOwnerCurrent(); + } + return true; + }; + if (await rollbackRuntimeSelectionForSupersededOwner()) { return { deferred: false, superseded: true }; } if (atomicSelection) { @@ -15316,7 +15981,18 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) patch.fastMode = atomicSelection.fastMode; } try { - await persistSessionFields(sessionId, patch); + if (!preservePersistedRoute && !appliedPersistedRuntimeRoute) { + await persistSessionFields(sessionId, patch); + appliedPersistedRuntimeRoute = { + model, + providerId: + effectiveProviderId === undefined + ? runtimeStatus.providerId + : (normalizeSessionProviderId(effectiveProviderId) ?? null), + effort: atomicSelection?.effort ?? runtimeStatus.effort, + fastMode: atomicSelection?.fastMode ?? runtimeStatus.fastMode, + }; + } } catch (persistenceError) { // The live route and host stores are applied before SQLite so the // harness can switch atomically. If SQLite rejects, unwind every @@ -15324,7 +16000,17 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) // describe the old profile. pendingCredentialSwitchHolder?.clear(sessionId); restoreControlStores(); - let recoveryError: unknown; + const recoveryErrors: unknown[] = []; + if (appliedCodexThreadRelink) { + try { + const restored = await rollbackAppliedCodexThreadRelink(); + if (!restored) { + throw new Error('Codex thread relink rollback was superseded'); + } + } catch (error) { + recoveryErrors.push(error); + } + } if ( result.status !== 'deferred' && previousRuntime.hadLiveSession && @@ -15333,28 +16019,29 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) try { await withRehydrateCloseSuppressed(sessionId, () => maker.closeSession(sessionId)); } catch (error) { - recoveryError = error; + recoveryErrors.push(error); } } if (previousRuntime.pendingCredentialSwitch) { - pendingCredentialSwitchHolder?.register( + await pendingCredentialSwitchHolder?.register( sessionId, previousRuntime.pendingCredentialSwitch, ); - } else if (!recoveryError) { + } else if (recoveryErrors.length === 0) { wakeSessionInputAfterCredentialSwitch(sessionId); } - if (recoveryError) { + if (recoveryErrors.length > 0) { await reconcileRetainedLiveProfile(); throw new AggregateError( - [persistenceError, recoveryError], + [persistenceError, ...recoveryErrors], 'runtime selection persistence and session recovery both failed', ); } throw persistenceError; } if (isDeviceLinkInvoke()) { - // dispatch 继续兼容最小/旧 handler 的锁外回流;标记本结果避免重复写。 + // dispatch 继续兼容最小/旧 handler 的锁外回流。deferred relink 即使刻意 + // 保留旧 route,也必须标记为 host 已处理,禁止兼容回流把目标 route 写回。 markRemoteSettingPersistedInsideHandler(response); } } @@ -15363,7 +16050,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) agentSwitchIntent: null, agentSwitchIntentCanceled: true, }); - if (supersededByOwnerBoundary()) { + if (await rollbackRuntimeSelectionForSupersededOwner()) { return { deferred: false, superseded: true }; } let generation: number; @@ -15477,12 +16164,29 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) error: error instanceof Error ? error.message : String(error), }); }); + // No await is allowed between this final boundary check and success return. Teardown + // may begin while context/projection work above is awaiting; compensate the captured + // old-profile thread and route before the request can report success or supersession. + if (await rollbackRuntimeSelectionForSupersededOwner()) { + return { deferred: false, superseded: true }; + } return { ...response, generation, effectiveProviderId: normalizeSessionProviderId(effectiveProviderId) ?? null, }; } catch (err) { + try { + const restored = await rollbackAppliedRuntimeSelection(); + if (!restored) { + throw new Error('runtime selection rollback was superseded'); + } + } catch (rollbackError) { + throw new AggregateError( + [err, rollbackError], + 'runtime selection failed and persisted route rollback also failed', + ); + } if (err instanceof CredentialModeSwitchBusyError) { // 兜底(正常路径 busy 已转 deferred):切模型撞上凭证切换忙,独立 code, // renderer toast 走 ipcError.CREDENTIAL_SWITCH_BUSY 专属文案。 diff --git a/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts b/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts index 414692b8cd..7b3e731a29 100644 --- a/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts +++ b/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts @@ -8,6 +8,8 @@ import { } from '../maker-host/session-provider-store.js'; // type-only import:编译期擦除,不会把 codex-proxy-host 的运行时依赖拖进本模块/单测。 import type { CodexProxyAuthInjection } from '../maker-host/codex-proxy-host.js'; +import type { CodexProviderThreadRelinkReceipt } from './codexProviderThreadRelink.js'; +import type { PendingCredentialSwitchPersistedRoute } from './pendingCredentialSwitch.js'; import { CredentialModeSwitchBusyError, isCredentialModeSwitchBusyError, @@ -23,7 +25,10 @@ interface RuntimeSetModelSession { codexThreadModelProviderId?: string | null; codexCindyRemoteCompactionCompatible?: boolean | null; model: string; - setModel: (model: string, opts?: { providerId?: string | null; effort?: Effort }) => Promise; + setModel: ( + model: string, + opts?: { providerId?: string | null; effort?: Effort }, + ) => Promise; } interface RuntimeSetModelActiveSession { @@ -44,12 +49,29 @@ interface RuntimeSetModelLogger { info: (message: string, meta?: Record) => void; } +export interface RuntimeSetModelPersistedSession { + agentKind: AgentKind; + remoteHostId?: string | null; + model: string; + providerId: string | null; + sdkSessionId: string | null; + effort?: Effort; + fastMode?: boolean; +} + export interface ApplyRuntimeSetModelChangeInput { maker: RuntimeSetModelMaker; sessionId: string; model: string; providerId?: string | null; - effort?: Effort; + effort?: Effort | null; + fastMode?: boolean; + /** + * 锁内读取的切换前持久化会话身份。live handle 不存在时,旧 sdkSessionId 仍会在 + * 下一次 lazy-create 中作为 resumeSessionId;因此本地 Codex 的跨凭证家族切换 + * 必须基于这份旧快照先 relink,不能把「无 live handle」当成「无旧 thread」。 + */ + persistedSession?: RuntimeSetModelPersistedSession; /** * Orca Worker 的 live model/provider 属于执行单元身份,不能热切。即使凭证 * 形态相同,也必须沿用 credential-switch 的 idle close / busy defer 边界。 @@ -62,7 +84,26 @@ export interface ApplyRuntimeSetModelChangeInput { */ registerPendingCredentialSwitch?: ( sessionId: string, - target: { model: string; providerId: string | null }, + target: { + model: string; + providerId: string | null; + effort?: Effort; + fastMode?: boolean; + rebuildCodexThread?: boolean; + codexThreadRelinkCommitted?: boolean; + ownerScope?: { ownerScopeKey: string; runtimeOwnerEpoch: string }; + sourceCodexThreadModelProviderId?: string | null; + previousRoute?: { + model: string; + providerId: string | null; + effort?: string; + fastMode?: boolean; + }; + sourcePersistedSession?: RuntimeSetModelPersistedSession; + restoreStaleOwnerRoute?: ( + persistedRoute?: PendingCredentialSwitchPersistedRoute, + ) => Promise; + }, ) => void | Promise; /** * 「无需切换」分支清掉旧 pending(后选覆盖先选)。典型场景:deferred 登记后 @@ -83,23 +124,52 @@ export interface ApplyRuntimeSetModelChangeInput { * 模型是否仍需切换 —— 仍需则更新 pending 的模型,不需则取消 pending(review * P1 2026-07-04:折扣模型 pending 后切回普通模型,旧实现不清 pending)。 */ - getPendingCredentialSwitch?: ( - sessionId: string, - ) => { model: string; providerId: string | null } | undefined; + getPendingCredentialSwitch?: (sessionId: string) => + | { + model: string; + providerId: string | null; + rebuildCodexThread?: boolean; + codexThreadRelinkCommitted?: boolean; + ownerScope?: { ownerScopeKey: string; runtimeOwnerEpoch: string }; + sourceCodexThreadModelProviderId?: string | null; + previousRoute?: { + model: string; + providerId: string | null; + effort?: string; + fastMode?: boolean; + }; + restoreStaleOwnerRoute?: ( + persistedRoute?: PendingCredentialSwitchPersistedRoute, + ) => Promise; + } + | undefined; /** * 当前本地 Codex spawn 的鉴权注入形态(getCodexProxyAuthInjectionState())。 * shouldCloseSessionForCredentialSwitch 用它解析隐式来源的凭证家族,精确判定 * 是否跨「远端压缩身份」边界;不传时该判定按未知保守处理(倾向关会话重建)。 */ codexAuthInjection?: CodexProxyAuthInjection | null; + /** + * Codex 跨 cindy_gateway / cindy_openai 身份边界时,把旧 rollout 安全 fork 到 + * 新 thread 并原子替换 sessions.sdk_session_id。关 live Session 本身不会清除 + * 持久化 thread id;漏掉此步会在新来源下继续 resume 旧 thread。 + */ + relinkCodexThreadForProviderSwitch?: (input: { + sessionId: string; + sourceModel: string; + sourceProviderId: string | null; + sourceThreadModelProviderId?: string | null; + targetModel: string; + targetProviderId: string | null; + }) => Promise; logger?: RuntimeSetModelLogger; } export type ApplyRuntimeSetModelChangeResult = /** 直接生效(热切 route / 或已关会话待下次发送重建)。 */ - | { status: 'applied' } + | { status: 'applied'; codexThreadRelink?: CodexProviderThreadRelinkReceipt } /** 凭证形态要换但会话自己在跑:已登记 pending,turn 结束后自动生效。 */ - | { status: 'deferred' }; + | { status: 'deferred'; preservePersistedRoute?: true }; export function isRemoteModelSwitchRouteChangeError(error: unknown): boolean { return ( @@ -127,44 +197,60 @@ export function isRemoteModelSwitchRouteChangeError(error: unknown): boolean { export async function applyRuntimeSetModelChange( input: ApplyRuntimeSetModelChangeInput, ): Promise { - const { maker, sessionId, model, providerId, effort, isSessionInTurn, logger } = input; + const { maker, sessionId, model, providerId, effort, fastMode, isSessionInTurn, logger } = input; const normalizedProviderId = normalizeSessionProviderId(providerId); const sess = maker.getSession(sessionId); - const currentProviderId = getSessionProvider(sessionId); + const persistedCodexThread = + !sess && + input.persistedSession?.agentKind === 'codex' && + !input.persistedSession.remoteHostId && + Boolean(input.persistedSession.sdkSessionId) + ? input.persistedSession + : undefined; + const sourceSession = sess ?? persistedCodexThread; + // 无 live handle 时,provider store 可能尚未 hydrate,也可能被调用方的目标写入 + // 提前覆盖;切换前的 DB 快照才是旧 thread 的来源身份。 + const currentProviderId = persistedCodexThread + ? persistedCodexThread.providerId + : getSessionProvider(sessionId); // model-only 调用以 pending 的 providerId 为「当前来源意图」:用户先 deferred 选了 // 新来源、再换模型时,决策与登记都要沿用那个来源,不能回落到 store 里的旧值 // (否则会把 pending 的来源覆盖丢)。 const pendingTarget = - normalizedProviderId === undefined - ? input.getPendingCredentialSwitch?.(sessionId) - : undefined; + normalizedProviderId === undefined ? input.getPendingCredentialSwitch?.(sessionId) : undefined; const nextProviderId = normalizedProviderId !== undefined ? normalizedProviderId : pendingTarget !== undefined ? pendingTarget.providerId : currentProviderId; - const shouldCloseSession = sess - ? input.forceSessionRebuild === true || shouldCloseSessionForCredentialSwitch({ - agentKind: sess.agentKind, - remoteHostId: sess.remoteHostId, + const shouldCloseForCredentialSwitch = sourceSession + ? shouldCloseSessionForCredentialSwitch({ + agentKind: sourceSession.agentKind, + remoteHostId: sourceSession.remoteHostId, currentProviderId, nextProviderId, - currentModel: sess.model, + currentModel: sourceSession.model, nextModel: model, - currentCodexProxyActive: sess.codexProxyActive, - currentCodexThreadModelProviderId: sess.codexThreadModelProviderId, + // 持久化 sdkSessionId 是已经创建过的 provider-bound Codex thread。即使 + // app-server handle 已释放,跨远端压缩身份边界仍须按 proxy thread 判定。 + currentCodexProxyActive: sess ? sess.codexProxyActive : true, + currentCodexThreadModelProviderId: sess?.codexThreadModelProviderId, currentCodexCindyRemoteCompactionCompatible: - sess.codexCindyRemoteCompactionCompatible, + sess?.codexCindyRemoteCompactionCompatible, codexAuthInjection: input.codexAuthInjection, }) : false; + const shouldCloseSession = input.forceSessionRebuild === true || shouldCloseForCredentialSwitch; + const shouldRelinkCodexThread = + shouldCloseForCredentialSwitch && + sourceSession?.agentKind === 'codex' && + !sourceSession.remoteHostId && + (sess !== undefined || persistedCodexThread !== undefined); let selfBusyMemo: boolean | undefined; const isSelfBusy = (): boolean => { if (selfBusyMemo !== undefined) return selfBusyMemo; - const active = maker - .listActiveSessions() - .find((candidate) => candidate.id === sessionId); + const active = maker.listActiveSessions().find((candidate) => candidate.id === sessionId); selfBusyMemo = active ? isLocalSessionBusy(active, isSessionInTurn) : isSessionInTurn?.(sessionId) === true; @@ -187,6 +273,12 @@ export async function applyRuntimeSetModelChange( await input.registerPendingCredentialSwitch(sessionId, { model, providerId: nextProviderId, + ...(effort !== undefined && effort !== null ? { effort } : {}), + ...(fastMode !== undefined ? { fastMode } : {}), + ...(shouldRelinkCodexThread ? { rebuildCodexThread: true } : {}), + ...(input.persistedSession + ? { sourcePersistedSession: input.persistedSession } + : {}), }); logger?.info('set-model: Codex provider route switch deferred until turn end', { sessionId, @@ -195,7 +287,10 @@ export async function applyRuntimeSetModelChange( fromModel: sess.model, toModel: model, }); - return { status: 'deferred' }; + return { + status: 'deferred', + ...(shouldRelinkCodexThread ? { preservePersistedRoute: true } : {}), + }; } throw new CredentialModeSwitchBusyError( [sessionId], @@ -203,11 +298,17 @@ export async function applyRuntimeSetModelChange( ); } - if (sess && shouldCloseSession) { - if (isSelfBusy() && input.registerPendingCredentialSwitch) { + if ((sess && shouldCloseSession) || (!sess && shouldRelinkCodexThread)) { + if (sess && isSelfBusy() && input.registerPendingCredentialSwitch) { await input.registerPendingCredentialSwitch(sessionId, { model, providerId: nextProviderId, + ...(effort !== undefined && effort !== null ? { effort } : {}), + ...(fastMode !== undefined ? { fastMode } : {}), + ...(shouldRelinkCodexThread ? { rebuildCodexThread: true } : {}), + ...(input.persistedSession + ? { sourcePersistedSession: input.persistedSession } + : {}), }); logger?.info('set-model: credential switch deferred until turn end', { sessionId, @@ -217,7 +318,10 @@ export async function applyRuntimeSetModelChange( fromModel: sess.model, toModel: model, }); - return { status: 'deferred' }; + return { + status: 'deferred', + ...(shouldRelinkCodexThread ? { preservePersistedRoute: true } : {}), + }; } // 关会话前先清可能存在的 stale pending(后选覆盖先选):close 会触发宿主的 // onSessionClosed 钩子,pending 若还在会被它以**旧目标**抢先 finalize 并广播, @@ -228,44 +332,85 @@ export async function applyRuntimeSetModelChange( // (Greptile review #1035)。 const clearedPending = input.getPendingCredentialSwitch?.(sessionId); input.clearPendingCredentialSwitch?.(sessionId, { wake: false }); + let codexThreadRelink: CodexProviderThreadRelinkReceipt | null = null; try { - await prepareLocalSessionCredentialModeSwitch({ - maker, - sessionId, - isSessionInTurn, - }); + if (sess) { + await prepareLocalSessionCredentialModeSwitch({ + maker, + sessionId, + isSessionInTurn, + }); + } + if (shouldRelinkCodexThread) { + const relinkCodexThreadForProviderSwitch = input.relinkCodexThreadForProviderSwitch; + if (!relinkCodexThreadForProviderSwitch) { + throw new Error( + 'Codex provider thread relink is required for a credential-family switch', + ); + } + codexThreadRelink = await relinkCodexThreadForProviderSwitch({ + sessionId, + sourceModel: sourceSession!.model, + sourceProviderId: currentProviderId, + ...(sess?.codexThreadModelProviderId !== undefined + ? { sourceThreadModelProviderId: sess.codexThreadModelProviderId } + : {}), + targetModel: model, + targetProviderId: nextProviderId, + }); + if (persistedCodexThread && !codexThreadRelink) { + throw new Error( + 'Persisted Codex provider thread disappeared before credential-family relink', + ); + } + } } catch (err) { // 空闲判定与 close 之间的竞态(恰好起了新 turn):有 pending 通道就转延迟, // 没有(老调用方)保持抛 busy 的旧语义。 if (isCredentialModeSwitchBusyError(err) && input.registerPendingCredentialSwitch) { - input.registerPendingCredentialSwitch(sessionId, { + await input.registerPendingCredentialSwitch(sessionId, { model, providerId: nextProviderId, + ...(effort !== undefined && effort !== null ? { effort } : {}), + ...(fastMode !== undefined ? { fastMode } : {}), + ...(shouldRelinkCodexThread ? { rebuildCodexThread: true } : {}), + ...(input.persistedSession + ? { sourcePersistedSession: input.persistedSession } + : {}), }); logger?.info('set-model: credential switch deferred after busy race', { sessionId, - agentKind: sess.agentKind, + agentKind: sourceSession!.agentKind, }); - return { status: 'deferred' }; + return { + status: 'deferred', + ...(shouldRelinkCodexThread ? { preservePersistedRoute: true } : {}), + }; } // 非 busy 失败:恢复被清除的 pending,用户的待定来源选择不随异常丢失。 if (clearedPending && input.registerPendingCredentialSwitch) { - input.registerPendingCredentialSwitch(sessionId, clearedPending); + // 生产适配器会先等待旧 Profile 快照/路由补偿,再把 pending gate 重新登记。 + // 必须等它完成后才能把原 close/relink 错误返回给调用方;否则调用方可能在 + // gate 尚未恢复时继续派发,登记拒绝也会变成未处理 Promise rejection。 + await input.registerPendingCredentialSwitch(sessionId, clearedPending); } throw err; } if (providerId !== undefined) setSessionProvider(sessionId, nextProviderId); // close + route 都落定后再唤醒队列:排队消息按新凭证形态 lazy-create 派发。 input.wakeSessionInputQueue?.(sessionId); - logger?.info('set-model: closed live session after credential mode switch', { + logger?.info('set-model: rebuilt Codex thread or closed live session after credential switch', { sessionId, - agentKind: sess.agentKind, + agentKind: sourceSession!.agentKind, currentProviderId, nextProviderId, - fromModel: sess.model, + fromModel: sourceSession!.model, toModel: model, }); - return { status: 'applied' }; + return { + status: 'applied', + ...(codexThreadRelink ? { codexThreadRelink } : {}), + }; } if (providerId !== undefined) { @@ -291,12 +436,12 @@ export async function applyRuntimeSetModelChange( await sess.setModel(model, { // model-only 且内存尚未确认来源时不要把 providerId:null 传进 runtime, // 否则未 hydrate 的 custom 会话会被清成默认网关。 - ...(normalizedProviderId !== undefined - || hasSessionProvider(sessionId) - || pendingTarget !== undefined + ...(normalizedProviderId !== undefined || + hasSessionProvider(sessionId) || + pendingTarget !== undefined ? { providerId: nextProviderId } : {}), - ...(effort !== undefined ? { effort } : {}), + ...(effort !== undefined && effort !== null ? { effort } : {}), }); } catch (err) { if (providerId !== undefined) { @@ -306,7 +451,9 @@ export async function applyRuntimeSetModelChange( // (providerId !== undefined)时旧 pending 已被本次明确选择覆盖,恢复它会静默 // 撤销用户最后一次选择(Greptile review #1035 七轮)。 if (pendingTarget && input.registerPendingCredentialSwitch) { - input.registerPendingCredentialSwitch(sessionId, pendingTarget); + // 恢复适配器可能先等待旧 Profile 补偿再重新建立 pending gate;必须等它 + // 完成后再返回原 setModel 错误,避免后续输入穿透或 rejection 脱离调用链。 + await input.registerPendingCredentialSwitch(sessionId, pendingTarget); } throw err; } diff --git a/apps/desktop/src/main/windowsPackagedInstanceBarrier.ts b/apps/desktop/src/main/windowsPackagedInstanceBarrier.ts index 5a36b02ce2..99a52eb435 100644 --- a/apps/desktop/src/main/windowsPackagedInstanceBarrier.ts +++ b/apps/desktop/src/main/windowsPackagedInstanceBarrier.ts @@ -1,12 +1,33 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; const DEFAULT_ACQUIRE_TIMEOUT_MS = 5_000; +const HELPER_START_TIMEOUT_MS = 5_000; +const HELPER_PROBE_TIMEOUT_MS = 5_000; const HELPER_EXIT_TIMEOUT_MS = 2_000; const MAX_HELPER_OUTPUT_BYTES = 16 * 1024; const WINDOWS_PACKAGED_INSTANCE_BARRIER_SCRIPT = String.raw` $ErrorActionPreference = 'Stop' -Add-Type -TypeDefinition @' +[Console]::Out.WriteLine('{"status":"started"}') +[Console]::Out.Flush() + +$mutex = [System.Threading.Mutex]::new($false, $env:CINDY_SINGLETON_MUTEX_NAME) +$acquired = $false +try { + try { + $acquired = $mutex.WaitOne([int]$env:CINDY_SINGLETON_WAIT_MS) + } catch [System.Threading.AbandonedMutexException] { + $acquired = $true + } + if (-not $acquired) { + [Console]::Out.WriteLine('{"status":"busy"}') + [Console]::Out.Flush() + exit 2 + } + + [Console]::Out.WriteLine('{"status":"locked"}') + [Console]::Out.Flush() + Add-Type -TypeDefinition @' using System; using System.Runtime.InteropServices; @@ -24,20 +45,6 @@ public static class CindyProcessSingletonProbe { } '@ -$mutex = [System.Threading.Mutex]::new($false, $env:CINDY_SINGLETON_MUTEX_NAME) -$acquired = $false -try { - try { - $acquired = $mutex.WaitOne([int]$env:CINDY_SINGLETON_WAIT_MS) - } catch [System.Threading.AbandonedMutexException] { - $acquired = $true - } - if (-not $acquired) { - [Console]::Out.WriteLine('{"status":"busy"}') - [Console]::Out.Flush() - exit 2 - } - $messageOnlyWindow = [IntPtr]::new(-3) $window = [CindyProcessSingletonProbe]::FindWindowEx( $messageOnlyWindow, @@ -67,7 +74,11 @@ try { `; type BarrierStatus = - { status: 'acquired' } | { status: 'busy' } | { status: 'occupied'; pid: number }; + | { status: 'started' } + | { status: 'locked' } + | { status: 'acquired' } + | { status: 'busy' } + | { status: 'occupied'; pid: number }; function processSingletonNames(programName: string): { mutexName: string; @@ -93,7 +104,7 @@ function powershellPath(): string { return `${process.env.SystemRoot ?? 'C:\\Windows'}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`; } -function waitForFirstLine( +function waitForBarrierStatus( child: ChildProcessWithoutNullStreams, timeoutMs: number, ): Promise<{ line: string; stderr: string }> { @@ -101,6 +112,7 @@ function waitForFirstLine( let stdout = ''; let stderr = ''; let settled = false; + let timer: ReturnType | undefined; const finish = (settle: () => void): void => { if (settled) return; settled = true; @@ -111,10 +123,17 @@ function waitForFirstLine( child.off('exit', onExit); settle(); }; - const timer = setTimeout(() => { - finish(() => reject(new Error('timed out acquiring Windows packaged-instance barrier'))); - child.kill(); - }, timeoutMs + 1_000); + const armTimeout = (durationMs: number, stage: string): void => { + clearTimeout(timer); + timer = setTimeout(() => { + finish(() => + reject( + new Error(`timed out ${stage} Windows packaged-instance barrier`), + ), + ); + child.kill(); + }, durationMs); + }; const onStdout = (chunk: Buffer | string): void => { stdout += chunk.toString(); if (stdout.length > MAX_HELPER_OUTPUT_BYTES) { @@ -124,10 +143,29 @@ function waitForFirstLine( child.kill(); return; } - const newline = stdout.indexOf('\n'); - if (newline >= 0) { + let newline = stdout.indexOf('\n'); + while (newline >= 0) { const line = stdout.slice(0, newline).trim(); - finish(() => resolve({ line, stderr })); + stdout = stdout.slice(newline + 1); + let status: BarrierStatus; + try { + status = parseBarrierStatus(line); + } catch (error) { + finish(() => reject(error)); + child.kill(); + return; + } + if (status.status === 'started') { + // timeoutMs measures mutex contention only; PowerShell cold start is a separate phase. + armTimeout(timeoutMs + 1_000, 'waiting for'); + } else if (status.status === 'locked') { + // Add-Type + the message-window probe happen only after the mutex is safely held. + armTimeout(HELPER_PROBE_TIMEOUT_MS, 'probing'); + } else { + finish(() => resolve({ line, stderr })); + return; + } + newline = stdout.indexOf('\n'); } }; const onStderr = (chunk: Buffer | string): void => { @@ -149,12 +187,20 @@ function waitForFirstLine( child.stderr.on('data', onStderr); child.once('error', onError); child.once('exit', onExit); + armTimeout(HELPER_START_TIMEOUT_MS, 'starting'); }); } function parseBarrierStatus(line: string): BarrierStatus { const value = JSON.parse(line) as Partial; - if (value.status === 'acquired' || value.status === 'busy') return { status: value.status }; + if ( + value.status === 'started' || + value.status === 'locked' || + value.status === 'acquired' || + value.status === 'busy' + ) { + return { status: value.status }; + } if ( value.status === 'occupied' && typeof value.pid === 'number' && @@ -166,21 +212,31 @@ function parseBarrierStatus(line: string): BarrierStatus { throw new Error('Windows packaged-instance barrier returned an invalid status'); } -function waitForExit(child: ChildProcessWithoutNullStreams): Promise { +function waitForExit( + child: ChildProcessWithoutNullStreams, + timeoutMs = HELPER_EXIT_TIMEOUT_MS, +): Promise { if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(); return new Promise((resolve) => { let settled = false; const finish = (): void => { if (settled) return; settled = true; - clearTimeout(timer); + clearTimeout(terminateTimer); + clearTimeout(forceFinishTimer); child.off('exit', finish); resolve(); }; - const timer = setTimeout(() => { + let forceFinishTimer: ReturnType | undefined; + const terminateTimer = setTimeout(() => { child.kill(); - finish(); - }, HELPER_EXIT_TIMEOUT_MS); + // TerminateProcess is asynchronous on Windows. Keep waiting for the real exit event so a + // retry cannot race the still-owned mutex; retain a second bounded guard for broken hosts. + forceFinishTimer = setTimeout(() => { + child.kill(); + finish(); + }, timeoutMs); + }, timeoutMs); child.once('exit', finish); }); } @@ -222,7 +278,7 @@ export async function acquireWindowsPackagedInstanceBarrier(options: { ); let status: BarrierStatus; try { - const { line } = await waitForFirstLine(child, timeoutMs); + const { line } = await waitForBarrierStatus(child, timeoutMs); status = parseBarrierStatus(line); } catch (error) { child.kill(); @@ -264,4 +320,5 @@ export const __testing = { WINDOWS_PACKAGED_INSTANCE_BARRIER_SCRIPT, parseBarrierStatus, processSingletonNames, + waitForExit, }; diff --git a/apps/desktop/src/renderer/__tests__/deviceLinkInteractionScenarios.test.ts b/apps/desktop/src/renderer/__tests__/deviceLinkInteractionScenarios.test.ts index 3910cf4d39..e481a43f64 100644 --- a/apps/desktop/src/renderer/__tests__/deviceLinkInteractionScenarios.test.ts +++ b/apps/desktop/src/renderer/__tests__/deviceLinkInteractionScenarios.test.ts @@ -956,7 +956,7 @@ describe('远程交互接线不变式', () => { expect(body.slice(activeBranch, elseBranch)).not.toContain('modelMemory?.setFast'); }); - it('ChatInput 远程切模型优先原子提交 model/effort/fast,旧 host 才走兼容链', () => { + it('ChatInput 远程切模型优先原子提交,旧 host 的 deferred 也走完兼容链', () => { const src = read('components/new-chat/ChatInput.tsx'); const start = src.indexOf('if (sourceRemoteDeviceId) {'); expect(start).toBeGreaterThan(-1); @@ -967,6 +967,7 @@ describe('远程交互接线不变式', () => { const sync = body.indexOf('syncSessionDraftModelPrefs('); expect(atomic).toBeGreaterThan(-1); expect(fallback).toBeGreaterThan(atomic); + expect(body.slice(fallback, fallback + 80)).not.toContain('remoteDeferred'); expect(persist).toBeGreaterThan( body.indexOf('await remoteMaker.setEffort(sessionId, newEffort);'), ); @@ -978,7 +979,7 @@ describe('远程交互接线不变式', () => { expect(body.slice(sync, sync + 300)).toContain('fast: fastPersisted ? restoredFast : fastMode'); }); - it('ChatInput 远程切来源优先原子提交选择快照,旧 host 才走兼容链', () => { + it('ChatInput 远程切来源优先原子提交,旧 host 的 deferred 也走完兼容链', () => { const src = read('components/new-chat/ChatInput.tsx'); const start = src.indexOf('if (sessionId && sourceRemoteDeviceId)'); const end = src.indexOf('// 把这次切换后落定的 (model, effort)', start); @@ -992,6 +993,7 @@ describe('远程交互接线不变式', () => { const finalize = body.indexOf('onModelDidChange?.(targetModel);'); expect(atomic).toBeGreaterThan(-1); expect(fallback).toBeGreaterThan(atomic); + expect(body.slice(fallback, fallback + 80)).not.toContain('remoteDeferred'); expect(persist).toBeGreaterThan( body.indexOf('await remoteMaker.setEffort(sessionId, targetEffort);'), ); diff --git a/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx b/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx index a412291c89..8c2b86c260 100644 --- a/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx +++ b/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx @@ -6747,6 +6747,8 @@ export function ChatInput({ return false; } remoteDeferred = remoteSetModelResult?.deferred === true; + // Old hosts receive axes as separate calls. Their pending handlers persist these + // values without touching the running turn, so deferred must still finish the chain. if (!useAtomicSelection) { await remoteMaker.setEffort(sessionId, newEffort); fastPersisted = await persistFastModeChange(restoredFast, { @@ -7267,6 +7269,7 @@ export function ChatInput({ return false; } remoteDeferred = remoteSetModelResult?.deferred === true; + // Keep the old-host selection tuple complete even when SET_MODEL was deferred. if (!useAtomicSelection) { await remoteMaker.setEffort(sessionId, targetEffort); fastPersisted = await persistFastModeChange(restoredFast, {