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

140 lines
8.6 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 poll1s`pollActive()` 仅扫描正在运行容器的 session
- Sweep poll60s`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 用 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 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 查询。