开源 Agent 套件 ECC 为什么用 Rust 再写一层 ecc2

2026-07-29

本文基于 ECC 仓库 commit 591ab5c(2026-07-29)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/affaan-m/ECC 最新代码与文档为准。

ECC 用 Rust 再写一层 ecc2,搬过去的不是 agent、技能和命令那些 markdown 资产,而是「模型说了不算」的那部分:进程还活着吗、状态写到哪、git 工作区归谁管、哪次工具调用该拦一下。 这条界线不是按语言性能划的,是按「出错时谁兜底」划的。看懂它比看懂 Rust 本身有用——你自己搭 agent 平台时,迟早要做同一道题。

站内的 /learn/ai-jishu-xuanxing//learn/ai-xingneng-youhua/ 讲的是通用方法论:怎么在候选技术之间做取舍、性能问题从哪几个层次找。本篇不重复那套框架,只盯一个具体项目——它把方法论落到实处时,具体切在了哪一刀上,代价是什么。

一、先看清楚两边各自装着什么

ECC 仓库根目录是典型的 harness 增强件形态:agents/ 下 67 个 agent,skills/ 下 281 个技能,commands/ 下 94 个命令,另有 hooks/rules/scaffolds/mcp-configs/ 等目录。这些东西的共同点是——它们本身不执行,是被 harness 读进去、影响模型行为的资产。

ecc2/ 是另一副面孔。README 里第一句就把它定位成「当前的 Rust 版 ECC 2.0 控制面脚手架」,并且明写它是可用于本地实验的 alpha,不是完成品。它的 src 下只有七个模块声明,在 main.rs 开头一眼看完:commsconfignotificationsobservabilitysessiontuiworktree

没有 prompt 目录,没有技能加载器,没有模型调用封装。这个缺席本身就是答案的一半。

组成部分它负责什么对应仓库位置你什么时候会碰到它
markdown 资产层agent、技能、命令、钩子等由 harness 自己加载的内容agents/skills/commands/hooks/在 harness 内部干活时,一直在用
会话生命周期建会话、排队、派生、停止、恢复ecc2/src/session/manager.rs执行 startstopresume
进程托管与输出捕获拉起子进程、逐行收 stdout/stderr、打心跳、落终态ecc2/src/session/runtime.rs会话跑起来之后的全过程
状态存储SQLite 建表与读写,sessionstool_logsession_outputdecision_logecc2/src/session/store.rssessionsstatus
后台守护循环体检、定时任务派发、积压协调、工作区自动合并与清理ecc2/src/session/daemon.rs执行 daemon
git 工作区建工作区、看差异、判断能不能合、冲突与清理ecc2/src/worktree/mod.rs带工作区跑会话时
工具调用记录与风险打分ToolCallEventRiskAssessmentSuggestedActionecc2/src/observability/mod.rs想知道 agent 刚才碰了什么时
配置解析ecc2.toml 的加载与合并ecc2/src/config/mod.rsharness_runnersagent_profiles
终端界面面板、快捷键、多窗格布局ecc2/src/tui/dashboard.rs执行 dashboard

表里的位置都是真实路径,你 clone 下来就能一一对上。

二、被搬进 Rust 的,是哪三类职责

进程与状态:会话在你关掉终端之后还得活着

start 之后发生的事,比看上去绕一层。manager.rs 里并不是直接把 harness 的 CLI 拉起来,而是先用 std::env::current_exe() 拿到自己这个可执行文件的路径,再以 run-session 子命令重新拉起自己一份,把 --session-id--task--agent--cwd 传过去。这个子命令在 CLI 定义里是隐藏的,正常用户不会直接敲。

为什么要绕这一层?因为要脱离调用者的 shell。代码里对两个平台分别做了处理:unix 下在 pre_exec 里调 setsid(),Windows 下拼 DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP 这两个创建标志。目的写在注释里——让 runner 在 start 返回之后仍然继续处理会话。

