如果你在其他平台看到这篇文章,这可能不是最终版本。为了获得更好的阅读体验(包含最新的评论讨论和勘误),欢迎移步原文

本文部分内容采用 AI 辅助生成,核心素材与推演逻辑均提炼自排查该 Bug 时的真实 Session 对话文本。

越读越烂,返工一下 —— 2026.4.20

一个矛盾的回复

事情的起因源于 cc-connect 的一个 bug:同一分钟内,Telegram 群收到两条互相矛盾的回复

  • 一条说"会话已结束"
  • 另一条说“前一条请求仍在处理”。

显然这是矛盾的。

我很快梳理出了问题的链路,并提交了响应的 PR。

问题排查

第一层,agent 进程没死透。

agent/claudecode/session.goreadLoopbufio.Scanner 读 stdout。Claude CLI 退出时,若有子孙进程继承了 stdout fd,管道不会 EOF,scanner 就一直阻塞。cs.ctx.Done() 触发时,defer 里虽然会关 channel,但那一路逻辑分支没有关 stdout,于是 readLoop 永远不返回。

PR 的修法是加一个 watchdog:ctx 取消或 cmd.Wait() 返回后,给 50ms grace period 让 scanner 把内核 pipe buffer 里最后几行 drain 出来,再强关 fd。

第二层,engine 有两份真相。
core/engine.go 维护一张 interactiveStates map,core/session.goSession 结构体自己也有 busy 字段。
同一个会话的状态被两处分别记录,生命周期还错位—— cleanupInteractiveState 先删 map entry,再异步调 closeAgentSessionWithTimeout

在这个异步的窗口里,新消息查 map 查不到,走"创建新会话"路径;Session 对象还活着,继续往同一个 chat 推流。两条回复由此产生。

但这还没完,让我好奇的是,究竟为什么会产生两份不同的 truth,为了探究这个问题,我让 agent 帮我梳理了一遍, 于是就有了第三层

第三层,四次 commit 累积出来的裂痕。

  • 3b097a8 初始化:Session 带 busy,合理。
  • ecd0e00 引入 interactiveStates map:为了 engine 层调度,合理。
  • f292073 给 cleanup 加 timeout,改成"先删 map 再异步 close":为了避免 cleanup 卡主流程,合理。
  • 4089f78 handleMessage 开始读 map 判断忙态:为了队列化并发消息,合理。

每一步都正确。拼到一起就散架。

AI 的盲区与“局部正确”的代价

这种“单看每步都对,合起来冲突”的架构问题在大型系统中很常见。过去,项目有两道隐形护栏:开发者对项目历史的记忆,以及 PR 合入前的人类 Review

但当 AI 开始批量提交 PR 时,这两道护栏基本失效了。现在开源项目里堆积了大量 “Generated by Claude Code” 的 PR,Reviewer 从“门禁”直接变成了严重堵塞的“瓶颈”。

这并非维护者的水平问题,而是 AI 在开发时放大了三个缺陷:

  1. 不主动追溯 Git Log:AI 眼里的真相只有当前的 Diff 和代码字面量。

  2. 不主动确认设计意图:它默认当前代码已经表达了所有上下文。

  3. 极快的产出速度:AI 编写 PR 的速度,远超人类 Reviewer 理解代码逻辑的速度。

这导致“局部正确”的代码增长率,第一次超越了系统维护“整体一致性”的能力。

AI 究竟漏掉了什么?

在代码层面,interactiveStates 只是一个加锁的并发 safe map。AI 读懂了语法,却读不懂背后的隐性约束“map 里有这个 key,就代表会话在忙,任何时候都不允许出现两份独立的忙态记账”。这种约束很难通过类型系统或接口完全表达,它往往只存在于最初设计者的脑子里。

更糟糕的是,现在写代码的也是 AI。上一个 AI session 关掉后,当时的设计上下文就彻底消失了。六周后下一个 AI 接手,面对的是一个“彻底失忆”的代码库,只能靠当前的字面逻辑去盲猜。代码进入仓库后只记录了“做了什么”,却不记录“为什么这么做”,这正是系统产生语义漂移的根源。

我们能做点什么?

面对这种困境,常见的机械反应是“把 TDD(测试驱动开发)和 Spec 做到位”。这固然对,但没切中要害。

上述的 4 次 commit 都能通过单测和集成测试。要让 TDD 拦截这个 Bug,前提是必须有人先意识到“cleanup 之后,Session 的 busy 状态和 map 的 key 存在强绑定关系”,并写出对应的断言。但当时修改代码的 AI 根本没有意识到这个隐藏约束。TDD 只能验证你已经意识到的规则,无法替你发现未知的隐性约束。

经验丰富的核心开发者,在审阅代码时往往能靠直觉停顿一下:“等等,这个 map 和 Session 里的 busy 是不是在指代同一件事?”这种直觉是在 codebase 里长期沉淀出的隐性知识。AI 读代码极度细致,但恰恰缺乏这种“看它眼熟”的宏观直觉。

框架层面的工程化改良

既然无法依赖 AI 的直觉,那就必须由 Agent 框架从工程层面做出硬性约束,而不是寄希望于 Agent 自身的“自觉”。

1. 强制上下文注入(Hook 层约束)

