Skip to content

fix(gateway): retry failed kanban notifications - #44338

Closed
coderlaoma wants to merge 4 commits into
NousResearch:mainfrom
coderlaoma:fix/kanban-notifier-delivery-failures
Closed

fix(gateway): retry failed kanban notifications#44338
coderlaoma wants to merge 4 commits into
NousResearch:mainfrom
coderlaoma:fix/kanban-notifier-delivery-failures

Conversation

@coderlaoma

@coderlaoma coderlaoma commented Jun 11, 2026

Copy link
Copy Markdown

Summary

  • Treat SendResult(success=False) from platform adapters as a Kanban notifier delivery failure.
  • Rewind claimed notification cursors on failed sends so terminal events remain retryable.
  • Keep subscriptions after repeated send failures instead of silently dropping them.

Why

A platform adapter can report API-level send failures by returning SendResult(success=False) without raising. The notifier previously treated that as delivered, allowing blocked/completed Kanban notifications to be silently consumed. Repeated raised send failures could also drop the subscription, leaving no retry path.

Test Plan

  • uv run --with pytest --with pytest-asyncio --with pyyaml python -m pytest tests/gateway/test_kanban_notifier.py -q -o 'addopts='

Platforms tested

  • Linux — Ubuntu 24.04.4 LTS, Python 3.11 (CPython, uv venv). 10/10 tests in tests/gateway/test_kanban_notifier.py pass.

Manual verification

Beyond the unit tests, this change has been running on a live multi-profile hermes gateway run deployment since 2026-06-12. gateway.log shows the new path exercised in production against the WeChat/iLink adapter under real rate-limiting: subscriptions for tasks t_9a53bfd4 / t_49129a54 hit 11–14 consecutive SendResult-level failures, backed off (2560s → capped 3600s) instead of being dropped, and successfully delivered the completed-event notification once the rate limit cleared — exactly the "retryable instead of silently consumed" behavior this PR targets.

Cross-platform / Security impact

  • No cross-platform impact: pure-Python timer logic (time.monotonic), no file I/O / process / terminal changes; check-windows-footguns.py N/A.
  • No security impact: no shell / path / privilege surface touched.

Treat SendResult(success=False) as a delivery failure so terminal Kanban events are not silently consumed. Keep subscriptions after repeated send failures and rewind the cursor for retry instead of dropping the subscription.
@coderlaoma
coderlaoma force-pushed the fix/kanban-notifier-delivery-failures branch from 1c18eb9 to b6fe83f Compare June 11, 2026 15:05
@alt-glitch alt-glitch added type/bug Something isn't working P3 Low — cosmetic, nice to have comp/gateway Gateway runner, session dispatch, delivery labels Jun 11, 2026
@liuhao1024

Copy link
Copy Markdown
Contributor

Review: Unbounded retry loop for permanently dead chats

The PR removes the subscription-dropping mechanism (_kanban_unsub + sub_fail_counts.pop) when fails >= MAX_SEND_FAILURES, replacing it with a warning log. However, MAX_SEND_FAILURES = 3 is still defined and the counter still increments — it just no longer triggers any action.

Impact: For permanently unreachable chats (bot kicked, channel deleted, etc.), the notifier will retry adapter.send() every 5 seconds indefinitely. The warning log fires every tick once fails >= 3, generating unbounded log spam.

Suggestion: Keep the cursor-rewind behavior for the first N retries (transient failures), but add a backoff or dead-letter mechanism after repeated failures:

if fails >= MAX_SEND_FAILURES:
    # Exponential backoff: skip ticks proportional to failure count
    if fails % (2 ** min(fails - MAX_SEND_FAILURES, 6)) != 0:
        break
    logger.warning(
        "kanban notifier: subscription %s on %s has %d "
        "consecutive failures; retrying with backoff",
        sub["task_id"], platform_str, fails,
    )

This preserves the "never silently drop" intent while bounding retry frequency for dead chats.

Review follow-up: dropping the unsubscribe-after-3-failures path left
MAX_SEND_FAILURES as a dead counter — a permanently dead chat (bot
kicked, channel deleted) would burn an adapter.send call and a warning
log line every 5s tick, forever.

