mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 23:56:07 +00:00
fix(cron): sanitize target-last heartbeat wakes
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user