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>
This commit is contained in:
Sekhar Parida
2026-04-29 22:19:41 +05:30
committed by GitHub
parent 9f21335462
commit 3215ab6de5
7 changed files with 138 additions and 13 deletions

View File

@@ -25,6 +25,7 @@ Docs: https://docs.openclaw.ai
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.

View File

@@ -2,6 +2,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import dotenv from "dotenv";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveConfigDir } from "../utils.js";
import { resolveRequiredHomeDir } from "./home-dir.js";
import {
@@ -10,6 +11,8 @@ import {
normalizeEnvVarKey,
} from "./host-env-security.js";
const logger = createSubsystemLogger("infra:dotenv");
const BLOCKED_WORKSPACE_DOTENV_KEYS = new Set([
"ALL_PROXY",
"ANTHROPIC_API_KEY",
@@ -138,7 +141,7 @@ function readDotEnvFile(params: {
const code =
error && typeof error === "object" && "code" in error ? String(error.code) : undefined;
if (code !== "ENOENT") {
console.warn(`[dotenv] Failed to read ${params.filePath}: ${String(error)}`);
logger.warn(`Failed to read ${params.filePath}: ${String(error)}`, { error });
}
}
return null;
@@ -149,7 +152,7 @@ function readDotEnvFile(params: {
parsed = dotenv.parse(content);
} catch (error) {
if (!params.quiet) {
console.warn(`[dotenv] Failed to parse ${params.filePath}: ${String(error)}`);
logger.warn(`Failed to parse ${params.filePath}: ${String(error)}`, { error });
}
return null;
}
@@ -237,8 +240,9 @@ function loadParsedDotEnvFiles(files: LoadedDotEnvFile[]) {
if (keys.length === 0) {
continue;
}
console.warn(
`[dotenv] Conflicting values in ${conflict.keptPath} and ${conflict.ignoredPath} for ${keys.join(", ")}; keeping ${conflict.keptPath}.`,
logger.warn(
`Conflicting values in ${conflict.keptPath} and ${conflict.ignoredPath} for ${keys.join(", ")}; keeping ${conflict.keptPath}.`,
{ keptPath: conflict.keptPath, ignoredPath: conflict.ignoredPath, keys },
);
}
}

View File

@@ -314,7 +314,7 @@ describe("isHeartbeatEnabledForAgent", () => {
expect(isHeartbeatEnabledForAgent(cfg, "ops")).toBe(true);
});
it("falls back to default agent when no explicit heartbeat entries", () => {
it("uses global heartbeat defaults for all agents when no explicit heartbeat entries exist", () => {
const cfg: OpenClawConfig = {
agents: {
defaults: { heartbeat: { every: "30m" } },
@@ -322,6 +322,16 @@ describe("isHeartbeatEnabledForAgent", () => {
},
};
expect(isHeartbeatEnabledForAgent(cfg, "main")).toBe(true);
expect(isHeartbeatEnabledForAgent(cfg, "ops")).toBe(true);
});
it("falls back to default agent when no heartbeat config exists", () => {
const cfg: OpenClawConfig = {
agents: {
list: [{ id: "main" }, { id: "ops" }],
},
};
expect(isHeartbeatEnabledForAgent(cfg, "main")).toBe(true);
expect(isHeartbeatEnabledForAgent(cfg, "ops")).toBe(false);
});
});
@@ -1404,6 +1414,78 @@ describe("runHeartbeatOnce", () => {
}
});
it("keeps non-task HEARTBEAT.md context while stripping blank-line-separated task blocks", async () => {
const tmpDir = await createCaseDir("openclaw-hb-tasks-context");
const storePath = path.join(tmpDir, "sessions.json");
const workspaceDir = path.join(tmpDir, "workspace");
await fs.mkdir(workspaceDir, { recursive: true });
await fs.writeFile(
path.join(workspaceDir, "HEARTBEAT.md"),
`# Keep this header
Remember escalation policy.
tasks:
- name: inbox
interval: 5m
prompt: Check urgent inbox items
- name: calendar
interval: 5m
prompt: Check calendar changes
Some global directive after tasks.
`,
"utf-8",
);
const cfg: OpenClawConfig = {
agents: {
defaults: {
workspace: workspaceDir,
heartbeat: { every: "5m", target: "whatsapp" },
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
};
await fs.writeFile(
storePath,
JSON.stringify({
[resolveMainSessionKey(cfg)]: {
sessionId: "sid",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "120363401234567890@g.us",
},
}),
);
const replySpy = vi.fn().mockResolvedValue({ text: "Handled due heartbeat tasks" });
const sendWhatsApp = vi
.fn<
(to: string, text: string, opts?: unknown) => Promise<{ messageId: string; toJid: string }>
>()
.mockResolvedValue({ messageId: "m1", toJid: "jid" });
const res = await runHeartbeatOnce({
cfg,
deps: createHeartbeatDeps(sendWhatsApp, { getReplyFromConfig: replySpy }),
});
expect(res.status).toBe("ran");
expect(replySpy).toHaveBeenCalledTimes(1);
const calledCtx = replySpy.mock.calls[0]?.[0] as { Body?: string };
expect(calledCtx.Body).toContain("- inbox: Check urgent inbox items");
expect(calledCtx.Body).toContain("- calendar: Check calendar changes");
expect(calledCtx.Body).toContain("Additional context from HEARTBEAT.md");
expect(calledCtx.Body).toContain("# Keep this header");
expect(calledCtx.Body).toContain("Remember escalation policy.");
expect(calledCtx.Body).toContain("Some global directive after tasks.");
expect(calledCtx.Body).not.toContain("name: inbox");
expect(calledCtx.Body).not.toContain("name: calendar");
replySpy.mockReset();
});
it("applies HEARTBEAT.md gating rules across file states and triggers", async () => {
const cases: Array<{
name: string;

View File

@@ -698,6 +698,40 @@ function appendHeartbeatWorkspacePathHint(prompt: string, workspaceDir: string):
return `${prompt}\n${hint}`;
}
function stripHeartbeatTasksBlock(content: string): string {
const lines = content.split(/\r?\n/);
const kept: string[] = [];
let inTasksBlock = false;
for (const line of lines) {
const trimmed = line.trim();
if (!inTasksBlock && trimmed === "tasks:") {
inTasksBlock = true;
continue;
}
if (inTasksBlock) {
if (!trimmed) {
continue;
}
const isIndented = /^[\s]/.test(line);
const isTaskListItem = trimmed.startsWith("-");
const isTaskField =
trimmed.startsWith("interval:") ||
trimmed.startsWith("prompt:") ||
trimmed.startsWith("name:");
if (isIndented || isTaskListItem || isTaskField) {
continue;
}
inTasksBlock = false;
}
kept.push(line);
}
return kept.join("\n");
}
function resolveHeartbeatRunPrompt(params: {
cfg: OpenClawConfig;
heartbeat?: HeartbeatConfig;
@@ -742,9 +776,7 @@ ${taskList}
After completing all due tasks, reply HEARTBEAT_OK.`;
if (params.heartbeatFileContent) {
const directives = params.heartbeatFileContent
.replace(/^[\s\S]*?^tasks:[\s\S]*?(?=^[^\s]|^$)/m, "")
.trim();
const directives = stripHeartbeatTasksBlock(params.heartbeatFileContent).trim();
if (directives) {
prompt += `\n\nAdditional context from HEARTBEAT.md:\n${directives}`;
}

View File

@@ -38,6 +38,9 @@ export function isHeartbeatEnabledForAgent(cfg: OpenClawConfig, agentId?: string
(entry) => Boolean(entry?.heartbeat) && normalizeAgentId(entry?.id) === resolvedAgentId,
);
}
if (cfg.agents?.defaults?.heartbeat) {
return true;
}
return resolvedAgentId === resolveDefaultAgentId(cfg);
}

View File

@@ -1,8 +1,11 @@
import crypto from "node:crypto";
import { mkdtemp, rm } from "node:fs/promises";
import path from "node:path";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolvePreferredOpenClawTmpDir } from "./tmp-openclaw-dir.js";
const logger = createSubsystemLogger("infra:temp-download");
export { resolvePreferredOpenClawTmpDir } from "./tmp-openclaw-dir.js";
export type TempDownloadTarget = {
@@ -50,7 +53,7 @@ async function cleanupTempDir(dir: string) {
await rm(dir, { recursive: true, force: true });
} catch (err) {
if (!isNodeErrorWithCode(err, "ENOENT")) {
console.warn(`temp-path cleanup failed for ${dir}: ${String(err)}`);
logger.warn(`temp-path cleanup failed for ${dir}: ${String(err)}`, { dir, error: err });
}
}
}

View File

@@ -1,8 +1,11 @@
import os from "node:os";
import path from "node:path";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { runExec } from "../process/exec.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
const log = createSubsystemLogger("security/windows-acl");
export type ExecFn = typeof runExec;
export type WindowsAclEntry = {
@@ -308,10 +311,7 @@ async function resolveCurrentUserSid(
} catch (err) {
// Log but do not propagate — SID resolution is best-effort.
// Callers fall back to env-based resolution when this returns null.
console.warn("[windows-acl] resolveCurrentUserSid failed:", String(err));
// TODO: replace with a structured logger call once a lightweight per-module
// logger is available; console.warn can be noisy on constrained Windows hosts
// (e.g. strict output-capture environments or CI runners with limited stdio).
log.warn("resolveCurrentUserSid failed", { error: String(err) });
return null;
}
}