April 21, 2026

[心拍実行でHEARTBEAT_OK生成時にユーザーメッセージが失われる] - Steered User Message Swallowed When Heartbeat Run Produces HEARTBEAT_OK

操舵キューモードでは、エージェントがHEARTBEAT_OKを生成すると、心拍実行に挿入されたユーザーメッセージが失われます。これは、実行レベルの応答抑制ロジックが実行全体を心拍のno-op(何もしない操作)として扱うためです。

🔍 症状

主な症状

steerキューモードでアクティブなハートビートラン実行中にユーザーがTelegramでメッセージを送信すると、エージェントは正しい返信を生成しているにもかかわらず、ユーザーはレスポンスを受け取れません。

正確な再現手順

  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"  # ← 回避策:別々のターンとしてキューに入れる

collectモードでは、ユーザーメッセージは独立したフォローアップターンとしてキューに入れられ、完全な配信和自己的なランを実行します。

🧠 原因

アーキテクチャ分析:ランレベルのレスポンス抑制

この問題は、ハートビート処理ロジックにおける根本的な設計上の前提に起因しています:HEARTBEAT_OKを含むランは「ハートビートのno-op」と分類され、ラン全体のアウトバウンド配信が抑制されます。

障害シーケンス

  1. ステアモード注入steerモードでは、アクティブなハートビートラン 중에到着した受信ユーザーメッセージは、新しいランを作成する代わりにそのランに注入されます。これは意図的な動作です—steerは分離よりもレイテンシを優先します。
  2. マルチレスポンスラン生成:エージェントはハートビート+ユーザーメッセージのコンテキストを処理し、複数のレスポンスを連続して生成します:
    Response 1: HEARTBEAT_OK    // エージェントはアクションなしでハートビートを確認
    Response 2: TEXT           // エージェントはユーザーの実際の質問に応答
    
  3. ラン分類ロジック:ラン分類ロジックはレスポンスをスキャンしてHEARTBEAT_*マーカーを探します:
    // 簡略化された分類疑似コード
    function classifyRun(responses):
      for response in responses:
        if response.type.startsWith("HEARTBEAT_"):
          return RUN_TYPE.HEARTBEAT_NOOP  // ← 抑制をトリガー
      return RUN_TYPE.NORMAL
    
  4. 早期抑制HEARTBEAT_OKが存在するため、ラン全体がHEARTBEAT_NOOPとしてマークされ、アウトバウンド配信レイヤーの破棄ロジックがトリガーされます:
    // アウトバウンド配信疑似コード
    function deliverResponses(run):
      if run.classification === RUN_TYPE.HEARTBEAT_NOOP:
        return  // ← ラン全体の配信がスキップされる(ユーザーレスポンスを含む)
      deliverToChannel(run.responses)
    
  5. ユーザーレスポンスの損失TEXTレスポンス(有効なユーザー向けコンテンツ)は、HEARTBEAT_OKと同じランを共有するため破棄されます。

コード参照場所

コンポーネントファイル問題
ステア注入src/queue/steer.tsユーザーメッセージをアクティブなハートビートランに注入する
ラン分類src/runs/classifier.tsHEARTBEAT_*の存在に基づいてラン全体を分類する
アウトバウンド配信src/outbound/delivery.tsHEARTBEAT_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

デプロイ手順

  1. 設定をバックアップ
    cp config.yaml config.yaml.backup
  2. 修正を適用
    # オプション1または2を使用する場合:
    vim src/runs/classifier.ts   # または src/outbound/delivery.ts
    npm run build
  3. サービスを再起動
    docker-compose down && docker-compose up -d
    # またはsystemdの場合:
    sudo systemctl restart openclaw
  4. 設定を確認
    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_OKTEXTの両方のレスポンスが表示される

テストケース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

タイミング感受性

このバグはタイミングに大きく依存します。競合ウィンドウは以下の間に存在します:

  1. ハートビートラン開始(HEARTBEAT_RUNがログに記録される)
  2. ハートビート確認生成(HEARTBEAT_OKがログに記録される)
  3. ユーザーメッセージが到着して注入される
  4. ユーザー応答が生成される
  5. ラン分類が発生する

推奨:再現テストには3sまたは5sのハートビート間隔を使用してください。1s未満の間隔では競合条件が厳しすぎて可靠的にトリガーできない可能性があります。

設定の間違い

  • steerforceの混同
    # 間違い - 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_OKTEXTの両方が存在し、ログにDiscarding runと表示されます。

部分的な修正の落とし穴

オプション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] 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 Intelligence パイプラインによってコミュニティの議論から自動的に合成されました。