Keep the no-silent-drop semantics but add per-subscription exponential
backoff: the first MAX_SEND_FAILURES failures retry on the normal tick
cadence (covers transient errors); after that, each failure doubles the
retry delay (capped at 1h). Backoff is gated before the cursor claim,
so suppressed ticks do no claim/rewind churn and emit no logs. Any
successful send clears the state, restoring the normal cadence.

Note: backoff keys off a next_retry_at timestamp rather than tick-count
modulo on the failure counter — the counter only advances on actual
attempts, so a modulo gate would deadlock once it lands on a non-zero
residue and never retry again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderlaoma

Copy link
Copy Markdown
Author

Thanks for catching this — you're right that dropping the unsubscribe path left MAX_SEND_FAILURES as a dead counter, and a permanently dead chat would hit adapter.send() plus a warning line every 5s tick forever. Fixed in 45459de.

What changed:

  • Replaced the bare failure counter with per-subscription state: {fails, next_retry_at}.
  • First MAX_SEND_FAILURES (3) failures retry on the normal tick cadence, preserving fast recovery from transient errors.
  • Beyond that, each failure doubles the retry delay (interval * 2^(fails - MAX + 1), capped at 1h). For a dead chat this means the send attempts and log lines decay to ~1/hour steady state instead of every 5s.
  • The backoff gate sits before the cursor claim in _collect, so suppressed ticks do zero work: no claim, no rewind churn on the DB, no log output.
  • Any successful send pops the state, restoring normal cadence — so the no-silent-drop semantics of the PR are intact: the subscription and the unseen event survive indefinitely, and delivery recovers as soon as the chat does.

Why not the tick-modulo gate from the suggestion: fails only increments on actual send attempts, so fails % (2 ** min(fails - MAX_SEND_FAILURES, 6)) != 0 → break deadlocks once fails lands on a non-zero residue — e.g. at fails=5, 5 % 4 != 0 skips the tick, but skipping never changes fails, so every subsequent tick re-evaluates the same expression and breaks forever. That would silently stop retrying, which is exactly what this PR set out to avoid. Backing off on a time.monotonic() deadline avoids that class of bug entirely.

Added two tests: test_kanban_notifier_backs_off_after_repeated_send_failures (window suppresses sends entirely, retry resumes after expiry, backoff grows) and test_kanban_notifier_recovers_after_backoff_when_chat_comes_back (success during a backoff retry delivers and clears state). Full tests/gateway/ suite run locally — the 10 failures are pre-existing on the base branch (telegram markdown-escaping / wecom callback / agent-cache mtime), unrelated to this change.

A proper dead-letter path (adapters classifying permanent errors like 403 bot-kicked in SendResult.error, then unsubscribing immediately) would be the real fix for dead chats, but it needs per-platform adapter changes — happy to follow up in a separate PR if you think it's worth it.

@liuhao1024

Copy link
Copy Markdown
Contributor

Verification: Code Review ✅

Reviewed the diff — the implementation is solid:

  • Exponential backoff is correctly bounded: delay = min(interval * (2 ** (fails - MAX_SEND_FAILURES + 1)), MAX_RETRY_DELAY) with MAX_RETRY_DELAY = 3600.0 (1 hour cap). This prevents both infinite retry spam and unbounded backoff growth.

  • Backoff skips before claiming: The if time.monotonic() < fail_state["next_retry_at"] check happens before claim_unseen_events_for_sub, so the cursor is untouched during backoff windows. This avoids claim/rewind churn on dead chats — correct design.

  • SendResult(success=False) handled: The new if getattr(send_result, "success", True) is False check converts API-level delivery failures (which some adapters return instead of raising) into RuntimeError so they enter the same failure-counting path. Good catch.

  • Subscription never silently dropped: The old _kanban_unsub call on repeated failures is removed. The subscription stays alive and the event stays unseen — the user eventually gets their blocked/completed notification when the chat recovers. This is the correct "never silently consume a terminal notification" policy.

  • Test coverage: 4 new tests cover SendResult(success=False), repeated-failure subscription retention, backoff window suppression, and post-backoff recovery. The state["next_retry_at"] = time.monotonic() - 1 trick to expire the window in tests is clean.

  • No issues found. The design correctly balances "never drop notifications" with "don't hammer dead chats."

@coderlaoma

coderlaoma commented Jun 13, 2026

Copy link
Copy Markdown
Author

8 workflows awaiting approval

Hi @liuhao1024 , thanks again for reviewing the PR.

I noticed that the Checks tab shows 8 workflows awaiting approval for this PR, so CI has not started yet. Could you please help approve the workflow runs when you have a chance, or let me know if there is anything I need to do on my side?

Thanks!

@coderlaoma

coderlaoma commented Jun 15, 2026

Copy link
Copy Markdown
Author

@alt-glitch When you have a moment, could you approve the pending workflow runs for this PR? CI hasn't been triggered yet since it's a first-time contribution from a fork. The change is review-approved and ready — thanks!

@coderlaoma coderlaoma closed this Jun 18, 2026
@coderlaoma
coderlaoma deleted the fix/kanban-notifier-delivery-failures branch June 18, 2026 15:55
cwest added a commit to cwest/hermes-agent that referenced this pull request Jun 18, 2026
Treat a SendResult(success=False) from adapter.send as a delivery failure,
not a delivered ping. Some platform adapters (e.g. matrix) surface an
API-level delivery failure by returning SendResult(success=False) rather
than raising, so the kanban notifier was advancing the cursor and silently
consuming a terminal blocked/completed event the human never saw.

The notifier now inspects the send result, raises on success=False, and on
any send failure keeps the subscription alive and rewinds the pre-send claim
so the event is retried on a later tick. A permanently dead chat no longer
drops the subscription; instead, after MAX_SEND_FAILURES consecutive
failures the subscription enters per-subscription exponential backoff
(doubling per failure, capped at 1h) gated before the cursor claim, so a
dead chat does not burn an adapter.send call and a warning line every tick
forever, while a chat that recovers still gets the notification.

Ported by hand onto v2026.6.5: upstream refactored the notifier into
gateway/kanban_watchers.py, but on this base the loop lives in
GatewayRunner._kanban_notifier_watcher in gateway/run.py. The per-sub
failure counter (_kanban_sub_fail_counts) becomes a state dict
(_kanban_sub_fail_states) carrying {fails, next_retry_at}; test helpers
updated to match. Adds four notifier tests: false-result delivery, keep
subscription after repeated failures, backoff window suppression, and
recovery after backoff.

Carries upstream PR NousResearch#44338 (upstream-pending) — the SendResult-non-delivery
fix only. The PR's follow-up child-event escalation work (commits cc328e2,
2021d6b) is not included here.

Upstream-PR: NousResearch#44338
Cherry-picked-from: b6fe83f (upstream)
Cherry-picked-from: 45459de (upstream)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
cwest added a commit to cwest/hermes-agent that referenced this pull request Jun 18, 2026
…stream-pending

Add the upstream-pending row for the SendResult-non-delivery fix carried in
the previous commit (70bb715). Base tag v2026.6.5. Auto-retires when
NousResearch#44338 lands in a tagged release at or above the base.

Upstream-PR: NousResearch#44338
cwest added a commit to cwest/hermes-agent that referenced this pull request Jun 18, 2026
Casey decided to carry only PR NousResearch#44338's SendResult-non-delivery fix and
leave its child-to-ancestor escalation feature behind, because the base
tag v2026.6.5 predates upstream's notifier refactor and lacks every
prerequisite the escalation feature needs.

Scope the PATCHES.md row honestly (carried commits vs. left-behind
commits, the reason, the auto-retire trigger) and add a dedicated
decision record under docs/patches/ so a future maintainer can see the
boundary and its re-evaluation trigger at a glance.
cwest added a commit to cwest/hermes-agent that referenced this pull request Jun 21, 2026
Treat a SendResult(success=False) from adapter.send as a delivery failure,
not a delivered ping. Some platform adapters (e.g. matrix) surface an
API-level delivery failure by returning SendResult(success=False) rather
than raising, so the kanban notifier was advancing the cursor and silently
consuming a terminal blocked/completed event the human never saw.

