Ë
    âß²j€Î  ã            
      óÜ  — d Z ddlmZ ddlZddlZddlZddlZddlmZ ddl	m
Z
mZmZmZmZmZ ddlmZmZmZmZmZ  ej,                  d«      Zd ed	d
«      › d ed	d«      › d ej0                  d¬«      › d�ZdZe› de› de› d�Ze› de› de› d�ZdZd ed«      › d edd
«      › d edd«      › d�Zde› d �Zde› d! ed"«      › d# ed"«      › d$ ed"«      › d%�	Z d&Z!d+d'„Z"d(Z# G d)„ d*«      Z$y),zäGateway-facing SessionDB persistence: routing index, peers, orphans, heartbeats, handoffs.

Mixin bound onto ``SessionDB`` via the MRO; built on its ``_read_ctx`` /
``_execute_write`` / ``_write_sql`` / ``_read_all`` primitives.é    )ÚannotationsN)ÚPath)ÚAnyÚDictÚListÚOptionalÚSetÚTuple)Ú_RECOVERABLE_END_REASONS_SQLÚ_RESET_CHILD_SQLÚ_RESET_END_REASONS_SQLÚ_sql_json_extractÚ_sql_session_last_activeÚhermes_stateaÙ  
                    WITH RECURSIVE compression_lineage(id) AS (
                        SELECT ?
                        UNION
                        SELECT parent.id
                        FROM compression_lineage lineage
                        JOIN sessions child ON child.id = lineage.id
                        JOIN sessions parent ON parent.id = child.parent_session_id
                        WHERE parent.end_reason = 'compression'
                          AND zchild.model_configz$._branched_fromz' IS NULL
                          AND z$._delegate_fromz, IS NULL
                          AND NOT (Úchild)Úazk)
                          AND COALESCE(child.source, '') != 'tool'
                    )
                aÉ  
                SELECT s.*,
                       COALESCE(sp.prompt, s.system_prompt)
                           AS _system_prompt_resolved,
                       (COALESCE(s.message_count, 0) > 0 OR EXISTS (
                           SELECT 1 FROM messages WHERE messages.session_id = s.id LIMIT 1
                       )) AS _has_messages
                FROM sessions s
                LEFT JOIN system_prompts sp ON sp.hash = s.system_prompt_hash
z‰                WHERE s.session_key = ?
                  AND s.source = ?
                  AND (s.ended_at IS NULL OR s.end_reason IN (a  ))
                  AND NOT EXISTS (
                      SELECT 1 FROM sessions b
                      WHERE b.session_key = s.session_key
                        AND b.source = s.source
                        AND b.ended_at IS NOT NULL
                        AND b.end_reason IN (a$  )
                        AND b.ended_at
                            > COALESCE(s.last_activity_at, s.started_at)
                  )
                ORDER BY _has_messages DESC,
                         COALESCE(s.last_activity_at, s.started_at) DESC
                LIMIT 1
                aª                  WHERE s.source = ?
                  AND COALESCE(s.user_id, '') = COALESCE(?, '')
                  AND COALESCE(s.chat_id, '') = COALESCE(?, '')
                  AND COALESCE(s.chat_type, '') = COALESCE(?, '')
                  AND COALESCE(s.thread_id, '') = COALESCE(?, '')
                  AND (? IS NULL OR COALESCE(s.profile_name, ?) = ?)
                  AND (s.ended_at IS NULL OR s.end_reason IN (aÔ  ))
                  AND (COALESCE(s.message_count, 0) > 0 OR EXISTS (
                      SELECT 1 FROM messages WHERE messages.session_id = s.id LIMIT 1
                  ))
                  AND NOT EXISTS (
                      SELECT 1 FROM sessions b
                      WHERE b.source = s.source
                        AND COALESCE(b.user_id, '') = COALESCE(s.user_id, '')
                        AND COALESCE(b.chat_id, '') = COALESCE(s.chat_id, '')
                        AND COALESCE(b.chat_type, '') = COALESCE(s.chat_type, '')
                        AND COALESCE(b.thread_id, '') = COALESCE(s.thread_id, '')
                        AND b.ended_at IS NOT NULL
                        AND b.end_reason IN (z÷)
                        AND b.ended_at
                            > COALESCE(s.last_activity_at, s.started_at)
                  )
                ORDER BY COALESCE(s.last_activity_at, s.started_at) DESC
                LIMIT 1
                zpd.id, d.session_key, d.chat_id, d.chat_type, d.thread_id, d.user_id, d.origin_json, d.display_name, d.end_reasonz„
                SELECT o.id, o.source, o.user_id, o.started_at,
                       o.parent_session_id,
                       Úoa˜   AS last_active,
                       (SELECT COUNT(*) FROM messages m
                         WHERE m.session_id = o.id) AS message_count
                FROM sessions o
                WHERE o.session_key IS NULL
                  AND EXISTS (SELECT 1 FROM messages m
                               WHERE m.session_id = o.id)
                  AND COALESCE(o.source, '') != 'tool'
                  AND zo.model_configz IS NULL
                  AND zC IS NULL
                ORDER BY o.started_at ASC
                z 
                        SELECT zç
                        FROM sessions d
                        WHERE d.id = ?
                          AND d.session_key IS NOT NULL
                          AND COALESCE(d.source, '') = COALESCE(?, '')
                        ú, Úda¡   AS last_active
                        FROM sessions d
                        WHERE d.session_key IS NOT NULL
                          AND d.id != ?
                          AND COALESCE(d.source, '') = COALESCE(?, '')
                          AND (COALESCE(d.user_id, '') = ''
                               OR COALESCE(?, '') = ''
                               OR d.user_id = ?)
                          AND z/ BETWEEN ? AND ?
                          AND zo < ?
                        ORDER BY last_active DESC
                        LIMIT 2
                        )Útelegram_dm_topic_modeÚtelegram_dm_topic_bindingsÚdelivery_obligationsc           
     óì   — | j                  dt        «      D �ch c]  }|d   ’Œ	 }}t        D ��ci c]-  }||v r'|| j                  d|› d�«      D �ch c]  }|d   ’Œ	 c}“Œ/ c}}S c c}w c c}w c c}}w )a  Live column sets of the lazily-created tables that exist (``{}`` when none do).

    ``apply_telegram_topic_migration`` runs only on explicit ``/topic`` opt-in, so a store
    can hold supported v1/v2 tables without ``profile_name`` for its whole life; likewise the
    delivery ledger adds ``delivery_obligations.adapter_profile`` only when a gateway opens
    it. Identity settlement must gate its column SQL on the column actually being there
    rather than on table existence, and must not force those migrations (#113757).
    zGSELECT name FROM sqlite_master WHERE type='table' AND name IN (?, ?, ?)r   zPRAGMA table_info('z')é   )ÚexecuteÚ_OPTIONAL_TABLE_NAMES)ÚconnÚrowÚexistingÚtables       ú9/home/ubuntu/.hermes/hermes-agent/hermes_state_gateway.pyÚ_optional_table_columnsr"   Š   s‹   € ð #'§,¡,ØQÜó#ö  ˜3��A“ð  €Hð  ô
 +÷à¨e°xÑ.?ð 	 $§,¡,Ð1DÀUÀGÈ2Ð/NÓ"OÖP˜3��A“ÒPÑPóð ùò ùò Qùós   ™A&°!A0ÁA+ÁA0Á+A0zFUPDATE sessions SET handoff_state = 'failed', handoff_error = ? WHERE c            
      óô  — e Zd ZdZ	 	 	 	 	 	 d,d„Zddddddddddœ		 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 d-d„Zddœd.d	„Zddœd/d
„Zddœd0d„Zd1d„Z	d2d„Z
d3d„Zddœ	 	 	 	 	 d4d„Zdddœ	 	 	 	 	 d5d„Zddddddœ	 	 	 	 	 	 	 	 	 	 	 	 	 d6d„Zddœd7d„Zd8d„Zd9d„Zd:d„Zd;d„Zd<d„Zed=d„«       Zddddœ	 	 	 	 	 	 	 	 	 	 	 	 	 d>d„Zd?d „Zd@d!„ZdAd"„ZdBd#„Zd2d$„ZdAd%„ZdCd&„ZdDd'„ZdEd(„Z dd)œ	 	 	 	 	 	 	 dFd*„Z!dGd+„Z"y)HÚSessionGatewayMixinzLRouting index, session peers/orphans, hygiene streaks, heartbeats, handoffs.c          
     óè  — ddl m} ddlm}m} t
        j                  j                  d«      r|€g S 	 ddlm	} t        j                  «       }g } || j                  |«      D ]�  }		 |j                  |	«      }
|
j                  d¬«      D �cg c]  }|j                  ‘Œ }} ||
j!                  «       ||
j#                  «       z
  | ||
j%                  «       «      |¬«      sŒ	 |j'                  |
«       Œ’ g }|D ].  }
	 |
j)                  «        |j'                  |
j*                  «       Œ0 |sg S 	 |j1                  |d	¬
«      \  }}|D ]  }
	 |
j3                  «        Œ |r	 |j1                  |d	¬
«       |S |S # t        $ r g cY S w xY wc c}w # t        $ r Y �Œ7w xY w# |j,                  t.        f$ r Y Œ»w xY w# t        $ r g }Y Œ€w xY w# |j,                  t.        f$ r Y Œ™w xY w# t        $ r Y |S w xY w)zÏTerminate old PPID-1 Desktop ephemeral backends with no client.

        Fails closed: anything whose parent, age, argv, or network connections
        cannot be proved safe remains a repair-blocking holder.r   )Úpsutil)Ú_concrete_state_db_holder_pidsÚ"_is_inactive_orphan_desktop_holderÚlinux)Ú_is_ephemeral_port_zero_backendÚinet)Úkind)ÚppidÚage_secondsÚmin_age_secondsÚephemeral_backendÚconnection_statusesg      ø?)Útimeout)r   r&   Úhermes_state_dbfiler'   r(   ÚsysÚplatformÚ
startswithÚhermes_cli.dashboard_procsr*   Ú	ExceptionÚtimeÚdb_pathÚProcessÚnet_connectionsÚstatusr-   Úcreate_timeÚcmdlineÚappendÚ	terminateÚpidÚErrorÚOSErrorÚ
wait_procsÚkill)ÚselfÚholdersr/   r&   r'   r(   r*   ÚnowÚ
candidatesrB   Úprocessr   ÚstatusesÚ	signalledÚ_goneÚalives                   r!   Ú%_reap_inactive_orphan_desktop_holdersz9SessionGatewayMixin._reap_inactive_orphan_desktop_holders¢   s"  € õ 	(ßjÜ�|‰|×&Ñ& wÔ/°6°>ØˆIð	ÝRô �i‰i‹kˆØˆ
Ù1°$·,±,ÀÓHò 	'ˆCð
Ø Ÿ.™.¨Ó-�Ø4;×4KÑ4KÐQWÐ4KÓ4XÖY¨D˜DŸK›KÐY�ÐYÙ9Ø Ÿ™›°S¸7×;NÑ;NÓ;PÑ5PØ$3Ù&EÀgÇoÁoÓFWÓ&XØ(0õ	2ð
 ð2ð ×Ñ˜gÕ&ð	'ð  "ˆ	Ø!ò 	ˆGðØ×!Ñ!Ô#Ø× Ñ  §¡Õ-ð	ñ ØˆIð	Ø!×,Ñ,¨ZÀÐ,ÓE‰LˆE�5ð ò 	ˆGðØ—‘•ð	ñ
 ðØ×!Ñ! %°Ð!Ô5ð ÐˆyÐøôQ ò 	ØŠIð	üò Zøô ò Úðûð —L‘L¤'Ð*ò Ùðûô ò 	ØŠEð	ûð
 —L‘L¤'Ð*ò Ùðûô
 ò ØØÐðús~   ³E7 Á&%FÂFÂAFÃ=+FÄ/F8 ÅG	Å G$ Å7FÆFÆFÆ	FÆFÆF5Æ4F5Æ8GÇGÇ	G!Ç G!Ç$	G1Ç0G1NF)	Úuser_idÚsession_keyÚchat_idÚ	chat_typeÚ	thread_idÚdisplay_nameÚorigin_jsonÚinclude_compression_ancestorsÚtransport_profilec       
        ó    ‡ ‡‡‡‡‡‡‡‡‡	‡‡‡— ‰r‰sy‰‰‰‰‰‰‰‰	‰f	}|
Š‰r‰g|¢ng |¢‰‘Šˆˆˆˆˆ	ˆˆ ˆˆˆˆˆˆfd„}‰ j                  |«       y)a  Persist the gateway routing peer for an existing session row. ``display_name`` / ``origin_json``:
        ``None`` leaves the stored value untouched (consumers read routing data from state.db, not
        sessions.json). ``include_compression_ancestors`` keeps a compression lineage on one routing peer
        when an explicit resume moves its tip to another lane; per-turn refreshes update only the supplied
        row. Self-healing: a missing target row (deferred ``create_session`` write, or crash between routing
        publication and row creation) is INSERTed with full identity rather than no-opped, so a gateway row
        is never first-created by the identity-less lazy writer (``update_token_counts``) and left
        unroutable forever.

        See #9006.
        Nc                ó  •— | j                  ‰rt        nd› d‰rdnd› �‰«       ‰ry | j                  d‰f«      j                  «       €?| j                  d‰‰
‰‰	‰‰‰‰‰‰j                  «       ‰t	        j                  «       f«       y y )NÚ a„  
                   UPDATE sessions
                   SET session_key = ?, source = ?, user_id = ?, chat_id = ?,
                       chat_type = ?, thread_id = ?,
                       display_name = COALESCE(?, display_name),
                       origin_json = COALESCE(?, origin_json),
                       transport_profile = COALESCE(?, transport_profile)
                   z0WHERE id IN (SELECT id FROM compression_lineage)zWHERE id = ?z+SELECT 1 FROM sessions WHERE id = ? LIMIT 1aI  INSERT INTO sessions (
                               id, source, user_id, session_key, chat_id,
                               chat_type, thread_id, display_name, origin_json,
                               profile_name, transport_profile, started_at
                           )
                           VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                           ON CONFLICT(id) DO UPDATE SET
                               session_key = COALESCE(sessions.session_key, excluded.session_key),
                               chat_id = COALESCE(sessions.chat_id, excluded.chat_id),
                               chat_type = COALESCE(sessions.chat_type, excluded.chat_type),
                               thread_id = COALESCE(sessions.thread_id, excluded.thread_id),
                               display_name = COALESCE(sessions.display_name, excluded.display_name),
                               origin_json = COALESCE(sessions.origin_json, excluded.origin_json),
                               transport_profile = COALESCE(sessions.transport_profile, excluded.transport_profile))r   Ú_COMPRESSION_LINEAGE_CTEÚfetchoneÚ_own_profile_namer9   )r   Ú	ancestorsrS   rT   rV   rW   Úquery_paramsrG   Ú
session_idrR   ÚsourcerU   rY   rQ   s    €€€€€€€€€€€€€r!   Ú_doz<SessionGatewayMixin.record_gateway_session_peer.<locals>._doï   s¬   ø€ Ø�L‰LÙ1:Õ-ÀÐCð Dñ KTÑFÐYgÐhðlð ô
ñ Øà�|‰|ÐIÈJÈ=ÓY×bÑbÓdÐlØ—‘ðwð    ¨°+¸wÈ	ÐS\Ð^jØ  $×"8Ñ"8Ó":Ð<MÌtÏyÉyË{ð\õ#ð mó    ©Ú_execute_write)rG   rb   rc   rQ   rR   rS   rT   rU   rV   rW   rX   rY   Úidentityrd   r`   ra   s   `````````` `  @@r!   Úrecord_gateway_session_peerz/SessionGatewayMixin.record_gateway_session_peerØ   sm   ÿü€ ñ  ¡Øà˜ ¨'°9¸iÈÐWbØðˆð 2ˆ	Ù2;˜
Ð. XÑ.ÐAXÀ8ÐAXÈZÐAXˆ÷#	÷ #	ð #	ðH 	×Ñ˜CÕ re   r\   )Úscopec               ó^   — |r|sy| j                  d|||t        j                  «       f«       y)z¥Upsert one gateway routing entry (session_key -> SessionEntry JSON); ``scope``
        namespaces the index per sessions_dir so two stores never share routing state.Na  INSERT INTO gateway_routing (scope, session_key, entry_json, updated_at)
               VALUES (?, ?, ?, ?)
               ON CONFLICT(scope, session_key) DO UPDATE SET
                   entry_json = excluded.entry_json,
                   updated_at = excluded.updated_at)Ú
_write_sqlr9   )rG   rR   Ú
entry_jsonrj   s       r!   Úsave_gateway_routing_entryz.SessionGatewayMixin.save_gateway_routing_entry  s1   € ñ ¡*ØØ�‰ð7ð
 �K ¬T¯Y©Y«[Ð9õ	
re   c               ób   ‡‡‡— t        j                   «       Šˆˆˆfd„}| j                  |«       y)zzAtomically replace the routing index for *scope* (keys absent from *entries*
        are removed); other scopes untouched.c                ó¸   •— | j                  d‰f«       ‰r>| j                  d‰j                  «       D ��cg c]  \  }}|sŒ	|sŒ‰||‰f‘Œ c}}«       y y c c}}w )Nz+DELETE FROM gateway_routing WHERE scope = ?z\INSERT INTO gateway_routing (scope, session_key, entry_json, updated_at) VALUES (?, ?, ?, ?))r   ÚexecutemanyÚitems)r   ÚkÚvÚentriesrI   rj   s      €€€r!   rd   z@SessionGatewayMixin.replace_gateway_routing_entries.<locals>._do'  s[   ø€ Ø�L‰LÐFÈÈÔQÙØ× Ñ ð*à4;·M±M³O×O©D¨A¨qÂqÊQ�e˜Q  3Ò'ÓOõQð ùó Ps   ¶
