背景
收到外部反馈:在部分飞书群聊中 @Codex Remote bot,bot 没有在群里回复,而是把回复发到了私聊通道。该现象在另一些群里又是正常的。反馈者不确定使用的是 master 还是 release/1.8。
当前已知约束:
- 这是别人机器上的问题,当前调研不能依赖本机日志或本机运行态。
- 需要先用静态代码分析确认是否存在结构性问题,再决定修复方向。
- 用户明确不接受把真实原因误报成平台行为;需要找到我们自己的设计/状态边界问题。
目标
确认仓库当前实现中,哪些路径可能导致“群消息最终按私聊目标回复”,并形成后续修复所需的根因边界与设计约束。
范围
internal/adapter/feishu/gateway/*
internal/app/daemon/app_ui.go
internal/app/daemon/app_surface_resume_state.go
internal/app/daemon/surfaceresume/*
internal/core/orchestrator/service_surface.go
master / release/1.8 的相关分支差异确认
非目标
- 不在当前机器上采集或解释别人的运行日志
- 不先做症状级兜底补丁来掩盖真实原因
- 不把飞书平台行为当成默认解释,除非代码证据先排除本地结构性问题
完成标准
- 明确至少一个真实可达的代码路径,解释“群里 @bot 最终发到私聊”的触发条件。
- 说清楚这是:
- inbound 本身缺失
chat_id
- surface 首次 materialize 进入了错误 scope
- surface 恢复 / 复用沿用了空
ChatID
- 或其他更深的状态机 / 路由设计问题。
- 明确哪些场景可以安全自修复,哪些场景必须调整 surface / routing 设计边界。
- 如进入实现阶段,修复方案不能引入新的 user/chat scope 串线风险。
当前已证实事实
- 普通群文本消息的标准 inbound 路径会从事件中读取
message.ChatId / message.ChatType,按 chat scope 建 surface。
- daemon 发 UI 事件时不会直接复用原始消息目标,而是读取 surface 当前保存的
ChatID / ActorUserID 重新调用 ResolveReceiveTarget(...) 计算目标。
ResolveReceiveTarget(...) 的规则是:ChatID != "" 则发群;ChatID == "" 则回退到 ActorUserID 私聊。
ensureSurface(...) 只有在 action.ChatID != "" 时才更新 surface.ChatID;因此如果 surface 首次 materialize 时没有拿到 ChatID,后续很多 action 会长期沿用空 ChatID 状态。
- surface resume 会把当前 surface 的
ChatID / ActorUserID 原样持久化,并在 daemon 重启后重新 materialize;坏状态可能跨重启存活。
- 仓库中存在天然 user-scope 入口,例如 bot menu 明确构造成 p2p/user surface;card action 在查不到消息到 surface 的历史映射时也会 fallback 自行推导 surface。
master 与 release/1.8 在本问题相关的关键路径上目前未发现实质差异,暂未看到“只在某个分支出现”的证据。
- 当前代码与测试已经承认一种混合态:
user scope surface 也可能携带 ChatID 并被持久化,这意味着“仅补 ChatID”并不天然违反现有数据模型,但存在串线边界。
inbound_lane 的异步文本/图片/文件入口与同步 ParseMessageEvent(...) 一样,都会先按 SurfaceIDForInbound(gatewayID, chatID, chatType, senderUserID) 计算 surface;没有看到队列层把群 surface 改写成 user surface 的逻辑。
- group card callback 在有
messageID -> surfaceID 映射时会保留原 chat-scope surface;现有测试明确覆盖了 group card callback 不应回落为 user surface 的情况。
- 正常 attach / route transition 通过
instanceClaims / threadClaims 保证实例和线程在 surface 之间互斥持有;未看到“同一实例同时被群 surface 和私聊 surface 正常双持”后再被执行中途抢走的路径。
maybeAutoSteerReply(...) 只会在“当前 action 所在 surface == active remote binding 所在 surface”时工作,不能把群 surface 上的新消息跨 surface 吸到另一个私聊 turn。
- 如果问题只是“同一个群 surface 上的
ChatID 暂时为空”,那么该 surface 后续只要再收到一次带 ChatID 的群 inbound,ensureSurface(...) 就会把它补回;因此长期稳定表现为“群里发起、私聊收结果”时,更像是 turn 一开始就绑错了 surface,或恢复出的 surface 本身就是坏的。
深入调研补充(2026-06-05)
- 目前已经基本排除“普通群文本入口自己把消息改投到私聊 surface”。同步入口
ParseMessageEvent(...) 与异步入口 PlanInboundMessageEvent(...) / QueuedMessageWork.parseAction(...) 的 surface 计算方式一致,都是 group -> chat scope。
- 目前也基本排除“执行中途被另一条 private surface 抢占”的主路径。claim 互斥决定了这更像是入口阶段就选错 surface,后面的 remote binding 只是在错误 surface 上稳定延续。
- 因为
deliverUIEventWithContextMode(...) 的实际回发目标完全取决于 event.SurfaceSessionID 当前对应 surface 上保存的 ChatID / ActorUserID,所以只要某次 turn 在开始前已经绑到了 user-scope 或空-ChatID surface,后续结果稳定发私聊是结构上可达的。
当前根因假设
更可能的问题不是“普通群文本消息解析错误”,而是:
- 某些群场景在最开始就进入了 user-scope / 无
ChatID 的 surface;或
- surface 恢复 / 复用后保留了 user-scope / 空
ChatID 坏状态;随后 UI 事件按 surface 上下文重新计算目标时,显式降级成私聊。
相对次要、目前证据不足的方向是:
- remote turn 在 dispatch 前绑定到了与当前 inbound 不一致的 surface;这仍可能发生,但比“入口就选错 surface”更像次级嫌疑。
待调查问题
- 哪些具体入口会在群场景下首次 materialize 出无
ChatID 或 user-scope surface:
- bot menu
- card action fallback
- surface resume / 旧状态恢复
- 其他 daemon ingress / control action 入口
- 哪些路径只是“user-scope 但可安全补齐
ChatID”,哪些路径必须重建 / 重路由到 chat-scope surface。
- 是否存在安全的自修复策略:
- 当收到新的群文本消息且当前 surface 缺失
ChatID 时,只补 ChatID
- 或直接重建/切换到 chat-scope surface
- 需要新增哪些日志/断言,才能在反馈者机器上快速区分:
- inbound 本身就没拿到群
chat_id
- surface 中途丢了
ChatID
- 回复目标被错误地按 user-scope 计算
- dispatch 绑定在 turn 开始前就已经选错了 surface
日志可观测性
当前已有日志基本能区分三大类场景:
- inbound 是否一开始就进错 surface
surface action: surface=..., chat=..., actor=..., kind=..., message=...
- 如果群消息进入时
surface= 已经是 feishu:<gateway>:user:<user>,那就是入口阶段选错 surface。
- dispatch 绑定是否在 turn 开始前漂移
dispatch prepare / dispatch bound / dispatch sent
- 这些日志会带
surface=,同时 pending= / active= 中也会列出 surfaceSessionID。
- 如果 inbound 是
chat: surface,但 pending/active remote 绑定成了 user: surface,说明是 dispatch 前后绑定漂移。
- outbound 是否因为 surface 上下文缺
ChatID 而退化成私聊
ui event: surface=..., chat=..., actor=..., kind=...
- 如果这里
surface= 仍是目标 surface,但 chat= 为空且最终按 actor 发出,则是 surface 自身路由态坏了。
当前日志的不足是:
summarizeRemoteStatuses(...) 会打印 surfaceSessionID,但不会直接展开 surface 当前的 ChatID;排查时还需要对照 surface id 命名或同时看 ui event / surface action 日志。
建议范围
- 先把问题收敛到“首次建错 surface”还是“恢复/复用保留坏状态”两大类。
- 调研阶段优先找最小可证明根因,不在未锁定根因前设计补丁。
- 若确认需要修复,优先修 surface 选择 / 状态来源,而不是仅在最终发送前加兜底回退。
实现参考
ParseMessageEvent(...) / PlanInboundMessageEvent(...) / SurfaceIDForInbound(...)
deliverUIEventWithContextMode(...)
ensureSurface(...)
materializeSurfaceResumeStateLocked(...) / currentSurfaceResumeEntryLocked(...)
surfaceForCardAction(...) 与 card action fallback surface 推导
transitionSurfaceRouteCore(...) / instanceClaims / threadClaims
检查参考
- 对比
master / release/1.8 是否存在相关逻辑分叉
- 检查 user-scope 入口能否在群场景下错误复用到业务回复链
- 检查恢复态是否会把空
ChatID 持久化并稳定带回
- 检查“收到新的群文本消息后补
ChatID”是否会在多群同人场景下串线
- 检查 dispatch 绑定是否可能在 inbound surface 正确时仍绑定到另一条 surface
相关文档
docs/general/remote-surface-state-machine.md
docs/general/feishu-card-ui-state-machine.md
涉及文件
internal/adapter/feishu/gateway/inbound.go
internal/adapter/feishu/gateway/inbound_lane.go
internal/adapter/feishu/gateway/routing.go
internal/adapter/feishu/gateway_runtime_support.go
internal/app/daemon/app_ui.go
internal/app/daemon/app_surface_resume_state.go
internal/app/daemon/surfaceresume/feishu.go
internal/core/orchestrator/service_surface.go
internal/core/orchestrator/service_queue.go
internal/core/orchestrator/service_queue_binding.go
internal/core/orchestrator/service_route_core.go
internal/core/orchestrator/service_routing_claims.go
internal/core/orchestrator/service_reply_auto_steer.go
收尾参考
- 若后续进入修复,必须补充能区分 inbound/surface/send-target 三段的日志或测试断言
- 若修复触及 surface/routing 逻辑,评估是否需要同步相关 state-machine 文档
执行决策
- 当前不拆子 issue,原因:问题范围仍集中在 Feishu gateway + daemon surface routing + surface resume 三条紧邻链路内,尚未出现需要独立并行推进的第二目标。
- 当前 issue 暂作为单个调研 worker 单元,不进入实现阶段,先追求调研闭包。
- 当前 worker 单元:定位
ChatID 缺失/丢失的真实入口,并给出“可安全自修复”和“必须改设计边界”的分界线。
- verifier:当前阶段不需要独立 verifier;若后续进入代码修复且变更跨 gateway/orchestrator/resume 三层,默认需要 verifier pass 再 close。
当前执行点
已经完成一轮跨 gateway / ingress / orchestrator / resume 的静态排查,排除了普通 group text 入口、自身 inbound lane、group card callback 正常路径、auto-steer reply、以及“执行中途被另一 surface 抢占”的主路径;当前重点收敛到:
- 某些入口一开始就选成了 user-scope surface
- 或恢复出的 surface 在当前 turn 开始前已经带着坏路由态
下一步
- 继续锁定 1~2 个最可疑入口,优先看:
surfaceForCardAction(...) 的 fallback 使用条件
- surface resume 恢复出的 user-scope / 空-
ChatID surface 在后续 attach / dispatch 时如何进入业务回复链
- 基于外部日志制定最小采证清单,要求一次就能区分:入口建错 surface、dispatch 绑错 surface、还是 surface 自身路由态损坏。
背景
收到外部反馈:在部分飞书群聊中
@Codex Remote bot,bot 没有在群里回复,而是把回复发到了私聊通道。该现象在另一些群里又是正常的。反馈者不确定使用的是master还是release/1.8。当前已知约束:
目标
确认仓库当前实现中,哪些路径可能导致“群消息最终按私聊目标回复”,并形成后续修复所需的根因边界与设计约束。
范围
internal/adapter/feishu/gateway/*internal/app/daemon/app_ui.gointernal/app/daemon/app_surface_resume_state.gointernal/app/daemon/surfaceresume/*internal/core/orchestrator/service_surface.gomaster/release/1.8的相关分支差异确认非目标
完成标准
chat_idChatID当前已证实事实
message.ChatId/message.ChatType,按 chat scope 建 surface。ChatID/ActorUserID重新调用ResolveReceiveTarget(...)计算目标。ResolveReceiveTarget(...)的规则是:ChatID != ""则发群;ChatID == ""则回退到ActorUserID私聊。ensureSurface(...)只有在action.ChatID != ""时才更新surface.ChatID;因此如果 surface 首次 materialize 时没有拿到ChatID,后续很多 action 会长期沿用空ChatID状态。ChatID/ActorUserID原样持久化,并在 daemon 重启后重新 materialize;坏状态可能跨重启存活。master与release/1.8在本问题相关的关键路径上目前未发现实质差异,暂未看到“只在某个分支出现”的证据。userscope surface 也可能携带ChatID并被持久化,这意味着“仅补ChatID”并不天然违反现有数据模型,但存在串线边界。inbound_lane的异步文本/图片/文件入口与同步ParseMessageEvent(...)一样,都会先按SurfaceIDForInbound(gatewayID, chatID, chatType, senderUserID)计算 surface;没有看到队列层把群 surface 改写成 user surface 的逻辑。messageID -> surfaceID映射时会保留原 chat-scope surface;现有测试明确覆盖了 group card callback 不应回落为 user surface 的情况。instanceClaims/threadClaims保证实例和线程在 surface 之间互斥持有;未看到“同一实例同时被群 surface 和私聊 surface 正常双持”后再被执行中途抢走的路径。maybeAutoSteerReply(...)只会在“当前 action 所在 surface == active remote binding 所在 surface”时工作,不能把群 surface 上的新消息跨 surface 吸到另一个私聊 turn。ChatID暂时为空”,那么该 surface 后续只要再收到一次带ChatID的群 inbound,ensureSurface(...)就会把它补回;因此长期稳定表现为“群里发起、私聊收结果”时,更像是 turn 一开始就绑错了 surface,或恢复出的 surface 本身就是坏的。深入调研补充(2026-06-05)
ParseMessageEvent(...)与异步入口PlanInboundMessageEvent(...)/QueuedMessageWork.parseAction(...)的 surface 计算方式一致,都是 group -> chat scope。deliverUIEventWithContextMode(...)的实际回发目标完全取决于event.SurfaceSessionID当前对应 surface 上保存的ChatID/ActorUserID,所以只要某次 turn 在开始前已经绑到了 user-scope 或空-ChatIDsurface,后续结果稳定发私聊是结构上可达的。当前根因假设
更可能的问题不是“普通群文本消息解析错误”,而是:
ChatID的 surface;或ChatID坏状态;随后 UI 事件按 surface 上下文重新计算目标时,显式降级成私聊。相对次要、目前证据不足的方向是:
待调查问题
ChatID或 user-scope surface:ChatID”,哪些路径必须重建 / 重路由到 chat-scope surface。ChatID时,只补ChatIDchat_idChatID日志可观测性
当前已有日志基本能区分三大类场景:
surface action: surface=..., chat=..., actor=..., kind=..., message=...surface=已经是feishu:<gateway>:user:<user>,那就是入口阶段选错 surface。dispatch prepare/dispatch bound/dispatch sentsurface=,同时pending=/active=中也会列出surfaceSessionID。chat:surface,但 pending/active remote 绑定成了user:surface,说明是 dispatch 前后绑定漂移。ChatID而退化成私聊ui event: surface=..., chat=..., actor=..., kind=...surface=仍是目标 surface,但chat=为空且最终按 actor 发出,则是 surface 自身路由态坏了。当前日志的不足是:
summarizeRemoteStatuses(...)会打印surfaceSessionID,但不会直接展开 surface 当前的ChatID;排查时还需要对照 surface id 命名或同时看ui event/surface action日志。建议范围
实现参考
ParseMessageEvent(...)/PlanInboundMessageEvent(...)/SurfaceIDForInbound(...)deliverUIEventWithContextMode(...)ensureSurface(...)materializeSurfaceResumeStateLocked(...)/currentSurfaceResumeEntryLocked(...)surfaceForCardAction(...)与 card action fallback surface 推导transitionSurfaceRouteCore(...)/instanceClaims/threadClaims检查参考
master/release/1.8是否存在相关逻辑分叉ChatID持久化并稳定带回ChatID”是否会在多群同人场景下串线相关文档
docs/general/remote-surface-state-machine.mddocs/general/feishu-card-ui-state-machine.md涉及文件
internal/adapter/feishu/gateway/inbound.gointernal/adapter/feishu/gateway/inbound_lane.gointernal/adapter/feishu/gateway/routing.gointernal/adapter/feishu/gateway_runtime_support.gointernal/app/daemon/app_ui.gointernal/app/daemon/app_surface_resume_state.gointernal/app/daemon/surfaceresume/feishu.gointernal/core/orchestrator/service_surface.gointernal/core/orchestrator/service_queue.gointernal/core/orchestrator/service_queue_binding.gointernal/core/orchestrator/service_route_core.gointernal/core/orchestrator/service_routing_claims.gointernal/core/orchestrator/service_reply_auto_steer.go收尾参考
执行决策
ChatID缺失/丢失的真实入口,并给出“可安全自修复”和“必须改设计边界”的分界线。当前执行点
已经完成一轮跨 gateway / ingress / orchestrator / resume 的静态排查,排除了普通 group text 入口、自身 inbound lane、group card callback 正常路径、auto-steer reply、以及“执行中途被另一 surface 抢占”的主路径;当前重点收敛到:
下一步
surfaceForCardAction(...)的 fallback 使用条件ChatIDsurface 在后续 attach / dispatch 时如何进入业务回复链