From 7847e67f8acb19072e638aa97758cdbd9867e9ae Mon Sep 17 00:00:00 2001 From: M1a0 Date: Thu, 26 Mar 2026 01:47:01 +0800 Subject: [PATCH] plugin-runtime: expose runHeartbeatOnce in system API (#40299) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * plugin-runtime: expose runHeartbeatOnce in system API Plugins that enqueue system events and need the agent to deliver responses to the originating channel currently have no way to override the default `heartbeat.target: "none"` behaviour. Expose `runHeartbeatOnce` in the plugin runtime `system` namespace so plugins can trigger a single heartbeat cycle with an explicit `heartbeat: { target: "last" }` override — the same pattern the cron service already uses (see #28508). Changes: - Add `RunHeartbeatOnceOptions` type and `runHeartbeatOnce` to `PluginRuntimeCore.system` (types-core.ts) - Wire the function through a thin wrapper in runtime-system.ts - Update the test-utils plugin-runtime mock Made-with: Cursor * feat(plugins): expose runHeartbeatOnce in system API (#40299) (thanks @loveyana) --------- Co-authored-by: George Zhang --- CHANGELOG.md | 1 + docs/.generated/plugin-sdk-api-baseline.json | 2 +- docs/.generated/plugin-sdk-api-baseline.jsonl | 2 +- src/plugins/runtime/runtime-system.ts | 12 ++++++++++++ src/plugins/runtime/types-core.ts | 18 ++++++++++++++++++ test/helpers/extensions/plugin-runtime-mock.ts | 4 ++++ 6 files changed, 37 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 121d7b80682..f953d9298a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai - MiniMax: add image generation provider for `image-01` model, supporting generate and image-to-image editing with aspect ratio control. (#54487) Thanks @liyuan97. - MiniMax: trim model catalog to M2.7 only, removing legacy M2, M2.1, M2.5, and VL-01 models. (#54487) Thanks @liyuan97. +- Plugins/runtime: expose `runHeartbeatOnce` in the plugin runtime `system` namespace so plugins can trigger a single heartbeat cycle with an explicit delivery target override (e.g. `heartbeat: { target: "last" }`). (#40299) Thanks @loveyana. ### Fixes diff --git a/docs/.generated/plugin-sdk-api-baseline.json b/docs/.generated/plugin-sdk-api-baseline.json index 18cc09cdfc5..e73c4afb7be 100644 --- a/docs/.generated/plugin-sdk-api-baseline.json +++ b/docs/.generated/plugin-sdk-api-baseline.json @@ -496,7 +496,7 @@ "exportName": "RuntimeLogger", "kind": "type", "source": { - "line": 4, + "line": 7, "path": "src/plugins/runtime/types-core.ts" } }, diff --git a/docs/.generated/plugin-sdk-api-baseline.jsonl b/docs/.generated/plugin-sdk-api-baseline.jsonl index 7a659652dda..6b1fc85b388 100644 --- a/docs/.generated/plugin-sdk-api-baseline.jsonl +++ b/docs/.generated/plugin-sdk-api-baseline.jsonl @@ -53,7 +53,7 @@ {"declaration":"export type ProviderRuntimeModel = ProviderRuntimeModel;","entrypoint":"index","exportName":"ProviderRuntimeModel","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":295,"sourcePath":"src/plugins/types.ts"} {"declaration":"export type ReplyPayload = ReplyPayload;","entrypoint":"index","exportName":"ReplyPayload","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":76,"sourcePath":"src/auto-reply/types.ts"} {"declaration":"export type RuntimeEnv = RuntimeEnv;","entrypoint":"index","exportName":"RuntimeEnv","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":4,"sourcePath":"src/runtime.ts"} -{"declaration":"export type RuntimeLogger = RuntimeLogger;","entrypoint":"index","exportName":"RuntimeLogger","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":4,"sourcePath":"src/plugins/runtime/types-core.ts"} +{"declaration":"export type RuntimeLogger = RuntimeLogger;","entrypoint":"index","exportName":"RuntimeLogger","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":7,"sourcePath":"src/plugins/runtime/types-core.ts"} {"declaration":"export type SecretInput = SecretInput;","entrypoint":"index","exportName":"SecretInput","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":16,"sourcePath":"src/config/types.secrets.ts"} {"declaration":"export type SecretRef = SecretRef;","entrypoint":"index","exportName":"SecretRef","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":10,"sourcePath":"src/config/types.secrets.ts"} {"declaration":"export type SpeechProviderPlugin = SpeechProviderPlugin;","entrypoint":"index","exportName":"SpeechProviderPlugin","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":933,"sourcePath":"src/plugins/types.ts"} diff --git a/src/plugins/runtime/runtime-system.ts b/src/plugins/runtime/runtime-system.ts index 06b9c72f8ec..99e8dd1e481 100644 --- a/src/plugins/runtime/runtime-system.ts +++ b/src/plugins/runtime/runtime-system.ts @@ -1,13 +1,25 @@ +import { runHeartbeatOnce as runHeartbeatOnceInternal } from "../../infra/heartbeat-runner.js"; import { requestHeartbeatNow } from "../../infra/heartbeat-wake.js"; import { enqueueSystemEvent } from "../../infra/system-events.js"; import { runCommandWithTimeout } from "../../process/exec.js"; import { formatNativeDependencyHint } from "./native-deps.js"; +import type { RunHeartbeatOnceOptions } from "./types-core.js"; import type { PluginRuntime } from "./types.js"; export function createRuntimeSystem(): PluginRuntime["system"] { return { enqueueSystemEvent, requestHeartbeatNow, + runHeartbeatOnce: (opts?: RunHeartbeatOnceOptions) => { + // Destructure to forward only the plugin-safe subset; prevent cfg/deps injection at runtime. + const { reason, agentId, sessionKey, heartbeat } = opts ?? {}; + return runHeartbeatOnceInternal({ + reason, + agentId, + sessionKey, + heartbeat: heartbeat ? { target: heartbeat.target } : undefined, + }); + }, runCommandWithTimeout, formatNativeDependencyHint, }; diff --git a/src/plugins/runtime/types-core.ts b/src/plugins/runtime/types-core.ts index a57cbe207b6..f004ad7fa90 100644 --- a/src/plugins/runtime/types-core.ts +++ b/src/plugins/runtime/types-core.ts @@ -1,5 +1,8 @@ +import type { HeartbeatRunResult } from "../../infra/heartbeat-wake.js"; import type { LogLevel } from "../../logging/levels.js"; +export type { HeartbeatRunResult }; + /** Structured logger surface injected into runtime-backed plugin helpers. */ export type RuntimeLogger = { debug?: (message: string, meta?: Record) => void; @@ -8,6 +11,14 @@ export type RuntimeLogger = { error: (message: string, meta?: Record) => void; }; +export type RunHeartbeatOnceOptions = { + reason?: string; + agentId?: string; + sessionKey?: string; + /** Override heartbeat config (e.g. `{ target: "last" }` to deliver to the last active channel). */ + heartbeat?: { target?: string }; +}; + /** Core runtime helpers exposed to trusted native plugins. */ export type PluginRuntimeCore = { version: string; @@ -37,6 +48,13 @@ export type PluginRuntimeCore = { system: { enqueueSystemEvent: typeof import("../../infra/system-events.js").enqueueSystemEvent; requestHeartbeatNow: typeof import("../../infra/heartbeat-wake.js").requestHeartbeatNow; + /** + * Run a single heartbeat cycle immediately (bypassing the coalesce timer). + * Accepts an optional `heartbeat` config override so callers can force + * delivery to the last active channel — the same pattern the cron service + * uses to avoid the default `target: "none"` suppression. + */ + runHeartbeatOnce: (opts?: RunHeartbeatOnceOptions) => Promise; runCommandWithTimeout: typeof import("../../process/exec.js").runCommandWithTimeout; formatNativeDependencyHint: typeof import("./native-deps.js").formatNativeDependencyHint; }; diff --git a/test/helpers/extensions/plugin-runtime-mock.ts b/test/helpers/extensions/plugin-runtime-mock.ts index c0b73a6e15d..75ec63a53f6 100644 --- a/test/helpers/extensions/plugin-runtime-mock.ts +++ b/test/helpers/extensions/plugin-runtime-mock.ts @@ -87,6 +87,10 @@ export function createPluginRuntimeMock(overrides: DeepPartial = system: { enqueueSystemEvent: vi.fn() as unknown as PluginRuntime["system"]["enqueueSystemEvent"], requestHeartbeatNow: vi.fn() as unknown as PluginRuntime["system"]["requestHeartbeatNow"], + runHeartbeatOnce: vi.fn(async () => ({ + status: "ran" as const, + durationMs: 0, + })) as unknown as PluginRuntime["system"]["runHeartbeatOnce"], runCommandWithTimeout: vi.fn() as unknown as PluginRuntime["system"]["runCommandWithTimeout"], formatNativeDependencyHint: vi.fn( () => "",