The notifier now inspects the send result, raises on success=False, and on
any send failure keeps the subscription alive and rewinds the pre-send claim
so the event is retried on a later tick. A permanently dead chat no longer
drops the subscription; instead, after MAX_SEND_FAILURES consecutive
failures the subscription enters per-subscription exponential backoff
(doubling per failure, capped at 1h) gated before the cursor claim, so a
dead chat does not burn an adapter.send call and a warning line every tick
forever, while a chat that recovers still gets the notification.

Re-ported by hand onto v2026.6.19: upstream's god-file Phase 3 refactor
extracted the notifier loop out of GatewayRunner._kanban_notifier_watcher in
gateway/run.py into the GatewayKanbanWatchersMixin in
gateway/kanban_watchers.py. The original carry (70bb715) targeted the
loop's old home in gateway/run.py, so the cherry-pick collided with that
deletion; the four behaviors were re-applied to the loop's new home in
gateway/kanban_watchers.py. run.py is taken --ours (the extraction stands).
The per-sub failure counter (_kanban_sub_fail_counts) becomes a state dict
(_kanban_sub_fail_states) carrying {fails, next_retry_at}; test helpers
updated to match. Adds four notifier tests: false-result delivery, keep
subscription after repeated failures, backoff window suppression, and
recovery after backoff.

Carries upstream PR NousResearch#44338 (upstream-pending) — the SendResult-non-delivery
fix only. The PR's follow-up child-event escalation work (commits cc328e2,
2021d6b) is not included here.

Upstream-PR: NousResearch#44338
Cherry-picked-from: b6fe83f (upstream)
Cherry-picked-from: 45459de (upstream)
(cherry picked from commit 70bb715)
cwest added a commit to cwest/hermes-agent that referenced this pull request Jun 21, 2026
…bump base-tag

NousResearch#44338 was closed administratively (fork CI gating), not merged, so the
manifest's default "auto-retire when the PR lands in a release" rule can
never fire for this row — and the only strictly-weaker alternative PR
(NousResearch#45940, detection-only) would regress the keep-alive and backoff behaviors
if the carry were dropped on its merge.

Rewrite the NousResearch#44338 row's retire trigger to be behavior-keyed: retire only
when upstream gateway/kanban_watchers.py implements ALL of (i) SendResult
failure-detection, (ii) keep-subscription-alive-on-permanent-failure, and
(iii) bounded exponential backoff. Watch NousResearch#45940 and NousResearch#46443 but do not drop on
NousResearch#45940 merge alone. Update the port-location note to the post-refactor home
(GatewayKanbanWatchersMixin in gateway/kanban_watchers.py) and bump the row's
base-tag to v2026.6.19. Add a "per-row override" caveat to the global
Auto-retire rule so a future rebaser does not naively apply the PR-merge rule
to a behavior-keyed row.
cwest added a commit to cwest/hermes-agent that referenced this pull request Jul 1, 2026
Treat SendResult(success=False) from adapter.send as a delivery failure (not a
delivered ping), keep the subscription alive on send failure, rewind the
pre-send claim so the terminal blocked/completed event is retried, and back off
per-subscription (exponential, capped at 1h) so a dead chat is not hammered
every tick. Ported into GatewayKanbanWatchersMixin._kanban_notifier_watcher.

upstream-pending: PR NousResearch#44338 (partial carry — SendResult non-delivery only)
cwest added a commit to cwest/hermes-agent that referenced this pull request Jul 26, 2026
Treat SendResult(success=False) from adapter.send as a delivery failure (not a
delivered ping), keep the subscription alive on send failure, rewind the
pre-send claim so the terminal blocked/completed event is retried, and back off
per-subscription (exponential, capped at 1h) so a dead chat is not hammered
every tick. Ported into GatewayKanbanWatchersMixin._kanban_notifier_watcher.

upstream-pending: PR NousResearch#44338 (partial carry — SendResult non-delivery only)
(cherry picked from commit d2dffff)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery P3 Low — cosmetic, nice to have type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants