Skip to content

Commit 3215ab6

Browse files
Sekhar03steipete
andauthored
infra: fix heartbeat directive preservation and global enablement (#74471)
* refactor(security): replace console.warn with structured logger in windows-acl * infra: fix heartbeat directive preservation and global enablement * logging: migrate dotenv and temp-download to subsystem logger * logging: migrate command-auth, unhandled-rejections, and index to subsystem logger * logging: migrate config defaults to subsystem logger * fix(heartbeat): preserve heartbeat task context --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
1 parent 9f21335 commit 3215ab6

7 files changed

Lines changed: 138 additions & 13 deletions

File tree

CHANGELOG.md

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

2626
- 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.
2727
- 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.
28+
- 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.
2829
- 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.
2930
- 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.
3031
- 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/infra/dotenv.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import fs from "node:fs";
22
import os from "node:os";
33
import path from "node:path";
44
import dotenv from "dotenv";
5+
import { createSubsystemLogger } from "../logging/subsystem.js";
56
import { resolveConfigDir } from "../utils.js";
67
import { resolveRequiredHomeDir } from "./home-dir.js";
78
import {
@@ -10,6 +11,8 @@ import {
1011
normalizeEnvVarKey,
1112
} from "./host-env-security.js";
1213

14+
const logger = createSubsystemLogger("infra:dotenv");
15+
1316
const BLOCKED_WORKSPACE_DOTENV_KEYS = new Set([
1417
"ALL_PROXY",
1518
"ANTHROPIC_API_KEY",
@@ -138,7 +141,7 @@ function readDotEnvFile(params: {
138141
const code =
139142
error && typeof error === "object" && "code" in error ? String(error.code) : undefined;
140143
if (code !== "ENOENT") {
141-
console.warn(`[dotenv] Failed to read ${params.filePath}: ${String(error)}`);
144+
logger.warn(`Failed to read ${params.filePath}: ${String(error)}`, { error });
142145
}
143146
}
144147
return null;
@@ -149,7 +152,7 @@ function readDotEnvFile(params: {
149152
parsed = dotenv.parse(content);
150153
} catch (error) {
151154
if (!params.quiet) {
152-
console.warn(`[dotenv] Failed to parse ${params.filePath}: ${String(error)}`);
155+
logger.warn(`Failed to parse ${params.filePath}: ${String(error)}`, { error });
153156
}
154157
return null;
155158
}
@@ -237,8 +240,9 @@ function loadParsedDotEnvFiles(files: LoadedDotEnvFile[]) {
237240
if (keys.length === 0) {
238241
continue;
239242
}
240-
console.warn(
241-
`[dotenv] Conflicting values in ${conflict.keptPath} and ${conflict.ignoredPath} for ${keys.join(", ")}; keeping ${conflict.keptPath}.`,
243+
logger.warn(
244+
`Conflicting values in ${conflict.keptPath} and ${conflict.ignoredPath} for ${keys.join(", ")}; keeping ${conflict.keptPath}.`,
245+
{ keptPath: conflict.keptPath, ignoredPath: conflict.ignoredPath, keys },
242246
);
243247
}
244248
}

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: "120363401234567890@g.us",
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(/^[\s\S]*?^tasks:[\s\S]*?(?=^[^\s]|^$)/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/heartbeat-summary.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ export function isHeartbeatEnabledForAgent(cfg: OpenClawConfig, agentId?: string
3838
(entry) => Boolean(entry?.heartbeat) && normalizeAgentId(entry?.id) === resolvedAgentId,
3939
);
4040
}
41+
if (cfg.agents?.defaults?.heartbeat) {
42+
return true;
43+
}
4144
return resolvedAgentId === resolveDefaultAgentId(cfg);
4245
}
4346

src/infra/temp-download.ts

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

7+
const logger = createSubsystemLogger("infra:temp-download");
8+
69
export { resolvePreferredOpenClawTmpDir } from "./tmp-openclaw-dir.js";
710

811
export type TempDownloadTarget = {
@@ -50,7 +53,7 @@ async function cleanupTempDir(dir: string) {
5053
await rm(dir, { recursive: true, force: true });
5154
} catch (err) {
5255
if (!isNodeErrorWithCode(err, "ENOENT")) {
53-
console.warn(`temp-path cleanup failed for ${dir}: ${String(err)}`);
56+
logger.warn(`temp-path cleanup failed for ${dir}: ${String(err)}`, { dir, error: err });
5457
}
5558
}
5659
}

src/security/windows-acl.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import os from "node:os";
22
import path from "node:path";
3+
import { createSubsystemLogger } from "../logging/subsystem.js";
34
import { runExec } from "../process/exec.js";
45
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
56

7+
const log = createSubsystemLogger("security/windows-acl");
8+
69
export type ExecFn = typeof runExec;
710

811
export type WindowsAclEntry = {
@@ -308,10 +311,7 @@ async function resolveCurrentUserSid(
308311
} catch (err) {
309312
// Log but do not propagate — SID resolution is best-effort.
310313
// Callers fall back to env-based resolution when this returns null.
311-
console.warn("[windows-acl] resolveCurrentUserSid failed:", String(err));
312-
// TODO: replace with a structured logger call once a lightweight per-module
313-
// logger is available; console.warn can be noisy on constrained Windows hosts
314-
// (e.g. strict output-capture environments or CI runners with limited stdio).
314+
log.warn("resolveCurrentUserSid failed", { error: String(err) });
315315
return null;
316316
}
317317
}

0 commit comments

Comments
 (0)