fix(heartbeat): suppress metadata-only exec completion noise

This commit is contained in:
Peter Steinberger
2026-04-29 21:39:27 +01:00
parent 470098bd26
commit 65c9eddae8
5 changed files with 211 additions and 22 deletions

View File

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

View File

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

View File

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

View File

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

View File

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