Skip to content

Commit 39a9a34

Browse files
authored
Fix heartbeat runner failure copy (#82848)
* fix: scope heartbeat runner failures * chore: add heartbeat failure changelog
1 parent 45d9a09 commit 39a9a34

6 files changed

Lines changed: 131 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ Docs: https://docs.openclaw.ai
118118
- ACP/control plane: refresh cached runtime handles when agent config changes so ACP sessions stop using stale runtimes after `agents.defaults` edits. Fixes #82237. Thanks @giodl73-repo.
119119
- Gateway/sessions: scope session data lookups by agent id so multi-agent gateway state cannot cross-leak session records across configured agents. (#81386) Thanks @pgondhi987.
120120
- Gateway/restart: mark active main sessions as restart-aborted before forced restarts so startup recovery can resume interrupted turns instead of leaving them stranded as running. Fixes #82433. (#82772) Thanks @joshavant.
121+
- Gateway/heartbeat: report heartbeat runner failures with background-specific copy instead of foreground `/new` recovery guidance. Fixes #82708. (#82848) Thanks @joshavant.
121122
- Agents/media: require generated music/video completion agents to use the message tool for visible delivery and stop merging generated image attachments into message-tool-only source reply mirrors, avoiding direct fallback posts that can duplicate media the model already sent.
122123
- Agents/media: accept generated media attachments on internal completion events and report delivery-loss failures as errors, so completed background music/video tasks do not disappear after provider success.
123124
- Matrix/approvals: release in-flight reaction bindings when the channel approval handler stops mid-delivery, preventing stale approval targets after restart. Fixes #82485. (#82482) Thanks @Feelw00.

src/auto-reply/reply/agent-runner-execution.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
MAX_LIVE_SWITCH_RETRIES,
1313
resolveRunAfterAutoFallbackPrimaryProbeRecheck,
1414
} from "./agent-runner-execution.js";
15+
import { HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT } from "./agent-runner-failure-copy.js";
1516
import type { FollowupRun } from "./queue.js";
1617
import type { ReplyOperation } from "./reply-run-registry.js";
1718
import type { TypingSignaler } from "./typing-mode.js";
@@ -3744,6 +3745,26 @@ describe("runAgentTurnWithFallback", () => {
37443745
}
37453746
});
37463747

