跳到主要内容

数据截至 (上游 commit afe54827dd65)

05 · 巧妙之处、边界与对比

本章讲什么: 读完前四章之后,真正值得抄走的是哪几条;以及 Crush 刻意不做什么、 会在哪里出问题。


1. 巧妙之处(可借鉴的技术)

1.1 用单调序号把「取消覆盖谁」讲清楚

妙在哪: 「取消」在异步系统里是个含义模糊的动作——用户按 Esc 那一刻, 有的 prompt 已经在跑、有的刚被接收、有的还在排队。绝大多数实现要么一刀切全杀, 要么漏杀。

Crush 给每次「接受」发一个全局单调序号,取消时记录一个高水位, 规则简化成一句话:序号 ≤ 水位的被取消,> 水位的不受影响。

于是「一次取消覆盖当时在飞的所有 prompt」和「取消不会毒死之后新提交的 prompt」 这两个看似矛盾的需求,用一个整数比较就同时满足了。

依据:internal/agent/agent.goBeginAcceptedcanceledBySeqCancelendAccepted

1.2 终结事件必须「恰好一次」,并且可相关

妙在哪: 非交互调用方(crush run、脚本、CI)需要一个可靠的「完成了」信号。 Crush 把这件事当契约来做,凑齐了四个条件:

条件手段
不早发401 重试链上的中间失败被 onComplete 闭包吞掉,只发最终结果
不丢PublishMustDeliver 而非尽力送达的 Publish
不重复递归接力时算清「本轮欠不欠自己一个终结事件」
不认错每次调用带 UUID RunID,客户端只认自己的那个

很多 agent 框架栽在第一条和第四条上:会话里有并发轮次时, 光靠 sessionID 分不清哪个完成事件是自己的。

依据:internal/agent/coordinator.gocoordinator.run 里的 onCompleteinternal/cmd/run.go:263runIDinternal/agent/agent.go 的 defer 发布块

1.3 编辑落地的三层保险

妙在哪: 「把模型说的旧代码片段精确落到真实文件上」是编码 agent 的核心难题, Crush 的三层是层层递进而不是简单重试:

第一层:必须先读 + mtime 没变过 ← 防止基于陈旧内容盲改
│失败即拒绝
第二层:精确匹配 + 唯一性检查 ← 多处命中报错,不猜
│找不到
第三层:空白归一化匹配 ← 只接受整行、只接受唯一
└─► 命中后把 new_string 的缩进改造成文件风格
并在返回里明确告知"我做了容错,请核对"

第三层里两个自我约束特别值得学:只接受整行匹配(因为替换按行做)、 歧义就放弃并交还给模型。容错不等于放弃精确性。

依据:internal/agent/tools/edit.goloadExistingFilefindAndReplaceinternal/agent/tools/edit_whitespace.gofindNormalizedMatchesadaptIndentation

1.4 用退出码当扩展协议

妙在哪: hooks 不需要用户学 JSON schema——exit 2 拦住这次调用、 exit 49 终止整轮、stderr 就是理由。写一行 shell 就能接入。

更细的表达(改写参数、显式放行)才走 stdout 的 JSON。 简单需求零学习成本,复杂需求仍有出口,这个分层很实用。

49 这个魔数的选取理由也值得记:避开通用错误码、sysexits 和信号码区间,保证不会被误撞。

依据:internal/hooks/hooks.goHaltExitCodeinternal/hooks/runner.gorunOne

1.5 检查链按代价排序

妙在哪: 两处独立出现了同一个模式,说明是团队的自觉习惯:

  • 权限闸门:全局 skip → 静态白名单 → hook 预批 → 会话自动批准 → 四元组记忆 → 弹窗等人。
  • LSP 自动启动:命令名黑名单 → 文件类型/根标记 → 不可用冷却 → 查 PATH(最贵,放最后)。

