diff --git a/CHANGELOG.md b/CHANGELOG.md index c215cf32121..4be1ae3aa75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ Docs: https://docs.openclaw.ai - CLI/setup: order the model/auth provider picker as OpenAI, Anthropic, xAI, Google, then the remaining providers alphabetically. - Gateway/diagnostics: add opt-in critical memory pressure stability snapshots with gateway logs, V8 heap, cgroup, active-resource, and redacted large session-file evidence. Fixes #82518. - Doctor/Gateway: avoid treating unrelated macOS LaunchAgents as legacy gateways just because their environment values mention old checkout paths. +- Gateway/heartbeat: defer heartbeat runs while the target reply operation is queued or active, preventing heartbeat prompts from interleaving with WebChat responses before the streaming lane starts. Fixes #82722. Thanks @Andy-Xie-1145. - CLI/setup: collapse raw gateway config keys in existing-config summaries into friendly `Model` and `Gateway` rows. - CLI/config: show concise human config-write output with an indented backup path instead of printing checksum-heavy overwrite audit details by default. - CLI/docs: call the canonical lowercase docs MCP search tool and surface MCP errors instead of returning empty search results. Fixes #82702. (#82704) Thanks @hclsys. diff --git a/src/infra/heartbeat-runner.skips-busy-session-lane.test.ts b/src/infra/heartbeat-runner.skips-busy-session-lane.test.ts index e36a5dfae11..54f86587ee8 100644 --- a/src/infra/heartbeat-runner.skips-busy-session-lane.test.ts +++ b/src/infra/heartbeat-runner.skips-busy-session-lane.test.ts @@ -228,6 +228,28 @@ describe("heartbeat runner skips when target session lane is busy", () => { }); }); + it("returns requests-in-flight when the target session has an active reply run", async () => { + await withTempHeartbeatSandbox(async ({ storePath, replySpy }) => { + const cfg = createHeartbeatTelegramConfig(); + const sessionKey = await seedHeartbeatTelegramSession(storePath, cfg); + const isReplyRunActive = vi.fn((key: string) => key === sessionKey); + + const result = await runHeartbeatOnce({ + cfg, + deps: { + getQueueSize: vi.fn((_lane?: string) => 0), + isReplyRunActive, + nowMs: () => Date.now(), + getReplyFromConfig: replySpy, + } as HeartbeatDeps, + }); + + expect(result).toEqual({ status: "skipped", reason: HEARTBEAT_SKIP_REQUESTS_IN_FLIGHT }); + expect(isReplyRunActive).toHaveBeenCalledWith(sessionKey); + expect(replySpy).not.toHaveBeenCalled(); + }); + }); + it("does not defer on a recent heartbeat ack pending final delivery", async () => { await withTempHeartbeatSandbox(async ({ storePath, replySpy }) => { const cfg = createHeartbeatTelegramConfig(); diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index f9c9c0037fc..c3f11ffd2de 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -34,6 +34,7 @@ import { type HeartbeatTask, } from "../auto-reply/heartbeat.js"; import { resolveDefaultModel } from "../auto-reply/reply/directive-handling.defaults.js"; +import { replyRunRegistry } from "../auto-reply/reply/reply-run-registry.js"; import { resolveResponsePrefixTemplate } from "../auto-reply/reply/response-prefix-template.js"; import { HEARTBEAT_TOKEN } from "../auto-reply/tokens.js"; import type { ReplyPayload } from "../auto-reply/types.js"; @@ -142,6 +143,7 @@ export type HeartbeatDeps = OutboundSendDeps & runtime?: RuntimeEnv; getQueueSize?: (lane?: string) => number; getCommandLaneSnapshots?: () => readonly CommandLaneSnapshot[]; + isReplyRunActive?: (sessionKey: string) => boolean; nowMs?: () => number; }; @@ -1350,6 +1352,16 @@ export async function runHeartbeatOnce(opts: { return { status: "skipped", reason: preflight.skipReason }; } const { entry, sessionKey, storePath, suppressOriginatingContext } = preflight.session; + const isReplyRunActive = + opts.deps?.isReplyRunActive ?? ((key: string) => replyRunRegistry.isActive(key)); + if (isReplyRunActive(sessionKey)) { + emitHeartbeatEvent({ + status: "skipped", + reason: HEARTBEAT_SKIP_REQUESTS_IN_FLIGHT, + durationMs: Date.now() - startedAt, + }); + return { status: "skipped", reason: HEARTBEAT_SKIP_REQUESTS_IN_FLIGHT }; + } // Check the resolved session lane — if it is busy, skip to avoid interrupting // an active streaming turn. The wake-layer retry (heartbeat-wake.ts) will