From ea95e88dd60b09f5a30f618f542ec8ca88baf3f0 Mon Sep 17 00:00:00 2001 From: Marcus Widing Date: Fri, 13 Feb 2026 21:14:32 +0100 Subject: [PATCH] fix(cron): prevent duplicate delivery for isolated jobs with announce mode When an isolated cron job delivers its output via deliverOutboundPayloads or the subagent announce flow, the finish handler in executeJobCore unconditionally posts a summary to the main agent session and wakes it via requestHeartbeatNow. The main agent then generates a second response that is also delivered to the target channel, resulting in duplicate messages with different content. Add a `delivered` flag to RunCronAgentTurnResult that is set to true when the isolated run successfully delivers its output. In executeJobCore, skip the enqueueSystemEvent + requestHeartbeatNow call when the flag is set, preventing the main agent from waking up and double-posting. Fixes #15692 --- src/cron/isolated-agent/run.ts | 15 +++++++++++++-- src/cron/service/state.ts | 5 +++++ src/cron/service/timer.ts | 9 +++++++-- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index a329ef0e8..952894f6b 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -101,6 +101,13 @@ export type RunCronAgentTurnResult = { error?: string; sessionId?: string; sessionKey?: string; + /** + * `true` when the isolated run already delivered its output to the target + * channel (via outbound payloads or the subagent announce flow). Callers + * should skip posting a summary to the main session to avoid duplicate + * messages. See: https://github.com/openclaw/openclaw/issues/15692 + */ + delivered?: boolean; }; export async function runCronIsolatedAgentTurn(params: { @@ -518,6 +525,7 @@ export async function runCronIsolatedAgentTurn(params: { }), ); + let delivered = false; if (deliveryRequested && !skipHeartbeatDelivery && !skipMessagingToolDelivery) { if (resolvedDelivery.error) { if (!deliveryBestEffort) { @@ -558,6 +566,7 @@ export async function runCronIsolatedAgentTurn(params: { bestEffort: deliveryBestEffort, deps: createOutboundSendDeps(params.deps), }); + delivered = true; } catch (err) { if (!deliveryBestEffort) { return withRunSession({ status: "error", summary, outputText, error: String(err) }); @@ -594,7 +603,9 @@ export async function runCronIsolatedAgentTurn(params: { outcome: { status: "ok" }, announceType: "cron job", }); - if (!didAnnounce) { + if (didAnnounce) { + delivered = true; + } else { const message = "cron announce delivery failed"; if (!deliveryBestEffort) { return withRunSession({ @@ -615,5 +626,5 @@ export async function runCronIsolatedAgentTurn(params: { } } - return withRunSession({ status: "ok", summary, outputText }); + return withRunSession({ status: "ok", summary, outputText, delivered }); } diff --git a/src/cron/service/state.ts b/src/cron/service/state.ts index 025da7b3f..0c7c3c70e 100644 --- a/src/cron/service/state.ts +++ b/src/cron/service/state.ts @@ -46,6 +46,11 @@ export type CronServiceDeps = { error?: string; sessionId?: string; sessionKey?: string; + /** + * `true` when the isolated run already delivered its output to the target + * channel. See: https://github.com/openclaw/openclaw/issues/15692 + */ + delivered?: boolean; }>; onEvent?: (evt: CronEvent) => void; }; diff --git a/src/cron/service/timer.ts b/src/cron/service/timer.ts index 0259dfc61..913165dcb 100644 --- a/src/cron/service/timer.ts +++ b/src/cron/service/timer.ts @@ -483,10 +483,15 @@ async function executeJobCore( message: job.payload.message, }); - // Post a short summary back to the main session. + // Post a short summary back to the main session — but only when the + // isolated run did NOT already deliver its output to the target channel. + // When `res.delivered` is true the announce flow (or direct outbound + // delivery) already sent the result, so posting the summary to main + // would wake the main agent and cause a duplicate message. + // See: https://github.com/openclaw/openclaw/issues/15692 const summaryText = res.summary?.trim(); const deliveryPlan = resolveCronDeliveryPlan(job); - if (summaryText && deliveryPlan.requested) { + if (summaryText && deliveryPlan.requested && !res.delivered) { const prefix = "Cron"; const label = res.status === "error" ? `${prefix} (error): ${summaryText}` : `${prefix}: ${summaryText}`;