8.6 KiB
Q1-Q3: 全局架构
Q1: 一条用户消息从 Slack 发出,到 agent 回复出现在聊天框里,完整路径是什么?
答案
一条消息的完整生命周期横跨四个阶段、两个进程、三个数据库。
阶段一:入站路由(Host / Node)
- Slack channel adapter 收到消息事件,调用
routeInbound(event)(src/router.ts:158) - 应用线程策略(非线程适配器折叠线程,line 166)
getMessagingGroupWithAgentCount()(src/db/messaging-groups.ts:53)通过一次 JOIN 查询中央库v2.db,同时获得 messaging group 行和 wired agent 数量。自动创建规则:只有 @mention 时才自动创建 messaging group(router.ts:184-201),普通消息静默丢弃- 如果 agent 数量为 0:记录到
unregistered_senders表,reason 为no_agent_wired(router.ts:210-246,写入src/db/dropped-messages.ts:16) - Sender resolver hook 执行:upsert 用户行到中央库
users表(router.ts:252) getMessagingGroupAgents()从中央库取出所有 wired agent 行,按priority DESC排序(messaging-groups.ts:193-196)- 对每个 agent 独立评估:
evaluateEngage()检查 trigger 模式(router.ts:364),access gate 检查权限(line 283),通过则执行deliverToAgent()(line 287) resolveSession()(session-manager.ts:92)在中央库sessions表查找/创建 session;必要时创建目录结构并初始化inbound.db+outbound.dbwriteSessionMessage()(session-manager.ts:193)写消息到inbound.db→messages_in表,使用偶数 seq;每次 open-write-closewakeContainer()(container-runner.ts:85)检查已运行容器、去重、spawn Docker 容器
阶段二:容器处理(Container / Bun)
container/agent-runner/src/poll-loop.ts:53—runPollLoop():getPendingMessages()(messages-in.ts:65)只读打开inbound.db,读status='pending'行markProcessing()(line 101)写processing_ack到outbound.db- 格式化消息,调
provider.query()(line 170) - 流式过程中每 500ms poll 后续消息,调用
provider.push() dispatchResultText()解析<message to="..">XML,调sendToDestination()→writeMessageOut()写outbound.db→messages_out,使用奇数 seqmarkCompleted()写outbound.db→processing_acktouchHeartbeat()更新.heartbeat文件的 mtime
阶段三:出站投递(Host / Node)
src/delivery.ts两层轮询:- Active poll(1s):
pollActive()仅扫描正在运行容器的 session - Sweep poll(60s):
pollSweep()扫描所有 active session inflightDeliveriesSet 防止二者竞态(line 50-51)
- Active poll(1s):
drainSession():只读打开outbound.db,读写打开inbound.dbgetDueOutboundMessages()读outbound.db→messages_out- 与
inbound.db→delivered表做去重比对 deliverMessage()调deliveryAdapter.deliver()发到 SlackmarkDelivered()写inbound.db→delivered表- 清理
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_senders,reason 为no_agent_engaged - 投递失败:重试最多 3 次,然后标记永久失败
accumulate模式:不 engage 但ignored_message_policy='accumulate'的消息写入trigger=0,静默存为上下文
Q2: 为什么 Host 用 Node,Container 用 Bun?两套运行时之间的"协议"是什么?
答案
为什么 Host 用 Node:
- Baileys(WhatsApp adapter)依赖
libsignal-node原生绑定和一个久经考验的 WebSocket/HTTP 栈。Bun 的 Node-API 兼容性已有改善,但风险仍然太高 - Host 负责所有平台级网络 I/O,Node 的生态系统在这个领域最成熟
为什么 Container 用 Bun:
bun:sqlite是内建的——不需要每次重建镜像时编译better-sqlite3原生模块- TypeScript 源码直接运行——镜像构建和容器启动时不需要
tsc编译步骤 bun install比npm 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.db(container/agent-runner/src/db/connection.ts:53)并轮询新行。Host 以 {readonly: true} 打开 outbound.db(src/db/session-db.ts:30)并轮询新行。
Host 在 spawn 容器时把 session 文件夹(inbound.db、outbound.db、.heartbeat)挂载到 /workspace,agent group 文件夹挂载到 /workspace/agent(src/container-runner.ts:267-271)。
Q3: inbound.db 和 outbound.db 为什么各只能有一个 writer?journal_mode=DELETE 为什么是必须的?seq 奇偶分配的规则是怎样的?
答案
为什么每文件只有一个 writer:
src/session-manager.ts:7-11 声明了三条跨 mount 不变式:
journal_mode=DELETE— WAL 模式的-shm是内存映射的,VirtioFS 不传播 mmap 一致性。Container 会卡在第一次读到的快照上,永远看不到新消息- Host 每次 open-write-close ——关闭连接会使 Container 的页缓存失效;长连接会冻结在第一次读取时的视图
- 每文件一个 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 = DELETE(session-db.ts:15,23,38 / connection.ts:78)。Container 还额外设置 mmap_size = 0(connection.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:89—nextEvenSeq()只读messages_in的MAX(seq),输出:0→2, 1→2, 2→4, 3→4, 4→6... - Container(奇数):
container/agent-runner/src/db/messages-out.ts:45-56— 读messages_in和messages_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_out(container 发出的) - 偶数 →
messages_in(user/host 发出的)
奇偶性单字段即可区分消息归属,无需 JOIN 查询。