From 741005001b987914181f54fc97c9a27204ea286f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 07:41:25 +0100 Subject: [PATCH] fix(cron): keep implicit isolated delivery out of main --- CHANGELOG.md | 1 + .../isolated-agent.delivery-awareness.test.ts | 31 +++++++++++++++++++ .../delivery-dispatch.double-announce.test.ts | 25 +++++++++++++-- src/cron/isolated-agent/delivery-dispatch.ts | 25 ++++++++++++--- 4 files changed, 75 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1eeeed12f11..92609414de4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai - Active Memory: use the configured recall timeout as the blocking prompt-build hook budget by default and move cold-start setup grace behind explicit `setupGraceTimeoutMs` config, so the plugin no longer silently extends 15000 ms configs to 45000 ms on the main lane. Fixes #75843. Thanks @vishutdhar. - Plugins/web-provider: reuse the active gateway plugin registry for runtime web provider resolution after deriving the same candidate plugin ids as the loader path, avoiding a redundant `loadOpenClawPlugins` call on every request while preserving origin and scope filters. Fixes #75513. Thanks @jochen. - Crestodian/CLI: exit non-zero when interactive Crestodian is invoked without a TTY, so scripts and CI no longer treat the setup error as success. Fixes #73646 and supersedes #73928 and #74059. Thanks @bittoby, @luyao618, and @Linux2010. +- Cron: keep implicit/default isolated cron announce deliveries out of the main session awareness queue, so isolated jobs do not accumulate in the main conversation. Fixes #61426. Thanks @Lihannon. - Agents/sandbox: preserve existing workspace file modes when sandbox edits atomically replace files, so 0644 files do not collapse to 0600 after Write/Edit/apply_patch. Fixes #44077. Thanks @patosullivan. - Agents/models: keep legacy CLI runtime model refs such as `claude-cli/*` in the configured allowlist after canonical runtime migration, so cron `payload.model` overrides keep working. Fixes #75753. Thanks @RyanSandoval. - Codex/app-server: restart the shared Codex app-server client once when it closes during startup thread resume, preserving the existing thread binding instead of retrying `thread/start` on a closed client. Thanks @vincentkoc. diff --git a/src/cron/isolated-agent.delivery-awareness.test.ts b/src/cron/isolated-agent.delivery-awareness.test.ts index dead730e07f..09529359c72 100644 --- a/src/cron/isolated-agent.delivery-awareness.test.ts +++ b/src/cron/isolated-agent.delivery-awareness.test.ts @@ -104,4 +104,35 @@ describe("runCronIsolatedAgentTurn cron delivery awareness", () => { expect(peekSystemEvents("global")).toEqual(["global cron digest"]); }); }); + + it("does not queue main-session awareness for implicit last-target delivery", async () => { + await withTempCronHome(async (home) => { + const storePath = await writeDefaultAgentSessionStoreEntries({ + "agent:main:main": { + sessionId: "main-session", + updatedAt: Date.now(), + lastProvider: "telegram", + lastChannel: "telegram", + lastTo: "123", + }, + }); + const deps = createCliDeps(); + mockAgentPayloads([{ text: "implicit cron digest" }]); + + const result = await runAnnounceTurn({ + home, + storePath, + sessionKey: "cron:job-1", + deps, + delivery: { + mode: "announce", + channel: "last", + }, + }); + + expect(result.status).toBe("ok"); + expect(result.delivered).toBe(true); + expect(peekSystemEvents("agent:main:main")).toEqual([]); + }); + }); }); diff --git a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts index ccd21e8ecb4..7315d11c055 100644 --- a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts +++ b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts @@ -135,8 +135,12 @@ function makeBaseParams(overrides: { sessionTarget?: string; deliveryBestEffort?: boolean; runSessionKey?: string; + resolvedDeliveryMode?: "explicit" | "implicit"; }): Parameters[0] { - const resolvedDelivery = makeResolvedDelivery(); + const resolvedDelivery = { + ...makeResolvedDelivery(), + mode: overrides.resolvedDeliveryMode ?? "explicit", + } satisfies Extract; const runStartedAt = overrides.runStartedAt ?? Date.now(); return { cfg: {} as never, @@ -422,7 +426,7 @@ describe("dispatchCronDelivery — double-announce guard", () => { ); }); - it("queues main-session awareness for isolated cron jobs after delivery", async () => { + it("queues main-session awareness for isolated cron jobs with explicit delivery targets", async () => { vi.mocked(countActiveDescendantRuns).mockReturnValue(0); vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false); @@ -443,6 +447,23 @@ describe("dispatchCronDelivery — double-announce guard", () => { }); }); + it("skips main-session awareness for isolated cron jobs with implicit delivery targets", async () => { + vi.mocked(countActiveDescendantRuns).mockReturnValue(0); + vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false); + + const params = makeBaseParams({ + synthesizedText: "Implicit cron update.", + resolvedDeliveryMode: "implicit", + }); + const state = await dispatchCronDelivery(params); + + expect(state.result).toBeUndefined(); + expect(state.delivered).toBe(true); + expect(state.deliveryAttempted).toBe(true); + expect(deliverOutboundPayloads).toHaveBeenCalledTimes(1); + expect(enqueueSystemEvent).not.toHaveBeenCalled(); + }); + it("skips awareness text when direct delivery strips a silent caption", async () => { vi.mocked(countActiveDescendantRuns).mockReturnValue(0); vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false); diff --git a/src/cron/isolated-agent/delivery-dispatch.ts b/src/cron/isolated-agent/delivery-dispatch.ts index e07dcbeb72f..cc229eca2b3 100644 --- a/src/cron/isolated-agent/delivery-dispatch.ts +++ b/src/cron/isolated-agent/delivery-dispatch.ts @@ -361,10 +361,18 @@ function buildDirectCronDeliveryIdempotencyKey(params: { return `cron-direct-delivery:v1:${executionId}:${params.delivery.channel}:${accountId}:${normalizedTo}:${threadId}`; } -function shouldQueueCronAwareness(job: CronJob, deliveryBestEffort: boolean): boolean { - // Keep issue #52136 scoped to isolated runs. Session-bound cron jobs keep - // their existing behavior, and best-effort sends may only partially deliver. - return job.sessionTarget === "isolated" && !deliveryBestEffort; +function shouldQueueCronAwareness(params: { + job: CronJob; + delivery: SuccessfulDeliveryTarget; + deliveryBestEffort: boolean; +}): boolean { + // Keep issue #52136 scoped to isolated runs with an explicit delivery target. + // Default isolated announce delivery must not mirror text into the main session. + return ( + params.job.sessionTarget === "isolated" && + !params.deliveryBestEffort && + params.delivery.mode === "explicit" + ); } function resolveCronAwarenessMainSessionKey(params: { @@ -688,7 +696,14 @@ export async function dispatchCronDelivery( // Intentionally leave partial success uncached: replay may duplicate the // successful subset, but caching it here would permanently drop the // failed payloads by converting the replay into delivered=true. - if (delivered && shouldQueueCronAwareness(params.job, params.deliveryBestEffort)) { + if ( + delivered && + shouldQueueCronAwareness({ + job: params.job, + delivery, + deliveryBestEffort: params.deliveryBestEffort, + }) + ) { await queueCronAwarenessSystemEvent({ cfg: params.cfgWithAgentDefaults, jobId: params.job.id,