mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:50:43 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user