3748+
it("uses heartbeat failure copy for raw external errors during heartbeat runs", async () => {
3749+
state.runEmbeddedPiAgentMock.mockRejectedValueOnce(
3750+
new Error('Command lane "main" task timed out after 120000ms'),
3751+
);
3752+
3753+
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
3754+
const result = await runAgentTurnWithFallback({
3755+
...createMinimalRunAgentTurnParams(),
3756+
isHeartbeat: true,
3757+
});
3758+
3759+
expect(result.kind).toBe("final");
3760+
if (result.kind !== "final") {
3761+
throw new Error("expected final reply");
3762+
}
3763+
expect(result.payload.text).toBe(HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT);
3764+
expect(result.payload.text).not.toBe(GENERIC_RUN_FAILURE_TEXT);
3765+
expect(result.payload.text).not.toContain("/new");
3766+
});
3767+
37473768
it.each([
37483769
{
37493770
rejection: new Error("CLI exceeded timeout (300s) and was terminated."),

src/auto-reply/reply/agent-runner-execution.ts

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,10 @@ import {
8787
} from "../tokens.js";
8888
import type { GetReplyOptions, ReplyPayload } from "../types.js";
8989
import { resolveRunAuthProfile } from "./agent-runner-auth-profile.js";
90+
import {
91+
GENERIC_EXTERNAL_RUN_FAILURE_TEXT,
92+
HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT,
93+
} from "./agent-runner-failure-copy.js";
9094
import {
9195
buildEmbeddedRunExecutionParams,
9296
resolveQueuedReplyRuntimeConfig,
@@ -480,8 +484,6 @@ function collapseRepeatedFailureDetail(message: string): string {
480484
const SAFE_MISSING_API_KEY_PROVIDERS = new Set(["anthropic", "google", "openai", "openai-codex"]);
481485
const EXTERNAL_RUN_FAILURE_DETAIL_MAX_CHARS = 900;
482486
const AGENT_FAILED_BEFORE_REPLY_TEXT = "Agent failed before reply:";
483-
const GENERIC_EXTERNAL_RUN_FAILURE_TEXT =
484-
"⚠️ Something went wrong while processing your request. Please try again, or use /new to start a fresh session.";
485487

486488
type ExternalRunFailureReply = {
487489
text: string;
@@ -581,15 +583,9 @@ function formatForwardedExternalRunFailureText(message: string): string {
581583

582584
function buildExternalRunFailureReply(
583585
message: string,
584-
options?: { includeDetails?: boolean },
586+
options?: { includeDetails?: boolean; isHeartbeat?: boolean },
585587
): ExternalRunFailureReply {
586588
const normalizedMessage = collapseRepeatedFailureDetail(message);
587-
if (isToolResultTurnMismatchError(normalizedMessage)) {
588-
return {
589-
text: "⚠️ Session history got out of sync. Please try again, or use /new to start a fresh session.",
590-
isGenericRunnerFailure: false,
591-
};
592-
}
593589
const missingApiKeyFailure = buildMissingApiKeyFailureText(normalizedMessage);
594590
if (missingApiKeyFailure) {
595591
return { text: missingApiKeyFailure, isGenericRunnerFailure: false };
@@ -608,6 +604,15 @@ function buildExternalRunFailureReply(
608604
isGenericRunnerFailure: false,
609605
};
610606
}
607+
if (options?.isHeartbeat) {
608+
return { text: HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT, isGenericRunnerFailure: false };
609+
}
610+
if (isToolResultTurnMismatchError(normalizedMessage)) {
611+
return {
612+
text: "⚠️ Session history got out of sync. Please try again, or use /new to start a fresh session.",
613+
isGenericRunnerFailure: false,
614+
};
615+
}
611616
const cliBackendTimeoutFailure = buildCliBackendTimeoutFailureText(normalizedMessage);
612617
if (cliBackendTimeoutFailure) {
613618
return { text: cliBackendTimeoutFailure, isGenericRunnerFailure: false };
@@ -2590,8 +2595,12 @@ export async function runAgentTurnWithFallback(params: {
25902595
!shouldSurfaceToControlUi
25912596
? buildExternalRunFailureReply(message, {
25922597
includeDetails: isVerboseFailureDetailEnabled(params.resolvedVerboseLevel),
2598+
isHeartbeat: params.isHeartbeat,
25932599
})
25942600
: undefined;
2601+
const genericFallbackText = params.isHeartbeat
2602+
? HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT
2603+
: GENERIC_EXTERNAL_RUN_FAILURE_TEXT;
25952604
const fallbackText = isBilling
25962605
? BILLING_ERROR_USER_MESSAGE
25972606
: isRateLimit && !isOverloadedErrorMessage(message)
@@ -2604,7 +2613,7 @@ export async function runAgentTurnWithFallback(params: {
26042613
? "⚠️ Message ordering conflict - please try again. If this persists, use /new to start a fresh session."
26052614
: shouldSurfaceToControlUi
26062615
? `⚠️ Agent failed before reply: ${trimmedMessage}.\nLogs: openclaw logs --follow`
2607-
: (externalRunFailureReply?.text ?? GENERIC_EXTERNAL_RUN_FAILURE_TEXT);
2616+
: (externalRunFailureReply?.text ?? genericFallbackText);
26082617
const userVisibleFallbackText = resolveExternalRunFailureTextForConversation({
26092618
text: fallbackText,
26102619
sessionCtx: params.sessionCtx,
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
export const GENERIC_EXTERNAL_RUN_FAILURE_TEXT =
2+
"⚠️ Something went wrong while processing your request. Please try again, or use /new to start a fresh session.";
3+
4+
export const HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT =
5+
"⚠️ Heartbeat check failed before it could produce an update. The main chat session remains available.";
6+
7+
export function isGenericExternalRunFailureText(text: string | undefined): boolean {
8+
return text?.trim() === GENERIC_EXTERNAL_RUN_FAILURE_TEXT;
9+
}
10+
11+
export function replaceGenericExternalRunFailureText(text: string): {
12+
text: string;
13+
replaced: boolean;
14+
} {
15+
if (isGenericExternalRunFailureText(text)) {
16+
return { text: HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT, replaced: true };
17+
}
18+
19+
const genericStart = text.indexOf(GENERIC_EXTERNAL_RUN_FAILURE_TEXT);
20+
if (genericStart < 0) {
21+
return { text, replaced: false };
22+
}
23+
24+
const trailing = text.slice(genericStart + GENERIC_EXTERNAL_RUN_FAILURE_TEXT.length).trim();
25+
if (trailing) {
26+
return { text, replaced: false };
27+
}
28+
29+
const prefix = text.slice(0, genericStart).trimEnd();
30+
return {
31+
text: prefix
32+
? `${prefix} ${HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT}`
33+
: HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT,
34+
replaced: true,
35+
};
36+
}

src/infra/heartbeat-runner.tool-response.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,13 @@ import {
55
createHeartbeatToolResponsePayload,
66
type HeartbeatToolResponse,
77
} from "../auto-reply/heartbeat-tool-response.js";
8+
import {
9+
GENERIC_EXTERNAL_RUN_FAILURE_TEXT,
10+
HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT,
11+
} from "../auto-reply/reply/agent-runner-failure-copy.js";
812
import { markReplyPayloadForSourceSuppressionDelivery } from "../auto-reply/types.js";
913
import type { OpenClawConfig } from "../config/config.js";
14+
import { getLastHeartbeatEvent, resetHeartbeatEventsForTest } from "./heartbeat-events.js";
1015
import { runHeartbeatOnce, type HeartbeatDeps } from "./heartbeat-runner.js";
1116
import { installHeartbeatRunnerTestRuntime } from "./heartbeat-runner.test-harness.js";
1217
import {
@@ -21,6 +26,7 @@ describe("runHeartbeatOnce heartbeat response tool", () => {
2126

2227
afterEach(() => {
2328
vi.unstubAllEnvs();
29+
resetHeartbeatEventsForTest();
2430
});
2531

2632
function createConfig(params: {
@@ -264,6 +270,41 @@ describe("runHeartbeatOnce heartbeat response tool", () => {
264270
});
265271
});
266272

273+
it("rewrites foreground generic runner failure payloads before heartbeat delivery", async () => {
274+
await withTempTelegramHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => {
275+
const cfg = createConfig({ tmpDir, storePath });
276+
await seedMainSessionStore(storePath, cfg, {
277+
lastChannel: "telegram",
278+
lastProvider: "telegram",
279+
lastTo: TELEGRAM_GROUP,
280+
});
281+
replySpy.mockResolvedValue(
282+
markReplyPayloadForSourceSuppressionDelivery({
283+
text: GENERIC_EXTERNAL_RUN_FAILURE_TEXT,
284+
}),
285+
);
286+
const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1" });
287+
288+
const result = await runHeartbeatOnce({
289+
cfg,
290+
deps: createDeps({ sendTelegram, getReplyFromConfig: replySpy }),
291+
});
292+
293+
expect(result.status).toBe("ran");
294+
expectTelegramSend(sendTelegram, {
295+
text: HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT,
296+
cfg,
297+
});
298+
expect(HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT).not.toContain("/new");
299+
expect(getLastHeartbeatEvent()).toMatchObject({
300+
status: "failed",
301+
reason: "agent-runner-failure",
302+
preview: HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT,
303+
channel: "telegram",
304+
});
305+
});
306+
});
307+
267308
it("uses the heartbeat response tool prompt for auto-selected Codex model sessions", async () => {
268309
const result = await runPromptScenario({
269310
config: {

src/infra/heartbeat-runner.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
stripHeartbeatToken,
3434
type HeartbeatTask,
3535
} from "../auto-reply/heartbeat.js";
36+
import { replaceGenericExternalRunFailureText } from "../auto-reply/reply/agent-runner-failure-copy.js";
3637
import { resolveDefaultModel } from "../auto-reply/reply/directive-handling.defaults.js";
3738
import { replyRunRegistry } from "../auto-reply/reply/reply-run-registry.js";
3839
import { resolveResponsePrefixTemplate } from "../auto-reply/reply/response-prefix-template.js";
@@ -1822,6 +1823,14 @@ export async function runHeartbeatOnce(opts: {
18221823
normalized.text = execFallbackText;
18231824
normalized.shouldSkip = false;
18241825
}
1826+
const replacement = !heartbeatToolResponse
1827+
? replaceGenericExternalRunFailureText(normalized.text)
1828+
: { text: normalized.text, replaced: false };
1829+
const deliveredAgentRunFailure = replacement.replaced;
1830+
if (deliveredAgentRunFailure) {
1831+
normalized.text = replacement.text;
1832+
normalized.shouldSkip = false;
1833+
}
18251834
const shouldSkipMain =
18261835
normalized.shouldSkip && !normalized.hasMedia && !hasRelayableExecCompletion;
18271836
if (shouldSkipMain && reasoningPayloads.length === 0) {
@@ -2012,15 +2021,17 @@ export async function runHeartbeatOnce(opts: {
20122021
});
20132022
}
20142023

2024+
const sentStatus = deliveredAgentRunFailure ? "failed" : "sent";
20152025
emitHeartbeatEvent({
2016-
status: "sent",
2026+
status: sentStatus,
20172027
to: delivery.to,
2028+
...(deliveredAgentRunFailure ? { reason: "agent-runner-failure" } : {}),
20182029
preview: previewText?.slice(0, 200),
20192030
durationMs: Date.now() - startedAt,
20202031
hasMedia: mediaUrls.length > 0,
20212032
channel: delivery.channel,
20222033
accountId: delivery.accountId,
2023-
indicatorType: visibility.useIndicator ? resolveIndicatorType("sent") : undefined,
2034+
indicatorType: visibility.useIndicator ? resolveIndicatorType(sentStatus) : undefined,
20242035
});
20252036
await updateTaskTimestamps();
20262037
consumeInspectedSystemEvents();

0 commit comments

Comments
 (0)