目前像 Claude Code 这类工具,是否去读 git blame 或翻阅历史 PR,完全取决于 Agent 的行为随机性。框架应该在 Harness 层或 Hook 层做强限制:在编辑一个函数/模块前,强制拉取该模块最近几次的 commit message 和相关 PR 讨论塞进上下文。如果当时改动 cleanup 的 AI 在动手前,被强制喂了“当初引入 map 是为了保证 delete 和 close 原子性”的上下文,它大概率不会轻易把 cleanup 改成异步。

2. 沉淀 Session 快照作为版本资产

人类写代码,意图锁在脑子里;但 AI 写代码,其思考过程、跑过的测试、否决的方案,在当时都是数字化存在的。我们不应该把 Session 当作一次性垃圾丢掉。

框架可以将 AI 产生 commit 时的完整 Session 上下文、思考链路,打包成一份“快照资产”,以 Commit SHA 为索引归档。当下一个 Session 需要修改这块代码时,框架直接把当年的“AI 脑干”整份喂给它。我们不再依赖 commit message 那一两句简短的总结去反推意图,而是直接复读当时的思维快照。

结语

当然,这种方法也有局限。如果 AI 第一次动手时本身就存在逻辑盲点,那归档的快照也无法解决根本问题。框架能做的,是最大程度降低“局部正确导致整体散架”的概率。承认技术的边界,才不会把 Agent 框架当成解决所有架构问题的万灵药。

附录

来自 Claude 的梳理

第 1 步:状态 B(session.busy + TryLock)

Commit: 3b097a8 (2026-02-28 10:38) — init(仓库首提交)

从 day 1 就存在。core/session.goSession 自带 mu sync.Mutex + busy boolTryLock/Unlock 的语义就是「这个会话是否正在处理中」。

Go

// 3b097a8:core/session.go
type Session struct {
Key, AgentSessionID string
CreatedAt, UpdatedAt time.Time
mu sync.Mutex
busy bool
}
func (s *Session) TryLock() bool { ... }
func (s *Session) Unlock() { ... }

第 2 步:状态 A(interactiveStates map)

Commit: ecd0e00 (2026-02-28 22:05) — feat(claudecode): refactor agent into persistent session model …(同一天晚些)

为了支持持久化 claudecode session + 权限状态,在 core/engine.go 里引入 interactiveStates map[string]*interactiveStateinteractiveMu。从这一刻开始,「turn 在跑」有了两份独立的记账。

在这个 commit 里 cleanup 还是原子的(mu 锁住整个 Close + delete),所以两份状态的视图虽然分裂但在时间维度上没有窗口差:

Go

// ecd0e00:core/engine.go cleanupInteractiveState
mu.Lock()
defer mu.Unlock()
state := e.interactiveStates[key]
if ok { state.agentSession.Close() } // 同步,mu 持有
delete(e.interactiveStates, key)

同期的 cmdStop 也只读 map(和今天一样)。这两份真相此时已经存在,但由于 cleanup 在 mu 下串行化,读者要么看到 state-still-present、要么看到 state-gone,没有「map 空但 agent 还在收尾」的中间态。

第 3 步:把「原子 cleanup」拆成「先 delete 后 async-close」

Commit: f292073 (2026-03-04) — feat: add timeout handling for agent session closure

这个 commit 改了 cleanupInteractiveState:先 delete 再释放 interactiveMu,然后把 Close 丢到新 goroutine 里用 10s timeout 等(后面涨到 130s):

Go

// f292073:core/engine.go(diff 精简)
mu.Lock()
state := e.interactiveStates[key]
delete(e.interactiveStates, key) // ← 先删
mu.Unlock() // ← 立刻放锁

if ok && state != nil && state.agentSession != nil {
done := make(chan struct{})
go func() { state.agentSession.Close(); close(done) }()
select {
case <-done: ...
case <-time.After(10 * time.Second):
slog.Error("agent session close timed out (10s), abandoning", ...)
}
}

这是两份真相第一次在时间维度分开。map 项消失、但 session lock 和 Close 仍在生效。原来被 interactiveMu 吸收掉的等待时间现在泄漏成了一个「状态已删但工作仍在」的窗口。

第 4 步:让 handleMessage 的 busy 分支开始读 map

Commit: 4089f78 (2026-03-16) — feat: queue messages when agent is busy instead of discarding them

这之前,handleMessage 走到 busy 分支是无条件回 MsgPreviousProcessing——和 cmdStop 一样只根据一个信号决定回复,两者不会矛盾。

这个 commit 引入了 queueMessageForBusySessionTryLock 失败后先去读 interactiveStates 决定「排队还是回 busy」。这就让 handleMessage 的回复策略变成了 session.busy + interactiveStates 联合判定,而 cmdStop 仍然只看后者。

Go

// 4089f78:core/engine.go(新增)
if e.queueMessageForBusySession(p, msg, interactiveKey) {
if session.TryLock() { go e.drainOrphanedQueue(...) }
return
}
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgPreviousProcessing))

从这一刻起,同一份瞬态「map 空 + session lock 持有」下:

  • handleMessage 走 queue miss → MsgPreviousProcessing(因为 TryLock 失败且 queue 说没 state)
  • cmdStop 走 stopInteractiveSession miss → MsgNoExecution

小结

角色 Commit 日期 作用
引入状态 B 3b097a8 2026-02-28 Session.busy / TryLock,day 1
引入状态 A ecd0e00 2026-02-28 interactiveStates map
打开时间窗口 f292073 2026-03-04 cleanup 改成 delete-first + async-close
暴露矛盾回复 4089f78 2026-03-16 handleMessage busy 分支开始读 map