[心拍実行でHEARTBEAT_OK生成時にユーザーメッセージが失われる] - Steered User Message Swallowed When Heartbeat Run Produces HEARTBEAT_OK
操舵キューモードでは、エージェントがHEARTBEAT_OKを生成すると、心拍実行に挿入されたユーザーメッセージが失われます。これは、実行レベルの応答抑制ロジックが実行全体を心拍のno-op(何もしない操作)として扱うためです。
🔍 症状
主な症状
steerキューモードでアクティブなハートビートラン実行中にユーザーがTelegramでメッセージを送信すると、エージェントは正しい返信を生成しているにもかかわらず、ユーザーはレスポンスを受け取れません。
正確な再現手順
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" # ← 回避策:別々のターンとしてキューに入れる
collectモードでは、ユーザーメッセージは独立したフォローアップターンとしてキューに入れられ、完全な配信和自己的なランを実行します。
🧠 原因
アーキテクチャ分析:ランレベルのレスポンス抑制
この問題は、ハートビート処理ロジックにおける根本的な設計上の前提に起因しています:HEARTBEAT_OKを含むランは「ハートビートのno-op」と分類され、ラン全体のアウトバウンド配信が抑制されます。
障害シーケンス
- ステアモード注入:
steerモードでは、アクティブなハートビートラン 중에到着した受信ユーザーメッセージは、新しいランを作成する代わりにそのランに注入されます。これは意図的な動作です—steerは分離よりもレイテンシを優先します。 - マルチレスポンスラン生成:エージェントはハートビート+ユーザーメッセージのコンテキストを処理し、複数のレスポンスを連続して生成します:
Response 1: HEARTBEAT_OK // エージェントはアクションなしでハートビートを確認 Response 2: TEXT // エージェントはユーザーの実際の質問に応答 - ラン分類ロジック:ラン分類ロジックはレスポンスをスキャンして
HEARTBEAT_*マーカーを探します:// 簡略化された分類疑似コード function classifyRun(responses): for response in responses: if response.type.startsWith("HEARTBEAT_"): return RUN_TYPE.HEARTBEAT_NOOP // ← 抑制をトリガー return RUN_TYPE.NORMAL - 早期抑制:
HEARTBEAT_OKが存在するため、ラン全体がHEARTBEAT_NOOPとしてマークされ、アウトバウンド配信レイヤーの破棄ロジックがトリガーされます:// アウトバウンド配信疑似コード function deliverResponses(run): if run.classification === RUN_TYPE.HEARTBEAT_NOOP: return // ← ラン全体の配信がスキップされる(ユーザーレスポンスを含む) deliverToChannel(run.responses) - ユーザーレスポンスの損失:
TEXTレスポンス(有効なユーザー向けコンテンツ)は、HEARTBEAT_OKと同じランを共有するため破棄されます。
コード参照場所
| コンポーネント | ファイル | 問題 |
|---|---|---|
| ステア注入 | 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;
}
// ハートビートno-opランからユーザー向けレスポンスを抽出
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" # ハートビートランへの注入を回避
heartbeat:
interval: 60s # ハートビート中にユーザーメッセージが届く確率を低下
# またはアクティブな会話中はハートビートを無効化:
pause_on_active: true
デプロイ手順
- 設定をバックアップ
cp config.yaml config.yaml.backup - 修正を適用
# オプション1または2を使用する場合: vim src/runs/classifier.ts # または src/outbound/delivery.ts npm run build - サービスを再起動
docker-compose down && docker-compose up -d # またはsystemdの場合: sudo systemctl restart openclaw - 設定を確認
openclaw config show | grep -A5 "queue:" openclaw status
🧪 検証
テストケース1:注入されたメッセージの配信
目的:ハートビートランに注入されたユーザーメッセージが配信されることを確認します。
# 1. 短いハートビートでステアモードを構成
openclaw config set messages.queue.mode steer
openclaw config set heartbeat.interval 3s
openclaw restart
# 2. 1つのターミナルでログを監視
openclaw logs --follow | grep -E "(HEARTBEAT|steer|deliver)"
# 3. ハートビートウィンドウ中にユーザーメッセージを送信
# ログ行を待つ: [heartbeat] HEARTBEAT_RUN triggered
# 次にすぐに送信: "Testing steer delivery"
# 4. 配信を確認
# 期待結果: ユーザーはTelegramでレスポンスを受け取る
# 期待ログ: [outbound] Delivered: TEXT response to user
成功基準:
- ユーザーはTelegramでレスポンスを受け取る
- ログに
Delivered: TEXTが表示される(Discardedではない) - セッション抄録に
HEARTBEAT_OKとTEXTの両方のレスポンスが表示される
テストケース2:純粋なハートビートはまだ抑制される
目的:真のHEARTBEAT_NOOPラン(ユーザーメッセージなし)が引き続き抑制されることを確認します。
# 1. ユーザー操作なしでハートビートが発火するのを待つ
# クリーンなハートビートランのログを監視
# 2. 期待されるログ動作
[heartbeat] HEARTBEAT_RUN triggered
[heartbeat] HEARTBEAT_OK generated (no user message)
[outbound] Discarding run (HEARTBEAT_NOOP)
# 3. 確認: ユーザーはこのランから通知を受け取らないはず
成功基準:
- ハートビート専用のランはユーザー通知を生成しない
- 純粋なハートビートランに対してログに
Discardingが表示される
テストケース3:セッション抄録の検証
# 最近の会話からセッションIDを取得
openclaw session list --limit 5
詳細な抄録を表示
openclaw session show –id <SESSION_ID> –verbose
両方のレスポンスタイプが含まれていることを確認
期待される出力は以下の示すはずです:
[timestamp] → HEARTBEAT_OK
[timestamp] → TEXT: “response content”
[timestamp] ← Delivered: HEARTBEAT_OK, TEXT ← 両方配信済み
回帰テスト
# 既存のテストスイートを実行
npm test -- --grep "heartbeat"
ステアモード固有のテストを実行
npm test – –grep “steer”
期待結果: 以下を含むすべてのテストがパス
- ステアモードのメッセージ注入
- ハートビートレスポンスの分類
- アウトバウンド配信のフィルタリング
終了コードの確認
# 修正デプロイ後
openclaw health check; echo "Exit code: $?"
# 期待結果: 0 (正常)
サービスログを確認
docker-compose logs openclaw 2>&1 | tail -20
期待結果: 配信に関連するERRORレベルのエントリがない
⚠️ よくある落とし穴
環境固有のトラップ
| 環境 | 落とし穴 | 軽減策 |
|---|---|---|
| Docker | コンテナのクロックドリフトによりハートビートのタイミングが不安定になり、ハートビットランとユーザーメッセージ注入の競合条件が 악化する | NTP同期を確保: docker run --cap-add=SYS_TIME openclaw:latest |
| VPS/クラウド | Telegram APIとサーバー間のネットワークレイテンシにより、問題のタイミング感受性が隠される可能性がある | 変数を減らすためにロングポーリングではなくローカルbotウェブフックでテストする |
| macOS(開発) | システムスリープ/ハイバネート状態によりハートビートタイマーが可靠的に発火しない可能性がある | テスト中はシステムスリープを無効化: caffeinate -s |
| Windows(開発) | 設定ファイルの改行コードの違い(\r\n vs \n)が解析の問題を引き起こす可能性がある | Unix改行を使用: エディタでset FILE_OPTS=-o nowrap |
タイミング感受性
このバグはタイミングに大きく依存します。競合ウィンドウは以下の間に存在します:
- ハートビートラン開始(
HEARTBEAT_RUNがログに記録される) - ハートビート確認生成(
HEARTBEAT_OKがログに記録される) - ユーザーメッセージが到着して注入される
- ユーザー応答が生成される
- ラン分類が発生する
推奨:再現テストには3sまたは5sのハートビート間隔を使用してください。1s未満の間隔では競合条件が厳しすぎて可靠的にトリガーできない可能性があります。
設定の間違い
steerとforceの混同:# 間違い - forceモードはキューを完全に無視する messages.queue.mode: "force"正しい - steerモードはインテリジェントな注入を使用する
messages.queue.mode: “steer”
- ハートビート間隔が長すぎて問題が見えなくなる:
# この間隔ではユーザーメッセージと重なることがない可能性がある heartbeat.interval: 300s # 5分 - ユーザーメッセージを捕捉する可能性が低いテストにはこちらが適切
heartbeat.interval: 3s
- チャネル固有のキュー設定が欠落:
# 一部のチャネルはグローバルキュー設定をオーバーライドする場合がある telegram: queue_mode: "collect" # ← steer設定をオーバーライドする可能性チャネル非依存の設定を使用
messages.queue.mode: “steer”
誤診:症状の混同
以下の問題は似ているように見えますが、異なる根本的な原因があります:
- レート制限によるレスポンス欠落:ユーザーは何も受け取れないが、ログには
DiscardedではなくRate limitedと表示される - エージェント沈黙によるレスポンス欠落:エージェントはレスポンスを生成せず、ログのレスポンス配列に
TEXTが表示されない - Telegram配信失敗によるレスポンス欠落:ログには
Deliveredと表示されるがユーザーが受け取らない;これはTelegram/APIの問題
主要な判別点:このバグでは、抄録にHEARTBEAT_OKとTEXTの両方が存在し、ログにDiscarding runと表示されます。
部分的な修正の落とし穴
オプション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] 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キューモードドキュメント —
steervscollectvsforceの詳細な説明 - ハートビートシステムアーキテクチャ — ハートビートスケジューリングとレスポンス処理の技術的な深掘り
- Telegramチャネルアダプタ — チャネル固有の配信に関する考慮事項