Skip to content

Commit 675dcd8

Browse files
agocsclaude
andcommitted
fix(heartbeat): only clear pendingFinalDelivery this run owns
The send-success and duplicate-skip clears retired any pendingFinalDelivery unconditionally, so a heartbeat could erase an older, unsatisfied user-facing final it never produced (e.g. a message_tool_only run that does not refresh pending). Gate both clears on ownership via a shared helper: clear only when pendingFinalDeliveryCreatedAt is at/after the run start. Add regression tests covering the older-pending preserve case on both clear paths. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01SkPieH67337ieQPWVXQgMD
1 parent 19543a0 commit 675dcd8

2 files changed

Lines changed: 153 additions & 20 deletions

File tree

src/infra/heartbeat-runner.clears-pending-final-delivery.test.ts

Lines changed: 126 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,14 @@ describe("runHeartbeatOnce clears stuck pendingFinalDelivery state once delivery
3737
function heartbeatDeps(
3838
sendTelegram: ReturnType<typeof vi.fn>,
3939
replySpy: HeartbeatReplySpy,
40+
now?: number,
4041
): HeartbeatDeps {
4142
return {
4243
telegram: sendTelegram as unknown,
4344
getQueueSize: () => 0,
44-
nowMs: () => Date.now(),
45+
// A fixed clock lets a test seed pendingFinalDeliveryCreatedAt relative to
46+
// the run's startedAt, which is what the ownership guard compares against.
47+
nowMs: () => now ?? Date.now(),
4548
getReplyFromConfig: replySpy,
4649
} satisfies HeartbeatDeps;
4750
}
@@ -78,23 +81,25 @@ describe("runHeartbeatOnce clears stuck pendingFinalDelivery state once delivery
7881
it("nulls every pendingFinalDelivery* field after delivering substantive heartbeat content", async () => {
7982
await withTempHeartbeatSandbox(async ({ storePath, replySpy }) => {
8083
const cfg = createHeartbeatConfig(storePath);
84+
const NOW = Date.now();
8185

82-
// Seed a session that carries a stuck pendingFinalDelivery from a prior run.
83-
// pendingFinalDeliveryText is a heartbeat-ack token so the pendingFinalDelivery
84-
// gate at heartbeat-runner.ts:~1390 does not bail before the send.
86+
// Seed a stuck pendingFinalDelivery this run owns: createdAt at run start
87+
// marks it as produced by this heartbeat (the case the original fix
88+
// targets). pendingFinalDeliveryText is a heartbeat-ack token so the
89+
// pendingFinalDelivery defer gate does not bail before the send.
8590
const sessionKey = await seedMainSessionStore(storePath, cfg, {
8691
lastChannel: "telegram",
8792
lastProvider: "telegram",
8893
lastTo: TELEGRAM_GROUP,
89-
updatedAt: Date.now(),
94+
updatedAt: NOW,
9095
pendingFinalDelivery: true,
9196
pendingFinalDeliveryText: "HEARTBEAT_OK",
92-
pendingFinalDeliveryCreatedAt: 1,
97+
pendingFinalDeliveryCreatedAt: NOW,
9398
pendingFinalDeliveryAttemptCount: 3,
9499
pendingFinalDeliveryLastError: "prior-error",
95100
});
96101
await patchEntry(storePath, sessionKey, {
97-
pendingFinalDeliveryLastAttemptAt: 2,
102+
pendingFinalDeliveryLastAttemptAt: NOW,
98103
pendingFinalDeliveryContext: { foo: "bar" },
99104
pendingFinalDeliveryIntentId: "intent-send-success",
100105
});
@@ -105,7 +110,10 @@ describe("runHeartbeatOnce clears stuck pendingFinalDelivery state once delivery
105110
replySpy.mockResolvedValue({ text: replyText });
106111
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", toJid: "jid" });
107112

108-
const result = await runHeartbeatOnce({ cfg, deps: heartbeatDeps(sendTelegram, replySpy) });
113+
const result = await runHeartbeatOnce({
114+
cfg,
115+
deps: heartbeatDeps(sendTelegram, replySpy, NOW),
116+
});
109117

110118
expect(result.status).toBe("ran");
111119
expect(sendTelegram).toHaveBeenCalledTimes(1);
@@ -136,10 +144,11 @@ describe("runHeartbeatOnce clears stuck pendingFinalDelivery state once delivery
136144

137145
const body = "Heartbeat update: everything is green.";
138146
const deliveredText = `🤖 ${body}`;
147+
const NOW = Date.now();
139148
// updatedAt is well past the 30s defer window so the pendingFinalDelivery
140149
// gate does not bail before the duplicate branch (the pending text is
141150
// substantive, not a heartbeat ack).
142-
const staleAt = Date.now() - 60_000;
151+
const staleAt = NOW - 60_000;
143152

144153
const sessionKey = await seedMainSessionStore(storePath, cfg, {
145154
lastChannel: "telegram",
@@ -148,7 +157,9 @@ describe("runHeartbeatOnce clears stuck pendingFinalDelivery state once delivery
148157
updatedAt: staleAt,
149158
pendingFinalDelivery: true,
150159
pendingFinalDeliveryText: body, // prefix-less; diverges from deliveredText
151-
pendingFinalDeliveryCreatedAt: 1,
160+
// createdAt at run start: this run produced the pending (real runs stamp
161+
// a fresh createdAt during the agent turn the defer gate ran ahead of).
162+
pendingFinalDeliveryCreatedAt: NOW,
152163
pendingFinalDeliveryAttemptCount: 3,
153164
pendingFinalDeliveryLastError: "prior-error",
154165
});
@@ -157,7 +168,7 @@ describe("runHeartbeatOnce clears stuck pendingFinalDelivery state once delivery
157168
// which is what makes this run a duplicate and the pending clear safe.
158169
lastHeartbeatText: deliveredText,
159170
lastHeartbeatSentAt: staleAt,
160-
pendingFinalDeliveryLastAttemptAt: 2,
171+
pendingFinalDeliveryLastAttemptAt: NOW,
161172
pendingFinalDeliveryContext: { foo: "bar" },
162173
pendingFinalDeliveryIntentId: "intent-duplicate-skip",
163174
});
@@ -167,7 +178,10 @@ describe("runHeartbeatOnce clears stuck pendingFinalDelivery state once delivery
167178
replySpy.mockResolvedValue({ text: body });
168179
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", toJid: "jid" });
169180

170-
const result = await runHeartbeatOnce({ cfg, deps: heartbeatDeps(sendTelegram, replySpy) });
181+
const result = await runHeartbeatOnce({
182+
cfg,
183+
deps: heartbeatDeps(sendTelegram, replySpy, NOW),
184+
});
171185

172186
// status "ran" (not "skipped") proves the run reached the duplicate branch
173187
// rather than bailing at the pendingFinalDelivery gate.
@@ -181,4 +195,104 @@ describe("runHeartbeatOnce clears stuck pendingFinalDelivery state once delivery
181195
expectPendingFinalDeliveryCleared(entry);
182196
});
183197
});
198+
199+
it("preserves an older unsatisfied pendingFinalDelivery the heartbeat send did not create", async () => {
200+
await withTempHeartbeatSandbox(async ({ storePath, replySpy }) => {
201+
const cfg = createHeartbeatConfig(storePath);
202+
const NOW = Date.now();
203+
// An older user-facing final that failed delivery and still owns its own
204+
// get-reply redelivery recovery path. It predates this run (createdAt <
205+
// startedAt) and a message_tool_only/response-tool heartbeat would not
206+
// refresh it, so the send-success clear must NOT retire it.
207+
const olderCreatedAt = NOW - 60_000;
208+
const olderText = "Older final the user never received";
209+
const sessionKey = await seedMainSessionStore(storePath, cfg, {
210+
lastChannel: "telegram",
211+
lastProvider: "telegram",
212+
lastTo: TELEGRAM_GROUP,
213+
// Stale so the substantive-pending defer gate does not bail this run.
214+
updatedAt: NOW - 60_000,
215+
pendingFinalDelivery: true,
216+
pendingFinalDeliveryText: olderText,
217+
pendingFinalDeliveryCreatedAt: olderCreatedAt,
218+
pendingFinalDeliveryAttemptCount: 2,
219+
pendingFinalDeliveryLastError: "prior-delivery-failure",
220+
});
221+
await patchEntry(storePath, sessionKey, {
222+
pendingFinalDeliveryLastAttemptAt: NOW - 50_000,
223+
pendingFinalDeliveryContext: { channel: "telegram", to: "older-chat" },
224+
pendingFinalDeliveryIntentId: "intent-older-unsatisfied",
225+
});
226+
227+
// A fresh, different heartbeat payload that gets delivered this run.
228+
const replyText = "Fresh heartbeat content unrelated to the older final.";
229+
replySpy.mockResolvedValue({ text: replyText });
230+
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", toJid: "jid" });
231+
232+
const result = await runHeartbeatOnce({
233+
cfg,
234+
deps: heartbeatDeps(sendTelegram, replySpy, NOW),
235+
});
236+
237+
expect(result.status).toBe("ran");
238+
expect(sendTelegram).toHaveBeenCalledTimes(1);
239+
240+
const entry = await readEntry(storePath, sessionKey);
241+
// Send-success records the dedupe markers for the delivered payload...
242+
expect(entry?.lastHeartbeatText).toBe(replyText);
243+
// ...but the older, unowned pending-final survives for its own recovery.
244+
expect(entry?.pendingFinalDelivery).toBe(true);
245+
expect(entry?.pendingFinalDeliveryText).toBe(olderText);
246+
expect(entry?.pendingFinalDeliveryCreatedAt).toBe(olderCreatedAt);
247+
expect(entry?.pendingFinalDeliveryIntentId).toBe("intent-older-unsatisfied");
248+
});
249+
});
250+
251+
it("preserves an older unowned pendingFinalDelivery on a duplicate skip", async () => {
252+
await withTempHeartbeatSandbox(async ({ storePath, replySpy }) => {
253+
const cfg = createHeartbeatConfig(storePath);
254+
const NOW = Date.now();
255+
const body = "Recurring heartbeat status line.";
256+
const olderCreatedAt = NOW - 60_000;
257+
const olderText = "A different older final still awaiting delivery";
258+
const sessionKey = await seedMainSessionStore(storePath, cfg, {
259+
lastChannel: "telegram",
260+
lastProvider: "telegram",
261+
lastTo: TELEGRAM_GROUP,
262+
updatedAt: NOW - 60_000,
263+
pendingFinalDelivery: true,
264+
pendingFinalDeliveryText: olderText,
265+
pendingFinalDeliveryCreatedAt: olderCreatedAt,
266+
pendingFinalDeliveryAttemptCount: 2,
267+
pendingFinalDeliveryLastError: "prior-delivery-failure",
268+
});
269+
await patchEntry(storePath, sessionKey, {
270+
// Same payload already delivered within 24h -> this run is a duplicate skip.
271+
lastHeartbeatText: body,
272+
lastHeartbeatSentAt: NOW - 60_000,
273+
pendingFinalDeliveryLastAttemptAt: NOW - 50_000,
274+
pendingFinalDeliveryContext: { channel: "telegram", to: "older-chat" },
275+
pendingFinalDeliveryIntentId: "intent-older-dupe",
276+
});
277+
278+
replySpy.mockResolvedValue({ text: body });
279+
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", toJid: "jid" });
280+
281+
const result = await runHeartbeatOnce({
282+
cfg,
283+
deps: heartbeatDeps(sendTelegram, replySpy, NOW),
284+
});
285+
286+
expect(result.status).toBe("ran");
287+
// Duplicate payload: nothing is sent this run.
288+
expect(sendTelegram).not.toHaveBeenCalled();
289+
290+
const entry = await readEntry(storePath, sessionKey);
291+
// The duplicate-skip clear must not retire the older, unowned pending-final.
292+
expect(entry?.pendingFinalDelivery).toBe(true);
293+
expect(entry?.pendingFinalDeliveryText).toBe(olderText);
294+
expect(entry?.pendingFinalDeliveryCreatedAt).toBe(olderCreatedAt);
295+
expect(entry?.pendingFinalDeliveryIntentId).toBe("intent-older-dupe");
296+
});
297+
});
184298
});

src/infra/heartbeat-runner.ts

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1322,6 +1322,17 @@ const CLEARED_PENDING_FINAL_DELIVERY_FIELDS = {
13221322
pendingFinalDeliveryIntentId: undefined,
13231323
} as const;
13241324

1325+
// Clear pending-final only when this run produced it: the agent run stamps
1326+
// createdAt during the run, so createdAt >= run start means we own it. An older
1327+
// final (e.g. one a message_tool_only run never refreshed) must keep its recovery path.
1328+
function heartbeatRunOwnsPendingFinalDelivery(
1329+
entry: SessionEntry | undefined,
1330+
runStartedAt: number,
1331+
): boolean {
1332+
const createdAt = entry?.pendingFinalDeliveryCreatedAt;
1333+
return typeof createdAt === "number" && createdAt >= runStartedAt;
1334+
}
1335+
13251336
export async function runHeartbeatOnce(opts: {
13261337
cfg?: OpenClawConfig;
13271338
agentId?: string;
@@ -1705,13 +1716,13 @@ export async function runHeartbeatOnce(opts: {
17051716
};
17061717

17071718
// The duplicate-suppression branch returns before any send, so it never hits
1708-
// the send-success clear. A duplicate means this exact payload was already
1709-
// delivered within the dedupe window, and the agent run refreshed
1710-
// pendingFinalDelivery to this run's output, so the recovery state is
1711-
// satisfied. Clear it the same way the send-success path does. We must not
1719+
// the send-success clear. A duplicate means this run's own output was already
1720+
// delivered within the dedupe window, so this run's pending-final is satisfied
1721+
// and gets cleared the same way the send-success path does. We must not
17121722
// text-match the pending against the delivered text: agent-runner stores it
17131723
// pre-normalization (no responsePrefix), so a byte compare would leave
1714-
// prefixed agents permanently stuck.
1724+
// prefixed agents permanently stuck. Ownership is gated on createdAt instead,
1725+
// so an older final this run did not produce is preserved, not erased.
17151726
const clearSatisfiedPendingFinalDelivery = async () => {
17161727
await updateSessionStore(
17171728
storePath,
@@ -1720,6 +1731,9 @@ export async function runHeartbeatOnce(opts: {
17201731
if (current?.pendingFinalDelivery !== true && !current?.pendingFinalDeliveryText) {
17211732
return false;
17221733
}
1734+
if (!heartbeatRunOwnsPendingFinalDelivery(current, startedAt)) {
1735+
return false;
1736+
}
17231737
store[sessionKey] = { ...current, ...CLEARED_PENDING_FINAL_DELIVERY_FIELDS };
17241738
return true;
17251739
},
@@ -2161,13 +2175,18 @@ export async function runHeartbeatOnce(opts: {
21612175
if (!current) {
21622176
return;
21632177
}
2178+
// A heartbeat-driven agent run can leave its own pendingFinalDelivery
2179+
// set; a successful send completes it, so clear the recovery fields.
2180+
// Only clear the pending-final this run owns — an older final the run
2181+
// did not produce keeps its own recovery path.
2182+
const clearedRecoveryFields = heartbeatRunOwnsPendingFinalDelivery(current, startedAt)
2183+
? CLEARED_PENDING_FINAL_DELIVERY_FIELDS
2184+
: {};
21642185
store[sessionKey] = {
21652186
...current,
21662187
lastHeartbeatText: normalized.text,
21672188
lastHeartbeatSentAt: startedAt,
2168-
// Heartbeat-driven agent runs can leave pendingFinalDelivery set; a
2169-
// successful send completes it, so clear the recovery fields.
2170-
...CLEARED_PENDING_FINAL_DELIVERY_FIELDS,
2189+
...clearedRecoveryFields,
21712190
};
21722191
});
21732192
}

0 commit comments

Comments
 (0)