
    (Gj;)             '       
   d 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Zddl	Z	ddl
Z
ddlZ	 ddlZn# e$ r dZY nw xY w	 ddlZn# e$ r dZY nw xY wddlmZmZ ddlmZ ddlmZ ddlmZmZmZmZmZmZmZ  ej        e          Zddl m!Z" ddl#m$Z$ 	 dd	l%m%Z% d
Z&n# e$ r dZ&Y nw xY w e            '                                Z(e(dz  Z)e)dz  Z*e)dz  Z+e)dz  Z,dZ- ej.                    Z/ ej0                    Z1e)dz  Z2dZ3defdZ4ej5        d             Z6 e7dh          Z8de9defdZ:d|dee9         dee         dee9         fdZ;dee9ef         dee9ef         fdZ<d}ded e9de9fd!Z=dee9ef         de9fd"Z>dee9ef         dee9ef         fd#Z?d$efd%Z@d$efd&ZAd' ZBd(e9deCfd)ZDd*e9dee9ef         fd+ZEd,edefd-ZFd.ed/edeGfd0ZHd.ed/edeGfd1ZIdd2d*ee9ef         d3ed4ee9         dee9         fd5ZJd*eKdeCfd6ZLd~d*ee9ef         d4ee9         dee9         fd7ZMd$eddfd8ZNdd9eGddfd:ZOd$edeeP         fd;ZQdeeP         fd<ZRdeeP         fd=ZSdeee9ef                  fd>ZTd?eee9ef                  fd@ZUd?eee9ef                  fdAZVdBee9         dee9         fdCZWdee9         fdDZXddEdedFeGdee9         fdGZYdHedIedJedKedeee9         ee9         f         f
dLZZdee9ef         deee9         ee9         ee9         eGf         fdMZ[	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 ddNee9         d*e9dOee9         dPeeC         dQee9         dReee9ef                  dee9         deee9                  dIee9         dHee9         dJee9         dSee9         dTeee9ee9         f                  dUeee9                  dBee9         dKeGdVeeG         dee9ef         f$dWZ\de9deee9ef                  fdXZ] G dY dZe^          Z_d[e9deee9ef                  fd\Z`dd]eGdeee9ef                  fd^Zade9d_ee9ef         deee9ef                  fd`Zbd~de9daee9         deee9ef                  fdbZcde9deee9ef                  fdcZdde9deee9ef                  fddZede9deGfdeZf	 	 d|de9d9eGdfee9         dgee9         fdhZgde9deGfdiZhde9deGfdjZide9fdkZjdldmde9dneCdeGfdoZkdeee9ef                  fdpZldeee9ef                  fdqZmdrZndeCfdsZodtedueCdeCfdvZpde9de9fdwZqdee9         fdxZr	 	 d|dyeee9e9f                  dzeee9                  dee9ef         fd{ZsdS )z
Cron job storage and management.

Jobs are stored in ~/.hermes/cron/jobs.json
Output is saved to ~/.hermes/cron/output/{job_id}/{timestamp}.md
    N)datetime	timedelta)Path)get_hermes_home)OptionalDictListAnySetTupleUnion)now)atomic_replace)croniterTFcronz	jobs.jsonticker_heartbeatticker_last_success<   outputx   returnc                      t           dz  S )z=Return the advisory lock path for the current cron directory.z
.jobs.lock)CRON_DIR     //home/rurouni/.hermes/hermes-agent/cron/jobs.py_jobs_lock_filer   [   s    l""r   c            
   #   F  K   t          t          dd          } | rF| dz   t          _        	 dV  t          xj        dz  c_        n# t          xj        dz  c_        w xY wdS t          5  dt          _        d}	 	 t	                       t          t                      dd          }|                    d           t           t          j	        |t          j
                   nLt          E t          t          d          |                                t          t          d	          d           n9# t          t          f$ r%}t                              d
|           Y d}~nd}~ww xY w	 dV  |	 t           t          j	        |t          j                   nLt          E t          t          d          |                                t          t          d          d           n# t          t          f$ r Y nw xY w|                                 n# |                                 w xY wn# |	 t           t          j	        |t          j                   nLt          E t          t          d          |                                t          t          d          d           n# t          t          f$ r Y nw xY w|                                 w # |                                 w xY ww xY wdt          _        n# dt          _        w xY w	 ddd           dS # 1 swxY w Y   dS )u  Serialize a load_jobs→modify→save_jobs critical section.

    Combines the in-process threading lock (cheap mutual exclusion between
    the gateway's parallel tick threads) with a cross-process advisory file
    lock on ``<cron dir>/.jobs.lock`` (mutual exclusion between the gateway process
    and standalone ``hermes`` CLI invocations, which previously shared no lock
    at all — a `cron pause` could be silently clobbered by a concurrent
    gateway write, leaving a "paused" job still firing).

    The flock is blocking, but every critical section that uses it is short
    (field updates only — no agent execution), so contention resolves in
    milliseconds. If neither fcntl nor msvcrt is available the manager still
    provides in-process locking, matching the historical behaviour.

    Nested calls in the same thread reuse the held lock so legacy callers that
    invoke save_jobs() inside a broader mutation section don't deadlock or try
    to reacquire the advisory file lock.
    depthr      Nza+utf-8encodinglockingLK_LOCKzSjobs.json cross-process lock unavailable (%s); proceeding with in-process lock onlyLK_UNLCK)getattr_jobs_lock_stater   _jobs_file_lockensure_dirsopenr   seekfcntlflockLOCK_EXmsvcrtfilenoOSErrorIOErrorloggerwarningLOCK_UNclose)r   lock_fdes      r   
_jobs_lockr:   `   s     ( $gq11E !&	(EEE""a'"""""a'""""""	 ' '!"	'J00$IIIQ$K7777'.GFI..w~~/?/?QZA[A[]^___W% J J J   FGHJ J J J J J J JJ
(&( ,!K????#/6GFI66w~~7G7GQWYcIdIdfghhh#W-      '7&( ,!K????#/6GFI66w~~7G7GQWYcIdIdfghhh#W-      ' &'""Q"&&&&"?' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' ' 's   A A(L9B4D.-K7.E$?EK7E$$K7(H),K7/A3G#"H#G74H6G77H:K7H%%K7)K'-A3J! K!J5	2K4J5	5K8K'K#	#K''K7*L7LLLLidjob_idc                 *   t          | pd                                          }|r|dv sd|v sd|v rt          d|           t          |                                          st          |          j        rt          d|           t          |z  S )ad  Resolve a job's output directory, rejecting any path-escape attempt.

    Job IDs are filesystem path components under ``OUTPUT_DIR``. A legacy or
    crafted ID containing ``..``, absolute paths, or nested separators would
    allow output writes/deletes to escape the cron output sandbox. Reject
    anything that isn't a single safe path component.
     >   .../\z%Invalid cron job id for output path: )strstrip
ValueErrorr   is_absolutedrive
OUTPUT_DIR)r<   texts     r   _job_output_dirrJ      s     v|""$$D M4;&&#++KKKLLLDzz M4::#3 MKKKLLLr   skillskillsc                     || r| gng }n(t          |t                    r|g}nt          |          }g }|D ]@}t          |pd                                          }|r||vr|                    |           A|S )zPNormalize legacy/single-skill and multi-skill inputs into a unique ordered list.Nr>   )
isinstancerC   listrD   append)rK   rL   	raw_items
normalizeditemrI   s         r   _normalize_skill_listrT      s    ~$,UGG"			FC	 	  !H		LL	J $ $4:2$$&& 	$D
**d###r   jobc                     t          |           }t          |                    d          |                    d                    }||d<   |r|d         nd|d<   |S )zLReturn a job dict with canonical `skills` and legacy `skill` fields aligned.rK   rL   r   N)dictrT   get)rU   rR   rL   s      r   _apply_skill_fieldsrY      s[    cJ":>>'#:#:JNN8<T<TUUF!Jx'-7&))4Jwr   r>   valuefallbackc                 (    | |S t          |           S )zFCoerce legacy/hand-edited nullable cron fields to strings for readers.)rC   )rZ   r[   s     r   _coerce_job_textr]      s    }u::r   c                 l   t          |                     d                                                    }|r|S |                     d          }t          |t                    r@dD ]<}t          |                    |                                                    }|r|c S =n|t          |          S dS )Nschedule_displayschedule)displayrZ   exprrun_at?)r]   rX   rD   rN   rW   rC   )rU   ra   r`   keyrI   s        r   _schedule_display_for_jobrf      s    sww'9::;;AACCG wwz""H(D!! 9 	 	C#HLL$5$566<<>>D 	 
	8}}3r   c                    t          |           }t          |                    d          d          }t          |                    d                    }||d<   ||d<   t          |                    d                                                    }|st          |                    d                                                    }|p*|                    d          r|d         d         ndp|p|pd	}|d
