Files
nanoclaw/docs/answers/05-delivery-and-system-actions.md

11 KiB
Raw Blame History

Q13-Q15: 出站投递与系统动作


Q13: Agent 回复消息后delivery.ts 怎么知道用哪个 channel adapter 发送?重试和失败怎么处理?

答案

Delivery 系统使用两层轮询active poll每 1s扫描有运行容器的 sessionsweep poll每 60s扫描所有 active session。从 outbound.dbcontainer-owned读取inbound.dbdelivered 表中跟踪投递状态。Channel adapter 是 boot 时设置的单个全局 ChannelDeliveryAdapter

Adapter 的选择

  • messages_out 的每行带有 channel_typeplatform_id 字段container 的 writeMessageOut() 填入)
  • delivery.ts:356-363deliveryAdapter.deliver(channelType, platformId, threadId, kind, content, files) 被调用。Adapter 收到 channelType + platformId,负责路由到正确的平台
  • Adapter 通过 setDeliveryAdapter() 设置一次line 95是一个包装了所有 channel adapter 的 ChannelDeliveryAdapter

完整投递流程

1. Poll 触发: pollActive()1sline 121-133getRunningSessions()pollSweep()60sline 136-149getActiveSessions()

2. 防竞态line 151-162inflightDeliveriesSet<string> — 如果 active poll 和 sweep poll 竞态同个 session第二个调用者跳过防止重复投递

3. Drain sessiondrainSession()line 164-232

  • 只读打开 outbound.db,读写打开 inbound.db
  • getDueOutboundMessages(outDb)messages_outdeliver_after <= now
  • getDeliveredIds(inDb) 做去重比对line 183
  • 对每条未投递消息调用 deliverMessage()line 192

4. 消息路由deliverMessage()line 234-375

  • System actionsline 255-258msg.kind === 'system'handleSystemAction() → 查找 actionHandlers Map。模块通过 registerDeliveryAction() 注册处理器
  • Agent-to-agentline 264-271msg.channel_type === 'agent'routeAgentMessage()
  • Channel deliveryline 289-375
    • 权限检查line 289-311验证源 agent 是否有权发到目标 channel——要么目标是自己 session 的 originsession.messaging_group_id 匹配),要么 agent_destinations 中有显式行
    • Pending question 跟踪line 317-340ask_question 类型创建 pending_questions
    • 文件附件line 348-354从 session 的 outbox/<messageId>/ 读文件
    • Adapter callline 356-363实际发送
    • 清理line 372clearOutbox() 删除 outbox 目录

重试和失败处理

  • 投递尝试在 deliveryAttempts Map 中以消息 ID 为 key 在内存中跟踪(进程重启时重置,给失败消息全新机会)
  • MAX_DELIVERY_ATTEMPTS = 3 次失败后,标记为 permanent failedline 206-225
  • 重试是惰性的:消息留在 messages_out 表中未投递,下次 poll 迭代重新捡起

Q14: Agent 发起 install_packagesadd_mcp_server 的完整审批-执行链路是什么?

答案

自我修改采用 fire-and-forget 模式 + admin 审批门控。Agent 调 MCP tooltool 写 system message 到 outbound.db。Host delivery loop 拾起、验证/净化请求、排队审批admin 批准后应用修改、重建镜像如需要、kill 容器、写 on_wake 消息给新容器。

完整链路(逐步)

Phase 1: Agent 请求container 端)

  1. Agent 调 install_packagesadd_mcp_server MCP toolcontainer/agent-runner/src/mcp-tools/self-mod.ts

    • install_packagesline 53-78用正则验证包名APT_RENPM_RE,最多 20 个),写 kind: 'system' + action: 'install_packages' 的 outbound message
    • add_mcp_serverline 97-117验证 name 和 command 存在,写 action: 'add_mcp_server'
  2. outbound.db:用 writeMessageOut(),写入奇数 seq

Phase 2: Host delivery 拾起

  1. delivery.tsdeliverMessage() 看到 kind === 'system'handleSystemAction()
  2. handleSystemAction()line 410-425actionHandlers Map。self-mod 模块注册了 handler
    • handleInstallPackagesself-mod/request.ts:20-64
    • handleAddMcpServerself-mod/request.ts:66-91

Phase 3: 请求验证 + 审批排队

  1. Host 端验证(深度防御第二层):对 package 名称再次验证(同一正则),失败时调 notifyAgent() 告知 agent不创建审批

  2. 审批请求approvals/primitive.ts:164-220

    • pickApprover(session.agent_group_id) → scoped admins → global admins → owners
    • pickApprovalDelivery:找到可 DM 的审批者,优先同 channel 类型
    • deliveryAdapter.deliver()ask_question 卡片到 admin DM
    • 创建 pending_approvals 行,包含 actionpayloadJSONapproval_idsession_id

Phase 4: Admin 响应

  1. approvals/response-handler.ts:24-43handleApprovalsResponse()
    • Rejectline 72-77notify() 告知 agent
    • Approveline 80-105getApprovalHandler(approval.action) 注册的 handler传入 { session, payload, userId, notify }

