plugin-runtime: expose runHeartbeatOnce in system API (#40299)

* 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 <georgezhangtj97@gmail.com>
This commit is contained in:
M1a0 2026-03-26 01:47:01 +08:00 committed by GitHub
parent 4ae4d1fabe
commit 7847e67f8a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 37 additions and 2 deletions

View File

@ -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

View File

@ -496,7 +496,7 @@
"exportName": "RuntimeLogger",
"kind": "type",
"source": {
"line": 4,
"line": 7,
"path": "src/plugins/runtime/types-core.ts"
}
},

View File

@ -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"}

View File

@ -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,
};

View File

@ -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<string, unknown>) => void;
@ -8,6 +11,14 @@ export type RuntimeLogger = {
error: (message: string, meta?: Record<string, unknown>) => 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<HeartbeatRunResult>;
runCommandWithTimeout: typeof import("../../process/exec.js").runCommandWithTimeout;
formatNativeDependencyHint: typeof import("./native-deps.js").formatNativeDependencyHint;
};

View File

@ -87,6 +87,10 @@ export function createPluginRuntimeMock(overrides: DeepPartial<PluginRuntime> =
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(
() => "",