开源 Agent 套件 ECC 的会话守护进程:进程、落盘与输出怎么管

2026-07-29

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

一个编码 Agent 跑上几小时之后,最容易出事的不是模型输出质量,而是”这个会话到底还活着吗”这个问题没人负责回答。 ECC 把这个问题拆成了三块独立的代码:谁来定期问、状态写在哪、进程吐出来的字往哪去。这三块在仓库里对应三个文件,边界划得相当干脆,值得拿来当长跑 Agent 的工程参考。

ECC 是一套装在编码 Agent 之上的增强件,采用 MIT 许可证。仓库根目录下 agents 有 67 个 agent、skills 有 281 个技能、commands 有 94 个命令,这些是提示词层的资产。而 ecc2 目录是 Rust 写的控制面,Cargo.toml 里 package 名叫 ecc-tui,描述写的是「Agentic IDE control plane with TUI dashboard」。本文只看控制面里最枯燥也最要命的那一小块:ecc2/src/session/ 下的守护、落盘与运行时。

站内已经有三篇讲通用方法论的文章 —— Agent 谎报成功怎么识别 讲的是不信任 Agent 自述结论的判据,Agent 日常运维 讲的是运维动作清单,Agent checkpoint 与长任务续跑 讲的是断点设计的思路。那三篇给的是”你应该怎么想”,本篇给的是”有个项目真这么写了,代码长这样,代价在哪”。两边配着看比较省事。

一、守护进程守的不是进程,是”状态还可信”这件事

ecc2/src/session/daemon.rs 里的 run 函数是整个后台的主循环,结构简单到有点朴素:启动时先做一次崩溃恢复,然后进死循环,一轮里顺序跑七件事,每件事各自 if let Err(e) 记日志,跑完 sleep 一个心跳间隔再来一轮。

pub async fn run(db: StateStore, cfg: Config) -> Result<()> {
    tracing::info!("ECC daemon started");
    resume_crashed_sessions(&db)?;

    let heartbeat_interval = Duration::from_secs(cfg.heartbeat_interval_secs);
    loop {
        if let Err(e) = check_sessions(&db, &cfg) {
            tracing::error!("Session check failed: {e}");
        }
        ...
        time::sleep(heartbeat_interval).await;
    }
}

七件事分别是:心跳检查、到期定时任务派发、远程派发请求处理、积压任务的协调周期、就绪 worktree 自动合并、闲置 worktree 自动清理、排队中的 worktree 会话激活。注意它们是串行的,一轮里任何一件事卡住,后面全都要等;任何一件事报错,只写一条 tracing::error! 就跳过,循环本身不会退。这是个明确的取向:宁可某一类工作暂时失效,也不让守护进程整体倒下。

启动时那次 resume_crashed_sessions 是我认为最有借鉴价值的一段。它把库里所有状态为 Running 的会话拉出来,逐个拿 pid 去探活,探不到的直接改成 Failed 并把 pid 清空。理由很实在:上一次守护进程是被 kill -9 掉的、机器是重启的,那些会话在库里永远停在 Running,界面上看着还在跑,其实早没了。不做这一步,第一次崩溃之后你的会话列表就开始积攒僵尸。

探活的实现在同一个文件底部,只有两个分支:

#[cfg(unix)]
fn pid_is_alive(pid: u32) -> bool {
    if pid == 0 {
        return false;
    }
    // SAFETY: kill(pid, 0) probes process existence without delivering a signal.
    let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
    ...
}

#[cfg(not(unix))]
fn pid_is_alive(_pid: u32) -> bool {
    false
}

kill(pid, 0) 返回 0 算活着,返回 EPERM 也算活着(进程存在但不属于你)。非 Unix 平台那一支直接返回 false —— 这意味着在 Windows 上,守护进程每次启动都会把所有 Running 会话判定为已崩溃并标记 Failed。这不是 bug,是这段代码目前的事实行为,你在 Windows 上用它就得知道这一条。

二、生命周期:一个 pid,两条判定路径

会话的状态枚举定义在 ecc2/src/session/mod.rs,七个值:Pending、Running、Idle、Stale、Completed、Failed、Stopped。它不是随便一个字符串字段,can_transition_to 里写死了合法迁移矩阵,比如 Pending 只能去 Running / Failed / Stopped,Completed 只能再去 Stopped,同状态到同状态永远允许。

