fix(server): defer runtime-config restart until session is idle

In-progress generations were interrupted when a set_runtime_config
arrived mid-stream: the handler immediately stopped and restarted the
CLI subprocess. Track per-session busy state from outbound status
events and queue the restart, applying it (with the latest override,
collapsing multiple toggles) once the session returns to idle.

Bumps desktop app to 0.5.1 with matching release notes.

Tested: desktop tsc --noEmit; Windows x64 NSIS package + package-smoke.
Not-tested: check:server, desktop Vitest.
Scope-risk: narrow
Confidence: medium
This commit is contained in:
你的姓名 2026-06-08 02:18:00 +08:00
parent 1923d5553d
commit d88c10c2df
3 changed files with 81 additions and 3 deletions

View File

@ -1,7 +1,7 @@
{
"name": "claude-code-desktop",
"private": true,
"version": "0.4.0",
"version": "0.5.1",
"description": "Desktop coding agent workbench for Claude Code Haha.",
"homepage": "https://github.com/NanmiCoder/cc-haha",
"author": {

20
release-notes/v0.5.1.md Normal file
View File

@ -0,0 +1,20 @@
# v0.5.1
## 修复
- **流式生成进行中切换运行时配置不再打断当前回复**modelId / effortLevel / 会话级思考开关)。
此前任何 `set_runtime_config` 在会话忙时都会立刻 `stopSession` 并重启 CLI 子进程,
导致正在生成中的回复被截断。新增按会话的 busy 跟踪与"等待 idle 再 apply"的延迟队列:
会话忙时仅记录最新覆盖值,转入 idle 后再统一执行一次重启,期间多次切换会自然合并。
## 范围
- 修改:`src/server/ws/handler.ts`(新增 `sessionGenerationBusy` 跟踪 + 延迟重启队列,
`handleSetRuntimeConfig` 的两处重启改为 `scheduleRestartSessionWithRuntimeConfig`)。
- 仅服务端逻辑变更UI / IPC / 持久化 schema 均未改动。
## 验证
- `cd desktop && bun run lint`tsc --noEmit
- 本地 Windows x64 NSIS 打包(`desktop/scripts/build-windows-x64.ps1`+ package-smoke ✅
- Not-tested`bun run check:server`、desktop Vitest 未在本次执行;建议合并前补跑。

View File

@ -94,6 +94,19 @@ const prewarmIdleTimers = new Map<string, ReturnType<typeof setTimeout>>()
const DEFAULT_PREWARM_IDLE_TIMEOUT_MS = 5 * 60_000
const VALID_EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'max'])
// Track whether a session is currently mid-turn (anything other than 'idle').
// Updated inside sendMessage by observing outbound `status` events. Used to
// avoid yanking the CLI subprocess out from under an in-progress generation
// when the user changes runtime config (model / effort / thinking) mid-stream.
const sessionGenerationBusy = new Set<string>()
// When a runtime config change arrives while the session is busy, we record
// the WS that should drive the eventual restart and apply it on the next
// transition to 'idle'. The actual override values are read from
// `runtimeOverrides` at restart time, so a later set_runtime_config naturally
// supersedes an earlier deferred one.
const pendingDeferredRuntimeRestarts = new Map<string, ServerWebSocket<WebSocketData>>()
async function sendRepositoryStartupStatus(
ws: ServerWebSocket<WebSocketData>,
sessionId: string,
@ -632,7 +645,7 @@ async function handleSetRuntimeConfig(
if (conversationService.hasSession(sessionId)) {
await enqueueRuntimeTransition(sessionId, async () => {
await persistSessionRuntimeConfig(sessionId, nextOverride)
await restartSessionWithRuntimeConfig(ws, sessionId)
await scheduleRestartSessionWithRuntimeConfig(ws, sessionId)
})
return
}
@ -659,7 +672,7 @@ async function handleSetRuntimeConfig(
) {
return
}
await restartSessionWithRuntimeConfig(ws, sessionId)
await scheduleRestartSessionWithRuntimeConfig(ws, sessionId)
})
return
}
@ -1094,6 +1107,8 @@ function cleanupSessionRuntimeState(sessionId: string) {
runtimeTransitionPromises.delete(sessionId)
sessionStartupPromises.delete(sessionId)
lastResolvedStartupWorkDirs.delete(sessionId)
sessionGenerationBusy.delete(sessionId)
pendingDeferredRuntimeRestarts.delete(sessionId)
clearPrewarmState(sessionId)
}
@ -1792,9 +1807,52 @@ function toApiRetryServerMessage(cliMsg: any): ServerMessage | null {
}
function sendMessage(ws: ServerWebSocket<WebSocketData>, message: ServerMessage) {
// Track per-session generation busy state by observing outbound `status`
// events. This lets handleSetRuntimeConfig defer a restart that would
// otherwise kill the CLI subprocess in the middle of a streaming response.
const sessionId = ws.data.sessionId
if (sessionId && message.type === 'status') {
if (message.state === 'idle') {
const wasBusy = sessionGenerationBusy.delete(sessionId)
if (wasBusy) drainPendingRuntimeRestart(sessionId)
} else {
sessionGenerationBusy.add(sessionId)
}
}
ws.send(JSON.stringify(message))
}
// Apply a pending deferred runtime restart that was queued while the session
// was busy. Reads the latest override from `runtimeOverrides` at restart time,
// so multiple toggles during streaming naturally collapse into a single
// restart with the most recent config.
function drainPendingRuntimeRestart(sessionId: string) {
const ws = pendingDeferredRuntimeRestarts.get(sessionId)
if (!ws) return
pendingDeferredRuntimeRestarts.delete(sessionId)
if (!conversationService.hasSession(sessionId)) return
void enqueueRuntimeTransition(sessionId, async () => {
if (!conversationService.hasSession(sessionId)) return
await restartSessionWithRuntimeConfig(ws, sessionId)
})
}
// Schedule a runtime-config restart, but defer until the session is idle to
// avoid interrupting an in-progress generation. The override values are
// already in `runtimeOverrides[sessionId]` (and persisted) before this is
// called, so getRuntimeSettings will read them when the deferred restart
// finally runs.
async function scheduleRestartSessionWithRuntimeConfig(
ws: ServerWebSocket<WebSocketData>,
sessionId: string,
): Promise<void> {
if (sessionGenerationBusy.has(sessionId)) {
pendingDeferredRuntimeRestarts.set(sessionId, ws)
return
}
await restartSessionWithRuntimeConfig(ws, sessionId)
}
function sendError(ws: ServerWebSocket<WebSocketData>, message: string, code: string) {
sendMessage(ws, { type: 'error', message, code })
}