Skip to content
Open
Show file tree
Hide file tree
Changes from 35 commits
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 @@ -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(
Expand Down Expand Up @@ -548,6 +552,8 @@ describe('sendToSession ordering', () => {
);
expect(setModelBlock).toContain('pendingCredentialSwitchHolder?.clear(sessionId);');
expect(setModelBlock).toContain('restoreControlStores();');
expect(setModelBlock).toContain('const hadRelink = appliedCodexThreadRelink !== undefined;');
expect(setModelBlock).toContain('await recoverRuntimeAfterSelectionRollback({');
expect(setModelBlock).toContain('previousRuntime.pendingCredentialSwitch');
expect(setModelBlock).toContain('withRehydrateCloseSuppressed(sessionId');
expect(setModelBlock).toContain('recordRecoveredSessionRuntimeMutation(sessionId');
Expand Down
374 changes: 374 additions & 0 deletions apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,40 @@ const mocks = vi.hoisted(() => ({
getDesktopCcPrefs: vi.fn<() => DesktopCcPrefs | null>(() => null),
resolveLenientSessionRoute: vi.fn(),
applyRuntimeSetModelChange:
vi.fn<(input: unknown) => Promise<{ status: 'applied' | 'deferred' }>>(),
vi.fn<
(input: unknown) => Promise<
| {
status: 'applied';
codexThreadRelink?: {
previousSdkSessionId: string;
newSdkSessionId: string;
rollback: () => Promise<boolean>;
};
}
| { status: 'deferred'; preservePersistedRoute?: true }
>
>(),
relinkCodexThreadForCredentialSwitch: vi.fn<
(input: unknown) => Promise<{
previousSdkSessionId: string;
newSdkSessionId: string;
rollback: () => Promise<boolean>;
} | null>
>(async () => null),
registerPendingCredentialSwitchForSession: vi.fn(),
clearPendingCredentialSwitchForSession: vi.fn(),
wakeSessionInputAfterCredentialSwitch: vi.fn(),
getPendingCredentialSwitchTarget: vi.fn(() => undefined),
readModelRouteSnapshot: vi.fn(
async (): Promise<{ model: string; effort: string; providerId: string | null } | null> => null,
async (): Promise<{
agentKind: 'codex' | 'pi' | 'claude-code';
remoteHostId: string | null;
sdkSessionId: string | null;
model: string;
effort: string;
fastMode?: boolean;
providerId: string | null;
} | null> => null,
),
readPermissionMode: vi.fn(async () => 'auto'),
updatePermissionMode: vi.fn(async () => {}),
Expand Down Expand Up @@ -97,6 +124,7 @@ vi.mock('../../../maker-ipc/runtimeSetModel', () => ({
vi.mock('../../../maker-ipc/register', () => ({
cancelPendingAgentSwitchForSession: mocks.cancelPendingAgentSwitchForSession,
isSessionInTurn: mocks.isSessionInTurn,
relinkCodexThreadForCredentialSwitch: mocks.relinkCodexThreadForCredentialSwitch,
registerPendingCredentialSwitchForSession: mocks.registerPendingCredentialSwitchForSession,
clearPendingCredentialSwitchForSession: mocks.clearPendingCredentialSwitchForSession,
wakeSessionInputAfterCredentialSwitch: mocks.wakeSessionInputAfterCredentialSwitch,
Expand Down Expand Up @@ -646,6 +674,16 @@ describe('model:pick 持久化失败', () => {
});

it('busy provider 切换注入 pending hooks,并在 deferred 时不 mid-turn 改 effort', async () => {
const sourceSnapshot = {
agentKind: 'codex' as const,
remoteHostId: null,
sdkSessionId: 'thread-xd',
model: 'gpt-5.4',
effort: 'high',
fastMode: false,
providerId: 'xd',
};
mocks.readModelRouteSnapshot.mockResolvedValueOnce(sourceSnapshot);
const live = {
agentKind: 'codex',
remoteHostId: null,
Expand All @@ -658,12 +696,21 @@ describe('model:pick 持久化失败', () => {
const hooks = input as {
registerPendingCredentialSwitch?: (
sessionId: string,
target: { model: string; providerId: string | null },
target: {
model: string;
providerId: string | null;
effort?: string;
sourcePersistedSession?: unknown;
},
) => void;
persistedSession?: unknown;
effort?: string;
};
hooks.registerPendingCredentialSwitch?.('sess-target', {
model: 'claude-opus-4-7',
providerId: 'anthropic',
effort: hooks.effort,
sourcePersistedSession: hooks.persistedSession,
});
return { status: 'deferred' as const };
});
Expand All @@ -679,18 +726,88 @@ describe('model:pick 持久化失败', () => {
getPendingCredentialSwitch: mocks.getPendingCredentialSwitchTarget,
}),
);
expect(mocks.registerPendingCredentialSwitchForSession).toHaveBeenCalledWith('sess-target', {
model: 'claude-opus-4-7',
providerId: 'anthropic',
});
expect(mocks.registerPendingCredentialSwitchForSession).toHaveBeenCalledWith(
'sess-target',
expect.objectContaining({
model: 'claude-opus-4-7',
providerId: 'anthropic',
effort: 'high',
sourcePersistedSession: sourceSnapshot,
}),
);
expect(live.setEffort).not.toHaveBeenCalled();
expect(im.updateInteractiveCard).toHaveBeenCalledWith(
'model-card',
expect.objectContaining({ body: slackUi.cards.model.resolved('Opus 4.7', 'high') }),
);
});

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<typeof receipt | null>;
};
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,
Expand All @@ -700,6 +817,7 @@ describe('model:pick 持久化失败', () => {
};
(turnRunner.getMakerSessionById as ReturnType<typeof vi.fn>).mockReturnValue(live);
mocks.readModelRouteSnapshot.mockResolvedValueOnce({
agentKind: 'claude-code', remoteHostId: null, sdkSessionId: null,
model: 'claude-sonnet-4-6',
effort: 'medium',
providerId: 'openrouter',
Expand All @@ -715,19 +833,23 @@ describe('model:pick 持久化失败', () => {
'high',
'anthropic',
);
expect(mocks.applyRuntimeSetModelChange).not.toHaveBeenCalled();
expect(mocks.applyRuntimeSetModelChange).toHaveBeenCalledOnce();
expect(mocks.cancelPendingAgentSwitchForSession).not.toHaveBeenCalled();
expect(mocks.setSessionProvider).not.toHaveBeenCalled();
expect(live.setModel).not.toHaveBeenCalled();
expect(live.setEffort).not.toHaveBeenCalled();
expect(mocks.clearPendingCredentialSwitchForSession).toHaveBeenCalledWith('sess-target');
expect(mocks.setSessionProvider).toHaveBeenCalledWith('sess-target', 'openrouter');
expect(live.setModel).toHaveBeenCalledWith('claude-sonnet-4-6', {
providerId: 'openrouter',
});
expect(live.setEffort).toHaveBeenCalledWith('medium');
expect(im.updateInteractiveCard).toHaveBeenCalledWith(
'model-card',
expect.objectContaining({ body: slackUi.cards.model.failed('db locked') }),
);
});

it('runtime setModel 失败时恢复已落盘的旧路由', async () => {
it('runtime setModel 失败时不预写目标路由', async () => {
mocks.readModelRouteSnapshot.mockResolvedValueOnce({
agentKind: 'claude-code', remoteHostId: null, sdkSessionId: null,
model: 'claude-sonnet-4-6',
effort: 'medium',
providerId: 'openrouter',
Expand All @@ -737,19 +859,18 @@ describe('model:pick 持久化失败', () => {

await pressModelPick(im);

expect(mocks.updateModelEffort).toHaveBeenNthCalledWith(
1,
'sess-target',
'claude-opus-4-7',
'high',
'anthropic',
);
expect(mocks.updateModelEffort).toHaveBeenNthCalledWith(
2,
'sess-target',
'claude-sonnet-4-6',
'medium',
'openrouter',
expect(mocks.updateModelEffort).not.toHaveBeenCalled();
expect(mocks.applyRuntimeSetModelChange).toHaveBeenCalledWith(
expect.objectContaining({
persistedSession: {
agentKind: 'claude-code',
remoteHostId: null,
sdkSessionId: null,
model: 'claude-sonnet-4-6',
effort: 'medium',
providerId: 'openrouter',
},
}),
);
expect(im.updateInteractiveCard).toHaveBeenCalledWith(
'model-card',
Expand All @@ -771,6 +892,7 @@ describe('model:pick 持久化失败', () => {
};
(turnRunner.getMakerSessionById as ReturnType<typeof vi.fn>).mockReturnValue(live);
mocks.readModelRouteSnapshot.mockResolvedValueOnce({
agentKind: 'claude-code', remoteHostId: null, sdkSessionId: null,
model: 'claude-sonnet-4-6',
effort: 'medium',
providerId: 'openrouter',
Expand Down Expand Up @@ -819,6 +941,7 @@ describe('model:pick 持久化失败', () => {
};
(turnRunner.getMakerSessionById as ReturnType<typeof vi.fn>).mockReturnValue(live);
mocks.readModelRouteSnapshot.mockResolvedValueOnce({
agentKind: 'claude-code', remoteHostId: null, sdkSessionId: null,
model: 'claude-sonnet-4-6',
effort: 'medium',
providerId: null,
Expand Down
60 changes: 50 additions & 10 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 @@ -373,29 +374,69 @@ export function createCardActionHandler(
}
};

// 持久化与运行态切换必须和 send / agent switch 共用 session 锁,保证后选覆盖先选。
try {
await updateModelEffort(sessionId, modelId, effort ?? 'high', providerId);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
log.error(`model:pick DB update failed: ${msg}`);
return msg;
}

try {
const relinkWithPersistedRoute = async (
relinkInput: Parameters<typeof relinkCodexThreadForCredentialSwitch>[0],
) => {
if (!previousRoute) {
throw new Error('IM Codex thread relink requires a persisted source route snapshot');
}
return relinkCodexThreadForCredentialSwitch({
...relinkInput,
persistedRouteTransition: {
previous: {
model: previousRoute.model,
providerId: previousRoute.providerId,
effort: previousRoute.effort,
fastMode: previousRoute.fastMode,
},
next: {
model: relinkInput.targetModel,
providerId: relinkInput.targetProviderId,
effort: effort ?? 'high',
fastMode: previousRoute.fastMode,
},
},
});
};
const runtimeChange = await applyRuntimeSetModelChange({
maker: getMaker(),
sessionId,
model: modelId,
providerId,
...(effort !== null ? { effort } : {}),
...(previousRoute ? { persistedSession: previousRoute } : {}),
isSessionInTurn,
registerPendingCredentialSwitch: registerPendingCredentialSwitchForSession,
clearPendingCredentialSwitch: clearPendingCredentialSwitchForSession,
wakeSessionInputQueue: wakeSessionInputAfterCredentialSwitch,
getPendingCredentialSwitch: getPendingCredentialSwitchTarget,
relinkCodexThreadForProviderSwitch: relinkWithPersistedRoute,
logger: log,
});

const preservePersistedRoute =
runtimeChange.status === 'deferred' &&
runtimeChange.preservePersistedRoute === true;
const routePersistedWithRelink =
runtimeChange.status === 'applied' && runtimeChange.codexThreadRelink !== undefined;
if (!preservePersistedRoute && !routePersistedWithRelink) {
try {
// Non-relink selections persist only after runtime acceptance. Cross-family Codex
// relinks use the single SQLite CAS above, so a crash observes either the complete
// source tuple or the complete target tuple, never a mixed route/thread pair.
await updateModelEffort(sessionId, modelId, effort ?? 'high', providerId);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
log.error(`model:pick DB update failed: ${msg}`);
clearPendingCredentialSwitchForSession(sessionId);
if (runtimeChange.status !== 'deferred') {
await rollbackRuntimeChange('DB persistence');
}
return msg;
}
}

const liveAfterModel = turnRunner.getMakerSessionById(sessionId);
if (runtimeChange.status !== 'deferred' && liveAfterModel && effort) {
try {
Expand All @@ -411,7 +452,6 @@ export function createCardActionHandler(
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
log.warn(`model:pick runtime setModel failed: ${msg}`);
await restorePersistentRoute('runtime setModel');
return msg;
}

Expand Down
Loading