Skip to content

Commit 0cf090b

Browse files
committed
fix(heartbeat): preserve heartbeat task context
1 parent 06d6d77 commit 0cf090b

10 files changed

Lines changed: 153 additions & 40 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai
2424

2525
- Exec: reject invalid per-call `host` values instead of silently falling back to the default target, so hostname-like values fail before commands run. Fixes #74426. Thanks @scr00ge-00 and @vyctorbrzezowski.
2626
- Google/Gemini: send non-empty placeholder content when a Gemini run is triggered with empty or filtered user content, avoiding `contents is not specified` API errors. Thanks @CaoYuhaoCarl.
27+
- Heartbeat: preserve non-task `HEARTBEAT.md` context around `tasks:` blocks and apply `agents.defaults.heartbeat` to all agents unless per-agent heartbeat entries restrict scope. Thanks @Sekhar03.
2728
- Build/Gateway: route restart, shutdown, respawn, diagnostics, command-queue cleanup, and runtime cleanup through one stable gateway lifecycle runtime entry so rebuilt packages do not strand long-running gateways on stale hashed chunks. Carries forward #73964. Thanks @pashpashpash.
2829
- Memory/wiki: keep broad shared-source and generated related-link blocks from turning every page into a search hit, cap noisy backlinks, support all-term searches such as people-routing queries, and prefer readable page body snippets over generated metadata. Thanks @vincentkoc.
2930
- Cron/Gateway: abort and bounded-clean up timed-out isolated agent turns before recording the timeout, so stale cron sessions cannot leave Discord or other chat lanes stuck in `processing` after a timeout. Thanks @vincentkoc.

src/auto-reply/command-auth.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,11 @@ import {
1212
normalizeOptionalString,
1313
} from "../shared/string-coerce.js";
1414
import { normalizeStringEntries } from "../shared/string-normalization.js";
15-
import { createSubsystemLogger } from "../logging/subsystem.js";
1615
import {
1716
INTERNAL_MESSAGE_CHANNEL,
1817
isInternalMessageChannel,
1918
normalizeMessageChannel,
2019
} from "../utils/message-channel.js";
21-
22-
const logger = createSubsystemLogger("auto-reply:command-auth");
2320
import type { MsgContext } from "./templating.js";
2421