Phase 5: 应用修改

  1. install_packages handlerself-mod/apply.ts:22-83

    • 去重后追加新 apt/npm 包到 DB 中已有列表line 37-49
    • buildAgentGroupImage()line 57构建 per-agent-group Docker 镜像(container-runner.ts:468-515),用 docker build -t nanoclaw-agent:<agentGroupId> 拉 900s 超时
    • on_wake: 1 消息告知 agent
    • Kill 容器 with onExit callbackline 72-75killContainer(sessionId, 'rebuild applied', () => { wakeContainer(s) }) —— 保证旧容器退出后新容器才 spawn
    • 重建失败line 77-82通知 admin不 kill 容器
  2. add_mcp_server handlerself-mod/apply.ts:85-125

    • 添加 MCP server 到 DB 的 mcp_servers JSONline 99-105
    • on_wake: 1 消息line 107-120
    • Kill 容器 with onExitwakeContainer callbackline 121-124
    • 不需要重建镜像 —— Bun 直接运行 TS纯 MCP wiring 变动不需要 Dоcker 构建

Phase 6: 新容器启动

  1. onExit callback 触发 → wakeContainer()spawnContainer()
    • 从 DB 物化新 container.jsonline 127
    • composeGroupClaudeMd() 重新生成 CLAUDE.mdline 261
    • clearStaleProcessingAcks() 清掉旧 processing ackconnection.ts:175-177
    • getPendingMessages(isFirstPoll=true) 捡起 on_wake: 1 消息 —— 仅第一轮 poll 可见

为什么 on_wake 是防竞态的

messages_inon_wake 列 + getPendingMessages()isFirstPoll 门控:第一轮 poll 包含 on_wake = 1 行,后续轮排除(它们已 completed)。结合 killContaineronExit callback旧容器绝无可能先于新容器偷走 on_wake 消息。


Q15: 定时任务cron怎么实现

答案

定时任务实现为 messages_in 表中 kind='task' 的行piggyback 在核心 schema 上没有专用表。Agent 通过 MCP tool 创建任务host 把它们写入 inbound.dbrecurrence 由 host sweep hook 驱动:克隆已完成的周期性任务为新 pending 行。

创建任务

  1. Agent 通过 schedule_task MCP toolcontainer/agent-runner/src/mcp-tools/scheduling.ts)写 kind: 'system' + action: 'schedule_task' 的 outbound message包含 taskIdpromptprocessAfter(首次运行 ISO 时间戳)、可选 recurrencecron 表达式)

  2. Host delivery 拾起 → handleSystemAction() → 注册的 action: 'schedule_task' handlerscheduling/actions.ts:19-40

    • insertTask()scheduling/db.ts:17-36):插入 messages_in 行,kind = 'task'status = 'pending'process_after = <首次运行时间>recurrence = <cron-expr>series_id = <taskId>
    • 内容存储为 JSON { prompt, script }
  3. Agent 也可以创建非周期性调度消息:schedule_message 工具同理,但 kind 匹配原始消息类型

触发Host sweep + countDueMessages

  1. Host sweep 唤醒容器(host-sweep.ts:180-186

    • countDueMessages(inDb) 计数 status = 'pending' AND process_after <= now 的行
    • dueCount > 0 && !isContainerRunningwakeContainer(session)
  2. Container 处理任务:getPendingMessages() 读 pending 行,包括 task 行,格式化后给 provider

Recurrencehost sweep + handleRecurrence

  1. Recurrence fanoutscheduling/recurrence.ts:21-53),每 60s sweep 周期调用(host-sweep.ts:205-206
    • getCompletedRecurring(inDb)scheduling/db.ts:122-126):找 status = 'completed' AND recurrence IS NOT NULL 的行
    • 对每个完成的行:
      • 在用户时区(非 UTC解析 cron 表达式
      • 计算 nextRun = interval.next().toISOString()
      • insertRecurrence()scheduling/db.ts:128-149):复制原行,设置 process_after = nextRunstatus = 'pending'
      • clearRecurrence()line 151-153设原行 recurrence = NULL,防止下次周期重复克隆

其他任务生命周期操作

  1. Cancel/Pause/Resumeactions.ts:42-70
    • cancelTask()line 38-42通过 id OR series_id 匹配,设所有 pending/paused 行为 completed
    • pauseTask()line 44-48resumeTask()line 50-54同理
    • series_id 匹配意味着 agent 可以引用任意一次执行取消整个系列

Host sweep + recurrence 协作顺序

Host sweep每 60s
  Step 1: syncProcessingAcks()
  Step 2: countDueMessages() → 如果到期 + 容器不在运行 → wakeContainer()
  Step 3: enforceRunningContainerSla() ← heartbeat/claim-stuck 检查
  Step 4: resetStuckProcessingRows() ← 崩溃容器清理
  Step 5: handleRecurrence() ← 扫描已完成周期性任务,克隆下次执行

关键顺序: Step 2唤醒到期消息在 Step 4崩溃容器清理之前运行确保新容器有机会在启动时清自己的孤儿 processing_ack

边界情况

  • 没有专用表:任务是 messages_in 行——核心 messages_in schema 中 kind 字段足以区分
  • series_id:周期性任务的每次执行共享同一 series_id。Cancel/pause/resume 用 id OR series_id 匹配,影响整个系列
  • 时区Cron 表达式以用户配置的 TIMEZONE 解析(来自 .env),非 UTC
  • Pre-task scripts gatingTask 行可带 script 字段。applyPreTaskScripts() hookpoll-loop.ts:149,323)先跑 script。如果返回 wakeAgent: false,任务标记完成但不唤醒 agent——实现 "仅用户活跃时运行" 等条件
  • Agent 不能写 inbound.dbContainer 把 task 写成 kind: 'system' 的 outbound messagehost 的 delivery action handler 才是实际插入 messages_in 的组件——保持单 writer 不变式