Files
nanoclaw/docs/answers/01-global-architecture.md

8.6 KiB
Raw Permalink Blame History

Q1-Q3: 全局架构


Q1: 一条用户消息从 Slack 发出,到 agent 回复出现在聊天框里,完整路径是什么?

答案

一条消息的完整生命周期横跨四个阶段、两个进程、三个数据库。

阶段一入站路由Host / Node

  1. Slack channel adapter 收到消息事件,调用 routeInbound(event)src/router.ts:158
  2. 应用线程策略非线程适配器折叠线程line 166
  3. getMessagingGroupWithAgentCount()src/db/messaging-groups.ts:53)通过一次 JOIN 查询中央库 v2.db,同时获得 messaging group 行和 wired agent 数量。自动创建规则:只有 @mention 时才自动创建 messaging grouprouter.ts:184-201),普通消息静默丢弃
  4. 如果 agent 数量为 0记录到 unregistered_sendersreason 为 no_agent_wiredrouter.ts:210-246,写入 src/db/dropped-messages.ts:16
  5. Sender resolver hook 执行upsert 用户行到中央库 users 表(router.ts:252
  6. getMessagingGroupAgents() 从中央库取出所有 wired agent 行,按 priority DESC 排序(messaging-groups.ts:193-196
  7. 对每个 agent 独立评估:evaluateEngage() 检查 trigger 模式(router.ts:364access gate 检查权限line 283通过则执行 deliverToAgent()line 287
  8. resolveSession()session-manager.ts:92)在中央库 sessions 表查找/创建 session必要时创建目录结构并初始化 inbound.db + outbound.db
  9. writeSessionMessage()session-manager.ts:193)写消息到 inbound.dbmessages_in 表,使用偶数 seq;每次 open-write-close
  10. wakeContainer()container-runner.ts:85检查已运行容器、去重、spawn Docker 容器

阶段二容器处理Container / Bun

  1. container/agent-runner/src/poll-loop.ts:53runPollLoop()
    • getPendingMessages()messages-in.ts:65)只读打开 inbound.db,读 status='pending'
    • markProcessing()line 101processing_ackoutbound.db
    • 格式化消息,调 provider.query()line 170
    • 流式过程中每 500ms poll 后续消息,调用 provider.push()
    • dispatchResultText() 解析 <message to=".."> XMLsendToDestination()writeMessageOut()outbound.dbmessages_out,使用奇数 seq
    • markCompleted()outbound.dbprocessing_ack
    • touchHeartbeat() 更新 .heartbeat 文件的 mtime

阶段三出站投递Host / Node

  1. src/delivery.ts 两层轮询:
    • Active poll1spollActive() 仅扫描正在运行容器的 session
    • Sweep poll60spollSweep() 扫描所有 active session
    • inflightDeliveries Set 防止二者竞态line 50-51
  2. drainSession():只读打开 outbound.db,读写打开 inbound.db
    • getDueOutboundMessages()outbound.dbmessages_out
    • inbound.dbdelivered 表做去重比对
    • deliverMessage()deliveryAdapter.deliver() 发到 Slack
    • markDelivered()inbound.dbdelivered
    • 清理 outbox/ 目录

每个阶段涉及的数据库

阶段 DB Writer Reader
路由查找 v2.db messaging_groups, messaging_group_agents Host Host
发送者解析 v2.db users Host Host
Session 解析 v2.db sessions Host Host
写入站消息 inbound.db messages_in Host
Container poll inbound.db messages_in Container (RO)
声明处理中 outbound.db processing_ack Container
写回复 outbound.db messages_out Container
读回复 outbound.db messages_out Host (RO)
跟踪投递 inbound.db delivered Host
丢弃记录 v2.db unregistered_senders Host

涉及进程

  • Node host 进程:单进程,运行 src/index.ts,同时跑 router + delivery + host-sweep
  • Docker 容器:每个活跃 session 一个独立容器,运行 bun run /app/src/index.ts

边界情况

  • Messaging group 不存在:仅 @mention 时自动创建,普通聊天静默返回
  • 频道被 owner 拒绝mg.denied_at 已设置):静默丢弃
  • 没有 agent 响应engage:记录到 unregistered_sendersreason 为 no_agent_engaged
  • 投递失败:重试最多 3 次,然后标记永久失败
  • accumulate 模式:不 engage 但 ignored_message_policy='accumulate' 的消息写入 trigger=0,静默存为上下文

