8.8 KiB
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(完全不隔离)
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)
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(最严格隔离)
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):
createSession()写中央库sessions表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 为什么不会被旧容器偷走?
答案
唤醒路径有两个:
-
路由时立即唤醒:
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 容器
- 检查
-
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:
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:
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 回调:
() => {
const s = getSession(session.id);
if (s) wakeContainer(s);
}
这个设计保证:
- 旧容器完全退出(进程终止,mount 释放)后才触发新容器 spawn
- 新容器的
clearStaleProcessingAcks()(connection.ts:175)清掉旧容器遗留的processing声明 - 新容器第一轮 poll 时
isFirstPoll=true,捡起on_wake=1消息
容器从 idle 到 kill 到重新唤醒的生命周期
Host sweep 在 host-sweep.ts:147-211 中判断容器健康:
- 天花板检查(line 99-105):heartbeat mtime 年龄 > max(30min, Bash 超时) → kill
- per-claim 卡住检查(line 107-115):每个
processing_ack行,如果 claim 时间 > max(60s, Bash 超时) 且从此以后 heartbeat mtime 没有前进 → kill - 崩溃容器清理(line 199-201):容器已死但
processing_ack仍有遗留 →resetStuckProcessingRows()用指数退避(基数 5s × 2^tries,最多 5 次)重新调度消息 - 孤儿 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:155fs.rmSync(heartbeatPath(...), {force: true})清除孤儿 heartbeat - Bash 自定义超时:天花板和 claim-stuck 容忍度都扩展到
max(DEFAULT, declaredTimeoutMs),确保长时间 Bash 工具不被误杀