docs: add detailed answers for 21 learning roadmap questions
This commit is contained in:
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 工具不被误杀
|
||||
Reference in New Issue
Block a user