Skip to content

[Bug] Cron lane never opts into allowEmptyAssistantReplyAsSilent — intentionally-silent cron jobs fail with FailoverError "empty response" #94224

Description

@davh55

Summary

Cron jobs that intentionally produce an empty assistant reply (silent alert channels — "watch X, say nothing unless there's something to report") fail every run with:

FailoverError: CLI backend returned an empty response.  (reason: empty_response, lane: "cron")

even though the underlying claude CLI returns is_error: false / subtype: success. The job is marked error in openclaw cron list, and the Gateway logs a failover error on every scheduled tick.

Pre-2026.6.6 an intentionally-empty cron run was correctly classified ok / not-delivered. Since then, the empty-reply guard throws for the cron lane because nothing ever opts the cron lane into "empty reply is allowed / silent".

Why it matters

Silent-by-default alert crons are a core pattern (price/uptime/news watchers that only message when there's something to say). Right now every such job:

  • spams the Gateway log with FailoverError on every tick,
  • shows permanently error status in cron list (masking real failures),
  • and depending on delivery config can trigger failure-destination routing for a non-failure.

There is no settings-level workaroundallowEmptyAssistantReplyAsSilent is never exposed for cron jobs.

Reproduction shape

  • backend: claude-cli (bundled @anthropic-ai/claude-code)
  • delivery: announce -> telegram:<id> (also reproduces with other targets)
  • a cron job whose prompt instructs the agent to reply with nothing when there's nothing to report, e.g. schedule 0 8,11,14,17,20,23 * * *, lane cron, isolated session
  • on any tick where the agent legitimately produces no outbound text → FailoverError: CLI backend returned an empty response.
  • openclaw cron show <id>status: error

Root cause (from inspection of the shipped 2026.6.8 bundle)

  1. Throw site (cli-runner-*.js, the CLI messaging-delivery finalizer):

    if (!assistantText && !output.didSendViaMessagingTool && params.allowEmptyAssistantReplyAsSilent !== true)
      throw attachCliMessagingDeliveryEvidence(new FailoverError("CLI backend returned an empty response.", {
        reason: "empty_response",
        provider: params.provider,
        model: context.modelId,
        sessionId: params.sessionId,
        lane: params.lane,   // === "cron" for cron jobs
        ...
      }));

    So an empty reply is only tolerated when params.allowEmptyAssistantReplyAsSilent === true.

  2. The flag is only ever computed on the interactive reply path (get-reply-*.js), and purely from DM/group chat semantics:

    const allowEmptyAssistantReplyAsSilent =
        (isDirectChat && silentReplyConversationType === "direct" && silentReplySettings.policy === "allow")
        || (isGroupChat && resolveGroupSilentReplyBehavior({ sessionEntry, defaultActivation, silentReplyPolicy: ... }));

    For a cron run, isDirectChat and isGroupChat are both false → the flag is falsy.

  3. The cron invocation path never sets it. server-cron-*.js dispatches the run with lane: "cron" but does not compute or pass allowEmptyAssistantReplyAsSilent, so it defaults falsy and the throw in (1) fires for every intentionally-silent cron tick. There is also no per-job field on the cron schema (jobs-* store) to opt in.

Net effect: the cron lane has no path — config or code — to say "an empty reply here is intentional, deliver nothing and succeed."

Proposed fix

Any of (preferably A + C):

  • A. In the cron dispatch path (server-cron, where lane: "cron" is set), default allowEmptyAssistantReplyAsSilent: true — a cron tick producing no message is the intended silent-alert behavior, not a backend failure.
  • B. Honor the lane at the throw site: ... && params.allowEmptyAssistantReplyAsSilent !== true && params.lane !== "cron".
  • C. Expose a per-job boolean on the cron schema (e.g. silentWhenEmpty / allowEmptyReply, default true) that flows into the run params, so a job can opt out and treat empty as failure if desired.

Workaround in use

Patching the shipped bundle to skip the throw when params.lane includes "cron" (re-applied after each upgrade, since dist/ is overwritten):

// at the throw site in cli-runner-*.js
params.allowEmptyAssistantReplyAsSilent !== true && !String(params.lane || "").includes("cron")) throw ...

After patching, the same job's status flips errorok and the silent tick delivers nothing, as expected pre-2026.6.6.

Environment

  • OpenClaw 2026.6.8 (844f405)
  • macOS (Apple Silicon), global Homebrew install (/opt/homebrew/lib/node_modules/openclaw)
  • backend: claude-cli (@anthropic-ai/claude-code)
  • delivery: announce -> telegram
  • Regression window: worked ≤ 2026.6.5, broken from 2026.6.6 onward.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1High-priority user-facing bug, regression, or broken workflow.clawsweeper:linked-pr-openClawSweeper found an open linked pull request for this issue.clawsweeper:needs-product-decisionClawSweeper marked this issue as needing a product or behavior decision.clawsweeper:no-new-fix-prClawSweeper does not recommend queueing a new automated fix PR for this issue.clawsweeper:source-reproClawSweeper found a high-confidence source-level issue reproduction.impact:otherThis issue has meaningful maintainer-visible impact outside the owned taxonomy.issue-rating: 🦞 diamond lobsterVery strong issue quality with high-confidence source-level or clear reproduction.

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions