Files
nanoclaw/docs/answers/02-routing-and-sessions.md

174 lines
8.8 KiB
Markdown
Raw 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.

# 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 工具不被误杀