fix(gateway): defer heartbeats during active replies

* fix(gateway): defer heartbeats during active replies

* fix(gateway): bind heartbeat reply run fallback
This commit is contained in:
Peter Steinberger
2026-05-16 23:43:52 +01:00
committed by GitHub
parent 77ca3dc99c
commit bea4f0d2f4
3 changed files with 35 additions and 0 deletions

View File

@@ -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.

View File

@@ -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();

View File

@@ -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