hermes-agent/tests/gateway/test_delivery_ledger.py
Drexuxux 371ee065fd fix(gateway): don't spend a redelivery attempt when the platform is down
The delivery ledger durably records a final response before the send so a
crash between finalize and platform ACK can redeliver it on the next boot.
attempts is that redelivery budget, capped at MAX_ATTEMPTS=3.

sweep_recoverable() claims every dead-owner row and increments attempts
before the caller knows whether it can send. self.adapters only holds a
platform after its connect() succeeded, so when the platform failed to
connect this boot _redeliver_pending_obligations() hits its "adapter is
None" branch and continues WITHOUT sending — but the attempt is already
spent. Three such boots and the row abandons, having never been sent once.

That is the loss the ledger exists to prevent, and the trigger correlates
with the crash that created the obligation: the network trouble that killed
the send tends to still be there on the next boot. Worse, the message stays
lost — once abandoned it is never retried even after the platform recovers.

Reproduced against the real runner with an unconnected adapter:

    boot 1: claimed=1 state='attempting' attempts=1  (0 sends attempted)
    boot 2: claimed=1 state='attempting' attempts=2  (0 sends attempted)
    boot 3: claimed=1 state='attempting' attempts=3  (0 sends attempted)
    boot 4: claimed=0 state='abandoned'  attempts=3  (0 sends attempted)

Let the caller declare which platforms it can send on, and skip claiming
rows for the others. attempts then only ever buys a real send. Rows for a
platform that never returns are still bounded by the stale cutoff, so
nothing accumulates. The parameter is keyword-only and optional — omitting
it keeps the previous claim-everything behaviour for other callers.
2026-07-20 11:52:41 +05:30

404 lines
14 KiB
Python