Q2: 为什么 Host 用 NodeContainer 用 Bun两套运行时之间的"协议"是什么?

答案

为什么 Host 用 Node

  • BaileysWhatsApp adapter依赖 libsignal-node 原生绑定和一个久经考验的 WebSocket/HTTP 栈。Bun 的 Node-API 兼容性已有改善,但风险仍然太高
  • Host 负责所有平台级网络 I/ONode 的生态系统在这个领域最成熟

为什么 Container 用 Bun

  1. bun:sqlite 是内建的——不需要每次重建镜像时编译 better-sqlite3 原生模块
  2. TypeScript 源码直接运行——镜像构建和容器启动时不需要 tsc 编译步骤
  3. bun installnpm install 快 5-10 倍

两套运行时的"协议"——SQLite不是 IPC

Host 和 Container 从不共享代码或模块(各有自己的包树:pnpm-lock.yaml vs container/agent-runner/bun.lock)。它们之间的"协议"是两个 SQLite 文件:

Host  ──写──▶  inbound.db  ──读──▶  Container (Bun, 只读)
Host  ◀──读──  outbound.db  ◀──写──  Container (Bun)

没有 HTTP、没有 Unix socket、没有 stdin pipe。Container 的 poll-loop.ts{readonly: true} 打开 inbound.dbcontainer/agent-runner/src/db/connection.ts:53并轮询新行。Host 以 {readonly: true} 打开 outbound.dbsrc/db/session-db.ts:30)并轮询新行。

Host 在 spawn 容器时把 session 文件夹(inbound.dboutbound.db.heartbeat)挂载到 /workspaceagent group 文件夹挂载到 /workspace/agentsrc/container-runner.ts:267-271)。


Q3: inbound.dboutbound.db 为什么各只能有一个 writerjournal_mode=DELETE 为什么是必须的seq 奇偶分配的规则是怎样的?

答案

为什么每文件只有一个 writer

src/session-manager.ts:7-11 声明了三条跨 mount 不变式:

  1. journal_mode=DELETE — WAL 模式的 -shm 是内存映射的VirtioFS 不传播 mmap 一致性。Container 会卡在第一次读到的快照上,永远看不到新消息
  2. Host 每次 open-write-close ——关闭连接会使 Container 的页缓存失效;长连接会冻结在第一次读取时的视图
  3. 每文件一个 writer —— DELETE 模式的 journal 文件解链/重建在跨 mount 边界上不是原子的,并发 writer 会损坏数据库

journal_mode=DELETE 为什么负载关键:

container/agent-runner/src/db/connection.ts:12-18 说明WAL 的 -shm 内存映射不会被 VirtioFS 从 host 传播到 guest所以 WAL 模式的 inbound.db 会让 container reader 卡在早期快照上,永远看不到新的 host 消息。

Host 和 Container 两边都在每次打开 DB 时设置 PRAGMA journal_mode = DELETEsession-db.ts:15,23,38 / connection.ts:78。Container 还额外设置 mmap_size = 0connection.ts:55)禁用内存映射 I/O确保每次读都走内核无缓冲路径。

Host open-write-close 每次操作:

src/session-manager.ts:189-192 明确说明不要在多次调用间复用长连接——每次打开、写入、关闭使 SQLite 页缓存失效Container 才能看到最新写入。Container 对 getPendingMessages() 也使用 openInboundDb()(每次新连接),但用长期单例 getInboundDb() 读 host 只在 spawn 时写入一次的表destinations、session_routing

seq 奇偶分配规则:

  • Host偶数 src/db/session-db.ts:89nextEvenSeq() 只读 messages_inMAX(seq)输出0→2, 1→2, 2→4, 3→4, 4→6...
  • Container奇数 container/agent-runner/src/db/messages-out.ts:45-56 — 读 messages_inmessages_out 两者的 MAX(seq)取较大者向上取奇数0→1, 1→3, 2→3, 3→5...

为什么奇偶分配如此关键: seq 是 agent 面对的消息 ID。当 agent 调用 edit_message(seq=5) 时,getMessageIdBySeq()messages-out.ts:90)用奇偶性做快速路由:

  • 奇数 → messages_outcontainer 发出的)
  • 偶数 → messages_inuser/host 发出的)

奇偶性单字段即可区分消息归属,无需 JOIN 查询。