跳到主要内容

数据截至 (上游 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.dbmessages_in待处理的入站消息
inbound.dbdelivered主机记「这条出站消息发过了」+ 平台消息 id
inbound.dbdestinations这个 agent 能发给谁的名字→路由映射
inbound.dbsession_routing单行表:当前会话绑定的 channel/platform/thread
outbound.dbmessages_outagent 要发出去的消息
outbound.dbprocessing_ack容器认领/完成消息的状态
outbound.dbsession_state键值状态,主要存 provider 的会话续接 id
outbound.dbcontainer_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 的文档注释上直接挂了一个 ⚠ 标记:不要重构成复用长连接

铁律三:一个文件只能有一个写者

为什么: DELETE 模式下的 journal 删除跨挂载不是原子的。两个进程同时写,数据库会坏。

所以整套读写方向被钉死:

主机 容器
│ │
inbound.db 写 ─────────────▶ 读(只读打开)
│ │
outbound.db 读(只读打开) ◀───── 写

唯一的例外openOutboundDbRw(src/mailbox/sqlite/session-db.ts:38),注释写明「仅在没有容器在跑时安全」,用于两处:命令闸门的拒绝消息直写,和 sweep 杀掉容器后清理孤儿认领行。

容器侧还多加了一层:关掉 mmap 和页缓存

// container/agent-runner/src/mailbox/sqlite/connection.ts:54-56
const db = new Database(DEFAULT_INBOUND_PATH, { readonly: true });
db.exec('PRAGMA busy_timeout = 5000');
db.exec('PRAGMA mmap_size = 0');

注释说明了取舍:成本是每次查询几微秒,所以干脆全局用。这个 openInboundDb(每次调用新开一个连接,调用方负责 close)专门给轮询用;另一个 getInboundDb(:66,长连接单例)只用于主机在启动时写一次就不动的表(destinationssession_routing)。


4. seq:一个跨两个库的全局序号

4.1 为什么需要

agent 要能说「编辑第 5 条消息」「给第 3 条加个表情」。这个编号必须在入站和出站之间不重复,否则 edit_message #5 会解析到错的行。

4.2 奇偶分工

主机写 inbound.db → 偶数 seq: 2, 4, 6, 8 …
容器写 outbound.db → 奇数 seq: 1, 3, 5, 7 …

主机侧(src/mailbox/sqlite/session-db.ts:91):

export function nextEvenSeq(db: Database.Database): number {
const maxSeq = ...; // MAX(seq) FROM messages_in
return maxSeq < 2 ? 2 : maxSeq + 2 - (maxSeq % 2);
}

容器侧(container/agent-runner/src/db/messages-out.ts:94 writeMessageOut)更讲究——它同时读两个库的 max:

const max = Math.max(maxOut, maxIn);
const nextSeq = max % 2 === 0 ? max + 1 : max + 2; // 下一个奇数

这样两个库的 seq 合起来是一条大致递增的全局时间线,getMessageIdBySeq(:136)可以按 seq 跨两表查找。

注意这个「跨库读」是合法的:容器读 inbound.db 本来就是它的权利,写只写 outbound.db,不违反铁律三。

4.3 一处代码与注释不一致(诚实记录)

writeOutboundDirect(src/session-manager.ts:466)的注释说「偶数的主机 seq 待在容器的奇数空间之外」,但 SQL 是:

(SELECT COALESCE(MAX(seq), 0) + 2 FROM messages_out)

表为空时得到 2(偶,符合注释);但如果容器已经写过奇数行(比如 max=1),算出来是 3,仍是奇数。代码里看不到额外的奇偶校正。

后果 (inferred):messages_out.seq 有 UNIQUE 约束且这里用的是 INSERT OR IGNORE,理论上存在与容器算出的下一个奇数 seq 撞车、导致这条「权限拒绝」提示被静默丢弃的窗口。这条路径只被命令闸门的 deny 分支使用。


5. 状态机:消息的生命周期

容器不能写 inbound.db,所以它没法把 messages_in.status 改成「处理中」。解法:容器在自己的 processing_ack 表里记状态,主机定期同步回来。

主机写入 容器认领 容器完成 主机 sweep 同步
────────────────────────────────────────────────────────────────
messages_in messages_in
status=pending status=completed
│ ▲
│ processing_ack processing_ack │
└──▶ status=processing ──▶ status=completed ───┘
(markProcessing) (markCompleted) (syncProcessingAcks)

相关函数

动作谁做符号
认领一批消息容器markProcessing(container/agent-runner/src/db/messages-in.ts:84)
标记完成容器markCompleted(:88)
脚本闸门跳过容器markScriptSkipped(:92)
同步回 messages_in主机syncProcessingAcks(src/mailbox/sqlite/session-db.ts:140)
容器启动时清理上次崩溃的残留容器clearStaleProcessingAcks(container/agent-runner/src/db/container-state.ts:11)
主机杀容器后清理孤儿认领主机deleteOrphanProcessingClaims(src/mailbox/sqlite/session-db.ts:186)

script-skip:error 这个特殊状态

syncProcessingAcks 认三个终态:completedfailedscript-skip:error。最后一个是「定时任务的预检脚本崩了」,同步时记为 failed。这样重复任务的退避逻辑可以直接从历史行里数出「连续失败次数」,不用额外存计数器(src/modules/scheduling/recurrence.tstrailingFailedRuns)。


6. 心跳:为什么是 touch 文件而不是写 DB

// container/agent-runner/src/heartbeat.ts:5
export function touchHeartbeat(): void {
try { fs.utimesSync(p, now, now); }
catch { try { fs.writeFileSync(p, ''); } catch {} }
}

为什么不写 DB: 心跳是高频的(容器每收到一个 SDK 事件就 touch 一次,见 poll-loop.ts:525)。写 DB 意味着高频的跨挂载写竞争;utimes 一次系统调用就完了,主机只需要 fs.statSync().mtimeMs


7. 看门狗:host-sweep 怎么判断「卡了」

核心是一个纯函数 decideStuckAction(src/host-sweep.ts:63)——所有 IO 在调用方做,决策本身可以单测。

两条独立规则

规则 A(绝对天花板)
心跳年龄 > max(30 分钟, 声明的 Bash 超时)
→ kill-ceiling

规则 B(按认领判卡)
对每条 processing 认领行:
容忍度 = max(60 秒, 声明的 Bash 超时)
if 认领年龄 > 容忍度 且 心跳 mtime <= 认领时刻
→ kill-claim

规则 B 的语义是一句人话:「它认领了这条消息之后,就再没有过任何生命迹象」。心跳比认领时刻新,说明它一直在动,只是这一条慢——不杀。

「声明的 Bash 超时」从哪来

容器在 PreToolUse 钩子里把当前工具和它声明的超时写进 container_state 表(setContainerToolInFlight,container/agent-runner/src/db/container-state.ts:3),PostToolUse 清掉。主机读这张表(getContainerState,src/mailbox/sqlite/session-db.ts:203)来放宽容忍窗口。

这是双库设计给出的一个漂亮红利: 容器不需要「通知」主机「我要跑一个 10 分钟的脚本」,它只是往自己的库里写一行,主机自然读得到。

没有心跳文件时的 fallback

// src/host-sweep.ts:114
const effectiveHeartbeatMs = heartbeatMtimeMs !== 0 ? heartbeatMtimeMs : (containerStartedAtMs ?? 0);

注释解释得很细:「没有心跳文件」有两种含义——刚 spawn 还没来得及写(该给宽限),和跑完一轮但轮询循环从没碰到 SDK 事件(该老化)。用容器 spawn 时间兜底,两种都覆盖到了。

容器 spawn 前还会主动删掉旧心跳文件(src/container-runner.ts:231),否则上一个容器留下的陈旧 mtime 会让新容器刚起来就被杀。

重试退避

resetStuckProcessingRows(src/host-sweep.ts:305):

重试次数 tries: 0 1 2 3 4 5+
退避(秒): 5 10 20 40 80 → 标记 failed(MAX_TRIES=5)

公式是 BACKOFF_BASE_MS * 2^tries(:330)。已经排到未来的行不会被再次加计数——避免每个 tick 都把 process_after 往后推,导致永远轮不到(:321)。


8. 一个真实的坑:页缓存被污染

容器侧轮询里有一段专门处理 SQLite 报「database disk image is malformed」:

// container/agent-runner/src/poll-loop.ts:51
export function isCorruptionError(msg: string): boolean {
return msg.includes('database disk image is malformed')
|| msg.includes('SQLITE_CORRUPT')
|| msg.includes('file is not a database');
}

注释给出了完整的诊断:这几乎总不是真的文件损坏(主机侧 integrity_check 能过),而是 Docker Desktop macOS 的 virtiofs / gRPC-FUSE 一致性 bug——内核给 inbound.db 这个 bind mount 的页缓存,在主机写入过程中锁住了一个撕裂的快照。

关键结论:在容器进程内重开 DB 句柄救不回来,只有全新的容器挂载能。 所以处理方式是:

连续 10 次(约 5 秒)同类错误
→ 停止 touch 心跳(让主机的卡死检测尽快触发)
→ clearInterval
→ 延迟 100ms 后 process.exit(75) // 延迟是为了让日志刷出 Docker 日志驱动

然后 host-sweep 会用全新的挂载重新拉起容器。


9. 这套设计的取舍表

你得到了你付出了
消息天然持久化,容器崩了不丢端到端多 0.5~1 秒的轮询延迟
没有连接管理、没有重连逻辑每个活跃会话每秒都有 DB 开关 + 查询
状态可以直接用 SQL 查(调试友好)必须严守三条铁律,新人很容易改坏
容器可以完全不能主动联网容器侧要自己处理页缓存污染
主机、容器可以用不同运行时(Node vs Bun)两侧 schema 要手动保持一致

什么时候值得抄: 当你的两个进程之间的边界是「一个沙箱边界」,而且你希望沙箱侧的能力越少越好(不能开端口、不能主动连接)时,这个模式非常合适。

什么时候别抄: 高吞吐(每秒上千条)、低延迟(毫秒级)的场景;或者你的挂载实现本来就没有一致性问题(纯 Linux + overlayfs),那用更常规的方案更省心。


10. 代码地图(导航索引)

主题文件路径符号名
三条跨挂载铁律(文件头注释)src/session-manager.ts文件头 doc comment
会话双库 schemasrc/mailbox/sqlite/schema.tsINBOUND_SCHEMA / OUTBOUND_SCHEMA
建库并钉死 journal 模式src/mailbox/sqlite/session-db.tsensureSchema
主机侧开库src/mailbox/sqlite/session-db.tsopenInboundDb / openOutboundDb / openOutboundDbRw
偶数 seq 生成src/mailbox/sqlite/session-db.tsnextEvenSeq
奇数 seq 生成(跨库取 max)container/agent-runner/src/db/messages-out.tswriteMessageOut
容器侧免缓存读container/agent-runner/src/mailbox/sqlite/connection.tsopenInboundDb
心跳 touchcontainer/agent-runner/src/heartbeat.tstouchHeartbeat
工具在飞状态container/agent-runner/src/db/container-state.tssetContainerToolInFlight
卡死纯决策函数src/host-sweep.tsdecideStuckAction
SQLite 无时区时间戳修正src/host-sweep.tsparseSqliteUtc
重试退避src/host-sweep.tsresetStuckProcessingRows
ack 同步src/mailbox/sqlite/session-db.tssyncProcessingAcks
页缓存污染检测container/agent-runner/src/poll-loop.tsisCorruptionError