mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-22 16:25:58 +00:00
A final response generated but not confirmed-delivered was the one artifact the gateway could lose without a trace: crash or planned restart between finalize and platform ACK dropped it silently, and the resume path re-ran the whole turn at full cost (#58818 P1, #41696, #63695's gateway half). gateway/delivery_ledger.py records each outbound final response in state.db (same conventions as the async-delegation ledger: WAL, owner pid + process-start-time liveness, bounded retention): pending -> attempting -> delivered | failed startup sweep on dead-owner rows -> redeliver | abandoned Contract (the lessons from the closed delivery-outbox attempt #61790): - obligation recorded BEFORE the first send attempt; cleared only on SendResult.success (destination acceptance, #51184) - ambiguity is labeled, never silently retried: rows that were mid-send when the process died redeliver with a visible '♻️ Recovered reply — may be a duplicate' prefix (honest at-least-once) - stable ids from session_key + inbound message id + content, so distinct threads/topics can never collide - poison rows bounded: 3 attempts / 24h freshness -> abandoned; claim atomically re-stamps ownership so racing sweeps can't double-claim - redelivery clears resume_pending for the session so the resume path never re-runs a turn whose answer the ledger already holds - best-effort everywhere: ledger failure can never block or delay a send - slash-command/ephemeral/empty responses are not recorded; cron and proactive delivery stay on DeliveryRouter (separate subsystem) Config: gateway.delivery_ledger (default on; no version bump needed). Validation: 30 ledger+producer tests; 352 blast-radius gateway tests green; cross-process E2E (record in process A, kill it mid-send, claim + marker + redeliver in a fresh process B against the same state.db).
283 lines
9.5 KiB
Python
283 lines
9.5 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()
|