这个矩阵真正生效的地方只有一处。store.rsupdate_state 会先查当前状态,不合法就直接 anyhow::bail!("Invalid session state transition");而 update_state_and_pid 不查,直接 UPDATE。前面说的崩溃恢复走的正是后者 —— 把一个卡死的 Running 强行按成 Failed,本来就是一次”非正常”的迁移,走校验路径反而会被自己拦下。两个函数摆在一起看,这个不对称是有意的。

判活的另一条路径是心跳超时,实现在 ecc2/src/session/manager.rsenforce_session_heartbeats_with。它只看状态是 Running 或 Stale 的会话,用 cfg.session_timeout_secs 做窗口,超时之后分两种处理:如果配置里开了自动终止,就尝试杀掉 pid 并写成 Failed;没开,就把状态降级成 Stale,pid 保留。默认配置是不开自动终止的,也就是说默认行为是”标记出来给人看”,而不是”替你做决定”。

这两条路径管的其实是两种死法。pid 探活管的是进程没了但库里还写着在跑;心跳超时管的是进程还在但已经不干活了 —— 后者在 Agent 场景里更常见,比如卡在一个等不到响应的网络调用上。

三、落盘:一个 SQLite 文件扛住全部状态

ecc2/src/session/store.rs 是这三个文件里最大的一个,七千多行,本质是一个 SQLite 封装。StateStore::open 做的事很短:打开连接、PRAGMA foreign_keys = ONbusy_timeout 设 5 秒,然后 init_schema

schema 用的是 CREATE TABLE IF NOT EXISTS 一把梭,表清单能直接读出这个项目管了多少东西:sessions、tool_log、session_profiles、messages、session_output、session_board、decision_log、context_graph_entities / relations / observations / connector_checkpoints、pending_worktree_queue、scheduled_tasks、remote_dispatch_requests、conflict_incidents、daemon_activity。

迁移策略也很朴素:ensure_session_columns 里一长串 if !self.has_column(...)ALTER TABLE ... ADD COLUMN,一列一列补。没有版本号、没有迁移脚本目录,全靠”这一列在不在”来判断。好处是任何一个旧库文件直接打开就能升上来,顺序无关;代价是这个函数会一直变长,而且只能加列不能改列。

daemon_activity 表值得单独说,它整张表就一行,建表时写死了 id INTEGER PRIMARY KEY CHECK(id = 1),初始化时 INSERT OR IGNORE ... VALUES (1)。守护进程每跑完一轮派发就把结果写回这一行,下一轮开头再读出来当决策依据。饱和计数是在 SQL 里算的:

chronic_saturation_streak = CASE
   WHEN ?3 > 0 THEN chronic_saturation_streak + 1
   ELSE 0
END

有任务被推迟就累加,一轮顺利就归零。DaemonActivity 上挂着几个判断方法,prefers_rebalance_first 决定这一轮先重平衡还是先派发,dispatch_cooloff_active 在推迟数达到 2 或连续饱和达到 3 时进入冷却,operator_escalation_required 在冷却生效、连续饱和达到 5 且上次重平衡一个都没挪动时返回 true —— 那就是”自动化已经没办法了,叫人”。把这套退避规则做成持久化状态而不是内存变量,守护进程重启之后不会把已经踩过的坑再踩一遍。

四、运行时输出:一行日志要走两条路

ecc2/src/session/runtime.rs 全文不到四百行,干的是把一个子进程的 stdout / stderr 完整接住。这里有个设计选择比较有意思:它没有让异步任务直接去写 SQLite,而是给每个会话起一个专用的写入线程。

enum DbMessage {
    UpdateState { state: SessionState, ack: oneshot::Sender<DbAck> },
    UpdatePid { pid: Option<u32>, ack: oneshot::Sender<DbAck> },
    AppendOutputLine { stream: OutputStream, line: String, ack: oneshot::Sender<DbAck> },
    TouchHeartbeat { ack: oneshot::Sender<DbAck> },
}

DbWriter::startstd::thread::spawn 拉起一个真线程跑 run_db_writer,那个线程自己 StateStore::open 一份连接,然后 blocking_recv 死等消息。异步侧每次发消息都带一个 oneshot 回执并 await 它,所以写库虽然搬到了别的线程,调用方拿到的仍然是”写成功了没有”的确定答案。这解决的是 rusqlite 连接不能跨 await 随便用、而阻塞 I/O 又不该占着异步 runtime 的老问题。

