140 lines
8.6 KiB
Markdown
140 lines
8.6 KiB
Markdown
# 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()` 解析 `<message to="..">` 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 查询。
|