"""Tests for the gateway delivery-obligation ledger (gateway/delivery_ledger.py).
State machine, dead-owner claiming, attempts cap, stale cutoff, retention,
id stability, and the startup redelivery sweep's contract:
- pending rows redeliver plainly (send never started, no dup risk)
- attempting/failed rows carry the recovered-reply marker (honest
at-least-once; ambiguity is labeled, never silently resent)
- rows owned by a LIVE process are never claimed
- poison rows abandon at the attempts cap / stale cutoff
"""
import time
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from gateway import delivery_ledger as dl
@pytest.fixture(autouse=True)
def _fresh_db(tmp_path, monkeypatch):
"""Isolated state.db per test (autouse HERMES_HOME isolation already
redirects get_hermes_home; make the redirect explicit and per-test)."""
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setattr(dl, "_db_path", lambda: home / "state.db")
yield
def _record(oid="ob-1", session_key="agent:main:slack:channel:C1", **kw):
dl.record_obligation(
obligation_id=oid,
session_key=session_key,
platform=kw.get("platform", "slack"),
chat_id=kw.get("chat_id", "C1"),
thread_id=kw.get("thread_id", "171.001"),
content=kw.get("content", "the final answer"),
)
def _row(oid):
with dl._connect() as conn:
r = conn.execute(
"""SELECT state, attempts, owner_pid, content
FROM delivery_obligations WHERE obligation_id=?""",
(oid,),
).fetchone()
return None if r is None else {
"state": r[0], "attempts": r[1], "owner_pid": r[2], "content": r[3],
}
def _orphan(oid):
"""Make the row look like it belongs to a dead process."""
with dl._connect() as conn:
conn.execute(
"UPDATE delivery_obligations SET owner_pid=999999999, "
"owner_started_at=1 WHERE obligation_id=?",
(oid,),
)
class TestStateMachine:
def test_record_starts_pending(self):
_record()
assert _row("ob-1")["state"] == "pending"
def test_full_happy_path(self):
_record()
dl.mark_attempting("ob-1")
assert _row("ob-1")["state"] == "attempting"
dl.mark_delivered("ob-1")
assert _row("ob-1")["state"] == "delivered"
def test_failed_records_error(self):
_record()
dl.mark_attempting("ob-1")
dl.mark_failed("ob-1", "chat_not_found")
assert _row("ob-1")["state"] == "failed"
def test_rerecord_same_id_is_idempotent(self):
_record()
dl.mark_attempting("ob-1")
_record() # INSERT OR REPLACE resets to pending — same turn re-record
assert _row("ob-1")["state"] == "pending"
class TestObligationId:
def test_stable_and_distinct(self):
a = dl.compute_obligation_id("sk1", "msg1", "hello")
assert a == dl.compute_obligation_id("sk1", "msg1", "hello")
# Different thread (baked into session_key) → different id. This is
# the cron-topic collision class from the earlier outbox attempt.
assert a != dl.compute_obligation_id("sk1:threadB", "msg1", "hello")
assert a != dl.compute_obligation_id("sk1", "msg2", "hello")
assert a != dl.compute_obligation_id("sk1", "msg1", "other")
assert len(a) == 24
class TestSweep:
def test_live_owner_rows_never_claimed(self):
_record() # owner = this (live) process
assert dl.sweep_recoverable() == []
def test_dead_owner_pending_claimed_without_marker(self):
_record()
_orphan("ob-1")
claimed = dl.sweep_recoverable()
assert len(claimed) == 1
assert claimed[0]["needs_marker"] is False
assert claimed[0]["attempts"] == 1
# Claim re-stamps ownership: a second sweep in the same (live)
# process must not double-claim.
assert dl.sweep_recoverable() == []
def test_dead_owner_attempting_needs_marker(self):
_record()
dl.mark_attempting("ob-1")
_orphan("ob-1")
claimed = dl.sweep_recoverable()
assert claimed[0]["needs_marker"] is True
def test_dead_owner_failed_needs_marker(self):
_record()
dl.mark_failed("ob-1", "boom")
_orphan("ob-1")
claimed = dl.sweep_recoverable()
assert claimed[0]["needs_marker"] is True
def test_delivered_rows_ignored(self):
_record()
dl.mark_delivered("ob-1")
_orphan("ob-1")
assert dl.sweep_recoverable() == []
def test_attempts_cap_abandons(self):
_record()
_orphan("ob-1")
with dl._connect() as conn:
conn.execute(
"UPDATE delivery_obligations SET attempts=? WHERE obligation_id=?",
(dl.MAX_ATTEMPTS, "ob-1"),
)
assert dl.sweep_recoverable() == []
assert _row("ob-1")["state"] == "abandoned"
def test_stale_cutoff_abandons(self):
_record()
_orphan("ob-1")
future = time.time() + dl.STALE_AFTER_SECONDS + 60
assert dl.sweep_recoverable(now=future) == []
assert _row("ob-1")["state"] == "abandoned"
class TestPrune:
def test_old_delivered_rows_pruned(self):
_record()
dl.mark_delivered("ob-1")
with dl._connect() as conn:
conn.execute(
"UPDATE delivery_obligations SET updated_at=? WHERE obligation_id=?",
(time.time() - dl._RETENTION_SECONDS - 60, "ob-1"),
)
dl._prune()
assert _row("ob-1") is None
def test_undelivered_rows_survive_retention(self):
_record()
with dl._connect() as conn:
conn.execute(
"UPDATE delivery_obligations SET updated_at=? WHERE obligation_id=?",
(time.time() - dl._RETENTION_SECONDS - 60, "ob-1"),
)
dl._prune()
assert _row("ob-1") is not None
class TestLedgerEnabled:
def test_default_on(self):
assert dl.ledger_enabled({}) is True
assert dl.ledger_enabled({"gateway": {}}) is True
def test_explicit_off(self):
assert dl.ledger_enabled({"gateway": {"delivery_ledger": False}}) is False
assert dl.ledger_enabled({"gateway": {"delivery_ledger": "off"}}) is False
def test_truthy_strings(self):
assert dl.ledger_enabled({"gateway": {"delivery_ledger": "true"}}) is True
class TestGatewayRedeliverySweep:
"""Drive the real GatewayRunner._redeliver_pending_obligations."""
@staticmethod
def _runner(adapter=None):
from gateway.config import Platform
from gateway.run import GatewayRunner
runner = object.__new__(GatewayRunner)
runner.adapters = {Platform.SLACK: adapter} if adapter else {}
_store = MagicMock()
_store.clear_resume_pending = AsyncMock()
_store._store = None
runner.session_store = None
runner._async_session_store = _store
return runner
@staticmethod
def _adapter(success=True):
adapter = MagicMock()
adapter.send = AsyncMock(
return_value=MagicMock(success=success, error="" if success else "nope")
)
return adapter
@pytest.mark.asyncio
async def test_pending_redelivers_plain_and_clears_resume(self):
_record() # pending
_orphan("ob-1")
adapter = self._adapter()
runner = self._runner(adapter)
n = await runner._redeliver_pending_obligations()
assert n == 1
sent = adapter.send.call_args.kwargs
assert sent["content"] == "the final answer" # no marker
assert sent["metadata"] == {"thread_id": "171.001"}
assert _row("ob-1")["state"] == "delivered"
runner._async_session_store.clear_resume_pending.assert_awaited_once_with(
"agent:main:slack:channel:C1"
)
@pytest.mark.asyncio
async def test_attempting_redelivers_with_marker(self):
_record()
dl.mark_attempting("ob-1")
_orphan("ob-1")
adapter = self._adapter()
runner = self._runner(adapter)
await runner._redeliver_pending_obligations()
sent = adapter.send.call_args.kwargs
assert sent["content"].startswith(dl.RECOVERED_MARKER)
assert sent["content"].endswith("the final answer")
@pytest.mark.asyncio
async def test_send_failure_marks_failed_for_next_boot(self):
_record()
_orphan("ob-1")
runner = self._runner(self._adapter(success=False))
n = await runner._redeliver_pending_obligations()
assert n == 0
assert _row("ob-1")["state"] == "failed"
@pytest.mark.asyncio
async def test_missing_adapter_leaves_row_recoverable(self):
_record()
_orphan("ob-1")
runner = self._runner(adapter=None) # slack not connected
n = await runner._redeliver_pending_obligations()
assert n == 0
# Row still claimed by us but NOT delivered/abandoned — a later boot
# (attempts cap permitting) can retry once the platform connects.
assert _row("ob-1")["state"] == "pending"
@pytest.mark.asyncio
async def test_disabled_gate_short_circuits(self):
_record()
_orphan("ob-1")
adapter = self._adapter()
runner = self._runner(adapter)
with patch.object(dl, "ledger_enabled", return_value=False), patch(
"gateway.delivery_ledger.ledger_enabled", return_value=False
):
n = await runner._redeliver_pending_obligations()
assert n == 0
adapter.send.assert_not_awaited()
class TestAttemptsOnlySpentOnRealSends:
"""``attempts`` is the redelivery budget — it must buy a send.
``self.adapters`` only holds a platform after its ``connect()`` succeeded,
and the sweep claimed every dead-owner row regardless. A platform that
failed to connect this boot therefore burned one attempt per boot while
the caller's ``adapter is None`` branch skipped it without sending — so
after MAX_ATTEMPTS boots the row abandoned having never been sent once,
losing exactly the response the ledger exists to guarantee. That failure
correlates with the crash that created the obligation: the network
trouble that killed the send tends to still be there on the next boot.
"""
def test_absent_platform_does_not_burn_attempts(self):
_record(platform="telegram")
dl.mark_attempting("ob-1")
for _ in range(dl.MAX_ATTEMPTS + 2):
_orphan("ob-1")
assert dl.sweep_recoverable(deliverable_platforms={"discord"}) == []
row = dl.debug_rows()
assert "abandoned" not in row
with dl._connect() as conn:
state, attempts = conn.execute(
"SELECT state, attempts FROM delivery_obligations "
"WHERE obligation_id=?", ("ob-1",),
).fetchone()
assert attempts == 0, "an unsendable boot must not spend the budget"
assert state == "attempting"
def test_row_still_delivers_once_its_platform_returns(self):
_record(platform="telegram")
for _ in range(dl.MAX_ATTEMPTS + 2):
_orphan("ob-1")
dl.sweep_recoverable(deliverable_platforms={"discord"})
_orphan("ob-1")
claimed = dl.sweep_recoverable(deliverable_platforms={"telegram"})
assert len(claimed) == 1
assert claimed[0]["attempts"] == 1
def test_present_platform_still_claims(self):
_record(platform="slack")
_orphan("ob-1")
claimed = dl.sweep_recoverable(deliverable_platforms={"slack"})
assert len(claimed) == 1
def test_omitting_the_filter_claims_everything(self):
"""Back-compat: existing callers pass no platform set."""
_record(platform="telegram")
_orphan("ob-1")
assert len(dl.sweep_recoverable()) == 1
def test_stale_rows_abandon_even_when_undeliverable(self):
"""The cutoff still bounds rows whose platform never returns."""
_record(platform="telegram")
_orphan("ob-1")
future = time.time() + dl.STALE_AFTER_SECONDS + 10
assert dl.sweep_recoverable(
now=future, deliverable_platforms={"discord"}
) == []
with dl._connect() as conn:
state = conn.execute(
"SELECT state FROM delivery_obligations WHERE obligation_id=?",
("ob-1",),
).fetchone()[0]
assert state == "abandoned"
class TestUnconnectedPlatformKeepsItsBudget:
"""End-to-end through the real runner: boots where the platform failed to
connect must not consume the row's redelivery budget."""
@staticmethod
def _runner_without_slack():
from gateway.run import GatewayRunner
runner = object.__new__(GatewayRunner)
runner.adapters = {} # slack failed to connect this boot
_store = MagicMock()
_store.clear_resume_pending = AsyncMock()
_store._store = None
runner.session_store = None
runner._async_session_store = _store
return runner
@pytest.mark.asyncio
async def test_row_survives_boots_where_its_platform_is_down(self):
_record(platform="slack")
dl.mark_attempting("ob-1")
for _ in range(dl.MAX_ATTEMPTS + 1):
_orphan("ob-1")
runner = self._runner_without_slack()
assert await runner._redeliver_pending_obligations() == 0
assert _row("ob-1")["state"] != "abandoned", (
"the obligation was abandoned without a single send being attempted"
)
assert _row("ob-1")["attempts"] == 0
@pytest.mark.asyncio
async def test_delivers_when_the_platform_comes_back(self):
from gateway.config import Platform
_record(platform="slack")
for _ in range(dl.MAX_ATTEMPTS + 1):
_orphan("ob-1")
await self._runner_without_slack()._redeliver_pending_obligations()
_orphan("ob-1")
adapter = MagicMock()
adapter.send = AsyncMock(return_value=MagicMock(success=True, error=""))
runner = self._runner_without_slack()
runner.adapters = {Platform.SLACK: adapter}
assert await runner._redeliver_pending_obligations() == 1
assert _row("ob-1")["state"] == "delivered"