顺带一个细节:如果那个线程开库失败,它不会 panic,而是把错误字符串存下来,之后每一条消息都回同一个错误。也就是说数据库有问题时,会话不是静默丢数据,而是每次写入都明确失败。

capture_command_output 的流程是:piped 方式 spawn 子进程,拿到 pid 立刻写库、置 Running、打一次心跳,然后起三个 tokio 任务 —— 一个心跳 ticker(MissedTickBehavior::Delay,落后了就往后顺延而不是补打一堆)、两个分别读 stdout 和 stderr 的采集任务。等 child.wait() 返回后 abort 掉心跳、await 两个采集任务,按退出码写 Completed 或 Failed,pid 置空。整个块被包在一个 async 块里,只要它返回 Err,外层就补一次”pid 清空 + 状态 Failed”的兜底。

每一行输出走的是双写:

while let Some(line) = lines.next_line().await? {
    db_writer.append_output_line(stream, line.clone()).await?;
    output_store.push_line(&session_id, stream, line);
}

先落库,再进内存。内存那份是 ecc2/src/session/output.rs 里的 SessionOutputStore,一个按 session_id 分桶的 VecDeque 环形缓冲,加一个 broadcast 通道;容量常量 OUTPUT_BUFFER_LIMIT 是 1000。库里那份也不是无限增长的 —— append_output_line 每插一行就跟一条 DELETE,把该会话超出同一个上限的旧行删掉,然后顺手更新 last_heartbeat_at

最后这个”顺手”很关键:一个话多的进程靠输出就把自己的心跳续上了,心跳 ticker 实际是给安静进程兜底的。仓库里那个 capture_command_output_updates_heartbeat_for_quiet_processes 测试跑的就是 sleep 0.05 这种一声不吭的情况。

内存和库两份缓冲的分工,在 ecc2/src/tui/dashboard.rs 里能看到闭合:切换会话时它调 get_output_lines 把历史读回来,再 replace_lines 灌进内存缓冲,之后靠 broadcast 增量更新。

组成部分它负责什么仓库位置你什么时候会碰到它
守护主循环 run串行跑七类后台工作,出错只记日志不退出ecc2/src/session/daemon.rs后台任务莫名不生效,先看这一轮里它排第几
崩溃恢复 resume_crashed_sessions启动时按 pid 探活,把假 Running 打成 Failedecc2/src/session/daemon.rs机器重启或强杀之后,会话列表突然多出一批 Failed
心跳执法 enforce_session_heartbeats超时的 Running/Stale 会话降级或终止ecc2/src/session/manager.rs会话变成 Stale,但进程其实还在
状态存储 StateStore全部会话、消息、日志、调度的 SQLite 落盘与建表迁移ecc2/src/session/store.rs换机器迁移、想直接查库、或者怀疑状态不一致
单会话写入线程 DbWriter把异步侧的写库请求串成单线程消息队列ecc2/src/session/runtime.rs排查写库报错、或想知道为什么写入是串行的
输出缓冲 SessionOutputStore每会话 1000 行环形缓冲 + broadcast 推送ecc2/src/session/output.rs界面上翻不到更早的日志时
状态机 SessionState七个状态与合法迁移矩阵ecc2/src/session/mod.rs看到 Invalid session state transition 报错时
参数 Config心跳间隔、超时、并发上限等默认值ecc2/src/config/mod.rs调任何一个上面提到的行为之前

五、边界与代价:它明确不管的那些事

这套设计放弃了不少东西,且大多是主动放弃的。

日志不是审计留存。 库里每个会话只保留最后 1000 行,插一行删一行,删掉就没了。想复盘一次三小时长跑的完整输出,得自己在外面另存一份。做这类留存的思路可以参考 Agent 可观察日志怎么设计,那是另一个层面的事,这套代码不负责。

写入是串行的,且没配 WAL。 每个会话一个写线程,busy_timeout 5 秒,多个会话同时刷屏时全都撞在同一个 SQLite 文件上。轻量场景没问题,但这不是为高吞吐日志设计的存储。

pid 探活不区分身份。 kill(pid, 0) 只回答”这个号的进程存不存在”,回答不了”它还是不是你启动的那个”。pid 被系统回收复用的场景下,探活会给出乐观答案。

默认不替你杀进程。 auto_terminate_stale_sessions 默认 false,超时会话只降级成 Stale。想让它自动清场得自己打开,打开之后它会直接杀 pid,这个开关的两端各有各的风险。

