Skip to content

[code-review] config hot-reload's applyFreshConfig copies the legacy single Fallback field but never the modern Fallbacks array, so editing the fallbacks chain in ggcode.yaml logs 'config refreshed' while the provider keeps rebuilding with the startup snapshot until restart; desktop_stream's Error branch skips the round.Reset() the Done branch performs, leaking the aborted turn's partial text and tool counts into the next turn's EmitRoundSummary; AwaitApproval's select can randomly pick ctx.Done after the decision is already buffered and acknowledged, denying an approval the UI reported as granted #1482

Description

@topcheer

文件行号

internal/agentruntime/desktop_stream.go L135-142(Done 分支 L142 round.Reset())vs L151-160(Error 分支无任何 Reset——只构造 message/mirror.Flush/PushError)+ desktop/wailskit/chat.go:141(imRound 为 ChatBridge 字段——桥生命周期累积器;全文件仅 141/2302 两处引用,无外部 Reset)+ 对照 internal/im/round_state.go:23-27(前端显式 Reset——路径不对称);config_hotreload.go L164-166(applyFreshConfig 仅赋值 Vendors/Fallback/KnightConfig/MaxIterations/SessionTokenBudget/ToolCallBudget 六字段——无 Fallbacks)+ internal/config/config.go:556-567(FallbackChain 同时消费 c.Fallback 与 c.Fallbacks)+ L176-177("config refreshed ... fallback=%v" 日志)+ config_access.go:1508/1546(运行期可写 Fallbacks——单条路径热刷语义已确立);config_hotreload.go L174-177(解锁后读 old.Fallback.IsConfigured()——写路径 setFallback* 族 L1482-1547 均不持 cfgMu);interactions.go L40-47(AwaitApproval select)/L55-61(AwaitAskUser 同构)+ L64-74(ResolveApproval 先 remove 再非阻塞 send——UI 侧认为已批准)

问题

案 A(Med·复核升格 Med-High,静默失效+日志误导):热重载只刷新遗留单条 Fallback——现代 Fallbacks 链永远停留启动快照

  • 用户编辑 ggcode.yaml 的 fallbacks: 数组 → 2s 后日志打出 "config refreshed ... fallback=..." → provider 重建 FallbackChain 仍用旧数组——多级 failover 链静默失效,需重启;单条 fallback: 字段则正常热刷——行为不一致加重迷惑
  • 三重实证:applyFreshConfig 全文无 Fallbacks 赋值;a.cfg 指针从不替换(grep a.cfg = 零匹配——只改旧对象字段);日志只反映 legacy 单条却以整体 "config refreshed" 名义打出
  • 排障成本高:用户看到"已刷新"日志却毫无效果
    :applyFreshConfig 补 old.Fallbacks = fresh.Fallbacks(一行)。

案 B(Med,跨轮泄漏):Error 轮不 Reset round——错误轮残留文本/工具计数泄漏进下一轮 EmitRoundSummary

  • 轮 1 流式中途 provider 报错(网络断/abort)→ round 已累积部分文本+N 工具计数 → Error 分支不 Reset → 轮 2 完成 → Done 的 round.Text() = 轮 1 残留+轮 2 拼接、计数两轮之和 → EmitRoundSummary(IM/移动端推送)发出污染摘要
  • wailskit 桥无外部 Reset(imRound 全仓仅两处引用);emitIfCurrent 代守卫只决定"是否发"不清理 round;对照 IM 侧前端显式 Reset——路径不对称
    :Error 分支补 round.Reset()(在 mirror.PushError 后)。

案 C(Low-Med,已送达仍 Deny):AwaitApproval 的 ctx.Done 分支与已缓冲 decision 的 select 随机竞争——用户批准被确认接收后仍被丢弃

  • Go select 多就绪 case 均匀随机:decision 已入 ch(缓冲 1 必成功、ResolveApproval 已向 UI 返回 true="已批准")且 ctx 同调度点 Done → ~50% 概率选中 Done 分支返回 Deny——UI 显示已批准、实际被拒的可感知矛盾
  • [business-logic] Mobile 审批响应静默丢失 + Host AwaitApproval 无限阻塞(无超时无反馈) #1023 Deny-on-timeout(未送达降级)不同:已送达仍 Deny 超出设计意图;Done 分支 removeApproval 此刻是 no-op 无可挽回
    :Done 分支先非阻塞 drain ch 再降级 Deny。

案 D(Low,真实 race·日志-only 后果):热重载解锁后读 Fallback + 写路径全族不持锁

  • L177 解锁后读 old.Fallback.IsConfigured();更关键:setFallback* 族(L1482-1547)写 a.cfg.Fallback* 均不持 cfgMu——热刷新赋值与 /config set fallback.* 写路径无同步并发(-race 可报)
  • 后果限于 debug 日志瞬时失真+可能丢一次更新(saveAndPatch 落盘可恢复)
    :setFallback* 族补锁;日志读移锁内。

干净项

im_round.go 无问题(单 goroutine 累积器,三侧独立实例)。desktop_adapters.go:nil 接收者/回调判空完备;mirror 半部分主树零调用方(chat.go:2302 传 nil)为预留死代码 Info 级。config_hotreload:重复启动/watcher 生命周期/防抖自愈/坏 YAML last-good/a.cfg 与 agentInst 先行发生安全/vendors 保守门全对。desktop_stream:channel-free 同步转换/顺序固定/usage 无溢出/truncateRunes 边界全对。interactions:Resolve 原子出表双 resolve 不可能/超时职责划分/CancelAll 锁外投递无死锁/FirstPending 随机序不构成错路由。

严重程度

Med-High + Med + Low-Med + Low


独立复核确认:两分支 Reset 对照原文、imRound 全仓 grep 两处、FallbackChain 双字段消费、applyFreshConfig 全字段枚举、a.cfg= 零匹配、日志原文、setFallback 无锁族、select 随机性与 drain 修法、#1023 设计意图边界论证。*

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions