Files
hermes-agent/tests/gateway/test_relay_delivery_followups.py
T
victor-kyriazakos 6a7cf19302 fix(gateway,relay): stop frozen-preview finals and dropped idle-session delegation callbacks (#82592)
* 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>
2026-08-11 09:56:13 +10:00

476 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Follow-up regressions for the 2026-08-09 relay delivery fixes (#82592).
Four review findings on the original branch:
1. HIGH — classifier/resolver mismatch: ``_classify_completion_target``
returned "deliver" for idle-ended parents, but
``_resolve_async_delegation_session`` (untouched) still dropped every
non-compression-ended pin. The durable row was acked at adapter
acceptance, then the injection silently died inside the pipeline — a
falsely-acknowledged permanent loss, strictly worse than the honest
terminal drop on main. Fix: the resolver retargets non-user-boundary
ends to the chat's current session; both sides share
``_USER_BOUNDARY_END_REASONS`` so they cannot drift again.
2. HIGH — the drain-grace clamp only budgeted drain + 3×teardown, but
``RelayAdapter.disconnect`` spends monitor-teardown + go_idle time
BEFORE the transport drain inside the same runner wait_for. Fix: the
adapter measures its own elapsed time and threads the REMAINING budget
into ``transport.disconnect(budget_s=...)``.
3. P1 — a ``_request_response`` racing disconnect could register a future
after the fail-pending loop and strand its caller for the full
30s outbound timeout. Fix: fast-fail when ``_closing`` is set.
4. P1 — ``_build_process_event_source``'s last-resort reconstruction
omitted ``scope_id``, so a scoped relay completion whose session-store
origin was unavailable primed no scope discriminator and could still
bounce off the connector's tenant guard. Fix: thread ``scope_id``
through the reconstruction (and warn when it's absent for scoped chats).
"""
import asyncio
import os
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from gateway.config import Platform
from gateway.relay.ws_transport import (
_DISCONNECT_DRAIN_GRACE_S,
_TEARDOWN_AWAIT_TIMEOUT_S,
_disconnect_drain_grace_s,
WebSocketRelayTransport,
)
from gateway.run import GatewayRunner, _USER_BOUNDARY_END_REASONS
from gateway.session import AsyncSessionStore, SessionEntry
# ---------------------------------------------------------------------------
# 1. Classifier/resolver coherence (the falsely-acked-loss class)
# ---------------------------------------------------------------------------
def _entry(session_id, session_key="agent:main:slack:dm:U1"):
return SessionEntry(
session_key=session_key,
session_id=session_id,
created_at=datetime.now(),
updated_at=datetime.now(),
platform=Platform.SLACK,
chat_type="dm",
)
def _runner_with_rows(rows, *, switched_entry=None):
runner = object.__new__(GatewayRunner)
db = MagicMock()
db.get_session = AsyncMock(side_effect=lambda session_id: rows.get(session_id))
db.get_compression_tip = AsyncMock(return_value=None)
runner._session_db = db
runner.session_store = MagicMock()
runner.session_store.switch_session = MagicMock(return_value=switched_entry)
runner.session_store.advance_compression_session = MagicMock(
return_value=switched_entry
)
runner._async_session_store = AsyncSessionStore(runner.session_store)
return runner
@pytest.mark.asyncio
@pytest.mark.parametrize("end_reason", ["idle", "idle_timeout", "timeout", None, ""])
async def test_resolver_retargets_idle_ended_pin_to_current_session(end_reason):
"""The delivery leg the classifier's "deliver" verdict promises: an
idle-ended pin must resolve to the chat's CURRENT session, not drop."""
current = _entry("sess_current")
runner = _runner_with_rows(
{
"sess_idle": {
"id": "sess_idle",
"ended_at": "2026-08-09T00:00:00",
"end_reason": end_reason,
}
}
)
resolved = await runner._resolve_async_delegation_session(
current, "sess_idle"
)
assert resolved is current, (
f"end_reason={end_reason!r}: idle-ended pin must retarget to the "
f"chat's current session (got {resolved!r}); dropping here after "
"the classifier said 'deliver' acks the durable row for a message "
"that never went out"
)
@pytest.mark.asyncio
@pytest.mark.parametrize("end_reason", sorted(_USER_BOUNDARY_END_REASONS))
async def test_resolver_still_drops_user_boundary_ends(end_reason):
current = _entry("sess_current")
runner = _runner_with_rows(
{
"sess_closed": {
"id": "sess_closed",
"ended_at": "2026-08-09T00:00:00",
"end_reason": end_reason,
}
}
)
resolved = await runner._resolve_async_delegation_session(
current, "sess_closed"
)
assert resolved is None
@pytest.mark.asyncio
@pytest.mark.parametrize(
"end_reason",
["idle", "idle_timeout", "timeout", "", None, "agent_close", "cron_complete"],
)
async def test_classifier_and_resolver_agree_on_ended_parents(end_reason):
"""Coherence invariant: whenever the pre-flight classifier says
"deliver" for an ended parent, the in-pipeline resolver must actually
deliver (return a session), and when it says "terminal" the resolver
must drop. Divergence in the deliver->drop direction acks the durable
row for an injection the pipeline then discards."""
row = {
"id": "sess_x",
"ended_at": "2026-08-09T00:00:00",
"end_reason": end_reason,
}
current = _entry("sess_current")
runner = _runner_with_rows({"sess_x": row})
verdict = await runner._classify_completion_target("sess_x")
resolved = await runner._resolve_async_delegation_session(current, "sess_x")
if verdict == "deliver":
assert resolved is not None, (
f"classifier said deliver for end_reason={end_reason!r} but the "
"resolver dropped — durable row would be falsely acknowledged"
)
elif verdict == "terminal":
assert resolved is None
# ---------------------------------------------------------------------------
# 2. Disconnect budget threading (adapter -> transport drain)
# ---------------------------------------------------------------------------
def test_drain_grace_uses_threaded_remaining_budget(monkeypatch):
"""An explicit remaining budget must override the env-mirrored default:
the adapter has already spent monitor/go_idle time out of the runner's
wait_for, so the transport can only drain what is actually left."""
monkeypatch.delenv("HERMES_GATEWAY_ADAPTER_DISCONNECT_TIMEOUT", raising=False)
reserved = 3 * _TEARDOWN_AWAIT_TIMEOUT_S + 0.5
# Plenty of remaining budget: grace caps at the constant.
assert _disconnect_drain_grace_s(100.0) == _DISCONNECT_DRAIN_GRACE_S
# Exactly reserved left: no drain.
assert _disconnect_drain_grace_s(reserved) == 0.0
# Less than reserved / nothing left: clamped to zero, never negative.
assert _disconnect_drain_grace_s(0.0) == 0.0
# Partial remainder: drain gets exactly the surplus.
assert _disconnect_drain_grace_s(reserved + 1.0) == pytest.approx(1.0)
def test_drain_grace_env_fallback_unchanged(monkeypatch):
"""No threaded budget -> the env-mirrored runner default still applies
(the original #82592 clamp semantics are preserved)."""
monkeypatch.setenv("HERMES_GATEWAY_ADAPTER_DISCONNECT_TIMEOUT", "10")
reserved = 3 * _TEARDOWN_AWAIT_TIMEOUT_S + 0.5
assert _disconnect_drain_grace_s() == pytest.approx(
min(_DISCONNECT_DRAIN_GRACE_S, 10.0 - reserved)
)
@pytest.mark.asyncio
async def test_adapter_disconnect_threads_remaining_budget():
"""RelayAdapter.disconnect must pass budget_s to the transport, and the
value must be <= the full budget (its own spend subtracted)."""
from gateway.relay.adapter import RelayAdapter
adapter = object.__new__(RelayAdapter)
adapter._revocation_monitor = None
seen = {}
class _Transport:
async def go_idle(self, timeout_s=10.0):
await asyncio.sleep(0.05)
return True
def disconnect(self, budget_s=None):
seen["budget_s"] = budget_s
async def _noop():
return None
return _noop()
adapter._transport = _Transport()
await adapter.disconnect()
assert "budget_s" in seen, "transport.disconnect never received budget_s"
from gateway.relay.ws_transport import _env_disconnect_budget_s
full = _env_disconnect_budget_s()
assert seen["budget_s"] is not None
assert seen["budget_s"] <= full
# go_idle slept 0.05s, so some budget must have been consumed.
assert seen["budget_s"] < full
@pytest.mark.asyncio
async def test_adapter_disconnect_tolerates_legacy_transport_signature():
"""A transport without the budget_s keyword (stubs) must still be torn
down through the legacy no-arg call, not crash."""
from gateway.relay.adapter import RelayAdapter
adapter = object.__new__(RelayAdapter)
adapter._revocation_monitor = None
called = {}
class _LegacyTransport:
def disconnect(self): # no budget_s
called["legacy"] = True
async def _noop():
return None
return _noop()
adapter._transport = _LegacyTransport()
await adapter.disconnect()
assert called.get("legacy") is True
# ---------------------------------------------------------------------------
# 3. _request_response vs disconnect race
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_request_response_fails_fast_when_closing():
"""A request racing teardown must fail immediately instead of
registering a future the fail-pending loop may already have missed
(which would strand the caller for _OUTBOUND_TIMEOUT_S)."""
transport = object.__new__(WebSocketRelayTransport)
transport._closing = True
transport._ws = object() # socket still nominally open
transport._pending = {}
result = await transport._request_response({"a": 1})
assert result == {"success": False, "error": "relay transport closed"}
assert not transport._pending, (
"no future may be registered once _closing is set"
)
# ---------------------------------------------------------------------------
# 4. scope_id in fallback source reconstruction
# ---------------------------------------------------------------------------
def _fallback_runner():
runner = object.__new__(GatewayRunner)
store = MagicMock()
store._ensure_loaded = MagicMock(side_effect=RuntimeError("store down"))
store._entries = {}
runner.session_store = store
runner._session_sources = None
return runner
def test_fallback_source_reconstruction_carries_scope_id():
"""When the session-store origin is unavailable, the reconstructed
SessionSource must carry the event's scope_id so relay egress priming
still captures the tenant discriminator."""
runner = _fallback_runner()
evt = {
"session_key": "agent:main:discord:group:C123:U9",
"platform": "discord",
"chat_type": "group",
"chat_id": "C123",
"user_id": "U9",
"scope_id": "G777",
"type": "async_delegation",
}
source = runner._build_process_event_source(evt)
assert source is not None
assert source.scope_id == "G777"
assert source.user_id == "U9"
def test_fallback_source_reconstruction_without_scope_still_routes():
"""Absent scope_id must not fail the reconstruction (DMs and
author-bound scoped chats still route via user_id)."""
runner = _fallback_runner()
evt = {
"session_key": "agent:main:slack:dm:D42",
"platform": "slack",
"chat_type": "dm",
"chat_id": "D42",
"user_id": "U1",
"type": "async_delegation",
}
source = runner._build_process_event_source(evt)
assert source is not None
assert source.scope_id is None
assert source.user_id == "U1"
# ---------------------------------------------------------------------------
# 5. Cancellation-safe pending-future failure (round-3 finding 1)
# ---------------------------------------------------------------------------
def _bare_transport():
t = object.__new__(WebSocketRelayTransport)
t._closing = False
t._supervisor = None
t._reader = None
t._ws = None
t._pending = {}
t._going_idle_ack = None
return t
@pytest.mark.asyncio
async def test_disconnect_cancellation_still_fails_pending_futures():
"""A cancellation landing during the drain (outer cleanup deadline,
runner wait_for) must NOT leave registered futures unresolved — a
stranded waiter would block until _OUTBOUND_TIMEOUT_S (30s). The
fail-pending loop runs in a finally, so cancellation cannot skip it."""
t = _bare_transport()
loop = asyncio.get_running_loop()
fut = loop.create_future()
t._pending["req-1"] = fut
task = asyncio.create_task(t.disconnect(budget_s=60.0))
# Let the drain start waiting on the pending future...
await asyncio.sleep(0.05)
assert not task.done(), "disconnect should be inside the drain wait"
# ...then cancel it mid-drain, as the runner's wait_for would.
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert fut.done(), (
"pending outbound future left unresolved after cancelled disconnect"
)
with pytest.raises(RuntimeError, match="relay transport closed"):
fut.result()
assert not t._pending
@pytest.mark.asyncio
async def test_disconnect_idempotent_second_pass():
"""A second disconnect() (adapter and outer cleanup can both run one)
must be safe: done futures are skipped, cleared map stays cleared."""
t = _bare_transport()
await t.disconnect()
await t.disconnect() # must not raise
assert not t._pending
# ---------------------------------------------------------------------------
# 6. Durable routing origin: scope_id survives dispatch -> restart -> replay
# ---------------------------------------------------------------------------
def test_durable_dispatch_persists_and_recovers_scope_id(tmp_path, monkeypatch):
"""End-to-end restart shape: dispatch with a scoped session context bound,
simulate owner death, recover — the recovered completion event must carry
scope_id/user_id, and the reconstructed SessionSource must prime them."""
import tools.async_delegation as ad
from gateway.session_context import clear_session_vars, set_session_vars
ad._reset_for_tests()
monkeypatch.setattr(ad, "_db_path", lambda: tmp_path / "state.db")
tokens = set_session_vars(
platform="discord",
chat_id="C123",
chat_type="group",
user_id="U9",
scope_id="G777",
session_key="agent:main:discord:group:C123:U9",
)
try:
record = {
"delegation_id": "d-scope-1",
"session_key": "agent:main:discord:group:C123:U9",
"origin_ui_session_id": "",
"origin_session_id": "",
"parent_session_id": "sess-p",
"goal": "scoped goal",
"dispatched_at": 100.0,
**ad._capture_routing_origin(),
}
assert record.get("scope_id") == "G777", (
"dispatch-time capture must snapshot HERMES_SESSION_SCOPE_ID"
)
ad._persist_dispatch(record)
finally:
clear_session_vars(tokens)
# Simulate the owner process being gone: recovery marks the row unknown
# and rebuilds the completion event from the durable task_json.
monkeypatch.setattr("gateway.status._pid_exists", lambda pid: False)
ad.recover_abandoned_delegations()
with ad._transaction() as conn:
row = conn.execute(
"SELECT event_json FROM async_delegations WHERE delegation_id='d-scope-1'"
).fetchone()
assert row and row[0], "recovered row must have an event_json"
import json as _json
evt = _json.loads(row[0])
assert evt.get("scope_id") == "G777", (
"recovered completion event lost scope_id — post-restart scoped "
"relay egress would be declined by the connector's tenant guard"
)
assert evt.get("user_id") == "U9"
# The gateway-side fallback reconstruction must carry it into the source.
runner = _fallback_runner()
source = runner._build_process_event_source(evt)
assert source is not None
assert source.scope_id == "G777"
assert source.user_id == "U9"
def test_live_completion_event_carries_scope_id(tmp_path, monkeypatch):
"""The live (non-restart) completion event must carry the dispatch-time
routing origin too, so priming works even when the in-memory source
cache was evicted."""
import tools.async_delegation as ad
record = {
"delegation_id": "d-live-1",
"session_key": "agent:main:discord:group:C123:U9",
"scope_id": "G777",
"user_id": "U9",
"goal": "g",
"dispatched_at": 100.0,
"completed_at": 101.0,
}
captured = {}
class _Q:
def put(self, evt):
captured.update(evt)
class _PR:
completion_queue = _Q()
monkeypatch.setattr(ad, "_db_path", lambda: tmp_path / "state.db")
monkeypatch.setattr(
"tools.process_registry.process_registry", _PR(), raising=False
)
ad._push_completion_event(record, {"summary": "ok"}, "completed")
assert captured.get("scope_id") == "G777"
assert captured.get("user_id") == "U9"