Skip to content

fix(multi-server): 服务器作用域请求统一使用当前绑定的 serverId - #169

Merged
lehhair merged 3 commits into
lehhair:mainfrom
rayn1314:fix/server-scoped-request-stale-serverid
Sep 16, 2026
Merged

lehhair merged 3 commits into
lehhair:mainfrom
rayn1314:fix/server-scoped-request-stale-serverid

Conversation

@rayn1314

Copy link
Copy Markdown
Contributor

起因

我在 Windows 上主力用 WSL 跑 opencode,也在做 WSL 服务器管理那个 PR(#164)。用着用着发现一个很别扭的问题:在 WSL 的对话里,模型一提问(带选项的卡片),卡片能正常显示,但不管我选选项、输入自己的回答还是跳过,都点不动——点完卡片会消失一下(像发送成功了),过一会儿又冒出来,对话也不往前走。权限确认弹窗其实也一样,只是我没第一时间反应过来是同一回事。

排查过程

我让 AI 排查了这个问题,重点是「面板绑定的服务器」这条线:

  1. 面板绑定的服务器(paneServerId)在面板生命周期里会变——启动时活动服务器是 local,WSL sidecar 就绪后 wslStore 才把活动服务器切过去;切换会话当然也会变。而面板本身不会重挂(key 只是 paneId)。
  2. usePermissionHandler 里三个回复回调(权限回复、提问回复、提问跳过)的依赖数组是空的 []serverId 被冻在面板首次渲染时的值上。同一个 hook 里的 refreshPendingRequests 却是 [serverId](每次拿新值)——这一新一旧的不对称,正好解释「卡片消失又回来」。
  3. 为了确认不是我在猜,写了个临时探针测试(renderHook,先 localrerenderwsl:Ubuntu,然后分别调用三个函数,把各自收到的 serverId 打出来):
replyQuestion        serverId = "local"        ← 错,回复发到了本地
rejectQuestion       serverId = "local"        ← 错,跳过也发到了本地
getPendingQuestions  serverId = "wsl:Ubuntu"   ← 对,所以又把 WSL 上的提问拉回来了

数据出来就对上了:卡片显示走 SSE(按服务器分别订阅,是对的),回复走 HTTP(用的是旧 serverId,发到 local 报错),于是 WSL 那条请求一直 pending,刷新一拉就"复活"。探针脚本用完删掉了。

  1. 既然是一类问题,就把这条线走到底。用仓库已经在用的 ESLint 规则 react-hooks/exhaustive-deps 扫全仓:同类漏声明 serverId 的地方一共 29 条告警(还有 1 处规则看不到但同样会漏:useChatSession 的 SSE 回调)。逐个判断了哪些真会把请求打到旧服务器(被 routeSessionId 守卫的那几处不会,因为那种情况下 paneServerId 是它的纯函数),然后一起修掉。
  2. 还有一处是顺着数据流读出来的,ESLint 抓不到:task 工具在匹配子 session 的权限/提问卡片时,拿消息 metadata 里的原始 session id 去调 splitSessionKey,而这个函数碰到不带 :: 前缀的 id 会直接回退到「全局活动服务器」。于是子 agent 再起一层子 session 时,只要活动服务器和面板绑定的服务器不是同一台,那张卡片就永远匹配不上——和前面是同一个根因,只是换了个入口。修法是把权威 serverId 从面板一路传下来,而不是让匹配器去猜。

修复内容

  1. usePermissionHandler:三个回复回调的依赖数组 [][serverId],并在 hook 上写明约定「凡发起请求的回调都必须声明 serverId」
  2. useChatSession:补齐 paneServerId 依赖——SSE 回调、agents 列表、@ 目录与斜杠命令预取、待处理请求刷新、发送消息、fork、中止、执行命令、归档;同时把这条约定写进 paneServerId 定义处的注释(并标注例外:被 routeSessionId 守卫的单元)
  3. 其余服务器作用域单元一并补齐:useFileExplorer(4)、BottomPanel(4)、SessionChangesPanel(3)、SessionChildrenSlot(3)、ProjectDialog(2)、RightPanel(1)、Terminal(1)
  4. task 工具的子 session 匹配:InlineToolRequestContext 新增 TaskChildSessionRef(子 session key + 面板绑定的权威 serverId),context 值加上必填 serverId,复合 key 用它合成,不再走 splitSessionKey 的「裸 id 回退活动服务器」;ChatPanepaneServerId 发布进 context,ToolPartView / MessageRenderer 跟着透传
  5. 新增回归测试两条:面板从 local 切到 wsl:Ubuntu 后,回复与跳过必须打到 wsl:Ubuntu;活动服务器是 local 而面板绑定 wsl:Ubuntu 时,子 agent 下一层会话的权限/提问卡片必须能匹配上。两条都是把修复回退就会红的(前者 AssertionError: expected "vi.fn()" to be called with …,后者 expected undefined to be { id: 'perm-1', … }

验证

  • typecheck:0 错误
  • ESLint:src 下 0 错误 / 43 警告(与改动前基线一致);该类 serverId 漏声明告警 29 → 0
  • 测试:93 个文件 / 598 个用例全部通过
  • build:成功
  • 手工验证的路子(给 reviewer 参考):桌面端 F12 → Network 筛 question,点一个选项,看那条 POST 打到哪个端口——修复前是 127.0.0.1:4096(local)并报错,修复后是 WSL sidecar 的随机端口且 200。子 agent 那条路径同理:在别的服务器上开会话跑一个会再下一层的 task,看孙层卡片能不能点动
  • 顺带一处测试稳定性修改:ConfigSettings.search.test.tsx 挂了完整 ConfigSettings 和配置编辑器弹窗,单跑 0.5 秒,但全量并发跑会超过 5 秒默认 testTimeout 被误判超时(我这边一加测试文件就复现)。给它单独放宽到 20 秒并写了注释,断言没动。这一步是独立的 commit,跟上面的修复无关,觉得不合适可以只挑第一个

小提示:我本机上有个未跟踪的 opencode/ 参考目录会被 eslint . 和 vitest 扫到(它只在 WSL 分支的 cfd5ce6 里被排除),所以本地验证用的是 eslint srcvitest --dir src;CI 全新 clone 不受影响。

说明

这个缺陷的引入点是 main 里已有的多服务器提交(75a00b64),不是 #164 带来的,WSL 只是最容易触发它的场景(活动服务器是异步切过去的),所以我单独开了这个 PR,而不是塞进 #164

Fixes #168

- 修复 usePermissionHandler 的权限与提问回复回调:空依赖数组把 serverId 冻结在首次渲染的值上,切换服务器后回复被发到旧服务器,表现为弹窗消失又复现、对话不前进
- 补齐 useChatSession 中 paneServerId 依赖:SSE 回调、agents 列表、@ 目录与斜杠命令预取、待处理请求刷新、发送消息、fork、中止、命令执行、归档
- 补齐其余服务器作用域单元的 serverId 依赖:useFileExplorer、BottomPanel、RightPanel、SessionChangesPanel、Terminal、ProjectDialog、SessionChildrenSlot
- 新增回归测试:断言面板切换服务器后权限与提问回复打到当前服务器;该测试在修复前会失败
- 校验:typecheck、eslint src、vitest --dir src(92 文件 / 596 用例)、build 全部通过
task 工具匹配子/孙 session 的权限与提问卡片时,把消息 metadata 里的原始
session id 交给 splitSessionKey;该函数遇到不带 :: 前缀的 id 会回退到全局
活动服务器。多服务器 / WSL 场景下面板绑定的服务器与活动服务器不是同一台,
孙层请求因此永远匹配不上,卡片点不动。

- InlineToolRequestContext: 新增 TaskChildSessionRef(子 session key + 权威
  serverId),context 值补必填 serverId,复合 key 由它合成,不再猜服务器
- ChatPane: 把 paneServerId 发布进 context,并补进 memo 依赖
- ToolPartView / MessageRenderer: 调用点透传 serverId
- 新增契约测试:活动服务器为 local、面板绑定 wsl:Ubuntu 时,孙层的权限与
  提问必须能命中
该用例挂载完整 ConfigSettings 与配置编辑器弹窗,是 jsdom 下最重的路径之一:
单跑 0.5 秒通过,但全量并发跑会超过 5 秒默认 testTimeout 被误判为失败。
给它单独设 20 秒并写明原因,断言与流程都未改动。
rayn1314 added a commit to rayn1314/OpenCodeUI that referenced this pull request Sep 16, 2026
旧实现拿 changedId 与 active 比对,但面板数据主体是 serverId prop(可为
固定绑定):面板绑 WSL 而 active 在 local 时,这台 WSL 换端口重启被误跳过
(终端失联),无关服务器切换反而误触发重恢复。对齐 useVcsInfo 的绑定语义,
统一走共享谓词。依赖数组补 serverId 属 lehhair#169 范围,本提交不动。

@lehhair lehhair left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review:根因定位准确、修复到位、有红绿验证,Approve

这条修的是 #168(多服务器/WSL 下权限与提问回复打到旧服务器),我按同样流程做了独立复核:拉到本地、把 #164 合并后的 main 与它做实际集成合并、跑全套校验、并对两处核心修复做红绿验证

结论

可以合并,我没有阻塞意见。 三条提交各司其职:usePermissionHandler 的空依赖(真正的根因)、task 子 session 匹配猜服务器(同根因的另一入口)、以及一条独立的测试稳定性改动。

我独立复验的东西

1. 根因确实成立(红绿验证)

usePermissionHandler.ts 三个回复回调的依赖数组从 [] 改为 [serverId]。我把这三处改回 [] 后重跑,新增的那条契约测试恰好变红,其余用例不受影响;改回即绿:

FAIL  usePermissionHandler > routes replies to the server the pane is bound to now,
      not the one captured at mount
      Tests  1 failed | 2 passed (3)

replyQuestion(..., serverId) / rejectQuestion(..., serverId) 最终都走 getSDKClient(serverId),所以"serverId 冻结 → 请求打到旧服务器"这条因果链成立。

2. 子 session 匹配那条(commit 2)我也做了实证

你的判断(splitSessionKey 遇到不带 :: 的原始 id 会回退到全局活动服务器)我在 src/utils/sessionKey.ts:24-30 核实了,确实如此。为了不只看代码,我写了个临时探针(真实 isChildOf 语义、pane 绑定 wsl:Ubuntu、active 为 local、孙 session 请求):

PROBE correct binding (wsl:Ubuntu): perm-grand      ← 能命中
PROBE old behavior (guessed local):  NO MATCH       ← 旧实现命中不了,这就是 #168 的孙层路径
PROBE cross-server (wsl:Debian):     NO MATCH       ← 不会跨服务器误命中
PROBE direct callID match:           perm-grand     ← 直接 callID 匹配路径未被破坏

同样做了红绿:把 makeSessionKey(child.serverId, ...) 换回旧的猜服务器写法,InlineToolRequestContext.test.tsx 两条契约测试双双变红。修复后我按字节还原了文件(git status 干净)。

3. 29 处依赖数组:没有引入新告警,也没有"补错"

这条是我最担心的部分(批量补依赖最容易补出多余重渲染或请求风暴),所以我做了集合对比:把当前 main 与"main + 本 PR"分别在两个 worktree 里跑 eslint -f json,按 相对路径|规则|消息 归一化后取差集:

main(no-169)        = 93 warnings
integration(with-169) = 64 warnings
仅出现在 integration、不在 main 的告警 = 空

没有新增任何告警,减少的正是 29 条 serverId 漏声明。逐点抽看被补的依赖也都是真依赖(如 useFileExplorergetFileContent(path, effectiveDirectory, serverId)SessionChildrenSlotgetSessionChildren(..., serverId)ProjectDialoginitPath),不是机械加参数。

另外我确认了几处故意不补是合理的:resetPendingRequests 不发请求(保持 []);useGlobalEventsserverId 是每订阅一份的入参而非 hook 作用域;被 routeSessionId 守卫的单元里 paneServerId 是其纯函数。这些都和你正文里的说明一致。

4. 集成合并 + 全套校验(main 含 #164 + 本 PR)

校验 结果
git merge 无冲突(我实际合了,merge-tree 也干净)
tsc -b 0 错误
eslint . 0 error(告警见上,且无新增)
vitest run 96 文件 / 667 用例全过
vite build 通过

顺带更正我之前的说法:我在 #164 的 review 里提醒"#169 直接合会覆盖掉 N2 门控"——那是我没验证就下的结论,实际是错的。我这次真做了合并测试,BottomPanel.tsxaffectsBoundServer 门控与 #169 补的 serverId 依赖两者都保留(git 三方合并把两处改动分别应用了)。不需要 rebase。

5. 测试稳定性那条改动(commit 3)

ConfigSettings.search.test.tsx 单独放宽到 20s。我单跑该文件实测 2.47s(含 setup),在当前机器负载下确实容易被 5s 默认值卡住;断言与流程未动,是纯超时参数。这类改动我一般会警惕"用放宽超时掩盖真 bug",但这里的耗时体量(挂载完整 ConfigSettings + 配置编辑器弹窗,jsdom 最重路径)与现象一致,且你把它单独成一个 commit 便于摘除——处理方式合适。

一点非阻塞建议

本 PR 的验证里你提到用 eslint src / vitest --dir src 规避本地 opencode/ 参考目录。这个目录现在只存在于你本地#164 里那段排除配置已经按评审意见回退掉了),所以不影响 CI;但如果以后还想本地跑全量,建议在自己的全局 eslintignore / 本地未跟踪配置里处理,不要再进仓库。

合并方式

head 84ab01aa 与本 review 一致。我会按仓库规范 merge --no-ff(保留你的原始 commit 让 GitHub 显示 Merged),并按 AGENTS.md 的顺序先合 dev 再快进 main。合完跑一遍合并后的校验并把结果贴出来。

感谢这条独立开 PR 而不是塞进 #164——引入点是 main 里已有的 75a00b64,边界划分是对的。


关于审查方式:本评论由 AI 辅助产出(自动化跑通全套校验、逐行追踪、临时探针实测含红绿验证、worktree 双份 eslint 差集比对),结论经我确认后发布。

@lehhair
lehhair merged commit 9358705 into lehhair:main Sep 16, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: 多服务器/WSL 下权限与提问的回复打到旧服务器,卡片消失又复现、对话不前进

2 participants