AÁAÁAN)r9   rg   )rG   ru   rj   rd   rI   s    `` @r!   Úreplace_gateway_routing_entriesz3SessionGatewayMixin.replace_gateway_routing_entries#  s&   ú€ ô �i‰i‹kˆö	Qð 	×Ñ˜CÕ re   c               ó`   — | j                  d|f«      }|D �ci c]  }|d   |d   “Œ c}S c c}w )z>Load routing entries for *scope* as {session_key: entry_json}.zCSELECT session_key, entry_json FROM gateway_routing WHERE scope = ?rR   rm   )Ú	_read_all)rG   rj   ÚrowsÚrs       r!   Úload_gateway_routing_entriesz0SessionGatewayMixin.load_gateway_routing_entries0  s9   € à�~‰~ÐcÐfkÐemÓnˆØ;?Ö@°a��-Ñ  ! L¡/Ñ1Ò@Ð@ùÒ@s   ˜+c               óÒ   — |dk  rt        d|›d�«      ‚t        j                  «       t        |«      dz  z
  }| j                  d|f«      }|D �cg c]  }t	        |«      ‘Œ c}S c c}w )u*  Keyed, still-open rows with no evidence of a single turn (no messages, tokens, tool/API calls,
        activity, or title): leaked fixtures or chats routed but never answered. Safe to drop â€” the gateway
        mints a fresh session on the next message. Needs its own selector because ``bulk prune``/``archive``
        are pinned to ``ended_at IS NOT NULL``. ``pinned``/``archived`` = explicit keep intent.

        That is exactly the shape of a leaked test fixture (#82770) â€” and also of a chat that was routed but
        never answered.
        r   z"older_than_days must be >= 0, got zX: a negative retention builds a future cutoff that matches every never-active keyed row.g     õ@a˜  
            SELECT s.id, s.session_key, s.source, s.chat_id,
                   s.chat_type, s.user_id, s.started_at
              FROM sessions s
             WHERE s.session_key IS NOT NULL
               AND s.ended_at IS NULL
               AND s.title IS NULL
               AND s.last_activity_at IS NULL
               AND COALESCE(s.message_count, 0) = 0
               AND COALESCE(s.tool_call_count, 0) = 0
               AND COALESCE(s.api_call_count, 0) = 0
               AND COALESCE(s.input_tokens, 0) = 0
               AND COALESCE(s.output_tokens, 0) = 0
               AND COALESCE(s.pinned, 0) = 0
               AND COALESCE(s.archived, 0) = 0
               AND s.started_at IS NOT NULL
               AND s.started_at < ?
               AND NOT EXISTS (
                       SELECT 1 FROM messages m WHERE m.session_id = s.id
                   )
             ORDER BY s.started_at
            )Ú
ValueErrorr9   Úfloatrx   Údict)rG   Úolder_than_daysÚcutoffry   rz   s        r!   Ú list_never_active_keyed_sessionsz4SessionGatewayMixin.list_never_active_keyed_sessions5  s€   € ð ˜QÒÜØ4°_Ð4Gð H^ð ^ó_ð _ô —‘“¤ oÓ 6¸Ñ @ÑAˆØ�~‰~ðð, ˆIó/
ˆð2 "&Ö&˜A”�Q•Ò&Ð&ùÒ&s   ÁA$c                óÚ   — | j                  d«      D ]H  }	 t        j                  |d   xs d«      }t	        |t
        «      sŒ1|j                  d«      |k(  sŒF|c S  y# t        $ r Y ŒWw xY w)z½The routing entry (any scope) whose current owner is *session_id*, or None. The id lives
        only inside ``entry_json``, so matching is done in Python; an archived/rotated row has none.z&SELECT entry_json FROM gateway_routingrm   ú{}rb   N)rx   ÚjsonÚloadsr8   Ú
isinstancer   Úget)rG   rb   r   Úentrys       r!   Ú!gateway_routing_entry_for_sessionz5SessionGatewayMixin.gateway_routing_entry_for_session^  st   € ð —>‘>Ð"JÓKò 	ˆCðÜŸ
™
 3 |Ñ#4Ò#<¸Ó=�ô ˜%¤Õ&¨5¯9©9°\Ó+BÀjÓ+PØ’ð	ð øô	 ò Ùðús   –AÁ	A*Á)A*c                óP  — |syg }| j                  d«      D ]]  }	 t        j                  |d   xs d«      }t	        |t
        «      sŒ1|j                  d«      |v sŒE|j                  |d   |d   f«       Œ_ |sy| j                  d|d	¬
«       t        |«      S # t        $ r Y Œ�w xY w)z¦Drop ``gateway_routing`` rows pointing at any of *session_ids*; the target id
        lives only inside ``entry_json``, so matching is done in Python over all scopes.r   z:SELECT scope, session_key, entry_json FROM gateway_routingrm   r„   rb   rj   rR   z?DELETE FROM gateway_routing WHERE scope = ? AND session_key = ?T)Úmany)
rx   r…   r†   r8   r‡   r   rˆ   r@   rl   Úlen)rG   Úsession_idsÚdoomedr   r‰   s        r!   Ú$_delete_routing_entries_for_sessionsz8SessionGatewayMixin._delete_routing_entries_for_sessionsj  s¸   € ñ ØØ(*ˆØ—>‘>Ð"^Ó_ò 	BˆCðÜŸ
™
 3 |Ñ#4Ò#<¸Ó=�ô ˜%¤Õ&¨5¯9©9°\Ó+BÀkÒ+QØ—‘˜s 7™|¨S°Ñ-?Ð@ÕAð	Bñ ØØ�‰ÐYÐ[aÐhlˆÔmÜ�6‹{Ðøô ò Ùðús   ›BÂ	B%Â$B%©Úsessions_dirc               óÄ   ‡ ‡— ‰ j                  |¬«      }|sy|D �ch c]  }t        |d   «      ’Œ }}‰ j                  |«      }t        ˆ ˆfd„|D «       «      }||fS c c}w )aT  Delete never-active keyed rows and the routing entries naming them; returns
        ``(sessions_deleted, routing_entries_deleted)``. Routing entries go first: a stale
        entry outliving its target would have the gateway resume a nonexistent id.
        Deletion goes through :meth:`delete_session` (delegate cascade, FTS, transcripts).)r€   )r   r   Úidc              3  óJ   •K  — | ]  }‰j                  |‰¬ «      sŒd–— Œ y­w)r‘   r   N)Údelete_session)Ú.0ÚsidrG   r’   s     €€r!   ú	<genexpr>zHSessionGatewayMixin.prune_never_active_keyed_sessions.<locals>.<genexpr>‡  s$   øè ø€ Ò^˜C¨$×*=Ñ*=¸cÐP\Ð*=Õ*]”aÑ^ùs   ƒ#œ#)r‚   Ústrr�   Úsum)rG   r€   r’   rJ   r   ÚidsÚrouting_deletedÚdeleteds   ` `     r!   Ú!prune_never_active_keyed_sessionsz5SessionGatewayMixin.prune_never_active_keyed_sessions|  si   ù€ ð ×:Ñ:È?Ð:Ó[ˆ
ÙØØ)3Ö4 #Œs�3�t‘9�~Ð4ˆÐ4Ø×CÑCÀCÓHˆÜÔ^ 3Ô^Ó^ˆØ˜Ð)Ð)ùò 5s   œAT)r5   Úactive_onlyc               óÜ   — | j                  «        dt        d«      › d�}|r|gng }||rdnd|rdndz   dz   z  }| j                  ||«      D �cg c]  }| j                  |«      ‘Œ c}S c c}w )z’List gateway sessions (rows with a session_key): newest row per key, one live
        mapping per routing key. ``platform`` filters on ``source``.z¥
            SELECT sessions.*,
                   COALESCE(sp.prompt, sessions.system_prompt)
                       AS _system_prompt_resolved,
                   Úsessionsag   AS last_active
            FROM sessions
            LEFT JOIN system_prompts sp
              ON sp.hash = sessions.system_prompt_hash
            WHERE session_key IS NOT NULL
              AND started_at = (
                  SELECT MAX(s2.started_at) FROM sessions s2
                  WHERE s2.session_key = sessions.session_key
              )
        z AND LOWER(source) = LOWER(?)r\   z AND ended_at IS NULLz ORDER BY last_active DESC)Úflush_token_countsr   rx   Ú_session_row_dict)rG   r5   r    ÚqueryÚparamsrz   s         r!   Úlist_gateway_sessionsz)SessionGatewayMixin.list_gateway_sessionsŠ  s�   € ð
 	×ÑÔ!ðô -¨ZÓ8Ð9ð 	:	ð	ˆñ &.˜‘z°2ˆØ±XÑ1À2Ù'2Ñ#¸ñ<Ø>Zñ[ñ 	[ˆà37·>±>À%ÈÓ3PÖQ¨a�×&Ñ& qÕ)ÒQÐQùÒQs   ÁA))rQ   rR   rS   rT   rU   c               ó”  — |sy| j                  «       5 }|j                  t        ||f«      j                  «       }|�| j	                  |«      cddd«       S |�|€
	 ddd«       y| j                  «       }	|j                  t        ||||||	|	|	f«      j                  «       }ddd«       r| j	                  |«      S dS # 1 sw Y   ŒxY w)um  Find the latest recoverable gateway session for a routing peer. The durable ``session_key`` on the row rebuilds a missing/pruned ``sessions.json`` mapping. Rows
        ended only by the old ``agent_close`` bug or a mistaken TUI ``ws_orphan_reap`` are recoverable;
        explicit boundaries (/new, /resume switches, compression splits) are not. Ranked by
        ``last_activity_at`` (fallback ``started_at`` â€” alone it resurrected days-old zombies); rows with
        messages win, but an empty keyed row still beats ``None`` (which mints a new id while the transcript
        may live under a compression child). Reset fence: a candidate is rejected when a peer boundary row
        ended *after* its last activity, or the has-messages ranking could reach behind a /new. The
        exact-key fallback requires the complete peer tuple (never cross chats/threads/users) plus a profile
        fence: a Telegram DM's tuple is identical for every bot, so a sibling profile's legacy row would
        otherwise be adopted. Ours = profile_name is the owner or NULL; stores outside the profile tree
        derive no owner and stay unfenced.

        See #60609.
        Reset boundaries fence recovery (#68539): an intentional boundary such as ``session_reset`` (or any
        explicit non-recoverable end_reason) must block fallback to an *older* row for the same peer. Each
        candidate is therefore rejected when a boundary row for the peer ended *after* the candidate's last
        activity â€” if the conversation's most recent event is an intentional reset, recovery returns nothing
        rather than reaching behind it.
        N)Ú	_read_ctxr   Ú_PEER_BY_KEY_SQLr^   r¤   r_   Ú_PEER_BY_TUPLE_SQL)
rG   rc   rQ   rR   rS   rT   rU   r   r   Úowners
             r!   Ú$find_latest_gateway_session_for_peerz8SessionGatewayMixin.find_latest_gateway_session_for_peer£  sÛ   € ñ. ØØ�^‰^Óð 	 Ø—,‘,Ô/°+¸vÐ1FÓG×PÑPÓRˆCØˆØ×-Ñ-¨cÓ2÷	ñ 	ð ˆ )Ð"3Ø÷	ð 	ð ×*Ñ*Ó,ˆEØ—,‘,Ü" V¨W°g¸yÈ)ÐUZÐ\aÐchÐ$ióç‰h‹jð ÷	ñ /2ˆt×%Ñ% cÓ*Ð;°tÐ;÷	ð 	ús   ”9B>ÁB>Á%<B>Â>C)Ú	max_gap_sc               ó@  ‡— |€| j                   n
t        |«      }g }| j                  «       5 }|j                  t        «      j                  «       D ]è  }d}d}|d   r3d}|j                  t        |d   |d   f«      j                  «       }|€nd}nkd}|d   xs d	}	|j                  t        |d
   |d   |d   |d   |	|z
  |	|z   |d   f«      j                  «       }
|
sd|d›d�}nt        |
«      dkD  rd}n|
d	   }|j                  |d
   |d   |d   |d   |d   |r|d
   nd|r|d   nd|r|nd|du|dœ
«       Œê 	 ddd«       |D �‡ch c]#  Š‰d   rt        ˆfd„|D «       «      dkD  r‰d   ’Œ% }}|D ]  }|d   |v sŒd|d<   d|d<   Œ |S # 1 sw Y   ŒSxY wc c}w )u£  Report message-bearing rows that lost their routing identity (messages, no ``session_key``).
        Adoptable only when exactly one keyed predecessor can be named: ``lineage`` (``parent_session_id``
        is a keyed row of the same source; no time window) or ``contiguity`` (exactly one keyed same-source
        row with compatible ``user_id`` fell quiet within *max_gap_s* of the orphan's start and is older
        than its last activity). Ambiguity is reported ``adoptable=False`` with a reason, never guessed â€”
        mis-adopting splices one person's conversation into another's chat. Branch/delegate/tool rows are
        excluded: unkeyed by design, not damage.Nr\   Úparent_session_idÚlineagerc   z9parent session carries no gateway identity of this sourceÚ
contiguityÚ
started_atr   r”   rQ   Úlast_activez'no keyed predecessor fell quiet within z.0fzs of this session's startr   z>ambiguous: more than one keyed predecessor matches this windowÚmessage_countrR   )
Ú	orphan_idrc   rµ   r³   r´   Údonor_idrR   ÚevidenceÚ	adoptableÚreasonr¹   c              3  ó:   •K  — | ]  }|d    ‰d    k(  sŒd–— Œ y­w)r·   r   N© )r—   Úxrz   s     €r!   r™   zESessionGatewayMixin.find_orphaned_gateway_sessions.<locals>.<genexpr>ø  s    øè ø€ Ò%[¨A¸A¸j¹MÈQÈzÉ]Ó<Z¤aÑ%[ùs   ƒ”r·   Fz@ambiguous: more than one unkeyed session claims this predecessorrº   )Ú_ORPHAN_ADOPTION_MAX_GAP_Sr~   r©   r   Ú_ORPHANS_SQLÚfetchallÚ_ORPHAN_LINEAGE_DONOR_SQLr^   Ú_ORPHAN_CONTIGUITY_DONORS_SQLr�   r@   r›   )rG   r®   ÚgapÚrecordsr   ÚorphanÚdonorrº   r¸   ÚstartedrJ   rz   Ú	contestedÚrecords              `  r!   Úfind_orphaned_gateway_sessionsz2SessionGatewayMixin.find_orphaned_gateway_sessionsÍ  s6  ø€ ð 2;Ð1Bˆd×-Ò-ÌÈiÓHXˆØ(*ˆØ�^‰^Óð 	n ØŸ,™,¤|Ó4×=Ñ=Ó?ò n�Ø�Ø�ØÐ-Ò.Ø(�HØ ŸL™LÜ1°FÐ;NÑ4OÐQWÐX`ÑQaÐ3bódßdlÑdlÓdnð à�}Ø!\™à+�HØ$ \Ñ2Ò7°a�GØ!%§¡Ü5Ø ™ v¨hÑ'7¸À	Ñ9JÈFÐS\ÑL]Ø  3™¨°#©°v¸mÑ7LðNó"÷ ‘h“jð	 ñ
 &Ø#JÈ3ÈsÈ)ÐSlÐ!m™Ü˜Z›¨1Ò,Ø!a™à *¨1¡˜Ø—‘Ø!'¨¡¸ÀÑ9IØ%+¨OÑ%<ÈFÐS_ÑL`Ø#)¨-Ñ#8ÑUZÀeÈDÂkÐ`dÙ;@ 5¨Ò#7ÀdÙ,1¡°rÈÐUYÐHYÐekñ mõ nñ/n÷	nð@ $+÷
