Skip to content

Commit c114a56

Browse files
author
hclsys
committed
fix(heartbeat): thread ackMaxChars into pending-delivery classifier
shouldSkipHeartbeatPendingFinalDelivery was using the default 300-char threshold regardless of per-agent heartbeat config. Replace with inline logic that resolves ackMaxChars from cfg.agents[agentId].heartbeat -> cfg.agents.defaults.heartbeat -> DEFAULT_HEARTBEAT_ACK_MAX_CHARS. Also fix: store the stripped text (remainder after HEARTBEAT_OK) rather than the raw payload text. Previously pendingFinalDeliveryText would have contained the HEARTBEAT_OK prefix, causing heartbeat-runner to re-deliver it verbatim on retry. Resolves clawsweeper P2 review finding on #79270.
1 parent 38a3ad3 commit c114a56

2 files changed

Lines changed: 56 additions & 20 deletions

File tree

src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,37 @@ describe("runReplyAgent pending final delivery capture", () => {
401401
expect(stored.pendingFinalDelivery).toBe(true);
402402
expect(stored.pendingFinalDeliveryText).toBe("Sent daily summary to channel.");
403403
});
404+
405+
it("persists heartbeat reply remainder as pending delivery when remainder exceeds ackMaxChars", async () => {
406+
// When a heartbeat response contains HEARTBEAT_OK followed by substantive content,
407+
// the remainder after stripping the token must be persisted for durable delivery.
408+
// The default ackMaxChars is 300 — any remainder longer than that is treated as real content.
409+
const sessionEntry: SessionEntry = {
410+
sessionId: "session",
411+
updatedAt: Date.now(),
412+
};
413+
const sessionStore = { main: sessionEntry };
414+
const storePath = await createSessionStoreFile(sessionEntry);
415+
const longRemainder = "Sent daily digest to channel. ".repeat(12).trimEnd(); // ~360 chars, > 300
416+
state.runEmbeddedPiAgentMock.mockResolvedValueOnce({
417+
payloads: [{ text: `HEARTBEAT_OK ${longRemainder}` }],
418+
meta: {},
419+
});
420+
421+
const { run } = createMinimalRun({
422+
opts: { isHeartbeat: true },
423+
sessionEntry,
424+
sessionStore,
425+
sessionKey: "main",
426+
storePath,
427+
});
428+
429+
await run();
430+
431+
const stored = await readStoredMainSession(storePath);
432+
expect(stored.pendingFinalDelivery).toBe(true);
433+
expect(stored.pendingFinalDeliveryText).toBe(longRemainder);
434+
});
404435
});
405436

406437
describe("runReplyAgent typing (heartbeat)", () => {

src/auto-reply/reply/agent-runner.ts

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import fs from "node:fs/promises";
2-
import { hasConfiguredModelFallbacks, resolveSessionAgentId } from "../../agents/agent-scope.js";
2+
import {
3+
hasConfiguredModelFallbacks,
4+
resolveAgentConfig,
5+
resolveSessionAgentId,
6+
} from "../../agents/agent-scope.js";
37
import { resolveContextTokensForModel } from "../../agents/context.js";
48
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
59
import { resolveModelAuthMode } from "../../agents/model-auth.js";
@@ -39,7 +43,7 @@ import {
3943
buildFallbackNotice,
4044
resolveFallbackTransition,
4145
} from "../fallback-state.js";
42-
import { stripHeartbeatToken } from "../heartbeat.js";
46+
import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, stripHeartbeatToken } from "../heartbeat.js";
4347
import {
4448
markReplyPayloadForSourceSuppressionDelivery,
4549
setReplyPayloadMetadata,
@@ -829,16 +833,6 @@ function buildPendingFinalDeliveryText(payloads: ReplyPayload[]): string {
829833
.join("\n\n");
830834
}
831835

832-
function shouldSkipHeartbeatPendingFinalDelivery(params: {
833-
isHeartbeat: boolean;
834-
pendingText: string;
835-
}): boolean {
836-
if (!params.isHeartbeat) {
837-
return false;
838-
}
839-
return stripHeartbeatToken(params.pendingText, { mode: "heartbeat" }).shouldSkip;
840-
}
841-
842836
function enqueueCommitmentExtractionForTurn(params: {
843837
cfg: OpenClawConfig;
844838
commandBody: string;
@@ -1910,19 +1904,30 @@ export async function runReplyAgent(params: {
19101904
const pendingText = sourceReplyPolicy.suppressDelivery
19111905
? ""
19121906
: buildPendingFinalDeliveryText(finalPayloads);
1913-
if (
1914-
pendingText &&
1915-
!shouldSkipHeartbeatPendingFinalDelivery({
1916-
isHeartbeat,
1917-
pendingText,
1918-
})
1919-
) {
1907+
const agentId = followupRun.run.agentId;
1908+
const heartbeatAgentCfg = agentId ? resolveAgentConfig(cfg, agentId)?.heartbeat : undefined;
1909+
const heartbeatAckMaxChars = Math.max(
1910+
0,
1911+
heartbeatAgentCfg?.ackMaxChars ??
1912+
cfg.agents?.defaults?.heartbeat?.ackMaxChars ??
1913+
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
1914+
);
1915+
const resolvedPendingText = isHeartbeat
1916+
? (() => {
1917+
const stripped = stripHeartbeatToken(pendingText, {
1918+
mode: "heartbeat",
1919+
maxAckChars: heartbeatAckMaxChars,
1920+
});
1921+
return stripped.shouldSkip ? "" : stripped.text || pendingText;
1922+
})()
1923+
: pendingText;
1924+
if (resolvedPendingText) {
19201925
await updateSessionStoreEntry({
19211926
storePath,
19221927
sessionKey,
19231928
update: async () => ({
19241929
pendingFinalDelivery: true,
1925-
pendingFinalDeliveryText: pendingText,
1930+
pendingFinalDeliveryText: resolvedPendingText,
19261931
pendingFinalDeliveryCreatedAt: Date.now(),
19271932
updatedAt: Date.now(),
19281933
}),

0 commit comments

Comments
 (0)