Skip to content

Commit 31169ff

Browse files
authored
fix: bound default heartbeat run timeout (#88133)
Fixes #87438. Bound unset heartbeat run timeouts so background heartbeat turns no longer inherit the built-in 48-hour interactive agent default. Timeout precedence is explicit heartbeat timeout, explicit global agent timeout, then heartbeat cadence capped at 600 seconds. Verification: - git diff --check - Testbox tbx_01kstna69zvznn4fq7zrqr04a1: corepack pnpm test src/infra/heartbeat-runner.model-override.test.ts -- --reporter=verbose passed 13 tests - Direct node --import tsx runtime probe verified 300s, 600s, 60s, and 45s timeout precedence cases - Autoreview clean Known CI state: - PR CI run 26661465248 has failures matching latest main CI run 26661386468 at a7820b2; failures are outside this six-file heartbeat/docs diff.
1 parent 7f09d6a commit 31169ff

6 files changed

Lines changed: 59 additions & 7 deletions

File tree

docs/gateway/config-agents.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -617,7 +617,7 @@ Periodic heartbeat runs.
617617
- `every`: duration string (ms/s/m/h). Default: `30m` (API-key auth) or `1h` (OAuth auth). Set to `0m` to disable.
618618
- `includeSystemPromptSection`: when false, omits the Heartbeat section from the system prompt and skips `HEARTBEAT.md` injection into bootstrap context. Default: `true`.
619619
- `suppressToolErrorWarnings`: when true, suppresses tool error warning payloads during heartbeat runs.
620-
- `timeoutSeconds`: maximum time in seconds allowed for a heartbeat agent turn before it is aborted. Leave unset to use `agents.defaults.timeoutSeconds`.
620+
- `timeoutSeconds`: maximum time in seconds allowed for a heartbeat agent turn before it is aborted. Leave unset to use `agents.defaults.timeoutSeconds` when set, otherwise the heartbeat cadence capped at 600 seconds.
621621
- `directPolicy`: direct/DM delivery policy. `allow` (default) permits direct-target delivery. `block` suppresses direct-target delivery and emits `reason=dm-blocked`.
622622
- `lightContext`: when true, heartbeat runs use lightweight bootstrap context and keep only `HEARTBEAT.md` from workspace bootstrap files.
623623
- `isolatedSession`: when true, each heartbeat runs in a fresh session with no prior conversation history. Same isolation pattern as cron `sessionTarget: "isolated"`. Reduces per-heartbeat token cost from ~100K to ~2-5K tokens.

docs/gateway/heartbeat.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ Example config:
6363

6464
- Interval: `30m` (or `1h` when Anthropic OAuth/token auth is the detected auth mode, including Claude CLI reuse). Set `agents.defaults.heartbeat.every` or per-agent `agents.list[].heartbeat.every`; use `0m` to disable.
6565
- Prompt body (configurable via `agents.defaults.heartbeat.prompt`): `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
66+
- Timeout: unset heartbeat turns use `agents.defaults.timeoutSeconds` when set. Otherwise, they use the heartbeat cadence capped at 600 seconds. Set `agents.defaults.heartbeat.timeoutSeconds` or per-agent `agents.list[].heartbeat.timeoutSeconds` for longer heartbeat work.
6667
- The heartbeat prompt is sent **verbatim** as the user message. The system prompt includes a "Heartbeat" section only when heartbeats are enabled for the default agent, and the run is flagged internally.
6768
- When heartbeats are disabled with `0m`, normal runs also omit `HEARTBEAT.md` from bootstrap context so the model does not see heartbeat-only instructions.
6869
- Active hours (`heartbeat.activeHours`) are checked in the configured timezone. Outside the window, heartbeats are skipped until the next tick inside the window.
@@ -274,6 +275,10 @@ Use `accountId` to target a specific account on multi-account channels like Tele
274275
<ParamField path="suppressToolErrorWarnings" type="boolean">
275276
When true, suppresses tool error warning payloads during heartbeat runs.
276277

278+
</ParamField>
279+
<ParamField path="timeoutSeconds" type="number" default="global timeout or min(every, 600)">
280+
Maximum seconds allowed for a heartbeat agent turn before it is aborted. Leave unset to use `agents.defaults.timeoutSeconds` when set, otherwise the heartbeat cadence capped at 600 seconds.
281+
277282
</ParamField>
278283
<ParamField path="activeHours" type="object">
279284
Restricts heartbeat runs to a time window. Object with `start` (HH:MM, inclusive; use `00:00` for start-of-day), `end` (HH:MM exclusive; `24:00` allowed for end-of-day), and optional `timezone`.

src/config/schema.help.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -306,9 +306,9 @@ export const FIELD_HELP: Record<string, string> = {
306306
"agents.list[].heartbeat.suppressToolErrorWarnings":
307307
"Suppress tool error warning payloads during heartbeat runs.",
308308
"agents.defaults.heartbeat.timeoutSeconds":
309-
"Maximum time in seconds allowed for a heartbeat agent turn before it is aborted. Leave unset to use agents.defaults.timeoutSeconds.",
309+
"Maximum time in seconds allowed for a heartbeat agent turn before it is aborted. Leave unset to use agents.defaults.timeoutSeconds when set, otherwise the heartbeat cadence capped at 600 seconds.",
310310
"agents.list[].heartbeat.timeoutSeconds":
311-
"Per-agent maximum time in seconds allowed for a heartbeat agent turn before it is aborted. Leave unset to inherit the merged heartbeat/default agent timeout.",
311+
"Per-agent maximum time in seconds allowed for a heartbeat agent turn before it is aborted. Leave unset to inherit the merged heartbeat timeout, then agents.defaults.timeoutSeconds when set, otherwise the heartbeat cadence capped at 600 seconds.",
312312
"agents.defaults.heartbeat.skipWhenBusy":
313313
"When true, defer heartbeat turns on this agent's extra busy lanes: its own session-keyed subagent or nested command work. Cron lanes always defer heartbeat turns.",
314314
"agents.list[].heartbeat.skipWhenBusy":

src/config/types.agent-defaults.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -407,7 +407,7 @@ export type AgentDefaultsConfig = {
407407
ackMaxChars?: number;
408408
/** Suppress tool error warning payloads during heartbeat runs. */
409409
suppressToolErrorWarnings?: boolean;
410-
/** Run timeout in seconds for heartbeat agent turns. */
410+
/** Run timeout in seconds for heartbeat agent turns. Unset uses global timeout or heartbeat cadence capped at 600 seconds. */
411411
timeoutSeconds?: number;
412412
/**
413413
* If true, run heartbeat turns with lightweight bootstrap context.

src/infra/heartbeat-runner.model-override.test.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,8 @@ describe("runHeartbeatOnce – heartbeat model override", () => {
9696
}
9797

9898
async function runDefaultsHeartbeat(params: {
99+
every?: string;
100+
defaultTimeoutSeconds?: number;
99101
model?: string;
100102
suppressToolErrorWarnings?: boolean;
101103
timeoutSeconds?: number;
@@ -107,8 +109,9 @@ describe("runHeartbeatOnce – heartbeat model override", () => {
107109
agents: {
108110
defaults: {
109111
workspace: tmpDir,
112+
timeoutSeconds: params.defaultTimeoutSeconds,
110113
heartbeat: {
111-
every: "5m",
114+
every: params.every ?? "5m",
112115
target: "whatsapp",
113116
model: params.model,
114117
suppressToolErrorWarnings: params.suppressToolErrorWarnings,
@@ -209,6 +212,30 @@ describe("runHeartbeatOnce – heartbeat model override", () => {
209212
});
210213
});
211214

215+
it("uses heartbeat cadence as the default reply-run timeout override", async () => {
216+
const replyOpts = await runDefaultsHeartbeat({});
217+
expectReplyOptions(replyOpts, {
218+
isHeartbeat: true,
219+
timeoutOverrideSeconds: 300,
220+
});
221+
});
222+
223+
it("caps the default heartbeat reply-run timeout override", async () => {
224+
const replyOpts = await runDefaultsHeartbeat({ every: "30m" });
225+
expectReplyOptions(replyOpts, {
226+
isHeartbeat: true,
227+
timeoutOverrideSeconds: 600,
228+
});
229+
});
230+
231+
it("preserves explicit default agent timeout for heartbeat runs", async () => {
232+
const replyOpts = await runDefaultsHeartbeat({ defaultTimeoutSeconds: 60, every: "30m" });
233+
expectReplyOptions(replyOpts, {
234+
isHeartbeat: true,
235+
timeoutOverrideSeconds: 60,
236+
});
237+
});
238+
212239
it("passes bootstrapContextMode when heartbeat lightContext is enabled", async () => {
213240
const replyOpts = await runDefaultsHeartbeat({ lightContext: true });
214241
expectReplyOptions(replyOpts, {

src/infra/heartbeat-runner.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ function loadHeartbeatRunnerRuntime() {
166166
}
167167

168168
const HEARTBEAT_ALWAYS_BUSY_LANES = [CommandLane.Cron, CommandLane.CronNested] as const;
169+
const DEFAULT_HEARTBEAT_TIMEOUT_SECONDS = 10 * 60;
169170

170171
function hasQueuedWorkInLanes(
171172
lanes: readonly string[],
@@ -244,6 +245,26 @@ function resolveHeartbeatChannelPlugin(channel: string): ChannelPlugin | undefin
244245
return activePlugin ?? getChannelPlugin(channel as ChannelId);
245246
}
246247

248+
function resolveHeartbeatTimeoutOverrideSeconds(cfg: OpenClawConfig, heartbeat?: HeartbeatConfig) {
249+
if (typeof heartbeat?.timeoutSeconds === "number") {
250+
return heartbeat.timeoutSeconds;
251+
}
252+
const agentDefaultTimeoutSeconds = cfg.agents?.defaults?.timeoutSeconds;
253+
if (
254+
typeof agentDefaultTimeoutSeconds === "number" &&
255+
Number.isFinite(agentDefaultTimeoutSeconds)
256+
) {
257+
return Math.max(1, Math.floor(agentDefaultTimeoutSeconds));
258+
}
259+
// The wake dispatcher awaits heartbeat turns serially. Keep unset heartbeat
260+
// timeouts tied to the cadence instead of the 48h built-in agent default.
261+
const intervalMs = resolveHeartbeatIntervalMs(cfg, undefined, heartbeat);
262+
if (!intervalMs) {
263+
return DEFAULT_HEARTBEAT_TIMEOUT_SECONDS;
264+
}
265+
return Math.max(1, Math.min(DEFAULT_HEARTBEAT_TIMEOUT_SECONDS, Math.ceil(intervalMs / 1000)));
266+
}
267+
247268
export { areHeartbeatsEnabled, setHeartbeatsEnabled };
248269
export {
249270
isHeartbeatEnabledForAgent,
@@ -1752,8 +1773,7 @@ export async function runHeartbeatOnce(opts: {
17521773
await heartbeatTyping?.onReplyStart();
17531774
const heartbeatModelOverride = normalizeOptionalString(heartbeat?.model);
17541775
const suppressToolErrorWarnings = heartbeat?.suppressToolErrorWarnings === true;
1755-
const timeoutOverrideSeconds =
1756-
typeof heartbeat?.timeoutSeconds === "number" ? heartbeat.timeoutSeconds : undefined;
1776+
const timeoutOverrideSeconds = resolveHeartbeatTimeoutOverrideSeconds(cfg, heartbeat);
17571777
const bootstrapContextMode: "lightweight" | undefined =
17581778
heartbeat?.lightContext === true ? "lightweight" : undefined;
17591779
const replyOpts = {

0 commit comments

Comments
 (0)