ØØ�Š~¤#Ó%[°Ô%[Ó"[Ð^_Ò"_ð ˆj‹Mð
ˆ	ð 
ð ò 	fˆFØ�jÑ! YÒ.Ø&+��{Ñ#Ø#e��xÒ ð	fð ˆ÷O	nð 	nüò>
s   ­DFÅ
(FÆFc                óH   ‡‡— ‰r‰r‰‰k(  ryˆˆfd„}| j                  |«      S )zìStamp *orphan_id* with *donor_id*'s routing identity, retire *donor_id*.
        Re-verifies the pair inside the write txn so a concurrent gateway that healed
        either row makes this a no-op. Non-NULL orphan columns are preserved.Fc                óz  •— | j                  d‰f«      j                  «       }| j                  d‰f«      j                  «       }|�|�|d   r|d   s|d   xs d|d   xs dk7  ry| j                  d|d   |d   |d	   |d
   |d   |d   |d   ‰‰f	«       | j                  dt        j                  «       ‰f«       y)NzxSELECT session_key, chat_id, chat_type, thread_id, user_id, origin_json, display_name, source FROM sessions WHERE id = ?z5SELECT session_key, source FROM sessions WHERE id = ?rR   rc   r\   Fa6  UPDATE sessions
                      SET session_key = ?,
                          chat_id = COALESCE(chat_id, ?),
                          chat_type = COALESCE(chat_type, ?),
                          thread_id = COALESCE(thread_id, ?),
                          user_id = COALESCE(user_id, ?),
                          origin_json = COALESCE(origin_json, ?),
                          display_name = COALESCE(display_name, ?),
                          parent_session_id = COALESCE(parent_session_id, ?)
                    WHERE id = ? AND session_key IS NULLrS   rT   rU   rQ   rW   rV   zfUPDATE sessions SET ended_at = COALESCE(ended_at, ?), end_reason = 'superseded_by_repair' WHERE id = ?T)r   r^   r9   )r   rÆ   rÅ   r·   r¶   s      €€r!   rd   z?SessionGatewayMixin.adopt_orphaned_gateway_session.<locals>._do  sþ   ø€ Ø—L‘LðOà�ó÷ ‰h‹jð	 ð
 —\‘\ØGÈ)ÈóWßW_ÑW_ÓWað à�  °u¸]Ò7KÈvÐVcÒOdØ˜h™Ò-¨2°6¸(Ñ3CÒ3IÀrÒJØð �L‰Lð	<ð �}Ñ% u¨YÑ'7¸¸{Ñ9KÈUÐS^ÑM_Ø�yÑ! 5¨Ñ#7¸¸~Ñ9NÐPXÐZcðeôð  �L‰LðCä—‘“˜hÐ'ô)ð re   rf   )rG   r¶   r·   rd   s    `` r!   Úadopt_orphaned_gateway_sessionz2SessionGatewayMixin.adopt_orphaned_gateway_session   s-   ù€ ñ ¡¨I¸Ò,AØõ&	ðN ×"Ñ" 3Ó'Ð're   c                ó6   ‡— ‰syˆfd„}| j                  |«      S )zEAtomically increment the session-hygiene failure streak for one chat.r   c                óˆ   •— | j                  d‰f«       | j                  d‰f«      j                  «       }t        |d   «      S )NzëINSERT INTO gateway_hygiene_state (session_key, failure_streak)
                   VALUES (?, 1)
                   ON CONFLICT(session_key) DO UPDATE SET
                       failure_streak = gateway_hygiene_state.failure_streak + 1zFSELECT failure_streak FROM gateway_hygiene_state WHERE session_key = ?r   )r   r^   Úint)r   r   rR   s     €r!   rd   zASessionGatewayMixin.increment_hygiene_failure_streak.<locals>._do3  sO   ø€ Ø�L‰LðTð �ôð —,‘,ØXÐ[fÐZhóç‰h‹jð ô �s˜1‘v“;Ðre   rf   )rG   rR   rd   s    ` r!   Ú increment_hygiene_failure_streakz4SessionGatewayMixin.increment_hygiene_failure_streak/  s!   ø€ áØô	ð ×"Ñ" 3Ó'Ð're   c                ó0   — |sy| j                  d|f«       y)z@Clear the persisted session-hygiene failure streak for one chat.Nz7DELETE FROM gateway_hygiene_state WHERE session_key = ?©rl   )rG   rR   s     r!   Úreset_hygiene_failure_streakz0SessionGatewayMixin.reset_hygiene_failure_streakA  s   € áØØ�‰ÐQÐT_ÐSaÕbre   c                óä   ‡‡‡‡‡‡	— |xs dj                  «       |xs dj                  «       cŠŠi Š‰r‰r‰‰k(  r‰S d‰› d�d‰› d�cŠ	Št        ‰	«      Šˆˆˆˆˆˆ	fd„}| j                  |«       ‰S )zAAtomically rewrite exact profile identity in this state database.r\   úagent:ú:c           
     óz  •— | j                  d«      j                  «       D �ch c]  }|d   ’Œ	 }}t        | «      }| j                  d‰‰dz   ‰‰dz   ‰‰f«      j                  «       }|�t	        d|d   ›d|d   ›�«      ‚dD ]v  \  }}d|j                  |t        «       «      vrŒ#d	j                  d
„ |D «       «      }| j                  d|› d|› d|› d�‰‰f«      j                  «       }|€Œjt	        d|› �«      ‚ | j                  d‰‰f«      j                  ‰d<   | j                  d‰‰f«      j                  ‰d<   | j                  d‰‰dz   ‰‰f«      j                  ‰d<   d}| j                  d«      j                  «       D ]t  \  }	}
	 t        j                  |
«      }t        |t        «      sŒ-|j                  d«      ‰k(  sŒB‰|d<   | j                  dt        j                  |d¬«      |	f«       |dz  }Œv |‰d<   d|v rcd|j                  dt        «       «      v r!| j                  d‰‰f«      j                  ‰d<   | j                  d ‰‰dz   ‰‰f«      j                  ‰d!<   d"D ]G  }d|j                  |t        «       «      v sŒ | j                  d#|› d$�‰‰f«      j                  ‰|› d%�<   ŒI d&|j                  d't        «       «      v r&| j                  d(‰‰dz   ‰‰f«      j                  ‰d)<   | j                  d*‰‰f«      j                  «       }|D ]ß  \  }}}‰|‰d  z   }|}|r¸	 t        j                  |«      }t        |t        «      r’t        |j                  d&«      t        «      r"|d&   j!                  ‰«      r‰|d&   ‰d  z   |d&<   |j                  d+«      }t        |t        «      r|j                  d«      ‰k(  r‰|d<   t        j                  |d¬«      }| j                  d,|||f«       Œá t#        |«      ‰d-<   y c c}w # t        t        f$ r Y �Œ�w xY w# t        t        f$ r d }Y Œöw xY w).Nú1SELECT name FROM sqlite_master WHERE type='table'r   zñSELECT old.scope, ? || substr(old.session_key, ?) FROM gateway_routing AS old JOIN gateway_routing AS target ON target.scope = old.scope AND target.session_key = ? || substr(old.session_key, ?) WHERE substr(old.session_key, 1, ?) = ? LIMIT 1r   z#profile routing collision in scope z: ))r   )rS   )r   )rS   rU   Úprofile_namez AND c              3  ó,   K  — | ]  }d |› d|› �–— Œ y­w)ztarget.z = old.Nr¼   )r—   Úcolumns     r!   r™   zGSessionGatewayMixin.rekey_profile_state.<locals>._do.<locals>.<genexpr>e  s$   è ø€ ò (MØ:@�g˜f˜X W¨V¨HÔ5ñ(Mùs   ‚zSELECT 1 FROM z AS old JOIN z* AS target ON target.profile_name = ? AND z# WHERE old.profile_name = ? LIMIT 1zprofile identity collision in z;UPDATE sessions SET profile_name = ? WHERE profile_name = ?Úsessions_profile_namez;UPDATE gateway_heartbeats SET profile = ? WHERE profile = ?Úgateway_heartbeats_profilezaUPDATE sessions SET session_key = ? || substr(session_key, ?) WHERE substr(session_key, 1, ?) = ?Úsessions_session_keyzBSELECT id, origin_json FROM sessions WHERE origin_json IS NOT NULLÚprofilez0UPDATE sessions SET origin_json = ? WHERE id = ?F)Úensure_asciiÚsessions_origin_jsonr   Úadapter_profilezMUPDATE delivery_obligations SET adapter_profile = ? WHERE adapter_profile = ?Ú$delivery_obligations_adapter_profilezmUPDATE delivery_obligations SET session_key = ? || substr(session_key, ?) WHERE substr(session_key, 1, ?) = ?Ú delivery_obligations_session_key)r   r   zUPDATE z, SET profile_name = ? WHERE profile_name = ?Ú_profile_namerR   r   zsUPDATE telegram_dm_topic_bindings SET session_key = ? || substr(session_key, ?) WHERE substr(session_key, 1, ?) = ?Ú&telegram_dm_topic_bindings_session_keyz^SELECT rowid, session_key, entry_json FROM gateway_routing WHERE substr(session_key, 1, ?) = ?ÚoriginzJUPDATE gateway_routing SET session_key = ?, entry_json = ? WHERE rowid = ?Úgateway_routing)r   rÀ   r"   r^   r}   rˆ   ÚsetÚjoinÚrowcountr…   r†   Ú	TypeErrorr‡   r   Údumpsrš   r6   r�   )r   r   r   Útopic_columnsÚ	collisionr    ÚcolumnsÚequalityÚorigin_countrb   rW   ÚpayloadÚroutingÚrowidrR   rm   Únew_session_keyÚnew_jsonrè   ÚcountsÚnewÚnew_nsÚns_lenÚoldÚold_nss                      €€€€€€r!   rd   z4SessionGatewayMixin.rekey_profile_state.<locals>._doP  s"  ø€ Ø*.¯,©,ØCó+EßEMÁXÃZöQ 3˜˜A›ð QˆHð Qä3°DÓ9ˆMØŸ™ðBð
 ˜ !™ V¨V°a©Z¸ÀÐHó÷ ‰h‹jð ð Ð$Ü Ø9¸)ÀA¹,Ð9IÈÈIÐVWÉLÐK[Ð\ó^ð ^ð#ò O‘��wð "¨×):Ñ):¸5Ä#Ã%Ó)HÑHØØ"Ÿ<™<ñ (MØDKô(Mó M�à ŸL™LØ$ U G¨=¸¸ð @6Ø6>°Zð @9ð9à;>À¸*óF÷ GOÁhÃjð ð Ñ(Ü$Ð'EÀeÀWÐ%MÓNÐNðOð /3¯l©lØMÐPSÐUXÈzó/[ß[cÑ[cð Ð*Ñ+à37·<±<ØMÐPSÐUXÈzó4[ß[cÑ[cð Ð/Ñ0à-1¯\©\ð6à˜ !™ V¨VÐ4ó.6÷ 7?±hð Ð)Ñ*ð
 ˆLØ+/¯<©<ØXó,ZßZbÑZbÓZdò
&Ñ'�
˜KðÜ"Ÿj™j¨Ó5�Gô ˜g¤tÕ,°·±¸YÓ1GÈ3Ó1NØ),�G˜IÑ&Ø—L‘LÐ!SÜ"&§*¡*¨WÀ5Ô"IÈ:Ð!VôXà  AÑ%‘Lð
&ð .:ˆFÐ)Ñ*à%¨Ñ1Ø$¨×(9Ñ(9Ð:PÔRUÓRWÓ(XÑXØEIÇ\Á\ØgØ˜c˜
óF$ç$,¡Hð ÐAÑBð >B¿\¹\ð:à˜V a™Z¨°Ð8ó>:÷ ;C¹(ð Ð9Ñ:ð Rò -�Ø! ]×%6Ñ%6°u¼c»eÓ%DÒDØ6:·l±lØ! % Ð(TÐUØ˜c˜
ó7$ç$,¡Hð ˜e˜W MÐ2Ò3ð-ð
  × 1Ñ 1Ð2NÔPSÓPUÓ VÑVØCGÇ<Á<ð:ð ˜V a™Z¨°Ð8ó	D:÷ ;C¹(ð	 Ð?Ñ@ð —l‘lð6Ø8>ÀÐ7GóIçIQÉËð ð 3:ò 8Ñ.��{ JØ"(¨;°v°wÐ+?Ñ"?�Ø%�Ùð'Ü"&§*¡*¨ZÓ"8˜ô " '¬4Ô0Ü% g§k¡k°-Ó&@Ä#ÔFÈ7ÐS`ÑKa×KlÑKlÐmsÔKtØ5;¸gÀmÑ>TÐU[ÐU\Ð>]Ñ5]˜G MÑ2Ø!(§¡¨XÓ!6˜Ü% f¬dÔ3¸¿
¹
À9Ó8MÐQTÒ8TØ03˜F 9Ñ-Ü#'§:¡:¨gÀEÔ#J˜Ø—‘Ø`Ø$ h°Ð6õ8ð8ô$ ),¨G«ˆFÐ$Ò%ùò}QøôV #¤IÐ.ò ÚðûôN '¬	Ð2ò 'Ø"&šð'ús)   £PÆPÌ.P&ÐP#Ð"P#Ð&P:Ð9P:©Ústripr�   rg   )
rG   Úold_nameÚnew_namerd   rù   rú   rû   rü   rý   rþ   s
       @@@@@@r!   Úrekey_profile_statez'SessionGatewayMixin.rekey_profile_stateG  sƒ   ý€ à’N ×)Ñ)Ó+¨hªn¸"×-CÑ-CÓ-EˆˆˆSØ!#ˆÙ™# ¨¢ØˆMØ! #  a˜¨F°3°%°q¨/ˆˆ�Ü�V“ˆ÷_	5ñ _	5ðB 	×Ñ˜CÔ Øˆre   c                ó    ‡‡‡‡— |xs dj                  «       Ši Š‰s‰S d‰› d�t        d‰› d�«      cŠŠˆˆˆˆfd„}| j                  |«       ‰S )u  Delete exact profile identity from this state database (#111926, delete side).

        The mirror of :meth:`rekey_profile_state`: a rename must rekey a profile's identity, a
        delete must purge it. ``agent:<name>:*`` routing keys, ``gateway_heartbeats.profile``,
        ``delivery_obligations`` and the telegram topic tables are bookkeeping for a profile that
        no longer exists â€” left behind, every inbound event on a chat keyed to the deleted name
        enters the routing index, resolves a profile whose directory is gone, and logs
        ``Profile '<name>' does not exist`` on each event for the life of the store.

        What each store gets, and why:

        * Routing keys and heartbeat rows are hard-deleted â€” pure bookkeeping for a dead name.
        * ``delivery_obligations`` rows are **terminalized** (``state='abandoned'``), not deleted:
          a pending obligation is delivery state that should not vanish silently, and the ledger's
          own retention prunes abandoned rows. Delivered history is left as it was.
        * ``sessions`` rows are not deleted here: this helper settles identity, not history, and it
          does not decide what a delete leaves of a profile's conversation record â€” ``delete_profile``
          removes the profile's own home, ``state.db`` included, with the directory. Rows in a shared
          store keep whatever ownership they had; re-binding or archiving them belongs to the flow
          that recreates the name, not to this purge.

        Idempotent.
        r\   rÖ   r×   c                ó(  •— | j                  d«      j                  «       D �ch c]  }|d   ’Œ	 }}t        | «      }d|v r!| j                  d‰
