Skip to content

fix(cron): channel "last" fallback for unconfigured and cross-channel delivery#19034

Open
tangopium wants to merge 7 commits intoopenclaw:mainfrom
innovandio:fix/cron-channel-last-fallback
Open

fix(cron): channel "last" fallback for unconfigured and cross-channel delivery#19034
tangopium wants to merge 7 commits intoopenclaw:mainfrom
innovandio:fix/cron-channel-last-fallback

Conversation

@tangopium
Copy link

@tangopium tangopium commented Feb 17, 2026

Summary

  • Fix channel "last" fallback in delivery-target.ts so resolveSessionDeliveryTarget resolves the correct fallback channel internally, rather than relying on the caller to shadow a stale value
  • Omit allowMismatchedLastTo from the fallback call to prevent cross-channel to leakage (e.g. a WhatsApp E.164 number reaching the Telegram API)
  • Loosen the no-target early-return in run.ts so the announce flow can proceed when synthesized text is available but no direct to was resolved
  • Bypass the agent-enabled check in heartbeat-runner.ts for cron-triggered heartbeats, so non-default agents can run

Test plan

  • src/cron/isolated-agent.channel-last-resolution.test.ts — 4 new tests (no history, unconfigured channel, normal + best-effort)
  • src/cron/isolated-agent/delivery-target.test.ts — 5 existing tests pass
  • src/cron/service.runs-one-shot-main-job-disables-it.test.ts — 13 tests pass (1 new)
  • src/cron/service.non-default-agent-main-job.test.ts — 3 new tests
  • src/infra/heartbeat-runner.returns-default-unset.test.ts — 35 tests pass (2 new)

Fixes #17905

Greptile Summary

Fixes cross-channel delivery leakage and fallback resolution for cron jobs using channel "last", and enables cron-triggered heartbeats for non-default agents.

  • delivery-target.ts: Adds verification that a session's "last" channel is actually configured before using it. When it isn't (e.g. WhatsApp removed from config but still referenced in session), falls back to a configured channel. The fallback call now passes requestedChannel: fallbackChannel instead of "last", and omits allowMismatchedLastTo to prevent cross-channel to leakage (e.g. a WhatsApp E.164 number reaching Telegram).
  • run.ts: Loosens the no-target early return so the announce flow can proceed when synthesizedText is available but no direct to was resolved. Adds a resolvedDelivery.to guard before attempting direct outbound delivery, ensuring the code falls through to the announce path when there's no target address.
  • heartbeat-runner.ts: Bypasses the isHeartbeatEnabledForAgent and resolveHeartbeatIntervalMs checks when the heartbeat is triggered by cron (reason starts with "cron:"), allowing non-default agents to run cron-scheduled heartbeats even without explicit heartbeat config.
  • Tests: Adds comprehensive coverage for all changes — 4 new channel-last resolution tests, 3 non-default agent main-session tests, 1 new service test for agentId propagation, and 2 new heartbeat-runner tests.

Confidence Score: 4/5

  • This PR is safe to merge — it fixes real bugs with cross-channel delivery leakage and non-default agent heartbeats, with thorough test coverage.
  • The changes are well-scoped bug fixes addressing specific failure modes (cross-channel to leakage, unconfigured channel fallback, non-default agent cron heartbeats). The logic changes in delivery-target.ts, run.ts, and heartbeat-runner.ts are correct and consistent with the existing codebase patterns. Test coverage is comprehensive. Minor score reduction because the fallback plugin registry check in the delivery-target.ts catch block (line 72) checks plugin registration rather than account configuration, which is a known narrow edge case already discussed in prior review threads.
  • src/cron/isolated-agent/delivery-target.ts — the catch block fallback path (lines 68-78) uses plugin registry rather than configured account checks, a trade-off already noted in prior review discussion.

Last reviewed commit: 6213a85

…target

When channel "last" resolved to an unconfigured channel, the second
resolveSessionDeliveryTarget call passed requestedChannel: "last", causing
the function to re-resolve to the stale lastChannel and ignore the
fallbackChannel parameter. The caller then shadowed the stale value,
making the fix fragile.

