docs: add detailed answers for 21 learning roadmap questions
This commit is contained in:
139
docs/answers/01-global-architecture.md
Normal file
139
docs/answers/01-global-architecture.md
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
# 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 查询。
|
||||||
173
docs/answers/02-routing-and-sessions.md
Normal file
173
docs/answers/02-routing-and-sessions.md
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
# Q4-Q6: 路由与会话
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q4: 一个 messaging group 怎么决定路由到哪个 agent group?没匹配上怎么办?
|
||||||
|
|
||||||
|
### 答案
|
||||||
|
|
||||||
|
路由发生在 `src/router.ts:158` 的 `routeInbound()` 函数中,分多步决策:
|
||||||
|
|
||||||
|
**第一步:快速截断** — `getMessagingGroupWithAgentCount(channelType, platformId)`(line 176)一次数据库读就能判断频道是否已知、是否有 agent。如果没有匹配的 messaging group 且没有被 @mention,直接静默返回(line 211)。
|
||||||
|
|
||||||
|
**第二步:记录无法投递的消息** — 如果 agentCount 为 0 但被 @mention 了(或频道已通过审批但没 agent):
|
||||||
|
- 如果 `mg.denied_at` 被设置(owner 拒绝了这个频道)→ 静默丢弃(line 212-217)
|
||||||
|
- 否则 → 记录到 `unregistered_senders` 表,reason 为 `no_agent_wired`(line 221)
|
||||||
|
- 可选触发 `channelRequestGate` 升级给 owner(line 231-238)
|
||||||
|
|
||||||
|
**第三步:Fan-out** — 如果 agentCount > 0,`getMessagingGroupAgents()`(line 256)取出所有 wired agent 行,按 `priority DESC` 排序。
|
||||||
|
|
||||||
|
**第四步:逐个评估 engage 条件**(`evaluateEngage()`,line 364):
|
||||||
|
|
||||||
|
| 模式 | 行为 |
|
||||||
|
|------|------|
|
||||||
|
| `pattern` | 对消息文本做正则匹配;`'.'` 匹配所有消息 |
|
||||||
|
| `mention` | bot 必须被 @mention |
|
||||||
|
| `mention-sticky` | @mention **或者** 此 (agent, mg, thread) 组合已存在活跃 session(line 384-390) |
|
||||||
|
|
||||||
|
**第五步:access gate + senderScope gate**(line 283-284)做模块级策略拒绝。
|
||||||
|
|
||||||
|
**第六步:交付决策:**
|
||||||
|
- **engages + 通过 gate** → `deliverToAgent()` 且 `wake=true`
|
||||||
|
- **不 engage 但 `ignored_message_policy='accumulate'`** → `deliverToAgent()` 且 `wake=false`,写入 `trigger=0` 作为静默上下文
|
||||||
|
- **都不满足** → 丢弃,记录 `no_agent_engaged`
|
||||||
|
|
||||||
|
### `unregistered_senders` 表的作用
|
||||||
|
|
||||||
|
尽管模块名叫 `dropped-messages.ts`,实际表名是 `unregistered_senders`(`src/db/dropped-messages.ts:16-38`)。它是核心审计基础设施:
|
||||||
|
- 记录结构性丢弃(no agent wired / no engagement)和策略拒绝(access gate 拒绝)
|
||||||
|
- 用 `ON CONFLICT DO UPDATE` 做 per-channel 聚合,统计 `message_count`、更新 `last_seen`
|
||||||
|
- 可通过 `ncl dropped-messages list` 只读查看
|
||||||
|
- `reason` 字段记录丢弃原因
|
||||||
|
|
||||||
|
### 边界情况
|
||||||
|
|
||||||
|
- **多个 agent wired 到同一频道**:每个 agent 独立评估,一个频道消息可以唤醒多个 agent 容器
|
||||||
|
- **mention-sticky + 线程平台**:`adapter.subscribe()` 被调用一次(line 306),订阅线程以便后续消息不需重新 @mention
|
||||||
|
- **pattern 模式的正则错误**:`evaluateEngage()` 捕获正则错误并 fail-open(line 378),让 admin 看到 agent 响应并可以修复
|
||||||
|
- **access gate 拒绝 + accumulate**:消息不累积(line 310-316),这是安全决策——静默存储越权消息会破坏 gate 的目的
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q5: Session 什么时候创建?三种隔离模式的复用逻辑有什么不同?
|
||||||
|
|
||||||
|
### 答案
|
||||||
|
|
||||||
|
Session **懒创建**——只在第一条匹配 (agent group, messaging group, thread) 的消息到达时创建。创建由 `deliverToAgent()` 调用 `resolveSession()` 触发(`src/router.ts:415`)。
|
||||||
|
|
||||||
|
三种模式实现于 `src/session-manager.ts:92-133`:
|
||||||
|
|
||||||
|
### `agent-shared`(完全不隔离)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT * FROM sessions WHERE agent_group_id = ? AND status = 'active' ORDER BY created_at DESC LIMIT 1
|
||||||
|
```
|
||||||
|
(`src/db/sessions.ts:56-59`)
|
||||||
|
|
||||||
|
- 完全忽略 `messagingGroupId` 和 `threadId`,所有 wired 的 messaging group 共用一个 session
|
||||||
|
- GitHub PR 评论和 Slack 消息出现在同一个对话中
|
||||||
|
- 如果没有 session,创建一个且 `messaging_group_id = null`
|
||||||
|
|
||||||
|
### `shared`(per-channel)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT * FROM sessions WHERE agent_group_id = ? AND messaging_group_id = ? AND thread_id IS NULL AND status = 'active'
|
||||||
|
```
|
||||||
|
(`src/db/sessions.ts:36-53`)
|
||||||
|
|
||||||
|
- 折叠所有线程——同一 messaging group 内使用一个 session
|
||||||
|
- 每个 messaging group 有自己的 session,但共享 agent group workspace
|
||||||
|
|
||||||
|
### `per-thread`(最严格隔离)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT * FROM sessions WHERE agent_group_id = ? AND messaging_group_id = ? AND thread_id = ? AND status = 'active'
|
||||||
|
```
|
||||||
|
|
||||||
|
- 每个 Slack thread / Discord thread 一个独立 session
|
||||||
|
- 支持线程的 adapter(Slack、Discord)会把 `session_mode: 'shared'` 的 wiring 强制重写为 `per-thread`(`router.ts:410-413`),除非 wiring 明确使用 `agent-shared`
|
||||||
|
- DM 除外(`mg.is_group === 0`),不做强制重写
|
||||||
|
|
||||||
|
### Session 创建的副作用
|
||||||
|
|
||||||
|
首次创建时(`created: true`,line 128-131):
|
||||||
|
1. `createSession()` 写中央库 `sessions` 表
|
||||||
|
2. `initSessionFolder()`(line 136-143)在 `data/v2-sessions/<agent_group_id>/<session_id>/` 下创建目录,初始化 `inbound.db` + `outbound.db` 的 schema
|
||||||
|
|
||||||
|
### 边界情况
|
||||||
|
|
||||||
|
- **Fan-out 到多个 agent**:每个 agent 有自己的 session。`resolveSession()` 按 `agent_group_id` 限定查询范围
|
||||||
|
- **容器重启后 session 复用**:session 在中央库持久存在。同一 (agent, mg, thread) 的新消息触发 `resolveSession()`,找到已有 session 返回 `created: false`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q6: 容器 idle 后被 kill,用户再发消息怎么被唤醒?`on_wake` 为什么不会被旧容器偷走?
|
||||||
|
|
||||||
|
### 答案
|
||||||
|
|
||||||
|
**唤醒路径有两个:**
|
||||||
|
|
||||||
|
1. **路由时立即唤醒:** `wakeContainer(session)` 在 `src/router.ts:478` 被调用(仅对 `wake=true` 的消息)→ `container-runner.ts:85`:
|
||||||
|
- 检查 `activeContainers.has(session.id)` — 已在运行则直接返回
|
||||||
|
- 检查 `wakePromises.get(session.id)` — 去重并发唤醒(line 90-94)
|
||||||
|
- 调用 `spawnContainer()`,重新构建所有 mount、组合 CLAUDE.md、生成 container.json,spawn Docker 容器
|
||||||
|
|
||||||
|
2. **Host sweep 兜底唤醒:** `src/host-sweep.ts:180-186`:
|
||||||
|
```
|
||||||
|
dueCount = countDueMessages(inDb)
|
||||||
|
if dueCount > 0 && !isContainerRunning(session.id) → wakeContainer(session)
|
||||||
|
```
|
||||||
|
处理 `wakeContainer()` 返回 `false`(瞬时失败)的情况——消息保持 pending,下次 60s sweep 周期重试。
|
||||||
|
|
||||||
|
### `on_wake` 防竞态机制
|
||||||
|
|
||||||
|
**第一步:`on_wake` 列** — `docs/db-session.md:51`:`messages_in` 表有 `on_wake INTEGER NOT NULL DEFAULT 0` 列。当 `restartAgentGroupContainers()` 被调用且带有 `wakeMessage` 时(`container-restart.ts:28-41`),写入 `onWake: 1`。
|
||||||
|
|
||||||
|
**第二步:Container 端的 `isFirstPoll` 过滤器** — `container/agent-runner/src/db/messages-in.ts:65-97`:
|
||||||
|
```sql
|
||||||
|
AND (on_wake = 0 OR ?1 = 1)
|
||||||
|
```
|
||||||
|
- `isFirstPoll` 仅在容器第一次 poll 时为 `true`(`poll-loop.ts:71-74`)
|
||||||
|
- `isFirstPoll = true` 时:过滤条件变为 `on_wake = 0 OR true` → 所有 pending 消息都可见
|
||||||
|
- `isFirstPoll = false` 时:过滤条件变为 `on_wake = 0 OR false` → `on_wake=1` 的行不可见
|
||||||
|
- **正在被 kill 的旧容器已过第一轮 poll**,即使它撑到下一轮 poll,也看不到 `on_wake=1` 的消息
|
||||||
|
|
||||||
|
### `killContainer` 的 `onExit` 回调机制
|
||||||
|
|
||||||
|
`src/container-runner.ts:193-207`:
|
||||||
|
```typescript
|
||||||
|
export function killContainer(sessionId, reason, onExit?) {
|
||||||
|
const entry = activeContainers.get(sessionId);
|
||||||
|
if (onExit) entry.process.once('close', onExit); // 先注册回调
|
||||||
|
stopContainer(entry.containerName); // 再发送 kill 信号
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`container-restart.ts:47-51` 中的 `onExit` 回调:
|
||||||
|
```typescript
|
||||||
|
() => {
|
||||||
|
const s = getSession(session.id);
|
||||||
|
if (s) wakeContainer(s);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
这个设计保证:
|
||||||
|
1. 旧容器**完全退出**(进程终止,mount 释放)后才触发新容器 spawn
|
||||||
|
2. 新容器的 `clearStaleProcessingAcks()`(`connection.ts:175`)清掉旧容器遗留的 `processing` 声明
|
||||||
|
3. 新容器第一轮 poll 时 `isFirstPoll=true`,捡起 `on_wake=1` 消息
|
||||||
|
|
||||||
|
### 容器从 idle 到 kill 到重新唤醒的生命周期
|
||||||
|
|
||||||
|
Host sweep 在 `host-sweep.ts:147-211` 中判断容器健康:
|
||||||
|
|
||||||
|
1. **天花板检查**(line 99-105):heartbeat mtime 年龄 > max(30min, Bash 超时) → kill
|
||||||
|
2. **per-claim 卡住检查**(line 107-115):每个 `processing_ack` 行,如果 claim 时间 > max(60s, Bash 超时) 且从此以后 heartbeat mtime 没有前进 → kill
|
||||||
|
3. **崩溃容器清理**(line 199-201):容器已死但 `processing_ack` 仍有遗留 → `resetStuckProcessingRows()` 用指数退避(基数 5s × 2^tries,最多 5 次)重新调度消息
|
||||||
|
4. **孤儿 claim 清理**(line 319):kill 后 `deleteOrphanProcessingClaims()` 删掉 `outbound.db` 中的 processing 行,防止 sweep 立即 kill 新 spawn 的容器
|
||||||
|
|
||||||
|
### 边界情况
|
||||||
|
|
||||||
|
- **新容器还没有 heartbeat**:天花板检查在 `heartbeatMtimeMs === 0` 时跳过(line 92-93)
|
||||||
|
- **`wakePromises` 去重**:spawn 过程中来多条消息,只 spawn 一个容器
|
||||||
|
- **Spawn 前清 heartbeat 文件**:`container-runner.ts:155` `fs.rmSync(heartbeatPath(...), {force: true})` 清除孤儿 heartbeat
|
||||||
|
- **Bash 自定义超时**:天花板和 claim-stuck 容忍度都扩展到 `max(DEFAULT, declaredTimeoutMs)`,确保长时间 Bash 工具不被误杀
|
||||||
166
docs/answers/03-permissions-and-security.md
Normal file
166
docs/answers/03-permissions-and-security.md
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
# Q7-Q9: 权限与安全
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q7: 用户身份怎么确定?owner / admin / member 三级权限检查在哪里完成?
|
||||||
|
|
||||||
|
### 答案
|
||||||
|
|
||||||
|
**用户身份确定(两步):**
|
||||||
|
|
||||||
|
`src/modules/permissions/index.ts:67-103` — `extractAndUpsertUser` 函数:
|
||||||
|
|
||||||
|
1. **解析原始 handle:** 从入站消息 JSON payload 的三处位置按优先级查找:`senderId`、`sender`、`author.userId`(chat-sdk-bridge 的嵌套格式)。三处都没有返回 `null`
|
||||||
|
2. **命名空间标准化:** 如果原始 handle 已含 `:` 前缀(如 `slack:U0ABC`),直接使用;否则前缀 `channelType:` → 全局唯一 `userId`
|
||||||
|
3. **Upsert:** `users` 表中不存在则创建(`db/users.ts:13-22`),`kind` 记录 channel 类型,`display_name` 从消息中提取
|
||||||
|
|
||||||
|
这个 resolver 通过 `setSenderResolver(extractAndUpsertUser)` 注册到 router(`index.ts:171`),在 agent 解析**之前**执行(`router.ts:252`),确保即使后续被拒绝访问,`users` 行也已存在。
|
||||||
|
|
||||||
|
**三级权限表:**
|
||||||
|
|
||||||
|
| 表 | 用途 | 关键字段 |
|
||||||
|
|-----|------|---------|
|
||||||
|
| `users` | 用户身份 | `id`(命名空间化的userId),`kind`,`display_name` |
|
||||||
|
| `user_roles` | 特权角色 | `user_id`,`role`(owner/admin),`agent_group_id`(NULL=全局) |
|
||||||
|
| `agent_group_members` | 非特权成员 | `user_id`,`agent_group_id` |
|
||||||
|
|
||||||
|
**`canAccessAgentGroup` 完整逻辑:**
|
||||||
|
|
||||||
|
`src/modules/permissions/access.ts:21-28` — 五步短路求值:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function canAccessAgentGroup(userId: string, agentGroupId: string): AccessDecision {
|
||||||
|
if (!getUser(userId)) return { allowed: false, reason: 'unknown_user' }; // Gate 0
|
||||||
|
if (isOwner(userId)) return { allowed: true, reason: 'owner' }; // Gate 1
|
||||||
|
if (isGlobalAdmin(userId)) return { allowed: true, reason: 'global_admin' };// Gate 2
|
||||||
|
if (isAdminOfAgentGroup(userId, agentGroupId)) return { allowed: true, reason: 'admin_of_group' };// Gate 3
|
||||||
|
if (isMember(userId, agentGroupId)) return { allowed: true, reason: 'member' };// Gate 4
|
||||||
|
return { allowed: false, reason: 'not_member' };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**查库函数**(`db/user-roles.ts`):
|
||||||
|
- `isOwner`:`role='owner' AND agent_group_id IS NULL`(line 36-41)—— owner 必须全局
|
||||||
|
- `isGlobalAdmin`:`role='admin' AND agent_group_id IS NULL`(line 43-48)
|
||||||
|
- `isAdminOfAgentGroup`:`role='admin' AND agent_group_id=<id>`(line 50-55)
|
||||||
|
|
||||||
|
**`isMember` 的隐式 admin 语义**(`db/agent-group-members.ts:28-36`):
|
||||||
|
先检查 `isOwner || isGlobalAdmin || isAdminOfAgentGroup` 再查 `agent_group_members` 表——owner 和 admin **自动成为 member**,不需要额外插入行。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q8: 陌生人在群里 @bot,系统怎么决定忽略、审批、还是直接响应?
|
||||||
|
|
||||||
|
### 答案
|
||||||
|
|
||||||
|
**`unknown_sender_policy` 三态**(`src/types.ts:31`,默认 `'strict'`):
|
||||||
|
|
||||||
|
| 值 | 行为 |
|
||||||
|
|-----|------|
|
||||||
|
| `public` | 完全跳过访问检查。`router.ts` access gate 在 `index.ts:175` 直接返回 `{ allowed: true }` |
|
||||||
|
| `strict` | 非 member 的消息静默丢弃,记录到 `unregistered_senders`,不发送通知 |
|
||||||
|
| `request_approval` | 非 member 的消息被丢弃 + 发起一轮审批流程:DM 一个 admin/owner,展示 Approve/Deny 卡片 |
|
||||||
|
|
||||||
|
注意:即使 `unknown_sender_policy='public'`,`sender_scope='known'` 仍可作为更严格的叠加层(`router.ts:284` `senderScopeGate`)。
|
||||||
|
|
||||||
|
**完整决策链路**(`src/router.ts` `routeInbound`):
|
||||||
|
|
||||||
|
1. Message interceptor 先检查拦截器 hook 是否消费了该消息
|
||||||
|
2. Messaging group 解析 → sender resolver → agent fan-out
|
||||||
|
3. **Access gate**(line 283):就是 `canAccessAgentGroup` —— 此处 `unknown_sender_policy` 生效
|
||||||
|
4. **Sender scope gate**(line 284):per-wiring 的 `sender_scope='known'` 额外检查
|
||||||
|
5. 如果 access gate 拒绝 → `handleUnknownSender`
|
||||||
|
|
||||||
|
### sender-approval 完整流程
|
||||||
|
|
||||||
|
**Phase 1: 触发与去重**(`permissions/index.ts:113-169`):
|
||||||
|
- 记录 `dropped_message`,reason=`unknown_sender_request_approval`
|
||||||
|
- 调用 `requestSenderApproval`
|
||||||
|
- **去重门控**(`sender-approval.ts:59-65`):检查 `pending_sender_approvals` 表中的 `UNIQUE(messaging_group_id, sender_identity)` —— 同一个人已有 pending 卡片时,后续消息静默丢弃
|
||||||
|
|
||||||
|
**Phase 2: 选择审批者**(`sender-approval.ts:67-87`):
|
||||||
|
- `pickApprover(agentGroupId)` → scoped admins → global admins → owners(`primitive.ts:76-93`)
|
||||||
|
- `pickApprovalDelivery`(`primitive.ts:103-119`):遍历审批者,调用 `ensureUserDm` 找到可 DM 的人,优先同 channel 类型
|
||||||
|
|
||||||
|
**Phase 3: 创建 pending + 发送卡片**:
|
||||||
|
- 标题和内容:`"New sender / <name> wants to talk to your agent. Allow?"`
|
||||||
|
- `ask_question` 卡片,通过 `deliveryAdapter.deliver` 发到审批者 DM
|
||||||
|
|
||||||
|
**Phase 4: 审批响应**(`index.ts:225-283`):
|
||||||
|
|
||||||
|
**APPROVE:**
|
||||||
|
1. **授权验证**(line 234-244):检查点击者是不是 `approver_user_id` **或**有 admin 权限——防止随机用户通过转发卡片自我授权
|
||||||
|
2. `addMember` 添加到 `agent_group_members`(line 249-254)
|
||||||
|
3. **先删除 pending 行**(line 264),**再**重新调用 `routeInbound(event)`(line 268)——此时重试经过 access gate,user 已是 member
|
||||||
|
|
||||||
|
**DENY:**
|
||||||
|
- 仅删除 `pending_sender_approvals` 行
|
||||||
|
- **不创建拒收列表**——同一发送者的新消息会重新触发新审批卡片
|
||||||
|
|
||||||
|
### 失败模式
|
||||||
|
|
||||||
|
- 没有 owner/admin → 消息永久丢弃
|
||||||
|
- 没有任何审批者可达(无 DM channel)→ 同上
|
||||||
|
- 没有 delivery adapter → pending 行仍创建(可手动查看),但卡片不发送
|
||||||
|
- 卡片发送异常 → error log,消息已丢弃
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q9: Agent 在容器里能用 `ncl` 命令吗?能查其他 agent group 的数据吗?`cli_scope` 的三个值在哪里被检查?
|
||||||
|
|
||||||
|
### 答案
|
||||||
|
|
||||||
|
**`cli_scope` 的三个值**(`container_configs` 表,迁移 015 添加,默认 `'group'`):
|
||||||
|
|
||||||
|
| 值 | 含义 |
|
||||||
|
|-----|------|
|
||||||
|
| `disabled` | Agent 完全不知道 ncl 存在;host 拒绝所有 CLI 请求 |
|
||||||
|
| `group`(默认) | Agent 只能操作自己的 group(4个资源),args 被自动绑定到自己 group;无法查看其他 group 数据 |
|
||||||
|
| `global` | 无限制。仅通过 `init-first-agent` 脚本为 owner 的 agent group 设置 |
|
||||||
|
|
||||||
|
### 四层防御
|
||||||
|
|
||||||
|
**防御层 1:CLAUDE.md 层面** — `src/claude-md-compose.ts:82-90`
|
||||||
|
`cli_scope === 'disabled'` 时,`cli.instructions.md` 从 CLAUDE.md 的 fragment 列表中排除。Agent 学不到 ncl 命令的存在。
|
||||||
|
|
||||||
|
**防御层 2:Host dispatch 核心强制** — `src/cli/dispatch.ts`
|
||||||
|
|
||||||
|
- **disabled 检查**(line 46-48):所有 CLI 请求立即拒绝
|
||||||
|
- **资源白名单**(line 51-55):`group` 只允许 `groups`、`sessions`、`destinations`、`members` 四种资源
|
||||||
|
- **参数强制绑定**(line 60-72):检查 `agent_group_id`、`group`、`--id` 参数,不匹配调用者的 group 则拒绝
|
||||||
|
- **特权升级阻止**(line 74-77):拒绝任何包含 `cli_scope` 或 `cli-scope` 参数的命令
|
||||||
|
- **自动填充**(line 81-90):主动填充 `agent_group_id`、`group`、`--id` 为调用者的 group ID——agent 不需要也不能指定
|
||||||
|
- **sessions-get 防 existence oracle**(line 95-100):如果传入 session UUID 属于其他 group,返回 "not found" 而非 "forbidden"
|
||||||
|
- **Post-handler 过滤**(line 150-173):对 `list` 返回的 rows 按 `scopeField` 再次过滤,丢弃不属于调用者 group 的行
|
||||||
|
|
||||||
|
各资源的 `scopeField` 映射(`src/cli/resources/`):
|
||||||
|
- `groups` → `'id'`(`groups.ts:41`)
|
||||||
|
- `sessions` → `'agent_group_id'`(`sessions.ts:10`)
|
||||||
|
- `destinations` → `'agent_group_id'`(`destinations.ts:11`)
|
||||||
|
- `members` → `'agent_group_id'`(`members.ts:11`)
|
||||||
|
|
||||||
|
**防御层 3:Container 端** — `container/agent-runner/src/cli/ncl.ts`
|
||||||
|
Container 内的 ncl **不做任何权限检查**——它是纯粹的 DB transport 客户端。将 CLI 请求写入 `outbound.db`,从 `inbound.db` poll 响应。权限检查全在 host 端。Container 无法绕过,因为它只能访问 session DB,不能直接连中央库或 socket server。
|
||||||
|
|
||||||
|
**防御层 4:Command Gate(斜杠命令)** — `src/command-gate.ts:23-63`
|
||||||
|
|
||||||
|
独立于 `cli_scope`,在消息写入 `messages_in` 之前运行(`router.ts:430-448`):
|
||||||
|
- **Filtered commands**(`/help`, `/login`, `/logout`, `/doctor`, `/config`, `/remote-control`)→ 静默丢弃永不进容器
|
||||||
|
- **Admin commands**(`/clear`, `/compact`, `/context`, `/cost`, `/files`)→ 仅 owner / admin 可通过。否则直接写 `messages_out` 返回权限拒绝
|
||||||
|
- **降级安全**(line 51):`user_roles` 表不存在 → 所有 admin 命令放行
|
||||||
|
|
||||||
|
### 跨层防御矩阵总结
|
||||||
|
|
||||||
|
| 防御层 | disabled | group | global |
|
||||||
|
|--------|----------|-------|--------|
|
||||||
|
| CLAUDE.md 隐藏 ncl | ✅ 排除 | ❌ 包含 | ❌ 包含 |
|
||||||
|
| Host dispatch 拒绝所有 | ✅ 拒绝 | ❌ | ❌ |
|
||||||
|
| 资源白名单 | N/A | ✅ 4种 | ❌ 无限制 |
|
||||||
|
| 参数强制绑定 | N/A | ✅ 自动填+拒绝跨界 | ❌ |
|
||||||
|
| `cli_scope` 自我修改阻止 | N/A | ✅ 拒绝 | ❌ |
|
||||||
|
| Post-handler rows 过滤 | N/A | ✅ scopeField | ❌ |
|
||||||
|
| Sessions oracle 防护 | N/A | ✅ fail-closed | ❌ |
|
||||||
|
| Command gate 斜杠命令 | ✅ | ✅ | ✅ |
|
||||||
|
| Approval gating(非host) | N/A | ✅ | ✅ |
|
||||||
|
|
||||||
|
**关键 gotcha:** group-scoped agent 可以通过 `ncl groups config get` 读到自己的完整 config(包括 `cli_scope` 字段),所以知道自己在 `group` 范围内。但由于 `cli_scope` 参数被第 74-77 行硬阻止,不能修改自己的 scope。
|
||||||
142
docs/answers/04-container-lifecycle.md
Normal file
142
docs/answers/04-container-lifecycle.md
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
# Q10-Q12: 容器生命周期
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q10: 启动 agent 容器时 mount 了哪些东西?
|
||||||
|
|
||||||
|
### 答案
|
||||||
|
|
||||||
|
Container mount 由 `src/container-runner.ts:242-335` 的 `buildMounts()` 构建。容器内文件系统结构:
|
||||||
|
|
||||||
|
| 容器路径 | 宿主机来源 | 权限 | 用途 |
|
||||||
|
|----------|-----------|------|------|
|
||||||
|
| `/workspace/` | `data/v2-sessions/<agentGroup>/<session>/` | RW | Session 目录:`inbound.db`、`outbound.db`、`.heartbeat`、`outbox/`、`inbox/` |
|
||||||
|
| `/workspace/agent/` | `groups/<folder>/` | RW | Per-group 工作文件 + `CLAUDE.local.md` |
|
||||||
|
| `/workspace/agent/container.json` | `groups/<folder>/container.json` | **RO** | 嵌套 RO 覆盖——agent 可读不能改(line 276-278) |
|
||||||
|
| `/workspace/agent/CLAUDE.md` | `groups/<folder>/CLAUDE.md` | **RO** | 组合的 CLAUDE.md,spawn 时重新生成(line 287-290) |
|
||||||
|
| `/workspace/agent/.claude-fragments/` | `groups/<folder>/.claude-fragments/` | **RO** | per-skill/per-MCP 指令片段 |
|
||||||
|
| `/workspace/global/` | `groups/global/` | **RO** | 共享全局记忆 |
|
||||||
|
| `/app/CLAUDE.md` | `container/CLAUDE.md` | **RO** | 共享基础 CLAUDE.md,通过 `.claude-shared.md` symlink 导入 |
|
||||||
|
| `/home/node/.claude/` | `data/v2-sessions/<agentGroup>/.claude-shared/` | RW | Claude SDK 状态、`settings.json`、skill symlinks |
|
||||||
|
| `/app/src/` | `container/agent-runner/src/` | **RO** | 共享 agent-runner TypeScript 源码 |
|
||||||
|
| `/app/skills/` | `container/skills/` | **RO** | 共享容器技能 |
|
||||||
|
| 额外 | `containerConfig.additionalMounts` | → | Provider-contributed mounts(line 330) |
|
||||||
|
|
||||||
|
### 调用链
|
||||||
|
|
||||||
|
1. `spawnContainer()`(line 108)→ `buildMounts()`(line 134)
|
||||||
|
2. Pre-mount 初始化:
|
||||||
|
- `initGroupFilesystem(agentGroup)`(line 253):幂等创建 `groups/<folder>/`、`CLAUDE.local.md`、`.claude-shared/` 目录和 DB 行
|
||||||
|
- `syncSkillSymlinks()`(line 257):根据 `container.json` 的 `skills` 选择,在 `.claude-shared/skills/` 下创建 symlink
|
||||||
|
- `composeGroupClaudeMd(agentGroup)`(line 261):重新生成组合 CLAUDE.md
|
||||||
|
3. Mount 按顺序组装(line 267-333)
|
||||||
|
4. 所有 volume mount 进入 `buildContainerArgs()`(line 447-453)
|
||||||
|
|
||||||
|
### 边界情况
|
||||||
|
|
||||||
|
- `CLAUDE.md` 和 `.claude-fragments/` 是嵌套 RO mount,叠加在 RW group 目录上——agent 只能写 `CLAUDE.local.md`
|
||||||
|
- `container.json` 单独 RO mount 防止 agent 修改自己的配置
|
||||||
|
- Skill symlinks 指向容器内路径(`/app/skills/<name>`),在宿主机上是悬空符号链接,容器内有效
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q11: Agent 的 system prompt 是怎么拼出来的?
|
||||||
|
|
||||||
|
### 答案
|
||||||
|
|
||||||
|
Agent 的 system prompt 由三部分拼成:**(A)** 共享基础 `CLAUDE.md`,**(B)** per-skill/per-MCP 指令片段,**(C)** 运行时 addendum(身份 + destinations)。
|
||||||
|
|
||||||
|
### Host 端组合(spawn 时)
|
||||||
|
|
||||||
|
`src/claude-md-compose.ts:43-136` → `composeGroupClaudeMd()`:
|
||||||
|
|
||||||
|
1. **共享基础 symlink**(line 49-50):`groups/<folder>/.claude-shared.md` → `/app/CLAUDE.md`(21 行通用 agent 指令:交流风格、workspace、memory、conversation history)
|
||||||
|
|
||||||
|
2. **Fragment 发现**(line 58-107):
|
||||||
|
- **Skill fragments**(line 66-76):`container/skills/` 下任何有 `instructions.md` 的技能
|
||||||
|
- **内置模块 fragments**(line 83-96):`container/agent-runner/src/mcp-tools/` 下的 `.instructions.md`。**`cli.instructions.md` 在 `cli_scope='disabled'` 时被跳过**
|
||||||
|
- **MCP server fragments**(line 100-107):`container.json` 中外部 MCP server 的 `instructions` 字段,生成内联 fragment 文件
|
||||||
|
|
||||||
|
3. **Fragment 协调**(line 110-122):删除不再需要的过期 fragment,创建/更新 symlink
|
||||||
|
|
||||||
|
4. **组合入口**(line 125-130):写出 `groups/<folder>/CLAUDE.md`,只含 import 指令:
|
||||||
|
```
|
||||||
|
@./.claude-shared.md
|
||||||
|
@./.claude-fragments/skill-onecli-gateway.md
|
||||||
|
@./.claude-fragments/skill-welcome.md
|
||||||
|
@./.claude-fragments/module-cli.md
|
||||||
|
```
|
||||||
|
Claude Code 跟随 `@` import 解决所有 fragment
|
||||||
|
|
||||||
|
5. **Per-group 记忆**(line 132-135):确保 `CLAUDE.local.md` 存在——这是唯一可写的 CLAUDE.md 文件
|
||||||
|
|
||||||
|
### Container 端运行时 addendum
|
||||||
|
|
||||||
|
`container/agent-runner/src/destinations.ts:82-92` → `buildSystemPromptAddendum()`:
|
||||||
|
|
||||||
|
- **身份**(line 85-87):如果设置了 `assistantName`:`"You are <name>"` + 自我介绍和签名指引
|
||||||
|
- **Destination map**(line 94-130):从 `inbound.db` 的 `destinations` 表读取,生成 "Sending messages" 部分
|
||||||
|
|
||||||
|
### 各部分贡献
|
||||||
|
|
||||||
|
| 来源 | 内容 | Agent 可修改? |
|
||||||
|
|------|------|---------------|
|
||||||
|
| `container/CLAUDE.md`(共享基础) | 通用 agent 行为 | 否(RO mount) |
|
||||||
|
| Skill `instructions.md` | Per-skill 指引 | 否(RO) |
|
||||||
|
| MCP tool `.instructions.md` | 如何使用内置工具 | 否(RO) |
|
||||||
|
| MCP server `instructions` | 外部 MCP server 指引 | 仅 admin(DB中) |
|
||||||
|
| `CLAUDE.local.md` | Per-group 记忆 | **是**(唯一可写) |
|
||||||
|
| `buildSystemPromptAddendum()` | 身份 + destination map | 生成时自动 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q12: 容器心跳怎么检测?进程活着但 poll loop 卡死了怎么发现?
|
||||||
|
|
||||||
|
### 答案
|
||||||
|
|
||||||
|
心跳是**文件 touch 机制**,不是 DB 写入,避免跨 mount DB 写入争用。
|
||||||
|
|
||||||
|
### Container 端:心跳 touch
|
||||||
|
|
||||||
|
- **Path**:`/workspace/.heartbeat`(`container/agent-runner/src/db/connection.ts:25`)
|
||||||
|
- **`touchHeartbeat()`**(`connection.ts:156-168`):用 `fs.utimesSync()` 更新文件 mtime。失败时回退 `fs.writeFileSync()`
|
||||||
|
- **触发时机**:`poll-loop.ts:361`,在 `for await (const event of query.events)` 循环中——每个 SDK event(`init`、`result`、`error`、`progress`)都触发。意味着**仅 agent 活跃流式回复时更新**,poll 轮空间歇不更新
|
||||||
|
|
||||||
|
### Host 端:卡住检测
|
||||||
|
|
||||||
|
Host sweep 每 60s 运行(`host-sweep.ts:61`),对每个有运行容器的 session 调用 `enforceRunningContainerSla()`(line 192 → line 228)。
|
||||||
|
|
||||||
|
**两种检测层级**(`decideStuckAction()`,line 82-118):
|
||||||
|
|
||||||
|
**1. 绝对天花板**(line 91-105):heartbeat mtime 年龄 > `max(30 min, 当前Bash超时)` → `kill-ceiling`
|
||||||
|
- `ABSOLUTE_CEILING_MS = 30 * 60 * 1000`
|
||||||
|
- 扩展 `declaredBashMs`:从 `outbound.db` 读 `container_state`,如果当前工具是 Bash 且有 `tool_declared_timeout_ms`,天花板扩大到该值
|
||||||
|
- **关键守护**(line 92-98):`heartbeatMtimeMs === 0`(刚 spawn,还没有 SDK activity)→ 跳过
|
||||||
|
|
||||||
|
**2. Claim-stuck per-message**(line 107-115):对每个 `processing_ack` 中的 `processing` 行,如果 `(claim_age > tolerance)` 且 `(heartbeat_mtime <= status_changed)` → `kill-claim`
|
||||||
|
- `CLAIM_STUCK_MS = 60s`:claim 一条消息后 60s 内没有任何 heartbeat → poll loop 卡住
|
||||||
|
- 条件 `heartbeat_mtime <= claimedAt` 恰好检测 "claim 了消息后没有任何生命迹象"
|
||||||
|
|
||||||
|
**3. 容器不在运行**(line 199-201):`!isContainerRunning` → `resetStuckProcessingRows()` 用指数退避(基数 5s × 2^tries,最多 5 次)重调度消息
|
||||||
|
|
||||||
|
### 处理器卡在 poll loop 的完整场景
|
||||||
|
|
||||||
|
```
|
||||||
|
Container 进程存活,poll loop 卡住:
|
||||||
|
poll-loop.ts:101 → markProcessing(ids) → DB: status='processing', status_changed=NOW
|
||||||
|
poll-loop.ts:174 → config.provider.query(...) → 启动,但 SDK 挂起
|
||||||
|
[没有 heartbeat touch,因为没有 event 触发]
|
||||||
|
...
|
||||||
|
Host sweep(60s后):
|
||||||
|
→ getProcessingClaims(outDb) → 发现 claim,status_changed 很旧
|
||||||
|
→ heartbeatMtimeMs() → 返回旧 mtime(挂起前最后一次 event 的)
|
||||||
|
→ decideStuckAction(): claimAge > 60s, heartbeat_mtime <= claimedAt
|
||||||
|
→ action: 'kill-claim' → killContainer() + resetStuckProcessingRows()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 边界情况
|
||||||
|
|
||||||
|
- **新 spawn 宽容期**:heartbeat 文件不存在时跳过天花板检查,但 claim-stuck 检查仍然处理 "claim 了消息但在门口卡住"
|
||||||
|
- **每次 spawn 清 heartbeat**(`container-runner.ts:155`):防止旧容器的过期 mtime 立即触发 kill
|
||||||
|
- **孤儿 claim 清理**(line 319):kill 后 `deleteOrphanProcessingClaims()` 清掉 `outbound.db` 中的 processing 行,防止 sweep 立即 kill 新容器
|
||||||
|
- **Bash 自定义超时**:扩展容忍度,确保长运行 Bash 不被误杀
|
||||||
175
docs/answers/05-delivery-and-system-actions.md
Normal file
175
docs/answers/05-delivery-and-system-actions.md
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
# Q13-Q15: 出站投递与系统动作
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q13: Agent 回复消息后,delivery.ts 怎么知道用哪个 channel adapter 发送?重试和失败怎么处理?
|
||||||
|
|
||||||
|
### 答案
|
||||||
|
|
||||||
|
Delivery 系统使用**两层轮询**:active poll(每 1s)扫描有运行容器的 session,sweep poll(每 60s)扫描所有 active session。从 `outbound.db`(container-owned)读取,在 `inbound.db` 的 `delivered` 表中跟踪投递状态。Channel adapter 是 boot 时设置的单个全局 `ChannelDeliveryAdapter`。
|
||||||
|
|
||||||
|
### Adapter 的选择
|
||||||
|
|
||||||
|
- `messages_out` 的每行带有 `channel_type` 和 `platform_id` 字段(container 的 `writeMessageOut()` 填入)
|
||||||
|
- `delivery.ts:356-363`:`deliveryAdapter.deliver(channelType, platformId, threadId, kind, content, files)` 被调用。Adapter 收到 `channelType` + `platformId`,负责路由到正确的平台
|
||||||
|
- Adapter 通过 `setDeliveryAdapter()` 设置一次(line 95),是一个包装了所有 channel adapter 的 `ChannelDeliveryAdapter`
|
||||||
|
|
||||||
|
### 完整投递流程
|
||||||
|
|
||||||
|
**1. Poll 触发:** `pollActive()`(1s,line 121-133)→ `getRunningSessions()`;`pollSweep()`(60s,line 136-149)→ `getActiveSessions()`
|
||||||
|
|
||||||
|
**2. 防竞态**(line 151-162):`inflightDeliveries` 是 `Set<string>` — 如果 active poll 和 sweep poll 竞态同个 session,第二个调用者跳过,防止重复投递
|
||||||
|
|
||||||
|
**3. Drain session**(`drainSession()`,line 164-232):
|
||||||
|
- 只读打开 `outbound.db`,读写打开 `inbound.db`
|
||||||
|
- `getDueOutboundMessages(outDb)` 读 `messages_out`(`deliver_after <= now`)
|
||||||
|
- `getDeliveredIds(inDb)` 做去重比对(line 183)
|
||||||
|
- 对每条未投递消息调用 `deliverMessage()`(line 192)
|
||||||
|
|
||||||
|
**4. 消息路由**(`deliverMessage()`,line 234-375):
|
||||||
|
- **System actions**(line 255-258):`msg.kind === 'system'` → `handleSystemAction()` → 查找 `actionHandlers` Map。模块通过 `registerDeliveryAction()` 注册处理器
|
||||||
|
- **Agent-to-agent**(line 264-271):`msg.channel_type === 'agent'` → `routeAgentMessage()`
|
||||||
|
- **Channel delivery**(line 289-375):
|
||||||
|
- **权限检查**(line 289-311):验证源 agent 是否有权发到目标 channel——要么目标是自己 session 的 origin(`session.messaging_group_id` 匹配),要么 `agent_destinations` 中有显式行
|
||||||
|
- **Pending question 跟踪**(line 317-340):`ask_question` 类型创建 `pending_questions` 行
|
||||||
|
- **文件附件**(line 348-354):从 session 的 `outbox/<messageId>/` 读文件
|
||||||
|
- **Adapter call**(line 356-363):实际发送
|
||||||
|
- **清理**(line 372):`clearOutbox()` 删除 outbox 目录
|
||||||
|
|
||||||
|
### 重试和失败处理
|
||||||
|
|
||||||
|
- 投递尝试在 `deliveryAttempts` Map 中以消息 ID 为 key 在内存中跟踪(进程重启时重置,给失败消息全新机会)
|
||||||
|
- 在 `MAX_DELIVERY_ATTEMPTS = 3` 次失败后,标记为 permanent failed(line 206-225)
|
||||||
|
- 重试是惰性的:消息留在 `messages_out` 表中未投递,下次 poll 迭代重新捡起
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q14: Agent 发起 `install_packages` 或 `add_mcp_server` 的完整审批-执行链路是什么?
|
||||||
|
|
||||||
|
### 答案
|
||||||
|
|
||||||
|
自我修改采用 **fire-and-forget** 模式 + admin 审批门控。Agent 调 MCP tool,tool 写 system message 到 `outbound.db`。Host delivery loop 拾起、验证/净化请求、排队审批,admin 批准后应用修改、重建镜像(如需要)、kill 容器、写 `on_wake` 消息给新容器。
|
||||||
|
|
||||||
|
### 完整链路(逐步)
|
||||||
|
|
||||||
|
**Phase 1: Agent 请求(container 端)**
|
||||||
|
|
||||||
|
1. Agent 调 `install_packages` 或 `add_mcp_server` MCP tool(`container/agent-runner/src/mcp-tools/self-mod.ts`)
|
||||||
|
- `install_packages`(line 53-78):用正则验证包名(`APT_RE`、`NPM_RE`,最多 20 个),写 `kind: 'system'` + `action: 'install_packages'` 的 outbound message
|
||||||
|
- `add_mcp_server`(line 97-117):验证 name 和 command 存在,写 `action: 'add_mcp_server'`
|
||||||
|
|
||||||
|
2. 写 `outbound.db`:用 `writeMessageOut()`,写入**奇数 seq**
|
||||||
|
|
||||||
|
**Phase 2: Host delivery 拾起**
|
||||||
|
|
||||||
|
3. `delivery.ts` → `deliverMessage()` 看到 `kind === 'system'` → `handleSystemAction()`
|
||||||
|
4. `handleSystemAction()`(line 410-425):查 `actionHandlers` Map。self-mod 模块注册了 handler:
|
||||||
|
- `handleInstallPackages`(`self-mod/request.ts:20-64`)
|
||||||
|
- `handleAddMcpServer`(`self-mod/request.ts:66-91`)
|
||||||
|
|
||||||
|
**Phase 3: 请求验证 + 审批排队**
|
||||||
|
|
||||||
|
5. **Host 端验证**(深度防御第二层):对 package 名称再次验证(同一正则),失败时调 `notifyAgent()` 告知 agent,**不创建审批**
|
||||||
|
|
||||||
|
6. **审批请求**(`approvals/primitive.ts:164-220`):
|
||||||
|
- `pickApprover(session.agent_group_id)` → scoped admins → global admins → owners
|
||||||
|
- `pickApprovalDelivery`:找到可 DM 的审批者,优先同 channel 类型
|
||||||
|
- 用 `deliveryAdapter.deliver()` 发 `ask_question` 卡片到 admin DM
|
||||||
|
- 创建 `pending_approvals` 行,包含 `action`、`payload`(JSON)、`approval_id`、`session_id`
|
||||||
|
|
||||||
|
**Phase 4: Admin 响应**
|
||||||
|
|
||||||
|
7. `approvals/response-handler.ts:24-43` → `handleApprovalsResponse()`:
|
||||||
|
- **Reject**(line 72-77):调 `notify()` 告知 agent
|
||||||
|
- **Approve**(line 80-105):查 `getApprovalHandler(approval.action)` 注册的 handler,传入 `{ session, payload, userId, notify }`
|
||||||
|
|
||||||
|
**Phase 5: 应用修改**
|
||||||
|
|
||||||
|
8. **`install_packages` handler** — `self-mod/apply.ts:22-83`:
|
||||||
|
- 去重后追加新 apt/npm 包到 DB 中已有列表(line 37-49)
|
||||||
|
- `buildAgentGroupImage()`(line 57):构建 per-agent-group Docker 镜像(`container-runner.ts:468-515`),用 `docker build -t nanoclaw-agent:<agentGroupId>` 拉 900s 超时
|
||||||
|
- 写 `on_wake: 1` 消息告知 agent
|
||||||
|
- **Kill 容器 with `onExit` callback**(line 72-75):`killContainer(sessionId, 'rebuild applied', () => { wakeContainer(s) })` —— 保证旧容器退出后新容器才 spawn
|
||||||
|
- 重建失败(line 77-82):通知 admin,不 kill 容器
|
||||||
|
|
||||||
|
9. **`add_mcp_server` handler** — `self-mod/apply.ts:85-125`:
|
||||||
|
- 添加 MCP server 到 DB 的 `mcp_servers` JSON(line 99-105)
|
||||||
|
- 写 `on_wake: 1` 消息(line 107-120)
|
||||||
|
- Kill 容器 with `onExit` → `wakeContainer` callback(line 121-124)
|
||||||
|
- **不需要重建镜像** —— Bun 直接运行 TS,纯 MCP wiring 变动不需要 Dоcker 构建
|
||||||
|
|
||||||
|
**Phase 6: 新容器启动**
|
||||||
|
|
||||||
|
10. `onExit` callback 触发 → `wakeContainer()` → `spawnContainer()`:
|
||||||
|
- 从 DB 物化新 `container.json`(line 127)
|
||||||
|
- `composeGroupClaudeMd()` 重新生成 CLAUDE.md(line 261)
|
||||||
|
- `clearStaleProcessingAcks()` 清掉旧 processing ack(`connection.ts:175-177`)
|
||||||
|
- `getPendingMessages(isFirstPoll=true)` 捡起 `on_wake: 1` 消息 —— 仅第一轮 poll 可见
|
||||||
|
|
||||||
|
### 为什么 `on_wake` 是防竞态的
|
||||||
|
|
||||||
|
`messages_in` 表 `on_wake` 列 + `getPendingMessages()` 中 `isFirstPoll` 门控:第一轮 poll 包含 `on_wake = 1` 行,后续轮排除(它们已 `completed`)。结合 `killContainer` 的 `onExit` callback,旧容器绝无可能先于新容器偷走 on_wake 消息。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q15: 定时任务(cron)怎么实现?
|
||||||
|
|
||||||
|
### 答案
|
||||||
|
|
||||||
|
定时任务实现为 **`messages_in` 表中 `kind='task'` 的行**,piggyback 在核心 schema 上,没有专用表。Agent 通过 MCP tool 创建任务,host 把它们写入 `inbound.db`,recurrence 由 host sweep hook 驱动:克隆已完成的周期性任务为新 pending 行。
|
||||||
|
|
||||||
|
### 创建任务
|
||||||
|
|
||||||
|
1. Agent 通过 `schedule_task` MCP tool(`container/agent-runner/src/mcp-tools/scheduling.ts`)写 `kind: 'system'` + `action: 'schedule_task'` 的 outbound message,包含 `taskId`、`prompt`、`processAfter`(首次运行 ISO 时间戳)、可选 `recurrence`(cron 表达式)
|
||||||
|
|
||||||
|
2. Host delivery 拾起 → `handleSystemAction()` → 注册的 `action: 'schedule_task'` handler(`scheduling/actions.ts:19-40`):
|
||||||
|
- 调 `insertTask()`(`scheduling/db.ts:17-36`):插入 `messages_in` 行,`kind = 'task'`、`status = 'pending'`、`process_after = <首次运行时间>`、`recurrence = <cron-expr>`、`series_id = <taskId>`
|
||||||
|
- 内容存储为 JSON `{ prompt, script }`
|
||||||
|
|
||||||
|
3. Agent 也可以创建非周期性调度消息:`schedule_message` 工具同理,但 `kind` 匹配原始消息类型
|
||||||
|
|
||||||
|
### 触发:Host sweep + `countDueMessages`
|
||||||
|
|
||||||
|
4. Host sweep 唤醒容器(`host-sweep.ts:180-186`):
|
||||||
|
- `countDueMessages(inDb)` 计数 `status = 'pending' AND process_after <= now` 的行
|
||||||
|
- `dueCount > 0 && !isContainerRunning` → `wakeContainer(session)`
|
||||||
|
|
||||||
|
5. Container 处理任务:`getPendingMessages()` 读 pending 行,包括 task 行,格式化后给 provider
|
||||||
|
|
||||||
|
### Recurrence:host sweep + `handleRecurrence`
|
||||||
|
|
||||||
|
6. Recurrence fanout(`scheduling/recurrence.ts:21-53`),每 60s sweep 周期调用(`host-sweep.ts:205-206`):
|
||||||
|
- `getCompletedRecurring(inDb)`(`scheduling/db.ts:122-126`):找 `status = 'completed' AND recurrence IS NOT NULL` 的行
|
||||||
|
- 对每个完成的行:
|
||||||
|
- 在用户时区(非 UTC)解析 cron 表达式
|
||||||
|
- 计算 `nextRun = interval.next().toISOString()`
|
||||||
|
- `insertRecurrence()`(`scheduling/db.ts:128-149`):复制原行,设置 `process_after = nextRun`、`status = 'pending'`
|
||||||
|
- `clearRecurrence()`(line 151-153):设原行 `recurrence = NULL`,防止下次周期重复克隆
|
||||||
|
|
||||||
|
### 其他任务生命周期操作
|
||||||
|
|
||||||
|
7. **Cancel/Pause/Resume**(`actions.ts:42-70`):
|
||||||
|
- `cancelTask()`(line 38-42):通过 `id OR series_id` 匹配,设所有 `pending`/`paused` 行为 `completed`
|
||||||
|
- `pauseTask()`(line 44-48)、`resumeTask()`(line 50-54):同理
|
||||||
|
- 用 `series_id` 匹配意味着 agent 可以引用任意一次执行取消整个系列
|
||||||
|
|
||||||
|
### Host sweep + recurrence 协作顺序
|
||||||
|
|
||||||
|
```
|
||||||
|
Host sweep(每 60s):
|
||||||
|
Step 1: syncProcessingAcks()
|
||||||
|
Step 2: countDueMessages() → 如果到期 + 容器不在运行 → wakeContainer()
|
||||||
|
Step 3: enforceRunningContainerSla() ← heartbeat/claim-stuck 检查
|
||||||
|
Step 4: resetStuckProcessingRows() ← 崩溃容器清理
|
||||||
|
Step 5: handleRecurrence() ← 扫描已完成周期性任务,克隆下次执行
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键顺序:** Step 2(唤醒到期消息)在 Step 4(崩溃容器清理)之前运行,确保新容器有机会在启动时清自己的孤儿 `processing_ack`。
|
||||||
|
|
||||||
|
### 边界情况
|
||||||
|
|
||||||
|
- **没有专用表**:任务是 `messages_in` 行——核心 `messages_in` schema 中 `kind` 字段足以区分
|
||||||
|
- **`series_id`**:周期性任务的每次执行共享同一 `series_id`。Cancel/pause/resume 用 `id OR series_id` 匹配,影响整个系列
|
||||||
|
- **时区**:Cron 表达式以用户配置的 `TIMEZONE` 解析(来自 `.env`),非 UTC
|
||||||
|
- **Pre-task scripts gating**:Task 行可带 `script` 字段。`applyPreTaskScripts()` hook(`poll-loop.ts:149,323`)先跑 script。如果返回 `wakeAgent: false`,任务标记完成但不唤醒 agent——实现 "仅用户活跃时运行" 等条件
|
||||||
|
- **Agent 不能写 inbound.db**:Container 把 task 写成 `kind: 'system'` 的 outbound message;host 的 delivery action handler 才是实际插入 `messages_in` 的组件——保持单 writer 不变式
|
||||||
167
docs/answers/06-data-model.md
Normal file
167
docs/answers/06-data-model.md
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
# Q16-Q17: 数据模型
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q16: 中央库 `data/v2.db` 有哪些表?它们之间的外键关系是怎样的?
|
||||||
|
|
||||||
|
### 答案
|
||||||
|
|
||||||
|
中央库有约 20 张表,横跨多次迁移。以下按逻辑分类:
|
||||||
|
|
||||||
|
### 核心实体表(迁移 001)
|
||||||
|
|
||||||
|
| # | 表 | 用途 | 关键 FK 关系 |
|
||||||
|
|---|-----|------|-------------|
|
||||||
|
| 1 | `agent_groups` | Agent 工作区(folder、skills、CLAUDE.md) | 根实体——无 FK 出 |
|
||||||
|
| 2 | `messaging_groups` | 一个平台聊天/频道/DM | 根实体;`UNIQUE(channel_type, platform_id)` |
|
||||||
|
| 3 | `messaging_group_agents` | 多对多 wiring:agent↔channel | `→ messaging_groups(id)`, `→ agent_groups(id)`;`UNIQUE(messaging_group_id, agent_group_id)` |
|
||||||
|
| 4 | `users` | 平台用户身份(`<channel>:<handle>`) | 根实体 |
|
||||||
|
| 5 | `user_roles` | 特权授予(owner/admin) | `→ users(id)` (x2: user + granted_by), `→ agent_groups(id)`;`PK (user_id, role, agent_group_id)` |
|
||||||
|
| 6 | `agent_group_members` | 非特权访问门 | `→ users(id)` (x2), `→ agent_groups(id)`;`PK (user_id, agent_group_id)` |
|
||||||
|
| 7 | `user_dms` | 冷 DM 缓存 | `→ users(id)`, `→ messaging_groups(id)`;`PK (user_id, channel_type)` |
|
||||||
|
| 8 | `sessions` | Session 生命周期元数据 | `→ agent_groups(id)`, `→ messaging_groups(id)` |
|
||||||
|
| 9 | `pending_questions` | 交互式问题状态 | `→ sessions(id)` |
|
||||||
|
|
||||||
|
### 后续迁移添加的表
|
||||||
|
|
||||||
|
| # | 表 | 迁移 | 说明 |
|
||||||
|
|---|-----|------|------|
|
||||||
|
| 10 | `chat_sdk_kv` | 002 | Chat SDK 不透明状态 |
|
||||||
|
| 11 | `chat_sdk_subscriptions` | 002 | Chat SDK 线程订阅 |
|
||||||
|
| 12 | `chat_sdk_locks` | 002 | Chat SDK 分布式锁 |
|
||||||
|
| 13 | `chat_sdk_lists` | 002 | Chat SDK 列表状态 |
|
||||||
|
| 14 | `pending_approvals` | module-003 | `→ sessions(id)`, `→ agent_groups(id)` |
|
||||||
|
| 15 | `agent_destinations` | module-004 | `→ agent_groups(id)`;`PK (agent_group_id, local_name)` |
|
||||||
|
| 16 | `unregistered_senders` | 008 | 审计跟踪;`PK (channel_type, platform_id)` |
|
||||||
|
| 17 | `container_configs` | 014 | `→ agent_groups(id) ON DELETE CASCADE` |
|
||||||
|
| 18 | `pending_sender_approvals` | 011 | `→ messaging_groups(id)`, `→ agent_groups(id)`;`UNIQUE(messaging_group_id, sender_identity)` |
|
||||||
|
| 19 | `pending_channel_approvals` | 012 | `→ messaging_groups(id)`, `→ agent_groups(id)` |
|
||||||
|
| 20 | `schema_version` | 内建 | 迁移分类账——无 FK |
|
||||||
|
|
||||||
|
### 已删除的表
|
||||||
|
|
||||||
|
- `pending_credentials` — 在迁移 009 中删除(已废弃)
|
||||||
|
|
||||||
|
### 列级变更
|
||||||
|
|
||||||
|
- `agent_groups.denied_at` — 迁移 012 添加
|
||||||
|
- `messaging_group_agents` 的 4 列(`engage_mode`、`engage_pattern`、`sender_scope`、`ignored_message_policy`)— 迁移 010 添加,替换旧的 `trigger_rules` + `response_scope`
|
||||||
|
- `container_configs.cli_scope` — 迁移 015 添加
|
||||||
|
|
||||||
|
### ER 关系图
|
||||||
|
|
||||||
|
```
|
||||||
|
agent_groups ──────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
├── messaging_group_agents ──→ messaging_groups ◄── user_dms │
|
||||||
|
│ (UNIQUE pair) │
|
||||||
|
│ │
|
||||||
|
├── sessions ──→ messaging_groups │
|
||||||
|
│ └── pending_questions │
|
||||||
|
│ │
|
||||||
|
├── user_roles (privilege: owner/admin, scoped or global) ──→ users (user_id, granted_by)
|
||||||
|
│ │
|
||||||
|
├── agent_group_members (unprivileged access gate) ──→ users (user_id, added_by) │
|
||||||
|
│ │
|
||||||
|
├── agent_destinations (ACL + name→target routing map) │
|
||||||
|
│ │
|
||||||
|
├── container_configs (1:1, ON DELETE CASCADE) │
|
||||||
|
│ │
|
||||||
|
├── pending_approvals │
|
||||||
|
├── pending_sender_approvals │
|
||||||
|
└── pending_channel_approvals │
|
||||||
|
|
||||||
|
users ────────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
├── user_roles (user_id, granted_by 都自引用 users) │
|
||||||
|
├── agent_group_members │
|
||||||
|
└── user_dms │
|
||||||
|
|
||||||
|
schema_version, chat_sdk_*, unregistered_senders — 无 FK;独立叶子表
|
||||||
|
```
|
||||||
|
|
||||||
|
### 关键不变式
|
||||||
|
|
||||||
|
- **Owner 必须全局:** `role='owner'` 意味着 `user_roles` 中 `agent_group_id IS NULL`。`grantRole()` 中强制
|
||||||
|
- **Admin 隐含 membership:** agent group A 的 admin 自动是 member——不需要 `agent_group_members` 行
|
||||||
|
- **`agent_destinations` 双重角色:** 既是路由表也是 ACL。无行 = 未授权发送。源是中央库,spawn 时投影写入容器内 `inbound.db`
|
||||||
|
- **Chat SDK 表是不透明的**:NanoClaw 代码很少直接操作它们——由 `state-sqlite.ts` 持有
|
||||||
|
- **Session DB 是分离的**:`inbound.db` / `outbound.db` 在 `data/v2-sessions/<session_id>/` 下,用 `CREATE TABLE IF NOT EXISTS` 做前向兼容
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q17: DB 迁移怎么组织?我要加一张表或一个字段该改哪些文件?
|
||||||
|
|
||||||
|
### 答案
|
||||||
|
|
||||||
|
### 迁移文件组织
|
||||||
|
|
||||||
|
每个迁移是 `src/db/migrations/` 下的一个文件,导出 `Migration` 对象:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/db/migrations/index.ts:18-22
|
||||||
|
interface Migration {
|
||||||
|
version: number; // 在 barrel 数组中的排序提示
|
||||||
|
name: string; // UNIQUE name — schema_version 中的去重 key
|
||||||
|
up: (db: Database) => void; // 在事务中运行
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 迁移注册顺序(`migrations/index.ts:24-38`)
|
||||||
|
|
||||||
|
```
|
||||||
|
001-initial.ts → 核心表
|
||||||
|
002-chat-sdk-state.ts → Chat SDK 表
|
||||||
|
module-approvals-pending-approvals.ts → pending_approvals
|
||||||
|
module-agent-to-agent-destinations.ts → agent_destinations
|
||||||
|
module-approvals-title-options.ts → ALTER pending_approvals
|
||||||
|
008-dropped-messages.ts → unregistered_senders
|
||||||
|
009-drop-pending-credentials.ts → DROP pending_credentials
|
||||||
|
010-engage-modes.ts → ALTER messaging_group_agents
|
||||||
|
011-pending-sender-approvals.ts
|
||||||
|
012-channel-registration.ts
|
||||||
|
013-approval-render-metadata.ts
|
||||||
|
014-container-configs.ts
|
||||||
|
015-cli-scope.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
005 和 006 编号空着——早前重编号的。
|
||||||
|
|
||||||
|
### 注册机制(`runMigrations`,`index.ts:40-77`)
|
||||||
|
|
||||||
|
1. 如果不存在则创建 `schema_version` 表。唯一性在 `name`,不在 `version`
|
||||||
|
2. 读 `SELECT name FROM schema_version` 到 `Set<string>` —— **去重 key 是 `name`**,不是 `version`。这允许模块迁移使用任意版本号
|
||||||
|
3. `migrations.filter(m => !applied.has(m.name))` 得到待执行列表
|
||||||
|
4. 在 `db.transaction()` 中运行每个待执行迁移:
|
||||||
|
- 调 `m.up(db)` 执行 DDL
|
||||||
|
- 计算 `next = MAX(version) + 1`
|
||||||
|
- 插入 `schema_version`
|
||||||
|
|
||||||
|
### 加一张表
|
||||||
|
|
||||||
|
1. 创建 `src/db/migrations/016-your-table.ts`:
|
||||||
|
```typescript
|
||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
import type { Migration } from './index.js';
|
||||||
|
export const migration016: Migration = {
|
||||||
|
version: 16,
|
||||||
|
name: 'your-table', // 必须在所有迁移中全局唯一
|
||||||
|
up(db: Database) {
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS your_table (...)`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 在 `src/db/migrations/index.ts` 中:
|
||||||
|
- 添加 `import { migration016 } from './016-your-table.js';`
|
||||||
|
- 追加到 `migrations` 数组
|
||||||
|
|
||||||
|
### 加一个字段
|
||||||
|
|
||||||
|
同理,写一个 ALTER TABLE 的迁移。复杂变更(回填)做 JS 行级更新。**幂等守护模式**(迁移 012,line 29-32):ALTER TABLE ADD COLUMN 前先 `PRAGMA table_info()` 检查列是否已存在。
|
||||||
|
|
||||||
|
### 边界情况 / Gotchas
|
||||||
|
|
||||||
|
- **DROP TABLE 中的 FK 完整性:** 迁移事务中 DROP TABLE 会触发 SQLite FK 完整性检查。不能 toggle `PRAGMA foreign_keys`。优先 ALTER TABLE ADD COLUMN
|
||||||
|
- **模块迁移用任意版本:** `module-` 前缀文件的 `name` 与文件名不同——`name` 保持稳定,防止重命名后重新运行
|
||||||
|
- **Session DB 迁移是独立的:** Session DB schema(`INBOUND_SCHEMA`、`OUTBOUND_SCHEMA`)用 `CREATE TABLE IF NOT EXISTS`,新列通过惰性迁移 helper(`migrateDeliveredTable()` 等)落地,不跟踪 `schema_version`
|
||||||
|
- **Container 端也有自己的迁移**:`container/agent-runner/src/db/connection.ts:86-110` 有惰性 session DB 迁移(添加 `on_wake` 列、`delivered` 表列等)
|
||||||
170
docs/answers/07-provider-and-mcp.md
Normal file
170
docs/answers/07-provider-and-mcp.md
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
# Q18-Q19: Provider 与 MCP
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q18: Claude Agent SDK、OpenCode、Ollama 三个 provider 怎么抽象成统一接口?切换 provider 改什么?
|
||||||
|
|
||||||
|
### 答案
|
||||||
|
|
||||||
|
### 统一接口:`AgentProvider`
|
||||||
|
|
||||||
|
定义在 `container/agent-runner/src/providers/types.ts:1-92`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface AgentProvider {
|
||||||
|
readonly supportsNativeSlashCommands: boolean; // SDK 是否原生处理 /commands?
|
||||||
|
query(input: QueryInput): AgentQuery; // 开始一轮对话
|
||||||
|
isSessionInvalid(err: unknown): boolean; // 检测过期的延续语境错误
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `QueryInput`(line 40-60):携带 `prompt`、`continuation`(不透明 session token)、`cwd`、`systemContext`。Provider 自己决定 "continuation" 的含义
|
||||||
|
- `AgentQuery`(line 68-80):流式句柄,`push(message)` 推送后续输入,`events: AsyncIterable<ProviderEvent>` 流式输出,`abort()` 强制停止
|
||||||
|
- `ProviderEvent`(line 82-92):判别联合类型 `init | result | error | progress | activity`
|
||||||
|
- `ProviderOptions`(line 23-38):`assistantName`、`mcpServers`、`env`、`additionalDirectories`、`model`、`effort`
|
||||||
|
|
||||||
|
### 当前 Providers
|
||||||
|
|
||||||
|
**ClaudeProvider**(`claude.ts:253-360`):
|
||||||
|
- 包装 `@anthropic-ai/claude-agent-sdk` 的 `query()` 函数
|
||||||
|
- 用自定义 `MessageStream` async generator(line 80-112)把后续消息推入 SDK 流式输入
|
||||||
|
- 把原始 SDK 事件翻译成 `ProviderEvent` 联合类型(`translateEvents()`,line 318-346)
|
||||||
|
- Hooks:PreToolUse(记录 in-flight tool + 屏蔽不允许的 SDK builtins)、PostToolUse(清除 in-flight)、PreCompact(转录归档)
|
||||||
|
- 在 line 360 注册:`registerProvider('claude', (opts) => new ClaudeProvider(opts))`
|
||||||
|
|
||||||
|
**MockProvider**(`mock.ts:8-76`):
|
||||||
|
- 测试用——从 `responseFactory` 返回预设回复
|
||||||
|
|
||||||
|
**OpenCode:** 从 `providers` 分支通过 `/add-opencode` skill 安装,实现 `AgentProvider` 接口
|
||||||
|
|
||||||
|
**Ollama:** 尚未实现。会实现 `AgentProvider` 直接调用 Ollama API
|
||||||
|
|
||||||
|
### factory.ts 的选择逻辑
|
||||||
|
|
||||||
|
`container/agent-runner/src/providers/factory.ts`(全部 13 行):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function createProvider(name: string, options: ProviderOptions = {}): AgentProvider {
|
||||||
|
return getProviderFactory(name)(options);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
这是对自注册 registry(`provider-registry.ts`)的薄分发。Registry 是 `Map<string, ProviderFactory>`,每个 provider 模块在模块作用域调用 `registerProvider(name, factory)`。
|
||||||
|
|
||||||
|
选择逻辑:
|
||||||
|
1. 调用者传入 provider name 字符串(来自 `container_configs.provider` 列)
|
||||||
|
2. `getProviderFactory(name)` 查 Map——未找到抛错:`"Unknown provider: ${name}"`
|
||||||
|
3. 调用 factory 函数并传入 options
|
||||||
|
|
||||||
|
### 注册流程
|
||||||
|
|
||||||
|
1. 每个 provider 文件在顶层调用 `registerProvider()`(如 `claude.ts:360`)
|
||||||
|
2. Barrel `container/agent-runner/src/providers/index.ts` 导入所有 provider 模块做副作用:
|
||||||
|
```typescript
|
||||||
|
import './claude.js';
|
||||||
|
import './mock.js';
|
||||||
|
```
|
||||||
|
3. 运行时 agent-runner poll loop 从 `container_configs` 解析 provider name → `createProvider(name, options)` → 获取 `AgentProvider` → `provider.query(input)`
|
||||||
|
|
||||||
|
### 切换 Provider
|
||||||
|
|
||||||
|
修改 agent group 的 `container_configs.provider` 列:
|
||||||
|
```bash
|
||||||
|
ncl groups config update --id <group-id> --provider opencode
|
||||||
|
```
|
||||||
|
写入中央库 → 下次容器 spawn 物化到 `container.json` → agent-runner 调用 `createProvider('opencode', ...)`
|
||||||
|
|
||||||
|
### Host 端 Provider 注册
|
||||||
|
|
||||||
|
对于需要 host 端 setup 的 provider(额外 mounts、env 传递),有独立的 registry:`src/providers/provider-container-registry.ts:43-54`。Provider 注册 `ProviderContainerConfigFn`,container-runner 在 spawn 时调用它来合并额外的 mounts/env。目前仅 `claude` 内建——用默认容器,无需额外注册。非默认 provider(如 OpenCode)会在这里注册。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q19: 容器里的 MCP server 怎么启动?内置工具和外部 MCP server 有什么不同?
|
||||||
|
|
||||||
|
### 答案
|
||||||
|
|
||||||
|
### MCP Server Bootstrap
|
||||||
|
|
||||||
|
入口是 `container/agent-runner/src/mcp-tools/index.ts:1-22`:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 导入链:index.ts → 导入 core.ts, scheduling.ts, interactive.ts,
|
||||||
|
agents.ts, self-mod.ts 以便它们的副作用 registerTools([...]) 调用生效
|
||||||
|
2. 所有导入解决后,调用 startMcpServer()
|
||||||
|
3. 如果 server 崩溃,process.exit(1)
|
||||||
|
```
|
||||||
|
|
||||||
|
`startMcpServer()`(`server.ts:35-54`):
|
||||||
|
1. 创建 MCP `Server` 实例,name=`'nanoclaw'`,version=`'2.0.0'`
|
||||||
|
2. 注册两个请求 handler:
|
||||||
|
- `ListToolsRequestSchema` → 返回 `allTools.map(t => t.tool)`(line 38-40)
|
||||||
|
- `CallToolRequestSchema` → 查 `toolMap.get(name)`,调 `tool.handler(args)`(line 42-48)
|
||||||
|
3. 创建 `StdioServerTransport` 并连接(line 51-52)——Claude SDK 通过 stdio 发现 MCP server
|
||||||
|
4. 记录所有注册的 tool 名称
|
||||||
|
|
||||||
|
### Tool 自注册模式
|
||||||
|
|
||||||
|
每个 tool 模块在模块作用域调用 `registerTools([...])`。`registerTools()`(`server.ts:24-33`):将每个 `McpToolDefinition` 推入两个结构:`allTools[]`(ListTools 用)和 `toolMap`(name→definition,CallTool 用)。重复的名称警告但不报错。
|
||||||
|
|
||||||
|
`McpToolDefinition` 类型(`types.ts:1-6`):
|
||||||
|
```typescript
|
||||||
|
interface McpToolDefinition {
|
||||||
|
tool: Tool; // MCP SDK Tool schema (name, description, inputSchema)
|
||||||
|
handler: (args) => Promise<CallToolResult>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 内置 Tool vs 外部 MCP Server
|
||||||
|
|
||||||
|
**内置(in-tree)工具** — 定义在 `container/agent-runner/src/mcp-tools/`:
|
||||||
|
|
||||||
|
| 模块 | 工具 | 机制 |
|
||||||
|
|------|------|------|
|
||||||
|
| `core.ts`(line 95-263) | `send_message`、`send_file`、`edit_message`、`add_reaction` | 写 `outbound.db` 的 `messages_out` 表;通过 local destinations map 解析目标 |
|
||||||
|
| `scheduling.ts` | `schedule_task` | 持久调度,`process_after` / `recurrence` 字段 |
|
||||||
|
| `interactive.ts` | `ask_user_question` | 写中央库 `pending_questions`,host poll 并发送卡片 |
|
||||||
|
| `agents.ts` | `create_agent` | 通过 system actions spawn 子 agent 容器 |
|
||||||
|
| `self-mod.ts` | `install_packages`、`add_mcp_server` | 写 `pending_approvals` 表;host 处理审批和容器重建 |
|
||||||
|
|
||||||
|
这些工具以 JavaScript 函数形式**运行在容器进程内部**。与 host 通信通过写 DB 表(`messages_out`、`pending_approvals` 等)——不是 IPC。
|
||||||
|
|
||||||
|
**外部 MCP server** — per-agent-group 配置在 `container_configs.mcp_servers`(JSON 字符串,默认 `'{}'`):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"server-name": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@some/mcp-server"],
|
||||||
|
"env": { "API_KEY": "..." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Claude provider 把 `this.mcpServers` 直接传给 SDK 的 `mcpServers` 选项(`claude.ts:306`)。SDK 以子进程方式 **spawn 它们**,通过 stdio 自动发现它们的工具。Tool allowlist(`claude.ts:66-68`)派生 MCP patterns:
|
||||||
|
```typescript
|
||||||
|
function mcpAllowPattern(serverName: string): string {
|
||||||
|
return `mcp__${serverName.replace(/[^a-zA-Z0-9_-]/g, '_')}__*`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键区别:** 内置工具写 session DB;外部 MCP server 是由 SDK 管理的 stdio 子进程,通过 MCP 协议通信。内置工具能直接访问所有内部 DB 表和 destination/resolution 系统;外部 MCP 工具只能看到 provider SDK 通过 MCP 协议暴露的内容。
|
||||||
|
|
||||||
|
### MCP Tools ↔ Provider 交互
|
||||||
|
|
||||||
|
交互是间接的:
|
||||||
|
|
||||||
|
1. Provider 启动 → 传 `mcpServers` config 给 SDK → SDK spawn 外部 MCP 进程并发现它们的 tool schema
|
||||||
|
2. **NanoClaw MCP server**(stdio)在 provider 之前由 `index.ts` 启动——SDK 连接它并发现内置 tool schema
|
||||||
|
3. Agent(Claude 模型)决定调用 tool → SDK 路由到内置或外部 MCP server → 结果返回模型
|
||||||
|
4. Provider hooks(`PreToolUse`、`PostToolUse`)对**所有** tool 调用运行,不管来源(`claude.ts:160-189`)——用于为 host sweep 的卡住容忍度逻辑跟踪 tool-in-flight 状态
|
||||||
|
|
||||||
|
Provider 永远不会直接"调用"MCP tool。模型调用。Provider 只设置环境(MCP server configs、tool allowlists、hooks)。
|
||||||
|
|
||||||
|
### 不允许的 SDK Builtins
|
||||||
|
|
||||||
|
`claude.ts:25-35` 定义 `SDK_DISALLOWED_TOOLS` —— Claude Code SDK builtins 被屏蔽,因为 NanoClaw 有等价实现或它们不适合无头模型:
|
||||||
|
- `CronCreate/CronDelete/CronList/ScheduleWakeup` → 被 `mcp__nanoclaw__schedule_task` 替代
|
||||||
|
- `AskUserQuestion` → 被 `mcp__nanoclaw__ask_user_question` 替代
|
||||||
|
- `EnterPlanMode/ExitPlanMode/EnterWorktree/ExitWorktree` → Claude Code UI 功能;在无头容器中挂起
|
||||||
|
|
||||||
|
如果 disallowed tool 意外通过 allowlist 过滤器,`preToolUseHook`(line 160-169)在调用时拦截它——深度防御。
|
||||||
158
docs/answers/08-channel-adapters.md
Normal file
158
docs/answers/08-channel-adapters.md
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
# Q20-Q21: Channel 适配器
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q20: 如果要加一个新的 channel(比如钉钉),需要实现什么接口、改哪些文件?
|
||||||
|
|
||||||
|
### 答案
|
||||||
|
|
||||||
|
### `ChannelAdapter` 接口(完整合约)
|
||||||
|
|
||||||
|
定义在 `src/channels/adapter.ts:111-167`:
|
||||||
|
|
||||||
|
**必需属性:**
|
||||||
|
|
||||||
|
| 属性 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `name` | `string` | 适配器显示名称 |
|
||||||
|
| `channelType` | `string` | 唯一类型标识(如 `'dingtalk'`) |
|
||||||
|
| `supportsThreads` | `boolean` | `true`=平台以线程为主要对话单元,`false`=频道本身就是对话 |
|
||||||
|
|
||||||
|
**必需方法:**
|
||||||
|
|
||||||
|
| 方法 | 签名 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `setup(config)` | `(config: ChannelSetup) => Promise<void>` | 初始化连接,注册事件 handler |
|
||||||
|
| `teardown()` | `() => Promise<void>` | 优雅关闭 |
|
||||||
|
| `isConnected()` | `() => boolean` | 连接状态 |
|
||||||
|
| `deliver(platformId, threadId, message)` | `(string, string\|null, OutboundMessage) => Promise<string\|undefined>` | 发送出站消息;如有则返回平台消息 ID |
|
||||||
|
|
||||||
|
**可选方法:**
|
||||||
|
|
||||||
|
| 方法 | 何时实现 | 用途 |
|
||||||
|
|------|---------|------|
|
||||||
|
| `setTyping?(platformId, threadId)` | 有 typing 指示器的聊天平台 | 显示 "bot is typing..." |
|
||||||
|
| `syncConversations?()` | 有频道发现的平台 | 列出所有可访问的对话 |
|
||||||
|
| `resolveChannelName?(platformId)` | 显示需要 | 频道 ID 的人类可读名称 |
|
||||||
|
| `subscribe?(platformId, threadId)` | 有线程订阅的平台 | 订阅 bot 到线程;幂等——调用两次是 no-op |
|
||||||
|
| `openDM?(userHandle)` | 需要区分 user ID 和 DM channel ID 的平台 | 打开或获取 DM 频道,返回 DM 的 `platform_id`。仅 Discord、Slack、Teams、Webex、gChat 需要。Telegram、WhatsApp、iMessage 等跳过——user handle 就是 DM chat ID |
|
||||||
|
|
||||||
|
### `ChannelSetup` 接口(适配器拿到的回调)
|
||||||
|
|
||||||
|
定义在 `adapter.ts:9-26`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ChannelSetup {
|
||||||
|
onInbound(platformId, threadId, message: InboundMessage): void | Promise<void>; // 入站消息
|
||||||
|
onInboundEvent(event: InboundEvent): void; // CLI/admin 传输
|
||||||
|
onMetadata(platformId, name?, isGroup?): void; // 频道元数据
|
||||||
|
onAction(questionId, selectedOption, userId): void; // 按钮点击
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 两种实现模式
|
||||||
|
|
||||||
|
**模式 1:原生 Adapter** — 直接实现 `ChannelAdapter`,处理平台原始协议(HTTP API、WebSocket 等)。示例:WhatsApp (Baileys)、Signal、iMessage。
|
||||||
|
|
||||||
|
**模式 2:Chat SDK Bridge** — 用 `createChatSdkBridge(config)`(`chat-sdk-bridge.ts:122`)把已有 Chat SDK `Adapter` 包装成 `ChannelAdapter`。用于 Discord、Slack、Telegram、Teams、GitHub、Linear、Webex、Matrix、Google Chat。
|
||||||
|
|
||||||
|
### 加新 Channel 要创建/修改的文件
|
||||||
|
|
||||||
|
1. **创建适配器模块:**
|
||||||
|
- 如果用 Chat SDK:`src/channels/dingtalk.ts` 调 `createChatSdkBridge({...})`
|
||||||
|
- 如果用原生:`src/channels/dingtalk.ts` 直接实现 `ChannelAdapter`
|
||||||
|
|
||||||
|
2. **自注册:** 调 `registerChannelAdapter('dingtalk', { factory, containerConfig? })`
|
||||||
|
|
||||||
|
3. **接入导入:** 在 `src/channels/index.ts` 添加 `import './dingtalk.js';`(启动时加载所有适配器的 barrel)
|
||||||
|
|
||||||
|
4. **Container config(可选):** 如果适配器需要在 agent 容器内额外 mount 或 env var:
|
||||||
|
```typescript
|
||||||
|
registerChannelAdapter('dingtalk', {
|
||||||
|
factory: () => createDingTalkAdapter(),
|
||||||
|
containerConfig: {
|
||||||
|
mounts: [{ hostPath: '/path/to/certs', containerPath: '/certs', readonly: true }],
|
||||||
|
env: { DINGTALK_APP_KEY: process.env.DINGTALK_APP_KEY! },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Skill 打包:** 按 `CONTRIBUTING.md` 规范写成 channel install skill:
|
||||||
|
- `skills/add-dingtalk/SKILL.md` — 面向用户的指引
|
||||||
|
- Skill 从 `channels` 分支复制 `src/channels/dingtalk.ts`,追加 import 到 barrel,`pnpm install <pkg>`
|
||||||
|
|
||||||
|
### 关键边界情况
|
||||||
|
|
||||||
|
| 情况 | 如何处理 |
|
||||||
|
|------|---------|
|
||||||
|
| 缺少凭据 | Factory 返回 `null` → `initChannelAdapters` 跳过并 warn(`channel-registry.ts:57-59`) |
|
||||||
|
| Setup 时网络错误 | 指数退避重试:[2s, 5s, 10s](`channel-registry.ts:10,68-87`) |
|
||||||
|
| 重复注册 | `registerChannelAdapter`(line 26):`registry.set()` 静默覆盖 |
|
||||||
|
| `isMention` flag 传播 | 知道平台 mention 语义的适配器设置 `InboundMessage.isMention`——router 用它替代正则 name-matching |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q21: Chat SDK bridge 是什么?为什么 Discord/Slack/Telegram 等共用它?
|
||||||
|
|
||||||
|
### 答案
|
||||||
|
|
||||||
|
### 什么是 Chat SDK Bridge
|
||||||
|
|
||||||
|
Chat SDK bridge(`src/channels/chat-sdk-bridge.ts`,680 行)是一个**通用适配器 shim**,把任何 [Chat SDK](https://github.com/nicepkg/chat) `Adapter` 实例包装成 NanoClaw 兼容的 `ChannelAdapter`。它一次性处理平台无关的 concern,让每个具体 channel 模块只需提供平台特定配置。
|
||||||
|
|
||||||
|
核心工厂函数 `createChatSdkBridge(config)`(line 122),返回完整实现所有必需和可选方法的 `ChannelAdapter` 对象。
|
||||||
|
|
||||||
|
### Bridge 集中化的 10 项 concern
|
||||||
|
|
||||||
|
1. **四种 dispatch 路径**(line 213-266)——Chat SDK 区分四种入站消息,bridge 把它们映射为正确的 `isMention` flag:
|
||||||
|
- `onSubscribedMessage` → `onInbound(channelId, threadId, message, isMention=message.isMention)`
|
||||||
|
- `onNewMention` → `onInbound(channelId, threadId, message, isMention=true)`
|
||||||
|
- `onDirectMessage` → `onInbound(channelId, threadId, message, isMention=true, isGroup=false)`
|
||||||
|
- `onNewMessage(/[\s\S]*/)` → `onInbound(channelId, threadId, message, isMention=false, isGroup=true)`
|
||||||
|
|
||||||
|
2. **附件下载**(line 138-163)——序列化消息到 JSON 前先下载附件为 base64,使其在 `inbound.db` content 列中存活
|
||||||
|
|
||||||
|
3. **Reply context 提取**(line 164-169)——平台特定 hook(`extractReplyContext`)
|
||||||
|
|
||||||
|
4. **Sender 字段归一化**(line 173-180)——把 Chat SDK 的嵌套 `author` 对象投影为 router 需要的扁平 `senderId`/`sender`/`senderName`
|
||||||
|
|
||||||
|
5. **卡片渲染**(line 387-470)——把 `ask_question` 和 `send_card` MCP tool payload 渲染为带按钮的 Card。处理按钮编码(整数 index vs 全值,适配 Telegram 64 字节 callback_data 限制)
|
||||||
|
|
||||||
|
6. **文本拆分**(line 480-497,`splitForLimit` line 106-120)——按 paragraph→line→space→char 边界拆分消息,适配 `maxTextLength`(Discord 2000,Telegram 4096)
|
||||||
|
|
||||||
|
7. **Gateway listener**(line 303-359)——对支持 Gateway 的适配器(Discord),启动本地 HTTP server 接收转发事件,指数退避重启(上限 1h)
|
||||||
|
|
||||||
|
8. **Webhook 注册**(line 362)——对非 gateway 适配器(Slack、Teams、GitHub),注册到共享 webhook server
|
||||||
|
|
||||||
|
9. **`openDM` 委托**(line 534-549)——直接委托 `adapter.openDM()` 而非 `chat.openDM()`
|
||||||
|
|
||||||
|
10. **Transform hook**(line 124)——`transformOutboundText` 允许 per-platform 文本清理
|
||||||
|
|
||||||
|
### 原生 Adapter vs Chat SDK Bridge
|
||||||
|
|
||||||
|
| 方面 | Chat SDK Bridge | 原生 Adapter |
|
||||||
|
|------|----------------|--------------|
|
||||||
|
| **平台协议** | 由 Chat SDK 的 `Adapter` 处理 | 直接处理(如 Baileys 处理 WhatsApp WebSocket) |
|
||||||
|
| **消息解析** | Chat SDK 归一化为 `Message` 类型;bridge 序列化为 JSON | Adapter 解析原始平台 payload,直接构造 `InboundMessage` |
|
||||||
|
| **Dispatch** | Bridge 映射 4 个 SDK dispatch 路径到 `onInbound` | Adapter 从自己的 event handler 直接调用 `onInbound` |
|
||||||
|
| **卡片/按钮渲染** | Bridge 用 Chat SDK 的 `Card`/`Button`/`Actions` 组件渲染 | Adapter 必须实现平台特定的交互式消息渲染 |
|
||||||
|
| **Gateway/Webhook** | Bridge 处理 Gateway listener 生命周期 | Adapter 自己处理连接生命周期 |
|
||||||
|
| **openDM** | 委托 `adapter.openDM()` | Adapter 通过平台 API 直接实现 |
|
||||||
|
| **Channel ID 编码** | `adapter.channelIdFromThreadId()` | Adapter 用自定义 ID 方案 |
|
||||||
|
| **示例** | Discord、Slack、Telegram、Teams、GitHub、Linear、Webex、Matrix、Google Chat、Resend | WhatsApp (Baileys)、Signal、iMessage |
|
||||||
|
|
||||||
|
### Bridge 处理的边界情况
|
||||||
|
|
||||||
|
| 情况 | 位置 |
|
||||||
|
|------|------|
|
||||||
|
| Gateway crash → IP block 保护 | Line 314-357:指数退避上限 1h,5min 健康运行后重置计数器 |
|
||||||
|
| Telegram 64 字节 callback_data 限制 | Line 400-408:把 button value 编码为整数 index |
|
||||||
|
| Discord interaction 更新 | Line 607-668:通过 Discord REST API 更新卡片,再 dispatch 到 `onAction` |
|
||||||
|
| 序列化时附件数据丢失 | Line 138-163:调用 `toJSON()` **前**先下载附件数据为 base64 |
|
||||||
|
| 媒体 only 空文本消息 | Line 263:`onNewMessage(/[\s\S]*/)` 匹配所有消息包括空文本 |
|
||||||
|
| `chat.openDM` 对非标准 user ID 抛错 | Line 534-549:直接委托 `adapter.openDM()` |
|
||||||
|
| Raw message 字段过大 | Line 183:`serialized.raw = undefined` 省 DB 空间 |
|
||||||
|
|
||||||
|
### `user-dm.ts` 兜底
|
||||||
|
|
||||||
|
对于没有 `openDM` 支持的 channel(Telegram、WhatsApp、iMessage、email、Matrix),`src/user-dm.ts` 直接把 user handle 当 DM platform_id——user 本身就是 DM chat。Bridge 仅当底层 `adapter.openDM` 存在时才附着 `openDM`(line 544)。
|
||||||
16
docs/answers/README.md
Normal file
16
docs/answers/README.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# 源码学习路线图 — 问题答案
|
||||||
|
|
||||||
|
> 对应 [`docs/learning-roadmap.md`](../learning-roadmap.md) 中的 21 个引导性问题。按子系统分组,每个问题附带完整的调用链、关键代码位置和边界情况分析。
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
| 文件 | 覆盖问题 | 内容 |
|
||||||
|
|------|----------|------|
|
||||||
|
| [01-global-architecture.md](01-global-architecture.md) | Q1, Q2, Q3 | 消息完整链路、Node/Bun 运行时分离、seq 奇偶规则、跨 mount 不变式 |
|
||||||
|
| [02-routing-and-sessions.md](02-routing-and-sessions.md) | Q4, Q5, Q6 | 路由决策、三种隔离模式、`on_wake` 防竞态机制 |
|
||||||
|
| [03-permissions-and-security.md](03-permissions-and-security.md) | Q7, Q8, Q9 | 三级权限检查、陌生人审批、`cli_scope` 四层防御 |
|
||||||
|
| [04-container-lifecycle.md](04-container-lifecycle.md) | Q10, Q11, Q12 | Mount 架构、system prompt 组合、心跳检测与卡住判定 |
|
||||||
|
| [05-delivery-and-system-actions.md](05-delivery-and-system-actions.md) | Q13, Q14, Q15 | 投递重试、自我修改审批链路、定时任务 cron |
|
||||||
|
| [06-data-model.md](06-data-model.md) | Q16, Q17 | 中央库 ER 图、迁移系统注册机制 |
|
||||||
|
| [07-provider-and-mcp.md](07-provider-and-mcp.md) | Q18, Q19 | Provider 工厂模式、内置 MCP vs 外部 MCP server |
|
||||||
|
| [08-channel-adapters.md](08-channel-adapters.md) | Q20, Q21 | `ChannelAdapter` 接口、Chat SDK bridge 架构 |
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# NanoClaw 源码学习路线图
|
# NanoClaw 源码学习路线图
|
||||||
|
|
||||||
|
> **配套答案:** 路线图中 21 个问题的详细答案见 [`docs/answers/`](answers/) 目录。
|
||||||
|
|
||||||
## 前置知识
|
## 前置知识
|
||||||
|
|
||||||
阅读源码前,确保理解以下核心概念:
|
阅读源码前,确保理解以下核心概念:
|
||||||
|
|||||||
Reference in New Issue
Block a user