
    xj[8                         d Z ddlZddlZddlZddlmZmZ  ej        e          Z	dZ
dZ G d de          Z G d d	          Z G d
 d          Z G d d          ZdS )ub  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-closed on timeout.** A timed-out waiter raises
  :class:`TurnLeaseTimeoutError` and must be rejected by the dispatch layer
  with a visible resend notice. It never runs concurrently against the
  still-held lease and therefore cannot defeat the serialization invariant.
- **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.
    N)DictOptionali   g      @c            
       8     e Zd ZdZdededededdf
 fdZ xZS )	TurnLeaseTimeoutErrorzThe session lease stayed held for the caller's full wait budget.

    This is a fail-closed signal: the caller did not acquire the lease and
    must not enter the transcript load/run/flush region for this turn.
    
session_id	owner_key
generationwait_secondsreturnNc                    || _         || _        || _        || _        t	                                          d|dd| d| d| d	           d S )Nz turn lease wait timed out after z.0fzs on session z for routing key z (gen ))r   r   r	   r
   super__init__)selfr   r   r	   r
   	__class__s        6/home/agent/.hermes/hermes-agent/gateway/turn_lease.pyr   zTurnLeaseTimeoutError.__init__O   s     %"$(K|N K KK K,5K K=GK K K	
 	
 	
 	
 	
    )	__name__
__module____qualname____doc__strintfloatr   __classcell__)r   s   @r   r   r   H   sw         

 	

 
 
 

 
 
 
 
 
 
 
 
 
r   r   c                   :    e Zd ZdZdZdedededdfdZdefd	ZdS )
TurnLeaseTokenzHandle returned by :meth:`SessionTurnLeaseRegistry.acquire`.

    A timeout raises :class:`TurnLeaseTimeoutError` instead of returning a
    token, so every token handed out is a held lease. ``released`` makes
    release idempotent.
    r   r   r	   releasedr   r   r	   r   Nc                 >    || _         || _        || _        d| _        d S )NFr   )r   r   r   r	   s       r   r   zTurnLeaseToken.__init__k   s$     %"$r   c           	      H    d| j         d| j        d| j         d| j         d	S )NzTurnLeaseToken(session_id=z, owner_key=z, generation=z, released=r   r   r   s    r   __repr__zTurnLeaseToken.__repr__v   sL    ) ) )) )8<) )) ) )	
r   )	r   r   r   r   	__slots__r   r   r   r#    r   r   r   r   a   s{          FI		 	 		
 
	 	 	 	
# 
 
 
 
 
 
r   r   c                   6    e Zd ZdZddZedefd            ZdS )_SessionLease)lockholderacquired_at	last_usedpending_acquiresr   Nc                     t          j                    | _        d | _        d| _        t          j                    | _        d| _        d S )N        r   )asyncioLockr(   r)   r*   timer+   r,   r"   s    r   r   z_SessionLease.__init__   s9    LNN	04 !r   c                 ^    | j         du o$| j                                         o
| j        dk    S )z?True when this lease can be evicted: nobody holds or awaits it.Nr   )r)   r(   lockedr,   r"   s    r   idlez_SessionLease.idle   s<     K4 +I$$&&&+%*	
r   r   N)r   r   r   r$   r   propertyboolr4   r%   r   r   r'   r'   ~   sV        I" " " " 
d 
 
 
 X
 
 
r   r'   c                       e Zd ZdZefdeddfdZdefdZdede	fdZ
dd	Zdd
dedededee         dee         f
dZdee         dedefdZdee         defdZdS )SessionTurnLeaseRegistryu  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.
    max_entriesr   Nc                 X    i | _         t          dt          |                    | _        d S )N   )_leasesmaxr   _max_entries)r   r:   s     r   r   z!SessionTurnLeaseRegistry.__init__   s)    133{#3#344r   c                 *    t          | j                  S N)lenr=   r"   s    r   __len__z SessionTurnLeaseRegistry.__len__   s    4<   r   r   c                     | j                             |          }|,|                                  t                      }|| j         |<   t	          j                    |_        |S rA   )r=   get_evict_idler'   r1   r+   )r   r   leases      r   _get_or_createz'SessionTurnLeaseRegistry._get_or_create   sU      ,,=!OOE',DL$)++r   c                     t           j                   j        z
  dz   }|dk    rdS t          d  j                                        D              fd          }|d|         D ]} j                            |d           dS )u   Drop oldest idle entries so a new lease fits under the cap.

        Never evicts a held or contended lease — correctness beats the cap.
        r<   r   Nc              3   .   K   | ]\  }}|j         |V  d S rA   )r4   ).0sidrG   s      r   	<genexpr>z7SessionTurnLeaseRegistry._evict_idle.<locals>.<genexpr>   s-      FFZS%5:FSFFFFFFr   c                 (    j         |          j        S rA   )r=   r+   )rL   r   s    r   <lambda>z6SessionTurnLeaseRegistry._evict_idle.<locals>.<lambda>   s    DL-7 r   )key)rB   r=   r?   sorteditemspop)r   overflowidle_idsrL   s   `   r   rF   z$SessionTurnLeaseRegistry._evict_idle   s    
 t|$$t'881<q==FFF4<#5#5#7#7FFF7777
 
 
 IXI& 	( 	(CLS$''''	( 	(r   timeoutr   r	   rW   c          
        K   |sdS |r|dk    rt          |          nt          }t          ||t          |                    }|                     |          }|j                                        r[|j        }t          	                    d||||r|j
        nd|r|j        nd|j        rt          j                    |j        z
  nd           |xj        dz  c_        	 t          j        |j                                        |           d{V  n`# t          j        $ rN |j        }t                              d|||||r|j
        nd|r|j        nd           t)          ||||	          dw xY w	 |xj        dz  c_        n# |xj        dz  c_        w xY w||_        t          j                    |_        |j        |_        |S )