真正跑 harness 的动作在 run_session 里,它先按 agent 类型解析出程序名(claudecodexopencodegemini,或者你在 ecc2.tomlharness_runners 里自定义的那个),再由 build_agent_command 拼参数。runtime.rs 里的 capture_command_output 接手之后干四件事:把 stdout 和 stderr 分别接管、逐行写进 SQLite、按 heartbeat_interval_secs 打心跳、进程退出时按 exit status 把状态落成 CompletedFailed

它写数据库的方式也有讲究:不是在异步任务里直接摸连接,而是单开一个线程跑 run_db_writer,其他地方通过 channel 发消息、等 ack。SQLite 的并发写在这里被收敛成了一条串行通道。

daemon.rs 是另一半。它启动时先跑 resume_crashed_sessions,用进程是否还活着来判断哪些会话是「记录说在跑,实际已经死了」,把这些会话直接标成失败,再进入按心跳间隔推进的循环:体检会话、派发到点的定时任务、处理远程队列、协调积压、自动合并与清理工作区、激活排队中的工作区会话。每一步都单独 catch 错误、打日志、继续下一步,任何一环炸掉不会把守护进程带走。

git 工作区:并发的前提是隔离

多个 agent 会话同时改同一个仓库,这事在没有隔离时基本无解。ECC 的做法是给会话配 git worktree,配置项就叫 auto_create_worktrees,默认开着,命令行上可以用 --worktree / --no-worktree 覆盖,优先级在 WorktreePolicyArgs::resolve 里写得很直白。数量上限由 max_parallel_worktrees 卡着,超了就进 pending_worktree_queue 表排队,等守护进程有空位再激活。

worktree/mod.rs 有个细节值得说:Cargo.toml 里明明依赖了 git2 这个绑定库,但翻遍 src 找不到一处 git2 的调用,所有 git 动作都是 Command::new("git") 直接拉外部命令完成的,创建草稿 PR 那一处则是调 gh 命令行。这是个务实的选择——分支状态、rebase、冲突中止这些操作,用官方命令行的语义比用库更稳,出了问题也能把同一条命令手敲一遍复现。代价你得认下来:它对宿主机的 git 版本和 PATH 有硬依赖,容器或 CI 里少装一个 gh,相关路径就直接失败;而那个没被用上的 git2 依赖仍然要参与编译,构建时间是白付的。

关于隔离本身的取舍,站内 /learn/agent-gongzuoqu-geli/ 有更一般化的讨论,这里只看 ECC 的落法。

观测与风险:给工具调用打分,而不是给模型打分

observability/mod.rs 里定义了 ToolCallEvent,字段包括工具名、输入摘要、输出摘要、触发说明、耗时和风险分。风险分由 compute_risk 算出来,思路是四项相加再截断:工具本身的基础风险(shell 执行高于写文件,写文件高于改文件)、输入里有没有碰到敏感文件特征(.env、密钥、凭据一类字样)、影响面有多大、这个动作可不可逆。

分数出来之后映射成 SuggestedAction 的四档:AllowReviewRequireConfirmationBlock。分档的门槛在 Configrisk_thresholds 里,可以按项目改。

这套东西的定位要看清楚:它是启发式的字符串特征匹配,不是沙箱,也不是权限系统。它给你的是「这次调用值不值得看一眼」的排序信号。日志层面的通用做法可参考 /learn/agent-keguancha-rizhi/,权限该怎么收敛可参考 /learn/agent-zuixiao-quanxian-sheji/

三、这条界线是按什么划出来的

把三类职责摆在一起看,共同点很清楚:它们都必须在 agent 出错、模型跑偏、终端被关掉之后仍然成立。 进程死没死不能靠模型自述,状态写没写进去不能靠模型确认,分支能不能合不能靠模型判断,工具调用有多危险不能只由发起调用的那一方说了算。这类事情需要一个不参与推理、只负责记录和执行的旁观者,而它的正确性要能被测试覆盖、被类型系统约束。

反过来,另一侧的证据同样明确:ecc2 没有试图接管模型行为,反而主动把 markdown 那一层塞回 harness。build_agent_command 拼命令时会调 apply_shared_harness_runtime_env,给子进程统一注入一批环境变量:

command.env("ECC_SESSION_ID", session_id);
command.env("ECC_HARNESS", &harness_label);
command.env("ECC_WORKING_DIR", working_dir);
command.env("ECC_PROJECT_DIR", working_dir);
command.env("CLAUDE_SESSION_ID", session_id);
command.env("CLAUDE_PROJECT_DIR", working_dir);
command.env("CLAUDE_CODE_ENTRYPOINT", "cli");

同一个函数后半段还有一段条件注入:只要 resolve_ecc_plugin_root 能从可执行文件路径逐级往上找到 ECC 插件根目录,就再补上 ECC_PLUGIN_ROOTCLAUDE_PLUGIN_ROOT。而判断一个目录是不是插件根的函数只有一行:

fn is_ecc_plugin_root(candidate: &Path) -> bool {
    candidate.join("scripts/lib/utils.js").is_file() && candidate.join("hooks/hooks.json").is_file()
}

它认的是 JS 脚本和钩子清单文件。也就是说,Rust 侧不但没打算取代原来那套资产,还专门去找它、把路径递给 harness。

agent_profiles 的处理方式也说明了同一件事。profile 里配的 modelallowed_toolsdisallowed_toolspermission_modeadd_dirs 这些字段,最终都是被翻译成对应 harness 的命令行参数拼上去的——针对 Claude 拼一套,针对 Codex 拼另一套(exec--sandbox--cd 那一组),针对 OpenCode 和 Gemini 各拼各的。Rust 侧只做「把意图翻译成参数」,参数背后的行为归 harness 自己。

所以这条界线可以用一句话概括:能被表达成状态机、文件系统操作和进程信号的,搬进 Rust;需要靠语言理解、需要经常被人手改的,留在 markdown。

四、边界与代价:它明确不管什么

先说项目自己承认的部分。ecc2/README.md 有一节专门列还缺什么:更丰富的多 agent 编排、显式的 agent 间委派与摘要、可视化的工作区与 diff 审阅界面、更强的外部 harness 兼容、更深的记忆与路线规划层、发布打包与安装方案。文末还有一条仓库规矩,大意是不要因为脚手架能编译就把 ecc2/ 说成完成品。这种自我限定在开源项目里不多见,值得当成一手信息看待。

再说结构上的代价,这些是你用之前要自己认下来的:

跨 harness 是有梯度的。 HarnessKind 枚举里认得的类型不少,除了 Claude、Codex、OpenCode、Gemini,还有 Cursor、Kiro、Trae、Zed、FactoryDroid、Windsurf。但 supports_direct_execution 只对前四种返回真,其余的作用主要是靠项目里的 .cursor.zed 这类标记目录做探测。想让 ecc2 真的去驱动它们,得自己在 harness_runners 里把程序名和各个参数开关配全。

它会往你的机器上写东西。 默认的状态库落在用户目录下 .claude/ecc2.db,工作区根目录默认在 /tmp/ecc-worktrees(这个默认值在 Windows 上显然需要你改),后台 runner 的 stderr 会写到工作目录下 .claude/ecc2/logs 里、以 .runner-stderr.log 结尾的文件。加上 git worktree 本身会在磁盘上建实体目录,这套东西的痕迹不算轻。

它会往外发请求,而且两条通道的默认值不一样。 notifications 模块认得五类事件:会话开始、完成、失败、预算告警、审批请求。webhook 这一路默认是关的,你得自己打开开关并填目标地址(代码里区分 Slack 与 Discord 两种载荷格式),一旦打开,会话标题和摘要就以 POST 请求离开了你的机器。桌面通知那一路正相反,默认是开着的——macOS 走 osascript 拼命令(代码里对引号和换行做了转义),Linux 走 notify-send,Windows 没有对应实现所以什么也不发。这个差异值得单独记一下:默认状态下你只会看到本机弹窗,不会有数据外发;真正需要你审一遍的是 webhook 那一栏。

远程派发是要开端口的。 remote serve 会绑一个地址监听、用 bearer token 校验请求,把外部提交的任务排进队列等守护进程处理。它走的是自己手写的明文 HTTP 应答,没有 TLS。这条路适合的场景是本机回环或你已经自己套了反向代理,直接暴露到公网并不合适。

