From e5dd4d700082027b7687e083720a4bf805141dae Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Fri, 28 Aug 2026 02:49:01 +0800 Subject: [PATCH 01/36] fix(desktop): relink Codex thread across providers Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- .../cardActionTakeoverReplace.test.ts | 2 + .../src/main/im/shared/cardActionHandler.ts | 2 + .../maker-host/codex-credential-switch.ts | 7 +- .../codexProviderThreadRelink.test.ts | 80 +++++++++++++++++ .../__tests__/messageDeleteHandler.test.ts | 6 +- .../__tests__/pendingCredentialSwitch.test.ts | 61 ++++++++++++- .../__tests__/runtimeSetModel.test.ts | 89 ++++++++++++++++++- .../maker-ipc/codexProviderThreadRelink.ts | 64 +++++++++++++ .../main/maker-ipc/pendingCredentialSwitch.ts | 46 ++++++++++ apps/desktop/src/main/maker-ipc/register.ts | 86 +++++++++++++++++- .../src/main/maker-ipc/runtimeSetModel.ts | 41 ++++++++- 11 files changed, 473 insertions(+), 11 deletions(-) create mode 100644 apps/desktop/src/main/maker-ipc/__tests__/codexProviderThreadRelink.test.ts create mode 100644 apps/desktop/src/main/maker-ipc/codexProviderThreadRelink.ts 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..634943718a 100644 --- a/apps/desktop/src/main/im/shared/__tests__/cardActionTakeoverReplace.test.ts +++ b/apps/desktop/src/main/im/shared/__tests__/cardActionTakeoverReplace.test.ts @@ -23,6 +23,7 @@ const mocks = vi.hoisted(() => ({ resolveLenientSessionRoute: vi.fn(), applyRuntimeSetModelChange: vi.fn<(input: unknown) => Promise<{ status: 'applied' | 'deferred' }>>(), + relinkCodexThreadForCredentialSwitch: vi.fn(async () => {}), registerPendingCredentialSwitchForSession: vi.fn(), clearPendingCredentialSwitchForSession: vi.fn(), wakeSessionInputAfterCredentialSwitch: vi.fn(), @@ -97,6 +98,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, diff --git a/apps/desktop/src/main/im/shared/cardActionHandler.ts b/apps/desktop/src/main/im/shared/cardActionHandler.ts index 036d609bd2..1025cc8c9d 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, @@ -393,6 +394,7 @@ export function createCardActionHandler( clearPendingCredentialSwitch: clearPendingCredentialSwitchForSession, wakeSessionInputQueue: wakeSessionInputAfterCredentialSwitch, getPendingCredentialSwitch: getPendingCredentialSwitchTarget, + relinkCodexThreadForProviderSwitch: relinkCodexThreadForCredentialSwitch, logger: log, }); 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 9b59530b38..fb795c4323 100644 --- a/apps/desktop/src/main/maker-host/codex-credential-switch.ts +++ b/apps/desktop/src/main/maker-host/codex-credential-switch.ts @@ -247,8 +247,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 时按未知处理 → 与另一侧不同即保守关闭。 @@ -259,7 +260,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..03e581b6d2 --- /dev/null +++ b/apps/desktop/src/main/maker-ipc/__tests__/codexProviderThreadRelink.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { relinkCodexProviderThread } from '../codexProviderThreadRelink.js'; + +describe('relinkCodexProviderThread', () => { + 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', model: 'gpt-5.6-sol', providerId: 'openai' }, + ), + ).resolves.toEqual({ + previousSdkSessionId: 'thread-xd', + newSdkSessionId: 'thread-openai', + }); + + expect(fork).toHaveBeenCalledWith({ + sourceSdkSessionId: 'thread-xd', + model: 'gpt-5.6-sol', + providerId: 'openai', + 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('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', model: 'gpt-5.6-sol', providerId: 'openai' }, + ), + ).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', model: 'gpt-5.6-sol', providerId: 'openai' }, + ), + ).rejects.toThrow(/superseded/); + }); +}); 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..5717b1c1ee 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/pendingCredentialSwitch.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/pendingCredentialSwitch.test.ts @@ -41,6 +41,9 @@ function createHarness( const persistRoute = vi.fn>( async () => {}, ); + const relinkCodexThreadForProviderSwitch = vi.fn< + NonNullable + >(async () => {}); const service = new PendingCredentialSwitchService({ maker: { listActiveSessions: () => sessions, @@ -49,10 +52,19 @@ function createHarness( broadcastApplied, onApplied, persistRoute, + relinkCodexThreadForProviderSwitch, ...(opts?.resolveRoute ? { resolveRoute: opts.resolveRoute } : {}), ...(opts?.retryDelayMs !== undefined ? { retryDelayMs: opts.retryDelayMs } : {}), }); - return { service, closeSession, broadcastApplied, onApplied, persistRoute, sessions }; + return { + service, + closeSession, + broadcastApplied, + onApplied, + persistRoute, + relinkCodexThreadForProviderSwitch, + sessions, + }; } describe('PendingCredentialSwitchService', () => { @@ -93,6 +105,53 @@ describe('PendingCredentialSwitchService', () => { expect(h.onApplied).toHaveBeenCalledWith(sessionId); }); + it('relinks a deferred Codex thread before route persistence and queue wake', async () => { + const sessionId = rememberSession('pending-switch-relink-codex-thread'); + setSessionProvider(sessionId, 'xd'); + const h = createHarness([ + { id: sessionId, agentKind: 'codex', remoteHostId: null, isTurnRunning: () => false }, + ]); + + h.service.register(sessionId, { + model: 'gpt-5.6-sol', + providerId: 'openai', + rebuildCodexThread: true, + }); + await h.service.onTurnSettled(sessionId); + + expect(h.closeSession).toHaveBeenCalledWith(sessionId); + expect(h.relinkCodexThreadForProviderSwitch).toHaveBeenCalledWith({ + sessionId, + model: 'gpt-5.6-sol', + providerId: 'openai', + }); + expect(h.relinkCodexThreadForProviderSwitch.mock.invocationCallOrder[0]).toBeLessThan( + h.onApplied.mock.invocationCallOrder[0]!, + ); + }); + + 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('re-registration overwrites the previous pending target (last click wins)', async () => { const sessionId = rememberSession('pending-switch-overwrite'); setSessionProvider(sessionId, 'openai'); 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 19a1329f31..4f658524c7 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 () => {}), }); expect(closeSession).toHaveBeenCalledWith(sessionId); @@ -270,6 +271,7 @@ describe('applyRuntimeSetModelChange', () => { sessionId, model: 'xai/grok-4.3', providerId: 'xai', + relinkCodexThreadForProviderSwitch: vi.fn(async () => {}), }); expect(closeSession).toHaveBeenCalledWith(sessionId); @@ -343,6 +345,7 @@ describe('applyRuntimeSetModelChange', () => { expect(registerPendingCredentialSwitch).toHaveBeenCalledWith(sessionId, { model: 'codex/gpt-5.5', providerId: 'xd', + rebuildCodexThread: true, }); expect(closeSession).not.toHaveBeenCalled(); expect(setModel).not.toHaveBeenCalled(); @@ -357,6 +360,7 @@ describe('applyRuntimeSetModelChange', () => { const setModel = vi.fn(async () => {}); const closeSession = vi.fn(async () => {}); const registerPendingCredentialSwitch = vi.fn(); + const relinkCodexThreadForProviderSwitch = vi.fn(async () => {}); const maker: RuntimeSetModelMaker = { getSession: () => ({ agentKind: 'codex', remoteHostId: null, codexProxyActive: true, @@ -376,15 +380,90 @@ 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, + model: 'codex/gpt-5.5', + providerId: '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 () => {}); + 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, + model: 'gpt-5.6-sol', + providerId: 'openai', + }); + expect(getSessionProvider(sessionId)).toBe('openai'); + }); + + 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('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'); @@ -413,6 +492,7 @@ describe('applyRuntimeSetModelChange', () => { sessionId, model: 'deepseek/deepseek-v4-pro', providerId: 'deepseek', + relinkCodexThreadForProviderSwitch: vi.fn(async () => {}), }); expect(result).toEqual({ status: 'applied' }); @@ -451,6 +531,7 @@ describe('applyRuntimeSetModelChange', () => { expect(registerPendingCredentialSwitch).toHaveBeenCalledWith(sessionId, { model: 'codex/gpt-5.5', providerId: 'xd', + rebuildCodexThread: true, }); expect(setModel).not.toHaveBeenCalled(); expect(getSessionProvider(sessionId)).toBe('openai'); @@ -584,6 +665,7 @@ describe('applyRuntimeSetModelChange', () => { model: 'xai/grok-4.3', providerId: 'xai', codexAuthInjection: 'oauth-bearer', + relinkCodexThreadForProviderSwitch: vi.fn(async () => {}), }); expect(closeSession).toHaveBeenCalledWith(sessionId); @@ -618,6 +700,7 @@ describe('applyRuntimeSetModelChange', () => { model: 'codex/gpt-5.5', providerId: 'xd', registerPendingCredentialSwitch: vi.fn(), + relinkCodexThreadForProviderSwitch: vi.fn(async () => {}), }); expect(result).toEqual({ status: 'applied' }); @@ -658,6 +741,7 @@ describe('applyRuntimeSetModelChange', () => { expect(registerPendingCredentialSwitch).toHaveBeenCalledWith(sessionId, { model: 'gpt-5.5', providerId: 'xd', + rebuildCodexThread: true, }); expect(closeSession).not.toHaveBeenCalled(); // route 保持旧值:运行中的 turn 继续用原来源,pending 兑现时才写新值。 @@ -764,6 +848,7 @@ describe('applyRuntimeSetModelChange', () => { expect(registerPendingCredentialSwitch).toHaveBeenCalledWith(sessionId, { model: 'gpt-5.4', providerId: 'xd', + rebuildCodexThread: true, }); // route 保持旧值,等 pending 兑现。 expect(getSessionProvider(sessionId)).toBe('openai'); @@ -780,6 +865,7 @@ 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'); }); const maker: RuntimeSetModelMaker = { getSession: () => ({ agentKind: 'codex', @@ -801,12 +887,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..3a85d9c47e --- /dev/null +++ b/apps/desktop/src/main/maker-ipc/codexProviderThreadRelink.ts @@ -0,0 +1,64 @@ +/** + * 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 CodexProviderThreadRelinkDeps { + readSource(sessionId: string): Promise; + fork(input: { + sourceSdkSessionId: string; + model: string; + providerId: string | null; + workingDir?: string; + }): Promise<{ newSdkSessionId: string }>; + commit(input: { + sessionId: string; + expectedSdkSessionId: string; + newSdkSessionId: string; + }): Promise; + onCommitted?(input: { + sessionId: string; + previousSdkSessionId: string; + newSdkSessionId: string; + }): void; +} + +export async function relinkCodexProviderThread( + deps: CodexProviderThreadRelinkDeps, + input: { sessionId: string; model: string; providerId: string | null }, +): 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, + model: input.model, + providerId: input.providerId, + ...(source.workingDir ? { workingDir: source.workingDir } : {}), + }); + const committed = await deps.commit({ + sessionId: input.sessionId, + expectedSdkSessionId: source.sdkSessionId, + newSdkSessionId: forked.newSdkSessionId, + }); + 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..a7b8193056 100644 --- a/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts +++ b/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts @@ -38,6 +38,8 @@ const PENDING_APPLY_RETRY_DELAY_MS = 10_000; export interface PendingCredentialSwitch { model: string; providerId: string | null; + /** 跨 cindy_gateway / cindy_openai 身份边界,收口前必须换成新的 native thread。 */ + rebuildCodexThread?: boolean; /** 目标会话的 agent(register 时由调用方捕获,收口前的停用重裁决用;可缺席 = 不裁决)。 */ agentKind?: AgentKind; /** @@ -102,6 +104,15 @@ export interface PendingCredentialSwitchDeps { sessionId: string, route: { providerId: string | null; model?: string; effort?: string; fastMode?: boolean }, ) => Promise; + /** + * 把旧 Codex rollout 安全 fork 到目标来源使用的新 thread,并 CAS 替换持久化 + * sdkSessionId。失败时 pending 保留、输入队列继续 gated,等待自愈重试。 + */ + relinkCodexThreadForProviderSwitch?: (input: { + sessionId: string; + model: string; + providerId: string | null; + }) => Promise; /** 自愈兜底重试间隔覆写(测试用)。 */ retryDelayMs?: number; logger?: { @@ -125,6 +136,7 @@ export class PendingCredentialSwitchService { target: { model: string; providerId: string | null; + rebuildCodexThread?: boolean; agentKind?: AgentKind; previousRoute?: { model: string; providerId: string | null; effort?: string; fastMode?: boolean }; }, @@ -132,6 +144,7 @@ export class PendingCredentialSwitchService { this.pending.set(sessionId, { model: target.model, providerId: target.providerId, + ...(target.rebuildCodexThread ? { rebuildCodexThread: true } : {}), ...(target.agentKind ? { agentKind: target.agentKind } : {}), ...(target.previousRoute ? { previousRoute: target.previousRoute } : {}), requestedAt: Date.now(), @@ -341,6 +354,39 @@ export class PendingCredentialSwitchService { reason: string, ): Promise { if (resolved.apply) { + if (target.rebuildCodexThread) { + 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.pending.get(sessionId) === target) this.scheduleRetry(sessionId); + return; + } + try { + await relink({ + sessionId, + model: resolved.model ?? target.model, + providerId: resolved.providerId, + }); + } 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 (this.pending.get(sessionId) === target) this.scheduleRetry(sessionId); + return; + } + if (this.pending.get(sessionId) !== target) return; + // persistRoute 失败会重试本 target;thread 已完成 CAS 换代,不得再次 fork。 + target.rebuildCodexThread = false; + } setSessionProvider(sessionId, resolved.providerId); // 裁决改了落地路由(reroute / 丢弃停用显式来源 / 全停换模型或清空):回写 DB // —— renderer 在 deferred 接受时已按请求值落盘 sessions 行,不纠正的话下一次 diff --git a/apps/desktop/src/main/maker-ipc/register.ts b/apps/desktop/src/main/maker-ipc/register.ts index 1925552b8d..217b1f9971 100644 --- a/apps/desktop/src/main/maker-ipc/register.ts +++ b/apps/desktop/src/main/maker-ipc/register.ts @@ -881,6 +881,7 @@ import { applyRuntimeSetModelChange, isRemoteModelSwitchRouteChangeError, } from './runtimeSetModel.js'; +import { relinkCodexProviderThread } from './codexProviderThreadRelink.js'; import { applyRuntimeSelectionAxesWithRecovery, commitRuntimeAxisAfterPersistence, @@ -3053,7 +3054,11 @@ export function cancelPendingAgentSwitchForSession(sessionId: string): void { */ export async function registerPendingCredentialSwitchForSession( sessionId: string, - target: { model: string; providerId: string | null }, + target: { + model: string; + providerId: string | null; + rebuildCodexThread?: boolean; + }, ): Promise { const service = pendingCredentialSwitchHolder; if (!service) { @@ -3107,6 +3112,83 @@ export async function registerPendingCredentialSwitchForSession( }); } +/** + * 跨订阅远端压缩身份边界时,把旧 rollout 安全 fork 到新 thread,再以 CAS + * 替换同一个 Cindy session 的 sdkSessionId。该函数同时供 Desktop 与 IM 模型选择 + * 使用;调用方必须先关闭 live Session,并保持输入队列 gated。 + */ +export async function relinkCodexThreadForCredentialSwitch(input: { + sessionId: string; + model: string; + providerId: string | null; +}): Promise { + const ownerScope = captureDataOwnerBroadcastScope(); + const dbSnapshot = getCurrentDbClientSnapshot(); + if (!dbSnapshot) throw new Error('Codex provider thread relink requires an active database'); + + 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, model, providerId, workingDir }) => { + const forked = await getMaker().forkSdkSession('codex', { + sourceSdkSessionId, + model, + providerId, + upToMessageId: undefined, + ...(workingDir ? { workingDir } : {}), + stripEncryptedReasoning: true, + remoteHostId: null, + }); + return { newSdkSessionId: forked.newSdkSessionId }; + }, + commit: async ({ sessionId, expectedSdkSessionId, newSdkSessionId }) => { + if ( + !isDataOwnerBroadcastScopeCurrent(ownerScope) || + getCurrentDbClientSnapshot()?.clientEpoch !== dbSnapshot.clientEpoch + ) { + return false; + } + const now = Date.now(); + const write = await dbSnapshot.client + .drizzle.update(sessions) + .set({ sdkSessionId: newSdkSessionId, updatedAt: now }) + .where(and(eq(sessions.id, sessionId), eq(sessions.sdkSessionId, expectedSdkSessionId))) + .run(); + if (write.changes === 0) return false; + broadcastSessionPatched( + sessionId, + { + sdkSessionId: newSdkSessionId, + 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, + model: input.model, + providerId: input.providerId, + }); + }, + }, + input, + ); +} + export function clearPendingCredentialSwitchForSession( sessionId: string, opts?: { wake?: boolean }, @@ -13426,6 +13508,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) onApplied: (sessionId) => { inputCoordinator.wakeSession(sessionId, 'pending-credential-switch-applied'); }, + relinkCodexThreadForProviderSwitch: relinkCodexThreadForCredentialSwitch, // 停用轴:deferred 切换收口前重裁决(SET_MODEL 时刻裁决过,但生效可能在数分钟 // 后,期间目标可能被停用;PR #744 review 第七轮,第十四轮换宽松降级形态 —— // 目标全停时连模型一起换到启用兜底)。 @@ -15256,6 +15339,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) // 解析隐式来源的凭证家族,精确判定是否跨远端压缩身份边界(见 // shouldCloseSessionForCredentialSwitch.codexAuthInjection)。 codexAuthInjection: getCodexProxyAuthInjectionState(), + relinkCodexThreadForProviderSwitch: relinkCodexThreadForCredentialSwitch, logger: log, }) : { status: 'applied' as const }; diff --git a/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts b/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts index 3a04a27397..aed7ba8047 100644 --- a/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts +++ b/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts @@ -61,7 +61,11 @@ export interface ApplyRuntimeSetModelChangeInput { */ registerPendingCredentialSwitch?: ( sessionId: string, - target: { model: string; providerId: string | null }, + target: { + model: string; + providerId: string | null; + rebuildCodexThread?: boolean; + }, ) => void | Promise; /** * 「无需切换」分支清掉旧 pending(后选覆盖先选)。典型场景:deferred 登记后 @@ -91,6 +95,16 @@ export interface ApplyRuntimeSetModelChangeInput { * 是否跨「远端压缩身份」边界;不传时该判定按未知保守处理(倾向关会话重建)。 */ 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; + model: string; + providerId: string | null; + }) => Promise; logger?: RuntimeSetModelLogger; } @@ -143,8 +157,8 @@ export async function applyRuntimeSetModelChange( : pendingTarget !== undefined ? pendingTarget.providerId : currentProviderId; - const shouldCloseSession = sess - ? input.forceSessionRebuild === true || shouldCloseSessionForCredentialSwitch({ + const shouldCloseForCredentialSwitch = sess + ? shouldCloseSessionForCredentialSwitch({ agentKind: sess.agentKind, remoteHostId: sess.remoteHostId, currentProviderId, @@ -156,6 +170,11 @@ export async function applyRuntimeSetModelChange( codexAuthInjection: input.codexAuthInjection, }) : false; + const shouldCloseSession = input.forceSessionRebuild === true || shouldCloseForCredentialSwitch; + const shouldRelinkCodexThread = + shouldCloseForCredentialSwitch && + sess?.agentKind === 'codex' && + !sess.remoteHostId; let selfBusyMemo: boolean | undefined; const isSelfBusy = (): boolean => { if (selfBusyMemo !== undefined) return selfBusyMemo; @@ -184,6 +203,7 @@ export async function applyRuntimeSetModelChange( await input.registerPendingCredentialSwitch(sessionId, { model, providerId: nextProviderId, + ...(shouldRelinkCodexThread ? { rebuildCodexThread: true } : {}), }); logger?.info('set-model: Codex provider route switch deferred until turn end', { sessionId, @@ -205,6 +225,7 @@ export async function applyRuntimeSetModelChange( await input.registerPendingCredentialSwitch(sessionId, { model, providerId: nextProviderId, + ...(shouldRelinkCodexThread ? { rebuildCodexThread: true } : {}), }); logger?.info('set-model: credential switch deferred until turn end', { sessionId, @@ -231,6 +252,19 @@ export async function applyRuntimeSetModelChange( sessionId, isSessionInTurn, }); + if (shouldRelinkCodexThread) { + const relinkCodexThreadForProviderSwitch = input.relinkCodexThreadForProviderSwitch; + if (!relinkCodexThreadForProviderSwitch) { + throw new Error( + 'Codex provider thread relink is required for a credential-family switch', + ); + } + await relinkCodexThreadForProviderSwitch({ + sessionId, + model, + providerId: nextProviderId, + }); + } } catch (err) { // 空闲判定与 close 之间的竞态(恰好起了新 turn):有 pending 通道就转延迟, // 没有(老调用方)保持抛 busy 的旧语义。 @@ -238,6 +272,7 @@ export async function applyRuntimeSetModelChange( input.registerPendingCredentialSwitch(sessionId, { model, providerId: nextProviderId, + ...(shouldRelinkCodexThread ? { rebuildCodexThread: true } : {}), }); logger?.info('set-model: credential switch deferred after busy race', { sessionId, From 239d279be4e82ae117a2f3704bdf5a3b0e10a02d Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:07:08 +0800 Subject: [PATCH 02/36] fix(desktop): close Codex relink races Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- .../sessionRuntimeControlWiring.test.ts | 27 +++ .../codexProviderThreadRelink.test.ts | 72 ++++++- .../__tests__/pendingCredentialSwitch.test.ts | 70 ++++++- .../__tests__/runtimeSetModel.test.ts | 84 +++++++-- .../maker-ipc/codexProviderThreadRelink.ts | 42 ++++- .../main/maker-ipc/pendingCredentialSwitch.ts | 100 ++++++++-- apps/desktop/src/main/maker-ipc/register.ts | 178 ++++++++++++++---- .../src/main/maker-ipc/runtimeSetModel.ts | 76 +++++--- 8 files changed, 546 insertions(+), 103 deletions(-) diff --git a/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts b/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts index 06b7682f8e..e946a827b6 100644 --- a/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts +++ b/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts @@ -186,6 +186,33 @@ describe('session runtime control wiring', () => { expect(axisValidation).toBeLessThan(setModel.indexOf('persistSessionFields(sessionId')); }); + it('rolls back a committed Codex thread relink when atomic route persistence fails', () => { + const setModel = handlerBody( + registerSource, + 'const handleSetModel = async (', + 'const recoverRemoteRuntimeAxisPersistence', + ); + const persist = setModel.indexOf('await persistSessionFields(sessionId, patch)'); + const rollback = setModel.indexOf('await result.codexThreadRelink.rollback()'); + + expect(persist).toBeGreaterThan(-1); + expect(rollback).toBeGreaterThan(persist); + expect(setModel).toContain("throw new Error('Codex thread relink rollback was superseded')"); + }); + + 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.sourceCodexThreadModelProviderId'); + expect(getter).toContain('pending.previousRoute'); + }); + 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/maker-ipc/__tests__/codexProviderThreadRelink.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/codexProviderThreadRelink.test.ts index 03e581b6d2..0cc817f8cd 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/codexProviderThreadRelink.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/codexProviderThreadRelink.test.ts @@ -19,7 +19,11 @@ describe('relinkCodexProviderThread', () => { commit, onCommitted, }, - { sessionId: 'session-1', model: 'gpt-5.6-sol', providerId: 'openai' }, + { + sessionId: 'session-1', + sourceModel: 'codex/gpt-5.6-sol', + sourceProviderId: 'xd', + }, ), ).resolves.toEqual({ previousSdkSessionId: 'thread-xd', @@ -28,8 +32,8 @@ describe('relinkCodexProviderThread', () => { expect(fork).toHaveBeenCalledWith({ sourceSdkSessionId: 'thread-xd', - model: 'gpt-5.6-sol', - providerId: 'openai', + sourceModel: 'codex/gpt-5.6-sol', + sourceProviderId: 'xd', workingDir: '/work', }); expect(commit).toHaveBeenCalledWith({ @@ -55,7 +59,11 @@ describe('relinkCodexProviderThread', () => { fork, commit, }, - { sessionId: 'session-1', model: 'gpt-5.6-sol', providerId: 'openai' }, + { + sessionId: 'session-1', + sourceModel: 'codex/gpt-5.6-sol', + sourceProviderId: 'xd', + }, ), ).resolves.toBeNull(); expect(fork).not.toHaveBeenCalled(); @@ -73,8 +81,62 @@ describe('relinkCodexProviderThread', () => { fork: vi.fn(async () => ({ newSdkSessionId: 'thread-openai' })), commit: vi.fn(async () => false), }, - { sessionId: 'session-1', model: 'gpt-5.6-sol', providerId: 'openai' }, + { + 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__/pendingCredentialSwitch.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/pendingCredentialSwitch.test.ts index 5717b1c1ee..9cfe504e5b 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/pendingCredentialSwitch.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/pendingCredentialSwitch.test.ts @@ -43,7 +43,7 @@ function createHarness( ); const relinkCodexThreadForProviderSwitch = vi.fn< NonNullable - >(async () => {}); + >(async () => null); const service = new PendingCredentialSwitchService({ maker: { listActiveSessions: () => sessions, @@ -116,14 +116,18 @@ describe('PendingCredentialSwitchService', () => { 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.closeSession).toHaveBeenCalledWith(sessionId); expect(h.relinkCodexThreadForProviderSwitch).toHaveBeenCalledWith({ sessionId, - model: 'gpt-5.6-sol', - providerId: 'openai', + sourceModel: 'codex/gpt-5.6-sol', + sourceProviderId: 'xd', + targetModel: 'gpt-5.6-sol', + targetProviderId: 'openai', + isCurrent: expect.any(Function), }); expect(h.relinkCodexThreadForProviderSwitch.mock.invocationCallOrder[0]).toBeLessThan( h.onApplied.mock.invocationCallOrder[0]!, @@ -152,6 +156,66 @@ describe('PendingCredentialSwitchService', () => { h.service.clear(sessionId); }); + it('rolls back the relink when deferred route persistence fails', 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.mockResolvedValueOnce({ + previousSdkSessionId: 'thread-xd', + newSdkSessionId: 'thread-openai', + rollback, + }); + h.persistRoute.mockRejectedValueOnce(new Error('sqlite failed')); + + 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(rollback).toHaveBeenCalledOnce(); + expect(h.service.get(sessionId)?.rebuildCodexThread).toBe(true); + expect(h.onApplied).not.toHaveBeenCalled(); + h.service.clear(sessionId); + }); + + 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' }, + }); + 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'); 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 4f658524c7..d086178bd7 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/runtimeSetModel.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/runtimeSetModel.test.ts @@ -235,7 +235,7 @@ describe('applyRuntimeSetModelChange', () => { sessionId, model: 'codex/gpt-5.5', providerId: 'xd', - relinkCodexThreadForProviderSwitch: vi.fn(async () => {}), + relinkCodexThreadForProviderSwitch: vi.fn(async () => null), }); expect(closeSession).toHaveBeenCalledWith(sessionId); @@ -271,7 +271,7 @@ describe('applyRuntimeSetModelChange', () => { sessionId, model: 'xai/grok-4.3', providerId: 'xai', - relinkCodexThreadForProviderSwitch: vi.fn(async () => {}), + relinkCodexThreadForProviderSwitch: vi.fn(async () => null), }); expect(closeSession).toHaveBeenCalledWith(sessionId); @@ -360,7 +360,7 @@ describe('applyRuntimeSetModelChange', () => { const setModel = vi.fn(async () => {}); const closeSession = vi.fn(async () => {}); const registerPendingCredentialSwitch = vi.fn(); - const relinkCodexThreadForProviderSwitch = vi.fn(async () => {}); + const relinkCodexThreadForProviderSwitch = vi.fn(async () => null); const maker: RuntimeSetModelMaker = { getSession: () => ({ agentKind: 'codex', remoteHostId: null, codexProxyActive: true, @@ -388,8 +388,10 @@ describe('applyRuntimeSetModelChange', () => { expect(closeSession).toHaveBeenCalledWith(sessionId); expect(relinkCodexThreadForProviderSwitch).toHaveBeenCalledWith({ sessionId, - model: 'codex/gpt-5.5', - providerId: 'xd', + sourceModel: 'gpt-5.4', + sourceProviderId: 'openai', + targetModel: 'codex/gpt-5.5', + targetProviderId: 'xd', }); expect(setModel).not.toHaveBeenCalled(); expect(getSessionProvider(sessionId)).toBe('xd'); @@ -399,7 +401,7 @@ describe('applyRuntimeSetModelChange', () => { const sessionId = rememberSession('runtime-set-model-xd-to-subscription-relink'); setSessionProvider(sessionId, 'xd'); const closeSession = vi.fn(async () => {}); - const relinkCodexThreadForProviderSwitch = vi.fn(async () => {}); + const relinkCodexThreadForProviderSwitch = vi.fn(async () => null); const maker: RuntimeSetModelMaker = { getSession: () => ({ agentKind: 'codex', @@ -432,8 +434,11 @@ describe('applyRuntimeSetModelChange', () => { expect(closeSession).toHaveBeenCalledWith(sessionId); expect(relinkCodexThreadForProviderSwitch).toHaveBeenCalledWith({ sessionId, - model: 'gpt-5.6-sol', - providerId: 'openai', + sourceModel: 'codex/gpt-5.6-sol', + sourceProviderId: 'xd', + sourceThreadModelProviderId: 'cindy_gateway', + targetModel: 'gpt-5.6-sol', + targetProviderId: 'openai', }); expect(getSessionProvider(sessionId)).toBe('openai'); }); @@ -464,6 +469,58 @@ describe('applyRuntimeSetModelChange', () => { 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'); + const registerPendingCredentialSwitch = 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: vi.fn(async () => {}), + }; + + await expect( + applyRuntimeSetModelChange({ + maker, + sessionId, + model: 'gpt-5.6-sol', + providerId: 'openai', + getPendingCredentialSwitch: () => ({ + model: 'gpt-5.6-sol', + providerId: 'openai', + rebuildCodexThread: 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'); + }), + }), + ).rejects.toThrow('fork failed'); + + expect(registerPendingCredentialSwitch).toHaveBeenCalledWith(sessionId, { + model: 'gpt-5.6-sol', + providerId: 'openai', + rebuildCodexThread: true, + sourceCodexThreadModelProviderId: 'cindy_gateway', + previousRoute: { model: 'codex/gpt-5.6-sol', providerId: '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'); @@ -492,7 +549,7 @@ describe('applyRuntimeSetModelChange', () => { sessionId, model: 'deepseek/deepseek-v4-pro', providerId: 'deepseek', - relinkCodexThreadForProviderSwitch: vi.fn(async () => {}), + relinkCodexThreadForProviderSwitch: vi.fn(async () => null), }); expect(result).toEqual({ status: 'applied' }); @@ -665,7 +722,7 @@ describe('applyRuntimeSetModelChange', () => { model: 'xai/grok-4.3', providerId: 'xai', codexAuthInjection: 'oauth-bearer', - relinkCodexThreadForProviderSwitch: vi.fn(async () => {}), + relinkCodexThreadForProviderSwitch: vi.fn(async () => null), }); expect(closeSession).toHaveBeenCalledWith(sessionId); @@ -700,7 +757,7 @@ describe('applyRuntimeSetModelChange', () => { model: 'codex/gpt-5.5', providerId: 'xd', registerPendingCredentialSwitch: vi.fn(), - relinkCodexThreadForProviderSwitch: vi.fn(async () => {}), + relinkCodexThreadForProviderSwitch: vi.fn(async () => null), }); expect(result).toEqual({ status: 'applied' }); @@ -865,7 +922,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'); }); + const relinkCodexThreadForProviderSwitch = vi.fn(async () => { + order.push('relink'); + return null; + }); const maker: RuntimeSetModelMaker = { getSession: () => ({ agentKind: 'codex', diff --git a/apps/desktop/src/main/maker-ipc/codexProviderThreadRelink.ts b/apps/desktop/src/main/maker-ipc/codexProviderThreadRelink.ts index 3a85d9c47e..aa820bb0c1 100644 --- a/apps/desktop/src/main/maker-ipc/codexProviderThreadRelink.ts +++ b/apps/desktop/src/main/maker-ipc/codexProviderThreadRelink.ts @@ -11,18 +11,40 @@ export interface CodexProviderThreadRelinkSource { 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; +} + +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; - model: string; - providerId: string | null; + 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; @@ -33,21 +55,31 @@ export interface CodexProviderThreadRelinkDeps { export async function relinkCodexProviderThread( deps: CodexProviderThreadRelinkDeps, - input: { sessionId: string; model: string; providerId: string | null }, + 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, - model: input.model, - providerId: input.providerId, + sourceModel: input.sourceModel, + sourceProviderId: resolveForkSourceProviderId( + input.sourceProviderId, + input.sourceThreadModelProviderId, + ), ...(source.workingDir ? { workingDir: source.workingDir } : {}), }); const committed = await deps.commit({ sessionId: input.sessionId, expectedSdkSessionId: source.sdkSessionId, newSdkSessionId: forked.newSdkSessionId, + ...(input.isCurrent ? { isCurrent: input.isCurrent } : {}), }); if (!committed) { throw new Error(`Codex provider thread relink was superseded for session ${input.sessionId}`); diff --git a/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts b/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts index a7b8193056..83a747a597 100644 --- a/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts +++ b/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts @@ -6,6 +6,7 @@ import { prepareLocalSessionCredentialModeSwitch, } from '../maker-host/codex-credential-switch.js'; import { setSessionProvider } from '../maker-host/session-provider-store.js'; +import type { CodexProviderThreadRelinkReceipt } from './codexProviderThreadRelink.js'; /** * PendingCredentialSwitchService —— 会话凭证形态切换的「延迟生效」登记表。 @@ -40,6 +41,8 @@ export interface PendingCredentialSwitch { providerId: string | null; /** 跨 cindy_gateway / cindy_openai 身份边界,收口前必须换成新的 native thread。 */ rebuildCodexThread?: boolean; + /** app-server 确认的源 thread provider 身份,路由 store 被提前覆盖时仍是事实源。 */ + sourceCodexThreadModelProviderId?: string | null; /** 目标会话的 agent(register 时由调用方捕获,收口前的停用重裁决用;可缺席 = 不裁决)。 */ agentKind?: AgentKind; /** @@ -110,9 +113,13 @@ export interface PendingCredentialSwitchDeps { */ relinkCodexThreadForProviderSwitch?: (input: { sessionId: string; - model: string; - providerId: string | null; - }) => Promise; + sourceModel: string; + sourceProviderId: string | null; + sourceThreadModelProviderId?: string | null; + targetModel: string; + targetProviderId: string | null; + isCurrent: () => boolean; + }) => Promise; /** 自愈兜底重试间隔覆写(测试用)。 */ retryDelayMs?: number; logger?: { @@ -137,14 +144,23 @@ export class PendingCredentialSwitchService { model: string; providerId: string | null; rebuildCodexThread?: 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; + }; }, ): void { this.pending.set(sessionId, { model: target.model, providerId: target.providerId, ...(target.rebuildCodexThread ? { rebuildCodexThread: true } : {}), + ...(target.sourceCodexThreadModelProviderId !== undefined + ? { sourceCodexThreadModelProviderId: target.sourceCodexThreadModelProviderId } + : {}), ...(target.agentKind ? { agentKind: target.agentKind } : {}), ...(target.previousRoute ? { previousRoute: target.previousRoute } : {}), requestedAt: Date.now(), @@ -200,10 +216,13 @@ export class PendingCredentialSwitchService { } // 关闭失败也要落 route:下一次发送的 getHost 仲裁仍会按新来源协调, // 不能让用户的选择因一次 close 失败而静默蒸发。 - this.deps.logger?.warn('pending credential switch: close session failed; applying route anyway', { + this.deps.logger?.warn( + 'pending credential switch: close session failed; applying route anyway', + { sessionId, error: err instanceof Error ? err.message : String(err), - }); + }, + ); } } // await close 期间用户可能又 register(改选)或 clear(取消):以**当前**登记为准 @@ -246,7 +265,13 @@ 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 }; try { @@ -350,9 +375,16 @@ 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) { if (target.rebuildCodexThread) { const relink = this.deps.relinkCodexThreadForProviderSwitch; @@ -365,10 +397,18 @@ export class PendingCredentialSwitchService { return; } try { - await relink({ + relinkReceipt = await relink({ sessionId, - model: resolved.model ?? target.model, - providerId: resolved.providerId, + 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.pending.get(sessionId) === target, }); } catch (err) { this.deps.logger?.error?.( @@ -384,8 +424,6 @@ export class PendingCredentialSwitchService { return; } if (this.pending.get(sessionId) !== target) return; - // persistRoute 失败会重试本 target;thread 已完成 CAS 换代,不得再次 fork。 - target.rebuildCodexThread = false; } setSessionProvider(sessionId, resolved.providerId); // 裁决改了落地路由(reroute / 丢弃停用显式来源 / 全停换模型或清空):回写 DB @@ -419,16 +457,41 @@ export class PendingCredentialSwitchService { : {}), }); } catch (err) { + let relinkRollbackError: unknown; + if (relinkReceipt) { + try { + const restored = await relinkReceipt.rollback(); + if (!restored) { + throw new Error('Codex thread relink rollback was superseded'); + } + } 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; + } + } // 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), - }); + ...(relinkRollbackError + ? { + relinkRollbackError: + relinkRollbackError instanceof Error + ? relinkRollbackError.message + : String(relinkRollbackError), + } + : {}), + }, + ); if (this.pending.get(sessionId) === target) this.scheduleRetry(sessionId); return; } @@ -446,11 +509,14 @@ 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); return; } diff --git a/apps/desktop/src/main/maker-ipc/register.ts b/apps/desktop/src/main/maker-ipc/register.ts index 217b1f9971..8d18d8ce49 100644 --- a/apps/desktop/src/main/maker-ipc/register.ts +++ b/apps/desktop/src/main/maker-ipc/register.ts @@ -881,7 +881,10 @@ import { applyRuntimeSetModelChange, isRemoteModelSwitchRouteChangeError, } from './runtimeSetModel.js'; -import { relinkCodexProviderThread } from './codexProviderThreadRelink.js'; +import { + relinkCodexProviderThread, + type CodexProviderThreadRelinkReceipt, +} from './codexProviderThreadRelink.js'; import { applyRuntimeSelectionAxesWithRecovery, commitRuntimeAxisAfterPersistence, @@ -3058,6 +3061,13 @@ export async function registerPendingCredentialSwitchForSession( model: string; providerId: string | null; rebuildCodexThread?: boolean; + sourceCodexThreadModelProviderId?: string | null; + previousRoute?: { + model: string; + providerId: string | null; + effort?: string; + fastMode?: boolean; + }; }, ): Promise { const service = pendingCredentialSwitchHolder; @@ -3098,8 +3108,11 @@ export async function registerPendingCredentialSwitchForSession( const prevModel = live?.model ?? prevRow?.model ?? null; service.register(sessionId, { ...target, + ...(live?.codexThreadModelProviderId !== undefined + ? { sourceCodexThreadModelProviderId: live.codexThreadModelProviderId } + : {}), ...(dbAgentKind ? { agentKind: dbToMakerAgentKind(dbAgentKind) } : {}), - ...(prevModel + ...(!target.previousRoute && prevModel ? { previousRoute: { model: prevModel, @@ -3119,18 +3132,22 @@ export async function registerPendingCredentialSwitchForSession( */ export async function relinkCodexThreadForCredentialSwitch(input: { sessionId: string; - model: string; - providerId: string | null; -}): Promise { + sourceModel: string; + sourceProviderId: string | null; + sourceThreadModelProviderId?: string | null; + targetModel: string; + targetProviderId: string | null; + isCurrent?: () => boolean; +}): Promise { const ownerScope = captureDataOwnerBroadcastScope(); const dbSnapshot = getCurrentDbClientSnapshot(); if (!dbSnapshot) throw new Error('Codex provider thread relink requires an active database'); - await relinkCodexProviderThread( + const relinked = await relinkCodexProviderThread( { readSource: async (sessionId) => { - const [row] = await dbSnapshot.client - .drizzle.select({ + const [row] = await dbSnapshot.client.drizzle + .select({ sdkSessionId: sessions.sdkSessionId, workingDir: sessions.workingDir, }) @@ -3139,11 +3156,11 @@ export async function relinkCodexThreadForCredentialSwitch(input: { .limit(1); return row ?? null; }, - fork: async ({ sourceSdkSessionId, model, providerId, workingDir }) => { + fork: async ({ sourceSdkSessionId, sourceModel, sourceProviderId, workingDir }) => { const forked = await getMaker().forkSdkSession('codex', { sourceSdkSessionId, - model, - providerId, + model: sourceModel, + providerId: sourceProviderId, upToMessageId: undefined, ...(workingDir ? { workingDir } : {}), stripEncryptedReasoning: true, @@ -3151,20 +3168,31 @@ export async function relinkCodexThreadForCredentialSwitch(input: { }); return { newSdkSessionId: forked.newSdkSessionId }; }, - commit: async ({ sessionId, expectedSdkSessionId, newSdkSessionId }) => { + commit: async ({ sessionId, expectedSdkSessionId, newSdkSessionId, isCurrent }) => { if ( + isCurrent?.() === false || !isDataOwnerBroadcastScopeCurrent(ownerScope) || getCurrentDbClientSnapshot()?.clientEpoch !== dbSnapshot.clientEpoch ) { return false; } const now = Date.now(); - const write = await dbSnapshot.client - .drizzle.update(sessions) + const write = await dbSnapshot.client.drizzle + .update(sessions) .set({ sdkSessionId: newSdkSessionId, updatedAt: now }) .where(and(eq(sessions.id, sessionId), eq(sessions.sdkSessionId, expectedSdkSessionId))) .run(); if (write.changes === 0) return false; + // register()/clear() can supersede a deferred target while SQLite awaits. Undo the + // just-written CAS before yielding to the newer generation. + if (isCurrent?.() === false) { + await dbSnapshot.client.drizzle + .update(sessions) + .set({ sdkSessionId: expectedSdkSessionId, updatedAt: Date.now() }) + .where(and(eq(sessions.id, sessionId), eq(sessions.sdkSessionId, newSdkSessionId))) + .run(); + return false; + } broadcastSessionPatched( sessionId, { @@ -3180,13 +3208,54 @@ export async function relinkCodexThreadForCredentialSwitch(input: { sessionId, fromThreadId: previousSdkSessionId, toThreadId: newSdkSessionId, - model: input.model, - providerId: input.providerId, + sourceModel: input.sourceModel, + sourceProviderId: input.sourceProviderId, + targetModel: input.targetModel, + targetProviderId: input.targetProviderId, }); }, }, input, ); + if (!relinked) return null; + + return { + ...relinked, + rollback: async () => { + if ( + !isDataOwnerBroadcastScopeCurrent(ownerScope) || + getCurrentDbClientSnapshot()?.clientEpoch !== dbSnapshot.clientEpoch + ) { + return false; + } + const now = Date.now(); + const write = await dbSnapshot.client.drizzle + .update(sessions) + .set({ sdkSessionId: relinked.previousSdkSessionId, updatedAt: now }) + .where( + and( + eq(sessions.id, input.sessionId), + eq(sessions.sdkSessionId, relinked.newSdkSessionId), + ), + ) + .run(); + if (write.changes === 0) return false; + 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( @@ -3205,11 +3274,32 @@ export function wakeSessionInputAfterCredentialSwitch(sessionId: string): void { 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; + rebuildCodexThread?: boolean; + sourceCodexThreadModelProviderId?: string | null; + previousRoute?: { + model: string; + providerId: string | null; + effort?: string; + fastMode?: boolean; + }; + } + | undefined { const pending = pendingCredentialSwitchHolder?.get(sessionId); - return pending ? { model: pending.model, providerId: pending.providerId } : undefined; + return pending + ? { + model: pending.model, + providerId: pending.providerId, + ...(pending.rebuildCodexThread ? { rebuildCodexThread: true } : {}), + ...(pending.sourceCodexThreadModelProviderId !== undefined + ? { sourceCodexThreadModelProviderId: pending.sourceCodexThreadModelProviderId } + : {}), + ...(pending.previousRoute ? { previousRoute: pending.previousRoute } : {}), + } + : undefined; } // ── Scheduler 撞忙排队桥(scheduler-host runner 消费)──────────────────────── @@ -4537,8 +4627,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, @@ -5991,11 +6080,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 { @@ -6130,8 +6217,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); @@ -12052,7 +12140,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; @@ -12086,8 +12180,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(); @@ -15408,7 +15502,17 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) // describe the old profile. pendingCredentialSwitchHolder?.clear(sessionId); restoreControlStores(); - let recoveryError: unknown; + const recoveryErrors: unknown[] = []; + if (result.status === 'applied' && result.codexThreadRelink) { + try { + const restored = await result.codexThreadRelink.rollback(); + if (!restored) { + throw new Error('Codex thread relink rollback was superseded'); + } + } catch (error) { + recoveryErrors.push(error); + } + } if ( result.status !== 'deferred' && previousRuntime.hadLiveSession && @@ -15417,7 +15521,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) try { await withRehydrateCloseSuppressed(sessionId, () => maker.closeSession(sessionId)); } catch (error) { - recoveryError = error; + recoveryErrors.push(error); } } if (previousRuntime.pendingCredentialSwitch) { @@ -15425,13 +15529,13 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) 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', ); } diff --git a/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts b/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts index aed7ba8047..4a6e2ab429 100644 --- a/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts +++ b/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts @@ -8,6 +8,7 @@ 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 { CredentialModeSwitchBusyError, isCredentialModeSwitchBusyError, @@ -22,7 +23,10 @@ interface RuntimeSetModelSession { codexProxyActive?: boolean | null; codexThreadModelProviderId?: string | 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 { @@ -65,6 +69,13 @@ export interface ApplyRuntimeSetModelChangeInput { model: string; providerId: string | null; rebuildCodexThread?: boolean; + sourceCodexThreadModelProviderId?: string | null; + previousRoute?: { + model: string; + providerId: string | null; + effort?: string; + fastMode?: boolean; + }; }, ) => void | Promise; /** @@ -86,9 +97,20 @@ 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; + sourceCodexThreadModelProviderId?: string | null; + previousRoute?: { + model: string; + providerId: string | null; + effort?: string; + fastMode?: boolean; + }; + } + | undefined; /** * 当前本地 Codex spawn 的鉴权注入形态(getCodexProxyAuthInjectionState())。 * shouldCloseSessionForCredentialSwitch 用它解析隐式来源的凭证家族,精确判定 @@ -102,15 +124,18 @@ export interface ApplyRuntimeSetModelChangeInput { */ relinkCodexThreadForProviderSwitch?: (input: { sessionId: string; - model: string; - providerId: string | null; - }) => Promise; + 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' }; @@ -148,9 +173,7 @@ export async function applyRuntimeSetModelChange( // 新来源、再换模型时,决策与登记都要沿用那个来源,不能回落到 store 里的旧值 // (否则会把 pending 的来源覆盖丢)。 const pendingTarget = - normalizedProviderId === undefined - ? input.getPendingCredentialSwitch?.(sessionId) - : undefined; + normalizedProviderId === undefined ? input.getPendingCredentialSwitch?.(sessionId) : undefined; const nextProviderId = normalizedProviderId !== undefined ? normalizedProviderId @@ -172,15 +195,11 @@ export async function applyRuntimeSetModelChange( : false; const shouldCloseSession = input.forceSessionRebuild === true || shouldCloseForCredentialSwitch; const shouldRelinkCodexThread = - shouldCloseForCredentialSwitch && - sess?.agentKind === 'codex' && - !sess.remoteHostId; + shouldCloseForCredentialSwitch && sess?.agentKind === 'codex' && !sess.remoteHostId; 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; @@ -246,6 +265,7 @@ 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, @@ -259,10 +279,15 @@ export async function applyRuntimeSetModelChange( 'Codex provider thread relink is required for a credential-family switch', ); } - await relinkCodexThreadForProviderSwitch({ + codexThreadRelink = await relinkCodexThreadForProviderSwitch({ sessionId, - model, - providerId: nextProviderId, + sourceModel: sess.model, + sourceProviderId: currentProviderId, + ...(sess.codexThreadModelProviderId !== undefined + ? { sourceThreadModelProviderId: sess.codexThreadModelProviderId } + : {}), + targetModel: model, + targetProviderId: nextProviderId, }); } } catch (err) { @@ -297,7 +322,10 @@ export async function applyRuntimeSetModelChange( fromModel: sess.model, toModel: model, }); - return { status: 'applied' }; + return { + status: 'applied', + ...(codexThreadRelink ? { codexThreadRelink } : {}), + }; } if (providerId !== undefined) { @@ -323,9 +351,9 @@ 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 } : {}), From 3cb26b12ee027bd308c097978b20385613ed80b0 Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:35:20 +0800 Subject: [PATCH 03/36] fix(desktop): roll back stale Codex relinks Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- .../__tests__/pendingCredentialSwitch.test.ts | 50 +++++++++++++++++++ .../main/maker-ipc/pendingCredentialSwitch.ts | 26 +++++++++- 2 files changed, 75 insertions(+), 1 deletion(-) 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 9cfe504e5b..84003675a0 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/pendingCredentialSwitch.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/pendingCredentialSwitch.test.ts @@ -191,6 +191,56 @@ describe('PendingCredentialSwitchService', () => { h.service.clear(sessionId); }); + it('rolls back an abandoned relink when route persistence is superseded', async () => { + const sessionId = rememberSession('pending-switch-superseded-after-persist'); + setSessionProvider(sessionId, 'xd'); + let sdkSessionId = 'thread-openai'; + const rollback = vi.fn(async () => { + if (sdkSessionId !== 'thread-openai') return false; + sdkSessionId = 'thread-xd'; + return true; + }); + const h = createHarness( + [{ id: sessionId, agentKind: 'codex', remoteHostId: null, isTurnRunning: () => false }], + { + resolveRoute: async (_agent, model, providerId) => ({ + model, + providerId, + degraded: false, + }), + }, + ); + h.relinkCodexThreadForProviderSwitch.mockResolvedValueOnce({ + previousSdkSessionId: 'thread-xd', + newSdkSessionId: 'thread-openai', + rollback, + }); + h.persistRoute.mockImplementationOnce(async () => { + h.service.register(sessionId, { + model: 'gpt-5.6-sol', + 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(rollback).toHaveBeenCalledOnce(); + expect(sdkSessionId).toBe('thread-xd'); + expect(h.service.get(sessionId)).toMatchObject({ + model: 'gpt-5.6-sol', + providerId: 'xd', + }); + expect(h.onApplied).not.toHaveBeenCalled(); + expect(h.broadcastApplied).not.toHaveBeenCalled(); + h.service.clear(sessionId); + }); + 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'); diff --git a/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts b/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts index 83a747a597..e6dbe7e931 100644 --- a/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts +++ b/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts @@ -496,7 +496,31 @@ export class PendingCredentialSwitchService { return; } // await 期间用户可能改选 / 取消:本次让位,新登记有自己的收口路径。 - if (this.pending.get(sessionId) !== target) return; + // relink 已在 persistRoute 之前提交;若这一代此刻已过时,必须先把它回滚, + // 否则下一次无 live session 的选择会沿用被放弃来源的 sdk_session_id。 + if (this.pending.get(sessionId) !== target) { + if (relinkReceipt) { + try { + const restored = await relinkReceipt.rollback(); + if (!restored) { + this.deps.logger?.warn( + 'pending credential switch: stale Codex thread relink rollback was superseded', + { sessionId }, + ); + } + } catch (rollbackError) { + this.deps.logger?.error?.( + 'pending credential switch: stale Codex thread relink rollback failed', + { + sessionId, + error: + rollbackError instanceof Error ? rollbackError.message : String(rollbackError), + }, + ); + } + } + return; + } } this.deps.logger?.info(`pending credential switch applied on ${reason}`, { sessionId, From fe2aa3bf2757b0e6fac543d3afaeb8455f3749ea Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:06:19 +0800 Subject: [PATCH 04/36] fix(desktop): relink persisted Codex threads without live handles Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- .../sessionRuntimeControlWiring.test.ts | 14 +++ .../cardActionTakeoverReplace.test.ts | 25 +++- .../src/main/im/shared/cardActionHandler.ts | 1 + .../desktop/src/main/im/shared/sessionRepo.ts | 9 ++ .../__tests__/runtimeSetModel.test.ts | 110 ++++++++++++++++++ apps/desktop/src/main/maker-ipc/register.ts | 17 ++- .../src/main/maker-ipc/runtimeSetModel.ts | 82 +++++++++---- 7 files changed, 234 insertions(+), 24 deletions(-) diff --git a/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts b/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts index e946a827b6..aa105f80a0 100644 --- a/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts +++ b/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts @@ -200,6 +200,20 @@ describe('session runtime control wiring', () => { expect(setModel).toContain("throw new Error('Codex thread relink rollback was superseded')"); }); + 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, 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 634943718a..91ebbf1361 100644 --- a/apps/desktop/src/main/im/shared/__tests__/cardActionTakeoverReplace.test.ts +++ b/apps/desktop/src/main/im/shared/__tests__/cardActionTakeoverReplace.test.ts @@ -29,7 +29,14 @@ const mocks = vi.hoisted(() => ({ 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; + providerId: string | null; + } | null> => null, ), readPermissionMode: vi.fn(async () => 'auto'), updatePermissionMode: vi.fn(async () => {}), @@ -702,6 +709,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', @@ -730,6 +738,7 @@ describe('model:pick 持久化失败', () => { it('runtime setModel 失败时恢复已落盘的旧路由', async () => { mocks.readModelRouteSnapshot.mockResolvedValueOnce({ + agentKind: 'claude-code', remoteHostId: null, sdkSessionId: null, model: 'claude-sonnet-4-6', effort: 'medium', providerId: 'openrouter', @@ -753,6 +762,18 @@ describe('model:pick 持久化失败', () => { 'medium', 'openrouter', ); + 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', expect.objectContaining({ body: slackUi.cards.model.failed('runtime rejected') }), @@ -773,6 +794,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', @@ -821,6 +843,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 1025cc8c9d..b3eea2b858 100644 --- a/apps/desktop/src/main/im/shared/cardActionHandler.ts +++ b/apps/desktop/src/main/im/shared/cardActionHandler.ts @@ -389,6 +389,7 @@ export function createCardActionHandler( sessionId, model: modelId, providerId, + ...(previousRoute ? { persistedSession: previousRoute } : {}), isSessionInTurn, registerPendingCredentialSwitch: registerPendingCredentialSwitchForSession, clearPendingCredentialSwitch: clearPendingCredentialSwitchForSession, diff --git a/apps/desktop/src/main/im/shared/sessionRepo.ts b/apps/desktop/src/main/im/shared/sessionRepo.ts index d8775ddb0e..82ecd9580c 100644 --- a/apps/desktop/src/main/im/shared/sessionRepo.ts +++ b/apps/desktop/src/main/im/shared/sessionRepo.ts @@ -70,6 +70,9 @@ export interface ImSessionRow { } export interface SessionModelRouteSnapshot { + agentKind: AgentKind; + remoteHostId: string | null; + sdkSessionId: string | null; model: string; effort: Effort; providerId: string | null; @@ -585,6 +588,9 @@ 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, providerId: sessions.providerId, @@ -595,6 +601,9 @@ 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, providerId: row.providerId ?? null, 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 8fabb5dee4..40465ba974 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/runtimeSetModel.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/runtimeSetModel.test.ts @@ -480,6 +480,116 @@ describe('applyRuntimeSetModelChange', () => { 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'); diff --git a/apps/desktop/src/main/maker-ipc/register.ts b/apps/desktop/src/main/maker-ipc/register.ts index 8d18d8ce49..d494723632 100644 --- a/apps/desktop/src/main/maker-ipc/register.ts +++ b/apps/desktop/src/main/maker-ipc/register.ts @@ -15093,7 +15093,15 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) // 状态可封住两种次序:本请求先拿锁时终态写等待并在之后清理 override;终态 // 先拿锁时旧请求看到非 active,不能在 cleanup 后重新建立 pending/override。 const [runtimeStatus] = await getDbClient() - .drizzle.select({ status: sessions.status, orcaRole: sessions.orcaRole }) + .drizzle.select({ + status: sessions.status, + orcaRole: sessions.orcaRole, + agentKind: sessions.agentKind, + remoteHostId: sessions.remoteHostId, + sdkSessionId: sessions.sdkSessionId, + model: sessions.model, + providerId: sessions.providerId, + }) .from(sessions) .where(eq(sessions.id, sessionId)) .limit(1); @@ -15414,6 +15422,13 @@ 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, + }, ...(atomicSelection?.effort ? { effort: atomicSelection.effort as diff --git a/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts b/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts index 31b191735f..09c2538dde 100644 --- a/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts +++ b/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts @@ -48,12 +48,26 @@ interface RuntimeSetModelLogger { info: (message: string, meta?: Record) => void; } +export interface RuntimeSetModelPersistedSession { + agentKind: AgentKind; + remoteHostId?: string | null; + model: string; + providerId: string | null; + sdkSessionId: string | null; +} + export interface ApplyRuntimeSetModelChangeInput { maker: RuntimeSetModelMaker; sessionId: string; model: string; providerId?: string | null; effort?: Effort; + /** + * 锁内读取的切换前持久化会话身份。live handle 不存在时,旧 sdkSessionId 仍会在 + * 下一次 lazy-create 中作为 resumeSessionId;因此本地 Codex 的跨凭证家族切换 + * 必须基于这份旧快照先 relink,不能把「无 live handle」当成「无旧 thread」。 + */ + persistedSession?: RuntimeSetModelPersistedSession; /** * Orca Worker 的 live model/provider 属于执行单元身份,不能热切。即使凭证 * 形态相同,也必须沿用 credential-switch 的 idle close / busy defer 边界。 @@ -169,7 +183,19 @@ export async function applyRuntimeSetModelChange( const { maker, sessionId, model, providerId, effort, 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 的来源覆盖丢)。 @@ -181,24 +207,29 @@ export async function applyRuntimeSetModelChange( : pendingTarget !== undefined ? pendingTarget.providerId : currentProviderId; - const shouldCloseForCredentialSwitch = sess + const shouldCloseForCredentialSwitch = sourceSession ? shouldCloseSessionForCredentialSwitch({ - agentKind: sess.agentKind, - remoteHostId: sess.remoteHostId, + 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 && sess?.agentKind === 'codex' && !sess.remoteHostId; + shouldCloseForCredentialSwitch && + sourceSession?.agentKind === 'codex' && + !sourceSession.remoteHostId && + (sess !== undefined || persistedCodexThread !== undefined); let selfBusyMemo: boolean | undefined; const isSelfBusy = (): boolean => { if (selfBusyMemo !== undefined) return selfBusyMemo; @@ -242,8 +273,8 @@ 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, @@ -270,11 +301,13 @@ export async function applyRuntimeSetModelChange( 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) { @@ -284,14 +317,19 @@ export async function applyRuntimeSetModelChange( } codexThreadRelink = await relinkCodexThreadForProviderSwitch({ sessionId, - sourceModel: sess.model, + sourceModel: sourceSession!.model, sourceProviderId: currentProviderId, - ...(sess.codexThreadModelProviderId !== undefined + ...(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 通道就转延迟, @@ -304,7 +342,7 @@ export async function applyRuntimeSetModelChange( }); logger?.info('set-model: credential switch deferred after busy race', { sessionId, - agentKind: sess.agentKind, + agentKind: sourceSession!.agentKind, }); return { status: 'deferred' }; } @@ -317,12 +355,12 @@ export async function applyRuntimeSetModelChange( 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 { From 7319ce02e0776cbf58e7a8ef0962cf57a0c55103 Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:15:46 +0800 Subject: [PATCH 05/36] fix(desktop): recalculate deferred Codex thread relink Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- .../__tests__/pendingCredentialSwitch.test.ts | 69 +++++++++++++++++++ .../main/maker-ipc/pendingCredentialSwitch.ts | 35 +++++++++- 2 files changed, 103 insertions(+), 1 deletion(-) 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 84003675a0..db98a90094 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/pendingCredentialSwitch.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/pendingCredentialSwitch.test.ts @@ -134,6 +134,75 @@ describe('PendingCredentialSwitchService', () => { ); }); + 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.service.register(sessionId, { + model: 'xai/grok-4.3', + providerId: 'xai', + agentKind: 'codex', + sourceCodexThreadModelProviderId: 'cindy_gateway', + previousRoute: { model: 'codex/gpt-5.6-sol', providerId: 'xd' }, + }); + 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), + }); + expect(h.persistRoute).toHaveBeenCalledWith(sessionId, { + model: 'gpt-5.6-sol', + providerId: 'openai', + }); + }); + + 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'); diff --git a/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts b/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts index e6dbe7e931..18f7cd3062 100644 --- a/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts +++ b/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts @@ -4,7 +4,12 @@ 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 { setSessionProvider } from '../maker-host/session-provider-store.js'; import type { CodexProviderThreadRelinkReceipt } from './codexProviderThreadRelink.js'; @@ -41,6 +46,8 @@ export interface PendingCredentialSwitch { providerId: string | null; /** 跨 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 时由调用方捕获,收口前的停用重裁决用;可缺席 = 不裁决)。 */ @@ -386,7 +393,32 @@ export class PendingCredentialSwitchService { ): Promise { let relinkReceipt: CodexProviderThreadRelinkReceipt | null = null; if (resolved.apply) { - if (target.rebuildCodexThread) { + // 等待期间停用裁决可能把最终 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?.( @@ -469,6 +501,7 @@ export class PendingCredentialSwitchService { // 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 预写的停用 From 8fc960802ce6f150e6de2c3361d29e892e993f7d Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:25:07 +0800 Subject: [PATCH 06/36] fix(codex): preserve committed relink recovery state Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- .../sessionRuntimeControlWiring.test.ts | 2 + .../__tests__/pendingCredentialSwitch.test.ts | 48 +++++++++++++++++++ .../__tests__/runtimeSetModel.test.ts | 2 + .../main/maker-ipc/pendingCredentialSwitch.ts | 2 + apps/desktop/src/main/maker-ipc/register.ts | 5 ++ .../src/main/maker-ipc/runtimeSetModel.ts | 2 + 6 files changed, 61 insertions(+) diff --git a/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts b/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts index aa105f80a0..2fe720c91e 100644 --- a/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts +++ b/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts @@ -223,6 +223,8 @@ describe('session runtime control wiring', () => { expect(getter).toContain('pending.rebuildCodexThread'); expect(getter).toContain('{ rebuildCodexThread: true }'); + expect(getter).toContain('pending.codexThreadRelinkCommitted'); + expect(getter).toContain('{ codexThreadRelinkCommitted: true }'); expect(getter).toContain('pending.sourceCodexThreadModelProviderId'); expect(getter).toContain('pending.previousRoute'); }); 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 db98a90094..79242dcfed 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/pendingCredentialSwitch.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/pendingCredentialSwitch.test.ts @@ -260,6 +260,54 @@ describe('PendingCredentialSwitchService', () => { h.service.clear(sessionId); }); + it('restores a committed relink marker after cleanup so retry does not fork again', async () => { + const sessionId = rememberSession('pending-switch-restore-committed-relink'); + setSessionProvider(sessionId, 'xd'); + const rollback = vi.fn(async () => { + throw new Error('rollback failed'); + }); + const h = createHarness( + [{ id: sessionId, agentKind: 'codex', remoteHostId: null, isTurnRunning: () => false }], + { + resolveRoute: async (_agent, model, providerId) => ({ + model, + providerId, + degraded: false, + }), + retryDelayMs: 60_000, + }, + ); + h.relinkCodexThreadForProviderSwitch.mockResolvedValueOnce({ + previousSdkSessionId: 'thread-xd', + newSdkSessionId: 'thread-openai', + rollback, + }); + h.persistRoute.mockRejectedValueOnce(new Error('sqlite failed')); + + 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(rollback).toHaveBeenCalledOnce(); + expect(h.service.get(sessionId)?.codexThreadRelinkCommitted).toBe(true); + const recovered = h.service.get(sessionId)!; + h.service.clear(sessionId); + h.service.register(sessionId, recovered); + + await h.service.onTurnSettled(sessionId); + + expect(h.relinkCodexThreadForProviderSwitch).toHaveBeenCalledOnce(); + expect(h.persistRoute).toHaveBeenCalledTimes(2); + expect(h.service.has(sessionId)).toBe(false); + expect(h.onApplied).toHaveBeenCalledOnce(); + }); + it('rolls back an abandoned relink when route persistence is superseded', async () => { const sessionId = rememberSession('pending-switch-superseded-after-persist'); setSessionProvider(sessionId, 'xd'); 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 40465ba974..48a4619688 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/runtimeSetModel.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/runtimeSetModel.test.ts @@ -648,6 +648,7 @@ describe('applyRuntimeSetModelChange', () => { model: 'gpt-5.6-sol', providerId: 'openai', rebuildCodexThread: true, + codexThreadRelinkCommitted: true, sourceCodexThreadModelProviderId: 'cindy_gateway', previousRoute: { model: 'codex/gpt-5.6-sol', providerId: 'xd' }, }), @@ -663,6 +664,7 @@ describe('applyRuntimeSetModelChange', () => { model: 'gpt-5.6-sol', providerId: 'openai', rebuildCodexThread: true, + codexThreadRelinkCommitted: true, sourceCodexThreadModelProviderId: 'cindy_gateway', previousRoute: { model: 'codex/gpt-5.6-sol', providerId: 'xd' }, }); diff --git a/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts b/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts index 18f7cd3062..65bd902b4d 100644 --- a/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts +++ b/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts @@ -151,6 +151,7 @@ export class PendingCredentialSwitchService { model: string; providerId: string | null; rebuildCodexThread?: boolean; + codexThreadRelinkCommitted?: boolean; sourceCodexThreadModelProviderId?: string | null; agentKind?: AgentKind; previousRoute?: { @@ -165,6 +166,7 @@ export class PendingCredentialSwitchService { model: target.model, providerId: target.providerId, ...(target.rebuildCodexThread ? { rebuildCodexThread: true } : {}), + ...(target.codexThreadRelinkCommitted ? { codexThreadRelinkCommitted: true } : {}), ...(target.sourceCodexThreadModelProviderId !== undefined ? { sourceCodexThreadModelProviderId: target.sourceCodexThreadModelProviderId } : {}), diff --git a/apps/desktop/src/main/maker-ipc/register.ts b/apps/desktop/src/main/maker-ipc/register.ts index d494723632..baec356931 100644 --- a/apps/desktop/src/main/maker-ipc/register.ts +++ b/apps/desktop/src/main/maker-ipc/register.ts @@ -3061,6 +3061,7 @@ export async function registerPendingCredentialSwitchForSession( model: string; providerId: string | null; rebuildCodexThread?: boolean; + codexThreadRelinkCommitted?: boolean; sourceCodexThreadModelProviderId?: string | null; previousRoute?: { model: string; @@ -3279,6 +3280,7 @@ export function getPendingCredentialSwitchTarget(sessionId: string): model: string; providerId: string | null; rebuildCodexThread?: boolean; + codexThreadRelinkCommitted?: boolean; sourceCodexThreadModelProviderId?: string | null; previousRoute?: { model: string; @@ -3294,6 +3296,9 @@ export function getPendingCredentialSwitchTarget(sessionId: string): model: pending.model, providerId: pending.providerId, ...(pending.rebuildCodexThread ? { rebuildCodexThread: true } : {}), + ...(pending.codexThreadRelinkCommitted + ? { codexThreadRelinkCommitted: true } + : {}), ...(pending.sourceCodexThreadModelProviderId !== undefined ? { sourceCodexThreadModelProviderId: pending.sourceCodexThreadModelProviderId } : {}), diff --git a/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts b/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts index 09c2538dde..68a929f93e 100644 --- a/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts +++ b/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts @@ -84,6 +84,7 @@ export interface ApplyRuntimeSetModelChangeInput { model: string; providerId: string | null; rebuildCodexThread?: boolean; + codexThreadRelinkCommitted?: boolean; sourceCodexThreadModelProviderId?: string | null; previousRoute?: { model: string; @@ -117,6 +118,7 @@ export interface ApplyRuntimeSetModelChangeInput { model: string; providerId: string | null; rebuildCodexThread?: boolean; + codexThreadRelinkCommitted?: boolean; sourceCodexThreadModelProviderId?: string | null; previousRoute?: { model: string; From ad849fafce93c4a384f6ca518ab565bc78421f3b Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:29:00 +0800 Subject: [PATCH 07/36] fix(codex): roll back relink across owner changes Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- .../sessionRuntimeControlWiring.test.ts | 20 ++- .../codexProviderThreadRelink.test.ts | 33 ++++- .../maker-ipc/codexProviderThreadRelink.ts | 18 +++ apps/desktop/src/main/maker-ipc/register.ts | 128 +++++++++++------- 4 files changed, 151 insertions(+), 48 deletions(-) diff --git a/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts b/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts index 2fe720c91e..22e5f536f7 100644 --- a/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts +++ b/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts @@ -193,13 +193,31 @@ describe('session runtime control wiring', () => { 'const recoverRemoteRuntimeAxisPersistence', ); const persist = setModel.indexOf('await persistSessionFields(sessionId, patch)'); - const rollback = setModel.indexOf('await result.codexThreadRelink.rollback()'); + const rollback = setModel.indexOf('await result.codexThreadRelink.rollback()', persist); expect(persist).toBeGreaterThan(-1); expect(rollback).toBeGreaterThan(persist); expect(setModel).toContain("throw new Error('Codex thread relink rollback was superseded')"); }); + it('rolls back a committed Codex relink before an owner-boundary request exits', () => { + const setModel = handlerBody( + registerSource, + 'const handleSetModel = async (', + 'const recoverRemoteRuntimeAxisPersistence', + ); + const guard = setModel.indexOf( + 'const rollbackRelinkForSupersededOwner = async (): Promise => {', + ); + const rollback = setModel.indexOf('await result.codexThreadRelink.rollback();', guard); + const ownerFailure = setModel.indexOf('assertRuntimeOwnerCurrent();', guard); + + expect(guard).toBeGreaterThan(-1); + expect(rollback).toBeGreaterThan(guard); + expect(ownerFailure).toBeGreaterThan(rollback); + expect(setModel.match(/await rollbackRelinkForSupersededOwner\(\)/g)).toHaveLength(2); + }); + it('passes the locked persistent thread identity into runtime model switching', () => { const setModel = handlerBody( registerSource, diff --git a/apps/desktop/src/main/maker-ipc/__tests__/codexProviderThreadRelink.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/codexProviderThreadRelink.test.ts index 0cc817f8cd..9e85181e2a 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/codexProviderThreadRelink.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/codexProviderThreadRelink.test.ts @@ -1,8 +1,39 @@ import { describe, expect, it, vi } from 'vitest'; -import { relinkCodexProviderThread } from '../codexProviderThreadRelink.js'; +import { + commitCodexProviderThreadRelinkWithBoundaryGuard, + relinkCodexProviderThread, +} from '../codexProviderThreadRelink.js'; describe('relinkCodexProviderThread', () => { + 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('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); diff --git a/apps/desktop/src/main/maker-ipc/codexProviderThreadRelink.ts b/apps/desktop/src/main/maker-ipc/codexProviderThreadRelink.ts index aa820bb0c1..7d38d94363 100644 --- a/apps/desktop/src/main/maker-ipc/codexProviderThreadRelink.ts +++ b/apps/desktop/src/main/maker-ipc/codexProviderThreadRelink.ts @@ -18,6 +18,24 @@ export interface CodexProviderThreadRelinkReceipt { rollback(): Promise; } +/** + * 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 input.rollback(); + return false; +} + function resolveForkSourceProviderId( sourceProviderId: string | null, sourceThreadModelProviderId: string | null | undefined, diff --git a/apps/desktop/src/main/maker-ipc/register.ts b/apps/desktop/src/main/maker-ipc/register.ts index baec356931..fc20052aa1 100644 --- a/apps/desktop/src/main/maker-ipc/register.ts +++ b/apps/desktop/src/main/maker-ipc/register.ts @@ -882,6 +882,7 @@ import { isRemoteModelSwitchRouteChangeError, } from './runtimeSetModel.js'; import { + commitCodexProviderThreadRelinkWithBoundaryGuard, relinkCodexProviderThread, type CodexProviderThreadRelinkReceipt, } from './codexProviderThreadRelink.js'; @@ -3144,6 +3145,24 @@ export async function relinkCodexThreadForCredentialSwitch(input: { 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 write = await dbSnapshot.client.drizzle + .update(sessions) + .set({ sdkSessionId: params.previousSdkSessionId, updatedAt: Date.now() }) + .where( + and( + eq(sessions.id, params.sessionId), + eq(sessions.sdkSessionId, params.newSdkSessionId), + ), + ) + .run(); + return write.changes > 0; + }; + const relinked = await relinkCodexProviderThread( { readSource: async (sessionId) => { @@ -3170,28 +3189,39 @@ export async function relinkCodexThreadForCredentialSwitch(input: { return { newSdkSessionId: forked.newSdkSessionId }; }, commit: async ({ sessionId, expectedSdkSessionId, newSdkSessionId, isCurrent }) => { - if ( - isCurrent?.() === false || - !isDataOwnerBroadcastScopeCurrent(ownerScope) || - getCurrentDbClientSnapshot()?.clientEpoch !== dbSnapshot.clientEpoch - ) { - return false; - } + const isBoundaryCurrent = (): boolean => + isCurrent?.() !== false && + isDataOwnerBroadcastScopeCurrent(ownerScope) && + getCurrentDbClientSnapshot()?.clientEpoch === dbSnapshot.clientEpoch; const now = Date.now(); - const write = await dbSnapshot.client.drizzle - .update(sessions) - .set({ sdkSessionId: newSdkSessionId, updatedAt: now }) - .where(and(eq(sessions.id, sessionId), eq(sessions.sdkSessionId, expectedSdkSessionId))) - .run(); - if (write.changes === 0) return false; - // register()/clear() can supersede a deferred target while SQLite awaits. Undo the - // just-written CAS before yielding to the newer generation. - if (isCurrent?.() === false) { - await dbSnapshot.client.drizzle - .update(sessions) - .set({ sdkSessionId: expectedSdkSessionId, updatedAt: Date.now() }) - .where(and(eq(sessions.id, sessionId), eq(sessions.sdkSessionId, newSdkSessionId))) - .run(); + const committed = await commitCodexProviderThreadRelinkWithBoundaryGuard({ + isBoundaryCurrent, + commit: async () => { + const write = await dbSnapshot.client.drizzle + .update(sessions) + .set({ sdkSessionId: newSdkSessionId, updatedAt: now }) + .where( + and(eq(sessions.id, sessionId), eq(sessions.sdkSessionId, expectedSdkSessionId)), + ) + .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 rollbackCapturedRelink({ + sessionId, + previousSdkSessionId: expectedSdkSessionId, + newSdkSessionId, + }); return false; } broadcastSessionPatched( @@ -3223,32 +3253,28 @@ export async function relinkCodexThreadForCredentialSwitch(input: { 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 ( - !isDataOwnerBroadcastScopeCurrent(ownerScope) || - getCurrentDbClientSnapshot()?.clientEpoch !== dbSnapshot.clientEpoch + isDataOwnerBroadcastScopeCurrent(ownerScope) && + getCurrentDbClientSnapshot()?.clientEpoch === dbSnapshot.clientEpoch ) { - return false; + const now = Date.now(); + broadcastSessionPatched( + input.sessionId, + { + sdkSessionId: relinked.previousSdkSessionId, + updatedAt: new Date(now).toISOString(), + }, + ownerScope, + ); } - const now = Date.now(); - const write = await dbSnapshot.client.drizzle - .update(sessions) - .set({ sdkSessionId: relinked.previousSdkSessionId, updatedAt: now }) - .where( - and( - eq(sessions.id, input.sessionId), - eq(sessions.sdkSessionId, relinked.newSdkSessionId), - ), - ) - .run(); - if (write.changes === 0) return false; - 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, @@ -15463,7 +15489,17 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) deferred: result.status === 'deferred', superseded: false, }; - if (supersededByOwnerBoundary()) { + const rollbackRelinkForSupersededOwner = async (): Promise => { + if (sessionRuntimeControlOwnerEpochMatches(runtimeOwnerEpoch)) return false; + if (result.status === 'applied' && result.codexThreadRelink) { + await result.codexThreadRelink.rollback(); + } + if (internalOptions.source === 'user') { + assertRuntimeOwnerCurrent(); + } + return true; + }; + if (await rollbackRelinkForSupersededOwner()) { return { deferred: false, superseded: true }; } if (atomicSelection) { @@ -15571,7 +15607,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) agentSwitchIntent: null, agentSwitchIntentCanceled: true, }); - if (supersededByOwnerBoundary()) { + if (await rollbackRelinkForSupersededOwner()) { return { deferred: false, superseded: true }; } let generation: number; From 7b74e31f2c4c870aa5f2fb7941f5486e7265bcc5 Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:39:48 +0800 Subject: [PATCH 08/36] fix(codex): fence relink during app teardown Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- .../sessionRuntimeControlWiring.test.ts | 33 ++++++++++++-- apps/desktop/src/main/maker-ipc/register.ts | 44 +++++++++++++++---- 2 files changed, 66 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts b/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts index 22e5f536f7..cd0e46dd5f 100644 --- a/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts +++ b/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts @@ -193,7 +193,10 @@ describe('session runtime control wiring', () => { 'const recoverRemoteRuntimeAxisPersistence', ); const persist = setModel.indexOf('await persistSessionFields(sessionId, patch)'); - const rollback = setModel.indexOf('await result.codexThreadRelink.rollback()', persist); + const rollback = setModel.indexOf( + 'const restored = await rollbackAppliedCodexThreadRelink()', + persist, + ); expect(persist).toBeGreaterThan(-1); expect(rollback).toBeGreaterThan(persist); @@ -209,13 +212,37 @@ describe('session runtime control wiring', () => { const guard = setModel.indexOf( 'const rollbackRelinkForSupersededOwner = async (): Promise => {', ); - const rollback = setModel.indexOf('await result.codexThreadRelink.rollback();', guard); + const rollback = setModel.indexOf('await rollbackAppliedCodexThreadRelink();', guard); const ownerFailure = setModel.indexOf('assertRuntimeOwnerCurrent();', guard); + expect(setModel).toContain('!isAppSessionBoundaryPending()'); + expect(setModel).toContain('const rollbackAppliedCodexThreadRelink = async ()'); expect(guard).toBeGreaterThan(-1); expect(rollback).toBeGreaterThan(guard); expect(ownerFailure).toBeGreaterThan(rollback); - expect(setModel.match(/await rollbackRelinkForSupersededOwner\(\)/g)).toHaveLength(2); + expect(setModel.match(/await rollbackRelinkForSupersededOwner\(\)/g)).toHaveLength(3); + const outerCatch = setModel.lastIndexOf('} catch (err) {'); + expect(setModel.indexOf('await rollbackAppliedCodexThreadRelink();', outerCatch)).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', () => { diff --git a/apps/desktop/src/main/maker-ipc/register.ts b/apps/desktop/src/main/maker-ipc/register.ts index fc20052aa1..3b23937466 100644 --- a/apps/desktop/src/main/maker-ipc/register.ts +++ b/apps/desktop/src/main/maker-ipc/register.ts @@ -3190,6 +3190,7 @@ export async function relinkCodexThreadForCredentialSwitch(input: { }, commit: async ({ sessionId, expectedSdkSessionId, newSdkSessionId, isCurrent }) => { const isBoundaryCurrent = (): boolean => + !isAppSessionBoundaryPending() && isCurrent?.() !== false && isDataOwnerBroadcastScopeCurrent(ownerScope) && getCurrentDbClientSnapshot()?.clientEpoch === dbSnapshot.clientEpoch; @@ -3262,6 +3263,7 @@ export async function relinkCodexThreadForCredentialSwitch(input: { }); if (!restored) return false; if ( + !isAppSessionBoundaryPending() && isDataOwnerBroadcastScopeCurrent(ownerScope) && getCurrentDbClientSnapshot()?.clientEpoch === dbSnapshot.clientEpoch ) { @@ -15097,8 +15099,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', @@ -15106,7 +15111,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(); } @@ -15446,6 +15451,14 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) }); }); }; + let appliedCodexThreadRelink: CodexProviderThreadRelinkReceipt | undefined; + const rollbackAppliedCodexThreadRelink = async (): Promise => { + const receipt = appliedCodexThreadRelink; + if (!receipt) return true; + const restored = await receipt.rollback(); + appliedCodexThreadRelink = undefined; + return restored; + }; try { const result = routeExplicit ? await applyRuntimeSetModelChange({ @@ -15483,6 +15496,9 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) logger: log, }) : { status: 'applied' as const }; + if (result.status === 'applied') { + appliedCodexThreadRelink = result.codexThreadRelink; + } // deferred = 会话自己在跑,选择已登记、turn 结束自动生效。renderer 据此提示 // "任务结束后生效"而不是当成已即时切换。 const response = { @@ -15490,10 +15506,8 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) superseded: false, }; const rollbackRelinkForSupersededOwner = async (): Promise => { - if (sessionRuntimeControlOwnerEpochMatches(runtimeOwnerEpoch)) return false; - if (result.status === 'applied' && result.codexThreadRelink) { - await result.codexThreadRelink.rollback(); - } + if (runtimeOwnerBoundaryCurrent()) return false; + await rollbackAppliedCodexThreadRelink(); if (internalOptions.source === 'user') { assertRuntimeOwnerCurrent(); } @@ -15559,9 +15573,9 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) pendingCredentialSwitchHolder?.clear(sessionId); restoreControlStores(); const recoveryErrors: unknown[] = []; - if (result.status === 'applied' && result.codexThreadRelink) { + if (appliedCodexThreadRelink) { try { - const restored = await result.codexThreadRelink.rollback(); + const restored = await rollbackAppliedCodexThreadRelink(); if (!restored) { throw new Error('Codex thread relink rollback was superseded'); } @@ -15721,12 +15735,26 @@ 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 relink before the request can report success or owner supersession. + if (await rollbackRelinkForSupersededOwner()) { + return { deferred: false, superseded: true }; + } return { ...response, generation, effectiveProviderId: normalizeSessionProviderId(effectiveProviderId) ?? null, }; } catch (err) { + try { + await rollbackAppliedCodexThreadRelink(); + } catch (rollbackError) { + throw new AggregateError( + [err, rollbackError], + 'runtime selection failed and Codex thread relink rollback also failed', + ); + } if (err instanceof CredentialModeSwitchBusyError) { // 兜底(正常路径 busy 已转 deferred):切模型撞上凭证切换忙,独立 code, // renderer toast 走 ipcError.CREDENTIAL_SWITCH_BUSY 专属文案。 From 88dcd725fd2418861263119d5ac7bca61b1fbebe Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:43:20 +0800 Subject: [PATCH 09/36] fix(codex): discard stale owner pending switches Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- .../sessionRuntimeControlWiring.test.ts | 25 ++++ .../__tests__/pendingCredentialSwitch.test.ts | 82 ++++++++++++- .../main/maker-ipc/pendingCredentialSwitch.ts | 109 +++++++++++++++--- apps/desktop/src/main/maker-ipc/register.ts | 15 +++ .../src/main/maker-ipc/runtimeSetModel.ts | 2 + 5 files changed, 218 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts b/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts index cd0e46dd5f..aef2875a93 100644 --- a/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts +++ b/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts @@ -270,10 +270,35 @@ describe('session runtime control wiring', () => { 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'); }); + 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(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/maker-ipc/__tests__/pendingCredentialSwitch.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/pendingCredentialSwitch.test.ts index 79242dcfed..94456119ec 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/pendingCredentialSwitch.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/pendingCredentialSwitch.test.ts @@ -33,7 +33,11 @@ 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>(); @@ -53,6 +57,9 @@ function createHarness( onApplied, persistRoute, relinkCodexThreadForProviderSwitch, + ...(opts?.isOwnerScopeCurrent + ? { isOwnerScopeCurrent: opts.isOwnerScopeCurrent } + : {}), ...(opts?.resolveRoute ? { resolveRoute: opts.resolveRoute } : {}), ...(opts?.retryDelayMs !== undefined ? { retryDelayMs: opts.retryDelayMs } : {}), }); @@ -225,6 +232,79 @@ describe('PendingCredentialSwitchService', () => { h.service.clear(sessionId); }); + it('discards an old-owner relink target during teardown and never retries it after recovery', async () => { + const sessionId = rememberSession('pending-switch-owner-teardown'); + setSessionProvider(sessionId, 'xd'); + let currentOwner = { ownerScopeKey: 'owner-a:1', runtimeOwnerEpoch: '7' }; + const h = createHarness( + [{ id: sessionId, agentKind: 'codex', remoteHostId: null, isTurnRunning: () => false }], + { + retryDelayMs: 10, + isOwnerScopeCurrent: (scope) => + scope.ownerScopeKey === currentOwner.ownerScopeKey && + scope.runtimeOwnerEpoch === currentOwner.runtimeOwnerEpoch, + }, + ); + const rollback = vi.fn(async () => { + throw new Error('old profile teardown closed the client'); + }); + h.relinkCodexThreadForProviderSwitch.mockImplementationOnce(async (input) => { + currentOwner = { ownerScopeKey: 'owner-boundary', runtimeOwnerEpoch: '7' }; + expect(input.isCurrent()).toBe(false); + return { + 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' }, + }); + await h.service.onTurnSettled(sessionId); + + expect(h.service.has(sessionId)).toBe(false); + expect(rollback).toHaveBeenCalledOnce(); + currentOwner = { ownerScopeKey: 'owner-a:2', runtimeOwnerEpoch: '8' }; + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(h.relinkCodexThreadForProviderSwitch).toHaveBeenCalledOnce(); + expect(h.persistRoute).not.toHaveBeenCalled(); + expect(h.onApplied).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('rolls back the relink when deferred route persistence fails', async () => { const sessionId = rememberSession('pending-switch-persist-rollback'); setSessionProvider(sessionId, 'xd'); diff --git a/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts b/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts index 65bd902b4d..0c311f4b6b 100644 --- a/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts +++ b/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts @@ -41,6 +41,11 @@ import type { CodexProviderThreadRelinkReceipt } from './codexProviderThreadReli /** 自愈兜底重试间隔(事件路径正常时用户感知不到它)。 */ const PENDING_APPLY_RETRY_DELAY_MS = 10_000; +export interface PendingCredentialSwitchOwnerScope { + ownerScopeKey: string; + runtimeOwnerEpoch: string; +} + export interface PendingCredentialSwitch { model: string; providerId: string | null; @@ -59,6 +64,8 @@ export interface PendingCredentialSwitch { * 缺席 = 无从回滚,退化为只清显式来源。 */ previousRoute?: { model: string; providerId: string | null; effort?: string; fastMode?: boolean }; + /** 登记时的 data owner + runtime epoch;跨账号/teardown 后旧 target 必须终态丢弃。 */ + ownerScope?: PendingCredentialSwitchOwnerScope; requestedAt: number; } @@ -75,6 +82,8 @@ export interface PendingCredentialSwitchDeps { closeSession: (sessionId: string) => Promise; }; isSessionInTurn?: (sessionId: string) => boolean; + /** 缺席仅供旧调用方/最小测试;生产必须校验 pending 登记时捕获的 owner。 */ + isOwnerScopeCurrent?: (scope: PendingCredentialSwitchOwnerScope) => boolean; /** 生效后广播给 renderer(清「任务结束后生效」标记 / 会话内 toast)。 */ broadcastApplied?: (payload: { sessionId: string; @@ -160,9 +169,10 @@ export class PendingCredentialSwitchService { effort?: string; fastMode?: boolean; }; + ownerScope?: PendingCredentialSwitchOwnerScope; }, ): void { - this.pending.set(sessionId, { + const pending: PendingCredentialSwitch = { model: target.model, providerId: target.providerId, ...(target.rebuildCodexThread ? { rebuildCodexThread: true } : {}), @@ -172,8 +182,18 @@ export class PendingCredentialSwitchService { : {}), ...(target.agentKind ? { agentKind: target.agentKind } : {}), ...(target.previousRoute ? { previousRoute: target.previousRoute } : {}), + ...(target.ownerScope ? { ownerScope: target.ownerScope } : {}), requestedAt: Date.now(), - }); + }; + if (!this.ownerScopeCurrent(pending)) { + const existing = this.pending.get(sessionId); + if (existing && !this.ownerScopeCurrent(existing)) this.clear(sessionId); + this.deps.logger?.info('stale pending credential switch discarded at registration', { + sessionId, + }); + return; + } + this.pending.set(sessionId, pending); this.scheduleRetry(sessionId); this.deps.logger?.info('pending credential switch registered', { sessionId, @@ -183,11 +203,15 @@ export class PendingCredentialSwitchService { } has(sessionId: string): boolean { - return this.pending.has(sessionId); + const target = this.pending.get(sessionId); + if (!target) return false; + return !this.discardIfOwnerStale(sessionId, target); } 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 { @@ -203,6 +227,7 @@ export class PendingCredentialSwitchService { async onTurnSettled(sessionId: string): Promise { 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() @@ -238,6 +263,7 @@ export class PendingCredentialSwitchService { // 收口,不能用进入函数时捕获的 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); @@ -255,6 +281,7 @@ export class PendingCredentialSwitchService { 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); @@ -371,7 +398,10 @@ export class PendingCredentialSwitchService { 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); } @@ -427,7 +457,8 @@ export class PendingCredentialSwitchService { 'pending credential switch: Codex thread relink dependency is unavailable; keeping queue gated', { sessionId, model: resolved.model ?? target.model, providerId: resolved.providerId }, ); - if (this.pending.get(sessionId) === target) this.scheduleRetry(sessionId); + if (this.targetCurrent(sessionId, target)) this.scheduleRetry(sessionId); + else this.discardIfOwnerStale(sessionId, target); return; } try { @@ -442,7 +473,7 @@ export class PendingCredentialSwitchService { : {}), targetModel: resolved.model ?? target.model, targetProviderId: resolved.providerId, - isCurrent: () => this.pending.get(sessionId) === target, + isCurrent: () => this.targetCurrent(sessionId, target), }); } catch (err) { this.deps.logger?.error?.( @@ -454,10 +485,29 @@ export class PendingCredentialSwitchService { error: err instanceof Error ? err.message : String(err), }, ); - if (this.pending.get(sessionId) === target) this.scheduleRetry(sessionId); + if (this.targetCurrent(sessionId, target)) this.scheduleRetry(sessionId); + else this.discardIfOwnerStale(sessionId, target); + return; + } + if (!this.targetCurrent(sessionId, target)) { + try { + if (relinkReceipt) await relinkReceipt.rollback(); + } catch (rollbackError) { + this.deps.logger?.error?.( + 'pending credential switch: owner-stale Codex relink rollback failed', + { + sessionId, + error: + rollbackError instanceof Error + ? rollbackError.message + : String(rollbackError), + }, + ); + } finally { + this.discardIfOwnerStale(sessionId, target); + } return; } - if (this.pending.get(sessionId) !== target) return; } setSessionProvider(sessionId, resolved.providerId); // 裁决改了落地路由(reroute / 丢弃停用显式来源 / 全停换模型或清空):回写 DB @@ -527,13 +577,14 @@ export class PendingCredentialSwitchService { : {}), }, ); - if (this.pending.get(sessionId) === target) this.scheduleRetry(sessionId); + if (this.targetCurrent(sessionId, target)) this.scheduleRetry(sessionId); + else this.discardIfOwnerStale(sessionId, target); return; } // await 期间用户可能改选 / 取消:本次让位,新登记有自己的收口路径。 // relink 已在 persistRoute 之前提交;若这一代此刻已过时,必须先把它回滚, // 否则下一次无 live session 的选择会沿用被放弃来源的 sdk_session_id。 - if (this.pending.get(sessionId) !== target) { + if (!this.targetCurrent(sessionId, target)) { if (relinkReceipt) { try { const restored = await relinkReceipt.rollback(); @@ -554,6 +605,7 @@ export class PendingCredentialSwitchService { ); } } + this.discardIfOwnerStale(sessionId, target); return; } } @@ -576,7 +628,8 @@ export class PendingCredentialSwitchService { 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 回写之后。 @@ -606,12 +659,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); } }); @@ -624,4 +680,29 @@ export class PendingCredentialSwitchService { if (timer) clearTimeout(timer); this.retryTimers.delete(sessionId); } + + 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 discardIfOwnerStale( + sessionId: string, + target: PendingCredentialSwitch, + ): boolean { + if (this.ownerScopeCurrent(target)) return false; + if (this.pending.get(sessionId) === target) { + this.pending.delete(sessionId); + this.clearRetry(sessionId); + this.deps.logger?.info('stale pending credential switch discarded after owner boundary', { + sessionId, + }); + } + return true; + } } diff --git a/apps/desktop/src/main/maker-ipc/register.ts b/apps/desktop/src/main/maker-ipc/register.ts index 3b23937466..54efb92821 100644 --- a/apps/desktop/src/main/maker-ipc/register.ts +++ b/apps/desktop/src/main/maker-ipc/register.ts @@ -59,6 +59,7 @@ import { import { and, desc, eq, gte, inArray, isNull, lt, sql } from 'drizzle-orm'; import { app, BrowserWindow, dialog, ipcMain, shell, type IpcMainInvokeEvent } from 'electron'; import { + activeOwnerScopeKey, getActiveAppSession, getActiveDataOwnerPushStamp, isAppSessionBoundaryPending, @@ -3063,6 +3064,7 @@ export async function registerPendingCredentialSwitchForSession( providerId: string | null; rebuildCodexThread?: boolean; codexThreadRelinkCommitted?: boolean; + ownerScope?: { ownerScopeKey: string; runtimeOwnerEpoch: string }; sourceCodexThreadModelProviderId?: string | null; previousRoute?: { model: string; @@ -3076,6 +3078,12 @@ export async function registerPendingCredentialSwitchForSession( 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(), + }; // 捕获会话 agent:deferred 切换在收口时刻要重过停用裁决(期间目标可能被停用, // PR #744 review 第七轮);读不到(会话行缺失)则登记不带 agentKind = 收口不裁决。 const dbAgentKind = getSessionDbAgentKind(sessionId); @@ -3110,6 +3118,7 @@ export async function registerPendingCredentialSwitchForSession( const prevModel = live?.model ?? prevRow?.model ?? null; service.register(sessionId, { ...target, + ownerScope, ...(live?.codexThreadModelProviderId !== undefined ? { sourceCodexThreadModelProviderId: live.codexThreadModelProviderId } : {}), @@ -3309,6 +3318,7 @@ export function getPendingCredentialSwitchTarget(sessionId: string): providerId: string | null; rebuildCodexThread?: boolean; codexThreadRelinkCommitted?: boolean; + ownerScope?: { ownerScopeKey: string; runtimeOwnerEpoch: string }; sourceCodexThreadModelProviderId?: string | null; previousRoute?: { model: string; @@ -3327,6 +3337,7 @@ export function getPendingCredentialSwitchTarget(sessionId: string): ...(pending.codexThreadRelinkCommitted ? { codexThreadRelinkCommitted: true } : {}), + ...(pending.ownerScope ? { ownerScope: pending.ownerScope } : {}), ...(pending.sourceCodexThreadModelProviderId !== undefined ? { sourceCodexThreadModelProviderId: pending.sourceCodexThreadModelProviderId } : {}), @@ -13629,6 +13640,10 @@ 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); }, diff --git a/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts b/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts index 68a929f93e..5384e487b2 100644 --- a/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts +++ b/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts @@ -85,6 +85,7 @@ export interface ApplyRuntimeSetModelChangeInput { providerId: string | null; rebuildCodexThread?: boolean; codexThreadRelinkCommitted?: boolean; + ownerScope?: { ownerScopeKey: string; runtimeOwnerEpoch: string }; sourceCodexThreadModelProviderId?: string | null; previousRoute?: { model: string; @@ -119,6 +120,7 @@ export interface ApplyRuntimeSetModelChangeInput { providerId: string | null; rebuildCodexThread?: boolean; codexThreadRelinkCommitted?: boolean; + ownerScope?: { ownerScopeKey: string; runtimeOwnerEpoch: string }; sourceCodexThreadModelProviderId?: string | null; previousRoute?: { model: string; From c0d129adb7fa58f427c2c8596d21e713027879d5 Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:53:08 +0800 Subject: [PATCH 10/36] fix(desktop): roll back persisted Codex route atomically Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- .../sessionRuntimeControlWiring.test.ts | 26 +++- .../codexProviderThreadRelink.test.ts | 50 ++++++++ .../maker-ipc/codexProviderThreadRelink.ts | 38 ++++++ apps/desktop/src/main/maker-ipc/register.ts | 121 ++++++++++++++++-- 4 files changed, 219 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts b/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts index aef2875a93..35817f8f1d 100644 --- a/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts +++ b/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts @@ -203,26 +203,40 @@ describe('session runtime control wiring', () => { expect(setModel).toContain("throw new Error('Codex thread relink rollback was superseded')"); }); - it('rolls back a committed Codex relink before an owner-boundary request exits', () => { + 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 rollbackRelinkForSupersededOwner = async (): Promise => {', + 'const rollbackRuntimeSelectionForSupersededOwner = async (): Promise => {', ); - const rollback = setModel.indexOf('await rollbackAppliedCodexThreadRelink();', guard); + const rollback = setModel.indexOf('await rollbackAppliedRuntimeSelection();', guard); const ownerFailure = setModel.indexOf('assertRuntimeOwnerCurrent();', guard); expect(setModel).toContain('!isAppSessionBoundaryPending()'); - expect(setModel).toContain('const rollbackAppliedCodexThreadRelink = async ()'); + 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 rollbackRelinkForSupersededOwner\(\)/g)).toHaveLength(3); + expect(setModel.match(/await rollbackRuntimeSelectionForSupersededOwner\(\)/g)).toHaveLength(3); const outerCatch = setModel.lastIndexOf('} catch (err) {'); - expect(setModel.indexOf('await rollbackAppliedCodexThreadRelink();', outerCatch)).toBeGreaterThan( + expect(setModel.indexOf('await rollbackAppliedRuntimeSelection();', outerCatch)).toBeGreaterThan( outerCatch, ); }); diff --git a/apps/desktop/src/main/maker-ipc/__tests__/codexProviderThreadRelink.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/codexProviderThreadRelink.test.ts index 9e85181e2a..81fd9c1128 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/codexProviderThreadRelink.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/codexProviderThreadRelink.test.ts @@ -3,9 +3,59 @@ import { describe, expect, it, vi } from 'vitest'; import { 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 () => { diff --git a/apps/desktop/src/main/maker-ipc/codexProviderThreadRelink.ts b/apps/desktop/src/main/maker-ipc/codexProviderThreadRelink.ts index 7d38d94363..647679d6fd 100644 --- a/apps/desktop/src/main/maker-ipc/codexProviderThreadRelink.ts +++ b/apps/desktop/src/main/maker-ipc/codexProviderThreadRelink.ts @@ -18,6 +18,44 @@ export interface CodexProviderThreadRelinkReceipt { rollback(): Promise; } +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 diff --git a/apps/desktop/src/main/maker-ipc/register.ts b/apps/desktop/src/main/maker-ipc/register.ts index 54efb92821..60d781c4c9 100644 --- a/apps/desktop/src/main/maker-ipc/register.ts +++ b/apps/desktop/src/main/maker-ipc/register.ts @@ -885,6 +885,7 @@ import { import { commitCodexProviderThreadRelinkWithBoundaryGuard, relinkCodexProviderThread, + rollbackPersistedCodexRuntimeSelection, type CodexProviderThreadRelinkReceipt, } from './codexProviderThreadRelink.js'; import { @@ -15140,11 +15141,16 @@ 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({ + const [runtimeStatus] = await runtimeDbSnapshot.client.drizzle + .select({ status: sessions.status, orcaRole: sessions.orcaRole, agentKind: sessions.agentKind, @@ -15152,6 +15158,8 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) sdkSessionId: sessions.sdkSessionId, model: sessions.model, providerId: sessions.providerId, + effort: sessions.effort, + fastMode: sessions.fastMode, }) .from(sessions) .where(eq(sessions.id, sessionId)) @@ -15467,6 +15475,14 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) }); }; let appliedCodexThreadRelink: CodexProviderThreadRelinkReceipt | undefined; + let appliedPersistedRuntimeRoute: + | { + model: string; + providerId: string | null; + effort: typeof runtimeStatus.effort; + fastMode: boolean; + } + | undefined; const rollbackAppliedCodexThreadRelink = async (): Promise => { const receipt = appliedCodexThreadRelink; if (!receipt) return true; @@ -15474,6 +15490,76 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) appliedCodexThreadRelink = undefined; return restored; }; + const rollbackAppliedRuntimeSelection = async (): Promise => { + const persistedRoute = appliedPersistedRuntimeRoute; + if (!persistedRoute) return rollbackAppliedCodexThreadRelink(); + + // 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: + | { + sdkSessionId: string | null; + model: string; + providerId: string | null; + effort: typeof runtimeStatus.effort; + fastMode: boolean; + } + | 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; + + if ( + runtimeOwnerBoundaryCurrent() && + isDataOwnerBroadcastScopeCurrent(runtimeOwnerScope) && + getCurrentDbClientSnapshot()?.clientEpoch === runtimeDbSnapshot.clientEpoch + ) { + broadcastSessionPatched( + sessionId, + { + ...restoredState, + }, + runtimeOwnerScope, + ); + } + return true; + }; try { const result = routeExplicit ? await applyRuntimeSetModelChange({ @@ -15520,15 +15606,18 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) deferred: result.status === 'deferred', superseded: false, }; - const rollbackRelinkForSupersededOwner = async (): Promise => { + const rollbackRuntimeSelectionForSupersededOwner = async (): Promise => { if (runtimeOwnerBoundaryCurrent()) return false; - await rollbackAppliedCodexThreadRelink(); + const restored = await rollbackAppliedRuntimeSelection(); + if (!restored) { + throw new Error('runtime selection rollback was superseded'); + } if (internalOptions.source === 'user') { assertRuntimeOwnerCurrent(); } return true; }; - if (await rollbackRelinkForSupersededOwner()) { + if (await rollbackRuntimeSelectionForSupersededOwner()) { return { deferred: false, superseded: true }; } if (atomicSelection) { @@ -15580,6 +15669,15 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) } try { 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 @@ -15636,7 +15734,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) agentSwitchIntent: null, agentSwitchIntentCanceled: true, }); - if (await rollbackRelinkForSupersededOwner()) { + if (await rollbackRuntimeSelectionForSupersededOwner()) { return { deferred: false, superseded: true }; } let generation: number; @@ -15752,8 +15850,8 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) }); // 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 relink before the request can report success or owner supersession. - if (await rollbackRelinkForSupersededOwner()) { + // old-profile thread and route before the request can report success or supersession. + if (await rollbackRuntimeSelectionForSupersededOwner()) { return { deferred: false, superseded: true }; } return { @@ -15763,11 +15861,14 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) }; } catch (err) { try { - await rollbackAppliedCodexThreadRelink(); + 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 Codex thread relink rollback also failed', + 'runtime selection failed and persisted route rollback also failed', ); } if (err instanceof CredentialModeSwitchBusyError) { From 7337ac6ad9247e7444373dc0b05067ab6b567848 Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:03:11 +0800 Subject: [PATCH 11/36] fix(desktop): compensate stale pending Codex routes Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- .../sessionRuntimeControlWiring.test.ts | 17 ++++ .../__tests__/pendingCredentialSwitch.test.ts | 65 +++++++++++++ .../main/maker-ipc/pendingCredentialSwitch.ts | 55 +++++++++-- apps/desktop/src/main/maker-ipc/register.ts | 96 ++++++++++++++++--- .../src/main/maker-ipc/runtimeSetModel.ts | 2 + 5 files changed, 217 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts b/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts index 35817f8f1d..55b35db7f4 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); @@ -287,6 +291,7 @@ describe('session runtime control wiring', () => { expect(getter).toContain('pending.ownerScope'); expect(getter).toContain('pending.sourceCodexThreadModelProviderId'); expect(getter).toContain('pending.previousRoute'); + expect(getter).toContain('pending.restoreStaleOwnerRoute'); }); it('binds deferred credential switches to the captured owner scope and runtime epoch', () => { @@ -306,6 +311,18 @@ describe('session runtime control wiring', () => { 'runtimeOwnerEpoch: captureSessionRuntimeControlOwnerEpoch()', ); expect(registration).toContain('target.ownerScope ??'); + expect(registration).toContain('const dbSnapshot = getCurrentDbClientSnapshot();'); + expect(registration).toContain('restoreCapturedProfileRoute'); + 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', + ); + expect(pendingCredentialSwitchSource).toContain('restoreStaleOwnerRoute'); + expect(pendingCredentialSwitchSource).toContain('this.staleDiscards.has(target)'); expect(service).toContain('!isAppSessionBoundaryPending()'); expect(service).toContain('activeOwnerScopeKey() === scope.ownerScopeKey'); expect(service).toContain( 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 94456119ec..047e21bc66 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/pendingCredentialSwitch.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/pendingCredentialSwitch.test.ts @@ -276,6 +276,71 @@ describe('PendingCredentialSwitchService', () => { expect(h.onApplied).not.toHaveBeenCalled(); }); + 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(false); + expect(h.service.get(sessionId)).toBeUndefined(); + await vi.waitFor(() => expect(restoreStaleOwnerRoute).toHaveBeenCalledOnce()); + + 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('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' }; diff --git a/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts b/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts index 0c311f4b6b..52d3fe3d21 100644 --- a/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts +++ b/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts @@ -63,9 +63,16 @@ 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; + /** owner 失效时,针对登记时捕获的旧 Profile 成套恢复 thread + route。 */ + restoreStaleOwnerRoute?: () => Promise; requestedAt: number; } @@ -151,6 +158,8 @@ 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(); constructor(private readonly deps: PendingCredentialSwitchDeps) {} @@ -170,6 +179,7 @@ export class PendingCredentialSwitchService { fastMode?: boolean; }; ownerScope?: PendingCredentialSwitchOwnerScope; + restoreStaleOwnerRoute?: () => Promise; }, ): void { const pending: PendingCredentialSwitch = { @@ -183,11 +193,16 @@ export class PendingCredentialSwitchService { ...(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.clear(sessionId); + if (existing && !this.ownerScopeCurrent(existing)) { + this.discardIfOwnerStale(sessionId, existing); + } this.deps.logger?.info('stale pending credential switch discarded at registration', { sessionId, }); @@ -697,11 +712,39 @@ export class PendingCredentialSwitchService { ): boolean { if (this.ownerScopeCurrent(target)) return false; if (this.pending.get(sessionId) === target) { - this.pending.delete(sessionId); + // Stop retries immediately, but keep the exact target identity until its captured old + // Profile has been compensated. A new owner may register the same session id meanwhile; + // the identity check in finally prevents the old cleanup from deleting that new target. this.clearRetry(sessionId); - this.deps.logger?.info('stale pending credential switch discarded after owner boundary', { - sessionId, - }); + if (this.staleDiscards.has(target)) return true; + this.staleDiscards.add(target); + const restore = target.restoreStaleOwnerRoute; + void (async () => { + try { + if (restore) { + const restored = await restore(); + if (!restored) { + this.deps.logger?.warn( + 'stale pending credential switch route compensation was superseded', + { sessionId }, + ); + } + } + } catch (error) { + this.deps.logger?.error?.('stale pending credential switch route compensation failed', { + sessionId, + error: error instanceof Error ? error.message : String(error), + }); + } finally { + this.staleDiscards.delete(target); + if (this.pending.get(sessionId) === target) { + this.pending.delete(sessionId); + } + this.deps.logger?.info('stale pending credential switch discarded after owner boundary', { + sessionId, + }); + } + })(); } return true; } diff --git a/apps/desktop/src/main/maker-ipc/register.ts b/apps/desktop/src/main/maker-ipc/register.ts index 60d781c4c9..1c40a983f8 100644 --- a/apps/desktop/src/main/maker-ipc/register.ts +++ b/apps/desktop/src/main/maker-ipc/register.ts @@ -56,7 +56,7 @@ 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, @@ -3073,6 +3073,7 @@ export async function registerPendingCredentialSwitchForSession( effort?: string; fastMode?: boolean; }; + restoreStaleOwnerRoute?: () => Promise; }, ): Promise { const service = pendingCredentialSwitchHolder; @@ -3085,6 +3086,7 @@ export async function registerPendingCredentialSwitchForSession( ownerScopeKey: activeOwnerScopeKey(), runtimeOwnerEpoch: captureSessionRuntimeControlOwnerEpoch(), }; + const dbSnapshot = getCurrentDbClientSnapshot(); // 捕获会话 agent:deferred 切换在收口时刻要重过停用裁决(期间目标可能被停用, // PR #744 review 第七轮);读不到(会话行缺失)则登记不带 agentKind = 收口不裁决。 const dbAgentKind = getSessionDbAgentKind(sessionId); @@ -3100,26 +3102,92 @@ export async function registerPendingCredentialSwitchForSession( 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; + 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; + let restoreCapturedProfileRoute: (() => 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 (): Promise => { + const sdkMatches = + capturedPrevRow.sdkSessionId === null + ? isNull(sessions.sdkSessionId) + : eq(sessions.sdkSessionId, capturedPrevRow.sdkSessionId); + const targetProviderMatches = + target.providerId === null + ? isNull(sessions.providerId) + : eq(sessions.providerId, target.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, + effort: capturedPrevRow.effort, + fastMode: capturedPrevRow.fastMode, + updatedAt: Date.now(), + }) + .where( + and( + eq(sessions.id, sessionId), + sdkMatches, + or( + and(eq(sessions.model, target.model), targetProviderMatches), + 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'); + } service.register(sessionId, { ...target, ownerScope, + ...(target.restoreStaleOwnerRoute + ? { restoreStaleOwnerRoute: target.restoreStaleOwnerRoute } + : restoreCapturedProfileRoute + ? { restoreStaleOwnerRoute: restoreCapturedProfileRoute } + : {}), ...(live?.codexThreadModelProviderId !== undefined ? { sourceCodexThreadModelProviderId: live.codexThreadModelProviderId } : {}), @@ -3327,6 +3395,7 @@ export function getPendingCredentialSwitchTarget(sessionId: string): effort?: string; fastMode?: boolean; }; + restoreStaleOwnerRoute?: () => Promise; } | undefined { const pending = pendingCredentialSwitchHolder?.get(sessionId); @@ -3343,6 +3412,9 @@ export function getPendingCredentialSwitchTarget(sessionId: string): ? { sourceCodexThreadModelProviderId: pending.sourceCodexThreadModelProviderId } : {}), ...(pending.previousRoute ? { previousRoute: pending.previousRoute } : {}), + ...(pending.restoreStaleOwnerRoute + ? { restoreStaleOwnerRoute: pending.restoreStaleOwnerRoute } + : {}), } : undefined; } diff --git a/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts b/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts index 5384e487b2..08521d7f42 100644 --- a/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts +++ b/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts @@ -93,6 +93,7 @@ export interface ApplyRuntimeSetModelChangeInput { effort?: string; fastMode?: boolean; }; + restoreStaleOwnerRoute?: () => Promise; }, ) => void | Promise; /** @@ -128,6 +129,7 @@ export interface ApplyRuntimeSetModelChangeInput { effort?: string; fastMode?: boolean; }; + restoreStaleOwnerRoute?: () => Promise; } | undefined; /** From 31b9c02bf6736460a290ae078a38aa0a1358be90 Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:08:24 +0800 Subject: [PATCH 12/36] fix(desktop): restore live runtime after route rollback Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- .../sessionRuntimeControlWiring.test.ts | 34 ++++++++++++++++++ apps/desktop/src/main/maker-ipc/register.ts | 35 +++++++++++++++++-- 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts b/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts index 55b35db7f4..6f7934d6fc 100644 --- a/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts +++ b/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts @@ -245,6 +245,40 @@ describe('session runtime control wiring', () => { ); }); + it('restores host stores and retires the live session after a post-persistence failure', () => { + const setModel = handlerBody( + registerSource, + 'const handleSetModel = async (', + 'const recoverRemoteRuntimeAxisPersistence', + ); + const rollback = setModel.indexOf( + 'const rollbackAppliedRuntimeSelection = async (): Promise => {', + ); + const sqliteRestore = setModel.indexOf('rollbackPersistedCodexRuntimeSelection({', rollback); + const ownerGuard = setModel.indexOf('const runtimeRecoveryCurrent =', sqliteRestore); + const restoreStores = setModel.indexOf('restoreControlStores();', ownerGuard); + const retireLiveSession = setModel.indexOf( + 'await withRehydrateCloseSuppressed(sessionId, () => maker.closeSession(sessionId));', + restoreStores, + ); + const projectionRead = setModel.indexOf('const projectionMeta = await maker.getSessionMeta(sessionId);'); + const outerCatch = setModel.lastIndexOf('} catch (err) {'); + + expect(sqliteRestore).toBeGreaterThan(rollback); + expect(ownerGuard).toBeGreaterThan(sqliteRestore); + expect(restoreStores).toBeGreaterThan(ownerGuard); + expect(retireLiveSession).toBeGreaterThan(restoreStores); + 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('treats teardown pending as stale at the final relink commit boundary', () => { const relink = handlerBody( registerSource, diff --git a/apps/desktop/src/main/maker-ipc/register.ts b/apps/desktop/src/main/maker-ipc/register.ts index 1c40a983f8..acdd1bbb06 100644 --- a/apps/desktop/src/main/maker-ipc/register.ts +++ b/apps/desktop/src/main/maker-ipc/register.ts @@ -15555,6 +15555,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) fastMode: boolean; } | undefined; + let appliedRuntimeSelectionWasDeferred = false; const rollbackAppliedCodexThreadRelink = async (): Promise => { const receipt = appliedCodexThreadRelink; if (!receipt) return true; @@ -15617,11 +15618,32 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) appliedCodexThreadRelink = undefined; if (!restored || !restoredState) return false; - if ( + const runtimeRecoveryCurrent = runtimeOwnerBoundaryCurrent() && isDataOwnerBroadcastScopeCurrent(runtimeOwnerScope) && - getCurrentDbClientSnapshot()?.clientEpoch === runtimeDbSnapshot.clientEpoch - ) { + getCurrentDbClientSnapshot()?.clientEpoch === runtimeDbSnapshot.clientEpoch; + if (runtimeRecoveryCurrent) { + // Device Link receives this handler's failure as the complete SET_MODEL result and + // does not issue a renderer-side rollback. Restore the host stores and retire any + // already-mutated live Session in the same compensation unit as the SQLite route. + pendingCredentialSwitchHolder?.clear(sessionId); + restoreControlStores(); + const recoveryErrors: unknown[] = []; + if (!appliedRuntimeSelectionWasDeferred && maker.getSession(sessionId)) { + try { + await withRehydrateCloseSuppressed(sessionId, () => maker.closeSession(sessionId)); + } catch (error) { + recoveryErrors.push(error); + } + } + if (previousRuntime.pendingCredentialSwitch) { + pendingCredentialSwitchHolder?.register( + sessionId, + previousRuntime.pendingCredentialSwitch, + ); + } else if (recoveryErrors.length === 0) { + wakeSessionInputAfterCredentialSwitch(sessionId); + } broadcastSessionPatched( sessionId, { @@ -15629,6 +15651,12 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) }, runtimeOwnerScope, ); + if (recoveryErrors.length > 0) { + throw new AggregateError( + recoveryErrors, + 'persisted runtime selection rollback could not retire the live session', + ); + } } return true; }; @@ -15678,6 +15706,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) deferred: result.status === 'deferred', superseded: false, }; + appliedRuntimeSelectionWasDeferred = response.deferred; const rollbackRuntimeSelectionForSupersededOwner = async (): Promise => { if (runtimeOwnerBoundaryCurrent()) return false; const restored = await rollbackAppliedRuntimeSelection(); From 45e8e9622a28588605debc7d26f4a7e289c01ba3 Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:13:46 +0800 Subject: [PATCH 13/36] fix(desktop): preserve IM switch source route Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- .../sessionRuntimeControlWiring.test.ts | 3 + .../cardActionTakeoverReplace.test.ts | 31 +++++++-- .../desktop/src/main/im/shared/sessionRepo.ts | 3 + .../__tests__/runtimeSetModel.test.ts | 18 +++++ apps/desktop/src/main/maker-ipc/register.ts | 67 +++++++++++++------ .../src/main/maker-ipc/runtimeSetModel.ts | 12 ++++ 6 files changed, 107 insertions(+), 27 deletions(-) diff --git a/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts b/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts index 6f7934d6fc..5da809d0d8 100644 --- a/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts +++ b/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts @@ -346,6 +346,9 @@ describe('session runtime control wiring', () => { ); 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('sdkSessionId: capturedPrevRow.sdkSessionId'); expect(registration).toContain('model: capturedPrevRow.model'); 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 91ebbf1361..6ddb6475c4 100644 --- a/apps/desktop/src/main/im/shared/__tests__/cardActionTakeoverReplace.test.ts +++ b/apps/desktop/src/main/im/shared/__tests__/cardActionTakeoverReplace.test.ts @@ -35,6 +35,7 @@ const mocks = vi.hoisted(() => ({ sdkSessionId: string | null; model: string; effort: string; + fastMode?: boolean; providerId: string | null; } | null> => null, ), @@ -655,6 +656,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, @@ -667,12 +678,18 @@ describe('model:pick 持久化失败', () => { const hooks = input as { registerPendingCredentialSwitch?: ( sessionId: string, - target: { model: string; providerId: string | null }, + target: { + model: string; + providerId: string | null; + sourcePersistedSession?: unknown; + }, ) => void; + persistedSession?: unknown; }; hooks.registerPendingCredentialSwitch?.('sess-target', { model: 'claude-opus-4-7', providerId: 'anthropic', + sourcePersistedSession: hooks.persistedSession, }); return { status: 'deferred' as const }; }); @@ -688,10 +705,14 @@ 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', + sourcePersistedSession: sourceSnapshot, + }), + ); expect(live.setEffort).not.toHaveBeenCalled(); expect(im.updateInteractiveCard).toHaveBeenCalledWith( 'model-card', diff --git a/apps/desktop/src/main/im/shared/sessionRepo.ts b/apps/desktop/src/main/im/shared/sessionRepo.ts index 82ecd9580c..c10899faca 100644 --- a/apps/desktop/src/main/im/shared/sessionRepo.ts +++ b/apps/desktop/src/main/im/shared/sessionRepo.ts @@ -75,6 +75,7 @@ export interface SessionModelRouteSnapshot { sdkSessionId: string | null; model: string; effort: Effort; + fastMode: boolean; providerId: string | null; } @@ -593,6 +594,7 @@ export async function readModelRouteSnapshot( sdkSessionId: sessions.sdkSessionId, model: sessions.model, effort: sessions.effort, + fastMode: sessions.fastMode, providerId: sessions.providerId, }) .from(sessions) @@ -606,6 +608,7 @@ export async function readModelRouteSnapshot( 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-ipc/__tests__/runtimeSetModel.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/runtimeSetModel.test.ts index 48a4619688..f0d461c2f4 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/runtimeSetModel.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/runtimeSetModel.test.ts @@ -375,6 +375,15 @@ 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, }); @@ -383,6 +392,15 @@ describe('applyRuntimeSetModelChange', () => { 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(); diff --git a/apps/desktop/src/main/maker-ipc/register.ts b/apps/desktop/src/main/maker-ipc/register.ts index acdd1bbb06..a2991b4ea3 100644 --- a/apps/desktop/src/main/maker-ipc/register.ts +++ b/apps/desktop/src/main/maker-ipc/register.ts @@ -881,6 +881,7 @@ import { import { applyRuntimeSetModelChange, isRemoteModelSwitchRouteChangeError, + type RuntimeSetModelPersistedSession, } from './runtimeSetModel.js'; import { commitCodexProviderThreadRelinkWithBoundaryGuard, @@ -3073,6 +3074,7 @@ export async function registerPendingCredentialSwitchForSession( effort?: string; fastMode?: boolean; }; + sourcePersistedSession?: RuntimeSetModelPersistedSession; restoreStaleOwnerRoute?: () => Promise; }, ): Promise { @@ -3091,12 +3093,14 @@ export async function registerPendingCredentialSwitchForSession( // 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; @@ -3104,25 +3108,38 @@ export async function registerPendingCredentialSwitchForSession( providerId: string | null; sdkSessionId: string | null; } | null = null; - 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; + 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; } - } catch { - prevRow = null; } - const prevModel = live?.model ?? prevRow?.model ?? null; + const prevModel = sourcePersistedSession?.model ?? live?.model ?? prevRow?.model ?? null; let restoreCapturedProfileRoute: (() => Promise) | undefined; if (dbSnapshot && prevRow?.model && prevRow.effort && prevRow.fastMode !== null) { const capturedPrevRow = { @@ -3196,7 +3213,11 @@ export async function registerPendingCredentialSwitchForSession( ? { 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 } : {}), }, @@ -15673,6 +15694,8 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) sdkSessionId: runtimeStatus.sdkSessionId, model: runtimeStatus.model, providerId: runtimeStatus.providerId ?? null, + effort: runtimeStatus.effort, + fastMode: runtimeStatus.fastMode, }, ...(atomicSelection?.effort ? { diff --git a/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts b/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts index 08521d7f42..0f3575022b 100644 --- a/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts +++ b/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts @@ -54,6 +54,8 @@ export interface RuntimeSetModelPersistedSession { model: string; providerId: string | null; sdkSessionId: string | null; + effort?: Effort; + fastMode?: boolean; } export interface ApplyRuntimeSetModelChangeInput { @@ -93,6 +95,7 @@ export interface ApplyRuntimeSetModelChangeInput { effort?: string; fastMode?: boolean; }; + sourcePersistedSession?: RuntimeSetModelPersistedSession; restoreStaleOwnerRoute?: () => Promise; }, ) => void | Promise; @@ -263,6 +266,9 @@ export async function applyRuntimeSetModelChange( model, providerId: nextProviderId, ...(shouldRelinkCodexThread ? { rebuildCodexThread: true } : {}), + ...(input.persistedSession + ? { sourcePersistedSession: input.persistedSession } + : {}), }); logger?.info('set-model: Codex provider route switch deferred until turn end', { sessionId, @@ -285,6 +291,9 @@ export async function applyRuntimeSetModelChange( model, providerId: nextProviderId, ...(shouldRelinkCodexThread ? { rebuildCodexThread: true } : {}), + ...(input.persistedSession + ? { sourcePersistedSession: input.persistedSession } + : {}), }); logger?.info('set-model: credential switch deferred until turn end', { sessionId, @@ -345,6 +354,9 @@ export async function applyRuntimeSetModelChange( model, providerId: nextProviderId, ...(shouldRelinkCodexThread ? { rebuildCodexThread: true } : {}), + ...(input.persistedSession + ? { sourcePersistedSession: input.persistedSession } + : {}), }); logger?.info('set-model: credential switch deferred after busy race', { sessionId, From 9f5c3a95f507169e41523a953a51d19d3d0ead31 Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:17:51 +0800 Subject: [PATCH 14/36] fix(desktop): compensate resolved pending route Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- .../sessionRuntimeControlWiring.test.ts | 2 + .../__tests__/pendingCredentialSwitch.test.ts | 58 +++++++++++++++++++ .../main/maker-ipc/pendingCredentialSwitch.ts | 45 +++++++++----- apps/desktop/src/main/maker-ipc/register.ts | 43 +++++++++++--- .../src/main/maker-ipc/runtimeSetModel.ts | 9 ++- 5 files changed, 133 insertions(+), 24 deletions(-) diff --git a/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts b/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts index 5da809d0d8..e79cf79d41 100644 --- a/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts +++ b/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts @@ -350,6 +350,8 @@ describe('session runtime control wiring', () => { 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'); 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 047e21bc66..815bfa8486 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/pendingCredentialSwitch.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/pendingCredentialSwitch.test.ts @@ -503,6 +503,64 @@ describe('PendingCredentialSwitchService', () => { h.service.clear(sessionId); }); + it('compensates the actual fallback route when the owner changes during persistence', async () => { + const sessionId = rememberSession('pending-switch-owner-stale-during-fallback-persist'); + setSessionProvider(sessionId, 'xd'); + let ownerCurrent = true; + const rollback = vi.fn(async () => true); + 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.mockResolvedValueOnce({ + previousSdkSessionId: 'thread-xd', + newSdkSessionId: 'thread-openai', + rollback, + }); + h.persistRoute.mockImplementationOnce(async () => { + ownerCurrent = false; + }); + + 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).toHaveBeenCalledWith(sessionId, persistedFallback); + expect(rollback).toHaveBeenCalledOnce(); + expect(restoreStaleOwnerRoute).toHaveBeenCalledWith(persistedFallback); + expect(h.service.get(sessionId)).toBeUndefined(); + expect(h.onApplied).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'); diff --git a/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts b/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts index 52d3fe3d21..9be5789ad5 100644 --- a/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts +++ b/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts @@ -46,6 +46,13 @@ export interface PendingCredentialSwitchOwnerScope { runtimeOwnerEpoch: string; } +export interface PendingCredentialSwitchPersistedRoute { + model: string; + providerId: string | null; + effort?: string; + fastMode?: boolean; +} + export interface PendingCredentialSwitch { model: string; providerId: string | null; @@ -72,7 +79,9 @@ export interface PendingCredentialSwitch { /** 登记时的 data owner + runtime epoch;跨账号/teardown 后旧 target 必须终态丢弃。 */ ownerScope?: PendingCredentialSwitchOwnerScope; /** owner 失效时,针对登记时捕获的旧 Profile 成套恢复 thread + route。 */ - restoreStaleOwnerRoute?: () => Promise; + restoreStaleOwnerRoute?: ( + persistedRoute?: PendingCredentialSwitchPersistedRoute, + ) => Promise; requestedAt: number; } @@ -179,7 +188,9 @@ export class PendingCredentialSwitchService { fastMode?: boolean; }; ownerScope?: PendingCredentialSwitchOwnerScope; - restoreStaleOwnerRoute?: () => Promise; + restoreStaleOwnerRoute?: ( + persistedRoute?: PendingCredentialSwitchPersistedRoute, + ) => Promise; }, ): void { const pending: PendingCredentialSwitch = { @@ -534,6 +545,19 @@ export class PendingCredentialSwitchService { // 冻结;内存 store 已是权威路由,DB 差异在下次显式切换收敛)。 const modelChanged = !!resolved.model && resolved.model !== target.model; const routeChanged = resolved.providerId !== target.providerId || modelChanged; + const resolvedRoute: PendingCredentialSwitchPersistedRoute = { + providerId: resolved.providerId, + model: resolved.model ?? target.model, + ...(modelChanged + ? { + ...(resolved.effort ? { effort: resolved.effort } : {}), + ...(resolved.fastMode !== undefined ? { fastMode: resolved.fastMode } : {}), + } + : {}), + }; + // Only a successful await proves this exact fallback tuple reached SQLite. If the owner + // expires in that window, stale cleanup must compare against it rather than the request. + let persistedResolvedRoute: PendingCredentialSwitchPersistedRoute | undefined; // 恒写幂等回正(最后收口者赢,PR #744 review 第二十三轮):旧登记的 finalizer 在 // await persistRoute 期间被新选择替换时,旧写可能晚于 renderer 为新选择落的盘; // 若新登记收口时因「裁决未改路由」跳过回写,DB 会留着旧 finalizer 的迟到值, @@ -545,16 +569,8 @@ export class PendingCredentialSwitchService { // 既无竞态也不能引入异步,保持只在路由改动时回写。 if (this.deps.persistRoute && (routeChanged || this.deps.resolveRoute)) { 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; if (relinkReceipt) { @@ -620,7 +636,7 @@ export class PendingCredentialSwitchService { ); } } - this.discardIfOwnerStale(sessionId, target); + this.discardIfOwnerStale(sessionId, target, persistedResolvedRoute); return; } } @@ -709,6 +725,7 @@ export class PendingCredentialSwitchService { private discardIfOwnerStale( sessionId: string, target: PendingCredentialSwitch, + persistedRoute?: PendingCredentialSwitchPersistedRoute, ): boolean { if (this.ownerScopeCurrent(target)) return false; if (this.pending.get(sessionId) === target) { @@ -722,7 +739,7 @@ export class PendingCredentialSwitchService { void (async () => { try { if (restore) { - const restored = await restore(); + const restored = await restore(persistedRoute); if (!restored) { this.deps.logger?.warn( 'stale pending credential switch route compensation was superseded', diff --git a/apps/desktop/src/main/maker-ipc/register.ts b/apps/desktop/src/main/maker-ipc/register.ts index a2991b4ea3..4fcbf932dd 100644 --- a/apps/desktop/src/main/maker-ipc/register.ts +++ b/apps/desktop/src/main/maker-ipc/register.ts @@ -923,7 +923,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, @@ -3075,7 +3078,9 @@ export async function registerPendingCredentialSwitchForSession( fastMode?: boolean; }; sourcePersistedSession?: RuntimeSetModelPersistedSession; - restoreStaleOwnerRoute?: () => Promise; + restoreStaleOwnerRoute?: ( + persistedRoute?: PendingCredentialSwitchPersistedRoute, + ) => Promise; }, ): Promise { const service = pendingCredentialSwitchHolder; @@ -3148,15 +3153,21 @@ export async function registerPendingCredentialSwitchForSession( effort: prevRow.effort as typeof sessions.$inferSelect.effort, fastMode: prevRow.fastMode, }; - restoreCapturedProfileRoute = async (): Promise => { + restoreCapturedProfileRoute = async ( + persistedRoute?: PendingCredentialSwitchPersistedRoute, + ): Promise => { + const appliedRoute = persistedRoute ?? { + model: target.model, + providerId: target.providerId, + }; const sdkMatches = capturedPrevRow.sdkSessionId === null ? isNull(sessions.sdkSessionId) : eq(sessions.sdkSessionId, capturedPrevRow.sdkSessionId); - const targetProviderMatches = - target.providerId === null + const appliedProviderMatches = + appliedRoute.providerId === null ? isNull(sessions.providerId) - : eq(sessions.providerId, target.providerId); + : eq(sessions.providerId, appliedRoute.providerId); const previousProviderMatches = capturedPrevRow.providerId === null ? isNull(sessions.providerId) @@ -3176,7 +3187,21 @@ export async function registerPendingCredentialSwitchForSession( eq(sessions.id, sessionId), sdkMatches, or( - and(eq(sessions.model, target.model), targetProviderMatches), + 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, @@ -3416,7 +3441,9 @@ export function getPendingCredentialSwitchTarget(sessionId: string): effort?: string; fastMode?: boolean; }; - restoreStaleOwnerRoute?: () => Promise; + restoreStaleOwnerRoute?: ( + persistedRoute?: PendingCredentialSwitchPersistedRoute, + ) => Promise; } | undefined { const pending = pendingCredentialSwitchHolder?.get(sessionId); diff --git a/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts b/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts index 0f3575022b..bdd0edd05d 100644 --- a/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts +++ b/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts @@ -9,6 +9,7 @@ import { // 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, @@ -96,7 +97,9 @@ export interface ApplyRuntimeSetModelChangeInput { fastMode?: boolean; }; sourcePersistedSession?: RuntimeSetModelPersistedSession; - restoreStaleOwnerRoute?: () => Promise; + restoreStaleOwnerRoute?: ( + persistedRoute?: PendingCredentialSwitchPersistedRoute, + ) => Promise; }, ) => void | Promise; /** @@ -132,7 +135,9 @@ export interface ApplyRuntimeSetModelChangeInput { effort?: string; fastMode?: boolean; }; - restoreStaleOwnerRoute?: () => Promise; + restoreStaleOwnerRoute?: ( + persistedRoute?: PendingCredentialSwitchPersistedRoute, + ) => Promise; } | undefined; /** From 1c3763da2588bee8caea17be6d843afc1cd9053a Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:21:49 +0800 Subject: [PATCH 15/36] fix(desktop): reconcile retained runtime after close failure Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- .../sessionRuntimeControlWiring.test.ts | 18 ++++++++++++ apps/desktop/src/main/maker-ipc/register.ts | 28 ++++++++++++++----- 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts b/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts index e79cf79d41..d1e67bab9c 100644 --- a/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts +++ b/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts @@ -261,6 +261,18 @@ describe('session runtime control wiring', () => { '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( + "'persisted runtime selection rollback could not retire the live session'", + reconcileRetainedLive, + ); const projectionRead = setModel.indexOf('const projectionMeta = await maker.getSessionMeta(sessionId);'); const outerCatch = setModel.lastIndexOf('} catch (err) {'); @@ -268,6 +280,12 @@ describe('session runtime control wiring', () => { expect(ownerGuard).toBeGreaterThan(sqliteRestore); expect(restoreStores).toBeGreaterThan(ownerGuard); expect(retireLiveSession).toBeGreaterThan(restoreStores); + expect(restoredRouteBroadcast).toBeGreaterThan(retireLiveSession); + expect(reconcileRetainedLive).toBeGreaterThan(restoredRouteBroadcast); + expect(rollbackFailure).toBeGreaterThan(reconcileRetainedLive); + 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', diff --git a/apps/desktop/src/main/maker-ipc/register.ts b/apps/desktop/src/main/maker-ipc/register.ts index 4fcbf932dd..0e42b41f50 100644 --- a/apps/desktop/src/main/maker-ipc/register.ts +++ b/apps/desktop/src/main/maker-ipc/register.ts @@ -15677,20 +15677,24 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) 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 (previousRuntime.pendingCredentialSwitch) { - pendingCredentialSwitchHolder?.register( - sessionId, - previousRuntime.pendingCredentialSwitch, - ); - } else if (recoveryErrors.length === 0) { - wakeSessionInputAfterCredentialSwitch(sessionId); + if (recoveryErrors.length === 0) { + if (previousRuntime.pendingCredentialSwitch) { + pendingCredentialSwitchHolder?.register( + sessionId, + previousRuntime.pendingCredentialSwitch, + ); + } else { + wakeSessionInputAfterCredentialSwitch(sessionId); + } } broadcastSessionPatched( sessionId, @@ -15699,6 +15703,16 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) }, runtimeOwnerScope, ); + if (retainedLiveSession) { + try { + // The SQLite/thread route is already restored, but a close failure leaves the + // target live Session in memory. Re-adopt its actual profile into host stores and + // the effective runtime projection so later input never runs with mixed axes. + await reconcileRetainedLiveProfile(); + } catch (error) { + recoveryErrors.push(error); + } + } if (recoveryErrors.length > 0) { throw new AggregateError( recoveryErrors, From 9e4a6954adfadd0ab6ca1d7a4aae048e96c4c4c9 Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:27:25 +0800 Subject: [PATCH 16/36] fix(desktop): compensate stale pending registration Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- .../sessionRuntimeControlWiring.test.ts | 12 +++++ .../__tests__/pendingCredentialSwitch.test.ts | 51 +++++++++++++++++++ .../main/maker-ipc/pendingCredentialSwitch.ts | 49 +++++++++++------- apps/desktop/src/main/maker-ipc/register.ts | 9 ++-- 4 files changed, 100 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts b/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts index d1e67bab9c..bb817e0874 100644 --- a/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts +++ b/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts @@ -378,7 +378,19 @@ describe('session runtime control wiring', () => { 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'); 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 815bfa8486..5788a2c702 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/pendingCredentialSwitch.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/pendingCredentialSwitch.test.ts @@ -341,6 +341,57 @@ describe('PendingCredentialSwitchService', () => { 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' }; diff --git a/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts b/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts index 9be5789ad5..8ad79dff54 100644 --- a/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts +++ b/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts @@ -172,7 +172,7 @@ export class PendingCredentialSwitchService { constructor(private readonly deps: PendingCredentialSwitchDeps) {} - register( + async register( sessionId: string, target: { model: string; @@ -192,7 +192,7 @@ export class PendingCredentialSwitchService { persistedRoute?: PendingCredentialSwitchPersistedRoute, ) => Promise; }, - ): void { + ): Promise { const pending: PendingCredentialSwitch = { model: target.model, providerId: target.providerId, @@ -214,10 +214,13 @@ export class PendingCredentialSwitchService { 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. + await this.compensateStaleOwnerRoute(sessionId, pending); this.deps.logger?.info('stale pending credential switch discarded at registration', { sessionId, }); - return; + return false; } this.pending.set(sessionId, pending); this.scheduleRetry(sessionId); @@ -226,6 +229,7 @@ export class PendingCredentialSwitchService { model: target.model, providerId: target.providerId, }); + return true; } has(sessionId: string): boolean { @@ -722,6 +726,29 @@ export class PendingCredentialSwitchService { return this.pending.get(sessionId) === target && this.ownerScopeCurrent(target); } + private async compensateStaleOwnerRoute( + sessionId: string, + target: PendingCredentialSwitch, + persistedRoute?: PendingCredentialSwitchPersistedRoute, + ): Promise { + try { + if (target.restoreStaleOwnerRoute) { + const restored = await target.restoreStaleOwnerRoute(persistedRoute); + if (!restored) { + this.deps.logger?.warn( + 'stale pending credential switch route compensation was superseded', + { sessionId }, + ); + } + } + } catch (error) { + this.deps.logger?.error?.('stale pending credential switch route compensation failed', { + sessionId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + private discardIfOwnerStale( sessionId: string, target: PendingCredentialSwitch, @@ -735,23 +762,9 @@ export class PendingCredentialSwitchService { this.clearRetry(sessionId); if (this.staleDiscards.has(target)) return true; this.staleDiscards.add(target); - const restore = target.restoreStaleOwnerRoute; void (async () => { try { - if (restore) { - const restored = await restore(persistedRoute); - if (!restored) { - this.deps.logger?.warn( - 'stale pending credential switch route compensation was superseded', - { sessionId }, - ); - } - } - } catch (error) { - this.deps.logger?.error?.('stale pending credential switch route compensation failed', { - sessionId, - error: error instanceof Error ? error.message : String(error), - }); + await this.compensateStaleOwnerRoute(sessionId, target, persistedRoute); } finally { this.staleDiscards.delete(target); if (this.pending.get(sessionId) === target) { diff --git a/apps/desktop/src/main/maker-ipc/register.ts b/apps/desktop/src/main/maker-ipc/register.ts index 0e42b41f50..fb69552ddc 100644 --- a/apps/desktop/src/main/maker-ipc/register.ts +++ b/apps/desktop/src/main/maker-ipc/register.ts @@ -3222,7 +3222,7 @@ export async function registerPendingCredentialSwitchForSession( ) { throw new Error('Codex pending credential switch requires an old-profile rollback snapshot'); } - service.register(sessionId, { + const registered = await service.register(sessionId, { ...target, ownerScope, ...(target.restoreStaleOwnerRoute @@ -3249,6 +3249,9 @@ export async function registerPendingCredentialSwitchForSession( } : {}), }); + if (!registered) { + throw new Error('Pending credential switch owner changed during registration'); + } } /** @@ -15688,7 +15691,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) } if (recoveryErrors.length === 0) { if (previousRuntime.pendingCredentialSwitch) { - pendingCredentialSwitchHolder?.register( + await pendingCredentialSwitchHolder?.register( sessionId, previousRuntime.pendingCredentialSwitch, ); @@ -15873,7 +15876,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) } } if (previousRuntime.pendingCredentialSwitch) { - pendingCredentialSwitchHolder?.register( + await pendingCredentialSwitchHolder?.register( sessionId, previousRuntime.pendingCredentialSwitch, ); From feccf41e58d58ebf6d7819051f39d7c2cdb500dc Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:29:49 +0800 Subject: [PATCH 17/36] fix(desktop): gate deferred relink on close failure Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- .../__tests__/pendingCredentialSwitch.test.ts | 27 ++++++++++++------- .../main/maker-ipc/pendingCredentialSwitch.ts | 18 ++++++++----- 2 files changed, 30 insertions(+), 15 deletions(-) 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 5788a2c702..040cb3dfde 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/pendingCredentialSwitch.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/pendingCredentialSwitch.test.ts @@ -670,21 +670,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/pendingCredentialSwitch.ts b/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts index 8ad79dff54..71f7d59ff3 100644 --- a/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts +++ b/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts @@ -278,15 +278,21 @@ 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', + // 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), + 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(取消):以**当前**登记为准 From 0f75b86aa4a1643b4b368d72491c4a76f774c6c8 Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:44:24 +0800 Subject: [PATCH 18/36] fix(desktop): keep deferred relink route restart-safe Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- .../makerSendToSessionOrdering.test.ts | 4 + .../sessionRuntimeControlWiring.test.ts | 22 ++++++ .../cardActionTakeoverReplace.test.ts | 4 + .../src/main/im/shared/cardActionHandler.ts | 1 + .../__tests__/pendingCredentialSwitch.test.ts | 20 +++++ .../__tests__/runtimeSetModel.test.ts | 8 +- .../main/maker-ipc/pendingCredentialSwitch.ts | 73 ++++++++++++------- apps/desktop/src/main/maker-ipc/register.ts | 72 +++++++++++++----- .../src/main/maker-ipc/runtimeSetModel.ts | 34 +++++++-- .../deviceLinkInteractionScenarios.test.ts | 4 +- .../components/new-chat/ChatInput.tsx | 4 +- 11 files changed, 186 insertions(+), 60 deletions(-) diff --git a/apps/desktop/src/main/__tests__/makerSendToSessionOrdering.test.ts b/apps/desktop/src/main/__tests__/makerSendToSessionOrdering.test.ts index dcd9c9f808..54005a7c60 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( diff --git a/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts b/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts index bb817e0874..98eea9a8e7 100644 --- a/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts +++ b/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts @@ -346,6 +346,28 @@ describe('session runtime control wiring', () => { 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, 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 6ddb6475c4..23a6880f52 100644 --- a/apps/desktop/src/main/im/shared/__tests__/cardActionTakeoverReplace.test.ts +++ b/apps/desktop/src/main/im/shared/__tests__/cardActionTakeoverReplace.test.ts @@ -681,14 +681,17 @@ describe('model:pick 持久化失败', () => { 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 }; @@ -710,6 +713,7 @@ describe('model:pick 持久化失败', () => { expect.objectContaining({ model: 'claude-opus-4-7', providerId: 'anthropic', + effort: 'high', sourcePersistedSession: sourceSnapshot, }), ); diff --git a/apps/desktop/src/main/im/shared/cardActionHandler.ts b/apps/desktop/src/main/im/shared/cardActionHandler.ts index b3eea2b858..d4b6b28d6f 100644 --- a/apps/desktop/src/main/im/shared/cardActionHandler.ts +++ b/apps/desktop/src/main/im/shared/cardActionHandler.ts @@ -389,6 +389,7 @@ export function createCardActionHandler( sessionId, model: modelId, providerId, + ...(effort !== null ? { effort } : {}), ...(previousRoute ? { persistedSession: previousRoute } : {}), isSessionInTurn, registerPendingCredentialSwitch: registerPendingCredentialSwitchForSession, 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 040cb3dfde..bf54df1fc6 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/pendingCredentialSwitch.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/pendingCredentialSwitch.test.ts @@ -118,13 +118,23 @@ describe('PendingCredentialSwitchService', () => { const h = createHarness([ { id: sessionId, agentKind: 'codex', remoteHostId: null, isTurnRunning: () => false }, ]); + h.relinkCodexThreadForProviderSwitch.mockResolvedValueOnce({ + 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' }, }); + // 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); @@ -139,6 +149,16 @@ describe('PendingCredentialSwitchService', () => { expect(h.relinkCodexThreadForProviderSwitch.mock.invocationCallOrder[0]).toBeLessThan( h.onApplied.mock.invocationCallOrder[0]!, ); + expect(h.persistRoute).toHaveBeenCalledTimes(1); + expect(h.persistRoute).toHaveBeenCalledWith(sessionId, { + model: 'gpt-5.6-sol', + providerId: 'openai', + effort: 'xhigh', + fastMode: true, + }); + expect(h.persistRoute.mock.invocationCallOrder[0]).toBeLessThan( + h.onApplied.mock.invocationCallOrder[0]!, + ); }); it('recomputes relink from the final route when revalidation crosses thread families', 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 f0d461c2f4..ac66607073 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/runtimeSetModel.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/runtimeSetModel.test.ts @@ -387,7 +387,7 @@ describe('applyRuntimeSetModelChange', () => { registerPendingCredentialSwitch, }); - expect(result).toEqual({ status: 'deferred' }); + expect(result).toEqual({ status: 'deferred', preservePersistedRoute: true }); expect(registerPendingCredentialSwitch).toHaveBeenCalledWith(sessionId, { model: 'codex/gpt-5.5', providerId: 'xd', @@ -751,7 +751,7 @@ 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', @@ -961,7 +961,7 @@ 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', @@ -1068,7 +1068,7 @@ 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', diff --git a/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts b/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts index 71f7d59ff3..0514b3c061 100644 --- a/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts +++ b/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts @@ -34,8 +34,9 @@ import type { CodexProviderThreadRelinkReceipt } from './codexProviderThreadReli * 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 后才由 persistRoute 一次提交目标四轴。进程若提前 + * 退出,内存 pending 虽丢失,重启也只会安全恢复源 route,不会跨来源 resume。 */ /** 自愈兜底重试间隔(事件路径正常时用户感知不到它)。 */ @@ -56,6 +57,9 @@ export interface PendingCredentialSwitchPersistedRoute { 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。 */ @@ -130,14 +134,18 @@ export interface PendingCredentialSwitchDeps { }>; /** * 把收口裁决后的实际 route 持久化(生产 = 直写 sessions 行 + 广播 - * sessions:patched)。renderer 在 deferred 被接受那一刻已按**请求值**落盘 DB; - * 收口裁决改道 / 丢弃被停用的显式来源 / 全停换模型时必须回写实际值 —— 否则下 - * 一次懒 resume 按 DB 里的停用路由重建(resume 免裁决,PR #744 review 第十、 - * 十四轮)。缺席 = 只写内存 store(测试最小 harness)。 + * sessions:patched)。跨 family relink 在此首次提交目标 route;其它 deferred 可能已由 + * renderer 按请求值落盘,若收口裁决改道仍必须回写实际值。缺席 = 只写内存 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 替换持久化 @@ -177,6 +185,8 @@ export class PendingCredentialSwitchService { target: { model: string; providerId: string | null; + effort?: string; + fastMode?: boolean; rebuildCodexThread?: boolean; codexThreadRelinkCommitted?: boolean; sourceCodexThreadModelProviderId?: string | null; @@ -196,6 +206,8 @@ export class PendingCredentialSwitchService { 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 @@ -345,7 +357,11 @@ export class PendingCredentialSwitchService { 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[]) { @@ -355,10 +371,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,不支持 @@ -382,7 +398,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 } : {}), } : {}), @@ -400,7 +416,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 @@ -428,7 +444,12 @@ 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; @@ -546,9 +567,9 @@ export class PendingCredentialSwitchService { } } setSessionProvider(sessionId, resolved.providerId); - // 裁决改了落地路由(reroute / 丢弃停用显式来源 / 全停换模型或清空):回写 DB - // —— renderer 在 deferred 接受时已按请求值落盘 sessions 行,不纠正的话下一次 - // 懒 resume 按停用路由重建(resume 免裁决)。回写必须 **await 且先于**删登记 / + // 跨 family deferred 在登记时刻刻意让 DB 保持源 route;只有 relink 成功后才 + // 在这里提交目标 route。裁决改道也必须回写,否则下次懒 resume 会按旧值重建。 + // 回写必须 **await 且先于**删登记 / // 唤醒队列:排队消息在唤醒后立刻按 DB 懒 resume,fire-and-forget 会让它抢在 // 替换模型落库前用停用目标重建(PR #744 review 第十五轮);await 期间 pending // 仍在,coordinator 的 pending 门保持关闭。失败留痕后仍收口(队列不能永久 @@ -558,26 +579,28 @@ export class PendingCredentialSwitchService { const resolvedRoute: PendingCredentialSwitchPersistedRoute = { providerId: resolved.providerId, model: resolved.model ?? target.model, - ...(modelChanged - ? { - ...(resolved.effort ? { effort: resolved.effort } : {}), - ...(resolved.fastMode !== undefined ? { fastMode: resolved.fastMode } : {}), - } - : {}), + ...(resolved.effort !== undefined ? { effort: resolved.effort } : {}), + ...(resolved.fastMode !== undefined ? { fastMode: resolved.fastMode } : {}), }; // Only a successful await proves this exact fallback tuple reached SQLite. If the owner // expires in that window, stale cleanup must compare against it rather than the request. let persistedResolvedRoute: PendingCredentialSwitchPersistedRoute | undefined; // 恒写幂等回正(最后收口者赢,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 && + (routeChanged || + this.deps.resolveRoute || + shouldRelinkCodexThread || + target.codexThreadRelinkCommitted === true) + ) { try { await this.deps.persistRoute(sessionId, resolvedRoute); persistedResolvedRoute = resolvedRoute; diff --git a/apps/desktop/src/main/maker-ipc/register.ts b/apps/desktop/src/main/maker-ipc/register.ts index fb69552ddc..8dee521a83 100644 --- a/apps/desktop/src/main/maker-ipc/register.ts +++ b/apps/desktop/src/main/maker-ipc/register.ts @@ -3067,6 +3067,8 @@ export async function registerPendingCredentialSwitchForSession( target: { model: string; providerId: string | null; + effort?: string; + fastMode?: boolean; rebuildCodexThread?: boolean; codexThreadRelinkCommitted?: boolean; ownerScope?: { ownerScopeKey: string; runtimeOwnerEpoch: string }; @@ -3145,7 +3147,9 @@ export async function registerPendingCredentialSwitchForSession( } } const prevModel = sourcePersistedSession?.model ?? live?.model ?? prevRow?.model ?? null; - let restoreCapturedProfileRoute: (() => Promise) | undefined; + let restoreCapturedProfileRoute: + | ((persistedRoute?: PendingCredentialSwitchPersistedRoute) => Promise) + | undefined; if (dbSnapshot && prevRow?.model && prevRow.effort && prevRow.fastMode !== null) { const capturedPrevRow = { ...prevRow, @@ -3222,14 +3226,26 @@ export async function registerPendingCredentialSwitchForSession( ) { 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, - ...(target.restoreStaleOwnerRoute - ? { restoreStaleOwnerRoute: target.restoreStaleOwnerRoute } - : restoreCapturedProfileRoute - ? { restoreStaleOwnerRoute: restoreCapturedProfileRoute } - : {}), + ...(restoreStaleOwnerRoute ? { restoreStaleOwnerRoute } : {}), ...(live?.codexThreadModelProviderId !== undefined ? { sourceCodexThreadModelProviderId: live.codexThreadModelProviderId } : {}), @@ -3434,6 +3450,8 @@ export function getPendingCredentialSwitchTarget(sessionId: string): | { model: string; providerId: string | null; + effort?: string; + fastMode?: boolean; rebuildCodexThread?: boolean; codexThreadRelinkCommitted?: boolean; ownerScope?: { ownerScopeKey: string; runtimeOwnerEpoch: string }; @@ -3454,6 +3472,8 @@ export function getPendingCredentialSwitchTarget(sessionId: string): ? { 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 } @@ -15744,9 +15764,18 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) ...(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, @@ -15773,6 +15802,8 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) deferred: result.status === 'deferred', superseded: false, }; + const preservePersistedRoute = + result.status === 'deferred' && result.preservePersistedRoute === true; appliedRuntimeSelectionWasDeferred = response.deferred; const rollbackRuntimeSelectionForSupersededOwner = async (): Promise => { if (runtimeOwnerBoundaryCurrent()) return false; @@ -15836,16 +15867,18 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) patch.fastMode = atomicSelection.fastMode; } try { - await persistSessionFields(sessionId, patch); - appliedPersistedRuntimeRoute = { - model, - providerId: - effectiveProviderId === undefined - ? runtimeStatus.providerId - : (normalizeSessionProviderId(effectiveProviderId) ?? null), - effort: atomicSelection?.effort ?? runtimeStatus.effort, - fastMode: atomicSelection?.fastMode ?? runtimeStatus.fastMode, - }; + if (!preservePersistedRoute) { + 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 @@ -15893,7 +15926,8 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) throw persistenceError; } if (isDeviceLinkInvoke()) { - // dispatch 继续兼容最小/旧 handler 的锁外回流;标记本结果避免重复写。 + // dispatch 继续兼容最小/旧 handler 的锁外回流。deferred relink 即使刻意 + // 保留旧 route,也必须标记为 host 已处理,禁止兼容回流把目标 route 写回。 markRemoteSettingPersistedInsideHandler(response); } } diff --git a/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts b/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts index bdd0edd05d..46577c3bf4 100644 --- a/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts +++ b/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts @@ -64,7 +64,8 @@ export interface ApplyRuntimeSetModelChangeInput { sessionId: string; model: string; providerId?: string | null; - effort?: Effort; + effort?: Effort | null; + fastMode?: boolean; /** * 锁内读取的切换前持久化会话身份。live handle 不存在时,旧 sdkSessionId 仍会在 * 下一次 lazy-create 中作为 resumeSessionId;因此本地 Codex 的跨凭证家族切换 @@ -86,6 +87,8 @@ export interface ApplyRuntimeSetModelChangeInput { target: { model: string; providerId: string | null; + effort?: Effort; + fastMode?: boolean; rebuildCodexThread?: boolean; codexThreadRelinkCommitted?: boolean; ownerScope?: { ownerScopeKey: string; runtimeOwnerEpoch: string }; @@ -166,7 +169,7 @@ export type ApplyRuntimeSetModelChangeResult = /** 直接生效(热切 route / 或已关会话待下次发送重建)。 */ | { status: 'applied'; codexThreadRelink?: CodexProviderThreadRelinkReceipt } /** 凭证形态要换但会话自己在跑:已登记 pending,turn 结束后自动生效。 */ - | { status: 'deferred' }; + | { status: 'deferred'; preservePersistedRoute?: true }; export function isRemoteModelSwitchRouteChangeError(error: unknown): boolean { return ( @@ -194,7 +197,7 @@ 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 persistedCodexThread = @@ -270,6 +273,8 @@ 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 } @@ -282,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], @@ -295,6 +303,8 @@ 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 } @@ -308,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 并广播, @@ -355,9 +368,11 @@ export async function applyRuntimeSetModelChange( // 空闲判定与 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 } @@ -367,7 +382,10 @@ export async function applyRuntimeSetModelChange( sessionId, agentKind: sourceSession!.agentKind, }); - return { status: 'deferred' }; + return { + status: 'deferred', + ...(shouldRelinkCodexThread ? { preservePersistedRoute: true } : {}), + }; } // 非 busy 失败:恢复被清除的 pending,用户的待定来源选择不随异常丢失。 if (clearedPending && input.registerPendingCredentialSwitch) { @@ -420,7 +438,7 @@ export async function applyRuntimeSetModelChange( pendingTarget !== undefined ? { providerId: nextProviderId } : {}), - ...(effort !== undefined ? { effort } : {}), + ...(effort !== undefined && effort !== null ? { effort } : {}), }); } catch (err) { if (providerId !== undefined) { diff --git a/apps/desktop/src/renderer/__tests__/deviceLinkInteractionScenarios.test.ts b/apps/desktop/src/renderer/__tests__/deviceLinkInteractionScenarios.test.ts index 3910cf4d39..546573c677 100644 --- a/apps/desktop/src/renderer/__tests__/deviceLinkInteractionScenarios.test.ts +++ b/apps/desktop/src/renderer/__tests__/deviceLinkInteractionScenarios.test.ts @@ -962,7 +962,7 @@ describe('远程交互接线不变式', () => { expect(start).toBeGreaterThan(-1); const body = src.slice(start, start + 2800); const atomic = body.indexOf('? { effort: newEffort, fastMode: restoredFast }'); - const fallback = body.indexOf('if (!useAtomicSelection) {'); + const fallback = body.indexOf('if (!useAtomicSelection && !remoteDeferred) {'); const persist = body.indexOf('fastPersisted = await persistFastModeChange(restoredFast, {'); const sync = body.indexOf('syncSessionDraftModelPrefs('); expect(atomic).toBeGreaterThan(-1); @@ -986,7 +986,7 @@ describe('远程交互接线不变式', () => { expect(end).toBeGreaterThan(start); const body = src.slice(start, end); const atomic = body.indexOf('? { effort: targetEffort, fastMode: restoredFast }'); - const fallback = body.indexOf('if (!useAtomicSelection) {'); + const fallback = body.indexOf('if (!useAtomicSelection && !remoteDeferred) {'); const persist = body.indexOf('fastPersisted = await persistFastModeChange(restoredFast, {'); const sync = body.indexOf('syncSessionDraftModelPrefs('); const finalize = body.indexOf('onModelDidChange?.(targetModel);'); diff --git a/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx b/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx index a412291c89..b7827aed12 100644 --- a/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx +++ b/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx @@ -6747,7 +6747,7 @@ export function ChatInput({ return false; } remoteDeferred = remoteSetModelResult?.deferred === true; - if (!useAtomicSelection) { + if (!useAtomicSelection && !remoteDeferred) { await remoteMaker.setEffort(sessionId, newEffort); fastPersisted = await persistFastModeChange(restoredFast, { silent: true, @@ -7267,7 +7267,7 @@ export function ChatInput({ return false; } remoteDeferred = remoteSetModelResult?.deferred === true; - if (!useAtomicSelection) { + if (!useAtomicSelection && !remoteDeferred) { await remoteMaker.setEffort(sessionId, targetEffort); fastPersisted = await persistFastModeChange(restoredFast, { silent: true, From ee449071f537854d19f6242b5f891767476d348c Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:51:40 +0800 Subject: [PATCH 19/36] fix(desktop): compensate unpersisted relink failures Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- .../makerSendToSessionOrdering.test.ts | 2 + .../sessionRuntimeControlWiring.test.ts | 68 ++++++-- apps/desktop/src/main/maker-ipc/register.ts | 148 ++++++++++-------- 3 files changed, 144 insertions(+), 74 deletions(-) diff --git a/apps/desktop/src/main/__tests__/makerSendToSessionOrdering.test.ts b/apps/desktop/src/main/__tests__/makerSendToSessionOrdering.test.ts index 54005a7c60..86683012f6 100644 --- a/apps/desktop/src/main/__tests__/makerSendToSessionOrdering.test.ts +++ b/apps/desktop/src/main/__tests__/makerSendToSessionOrdering.test.ts @@ -552,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 98eea9a8e7..6528b987e2 100644 --- a/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts +++ b/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts @@ -245,24 +245,23 @@ describe('session runtime control wiring', () => { ); }); - it('restores host stores and retires the live session after a post-persistence failure', () => { + it('restores host stores and retires the live session after a post-selection failure', () => { const setModel = handlerBody( registerSource, 'const handleSetModel = async (', 'const recoverRemoteRuntimeAxisPersistence', ); - const rollback = setModel.indexOf( - 'const rollbackAppliedRuntimeSelection = async (): Promise => {', + const runtimeRecovery = setModel.indexOf( + 'const recoverRuntimeAfterSelectionRollback = async (', ); - const sqliteRestore = setModel.indexOf('rollbackPersistedCodexRuntimeSelection({', rollback); - const ownerGuard = setModel.indexOf('const runtimeRecoveryCurrent =', sqliteRestore); + 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,', + 'broadcastSessionPatched(\n sessionId,\n {\n ...restoredState,', retireLiveSession, ); const reconcileRetainedLive = setModel.indexOf( @@ -270,19 +269,25 @@ describe('session runtime control wiring', () => { restoredRouteBroadcast, ); const rollbackFailure = setModel.indexOf( - "'persisted runtime selection rollback could not retire the live session'", + "'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(sqliteRestore).toBeGreaterThan(rollback); - expect(ownerGuard).toBeGreaterThan(sqliteRestore); + 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)', ); @@ -297,6 +302,51 @@ describe('session runtime control wiring', () => { ); }); + it('compensates ordinary renderer runtime state when a post-relink 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 runtimeRecovery = setModel.indexOf( + 'await recoverRuntimeAfterSelectionRollback({', + rollbackRelink, + ); + 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(runtimeRecovery).toBeGreaterThan(rollbackRelink); + 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, diff --git a/apps/desktop/src/main/maker-ipc/register.ts b/apps/desktop/src/main/maker-ipc/register.ts index 8dee521a83..7008e22c7b 100644 --- a/apps/desktop/src/main/maker-ipc/register.ts +++ b/apps/desktop/src/main/maker-ipc/register.ts @@ -15627,6 +15627,13 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) } | 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; @@ -15634,22 +15641,87 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) 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) return rollbackAppliedCodexThreadRelink(); + if (!persistedRoute) { + const hadRelink = appliedCodexThreadRelink !== undefined; + const restored = await rollbackAppliedCodexThreadRelink(); + if (!restored) return false; + if (hadRelink) { + 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: - | { - sdkSessionId: string | null; - model: string; - providerId: string | null; - effort: typeof runtimeStatus.effort; - fastMode: boolean; - } - | undefined; + let restoredState: RestoredRuntimeSelectionState | undefined; const restored = await rollbackPersistedCodexRuntimeSelection({ previous: { sdkSessionId: runtimeStatus.sdkSessionId, @@ -15688,61 +15760,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) appliedPersistedRuntimeRoute = undefined; appliedCodexThreadRelink = undefined; if (!restored || !restoredState) return false; - - const runtimeRecoveryCurrent = - runtimeOwnerBoundaryCurrent() && - isDataOwnerBroadcastScopeCurrent(runtimeOwnerScope) && - getCurrentDbClientSnapshot()?.clientEpoch === runtimeDbSnapshot.clientEpoch; - if (runtimeRecoveryCurrent) { - // Device Link receives this handler's failure as the complete SET_MODEL result and - // does not issue a renderer-side rollback. Restore the host stores and retire any - // already-mutated live Session in the same compensation unit as the SQLite route. - 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 { - // The SQLite/thread route is already restored, but a close failure leaves the - // target live Session in memory. Re-adopt its actual profile into host stores and - // the effective runtime projection so later input never runs with mixed axes. - await reconcileRetainedLiveProfile(); - } catch (error) { - recoveryErrors.push(error); - } - } - if (recoveryErrors.length > 0) { - throw new AggregateError( - recoveryErrors, - 'persisted runtime selection rollback could not retire the live session', - ); - } - } + await recoverRuntimeAfterSelectionRollback(restoredState); return true; }; try { From 7bb48a6fe858f5b15b17107f1a837ed26ba614d8 Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:55:04 +0800 Subject: [PATCH 20/36] test(desktop): guard deferred switch registration Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- .../__tests__/runtimeSetModel.test.ts | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) 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 ac66607073..85a014df8f 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/runtimeSetModel.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/runtimeSetModel.test.ts @@ -761,6 +761,98 @@ describe('applyRuntimeSetModelChange', () => { 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'); From 8f4d246e28d237866d72dbe55093bc42883c9d0d Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:59:02 +0800 Subject: [PATCH 21/36] test(desktop): preload Codex proxy host suite Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- .../__tests__/codexProxyHost.test.ts | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) 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'); } From 821566a95e1ca9aaedea049461b6aabd9b575211 Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Fri, 28 Aug 2026 20:02:13 +0800 Subject: [PATCH 22/36] fix(desktop): complete deferred remote selections Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- .../__tests__/deviceLinkInteractionScenarios.test.ts | 10 ++++++---- .../src/renderer/components/new-chat/ChatInput.tsx | 7 +++++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/renderer/__tests__/deviceLinkInteractionScenarios.test.ts b/apps/desktop/src/renderer/__tests__/deviceLinkInteractionScenarios.test.ts index 546573c677..e481a43f64 100644 --- a/apps/desktop/src/renderer/__tests__/deviceLinkInteractionScenarios.test.ts +++ b/apps/desktop/src/renderer/__tests__/deviceLinkInteractionScenarios.test.ts @@ -956,17 +956,18 @@ 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); const body = src.slice(start, start + 2800); const atomic = body.indexOf('? { effort: newEffort, fastMode: restoredFast }'); - const fallback = body.indexOf('if (!useAtomicSelection && !remoteDeferred) {'); + const fallback = body.indexOf('if (!useAtomicSelection) {'); const persist = body.indexOf('fastPersisted = await persistFastModeChange(restoredFast, {'); 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); @@ -986,12 +987,13 @@ describe('远程交互接线不变式', () => { expect(end).toBeGreaterThan(start); const body = src.slice(start, end); const atomic = body.indexOf('? { effort: targetEffort, fastMode: restoredFast }'); - const fallback = body.indexOf('if (!useAtomicSelection && !remoteDeferred) {'); + const fallback = body.indexOf('if (!useAtomicSelection) {'); const persist = body.indexOf('fastPersisted = await persistFastModeChange(restoredFast, {'); const sync = body.indexOf('syncSessionDraftModelPrefs('); 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 b7827aed12..8c2b86c260 100644 --- a/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx +++ b/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx @@ -6747,7 +6747,9 @@ export function ChatInput({ return false; } remoteDeferred = remoteSetModelResult?.deferred === true; - if (!useAtomicSelection && !remoteDeferred) { + // 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, { silent: true, @@ -7267,7 +7269,8 @@ export function ChatInput({ return false; } remoteDeferred = remoteSetModelResult?.deferred === true; - if (!useAtomicSelection && !remoteDeferred) { + // Keep the old-host selection tuple complete even when SET_MODEL was deferred. + if (!useAtomicSelection) { await remoteMaker.setEffort(sessionId, targetEffort); fastPersisted = await persistFastModeChange(restoredFast, { silent: true, From 6f3ef30edc3c8a2e16cb24e34ea4ef479fd23cfe Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Fri, 28 Aug 2026 20:20:04 +0800 Subject: [PATCH 23/36] fix(desktop): compensate superseded deferred routes Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- .../__tests__/pendingCredentialSwitch.test.ts | 253 +++++++++++++++++- .../main/maker-ipc/pendingCredentialSwitch.ts | 180 ++++++++++++- apps/desktop/src/main/maker-ipc/register.ts | 29 +- 3 files changed, 433 insertions(+), 29 deletions(-) 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 bf54df1fc6..923b81cc71 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/pendingCredentialSwitch.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/pendingCredentialSwitch.test.ts @@ -5,6 +5,12 @@ 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, @@ -15,6 +21,8 @@ const touchedSessions = new Set(); afterEach(() => { for (const sessionId of touchedSessions) { clearSessionProvider(sessionId); + setSessionEffort(sessionId, null); + setSessionFastMode(sessionId, false); } touchedSessions.clear(); }); @@ -42,6 +50,8 @@ function createHarness( const closeSession = vi.fn(async (_sessionId: string) => {}); const broadcastApplied = vi.fn>(); const onApplied = vi.fn>(); + const onCancellationCompensated = + vi.fn>(); const persistRoute = vi.fn>( async () => {}, ); @@ -55,6 +65,7 @@ function createHarness( }, broadcastApplied, onApplied, + onCancellationCompensated, persistRoute, relinkCodexThreadForProviderSwitch, ...(opts?.isOwnerScopeCurrent @@ -68,6 +79,7 @@ function createHarness( closeSession, broadcastApplied, onApplied, + onCancellationCompensated, persistRoute, relinkCodexThreadForProviderSwitch, sessions, @@ -522,17 +534,48 @@ describe('PendingCredentialSwitchService', () => { expect(h.persistRoute).toHaveBeenCalledTimes(2); expect(h.service.has(sessionId)).toBe(false); expect(h.onApplied).toHaveBeenCalledOnce(); + expect(h.onCancellationCompensated).not.toHaveBeenCalled(); }); it('rolls back an abandoned relink when route persistence is superseded', async () => { const sessionId = rememberSession('pending-switch-superseded-after-persist'); setSessionProvider(sessionId, 'xd'); - let sdkSessionId = 'thread-openai'; + 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 (sdkSessionId !== 'thread-openai') return false; - sdkSessionId = 'thread-xd'; + if (persistedProfile.sdkSessionId !== 'thread-openai') return false; + persistedProfile.sdkSessionId = 'thread-xd'; 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 }], { @@ -548,32 +591,224 @@ describe('PendingCredentialSwitchService', () => { newSdkSessionId: 'thread-openai', rollback, }); - h.persistRoute.mockImplementationOnce(async () => { - h.service.register(sessionId, { + h.persistRoute.mockImplementationOnce(async (_persistedSessionId, route) => { + persistedProfile = { + ...persistedProfile, + model: route.model ?? persistedProfile.model, + providerId: route.providerId, + effort: route.effort ?? persistedProfile.effort, + fastMode: route.fastMode ?? persistedProfile.fastMode, + }; + h.service.clear(sessionId, { wake: false }); + await h.service.register(sessionId, { model: 'gpt-5.6-sol', providerId: 'xd', }); }); - h.service.register(sessionId, { + 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' }, + previousRoute: { + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + effort: 'high', + fastMode: false, + }, + restoreStaleOwnerRoute, }); await h.service.onTurnSettled(sessionId); - expect(rollback).toHaveBeenCalledOnce(); - expect(sdkSessionId).toBe('thread-xd'); + const abandonedRoute = { + model: 'gpt-5.6-sol', + providerId: 'openai', + effort: 'xhigh', + fastMode: true, + }; + expect(rollback).not.toHaveBeenCalled(); + expect(restoreStaleOwnerRoute).toHaveBeenCalledWith(abandonedRoute, 'thread-openai'); + expect(persistedProfile).toEqual({ + sdkSessionId: 'thread-xd', + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + effort: 'high', + fastMode: false, + }); + expect(h.relinkCodexThreadForProviderSwitch).toHaveBeenCalledOnce(); 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 persisted route is atomically 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 markPersistStarted!: () => void; + const persistStarted = new Promise((resolve) => { + markPersistStarted = resolve; + }); + let releasePersist!: () => void; + const persistGate = new Promise((resolve) => { + releasePersist = 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 () => { + persistedProfile.sdkSessionId = 'thread-openai'; + return { + previousSdkSessionId: 'thread-xd', + newSdkSessionId: 'thread-openai', + rollback: vi.fn(async () => false), + }; + }); + h.persistRoute.mockImplementationOnce(async (_persistedSessionId, route) => { + markPersistStarted(); + await persistGate; + persistedProfile = { + ...persistedProfile, + model: route.model ?? persistedProfile.model, + providerId: route.providerId, + effort: route.effort ?? persistedProfile.effort, + fastMode: route.fastMode ?? persistedProfile.fastMode, + }; + }); + + 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 persistStarted; + + h.service.clear(sessionId); + expect(h.service.has(sessionId)).toBe(true); + expect(h.onApplied).not.toHaveBeenCalled(); + releasePersist(); + 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.broadcastApplied).not.toHaveBeenCalled(); + }); + + it('keeps cancellation gated and retries when persisted-route 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.mockResolvedValueOnce({ + previousSdkSessionId: 'thread-xd', + newSdkSessionId: 'thread-openai', + rollback: vi.fn(async () => false), + }); + h.persistRoute.mockImplementationOnce(async () => { + h.service.clear(sessionId); + }); + + 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'); + }); + it('compensates the actual fallback route when the owner changes during persistence', async () => { const sessionId = rememberSession('pending-switch-owner-stale-during-fallback-persist'); setSessionProvider(sessionId, 'xd'); diff --git a/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts b/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts index 0514b3c061..30f5074e71 100644 --- a/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts +++ b/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts @@ -10,6 +10,10 @@ 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 type { CodexProviderThreadRelinkReceipt } from './codexProviderThreadRelink.js'; @@ -82,9 +86,10 @@ export interface PendingCredentialSwitch { }; /** 登记时的 data owner + runtime epoch;跨账号/teardown 后旧 target 必须终态丢弃。 */ ownerScope?: PendingCredentialSwitchOwnerScope; - /** owner 失效时,针对登记时捕获的旧 Profile 成套恢复 thread + route。 */ + /** 针对登记时捕获的旧 Profile 成套恢复 thread + route;第二参数用于迟到提交 CAS。 */ restoreStaleOwnerRoute?: ( persistedRoute?: PendingCredentialSwitchPersistedRoute, + expectedSdkSessionId?: string, ) => Promise; requestedAt: number; } @@ -112,6 +117,8 @@ export interface PendingCredentialSwitchDeps { }) => void; /** 生效后唤醒该会话的输入队列(排队消息此前被 pending 门挡住)。 */ onApplied?: (sessionId: string) => void; + /** clear 撞上 finalizer 时,待补偿/关闭完成后再次唤醒此前被临时门挡住的队列。 */ + onCancellationCompensated?: (sessionId: string) => void; /** * 停用轴裁决(生产 = model-route-guard-live 的 resolveLenientSessionRoute)。 * SET_MODEL 请求时刻已裁决过,但 deferred 切换的**生效**可能在数分钟后 —— 期间 @@ -177,6 +184,18 @@ export class PendingCredentialSwitchService { private readonly retryTimers = new Map>(); /** owner-stale 补偿中的 target;同步 has/get 重入不得重复写旧 Profile。 */ private readonly staleDiscards = new Set(); + /** clear() 不能在迟到 finalizer 完成原子补偿前解除输入队列门。 */ + 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 Set(); + private readonly compensationRetryTimers = new Map< + string, + ReturnType + >(); constructor(private readonly deps: PendingCredentialSwitchDeps) {} @@ -200,6 +219,7 @@ export class PendingCredentialSwitchService { ownerScope?: PendingCredentialSwitchOwnerScope; restoreStaleOwnerRoute?: ( persistedRoute?: PendingCredentialSwitchPersistedRoute, + expectedSdkSessionId?: string, ) => Promise; }, ): Promise { @@ -245,6 +265,7 @@ export class PendingCredentialSwitchService { } has(sessionId: string): boolean { + if (this.clearedDuringApply.has(sessionId)) return true; const target = this.pending.get(sessionId); if (!target) return false; return !this.discardIfOwnerStale(sessionId, target); @@ -256,9 +277,19 @@ export class PendingCredentialSwitchService { 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; } /** @@ -314,7 +345,7 @@ export class PendingCredentialSwitchService { if (this.discardIfOwnerStale(sessionId, latest)) return; await this.finalizeApplyChecked(sessionId, latest, 'turn end'); } finally { - this.applying.delete(sessionId); + this.finishApplying(sessionId); } } @@ -332,7 +363,7 @@ export class PendingCredentialSwitchService { 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); }); } @@ -646,17 +677,19 @@ export class PendingCredentialSwitchService { return; } // await 期间用户可能改选 / 取消:本次让位,新登记有自己的收口路径。 - // relink 已在 persistRoute 之前提交;若这一代此刻已过时,必须先把它回滚, - // 否则下一次无 live session 的选择会沿用被放弃来源的 sdk_session_id。 + // 同 owner 的迟到写必须把实际 route + replacement thread 作为一个 CAS + // 成套补偿;先单独回滚 thread 会留下可被唤醒队列/重启观察到的裂分状态。 if (!this.targetCurrent(sessionId, target)) { - if (relinkReceipt) { + if (!this.ownerScopeCurrent(target)) { try { - const restored = await relinkReceipt.rollback(); - if (!restored) { - this.deps.logger?.warn( - 'pending credential switch: stale Codex thread relink rollback was superseded', - { sessionId }, - ); + if (relinkReceipt) { + const restored = await relinkReceipt.rollback(); + if (!restored) { + this.deps.logger?.warn( + 'pending credential switch: stale Codex thread relink rollback was superseded', + { sessionId }, + ); + } } } catch (rollbackError) { this.deps.logger?.error?.( @@ -668,8 +701,15 @@ export class PendingCredentialSwitchService { }, ); } + this.discardIfOwnerStale(sessionId, target, persistedResolvedRoute); + } else { + await this.compensateSupersededPersistedRoute( + sessionId, + target, + persistedResolvedRoute, + relinkReceipt, + ); } - this.discardIfOwnerStale(sessionId, target, persistedResolvedRoute); return; } } @@ -699,6 +739,7 @@ export class PendingCredentialSwitchService { // 删登记(解除 coordinator 的 pending 门)必须在 route 写入 + DB 回写之后。 this.pending.delete(sessionId); this.clearRetry(sessionId); + this.requestWakeAfterCancellationBarrier(sessionId); try { this.deps.onApplied?.(sessionId); } catch (err) { @@ -745,6 +786,30 @@ export class PendingCredentialSwitchService { this.retryTimers.delete(sessionId); } + private finishApplying(sessionId: string): void { + this.applying.delete(sessionId); + if (this.compensationFailures.has(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 @@ -778,6 +843,93 @@ export class PendingCredentialSwitchService { } } + /** + * 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, + relinkReceipt: CodexProviderThreadRelinkReceipt | null, + ): Promise { + const safe = await this.tryCompensateSupersededPersistedRoute( + sessionId, + target, + persistedRoute, + relinkReceipt, + ); + if (safe) return; + this.compensationFailures.add(sessionId); + this.scheduleCompensationRetry(sessionId, target, persistedRoute, relinkReceipt); + } + + private async tryCompensateSupersededPersistedRoute( + sessionId: string, + target: PendingCredentialSwitch, + persistedRoute: PendingCredentialSwitchPersistedRoute, + 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. + if (!this.pending.has(sessionId) && target.previousRoute) { + setSessionProvider(sessionId, target.previousRoute.providerId); + setSessionEffort(sessionId, target.previousRoute.effort); + if (target.previousRoute.fastMode !== undefined) { + setSessionFastMode(sessionId, target.previousRoute.fastMode); + } + } + 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 scheduleCompensationRetry( + sessionId: string, + target: PendingCredentialSwitch, + persistedRoute: PendingCredentialSwitchPersistedRoute, + relinkReceipt: CodexProviderThreadRelinkReceipt | null, + ): void { + if (this.compensationRetryTimers.has(sessionId)) return; + const timer = setTimeout(() => { + this.compensationRetryTimers.delete(sessionId); + void this.tryCompensateSupersededPersistedRoute( + sessionId, + target, + persistedRoute, + relinkReceipt, + ).then((safe) => { + if (!safe) { + this.scheduleCompensationRetry(sessionId, target, persistedRoute, relinkReceipt); + return; + } + this.compensationFailures.delete(sessionId); + this.releaseClearedDuringApply(sessionId); + }); + }, this.deps.retryDelayMs ?? PENDING_APPLY_RETRY_DELAY_MS); + this.compensationRetryTimers.set(sessionId, timer); + } + private discardIfOwnerStale( sessionId: string, target: PendingCredentialSwitch, diff --git a/apps/desktop/src/main/maker-ipc/register.ts b/apps/desktop/src/main/maker-ipc/register.ts index 7008e22c7b..16fabc1588 100644 --- a/apps/desktop/src/main/maker-ipc/register.ts +++ b/apps/desktop/src/main/maker-ipc/register.ts @@ -3082,6 +3082,7 @@ export async function registerPendingCredentialSwitchForSession( sourcePersistedSession?: RuntimeSetModelPersistedSession; restoreStaleOwnerRoute?: ( persistedRoute?: PendingCredentialSwitchPersistedRoute, + expectedSdkSessionId?: string, ) => Promise; }, ): Promise { @@ -3148,7 +3149,10 @@ export async function registerPendingCredentialSwitchForSession( } const prevModel = sourcePersistedSession?.model ?? live?.model ?? prevRow?.model ?? null; let restoreCapturedProfileRoute: - | ((persistedRoute?: PendingCredentialSwitchPersistedRoute) => Promise) + | (( + persistedRoute?: PendingCredentialSwitchPersistedRoute, + expectedSdkSessionId?: string, + ) => Promise) | undefined; if (dbSnapshot && prevRow?.model && prevRow.effort && prevRow.fastMode !== null) { const capturedPrevRow = { @@ -3159,15 +3163,17 @@ export async function registerPendingCredentialSwitchForSession( }; restoreCapturedProfileRoute = async ( persistedRoute?: PendingCredentialSwitchPersistedRoute, + expectedSdkSessionId?: string, ): Promise => { const appliedRoute = persistedRoute ?? { model: target.model, providerId: target.providerId, }; + const expectedSdk = expectedSdkSessionId ?? capturedPrevRow.sdkSessionId; const sdkMatches = - capturedPrevRow.sdkSessionId === null + expectedSdk === null ? isNull(sessions.sdkSessionId) - : eq(sessions.sdkSessionId, capturedPrevRow.sdkSessionId); + : eq(sessions.sdkSessionId, expectedSdk); const appliedProviderMatches = appliedRoute.providerId === null ? isNull(sessions.providerId) @@ -3182,8 +3188,12 @@ export async function registerPendingCredentialSwitchForSession( sdkSessionId: capturedPrevRow.sdkSessionId, model: capturedPrevRow.model, providerId: capturedPrevRow.providerId, - effort: capturedPrevRow.effort, - fastMode: capturedPrevRow.fastMode, + ...(appliedRoute.effort !== undefined + ? { effort: capturedPrevRow.effort } + : {}), + ...(appliedRoute.fastMode !== undefined + ? { fastMode: capturedPrevRow.fastMode } + : {}), updatedAt: Date.now(), }) .where( @@ -3434,7 +3444,7 @@ 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) { @@ -3443,6 +3453,9 @@ export function clearPendingCredentialSwitchForSession( } export function wakeSessionInputAfterCredentialSwitch(sessionId: string): void { + if (pendingCredentialSwitchHolder?.requestWakeAfterCancellationBarrier(sessionId) === false) { + return; + } agentInputCoordinatorHolder?.wakeSession(sessionId, 'credential-switch-applied-inline'); } @@ -3464,6 +3477,7 @@ export function getPendingCredentialSwitchTarget(sessionId: string): }; restoreStaleOwnerRoute?: ( persistedRoute?: PendingCredentialSwitchPersistedRoute, + expectedSdkSessionId?: string, ) => Promise; } | undefined { @@ -13794,6 +13808,9 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) 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 第七轮,第十四轮换宽松降级形态 —— From 53f512531af55c77ecef322b2c1bd8008ff56e60 Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Fri, 28 Aug 2026 20:35:02 +0800 Subject: [PATCH 24/36] fix(desktop): restore cancelled deferred runtime route Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- .../__tests__/pendingCredentialSwitch.test.ts | 52 +++++++++++++++++++ .../main/maker-ipc/pendingCredentialSwitch.ts | 50 ++++++++++++++---- 2 files changed, 93 insertions(+), 9 deletions(-) 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 923b81cc71..54dbb3c929 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/pendingCredentialSwitch.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/pendingCredentialSwitch.test.ts @@ -488,6 +488,58 @@ describe('PendingCredentialSwitchService', () => { h.service.clear(sessionId); }); + it('restores runtime axes before waking a cancellation whose route persistence failed', 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.mockResolvedValueOnce({ + previousSdkSessionId: 'thread-xd', + newSdkSessionId: 'thread-openai', + rollback, + }); + h.persistRoute.mockImplementationOnce(async () => { + h.service.clear(sessionId); + throw new Error('sqlite failed'); + }); + + 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(); + }); + it('restores a committed relink marker after cleanup so retry does not fork again', async () => { const sessionId = rememberSession('pending-switch-restore-committed-relink'); setSessionProvider(sessionId, 'xd'); diff --git a/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts b/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts index 30f5074e71..65c58f78c5 100644 --- a/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts +++ b/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts @@ -637,12 +637,14 @@ export class PendingCredentialSwitchService { 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 @@ -672,8 +674,29 @@ export class PendingCredentialSwitchService { : {}), }, ); - if (this.targetCurrent(sessionId, target)) this.scheduleRetry(sessionId); - else this.discardIfOwnerStale(sessionId, target); + 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 期间用户可能改选 / 取消:本次让位,新登记有自己的收口路径。 @@ -887,13 +910,7 @@ export class PendingCredentialSwitchService { } // 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. - if (!this.pending.has(sessionId) && target.previousRoute) { - setSessionProvider(sessionId, target.previousRoute.providerId); - setSessionEffort(sessionId, target.previousRoute.effort); - if (target.previousRoute.fastMode !== undefined) { - setSessionFastMode(sessionId, target.previousRoute.fastMode); - } - } + this.restoreCancelledRuntimeRoute(sessionId, target); return true; } catch (error) { this.deps.logger?.error?.('superseded pending credential switch route compensation failed', { @@ -904,6 +921,21 @@ export class PendingCredentialSwitchService { } } + 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, From 78876ec35e44c78c8de5bc93f332c78f0d404048 Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Fri, 28 Aug 2026 20:39:59 +0800 Subject: [PATCH 25/36] fix(desktop): await deferred switch restoration Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- .../__tests__/runtimeSetModel.test.ts | 79 +++++++++++++++++-- .../src/main/maker-ipc/runtimeSetModel.ts | 5 +- 2 files changed, 78 insertions(+), 6 deletions(-) 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 85a014df8f..6da442b771 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/runtimeSetModel.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/runtimeSetModel.test.ts @@ -637,7 +637,15 @@ describe('applyRuntimeSetModelChange', () => { 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'); - const registerPendingCredentialSwitch = vi.fn(); + 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', @@ -656,8 +664,8 @@ describe('applyRuntimeSetModelChange', () => { closeSession: vi.fn(async () => {}), }; - await expect( - applyRuntimeSetModelChange({ + let settled = false; + const result = applyRuntimeSetModelChange({ maker, sessionId, model: 'gpt-5.6-sol', @@ -675,8 +683,18 @@ describe('applyRuntimeSetModelChange', () => { relinkCodexThreadForProviderSwitch: vi.fn(async () => { throw new Error('fork failed'); }), - }), - ).rejects.toThrow('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', @@ -688,6 +706,57 @@ describe('applyRuntimeSetModelChange', () => { }); }); + 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'); diff --git a/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts b/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts index 46577c3bf4..4444de14dc 100644 --- a/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts +++ b/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts @@ -389,7 +389,10 @@ export async function applyRuntimeSetModelChange( } // 非 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; } From de0148978c00f4c5a65ca23c602899b961160733 Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Fri, 28 Aug 2026 20:46:52 +0800 Subject: [PATCH 26/36] fix(desktop): retry stale owner route compensation Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- .../__tests__/pendingCredentialSwitch.test.ts | 115 +++++++++++++++++- .../main/maker-ipc/pendingCredentialSwitch.ts | 115 ++++++++++++++---- 2 files changed, 204 insertions(+), 26 deletions(-) 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 54dbb3c929..e593498307 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/pendingCredentialSwitch.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/pendingCredentialSwitch.test.ts @@ -356,9 +356,10 @@ describe('PendingCredentialSwitchService', () => { }); currentOwner = { ownerScopeKey: 'owner-boundary', runtimeOwnerEpoch: '7' }; - expect(h.service.has(sessionId)).toBe(false); + 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)); @@ -919,6 +920,118 @@ describe('PendingCredentialSwitchService', () => { expect(h.onApplied).not.toHaveBeenCalled(); }); + it('keeps an owner-stale persisted route 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 () => { + if (persistedProfile.sdkSessionId !== 'thread-openai') return false; + persistedProfile.sdkSessionId = 'thread-xd'; + return true; + }); + const restoreStaleOwnerRoute = vi + .fn() + .mockRejectedValueOnce(new Error('sqlite temporarily unavailable')) + .mockImplementationOnce(async (route) => { + if ( + persistedProfile.sdkSessionId !== 'thread-xd' || + 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: 5, + isOwnerScopeCurrent: () => ownerCurrent, + }, + ); + h.relinkCodexThreadForProviderSwitch.mockImplementationOnce(async () => { + persistedProfile.sdkSessionId = 'thread-openai'; + return { + previousSdkSessionId: 'thread-xd', + newSdkSessionId: 'thread-openai', + rollback, + }; + }); + h.persistRoute.mockImplementationOnce(async (_persistedSessionId, route) => { + persistedProfile = { + ...persistedProfile, + model: route.model ?? persistedProfile.model, + providerId: route.providerId, + effort: route.effort ?? persistedProfile.effort, + fastMode: route.fastMode ?? persistedProfile.fastMode, + }; + ownerCurrent = false; + }); + + 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).toHaveBeenCalledOnce(); + expect(persistedProfile).toEqual({ + sdkSessionId: 'thread-xd', + 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(); + + await vi.waitFor(() => expect(restoreStaleOwnerRoute).toHaveBeenCalledTimes(2)); + 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).toHaveBeenCalledOnce(); + expect(h.persistRoute).toHaveBeenCalledOnce(); + 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'); diff --git a/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts b/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts index 65c58f78c5..0a2073cfd5 100644 --- a/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts +++ b/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts @@ -184,7 +184,14 @@ export class PendingCredentialSwitchService { private readonly retryTimers = new Map>(); /** owner-stale 补偿中的 target;同步 has/get 重入不得重复写旧 Profile。 */ private readonly staleDiscards = new Set(); - /** clear() 不能在迟到 finalizer 完成原子补偿前解除输入队列门。 */ + /** 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 + >(); + /** clear() / owner-stale 不能在迟到 finalizer 完成 Profile 补偿前解除输入队列门。 */ private readonly clearedDuringApply = new Set(); /** wake:false 的显式替换由新选择收尾,旧 finalizer 不得代为唤醒。 */ private readonly suppressedCancellationWakes = new Set(); @@ -248,7 +255,10 @@ export class PendingCredentialSwitchService { } // This target never enters the map, so discardIfOwnerStale cannot compensate it later. // Finish its captured old-Profile restore before the caller receives registration failure. - await this.compensateStaleOwnerRoute(sessionId, pending); + const compensated = await this.compensateStaleOwnerRoute(sessionId, pending); + if (!compensated) { + throw new Error('Stale pending credential switch route compensation failed'); + } this.deps.logger?.info('stale pending credential switch discarded at registration', { sessionId, }); @@ -265,10 +275,13 @@ export class PendingCredentialSwitchService { } has(sessionId: string): boolean { - if (this.clearedDuringApply.has(sessionId)) return true; + if (this.clearedDuringApply.has(sessionId) || this.staleCompensations.has(sessionId)) { + return true; + } const target = this.pending.get(sessionId); if (!target) return false; - return !this.discardIfOwnerStale(sessionId, target); + if (!this.discardIfOwnerStale(sessionId, target)) return true; + return this.clearedDuringApply.has(sessionId) || this.staleCompensations.has(sessionId); } get(sessionId: string): PendingCredentialSwitch | undefined { @@ -298,6 +311,7 @@ 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; @@ -357,6 +371,7 @@ 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; @@ -811,7 +826,7 @@ export class PendingCredentialSwitchService { private finishApplying(sessionId: string): void { this.applying.delete(sessionId); - if (this.compensationFailures.has(sessionId)) return; + if (this.compensationFailures.has(sessionId) || this.staleCompensations.has(sessionId)) return; this.releaseClearedDuringApply(sessionId); } @@ -847,7 +862,7 @@ export class PendingCredentialSwitchService { sessionId: string, target: PendingCredentialSwitch, persistedRoute?: PendingCredentialSwitchPersistedRoute, - ): Promise { + ): Promise { try { if (target.restoreStaleOwnerRoute) { const restored = await target.restoreStaleOwnerRoute(persistedRoute); @@ -858,11 +873,68 @@ export class PendingCredentialSwitchService { ); } } + return true; } catch (error) { this.deps.logger?.error?.('stale pending credential switch route compensation failed', { sessionId, error: error instanceof Error ? error.message : String(error), }); + return false; + } + } + + private async retryStaleOwnerCompensation( + sessionId: string, + target: PendingCredentialSwitch, + persistedRoute?: PendingCredentialSwitchPersistedRoute, + ): Promise { + const compensated = await this.compensateStaleOwnerRoute( + sessionId, + target, + persistedRoute, + ); + if (!compensated) { + this.scheduleStaleOwnerCompensationRetry(sessionId, target, persistedRoute); + return; + } + this.completeStaleOwnerCompensation(sessionId, target); + } + + private scheduleStaleOwnerCompensationRetry( + sessionId: string, + target: PendingCredentialSwitch, + persistedRoute?: PendingCredentialSwitchPersistedRoute, + ): void { + if (this.staleCompensationRetryTimers.has(target)) return; + const timer = setTimeout(() => { + this.staleCompensationRetryTimers.delete(target); + void this.retryStaleOwnerCompensation(sessionId, target, persistedRoute); + }, this.deps.retryDelayMs ?? PENDING_APPLY_RETRY_DELAY_MS); + this.staleCompensationRetryTimers.set(target, timer); + } + + private completeStaleOwnerCompensation( + sessionId: string, + target: PendingCredentialSwitch, + ): void { + const timer = this.staleCompensationRetryTimers.get(target); + if (timer) clearTimeout(timer); + this.staleCompensationRetryTimers.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, + }); + if ( + !this.staleCompensations.has(sessionId) && + !this.compensationFailures.has(sessionId) + ) { + this.releaseClearedDuringApply(sessionId); } } @@ -968,26 +1040,19 @@ export class PendingCredentialSwitchService { persistedRoute?: PendingCredentialSwitchPersistedRoute, ): boolean { if (this.ownerScopeCurrent(target)) return false; - if (this.pending.get(sessionId) === target) { - // Stop retries immediately, but keep the exact target identity until its captured old - // Profile has been compensated. A new owner may register the same session id meanwhile; - // the identity check in finally prevents the old cleanup from deleting that new target. - this.clearRetry(sessionId); - if (this.staleDiscards.has(target)) return true; + 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); - void (async () => { - try { - await this.compensateStaleOwnerRoute(sessionId, target, persistedRoute); - } finally { - this.staleDiscards.delete(target); - if (this.pending.get(sessionId) === target) { - this.pending.delete(sessionId); - } - this.deps.logger?.info('stale pending credential switch discarded after owner boundary', { - sessionId, - }); - } - })(); + 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); + void this.retryStaleOwnerCompensation(sessionId, target, persistedRoute); } return true; } From 12b3750783e557b06273f18221846353e5d373cd Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Fri, 28 Aug 2026 20:49:14 +0800 Subject: [PATCH 27/36] fix(desktop): await hot switch pending recovery Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- .../__tests__/runtimeSetModel.test.ts | 86 +++++++++++++++++++ .../src/main/maker-ipc/runtimeSetModel.ts | 4 +- 2 files changed, 89 insertions(+), 1 deletion(-) 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 6da442b771..cc536fc62e 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/runtimeSetModel.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/runtimeSetModel.test.ts @@ -1202,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 丢掉)。 diff --git a/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts b/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts index 4444de14dc..7b3e731a29 100644 --- a/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts +++ b/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts @@ -451,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; } From 04663bf7f9b5322529c630f48deb65c6c7a72095 Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Fri, 28 Aug 2026 21:04:00 +0800 Subject: [PATCH 28/36] fix(desktop): atomically persist IM Codex relinks Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- .../cardActionTakeoverReplace.test.ts | 117 ++++++++++++++---- .../src/main/im/shared/cardActionHandler.ts | 58 +++++++-- .../codexProviderThreadRelink.test.ts | 71 +++++++++++ apps/desktop/src/main/maker-ipc/register.ts | 46 ++++++- 4 files changed, 256 insertions(+), 36 deletions(-) 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 23a6880f52..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,8 +22,26 @@ const mocks = vi.hoisted(() => ({ getDesktopCcPrefs: vi.fn<() => DesktopCcPrefs | null>(() => null), resolveLenientSessionRoute: vi.fn(), applyRuntimeSetModelChange: - vi.fn<(input: unknown) => Promise<{ status: 'applied' | 'deferred' }>>(), - relinkCodexThreadForCredentialSwitch: vi.fn(async () => {}), + 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(), @@ -724,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, @@ -750,18 +833,21 @@ 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', @@ -773,20 +859,7 @@ 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: { diff --git a/apps/desktop/src/main/im/shared/cardActionHandler.ts b/apps/desktop/src/main/im/shared/cardActionHandler.ts index d4b6b28d6f..1b4f879dca 100644 --- a/apps/desktop/src/main/im/shared/cardActionHandler.ts +++ b/apps/desktop/src/main/im/shared/cardActionHandler.ts @@ -374,16 +374,31 @@ 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, @@ -396,10 +411,32 @@ export function createCardActionHandler( clearPendingCredentialSwitch: clearPendingCredentialSwitchForSession, wakeSessionInputQueue: wakeSessionInputAfterCredentialSwitch, getPendingCredentialSwitch: getPendingCredentialSwitchTarget, - relinkCodexThreadForProviderSwitch: relinkCodexThreadForCredentialSwitch, + 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 { @@ -415,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/maker-ipc/__tests__/codexProviderThreadRelink.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/codexProviderThreadRelink.test.ts index 81fd9c1128..f2529be86f 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/codexProviderThreadRelink.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/codexProviderThreadRelink.test.ts @@ -129,6 +129,77 @@ describe('relinkCodexProviderThread', () => { }); }); + 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(); diff --git a/apps/desktop/src/main/maker-ipc/register.ts b/apps/desktop/src/main/maker-ipc/register.ts index 16fabc1588..335d594360 100644 --- a/apps/desktop/src/main/maker-ipc/register.ts +++ b/apps/desktop/src/main/maker-ipc/register.ts @@ -888,6 +888,7 @@ import { relinkCodexProviderThread, rollbackPersistedCodexRuntimeSelection, type CodexProviderThreadRelinkReceipt, + type PersistedCodexRuntimeSelectionState, } from './codexProviderThreadRelink.js'; import { applyRuntimeSelectionAxesWithRecovery, @@ -3293,6 +3294,11 @@ export async function relinkCodexThreadForCredentialSwitch(input: { targetModel: string; targetProviderId: string | null; isCurrent?: () => boolean; + /** IM 等先于通用持久化调用 relink 的入口,用单条 CAS 成套提交 thread + route。 */ + persistedRouteTransition?: { + previous: Omit; + next: Omit; + }; }): Promise { const ownerScope = captureDataOwnerBroadcastScope(); const dbSnapshot = getCurrentDbClientSnapshot(); @@ -3303,13 +3309,28 @@ export async function relinkCodexThreadForCredentialSwitch(input: { previousSdkSessionId: string; newSdkSessionId: string; }): Promise => { + const transition = input.persistedRouteTransition; const write = await dbSnapshot.client.drizzle .update(sessions) - .set({ sdkSessionId: params.previousSdkSessionId, updatedAt: Date.now() }) + .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(); @@ -3342,6 +3363,7 @@ export async function relinkCodexThreadForCredentialSwitch(input: { return { newSdkSessionId: forked.newSdkSessionId }; }, commit: async ({ sessionId, expectedSdkSessionId, newSdkSessionId, isCurrent }) => { + const transition = input.persistedRouteTransition; const isBoundaryCurrent = (): boolean => !isAppSessionBoundaryPending() && isCurrent?.() !== false && @@ -3353,9 +3375,26 @@ export async function relinkCodexThreadForCredentialSwitch(input: { commit: async () => { const write = await dbSnapshot.client.drizzle .update(sessions) - .set({ sdkSessionId: newSdkSessionId, updatedAt: now }) + .set({ + sdkSessionId: newSdkSessionId, + ...(transition ? transition.next : {}), + updatedAt: now, + }) .where( - and(eq(sessions.id, sessionId), eq(sessions.sdkSessionId, expectedSdkSessionId)), + 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; @@ -3382,6 +3421,7 @@ export async function relinkCodexThreadForCredentialSwitch(input: { sessionId, { sdkSessionId: newSdkSessionId, + ...(transition ? transition.next : {}), updatedAt: new Date(now).toISOString(), }, ownerScope, From a1bdaa900bce615aad0297adeb10dcd6577dee36 Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Fri, 28 Aug 2026 22:29:13 +0800 Subject: [PATCH 29/36] fix(desktop): atomically restore stale Codex routes Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- .../__tests__/pendingCredentialSwitch.test.ts | 48 ++++++-- .../main/maker-ipc/pendingCredentialSwitch.ts | 107 +++++++++++++----- 2 files changed, 117 insertions(+), 38 deletions(-) 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 e593498307..7d53af1294 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/pendingCredentialSwitch.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/pendingCredentialSwitch.test.ts @@ -914,8 +914,11 @@ describe('PendingCredentialSwitchService', () => { fastMode: true, }; expect(h.persistRoute).toHaveBeenCalledWith(sessionId, persistedFallback); - expect(rollback).toHaveBeenCalledOnce(); - expect(restoreStaleOwnerRoute).toHaveBeenCalledWith(persistedFallback); + expect(rollback).not.toHaveBeenCalled(); + expect(restoreStaleOwnerRoute).toHaveBeenCalledWith( + persistedFallback, + 'thread-openai', + ); expect(h.service.get(sessionId)).toBeUndefined(); expect(h.onApplied).not.toHaveBeenCalled(); }); @@ -939,9 +942,10 @@ describe('PendingCredentialSwitchService', () => { const restoreStaleOwnerRoute = vi .fn() .mockRejectedValueOnce(new Error('sqlite temporarily unavailable')) - .mockImplementationOnce(async (route) => { + .mockResolvedValueOnce(false) + .mockImplementationOnce(async (route, expectedSdkSessionId) => { if ( - persistedProfile.sdkSessionId !== 'thread-xd' || + persistedProfile.sdkSessionId !== expectedSdkSessionId || persistedProfile.model !== route?.model || persistedProfile.providerId !== route.providerId || persistedProfile.effort !== route.effort || @@ -961,7 +965,7 @@ describe('PendingCredentialSwitchService', () => { const h = createHarness( [{ id: sessionId, agentKind: 'codex', remoteHostId: null, isTurnRunning: () => false }], { - retryDelayMs: 5, + retryDelayMs: 50, isOwnerScopeCurrent: () => ownerCurrent, }, ); @@ -982,6 +986,9 @@ describe('PendingCredentialSwitchService', () => { fastMode: route.fastMode ?? persistedProfile.fastMode, }; ownerCurrent = false; + // A concurrent queue probe can begin stale cleanup before persistRoute returns. The + // finalizer must upgrade that in-flight cleanup with the persisted tuple + replacement id. + expect(h.service.has(sessionId)).toBe(true); }); await h.service.register(sessionId, { @@ -1001,10 +1008,10 @@ describe('PendingCredentialSwitchService', () => { }); await h.service.onTurnSettled(sessionId); - expect(rollback).toHaveBeenCalledOnce(); - expect(restoreStaleOwnerRoute).toHaveBeenCalledOnce(); + expect(rollback).not.toHaveBeenCalled(); + expect(restoreStaleOwnerRoute).toHaveBeenCalledTimes(2); expect(persistedProfile).toEqual({ - sdkSessionId: 'thread-xd', + sdkSessionId: 'thread-openai', model: 'gpt-5.6-sol', providerId: 'openai', effort: 'xhigh', @@ -1016,8 +1023,31 @@ describe('PendingCredentialSwitchService', () => { expect(h.onApplied).not.toHaveBeenCalled(); expect(h.broadcastApplied).not.toHaveBeenCalled(); - await vi.waitFor(() => expect(restoreStaleOwnerRoute).toHaveBeenCalledTimes(2)); + 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', diff --git a/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts b/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts index 0a2073cfd5..2f34fd17b7 100644 --- a/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts +++ b/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts @@ -191,6 +191,16 @@ export class PendingCredentialSwitchService { 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 不得代为唤醒。 */ @@ -719,27 +729,15 @@ export class PendingCredentialSwitchService { // 成套补偿;先单独回滚 thread 会留下可被唤醒队列/重启观察到的裂分状态。 if (!this.targetCurrent(sessionId, target)) { if (!this.ownerScopeCurrent(target)) { - try { - if (relinkReceipt) { - const restored = await relinkReceipt.rollback(); - if (!restored) { - this.deps.logger?.warn( - 'pending credential switch: stale Codex thread relink rollback was superseded', - { sessionId }, - ); - } - } - } catch (rollbackError) { - this.deps.logger?.error?.( - 'pending credential switch: stale Codex thread relink rollback failed', - { - sessionId, - error: - rollbackError instanceof Error ? rollbackError.message : String(rollbackError), - }, - ); - } - this.discardIfOwnerStale(sessionId, target, persistedResolvedRoute); + // 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, @@ -862,15 +860,20 @@ export class PendingCredentialSwitchService { sessionId: string, target: PendingCredentialSwitch, persistedRoute?: PendingCredentialSwitchPersistedRoute, + expectedSdkSessionId?: string, ): Promise { try { if (target.restoreStaleOwnerRoute) { - const restored = await target.restoreStaleOwnerRoute(persistedRoute); + const restored = await target.restoreStaleOwnerRoute( + persistedRoute, + expectedSdkSessionId, + ); if (!restored) { this.deps.logger?.warn( 'stale pending credential switch route compensation was superseded', { sessionId }, ); + return false; } } return true; @@ -886,15 +889,25 @@ export class PendingCredentialSwitchService { private async retryStaleOwnerCompensation( sessionId: string, target: PendingCredentialSwitch, - persistedRoute?: PendingCredentialSwitchPersistedRoute, ): Promise { + if (this.staleCompensationRunning.has(target)) return; + this.staleCompensationRunning.add(target); + const context = this.staleCompensationContexts.get(target); const compensated = await this.compensateStaleOwnerRoute( sessionId, target, - persistedRoute, + 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 (!compensated) { - this.scheduleStaleOwnerCompensationRetry(sessionId, target, persistedRoute); + this.scheduleStaleOwnerCompensationRetry(sessionId, target); return; } this.completeStaleOwnerCompensation(sessionId, target); @@ -903,16 +916,26 @@ export class PendingCredentialSwitchService { private scheduleStaleOwnerCompensationRetry( sessionId: string, target: PendingCredentialSwitch, - persistedRoute?: PendingCredentialSwitchPersistedRoute, ): void { if (this.staleCompensationRetryTimers.has(target)) return; const timer = setTimeout(() => { this.staleCompensationRetryTimers.delete(target); - void this.retryStaleOwnerCompensation(sessionId, target, persistedRoute); + 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, @@ -920,6 +943,8 @@ export class PendingCredentialSwitchService { 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); @@ -932,7 +957,8 @@ export class PendingCredentialSwitchService { }); if ( !this.staleCompensations.has(sessionId) && - !this.compensationFailures.has(sessionId) + !this.compensationFailures.has(sessionId) && + !this.applying.has(sessionId) ) { this.releaseClearedDuringApply(sessionId); } @@ -1038,8 +1064,27 @@ export class PendingCredentialSwitchService { 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 @@ -1052,7 +1097,11 @@ export class PendingCredentialSwitchService { // 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); - void this.retryStaleOwnerCompensation(sessionId, target, persistedRoute); + 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; } From 0fc8d8e8e1985f7ee72598b9c8b97b19f2769c07 Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Fri, 28 Aug 2026 22:39:40 +0800 Subject: [PATCH 30/36] fix(desktop): cancel failed deferred runtime switches Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- .../sessionRuntimeControlWiring.test.ts | 34 +++++++++++++++++-- apps/desktop/src/main/maker-ipc/register.ts | 2 +- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts b/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts index 6528b987e2..7423cef237 100644 --- a/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts +++ b/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts @@ -302,7 +302,7 @@ describe('session runtime control wiring', () => { ); }); - it('compensates ordinary renderer runtime state when a post-relink metadata read fails', () => { + it('cancels a deferred runtime selection when a later metadata read fails', () => { const setModel = handlerBody( registerSource, 'const handleSetModel = async (', @@ -320,9 +320,32 @@ describe('session runtime control wiring', () => { 'const restored = await rollbackAppliedCodexThreadRelink();', captureRelink, ); + const deferredRecoveryGuard = setModel.indexOf( + 'if (hadRelink || appliedRuntimeSelectionWasDeferred) {', + rollbackRelink, + ); const runtimeRecovery = setModel.indexOf( 'await recoverRuntimeAfterSelectionRollback({', - rollbackRelink, + 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);', @@ -336,7 +359,12 @@ describe('session runtime control wiring', () => { expect(noPersistedRoute).toBeGreaterThan(rollback); expect(captureRelink).toBeGreaterThan(noPersistedRoute); expect(rollbackRelink).toBeGreaterThan(captureRelink); - expect(runtimeRecovery).toBeGreaterThan(rollbackRelink); + 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', ); diff --git a/apps/desktop/src/main/maker-ipc/register.ts b/apps/desktop/src/main/maker-ipc/register.ts index 335d594360..c303bb8ea7 100644 --- a/apps/desktop/src/main/maker-ipc/register.ts +++ b/apps/desktop/src/main/maker-ipc/register.ts @@ -15763,7 +15763,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) const hadRelink = appliedCodexThreadRelink !== undefined; const restored = await rollbackAppliedCodexThreadRelink(); if (!restored) return false; - if (hadRelink) { + if (hadRelink || appliedRuntimeSelectionWasDeferred) { await recoverRuntimeAfterSelectionRollback({ sdkSessionId: runtimeStatus.sdkSessionId, model: runtimeStatus.model, From 06f8b79eb11eb773ae40067e2ac39a52e58b2f37 Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Fri, 28 Aug 2026 22:44:58 +0800 Subject: [PATCH 31/36] fix(desktop): atomically persist immediate Codex relinks Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- .../sessionRuntimeControlWiring.test.ts | 53 ++++++++++++++++--- apps/desktop/src/main/maker-ipc/register.ts | 43 ++++++++++++++- 2 files changed, 87 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts b/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts index 7423cef237..18f769a441 100644 --- a/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts +++ b/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts @@ -190,21 +190,60 @@ describe('session runtime control wiring', () => { expect(axisValidation).toBeLessThan(setModel.indexOf('persistSessionFields(sessionId')); }); - it('rolls back a committed Codex thread relink when atomic route persistence fails', () => { + it('commits an immediate user Codex relink and route in one SQLite CAS', () => { const setModel = handlerBody( registerSource, 'const handleSetModel = async (', 'const recoverRemoteRuntimeAxisPersistence', ); - const persist = setModel.indexOf('await persistSessionFields(sessionId, patch)'); - const rollback = setModel.indexOf( - 'const restored = await rollbackAppliedCodexThreadRelink()', - persist, + 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)).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(skipSecondWrite).toBeGreaterThan(markAtomicRoute); expect(persist).toBeGreaterThan(-1); - expect(rollback).toBeGreaterThan(persist); - expect(setModel).toContain("throw new Error('Codex thread relink rollback was superseded')"); + expect(persist).toBeGreaterThan(skipSecondWrite); }); it('rolls back the complete persisted route before an owner-boundary request exits', () => { diff --git a/apps/desktop/src/main/maker-ipc/register.ts b/apps/desktop/src/main/maker-ipc/register.ts index c303bb8ea7..fd13b22fb6 100644 --- a/apps/desktop/src/main/maker-ipc/register.ts +++ b/apps/desktop/src/main/maker-ipc/register.ts @@ -15674,6 +15674,41 @@ 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( + internalOptions.source === 'user' + ? { + ...relinkInput, + persistedRouteTransition: { + previous: persistedRuntimeSourceRoute, + next: persistedRuntimeTargetRoute, + }, + } + : relinkInput, + ); let appliedCodexThreadRelink: CodexProviderThreadRelinkReceipt | undefined; let appliedPersistedRuntimeRoute: | { @@ -15864,12 +15899,16 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) // 解析隐式来源的凭证家族,精确判定是否跨远端压缩身份边界(见 // shouldCloseSessionForCredentialSwitch.codexAuthInjection)。 codexAuthInjection: getCodexProxyAuthInjectionState(), - relinkCodexThreadForProviderSwitch: relinkCodexThreadForCredentialSwitch, + relinkCodexThreadForProviderSwitch: relinkCodexThreadForRuntimeSelection, logger: log, }) : { status: 'applied' as const }; if (result.status === 'applied') { appliedCodexThreadRelink = result.codexThreadRelink; + if (appliedCodexThreadRelink && internalOptions.source === 'user') { + // The relink CAS already committed thread + route as one restart-safe tuple. + appliedPersistedRuntimeRoute = persistedRuntimeTargetRoute; + } } // deferred = 会话自己在跑,选择已登记、turn 结束自动生效。renderer 据此提示 // "任务结束后生效"而不是当成已即时切换。 @@ -15942,7 +15981,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) patch.fastMode = atomicSelection.fastMode; } try { - if (!preservePersistedRoute) { + if (!preservePersistedRoute && !appliedPersistedRuntimeRoute) { await persistSessionFields(sessionId, patch); appliedPersistedRuntimeRoute = { model, From 013cc174f2542fecd8bc94ac76fd71af04a22d5e Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:04:07 +0800 Subject: [PATCH 32/36] fix(desktop): retain post-commit relink compensation Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- .../codexProviderThreadRelink.test.ts | 90 +++++++++++ .../__tests__/pendingCredentialSwitch.test.ts | 150 ++++++++++++++++-- .../maker-ipc/codexProviderThreadRelink.ts | 77 ++++++++- .../main/maker-ipc/pendingCredentialSwitch.ts | 98 ++++++++---- apps/desktop/src/main/maker-ipc/register.ts | 13 +- 5 files changed, 376 insertions(+), 52 deletions(-) diff --git a/apps/desktop/src/main/maker-ipc/__tests__/codexProviderThreadRelink.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/codexProviderThreadRelink.test.ts index f2529be86f..0e573ed377 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/codexProviderThreadRelink.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/codexProviderThreadRelink.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { + CodexProviderThreadRelinkCompensationRequiredError, commitCodexProviderThreadRelinkWithBoundaryGuard, relinkCodexProviderThread, rollbackPersistedCodexRuntimeSelection, @@ -84,6 +85,95 @@ describe('relinkCodexProviderThread', () => { }, ); + 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); 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 7d53af1294..db377542cb 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/pendingCredentialSwitch.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/pendingCredentialSwitch.test.ts @@ -15,6 +15,7 @@ import { PendingCredentialSwitchService, type PendingCredentialSwitchDeps, } from '../pendingCredentialSwitch.js'; +import { CodexProviderThreadRelinkCompensationRequiredError } from '../codexProviderThreadRelink.js'; const touchedSessions = new Set(); @@ -264,30 +265,49 @@ describe('PendingCredentialSwitchService', () => { h.service.clear(sessionId); }); - it('discards an old-owner relink target during teardown and never retries it after recovery', async () => { + 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: 10, + retryDelayMs: 50, isOwnerScopeCurrent: (scope) => scope.ownerScopeKey === currentOwner.ownerScopeKey && scope.runtimeOwnerEpoch === currentOwner.runtimeOwnerEpoch, }, ); - const rollback = vi.fn(async () => { - throw new Error('old profile teardown closed the client'); - }); + 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); - return { + throw new CodexProviderThreadRelinkCompensationRequiredError({ previousSdkSessionId: 'thread-xd', newSdkSessionId: 'thread-openai', rollback, - }; + }); }); h.service.register(sessionId, { @@ -295,17 +315,77 @@ describe('PendingCredentialSwitchService', () => { providerId: 'openai', rebuildCodexThread: true, ownerScope: { ownerScopeKey: 'owner-a:1', runtimeOwnerEpoch: '7' }, - previousRoute: { model: 'codex/gpt-5.6-sol', providerId: 'xd' }, + 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(false); - expect(rollback).toHaveBeenCalledOnce(); - currentOwner = { ownerScopeKey: 'owner-a:2', runtimeOwnerEpoch: '8' }; - await new Promise((resolve) => setTimeout(resolve, 30)); + 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 () => { @@ -454,6 +534,50 @@ describe('PendingCredentialSwitchService', () => { 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('rolls back the relink when deferred route persistence fails', async () => { const sessionId = rememberSession('pending-switch-persist-rollback'); setSessionProvider(sessionId, 'xd'); @@ -942,7 +1066,7 @@ describe('PendingCredentialSwitchService', () => { const restoreStaleOwnerRoute = vi .fn() .mockRejectedValueOnce(new Error('sqlite temporarily unavailable')) - .mockResolvedValueOnce(false) + .mockRejectedValueOnce(new Error('sqlite still temporarily unavailable')) .mockImplementationOnce(async (route, expectedSdkSessionId) => { if ( persistedProfile.sdkSessionId !== expectedSdkSessionId || diff --git a/apps/desktop/src/main/maker-ipc/codexProviderThreadRelink.ts b/apps/desktop/src/main/maker-ipc/codexProviderThreadRelink.ts index 647679d6fd..702a3e2857 100644 --- a/apps/desktop/src/main/maker-ipc/codexProviderThreadRelink.ts +++ b/apps/desktop/src/main/maker-ipc/codexProviderThreadRelink.ts @@ -18,6 +18,51 @@ export interface CodexProviderThreadRelinkReceipt { 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; @@ -70,7 +115,7 @@ export async function commitCodexProviderThreadRelinkWithBoundaryGuard(input: { const committed = await input.commit(); if (!committed) return false; if (input.isBoundaryCurrent()) return true; - await input.rollback(); + await rollbackCommittedCodexProviderThreadRelink(input.rollback); return false; } @@ -131,12 +176,30 @@ export async function relinkCodexProviderThread( ), ...(source.workingDir ? { workingDir: source.workingDir } : {}), }); - const committed = await deps.commit({ - sessionId: input.sessionId, - expectedSdkSessionId: source.sdkSessionId, - newSdkSessionId: forked.newSdkSessionId, - ...(input.isCurrent ? { isCurrent: input.isCurrent } : {}), - }); + 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}`); } diff --git a/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts b/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts index 2f34fd17b7..0a0a5f2d98 100644 --- a/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts +++ b/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts @@ -15,7 +15,10 @@ import { setSessionFastMode, } from '../maker-host/session-effort-store.js'; import { setSessionProvider } from '../maker-host/session-provider-store.js'; -import type { CodexProviderThreadRelinkReceipt } from './codexProviderThreadRelink.js'; +import { + isCodexProviderThreadRelinkCompensationRequiredError, + type CodexProviderThreadRelinkReceipt, +} from './codexProviderThreadRelink.js'; /** * PendingCredentialSwitchService —— 会话凭证形态切换的「延迟生效」登记表。 @@ -265,8 +268,8 @@ export class PendingCredentialSwitchService { } // 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 compensated = await this.compensateStaleOwnerRoute(sessionId, pending); - if (!compensated) { + 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', { @@ -598,25 +601,66 @@ export class PendingCredentialSwitchService { error: err instanceof Error ? err.message : String(err), }, ); - if (this.targetCurrent(sessionId, target)) this.scheduleRetry(sessionId); - else this.discardIfOwnerStale(sessionId, target); + if (isCodexProviderThreadRelinkCompensationRequiredError(err)) { + const receipt = err.receipt; + if (!this.ownerScopeCurrent(target)) { + this.discardIfOwnerStale( + sessionId, + target, + undefined, + receipt.newSdkSessionId, + ); + } else { + await this.compensateSupersededPersistedRoute( + sessionId, + target, + undefined, + 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)) { - try { - if (relinkReceipt) await relinkReceipt.rollback(); - } catch (rollbackError) { - this.deps.logger?.error?.( - 'pending credential switch: owner-stale Codex relink rollback failed', - { + 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, - error: - rollbackError instanceof Error - ? rollbackError.message - : String(rollbackError), - }, - ); - } finally { + target, + undefined, + relinkReceipt.newSdkSessionId, + ); + } else { + await this.compensateSupersededPersistedRoute( + sessionId, + target, + undefined, + relinkReceipt, + ); + } + } else { this.discardIfOwnerStale(sessionId, target); } return; @@ -861,7 +905,7 @@ export class PendingCredentialSwitchService { target: PendingCredentialSwitch, persistedRoute?: PendingCredentialSwitchPersistedRoute, expectedSdkSessionId?: string, - ): Promise { + ): Promise<'safe' | 'retry'> { try { if (target.restoreStaleOwnerRoute) { const restored = await target.restoreStaleOwnerRoute( @@ -873,16 +917,16 @@ export class PendingCredentialSwitchService { 'stale pending credential switch route compensation was superseded', { sessionId }, ); - return false; + return 'safe'; } } - return true; + 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 false; + return 'retry'; } } @@ -893,7 +937,7 @@ export class PendingCredentialSwitchService { if (this.staleCompensationRunning.has(target)) return; this.staleCompensationRunning.add(target); const context = this.staleCompensationContexts.get(target); - const compensated = await this.compensateStaleOwnerRoute( + const compensation = await this.compensateStaleOwnerRoute( sessionId, target, context?.persistedRoute, @@ -906,7 +950,7 @@ export class PendingCredentialSwitchService { this.requestStaleOwnerCompensation(sessionId, target); return; } - if (!compensated) { + if (compensation === 'retry') { this.scheduleStaleOwnerCompensationRetry(sessionId, target); return; } @@ -973,7 +1017,7 @@ export class PendingCredentialSwitchService { private async compensateSupersededPersistedRoute( sessionId: string, target: PendingCredentialSwitch, - persistedRoute: PendingCredentialSwitchPersistedRoute, + persistedRoute: PendingCredentialSwitchPersistedRoute | undefined, relinkReceipt: CodexProviderThreadRelinkReceipt | null, ): Promise { const safe = await this.tryCompensateSupersededPersistedRoute( @@ -990,7 +1034,7 @@ export class PendingCredentialSwitchService { private async tryCompensateSupersededPersistedRoute( sessionId: string, target: PendingCredentialSwitch, - persistedRoute: PendingCredentialSwitchPersistedRoute, + persistedRoute: PendingCredentialSwitchPersistedRoute | undefined, relinkReceipt: CodexProviderThreadRelinkReceipt | null, ): Promise { try { @@ -1037,7 +1081,7 @@ export class PendingCredentialSwitchService { private scheduleCompensationRetry( sessionId: string, target: PendingCredentialSwitch, - persistedRoute: PendingCredentialSwitchPersistedRoute, + persistedRoute: PendingCredentialSwitchPersistedRoute | undefined, relinkReceipt: CodexProviderThreadRelinkReceipt | null, ): void { if (this.compensationRetryTimers.has(sessionId)) return; diff --git a/apps/desktop/src/main/maker-ipc/register.ts b/apps/desktop/src/main/maker-ipc/register.ts index fd13b22fb6..481ca3e638 100644 --- a/apps/desktop/src/main/maker-ipc/register.ts +++ b/apps/desktop/src/main/maker-ipc/register.ts @@ -886,6 +886,7 @@ import { import { commitCodexProviderThreadRelinkWithBoundaryGuard, relinkCodexProviderThread, + rollbackCommittedCodexProviderThreadRelink, rollbackPersistedCodexRuntimeSelection, type CodexProviderThreadRelinkReceipt, type PersistedCodexRuntimeSelectionState, @@ -3410,11 +3411,13 @@ export async function relinkCodexThreadForCredentialSwitch(input: { // 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 rollbackCapturedRelink({ - sessionId, - previousSdkSessionId: expectedSdkSessionId, - newSdkSessionId, - }); + await rollbackCommittedCodexProviderThreadRelink(() => + rollbackCapturedRelink({ + sessionId, + previousSdkSessionId: expectedSdkSessionId, + newSdkSessionId, + }), + ); return false; } broadcastSessionPatched( From 75ef786aeee616765529e90050eadadc8dd47590 Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:07:57 +0800 Subject: [PATCH 33/36] fix(desktop): isolate relink compensation retries Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- .../__tests__/pendingCredentialSwitch.test.ts | 168 ++++++++++++++++++ .../main/maker-ipc/pendingCredentialSwitch.ts | 62 +++++-- 2 files changed, 213 insertions(+), 17 deletions(-) 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 db377542cb..a715732bb3 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/pendingCredentialSwitch.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/pendingCredentialSwitch.test.ts @@ -986,6 +986,174 @@ describe('PendingCredentialSwitchService', () => { expect(getSessionProvider(sessionId)).toBe('xd'); }); + 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 () => { + persistedProfile.sdkSessionId = 'thread-older'; + return { + previousSdkSessionId: 'thread-xd', + newSdkSessionId: 'thread-older', + rollback: vi.fn(async () => false), + }; + }) + .mockImplementationOnce(async () => { + persistedProfile.sdkSessionId = 'thread-newer'; + return { + previousSdkSessionId: 'thread-older', + newSdkSessionId: 'thread-newer', + rollback: vi.fn(async () => false), + }; + }); + h.persistRoute + .mockImplementationOnce(async (_persistedSessionId, route) => { + persistedProfile = { + ...persistedProfile, + model: route.model ?? persistedProfile.model, + providerId: route.providerId, + effort: route.effort ?? persistedProfile.effort, + fastMode: route.fastMode ?? persistedProfile.fastMode, + }; + h.service.clear(sessionId); + }) + .mockImplementationOnce(async (_persistedSessionId, route) => { + persistedProfile = { + ...persistedProfile, + model: route.model ?? persistedProfile.model, + providerId: route.providerId, + effort: route.effort ?? persistedProfile.effort, + fastMode: route.fastMode ?? persistedProfile.fastMode, + }; + h.service.clear(sessionId); + }); + + 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).toHaveBeenCalledTimes(2); + expect(h.onApplied).not.toHaveBeenCalled(); + }, + ); + it('compensates the actual fallback route when the owner changes during persistence', async () => { const sessionId = rememberSession('pending-switch-owner-stale-during-fallback-persist'); setSessionProvider(sessionId, 'xd'); diff --git a/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts b/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts index 0a0a5f2d98..eec0eb58c0 100644 --- a/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts +++ b/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts @@ -211,9 +211,12 @@ export class PendingCredentialSwitchService { /** 新选择已完成但唤醒曾被旧 finalizer barrier 挡住,补偿结束后补发。 */ private readonly deferredCancellationWakes = new Set(); /** SQLite 暂时失败时保持 gate,并用捕获的旧 Profile 继续补偿。 */ - private readonly compensationFailures = new Set(); - private readonly compensationRetryTimers = new Map< + private readonly compensationFailures = new Map< string, + Set + >(); + private readonly compensationRetryTimers = new Map< + PendingCredentialSwitch, ReturnType >(); @@ -868,7 +871,21 @@ export class PendingCredentialSwitchService { private finishApplying(sessionId: string): void { this.applying.delete(sessionId); - if (this.compensationFailures.has(sessionId) || this.staleCompensations.has(sessionId)) return; + 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); } @@ -999,13 +1016,7 @@ export class PendingCredentialSwitchService { this.deps.logger?.info('stale pending credential switch discarded after owner boundary', { sessionId, }); - if ( - !this.staleCompensations.has(sessionId) && - !this.compensationFailures.has(sessionId) && - !this.applying.has(sessionId) - ) { - this.releaseClearedDuringApply(sessionId); - } + this.maybeReleaseCancellationBarrier(sessionId); } /** @@ -1026,8 +1037,13 @@ export class PendingCredentialSwitchService { persistedRoute, relinkReceipt, ); - if (safe) return; - this.compensationFailures.add(sessionId); + 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); } @@ -1084,9 +1100,9 @@ export class PendingCredentialSwitchService { persistedRoute: PendingCredentialSwitchPersistedRoute | undefined, relinkReceipt: CodexProviderThreadRelinkReceipt | null, ): void { - if (this.compensationRetryTimers.has(sessionId)) return; + if (this.compensationRetryTimers.has(target)) return; const timer = setTimeout(() => { - this.compensationRetryTimers.delete(sessionId); + this.compensationRetryTimers.delete(target); void this.tryCompensateSupersededPersistedRoute( sessionId, target, @@ -1097,11 +1113,23 @@ export class PendingCredentialSwitchService { this.scheduleCompensationRetry(sessionId, target, persistedRoute, relinkReceipt); return; } - this.compensationFailures.delete(sessionId); - this.releaseClearedDuringApply(sessionId); + this.completeSupersededCompensation(sessionId, target); }); }, this.deps.retryDelayMs ?? PENDING_APPLY_RETRY_DELAY_MS); - this.compensationRetryTimers.set(sessionId, timer); + 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( From 27922225e42097ad883e0510ea27a99ae6c5f0db Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:13:30 +0800 Subject: [PATCH 34/36] fix(desktop): persist non-user Codex relink routes Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- .../sessionRuntimeControlWiring.test.ts | 26 +++++++++++++++++-- apps/desktop/src/main/maker-ipc/register.ts | 23 +++++++--------- 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts b/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts index 18f769a441..4d78a23f80 100644 --- a/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts +++ b/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts @@ -190,12 +190,22 @@ describe('session runtime control wiring', () => { expect(axisValidation).toBeLessThan(setModel.indexOf('persistSessionFields(sessionId')); }); - it('commits an immediate user Codex relink and route in one SQLite CAS', () => { + 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 = ('); @@ -234,16 +244,28 @@ describe('session runtime control wiring', () => { 'fastMode: atomicSelection?.fastMode ?? runtimeStatus.fastMode', ); expect(wrapper).toBeGreaterThan(targetTuple); - expect(setModel.slice(wrapper, injected)).toContain("internalOptions.source === 'user'"); + 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('rolls back the complete persisted route before an owner-boundary request exits', () => { diff --git a/apps/desktop/src/main/maker-ipc/register.ts b/apps/desktop/src/main/maker-ipc/register.ts index 481ca3e638..6c20af0d6e 100644 --- a/apps/desktop/src/main/maker-ipc/register.ts +++ b/apps/desktop/src/main/maker-ipc/register.ts @@ -15701,17 +15701,13 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) const relinkCodexThreadForRuntimeSelection = ( relinkInput: Parameters[0], ) => - relinkCodexThreadForCredentialSwitch( - internalOptions.source === 'user' - ? { - ...relinkInput, - persistedRouteTransition: { - previous: persistedRuntimeSourceRoute, - next: persistedRuntimeTargetRoute, - }, - } - : relinkInput, - ); + relinkCodexThreadForCredentialSwitch({ + ...relinkInput, + persistedRouteTransition: { + previous: persistedRuntimeSourceRoute, + next: persistedRuntimeTargetRoute, + }, + }); let appliedCodexThreadRelink: CodexProviderThreadRelinkReceipt | undefined; let appliedPersistedRuntimeRoute: | { @@ -15908,8 +15904,9 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) : { status: 'applied' as const }; if (result.status === 'applied') { appliedCodexThreadRelink = result.codexThreadRelink; - if (appliedCodexThreadRelink && internalOptions.source === 'user') { - // The relink CAS already committed thread + route as one restart-safe tuple. + 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; } } From 232413424248b2a2414fa4ce7ad40318df129149 Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:31:25 +0800 Subject: [PATCH 35/36] fix(desktop): atomically persist deferred Codex relinks Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- .../sessionRuntimeControlWiring.test.ts | 34 ++ .../__tests__/pendingCredentialSwitch.test.ts | 371 ++++++++++-------- .../main/maker-ipc/pendingCredentialSwitch.ts | 114 ++++-- 3 files changed, 329 insertions(+), 190 deletions(-) diff --git a/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts b/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts index 4d78a23f80..9c8226271c 100644 --- a/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts +++ b/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts @@ -268,6 +268,40 @@ describe('session runtime control wiring', () => { } }); + 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, 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 a715732bb3..2bfb383bf9 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/pendingCredentialSwitch.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/pendingCredentialSwitch.test.ts @@ -125,16 +125,34 @@ describe('PendingCredentialSwitchService', () => { expect(h.onApplied).toHaveBeenCalledWith(sessionId); }); - it('relinks a deferred Codex thread before route persistence and queue wake', async () => { + 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.mockResolvedValueOnce({ - previousSdkSessionId: 'thread-xd', - newSdkSessionId: 'thread-openai', - rollback: vi.fn(async () => true), + 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, { @@ -143,7 +161,12 @@ describe('PendingCredentialSwitchService', () => { effort: 'xhigh', fastMode: true, rebuildCodexThread: true, - previousRoute: { model: 'codex/gpt-5.6-sol', providerId: 'xd' }, + 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. @@ -158,20 +181,32 @@ describe('PendingCredentialSwitchService', () => { 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).toHaveBeenCalledTimes(1); - expect(h.persistRoute).toHaveBeenCalledWith(sessionId, { + expect(h.persistRoute).not.toHaveBeenCalled(); + expect(persistedProfile).toEqual({ + sdkSessionId: 'thread-openai', model: 'gpt-5.6-sol', providerId: 'openai', effort: 'xhigh', fastMode: true, }); - expect(h.persistRoute.mock.invocationCallOrder[0]).toBeLessThan( - h.onApplied.mock.invocationCallOrder[0]!, - ); }); it('recomputes relink from the final route when revalidation crosses thread families', async () => { @@ -187,13 +222,23 @@ describe('PendingCredentialSwitchService', () => { }), }, ); + 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' }, + previousRoute: { + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + effort: 'high', + fastMode: false, + }, }); await h.service.onTurnSettled(sessionId); @@ -205,11 +250,22 @@ describe('PendingCredentialSwitchService', () => { 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).toHaveBeenCalledWith(sessionId, { - model: 'gpt-5.6-sol', - providerId: 'openai', - }); + expect(h.persistRoute).not.toHaveBeenCalled(); }); it('skips a stale relink marker when revalidation returns to the source thread family', async () => { @@ -578,7 +634,7 @@ describe('PendingCredentialSwitchService', () => { expect(h.service.has(sessionId)).toBe(false); }); - it('rolls back the relink when deferred route persistence fails', async () => { + 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); @@ -592,28 +648,35 @@ describe('PendingCredentialSwitchService', () => { }), }, ); - h.relinkCodexThreadForProviderSwitch.mockResolvedValueOnce({ - previousSdkSessionId: 'thread-xd', - newSdkSessionId: 'thread-openai', - rollback, - }); - h.persistRoute.mockRejectedValueOnce(new Error('sqlite failed')); + 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' }, + 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 whose route persistence failed', async () => { + 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'); @@ -629,14 +692,13 @@ describe('PendingCredentialSwitchService', () => { }), }, ); - h.relinkCodexThreadForProviderSwitch.mockResolvedValueOnce({ - previousSdkSessionId: 'thread-xd', - newSdkSessionId: 'thread-openai', - rollback, - }); - h.persistRoute.mockImplementationOnce(async () => { + h.relinkCodexThreadForProviderSwitch.mockImplementationOnce(async () => { h.service.clear(sessionId); - throw new Error('sqlite failed'); + throw new CodexProviderThreadRelinkCompensationRequiredError({ + previousSdkSessionId: 'thread-xd', + newSdkSessionId: 'thread-openai', + rollback, + }); }); await h.service.register(sessionId, { @@ -663,14 +725,16 @@ describe('PendingCredentialSwitchService', () => { expect(h.onApplied).not.toHaveBeenCalled(); expect(h.onCancellationCompensated).toHaveBeenCalledOnce(); expect(h.broadcastApplied).not.toHaveBeenCalled(); + expect(h.persistRoute).not.toHaveBeenCalled(); }); - it('restores a committed relink marker after cleanup so retry does not fork again', async () => { - const sessionId = rememberSession('pending-switch-restore-committed-relink'); + 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(async () => { - throw new Error('rollback failed'); - }); + const rollback = vi + .fn() + .mockRejectedValueOnce(new Error('rollback failed')) + .mockResolvedValueOnce(true); const h = createHarness( [{ id: sessionId, agentKind: 'codex', remoteHostId: null, isTurnRunning: () => false }], { @@ -679,15 +743,17 @@ describe('PendingCredentialSwitchService', () => { providerId, degraded: false, }), - retryDelayMs: 60_000, + retryDelayMs: 5, }, ); - h.relinkCodexThreadForProviderSwitch.mockResolvedValueOnce({ - previousSdkSessionId: 'thread-xd', - newSdkSessionId: 'thread-openai', - rollback, + h.relinkCodexThreadForProviderSwitch.mockImplementationOnce(async () => { + h.service.clear(sessionId); + throw new CodexProviderThreadRelinkCompensationRequiredError({ + previousSdkSessionId: 'thread-xd', + newSdkSessionId: 'thread-openai', + rollback, + }); }); - h.persistRoute.mockRejectedValueOnce(new Error('sqlite failed')); h.service.register(sessionId, { model: 'gpt-5.6-sol', @@ -695,26 +761,25 @@ describe('PendingCredentialSwitchService', () => { rebuildCodexThread: true, agentKind: 'codex', sourceCodexThreadModelProviderId: 'cindy_gateway', - previousRoute: { model: 'codex/gpt-5.6-sol', providerId: 'xd' }, + 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)?.codexThreadRelinkCommitted).toBe(true); - const recovered = h.service.get(sessionId)!; - h.service.clear(sessionId); - h.service.register(sessionId, recovered); - - await h.service.onTurnSettled(sessionId); - + expect(h.service.has(sessionId)).toBe(true); expect(h.relinkCodexThreadForProviderSwitch).toHaveBeenCalledOnce(); - expect(h.persistRoute).toHaveBeenCalledTimes(2); - expect(h.service.has(sessionId)).toBe(false); - expect(h.onApplied).toHaveBeenCalledOnce(); - expect(h.onCancellationCompensated).not.toHaveBeenCalled(); + await vi.waitFor(() => expect(rollback).toHaveBeenCalledTimes(2)); + expect(h.relinkCodexThreadForProviderSwitch).toHaveBeenCalledOnce(); + expect(h.persistRoute).not.toHaveBeenCalled(); + h.service.clear(sessionId); }); - it('rolls back an abandoned relink when route persistence is superseded', async () => { + 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 = { @@ -726,7 +791,13 @@ describe('PendingCredentialSwitchService', () => { }; const rollback = vi.fn(async () => { if (persistedProfile.sdkSessionId !== 'thread-openai') return false; - persistedProfile.sdkSessionId = 'thread-xd'; + persistedProfile = { + sdkSessionId: 'thread-xd', + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + effort: 'high', + fastMode: false, + }; return true; }); const restoreStaleOwnerRoute = vi.fn( @@ -763,24 +834,21 @@ describe('PendingCredentialSwitchService', () => { }), }, ); - h.relinkCodexThreadForProviderSwitch.mockResolvedValueOnce({ - previousSdkSessionId: 'thread-xd', - newSdkSessionId: 'thread-openai', - rollback, - }); - h.persistRoute.mockImplementationOnce(async (_persistedSessionId, route) => { + h.relinkCodexThreadForProviderSwitch.mockImplementationOnce(async (input) => { persistedProfile = { - ...persistedProfile, - model: route.model ?? persistedProfile.model, - providerId: route.providerId, - effort: route.effort ?? persistedProfile.effort, - fastMode: route.fastMode ?? persistedProfile.fastMode, + 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, { @@ -799,14 +867,8 @@ describe('PendingCredentialSwitchService', () => { }); await h.service.onTurnSettled(sessionId); - const abandonedRoute = { - model: 'gpt-5.6-sol', - providerId: 'openai', - effort: 'xhigh', - fastMode: true, - }; - expect(rollback).not.toHaveBeenCalled(); - expect(restoreStaleOwnerRoute).toHaveBeenCalledWith(abandonedRoute, 'thread-openai'); + expect(rollback).toHaveBeenCalledOnce(); + expect(restoreStaleOwnerRoute).not.toHaveBeenCalled(); expect(persistedProfile).toEqual({ sdkSessionId: 'thread-xd', model: 'codex/gpt-5.6-sol', @@ -815,6 +877,7 @@ describe('PendingCredentialSwitchService', () => { fastMode: false, }); expect(h.relinkCodexThreadForProviderSwitch).toHaveBeenCalledOnce(); + expect(h.persistRoute).not.toHaveBeenCalled(); expect(h.service.get(sessionId)).toMatchObject({ model: 'gpt-5.6-sol', providerId: 'xd', @@ -825,7 +888,7 @@ describe('PendingCredentialSwitchService', () => { h.service.clear(sessionId); }); - it('keeps cancellation gated until a late persisted route is atomically compensated', async () => { + 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'); @@ -837,13 +900,13 @@ describe('PendingCredentialSwitchService', () => { effort: 'high', fastMode: false, }; - let markPersistStarted!: () => void; - const persistStarted = new Promise((resolve) => { - markPersistStarted = resolve; + let markCommitStarted!: () => void; + const commitStarted = new Promise((resolve) => { + markCommitStarted = resolve; }); - let releasePersist!: () => void; - const persistGate = new Promise((resolve) => { - releasePersist = resolve; + let releaseCommit!: () => void; + const commitGate = new Promise((resolve) => { + releaseCommit = resolve; }); const restoreStaleOwnerRoute = vi.fn( async ( @@ -873,25 +936,19 @@ describe('PendingCredentialSwitchService', () => { [{ id: sessionId, agentKind: 'codex', remoteHostId: null, isTurnRunning: () => false }], { retryDelayMs: 60_000 }, ); - h.relinkCodexThreadForProviderSwitch.mockImplementationOnce(async () => { - persistedProfile.sdkSessionId = 'thread-openai'; + 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), }; }); - h.persistRoute.mockImplementationOnce(async (_persistedSessionId, route) => { - markPersistStarted(); - await persistGate; - persistedProfile = { - ...persistedProfile, - model: route.model ?? persistedProfile.model, - providerId: route.providerId, - effort: route.effort ?? persistedProfile.effort, - fastMode: route.fastMode ?? persistedProfile.fastMode, - }; - }); await h.service.register(sessionId, { model: 'gpt-5.6-sol', @@ -908,12 +965,12 @@ describe('PendingCredentialSwitchService', () => { restoreStaleOwnerRoute, }); const settling = h.service.onTurnSettled(sessionId); - await persistStarted; + await commitStarted; h.service.clear(sessionId); expect(h.service.has(sessionId)).toBe(true); expect(h.onApplied).not.toHaveBeenCalled(); - releasePersist(); + releaseCommit(); await settling; expect(restoreStaleOwnerRoute).toHaveBeenCalledWith( @@ -939,10 +996,11 @@ describe('PendingCredentialSwitchService', () => { 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 persisted-route compensation fails', async () => { + 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 @@ -953,13 +1011,13 @@ describe('PendingCredentialSwitchService', () => { [{ id: sessionId, agentKind: 'codex', remoteHostId: null, isTurnRunning: () => false }], { retryDelayMs: 5 }, ); - h.relinkCodexThreadForProviderSwitch.mockResolvedValueOnce({ - previousSdkSessionId: 'thread-xd', - newSdkSessionId: 'thread-openai', - rollback: vi.fn(async () => false), - }); - h.persistRoute.mockImplementationOnce(async () => { + 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, { @@ -984,6 +1042,7 @@ describe('PendingCredentialSwitchService', () => { 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)( @@ -1049,43 +1108,30 @@ describe('PendingCredentialSwitchService', () => { { retryDelayMs: 5 }, ); h.relinkCodexThreadForProviderSwitch - .mockImplementationOnce(async () => { - persistedProfile.sdkSessionId = 'thread-older'; + .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 () => { - persistedProfile.sdkSessionId = 'thread-newer'; + .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), }; }); - h.persistRoute - .mockImplementationOnce(async (_persistedSessionId, route) => { - persistedProfile = { - ...persistedProfile, - model: route.model ?? persistedProfile.model, - providerId: route.providerId, - effort: route.effort ?? persistedProfile.effort, - fastMode: route.fastMode ?? persistedProfile.fastMode, - }; - h.service.clear(sessionId); - }) - .mockImplementationOnce(async (_persistedSessionId, route) => { - persistedProfile = { - ...persistedProfile, - model: route.model ?? persistedProfile.model, - providerId: route.providerId, - effort: route.effort ?? persistedProfile.effort, - fastMode: route.fastMode ?? persistedProfile.fastMode, - }; - h.service.clear(sessionId); - }); await h.service.register(sessionId, { model: 'gpt-5.6-sol', @@ -1149,16 +1195,16 @@ describe('PendingCredentialSwitchService', () => { }); expect(h.onCancellationCompensated).toHaveBeenCalledOnce(); expect(h.relinkCodexThreadForProviderSwitch).toHaveBeenCalledTimes(2); - expect(h.persistRoute).toHaveBeenCalledTimes(2); + expect(h.persistRoute).not.toHaveBeenCalled(); expect(h.onApplied).not.toHaveBeenCalled(); }, ); - it('compensates the actual fallback route when the owner changes during persistence', async () => { + 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 () => true); + const rollback = vi.fn(async () => false); const restoreStaleOwnerRoute = vi.fn(async () => true); const h = createHarness( [{ id: sessionId, agentKind: 'codex', remoteHostId: null, isTurnRunning: () => false }], @@ -1173,13 +1219,13 @@ describe('PendingCredentialSwitchService', () => { }), }, ); - h.relinkCodexThreadForProviderSwitch.mockResolvedValueOnce({ - previousSdkSessionId: 'thread-xd', - newSdkSessionId: 'thread-openai', - rollback, - }); - h.persistRoute.mockImplementationOnce(async () => { + h.relinkCodexThreadForProviderSwitch.mockImplementationOnce(async () => { ownerCurrent = false; + return { + previousSdkSessionId: 'thread-xd', + newSdkSessionId: 'thread-openai', + rollback, + }; }); h.service.register(sessionId, { @@ -1205,8 +1251,8 @@ describe('PendingCredentialSwitchService', () => { effort: 'xhigh', fastMode: true, }; - expect(h.persistRoute).toHaveBeenCalledWith(sessionId, persistedFallback); - expect(rollback).not.toHaveBeenCalled(); + expect(h.persistRoute).not.toHaveBeenCalled(); + expect(rollback).toHaveBeenCalledOnce(); expect(restoreStaleOwnerRoute).toHaveBeenCalledWith( persistedFallback, 'thread-openai', @@ -1215,7 +1261,7 @@ describe('PendingCredentialSwitchService', () => { expect(h.onApplied).not.toHaveBeenCalled(); }); - it('keeps an owner-stale persisted route gated until Profile compensation retry succeeds', async () => { + 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; @@ -1226,11 +1272,7 @@ describe('PendingCredentialSwitchService', () => { effort: 'high', fastMode: false, }; - const rollback = vi.fn(async () => { - if (persistedProfile.sdkSessionId !== 'thread-openai') return false; - persistedProfile.sdkSessionId = 'thread-xd'; - return true; - }); + const rollback = vi.fn(async () => false); const restoreStaleOwnerRoute = vi .fn() .mockRejectedValueOnce(new Error('sqlite temporarily unavailable')) @@ -1261,27 +1303,21 @@ describe('PendingCredentialSwitchService', () => { isOwnerScopeCurrent: () => ownerCurrent, }, ); - h.relinkCodexThreadForProviderSwitch.mockImplementationOnce(async () => { - persistedProfile.sdkSessionId = 'thread-openai'; + 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, }; }); - h.persistRoute.mockImplementationOnce(async (_persistedSessionId, route) => { - persistedProfile = { - ...persistedProfile, - model: route.model ?? persistedProfile.model, - providerId: route.providerId, - effort: route.effort ?? persistedProfile.effort, - fastMode: route.fastMode ?? persistedProfile.fastMode, - }; - ownerCurrent = false; - // A concurrent queue probe can begin stale cleanup before persistRoute returns. The - // finalizer must upgrade that in-flight cleanup with the persisted tuple + replacement id. - expect(h.service.has(sessionId)).toBe(true); - }); await h.service.register(sessionId, { model: 'gpt-5.6-sol', @@ -1300,7 +1336,7 @@ describe('PendingCredentialSwitchService', () => { }); await h.service.onTurnSettled(sessionId); - expect(rollback).not.toHaveBeenCalled(); + expect(rollback).toHaveBeenCalledOnce(); expect(restoreStaleOwnerRoute).toHaveBeenCalledTimes(2); expect(persistedProfile).toEqual({ sdkSessionId: 'thread-openai', @@ -1349,7 +1385,7 @@ describe('PendingCredentialSwitchService', () => { }); expect(h.onCancellationCompensated).toHaveBeenCalledOnce(); expect(h.relinkCodexThreadForProviderSwitch).toHaveBeenCalledOnce(); - expect(h.persistRoute).toHaveBeenCalledOnce(); + expect(h.persistRoute).not.toHaveBeenCalled(); expect(h.onApplied).not.toHaveBeenCalled(); expect(h.broadcastApplied).not.toHaveBeenCalled(); }); @@ -1370,7 +1406,12 @@ describe('PendingCredentialSwitchService', () => { model: 'gpt-5.6-sol', providerId: 'openai', rebuildCodexThread: true, - previousRoute: { model: 'codex/gpt-5.6-sol', providerId: 'xd' }, + previousRoute: { + model: 'codex/gpt-5.6-sol', + providerId: 'xd', + effort: 'high', + fastMode: false, + }, }); await h.service.onTurnSettled(sessionId); diff --git a/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts b/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts index eec0eb58c0..a251811656 100644 --- a/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts +++ b/apps/desktop/src/main/maker-ipc/pendingCredentialSwitch.ts @@ -1,4 +1,4 @@ -import type { AgentKind } from '@cindy/maker-core'; +import type { AgentKind, Effort } from '@cindy/maker-core'; import { isCredentialModeSwitchBusyError, @@ -42,13 +42,25 @@ import { * 重试 onTurnSettled,杜绝"事件丢失 → 永久冻结"。 * * 跨 Codex thread family 的 deferred 登记期间,SQLite 必须保留源 route/thread; - * finalizer 确认 close + relink 后才由 persistRoute 一次提交目标四轴。进程若提前 - * 退出,内存 pending 虽丢失,重启也只会安全恢复源 route,不会跨来源 resume。 + * 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; @@ -61,6 +73,13 @@ export interface PendingCredentialSwitchPersistedRoute { fastMode?: boolean; } +interface PendingCredentialSwitchAtomicPersistedRoute { + model: string; + providerId: string | null; + effort: Effort; + fastMode: boolean; +} + export interface PendingCredentialSwitch { model: string; providerId: string | null; @@ -144,9 +163,9 @@ export interface PendingCredentialSwitchDeps { }>; /** * 把收口裁决后的实际 route 持久化(生产 = 直写 sessions 行 + 广播 - * sessions:patched)。跨 family relink 在此首次提交目标 route;其它 deferred 可能已由 - * renderer 按请求值落盘,若收口裁决改道仍必须回写实际值。缺席 = 只写内存 store - * (测试最小 harness)。 + * sessions:patched)。无 native thread 的切换及其它 deferred 会走这里;跨 family + * relink 的 route 已与 sdkSessionId 在 relink CAS 中提交,不再二次写。若收口裁决 + * 改道仍必须回写实际值。缺席 = 只写内存 store(测试最小 harness)。 */ persistRoute?: ( sessionId: string, @@ -158,8 +177,8 @@ export interface PendingCredentialSwitchDeps { }, ) => Promise; /** - * 把旧 Codex rollout 安全 fork 到目标来源使用的新 thread,并 CAS 替换持久化 - * sdkSessionId。失败时 pending 保留、输入队列继续 gated,等待自愈重试。 + * 把旧 Codex rollout 安全 fork 到目标来源使用的新 thread,并用同一 CAS 替换持久化 + * sdkSessionId + 完整最终 route。失败时 pending 保留、输入队列继续 gated。 */ relinkCodexThreadForProviderSwitch?: (input: { sessionId: string; @@ -169,6 +188,10 @@ export interface PendingCredentialSwitchDeps { targetModel: string; targetProviderId: string | null; isCurrent: () => boolean; + persistedRouteTransition: { + previous: PendingCredentialSwitchAtomicPersistedRoute; + next: PendingCredentialSwitchAtomicPersistedRoute; + }; }) => Promise; /** 自愈兜底重试间隔覆写(测试用)。 */ retryDelayMs?: number; @@ -544,6 +567,21 @@ export class PendingCredentialSwitchService { ): 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 兼容语义。 @@ -580,6 +618,36 @@ export class PendingCredentialSwitchService { 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, @@ -593,7 +661,9 @@ export class PendingCredentialSwitchService { 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', @@ -610,14 +680,14 @@ export class PendingCredentialSwitchService { this.discardIfOwnerStale( sessionId, target, - undefined, + resolvedRoute, receipt.newSdkSessionId, ); } else { await this.compensateSupersededPersistedRoute( sessionId, target, - undefined, + resolvedRoute, receipt, ); if (this.targetCurrent(sessionId, target)) this.scheduleRetry(sessionId); @@ -652,14 +722,14 @@ export class PendingCredentialSwitchService { this.discardIfOwnerStale( sessionId, target, - undefined, + persistedResolvedRoute, relinkReceipt.newSdkSessionId, ); } else { await this.compensateSupersededPersistedRoute( sessionId, target, - undefined, + persistedResolvedRoute, relinkReceipt, ); } @@ -670,24 +740,17 @@ export class PendingCredentialSwitchService { } } setSessionProvider(sessionId, resolved.providerId); - // 跨 family deferred 在登记时刻刻意让 DB 保持源 route;只有 relink 成功后才 - // 在这里提交目标 route。裁决改道也必须回写,否则下次懒 resume 会按旧值重建。 + // 跨 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; - const resolvedRoute: PendingCredentialSwitchPersistedRoute = { - providerId: resolved.providerId, - model: resolved.model ?? target.model, - ...(resolved.effort !== undefined ? { effort: resolved.effort } : {}), - ...(resolved.fastMode !== undefined ? { fastMode: resolved.fastMode } : {}), - }; - // Only a successful await proves this exact fallback tuple reached SQLite. If the owner - // expires in that window, stale cleanup must compare against it rather than the request. - let persistedResolvedRoute: PendingCredentialSwitchPersistedRoute | undefined; + // 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 期间被新选择替换时,旧写可能晚于新选择的登记/落盘; // 若新登记收口时因「裁决未改路由」跳过回写,DB 会留着旧 finalizer 的迟到值, @@ -699,6 +762,7 @@ export class PendingCredentialSwitchService { // 既无竞态也不能引入异步,保持只在路由改动时回写。 if ( this.deps.persistRoute && + !routeCommittedWithRelink && (routeChanged || this.deps.resolveRoute || shouldRelinkCodexThread || From 7f4795060423560f924788a64250a7a28d2fe4bc Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Fri, 28 Aug 2026 20:24:37 +0800 Subject: [PATCH 36/36] fix(desktop): stabilize Windows startup barrier Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- .../windowsPackagedInstanceBarrier.test.ts | 39 +++++- .../main/windowsPackagedInstanceBarrier.ts | 119 +++++++++++++----- 2 files changed, 126 insertions(+), 32 deletions(-) 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/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, };