‰	f«      j                  ‰d<   d|v r | j                  d‰f«      j                  ‰d<   d|v rtd|j	                  dt        «       «      v rd	nd
}|rt        j                  «       ‰‰
‰	fnt        j                  «       ‰
‰	f}| j                  d|› d�|«      j                  ‰d<   d|j	                  dt        «       «      v r | j                  d‰f«      j                  ‰d<   |j	                  dt        «       «      }d|v r7d|v rdnd
}|r‰‰
‰	fn‰
‰	f}| j                  d|› d�|«      j                  ‰d<   y y c c}w )NrÙ   r   ré   z?DELETE FROM gateway_routing WHERE substr(session_key, 1, ?) = ?Úgateway_heartbeatsz0DELETE FROM gateway_heartbeats WHERE profile = ?r   rã   zadapter_profile = ? OR r\   zGUPDATE delivery_obligations SET state='abandoned', updated_at=? WHERE (zJsubstr(session_key, 1, ?) = ?) AND state NOT IN ('delivered', 'abandoned')rÚ   r   z9DELETE FROM telegram_dm_topic_mode WHERE profile_name = ?r   rR   zprofile_name = ? OR z-DELETE FROM telegram_dm_topic_bindings WHERE zsubstr(session_key, 1, ?) = ?)r   rÀ   r"   rì   rˆ   rê   r9   )r   r   r   rï   Ú
by_profiler¦   Úbinding_columnsrù   ÚnameÚnsrü   s          €€€€r!   rd   z4SessionGatewayMixin.purge_profile_state.<locals>._doÒ  s÷  ø€ Ø*.¯,©,ØCó+EßEMÁXÃZöQ 3˜˜A›ð QˆHð Qä3°DÓ9ˆMØ  HÑ,Ø,0¯L©LØUØ˜R�Ló-"ç"*¡(ð Ð(Ñ)ð $ xÑ/Ø/3¯|©|ØFÈÈó0QßQYÑQYð Ð+Ñ,à%¨Ñ1ð "3°m×6GÑ6GÐH^Ô`cÓ`eÓ6fÑ!fñ 8à#%ð ñ =Gœ$Ÿ)™)›+ t¨V°RÑ8ÌTÏYÉYË[ÐZ`ÐbdÐLe�Ø15·±ðØ(˜\ð *BðBàCIó2K÷ LTÉ8ð Ð-Ñ.ð  ×!2Ñ!2Ð3KÌSËUÓ!SÑSØ37·<±<ØOÐRVÐQXó4ZßZbÑZbð Ð/Ñ0à+×/Ñ/Ð0LÌcËeÓTˆOØ Ñ/ð
 8FÈÑ7XÑ3Ð^`�
Ù/9˜$ ¨Ñ+ÀÈ¸|�Ø7;·|±|ðØ'˜LÐ(EðGØHNó8PçPXÑPXð Ð3Ò4ð 0ùò7Qs   £Frÿ   )rG   rà   rd   rù   r	  r
  rü   s      @@@@r!   Úpurge_profile_statez'SessionGatewayMixin.purge_profile_state´  sb   û€ ð0 ’˜2×$Ñ$Ó&ˆØ!#ˆÙØˆMØ˜d˜V 1Ð%¤s¨V°D°6¸Ð+;Ó'<ˆ
ˆˆF÷%	YðN 	×Ñ˜CÔ Øˆre   c                ó  — ddl m} | xs i j                  d«      }t        |t        «      r	 t        j                  |«      }t        |t        «      si }|j                  d«      }t        |t        «      r8|j                  d«      r'|j                  «       D ��ci c]  \  }}|€Œ	||“Œ c}}S dD �ci c]&  }|j                  |«      sŒ||j                  |«      “Œ( }}|r|S t	        | xs i j                  d«      xs d«      j                  «       }|r|j                  «       |vrd|iS t        |t        «      si S |j                  «       D ��ci c]  \  }}|€Œ	||“Œ c}}S # t        $ r i }Y �Œ/w xY wc c}}w c c}w c c}}w )	u·  Read the persisted runtime route off a session row dict (``model_config`` as
        JSON string or parsed dict). Precedence: nested ``gateway_runtime`` (gateway sync /
        CLI ``/model``), then top-level ``provider``/``base_url``/``api_mode`` (TUI), then
        ``billing_provider`` so sessions that never ran ``/model`` still restore the
        provider that served them. Empty dict on parse failure â€” resume uses ambient config.r   )Ú_BARE_BILLING_PROVIDERSÚmodel_configÚgateway_runtimeÚprovider)r  Úbase_urlÚapi_modeÚbilling_providerr\   )r   r  rˆ   r‡   rš   r…   r†   r8   r   rr   r   Úlower)	Úsession_metar  ÚrawÚruntimers   rt   ÚkeyÚ	top_levelr  s	            r!   Úsession_gateway_runtimez+SessionGatewayMixin.session_gateway_runtimeü  sm  € õ 	9ØÒ!˜r×&Ñ& ~Ó6ˆÜ�cœ3ÔðÜ—j‘j “o�ô ˜#œtÔ$ØˆCØ—'‘'Ð+Ó,ˆô �gœtÔ$¨¯©°ZÔ)@Ø%,§]¡]£_×F™T˜Q ¸¹�A�q‘DÓFÐFØ2VÖg¨3ÐZ]×ZaÑZaÐbeÕZf�S˜#Ÿ'™' #›,Ñ&Ðgˆ	ÐgÙØÐô  Ò 2°×7Ñ7Ð8JÓKÒQÈrÓR×XÑXÓZÐÙÐ 0× 6Ñ 6Ó 8Ð@WÑ WØÐ 0Ð1Ð1Ü˜'¤4Ô(ØˆIØ!(§¡£×B™˜˜A°A±M��1‘ÓBÐBøô) ò Ø“ðüó GùÚgùó Cs5   ­E# Â
E5Â$E5Â1E;ÃE;Å
F ÅF Å#E2Å1E2)Úlast_heartbeatrà   Úhostc               óÖ   — |sy|€t        j                   «       n
t        |«      }| j                  dt        |«      t	        |«      t        |«      |t        |«      t        |«      f«       y)aT  Upsert this backend's liveness row. ``backend_id`` MUST be stable for the process
        lifetime (e.g. ``f"{profile}@{host}:{pid}"``) so a respawn cannot inherit a dead
        predecessor's heartbeat; ``started_at`` is when THIS process started, so a backend
        whose previous run died is not mistaken for a freshly-spawned sibling.Na,  INSERT INTO gateway_heartbeats (backend_id, pid, started_at, last_heartbeat, profile, host) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(backend_id) DO UPDATE SET pid = excluded.pid, started_at = excluded.started_at, last_heartbeat = excluded.last_heartbeat, profile = excluded.profile, host = excluded.host)r9   r~   rl   rš   rÐ   )rG   Ú
backend_idrB   r³   r  rà   r  Útss           r!   Úregister_backend_heartbeatz.SessionGatewayMixin.register_backend_heartbeat  s\   € ñ ØØ*Ð2ŒT�Y‰YŒ[¼¸nÓ8MˆØ�‰ð@ô �‹_œc #›h¬¨jÓ(9¸2¼sÀ7»|ÌSÐQUËYÐWõ	Yre   c                óF   — |sy| j                  dt        |«      f«      dkD  S )z™Remove this backend's heartbeat row (from ``atexit``); True if removed. A crashed
        backend's row is reclaimed later by ``prune_stale_heartbeats``.Fz3DELETE FROM gateway_heartbeats WHERE backend_id = ?r   )Ú_write_rowcountrš   )rG   r  s     r!   Úclear_backend_heartbeatz+SessionGatewayMixin.clear_backend_heartbeat/  s4   € ñ ØØ×#Ñ#ØAÄCÈ
ÃOÐCUóWØYZñ[ð 	[re   c               ó†   ‡— |dk  rg S t        j                   «       |z
  Šˆfd„}t        | j                  |«      xs g «      S )u�   Drop heartbeat rows older than the staleness window; return removed backend ids.
        Safe from any process â€” only stale rows are touched.r   c                óˆ   •— | j                  d‰f«      }|j                  «       D �cg c]  }t        |d   «      ‘Œ c}S c c}w )NzLDELETE FROM gateway_heartbeats WHERE last_heartbeat < ? RETURNING backend_idr   )r   rÀ   rš   )r   Úcurrz   r�   s      €r!   rd   z7SessionGatewayMixin.prune_stale_heartbeats.<locals>._do=  s=   ø€ Ø—,‘,Ø^Ø�	óˆCð (+§|¡|£~Ö6 !”C˜˜!™•IÒ6Ð6ùÒ6s   §?)r9   Úlistrg   )rG   Úmax_age_secondsrd   r�   s      @r!   Úprune_stale_heartbeatsz*SessionGatewayMixin.prune_stale_heartbeats7  sD   ø€ ð ˜aÒØˆIÜ—‘“˜Ñ.ˆô	7ô
 �D×'Ñ'¨Ó,Ò2°Ó3Ð3re   c                ó`   — | j                  d«      }|D �cg c]  }t        |«      ‘Œ c}S c c}w )zQSnapshot of every backend heartbeat (diagnostics/tests); fields mirror the table.zvSELECT backend_id, pid, started_at, last_heartbeat, profile, host FROM gateway_heartbeats ORDER BY last_heartbeat DESC)rx   r   ©rG   ry   rz   s      r!   Úlist_backend_heartbeatsz+SessionGatewayMixin.list_backend_heartbeatsD  s/   € à�~‰~ð,ó-ˆð "&Ö&˜A”�Q•Ò&Ð&ùÒ&s   –+c                ó0   — | j                  d||f«      dkD  S )zVMark a session pending handoff to *platform*; False if a handoff is already in flight.zÈUPDATE sessions SET handoff_state = 'pending',     handoff_platform = ?,     handoff_error = NULL WHERE id = ? AND (handoff_state IS NULL                   OR handoff_state IN ('completed', 'failed'))r   ©r"  )rG   rb   r5   s      r!   Úrequest_handoffz#SessionGatewayMixin.request_handoffK  s/   € à×#Ñ#ðMð �zÐ"ó	
