Skip to content

Commit 9dc6577

Browse files
committed
fix heartbeat suppressed commitment delivery
1 parent 575cae5 commit 9dc6577

2 files changed

Lines changed: 110 additions & 10 deletions

File tree

src/infra/heartbeat-runner.commitments.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { HEARTBEAT_TOKEN } from "../auto-reply/tokens.js";
66
import { loadCommitmentStore, saveCommitmentStore } from "../commitments/store.js";
77
import type { CommitmentRecord, CommitmentStoreFile } from "../commitments/types.js";
88
import type { OpenClawConfig } from "../config/config.js";
9+
import { getLastHeartbeatEvent, resetHeartbeatEventsForTest } from "./heartbeat-events.js";
910
import {
1011
runHeartbeatOnce,
1112
setHeartbeatsEnabled,
@@ -25,6 +26,7 @@ describe("runHeartbeatOnce commitments", () => {
2526
setHeartbeatsEnabled(true);
2627
vi.useRealTimers();
2728
vi.unstubAllEnvs();
29+
resetHeartbeatEventsForTest();
2830
});
2931

3032
function buildCommitment(params: {
@@ -395,6 +397,94 @@ describe("runHeartbeatOnce commitments", () => {
395397
});
396398
});
397399

400+
it("does not mark suppressed commitment sends as delivered or duplicate-dismiss their retry", async () => {
401+
await withTempHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => {
402+
vi.stubEnv("OPENCLAW_STATE_DIR", tmpDir);
403+
const sessionKey = "agent:main:telegram:user-155462274";
404+
const cfg: OpenClawConfig = {
405+
agents: {
406+
defaults: {
407+
workspace: tmpDir,
408+
heartbeat: {
409+
every: "5m",
410+
target: "last",
411+
},
412+
},
413+
},
414+
channels: { telegram: { allowFrom: ["*"] } },
415+
session: { store: storePath },
416+
commitments: { enabled: true },
417+
};
418+
await seedSessionStore(storePath, sessionKey, {
419+
lastChannel: "telegram",
420+
lastProvider: "telegram",
421+
lastTo: "155462274",
422+
});
423+
await saveCommitmentStore(undefined, {
424+
version: 1,
425+
commitments: [buildCommitment({ id: "cm_interview", sessionKey, to: "155462274" })],
426+
});
427+
428+
const sendTelegram = vi.fn().mockResolvedValue({
429+
messageId: "m2",
430+
chatId: "155462274",
431+
});
432+
replySpy
433+
.mockResolvedValueOnce({ text: "No channel reply." })
434+
.mockResolvedValueOnce({ text: "How did the interview go?" });
435+
436+
const runOnce = async () =>
437+
await runHeartbeatOnce({
438+
cfg,
439+
agentId: "main",
440+
sessionKey,
441+
deps: {
442+
getReplyFromConfig: replySpy,
443+
telegram: sendTelegram,
444+
getQueueSize: () => 0,
445+
nowMs: () => nowMs,
446+
},
447+
});
448+
449+
const first = await runOnce();
450+
451+
expect(first.status).toBe("ran");
452+
expect(sendTelegram).not.toHaveBeenCalled();
453+
let store = await loadCommitmentStore();
454+
expectCommitmentFields(store.commitments[0], {
455+
id: "cm_interview",
456+
status: "pending",
457+
attempts: 1,
458+
lastAttemptAtMs: nowMs,
459+
});
460+
expect(store.commitments[0]?.sentAtMs).toBeUndefined();
461+
const sessionStoreAfterSuppressed = JSON.parse(
462+
await fs.readFile(storePath, "utf-8"),
463+
) as Record<string, { lastHeartbeatText?: string; lastHeartbeatSentAt?: number }>;
464+
expect(sessionStoreAfterSuppressed[sessionKey]?.lastHeartbeatText).toBeUndefined();
465+
expect(sessionStoreAfterSuppressed[sessionKey]?.lastHeartbeatSentAt).toBeUndefined();
466+
expect(getLastHeartbeatEvent()).toMatchObject({
467+
status: "skipped",
468+
reason: "no_visible_payload",
469+
});
470+
471+
const second = await runOnce();
472+
473+
expect(second.status).toBe("ran");
474+
expect(sendTelegram).toHaveBeenCalledTimes(1);
475+
store = await loadCommitmentStore();
476+
expectCommitmentFields(store.commitments[0], {
477+
id: "cm_interview",
478+
status: "sent",
479+
attempts: 2,
480+
sentAtMs: nowMs,
481+
});
482+
expect(getLastHeartbeatEvent()).toMatchObject({
483+
status: "sent",
484+
});
485+
});
486+
});
487+
398488
it("tolerates Date-invalid commitment due timestamps in heartbeat prompts", async () => {
399489
const { result, sendTelegram, store } = await setupCommitmentCase({
400490
dueWindow: {

src/infra/heartbeat-runner.ts

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2057,15 +2057,20 @@ export async function runHeartbeatOnce(opts: {
20572057
if (send.status === "failed" || send.status === "partial_failed") {
20582058
throw send.error;
20592059
}
2060-
await markCommitmentsStatus({
2061-
cfg,
2062-
ids: dueCommitmentIds,
2063-
status: shouldSkipMain ? "dismissed" : "sent",
2064-
nowMs: startedAt,
2065-
});
2060+
const visibleSendSucceeded = send.status === "sent";
2061+
// Suppressed durable sends committed no visible channel message. Keep due
2062+
// commitments and heartbeat dedupe state active so a later heartbeat can retry.
2063+
if (shouldSkipMain || visibleSendSucceeded) {
2064+
await markCommitmentsStatus({
2065+
cfg,
2066+
ids: dueCommitmentIds,
2067+
status: shouldSkipMain ? "dismissed" : "sent",
2068+
nowMs: startedAt,
2069+
});
2070+
}
20662071

20672072
// Record last delivered heartbeat payload for dedupe.
2068-
if (!shouldSkipMain && normalized.text.trim()) {
2073+
if (visibleSendSucceeded && !shouldSkipMain && normalized.text.trim()) {
20692074
await updateSessionStore(storePath, (store) => {
20702075
const current = store[sessionKey];
20712076
if (!current) {
@@ -2079,17 +2084,22 @@ export async function runHeartbeatOnce(opts: {
20792084
});
20802085
}
20812086

2082-
const sentStatus = deliveredAgentRunFailure ? "failed" : "sent";
2087+
const eventStatus = deliveredAgentRunFailure
2088+
? "failed"
2089+
: visibleSendSucceeded
2090+
? "sent"
2091+
: "skipped";
20832092
emitHeartbeatEvent({
2084-
status: sentStatus,
2093+
status: eventStatus,
20852094
to: delivery.to,
20862095
...(deliveredAgentRunFailure ? { reason: "agent-runner-failure" } : {}),
2096+
...(!deliveredAgentRunFailure && !visibleSendSucceeded ? { reason: send.reason } : {}),
20872097
preview: previewText?.slice(0, 200),
20882098
durationMs: Date.now() - startedAt,
20892099
hasMedia: mediaUrls.length > 0,
20902100
channel: delivery.channel,
20912101
accountId: delivery.accountId,
2092-
indicatorType: visibility.useIndicator ? resolveIndicatorType(sentStatus) : undefined,
2102+
indicatorType: visibility.useIndicator ? resolveIndicatorType(eventStatus) : undefined,
20932103
});
20942104
await updateTaskTimestamps();
20952105
consumeInspectedSystemEvents();

0 commit comments

Comments
 (0)