2522
export type CommandAuthorization = {
@@ -206,9 +203,8 @@ function resolveProviderAllowFrom(params: {
206203
};
207204
}
208205
if (!Array.isArray(allowFrom)) {
209-
logger.warn(
210-
`resolveAllowFrom returned an invalid allowFrom for provider "${providerId}", falling back to config allowFrom`,
211-
{ providerId, result: "invalid_result" },
206+
console.warn(
207+
`[command-auth] resolveAllowFrom returned an invalid allowFrom for provider "${providerId}", falling back to config allowFrom: invalid_result`,
212208
);
213209
return {
214210
allowFrom: resolveFallbackAllowFrom({ cfg, providerId, accountId }),
@@ -220,9 +216,8 @@ function resolveProviderAllowFrom(params: {
220216
hadResolutionError: false,
221217
};
222218
} catch (err) {
223-
logger.warn(
224-
`resolveAllowFrom threw for provider "${providerId}", falling back to config allowFrom`,
225-
{ providerId, error: describeAllowFromResolutionError(err) },
219+
console.warn(
220+
`[command-auth] resolveAllowFrom threw for provider "${providerId}", falling back to config allowFrom: ${describeAllowFromResolutionError(err)}`,
226221
);
227222
return {
228223
allowFrom: resolveFallbackAllowFrom({ cfg, providerId, accountId }),

src/config/defaults.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,6 @@ import {
66
normalizeProviderConfigForConfigDefaults,
77
} from "./provider-policy.js";
88
import { normalizeTalkConfig } from "./talk.js";
9-
import { createSubsystemLogger } from "../logging/subsystem.js";
10-
11-
const logger = createSubsystemLogger("config:defaults");
129
import type { ModelDefinitionConfig } from "./types.models.js";
1310
import type { OpenClawConfig } from "./types.openclaw.js";
1411

@@ -117,7 +114,7 @@ export function applySessionDefaults(
117114
}
118115

119116
const trimmed = session.mainKey.trim();
120-
const warn = options.warn ?? ((message: string) => logger.warn(message));
117+
const warn = options.warn ?? console.warn;
121118
const warnState = options.warnState ?? defaultWarnState;
122119

123120
const next: OpenClawConfig = {

src/index.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,6 @@ import process from "node:process";
33
import { fileURLToPath } from "node:url";
44
import { formatUncaughtError } from "./infra/errors.js";
55
import { runFatalErrorHooks } from "./infra/fatal-error-hooks.js";
6-
import { createSubsystemLogger } from "./logging/subsystem.js";
7-
8-
const logger = createSubsystemLogger("infra:runtime");
96
import { isMainModule } from "./infra/is-main.js";
107
import {
118
installUnhandledRejectionHandler,
@@ -97,21 +94,24 @@ if (isMain) {
9794
return;
9895
}
9996
if (isBenignUncaughtExceptionError(error)) {
100-
logger.warn("Non-fatal uncaught exception (continuing)", { error });
97+
console.warn(
98+
"[openclaw] Non-fatal uncaught exception (continuing):",
99+
formatUncaughtError(error),
100+
);
101101
return;
102102
}
103-
logger.error("Uncaught exception", { error });
103+
console.error("[openclaw] Uncaught exception:", formatUncaughtError(error));
104104
for (const message of runFatalErrorHooks({ reason: "uncaught_exception", error })) {
105-
logger.error(message, { reason: "uncaught_exception", error });
105+
console.error("[openclaw]", message);
106106
}
107107
restoreTerminalState("uncaught exception", { resumeStdinIfPaused: false });
108108
process.exit(1);
109109
});
110110

111111
void runLegacyCliEntry(process.argv).catch((err) => {
112-
logger.error("CLI failed", { error: err });
112+
console.error("[openclaw] CLI failed:", formatUncaughtError(err));
113113
for (const message of runFatalErrorHooks({ reason: "legacy_cli_failure", error: err })) {
114-
logger.error(message, { reason: "legacy_cli_failure", error: err });
114+
console.error("[openclaw]", message);
115115
}
116116
restoreTerminalState("legacy cli failure", { resumeStdinIfPaused: false });
117117
process.exit(1);

src/infra/dotenv.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,15 @@ import path from "node:path";
44
import dotenv from "dotenv";
55
import { createSubsystemLogger } from "../logging/subsystem.js";
66
import { resolveConfigDir } from "../utils.js";
7-
8-
const logger = createSubsystemLogger("infra:dotenv");
97
import { resolveRequiredHomeDir } from "./home-dir.js";
108
import {
119
isDangerousHostEnvOverrideVarName,
1210
isDangerousHostEnvVarName,
1311
normalizeEnvVarKey,
1412
} from "./host-env-security.js";
1513

14+
const logger = createSubsystemLogger("infra:dotenv");
15+
1616
const BLOCKED_WORKSPACE_DOTENV_KEYS = new Set([
1717
"ALL_PROXY",
1818
"ANTHROPIC_API_KEY",

src/infra/heartbeat-runner.returns-default-unset.test.ts

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -314,14 +314,24 @@ describe("isHeartbeatEnabledForAgent", () => {
314314
expect(isHeartbeatEnabledForAgent(cfg, "ops")).toBe(true);
315315
});
316316

317-
it("falls back to default agent when no explicit heartbeat entries", () => {
317+
it("uses global heartbeat defaults for all agents when no explicit heartbeat entries exist", () => {
318318
const cfg: OpenClawConfig = {
319319
agents: {
320320
defaults: { heartbeat: { every: "30m" } },
321321
list: [{ id: "main" }, { id: "ops" }],
322322
},
323323
};
324324
expect(isHeartbeatEnabledForAgent(cfg, "main")).toBe(true);
325+
expect(isHeartbeatEnabledForAgent(cfg, "ops")).toBe(true);
326+
});
327+
328+
it("falls back to default agent when no heartbeat config exists", () => {
329+
const cfg: OpenClawConfig = {
330+
agents: {
331+
list: [{ id: "main" }, { id: "ops" }],
332+
},
333+
};
334+
expect(isHeartbeatEnabledForAgent(cfg, "main")).toBe(true);
325335
expect(isHeartbeatEnabledForAgent(cfg, "ops")).toBe(false);
326336
});
327337
});
@@ -1404,6 +1414,78 @@ describe("runHeartbeatOnce", () => {
14041414
}
14051415
});
14061416

1417+
it("keeps non-task HEARTBEAT.md context while stripping blank-line-separated task blocks", async () => {
1418+
const tmpDir = await createCaseDir("openclaw-hb-tasks-context");
1419+
const storePath = path.join(tmpDir, "sessions.json");
1420+
const workspaceDir = path.join(tmpDir, "workspace");
1421+
await fs.mkdir(workspaceDir, { recursive: true });
1422+
await fs.writeFile(
1423+
path.join(workspaceDir, "HEARTBEAT.md"),
1424+
`# Keep this header
1425+
1426+
Remember escalation policy.
1427+
1428+
tasks:
1429+
- name: inbox
1430+
interval: 5m
1431+
prompt: Check urgent inbox items
1432+
1433+
- name: calendar
1434+
interval: 5m
1435+
prompt: Check calendar changes
1436+
1437+
Some global directive after tasks.
1438+
`,
1439+
"utf-8",
1440+
);
1441+
1442+
const cfg: OpenClawConfig = {
1443+
agents: {
1444+
defaults: {
1445+
workspace: workspaceDir,
1446+
heartbeat: { every: "5m", target: "whatsapp" },
1447+
},
1448+
},
1449+
channels: { whatsapp: { allowFrom: ["*"] } },
1450+
session: { store: storePath },
1451+
};
1452+
await fs.writeFile(
1453+
storePath,
1454+
JSON.stringify({
1455+
[resolveMainSessionKey(cfg)]: {
1456+
sessionId: "sid",
1457+
updatedAt: Date.now(),
1458+
lastChannel: "whatsapp",
1459+
lastTo: "[email protected]",
1460+
},
1461+
}),
1462+
);
1463+
const replySpy = vi.fn().mockResolvedValue({ text: "Handled due heartbeat tasks" });
1464+
const sendWhatsApp = vi
1465+
.fn<
1466+
(to: string, text: string, opts?: unknown) => Promise<{ messageId: string; toJid: string }>
1467+
>()
1468+
.mockResolvedValue({ messageId: "m1", toJid: "jid" });
1469+
1470+
const res = await runHeartbeatOnce({
1471+
cfg,
1472+
deps: createHeartbeatDeps(sendWhatsApp, { getReplyFromConfig: replySpy }),
1473+
});
1474+
1475+
expect(res.status).toBe("ran");
1476+
expect(replySpy).toHaveBeenCalledTimes(1);
1477+
const calledCtx = replySpy.mock.calls[0]?.[0] as { Body?: string };
1478+
expect(calledCtx.Body).toContain("- inbox: Check urgent inbox items");
1479+
expect(calledCtx.Body).toContain("- calendar: Check calendar changes");
1480+
expect(calledCtx.Body).toContain("Additional context from HEARTBEAT.md");
1481+
expect(calledCtx.Body).toContain("# Keep this header");
1482+
expect(calledCtx.Body).toContain("Remember escalation policy.");
1483+
expect(calledCtx.Body).toContain("Some global directive after tasks.");
1484+
expect(calledCtx.Body).not.toContain("name: inbox");
1485+
expect(calledCtx.Body).not.toContain("name: calendar");
1486+
replySpy.mockReset();
1487+
});
1488+
14071489
it("applies HEARTBEAT.md gating rules across file states and triggers", async () => {
14081490
const cases: Array<{
14091491
name: string;

src/infra/heartbeat-runner.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -698,6 +698,40 @@ function appendHeartbeatWorkspacePathHint(prompt: string, workspaceDir: string):
698698
return `${prompt}\n${hint}`;
699699
}
700700

701+
function stripHeartbeatTasksBlock(content: string): string {
702+
const lines = content.split(/\r?\n/);
703+
const kept: string[] = [];
704+
let inTasksBlock = false;
705+
706+
for (const line of lines) {
707+
const trimmed = line.trim();
708+
if (!inTasksBlock && trimmed === "tasks:") {
709+
inTasksBlock = true;
710+
continue;
711+
}
712+
713+
if (inTasksBlock) {
714+
if (!trimmed) {
715+
continue;
716+
}
717+
const isIndented = /^[\s]/.test(line);
718+
const isTaskListItem = trimmed.startsWith("-");
719+
const isTaskField =
720+
trimmed.startsWith("interval:") ||
721+
trimmed.startsWith("prompt:") ||
722+
trimmed.startsWith("name:");
723+
if (isIndented || isTaskListItem || isTaskField) {
724+
continue;
725+
}
726+
inTasksBlock = false;
727+
}
728+
729+
kept.push(line);
730+
}
731+
732+
return kept.join("\n");
733+
}
734+
701735
function resolveHeartbeatRunPrompt(params: {
702736
cfg: OpenClawConfig;
703737
heartbeat?: HeartbeatConfig;
@@ -742,9 +776,7 @@ ${taskList}
742776
After completing all due tasks, reply HEARTBEAT_OK.`;
743777

744778
if (params.heartbeatFileContent) {
745-
const directives = params.heartbeatFileContent
746-
.replace(/^tasks:[\s\S]*?(?=^[^\s]|\n\n|$)/m, "")
747-
.trim();
779+
const directives = stripHeartbeatTasksBlock(params.heartbeatFileContent).trim();
748780
if (directives) {
749781
prompt += `\n\nAdditional context from HEARTBEAT.md:\n${directives}`;
750782
}

src/infra/temp-download.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import crypto from "node:crypto";
22
import { mkdtemp, rm } from "node:fs/promises";
33
import path from "node:path";
4-
import { resolvePreferredOpenClawTmpDir } from "./tmp-openclaw-dir.js";
54
import { createSubsystemLogger } from "../logging/subsystem.js";
5+
import { resolvePreferredOpenClawTmpDir } from "./tmp-openclaw-dir.js";
66

77
const logger = createSubsystemLogger("infra:temp-download");
88

src/infra/unhandled-rejections.ts

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,6 @@ import {
88
readErrorName,
99
} from "./errors.js";
1010
import { runFatalErrorHooks } from "./fatal-error-hooks.js";
11-
import { createSubsystemLogger } from "../logging/subsystem.js";
12-
13-
const logger = createSubsystemLogger("infra:runtime");
1411

1512
type UnhandledRejectionHandler = (reason: unknown) => boolean;
1613
type UncaughtExceptionHandler = (error: unknown) => boolean;
@@ -368,7 +365,10 @@ export function isUnhandledRejectionHandled(reason: unknown): boolean {
368365
return true;
369366
}
370367
} catch (err) {
371-
logger.error("Unhandled rejection handler failed", { error: err });
368+
console.error(
369+
"[openclaw] Unhandled rejection handler failed:",
370+
err instanceof Error ? (err.stack ?? err.message) : err,
371+
);
372372
}
373373
}
374374
return false;
@@ -388,7 +388,10 @@ export function isUncaughtExceptionHandled(error: unknown): boolean {
388388
return true;
389389
}
390390
} catch (err) {
391-
logger.error("Uncaught exception handler failed", { error: err });
391+
console.error(
392+
"[openclaw] Uncaught exception handler failed:",
393+
err instanceof Error ? (err.stack ?? err.message) : err,
394+
);
392395
}
393396
}
394397
return false;
@@ -397,7 +400,7 @@ export function isUncaughtExceptionHandled(error: unknown): boolean {
397400
export function installUnhandledRejectionHandler(): void {
398401
const exitWithTerminalRestore = (reason: string, error?: unknown, hookReason = reason) => {
399402
for (const message of runFatalErrorHooks({ reason: hookReason, error })) {
400-
logger.error(message, { reason: hookReason, error });
403+
console.error("[openclaw]", message);
401404
}
402405
restoreTerminalState(reason, { resumeStdinIfPaused: false });
403406
process.exit(1);
@@ -411,28 +414,31 @@ export function installUnhandledRejectionHandler(): void {
411414
// AbortError is typically an intentional cancellation (e.g., during shutdown)
412415
// Log it but don't crash - these are expected during graceful shutdown
413416
if (isAbortError(reason)) {
414-
logger.warn("Suppressed AbortError", { error: reason });
417+
console.warn("[openclaw] Suppressed AbortError:", formatUncaughtError(reason));
415418
return;
416419
}
417420

418421
if (isFatalError(reason)) {
419-
logger.error("FATAL unhandled rejection", { error: reason });
422+
console.error("[openclaw] FATAL unhandled rejection:", formatUncaughtError(reason));
420423
exitWithTerminalRestore("fatal unhandled rejection", reason, "fatal_unhandled_rejection");
421424
return;
422425
}
423426

424427
if (isConfigError(reason)) {
425-
logger.error("CONFIGURATION ERROR - requires fix", { error: reason });
428+
console.error("[openclaw] CONFIGURATION ERROR - requires fix:", formatUncaughtError(reason));
426429
exitWithTerminalRestore("configuration error", reason, "configuration_error");
427430
return;
428431
}
429432

430433
if (isTransientUnhandledRejectionError(reason)) {
431-
logger.warn("Non-fatal unhandled rejection (continuing)", { error: reason });
434+
console.warn(
435+
"[openclaw] Non-fatal unhandled rejection (continuing):",
436+
formatUncaughtError(reason),
437+
);
432438
return;
433439
}
434440

435-
logger.error("Unhandled promise rejection", { error: reason });
441+
console.error("[openclaw] Unhandled promise rejection:", formatUncaughtError(reason));
436442
exitWithTerminalRestore("unhandled rejection", reason, "unhandled_rejection");
437443
});
438444
}

src/security/windows-acl.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import os from "node:os";
22
import path from "node:path";
3-
import { runExec } from "../process/exec.js";
43
import { createSubsystemLogger } from "../logging/subsystem.js";
4+
import { runExec } from "../process/exec.js";
55
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
66

77
const log = createSubsystemLogger("security/windows-acl");

0 commit comments

Comments
 (0)