fix(webui): Preserve active sessions during restore - #8824
Conversation
E2E test report — transactional WebUI session switchingValidated on macOS 26.4.1 arm64 with Node.js 22.22.3 and npm 10.9.8 after rebasing onto Build and static verification:
Focused integration command: cd integration-tests
NODE_OPTIONS=--no-experimental-webstorage npx vitest run cli/qwen-serve-webui-live-journal-recovery.test.ts --silent=passed-onlyResult: 3/3 passed in 18.66 seconds.
|
🖼️ web-shell visual previewRendered against a mock daemon (no real backend): the PR base vs this PR head Screenshots · before / afterℹ️ No screenshot changed against the PR base — but this PR edits 5 render-shaping files:
Either the change has no visual effect (logic, plumbing, a state the scenarios never reach), or no scenario renders this UI — in which case the preview cannot see it, and an empty result is a coverage gap rather than a clean bill of health. To make it visible, add a scenario to Full-resolution recordings (.webm) are attached to the workflow run. — Qwen Code · web-shell visuals |
Code Coverage Summary
CLI Package - Full Text ReportCore Package - Full Text ReportFor detailed HTML reports, please see the 'coverage-reports-22.x-ubuntu-latest' artifact from the main CI run. |
Keep the committed WebUI session live while a replacement restore is prepared, then switch ownership atomically after replay staging succeeds. Preserve the active session across target failures, superseded requests, and controlled workspace resolution, with transition-aware WebShell gating and focused coverage. Refs #8678 Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
67a2802 to
ba326b6
Compare
|
Please do not rebase or force-push to an active PR as it invalidates existing review comments. Note for future reference, the bots always squash all changes into a single commit automatically as part of the integration. 中文请勿对活跃的 PR 执行 rebase 或 force-push,因为这会使已有的评审评论失效。另外,供日后参考:作为集成流程的一部分,机器人始终会自动将所有改动压缩(squash)为单个提交。 |
E2E test report — transactional WebUI session switching (PR3a)Validated on macOS 26.4.1 arm64 with Node.js 22.22.3 and npm 10.9.8 after rebasing onto Build and static verification:
Focused integration command: cd integration-tests
npx vitest run cli/qwen-serve-webui-live-journal-recovery.test.ts --silent=passed-onlyResult after the final full build: 3/3 passed in 31.79 seconds.
The approximately 80 MiB manual benchmark was not rerun: PR3a makes an ownership/state-safety guarantee, not a restore latency or memory improvement claim. Branch creation/adoption and its SDK timeout contract were removed from this split and are deferred to PR3b. |
|
Superseded by the split PR3 series. #8833 now contains the independently releasable PR3a attachment-identity hardening (4 WebUI files, no transaction/WebShell/SDK/daemon changes). The remaining ordinary/controlled transaction, resync/repair, and branch adoption work will be submitted as separate PR3b/PR3c/PR3d follow-ups. The full prototype and its review history remain available here for reference. |
wenshao
left a comment
There was a problem hiding this comment.
Not reviewed: reverse-audit — one reverse-audit launch never opened the diff (coverage gate exit 3; the specific scope could not be identified from the truncated record).
Not explored to full depth (tool budget reached): chunk 4: I did not run the test suite or typecheck — verification was by code reading only.; chunk 8: test execution — the worktree has no node_modules , so I could not run DaemonSessionProvider.test.tsx to confirm the two new tests pass at runtime.; chunk 9: test execution blocked by incomplete worktree node_modules (npm install not run for this review worktree); all behavioral verification done by source cross-read…; chunk 16: could not run npx vitest run src/daemon/session/actions.test.ts — the worktree has no node_modules and resolution falls back to the parent repo, where enti…; You are review agent invariant-b — Invariant agent B: c...: full cross-file audit of loadMoreTranscript pagination-latch consumers.**, and 1 more.
Not reviewed: You are review agent reverse-audit — Reverse audit agen... — pointed at diff lines it never opened: it made tool calls, but none of them read the diff.
Not reviewed: reverse audit — an auditor ran and opened its brief, but no agent was launched with the prompt the CLI built — the launch was written by hand, and what the agent was actually asked is not what this skill certifies.
中文说明
未审查:reverse-audit — one reverse-audit launch never opened the diff (coverage gate exit 3; the specific scope could not be identified from the truncated record)。
未探索到全部深度(达到工具调用预算):chunk 4:I did not run the test suite or typecheck — verification was by code reading only.;chunk 8:test execution — the worktree has no node_modules , so I could not run DaemonSessionProvider.test.tsx to confirm the two new tests pass at runtime.;chunk 9:test execution blocked by incomplete worktree node_modules (npm install not run for this review worktree); all behavioral verification done by source cross-read…;chunk 16:could not run npx vitest run src/daemon/session/actions.test.ts — the worktree has no node_modules and resolution falls back to the parent repo, where enti…;You are review agent invariant-b — Invariant agent B: c...:full cross-file audit of loadMoreTranscript pagination-latch consumers.**,另有 1 条。
未审查:You are review agent reverse-audit — Reverse audit agen...——它被指向 diff 的行却从未打开:有工具调用,但没有一次读取 diff。
未审查:反向审计——有审计 agent 运行并打开了自己的 brief,但没有 agent 是用 CLI 构建的 prompt 启动的——启动 prompt 是手写的,agent 实际被要求做的并不是本 skill 所认证的内容。
— DeepSeek/deepseek-v4-flash via Qwen Code /review (v0.21.8)
| const requestSnapshotResync = (reason: string) => { | ||
| // The stream declared its cursor/base invalid. Preserve the last | ||
| // valid transcript prefix and attachment, but stop consuming this | ||
| // epoch immediately. The coordinator replaces it only after a pure | ||
| // snapshot stage succeeds. | ||
| flushTranscriptSync(); | ||
| if (reason === 'epoch_reset') activeSession.setLastEventId(0); |
There was a problem hiding this comment.
[Critical] A failed transactional resync permanently kills the SSE runner: requestSnapshotResync unconditionally calls runnerControl.stop() and delegates the reload to a single-shot coordinator transition with no retry, so on reload failure nothing restarts the runner.
Failure scenario (probe-verified): On a client_identity daemon, state_resync_required (ring eviction / epoch reset) → the reload fails transiently (network blip, 5xx, 504, watchdog). connection.status stays 'connected', sessionRef stays the source, and sessionTransition.phase === 'failed' is only diagnostic (no code reacts to it). A subsequent prompt is accepted by the daemon over HTTP but turn_complete only arrives over the dead SSE — the composer hangs in "streaming" forever while the daemon executes prompts invisibly (probe: the prompt never settled; the flip arm with a successful resync settles end_turn). The removed inline handlers re-entered the reconnect loop with retry/backoff; the replacement has no recovery, contradicting the design doc's "Failed is diagnostic and recoverable".
| const requestSnapshotResync = (reason: string) => { | |
| // The stream declared its cursor/base invalid. Preserve the last | |
| // valid transcript prefix and attachment, but stop consuming this | |
| // epoch immediately. The coordinator replaces it only after a pure | |
| // snapshot stage succeeds. | |
| flushTranscriptSync(); | |
| if (reason === 'epoch_reset') activeSession.setLastEventId(0); | |
| // On a failed resync reload (origin 'resync', phase 'failed'), restart the | |
| // source runner (bump a runner nonce so the attached-existing-session path | |
| // resumes the SSE), or gate sendPrompt while phase === 'failed' and surface | |
| // a disconnected state instead of 'connected'. |
中文说明
事务式 resync 失败后 SSE runner 被永久终止:`requestSnapshotResync` 无条件 `runnerControl.stop()`,重载交给一次性的、无重试的协调器转换;失败后没有任何路径重启 runner。连接仍显示 `connected`、`failed` phase 无人消费,下一条 prompt 被 daemon 通过 HTTP 接受但 `turn_complete` 只能走已死的 SSE——composer 永远停在 streaming(探针已复现)。被删除的内联处理器会带退避重试;替换实现没有恢复路径,违背了设计文档中 "Failed is diagnostic and recoverable" 的承诺。— DeepSeek/deepseek-v4-flash via Qwen Code /review (v0.21.8)
| clientIdRef.current = staged.session.clientId; | ||
| persistStableClientId(staged.session.clientId, staged.session.sessionId); |
There was a problem hiding this comment.
[Suggestion] persistStableClientId is unconditional in the transition commit path, contradicting the runner path's !requestedClientId guard — the same host-requested identity is honored differently by the two sibling commit paths.
Failure scenario: The runner's load path deliberately skips persistStableClientId when the host supplied an explicit clientId (the guard added in this PR); publishStagedCandidate persists unconditionally. A later prop-less mount of the same tab restores the session via getStableClientId(undefined, sessionId) and silently reuses the host identity the runner guard was added to prevent.
| clientIdRef.current = staged.session.clientId; | |
| persistStableClientId(staged.session.clientId, staged.session.sessionId); | |
| if (intent.request.clientId === undefined) { | |
| persistStableClientId(staged.session.clientId, staged.session.sessionId); | |
| } |
中文说明
transition commit 路径无条件执行 `persistStableClientId`,与 runner 路径的 `!requestedClientId` 守卫相矛盾——同一 host 指定的 clientId 在两条兄弟提交路径上被不同对待;之后未传 prop 的挂载会静默复用该 host 身份。— DeepSeek/deepseek-v4-flash via Qwen Code /review (v0.21.8)
| onCancel={handleCancel} | ||
| isRunning={isResponding} | ||
| isPreparing={sessionTransitionPending} |
There was a problem hiding this comment.
[Suggestion] isPreparing={sessionTransitionPending} disables the pane's only cancel control during a transition, contradicting the new test's assertion that cancel remains available — the test blesses a path the real UI cannot reach.
Failure scenario: In ChatEditor the send button is the sole onCancel trigger and early-returns when isPreparing; a pane whose turn is streaming when a transition starts loses its Stop button with no fallback (panes receive no cancelArmed; the App's Escape handler doesn't cover panes). The new test asserts expect(cancel).toHaveBeenCalledOnce() during a transition, but the mock's pane-cancel button calls props.onCancel unconditionally. The design doc promises "cancel ... remain available on the committed source".
中文说明
`isPreparing={sessionTransitionPending}` 会在切换期间禁用 pane 唯一的取消控件:ChatEditor 中发送按钮是唯一的 onCancel 触发点且 isPreparing 时直接 return,正在 streaming 的 turn 会失去 Stop 按钮且无后备。新测试断言切换期间 cancel 被调用一次,但 mock 的 pane-cancel 按钮无条件调用 onCancel——测试验证了真实 UI 无法到达的路径。— DeepSeek/deepseek-v4-flash via Qwen Code /review (v0.21.8)
| }, | ||
| useConnection: () => mocks.connection, | ||
| useWorkspace: () => mocks.workspace, | ||
| useWorkspaceActions: () => ({ addWorkspace: mocks.addWorkspace }), |
There was a problem hiding this comment.
[Suggestion] The new test file covers only the controlled transactional paths; the lockWorkspaceCwd registration state machine, the workspace.status === 'error' / notFound / capabilities-loading gate branches, and the usePrimaryNewSession fallback have zero coverage — and the addWorkspace mock is never invoked.
Failure scenario: A regression in the registration effect (registrationRef dedup, persist check, retry reset — roughly 50 lines of async logic) or in the primary-session fallback ships green: CI passes on the eight transactional tests while a broken locked-workspace load or error state only surfaces in the desktop/embedded flow after merge.
中文说明
新测试套件只覆盖受控事务路径;`lockWorkspaceCwd` 注册状态机、error/notFound/loading 门禁分支与 `usePrimaryNewSession` 回退零覆盖,且 `addWorkspace` mock 从未被调用。注册/回滚逻辑约 50 行的回归会全绿通过,仅在桌面/嵌入流程中暴露。— DeepSeek/deepseek-v4-flash via Qwen Code /review (v0.21.8)
| constructor(options?: { | ||
| client?: MockClient; | ||
| session?: Partial<MockSession>; | ||
| state?: Record<string, unknown>; |
There was a problem hiding this comment.
[Suggestion] The new MockDaemonSessionClient constructor is unreachable dead code carrying a silent-field-drop trap.
Failure scenario: grep finds zero new MockDaemonSessionClient( calls — sessions reach the provider only via takeSession/createMockSession and the static mocks, so the constructor's lastEventId validation and Object.assign never execute. A hypothetical new MockDaemonSessionClient({ client }) without session silently yields an instance with no session fields; session: { state } without a top-level state silently drops it. Misleading mock surface with no exercised behavior.
| constructor(options?: { | |
| client?: MockClient; | |
| session?: Partial<MockSession>; | |
| state?: Record<string, unknown>; | |
| // Delete the constructor, or have takeSession/the static mocks build the | |
| // returned session through it so the shape is actually exercised. |
中文说明
新增的 `MockDaemonSessionClient` 构造函数是不可达的死代码,还带有静默丢字段的陷阱:没有任何调用点,构造路径从未执行;`new MockDaemonSessionClient({ client })` 无 session 时静默得到空实例,`session:{state}` 而无顶层 state 时静默丢弃。— DeepSeek/deepseek-v4-flash via Qwen Code /review (v0.21.8)
| useEffect(() => { | ||
| setFailedTransitionTargetKey((failedKey) => | ||
| failedKey === undefined || failedKey === desiredTargetKey | ||
| ? failedKey | ||
| : undefined, | ||
| ); | ||
| }, [desiredTargetKey]); |
There was a problem hiding this comment.
[Suggestion] The failure→rollback→retry cycle (the failedTransitionTargetKey reset and the failedDesiredTargetKey clear) is entirely untested — neutralizing the reset effect keeps all 8 tests green (probe-verified).
Failure scenario: Every failure-path test re-renders with the same desired target key after the failure, so the reset effect never fires. A broken reset leaves the latch on retry: the App never re-enters the gated "switch in progress" state during the retry, and a second distinct failed-transition object silently rolls the user back with no indicator. The missing-workspace-appears re-arm is likewise untested.
中文说明
失败→回滚→重试周期(`failedTransitionTargetKey` 重置与 `failedDesiredTargetKey` 清除)完全无测试——探针验证中和 reset effect 后 8 个测试仍全绿。reset 失效会让重试期间 App 不再重新进入门禁状态,且第二次失败会静默回滚用户。— DeepSeek/deepseek-v4-flash via Qwen Code /review (v0.21.8)
| initialSelectedWorkspaceCwd={ | ||
| !lockWorkspaceCwd && visibleAppWorkspace | ||
| ? visibleAppWorkspace.cwd | ||
| : undefined | ||
| } |
There was a problem hiding this comment.
[Suggestion] Removing the provider key leaves App's selectedWorkspaceCwd mount-once state stale; a controlled workspace switch creates the next fresh session in the old workspace.
Failure scenario: App's selectedWorkspaceCwd is a useState(initialSelectedWorkspaceCwd) with no re-sync effect; the removed key previously remounted the App on each switch, re-initializing the state. A host-driven sessionless workspace switch (this PR's headline controlled flow) leaves the stale selection: in the next sessionless window ensureSessionForPrompt reads selectedWorkspaceCwdRef.current (the old workspace) and creates the session there — the user asked for /work/b and silently gets a session in /work/a.
中文说明
移除 provider `key` 后 App 的 `selectedWorkspaceCwd`(mount-once useState)在受控 workspace 切换后保持旧值:`ensureSessionForPrompt` 在无会话窗口读取过期选择,把新会话创建在旧 workspace——用户请求 /work/b 却静默得到 /work/a 的会话。建议在 commit 时按 `connection.workspaceCwd` 重新同步该状态。— DeepSeek/deepseek-v4-flash via Qwen Code /review (v0.21.8)
| for (const id of staged.dismissNoticeIds) dismissNotice(id); | ||
| for (const notice of staged.notices) addNotice(notice); |
There was a problem hiding this comment.
[Suggestion] Staged notice dismissals are applied before staged adds at commit, so a notice staged in the same pass as its own dismissal survives.
Failure scenario: A staged replay containing session_recording_degraded followed by a session_snapshot with recordingDegraded: false stages both the notice (deterministic id) and its dismissal in the same pass; at commit the dismissal runs first against a store that doesn't yet contain the notice, then addNotice re-creates it — the stale "recording degraded" warning stays visible. The live path processes in event order and ends with no notice (pinned by the existing 'clears a degraded notice' test).
| for (const id of staged.dismissNoticeIds) dismissNotice(id); | |
| for (const notice of staged.notices) addNotice(notice); | |
| for (const notice of staged.notices) addNotice(notice); | |
| for (const id of staged.dismissNoticeIds) dismissNotice(id); |
中文说明
commit 时先执行 staged dismiss 再执行 staged add:同一 staging pass 中被加入又被同 id 撤销的通知会在 dismiss 之后再被 addNotice 重建——过期的 "recording degraded" 警告残留。实时路径按事件顺序处理并最终无通知(已有测试钉住)。建议先 add 后 dismiss。— DeepSeek/deepseek-v4-flash via Qwen Code /review (v0.21.8)
| tokenUsage: | ||
| replayTokenUsage ?? | ||
| (preserveExistingTranscript ? currentConnection.tokenUsage : undefined), | ||
| tokenCount: | ||
| replayTokenCount ?? | ||
| (preserveExistingTranscript ? (currentConnection.tokenCount ?? 0) : 0), |
There was a problem hiding this comment.
[Suggestion] Same-session transitions with a usage-free replay reset tokenUsage/tokenCount to 0, diverging from the reconnect path which preserves them.
Failure scenario: The staged connection preserves token counters only under preserveExistingTranscript (empty replay + resume + same session), not the general same-session case; a resync/reload (mode: 'load') whose replay lacks a usage-bearing session_update publishes tokenUsage: undefined, tokenCount: 0 — the context-usage indicator drops to zero after the transition until the next usage event. The reconnect path keeps the old count for the same session; the two paths disagree about the same session's usage.
| tokenUsage: | |
| replayTokenUsage ?? | |
| (preserveExistingTranscript ? currentConnection.tokenUsage : undefined), | |
| tokenCount: | |
| replayTokenCount ?? | |
| (preserveExistingTranscript ? (currentConnection.tokenCount ?? 0) : 0), | |
| tokenUsage: replayTokenUsage ?? (sameLogicalSession ? currentConnection.tokenUsage : undefined), | |
| tokenCount: replayTokenCount ?? (sameLogicalSession ? (currentConnection.tokenCount ?? 0) : 0), |
中文说明
同会话事务转换在 replay 无 usage 事件时把 `tokenCount`/`tokenUsage` 重置为 0,而重连路径对同会话保留旧计数——两条路径对同一会话的 usage 结论不一致,转换后上下文指示器清零直到下一个 usage 事件。— DeepSeek/deepseek-v4-flash via Qwen Code /review (v0.21.8)
| requestSessionResyncRef.current?.(reason); | ||
| runnerControl.stop(); |
There was a problem hiding this comment.
[Suggestion] A deferred resync followed by a cancel leaves the runner dead with no pending resync: the connection claims 'connected' and every prompt hangs.
Failure scenario (probe-verified): requestSnapshotResync stops the runner unconditionally, but the coordinator may defer the resync when a higher-priority transition is in flight; if that transition is then cancelled, cancelSessionTransition clears deferredResyncRef and settles without committing — nothing relaunches the runner (the cancel bumps no nonce; finishRaw's scheduleDeferredResync finds the cleared ref). Probe: after a sentinel during a preparing transition followed by a cancel, the source events generator ran exactly once, the deferred resync never executed, and a subsequent prompt hung. The one-line fix (not clearing the ref in cancel) made the resync run.
中文说明
被延迟的 resync 在后续 cancel 时被静默丢弃,runner 保持已死:`cancelSessionTransition` 首行清空 `deferredResyncRef`,而未进入 runner 的转换永远不会触发唯一 flush(`scheduleDeferredResync`)。探针复现:sentinel 到达→cancel 后,source events 只运行一次、延迟 resync 从未执行、后续 prompt 挂起。建议只在真正放弃源会话时清空该 ref。— DeepSeek/deepseek-v4-flash via Qwen Code /review (v0.21.8)
What this PR does
This PR is the safety-complete PR3a split of transactional WebUI session switching. For ordinary load, resume, reload, full-resync, and live-journal repair, the committed session keeps ownership of its transcript, SSE stream, existing prompt waiters, workspace metadata, and allowed controls while the target restore runs and its replay is staged off-screen. A generation- and deadline-checked synchronous commit installs the target state atomically; failure, timeout, cancellation, or supersession leaves the source visible and usable.
The provider exposes an optional queued/preparing/failed transition state, serializes ordinary restore RPCs with exact-request coalescing and a latest-only queue, isolates stale events and asynchronous completions by captured owner identity, and performs best-effort cleanup of abandoned candidates with the concrete identity returned by the daemon. Full resync freezes the last valid source prefix at the resync sentinel, while a successful snapshot replaces it atomically.
The WebShell keeps desired and committed session/workspace identities separate without remounting the active App. Sidebar, overview, resume, dialog, and scheduled-task opens use the same transactional helper; new writes are gated before optimistic UI side effects; session-owned git/model/mode/recap metadata is keyed to the committed owner; and the scheduled-run 30-second timer starts only after restore commit to bound catch-up rather than restore.
This split intentionally removes the branch restored-payload adoption flow and the branch-specific SDK timeout extension from this PR. Branch creation/adoption remains on the existing path for now and will be handled in PR3b; this PR only prevents starting branch while an ordinary session transition is pending and reports branch creation without claiming that its asynchronous follow-up load has already committed.
Why it's needed
Large session restores can legitimately take much longer than an interactive request. Before this change, WebUI detached the current session, stopped its SSE stream, cleared its transcript and prompt state, and changed workspace metadata before the target restore completed. A 404, 504, transport failure, or superseding click could therefore leave the user with an empty or error view even though the original session was healthy.
PR #8691 made the daemon restore timeout and late-request lifecycle safe. This PR closes the ordinary WebUI ownership gap: the old session is preserved until the target has completed remote restore, pure local staging, and a final commit check, so the user sees either the intact source or one consistent target snapshot, never an intermediate empty or mixed state.
Reviewer Test Plan
How to verify
qwen servewith two persisted sessions, attach WebUI to session A, and hold delivery of session B's completed load response. While B is preparing, confirm A remains connected, its transcript remains visible, heartbeat/control succeeds, and a new A event is rendered. Release the response and confirm the first committed target state contains B's session, workspace, client identity, and transcript with no empty or mixed frame.state_resync_required, and a stale suffix. Confirm the valid prefix is frozen, the stale suffix is discarded, a failed reload preserves that prefix, and a successful reload replaces it atomically.Evidence (Before & After)
Before: starting an existing-session switch immediately detached the source and cleared its transcript/store while the target restore was still pending; a target failure surfaced an empty or error target instead of retaining the healthy source.
After: the focused real-daemon test completes B's server-side restore but withholds its HTTP response from the SDK; during that interval A stays connected, answers heartbeat, retains its transcript, and renders a new SSE turn. Releasing the response atomically installs B. A separate structured-504 scenario rejects the target load while A remains connected with its original transcript. After the final full build,
qwen-serve-webui-live-journal-recovery.test.tspassed 3/3 in 31.79 seconds on the branch rebased ontoorigin/mainat856b793c15.Tested on
Environment (optional)
macOS 26.4.1 arm64, Node.js 22.22.3, npm 10.9.8, local HTTP daemon with the repository mock ACP agent, and the JSDOM provider harness.
Risk & Scope
sessionTransitionis an additive optional TypeScript field. Daemons that explicitly lackclient_identityretain the existing destructive switch behavior; unknown capabilities or malformed modern client identities fail closed instead of silently downgrading.Linked Issues
Refs #8678
中文说明
本 PR 做了什么
本 PR 是事务化 WebUI 会话切换中安全能力完整的 PR3a 拆分。对于普通 load、resume、reload、full-resync 和 live-journal repair,当前已提交会话在目标 restore 与离屏 replay staging 期间继续持有 transcript、SSE、已有 prompt waiter、workspace 元数据和允许执行的控制能力。只有通过 generation 与 deadline 的最终检查后,才同步原子安装目标状态;失败、超时、取消或被新请求替换时,源会话保持可见且可用。
Provider 新增可选的 queued/preparing/failed transition 状态;普通 restore RPC 串行执行,相同请求 coalesce,后续请求只保留 latest;迟到事件和异步完成结果按捕获的 owner identity 隔离;废弃候选使用 daemon 实际返回的身份做 best-effort cleanup。Full resync 会在 resync sentinel 处冻结源会话最后的合法前缀,只有成功 snapshot 才原子替换它。
WebShell 分离 desired 与 committed session/workspace identity,切换时不会 remount 当前 App。sidebar、overview、resume、dialog 和 scheduled-task open 复用同一个事务式 helper;新写入会在 optimistic UI 副作用发生前被 gate;git/model/mode/recap 等 session 元数据按 committed owner 归属;scheduled-run 的 30 秒 timer 只在 restore commit 后开始,用来限制 catch-up 而不是 restore。
本次拆分有意从该 PR 移除了 branch restored-payload adoption 流程与 branch 专用 SDK timeout 扩展。branch creation/adoption 当前继续使用原有路径,并在 PR3b 中处理;本 PR 只禁止在普通 session transition pending 时启动 branch,并把 branch 结果表述为“已创建”,不再声称其异步 follow-up load 已经 commit。
为什么需要
大型会话 restore 合理情况下可能显著慢于普通交互请求。修改前,WebUI 会在目标 restore 完成前 detach 当前会话、停止 SSE、清空 transcript 和 prompt 状态,并提前切换 workspace 元数据。目标出现 404、504、网络失败或被新点击替换时,即使原会话健康,用户也可能只剩空白或错误视图。
PR #8691 已保证 daemon restore timeout 与迟到请求生命周期安全。本 PR 补齐普通 WebUI 切换的所有权缺口:旧会话会保留到目标完成远端 restore、纯本地 staging 和最终 commit 检查,因此用户只会看到完整的 source 或一致的 target snapshot,不会看到中间空白或混合状态。
Reviewer 测试计划
如何验证
qwen serve,让 WebUI attach 会话 A,并暂扣已完成的会话 B load response。在 B preparing 期间,确认 A 仍 connected、transcript 仍可见、heartbeat/control 成功且新的 A 事件能渲染。释放响应后,确认第一个 committed target 状态同时包含 B 的 session、workspace、client identity 和 transcript,没有空白或混合帧。state_resync_required和 stale suffix 的 burst。确认合法前缀被冻结、stale suffix 被丢弃、reload 失败时保留该前缀,reload 成功时才原子替换。证据(修改前与修改后)
修改前:打开已有会话会立即 detach source 并清空 transcript/store,此时 target restore 仍在 pending;目标失败会留下空白或错误 target,而不是保留健康 source。
修改后:focused 真实 daemon 测试先完成 B 的服务端 restore,但暂扣交给 SDK 的 HTTP response;此期间 A 保持 connected、heartbeat 成功、transcript 保留并继续渲染新的 SSE turn。释放 response 后原子安装 B。另一个结构化 504 场景中,target load 被拒绝,但 A 仍 connected 且原 transcript 完整保留。最终 full build 后,
qwen-serve-webui-live-journal-recovery.test.ts在 rebase 到origin/main的856b793c15的分支上 3/3 通过,总耗时 31.79 秒。测试平台
环境(可选)
macOS 26.4.1 arm64、Node.js 22.22.3、npm 10.9.8、本地 HTTP daemon、仓库 mock ACP agent 和 JSDOM Provider harness。
风险与范围
sessionTransition是 additive optional TypeScript 字段。明确缺少client_identity的旧 daemon 保留现有 destructive switch;capability 未知或现代 daemon 返回 malformed client identity 时 fail-closed,不会静默降级。关联 Issue
Refs #8678