数据截至 (上游 commit 53ea1e8ba6fd)
巧妙之处、边界与横向对比
本章讲什么: 前六章讲「它怎么运转」,这一章讲「你该带走什么」和「它会在哪崩」。最后给一张跨全书的总代码地图。
1. 巧妙之处(可借鉴的技术)
1.1 用「一文件一写者」把并发问题变成方向问题
妙在哪: 跨进程共享 SQLite 通常意味着锁、重试、SQLITE_BUSY。NanoClaw 直接把问题消掉了——不是解决写竞争,而是让写竞争在结构上不存在。每个文件恰好一个写者,读者用只读句柄。
于是「谁能改这个状态」这个问题永远有一个静态答案,代码里没有一处需要问「现在轮到我写了吗」。
src/session-manager.ts 文件头三条铁律 · src/mailbox/sqlite/session-db.ts:23-45 三个 open 函数
1.2 一个高频状态用文件 mtime,不用 DB
妙在哪: 心跳是最高频的信号(每个 SDK 事件一次)。写 DB 意味着高频跨挂载写;fs.utimesSync 一次系统调用,读侧 statSync 一次。选对存储介质本身就是优化。
container/agent-runner/src/heartbeat.ts:5 touchHeartbeat
1.3 一扇投递门,并且用 DB 当去重账本
妙在哪: 流式输出最容易出的两个 bug 是漏发和重发。NanoClaw 的做法是结构性地只留一扇门(声明了 emitsMidTurnText 的 provider,result 侧连内容都不发),然后用已经存在的 outbound.db 当去重依据——查 (turnStartSeq, segStartSeq] 这个 seq 窗口里有没有同目的地同内容的行。
没有引入进程内的「已发内容账本」,因为账本会和真实写入不同步。查真相源永远比维护副本可靠。
container/agent-runner/src/poll-loop.ts:944 wasWrittenInSeqWindow
1.4 批准满足 hold,永不推翻 deny
妙在哪: 大多数审批系统的实现是「批准 → 直接执行处理器」,时序漏洞就在这:批准和执行之间权限可能已经变了。
NanoClaw 的做法是批准后带着审批行重新进入同一个入口,结构性检查再跑一遍。审批行只是「人已经同意了」这一个事实的凭证,替代不了「你现在还有没有资格」。
加上审批行「解析时删除」这一条,重放天然只能执行一次。
src/guard/guard.ts:48-59 · src/delivery.ts:587 reenterGuardedDeliveryAction
1.5 让「不设防」在类型上不可省略
妙在哪: 安全检查最常见的失效方式不是「写错了」,是「忘了写」。registerDeliveryAction 的重载让你要么给 guard spec、要么给 unguarded(reason) 标记——省略是编译错误。
于是「决定不设防」这件事一定出现在 diff 里,而且 grep "unguarded(" 就是完整清单。
src/guard/types.ts:45 unguarded · src/delivery.ts:554-578
1.6 安全模块写清自己防不住什么
妙在哪: mount-security 的模块头有一整段 “SCOPE — read this before treating the list as protection”,明说黑名单只检查挂载根、不递归,并且以一句「不要把这里的条目读成『这个文件是安全的』」收尾。
大部分安全文档只写防住了什么。写清边界的那份才真的防住了误用。
src/modules/mount-security/index.ts:46-53
1.7 拒绝 agent 时给出教学,而不是错误码
妙在哪: 频率超限的拒绝消息足足十几行,解释了为什么(烧配额、可能被封号)、正确做法是什么(写预检脚本)、怎么学(ncl tasks create --help)、以及需要显式确认的逃生舱。
对一个会读错误消息并自我修正的调用方,错误消息就是 API 文档。
src/modules/scheduling/create.ts:11-23 RECURRENCE_LIMIT_WARNING
1.8 兼容性靠「行为忠实的兜底」而不是版本号
妙在哪: 引入渠道默认值声明时,没声明的旧适配器走一条兜底路径,注释保证它精确复现历史上由 supportsThreads 推导的路由行为。于是「只升级主干」这个动作对用户是零行为变化。
src/channels/channel-defaults.ts · src/channels/adapter.ts:276-283
1.9 纯函数化决策,IO 留在调用方
妙在哪: decideStuckAction 的输入全是普通值(now、心跳 mtime、容器启动时刻、容器状态、认领列表),文件读和 DB 读都在调用方。于是「什么时候该杀容器」这个最难测的逻辑变成一个可以穷举单测的纯函数。
src/host-sweep.ts:63
1.10 把「不可能的组合」变成不可表达
妙在哪: sessionMode: 'per-thread' 直接推导出 threads = 1,不提供独立的 threads 声明。于是「每线程会话 + 线程 id 被剥掉」这个自相矛盾的配置根本写不出来。
比校验更好的是不可表达。
src/channels/adapter.ts:141-155
2. 边界与局限(诚实)
2.1 它刻意不做的事
| 不做 | 理由(源码/README 依据) |
|---|---|
| 多租户 / 团队版 | README 明说「为个人用户而建」;data/ 和 groups/ 都是本地目录 |
| 配置文件体系 | README:「定制 = 改代码」;container_configs 是 DB 表不是 YAML |
| 监控面板 / 调试 UI | README:描述问题给 Claude Code,它来处理 |
| 主干自带渠道适配器 | 「Skills over features」——渠道住在 channels 分支 |
| 应用层的命令白名单 | 安全边界是容器,不是权限检查 |
2.2 会在哪崩 / 会怎么疼
| 问题 | 具体表现 | 依据 |
|---|---|---|
| 容器日志丢失 | --rm 意味着容器退出后日志没了;主机侧只在 debug 级别记 stderr,并保留最后 10 行在非零退出时打出来 | src/container-runner.ts:708、:182-190 |
| 单进程单线程 | 投递轮询在一个 for 循环里逐个会话 await;会话数一多,1 秒的轮询周期实际会被拉长 | src/delivery.ts:137 |
| 轮询延迟是硬底 | 主机 1 秒 + 容器 0.5~1 秒,端到端有秒级基线延迟 | ACTIVE_POLL_MS / POLL_INTERVAL_MS |
| 容器冷启动 | 每个新会话一次 docker run;还要跑 OneCLI ensureAgent + applyContainerConfig | src/container-runner.ts:539 |
| transcript 会撑爆冷恢复 | 长期会话的 .jsonl 越来越大,SDK 每次 resume 全量重载;超过阈值第一轮就可能超过主机的 30 分钟天花板被杀 | providers/types.ts:51-64(所以才有 maybeRotateContinuation) |
| 跨挂载页缓存污染 | Docker Desktop macOS 上会发生;只能靠容器退出 + 全新挂载恢复 | mailbox/sqlite/connection.ts:17-37 |
| 投递计数在内存 | 主机重启后失败计数清零,已经失败 2 次的消息会重新获得 3 次机会 | src/delivery.ts:36-37 |
writeOutboundDirect 的 seq 可能不是偶数 | 注释声称偶数,SQL 是 MAX(seq)+2;容器已写奇数行时结果仍为奇数,理论上有 UNIQUE 撞车后被 INSERT OR IGNORE 静默丢弃的窗口 (inferred) | src/session-manager.ts:466 |
| 挂载黑名单不递归 | 白名单里放了 ~,下面所有凭据都进容 器 | src/modules/mount-security/index.ts:46-53 |
| 正则触发模式 fail-open | engage_pattern 写错时 agent 会对所有消息响应 | src/router.ts:488-493 |
| 没有接线时用户消息被丢 | 只记审计行 + 发审批卡;审批通过后由钩子重放 | src/router.ts:299-336 |
| 两侧 schema 手动同步 | 主机 src/mailbox/sqlite/schema.ts 和容器 container/agent-runner/src/mailbox/sqlite/connection.ts 里各有一份表定义,靠人保持一致 | 两处 CREATE TABLE |
2.3 「小到能看懂」这个卖点的真实尺度
README 说「一个进程、几个源文件」。实际数字:
| 行数 | |
|---|---|
| 主机源码(不含测试) | ~23,600 |
| 主机测试 | ~20,900 |
| 容器 agent-runner(不含测试) | ~5,700 |
约 3 万行生产代码 + 2 万行测试。 这确实比它对标的 OpenClaw(README 称近 50 万行)小一个数量级,但「几个源文件」是修辞——src/ 下有 160+ 个非测试 .ts 文件。
不过一个关键事实是真的: 注释密度极高,很多文件的注释比代码长,关键设计决策都写在源码里。读这个项目的正确方式是读注释。