# 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 group(`router.ts:184-201`),普通消息静默丢弃 4. 如果 agent 数量为 0:记录到 `unregistered_senders` 表,reason 为 `no_agent_wired`(`router.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:364`),access 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.db` → `messages_in` 表,使用**偶数 seq**;每次 open-write-close 10. `wakeContainer()`(`container-runner.ts:85`)检查已运行容器、去重、spawn Docker 容器 **阶段二:容器处理(Container / Bun)** 11. `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()` 解析 `` XML,调 `sendToDestination()` → `writeMessageOut()` 写 `outbound.db` → `messages_out`,使用**奇数 seq** - `markCompleted()` 写 `outbound.db` → `processing_ack` - `touchHeartbeat()` 更新 `.heartbeat` 文件的 mtime **阶段三:出站投递(Host / Node)** 12. `src/delivery.ts` 两层轮询: - Active poll(1s):`pollActive()` 仅扫描正在运行容器的 session - Sweep poll(60s):`pollSweep()` 扫描所有 active session - `inflightDeliveries` Set 防止二者竞态(line 50-51) 13. `drainSession()`:只读打开 `outbound.db`,读写打开 `inbound.db` - `getDueOutboundMessages()` 读 `outbound.db` → `messages_out` - 与 `inbound.db` → `delivered` 表做去重比对 - `deliverMessage()` 调 `deliveryAdapter.deliver()` 发到 Slack - `markDelivered()` 写 `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:** 1. `bun:sqlite` 是内建的——不需要每次重建镜像时编译 `better-sqlite3` 原生模块 2. TypeScript 源码直接运行——镜像构建和容器启动时不需要 `tsc` 编译步骤 3. `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 不变式: 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 = 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 查询。