April 21, 2026

[当心跳运行产生 HEARTBEAT_OK 时引导用户消息被吞没] - Steered User Message Swallowed When Heartbeat Run Produces HEARTBEAT_OK

在引导队列模式下,注入到心跳运行中的用户消息会在代理产生 HEARTBEAT_OK 时丢失,原因是运行级别的响应抑制逻辑将整个运行视为心跳空操作。

🔍 症状

主要表现

当用户在 steer 队列模式下活跃的心跳运行期间发送消息时,用户不会收到任何回复,尽管代理生成了正确的回复。

精确复现步骤

  1. 将队列模式配置为 steer
    # config.yaml
    messages:
      queue:
        mode: "steer"
    heartbeat:
      interval: 3s
    
  2. 等待心跳运行触发(在日志中显示为 HEARTBEAT_RUN
  3. 在心搏窗口期间发送用户消息
  4. 在应用程序日志中观察以下内容:
    [heartbeat] HEARTBEAT_RUN triggered at 2024-01-15T10:30:01.234Z
    [steer] Injecting user message "What's the weather?" into heartbeat run
    [agent] Processing run #42 (heartbeat + steered message)
    [agent] Run #42 produced responses:
      - HEARTBEAT_OK (heartbeat ack)
      - TEXT: "The weather is sunny, 72°F"
    [outbound] Discarding run #42 responses (HEARTBEAT_OK detected)
    [channel] Delivered: HEARTBEAT_OK ack only (no user message)
    
  5. 用户未收到任何内容——TEXT 响应从未发送到 Telegram

诊断证据

面向用户的响应存在于会话记录中,生成时间戳正确,但从未到达通道层:

$ openclaw session show --id session-abc123
Session Transcript:
  [10:30:01.234] ← HEARTBEAT (scheduled)
  [10:30:02.456] ← USER: "What's the weather?" (steered into heartbeat run)
  [10:30:02.789] → HEARTBEAT_OK
  [10:30:03.012] → TEXT: "The weather is sunny, 72°F"  ← EXISTS IN TRANSCRIPT
  [10:30:03.100] ← Delivered: HEARTBEAT_OK only         ← MISSING TEXT

变通方案验证

切换到 collect 模式可以避免此问题:

# config.yaml
messages:
  queue:
    mode: "collect"  # ← Workaround: queues as separate turn

collect 模式下,用户消息会被独立排队,并获得自己的运行周期和完整投递。

🧠 根因分析

架构分析:运行级响应抑制

此问题的根本原因在于心跳处理逻辑中的一个基本设计假设:包含 HEARTBEAT_OK 的运行被归类为"心跳无操作",整个运行的出站投递都会被抑制。

故障序列

  1. Steer 模式注入:在 steer 模式下,在活跃心跳运行期间到达的传入用户消息会被注入到该运行中,而不是创建新运行。这是刻意设计的——steer 优先考虑延迟而非隔离。
  2. 多响应运行生成:代理处理组合的心跳 + 用户消息上下文,并按顺序生成多个响应:
    Response 1: HEARTBEAT_OK    // Agent acknowledges heartbeat with no action
    Response 2: TEXT           // Agent responds to user's actual question
    
  3. 运行分类逻辑:运行分类逻辑扫描响应中的任何 HEARTBEAT_* 标记:
    // Simplified classification pseudocode
    function classifyRun(responses):
      for response in responses:
        if response.type.startsWith("HEARTBEAT_"):
          return RUN_TYPE.HEARTBEAT_NOOP  // ← Triggers suppression
      return RUN_TYPE.NORMAL
    
  4. 过早抑制:由于存在 HEARTBEAT_OK,整个运行被标记为 HEARTBEAT_NOOP,触发出站投递层的丢弃逻辑:
    // Outbound delivery pseudocode
    function deliverResponses(run):
      if run.classification === RUN_TYPE.HEARTBEAT_NOOP:
        return  // ← Entire delivery skipped, including user response
      deliverToChannel(run.responses)
    
  5. 用户响应丢失TEXT 响应(有效的面向用户的内容)被丢弃,因为它与 HEARTBEAT_OK 共享一个运行。

代码位置参考

组件文件问题
Steer 注入src/queue/steer.ts将用户消息注入到活跃心跳运行中
运行分类src/runs/classifier.ts根据 HEARTBEAT_* 的存在对整个运行进行分类
出站投递src/outbound/delivery.ts跳过 HEARTBEAT_NOOP 运行的投递

为什么 Collect 模式有效

collect 模式下,用户消息被排队为独立的跟进轮次。它获得自己独立的运行,仅包含面向用户的响应——没有 HEARTBEAT_* 标记——因此分类逻辑正确地将其识别为 NORMAL 并进行投递。

🛠️ 逐步修复

方案 1:在分类前过滤响应(推荐)

修改运行分类逻辑,在确定运行类型时忽略 HEARTBEAT_OK 响应,允许同一运行中的面向用户响应被投递。

修改前

// src/runs/classifier.ts
function classifyRun(responses: Response[]): RunType {
  for (const response of responses) {
    if (response.type.startsWith("HEARTBEAT_")) {
      return RUN_TYPE.HEARTBEAT_NOOP;
    }
  }
  return RUN_TYPE.NORMAL;
}

修改后

// src/runs/classifier.ts
function classifyRun(responses: Response[]): RunType {
  const hasHeartbeatAction = responses.some(
    r => r.type.startsWith("HEARTBEAT_") && r.type !== "HEARTBEAT_OK"
  );
  const hasUserFacingResponse = responses.some(
    r => !r.type.startsWith("HEARTBEAT_") && r.type !== "CONTROL"
  );

if (hasHeartbeatAction && !hasUserFacingResponse) { return RUN_TYPE.HEARTBEAT_NOOP; } return RUN_TYPE.NORMAL; }

方案 2:修改出站投递以提取用户响应

如果无法修改分类逻辑,请修改出站投递层,从 HEARTBEAT_NOOP 运行中提取并投递非心跳响应。

// src/outbound/delivery.ts
function deliverResponses(run: Run): void {
  if (run.classification !== RUN_TYPE.HEARTBEAT_NOOP) {
    deliverToChannel(run.responses);
    return;
  }
  
  // Extract user-facing responses from heartbeat no-op runs
  const userResponses = run.responses.filter(
    r => !r.type.startsWith("HEARTBEAT_") && r.type !== "CONTROL"
  );
  
  if (userResponses.length > 0) {
    deliverToChannel(userResponses);
  }
}

方案 3:基于配置的工作区

如果无法立即进行代码修改,请配置系统以避免此条件:

# config.yaml
messages:
  queue:
    mode: "collect"  # Avoids steered injection into heartbeat runs
    
heartbeat:
  interval: 60s     # Reduces chance of user message arriving during heartbeat
  # Or disable heartbeat during active conversations:
  pause_on_active: true

部署步骤

  1. 备份配置
    cp config.yaml config.yaml.backup
  2. 应用修复
    # If using Option 1 or 2:
    vim src/runs/classifier.ts   # or src/outbound/delivery.ts
    npm run build
  3. 重启服务
    docker-compose down && docker-compose up -d
    # Or for systemd:
    sudo systemctl restart openclaw
  4. 验证配置
    openclaw config show | grep -A5 "queue:"
    openclaw status

🧪 验证

测试用例 1:Steered 消息投递

目的:验证注入到心跳运行中的用户消息是否被投递。

# 1. Configure steer mode with short heartbeat
openclaw config set messages.queue.mode steer
openclaw config set heartbeat.interval 3s
openclaw restart

# 2. Monitor logs in one terminal
openclaw logs --follow | grep -E "(HEARTBEAT|steer|deliver)"

# 3. Send user message during heartbeat window
# Wait for log line: [heartbeat] HEARTBEAT_RUN triggered
# Then immediately send: "Testing steer delivery"

# 4. Verify delivery
# Expected: User receives response on Telegram
# Expected log: [outbound] Delivered: TEXT response to user

成功标准

  • 用户在 Telegram 上收到回复
  • 日志显示 Delivered: TEXT(而不是 Discarded
  • 会话记录显示 HEARTBEAT_OKTEXT 响应

测试用例 2:纯心跳仍然被抑制

目的:确保真正的 HEARTBEAT_NOOP 运行(没有用户消息)仍然被抑制。

# 1. Wait for heartbeat to fire with NO user interaction
# Monitor logs for a clean heartbeat run

# 2. Expected log behavior
[heartbeat] HEARTBEAT_RUN triggered
[heartbeat] HEARTBEAT_OK generated (no user message)
[outbound] Discarding run (HEARTBEAT_NOOP)

# 3. Verify: User should NOT receive any notification from this run

成功标准

  • 仅心跳运行不会产生用户通知
  • 日志显示纯心跳运行的 Discarding

测试用例 3:会话记录验证

# Get session ID from a recent conversation
openclaw session list --limit 5

Show detailed transcript

openclaw session show –id <SESSION_ID> –verbose

Verify structure contains both response types

Expected output should show: [timestamp] → HEARTBEAT_OK [timestamp] → TEXT: “response content” [timestamp] ← Delivered: HEARTBEAT_OK, TEXT ← Both delivered

回归测试

# Run existing test suite
npm test -- --grep "heartbeat"

Run steer mode specific tests

npm test – –grep “steer”

Expected: All tests pass including:

- steer mode message injection

- heartbeat response classification

- outbound delivery filtering

退出码验证

# After fix deployment
openclaw health check; echo "Exit code: $?"
# Expected: 0 (healthy)

Check service logs

docker-compose logs openclaw 2>&1 | tail -20

Expected: No ERROR level entries related to delivery

⚠️ 常见陷阱

环境特定陷阱

环境陷阱缓解措施
Docker容器时钟漂移可能导致心跳计时变得不可靠,加剧心跳运行和用户消息注入之间的竞态条件确保 NTP 同步:docker run --cap-add=SYS_TIME openclaw:latest
VPS/云Telegram API 和服务器之间的网络延迟可能掩盖问题的时序敏感性使用本地机器人 webhook 而非长轮询进行测试,以减少变量
macOS(开发)由于系统睡眠/休眠状态,心跳计时器可能不太可靠触发测试期间禁用系统睡眠:caffeinate -s
Windows(开发)配置文件中的行尾差异(\r\n vs \n)可能导致解析问题使用 Unix 行尾:set FILE_OPTS=-o nowrap 在编辑器中

时序敏感性

此错误高度依赖时序。竞态窗口存在于以下之间:

  1. 心跳运行开始(记录 HEARTBEAT_RUN
  2. 心跳确认生成(记录 HEARTBEAT_OK
  3. 用户消息到达并被注入
  4. 用户响应生成
  5. 运行分类发生

建议:使用 3s5s 的心跳间隔进行可靠的复现测试。低于 1s 的间隔可能使竞态条件过于紧凑,无法可靠触发。

配置错误

  • steerforce 混淆
    # Wrong - force mode ignores queue entirely
    messages.queue.mode: "force"
    

    Correct - steer mode uses intelligent injection

    messages.queue.mode: “steer”

  • 心跳间隔过长掩盖问题
    # This interval may never overlap with user messages
    heartbeat.interval: 300s  # 5 minutes - unlikely to catch user messages
    

    Better for testing

    heartbeat.interval: 3s

  • 缺少通道特定的队列设置
    # Some channels may override global queue mode
    telegram:
      queue_mode: "collect"  # ← May override steer setting
    

    Use channel-agnostic config

    messages.queue.mode: “steer”

误诊:混淆症状

这些问题可能看起来相似但有不同的根本原因:

  • 由于速率限制导致响应丢失:用户未收到任何内容,但日志显示 Rate limited 而不是 Discarded
  • 由于代理静默导致响应丢失:代理从未生成响应,日志显示响应数组中没有 TEXT
  • 由于 Telegram 投递失败导致响应丢失:日志显示 Delivered 但用户未收到;这是 Telegram/API 问题

关键区分点:此错误在日志中显示 Discarding run,且记录中同时存在 HEARTBEAT_OKTEXT

部分修复陷阱

如果应用方案 2(出站提取),请确保:

  • 控制响应(例如 HANDOVERTRANSFER)也被适当过滤
  • 分析/追踪调用仍然接收完整的响应数组
  • Webhook 负载反映实际投递的内容,而不是原始运行

🔗 相关错误

  • HEARTBEAT_TIMEOUT — 心跳运行超过最大持续时间;如果连续超时超过阈值,可能触发会话清理
    [heartbeat] Run exceeded 30s timeout, forcing HEARTBEAT_TIMEOUT
    
  • HEARTBEAT_SKIP — 由于活跃对话导致心跳运行被完全跳过;配置 heartbeat.pause_on_active: true
    [heartbeat] Skipping HEARTBEAT_RUN - active conversation detected
    
  • STEER_INJECT_FAILED — Steer 模式无法将消息注入到活跃运行中;回退到排队
    [steer] Failed to inject message: no active heartbeat run in progress
    [steer] Falling back to queue for message "..."
    
  • DELIVERY_FILTERED — 响应被通道策略有意过滤(垃圾邮件检测、内容过滤)
    [outbound] DELIVERY_FILTERED: response contains blocked keyword
    
  • RATE_LIMIT_EXCEEDED — 达到 Telegram API 速率限制;响应排队等待重试
    [telegram] Rate limit exceeded (30/30), queuing response for retry in 60s
    
  • QUEUE_MODE_CONFLICT — 全局和通道特定配置之间的队列模式设置冲突
    [config] Warning: telegram.queue_mode conflicts with messages.queue.mode
    

历史背景

此问题是在运行分类系统为提高效率而统一时引入的回归。以前,每种响应类型都有独立的投递逻辑,但合并为运行级分类后,引入了对包含 HEARTBEAT_OK 的多响应运行的抑制行为。

另请参阅

依据与来源

本故障排除指南由 FixClaw 智能管线从社区讨论中自动合成。