fix(gateway): per-session turn lease + conversation-scope funnel (#64934) (#67401)

* fix(gateway): serialize concurrent turns per resolved session_id with a turn lease

Closes the serialization half of #64934. The busy guards are keyed by
routing key, but the durable transcript is owned by session_id — and
switch_session() makes the key→id mapping many-to-one (/resume from a
second chat/topic, CLI-continuity rebinding, async-delegation pinning,
topic-binding tip-walks). Two routing keys mapped to one session_id ran
concurrent turns on two different agent objects, invisible to every
per-key guard: flushes persisted in completion order, the identity-marker
dedup swallowed rows, and the second turn ran on a stale history base —
leaving a permanent user;user alternation wedge.

The fix: an asyncio lease keyed by RESOLVED session_id (gateway/turn_lease.py),
acquired in _handle_message_with_agent after session resolution is final
(post switch_session/tip-walk), immediately before the transcript load, and
released in _handle_message's finally on every exit path. Tokens are granted
per (routing key, run generation) so a stale unwind can never release a newer
turn's lease (#28686 ownership lesson). Same-key messages never reach the
acquisition point mid-turn (both routing-key guards hold them), so the lock
is uncontended outside the alias-key route — where the second turn now waits
for the first turn's flush and logs one WARNING naming the session and both
routing keys (pairs with the #67371 tripwire).

Fail-open: a stuck holder degrades to today's unserialized behavior with a
loud ERROR after agent.gateway_timeout — never a wedged session; a degraded
token holds nothing and can't steal the lease. Registry is size-capped and
never evicts a live lease. Persist-disabled review forks never dispatch
through _handle_message, so they cannot contend.

Known limits (tracked on #64934): CLI-continuity cross-process pairs need a
DB-level lease; mid-turn compression rotation leaves a small alias window
for a follow-up at the binding-sync sites.

Validation: 8 behavior tests (alias-key wait + flush order, no cross-session
contention, generation-scoped idempotent release, timeout fail-open without
lease theft, bounded registry, bare-runner-safe release wiring) + E2E against
a real SessionStore reproducing the issue's switch_session alias route —
strict alternation and arrival order preserved.

* refactor(gateway): conversation-scope funnel + mid-turn lease rebind

Completes the #64934 system beyond the point fix. Two structural changes,
both eliminating whole bug classes rather than instances:

1. _clear_conversation_scope — THE single conversation-boundary funnel.
   /new, /resume, auto-reset, expiry finalization, and the
   compression-exhausted reset each carried a hand-copied pop-list of the
   per-session dicts, and the lists drifted every time a new dict was
   added (#48031, #58403, #10702, #35809 were all 'boundary X forgot
   dict Y' bugs). All five sites now make one funnel call driven by the
   _CONVERSATION_SCOPED_STATE registry; adding a new conversation-scoped
   dict means adding one name to the registry, and every boundary picks
   it up automatically. Scope rules documented at the registry: turn-scoped
   state, the monotonic generation counter, and the agent cache are
   deliberately excluded (different lifecycles).

2. SessionTurnLeaseRegistry.rebind — the held turn lease now FOLLOWS
   mid-turn compression rotation. Both rotation sites (session-hygiene
   pre-compression, agent-result session_id swap) alias the same
   _SessionLease object under the new id, so an alias routing key
   resolving the fresh child (topic tip-walk) still serializes against
   the in-flight turn. Closes the rotation-alias window flagged as a
   known limit on #64934. Ownership-checked like release; when the
   target id already has a live lease the rebind fails open with a loud
   WARNING (never a mid-turn deadlock).

Tests: 3 new rebind behavior tests + 5 funnel behavior tests (including
a real-setter drift guard); the two AST change-detector pins in
test_10710/test_48031 were re-pointed at the funnel and the #58403 pin
converted to a behavioral test. E2E: rotation-alias scenario against a
real SessionStore + SessionDB — turn B on the fresh child waits behind
the rotated holder, sees its rows, alternation intact.
This commit is contained in:
Teknium
2026-07-19 03:49:29 -07:00
committed by GitHub
parent 027243eb46
commit 19527db731
7 changed files with 936 additions and 149 deletions
+211 -56
View File
@@ -1921,6 +1921,7 @@ from gateway.session import (
neutralize_untrusted_inline_text,
)
from gateway.delivery import DeliveryRouter, looks_like_telegram_private_chat_id
from gateway.turn_lease import SessionTurnLeaseRegistry
from gateway.authz_mixin import GatewayAuthorizationMixin
from gateway.kanban_watchers import GatewayKanbanWatchersMixin
from gateway.slash_commands import GatewaySlashCommandsMixin
@@ -2006,6 +2007,39 @@ def _own_policy_open_startup_violation(config) -> Optional[str]:
# between the guard check and actual agent creation.
_AGENT_PENDING_SENTINEL = object()
# Conversation-scoped per-session state registry. Every GatewayRunner dict
# keyed by session_key whose entries must NOT survive a conversation boundary
# (/new, /resume, auto-reset, expiry finalization, compression-exhausted
# reset) is listed here, and _clear_conversation_scope() pops them all.
# Boundaries used to each carry a hand-copied pop-list that drifted whenever
# a new dict was added (#48031, #58403, #10702, #35809). Adding a new
# conversation-scoped dict means adding its attribute name HERE — every
# boundary then handles it automatically.
#
# NOT in this list (different lifecycles):
# - _running_agents/_running_agents_ts/_active_session_leases/_busy_ack_ts/
# _turn_lease_tokens: turn-scoped, owned by _release_running_agent_state
# and the dispatch finally.
# - _session_run_generation: monotonic by design; clearing it would reset
# the counter and break stale-run detection (#28686).
# - _agent_cache: has its own eviction path (_evict_cached_agent) with
# resource cleanup; boundaries call it explicitly.
# - _pending_approvals/_update_prompt_pending/slash-confirm/tool-approval
# state: cleared via _clear_session_boundary_security_state, which
# _clear_conversation_scope calls.
_CONVERSATION_SCOPED_STATE: tuple = (
"_session_model_overrides",
"_pending_one_turn_model_restores",
"_session_reasoning_overrides",
"_pending_model_notes",
"_last_resolved_model",
"_queued_events",
# Staged-but-never-consumed sidecar notes (turn aborted between staging
# and run_sync) must not leak into a future conversation's first user
# message — session keys are source-derived and REUSED.
"_pending_turn_sidecar_notes",
)
# Sentinel for "caller did not pass metadata" vs "caller passed None".
_UNSET = object()
@@ -3152,6 +3186,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
self._running_agents: Dict[str, Any] = {}
self._running_agents_ts: Dict[str, float] = {} # start timestamp per session
self._active_session_leases: Dict[str, Any] = {}
# Per-SESSION_ID turn lease (#64934): serializes the
# [load history → run → flush] region when two ROUTING KEYS resolve
# to one session_id (switch_session's many-to-one mapping). The
# routing-key guards above cannot see that overlap. Acquired in
# _handle_message_with_agent after session resolution is final,
# released via _release_turn_lease in the same method's finally.
self._turn_leases = SessionTurnLeaseRegistry()
# Tokens for held turn leases, keyed by (routing key, run generation)
# so release is granted per-turn and a stale unwind can never free a
# newer turn's lease (#28686 ownership lesson).
self._turn_lease_tokens: Dict[tuple, Any] = {}
self._pending_messages: Dict[str, str] = {} # Queued messages during interrupt
# Last successfully-resolved (non-empty) model, keyed by session. Used
# as a fallback when a fresh config read transiently returns an empty
@@ -8335,39 +8380,19 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# be garbage-collected. Otherwise the cache grows
# unbounded across the gateway's lifetime.
self._evict_cached_agent(key)
# Permanently finalizing this session — drop its
# per-session control state so the dicts don't grow
# Permanently finalizing this session — one funnel
# call drops every conversation-scoped dict AND the
# boundary security state (approvals, update
# prompts, slash-confirm) so the dicts don't grow
# unbounded across the gateway's lifetime. (Idle
# agent-cache eviction must NOT prune these: the
# agent-cache eviction must NOT do this: the
# session is still alive and a resumed turn rebuilds
# its agent from these overrides. Only true session
# finalization, /new, and /reset clear them.)
self._session_model_overrides.pop(key, None)
self._pending_one_turn_model_restores.pop(key, None)
self._set_session_reasoning_override(key, None)
if hasattr(self, "_pending_model_notes"):
self._pending_model_notes.pop(key, None)
# Session keys are source-derived and REUSED by the
# next conversation: a staged-but-never-consumed
# sidecar note (turn aborted between staging and
# run_sync) must not leak into a future session's
# first user message.
_psn = getattr(self, "_pending_turn_sidecar_notes", None)
if isinstance(_psn, dict):
_psn.pop(key, None)
# Clear per-session model cache so a resumed turn
# resolves from current config, not a stale fallback
# cached before the session went idle (mirrors /new
# and the compression-exhausted auto-reset, #58403).
_lrm = getattr(self, "_last_resolved_model", None)
if _lrm is not None:
_lrm.pop(key, None)
_pending_approvals = getattr(self, "_pending_approvals", None)
if isinstance(_pending_approvals, dict):
_pending_approvals.pop(key, None)
_update_prompt_pending = getattr(self, "_update_prompt_pending", None)
if isinstance(_update_prompt_pending, dict):
_update_prompt_pending.pop(key, None)
# finalization, /new, and /reset clear them.) See
# _CONVERSATION_SCOPED_STATE.
self._clear_conversation_scope(
key, reason="expiry_finalized"
)
# Persist the finalized flag to sessions.json AND
# state.db (single write-path, #9006) — also drops
# the persisted /model override, since finalization
@@ -11371,6 +11396,10 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# inside _run_agent returns False, and the old sentinel-only check here
# missed the leftover real agent — locking the session out forever (#28686).
self._release_running_agent_state(_quick_key)
# Turn lease (#64934): release THIS turn's lease token — keyed by
# (routing key, run generation) so this unwind can only ever free
# the lease its own turn acquired, never a newer turn's.
self._release_turn_lease(_quick_key, _run_generation)
def _restore_moa_one_shot(self, event: "MessageEvent", quick_key: str) -> None:
"""Revert a ``/moa <prompt>`` one-shot model override after its turn.
@@ -11998,22 +12027,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# wiping model/reasoning overrides set between turns (Closes #48031).
_was_auto_reset = getattr(session_entry, "was_auto_reset", False)
if _was_auto_reset:
# Treat auto-reset as a full conversation boundary — drop every
# session-scoped transient state so the fresh session does not
# inherit the previous conversation's model/reasoning overrides
# or a queued "/model switched" note.
self._session_model_overrides.pop(session_key, None)
self._pending_one_turn_model_restores.pop(session_key, None)
self._set_session_reasoning_override(session_key, None)
if hasattr(self, "_pending_model_notes"):
self._pending_model_notes.pop(session_key, None)
# Clear per-session model cache so the fresh session resolves
# from current config, not a stale fallback cached before the
# auto-reset (mirrors /new and the compression-exhausted
# auto-reset, #58403).
_lrm = getattr(self, "_last_resolved_model", None)
if _lrm is not None:
_lrm.pop(session_key, None)
# Treat auto-reset as a full conversation boundary — clear every
# conversation-scoped per-session dict in one funnel call so the
# fresh session does not inherit the previous conversation's
# model/reasoning overrides, a queued "/model switched" note, or
# a stale resolved-model cache (#48031, #58403). See
# _CONVERSATION_SCOPED_STATE.
self._clear_conversation_scope(session_key, reason="auto_reset")
# Evict the cached agent so the fresh session does not inherit the
# previous conversation's context_compressor._previous_summary —
# the cache is keyed on the stable session_key, so an auto-reset
@@ -12183,6 +12203,33 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
except Exception as e:
logger.warning("[Gateway] Failed to auto-load skill(s) %s: %s", _skill_names, e)
# ── Turn lease (#64934) ────────────────────────────────────────
# Session resolution is FINAL here (get_or_create → async-delegation
# pinning → topic tip-walk switch_session are all above). Serialize
# the [load history → run → flush] region per resolved SESSION_ID:
# when a second routing key is mapped to this same session_id, its
# turn waits here for the previous turn's flush instead of loading a
# stale history base and interleaving transcript writes. Same-key
# messages never reach this point mid-turn (adapter + runner guards
# hold them), so the lock is uncontended outside the alias-key route.
# Fail-open: on timeout the token comes back degraded and the turn
# proceeds unserialized (never a wedged session). Released in
# _handle_message's finally via _release_turn_lease — granted per
# (routing key, run generation) so a stale unwind can't release a
# newer turn's lease.
_lease_registry = getattr(self, "_turn_leases", None)
if _lease_registry is not None:
_lease_token = await _lease_registry.acquire(
session_entry.session_id,
owner_key=_quick_key,
generation=run_generation,
timeout=_float_env("HERMES_AGENT_TIMEOUT", 1800),
)
if _lease_token is not None:
if not hasattr(self, "_turn_lease_tokens"):
self._turn_lease_tokens = {}
self._turn_lease_tokens[(_quick_key, run_generation)] = _lease_token
# Load conversation history from transcript
history = await self.async_session_store.load_transcript(session_entry.session_id)
@@ -12447,6 +12494,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
)
if _hyg_rotated:
session_entry.session_id = _hyg_new_sid
# The held turn lease follows the
# rotation so an alias key resolving
# the fresh child still serializes
# against this turn (#64934).
self._rebind_turn_lease(
_quick_key, run_generation, _hyg_new_sid
)
await self.async_session_store._save()
await asyncio.to_thread(
self._sync_telegram_topic_binding,
@@ -12927,6 +12981,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if agent_result.get("session_id") and agent_result["session_id"] != session_entry.session_id:
if session_entry.session_id == _run_start_session_id:
session_entry.session_id = agent_result["session_id"]
# The held turn lease follows the rotation: the transcript
# persistence below writes to the NEW id, so the
# serialization boundary must move with it or an alias
# key resolving the fresh child could interleave (#64934).
self._rebind_turn_lease(
_quick_key, run_generation, session_entry.session_id
)
await self.async_session_store._save()
await self.async_session_store._record_gateway_session_peer(
session_entry.session_id,
@@ -13138,16 +13199,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
)
new_entry = await self.async_session_store.reset_session(session_key)
self._evict_cached_agent(session_key)
self._session_model_overrides.pop(session_key, None)
self._pending_one_turn_model_restores.pop(session_key, None)
self._set_session_reasoning_override(session_key, None)
if hasattr(self, "_pending_model_notes"):
self._pending_model_notes.pop(session_key, None)
# Clear per-session model cache so the post-reset turn
# resolves from current config, not a stale fallback.
_lrm = getattr(self, "_last_resolved_model", None)
if _lrm is not None:
_lrm.pop(session_key, None)
# Conversation boundary: one funnel call clears every
# conversation-scoped per-session dict (#58403 and siblings).
# See _CONVERSATION_SCOPED_STATE.
self._clear_conversation_scope(
session_key, reason="compression_exhausted_reset"
)
if new_entry is not None:
# Drop the stale reference to the bloated compressed child and
# re-point the Telegram topic binding at the fresh session.
@@ -17459,6 +17516,104 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
self._persist_active_agents()
return True
def _release_turn_lease(self, session_key: str, run_generation: int) -> bool:
"""Release the turn lease acquired by (``session_key``, ``run_generation``).
Companion to the acquisition in ``_handle_message_with_agent``
(#64934). The token map is keyed by (routing key, run generation), so
this can only ever free the lease its own turn acquired — a stale
unwind whose generation was bumped by /stop or /new pops ITS token,
and the registry's identity check refuses it if a newer turn already
holds the lease. Idempotent and safe for bare test runners built via
``object.__new__`` (getattr defaults).
"""
if not session_key:
return False
tokens = getattr(self, "_turn_lease_tokens", None)
registry = getattr(self, "_turn_leases", None)
if tokens is None or registry is None:
return False
token = tokens.pop((session_key, run_generation), None)
if token is None:
return False
try:
return registry.release(token)
except Exception:
logger.debug("Failed to release turn lease", exc_info=True)
return False
def _rebind_turn_lease(
self, session_key: str, run_generation: int, new_session_id: str
) -> bool:
"""Follow a mid-turn session_id rotation with the held turn lease.
Compression (session-hygiene pre-compression or the agent's own
compressor) can rotate ``session_entry.session_id`` while this turn
is in flight. The turn's flush targets the NEW id, so the
serialization boundary must follow it — otherwise an alias routing
key resolving the new id (topic tip-walk onto the fresh child) could
start a concurrent turn the lease never sees (#64934 rotation-alias
window). Call at every site that reassigns session_entry.session_id
mid-turn. Fail-open no-op when there is no held token.
"""
if not session_key or not new_session_id:
return False
tokens = getattr(self, "_turn_lease_tokens", None)
registry = getattr(self, "_turn_leases", None)
if tokens is None or registry is None:
return False
token = tokens.get((session_key, run_generation))
if token is None:
return False
try:
return registry.rebind(token, new_session_id)
except Exception:
logger.debug("Failed to rebind turn lease", exc_info=True)
return False
def _clear_conversation_scope(self, session_key: str, *, reason: str) -> None:
"""Clear ALL conversation-scoped per-session state for ``session_key``.
THE single conversation-boundary funnel. Call this — and nothing
else — whenever a session_key crosses a conversation boundary:
/new, /resume, auto-reset (idle/daily/suspended), expiry
finalization, and the compression-exhausted auto-reset.
Why a funnel: these boundaries used to each carry a hand-copied
pop-list of the per-session dicts, and the lists drifted every time
a new dict was added (#48031, #58403, #10702, #35809 were all
"boundary X forgot dict Y" bugs — e.g. /new cleared the /model
override but not the /model --once restore snapshot). Adding a new
conversation-scoped dict now means adding its attribute name to
_CONVERSATION_SCOPED_STATE below; every boundary picks it up
automatically.
Scope rules:
- Conversation-scoped (cleared here): model/reasoning overrides,
one-turn restore snapshots, pending model notes, last-resolved
model cache, queued follow-up events, and the boundary security
state (approvals, /yolo, slash-confirm, update prompts).
- Turn-scoped (NOT cleared here): _running_agents/_ts, slot leases,
turn-lease tokens — owned by _release_running_agent_state and the
dispatch finally.
- Idle agent-cache eviction is NOT a conversation boundary: the
session is still alive and a resumed turn rebuilds from these
overrides. Only true boundaries call this.
Safe on bare test runners built via ``object.__new__`` (every
access is getattr-guarded).
"""
if not session_key:
return
for attr in _CONVERSATION_SCOPED_STATE:
store = getattr(self, attr, None)
if isinstance(store, dict):
store.pop(session_key, None)
self._clear_session_boundary_security_state(session_key)
logger.debug(
"Cleared conversation scope for %s (%s)", session_key, reason
)
def _clear_session_boundary_security_state(self, session_key: str) -> None:
"""Clear per-session control state that must not survive a boundary switch."""
if not session_key:
+13 -45
View File
@@ -164,12 +164,11 @@ class GatewaySlashCommandsMixin:
)
self._evict_cached_agent(session_key)
# Discard any /queue overflow for this session — /new is a
# conversation-boundary operation, queued follow-ups from the
# previous conversation must not bleed into the new one.
_qe = getattr(self, "_queued_events", None)
if _qe is not None:
_qe.pop(session_key, None)
# Conversation boundary: clear ALL conversation-scoped per-session
# state (model/reasoning overrides, one-turn restores, model notes,
# last-resolved cache, /queue overflow) + security state in one
# funnel call. See _CONVERSATION_SCOPED_STATE in gateway/run.py.
self._clear_conversation_scope(session_key, reason="session_reset")
# The old conversation's in-flight async delegations end WITH it
# (#55578): after the reset rotates the session id, their completions
@@ -204,24 +203,8 @@ class GatewaySlashCommandsMixin:
# Reset the session
new_entry = await self.async_session_store.reset_session(session_key)
# Clear any session-scoped model/reasoning overrides so the next agent
# picks up configured defaults instead of previous session switches.
self._session_model_overrides.pop(session_key, None)
self._set_session_reasoning_override(session_key, None)
if hasattr(self, "_pending_model_notes"):
self._pending_model_notes.pop(session_key, None)
# Clear the per-session last-resolved-model cache so the next turn
# reads from current config instead of falling back to a stale model
# after a config change (#58403).
_lrm = getattr(self, "_last_resolved_model", None)
if _lrm is not None:
_lrm.pop(session_key, None)
# Clear session-scoped dangerous-command approvals and /yolo state.
# /new is a conversation-boundary operation — approval state from the
# previous conversation must not survive the reset.
self._clear_session_boundary_security_state(session_key)
# (Conversation-scoped overrides + security state were already
# cleared via _clear_conversation_scope above.)
_old_sid = old_entry.session_id if old_entry else None
@@ -3914,28 +3897,13 @@ class GatewaySlashCommandsMixin:
new_entry = await self.async_session_store.switch_session(session_key, target_id)
if not new_entry:
return t("gateway.resume.switch_failed")
self._clear_session_boundary_security_state(session_key)
# Clear session-scoped model/reasoning overrides so the resumed
# conversation picks up configured defaults instead of a /model
# switch made in the previous session under the same chat
# session_key. /resume is a conversation boundary just like /new
# (which clears these too); without this, a stale override leaks
# across the switch. See #10702.
_overrides = getattr(self, "_session_model_overrides", None)
if isinstance(_overrides, dict):
_overrides.pop(session_key, None)
self._set_session_reasoning_override(session_key, None)
_pending_notes = getattr(self, "_pending_model_notes", None)
if isinstance(_pending_notes, dict):
_pending_notes.pop(session_key, None)
# Clear per-session model cache too, for the same reason — the
# resumed conversation must resolve from current config, not a
# stale value cached under this session_key before the switch
# (mirrors /new and the compression-exhausted auto-reset, #58403).
_lrm = getattr(self, "_last_resolved_model", None)
if isinstance(_lrm, dict):
_lrm.pop(session_key, None)
# Conversation boundary: clear ALL conversation-scoped per-session
# state (model/reasoning overrides #10702, one-turn restores, model
# notes, last-resolved cache #58403, /queue overflow) + security
# state in one funnel call. See _CONVERSATION_SCOPED_STATE in
# gateway/run.py.
self._clear_conversation_scope(session_key, reason="resume")
# Evict any cached agent for this session so the next message
# rebuilds with the correct session_id end-to-end — mirrors
+302
View File
@@ -0,0 +1,302 @@
"""Per-session turn lease — serializes the [load history → run → flush] region.
Why this exists (#64934): the gateway's busy guards are keyed by ROUTING KEY
(``_active_sessions`` in the adapter, ``_running_agents`` in the runner), but
the durable transcript is owned by SESSION_ID — and ``switch_session()`` makes
the key→id mapping many-to-one (``/resume`` of a named session from a second
chat/topic, CLI-continuity rebinding, async-delegation completion pinning,
Telegram topic-binding tip-walks). Two routing keys mapped to one session_id
run concurrent turns on two different agent objects, so no per-key guard ever
sees the collision. The two turns then interleave their flushes on one
transcript: rows persist in completion order instead of arrival order, the
identity-marker dedup over shared history dicts can swallow a row outright,
and the second turn runs on a history base that never saw the first turn's
exchange — leaving a permanent ``user;user`` alternation wedge that
``repair_message_sequence`` re-repairs on every request forever.
The lease closes that route by serializing per RESOLVED session_id: it is
acquired after session resolution is final (post ``switch_session``/tip-walk),
immediately before the transcript load, and released in the dispatch layer's
``finally`` on every exit path. Same-key messages never reach the acquisition
point while a turn runs (both routing-key guards hold them), so the lock is
uncontended everywhere except the alias-key route — where the second turn now
waits for the first turn's flush and logs one WARNING naming the session and
both routing keys (pairing with the cross-agent tripwire in
``agent/agent_runtime_helpers.note_turn_start``).
Safety properties:
- **Generation-scoped, identity-checked release.** A token records its owner
(routing key, run generation) and release only frees the lease when that
exact token is the current holder — a stale unwind can never release a
newer turn's lease (the #28686 ownership lesson applied). Release is
idempotent.
- **Fail-open on timeout.** A stuck holder degrades to today's unserialized
behavior with a loud ERROR after the configured wait — never a wedged
session. A degraded token holds nothing and releases nothing.
- **Bounded registry.** The per-session lease map is size-capped; eviction
only ever removes idle (unheld, uncontended) entries, never a live lease.
Known limits (deliberate, flagged on #64934):
- A CLI process sharing the session via CLI-continuity is outside any
in-process lock — that pair needs a DB-level lease (separate design).
- Mid-turn compression rotation leaves a small alias window: the tip-walk can
resolve a fresh child id while the parent-holding turn is still in flight.
The mid-turn binding-sync sites are the right place to alias the lease in a
follow-up.
"""
import asyncio
import logging
import time
from typing import Dict, Optional
logger = logging.getLogger(__name__)
# Upper bound on tracked per-session leases. Idle entries (no holder, no
# waiter) are evicted oldest-first once the cap is reached; live leases are
# never evicted, so a burst of distinct sessions can transiently exceed the
# cap rather than break serialization.
DEFAULT_MAX_LEASES = 512
# Fallback wait (seconds) when the caller passes no positive timeout. Matches
# the gateway's default agent inactivity timeout so a stuck holder fails open
# on the same clock the turn itself would be declared stuck on.
DEFAULT_LEASE_WAIT = 1800.0
class TurnLeaseToken:
"""Handle returned by :meth:`SessionTurnLeaseRegistry.acquire`.
``degraded`` means the acquire timed out and the turn is proceeding
UNSERIALIZED (fail-open); such a token holds nothing and its release is a
no-op. ``released`` makes release idempotent.
"""
__slots__ = ("session_id", "owner_key", "generation", "degraded", "released")
def __init__(
self,
session_id: str,
owner_key: str,
generation: int,
degraded: bool = False,
) -> None:
self.session_id = session_id
self.owner_key = owner_key
self.generation = generation
self.degraded = degraded
self.released = False
def __repr__(self) -> str: # pragma: no cover - debug aid
return (
f"TurnLeaseToken(session_id={self.session_id!r}, "
f"owner_key={self.owner_key!r}, generation={self.generation}, "
f"degraded={self.degraded}, released={self.released})"
)
class _SessionLease:
__slots__ = ("lock", "holder", "acquired_at", "last_used")
def __init__(self) -> None:
self.lock = asyncio.Lock()
self.holder: Optional[TurnLeaseToken] = None
self.acquired_at = 0.0
self.last_used = time.time()
@property
def idle(self) -> bool:
"""True when this lease can be evicted: nobody holds or awaits it."""
return self.holder is None and not self.lock.locked()
class SessionTurnLeaseRegistry:
"""Asyncio lease per resolved session_id serializing transcript turns.
Process-local and single-event-loop by design — the same visibility scope
as the routing-key guards it extends. All methods must be called from the
gateway's event loop.
"""
def __init__(self, max_entries: int = DEFAULT_MAX_LEASES) -> None:
self._leases: Dict[str, _SessionLease] = {}
self._max_entries = max(1, int(max_entries))
def __len__(self) -> int:
return len(self._leases)
def _get_or_create(self, session_id: str) -> _SessionLease:
lease = self._leases.get(session_id)
if lease is None:
self._evict_idle()
lease = _SessionLease()
self._leases[session_id] = lease
lease.last_used = time.time()
return lease
def _evict_idle(self) -> None:
"""Drop oldest idle entries so a new lease fits under the cap.
Never evicts a held or contended lease — correctness beats the cap.
"""
overflow = len(self._leases) - self._max_entries + 1
if overflow <= 0:
return
idle_ids = sorted(
(sid for sid, lease in self._leases.items() if lease.idle),
key=lambda sid: self._leases[sid].last_used,
)
for sid in idle_ids[:overflow]:
self._leases.pop(sid, None)
async def acquire(
self,
session_id: str,
*,
owner_key: str,
generation: int,
timeout: Optional[float] = None,
) -> Optional[TurnLeaseToken]:
"""Acquire the turn lease for ``session_id``, waiting if held.
Returns a :class:`TurnLeaseToken` — degraded when the wait timed out
(fail-open: caller proceeds unserialized). Returns ``None`` for a
falsy ``session_id``.
"""
if not session_id:
return None
wait = float(timeout) if timeout and timeout > 0 else DEFAULT_LEASE_WAIT
token = TurnLeaseToken(session_id, owner_key, int(generation))
lease = self._get_or_create(session_id)
if lease.lock.locked():
holder = lease.holder
logger.warning(
"turn lease contention on session %s: routing key %s (gen %s) "
"waiting behind in-flight turn held by routing key %s (gen %s, "
"held %.0fs) — two routing keys are mapped to one session_id "
"(#64934); serializing this turn behind the previous turn's "
"flush",
session_id,
owner_key,
generation,
holder.owner_key if holder else "?",
holder.generation if holder else "?",
time.time() - lease.acquired_at if lease.acquired_at else -1.0,
)
try:
await asyncio.wait_for(lease.lock.acquire(), timeout=wait)
except asyncio.TimeoutError:
holder = lease.holder
logger.error(
"turn lease wait timed out after %.0fs on session %s "
"(waiter: routing key %s gen %s; holder: routing key %s "
"gen %s) — failing open: this turn runs UNSERIALIZED against "
"the stuck holder rather than wedging the session; transcript "
"writes may interleave",
wait,
session_id,
owner_key,
generation,
holder.owner_key if holder else "?",
holder.generation if holder else "?",
)
token.degraded = True
return token
lease.holder = token
lease.acquired_at = time.time()
lease.last_used = lease.acquired_at
return token
def rebind(self, token: Optional[TurnLeaseToken], new_session_id: str) -> bool:
"""Alias a HELD lease onto ``new_session_id`` after mid-turn rotation.
Compression can rotate the durable session_id while a turn is in
flight (session-hygiene pre-compression, in-agent compression). The
turn's flush then targets the NEW id — so the serialization boundary
must follow it, or an alias routing key resolving the new id (e.g. a
topic tip-walk landing on the fresh child) could start a concurrent
turn the lease never sees. This closes the rotation-alias window
flagged on #64934.
Mechanism: the SAME ``_SessionLease`` object is registered under the
new id (the old mapping stays until it goes idle and is evicted), so
acquirers on either id serialize against one lock — no lock state is
moved, no asyncio internals are touched. Only the current holder can
rebind (identity-checked like release), and the token follows to the
new id so release frees the shared object.
Edge: if the new id already has a live lease of its own (another
turn is running on the target session), the two serialization
domains cannot be merged mid-wait — log loudly and keep the token on
the old id. Fail-open, never deadlock: a holder cannot wait mid-turn.
"""
if (
token is None
or token.degraded
or token.released
or not new_session_id
or new_session_id == token.session_id
):
return False
lease = self._leases.get(token.session_id)
if lease is None or lease.holder is not token:
return False
existing = self._leases.get(new_session_id)
if existing is not None and existing is not lease and not existing.idle:
holder = existing.holder
logger.warning(
"turn lease rebind blocked: session %s rotated to %s mid-turn "
"(holder: routing key %s gen %s) but the target session's "
"lease is already live (holder: routing key %s gen %s) — "
"keeping the lease on the old id; transcript writes on %s "
"may interleave (#64934 rotation-alias edge)",
token.session_id,
new_session_id,
token.owner_key,
token.generation,
holder.owner_key if holder else "?",
holder.generation if holder else "?",
new_session_id,
)
return False
self._leases[new_session_id] = lease
lease.last_used = time.time()
token.session_id = new_session_id
return True
def release(self, token: Optional[TurnLeaseToken]) -> bool:
"""Release ``token``'s lease. Idempotent; ownership-checked.
Returns True only when this exact token was the current holder and
the lock was freed. A degraded token, a re-release, or a stale token
whose slot has since been granted to a newer turn are all safe
no-ops — a stale unwind can never release a newer turn's lease.
"""
if token is None or token.degraded or token.released:
return False
token.released = True
lease = self._leases.get(token.session_id)
if lease is None:
return False
if lease.holder is not token:
logger.debug(
"turn lease release skipped on session %s: token (key %s "
"gen %s) is not the current holder",
token.session_id,
token.owner_key,
token.generation,
)
return False
lease.holder = None
lease.acquired_at = 0.0
lease.last_used = time.time()
if lease.lock.locked():
lease.lock.release()
return True
@@ -56,16 +56,18 @@ def test_auto_reset_cleanup_evicts_cached_agent():
tree = ast.parse(inspect.getsource(gateway_run))
# Fingerprint the cleanup branch: the `if <was_auto_reset>:` block that
# drops transient session state (calls the reasoning-override setter AND
# consumes the flag by setting was_auto_reset = False). The eviction must
# live in that same block.
# clears the conversation scope via the funnel (post-#64934 refactor:
# one _clear_conversation_scope call replaced the inline pops) and
# consumes the flag by setting was_auto_reset = False. The eviction must
# live in that same block — the funnel deliberately does NOT evict the
# agent cache (it has its own resource-cleanup path).
found = False
for node in ast.walk(tree):
if not isinstance(node, ast.If):
continue
calls = _calls(node)
if (
"_set_session_reasoning_override" in calls
"_clear_conversation_scope" in calls
and _assigns_false(node, "was_auto_reset")
):
assert "_evict_cached_agent" in calls, (
@@ -79,7 +81,7 @@ def test_auto_reset_cleanup_evicts_cached_agent():
break
assert found, (
"could not locate the auto-reset transient-state cleanup block in "
"gateway/run.py (fingerprint: _set_session_reasoning_override + "
"gateway/run.py (fingerprint: _clear_conversation_scope + "
"was_auto_reset = False)."
)
@@ -102,37 +104,18 @@ def _references_name(node: ast.AST, literal: str) -> bool:
def test_auto_reset_cleanup_clears_last_resolved_model():
"""Regression test for #58403.
The auto-reset cleanup block (daily/idle/suspended, fingerprinted by
`_set_session_reasoning_override` + `was_auto_reset = False`) already
pops `_session_model_overrides` and `_pending_model_notes` — the same
"full conversation boundary" treatment /new and the compression-exhausted
auto-reset give `_last_resolved_model`. Without also popping
`_last_resolved_model` here, the fresh auto-reset session could serve a
model cached before the reset on a transient config-cache miss.
Auto-reset is a full conversation boundary and now routes through the
`_clear_conversation_scope` funnel. The funnel must clear
`_last_resolved_model` — without it, the fresh auto-reset session could
serve a model cached before the reset on a transient config-cache miss.
Behavioral: exercises the real funnel instead of pinning source shape.
"""
tree = ast.parse(inspect.getsource(gateway_run))
found = False
for node in ast.walk(tree):
if not isinstance(node, ast.If):
continue
calls = _calls(node)
if (
"_set_session_reasoning_override" in calls
and _assigns_false(node, "was_auto_reset")
):
assert _references_name(node, "_last_resolved_model") and "pop" in _calls(
node
), (
"gateway/run.py auto-reset cleanup block must also pop the "
"session's entry from `_last_resolved_model`, mirroring the "
"existing `_session_model_overrides`/`_pending_model_notes` "
"pops in the same block (#58403)."
)
found = True
break
assert found, (
"could not locate the auto-reset transient-state cleanup block in "
"gateway/run.py (fingerprint: _set_session_reasoning_override + "
"was_auto_reset = False)."
runner = object.__new__(gateway_run.GatewayRunner)
key = "agent:main:telegram:dm:58403"
runner._last_resolved_model = {key: "stale/model", "other": "keep/me"}
runner._clear_conversation_scope(key, reason="auto_reset")
assert key not in runner._last_resolved_model, (
"the conversation-boundary funnel must pop the session's entry from "
"`_last_resolved_model` (#58403) — auto-reset routes through it"
)
assert runner._last_resolved_model.get("other") == "keep/me"
@@ -49,26 +49,22 @@ def test_run_consumes_was_auto_reset_in_cleanup_block():
wipe an override stored between turns (#48031)."""
tree = ast.parse(inspect.getsource(gateway_run))
# Find the cleanup branch: an `if <flag>:` block that pops a model/reasoning
# override AND clears the flag. We assert at least one such block sets
# was_auto_reset False.
# Find the cleanup branch: an `if <flag>:` block that clears the
# conversation scope (post-funnel: one _clear_conversation_scope call
# replaced the inline override pops) AND clears the flag. We assert at
# least one such block sets was_auto_reset False.
found = False
for node in ast.walk(tree):
if not isinstance(node, ast.If):
continue
names = {
n.attr
for n in ast.walk(node)
if isinstance(n, ast.Attribute)
}
calls = {
n.func.attr
for n in ast.walk(node)
if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute)
}
# The cleanup block references the reasoning-override setter and pops
# pending model notes — fingerprint of the transient-state cleanup.
if "_set_session_reasoning_override" in calls and _assigns_false(node, "was_auto_reset"):
# The cleanup block routes through the conversation-scope funnel —
# fingerprint of the transient-state cleanup.
if "_clear_conversation_scope" in calls and _assigns_false(node, "was_auto_reset"):
found = True
break
assert found, (
@@ -0,0 +1,78 @@
"""Behavior tests for _clear_conversation_scope — the single conversation-
boundary funnel (#64934 follow-up).
Boundaries (/new, /resume, auto-reset, expiry finalization,
compression-exhausted reset) used to each carry a hand-copied pop-list of the
per-session dicts, and the lists drifted whenever a new dict was added
(#48031, #58403, #10702, #35809 were all "boundary X forgot dict Y" bugs).
The funnel clears every dict registered in _CONVERSATION_SCOPED_STATE plus
the boundary security state, in one call.
"""
from gateway.run import _CONVERSATION_SCOPED_STATE, GatewayRunner
KEY = "agent:main:telegram:dm:777"
OTHER = "agent:main:discord:dm:888"
def _bare_runner() -> GatewayRunner:
runner = object.__new__(GatewayRunner)
for attr in _CONVERSATION_SCOPED_STATE:
setattr(runner, attr, {KEY: object(), OTHER: object()})
# Turn-scoped state that the funnel must NOT touch.
runner._running_agents = {KEY: object()}
runner._running_agents_ts = {KEY: 1.0}
runner._session_run_generation = {KEY: 7}
return runner
def test_funnel_clears_every_registered_dict_for_key_only():
runner = _bare_runner()
runner._clear_conversation_scope(KEY, reason="test")
for attr in _CONVERSATION_SCOPED_STATE:
store = getattr(runner, attr)
assert KEY not in store, f"{attr} not cleared by funnel"
assert OTHER in store, f"{attr} cleared the wrong session"
def test_funnel_leaves_turn_scoped_and_generation_state_alone():
runner = _bare_runner()
runner._clear_conversation_scope(KEY, reason="test")
# Turn-scoped: owned by _release_running_agent_state / dispatch finally.
assert KEY in runner._running_agents
assert KEY in runner._running_agents_ts
# Generation counter is monotonic by design (#28686) — never reset.
assert runner._session_run_generation[KEY] == 7
def test_funnel_is_bare_runner_safe_and_empty_key_noop():
runner = object.__new__(GatewayRunner)
# No dicts initialized at all — must not raise (pitfall #17).
runner._clear_conversation_scope(KEY, reason="test")
runner._clear_conversation_scope("", reason="test")
def test_funnel_clears_state_written_by_real_setters():
"""Behavioral invariant: state written through the runner's real setter
paths is cleared by the funnel. Guards against a registry entry drifting
out of sync with the attribute the setter actually writes (a typo'd
registry name would silently clear nothing and resurrect the
boundary-drift bug class the funnel exists to kill)."""
runner = object.__new__(GatewayRunner)
# Real setter: lazily creates _session_reasoning_overrides.
runner._set_session_reasoning_override(KEY, {"effort": "high"})
assert runner._session_reasoning_overrides.get(KEY) == {"effort": "high"}
runner._clear_conversation_scope(KEY, reason="test")
assert KEY not in runner._session_reasoning_overrides
def test_funnel_also_clears_boundary_security_state():
runner = _bare_runner()
runner._pending_approvals = {KEY: {"cmd": "rm -rf"}, OTHER: {}}
runner._update_prompt_pending = {KEY: True}
runner._pending_skills_reload_notes = {KEY: "note"}
runner._clear_conversation_scope(KEY, reason="test")
assert KEY not in runner._pending_approvals
assert OTHER in runner._pending_approvals
assert KEY not in runner._update_prompt_pending
assert KEY not in runner._pending_skills_reload_notes
+305
View File
@@ -0,0 +1,305 @@
"""Behavior tests for the per-session turn lease (#64934).
The lease serializes the [load history → run → flush] region per RESOLVED
session_id, closing the alias-key overlap route: two routing keys mapped to
one session_id via switch_session() run turns on two different agent objects,
invisible to every routing-key guard.
Covers:
- alias-key turn waits until the first turn's flush, and flush order is
preserved (the second turn loads history AFTER the first turn's release)
- distinct sessions do not contend
- generation-scoped, idempotent release: a stale unwind can never free a
newer turn's lease; double-release is a no-op
- timeout fail-open: a stuck holder degrades to unserialized with a degraded
token, never a wedged session, and the degraded token releases nothing
- registry stays bounded; live leases are never evicted
- GatewayRunner._release_turn_lease wiring (bare-runner safe, token-scoped)
"""
import asyncio
import pytest
from gateway.turn_lease import SessionTurnLeaseRegistry
def _run(coro):
return asyncio.run(coro)
# ---------------------------------------------------------------------------
# Serialization behavior
# ---------------------------------------------------------------------------
def test_alias_key_turn_waits_and_order_is_preserved():
"""Second routing key on the same session_id waits for the first turn's
release; events interleave in strict [load1, flush1, load2, flush2] order."""
async def scenario():
registry = SessionTurnLeaseRegistry()
events = []
async def turn(owner_key, generation, hold):
token = await registry.acquire(
"sess-1", owner_key=owner_key, generation=generation, timeout=5
)
assert token is not None and not token.degraded
events.append(f"load:{owner_key}")
await asyncio.sleep(hold) # simulate run + flush
events.append(f"flush:{owner_key}")
registry.release(token)
t1 = asyncio.create_task(turn("key-a", 1, hold=0.05))
await asyncio.sleep(0.01) # let turn 1 take the lease
t2 = asyncio.create_task(turn("key-b", 1, hold=0))
await asyncio.gather(t1, t2)
return events
events = _run(scenario())
assert events == ["load:key-a", "flush:key-a", "load:key-b", "flush:key-b"]
def test_distinct_sessions_do_not_contend():
async def scenario():
registry = SessionTurnLeaseRegistry()
order = []
async def turn(session_id, owner_key):
token = await registry.acquire(
session_id, owner_key=owner_key, generation=1, timeout=5
)
order.append(f"start:{session_id}")
await asyncio.sleep(0.05)
order.append(f"end:{session_id}")
registry.release(token)
await asyncio.gather(turn("sess-a", "key-a"), turn("sess-b", "key-b"))
return order
order = _run(scenario())
# Both started before either finished — no serialization across sessions.
assert order[:2] == ["start:sess-a", "start:sess-b"]
def test_contention_logs_named_warning(caplog):
async def scenario():
registry = SessionTurnLeaseRegistry()
t1 = await registry.acquire("sess-w", owner_key="key-a", generation=3, timeout=5)
async def second():
t2 = await registry.acquire(
"sess-w", owner_key="key-b", generation=7, timeout=5
)
registry.release(t2)
task = asyncio.create_task(second())
await asyncio.sleep(0.01)
registry.release(t1)
await task
with caplog.at_level("WARNING", logger="gateway.turn_lease"):
_run(scenario())
warnings = [r for r in caplog.records if "turn lease contention" in r.getMessage()]
assert len(warnings) == 1
msg = warnings[0].getMessage()
assert "sess-w" in msg and "key-a" in msg and "key-b" in msg
# ---------------------------------------------------------------------------
# Release semantics
# ---------------------------------------------------------------------------
def test_generation_scoped_idempotent_release():
"""A released token re-released is a no-op, and a stale token cannot free
a newer turn's lease."""
async def scenario():
registry = SessionTurnLeaseRegistry()
stale = await registry.acquire("sess-g", owner_key="key-a", generation=1, timeout=5)
assert stale is not None
assert registry.release(stale) is True
# Double release: no-op.
assert registry.release(stale) is False
newer = await registry.acquire("sess-g", owner_key="key-a", generation=2, timeout=5)
# Stale token (already released, older generation) must not free the
# newer holder even if some unwind calls release again.
stale.released = False # simulate a buggy double-unwind resurrecting it
assert registry.release(stale) is False
# Newer turn still holds the lease: a third acquire must wait.
waiter = asyncio.create_task(
registry.acquire("sess-g", owner_key="key-b", generation=3, timeout=5)
)
await asyncio.sleep(0.02)
assert not waiter.done()
assert registry.release(newer) is True
third = await waiter
assert third is not None and not third.degraded
registry.release(third)
_run(scenario())
def test_release_none_and_empty_session_are_noops():
async def scenario():
registry = SessionTurnLeaseRegistry()
assert registry.release(None) is False
assert await registry.acquire("", owner_key="k", generation=1) is None
_run(scenario())
# ---------------------------------------------------------------------------
# Fail-open on timeout
# ---------------------------------------------------------------------------
def test_timeout_fails_open_with_degraded_token(caplog):
async def scenario():
registry = SessionTurnLeaseRegistry()
holder = await registry.acquire(
"sess-t", owner_key="key-stuck", generation=1, timeout=5
)
degraded = await registry.acquire(
"sess-t", owner_key="key-b", generation=2, timeout=0.05
)
assert degraded is not None
assert degraded.degraded is True
# Degraded release must NOT free the stuck holder's lock (no lease theft).
assert registry.release(degraded) is False
third = asyncio.create_task(
registry.acquire("sess-t", owner_key="key-c", generation=3, timeout=5)
)
await asyncio.sleep(0.02)
assert not third.done() # still held by the original holder
registry.release(holder)
t3 = await third
assert t3 is not None and not t3.degraded
registry.release(t3)
with caplog.at_level("ERROR", logger="gateway.turn_lease"):
_run(scenario())
errors = [r for r in caplog.records if "failing open" in r.getMessage()]
assert len(errors) == 1
assert "sess-t" in errors[0].getMessage()
# ---------------------------------------------------------------------------
# Bounded registry
# ---------------------------------------------------------------------------
def test_registry_bounded_and_never_evicts_live_lease():
async def scenario():
registry = SessionTurnLeaseRegistry(max_entries=5)
live = await registry.acquire("live", owner_key="k", generation=1, timeout=5)
# Churn far past the cap with idle leases.
for i in range(50):
t = await registry.acquire(f"s{i}", owner_key="k", generation=1, timeout=5)
registry.release(t)
assert len(registry) <= 6 # cap + the transient new entry
# The live lease survived every eviction pass: releasing it works and
# it still serializes.
assert registry.release(live) is True
_run(scenario())
# ---------------------------------------------------------------------------
# Mid-turn rotation rebind
# ---------------------------------------------------------------------------
def test_rebind_moves_serialization_to_new_session_id():
"""After a mid-turn compression rotation, an alias key acquiring the NEW
id must wait behind the holder; release under the new id frees it."""
async def scenario():
registry = SessionTurnLeaseRegistry()
token = await registry.acquire("parent", owner_key="key-a", generation=1, timeout=5)
assert registry.rebind(token, "child") is True
assert token is not None and token.session_id == "child"
# Alias key resolving the fresh child id serializes behind the holder.
waiter = asyncio.create_task(
registry.acquire("child", owner_key="key-b", generation=1, timeout=5)
)
await asyncio.sleep(0.02)
assert not waiter.done()
assert registry.release(token) is True
t2 = await waiter
assert t2 is not None and not t2.degraded
registry.release(t2)
_run(scenario())
def test_rebind_is_ownership_checked_and_noop_safe():
async def scenario():
registry = SessionTurnLeaseRegistry()
token = await registry.acquire("s1", owner_key="k", generation=1, timeout=5)
assert token is not None
# Same id → no-op.
assert registry.rebind(token, "s1") is False
# Empty target → no-op.
assert registry.rebind(token, "") is False
# None / released tokens → no-op.
assert registry.rebind(None, "s2") is False
registry.release(token)
assert registry.rebind(token, "s2") is False
_run(scenario())
def test_rebind_blocked_when_target_lease_is_live():
"""Two live serialization domains can't be merged mid-wait — rebind
fails open (token stays on the old id) with a loud warning."""
async def scenario():
registry = SessionTurnLeaseRegistry()
t_a = await registry.acquire("sess-a", owner_key="key-a", generation=1, timeout=5)
t_b = await registry.acquire("sess-b", owner_key="key-b", generation=1, timeout=5)
assert t_a is not None and t_b is not None
assert registry.rebind(t_a, "sess-b") is False
assert t_a.session_id == "sess-a" # unchanged
# Both still release cleanly under their own ids.
assert registry.release(t_a) is True
assert registry.release(t_b) is True
_run(scenario())
# ---------------------------------------------------------------------------
# GatewayRunner wiring
# ---------------------------------------------------------------------------
def test_runner_release_turn_lease_is_token_scoped_and_bare_safe():
from gateway.run import GatewayRunner
runner = object.__new__(GatewayRunner)
# Bare runner without __init__: must be a safe no-op (pitfall #17).
assert runner._release_turn_lease("key-a", 1) is False
async def scenario():
runner._turn_leases = SessionTurnLeaseRegistry()
runner._turn_lease_tokens = {}
token = await runner._turn_leases.acquire(
"sess-r", owner_key="key-a", generation=1, timeout=5
)
runner._turn_lease_tokens[("key-a", 1)] = token
# Wrong generation: pops nothing, releases nothing.
assert runner._release_turn_lease("key-a", 2) is False
assert runner._turn_leases._leases["sess-r"].holder is token
# Right (key, generation): releases.
assert runner._release_turn_lease("key-a", 1) is True
# Idempotent.
assert runner._release_turn_lease("key-a", 1) is False
# Empty key guard.
assert runner._release_turn_lease("", 1) is False
_run(scenario())