diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a36c654886..36e622fc0e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/infra/dotenv.ts b/src/infra/dotenv.ts index 86a19c4d829..305a19a5e57 100644 --- a/src/infra/dotenv.ts +++ b/src/infra/dotenv.ts @@ -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 }, ); } } diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index ca753773930..d41feb55fa6 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -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; diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index af259caf072..03a9cce91a9 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -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}`; } diff --git a/src/infra/heartbeat-summary.ts b/src/infra/heartbeat-summary.ts index 44d31f9c7a4..21ac979cfb7 100644 --- a/src/infra/heartbeat-summary.ts +++ b/src/infra/heartbeat-summary.ts @@ -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); } diff --git a/src/infra/temp-download.ts b/src/infra/temp-download.ts index 6f7f9ac6ad2..b7f3dbfcf1f 100644 --- a/src/infra/temp-download.ts +++ b/src/infra/temp-download.ts @@ -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 }); } } } diff --git a/src/security/windows-acl.ts b/src/security/windows-acl.ts index 8c29bada485..af763b81144 100644 --- a/src/security/windows-acl.ts +++ b/src/security/windows-acl.ts @@ -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; } }