fix: defer runtime restarts during active turns (#626)

Avoid killing an in-flight desktop SDK turn when runtime config changes during tool execution. Persist the requested runtime immediately, then restart after the turn emits its terminal result.

Tested:
- bun run check:server
- real provider tool-turn model switch smoke
- bun run check:coverage (changed-line coverage passed; unrelated global lanes still fail)

Scope-risk: narrow
This commit is contained in:
程序员阿江(Relakkes)
2026-06-09 21:05:51 +08:00
parent 3ff6a79e62
commit 385b996736
2 changed files with 494 additions and 16 deletions
+81 -3
View File
@@ -76,11 +76,19 @@ const sessionTitleState = new Map<string, {
generationSeq: number
}>()
const runtimeOverrides = new Map<string, {
type RuntimeOverride = {
providerId: string | null
modelId: string
effort?: string
}>()
}
type ActiveUserTurnState = {
messageSent: boolean
}
const runtimeOverrides = new Map<string, RuntimeOverride>()
const activeUserTurns = new Map<string, ActiveUserTurnState>()
const deferredRuntimeRestarts = new Map<string, RuntimeOverride>()
const runtimeTransitionPromises = new Map<string, Promise<void>>()
const sessionStartupPromises = new Map<string, Promise<void>>()
@@ -306,8 +314,14 @@ async function handleUserMessage(
// Send thinking status
sendMessage(ws, { type: 'status', state: 'thinking', verb: 'Thinking' })
const activeTurn: ActiveUserTurnState = { messageSent: false }
activeUserTurns.set(sessionId, activeTurn)
const initialRuntimeTransition = await waitForRuntimeTransitionBeforeUserTurn(ws, sessionId)
if (!initialRuntimeTransition.ok) return
if (!initialRuntimeTransition.ok) {
clearActiveUserTurn(sessionId, activeTurn)
return
}
if (initialRuntimeTransition.waited) {
sendMessage(ws, { type: 'status', state: 'thinking', verb: 'Thinking' })
}
@@ -357,6 +371,7 @@ async function handleUserMessage(
err instanceof ConversationStartupError ? err.retryable : false,
})
sendMessage(ws, { type: 'status', state: 'idle' })
clearActiveUserTurn(sessionId, activeTurn)
return
}
@@ -366,6 +381,7 @@ async function handleUserMessage(
sendMessage(ws, { type: 'status', state: 'thinking', verb: 'Thinking' })
}
} else {
clearActiveUserTurn(sessionId, activeTurn)
return
}
@@ -387,6 +403,7 @@ async function handleUserMessage(
return shouldForwardCurrentTurnLocalCommand(cliMsg)
},
})
const removeActiveTurnOutputCallback = bindActiveUserTurnCompletion(ws, sessionId, activeTurn)
const sent = await conversationService.sendMessage(
sessionId,
@@ -394,6 +411,8 @@ async function handleUserMessage(
message.attachments
)
if (!sent) {
removeActiveTurnOutputCallback()
clearActiveUserTurn(sessionId, activeTurn)
removeTitleOutputCallback?.()
discardActiveTitleTurn(sessionId, titleTurnNumber)
sendMessage(ws, {
@@ -406,6 +425,57 @@ async function handleUserMessage(
}
userMessageSent = true
activeTurn.messageSent = true
}
function clearActiveUserTurn(sessionId: string, activeTurn: ActiveUserTurnState): void {
if (activeUserTurns.get(sessionId) === activeTurn) {
activeUserTurns.delete(sessionId)
}
}
function bindActiveUserTurnCompletion(
ws: ServerWebSocket<WebSocketData>,
sessionId: string,
activeTurn: ActiveUserTurnState,
): () => void {
const callback = (cliMsg: any) => {
if (!activeTurn.messageSent || cliMsg?.type !== 'result') return
conversationService.removeOutputCallback(sessionId, callback)
clearActiveUserTurn(sessionId, activeTurn)
applyDeferredRuntimeRestartAfterActiveTurn(ws, sessionId)
}
conversationService.onOutput(sessionId, callback)
return () => conversationService.removeOutputCallback(sessionId, callback)
}
function shouldDeferRuntimeRestartForActiveTurn(sessionId: string): boolean {
return activeUserTurns.get(sessionId)?.messageSent === true
}
function applyDeferredRuntimeRestartAfterActiveTurn(
ws: ServerWebSocket<WebSocketData>,
sessionId: string,
): void {
const deferred = deferredRuntimeRestarts.get(sessionId)
if (!deferred) return
deferredRuntimeRestarts.delete(sessionId)
void enqueueRuntimeTransition(sessionId, async () => {
const currentOverride = runtimeOverrides.get(sessionId)
if (
!currentOverride ||
currentOverride.providerId !== deferred.providerId ||
currentOverride.modelId !== deferred.modelId ||
currentOverride.effort !== deferred.effort ||
!conversationService.hasSession(sessionId)
) {
return
}
await restartSessionWithRuntimeConfig(ws, sessionId)
})
}
async function handleDesktopClearCommand(
@@ -621,6 +691,12 @@ async function handleSetRuntimeConfig(
(runtimeOverrideVersions.get(sessionId) ?? 0) + 1,
)
if (shouldDeferRuntimeRestartForActiveTurn(sessionId)) {
deferredRuntimeRestarts.set(sessionId, nextOverride)
await persistSessionRuntimeConfig(sessionId, nextOverride)
return
}
if (conversationService.hasSession(sessionId)) {
await enqueueRuntimeTransition(sessionId, async () => {
await persistSessionRuntimeConfig(sessionId, nextOverride)
@@ -1081,6 +1157,8 @@ function cleanupSessionRuntimeState(sessionId: string) {
sessionSlashCommands.delete(sessionId)
sessionTitleState.delete(sessionId)
runtimeOverrides.delete(sessionId)
activeUserTurns.delete(sessionId)
deferredRuntimeRestarts.delete(sessionId)
runtimeTransitionPromises.delete(sessionId)
sessionStartupPromises.delete(sessionId)
lastResolvedStartupWorkDirs.delete(sessionId)