Skip to content

Commit 65c9edd

Browse files
committed
fix(heartbeat): suppress metadata-only exec completion noise
1 parent 470098b commit 65c9edd

5 files changed

Lines changed: 211 additions & 22 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai
2727
### Fixes
2828

2929
- 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.
30+
- 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.
3031
- 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.
3132
- 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.
3233
- 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.

src/infra/heartbeat-events-filter.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
buildExecEventPrompt,
55
isCronSystemEvent,
66
isExecCompletionEvent,
7+
isRelayableExecCompletionEvent,
78
} from "./heartbeat-events-filter.js";
89

910
describe("heartbeat event prompts", () => {
@@ -75,6 +76,24 @@ describe("heartbeat event prompts", () => {
7576
expected: ["no command output was found", "Reply HEARTBEAT_OK only"],
7677
unexpected: ["Please relay the command output to the user", "system messages above"],
7778
},
79+
{
80+
name: "suppresses metadata-only successful exec completions",
81+
events: ["Exec completed (abc12345, code 0)"],
82+
opts: undefined,
83+
expected: ["no command output was found", "Reply HEARTBEAT_OK only"],
84+
unexpected: ["Please relay the command output to the user", "abc12345"],
85+
},
86+
{
87+
name: "reports metadata-only failed exec completions without asking for logs",
88+
events: ["Exec failed (abc12345, code 1)"],
89+
opts: undefined,
90+
expected: [
91+
"without captured stdout/stderr",
92+
"include the exit status or signal",
93+
"Do not ask the user to provide missing logs",
94+
],
95+
unexpected: ["Please relay the command output to the user"],
96+
},
7897
])("$name", ({ events, opts, expected, unexpected }) => {
7998
const prompt = buildExecEventPrompt(events, opts);
8099
for (const part of expected) {
@@ -98,7 +117,9 @@ describe("heartbeat event classification", () => {
98117
{ value: "exec finished: ok", expected: true },
99118
{ value: "Exec finished (node=abc, code 0)", expected: true },
100119
{ value: "Exec Finished (node=abc, code 1)", expected: true },
120+
{ value: "Exec completed (abc12345, code 0)", expected: true },
101121
{ value: "Exec completed (abc12345, code 0) :: some output", expected: true },
122+
{ value: "Exec failed (abc12345, code 1)", expected: true },
102123
{ value: "Exec failed (abc12345, signal SIGTERM) :: error output", expected: true },
103124
{ value: "Exec completed (rotate api keys)", expected: false },
104125
{ value: "Exec failed: notify me if this happens", expected: false },
@@ -119,11 +140,23 @@ describe("heartbeat event classification", () => {
119140
{ value: "heartbeat wake: noop", expected: false },
120141
{ value: "exec finished: ok", expected: false },
121142
{ value: "Exec finished (node=abc, code 0)", expected: false },
143+
{ value: "Exec completed (abc12345, code 0)", expected: false },
122144
{ value: "Exec completed (abc12345, code 0) :: some output", expected: false },
145+
{ value: "Exec failed (abc12345, code 1)", expected: false },
123146
{ value: "Exec failed (abc12345, signal SIGTERM) :: error output", expected: false },
124147
{ value: "Exec completed (rotate api keys)", expected: true },
125148
{ value: "Reminder: if exec failed, notify me", expected: true },
126149
])("classifies cron system events for %j", ({ value, expected }) => {
127150
expect(isCronSystemEvent(value)).toBe(expected);
128151
});
152+
153+
it.each([
154+
{ value: "Exec completed (abc12345, code 0)", expected: false },
155+
{ value: "Exec completed (abc12345, code 0) :: some output", expected: true },
156+
{ value: "Exec failed (abc12345, code 1)", expected: true },
157+
{ value: "Exec failed (abc12345, signal SIGTERM)", expected: true },
158+
{ value: "exec finished: ok", expected: true },
159+
])("classifies relayable exec completion events for %j", ({ value, expected }) => {
160+
expect(isRelayableExecCompletionEvent(value)).toBe(expected);
161+
});
129162
});

src/infra/heartbeat-events-filter.ts

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,71 @@ import { HEARTBEAT_TOKEN } from "../auto-reply/tokens.js";
22
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
33

44
const MAX_EXEC_EVENT_PROMPT_CHARS = 8_000;
5+
const STRUCTURED_EXEC_COMPLETION_EVENT_RE =
6+
/^exec (completed|failed) \(([a-z0-9_-]{1,64}), (code -?\d+|signal [^)]+)\)(?: :: ([\s\S]*))?$/i;
7+
8+
type StructuredExecCompletionEvent = {
9+
raw: string;
10+
action: string;
11+
id: string;
12+
result: string;
13+
output: string;
14+
succeeded: boolean;
15+
};
16+
17+
function parseStructuredExecCompletionEvent(evt: string): StructuredExecCompletionEvent | null {
18+
const trimmed = evt.trim();
19+
const match = STRUCTURED_EXEC_COMPLETION_EVENT_RE.exec(trimmed);
20+
if (!match) {
21+
return null;
22+
}
23+
const action = match[1] ?? "";
24+
const result = match[3] ?? "";
25+
return {
26+
raw: trimmed,
27+
action,
28+
id: match[2] ?? "",
29+
result,
30+
output: (match[4] ?? "").trim(),
31+
succeeded: action.toLowerCase() === "completed" && result.toLowerCase() === "code 0",
32+
};
33+
}
34+
35+
export function isRelayableExecCompletionEvent(evt: string): boolean {
36+
const parsed = parseStructuredExecCompletionEvent(evt);
37+
if (!parsed) {
38+
return isExecCompletionEvent(evt);
39+
}
40+
if (parsed.output) {
41+
return true;
42+
}
43+
return !parsed.succeeded;
44+
}
45+
46+
function formatExecEventPromptText(pendingEvents: string[]): {
47+
text: string;
48+
hasMissingOutputFailure: boolean;
49+
} {
50+
let hasMissingOutputFailure = false;
51+
const lines = pendingEvents.flatMap((event) => {
52+
const parsed = parseStructuredExecCompletionEvent(event);
53+
if (!parsed) {
54+
const trimmed = event.trim();
55+
return trimmed ? [trimmed] : [];
56+
}
57+
if (parsed.output) {
58+
return [parsed.raw];
59+
}
60+
if (parsed.succeeded) {
61+
return [];
62+
}
63+
hasMissingOutputFailure = true;
64+
return [
65+
`Exec ${parsed.action} (${parsed.id}, ${parsed.result}) without captured stdout/stderr.`,
66+
];
67+
});
68+
return { text: lines.join("\n").trim(), hasMissingOutputFailure };
69+
}
570

671
// Build a dynamic prompt for cron events by embedding the actual event content.
772
// This ensures the model sees the reminder text directly instead of relying on
@@ -45,7 +110,7 @@ export function buildExecEventPrompt(
45110
opts?: { deliverToUser?: boolean },
46111
): string {
47112
const deliverToUser = opts?.deliverToUser ?? true;
48-
const rawEventText = pendingEvents.join("\n").trim();
113+
const { text: rawEventText, hasMissingOutputFailure } = formatExecEventPromptText(pendingEvents);
49114
const eventText =
50115
rawEventText.length > MAX_EXEC_EVENT_PROMPT_CHARS
51116
? `${rawEventText.slice(0, MAX_EXEC_EVENT_PROMPT_CHARS)}\n\n[truncated]`
@@ -62,6 +127,15 @@ export function buildExecEventPrompt(
62127
"Handle the result internally and reply HEARTBEAT_OK only. Do not mention, summarize, or reuse command output."
63128
);
64129
}
130+
if (hasMissingOutputFailure) {
131+
return (
132+
"An async command you ran earlier completed without captured stdout/stderr. The completion details are:\n\n" +
133+
eventText +
134+
"\n\n" +
135+
"Tell the user the command completed without captured output and include the exit status or signal. " +
136+
"Do not ask the user to provide missing logs, and do not try to retrieve logs from an exec/session id."
137+
);
138+
}
65139
return (
66140
"An async command you ran earlier has completed. The command completion details are:\n\n" +
67141
eventText +
@@ -103,12 +177,11 @@ function isHeartbeatNoiseEvent(evt: string): boolean {
103177
}
104178

105179
export function isExecCompletionEvent(evt: string): boolean {
106-
const normalized = normalizeLowercaseStringOrEmpty(evt).trimStart();
180+
const trimmed = evt.trimStart();
181+
const normalized = normalizeLowercaseStringOrEmpty(trimmed);
107182
return (
108183
/^exec finished(?::|\s*\()/.test(normalized) ||
109-
/^exec (completed|failed) \([a-z0-9_-]{1,64}, (code -?\d+|signal [^)]+)\)( :: .*)?$/.test(
110-
normalized,
111-
)
184+
STRUCTURED_EXEC_COMPLETION_EVENT_RE.test(trimmed)
112185
);
113186
}
114187

src/infra/heartbeat-runner.ghost-reminder.test.ts

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -550,7 +550,7 @@ describe("Ghost reminder bug (issue #13317)", () => {
550550
expect(options?.messageThreadId).toBeUndefined();
551551
});
552552
});
553-
it("keeps exec-event delivery pinned to the original Telegram topic when session route drifts", async () => {
553+
it("keeps output-bearing exec-event delivery pinned to the original Telegram topic when session route drifts", async () => {
554554
await withTempHeartbeatSandbox(async ({ tmpDir, storePath }) => {
555555
const cfg: OpenClawConfig = {
556556
agents: {
@@ -586,7 +586,7 @@ describe("Ghost reminder bug (issue #13317)", () => {
586586
const getReplySpy = vi.fn().mockResolvedValue({
587587
text: "The review-worker spawn finished successfully.",
588588
});
589-
enqueueSystemEvent("Exec completed (review-run, code 0)", {
589+
enqueueSystemEvent("Exec completed (review-run, code 0) :: review-worker spawn finished", {
590590
sessionKey,
591591
trusted: false,
592592
deliveryContext: {
@@ -617,6 +617,72 @@ describe("Ghost reminder bug (issue #13317)", () => {
617617
});
618618
});
619619

620+
it("suppresses metadata-only successful exec completions", async () => {
621+
await withTempHeartbeatSandbox(async ({ tmpDir, storePath }) => {
622+
const cfg: OpenClawConfig = {
623+
agents: {
624+
defaults: {
625+
workspace: tmpDir,
626+
heartbeat: {
627+
every: "5m",
628+
target: "last",
629+
},
630+
},
631+
},
632+
channels: { telegram: { allowFrom: ["*"] } },
633+
session: { store: storePath },
634+
};
635+
const sessionKey = "agent:main:telegram:group:-1003774691294:topic:47";
636+
await fs.writeFile(
637+
storePath,
638+
JSON.stringify({
639+
[sessionKey]: {
640+
sessionId: "sid",
641+
updatedAt: Date.now(),
642+
lastChannel: "telegram",
643+
lastTo: "telegram:-1003774691294:topic:2175",
644+
lastThreadId: 2175,
645+
},
646+
}),
647+
);
648+
649+
const sendTelegram = vi.fn();
650+
const getReplySpy = vi.fn().mockResolvedValue({
651+
text: "HEARTBEAT_OK",
652+
});
653+
enqueueSystemEvent("Exec completed (review-run, code 0)", {
654+
sessionKey,
655+
trusted: false,
656+
deliveryContext: {
657+
channel: "telegram",
658+
to: "telegram:-1003774691294:topic:47",
659+
threadId: 47,
660+
},
661+
});
662+
663+
const result = await runHeartbeatOnce({
664+
cfg,
665+
agentId: "main",
666+
sessionKey,
667+
reason: "exec-event",
668+
deps: {
669+
getReplyFromConfig: getReplySpy,
670+
telegram: sendTelegram,
671+
},
672+
});
673+
674+
expect(result.status).toBe("ran");
675+
expect(getReplySpy).toHaveBeenCalledWith(
676+
expect.objectContaining({
677+
Body: expect.stringContaining("no command output was found"),
678+
}),
679+
expect.anything(),
680+
expect.anything(),
681+
);
682+
expect(sendTelegram).not.toHaveBeenCalled();
683+
});
684+
});
685+
620686
it("keeps Telegram topic routing for isolated scheduled heartbeats", async () => {
621687
await withTempHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => {
622688
const cfg = createLastTargetConfig({ tmpDir, storePath, isolatedSession: true });

src/infra/heartbeat-runner.ts

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -75,10 +75,11 @@ import { loadOrCreateDeviceIdentity } from "./device-identity.js";
7575
import { formatErrorMessage, hasErrnoCode } from "./errors.js";
7676
import { isWithinActiveHours } from "./heartbeat-active-hours.js";
7777
import {
78-
buildExecEventPrompt,
7978
buildCronEventPrompt,
79+
buildExecEventPrompt,
8080
isCronSystemEvent,
8181
isExecCompletionEvent,
82+
isRelayableExecCompletionEvent,
8283
} from "./heartbeat-events-filter.js";
8384
import { emitHeartbeatEvent, resolveIndicatorType } from "./heartbeat-events.js";
8485
import { resolveHeartbeatReasonKind } from "./heartbeat-reason.js";
@@ -683,6 +684,7 @@ async function resolveHeartbeatPreflight(params: {
683684
type HeartbeatPromptResolution = {
684685
prompt: string | null;
685686
hasExecCompletion: boolean;
687+
hasRelayableExecCompletion: boolean;
686688
hasCronEvents: boolean;
687689
};
688690

@@ -755,6 +757,8 @@ function resolveHeartbeatRunPrompt(params: {
755757
.map((event) => event.text)
756758
: [];
757759
const hasExecCompletion = execEvents.length > 0;
760+
const hasRelayableExecCompletion =
761+
params.canRelayToUser && execEvents.some((event) => isRelayableExecCompletionEvent(event));
758762
const hasCronEvents = cronEvents.length > 0;
759763

760764
if (params.preflight.tasks && params.preflight.tasks.length > 0) {
@@ -781,9 +785,19 @@ After completing all due tasks, reply HEARTBEAT_OK.`;
781785
prompt += `\n\nAdditional context from HEARTBEAT.md:\n${directives}`;
782786
}
783787
}
784-
return { prompt, hasExecCompletion: false, hasCronEvents: false };
788+
return {
789+
prompt,
790+
hasExecCompletion: false,
791+
hasRelayableExecCompletion: false,
792+
hasCronEvents: false,
793+
};
785794
}
786-
return { prompt: null, hasExecCompletion: false, hasCronEvents: false };
795+
return {
796+
prompt: null,
797+
hasExecCompletion: false,
798+
hasRelayableExecCompletion: false,
799+
hasCronEvents: false,
800+
};
787801
}
788802

789803
const basePrompt = hasExecCompletion
@@ -793,7 +807,7 @@ After completing all due tasks, reply HEARTBEAT_OK.`;
793807
: resolveHeartbeatPrompt(params.cfg, params.heartbeat);
794808
const prompt = appendHeartbeatWorkspacePathHint(basePrompt, params.workspaceDir);
795809

796-
return { prompt, hasExecCompletion, hasCronEvents };
810+
return { prompt, hasExecCompletion, hasRelayableExecCompletion, hasCronEvents };
797811
}
798812

799813
export async function runHeartbeatOnce(opts: {
@@ -931,15 +945,16 @@ export async function runHeartbeatOnce(opts: {
931945
delivery.channel !== "none" && delivery.to && visibility.showAlerts,
932946
);
933947
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
934-
const { prompt, hasExecCompletion, hasCronEvents } = resolveHeartbeatRunPrompt({
935-
cfg,
936-
heartbeat,
937-
preflight,
938-
canRelayToUser,
939-
workspaceDir,
940-
startedAt,
941-
heartbeatFileContent: preflight.heartbeatFileContent,
942-
});
948+
const { prompt, hasExecCompletion, hasRelayableExecCompletion, hasCronEvents } =
949+
resolveHeartbeatRunPrompt({
950+
cfg,
951+
heartbeat,
952+
preflight,
953+
canRelayToUser,
954+
workspaceDir,
955+
startedAt,
956+
heartbeatFileContent: preflight.heartbeatFileContent,
957+
});
943958

944959
// If no tasks are due, skip heartbeat entirely
945960
if (prompt === null) {
@@ -1202,14 +1217,15 @@ export async function runHeartbeatOnce(opts: {
12021217
// Also, if normalized.text is empty due to token stripping but we have exec completion,
12031218
// fall back to the original reply text.
12041219
const execFallbackText =
1205-
hasExecCompletion && !normalized.text.trim() && replyPayload.text?.trim()
1220+
hasRelayableExecCompletion && !normalized.text.trim() && replyPayload.text?.trim()
12061221
? replyPayload.text.trim()
12071222
: null;
12081223
if (execFallbackText) {
12091224
normalized.text = execFallbackText;
12101225
normalized.shouldSkip = false;
12111226
}
1212-
const shouldSkipMain = normalized.shouldSkip && !normalized.hasMedia && !hasExecCompletion;
1227+
const shouldSkipMain =
1228+
normalized.shouldSkip && !normalized.hasMedia && !hasRelayableExecCompletion;
12131229
if (shouldSkipMain && reasoningPayloads.length === 0) {
12141230
await restoreHeartbeatUpdatedAt({
12151231
storePath,

0 commit comments

Comments
 (0)