数据截至 (上游 commit 53ea1e8ba6fd)
两个 SQLite 文件当 IPC 用
本章讲什么: NanoClaw 最独特、也最值得抄的一块。主机和容器之间没有 socket、没有管道、没有 stdin、没有文件监听,只有两个 SQLite 文件。本章讲清楚为什么这么选、为了让它工作付出了哪些代价。
1. 它要解决的小问题
主机上跑着一个 Node 进程,容器里跑着一个 Bun 进程。两边要来回传消息。常规选项:
| 选项 | 为什么这里不合适 |
|---|---|
| stdin/stdout 管道 | 容器可能被杀、被重启;管道断了就要重连,而且没有持久化——重启后未处理的消息就丢了 |
| HTTP / gRPC | 要 开端口、要认证;容器本来就被设计成「不能主动连外面」(见第 3 章的出网封锁) |
| 共享文件 + inotify | 跨 Docker 挂载的文件事件在 macOS 上不可靠 |
NanoClaw 的选择:共享目录里放两个 SQLite 文件,双方各自轮询。 消息天然持久化,容器重启后未处理的行还在,状态查询就是一条 SQL。
代价是延迟:主机侧 1 秒轮询,容器侧 0.5~1 秒轮询。对聊天助理来说完全够用。
2. 结构:一个会话 = 一个目录 + 两个库
data/v2-sessions/<agent_group_id>/<session_id>/
├── inbound.db ← 主机写,容器只读
├── outbound.db ← 容器写,主机只读
├── .heartbeat ← 容器 touch,主机看 mtime
├── inbox/ ← 入站附件落盘
└── outbox/ ← 出站附件
这个目录被整体挂到容器的 /workspace(src/container-runner.ts:485)。
两个库各有什么表
定义在 src/mailbox/sqlite/schema.ts:2(INBOUND_SCHEMA)和 :48(OUTBOUND_SCHEMA):
| 库 | 表 | 干什么 |
|---|---|---|
| inbound.db | messages_in | 待处理的入站消息 |
| inbound.db | delivered | 主机记「这条出站消息发过了」+ 平台消息 id |
| inbound.db | destinations | 这个 agent 能发给谁的名字→路由映射 |
| inbound.db | session_routing | 单行表:当前会话绑定的 channel/platform/thread |
| outbound.db | messages_out | agent 要发出去的消息 |
| outbound.db | processing_ack | 容器认领/完成消息的状态 |
| outbound.db | session_state | 键值状态,主要存 provider 的会话续接 id |
| outbound.db | container_state | 单行表:当前在跑哪个工具、声明的超时是多久 |
注意 delivered 表在 inbound.db 里。 主机不能写 outbound.db,所以「已投递」这个由主机产生的事实只能记在自己的库里。这不是设计瑕疵,是「一文件一写者」铁律的直接后果。
3. 三条跨挂载铁律
这三条写在 src/mailbox/sqlite/session-db.ts:1-7 的文件头注释里,是整个方案能不能跑起来的分水岭。
铁律一:必须用 journal_mode = DELETE,不能用 WAL
// src/mailbox/sqlite/session-db.ts:15 ensureSchema
db.pragma('journal_mode = DELETE');
为什么: WAL 模式依赖一个 -shm 共享内存文件,它是 mmap 的。VirtioFS(Docker Desktop for Mac、Colima、Podman Machine 用的挂载实现)不传播 host→guest 的 mmap 一致性。用 WAL 的话,容器侧的读连接会永远冻结在它第一次读到的快照上——静默地再也看不到任何新消息。
容器侧的注释(container/agent-runner/src/mailbox/sqlite/connection.ts:10-16)明确点名了这个失败模式,还提到有一个 scripts/sanity-live-poll.ts 做过实测验证。
铁律二:主机「开→写→关」,每次操作一遍
// src/session-manager.ts:383 withInboundDb 的形状
const db = openInboundDb(agentGroupId, sessionId);
try { return fn(db); } finally { db.close(); }
为什么: close() 会让容器那边的页缓存失效。如果主机保持一个长连接,容器的视图会冻结在它第一次读的时刻。
writeSessionMessage 的文档注释上直接挂了一个 ⚠ 标记:不要重构成复用长连接。