ð
 ñð 	re   c                ól   — 	 | j                  d|f«      }|sy|d   |d   |d   dœS # t        $ r Y yw xY w)zWReturn ``{"state", "platform", "error"}`` or None if the session has no handoff record.zPSELECT handoff_state, handoff_platform, handoff_error FROM sessions WHERE id = ?NÚhandoff_stateÚhandoff_platformÚhandoff_error)Ústater5   Úerror)Ú	_read_oner8   )rG   rb   r   s      r!   Úget_handoff_statez%SessionGatewayMixin.get_handoff_stateT  sY   € ð		Ø—.‘.ØbØ�óˆCñ ØØ  Ñ1¸sÐCUÑ?VØ  Ñ1ñ3ð 3øäò 	Ùð	ús   ‚' ˜' §	3²3c                ó�   — 	 | j                  d«      }|D �cg c]  }| j                  |«      ‘Œ c}S c c}w # t        $ r g cY S w xY w)zPAll sessions in handoff_state='pending', oldest first (gateway handoff watcher).zÕSELECT s.*, COALESCE(sp.prompt, s.system_prompt) AS _system_prompt_resolved FROM sessions s LEFT JOIN system_prompts sp ON sp.hash = s.system_prompt_hash WHERE s.handoff_state = 'pending' ORDER BY s.started_at ASC)rx   r¤   r8   r+  s      r!   Úlist_pending_handoffsz)SessionGatewayMixin.list_pending_handoffsa  sO   € ð	Ø—>‘>ð,ó-ˆDð
 8<Ö<°!�D×*Ñ*¨1Õ-Ò<Ð<ùÒ<øÜò 	ØŠIð	ús   ‚7 —2¯7 ²7 ·AÁAc                ó.   — | j                  d|f«      dkD  S )uC   Atomically transition pending â†’ running. Returns True if claimed.zXUPDATE sessions SET handoff_state = 'running' WHERE id = ? AND handoff_state = 'pending'r   r.  ©rG   rb   s     r!   Úclaim_handoffz!SessionGatewayMixin.claim_handoffm  s'   € à×#Ñ#ØfØˆMó