注释里甚至写明了为什么 PATH 查找要放最后(可能对每个 PATH 目录做 stat)。 把「便宜且高命中」的判据放前面,是这类闸门型代码的通用心法。

依据:internal/permission/permission.goRequestinternal/lsp/manager.gocanAutoStart

1.6 性能优化自带正确性退路

妙在哪: 流式 markdown 的增量渲染缓存,注释里第一句就承认 「两次渲染拼接一般不等于整篇渲染一次」,然后把边界判定做得极度保守: 一有疑问就退回全量渲染并丢缓存。

同类的还有消息去抖:高频更新合并到 33ms 一次,但终止态同步落盘, 且 Run 退出前强制 FlushAll——快是默认,正确是兜底。

依据:internal/ui/chat/streaming_markdown.go 头部注释; internal/message/message.goshouldFlushNowFlushAll


2. 边界与局限

诚实说明:以下都基于源码可见的事实,不含猜测;标注 (inferred) 的是我的推断。

2.1 刻意不做的

不做什么依据
不在工具里跑网络下载命令(curl/wget/sshinternal/agent/tools/bash.go bannedCommands
不允许提权和包管理器全局安装同上 + blockFuncs
不主动 commit / pushcoder.md.tpl<critical_rules> 第 6、11 条
不做攻击性安全任务同上第 9 条
不猜 URL同上第 10 条
上下文窗口未知时不自动摘要internal/agent/agent.go StopWhen 里 cw == 0 直接返回 false

2.2 已知的弱点与粗糙处

多 agent 尚未成型。 Coordinator 接口里明确留着注释 「SetMainAgent 还没用上,等支持多 agent 时再用」,buildAgentModels 上方还挂着 一条 TODO:支持多 agent 后要按 agent 传各自的模型配置 (internal/agent/coordinator.go:89:805)。现在实际只有 codertask 两个固定角色。

客户端/服务端是实验形态。 需要环境变量 CRUSH_CLIENT_SERVER 显式开启,默认走进程内。

事件流没有回放。 断线期间发布的事件永久丢失,只能靠客户端重新同步 (internal/workspace/workspace.goErrStreamClosed 的注释直言 lost for good)。

只读命令白名单是前缀匹配。 判据是「不含串联符 + 命中前缀 + 后面跟空格/连字符/结尾」 (internal/agent/tools/bash.go:209-221)。这是语法层面的近似,不是语义分析。

hooks 不受命令黑名单约束。 设计上把 hook 视为与 shell alias 同等信任 (internal/hooks/runner.go:158 注释)。所以「配置文件的信任级别」等同于「能执行任意代码」—— crushrc 同理。

去抖窗口内读取需要显式 flush。 message.Service 的接口注释明说: 任何要读最新状态的路径(会话切换等)必须先 Flush/FlushAll, 否则会和去抖计时器竞态而读到旧值。这是一个必须靠调用方遵守的约定, 编译器不会帮你检查。

并发状态机的复杂度很高。 sessionAgent 一个结构体上挂着 dispatchMu / acceptedMu / dispatchMuCreate 三把锁加一个序号生成器, 注释比代码还长。这是被真实 bug 逼出来的复杂度,但也意味着改动这块的门槛很高 (inferred)。


3. 横向对比

同货架的编码 agent 都要回答同样几个问题,Crush 的取舍是这样的:

关切Crush 的做法取舍点
循环谁来写交给 fantasy SDK,自己只挂回调和停止条件少维护一套循环,但受 SDK 抽象约束
编辑怎么落地精确匹配为主 + 空白归一化回退 + 缩进适配不做行号定位,也不做整文件重写
安全边界运行时权限闸门(弹窗)+ 静态命令黑名单不做容器/沙箱隔离,靠人把关
上下文来源兼容读别家的 AGENTS.md/CLAUDE.md/.cursorrules降低迁移成本,但也继承了别家文件的噪声
扩展方式MCP(工具)+ Skills(流程)+ Hooks(拦截)三条正交的路概念多,但各管一层
上下文超限自动摘要 + 带「上文被截断」提示续跑不做外部向量检索
形态TUI 优先;C/S 是可选实验路径单机体验好,多客户端能力还在成型
语言/分发Go 单二进制,内嵌 POSIX 解释器与 SQLite无运行时依赖;代价是二进制大、要自己实现 shell 兼容

三个扩展机制的分工值得单独记一下——它们是正交的,不要混淆:

机制加的是谁触发
MCP工具模型决定调用
Skills流程知识模型看清单后自己决定要不要读正文
Hooks拦截点每次工具调用自动触发

4. 学习路径建议

如果你的目标是「照着做一个自己的编码 agent」,按这个顺序读源码性价比最高:

  1. internal/agent/tools/edit.go + edit_whitespace.go —— 最独立、最能直接抄的部分。
  2. internal/permission/permission.go —— 200 行看懂一个完整的权限闸门。
  3. internal/agent/agent.goRun —— 最难但收获最大;先看入场决策三分支,再看回调表。
  4. internal/agent/prompt/prompt.go + templates/coder.md.tpl —— 上下文工程的完整样本。
  5. internal/backend/backend.go —— 只在你要做多客户端时才需要。

5. 总代码地图

5.1 按主题

主题文件路径符号名
入口main.go / internal/cmd/root.gomainExecutesetupWorkspace
非交互模式internal/cmd/run.gorunCmd
前端门面internal/workspace/workspace.goWorkspace
装配层internal/agent/coordinator.goNewCoordinatorbuildToolsbuildProviderrunSubAgent
主循环状态机internal/agent/agent.gosessionAgent.RunBeginAcceptedCancelSummarize
循环检测internal/agent/loop_detection.gohasRepeatedToolCalls
权限internal/permission/permission.gopermissionService.Request
Hooksinternal/hooks/runner.gointernal/hooks/hooks.goRunner.RunaggregateHaltExitCode
Hooks 缝合internal/agent/hooked_tool.gowrapToolsWithHooks
文件编辑internal/agent/tools/edit.goedit_whitespace.gofindAndReplacenormalizedReplaceadaptIndentation
Shell 执行internal/agent/tools/bash.gointernal/shell/shell.goNewBashToolblockFuncsNewShell
只读命令白名单internal/agent/tools/safe.gosafeCommands
提示词internal/agent/prompt/prompt.gotemplates/coder.md.tplPrompt.BuildpromptData
Skillsinternal/skills/skills.gotracker.goToPromptXMLTracker.MarkLoaded
MCPinternal/agent/tools/mcp/init.goInitializeWaitForInit
LSPinternal/lsp/manager.goManager.StartcanAutoStart
配置internal/config/load.gointernal/shellconfig/load.goLoadlookupConfigsLoadShellConfig
消息持久化internal/message/message.goservice.UpdateFlushAll
事件广播internal/pubsub/broker.goPublishPublishMustDeliver
服务端internal/server/server.gointernal/backend/backend.goinstallHandlerCreateWorkspace
TUIinternal/ui/model/ui.gointernal/ui/chat/streaming_markdown.goUIstreamingMarkdown.Render
数据库internal/db/ConnectQueriermigrations/

5.2 按「我想改 X」

我想……从这里下手
加一个新工具internal/agent/tools/ 新建文件 + .md 描述 → 在 coordinator.gobuildTools 注册 → 在 agent 配置的 AllowedTools 里放行
改权限策略internal/permission/permission.goRequest 短路链
改主提示词internal/agent/templates/coder.md.tpl
改自动摘要阈值internal/agent/agent.go 顶部的三个常量
加一类 hook 事件internal/hooks/hooks.go 的事件常量 + 找一个新的包装点
加一个 HTTP 端点internal/server/server.goinstallHandler + internal/server/proto.go