From 36eae5a2c7501e6a349e987e1d02671696b6abad Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 07:09:01 +0100 Subject: [PATCH] fix: tighten silent cron exec notifications --- CHANGELOG.md | 1 + src/cron/isolated-agent/run-executor.ts | 14 ++++++------ .../run.message-tool-policy.test.ts | 22 ++++++++++++++++++- src/cron/isolated-agent/run.ts | 4 +++- 4 files changed, 32 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5947cf0ceb4..84b70a1fe0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -96,6 +96,7 @@ Docs: https://docs.openclaw.ai - Browser/startup: deduplicate concurrent lazy-start calls per profile so simultaneous browser tool requests no longer race into duplicate Chrome launches and `PortInUseError`. (#61772) Thanks @sukhdeepjohar. - Browser/profiles: recover from stale Chromium `Singleton*` profile locks after crashes or host moves by clearing dead/foreign locks and retrying launch once. Thanks @seanc-dev. - Browser/existing-session: keep Chrome MCP status probes transport-only and ephemeral, and retry stale cached Playwright attaches once so idle profile checks no longer poison the next real attach. (#57245) Thanks @josephbergvinson. +- Cron/exec: suppress automatic background exec completion wakes only for silent cron jobs with `delivery.mode="none"` while keeping webhook and announce runs observable. (#71391) Thanks @goldmar. - Reply media: allow sandboxed replies to deliver OpenClaw-managed `media/outbound` and `media/tool-*` attachments without treating them as sandbox escapes, while keeping alias-escape checks on the managed media root. Fixes #71138. Thanks @mayor686, @truffle-dev, and @neeravmakwana. - CLI/agent: keep `openclaw agent --json` stdout reserved for the JSON response by routing gateway, plugin, and embedded-fallback diagnostics to stderr before execution starts. Fixes #71319. - Agents/Gemini: retry reasoning-only, empty, and planning-only Gemini turns instead of letting sessions silently stall. Fixes #71074. (#71362) Thanks @neeravmakwana. diff --git a/src/cron/isolated-agent/run-executor.ts b/src/cron/isolated-agent/run-executor.ts index 0148d7e966c..385301a1adf 100644 --- a/src/cron/isolated-agent/run-executor.ts +++ b/src/cron/isolated-agent/run-executor.ts @@ -69,7 +69,7 @@ export function createCronPromptExecutor(params: { thinkLevel: ThinkLevel | undefined; timeoutMs: number; messageChannel: string | undefined; - deliveryRequested: boolean; + suppressExecNotifyOnExit: boolean; resolvedDelivery: { accountId?: string; to?: string; @@ -197,12 +197,12 @@ export function createCronPromptExecutor(params: { bootstrapContextMode: params.agentPayload?.lightContext ? "lightweight" : undefined, bootstrapContextRunKind: "cron", toolsAllow: params.agentPayload?.toolsAllow, - execOverrides: params.deliveryRequested - ? undefined - : { + execOverrides: params.suppressExecNotifyOnExit + ? { notifyOnExit: false, notifyOnExitEmptySuccess: false, - }, + } + : undefined, runId: params.cronSession.sessionEntry.sessionId, requireExplicitMessageTarget: params.toolPolicy.requireExplicitMessageTarget, disableMessageTool: params.toolPolicy.disableMessageTool, @@ -270,7 +270,7 @@ export async function executeCronRun(params: { isAborted: () => boolean; thinkLevel: ThinkLevel | undefined; timeoutMs: number; - deliveryRequested: boolean; + suppressExecNotifyOnExit: boolean; runStartedAt?: number; }): Promise { const resolvedVerboseLevel: VerboseLevel = @@ -294,7 +294,7 @@ export async function executeCronRun(params: { thinkLevel: params.thinkLevel, timeoutMs: params.timeoutMs, messageChannel: params.resolvedDelivery.channel, - deliveryRequested: params.deliveryRequested, + suppressExecNotifyOnExit: params.suppressExecNotifyOnExit, resolvedDelivery: params.resolvedDelivery, toolPolicy: params.toolPolicy, skillsSnapshot: params.skillsSnapshot, diff --git a/src/cron/isolated-agent/run.message-tool-policy.test.ts b/src/cron/isolated-agent/run.message-tool-policy.test.ts index 08ad99b169b..4fbe5cc31a9 100644 --- a/src/cron/isolated-agent/run.message-tool-policy.test.ts +++ b/src/cron/isolated-agent/run.message-tool-policy.test.ts @@ -241,7 +241,7 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { thinkLevel: undefined, timeoutMs: 60_000, messageChannel: "messagechat", - deliveryRequested: false, + suppressExecNotifyOnExit: true, toolPolicy: { requireExplicitMessageTarget: false, disableMessageTool: false, @@ -470,6 +470,26 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]?.execOverrides).toBeUndefined(); }); + it("keeps automatic exec completion notifications when webhook delivery is active", async () => { + mockRunCronFallbackPassthrough(); + resolveCronDeliveryPlanMock.mockReturnValue({ + requested: false, + mode: "webhook", + to: "https://example.invalid/cron", + }); + + await runCronIsolatedAgentTurn({ + ...makeParams(), + job: makeMessageToolPolicyJob({ + mode: "webhook", + to: "https://example.invalid/cron", + }), + }); + + expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]?.execOverrides).toBeUndefined(); + }); + it("disables the message tool when webhook delivery is active", async () => { await expectMessageToolDisabledForPlan({ requested: false, diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 467d5ea472e..9b6bdce576b 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -408,6 +408,7 @@ type PreparedCronRunContext = { deliveryPlan: CronDeliveryPlan; resolvedDelivery: ResolvedCronDeliveryTarget; deliveryRequested: boolean; + suppressExecNotifyOnExit: boolean; toolPolicy: ReturnType; skillsSnapshot: SkillSnapshot; liveSelection: CronLiveSelection; @@ -696,6 +697,7 @@ async function prepareCronRunContext(params: { deliveryPlan, resolvedDelivery, deliveryRequested, + suppressExecNotifyOnExit: deliveryPlan.mode === "none", toolPolicy, skillsSnapshot, liveSelection, @@ -977,7 +979,7 @@ export async function runCronIsolatedAgentTurn(params: { isAborted, thinkLevel: prepared.context.thinkLevel, timeoutMs: prepared.context.timeoutMs, - deliveryRequested: prepared.context.deliveryRequested, + suppressExecNotifyOnExit: prepared.context.suppressExecNotifyOnExit, }); if (isAborted()) { return prepared.context.withRunSession({ status: "error", error: abortReason() });