ð ñð 	re   c                ó(   — | j                  d«      duS )z[Bounded existence probe for the handoff watcher's idle gate (the gate fails open on error).z>SELECT 1 FROM sessions WHERE handoff_state = 'pending' LIMIT 1N)r6  )rG   s    r!   Úhas_pending_handoffsz(SessionGatewayMixin.has_pending_handoffst  s   € à�~‰~Ð^Ó_ÐgkÐkÐkre   c                ó*   — | j                  d|f«       y)zMark a handoff as completed.zRUPDATE sessions SET handoff_state = 'completed', handoff_error = NULL WHERE id = ?NrÓ   r;  s     r!   Úcomplete_handoffz$SessionGatewayMixin.complete_handoffx  s   € à�‰Ø`ØˆMõ	re   )Úonly_statesc               ó®   — |rt        |«      nd}t        dz   |rddj                  d„ |D «       «      › d�ndz   }| j                  ||dd	 |g|¢­«      d
kD  S )u¢  Mark a handoff failed and record the reason; True when a row transitioned. ``only_states`` makes
        the write a compare-and-swap on ``handoff_state``. Waiters that give up (CLI 60s poll, Desktop
        bounded poll) MUST pass ``only_states=("pending",)``: once the watcher has claimed the row
        (``running``) it owns the terminal state, and an unconditional waiter-side fail races the dispatch â€”
        the gateway later overwrites ``failed`` â†’ ``completed`` after the user was told the gateway is down
        (split-brain: the handoff delivered and ``switch_session`` re-pointed the session). The watcher
        fails its OWN claimed row unconditionally.r¼   zid = ?z AND handoff_state IN (r   c              3  ó    K  — | ]  }d –— Œ y­w)ú?Nr¼   )r—   Ú_s     r!   r™   z3SessionGatewayMixin.fail_handoff.<locals>.<genexpr>‰  s   è ø€ Ò/D¸´Ñ/Dùs   ‚ú)r\   Néô  r   )ÚtupleÚ_HANDOFF_FAIL_SQLrë   r"  )rG   rb   r5  rA  ÚstatesÚsqls         r!   Úfail_handoffz SessionGatewayMixin.fail_handoff~  sl   € ñ (3”�{Ô#¸ˆÜ (Ñ*ÙKQÐ% d§i¡iÑ/D¸VÔ/DÓ&DÐ%EÀQÑGÐWYñ[ˆà×#Ñ# C¨%°°¨+°zÐ)KÀFÑ)KÓLÈqÑPÐPre   c                óŠ   ‡— ˆfd„}	 | j                  |«      xs g S # t        $ r t        j                  dd¬«       g cY S w xY w)už  Fail every handoff stuck in ``running``; returns the ids reclaimed. Only the gateway watcher sets
        ``running``, for one in-process dispatch â€” so a ``running`` row at watcher startup belongs to a
        PREVIOUS gateway that died mid-dispatch. It is poisonous: ``request_handoff`` only accepts
        NULL/``completed``/``failed``, so the session could never hand off again, with no error surfaced.
        Failing rather than re-queueing is deliberate: the dead gateway may already have switched the
        session key and dispatched the synthetic turn, so a blind retry risks double delivery; a clean
        terminal state the user can retry from is right.c                ó²   •— | j                  d«      }|j                  «       D �cg c]  }|d   ‘Œ	 }}|r| j                  t        dz   ‰d d f«       |S c c}w )Nz7SELECT id FROM sessions WHERE handoff_state = 'running'r   zhandoff_state = 'running'rG  )r   rÀ   rI  )r   r&  rz   rœ   r5  s       €r!   rd   z?SessionGatewayMixin.reclaim_stale_running_handoffs.<locals>._do”  s[   ø€ Ø—,‘,ÐXÓYˆCØ!$§¡£Ö0˜A�1�Q“4Ð0ˆCÐ0ÙØ—‘Ô.Ð1LÑLÈuÐUYÐVYÈ{ÈnÔ]ØˆJùò 1s   ¥Azbreclaim_stale_running_handoffs failed; stranded 'running' handoff rows (if any) were left in placeT)Úexc_info)rg   r8   ÚloggerÚwarning)rG   r5  rd   s    ` r!   Úreclaim_stale_running_handoffsz2SessionGatewayMixin.reclaim_stale_running_handoffsŒ  sU   ø€ ô	ð	Ø×&Ñ& sÓ+Ò1¨rÐ1øÜò 	ô �N‰Nð;ØEIð ô Kð ŠIð	ús   ˆ �"AÁA)rH   zList[Tuple[int, str]]r/   r~   Úreturnz	List[int])rb   rš   rc   rš   rQ   rš   rR   rš   rS   rš   rT   rš   rU   rš   rV   rš   rW   rš   rX   ÚboolrY   rš   rS  ÚNone)rR   rš   rm   rš   rj   rš   rS  rU  )ru   úDict[str, str]rj   rš   rS  rU  )rj   rš   rS  rV  )r€   r~   rS  úList[Dict[str, Any]])rb   rš   rS  úOptional[Dict[str, Any]])rŽ   zSet[str]rS  rÐ   )r€   r~   r’   zOptional[Path]rS  zTuple[int, int])r5   úOptional[str]r    rT  rS  rW  )rc   rš   rQ   rY  rR   rY  rS   rY  rT   rY  rU   rY  rS  rX  )r®   úOptional[float]rS  rW  )r¶   rš   r·   rš   rS  rT  )rR   rš   rS  rÐ   )rR   rš   rS  rU  )r  rš   r  rš   rS  úDict[str, int])rà   rš   rS  r[  )r  rX  rS  zDict[str, Any])r  rš   rB   rÐ   r³   r~   r  rZ  rà   rš   r  rš   rS  rU  )r  rš   rS  rT  )r(  r~   rS  ú	List[str])rS  rW  )rb   rš   r5   rš   rS  rT  )rb   rš   rS  rT  )rS  rT  )rb   rš   rS  rU  )rb   rš   r5  rš   rA  zOptional[Tuple[str, ...]]rS  rT  )r5  rš   rS  r\  )#Ú__name__Ú
__module__Ú__qualname__Ú__doc__rP   ri   rn   rv   r{   r‚   rŠ   r�   rŸ   r§   r­   rÊ   rÍ   rÑ   rÔ   r  r  Ústaticmethodr  r   r#  r)  r,  r/  r7  r9  r<  r>  r@  rL  rR  r¼   re   r!   r$   r$   Ÿ   sv  „ ÙVð4Ø,ð4ØBGð4ØLUó4ðn ?CÐW[Ø¨dÀTÐ_cØÀuØ!%ñ	;!Øð;!Ø*-ð;!Ø8;ð;!ØQTð;!àð;!à(+ð;!à?Bð;!àY\ð;!ð ð;!ð AEð;!ð ð	;!ð +/ó	;!ðz ]_õ 
ð XZõ !ð <>õ Aó
''óR
óð& IMñ*Ø"'ð*Ø7Eð*ØQ`ó*ð ,0ÀTñRØ(ðRØ>BðRØNbóRð4 8<ÐZ^Ø!%À$Ðcgñ(<Øð(<Ø'4ð(<ØJWð(<àð(<à2?ð(<àS`ð(<ð 
"ó(<ðT NRõ 1óf-(ó^(ó$cókóZFðP òCó ðCðD bfØ rñYØ ðYØ'*ðYØ8=ðYØO^ðYàðYà!$ðYà.2óYó"[ó4ó'óóó
óólóð X\ñQØðQØ&)ðQØ;TðQØ`dóQôre   r$   )rS  zDict[str, Set[str]])%r`  Ú
__future__r   r…   Úloggingr4   r9   Úpathlibr   Útypingr   r   r   r   r	   r
   Úhermes_state_commonr   r   r   r   r   Ú	getLoggerrP  Úformatr]   Ú_PEER_SELECT_HEADrª   r«   Ú_ORPHAN_DONOR_COLUMNSr¿   rÁ   rÂ   r   r"   rI  r$   r¼   re   r!   ú<module>rk     sÚ  ðñCõ
 #ã Û Û 
Û Ý ß 8× 8÷õ ð
 
ˆ×	Ñ	˜>Ó	*€ð
	 ñ  1Ð1EÐGYÓZÐ[ð \Ù0Ð1EÐGYÓZÐ[ð \$Ø$;Ð$4×$;Ñ$;¸gÔ$FÐ#Gð HðÐ ð"	Ð ð *Ð*ð +?à?[Ð>\ð ].ð /EÐ-Eð FðÐ ð  ,Ð,ð -?ð @\Ð>\ð ].ð /EÐ-Eð Fð%Ð ð6=ð ðñ 1°Ó5Ð6ð 7ñ )Ð)9Ð;MÓNÐOð PÙ(Ð)9Ð;MÓNÐOð Pð€ð! Ø 5Ð6ð 7ðÐ ð% Ø 5Ð6°bÑ9QÐRUÓ9VÐ8Wð Xñ  8¸Ó<Ð=ð >Ù7¸Ó<Ð=ð >ð!Ð ðTÐ óð$ ]Ð ÷Cò Cre   