diff --git a/CHANGELOG.md b/CHANGELOG.md index 929ebbe36a2..4e18080f2dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/gateway/server-cron.test.ts b/src/gateway/server-cron.test.ts index e186ff89009..70eb3c42443 100644 --- a/src/gateway/server-cron.test.ts +++ b/src/gateway/server-cron.test.ts @@ -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(); } diff --git a/src/gateway/server-cron.ts b/src/gateway/server-cron.ts index c3a38b84484..22dd18a72f6 100644 --- a/src/gateway/server-cron.ts +++ b/src/gateway/server-cron.ts @@ -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 }, }); }, diff --git a/src/infra/heartbeat-runner.scheduler.test.ts b/src/infra/heartbeat-runner.scheduler.test.ts index ff3ee1c93b3..a494d7ba160 100644 --- a/src/infra/heartbeat-runner.scheduler.test.ts +++ b/src/infra/heartbeat-runner.scheduler.test.ts @@ -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 }); diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 7898b77b6b6..0b1fc018661 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -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,