From 65c9eddae87e3106423679852c1579caa8490a4f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 29 Apr 2026 21:39:27 +0100 Subject: [PATCH] fix(heartbeat): suppress metadata-only exec completion noise --- CHANGELOG.md | 1 + src/infra/heartbeat-events-filter.test.ts | 33 ++++++++ src/infra/heartbeat-events-filter.ts | 83 +++++++++++++++++-- .../heartbeat-runner.ghost-reminder.test.ts | 70 +++++++++++++++- src/infra/heartbeat-runner.ts | 46 ++++++---- 5 files changed, 211 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc1d66bb0d7..e135ede3164 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Agents/Codex: bound embedded-run cleanup, trajectory flushing, and command-lane task timeouts after runtime failures, so Discord and other chat sessions return to idle instead of staying stuck in processing. Thanks @vincentkoc. +- Heartbeat/exec: consume successful metadata-only async exec completions silently so Telegram and other chat surfaces no longer ask users for missing command logs after `No session found`. Fixes #74595. Thanks @gkoch02. - Web fetch: add a documented `tools.web.fetch.ssrfPolicy.allowIpv6UniqueLocalRange` opt-in and thread it through cache keys and DNS/IP checks so trusted fake-IP proxy stacks using `fc00::/7` can work without broad private-network access. Fixes #74351. Thanks @jeffrey701. - OpenAI Codex: restore `/verbose full` persistence and app-server tool-output forwarding, and retry Gateway E2E temp-home cleanup so debug runs do not regress on stale validation or cleanup flakes. Thanks @vincentkoc. - Anthropic/Meridian: preserve text and thinking content seeded on `content_block_start` in anthropic-messages streams, so `[thinking, text]` replies no longer persist as empty turns or trigger empty-response fallbacks. Fixes #74410. Thanks @vyctorbrzezowski. diff --git a/src/infra/heartbeat-events-filter.test.ts b/src/infra/heartbeat-events-filter.test.ts index 153f85f83ba..7be543bb684 100644 --- a/src/infra/heartbeat-events-filter.test.ts +++ b/src/infra/heartbeat-events-filter.test.ts @@ -4,6 +4,7 @@ import { buildExecEventPrompt, isCronSystemEvent, isExecCompletionEvent, + isRelayableExecCompletionEvent, } from "./heartbeat-events-filter.js"; describe("heartbeat event prompts", () => { @@ -75,6 +76,24 @@ describe("heartbeat event prompts", () => { expected: ["no command output was found", "Reply HEARTBEAT_OK only"], unexpected: ["Please relay the command output to the user", "system messages above"], }, + { + name: "suppresses metadata-only successful exec completions", + events: ["Exec completed (abc12345, code 0)"], + opts: undefined, + expected: ["no command output was found", "Reply HEARTBEAT_OK only"], + unexpected: ["Please relay the command output to the user", "abc12345"], + }, + { + name: "reports metadata-only failed exec completions without asking for logs", + events: ["Exec failed (abc12345, code 1)"], + opts: undefined, + expected: [ + "without captured stdout/stderr", + "include the exit status or signal", + "Do not ask the user to provide missing logs", + ], + unexpected: ["Please relay the command output to the user"], + }, ])("$name", ({ events, opts, expected, unexpected }) => { const prompt = buildExecEventPrompt(events, opts); for (const part of expected) { @@ -98,7 +117,9 @@ describe("heartbeat event classification", () => { { value: "exec finished: ok", expected: true }, { value: "Exec finished (node=abc, code 0)", expected: true }, { value: "Exec Finished (node=abc, code 1)", expected: true }, + { value: "Exec completed (abc12345, code 0)", expected: true }, { value: "Exec completed (abc12345, code 0) :: some output", expected: true }, + { value: "Exec failed (abc12345, code 1)", expected: true }, { value: "Exec failed (abc12345, signal SIGTERM) :: error output", expected: true }, { value: "Exec completed (rotate api keys)", expected: false }, { value: "Exec failed: notify me if this happens", expected: false }, @@ -119,11 +140,23 @@ describe("heartbeat event classification", () => { { value: "heartbeat wake: noop", expected: false }, { value: "exec finished: ok", expected: false }, { value: "Exec finished (node=abc, code 0)", expected: false }, + { value: "Exec completed (abc12345, code 0)", expected: false }, { value: "Exec completed (abc12345, code 0) :: some output", expected: false }, + { value: "Exec failed (abc12345, code 1)", expected: false }, { value: "Exec failed (abc12345, signal SIGTERM) :: error output", expected: false }, { value: "Exec completed (rotate api keys)", expected: true }, { value: "Reminder: if exec failed, notify me", expected: true }, ])("classifies cron system events for %j", ({ value, expected }) => { expect(isCronSystemEvent(value)).toBe(expected); }); + + it.each([ + { value: "Exec completed (abc12345, code 0)", expected: false }, + { value: "Exec completed (abc12345, code 0) :: some output", expected: true }, + { value: "Exec failed (abc12345, code 1)", expected: true }, + { value: "Exec failed (abc12345, signal SIGTERM)", expected: true }, + { value: "exec finished: ok", expected: true }, + ])("classifies relayable exec completion events for %j", ({ value, expected }) => { + expect(isRelayableExecCompletionEvent(value)).toBe(expected); + }); }); diff --git a/src/infra/heartbeat-events-filter.ts b/src/infra/heartbeat-events-filter.ts index 5a73004baab..c9c81238c3f 100644 --- a/src/infra/heartbeat-events-filter.ts +++ b/src/infra/heartbeat-events-filter.ts @@ -2,6 +2,71 @@ import { HEARTBEAT_TOKEN } from "../auto-reply/tokens.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; const MAX_EXEC_EVENT_PROMPT_CHARS = 8_000; +const STRUCTURED_EXEC_COMPLETION_EVENT_RE = + /^exec (completed|failed) \(([a-z0-9_-]{1,64}), (code -?\d+|signal [^)]+)\)(?: :: ([\s\S]*))?$/i; + +type StructuredExecCompletionEvent = { + raw: string; + action: string; + id: string; + result: string; + output: string; + succeeded: boolean; +}; + +function parseStructuredExecCompletionEvent(evt: string): StructuredExecCompletionEvent | null { + const trimmed = evt.trim(); + const match = STRUCTURED_EXEC_COMPLETION_EVENT_RE.exec(trimmed); + if (!match) { + return null; + } + const action = match[1] ?? ""; + const result = match[3] ?? ""; + return { + raw: trimmed, + action, + id: match[2] ?? "", + result, + output: (match[4] ?? "").trim(), + succeeded: action.toLowerCase() === "completed" && result.toLowerCase() === "code 0", + }; +} + +export function isRelayableExecCompletionEvent(evt: string): boolean { + const parsed = parseStructuredExecCompletionEvent(evt); + if (!parsed) { + return isExecCompletionEvent(evt); + } + if (parsed.output) { + return true; + } + return !parsed.succeeded; +} + +function formatExecEventPromptText(pendingEvents: string[]): { + text: string; + hasMissingOutputFailure: boolean; +} { + let hasMissingOutputFailure = false; + const lines = pendingEvents.flatMap((event) => { + const parsed = parseStructuredExecCompletionEvent(event); + if (!parsed) { + const trimmed = event.trim(); + return trimmed ? [trimmed] : []; + } + if (parsed.output) { + return [parsed.raw]; + } + if (parsed.succeeded) { + return []; + } + hasMissingOutputFailure = true; + return [ + `Exec ${parsed.action} (${parsed.id}, ${parsed.result}) without captured stdout/stderr.`, + ]; + }); + return { text: lines.join("\n").trim(), hasMissingOutputFailure }; +} // Build a dynamic prompt for cron events by embedding the actual event content. // This ensures the model sees the reminder text directly instead of relying on @@ -45,7 +110,7 @@ export function buildExecEventPrompt( opts?: { deliverToUser?: boolean }, ): string { const deliverToUser = opts?.deliverToUser ?? true; - const rawEventText = pendingEvents.join("\n").trim(); + const { text: rawEventText, hasMissingOutputFailure } = formatExecEventPromptText(pendingEvents); const eventText = rawEventText.length > MAX_EXEC_EVENT_PROMPT_CHARS ? `${rawEventText.slice(0, MAX_EXEC_EVENT_PROMPT_CHARS)}\n\n[truncated]` @@ -62,6 +127,15 @@ export function buildExecEventPrompt( "Handle the result internally and reply HEARTBEAT_OK only. Do not mention, summarize, or reuse command output." ); } + if (hasMissingOutputFailure) { + return ( + "An async command you ran earlier completed without captured stdout/stderr. The completion details are:\n\n" + + eventText + + "\n\n" + + "Tell the user the command completed without captured output and include the exit status or signal. " + + "Do not ask the user to provide missing logs, and do not try to retrieve logs from an exec/session id." + ); + } return ( "An async command you ran earlier has completed. The command completion details are:\n\n" + eventText + @@ -103,12 +177,11 @@ function isHeartbeatNoiseEvent(evt: string): boolean { } export function isExecCompletionEvent(evt: string): boolean { - const normalized = normalizeLowercaseStringOrEmpty(evt).trimStart(); + const trimmed = evt.trimStart(); + const normalized = normalizeLowercaseStringOrEmpty(trimmed); return ( /^exec finished(?::|\s*\()/.test(normalized) || - /^exec (completed|failed) \([a-z0-9_-]{1,64}, (code -?\d+|signal [^)]+)\)( :: .*)?$/.test( - normalized, - ) + STRUCTURED_EXEC_COMPLETION_EVENT_RE.test(trimmed) ); } diff --git a/src/infra/heartbeat-runner.ghost-reminder.test.ts b/src/infra/heartbeat-runner.ghost-reminder.test.ts index 6d553ad302e..12904a2a609 100644 --- a/src/infra/heartbeat-runner.ghost-reminder.test.ts +++ b/src/infra/heartbeat-runner.ghost-reminder.test.ts @@ -550,7 +550,7 @@ describe("Ghost reminder bug (issue #13317)", () => { expect(options?.messageThreadId).toBeUndefined(); }); }); - it("keeps exec-event delivery pinned to the original Telegram topic when session route drifts", async () => { + it("keeps output-bearing exec-event delivery pinned to the original Telegram topic when session route drifts", async () => { await withTempHeartbeatSandbox(async ({ tmpDir, storePath }) => { const cfg: OpenClawConfig = { agents: { @@ -586,7 +586,7 @@ describe("Ghost reminder bug (issue #13317)", () => { const getReplySpy = vi.fn().mockResolvedValue({ text: "The review-worker spawn finished successfully.", }); - enqueueSystemEvent("Exec completed (review-run, code 0)", { + enqueueSystemEvent("Exec completed (review-run, code 0) :: review-worker spawn finished", { sessionKey, trusted: false, deliveryContext: { @@ -617,6 +617,72 @@ describe("Ghost reminder bug (issue #13317)", () => { }); }); + it("suppresses metadata-only successful exec completions", async () => { + await withTempHeartbeatSandbox(async ({ tmpDir, storePath }) => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + workspace: tmpDir, + heartbeat: { + every: "5m", + target: "last", + }, + }, + }, + channels: { telegram: { allowFrom: ["*"] } }, + session: { store: storePath }, + }; + const sessionKey = "agent:main:telegram:group:-1003774691294:topic:47"; + await fs.writeFile( + storePath, + JSON.stringify({ + [sessionKey]: { + sessionId: "sid", + updatedAt: Date.now(), + lastChannel: "telegram", + lastTo: "telegram:-1003774691294:topic:2175", + lastThreadId: 2175, + }, + }), + ); + + const sendTelegram = vi.fn(); + const getReplySpy = vi.fn().mockResolvedValue({ + text: "HEARTBEAT_OK", + }); + enqueueSystemEvent("Exec completed (review-run, code 0)", { + sessionKey, + trusted: false, + deliveryContext: { + channel: "telegram", + to: "telegram:-1003774691294:topic:47", + threadId: 47, + }, + }); + + const result = await runHeartbeatOnce({ + cfg, + agentId: "main", + sessionKey, + reason: "exec-event", + deps: { + getReplyFromConfig: getReplySpy, + telegram: sendTelegram, + }, + }); + + expect(result.status).toBe("ran"); + expect(getReplySpy).toHaveBeenCalledWith( + expect.objectContaining({ + Body: expect.stringContaining("no command output was found"), + }), + expect.anything(), + expect.anything(), + ); + expect(sendTelegram).not.toHaveBeenCalled(); + }); + }); + it("keeps Telegram topic routing for isolated scheduled heartbeats", async () => { await withTempHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => { const cfg = createLastTargetConfig({ tmpDir, storePath, isolatedSession: true }); diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 03a9cce91a9..b75082039c7 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -75,10 +75,11 @@ import { loadOrCreateDeviceIdentity } from "./device-identity.js"; import { formatErrorMessage, hasErrnoCode } from "./errors.js"; import { isWithinActiveHours } from "./heartbeat-active-hours.js"; import { - buildExecEventPrompt, buildCronEventPrompt, + buildExecEventPrompt, isCronSystemEvent, isExecCompletionEvent, + isRelayableExecCompletionEvent, } from "./heartbeat-events-filter.js"; import { emitHeartbeatEvent, resolveIndicatorType } from "./heartbeat-events.js"; import { resolveHeartbeatReasonKind } from "./heartbeat-reason.js"; @@ -683,6 +684,7 @@ async function resolveHeartbeatPreflight(params: { type HeartbeatPromptResolution = { prompt: string | null; hasExecCompletion: boolean; + hasRelayableExecCompletion: boolean; hasCronEvents: boolean; }; @@ -755,6 +757,8 @@ function resolveHeartbeatRunPrompt(params: { .map((event) => event.text) : []; const hasExecCompletion = execEvents.length > 0; + const hasRelayableExecCompletion = + params.canRelayToUser && execEvents.some((event) => isRelayableExecCompletionEvent(event)); const hasCronEvents = cronEvents.length > 0; if (params.preflight.tasks && params.preflight.tasks.length > 0) { @@ -781,9 +785,19 @@ After completing all due tasks, reply HEARTBEAT_OK.`; prompt += `\n\nAdditional context from HEARTBEAT.md:\n${directives}`; } } - return { prompt, hasExecCompletion: false, hasCronEvents: false }; + return { + prompt, + hasExecCompletion: false, + hasRelayableExecCompletion: false, + hasCronEvents: false, + }; } - return { prompt: null, hasExecCompletion: false, hasCronEvents: false }; + return { + prompt: null, + hasExecCompletion: false, + hasRelayableExecCompletion: false, + hasCronEvents: false, + }; } const basePrompt = hasExecCompletion @@ -793,7 +807,7 @@ After completing all due tasks, reply HEARTBEAT_OK.`; : resolveHeartbeatPrompt(params.cfg, params.heartbeat); const prompt = appendHeartbeatWorkspacePathHint(basePrompt, params.workspaceDir); - return { prompt, hasExecCompletion, hasCronEvents }; + return { prompt, hasExecCompletion, hasRelayableExecCompletion, hasCronEvents }; } export async function runHeartbeatOnce(opts: { @@ -931,15 +945,16 @@ export async function runHeartbeatOnce(opts: { delivery.channel !== "none" && delivery.to && visibility.showAlerts, ); const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); - const { prompt, hasExecCompletion, hasCronEvents } = resolveHeartbeatRunPrompt({ - cfg, - heartbeat, - preflight, - canRelayToUser, - workspaceDir, - startedAt, - heartbeatFileContent: preflight.heartbeatFileContent, - }); + const { prompt, hasExecCompletion, hasRelayableExecCompletion, hasCronEvents } = + resolveHeartbeatRunPrompt({ + cfg, + heartbeat, + preflight, + canRelayToUser, + workspaceDir, + startedAt, + heartbeatFileContent: preflight.heartbeatFileContent, + }); // If no tasks are due, skip heartbeat entirely if (prompt === null) { @@ -1202,14 +1217,15 @@ export async function runHeartbeatOnce(opts: { // Also, if normalized.text is empty due to token stripping but we have exec completion, // fall back to the original reply text. const execFallbackText = - hasExecCompletion && !normalized.text.trim() && replyPayload.text?.trim() + hasRelayableExecCompletion && !normalized.text.trim() && replyPayload.text?.trim() ? replyPayload.text.trim() : null; if (execFallbackText) { normalized.text = execFallbackText; normalized.shouldSkip = false; } - const shouldSkipMain = normalized.shouldSkip && !normalized.hasMedia && !hasExecCompletion; + const shouldSkipMain = + normalized.shouldSkip && !normalized.hasMedia && !hasRelayableExecCompletion; if (shouldSkipMain && reasoningPayloads.length === 0) { await restoreHeartbeatUpdatedAt({ storePath,