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

8.8 KiB
Raw Blame History

Q4-Q6: 路由与会话


Q4: 一个 messaging group 怎么决定路由到哪个 agent group没匹配上怎么办

答案

路由发生在 src/router.ts:158routeInbound() 函数中,分多步决策:

第一步:快速截断getMessagingGroupWithAgentCount(channelType, platformId)line 176一次数据库读就能判断频道是否已知、是否有 agent。如果没有匹配的 messaging group 且没有被 @mention直接静默返回line 211

第二步:记录无法投递的消息 — 如果 agentCount 为 0 但被 @mention 了(或频道已通过审批但没 agent

  • 如果 mg.denied_at 被设置owner 拒绝了这个频道)→ 静默丢弃line 212-217
  • 否则 → 记录到 unregistered_sendersreason 为 no_agent_wiredline 221
  • 可选触发 channelRequestGate 升级给 ownerline 231-238

第三步Fan-out — 如果 agentCount > 0getMessagingGroupAgents()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 gateline 283-284做模块级策略拒绝。

第六步:交付决策:

  • engages + 通过 gatedeliverToAgent()wake=true
  • 不 engage 但 ignored_message_policy='accumulate'deliverToAgent()wake=false,写入 trigger=0 作为静默上下文
  • 都不满足 → 丢弃,记录 no_agent_engaged

unregistered_senders 表的作用

尽管模块名叫 dropped-messages.ts,实际表名是 unregistered_senderssrc/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(完全不隔离)

SELECT * FROM sessions WHERE agent_group_id = ? AND status = 'active' ORDER BY created_at DESC LIMIT 1

src/db/sessions.ts:56-59

  • 完全忽略 messagingGroupIdthreadId,所有 wired 的 messaging group 共用一个 session
  • GitHub PR 评论和 Slack 消息出现在同一个对话中
  • 如果没有 session创建一个且 messaging_group_id = null

sharedper-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
  • 支持线程的 adapterSlack、Discord会把 session_mode: 'shared' 的 wiring 强制重写为 per-threadrouter.ts:410-413),除非 wiring 明确使用 agent-shared
  • DM 除外(mg.is_group === 0),不做强制重写

Session 创建的副作用

首次创建时(created: trueline 128-131

  1. createSession() 写中央库 sessions
  2. initSessionFolder()line 136-143data/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_wakedocs/db-session.md:51messages_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 时为 truepoll-loop.ts:71-74
  • isFirstPoll = true 时:过滤条件变为 on_wake = 0 OR true → 所有 pending 消息都可见
  • isFirstPoll = false 时:过滤条件变为 on_wake = 0 OR falseon_wake=1 的行不可见
  • 正在被 kill 的旧容器已过第一轮 poll,即使它撑到下一轮 poll也看不到 on_wake=1 的消息

killContaineronExit 回调机制

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);
}

这个设计保证:

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