Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
e5dd4d7
fix(desktop): relink Codex thread across providers
zqchris Aug 27, 2026
239d279
fix(desktop): close Codex relink races
zqchris Aug 28, 2026
3cb26b1
fix(desktop): roll back stale Codex relinks
zqchris Aug 28, 2026
4c9b5c3
Merge remote-tracking branch 'upstream/main' into codex-provider-thre…
zqchris Aug 28, 2026
fe2aa3b
fix(desktop): relink persisted Codex threads without live handles
zqchris Aug 28, 2026
7319ce0
fix(desktop): recalculate deferred Codex thread relink
zqchris Aug 28, 2026
8fc9608
fix(codex): preserve committed relink recovery state
zqchris Aug 28, 2026
ad849fa
fix(codex): roll back relink across owner changes
zqchris Aug 28, 2026
7b74e31
fix(codex): fence relink during app teardown
zqchris Aug 28, 2026
88dcd72
fix(codex): discard stale owner pending switches
zqchris Aug 28, 2026
c0d129a
fix(desktop): roll back persisted Codex route atomically
zqchris Aug 28, 2026
7337ac6
fix(desktop): compensate stale pending Codex routes
zqchris Aug 28, 2026
31b9c02
fix(desktop): restore live runtime after route rollback
zqchris Aug 28, 2026
45e8e96
fix(desktop): preserve IM switch source route
zqchris Aug 28, 2026
9f5c3a9
fix(desktop): compensate resolved pending route
zqchris Aug 28, 2026
1c3763d
fix(desktop): reconcile retained runtime after close failure
zqchris Aug 28, 2026
9e4a695
fix(desktop): compensate stale pending registration
zqchris Aug 28, 2026
feccf41
fix(desktop): gate deferred relink on close failure
zqchris Aug 28, 2026
0f75b86
fix(desktop): keep deferred relink route restart-safe
zqchris Aug 28, 2026
ee44907
fix(desktop): compensate unpersisted relink failures
zqchris Aug 28, 2026
7bb48a6
test(desktop): guard deferred switch registration
zqchris Aug 28, 2026
8f4d246
test(desktop): preload Codex proxy host suite
zqchris Aug 28, 2026
821566a
fix(desktop): complete deferred remote selections
zqchris Aug 28, 2026
6f3ef30
fix(desktop): compensate superseded deferred routes
zqchris Aug 28, 2026
53f5125
fix(desktop): restore cancelled deferred runtime route
zqchris Aug 28, 2026
78876ec
fix(desktop): await deferred switch restoration
zqchris Aug 28, 2026
de01489
fix(desktop): retry stale owner route compensation
zqchris Aug 28, 2026
12b3750
fix(desktop): await hot switch pending recovery
zqchris Aug 28, 2026
04663bf
fix(desktop): atomically persist IM Codex relinks
zqchris Aug 28, 2026
a1bdaa9
fix(desktop): atomically restore stale Codex routes
zqchris Aug 28, 2026
0fc8d8e
fix(desktop): cancel failed deferred runtime switches
zqchris Aug 28, 2026
06f8b79
fix(desktop): atomically persist immediate Codex relinks
zqchris Aug 28, 2026
013cc17
fix(desktop): retain post-commit relink compensation
zqchris Aug 28, 2026
75ef786
fix(desktop): isolate relink compensation retries
zqchris Aug 28, 2026
2792222
fix(desktop): persist non-user Codex relink routes
zqchris Aug 28, 2026
2324134
fix(desktop): atomically persist deferred Codex relinks
zqchris Aug 28, 2026
7f47950
fix(desktop): stabilize Windows startup barrier
zqchris Aug 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/im/shared/cardActionHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
clearPendingCredentialSwitchForSession,
getPendingCredentialSwitchTarget,
isSessionInTurn,
relinkCodexThreadForCredentialSwitch,
registerPendingCredentialSwitchForSession,
withSendToSessionLock,
wakeSessionInputAfterCredentialSwitch,
Expand Down Expand Up @@ -393,6 +394,7 @@ export function createCardActionHandler(
clearPendingCredentialSwitch: clearPendingCredentialSwitchForSession,
wakeSessionInputQueue: wakeSessionInputAfterCredentialSwitch,
getPendingCredentialSwitch: getPendingCredentialSwitchTarget,
relinkCodexThreadForProviderSwitch: relinkCodexThreadForCredentialSwitch,
Comment thread
zqchris marked this conversation as resolved.
Outdated
logger: log,
});

Expand Down
7 changes: 4 additions & 3 deletions apps/desktop/src/main/maker-host/codex-credential-switch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 会把供应商私有历史
// 带到另一条路由)。这有意收窄了
Comment thread
zqchris marked this conversation as resolved.
// 方案 A 的「oauth 超集 host 热切 gateway-key / provider-oauth 会话」范围:
// host 仍复用不重建,只是该会话自身要走关闭重建。隐式来源解析不出家族且
// 未提供 codexAuthInjection 时按未知处理 → 与另一侧不同即保守关闭。
Expand All @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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/);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ function createHarness(
const persistRoute = vi.fn<NonNullable<PendingCredentialSwitchDeps['persistRoute']>>(
async () => {},
);
const relinkCodexThreadForProviderSwitch = vi.fn<
NonNullable<PendingCredentialSwitchDeps['relinkCodexThreadForProviderSwitch']>
>(async () => {});
const service = new PendingCredentialSwitchService({
maker: {
listActiveSessions: () => sessions,
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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');
Expand Down
Loading