d                                         pd	}||d<   t	          |          |d<   t          |                    d                                                    }|s|                    dd          rdnd}||d<   |S )a,  Return a read-safe cron job shape for UI/API/tool/scheduler consumers.

    Older or hand-edited jobs can have nullable fields like ``prompt``,
    ``name``, or ``schedule_display``.  Keep storage untouched on read, but
    ensure consumers never crash while formatting or running those records.
    r;   unknownpromptnamescriptrL   r   r>   cron jobN2   r_   stateenabledT	scheduledpaused)rY   r]   rX   rD   rf   )rU   rR   r<   ri   rj   rk   label_sourcern   s           r   _normalize_job_recordrs      s    %S))JjnnT22I>>FjnnX6677FJt!JxJNN6223399;;D 	7!*..":":;;AACC +5>>(+C+CK
8$Q''   	 CRC &&((6JJv%>z%J%JJ!"Z^^G4455;;==E M)~~i>>LHJwr   pathc                 b    	 t          j        | d           dS # t          t          f$ r Y dS w xY w)z<Set directory to owner-only access (0700). No-op on Windows.i  N)oschmodr2   NotImplementedErrorrt   s    r   _secure_dirrz   
  sG    
u()   s    ..c                     	 |                                  rt          j        | d           dS dS # t          t          f$ r Y dS w xY w)z;Set file to owner-only read/write (0600). No-op on Windows.i  N)existsrv   rw   r2   rx   ry   s    r   _secure_filer}     sa    ;;== 	"HT5!!!!!	" 	"()   s   )/ AAc                      t                               dd           t                              dd           t          t                      t          t                     dS )z6Ensure cron directories exist with secure permissions.Tparentsexist_okN)r   mkdirrH   rz   r   r   r   r*   r*     sS    NN4$N///TD111
r   sc                 >   |                                                                  } t          j        d|           }|st	          d|  d          t          |                    d                    }|                    d          d         }dddd	}|||         z  S )
u   
    Parse duration string into minutes.
    
    Examples:
        "30m" → 30
        "2h" → 120
        "1d" → 1440
    zD^(\d+)\s*(m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days)$zInvalid duration: 'z''. Use format like '30m', '2h', or '1d'r       r   r   i  )mhd)rD   lowerrematchrE   intgroup)r   r   rZ   unitmultiplierss        r   parse_durationr   '  s     	
		AH\^_``E [YqYYYZZZAE;;q>>!D..K;t$$$r   r`   c                    |                                  } | }|                                 }|                    d          r5| dd                                          }t          |          }d|d| ddS |                                 }t          |          dk    rut          d |dd         D                       rTt          st          d	          	 t          |            n'# t          $ r}t          d
|  d|           d}~ww xY wd| | dS d| v st          j        d|           r	 t          j        |                     dd                    }|j        )t#                      j        }|                    |          }d|                                d|                    d           dS # t          $ r}t          d|  d|           d}~ww xY w	 t          |           }t#                      t)          |          z   }	d|	                                d| dS # t          $ r Y nw xY wt          d| d          )uM  
    Parse schedule string into structured format.
    
    Returns dict with:
        - kind: "once" | "interval" | "cron"
        - For "once": "run_at" (ISO timestamp)
        - For "interval": "minutes" (int)
        - For "cron": "expr" (cron expression)
    
    Examples:
        "30m"              → once in 30 minutes
        "2h"               → once in 2 hours
        "every 30m"        → recurring every 30 minutes
        "every 2h"         → recurring every 2 hours
        "0 9 * * *"        → cron expression
        "2026-02-03T14:00" → once at timestamp
    zevery    Nintervalr   )kindminutesra      c              3   @   K   | ]}t          j        d |          V  dS )z^[\d\*\-,/]+$N)r   r   ).0ps     r   	<genexpr>z!parse_schedule.<locals>.<genexpr>_  s@        *+!1%%     r   zOCron expressions require 'croniter' package. Install with: pip install croniterzInvalid cron expression 'z': r   )r   rb   ra   Tz^\d{4}-\d{2}-\d{2}Zz+00:00tzinfooncezonce at z%Y-%m-%d %H:%M)r   rc   ra   zInvalid timestamp 'r   zonce in zInvalid schedule 'z'. Use:
  - Duration: '30m', '2h', '1d' (one-shot)
  - Interval: 'every 30m', 'every 2h' (recurring)
  - Cron: '0 9 * * *' (cron expression)
  - Timestamp: '2026-02-03T14:00:00' (one-shot at time))rD   r   
startswithr   splitlenallHAS_CRONITERrE   r   	Exceptionr   r   r   fromisoformatreplacer   _hermes_now	isoformatstrftimer   )
r`   originalschedule_lowerduration_strr   partsr9   dt	hermes_tzrc   s
             r   parse_scheduler   <  s   $ ~~HH^^%%N   ** 
|))++ ..****
 
 	
 NNE
5zzQ3  /4RaRy      	pnooo	KX 	K 	K 	KIIIaIIJJJ	K 
 
 	
 h"(#8(CC	E'(8(8h(G(GHHB y 'MM0	ZZyZ11,,..Ebkk2B&C&CEE  
  	E 	E 	EC8CCCCDDD	E	 **7!;!;!;;&&((,(,,
 
 	

     	CX 	C 	C 	C  sD   C$ $
D.DD+BF1 1
G;GGAH" "
H/.H/r   c                    t                      j        }| j        St          j                                                    j        }|                     |                              |          S |                     |          S )a  Return a timezone-aware datetime in Hermes configured timezone.

    Backward compatibility:
    - Older stored timestamps may be naive.
    - Naive values are interpreted as *system-local wall time* (the timezone
      `datetime.now()` used when they were created), then converted to the
      configured Hermes timezone.

    This preserves relative ordering for legacy naive timestamps across
    timezone changes and avoids false not-due results.
    Nr   )r   r   r   r   
astimezoner   )r   	target_tzlocal_tzs      r   _ensure_awarer     sf     $I	y<>>,,..5zzz**55i@@@==###r   storedcurrentc                 v    | j         |j         dS |                                 |                                k    S )u	  Return True when a stored aware timestamp uses a different UTC offset.

    Naive stored timestamps return False: they carry no offset to compare, and
    are normalized by ``_ensure_aware`` instead — they intentionally never take
    the offset-repair path.
    NF)r   	utcoffsetr   r   s     r   _timezone_offset_mismatchr     s;     } 6u!2!2!4!444r   c                 ^    |                      d          |                     d          k    S )a  Return True when the stored local wall-clock time has not arrived yet.

    Cron schedules express local wall-clock intent. If Hermes/system local time
    changes after next_run_at was persisted, an old offset can make a future
    wall-clock run look due at the converted absolute time (for example
    21:00+10 becomes 13:00+02). Comparing naive wall-clock values lets us
    distinguish that migration case from a genuinely missed run whose scheduled
    wall time has already passed.
    Nr   )r   r   s     r   _stored_wall_clock_is_futurer     s+     >>>&&)E)EEEr   last_run_atr   r   c                    |                      d          dk    rdS |rdS |                      d          }|sdS t          t          j        |                    }||t	          t
                    z
  k    r|S dS )a  Return a one-shot run time if it is still eligible to fire.

    One-shot jobs get a small grace window so jobs created a few seconds after
    their requested minute still run on the next tick. Once a one-shot has
    already run, it is never eligible again.
    r   r   Nrc   )seconds)rX   r   r   r   r   ONESHOT_GRACE_SECONDS)r`   r   r   rc   	run_at_dts        r   _recoverable_oneshot_run_atr     s     ||Fv%%t t\\(##F th4V<<==IC),ABBBBBB4r   c                 6   d}d}|                      d          }|dk    r<|                      dd          dz  }|dz  }t          |t          ||                    S |d	k    rt          r	 t	                      }t          | d
         |          }|                    t                    }|                    t                    }	t          |	|z
  	                                          }|dz  }t          |t          ||                    S # t          $ r Y nw xY w|S )a(  Compute how late a job can be and still catch up instead of fast-forwarding.

    Uses half the schedule period, clamped between 120 seconds and 2 hours.
    This ensures daily jobs can catch up if missed by up to 2 hours,
    while frequent jobs (every 5-10 min) still fast-forward quickly.
    r   i   r   r   r   r    r   r   r   rb   )rX   maxminr   r   r   get_nextr   r   total_secondsr   )
r`   	MIN_GRACE	MAX_GRACEr   period_secondsgracer   r   firstseconds
             r   _compute_grace_secondsr     s     II<<Dz!i33b8!#9c%33444v~~,~		--CHV,c22DMM(++E]]8,,F &5.!?!?!A!ABBN"a'Ey#eY"7"7888 	 	 	D	 s   *BD	 	
DDc                    t                      }| d         dk    rt          | ||          S | d         dk    rf| d         }|r5t          t          j        |                    }|t          |          z   }n|t          |          z   }|                                S | d         dk    rt          s0t          	                    d| 
                    d	                     d
S |}|r!t          t          j        |                    }t          | d	         |          }|                    t                    }|                                S d
S )zo
    Compute the next run time for a schedule.

    Returns ISO timestamp string, or None if no more runs.
    r   r   r   r   r   r   r   zCannot compute next run for cron schedule %r: 'croniter' is not installed. croniter is a core dependency as of v0.9.x; reinstall hermes-agent or run 'pip install croniter' in your runtime env.rb   N)r   r   r   r   r   r   r   r   r4   r5   rX   r   r   )r`   r   r   r   lastnext_run	base_timer   s           r   compute_next_runr     s[    --C6!!*8SkRRRR	&	Z	'	'9% 	8 !7!D!DEEDi8888HH Yw7777H!!###	&	V	#	# 	NN V$$   4
 	 	K%h&<[&I&IJJI()44==**!!###4r   c                 >   t                       t          j        t          t                    dd          \  }}	 t          j        |dd          5 }|                    t          t          j                                         |	                                 t          j
        |                                           ddd           n# 1 swxY w Y   t          ||            dS # t          $ r( 	 t          j        |           n# t          $ r Y nw xY w w xY w)a  Atomically write the current epoch time to ``path``.

    Uses the same tmpfile + ``atomic_replace`` pattern as ``save_jobs`` so a
    concurrent reader in another process (``hermes cron status``) never sees a
    torn/truncated file. Best-effort: failures are swallowed by callers.
    .tmpz.hb_dirsuffixprefixwr!   r"   N)r*   tempfilemkstemprC   r   rv   fdopenwritetimeflushfsyncr1   r   BaseExceptionunlinkr2   )rt   fdtmp_pathfs       r   _atomic_write_epochr   9  sO    MMM#HfVTTTLBYr3111 	!QGGC	$$%%%GGIIIHQXXZZ   	! 	! 	! 	! 	! 	! 	! 	! 	! 	! 	! 	! 	! 	! 	! 	x&&&&&   	Ih 	 	 	D	sT   C* A.CC* CC* CC* *
D5D
	D

DDDDsuccessc                     	 t          t                     n# t          $ r Y nw xY w| r(	 t          t                     dS # t          $ r Y dS w xY wdS )ud  Record a ticker liveness signal, and optionally a successful-tick signal.

    The ticker calls this once per loop iteration. ``success=True`` additionally
    bumps the *last successful tick* marker. We track two distinct signals so
    `hermes cron status` can tell a thread that is merely *alive and looping*
    (heartbeat fresh, success stale) from one that is actually *firing jobs*
    (both fresh) — a ticker stuck failing every tick would otherwise keep the
    plain heartbeat fresh and falsely report healthy (#32612, #32895).

    Best-effort: a write failure must never disrupt the tick loop.
    N)r   TICKER_HEARTBEAT_FILEr   TICKER_SUCCESS_FILE)r   s    r   record_ticker_heartbeatr   P  s    12222    	 344444 	 	 	DD	 s    
$$A   
AAc                     	 |                      d                                          }t          dt          j                    t	          |          z
            S # t
          $ r Y d S w xY w)Nr!   r"   g        )	read_textrD   r   r   floatr   )rt   raws     r   _epoch_file_ager   g  si    nngn..44663	eCjj0111   tts   AA 
A)(A)c                  *    t          t                    S )zSeconds since the ticker loop last iterated, or None if unknown.

    None = heartbeat file missing/unreadable (older build, never ran, or a
    torn read). Callers treat None as "cannot determine", not "dead".
    )r   r   r   r   r   get_ticker_heartbeat_ager   o  s     0111r   c                  *    t          t                    S )zHSeconds since the ticker last completed a tick WITHOUT raising, or None.)r   r   r   r   r   get_ticker_success_ager   x  s    .///r   c                  8   t                       t                                          sg S d} 	 t          t          dd          5 }t	          j        |          }ddd           n# 1 swxY w Y   n# t          j        $ r d} 	 t          t          dd          5 }t	          j        |                                d          }ddd           n# 1 swxY w Y   n@# t          $ r3}t                              d|           t          d	|           |d}~ww xY wY n?t          $ r3}t                              d
|           t          d|           |d}~ww xY wt          |t                    rE|                    dg           }| r+|r)t#          |           t                              d           |S t          |t&                    r-|r)t#          |           t                              d           |S t          dt)          |          j                   )zLoad all jobs from storage.Frr!   r"   NT)strictz#Failed to auto-repair jobs.json: %sz*Cron database corrupted and unrepairable: zIOError reading jobs.json: %szFailed to read cron database: jobsz8Auto-repaired jobs.json (had invalid control characters)z3Auto-repaired jobs.json (bare list wrapped as dict)z7Cron database corrupted: expected {'jobs': [...]}, got )r*   	JOBS_FILEr|   r+   jsonloadJSONDecodeErrorloadsreadr   r4   errorRuntimeErrorr3   rN   rW   rX   	save_jobsr5   rO   type__name__)_strict_retryr   datar9   r   s        r   	load_jobsr    s   MMM 	MH)S7333 	 q9Q<<D	  	  	  	  	  	  	  	  	  	  	  	  	  	  	  X X X	Xiw777 :1z!&&((5999: : : : : : : : : : : : : : : 	X 	X 	XLL>BBBOAOOPPVWW	X  H H H4a888?A??@@aGH $ xx## 	WT 	WdOOONNUVVV$   	RdOOONNPQQQ
YDJJDWYY  s   A1 A%A1 %A))A1 ,A)-A1 1EC)CCC	CC	CE
D%.DDE	E&.EEr   c                    t                       t          j        t          t          j                  dd          \  }}	 t          j        |dd          5 }t          j	        | t                                                      d|d	           |                                 t          j        |                                           d
d
d
           n# 1 swxY w Y   t          |t                     t!          t                     d
S # t"          $ r( 	 t          j        |           n# t&          $ r Y nw xY w w xY w)z8Save all jobs to storage. Caller must hold _jobs_lock().r   z.jobs_r   r   r!   r"   )r   
updated_atr   )indentN)r*   r   r   rC   r   parentrv   r   r   dumpr   r   r   r   r1   r   r}   r   r   r2   )r   r   r   r   s       r   _save_jobs_unlockedr    sq   MMM#I,<(=(=fU]^^^LBYr3111 	!QIt;==3J3J3L3LMMqYZ[[[[GGIIIHQXXZZ   	! 	! 	! 	! 	! 	! 	! 	! 	! 	! 	! 	! 	! 	! 	! 	x+++Y   	Ih 	 	 	D	sU   D A3CD CD C,D 
D?D-,D?-
D:7D?9D::D?c                 r    t                      5  t          |            ddd           dS # 1 swxY w Y   dS )zSave all jobs to storage.N)r:   r  )r   s    r   r  r    s~    	 " "D!!!" " " " " " " " " " " " " " " " " "s   ,00workdirc                    | dS t          |                                           }|sdS t          |                                          }|                                st          d|d          |                                }|                                st          d|           |                                st          d|           t          |          S )uh  Normalize and validate a cron job workdir.

    Rules:
      - Empty / None → None (feature off, preserves old behaviour).
      - ``~`` is expanded.  Relative paths are rejected — cron jobs run detached
        from any shell cwd, so relative paths have no stable meaning.
      - The path must exist and be a directory at create/update time.  We do
        NOT re-check at run time (a user might briefly unmount the dir; the
        scheduler will just fall back to old behaviour with a logged warning).

    Returns the absolute path string, or None when disabled.
    Raises ValueError on invalid input.
    Nz+Cron workdir must be an absolute path (got zN). Cron jobs run detached from any shell cwd, so relative paths are ambiguous.zCron workdir does not exist: z!Cron workdir is not a directory: )	rC   rD   r   
expanduserrF   rE   resolver|   is_dir)r  r   expandedresolveds       r   _normalize_workdirr    s     t
g,,



C tCyy##%%H!! 
[# [ [ [
 
 	
 !!H?? ECCCDDD?? IGXGGHHHx==r   c                     	 ddl } ddlm} t                      dz  }|                                sdS |                    d          5 }|                     |          pi }ddd           n# 1 swxY w Y   	 ddlm} |	                    |          }n# t          $ r Y nw xY w ||          }|                    d          pi }t          |t                    r|                                pdS t          |t                    rU|                    d	          p|                    d          }t          |t                    r|                                pdS dS # t          $ r Y dS w xY w)
uc  Resolve the global default model the same way the cron ticker does.

    Mirrors the unpinned-model resolution in ``cron/scheduler.py`` ``run_job``:
    read ``config.yaml`` ``model.default`` (or the ``model`` alias / bare string
    form), applying the managed-scope overlay and env expansion. Used by
    ``create_job`` to snapshot the default model for unpinned jobs so a later
    swap of the global default is detected at fire time (#44585).

    Returns the resolved model string, or ``None`` if config is missing/empty
    or resolution fails (fail-open — caller treats ``None`` as "no snapshot").
    r   N)_expand_env_varszconfig.yamlr!   r"   )managed_scopemodeldefault)yamlhermes_cli.configr  r   r|   r+   	safe_load
hermes_clir  apply_managed_overlayr   rX   rN   rC   rD   rW   )r   r  cfg_pathr   cfgr  	model_cfgr  s           r   _resolve_default_model_snapshotr(    s   666666"$$}4   	4]]G],, 	*..##)rC	* 	* 	* 	* 	* 	* 	* 	* 	* 	* 	* 	* 	* 	* 	*	00000055c::CC 	 	 	D	s##GGG$$*	i%% 	-??$$,,i&& 	/mmI..H)--2H2HG'3'' /}}.$.t   ttsf   /E E 	A-!E -A11E 4A15E 9B E 
B"E !B""AE 2A)E 
E,+E,strip_trailing_slashr*  c                    t          | t                    sd S |                                 }|r|                    d          }|pd S )NrA   )rN   rC   rD   rstrip)rZ   r*  rI   s      r   _normalize_job_optional_textr-    sI    eS!! t;;==D  {{3<4r   providerr  base_urlno_agentc                    t          |           }t          |          }t          |d          }t          |          rdS d}d}|y	 ddlm}	 ddi}
|r||
d<    |	di |
}t	          |                    d	          pd
                                                                          }|pd}n# t          $ r d}Y nw xY w|$	 t                      pd}n# t          $ r d}Y nw xY w||fS )ap  Snapshot unpinned inference axes for the provider/model drift guard.

    Agent cron jobs with unpinned provider/model follow global config at fire
    time. Capture the current resolution for each unpinned axis so a later
    global switch fails closed instead of silently changing spend. Pinned axes
    and no-agent script jobs intentionally carry no snapshot.
    Tr)  NNNr   )resolve_runtime_provider	requestedexplicit_base_urlr.  r>   r   )
r-  boolhermes_cli.runtime_providerr3  rC   rX   rD   r   r   r(  )r.  r  r/  r0  normalized_providernormalized_modelnormalized_base_urlprovider_snapshotmodel_snapshotr3  runtime_kwargssnapsnap_providers                r   !_compute_provider_model_snapshotsr@    sc    7x@@3E::6!   H~~ z'+$(N"
	%LLLLLL)40N" J6I23++==n==D 4 4 :;;AACCIIKKM - 5 	% 	% 	% $	%	"<>>F$NN 	" 	" 	"!NNN	"n,,s%   A%B. .B=<B=C C#"C#c                    t          |                     d                    t          |                     d                    t          |                     d          d          t          |                     d                    fS )zBReturn the stored inference-routing fields in their semantic form.r.  r  r/  Tr)  r0  )r-  rX   r6  )rU   s    r   _normalized_inference_axesrB  H  so     	%SWWZ%8%899$SWWW%5%566$SWWZ%8%8tTTTSWWZ  !!	 r   ri   rj   repeatdeliveroriginrk   context_fromenabled_toolsetsattach_to_sessionc                    t          |          }||dk    rd}|d         dk    r|d}||rdnd}t          j                    j        dd         }t	                                                      }t          ||          }t          |          }t          |	          }t          |
d	
          }t          |t                    r!t          |          
                                nd}|pd}|rd |D             nd}|pd}t          |          }t          |          }t          |t                    r|nd}|r|st          d          t          |t                    r,|
                                r|
                                gnd}n&t          |t                    rd |D             pd}nd}t          |           }ddlm}  |||           |p|r|d         ndp|r|ndpd}t%          ||||          \  } }!i d|d|p|dd         
                                d|d|d|r|d         ndd|d|d| d|!d|d|d|d|d|d |                    d!|          d"|dd#d$d	d%dd|t)          |          dddd||||d&}"|||"d'<   t+                      5  t-                      }#|#                    |"           t1          |#           ddd           n# 1 swxY w Y   |"S )(uX  
    Create a new cron job.

    Args:
        prompt: The prompt to run (must be self-contained, or a task instruction when skill is set).
                Ignored when ``no_agent=True`` except as an optional name hint.
        schedule: Schedule string (see parse_schedule)
        name: Optional friendly name
        repeat: How many times to run (None = forever, 1 = once)
        deliver: Where to deliver output ("origin", "local", "telegram", etc.)
        origin: Source info where job was created (for "origin" delivery)
        skill: Optional legacy single skill name to load before running the prompt
        skills: Optional ordered list of skills to load before running the prompt
        model: Optional per-job model override
        provider: Optional per-job provider override
        base_url: Optional per-job base URL override
        script: Optional path to a script whose stdout feeds the job. With
                ``no_agent=True`` the script IS the job — its stdout is
                delivered verbatim. Without ``no_agent``, its stdout is
                injected into the agent's prompt as context (data-collection /
                change-detection pattern). Paths resolve under
                ~/.hermes/scripts/; ``.sh`` / ``.bash`` files run via bash,
                anything else via Python.
        context_from: Optional job ID (or list of job IDs) whose most recent output
                      is injected into the prompt as context before each run.
                      Useful for chaining cron jobs: job A finds data, job B processes it.
        enabled_toolsets: Optional list of toolset names to restrict the agent to.
                          When set, only tools from these toolsets are loaded, reducing
                          token overhead. When omitted, all default tools are loaded.
                          Ignored when ``no_agent=True``.
        workdir: Optional absolute path.  When set, the job runs as if launched
                from that directory: AGENTS.md / CLAUDE.md / .cursorrules from
                that directory are injected into the system prompt, and the
                terminal/file/code_exec tools use it as their working directory
                (via TERMINAL_CWD).  When unset, the old behaviour is preserved
                (no context files injected, tools use the scheduler's cwd).
                With ``no_agent=True``, ``workdir`` is still applied as the
                script's cwd so relative paths inside the script behave
                predictably.
        no_agent: When True, skip the agent entirely — run ``script`` on schedule
                and deliver its stdout directly. Empty stdout = silent (no
                delivery). Requires ``script`` to be set. Ideal for classic
                watchdogs and periodic alerts that don't need LLM reasoning.

    Returns:
        The created job dict
    Nr   r   r   r    rE  local   Tr)  c                     g | ]D}t          |                                          #t          |                                          ES r   rC   rD   )r   ts     r   
<listcomp>zcreate_job.<locals>.<listcomp>  s9    VVVas1vv||~~V3q66<<>>VVVr   ud   no_agent=True requires a script — with no agent and no script there is nothing for the job to run.c                     g | ]D}t          |                                          #t          |                                          ES r   rM  r   js     r   rO  zcreate_job.<locals>.<listcomp>  s9    OOO1AOAOOOr   )check_gateway_lifecyclerl   r.  r  r/  r0  r;   rj   rm   ri   rL   rK   r  r.  r;  r<  r/  rk   r0  rF  r`   r_   ra   rC  )times	completedro   rp   )rn   	paused_atpaused_reason
created_atnext_run_atr   last_status
last_errorlast_delivery_errorrD  rE  rG  r  rH  )r   uuiduuid4hexr   r   rT   r-  rN   rC   rD   r  r6  rE   rO   r]   cron.lifecycle_guardrS  r@  rX   r   r:   r  rP   r  )$ri   r`   rj   rC  rD  rE  rK   rL   r  r.  r/  rk   rF  rG  r  r0  rH  parsed_scheduler<   r   normalized_skillsr9  r8  r:  normalized_scriptnormalized_toolsetsnormalized_workdirnormalized_no_agentnormalized_attachprompt_textrS  rr   r;  r<  rU   r   s$                                       r   
create_jobrj  R  s   D %X..O fkk v&((V^ $1(('Z\\crc"F
--
!
!
#
#C-eV<<3E::6x@@6xVZ[[[/9&#/F/FPF))+++D)1TZjtVV3CVVVVpt-5+G44x..-78I4-P-PZ))VZ
  
#4 
3
 
 	
 ,$$ 1=1C1C1E1EO**,,--4	L$	'	' OOOOOWSW"6**K =<<<<<K):;;;  Q<M$W$5a$8$8SW  Q  sF  ^P]n]n  LP  `  V`L(I$$$	) ) )%~&f&1SbS)//11& 	+& 	#	&
 	):D"1%%& 	!& 	'& 	.& 	.& 	'& 	#& 	'&  	!&" 	O#&$ 	O//	8DD%&& 	
 
'&. 	4/&0 '88#/%K& & &CT $#4 	  {{C$              
 Js   #3K""K&)K&c                 f    t                      }|D ]}|d         | k    rt          |          c S  dS )zGet a job by ID.r;   N)r  rs   )r<   r   rU   s      r   get_jobrl  
  sG    ;;D . .t9(----- 4r   c                   H     e Zd ZdZdedeeeef                  f fdZ xZ	S )AmbiguousJobReferencez1Raised when a job name matches more than one job.refmatchesc           	          || _         || _        d                    d |D                       }t                                          d| dt          |           d| d           d S )N, c              3   &   K   | ]}|d          V  dS )r;   Nr   )r   r   s     r   r   z1AmbiguousJobReference.__init__.<locals>.<genexpr>  s&      11A$111111r   z
Job name 'u   ' is ambiguous — matches z jobs: z. Use the job ID instead.)ro  rp  joinsuper__init__r   )selfro  rp  ids	__class__s       r   rv  zAmbiguousJobReference.__init__  s    ii1111111' ' 'W ' 'c ' ' '	
 	
 	
 	
 	
r   )
r  
__module____qualname____doc__rC   r	   r   r
   rv  __classcell__)ry  s   @r   rn  rn    s^        ;;
C 
$tCH~*> 
 
 
 
 
 
 
 
 
 
r   rn  ro  c                 <   | sdS t                      }|D ]}|d         | k    rt          |          c S  |                                 fd|D             }|sdS t          |          dk    rt	          | d |D                       t          |d                   S )aU  Resolve a job reference (ID or name) to a job record.

    - Exact ID match wins (works even if a different job's name equals this ID).
    - Otherwise, case-insensitive name match.
    - If a name matches more than one job, raises AmbiguousJobReference so the
      caller can surface the matching IDs rather than silently picking one.
    Nr;   c                 n    g | ]1}|                     d           pd                                k    /|2S )rj   r>   )rX   r   )r   rR  	ref_lowers     r   rO  z#resolve_job_ref.<locals>.<listcomp>/  s>    RRR!f(;'B'B'D'D	'Q'QA'Q'Q'Qr   r    c                 ,    g | ]}t          |          S r   rs   rQ  s     r   rO  z#resolve_job_ref.<locals>.<listcomp>4  s!    AAAq'**AAAr   r   )r  rs   r   r   rn  )ro  r   rU   name_matchesr  s       @r   resolve_job_refr     s      t;;D . .t9(----- 		IRRRRtRRRL t
<1#AALAAA
 
 	
 !a111r   include_disabledc                 R    d t                      D             }| sd |D             }|S )z2List all jobs, optionally including disabled ones.c                 ,    g | ]}t          |          S r   r  rQ  s     r   rO  zlist_jobs.<locals>.<listcomp>;  s!    :::!!$$:::r   c                 >    g | ]}|                     d d          |S )ro   T)rX   rQ  s     r   rO  zlist_jobs.<locals>.<listcomp>=  s+    :::a155D#9#9::::r   )r  )r  r   s     r   	list_jobsr  9  s9    ::ikk:::D ;::4:::Kr   updatesc                 @   t                               |pi           }|r2t          dd                    t	          |                               t                      5  t                      }t          |          D ]\  }}|d         | k    rd|v r$|d         }|dv rd|d<   nt          |          |d<   t          |          }t          i ||          }d|v }	t          h d                    |                    ot          |          |k    }
d	|v sd
|v rJt          |                    d
          |                    d	                    }||d	<   |r|d         nd|d
<   |	r|d         }t          |t                    rt!          |          }||d<   |                    d|                    d|                    d                              |d<   |                    d          dk    rt#          |          |d<   |
rlt%          |                    d          |                    d          |                    d          |                    d                    \  }}||d<   ||d<   |                    dd          rF|                    d          dk    r-|                    d          st#          |d                   |d<   |||<   t'          |           t)          ||                   c cddd           S 	 ddd           n# 1 swxY w Y   dS )zCUpdate a job by ID, refreshing derived schedule fields when needed.z%Cron job field(s) cannot be updated: rr  r;   r  >   FNr>   Nr`   >   r  r/  r0  r.  rL   rK   r   r_   ra   rn   rq   rZ  r.  r  r/  r0  rT  r;  r<  ro   T)_IMMUTABLE_JOB_FIELDSintersectionrE   rt  sortedr:   r  	enumerater  rB  rY   r6  rT   rX   rN   rC   r   r   r@  r  rs   )r<   r  
bad_fieldsr   irU   _wdprevious_inference_axesupdatedschedule_changedinference_fields_changedrc  updated_scheduler;  r<  s                  r   
update_jobr  A  s   
 '33GMrBBJ 
SDIIfZ>P>P4Q4QSS
 
 	
 
 92 92{{oo 7	2 7	2FAs4yF"" G##i(+++)-GI&&);C)@)@GI&&@&E&E#)*<S*<G*<==G)W4'+===JJ7SS( ( (Q,W559PP % 7""g&8&8$9'++g:N:NPWP[P[\dPePe$f$f!$5!;L#V#4Q#7#7RV  P#*:#6  .44 ;'56F'G'G$*:GJ'.5kk&$((GKK@R4S4STT/ /*+ ;;w''833-=>N-O-OGM*' ;4U$[[44!++g..$[[44$[[44	5 5 51!> 0A+,,:(){{9d++ OG0D0D0P0PY`YdYderYsYs0P)9'*:M)N)N&DGdOOO(a1111s92 92 92 92 92 92 92 927	292 92 92 92 92 92 92 92 92 92 92 92 92 92 92t 4s   JLLLreasonc                     t          |           }|sdS t          |d         ddt                                                      |d          S )z:Pause a job without deleting it. Accepts a job ID or name.Nr;   Frq   )ro   rn   rW  rX  r  r  r   r   )r<   r  rU   s      r   	pause_jobr    s[    
&
!
!C tD	$0022#		
 	
  r   c           	          t          |           }|sdS t          |d                   }t          |d         dddd|d          S )zWResume a paused job and compute the next future run from now. Accepts a job ID or name.Nr`   r;   Trp   ro   rn   rW  rX  rZ  )r  r   r  )r<   rU   rZ  s      r   
resume_jobr    s_    
&
!
!C t"3z?33KD	 !&	
 	
	 	 	r   c           	          t          |           }|sdS t          |d         ddddt                                                      d          S )zKSchedule a job to run on the next scheduler tick. Accepts a job ID or name.Nr;   Trp   r  r  )r<   rU   s     r   trigger_jobr    s^    
&
!
!C tD	 !&==2244	
 	
	 	 	r   c                    t          |           }|sdS |d         t                      5  t                      }t          |          }fd|D             }t          |          |k     rTt	                    }t          |           |                                rt          j        |           	 ddd           dS 	 ddd           n# 1 swxY w Y   dS )zRemove a job by ID or name.Fr;   c                 ,    g | ]}|d          k    |S )r;   r   )r   rR  canonical_ids     r   rO  zremove_job.<locals>.<listcomp>  s'    ;;;a1T7l#:#:#:#:#:r   NT)	r  r:   r  r   rJ   r  r|   shutilrmtree)r<   rU   r   original_lenjob_output_dirr  s        @r   
remove_jobr    s8   
&
!
!C ut9L	  {{4yy;;;;4;;;t99|## -\::NdOOO$$&& .n---        $	               5s   BCCCr  delivery_errorc           
         t                      5  t                      }t          |          D ]\  }}|d         | k    rt                                                      }||d<   |rdnd|d<   |s|nd|d<   ||d<   d|d	<   |                    d
          r|d
         }|                    d          }	|                    dd          }
|                    di                               d          }|dk    o|	duo|	dk    o|
dk    }|s
|
dz  }
|
|d<   |	>|	dk    r8|
|	k    r2|                    |           t          |            ddd           dS t          |d         |          |d<   |d         |                    di                               d          }|dv rVd|d<   |                    d          sd|d<   t          
                    d|                    d|d                   |           n)d|d<   d|d<   n|                    d          dk    rd|d<   t          |            ddd           dS t                              d|            ddd           dS # 1 swxY w Y   dS )uK  
    Mark a job as having been run.
    
    Updates last_run_at, last_status, increments completed count,
    computes next_run_at, and auto-deletes if repeat limit reached.

    ``delivery_error`` is tracked separately from the agent error — a job
    can succeed (agent produced output) but fail delivery (platform down).
    r;   r   okr  r[  Nr\  r]  
fire_claimrC  rU  rV  r   r`   r   r   r    rZ  >   r   r   rn   ztFailed to compute next run for recurring schedule (is the 'croniter' package installed in the gateway's Python env?)zyJob '%s' (%s) could not compute next_run_at; leaving enabled and marking state=error so the job is not silently disabled.rj   Fro   rq   rp   z0mark_job_run: job_id %s not found, skipping save)r:   r  r  r   r   rX   popr  r   r4   r  r5   )r<   r   r  r  r   r  rU   r   rC  rU  rV  r   preclaimed_oneshots                r   mark_job_runr    s    
 LS LS{{oo H	 H	FAs4yF""!mm--//%(M"-4%ATT'M"18$BEEdL!-;)* %)L! 778$$  ]F"JJw//E &

; : :I77:r2266v>>D *!-*!AI* &M	 ' . 8!Q	.7{+ (UQYY9;M;M!$OLS LS LS LS LS LS LS LST &6c*os%K%KM" }%-77:r2266v>>D333'.G"ww|44 !J  -
 <  GGFCI66     */I'2GWWW%%11#.CL$ULS LS LS LS LS LS LS LS #R 	I6RRRYLS LS LS LS LS LS LS LS LS LS LS LS LS LS LS LS LS LSs   D8I(CI(>I((I,/I,c           
         t                      5  t                      }t          |          D ]\  }}|d         | k    r|                    di                               d          dk    r ddd           dS |                    d          }|s ddd           dS |                    d          }||d	k    r ddd           dS |                    d
d	          }||k    ri|                    |           t          |           t                              d|                    d|d                   ||            ddd           dS |dz   |d
<   t          |           t                              d|                    d|d                   |d
         |            ddd           dS t                              d|            	 ddd           dS # 1 swxY w Y   dS )up  Atomically claim a finite one-shot job dispatch BEFORE execution.

    Increments ``repeat.completed`` under the cross-process jobs lock and
    persists the claim immediately, so that if the tick dies mid-execution
    (gateway kill, OOM, segfault, hard-timeout) the dispatch is not lost.
    This converts finite one-shot jobs from *at-least-once* to *at-most-times*
    semantics — a job that self-destructs fires at most ``repeat.times`` times
    instead of infinitely (issue #38758).

    Returns ``True`` if the caller may proceed to run the job, ``False`` if the
    dispatch limit is already reached (in which case the stale job is removed).

    Only claims jobs with ``schedule.kind == "once"`` and ``repeat.times > 0``.
    Recurring jobs (they use ``advance_next_run``) and infinite-repeat / no-repeat
    jobs are left unchanged and always allowed to proceed.
    r;   r`   r   r   NTrC  rU  r   rV  u5   Job '%s': dispatch limit reached (%d/%d) — removingrj   Fr    z Job '%s': claimed dispatch %d/%du|   claim_dispatch: job_id %s not in store — proceeding without claim (handed-in job dict; nothing to persist a claim against))	r:   r  r  rX   r  r  r4   infodebug)r<   r   r  rU   rC  rU  rV  s          r   claim_dispatchr  .  s   " 
 + +{{oo "	 "	FAs4yF""wwz2&&**622f<<+ + + + + + + + WWX&&F + + + + + + + + JJw''E}

+ + + + + + + + 

;22IE!! $KGGFCI..	   5+ + + + + + + +8 #,a-F;dOOOLL2D	**{#	   I+ + + + + + + +L 	G	
 	
 	

 W+ + + + + + + + + + + + + + + + + +s2   AG;G GA8GAG2GG"Gc                    t                      5  t                      }|D ]}|d         | k    r|                    di                               d          }|dvr ddd           dS t                                                      }t          |d         |          }|r;||                    d          k    r"||d<   t          |            ddd           dS  ddd           dS 	 ddd           dS # 1 swxY w Y   dS )	u  Preemptively advance next_run_at for a recurring job before execution.

    Call this BEFORE run_job() so that if the process crashes mid-execution,
    the job won't re-fire on the next gateway restart.  This converts the
    scheduler from at-least-once to at-most-once for recurring jobs — missing
    one run is far better than firing dozens of times in a crash loop.

    One-shot jobs are left unchanged so they can still retry on restart.

    Returns True if next_run_at was advanced, False otherwise.
    r;   r`   r   >   r   r   NFrZ  T)r:   r  rX   r   r   r   r  )r<   r   rU   r   r   new_nexts         r   advance_next_runr  m  s    
  {{ 	 	C4yF""wwz2..226::333         "mm--//+C
OSAA  CGGM,B,B B B)1C&dOOO                #                  s%   AC9)A&C9C9*C99C= C=c                      t          j        dd                                          } | r| S 	 ddl}|                                }n# t
          $ r d}Y nw xY w| dt          j                     S )zStable-ish identifier for claim attribution/debugging (NOT correctness).

    Uses ``HERMES_MACHINE_ID`` if set, else hostname + pid. The CAS correctness
    comes from the file lock + the fresh-claim check, not from this value.
    HERMES_MACHINE_IDr>   r   Nrh   :)rv   getenvrD   socketgethostnamer   getpid)explicitr  hosts      r   _machine_idr    s     y,b117799H !!##   ""RY[["""s   A AAi,  )claim_ttl_secondsr  c                \   t                      5  t                      }|D ]q}|d         | k    r|                    dd          r|                    d          dk    r ddd           dS t                      }|                    d          }|rb	 t	          t          j        |d	                             }||z
                                  |k     r ddd           dS n# t          $ r Y nw xY w|	                                t                      d
|d<   |                    di                               d          }|dv r/t          |d         |	                                          }|r||d<   t          |            ddd           dS 	 ddd           dS # 1 swxY w Y   dS )uT  Atomically claim a job for a single external 'fire' (multi-machine
    at-most-once). Returns True iff THIS caller won the claim.

    Used by the external-provider fire path (``CronScheduler.fire_due``) when an
    external scheduler (Chronos) signals a job is due across N gateway replicas:
    exactly one wins. Single-machine deployments always win.

    Under the file lock: reject if the job is missing/disabled/paused. If a
    fresh claim (younger than ``claim_ttl_seconds``) already exists, lose.
    Otherwise stamp a ``fire_claim`` and, for recurring jobs, advance
    ``next_run_at`` (mirrors ``advance_next_run``'s at-most-once bump so a stale
    re-delivery for the old time can't re-fire). One-shots keep ``next_run_at``
    but the fresh ``fire_claim`` blocks a duplicate retry for the same fire.
    ``mark_job_run`` clears the claim on completion so a re-armed recurring job
    is claimable again next fire.

    The stale-claim TTL means a machine that crashed after claiming but before
    completing doesn't wedge the job forever — after the TTL another fire can
    reclaim it.
    r;   ro   Trn   rq   NFr  at)r  byr`   r   >   r   r   rZ  )r:   r  rX   r   r   r   r   r   r   r   r  r   r  )	r<   r  r   rU   r   existing
claimed_atr   nxts	            r   claim_job_for_firer    sY   * 
  {{ 	 	C4yF""779d++ swww/?/?8/K/K        --Cww|,,H !.x/Ehtn/U/U!V!VJj(7799<MMM$        N    D'*}}kmm L LC77:r**..v66D+++&s:HH -),C&dOOO/       0 1                 sJ   AF!-%F!AC$F!#F!$
C1.F!0C11BF!F!!F%(F%c                  n    t                      5  t                      cddd           S # 1 swxY w Y   dS )u  Get all jobs that are due to run now.

    For recurring jobs (cron/interval), if the scheduled time is stale (more
    than one period in the past, e.g. because the gateway was down OR because a
    long-running previous execution overran the interval), the accumulated
    missed runs are collapsed — ``next_run_at`` is fast-forwarded to the next
    future occurrence so a backlog does NOT burst-fire on restart — but the job
    still fires ONCE now. This prevents the perpetual-defer loop (#33315) where
    a job whose runtime exceeds ``interval + grace`` would be skipped forever.

    Note: firing once on catch-up flows through ``mark_job_run``, so a job with
    a ``repeat.times`` limit consumes one of its runs on that catch-up fire.
    N)r:   _get_due_jobs_lockedr   r   r   get_due_jobsr    s{     
 & &#%%& & & & & & & & & & & & & & & & & &s   *..c            	         t                      } t                      }d t          j        |          D             }g }d}|D ]}|                    dd          s|                    d          }|s|                    di           }|                    d          }t          || |                    d          	          }	|	rd
nd}
|	s*|dv r&t          ||                                           }	|	r|}
|	s|	|d<   |	}t          	                    d|                    d|d                   |
|	           |D ]}|d         |d         k    r	|	|d<   d} nt          j        |          }|                    di           }|                    d          }t          |          }|dk    r|| k    rt          ||           rt          ||           rt          ||                                           }|r~t          	                    d|                    d|d                   |                                |                                 |           |D ]}|d         |d         k    r	||d<   d} n0|| k    r~t!          |          }|dv r| |z
                                  |k    r|t          ||                                           }|rXt          	                    d|                    d|d                   |||           |D ]}|d         |d         k    r	||d<   d} n|dk    r|                    d          }|r|                    d          }|                    dd          }|u|dk    ro||k    rit          	                    d|                    d|d                   ||           |D ]-}|d         |d         k    r|                    |           d} n.|                    |           |rt)          |           |S )zLInner implementation of get_due_jobs(); must be called with _jobs_lock held.c                 ,    g | ]}t          |          S r   )rY   rQ  s     r   rO  z(_get_due_jobs_locked.<locals>.<listcomp>  s!    DDDq""DDDr   Fro   TrZ  r`   r   r   r   zone-shotN>   r   r   z4Job '%s' had no next_run_at; recovering %s run at %srj   r;   r   zlJob '%s' next_run_at offset changed (%s -> %s). Recomputing cron run to preserve local wall-clock intent: %sz~Job '%s' missed its scheduled time (%s, grace=%ds). Running now; next run provisionally set to: %s (re-anchored on completion)r   rC  rU  rV  r   uN   Job '%s': one-shot dispatch limit reached (%d/%d) — removing stale due entry)r   r  copydeepcopyrX   r   r   r   r4   r  r   r   r   r   r   r   r   r   removerP   r  )r   raw_jobsr   due
needs_saverU   r   r`   r   recovered_nextrecovery_kindrjraw_next_run_dtnext_run_dtr  r   rC  rU  rV  s                      r   r  r    s   
--C{{HDDDM(,C,CDDDD
CJ T Twwy$'' 	77=)) %	wwz2..H<<''D 9GGM22  N
 +9BJJdM " )d.B&B&B!1(CMMOO!L!L! )$(M! !/C%HKKFD	**	     d8s4y(((6B}%!%JE )
 #0::77:r**||F###O44" FNNs"")/3?? #,_cBB # (#--//BBH SGGFCI..#--//MMOO   #  B$x3t9,,,4=)%)
 - #
 +844E+++{1B0Q0Q0S0SV[0[0[ ,HcmmooFF "KK6 D	22    " ' " "d8s4y0008B}-)-J!E 1 v~~** !"JJw//E &

; : :I(UQYY9;M;M;GGFCI66%!   #+ & &B!$x3t944 ( 3 3 3-1
 %  5 !JJsOOO (Jr   rm   c                      	 ddl m}   |             pi }t          |t                    r|                    di           ni }t          |                    dt                              S # t          $ r
 t          cY S w xY w)zVResolve the per-job output-file retention cap from config (``cron.output_retention``).r   )load_configr   output_retention)r!  r  rN   rW   rX   r   _CRON_OUTPUT_DEFAULT_KEEPr   )r  r&  cron_cfgs      r   _cron_output_keepr    s    )111111kmm!r*4S$*?*?G37762&&&R8<< 24MNNOOO ) ) )(((()s   A&A) )A=<A=r  keepc                 ^   |dk    rdS 	 t          d |                     d          D             d d          }n# t          $ r Y dS w xY wd}||d         D ]T}	 |                                 |dz  }# t          $ r+}t                              d	|j        |           Y d}~Md}~ww xY w|S )
a  Remove the oldest ``*.md`` run-output files beyond *keep*. Returns count deleted.

    Mirrors the quick-snapshot retention in ``hermes_cli.backup._prune_quick_snapshots``:
    output filenames are timestamp-based (``%Y-%m-%d_%H-%M-%S.md``) so a reverse
    lexical sort orders newest-first, and everything past *keep* is the tail to
    drop. A non-positive *keep* disables pruning. Pruning failures are swallowed
    so they can never break output saving.
    r   c              3   B   K   | ]}|                                 |V  d S N)is_file)r   r   s     r   r   z$_prune_job_output.<locals>.<genexpr>  s/      CC1qyy{{CQCCCCCCr   z*.mdc                     | j         S r  )rj   )r   s    r   <lambda>z#_prune_job_output.<locals>.<lambda>  s    !& r   T)re   reverseNr    z"Failed to prune cron output %s: %s)r  globr2   r   r4   r  rj   )r  r  filesdeletedstaleexcs         r   _prune_job_outputr    s    qyyqCC++F33CCC  
 
 

    qqGtuu P P	PLLNNNqLGG 	P 	P 	PLL=uz3OOOOOOOO	PNs'   0; 
A	A	A55
B*?!B%%B*c                    t                       t          |           }|                    dd           t          |           t	                                          d          }|| dz  }t          j        t          |          dd          \  }}	 t          j
        |dd	
          5 }|                    |           |                                 t          j        |                                           ddd           n# 1 swxY w Y   t          ||           t!          |           n5# t"          $ r( 	 t          j        |           n# t&          $ r Y nw xY w w xY wt)          |t+                                 |S )zSave job output to file.Tr   z%Y-%m-%d_%H-%M-%Sz.mdr   z.output_r   r   r!   r"   N)r*   rJ   r   rz   r   r   r   r   rC   rv   r   r   r   r   r1   r   r}   r   r   r2   r  r  )r<   r   r  	timestampoutput_filer   r   r   s           r   save_job_outputr    s   MMM$V,,N555&&':;;I i#4#4#44K#N(;(;FS]^^^LBYr3111 	!QGGFOOOGGIIIHQXXZZ   	! 	! 	! 	! 	! 	! 	! 	! 	! 	! 	! 	! 	! 	! 	! 	x---[!!!!   	Ih 	 	 	D	 n&7&9&9:::sU   D3 ,AD<D3 DD3 D"D3 3
E%>EE%
E E%E  E%c                     	 t                      } n:# t          $ r- t                              dd           t	                      cY S w xY wt	                      }| D ]}t          |t                    st          |                    d          |                    d                    D ]M}t          |          
                                                    d          }|r|                    |           N|S )u>  Return the set of skill names referenced by ANY cron job.

    Includes paused and disabled jobs deliberately: a paused job never
    fires, so its skills never get a ``bump_use`` from the scheduler, yet
    resuming it must still find its skills present. The curator uses this
    set to protect referenced skills from inactivity archival — a skill a
    live job depends on is "in use" regardless of when it was last loaded.

    Best-effort: a corrupt/unreadable jobs store returns an empty set
    rather than raising, so a cron issue can never break the curator.
    z0referenced_skill_names: failed to load cron jobsT)exc_inforK   rL   rA   )r  r   r4   r  setrN   rW   rT   rX   rC   rD   lstripadd)r   namesrU   rj   cleaneds        r   referenced_skill_namesr    s    {{   GRVWWWuu eeE # ##t$$ 	)#'''*:*:CGGH<M<MNN 	# 	#D$iioo''..s33G #		'"""	# Ls    4AAconsolidatedprunedc                 6   t          | pi           } t          |pg           }|t          |                                           z  }| s|sg dddS t                      5  t	                      }g }d}|D ]3}t          |                    d          |                    d                    }|s<i }g }	g }
|D ]b}|| v r)| |         }|||<   |r||
vr|
                    |           /||v r|	                    |           I||
vr|
                    |           c|s|	s|
|d<   |
r|
d         nd|d<   d}|                    |                    d          |                    d	          p|                    d          t          |          t          |
          ||	d
           5|r7t          |           t                              dt          |                     |t          |          t          |          dcddd           S # 1 swxY w Y   dS )u	  Rewrite cron job skill references after a curator consolidation pass.

    When the curator consolidates a skill X into umbrella Y (or archives X
    as pruned), any cron job that lists ``X`` in its ``skills`` field will
    fail to load ``X`` at run time — the scheduler logs a warning and
    skips the skill, so the job runs without the instructions it was
    scheduled to follow. See cron/scheduler.py where ``skill_view`` is
    called per skill name.

    This function repairs cron jobs in-place:

    - A skill listed in ``consolidated`` is replaced with its umbrella
      target (the ``into`` value). If the umbrella is already in the
      job's skill list, the stale name is dropped without duplication.
    - A skill listed in ``pruned`` is dropped outright — there is no
      forwarding target.
    - Ordering and other skills in the list are preserved.
    - The legacy ``skill`` field is realigned via ``_apply_skill_fields``.

    Args:
        consolidated: mapping of ``old_skill_name -> umbrella_skill_name``.
        pruned: list of skill names that were archived with no forwarding
            target.

    Returns a report dict::

        {
            "rewrites": [
                {
                    "job_id": ...,
                    "job_name": ...,
                    "before": [...],
                    "after": [...],
                    "mapped": {"old": "new", ...},
                    "dropped": ["old", ...],
                },
                ...
            ],
            "jobs_updated": N,
            "jobs_scanned": M,
        }

    Best-effort: exceptions from loading/saving propagate to the caller so
    tests can assert behaviour; the curator invocation site wraps this
    call in a try/except so a failure here never breaks the curator.
    r   )rewritesjobs_updatedjobs_scannedFrK   rL   NTr;   rj   )r<   job_namebeforeaftermappeddroppedz2Curator rewrote skill references in %d cron job(s))rW   r  keysr:   r  rT   rX   rP   rO   r  r4   r  r   )r  r  
pruned_setr   r  changedrU   skills_beforer  r  
new_skillsrj   targets                r   rewrite_skill_refsr    s   d *++LV\r""J #l''))***J F
 F1EEE	 3
 3
{{)+ "	 "	C1#'''2B2BCGGHDUDUVVM  %'F!#G$&J% 	, 	,<'')$/F#)F4L 2&
":":"))&111Z''NN4((((++%%d+++ ' &CM,6@:a==DCLGOO''$--GGFOO<swwt}}}--j)) "       	dOOOKKDc(mm  
 !MMII
 
_3
 3
 3
 3
 3
 3
 3
 3
 3
 3
 3
 3
 3
 3
 3
 3
 3
 3
s   F"HHHr2  )r>   r  )F)NNNNNNNNNNNNNFN)tr|  
contextlibr  r   loggingr  r   	threadingr   rv   r   r^  r-   ImportErrorr0   r   r   pathlibr   hermes_constantsr   typingr   r   r	   r
   r   r   r   	getLoggerr  r4   hermes_timer   r   utilsr   r   r   r  
HERMES_DIRr   r   r   r   TICKER_INTERVAL_SECONDSRLockr)   rJ  r(   rH   r   r   contextmanagerr:   	frozensetr  rC   rJ   rT   rY   r]   rf   rs   rz   r}   r*   r   r   r   r   r6  r   r   r   rW   r   r   r   r   r   r   r   r   r  r  r  r  r(  r-  r@  rB  rj  rl  LookupErrorrn  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r   r   r   <module>r     sY                  				 				 LLLL   EEEMMMM   FFF ( ( ( ( ( ( ( (       , , , , , , ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ?		8	$	$ * * * * * *            !!!!!!LL   LLL& _&&((
{"	 !#55  !66 
  
 ")/##"9?$$  
 # # # # #
 ;' ;' ;'B "	4&)) C D      # x} X\]`Xa    "T#s(^ S#X     C 3     4S> c    " tCH~  $sCx.        Fd    t      %c %c % % % %*aS aT#s(^ a a a aH$h $8 $ $ $ $&	5h 	5 	5d 	5 	5 	5 	5
F 
FH 
F 
F 
F 
F 
F" "&	  38n	 #	
 c]   6T c    @+ +tCH~ +HSM +U]^aUb + + + +dd t    . T d    .$ 8E?    2(5/ 2 2 2 20 0 0 0 0-4S#X' - - - -`d4S>2    &"Dc3h( " " " " (3-    B$# $ $ $ $N NS    d W_`cWd    *-*- *- 	*-
 *- 8C=(3-'(*- *- *- *-ZDcN uXc]HUXM[cdg[hjn=n7o      !'+"&"" 48,0!(,#u uSMuu 3-u SM	u
 c]u T#s(^$u C=u T#Yu C=u smu smu SMu 5d3i01u tCy)u c]u  !u"  ~#u$ 
#s(^%u u u upC HT#s(^4    

 

 

 

 

K 

 

 

2 2$sCx.!9 2 2 2 22  d38n1E    Es ET#s(^ Ec3h8P E E E EP c 8C= HT#s(^<T     s xS#X7    & c3h 8    "s t    . EI15WS WS WSt WSHSM WS!)#WS WS WS WSt<3 <4 < < < <~S T    :#S # # # #" AD - - -s -# - - - - -`&d4S>* & & & &$ad4S>2 a a a aR  )3 ) ) ) )d # #    :C     FC    < .2"&n
 n
4S>*n
T#Yn
 
#s(^n
 n
 n
 n
 n
 n
s0   5 ??A AAB! !B+*B+