* fix(gateway): stop frozen-preview finals and dropped idle-session delegation callbacks
Two relay-plane delivery losses from the 2026-08-09 staging incident:
1. stream_consumer: the skip-redundant-finalize branch recorded _accumulated
as the delivered turn-final payload even when the last ACKED edit was an
earlier throttled preview snapshot, so delivered_final_matches reconciled
True and the gateway suppressed the corrective final send — the user was
left with a cut-off message ending in the streaming cursor. Extracted
_mark_skip_redundant_finalize(): records the last acked wire payload
(cursor-stripped), so a preview/final mismatch now returns False and the
normal final send fires.
2. run.py: _classify_completion_target classified every ended parent session
terminal unless it ended by compression. Idle/timeout session ends are the
norm on scale-to-zero relay deployments and the chat route remains valid;
completed async delegation results were terminally dropped. Ended parents
now classify deliver unless the end was an explicit user boundary
(session_reset / user_exit / session_switch).
* fix(relay): drain in-flight outbound frames before transport teardown
disconnect() failed every pending outbound future immediately with
'relay transport closed', so a trailing finalize edit racing turn
teardown was lost even though the connector socket could still serve
it. Bounded drain grace (5s) lets in-flight requests resolve; silent
connectors still tear down promptly. asyncio.wait (not gather+wait_for)
so a timeout doesn't cancel futures owned by the fail-remaining loop.
* fix(gateway): route completion injection through the alias-aware transport resolver
Third relay-plane delivery loss from the 2026-08-09 staging incidents: a
delegation batch completed while the gateway was up, the watcher drained
the event, and delivery vanished with no log line. _inject_watch_notification
resolved its adapter with a literal p.value == platform_name scan of
self.adapters — a relay-fronted gateway registers ONE adapter under
Platform.RELAY fronting N logical platforms, so 'slack' never matched and
the injection returned None ('no gateway route'), silently dropping the
completion. The handoff path already documents this exact trap and uses
resolve_delivery_transport; the injection path now does the same (native
wins; relay eligible only when it fronts the logical platform), with the
literal scan kept as fallback for stub runners and exotic platforms.
* fix(relay): clamp disconnect drain grace to the runner's adapter-disconnect budget
Review finding (JoaoMarcos44, #82592): a fixed 5.0s drain in front of the
three 1.0s sequential teardown awaits gives an 8.0s worst case inside the
runner's 5.0s asyncio.wait_for(adapter.disconnect()) — tripping it cancels
teardown mid-drain, skips the fail-pending loop, and leaves outbound
callers blocked until _OUTBOUND_TIMEOUT_S (30s). The effective grace is
now budget - 3*TEARDOWN - margin (env-aware via the same
HERMES_GATEWAY_ADAPTER_DISCONNECT_TIMEOUT the runner reads), so the drain
can never push teardown past its caller's budget; a budget too small for
any drain disables it cleanly.
* test(gateway): pin the final-send suppression contract across a behaviour matrix
The gateway skips its own final send when the stream consumer claims the turn
final already reached the user. Every incident in that family — #71643 (stale
finalize snapshot), #78541 (payload-less multi-message split), #82656 (frozen
preview left with a visible cursor) — is the same failure: the consumer claimed
delivery for text the platform never rendered, so the corrective send was
suppressed and the answer was lost with no retry.
Each was fixed with a scenario test pinned to one branch of
GatewayStreamConsumer.run(). The got_done handler now has five sibling branches
that each set the suppression flags and record a turn-final payload, and nothing
checks them as a group: a new branch, or a new early `return True` in
_send_or_edit, can reintroduce the class without failing a test.
Pin the invariant instead of the branch — if the consumer offers the gateway any
signal it would trust, the complete final text must have reached the wire — and
assert it across {edit always / dies / never / lies} x {send always / never} x
{fresh-final on / off} x {clean / interrupted stream}.
The adapter records only frames that actually rendered, so an ACK the platform
drops does not count as delivery. 24 honest-transport scenarios hold the
invariant as a hard assertion. The 16 lying-transport scenarios are checked too;
the single combination that still violates it is reported as an expected
failure documenting the open exposure rather than asserting it away.
Refs #82656
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(gateway,relay): prime relay egress routing for synthetic injections + cap stale completion replay
Defect #4 from the 2026-08-09 staging incidents (upgrade-robustness):
after every gateway restart the durable async-delegation replay injected
completions correctly (post-741663cf1) but their replies bounced at the
connector — 'slack egress declined: target not routed to an onboarded
tenant'. The relay adapter re-attaches tenant discriminators
(metadata.scope_id / metadata.user_id) from per-chat caches warmed ONLY by
inbound traffic; synthetic turns race those cold caches on every deploy,
scale-to-zero wake, and crash recovery.
- relay adapter: prime_routing_cache() — feeds a synthetic event's
session-store origin through the same _capture_scope used for real
inbound (never raises).
- run.py injection path: prime the resolved adapter before handle_message
(duck-typed; native adapters unaffected).
- async_delegation: 48h staleness cap in restore_undelivered_completions —
a pending completion older than the cap is terminally dropped (payload
stays queryable) instead of re-run as a fresh full-context turn; the
post-restart replay of a July session burned a 102K-token context.
Also carried: JoaoMarcos44's suppression behaviour-matrix harness
(cherry-picked from #82676, authorship preserved) — 39 passed + 1 xfail
(the documented ACK-then-drop transport-honesty residue).
* test: use recent timestamps in restored-ownership fixtures
test_restore_stamps_restored_flag persisted its completion with epoch-era
toy timestamps (dispatched_at=1.0), which the new 48h replay staleness cap
correctly classifies as stale — the fixture then exercised the cap instead
of the restored-flag contract (CI slice 4 failure). Timestamps are now
now-relative; the staleness behavior itself is pinned separately in
test_relay_injection_egress_priming.py.
* fix(gateway,relay): close four review findings on the relay delivery fixes
Review follow-ups on this branch (NousResearch#82592):
1. HIGH — classifier/resolver mismatch (falsely-acknowledged loss).
_classify_completion_target now returns "deliver" for idle-ended
parents, but _resolve_async_delegation_session still dropped every
non-compression-ended pin: the durable row was acked at adapter
acceptance, then the injection died inside the pipeline with no
retry — strictly worse than the honest terminal drop on main, and
the delivery leg defect #2's fix depends on did not exist. The
resolver now retargets non-user-boundary ends (idle/timeout/
lifecycle) to the chat's current session — session_entry already IS
the routing key's current session for the same chat — while user
boundaries (session_reset / new_session / user_exit /
session_switch) stay fail-closed. Both sides share one module-level
_USER_BOUNDARY_END_REASONS so the verdict and the routing decision
cannot drift again; a coherence test asserts deliver-verdicts
resolve non-None across representative end reasons.
2. HIGH — drain clamp missed adapter-level spend. The effective drain
grace budgeted drain + 3x teardown, but RelayAdapter.disconnect
spends revocation-monitor teardown + go_idle time BEFORE the
transport drain inside the same runner wait_for; worst case still
blew the budget and cancelled teardown mid-drain (skipping the
fail-pending loop). The adapter now measures its own elapsed time
and threads the REMAINING budget into
transport.disconnect(budget_s=...); legacy/stub transports without
the keyword fall back to the no-arg signature.
3. P1 — _request_response racing disconnect() could register a future
after the fail-pending loop already ran, stranding the caller for
the full _OUTBOUND_TIMEOUT_S (30s). Fail fast with the same
"relay transport closed" error once _closing is set.
4. P1 — _build_process_event_source's last-resort reconstruction
dropped scope_id, so a scoped relay completion whose session-store
origin was unavailable primed no tenant discriminator and could
still bounce off the connector's fail-closed egress guard.
scope_id now threads through the reconstructed SessionSource, with
a warning when a scoped chat reconstructs without one.
All four: RED reproduced with the fix reverted, GREEN after; relay/
delegation delivery families pass (43 + 71 + 179 across the touched
suites); full tests/gateway run shows only failures already failing
identically on merge base 2446c8bb6 (env/dep issues).
* fix(gateway,relay): make pending-frame failure cancellation-safe; persist completion routing origin
Two remaining review findings on this branch (NousResearch#82592):
1. Cancellation could strand outbound waiters past the fail-pending
loop. transport.disconnect() failed pending futures only at the END
of the drain + three teardown awaits; a cancellation landing
mid-drain (the runner's wait_for budget, an outer cleanup deadline)
skipped the loop entirely and left registered futures unresolved —
their callers blocked until _OUTBOUND_TIMEOUT_S (30s). The budget
threading added earlier shrinks the window but is not a hard
guarantee. The fail-pending loop (and the going_idle ack failure)
now run in a `finally`, so no exit path — normal, error, or
cancelled — can leave a registered future unresolved. Idempotent:
done futures are skipped, a second disconnect() pass is a no-op.
2. Durable completions did not persist their routing origin, so the
scope_id threading in the fallback SessionSource reconstruction had
nothing to carry on the exact path it exists for (restart replay
with session store + source cache gone): the async-delegation event
producers never populated scope_id and the durable rows never
stored it. Dispatch now snapshots the originating turn's
scope_id/user_id/user_name from the session context
(_capture_routing_origin — a new HERMES_SESSION_SCOPE_ID contextvar
bound by the gateway at session-bind time alongside the existing
vars), stores them in the existing task_json payload (no schema
migration), and re-attaches them to all three completion-event
shapes (live single, live batch, crash-recovery rebuild). The
gateway's fallback reconstruction then primes both discriminators
after a restart.
Tests: cancellation mid-drain -> every pending future resolves with
"relay transport closed" (mutation: moving the loop out of the finally
goes RED); second-pass disconnect idempotence; end-to-end
dispatch -> owner-death recovery -> event carries scope_id -> fallback
SessionSource primes it (mutations: dropping the dispatch capture or
the task_json persistence both go RED); live completion event carries
the origin. 94 passed + 1 xfailed across the delivery/delegation
suites; tests/tools delegation family 73 passed (2 collection errors
pre-existing on merge base 2446c8bb6).
---------
Co-authored-by: joaomarcos <joaomarcosdias444@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Ben Barclay <ben@nousresearch.com>
273 lines
11 KiB
Python
273 lines
11 KiB
Python
"""Contract matrix for the gateway's final-send suppression (#82656).
|
|
|
|
The gateway skips its own final send when the stream consumer claims the turn
|
|
final already reached the user (``gateway/run.py``: ``final_response_sent`` /
|
|
``final_content_delivered``, reconciled through ``delivered_final_matches``).
|
|
Every incident in this family — #71643 (stale finalize snapshot), #78541
|
|
(payload-less multi-message split), #82656 (frozen preview left with a visible
|
|
cursor) — is the same failure: the consumer claimed delivery for text the
|
|
platform never rendered, so the corrective send was suppressed and the answer
|
|
was lost with no retry.
|
|
|
|
Each of those was fixed with a scenario test pinned to one branch of
|
|
``GatewayStreamConsumer.run()``. ``run()``'s ``got_done`` handler now has five
|
|
sibling branches that each set the suppression flags and record a turn-final
|
|
payload, and nothing checks them as a group — a new branch (or a new early
|
|
return in ``_send_or_edit``) can reintroduce the class without failing a test.
|
|
|
|
This module pins the invariant instead of the branch:
|
|
|
|
If the consumer offers the gateway any signal it would trust, the COMPLETE
|
|
final text must have reached the wire.
|
|
|
|
It drives the real consumer against a matrix of adapter behaviours and asserts
|
|
the invariant for every combination, so the guarantee holds no matter which
|
|
branch a given scenario happens to take.
|
|
"""
|
|
|
|
import asyncio
|
|
|
|
import pytest
|
|
|
|
from gateway.config import Platform, PlatformConfig
|
|
from gateway.platforms.base import BasePlatformAdapter, SendResult
|
|
from gateway.stream_consumer import GatewayStreamConsumer, StreamConsumerConfig
|
|
|
|
CURSOR = " ▉"
|
|
PREFIX = "Ack received. Starting the deploy now,"
|
|
TAIL = " and here is the rest of the answer, generated after the last preview edit."
|
|
FULL = PREFIX + TAIL
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Adapter behaviours
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# A behaviour maps "how many frames have rendered so far" to one of:
|
|
# True — the call succeeds and the frame renders
|
|
# False — the call fails (flood control, transport error)
|
|
# "lie" — the call is ACKed but the frame never renders
|
|
#
|
|
# The "lie" mode is the transport failure the #82656 report describes: an edit
|
|
# the platform accepts and then drops. The consumer advances its bookkeeping
|
|
# from the ACK, so it has no way to know.
|
|
|
|
ALWAYS = lambda rendered: True # noqa: E731
|
|
NEVER = lambda rendered: False # noqa: E731
|
|
DIES_AFTER_2 = lambda rendered: rendered < 2 # noqa: E731
|
|
LIES_AFTER_2 = lambda rendered: True if rendered < 2 else "lie" # noqa: E731
|
|
LIES_ALWAYS = lambda rendered: "lie" # noqa: E731
|
|
|
|
EDIT_BEHAVIOURS = {
|
|
"edit_always": ALWAYS,
|
|
"edit_dies_after_2": DIES_AFTER_2,
|
|
"edit_never": NEVER,
|
|
"edit_lies_after_2": LIES_AFTER_2,
|
|
"edit_lies_always": LIES_ALWAYS,
|
|
}
|
|
SEND_BEHAVIOURS = {
|
|
"send_always": ALWAYS,
|
|
"send_never": NEVER,
|
|
}
|
|
|
|
# Only a lying edit transport can put a claim on the wire that the consumer
|
|
# cannot audit. Those combinations are tracked separately (see
|
|
# ``test_lying_edit_transport_is_the_open_gap``) so the honest-transport matrix
|
|
# stays a hard guarantee.
|
|
LYING_EDITS = {"edit_lies_after_2", "edit_lies_always"}
|
|
|
|
|
|
class WireAdapter(BasePlatformAdapter):
|
|
"""Adapter that records only the frames a user could actually see."""
|
|
|
|
def __init__(self, *, edit_behaviour, send_behaviour, prefers_fresh_final):
|
|
super().__init__(PlatformConfig(enabled=True, token="***"), Platform.TELEGRAM)
|
|
self._edit_behaviour = edit_behaviour
|
|
self._send_behaviour = send_behaviour
|
|
self._prefers_fresh_final = prefers_fresh_final
|
|
self.wire = [] # (kind, payload) for every frame that rendered
|
|
self._next_id = 0
|
|
|
|
def prefers_fresh_final_streaming(self, text=None) -> bool:
|
|
return self._prefers_fresh_final
|
|
|
|
async def connect(self, *, is_reconnect: bool = False) -> bool:
|
|
return True
|
|
|
|
async def disconnect(self) -> None:
|
|
return None
|
|
|
|
async def get_chat_info(self, chat_id):
|
|
return {}
|
|
|
|
async def send_typing(self, chat_id, metadata=None) -> None:
|
|
return None
|
|
|
|
async def send(self, chat_id, content, reply_to=None, metadata=None) -> SendResult:
|
|
if self._send_behaviour(len(self.wire)) is not True:
|
|
return SendResult(success=False, error="send rejected")
|
|
self._next_id += 1
|
|
self.wire.append(("send", content))
|
|
return SendResult(success=True, message_id=f"m-{self._next_id}")
|
|
|
|
async def edit_message(
|
|
self, chat_id, message_id, content, *, finalize: bool = False, metadata=None
|
|
) -> SendResult:
|
|
verdict = self._edit_behaviour(len(self.wire))
|
|
if verdict is True:
|
|
self.wire.append(("edit", content))
|
|
return SendResult(success=True, message_id=message_id)
|
|
if verdict == "lie":
|
|
return SendResult(success=True, message_id=message_id)
|
|
return SendResult(success=False, error="flood control")
|
|
|
|
async def delete_message(self, chat_id, message_id) -> bool:
|
|
self.wire.append(("delete", message_id))
|
|
return True
|
|
|
|
def rendered_complete_answer(self, final_text: str) -> bool:
|
|
"""True when some frame the platform rendered carried *final_text*."""
|
|
return any(
|
|
kind in ("send", "edit") and final_text.strip() in payload
|
|
for kind, payload in self.wire
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def _drive(adapter, *, interrupt: bool):
|
|
"""Stream PREFIX then TAIL, then either finish or cancel the consumer."""
|
|
consumer = GatewayStreamConsumer(
|
|
adapter, "chat-1", StreamConsumerConfig(cursor=CURSOR, edit_interval=0.0)
|
|
)
|
|
task = asyncio.create_task(consumer.run())
|
|
for delta in (PREFIX, TAIL):
|
|
consumer.on_delta(delta)
|
|
await asyncio.sleep(0.01)
|
|
if interrupt:
|
|
task.cancel()
|
|
try:
|
|
await task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
else:
|
|
consumer.finish()
|
|
try:
|
|
await asyncio.wait_for(task, timeout=2.0)
|
|
except (asyncio.TimeoutError, asyncio.CancelledError):
|
|
task.cancel()
|
|
return consumer
|
|
|
|
|
|
def _consumer_claims_final_delivery(consumer, final_text: str) -> bool:
|
|
"""Whether the gateway would suppress its normal final send.
|
|
|
|
Mirrors the decision in ``gateway/run.py`` (``_stream_confirmed_final_delivery``
|
|
plus the ``_stale_finalized`` reconciliation), which lives inside
|
|
``_run_agent`` and cannot be imported. Kept deliberately small: a
|
|
``False`` verdict from ``delivered_final_matches`` vetoes both flags,
|
|
anything else lets them through.
|
|
"""
|
|
verdict = consumer.delivered_final_matches(final_text)
|
|
if verdict is False:
|
|
return False
|
|
return bool(consumer.final_response_sent or consumer.final_content_delivered)
|
|
|
|
|
|
def _scenarios(*, lying_edits: bool):
|
|
for edit_name, edit_behaviour in EDIT_BEHAVIOURS.items():
|
|
if (edit_name in LYING_EDITS) is not lying_edits:
|
|
continue
|
|
for send_name, send_behaviour in SEND_BEHAVIOURS.items():
|
|
for prefers_fresh_final in (False, True):
|
|
for interrupt in (False, True):
|
|
yield pytest.param(
|
|
edit_behaviour,
|
|
send_behaviour,
|
|
prefers_fresh_final,
|
|
interrupt,
|
|
id=f"{edit_name}-{send_name}"
|
|
f"-fresh{int(prefers_fresh_final)}"
|
|
f"-{'interrupted' if interrupt else 'clean'}",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The contract
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"edit_behaviour,send_behaviour,prefers_fresh_final,interrupt",
|
|
list(_scenarios(lying_edits=False)),
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_suppression_requires_the_complete_answer_on_the_wire(
|
|
edit_behaviour, send_behaviour, prefers_fresh_final, interrupt
|
|
):
|
|
"""No honest-transport scenario may claim delivery it cannot back up.
|
|
|
|
This is the guarantee the #71643 / #78541 / #82656 fixes each established
|
|
for one branch. Asserting it across the matrix means a new ``got_done``
|
|
branch, or a new early ``return True`` in ``_send_or_edit``, cannot
|
|
reintroduce the class unnoticed.
|
|
"""
|
|
adapter = WireAdapter(
|
|
edit_behaviour=edit_behaviour,
|
|
send_behaviour=send_behaviour,
|
|
prefers_fresh_final=prefers_fresh_final,
|
|
)
|
|
consumer = await _drive(adapter, interrupt=interrupt)
|
|
|
|
if _consumer_claims_final_delivery(consumer, FULL):
|
|
assert adapter.rendered_complete_answer(FULL), (
|
|
"consumer claims the turn final was delivered, but no rendered frame "
|
|
"carried the complete answer — the gateway would suppress its final "
|
|
f"send and lose it. flags=(response_sent={consumer.final_response_sent}, "
|
|
f"content_delivered={consumer.final_content_delivered}) "
|
|
f"verdict={consumer.delivered_final_matches(FULL)!r} wire={adapter.wire!r}"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"edit_behaviour,send_behaviour,prefers_fresh_final,interrupt",
|
|
list(_scenarios(lying_edits=True)),
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_lying_edit_transport_is_the_open_gap(
|
|
edit_behaviour, send_behaviour, prefers_fresh_final, interrupt
|
|
):
|
|
"""An edit ACKed but never rendered can still suppress the final send.
|
|
|
|
``_send_or_edit`` advances ``_last_sent_text`` from the call's return value,
|
|
and every ``got_done`` branch records its turn-final payload from that same
|
|
(or an even more optimistic) source. When the transport ACKs a frame it
|
|
drops, both the recorded payload and the acked text hold the complete
|
|
answer while the screen still shows the cursor-suffixed preview — exactly
|
|
the #82656 report.
|
|
|
|
This test documents the remaining exposure rather than asserting it away:
|
|
the invariant is checked, and the scenarios that still violate it are
|
|
reported as expected failures. Closing the gap turns them into passes,
|
|
at which point this test's ``xfail`` branch stops being reached and the
|
|
marker can be dropped along with the fix.
|
|
"""
|
|
adapter = WireAdapter(
|
|
edit_behaviour=edit_behaviour,
|
|
send_behaviour=send_behaviour,
|
|
prefers_fresh_final=prefers_fresh_final,
|
|
)
|
|
consumer = await _drive(adapter, interrupt=interrupt)
|
|
|
|
claims = _consumer_claims_final_delivery(consumer, FULL)
|
|
rendered = adapter.rendered_complete_answer(FULL)
|
|
if claims and not rendered:
|
|
pytest.xfail(
|
|
"known gap (#82656): claim rests on an ACK the platform dropped; "
|
|
f"wire={adapter.wire!r}"
|
|
)
|
|
assert not claims or rendered
|