Skip to content

Commit 11ac9f4

Browse files
committed
fix(auto-reply): unify overflow protection with in-flight drop policy, exclude heartbeats from stranded recovery
1 parent b7a707e commit 11ac9f4

7 files changed

Lines changed: 268 additions & 63 deletions

File tree

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,24 @@ vi.mock("../../cli/command-secret-gateway.js", () => ({
174174
}),
175175
}));
176176

177+
// Dedicated suites cover these sidecars; misc runner cases keep them inert to avoid unrelated graphs.
178+
vi.mock("../../cli/command-secret-targets.js", () => ({
179+
getAgentRuntimeCommandSecretTargetIds: () => new Set<string>(),
180+
getScopedChannelsCommandSecretTargets: () => ({ targetIds: new Set<string>() }),
181+
}));
182+
183+
vi.mock("../../agents/harness/runtime-plugin.js", () => ({
184+
ensureSelectedAgentHarnessPlugin: async () => undefined,
185+
}));
186+
187+
vi.mock("../../commitments/runtime.js", () => ({
188+
enqueueCommitmentExtraction: () => false,
189+
}));
190+
191+
vi.mock("./followup-runner.js", () => ({
192+
createFollowupRunner: () => vi.fn(async () => undefined),
193+
}));
194+
177195
vi.mock("../../utils/provider-utils.js", () => ({
178196
isReasoningTagProvider: (provider: string | undefined | null) =>
179197
provider === "google" || provider === "google-gemini-cli",
@@ -3467,6 +3485,7 @@ describe("runReplyAgent private message_tool_only final warning (#85714)", () =>
34673485
summaryLine?: string;
34683486
strandedReplyRetry?: boolean;
34693487
sendPolicyDenied?: boolean;
3488+
isHeartbeat?: boolean;
34703489
replyOperation?: ReturnType<typeof createReplyOperation>;
34713490
}) {
34723491
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-stranded-"));
@@ -3571,6 +3590,7 @@ describe("runReplyAgent private message_tool_only final warning (#85714)", () =>
35713590
resolvedBlockStreamingBreak: "message_end",
35723591
shouldInjectGroupIntro: false,
35733592
typingMode: "instant",
3593+
...(params.isHeartbeat ? { opts: { isHeartbeat: true } } : {}),
35743594
...(params.replyOperation ? { replyOperation: params.replyOperation } : {}),
35753595
});
35763596
return { storePath, tmp, sessionKey, result, finalAssistantText };
@@ -3708,6 +3728,14 @@ describe("runReplyAgent private message_tool_only final warning (#85714)", () =>
37083728
expect(vi.mocked(enqueueFollowupRun)).not.toHaveBeenCalled();
37093729
});
37103730

3731+
it("does not warn, enqueue retry, or emit diagnostic for heartbeat runs", async () => {
3732+
const { result } = await runPrivateFinalCase({ isHeartbeat: true });
3733+
expect(warnPrivateFinalSpy).not.toHaveBeenCalled();
3734+
expect(vi.mocked(enqueueFollowupRun)).not.toHaveBeenCalled();
3735+
const payloads = result === undefined ? [] : normalizeReplyPayloads(result);
3736+
expect(payloads.some((payload) => payload.text === strandedDiagnosticText)).toBe(false);
3737+
});
3738+
37113739
it("does not warn or enqueue retry when send policy denied source delivery", async () => {
37123740
await runPrivateFinalCase({ sendPolicyDenied: true });
37133741
expect(warnPrivateFinalSpy).not.toHaveBeenCalled();

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

Lines changed: 11 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,8 @@ import {
120120
type FollowupRun,
121121
type QueueSettings,
122122
} from "./queue.js";
123-
import { createReplyMediaContext } from "./reply-media-paths.js";
124123
import { normalizeReplyPayloadDirectives } from "./reply-delivery.js";
124+
import { createReplyMediaContext } from "./reply-media-paths.js";
125125
import { resolveReplyOperationRunState } from "./reply-operation-run-state.js";
126126
import {
127127
replyRunRegistry,
@@ -136,7 +136,7 @@ import { incrementRunCompactionCount, persistRunSessionUsage } from "./session-r
136136
import { resolveSourceReplyVisibilityPolicy } from "./source-reply-delivery-mode.js";
137137
import {
138138
buildStrandedReplyDeliveryFailurePayload,
139-
STRANDED_REPLY_RETRY_MARKER,
139+
buildStrandedReplyRetryFollowupRun,
140140
} from "./stranded-reply-recovery.js";
141141
import { createTypingSignaler } from "./typing-mode.js";
142142
import type { TypingController } from "./typing.js";
@@ -2051,10 +2051,7 @@ export async function runReplyAgent(params: {
20512051
}
20522052
return buildStrandedReplyDeliveryFailurePayload();
20532053
};
2054-
if (
2055-
opts?.sourceReplyDeliveryMode === "message_tool_only" &&
2056-
committedSourceReplyDelivery
2057-
) {
2054+
if (opts?.sourceReplyDeliveryMode === "message_tool_only" && committedSourceReplyDelivery) {
20582055
await opts.onObservedReplyDelivery?.();
20592056
}
20602057
const currentMessageId = sessionCtx.MessageSidFull ?? sessionCtx.MessageSid;
@@ -2634,7 +2631,10 @@ export async function runReplyAgent(params: {
26342631
: (rawAssistantText ?? ""),
26352632
);
26362633
const isRoomEvent = sessionCtx.InboundEventKind === "room_event";
2634+
// Heartbeats already deliver fallback finals via sendDurableMessageBatch;
2635+
// recovering here would duplicate that message.
26372636
const isStrandedReply =
2637+
!isHeartbeat &&
26382638
!isRoomEvent &&
26392639
shouldWarnAboutPrivateMessageToolFinal({
26402640
sourceReplyDeliveryMode: sourceReplyPolicy.sourceReplyDeliveryMode,
@@ -2644,6 +2644,7 @@ export async function runReplyAgent(params: {
26442644
});
26452645
const retryMissingSourceDelivery =
26462646
isStrandedReplyRetryRun &&
2647+
!isHeartbeat &&
26472648
!isRoomEvent &&
26482649
sourceReplyPolicy.sourceReplyDeliveryMode === "message_tool_only" &&
26492650
!sourceReplyPolicy.sendPolicyDenied &&
@@ -2663,30 +2664,12 @@ export async function runReplyAgent(params: {
26632664
if (isStrandedReplyRetryRun) {
26642665
finalPayloads = [...finalPayloads, buildStrandedReplyDeliveryFailurePayload()];
26652666
} else {
2666-
const retryDeliveryText = assistantFinalText;
2667-
const retryPrompt =
2668-
`[System] Your previous reply was not delivered to the conversation because ` +
2669-
`you did not call message(action=send). Your reply text was:\n\n` +
2670-
`"${retryDeliveryText}"\n\n` +
2671-
`Please deliver this reply now by calling message(action=send). ` +
2672-
`Do not add any extra commentary; just deliver the original reply.`;
26732667
const retryEnqueued = enqueueFollowupRun(
26742668
queueKey,
2675-
{
2676-
...followupRun,
2677-
prompt: retryPrompt,
2678-
summaryLine: STRANDED_REPLY_RETRY_MARKER,
2679-
strandedReplyRetry: true,
2680-
disableCollectBatching: true,
2681-
transcriptPrompt: undefined,
2682-
userTurnTranscriptRecorder: undefined,
2683-
currentInboundContext: undefined,
2684-
run: {
2685-
...followupRun.run,
2686-
sourceReplyDeliveryMode: sourceReplyPolicy.sourceReplyDeliveryMode,
2687-
suppressNextUserMessagePersistence: true,
2688-
},
2689-
},
2669+
buildStrandedReplyRetryFollowupRun(followupRun, {
2670+
finalText: assistantFinalText,
2671+
sourceReplyDeliveryMode: sourceReplyPolicy.sourceReplyDeliveryMode,
2672+
}),
26902673
resolvedQueue,
26912674
"none",
26922675
runFollowupTurn,

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

Lines changed: 12 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ import { incrementRunCompactionCount, persistRunSessionUsage } from "./session-r
123123
import { resolveSourceReplyVisibilityPolicy } from "./source-reply-delivery-mode.js";
124124
import {
125125
buildStrandedReplyDeliveryFailurePayload,
126-
STRANDED_REPLY_RETRY_MARKER,
126+
buildStrandedReplyRetryFollowupRun,
127127
} from "./stranded-reply-recovery.js";
128128
import { createTypingSignaler } from "./typing-mode.js";
129129
import type { TypingController } from "./typing.js";
@@ -1606,6 +1606,13 @@ export function createFollowupRunner(params: {
16061606
if (isStrandedReplyRetryFollowup(effectiveQueued)) {
16071607
return false;
16081608
}
1609+
// Heartbeat turns can reach this path: runReplyAgent builds the
1610+
// followup runner with opts.isHeartbeat and may enqueue-followup while
1611+
// another run is active. Heartbeats already deliver fallback finals
1612+
// via sendDurableMessageBatch, so recovery would duplicate delivery.
1613+
if (opts?.isHeartbeat === true) {
1614+
return false;
1615+
}
16091616
const sourceReplyPolicy = resolveSourceReplyVisibilityPolicy({
16101617
cfg: runtimeConfig,
16111618
ctx: {
@@ -1647,33 +1654,15 @@ export function createFollowupRunner(params: {
16471654
channel: queued.originatingChannel ?? run.messageProvider ?? activeSessionEntry?.channel,
16481655
finalTextLength: assistantFinalText.trim().length,
16491656
});
1650-
const retryDeliveryText = assistantFinalText;
1651-
const retryPrompt =
1652-
`[System] Your previous reply was not delivered to the conversation because ` +
1653-
`you did not call message(action=send). Your reply text was:\n\n` +
1654-
`"${retryDeliveryText}"\n\n` +
1655-
`Please deliver this reply now by calling message(action=send). ` +
1656-
`Do not add any extra commentary; just deliver the original reply.`;
16571657
const retryEnqueued =
16581658
typeof replySessionKey === "string" &&
16591659
replySessionKey.length > 0 &&
16601660
enqueueFollowupRun(
16611661
replySessionKey,
1662-
{
1663-
...effectiveQueued,
1664-
prompt: retryPrompt,
1665-
summaryLine: STRANDED_REPLY_RETRY_MARKER,
1666-
strandedReplyRetry: true,
1667-
disableCollectBatching: true,
1668-
transcriptPrompt: undefined,
1669-
userTurnTranscriptRecorder: undefined,
1670-
currentInboundContext: undefined,
1671-
run: {
1672-
...run,
1673-
sourceReplyDeliveryMode: sourceReplyPolicy.sourceReplyDeliveryMode,
1674-
suppressNextUserMessagePersistence: true,
1675-
},
1676-
},
1662+
buildStrandedReplyRetryFollowupRun(effectiveQueued, {
1663+
finalText: assistantFinalText,
1664+
sourceReplyDeliveryMode: sourceReplyPolicy.sourceReplyDeliveryMode,
1665+
}),
16771666
resolveQueueSettings({
16781667
cfg: runtimeConfig,
16791668
channel: queued.originatingChannel ?? run.messageProvider,

src/auto-reply/reply/queue.collect.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1993,6 +1993,38 @@ describe("followup queue collect routing", () => {
19931993
expect(getExistingFollowupQueue(key)?.summarySources).toHaveLength(0);
19941994
});
19951995

1996+
it("does not advance debounce stamp when overflow rejects an incoming message", () => {
1997+
const key = `test-priority-followup-debounce-reject-${Date.now()}`;
1998+
const settings: QueueSettings = {
1999+
mode: "followup",
2000+
debounceMs: 5_000,
2001+
cap: 1,
2002+
dropPolicy: "old",
2003+
};
2004+
2005+
const priorityAccepted = enqueueFollowupRun(
2006+
key,
2007+
createRun({ prompt: "priority retry" }),
2008+
settings,
2009+
"none",
2010+
undefined,
2011+
false,
2012+
{ position: "front" },
2013+
);
2014+
const queue = getExistingFollowupQueue(key);
2015+
expect(priorityAccepted).toBe(true);
2016+
expect(queue).toBeDefined();
2017+
const stampedAt = queue!.lastEnqueuedAt;
2018+
expect(stampedAt).toBeGreaterThan(0);
2019+
2020+
const rejected = enqueueFollowupRun(key, createRun({ prompt: "busy chat noise" }), settings);
2021+
expect(rejected).toBe(false);
2022+
expect(getExistingFollowupQueue(key)?.lastEnqueuedAt).toBe(stampedAt);
2023+
expect(getExistingFollowupQueue(key)?.items.map((item) => item.prompt)).toEqual([
2024+
"priority retry",
2025+
]);
2026+
});
2027+
19962028
it("leaves the queue untouched when protected overflow cannot drop enough items", () => {
19972029
const key = `test-priority-followup-atomic-overflow-${Date.now()}`;
19982030
const initialSettings: QueueSettings = {

src/auto-reply/reply/queue/enqueue.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,6 @@ export function enqueueFollowupRun(
137137
if (!markFollowupRunEnqueued(run)) {
138138
return false;
139139
}
140-
queue.lastEnqueuedAt = Date.now();
141-
queue.lastRun = run.run;
142140

143141
const shouldEnqueue = applyQueueDropPolicy({
144142
queue,
@@ -190,6 +188,10 @@ export function enqueueFollowupRun(
190188
completeFollowupRunLifecycle(run);
191189
return false;
192190
}
191+
// Only admitted items refresh debounce; rejected overflow must not starve
192+
// protected stranded-reply retries waiting for the quiet window.
193+
queue.lastEnqueuedAt = Date.now();
194+
queue.lastRun = run.run;
193195

194196
run.queueAbortSignal = queue.abortController.signal;
195197
if (options.position === "front") {

src/auto-reply/reply/stranded-reply-recovery.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import type { SourceReplyDeliveryMode } from "../get-reply-options.types.js";
12
import { markReplyPayloadForSourceSuppressionDelivery } from "../reply-payload.js";
23
import type { ReplyPayload } from "../types.js";
4+
import type { FollowupRun } from "./queue/types.js";
35

46
export const STRANDED_REPLY_RETRY_MARKER = "stranded-reply-retry";
57
export const STRANDED_REPLY_DELIVERY_FAILURE_TEXT =
@@ -12,3 +14,38 @@ export function buildStrandedReplyDeliveryFailurePayload(): ReplyPayload {
1214
isStatusNotice: true,
1315
});
1416
}
17+
18+
export function buildStrandedReplyRetryPrompt(finalText: string): string {
19+
return (
20+
`[System] Your previous reply was not delivered to the conversation because ` +
21+
`you did not call message(action=send). Your reply text was:\n\n` +
22+
`"${finalText}"\n\n` +
23+
`Please deliver this reply now by calling message(action=send). ` +
24+
`Do not add any extra commentary; just deliver the original reply.`
25+
);
26+
}
27+
28+
/** Build the one-shot recovery followup that re-prompts message(action=send). */
29+
export function buildStrandedReplyRetryFollowupRun(
30+
base: FollowupRun,
31+
params: {
32+
finalText: string;
33+
sourceReplyDeliveryMode: SourceReplyDeliveryMode | undefined;
34+
},
35+
): FollowupRun {
36+
return {
37+
...base,
38+
prompt: buildStrandedReplyRetryPrompt(params.finalText),
39+
summaryLine: STRANDED_REPLY_RETRY_MARKER,
40+
strandedReplyRetry: true,
41+
disableCollectBatching: true,
42+
transcriptPrompt: undefined,
43+
userTurnTranscriptRecorder: undefined,
44+
currentInboundContext: undefined,
45+
run: {
46+
...base.run,
47+
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
48+
suppressNextUserMessagePersistence: true,
49+
},
50+
};
51+
}

0 commit comments

Comments
 (0)