Pass requestedChannel: fallbackChannel so the function resolves the
correct channel internally. Omit allowMismatchedLastTo from the fallback
call to prevent cross-channel `to` leakage (e.g. a WhatsApp E.164 number
reaching the Telegram API).

Fixes openclaw#17905
…nconfigured (openclaw#17905)

Cron jobs with `delivery: { channel: "last" }` failed when the session's
last channel was not configured (e.g. whatsapp removed) or had no
deliverable session history (e.g. last provider was webchat).

In run.ts, loosen the no-target early-return so the announce flow can
proceed when synthesized text is available but no direct `to` was
resolved. Gate the direct-delivery path on `resolvedDelivery.to` to
prevent it from running without a target.

Add tests covering both scenarios (no session history, unconfigured
channel) in normal and best-effort modes.
…ats (openclaw#17905)

When a cron job triggers a heartbeat for a non-default agent,
runHeartbeatOnce skipped it because isHeartbeatEnabledForAgent returned
false (only the default agent or agents with explicit heartbeat config
pass that check).

Detect cron-triggered runs via the "cron:" reason prefix and bypass
the agent-enabled and interval checks, since the cron scheduler is the
authority on when these heartbeats should fire.
…nclaw#17905)

Verify that cron enqueues system events with the correct agentId for
non-default agents, and that the heartbeat runner processes them
when triggered via cron (bypassing the agent-enabled check).
Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

7 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

Comment on lines 68 to 77
} catch {
// resolveMessageChannelSelection failed (e.g. no configured accounts).
// Check plugin registry directly — if the session channel has no
// registered plugin, fall back to the first available outbound plugin.
if (!getChannelPlugin(preliminary.channel)) {
const alt = listChannelPlugins().find((p) => p.outbound);
if (alt) {
fallbackChannel = alt.id as Exclude<OutboundChannel, "none">;
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Plugin registry vs. configuration check

The catch block here handles the case where resolveMessageChannelSelection throws. One reason it throws is zero configured channels, but another is multiple configured channels (see channel-selection.ts:89). In the multiple-channels case, preliminary.channel might reference an unconfigured channel (e.g. Signal removed), but getChannelPlugin checks the plugin registry (all registered plugins, regardless of account configuration), not whether the channel has a valid configured account. So if a channel has a registered plugin but no configured account, this branch won't trigger the fallback.

This is a narrow edge case (multiple configured channels + unconfigured session channel + plugin still registered), so it's not blocking, but worth noting for future robustness. A potential improvement would be to check against listConfiguredMessageChannels or similar instead of getChannelPlugin.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/cron/isolated-agent/delivery-target.ts
Line: 68:77

Comment:
**Plugin registry vs. configuration check**

The catch block here handles the case where `resolveMessageChannelSelection` throws. One reason it throws is zero configured channels, but another is _multiple_ configured channels (see `channel-selection.ts:89`). In the multiple-channels case, `preliminary.channel` might reference an unconfigured channel (e.g. Signal removed), but `getChannelPlugin` checks the _plugin registry_ (all registered plugins, regardless of account configuration), not whether the channel has a valid configured account. So if a channel has a registered plugin but no configured account, this branch won't trigger the fallback.

This is a narrow edge case (multiple configured channels + unconfigured session channel + plugin still registered), so it's not blocking, but worth noting for future robustness. A potential improvement would be to check against `listConfiguredMessageChannels` or similar instead of `getChannelPlugin`.

How can I resolve this? If you propose a fix, please make it concise.

@alaindimabuyo
Copy link

@greptileai please review

@tangopium tangopium force-pushed the fix/cron-channel-last-fallback branch 2 times, most recently from 1027b78 to daea50d Compare February 17, 2026 08:43
@tangopium tangopium force-pushed the fix/cron-channel-last-fallback branch from daea50d to 9b1d833 Compare February 17, 2026 08:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cron jobs: multiple delivery and scheduling bugs (channel resolution, non-default agents, silent failures)

2 participants

Comments