
    .cj2                    F   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ZddlZddl	Z	ddl
mZmZmZmZ ddlmZ  ej        e          Z ej                    ZdZdZdZd	Zd
Zd Zd8dZd9dZd:dZd;dZd<d!Z d=d"Z!d=d#Z"d>d?d&Z#d>d@d(Z$	 dAdd)dBd/Z%dAdCd0Z&dAdDd3Z'dEdFd7Z(dS )Gu  Durable delivery-obligation ledger for gateway final responses.

A final agent response that was generated but not yet confirmed-delivered
to the messaging platform is the one artifact the gateway can lose without
a trace: the turn already burned its tokens, the text exists only in a
Python local, and a crash / planned restart between finalize and platform
ACK drops it silently (#58818, #41696, #63695).

This module records a small durable row per outbound final response in the
shared ``state.db`` (same file and conventions as
``tools.async_delegation`` — WAL, owner pid + process-start-time liveness,
bounded retention). The gateway writes three checkpoints around the send:

    record_obligation()   state='pending'     before any send attempt
    mark_attempting()     state='attempting'  immediately before the await
    mark_delivered() /    state='delivered'   only on SendResult.success
    mark_failed()         state='failed'      on a definitive rejection

On startup, ``sweep_recoverable()`` claims rows whose owning process is
dead and hands them to the gateway for redelivery. Crash semantics are
explicit about ambiguity (the contract review of the earlier
delivery-outbox attempt, #61790, closed it for silently resending
ambiguous sends):

- ``pending``     — the send never started: redeliver plainly, no dup risk.
- ``attempting``  — crashed mid-await: the platform MAY already have the
  message. Redelivered WITH a visible recovered-reply marker so the
  contract is honest at-least-once, never a silent duplicate.
- ``failed``      — definitively rejected once; the restart is a natural
  retry boundary. Also carries the marker.
- ``delivered``   — nothing to do; retention prunes.

Poison rows cannot spin: attempts are capped, stale rows expire, and both
transition to ``abandoned`` (kept briefly for inspection, then pruned).

Everything here is best-effort by design: ledger failures must never block
or delay an actual send. Callers wrap every call in try/except.
    )annotationsN)AnyDictListOptionalget_hermes_home   iQ i:	   u_   ♻️ Recovered reply — the gateway restarted during delivery, so this may be a duplicate:

c                 $    t                      dz  S )Nzstate.dbr        9/home/ice/.hermes/hermes-agent/gateway/delivery_ledger.py_db_pathr   I   s    z))r   returnsqlite3.Connectionc                     ddl m}  t                      }|j                            dd           t          j        |d          } | |d           |                    d	           |S )
Nr   )apply_wal_with_fallbackT)parentsexist_ok
   )timeoutzstate.db (delivery_ledger))db_labela  CREATE TABLE IF NOT EXISTS delivery_obligations (
            obligation_id TEXT PRIMARY KEY,
            session_key TEXT NOT NULL,
            platform TEXT NOT NULL,
            chat_id TEXT NOT NULL,
            thread_id TEXT,
            content TEXT NOT NULL,
            state TEXT NOT NULL,
            attempts INTEGER NOT NULL DEFAULT 0,
            created_at REAL NOT NULL,
            updated_at REAL NOT NULL,
            owner_pid INTEGER,
            owner_started_at INTEGER,
            last_error TEXT
        ))hermes_stater   r   parentmkdirsqlite3connectexecute)r   pathconns      r   _connectr"   M   s    444444::DKdT222?4,,,DD+GHHHHLL	  " Kr   tuple[int, Optional[int]]c                 x    t          j                    } 	 ddlm} |  ||           fS # t          $ r | d fcY S w xY w)Nr   get_process_start_time)osgetpidgateway.statusr&   	Exception)pidr&   s     r   _owner_stampr,   h   sf    
)++C999999**3////   Dys   ( 99r+   r   
started_atboolc                   | sdS 	 t          |           } n# t          t          f$ r Y dS w xY w	 ddlm}  ||           }n# t
          $ r d}Y nw xY w|B	 t          j        | d           n)# t          $ r Y dS t          $ r Y dS t          $ r Y dS w xY wdS |dS 	 t          |          t          |          k    S # t          t          f$ r Y dS w xY w)zFTrue when the recorded owning process still exists (pid + start time).Fr   r%   NT)int	TypeError
ValueErrorr)   r&   r*   r'   killProcessLookupErrorPermissionErrorOSError)r+   r-   r&   current_starts       r   _owner_aliver8   r   sZ    u#hhz"   uu999999..s33   	GCOOOO! 	 	 	55 	 	 	44 	 	 	55	tt=!!S__44z"   ttsM    ++A AAA, ,
B9	B	BBB< <CCsession_keystrmessage_refcontentc                    |  d| d| }t          j        |                    dd                                                    dd         S )a  Stable id: same turn + same content re-records idempotently, while
    distinct threads/topics on the same chat can never collide (the
    session_key carries platform, chat and thread; ``message_ref`` is the
    triggering inbound message id, distinguishing turns in one session).|zutf-8replaceN   )hashlibsha256encode	hexdigest)r9   r;   r<   payloads       r   compute_obligation_idrF      sR    
 66{66W66G>'..)<<==GGII#2#NNr   obligation_idplatformchat_id	thread_idOptional[str]Nonec                p   t          j                     }t                      \  }}t          5  t                      5 }	|	                    d| ||t          |          |rt          |          nd|||||f
           ddd           n# 1 swxY w Y   ddd           n# 1 swxY w Y   t                       dS )zBRecord a final response as owed to the platform (state='pending').a'  INSERT OR REPLACE INTO delivery_obligations
               (obligation_id, session_key, platform, chat_id, thread_id,
                content, state, attempts, created_at, updated_at,
                owner_pid, owner_started_at)
               VALUES (?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?, ?, ?)N)timer,   _DB_LOCKr"   r   r:   _prune)
rG   r9   rH   rI   rJ   r<   nowr+   startedr!   s
             r   record_obligationrS      s0    )++C>>LC	 

 

8:: 

F
 K3w<<(2S^^^dGS#'		
 		
 		


 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 HHHHHs4   B?B:BB
	
BB
	BB!$B!c                &    t          | d           d S )N
attempting_update_staterG   s    r   mark_attemptingrY      s    -.....r   c                &    t          | d           d S )N	deliveredrV   rX   s    r   mark_deliveredr\      s    ------r    errorc                *    t          | d|           d S )Nfailed)r^   rV   )rG   r^   s     r   mark_failedra      s    -777777r   statec           
         t           5  t                      5 }|                    d|t          j                    |r
|d d         nd | f           d d d            n# 1 swxY w Y   d d d            d S # 1 swxY w Y   d S )NzwUPDATE delivery_obligations
               SET state=?, updated_at=?, last_error=?
               WHERE obligation_id=?r   )rO   r"   r   rN   )rG   rb   r^   r!   s       r   rW   rW      s   	 
 
8:: 
( DIKK!?tt4O		
 	
 	

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
s4   A38AA3A	A3"A	#A33A7:A7)deliverable_platformsrQ   Optional[float]rd   Optional[set]List[Dict[str, Any]]c               z   | | nt          j                     } t                      \  }}g }t          5  t                      5 }|                    d                                          }|D ]\  }}}	}
}}}}}}}t          ||          r|t          k    s| |z
  t          k    r|                    d| |f           Q||	|vrX|                    d||| |||f          }|j	        r%|
                    |||	|
|||dk    |dz   d           	 ddd           n# 1 swxY w Y   ddd           n# 1 swxY w Y   |S )ae  Claim undelivered rows owned by dead processes; return them for
    redelivery.

    Claiming atomically re-stamps the owner to THIS process and increments
    ``attempts``, so a second gateway racing the same sweep cannot
    double-claim (the UPDATE is guarded on the previous owner stamp).
    Rows over the attempts cap or older than the stale cutoff transition to
    'abandoned' instead of being returned.

    ``deliverable_platforms`` (platform value strings) restricts claiming to
    platforms the caller can actually send on this boot.  ``attempts`` is the
    redelivery budget, so it must only be spent on a real send: a platform
    that failed to connect would otherwise burn one attempt per boot and hit
    the cap having never been sent once.  Rows for absent platforms are left
    untouched for a later boot; the stale cutoff still bounds them.
    Na  SELECT obligation_id, session_key, platform, chat_id, thread_id,
                      content, state, attempts, created_at,
                      owner_pid, owner_started_at
               FROM delivery_obligations
               WHERE state IN ('pending', 'attempting', 'failed')zlUPDATE delivery_obligations
                       SET state='abandoned', updated_at=? WHERE obligation_id=?zUPDATE delivery_obligations
                   SET owner_pid=?, owner_started_at=?, attempts=attempts+1,
                       updated_at=?
                   WHERE obligation_id=? AND (owner_pid IS ? OR owner_pid=?)pending   )rG   r9   rH   rI   rJ   r<   needs_markerattempts)rN   r,   rO   r"   r   fetchallr8   MAX_ATTEMPTSSTALE_AFTER_SECONDSrowcountappend)rQ   rd   r+   rR   claimedr!   rowsoidr9   rH   rI   rJ   r<   rb   rl   
created_at	owner_pidowner_started_atcursors                      r   sweep_recoverablery      s-   * ##dikkC>>LC$&G	 - -8:: -||E
 
 (** 	 DH%	 %	 @S+x)Wez9.>I'788 <''C*,<@S+S+ST#J  
 %1$999 \\P gsCI> F  %(#. (&!*& %*Y$6 (1      5%	- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -\ Ns6   D0CDD0D	D0 D	!D00D47D4c                   | | nt          j                     } | t          z
  }	 t                      5 }|                    d|f           |                    d                                          d         }t          d|t          z
            }|r|                    d|f           d d d            d S # 1 swxY w Y   d S # t          $ r  t          	                    dd           Y d S w xY w)NzpDELETE FROM delivery_obligations
                   WHERE state IN ('delivered', 'abandoned') AND updated_at < ?z)SELECT COUNT(*) FROM delivery_obligationsr   a  DELETE FROM delivery_obligations WHERE obligation_id IN (
                         SELECT obligation_id FROM delivery_obligations
                         ORDER BY CASE state
                                    WHEN 'delivered' THEN 0
                                    WHEN 'abandoned' THEN 1
                                    ELSE 2
                                  END, updated_at ASC
                         LIMIT ?)zdelivery ledger prune failedT)exc_info)
rN   _RETENTION_SECONDSr"   r   fetchonemax	_MAX_ROWSr*   loggerdebug)rQ   cutoffr!   totalexcesss        r   rP   rP     s[   ##dikkC%%FDZZ 	4LLS	  
 LL; hjjE EI-..F % I
 
 
	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	,  D D D3dCCCCCCDs5   C A6B4'C 4B88C ;B8<C &C+*C+configOptional[Dict[str, Any]]c                <   	 | ddl m}  |            } |                     d          pi }|                    dd          }t          |t                    r(|                                                                dvS t          |          S # t          $ r Y dS w xY w)z>Read the ``gateway.delivery_ledger`` config gate (default on).Nr   )load_configgatewaydelivery_ledgerT>   0noofffalse)	hermes_cli.configr   get
isinstancer:   striplowerr.   r*   )r   r   gwvalues       r   ledger_enabledr   4  s    >555555 []]FZZ	""(b($//eS!! 	L;;==&&((0KKKE{{   tts   A;B >B 
BB   limitr0   c                   t           5  t                      5 }|                    d| f                                          }ddd           n# 1 swxY w Y   ddd           n# 1 swxY w Y   t	          j        d |D             d          S )z>Human-readable dump for ad-hoc inspection (sqlite3-free path).zSELECT obligation_id, session_key, state, attempts,
                      created_at, updated_at, last_error
               FROM delivery_obligations
               ORDER BY updated_at DESC LIMIT ?Nc           
     v    g | ]6}|d          |d         |d         |d         |d         |d         |d         d7S )r   rj      r
            )idsessionrb   rl   ru   
updated_at
last_errorr   ).0rs     r   
<listcomp>zdebug_rows.<locals>.<listcomp>O  s]     	
 	
 	

  dqtad!d!A$ad 	
 	
 	
r   r   )indent)rO   r"   r   rm   jsondumps)r   r!   rs   s      r   
debug_rowsr   D  s#   	  8:: ||3 H
 
 (** 	                              :	
 	

 	
 	
 	
 	 	 	 	s4   A$*AA$A	A$A	A$$A(+A()r   r   )r   r#   )r+   r   r-   r   r   r.   )r9   r:   r;   r:   r<   r:   r   r:   )rG   r:   r9   r:   rH   r:   rI   r:   rJ   rK   r<   r:   r   rL   )rG   r:   r   rL   )r]   )rG   r:   r^   r:   r   rL   )rG   r:   rb   r:   r^   r:   r   rL   )N)rQ   re   rd   rf   r   rg   )rQ   re   r   rL   )r   r   r   r.   )r   )r   r0   r   r:   ))__doc__
__future__r   rA   r   loggingr'   r   	threadingrN   typingr   r   r   r   hermes_constantsr	   	getLogger__name__r   LockrO   rn   ro   r|   r   RECOVERED_MARKERr   r"   r,   r8   rF   rS   rY   r\   ra   rW   ry   rP   r   r   r   r   r   <module>r      s@  % %N # " " " " "    				       , , , , , , , , , , , , , , , , , ,		8	$	$9>
 " % 	
& * * *   6      DO O O O   4/ / / /. . . .8 8 8 8 8
 
 
 
 
  F ,0F F F F F FRD D D D D<           r   