Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -90,6 +90,52 @@ describe('applyRuntimeSetModelChange', () => {
expect(getSessionProvider(sessionId)).toBe('xd');
});

it('serializes route writes so an older failure cannot overwrite a newer switch', async () => {
const sessionId = rememberSession('runtime-set-model-concurrent-rollback');
setSessionProvider(sessionId, 'openai');
let rejectFirst!: (error: Error) => void;
const firstGate = new Promise<void>((_resolve, reject) => {
rejectFirst = reject;
});
let callCount = 0;
const setModel = vi.fn(() => {
callCount += 1;
return callCount === 1 ? firstGate : Promise.resolve();
});
const writes: Array<string | null> = [];
sessionProviderWriteObserver.current = (writtenSessionId, providerId) => {
if (writtenSessionId === sessionId) writes.push(providerId);
};
const maker: RuntimeSetModelMaker = {
getSession: () => ({
agentKind: 'codex',
remoteHostId: 'remote-1',
model: 'model-a',
setModel,
}),
listActiveSessions: () => [],
closeSession: vi.fn(async () => {}),
};

const firstSwitch = applyRuntimeSetModelChange({ maker, sessionId, model: 'model-b', providerId: 'xd' });
const firstFailure = expect(firstSwitch).rejects.toThrow('first switch failed');
await vi.waitFor(() => expect(setModel).toHaveBeenCalledTimes(1));
const secondSwitch = applyRuntimeSetModelChange({ maker, sessionId, model: 'model-c', providerId: 'xai' });
await Promise.resolve();
expect(setModel).toHaveBeenCalledTimes(1);
expect(getSessionProvider(sessionId)).toBe('xd');

rejectFirst(new Error('first switch failed'));
await firstFailure;
await secondSwitch;
expect(setModel.mock.calls).toEqual([
['model-b', { providerId: 'xd' }],
['model-c', { providerId: 'xai' }],
]);
expect(writes).toEqual(['xd', 'openai', 'xai']);
expect(getSessionProvider(sessionId)).toBe('xai');
});

it('keeps a successful provider route change after live setModel succeeds', async () => {
const sessionId = rememberSession('runtime-set-model-success');
const setModel = vi.fn(async () => {});
Expand Down
30 changes: 30 additions & 0 deletions apps/desktop/src/main/maker-ipc/runtimeSetModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,29 @@ export function isRemoteModelSwitchRouteChangeError(error: unknown): boolean {
);
}

const runtimeSetModelChangeLocks = new Map<string, Promise<void>>();

async function withRuntimeSetModelChangeLock<T>(
sessionId: string,
run: () => Promise<T>,
): Promise<T> {
const previous = runtimeSetModelChangeLocks.get(sessionId) ?? Promise.resolve();
let release!: () => void;
const current = new Promise<void>((resolve) => {
release = resolve;
});
runtimeSetModelChangeLocks.set(sessionId, current);
await previous;
try {
return await run();
} finally {
release();
if (runtimeSetModelChangeLocks.get(sessionId) === current) {
runtimeSetModelChangeLocks.delete(sessionId);
}
}
}

/**
* 应用本地运行时 model/provider 切换。
*
Expand All @@ -126,6 +149,13 @@ export function isRemoteModelSwitchRouteChangeError(error: unknown): boolean {
*/
export async function applyRuntimeSetModelChange(
input: ApplyRuntimeSetModelChangeInput,
): Promise<ApplyRuntimeSetModelChangeResult> {
return withRuntimeSetModelChangeLock(input.sessionId, () =>
applyRuntimeSetModelChangeUnlocked(input));
}

async function applyRuntimeSetModelChangeUnlocked(
input: ApplyRuntimeSetModelChangeInput,
): Promise<ApplyRuntimeSetModelChangeResult> {
const { maker, sessionId, model, providerId, effort, isSessionInTurn, logger } = input;
const normalizedProviderId = normalizeSessionProviderId(providerId);
Expand Down
Loading