Skip to content

Commit b645654

Browse files
committed
fix: avoid stale followup drain callbacks (#31902) (thanks @Lanfei)
1 parent 6013020 commit b645654

File tree

3 files changed

+31
-3
lines changed

3 files changed

+31
-3
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ Docs: https://docs.openclaw.ai
4747

4848
### Fixes
4949

50+
- Auto-reply/followup queue: avoid stale callback reuse across idle-window restarts by caching the followup runner only when a drain actually starts, preserving enqueue ordering after empty-finalize paths. (#31902) Thanks @Lanfei.
5051
- Gateway/Heartbeat model reload: treat `models.*` and `agents.defaults.model` config updates as heartbeat hot-reload triggers so heartbeat picks up model changes without a full gateway restart. (#32046) Thanks @stakeswky.
5152
- Slack/inbound debounce routing: isolate top-level non-DM message debounce keys by message timestamp to avoid cross-thread collisions, preserve DM batching, and flush pending top-level buffers before immediate non-debounce follow-ups to keep ordering stable. (#31951) Thanks @scoootscooob.
5253
- OpenRouter/x-ai compatibility: skip `reasoning.effort` injection for `x-ai/*` models (for example Grok) so OpenRouter requests no longer fail with invalid-arguments errors on unsupported reasoning params. (#32054) Thanks @scoootscooob.

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,13 +67,13 @@ export function scheduleFollowupDrain(
6767
key: string,
6868
runFollowup: (run: FollowupRun) => Promise<void>,
6969
): void {
70-
// Cache the callback so enqueueFollowupRun can restart drain after the queue
71-
// has been deleted and recreated (the post-drain idle window race condition).
72-
FOLLOWUP_RUN_CALLBACKS.set(key, runFollowup);
7370
const queue = beginQueueDrain(FOLLOWUP_QUEUES, key);
7471
if (!queue) {
7572
return;
7673
}
74+
// Cache callback only when a drain actually starts. Avoid keeping stale
75+
// callbacks around from finalize calls where no queue work is pending.
76+
FOLLOWUP_RUN_CALLBACKS.set(key, runFollowup);
7777
void (async () => {
7878
try {
7979
const collectState = { forceIndividualCollect: false };

src/auto-reply/reply/reply-flow.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1097,6 +1097,33 @@ describe("followup queue collect routing", () => {
10971097
});
10981098

10991099
describe("followup queue drain restart after idle window", () => {
1100+
it("does not retain stale callbacks when scheduleFollowupDrain runs with an empty queue", async () => {
1101+
const key = `test-no-stale-callback-${Date.now()}`;
1102+
const settings: QueueSettings = { mode: "followup", debounceMs: 0, cap: 50 };
1103+
const staleCalls: FollowupRun[] = [];
1104+
const freshCalls: FollowupRun[] = [];
1105+
const drained = createDeferred<void>();
1106+
1107+
// Simulate finalizeWithFollowup calling schedule without pending queue items.
1108+
scheduleFollowupDrain(key, async (run) => {
1109+
staleCalls.push(run);
1110+
});
1111+
1112+
enqueueFollowupRun(key, createRun({ prompt: "after-empty-schedule" }), settings);
1113+
await new Promise<void>((resolve) => setImmediate(resolve));
1114+
expect(staleCalls).toHaveLength(0);
1115+
1116+
scheduleFollowupDrain(key, async (run) => {
1117+
freshCalls.push(run);
1118+
drained.resolve();
1119+
});
1120+
await drained.promise;
1121+
1122+
expect(staleCalls).toHaveLength(0);
1123+
expect(freshCalls).toHaveLength(1);
1124+
expect(freshCalls[0]?.prompt).toBe("after-empty-schedule");
1125+
});
1126+
11001127
it("processes a message enqueued after the drain empties and deletes the queue", async () => {
11011128
const key = `test-idle-window-race-${Date.now()}`;
11021129
const calls: FollowupRun[] = [];

0 commit comments

Comments
 (0)