fix: drop active heartbeat followups from queue (#25610, thanks @mcaxtr)

Co-authored-by: Marcus Castro <mcaxtr@gmail.com>
This commit is contained in:
Peter Steinberger
2026-02-25 01:58:13 +00:00
parent 6fa7226a67
commit c736778b3f
3 changed files with 49 additions and 4 deletions

View File

@@ -49,6 +49,7 @@ Docs: https://docs.openclaw.ai
- Messaging tool dedupe: treat originating channel metadata as authoritative for same-target `message.send` suppression in proactive runs (heartbeat/cron/exec-event), including synthetic-provider contexts, so `delivery-mirror` transcript entries no longer cause duplicate Telegram sends. (#25835) Thanks @jadeathena84-arch.
- Cron/Heartbeat delivery: stop inheriting cached session `lastThreadId` for heartbeat-mode target resolution unless a thread/topic is explicitly requested, so announce-mode cron and heartbeat deliveries stay on top-level destinations instead of leaking into active conversation threads. (#25730) Thanks @markshields-tl.
- Heartbeat defaults/prompts: switch the implicit heartbeat delivery target from `last` to `none` (opt-in for external delivery), and use internal-only cron/exec heartbeat prompt wording when delivery is disabled so background checks do not nudge user-facing relay behavior. (#25871, #24638, #25851)
- Auto-reply/Heartbeat queueing: drop heartbeat runs when a session already has an active run instead of enqueueing a stale followup, preventing duplicate heartbeat response branches after queue drain. (#25610, #25606) Thanks @mcaxtr.
- Security/Sandbox media: restrict sandbox media tmp-path allowances to OpenClaw-managed tmp roots instead of broad host `os.tmpdir()` trust, and add outbound/channel guardrails (tmp-path lint + media-root smoke tests) to prevent regressions in local media attachment reads. Thanks @tdjackey for reporting.
- Security/Sandbox media: reject hard-linked OpenClaw tmp media aliases (including symlink-to-hardlink chains) during sandbox media path resolution to prevent out-of-sandbox inode alias reads. (#25820) Thanks @bmendonca3.
- Config/Plugins: treat stale removed `google-antigravity-auth` plugin references as compatibility warnings (not hard validation errors) across `plugins.entries`, `plugins.allow`, `plugins.deny`, and `plugins.slots.memory`, so startup no longer fails after antigravity removal. (#25538, #25862) Thanks @chilu18.

View File

@@ -8,7 +8,7 @@ import type { TypingMode } from "../../config/types.js";
import { withStateDirEnv } from "../../test-helpers/state-dir-env.js";
import type { TemplateContext } from "../templating.js";
import type { GetReplyOptions } from "../types.js";
import type { FollowupRun, QueueSettings } from "./queue.js";
import { enqueueFollowupRun, type FollowupRun, type QueueSettings } from "./queue.js";
import { createMockTypingController } from "./test-helpers.js";
type AgentRunParams = {
@@ -86,6 +86,7 @@ beforeAll(async () => {
beforeEach(() => {
state.runEmbeddedPiAgentMock.mockClear();
state.runCliAgentMock.mockClear();
vi.mocked(enqueueFollowupRun).mockClear();
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
});
@@ -98,6 +99,9 @@ function createMinimalRun(params?: {
storePath?: string;
typingMode?: TypingMode;
blockStreamingEnabled?: boolean;
isActive?: boolean;
shouldFollowup?: boolean;
resolvedQueueMode?: string;
runOverrides?: Partial<FollowupRun["run"]>;
}) {
const typing = createMockTypingController();
@@ -106,7 +110,9 @@ function createMinimalRun(params?: {
Provider: "whatsapp",
MessageSid: "msg",
} as unknown as TemplateContext;
const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings;
const resolvedQueue = {
mode: params?.resolvedQueueMode ?? "interrupt",
} as unknown as QueueSettings;
const sessionKey = params?.sessionKey ?? "main";
const followupRun = {
prompt: "hello",
@@ -147,8 +153,8 @@ function createMinimalRun(params?: {
queueKey: "main",
resolvedQueue,
shouldSteer: false,
shouldFollowup: false,
isActive: false,
shouldFollowup: params?.shouldFollowup ?? false,
isActive: params?.isActive ?? false,
isStreaming: false,
opts,
typing,
@@ -274,6 +280,39 @@ async function runReplyAgentWithBase(params: {
});
}
describe("runReplyAgent heartbeat followup guard", () => {
it("drops heartbeat runs when another run is active", async () => {
const { run, typing } = createMinimalRun({
opts: { isHeartbeat: true },
isActive: true,
shouldFollowup: true,
resolvedQueueMode: "collect",
});
const result = await run();
expect(result).toBeUndefined();
expect(vi.mocked(enqueueFollowupRun)).not.toHaveBeenCalled();
expect(state.runEmbeddedPiAgentMock).not.toHaveBeenCalled();
expect(typing.cleanup).toHaveBeenCalledTimes(1);
});
it("still enqueues non-heartbeat runs when another run is active", async () => {
const { run } = createMinimalRun({
opts: { isHeartbeat: false },
isActive: true,
shouldFollowup: true,
resolvedQueueMode: "collect",
});
const result = await run();
expect(result).toBeUndefined();
expect(vi.mocked(enqueueFollowupRun)).toHaveBeenCalledTimes(1);
expect(state.runEmbeddedPiAgentMock).not.toHaveBeenCalled();
});
});
describe("runReplyAgent typing (heartbeat)", () => {
async function withTempStateDir<T>(fn: (stateDir: string) => Promise<T>): Promise<T> {
return await withStateDirEnv(

View File

@@ -235,6 +235,11 @@ export async function runReplyAgent(params: {
}
}
if (isHeartbeat && isActive) {
typing.cleanup();
return undefined;
}
if (isActive && (shouldFollowup || resolvedQueue.mode === "steer")) {
enqueueFollowupRun(queueKey, followupRun, resolvedQueue);
await touchActiveSessionEntry();