From 4e966506cff94d344377198d187d46bb59ac050c Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:36:49 +0000 Subject: [PATCH] fix(gateway): release a boot claim whose adapter vanished before dispatch The boot sweep now moves every claimed row to attempting. If the platform went fatal between the claim and the send (restart notification, flood sleep), _obligation_adapter skipped the row without releasing it, so it sat in attempting owned by this live process: the reconnect sweep only takes failed rows and the boot sweep skips live owners, so the reply waited for the next restart (and then carried a false duplicate marker). Release any undispatched claim, not only runtime ones. Found by independent review of #120450. --- gateway/run_startup.py | 8 ++++---- tests/gateway/test_delivery_ledger.py | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/gateway/run_startup.py b/gateway/run_startup.py index 2ff3b743d1..a4e1448afc 100644 --- a/gateway/run_startup.py +++ b/gateway/run_startup.py @@ -478,13 +478,13 @@ class GatewayStartupMixin: else: # Startup rows preserve the historical default-adapter route. adapter = self.adapters.get(platform) - # A runtime claim whose reconnect vanished before dispatch is released without spending an - # attempt; startup claims stay 'attempting' for the next boot's marked redelivery (attempts cap + - # stale cutoff bound retries). + # A claim (runtime or boot) whose adapter vanished before dispatch was never sent: release it + # without spending an attempt so the reconnect sweep, which only takes 'failed' rows, delivers it + # instead of it waiting 'attempting' for the next restart. # Only a flood row keeps its error (the platform's wait must be honoured); any other row # becomes reconnect-only, or the redelivery timer would claim and release it until the # adapter is back. - if adapter is None and row.get("runtime_recovery"): + if adapter is None: from gateway.delivery_ledger import is_flood_error last_error = row.get("last_error") diff --git a/tests/gateway/test_delivery_ledger.py b/tests/gateway/test_delivery_ledger.py index 91f01c5c01..37a2adb673 100644 --- a/tests/gateway/test_delivery_ledger.py +++ b/tests/gateway/test_delivery_ledger.py @@ -519,6 +519,24 @@ class TestGatewayRedeliverySweep: assert dl.sweep_failed_for_runtime("telegram") == [] + @pytest.mark.parametrize("claimed_state", ["pending", "failed"]) + @pytest.mark.asyncio + async def test_boot_claim_whose_adapter_vanishes_is_left_for_the_reconnect_sweep(self, claimed_state): + """A boot claim never sent (platform went away before dispatch) must not strand in 'attempting'.""" + _record() + if claimed_state == "failed": + dl.mark_failed("ob-1", "send_path_degraded") + _orphan("ob-1") + runner = self._runner(self._adapter()) + claimed = await runner._claim_pending_obligations() + assert [r["obligation_id"] for r in claimed] == ["ob-1"] + runner.adapters = {} # platform went fatal during the restart notification / flood sleep + + await runner._redeliver_claimed_obligations(claimed) + + assert _row("ob-1")["state"] == "failed" and _row("ob-1")["attempts"] == 0 + assert [r["obligation_id"] for r in dl.sweep_failed_for_runtime("slack")] == ["ob-1"] + @pytest.mark.asyncio async def test_runtime_failed_redelivery_clears_resume_before_send(self): from gateway.config import Platform