detach 是双刃的。 会话进程脱离了你的 shell,好处是关终端不影响它,坏处是它不会随你的终端一起消失。停止路径上,unix 是先 SIGTERM 再 SIGKILL,Windows 是调 taskkill 带上强制和杀子树。真出问题时,你得知道去哪儿找它。

它不管内容质量。 这套控制面完全不看 agent 写出来的代码对不对、方案合不合理。风险打分看的是工具名和输入串的特征,不是语义。验收标准还是得你自己定。

五、上手清单与几个必踩的坑

先跑通再谈配置。 仓库根目录下进 ecc2 执行 cargo run 就能起来,README 给的常用命令是这些:

# Launch the dashboard
cargo run -- dashboard

# Start a new session
cargo run -- start --task "audit the repo and propose fixes" --agent claude --worktree

# List sessions
cargo run -- sessions

# Inspect a session
cargo run -- status latest

会踩的第一个坑:以为 agent 程序是 ecc2 提供的。 不是。agent_program 解析出来的只是一个程序名,claudecodex 这些得你自己装好、在 PATH 里能直接调起来。没装就是拉起子进程失败,会话直接落 Failed。上手前先在同一个 shell 里手敲一次那个命令,确认能跑。

第二个坑:在非 git 仓库或脏工作区里带 --worktree 起会话。 工作区创建走的是真实 git 命令,仓库状态不对它就建不出来。而 auto_create_worktrees 默认是开的,意味着你不加任何参数也可能触发这条路径。要么先把仓库整理干净,要么显式加 --no-worktree

第三个坑:只看命令行不看守护进程。 排队、定时任务、远程队列、工作区自动合并与清理这些动作,全都在 daemon 循环里推进。你 start 了一堆会话却没跑守护进程,超过并发上限的那些就一直躺在队列表里不动。判断方法很简单:sessions 里状态迟迟不变的,多半是没人推它。

第四个坑:误判风险打分的作用。 它是启发式排序,不是准入控制。Block 这一档也只是「建议」——真正的拦截仍然要靠 harness 侧的权限模式和你自己的确认习惯。把它当成日志里的高亮标记来用,别当护栏。

第五个坑:Windows 上照抄默认配置。 工作区根目录的默认值是 unix 风格路径,进程终止路径也按平台分了两套实现。换平台时,配置里凡是带路径的项都要重新过一遍。

第六个坑:忘了它是 alpha。 版本号在 ecc2/Cargo.toml 里写着,包名是 ecc-tui。这个阶段的项目,数据表结构、配置键、命令参数都可能变。别把生产流程直接架在上面,也别指望升级时状态库无痛迁移。

收尾:接下来该读哪个文件

如果你只想确认「这套划分是否适合我」,按这个顺序读三个文件就够:先 ecc2/README.md 看清项目自己承认的边界,再 ecc2/src/main.rs 开头的模块声明和 CLI 定义看职责范围,最后 ecc2/src/session/manager.rs 里的 run_sessionbuild_agent_command 看它到底怎么跟 harness 打交道。三个文件读完,你会发现它没有任何魔法——就是把「进程、状态、git、记录」这四件苦活接过去,把剩下的还给 harness。

对你自己的系统,可以拿三个问题自检:这件事情出错时,是模型兜底还是程序兜底?这件事情需不需要在会话崩溃之后仍然成立?这件事情是不是每周都要有人手改一版?第一个问题答「程序」、第二个答「需要」、第三个答「不是」,那它就该被写成代码,而不是写进提示词。ECC 划的那一刀,划的就是这三个答案的交集。

顺带一提,这个项目采用 MIT 许可证,仓库在 https://github.com/affaan-m/ECC 。它仍在高频改动,你读到的行数和结构大概率跟本文不完全一致——以仓库为准。

本文属于 ECC 开源 Agent 套件专题(共 40 篇,从选择性安装一直拆到内部机制)。同一批还写了另一种取向的项目——不给你资产、只给你纪律的 superpowers 方法论专题

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。