a>  Acquire the turn lease for ``session_id``, waiting if held.

        Returns a held :class:`TurnLeaseToken`. Raises
        :class:`TurnLeaseTimeoutError` when the wait budget expires; the caller
        must reject rather than enter the serialized region. Returns ``None``
        for a falsy ``session_id``.
        Nr   u   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?g      r<   rV   u   turn lease wait timed out after %.0fs on session %s (waiter: routing key %s gen %s; holder: routing key %s gen %s) — failing closed: refusing to run this turn UNSERIALIZED against the still-held lease)r   r	   r
   )r   DEFAULT_LEASE_WAITr   r   rH   r(   r3   r)   loggerwarningr   r	   r*   r1   r,   r/   wait_foracquireTimeoutErrorerrorr   r+   )	r   r   r   r	   rW   waittokenrG   r)   s	            r   r^   z SessionTurnLeaseRegistry.acquire   s.       	4!(PWq[[uW~~~>Pz9c*ooFF##J//: 	\FNN
 $*3  %+4!!383DN	e///$  & 	!#	("5:#5#5#7#7FFFFFFFFFFF# 	 	 	\FLL< $*3  %+4!!   (#%!	  
 	 G, ""a'"""E""a'""""""  IKK+s   3D F AE..F Frb   new_session_idc           
         ||j         s|r||j        k    rdS | j                            |j                  }|	|j        |urdS | j                            |          }|V||urR|j        sK|j        }t                              d|j        ||j        |j	        |r|j        nd|r|j	        nd|           dS || j        |<   t          j
                    |_        ||_        dS )u  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.
        NFu  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)rY   T)r   r   r=   rE   r)   r4   r[   r\   r   r	   r1   r+   )r   rb   rc   rG   existingr)   s         r   rebindzSessionTurnLeaseRegistry.rebind
  s   0 M~ !  !1115  !122=EL555<##N33HE$9$9(-$9_FNN>
   $*3  %+4!!   5',^$)++)tr   c                    ||j         rdS d|_         | j                            |j                  }|dS |j        |ur.t
                              d|j        |j        |j                   dS d|_        d|_	        t          j
                    |_        |j                                        r|j                                         dS )uZ  Release ``token``'s lease. Idempotent; ownership-checked.

        Returns True only when this exact token was the current holder and
        the lock was freed. A re-release or a stale token whose slot has
        since been granted to a newer turn are both safe no-ops — a stale
        unwind can never release a newer turn's lease.
        NFTzYturn lease release skipped on session %s: token (key %s gen %s) is not the current holderr.   )r   r=   rE   r   r)   r[   debugr   r	   r*   r1   r+   r(   r3   release)r   rb   rG   s      r   ri   z SessionTurnLeaseRegistry.releaseD  s     =EN=5  !122=5<u$$LL4     5)++: 	!J   tr   r5   )r   r   r   r   DEFAULT_MAX_LEASESr   r   rC   r   r'   rH   rF   r   r   r   r^   r7   rf   ri   r%   r   r   r9   r9      sY         += 5 5C 5 5 5 5 5! ! ! ! !     ( ( ( (* $(I I II 	I
 I %I 
.	!I I I IV8H^4 8c 8d 8 8 8 8tXn5 $      r   r9   )r   r/   loggingr1   typingr   r   	getLoggerr   r[   rj   rZ   r_   r   r   r'   r9   r%   r   r   <module>rn      s  0 0d    ! ! ! ! ! ! ! !		8	$	$    
 
 
 
 
L 
 
 
2
 
 
 
 
 
 
 
:
 
 
 
 
 
 
 
4H H H H H H H H H Hr   