From 06d8cd1b230b74335a219af71257ed65e640cdf3 Mon Sep 17 00:00:00 2001 From: HCL Date: Fri, 8 May 2026 13:40:18 +0800 Subject: [PATCH] fix(heartbeat): ignore ack-only pending delivery replay --- src/auto-reply/reply/agent-runner.ts | 19 ++++++- src/auto-reply/reply/get-reply.ts | 78 ++++++++++++++++++++-------- src/infra/heartbeat-runner.ts | 8 +++ 3 files changed, 82 insertions(+), 23 deletions(-) diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 33edc8b26e75..15c37119170a 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -42,6 +42,7 @@ import { buildFallbackNotice, resolveFallbackTransition, } from "../fallback-state.js"; +import { stripHeartbeatToken } from "../heartbeat.js"; import { markReplyPayloadForSourceSuppressionDelivery, setReplyPayloadMetadata, @@ -832,6 +833,16 @@ function buildPendingFinalDeliveryText(payloads: ReplyPayload[]): string { .join("\n\n"); } +function shouldSkipHeartbeatPendingFinalDelivery(params: { + isHeartbeat: boolean; + pendingText: string; +}): boolean { + if (!params.isHeartbeat) { + return false; + } + return stripHeartbeatToken(params.pendingText, { mode: "heartbeat" }).shouldSkip; +} + function enqueueCommitmentExtractionForTurn(params: { cfg: OpenClawConfig; commandBody: string; @@ -1911,7 +1922,13 @@ export async function runReplyAgent(params: { const pendingText = sourceReplyPolicy.suppressDelivery ? "" : buildPendingFinalDeliveryText(finalPayloads); - if (pendingText) { + if ( + pendingText && + !shouldSkipHeartbeatPendingFinalDelivery({ + isHeartbeat, + pendingText, + }) + ) { await updateSessionStoreEntry({ storePath, sessionKey, diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index 6e2a98fd7c2e..7d692056704b 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -19,6 +19,7 @@ import { createLazyImportLoader } from "../../shared/lazy-promise.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { normalizeStringEntries } from "../../shared/string-normalization.js"; import type { GetReplyOptions } from "../get-reply-options.types.js"; +import { stripHeartbeatToken } from "../heartbeat.js"; import type { ReplyPayload } from "../reply-payload.js"; import type { MsgContext } from "../templating.js"; import { normalizeVerboseLevel } from "../thinking.js"; @@ -51,6 +52,10 @@ import { createTypingController } from "./typing.js"; type ResetCommandAction = "new" | "reset"; +function isHeartbeatPendingFinalDeliveryEffectivelyEmpty(text: string): boolean { + return stripHeartbeatToken(text, { mode: "heartbeat" }).shouldSkip; +} + const sessionResetModelRuntimeLoader = createLazyImportLoader( () => import("./session-reset-model.runtime.js"), ); @@ -371,29 +376,58 @@ export async function getReplyFromConfig( // If it's a user message, we deliver the lost reply first, then continue. // For now, let's just return the lost reply if it's a heartbeat. if (opts?.isHeartbeat) { - const updatedAt = Date.now(); - const attemptCount = (sessionEntry.pendingFinalDeliveryAttemptCount ?? 0) + 1; - sessionEntry.pendingFinalDeliveryLastAttemptAt = updatedAt; - sessionEntry.pendingFinalDeliveryAttemptCount = attemptCount; - sessionEntry.pendingFinalDeliveryLastError = null; - sessionEntry.updatedAt = updatedAt; - if (sessionKey && sessionStore) { - sessionStore[sessionKey] = sessionEntry; + if (isHeartbeatPendingFinalDeliveryEffectivelyEmpty(text)) { + sessionEntry.pendingFinalDelivery = undefined; + sessionEntry.pendingFinalDeliveryText = undefined; + sessionEntry.pendingFinalDeliveryCreatedAt = undefined; + sessionEntry.pendingFinalDeliveryLastAttemptAt = undefined; + sessionEntry.pendingFinalDeliveryAttemptCount = undefined; + sessionEntry.pendingFinalDeliveryLastError = undefined; + sessionEntry.pendingFinalDeliveryContext = undefined; + if (sessionKey && sessionStore) { + sessionStore[sessionKey] = sessionEntry; + } + if (sessionKey && storePath) { + const { updateSessionStoreEntry } = await import("../../config/sessions.js"); + await updateSessionStoreEntry({ + storePath, + sessionKey, + update: async () => ({ + pendingFinalDelivery: undefined, + pendingFinalDeliveryText: undefined, + pendingFinalDeliveryCreatedAt: undefined, + pendingFinalDeliveryLastAttemptAt: undefined, + pendingFinalDeliveryAttemptCount: undefined, + pendingFinalDeliveryLastError: undefined, + pendingFinalDeliveryContext: undefined, + }), + }); + } + } else { + const updatedAt = Date.now(); + const attemptCount = (sessionEntry.pendingFinalDeliveryAttemptCount ?? 0) + 1; + sessionEntry.pendingFinalDeliveryLastAttemptAt = updatedAt; + sessionEntry.pendingFinalDeliveryAttemptCount = attemptCount; + sessionEntry.pendingFinalDeliveryLastError = null; + sessionEntry.updatedAt = updatedAt; + if (sessionKey && sessionStore) { + sessionStore[sessionKey] = sessionEntry; + } + if (sessionKey && storePath) { + const { updateSessionStoreEntry } = await import("../../config/sessions.js"); + await updateSessionStoreEntry({ + storePath, + sessionKey, + update: async () => ({ + pendingFinalDeliveryLastAttemptAt: updatedAt, + pendingFinalDeliveryAttemptCount: attemptCount, + pendingFinalDeliveryLastError: null, + updatedAt, + }), + }); + } + return { text }; } - if (sessionKey && storePath) { - const { updateSessionStoreEntry } = await import("../../config/sessions.js"); - await updateSessionStoreEntry({ - storePath, - sessionKey, - update: async () => ({ - pendingFinalDeliveryLastAttemptAt: updatedAt, - pendingFinalDeliveryAttemptCount: attemptCount, - pendingFinalDeliveryLastError: null, - updatedAt, - }), - }); - } - return { text }; } } diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 014e154360e9..7898b77b6b6b 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -1230,8 +1230,16 @@ export async function runHeartbeatOnce(opts: { opts.sessionKey, ); const HEARTBEAT_DEFER_WINDOW_MS = 30_000; + const pendingFinalDeliveryText = recentSessionEntry?.pendingFinalDeliveryText; + const pendingFinalDeliveryIsHeartbeatAck = + typeof pendingFinalDeliveryText === "string" && + stripHeartbeatToken(pendingFinalDeliveryText, { + mode: "heartbeat", + maxAckChars: resolveHeartbeatAckMaxChars(cfg, heartbeat), + }).shouldSkip; if ( recentSessionEntry?.pendingFinalDelivery === true && + !pendingFinalDeliveryIsHeartbeatAck && recentSessionEntry?.updatedAt && startedAt - recentSessionEntry.updatedAt < HEARTBEAT_DEFER_WINDOW_MS ) {