fix(gateway): retry failed kanban notifications - #44338
Conversation
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.
1c18eb9 to
b6fe83f
Compare
Review: Unbounded retry loop for permanently dead chatsThe PR removes the subscription-dropping mechanism ( Impact: For permanently unreachable chats (bot kicked, channel deleted, etc.), the notifier will retry 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>
|
Thanks for catching this — you're right that dropping the unsubscribe path left What changed:
Why not the tick-modulo gate from the suggestion: Added two tests: A proper dead-letter path (adapters classifying permanent errors like 403 bot-kicked in |
|
Verification: Code Review ✅ Reviewed the diff — the implementation is solid:
|
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! |
|
@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! |
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>
…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
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.
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)
…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.
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)
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)
Summary
SendResult(success=False)from platform adapters as a Kanban notifier delivery failure.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
tests/gateway/test_kanban_notifier.pypass.Manual verification
Beyond the unit tests, this change has been running on a live multi-profile
hermes gateway rundeployment since 2026-06-12.gateway.logshows the new path exercised in production against the WeChat/iLink adapter under real rate-limiting: subscriptions for taskst_9a53bfd4/t_49129a54hit 11–14 consecutiveSendResult-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
time.monotonic), no file I/O / process / terminal changes;check-windows-footguns.pyN/A.