[当心跳运行产生 HEARTBEAT_OK 时引导用户消息被吞没] - Steered User Message Swallowed When Heartbeat Run Produces HEARTBEAT_OK
在引导队列模式下,注入到心跳运行中的用户消息会在代理产生 HEARTBEAT_OK 时丢失,原因是运行级别的响应抑制逻辑将整个运行视为心跳空操作。
🔍 症状
主要表现
当用户在 steer 队列模式下活跃的心跳运行期间发送消息时,用户不会收到任何回复,尽管代理生成了正确的回复。
精确复现步骤
- 将队列模式配置为
steer:# config.yaml messages: queue: mode: "steer" heartbeat: interval: 3s - 等待心跳运行触发(在日志中显示为
HEARTBEAT_RUN) - 在心搏窗口期间发送用户消息
- 在应用程序日志中观察以下内容:
[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) - 用户未收到任何内容——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 的运行被归类为"心跳无操作",整个运行的出站投递都会被抑制。
故障序列
- Steer 模式注入:在
steer模式下,在活跃心跳运行期间到达的传入用户消息会被注入到该运行中,而不是创建新运行。这是刻意设计的——steer优先考虑延迟而非隔离。 - 多响应运行生成:代理处理组合的心跳 + 用户消息上下文,并按顺序生成多个响应:
Response 1: HEARTBEAT_OK // Agent acknowledges heartbeat with no action Response 2: TEXT // Agent responds to user's actual question - 运行分类逻辑:运行分类逻辑扫描响应中的任何
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 - 过早抑制:由于存在
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) - 用户响应丢失:
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
部署步骤
- 备份配置
cp config.yaml config.yaml.backup - 应用修复
# If using Option 1 or 2: vim src/runs/classifier.ts # or src/outbound/delivery.ts npm run build - 重启服务
docker-compose down && docker-compose up -d # Or for systemd: sudo systemctl restart openclaw - 验证配置
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_OK和TEXT响应
测试用例 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 在编辑器中 |
时序敏感性
此错误高度依赖时序。竞态窗口存在于以下之间:
- 心跳运行开始(记录
HEARTBEAT_RUN) - 心跳确认生成(记录
HEARTBEAT_OK) - 用户消息到达并被注入
- 用户响应生成
- 运行分类发生
建议:使用 3s 或 5s 的心跳间隔进行可靠的复现测试。低于 1s 的间隔可能使竞态条件过于紧凑,无法可靠触发。
配置错误
- 将
steer与force混淆:# 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 messagesBetter for testing
heartbeat.interval: 3s
- 缺少通道特定的队列设置:
# Some channels may override global queue mode telegram: queue_mode: "collect" # ← May override steer settingUse channel-agnostic config
messages.queue.mode: “steer”
误诊:混淆症状
这些问题可能看起来相似但有不同的根本原因:
- 由于速率限制导致响应丢失:用户未收到任何内容,但日志显示
Rate limited而不是Discarded - 由于代理静默导致响应丢失:代理从未生成响应,日志显示响应数组中没有
TEXT - 由于 Telegram 投递失败导致响应丢失:日志显示
Delivered但用户未收到;这是 Telegram/API 问题
关键区分点:此错误在日志中显示 Discarding run,且记录中同时存在 HEARTBEAT_OK 和 TEXT。
部分修复陷阱
如果应用方案 2(出站提取),请确保:
- 控制响应(例如
HANDOVER、TRANSFER)也被适当过滤 - 分析/追踪调用仍然接收完整的响应数组
- Webhook 负载反映实际投递的内容,而不是原始运行
🔗 相关错误
HEARTBEAT_TIMEOUT— 心跳运行超过最大持续时间;如果连续超时超过阈值,可能触发会话清理[heartbeat] Run exceeded 30s timeout, forcing HEARTBEAT_TIMEOUTHEARTBEAT_SKIP— 由于活跃对话导致心跳运行被完全跳过;配置heartbeat.pause_on_active: true[heartbeat] Skipping HEARTBEAT_RUN - active conversation detectedSTEER_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 keywordRATE_LIMIT_EXCEEDED— 达到 Telegram API 速率限制;响应排队等待重试[telegram] Rate limit exceeded (30/30), queuing response for retry in 60sQUEUE_MODE_CONFLICT— 全局和通道特定配置之间的队列模式设置冲突[config] Warning: telegram.queue_mode conflicts with messages.queue.mode
历史背景
此问题是在运行分类系统为提高效率而统一时引入的回归。以前,每种响应类型都有独立的投递逻辑,但合并为运行级分类后,引入了对包含 HEARTBEAT_OK 的多响应运行的抑制行为。
另请参阅
- OpenClaw 队列模式文档 —
steer与collect与force的详细解释 - 心跳系统架构 — 心跳调度和响应处理的技术深入探讨
- Telegram 通道适配器 — 通道特定投递注意事项