fix(cron): sanitize target-last heartbeat wakes

This commit is contained in:
Peter Steinberger
2026-05-11 15:27:19 +01:00
parent ec5a97467c
commit f994094cb4
5 changed files with 91 additions and 26 deletions

View File

@@ -62,6 +62,7 @@ Docs: https://docs.openclaw.ai
- Cron: keep long manual cron runs active in the task registry until completion, preventing transient `lost` markers before durable recovery reconciles. Fixes #78233. (#78243) Thanks @Feelw00.
- Doctor/GitHub CLI: surface a `GH_CONFIG_DIR` hint when the GitHub skill is usable but `gh` auth lives under a different operator HOME than the agent process, without warning for disabled or filtered skills. Fixes #78063. (#78095) Thanks @tmimmanuel.
- Gateway: dedupe concurrent `send`, `poll`, and `message.action` requests while delivery is still in flight, preventing duplicate outbound work for the same idempotency key. (#68341) Thanks @thesomewhatyou.
- Cron: keep main-session `systemEvent` heartbeat wakes on their bound session route for both direct and queued wake paths by dropping inherited explicit heartbeat destinations when forcing `target: "last"`. Fixes #73900. Thanks @richardmqq.
- Gateway: clear speculative node wake state when APNs registration is missing, preventing unregistered or mistyped node IDs from retaining wake throttle entries. Fixes #68847. (#68848) Thanks @Feelw00.
- Auto-reply: keep late follow-up queue drain finalizers from deleting a replacement queue registered after `/stop`, preventing immediate follow-up messages from being orphaned. Fixes #68838. (#68839) Thanks @Feelw00.
- Feishu: make manual App ID/App Secret setup the default channel-binding path while keeping QR scan-to-create as an optional best-effort flow, and document the manual fallback for domestic Feishu mobile clients that do not react to the QR code. Fixes #80591. Thanks @wei-wei-zhao.

View File

@@ -735,8 +735,7 @@ describe("buildGatewayCronService", () => {
expect(agentHeartbeat.target).toBe("last");
expect(agentHeartbeat.deliveryFormat).toBe("markdown");
const heartbeat = requireRecord(options.heartbeat, "heartbeat override");
expect(heartbeat.target).toBe("last");
expect(heartbeat.deliveryFormat).toBe("markdown");
expect(heartbeat).toEqual({});
} finally {
state.cron.stop();
}

View File

@@ -262,25 +262,6 @@ export function buildGatewayCronService(params: {
},
runHeartbeatOnce: async (opts) => {
const { runtimeConfig, agentId, sessionKey } = resolveCronWakeTarget(opts);
// Merge cron-supplied heartbeat overrides (e.g. target: "last") with the
// fully resolved agent heartbeat config so cron-triggered heartbeats
// respect agent-specific overrides (agents.list[].heartbeat) before
// falling back to agents.defaults.heartbeat.
const agentEntry =
Array.isArray(runtimeConfig.agents?.list) &&
runtimeConfig.agents.list.find(
(entry) =>
entry && typeof entry.id === "string" && normalizeAgentId(entry.id) === agentId,
);
const agentHeartbeat =
agentEntry && typeof agentEntry === "object" ? agentEntry.heartbeat : undefined;
const baseHeartbeat = {
...runtimeConfig.agents?.defaults?.heartbeat,
...agentHeartbeat,
};
const heartbeatOverride = opts?.heartbeat
? { ...baseHeartbeat, ...opts.heartbeat }
: undefined;
return await runHeartbeatOnce({
cfg: runtimeConfig,
source: opts?.source ?? "cron",
@@ -288,7 +269,7 @@ export function buildGatewayCronService(params: {
reason: opts?.reason,
agentId,
sessionKey,
heartbeat: heartbeatOverride,
heartbeat: opts?.heartbeat,
deps: { ...params.deps, runtime: defaultRuntime },
});
},

View File

@@ -473,6 +473,8 @@ describe("startHeartbeatRunner", () => {
prompt: "Ops prompt",
directPolicy: "block",
target: "discord:channel:ops",
to: "discord:dm:ops",
accountId: "ops-account",
},
},
]),
@@ -503,6 +505,49 @@ describe("startHeartbeatRunner", () => {
runner.stop();
});
it("keeps non-cron targeted wake destination overrides explicit", async () => {
useFakeHeartbeatTime();
const runSpy = vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 });
const runner = await expectWakeDispatch({
cfg: {
...heartbeatConfig([
{
id: "ops",
heartbeat: {
every: "15m",
target: "discord:channel:ops",
to: "discord:dm:ops",
accountId: "ops-account",
},
},
]),
} as OpenClawConfig,
runSpy,
wake: {
source: "hook",
intent: "event",
reason: "hook:job-123",
agentId: "ops",
sessionKey: "agent:ops:discord:channel:alerts",
heartbeat: { target: "last" },
coalesceMs: 0,
},
expectedCall: {
agentId: "ops",
reason: "hook:job-123",
sessionKey: "agent:ops:discord:channel:alerts",
heartbeat: {
every: "15m",
target: "last",
to: "discord:dm:ops",
accountId: "ops-account",
},
},
});
runner.stop();
});
it("clamps oversized scheduler delays so heartbeats do not fire in a tight loop (#71414)", async () => {
useFakeHeartbeatTime();
const runSpy = vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 });

View File

@@ -298,6 +298,34 @@ function resolveHeartbeatConfig(
return { ...defaults, ...overrides };
}
function omitExplicitHeartbeatDestination(heartbeat: HeartbeatConfig | undefined) {
if (!heartbeat) {
return undefined;
}
const next = { ...heartbeat };
delete next.to;
delete next.accountId;
return next;
}
function resolveHeartbeatForWake(params: {
cfg: OpenClawConfig;
agentId: string;
configuredHeartbeat?: HeartbeatConfig;
requestedHeartbeat?: HeartbeatConfig;
source?: HeartbeatWakeSource;
mergeRequestedHeartbeat: boolean;
}): HeartbeatConfig | undefined {
const base = params.configuredHeartbeat ?? resolveHeartbeatConfig(params.cfg, params.agentId);
const heartbeat =
params.requestedHeartbeat && params.mergeRequestedHeartbeat
? { ...base, ...params.requestedHeartbeat }
: (params.requestedHeartbeat ?? base);
return params.source === "cron" && params.requestedHeartbeat?.target === "last"
? omitExplicitHeartbeatDestination(heartbeat)
: heartbeat;
}
function resolveHeartbeatAgents(cfg: OpenClawConfig): HeartbeatAgent[] {
const list = cfg.agents?.list ?? [];
if (hasExplicitHeartbeatAgents(cfg)) {
@@ -1180,7 +1208,13 @@ export async function runHeartbeatOnce(opts: {
const agentId = normalizeAgentId(
explicitAgentId || forcedSessionAgentId || resolveDefaultAgentId(cfg),
);
const heartbeat = opts.heartbeat ?? resolveHeartbeatConfig(cfg, agentId);
const heartbeat = resolveHeartbeatForWake({
cfg,
agentId,
requestedHeartbeat: opts.heartbeat,
source: opts.source,
mergeRequestedHeartbeat: opts.source === "cron",
});
if (!areHeartbeatsEnabled()) {
return { status: "skipped", reason: "disabled" };
}
@@ -2183,8 +2217,6 @@ export function startHeartbeatRunner(opts: {
const requestedAgentId = params?.agentId ? normalizeAgentId(params.agentId) : undefined;
const requestedSessionKey = normalizeOptionalString(params?.sessionKey);
const requestedHeartbeat = params?.heartbeat;
const resolveRequestedHeartbeat = (heartbeat?: HeartbeatConfig) =>
requestedHeartbeat ? { ...heartbeat, ...requestedHeartbeat } : heartbeat;
const isInterval = reason === "interval";
const startedAt = Date.now();
const now = startedAt;
@@ -2208,7 +2240,14 @@ export function startHeartbeatRunner(opts: {
const res = await runOnce({
cfg: state.cfg,
agentId: targetAgent.agentId,
heartbeat: resolveRequestedHeartbeat(targetAgent.heartbeat),
heartbeat: resolveHeartbeatForWake({
cfg: state.cfg,
agentId: targetAgent.agentId,
configuredHeartbeat: targetAgent.heartbeat,
requestedHeartbeat,
source: params.source,
mergeRequestedHeartbeat: true,
}),
source: params.source,
intent,
reason,