状态迁移校验只在一条路径上。update_state_and_pid 的调用方是绕过矩阵的。你要在这套代码上加功能,得自己判断该走哪一个。

守护进程本身没有自我监控。 主循环崩了就是崩了,没有看门狗把它拉起来,恢复逻辑只在下次手动启动时跑。真要长期挂着,得靠外面的 systemd 之类。

还有一条不属于代码缺陷、但属于使用代价:这套东西会往你的机器里写文件。数据库默认落在 home 目录下 .claude 里的 ecc2.db,配置文件在系统配置目录的 ecc2/config.toml,仓库根目录还有 hooks、integrations、mcp-configs 这些会挂到 Agent 上的部分。装之前把这些路径看一遍,别等它已经写了半个月才去翻。涉及外部服务调用的部分同理,各家服务商的规则不同且会调整,以官方最新说明为准。

六、上手与避坑清单

别在 Windows 上依赖崩溃恢复。 pid_is_alive 在非 Unix 分支恒返回 false,守护进程一启动就会把库里所有 Running 判成崩溃。踩这个坑的典型场景是:你重启了守护进程,回头发现会话全成了 Failed,以为是 Agent 出问题,其实是探活压根没实现。避法很直接 —— 在 Windows 上把会话状态当参考而不是判据,真要判活自己另找信号。

改心跳间隔前先想清楚它同时是循环周期。 heartbeat_interval_secs 默认 30,这个值既是采集任务打心跳的节奏,也是守护主循环 sleep 的时长。有人嫌后台任务响应慢就把它调到 1 秒,结果是七类后台工作每秒全跑一遍,SQLite 被反复扫。要提高派发响应速度,先确认瓶颈真在轮询周期上。

session_timeout_secs 默认 3600,对长任务偏短。 一个真做完整重构的 Agent 会话,中间安静超过一小时不是稀奇事。它安静,输出就不刷新心跳,只剩 ticker 在撑;ticker 一旦因为写库失败退出,会话就会被判 Stale。跑长任务之前把这个值按你的实际任务长度往上调。

别用太短的 session id 前缀。 get_session 的查法是先列出全部会话,再找 session.id == id || session.id.starts_with(id)。前缀撞车时它返回列表里碰上的第一个,不报错。多会话并行时,短前缀操作错对象是安静发生的,脚本里请老老实实用完整 id。

别指望翻更早的日志。 1000 行上限在库和内存里都成立,且是插入时立即裁剪。等你发现要看更早的内容时,那些行早就 DELETE 掉了。要留就在启动会话时同步往文件里抄一份。

自动合并 worktree 是个有后果的开关。 auto_merge_ready_worktrees 默认 false。打开之后守护进程每轮都会尝试合并就绪的 worktree,代码里对活跃、冲突、有未提交改动的情况会跳过并记日志,但合并动作本身是无人值守发生的。在你还没建立起改动边界约定之前别急着开,相关判断可以看 Agent 改动边界怎么约定

改了 schema 记得走 has_column 那条路。 这个项目没有迁移版本号,全靠列存在性判断。你直接改 CREATE TABLE 语句对老库是不生效的(IF NOT EXISTS 会跳过),必须在 ensure_session_columns 里补一段 ALTER。这个坑在本地测试时看不出来,因为你的测试库是新建的。

收尾:三个可以当场核对的判断

会话守护这件事,剥到最后就三个问题:状态是谁写的、怎么知道它过期了、过期之后谁来收拾。ECC 的答案是 —— 状态由一个单线程写手串行写进 SQLite,过期由 pid 探活和心跳超时两条独立路径判定,收拾动作默认只标记不执行,要不要真动手交给配置。这套答案不复杂,但每一处都有明确的取舍,没有含糊过去。

想接着往下读的话,顺序建议是:先看 ecc2/src/session/mod.rs 里的 SessionStatecan_transition_to,那是理解其余所有代码的前提;再看 runtime.rs,四百行内能把子进程接管的完整链路走完;store.rs 太长,别通读,带着具体问题去搜函数名更划算;manager.rs 八千行是调度和派发的主体,属于另一个话题。

自检三条:你知道自己那台机器上的 pid_is_alive 走的是哪个分支吗?你的 session_timeout_secs 够不够你最长的那个任务跑完?你有没有在 1000 行之外另存一份输出?三个都答得上来,这套东西才算真装明白了。

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

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