docs: add detailed answers for 21 learning roadmap questions

This commit is contained in:
2026-05-13 03:40:13 +00:00
parent a8d90d2980
commit c4753da8f5
10 changed files with 1308 additions and 0 deletions

View 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` 升级给 ownerline 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) 组合已存在活跃 sessionline 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-openline 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
- 支持线程的 adapterSlack、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.jsonspawn 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-105heartbeat 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 319kill 后 `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 工具不被误杀