
    i7              #          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	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  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j$                    Z%e"dz  Z&dZ'dLdee(         dee         dee(         fdZ)dee(ef         dee(ef         fdZ*defdZ+defdZ,d Z-de(de.fdZ/de(dee(ef         fdZ0de	de	fdZ1dd dee(ef         d!e	d"ee(         dee(         fd#Z2de3de.fd$Z4dMdee(ef         d"ee(         dee(         fd%Z5deee(ef                  fd&Z6d'eee(ef                  fd(Z7d)ee(         dee(         fd*Z8	 	 	 	 	 	 	 	 	 	 	 	 	 dNd+e(de(d,ee(         d-ee.         d.ee(         d/eee(ef                  dee(         deee(                  d0ee(         d1ee(         d2ee(         d3ee(         d4eee(ee(         f                  d5eee(                  d)ee(         dee(ef         f d6Z9d7e(deee(ef                  fd8Z:dOd9e;deee(ef                  fd:Z<d7e(d;ee(ef         deee(ef                  fd<Z=dMd7e(d=ee(         deee(ef                  fd>Z>d7e(deee(ef                  fd?Z?d7e(deee(ef                  fd@Z@d7e(de;fdAZA	 	 dLd7e(dBe;dCee(         dDee(         fdEZBd7e(de;fdFZCdeee(ef                  fdGZDd7e(de(fdHZE	 	 dLdIeee(e(f                  dJeee(                  dee(ef         fdKZFdS )Pz
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Union)now)atomic_replace)croniterTFcronz	jobs.jsonoutputx   skillskillsreturnc                     || 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.N )
isinstancestrliststripappend)r   r   	raw_items
normalizeditemtexts         ./home/ubuntu/.hermes/hermes-agent/cron/jobs.py_normalize_skill_listr!   0   s    ~$,UGG"			FC	 	  !H		LL	J $ $4:2$$&& 	$D
**d###    jobc                     t          |           }t          |                    d          |                    d                    }||d<   |r|d         nd|d<   |S )zLReturn a job dict with canonical `skills` and legacy `skill` fields aligned.r   r   r   N)dictr!   get)r#   r   r   s      r    _apply_skill_fieldsr'   A   s[    cJ":>>'#:#:JNN8<T<TUUF!Jx'-7&))4Jw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chmodOSErrorNotImplementedErrorr(   s    r    _secure_dirr/   J   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)existsr*   r+   r,   r-   r.   s    r    _secure_filer2   R   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)CRON_DIRmkdir
OUTPUT_DIRr/    r"   r    ensure_dirsr;   [   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   <   i  )mhd)r   lowerrematch
ValueErrorintgroup)r<   rF   valueunitmultiplierss        r    parse_durationrM   g   s     	
		AH\^_``E [YqYYYZZZAE;;q>>!D..K;t$$$r"   schedulec                 `   |                                  } | }|                                 }|                    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        |                                }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intervalrA   )kindminutesdisplay   c              3   @   K   | ]}t          j        d |          V  dS )z^[\d\*\-,/]+$N)rE   rF   ).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   )rR   exprrT   Tz^\d{4}-\d{2}-\d{2}Zz+00:00oncezonce at z%Y-%m-%d %H:%M)rR   run_atrT   zInvalid timestamp 'rS   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))r   rD   
startswithrM   splitlenallHAS_CRONITERrG   r   	ExceptionrE   rF   r   fromisoformatreplacetzinfo
astimezone	isoformatstrftime_hermes_nowr   )	rN   originalschedule_lowerduration_strrS   partsedtr^   s	            r    parse_schedulers   |   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 ]]__,,..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+A0F 
G &F;;G AH 
HHrr   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.
    N)rh   )rl   rh   r   r   ri   rg   )rr   	target_tzlocal_tzs      r    _ensure_awarerw      sf     $I	y<>>,,..5zzz**55i@@@==###r"   last_run_atr   ry   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.
    rR   r]   Nr^   )seconds)r&   rw   r   rf   r   ONESHOT_GRACE_SECONDS)rN   r   ry   r^   	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   rR   rQ   rS   r>   r@   r?   r   rZ   )r&   maxminrd   rl   r   get_nextr   rH   total_secondsre   )
rN   	MIN_GRACE	MAX_GRACErR   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.
    rR   r]   rx   rQ   rS   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.rZ   N)rl   r~   rw   r   rf   r   rj   rd   loggerwarningr&   r   r   )rN   ry   r   rS   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                  h   t                       t                                          sg S 	 t          t          dd          5 } t	          j        |           }|                    dg           cddd           S # 1 swxY w Y   dS # t          j        $ r 	 t          t          dd          5 } t	          j        | 	                                d          }|                    dg           }|r)t          |           t                              d           |cddd           cY S # 1 swxY w Y   Y dS # t          $ r3}t                              d	|           t          d
|           |d}~ww xY wt           $ r3}t                              d|           t          d|           |d}~ww xY w)zLoad all jobs from storage.rutf-8encodingjobsNF)strictz8Auto-repaired jobs.json (had invalid control characters)z#Failed to auto-repair jobs.json: %sz*Cron database corrupted and unrepairable: zIOError reading jobs.json: %szFailed to read cron database: )r;   	JOBS_FILEr1   openjsonloadr&   JSONDecodeErrorloadsread	save_jobsr   r   re   errorRuntimeErrorIOError)fdatar   rq   s       r    	load_jobsr   U  sc   MMM 	H)S7333 	(q9Q<<D88FB''	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	( 	(  X X X	Xiw777 1z!&&((5999xx++ _dOOONN#]^^^                      	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s   B *A9,B 9A==B  A=B F1D5-A+D'D5$F1'D+	+D5.D+	/D55
E2?.E--E22F1>.F,,F1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)zSave all jobs to storage..tmpz.jobs_dirsuffixprefixwr   r   )r   
updated_atr?   )indentN)r;   tempfilemkstempr   r   parentr*   fdopenr   dumprl   rj   flushfsyncfilenor   r2   BaseExceptionunlinkr,   )r   fdtmp_pathr   s       r    r   r   r  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?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: )	r   r   r   
expanduseris_absoluterG   resolver1   is_dir)r   raw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"   promptnamerepeatdeliveroriginmodelproviderbase_urlscriptcontext_fromenabled_toolsetsc                    t          |          }||dk    rd}|d         dk    r|d}||rdnd}t          j                    j        dd         }t	                                                      }t          ||          }t          |t                    r!t          |          	                                nd}t          |	t                    r!t          |	          	                                nd}t          |
t                    r4t          |
          	                                
                    d	          nd}|pd}|pd}|pd}t          |t                    r!t          |          	                                nd}|pd}|rd
 |D             nd}|pd}t          |          }t          |t                    r,|	                                r|	                                gnd}n&t          |t                    rd |D             pd}nd}| p|r|d         ndpd}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#
}t                      }|                    |           t#          |           |S )$a  
    Create a new cron job.

    Args:
        prompt: The prompt to run (must be self-contained, or a task instruction when skill is set)
        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 Python script whose stdout is injected into the
                prompt each run.  The script runs before the agent turn, and its output
                is prepended as context.  Useful for data collection / change detection.
        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.
        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).

    Returns:
        The created job dict
    Nr   rR   r]   r>   r   local   /c                     g | ]D}t          |                                          #t          |                                          ES r:   r   r   )rW   ts     r    
<listcomp>zcreate_job.<locals>.<listcomp>  s9    VVVas1vv||~~V3q66<<>>VVVr"   c                     g | ]D}t          |                                          #t          |                                          ES r:   r   rW   js     r    r   zcreate_job.<locals>.<listcomp>  s9    OOO1AOAOOOr"   zcron jobidr   2   r   r   r   r   r   r   r   r   rN   schedule_displayrT   r   )times	completedenabledTstate	scheduled	paused_atpaused_reason)

created_atnext_run_atry   last_status
last_errorlast_delivery_errorr   r   r   r   )rs   uuiduuid4hexrl   rj   r!   r   r   r   rstripr   r   r&   r   r   r   r   )r   rN   r   r   r   r   r   r   r   r   r   r   r   r   r   parsed_schedulejob_idr   normalized_skillsnormalized_modelnormalized_providernormalized_base_urlnormalized_scriptnormalized_toolsetsnormalized_workdirlabel_sourcer#   r   s                               r    
create_jobr     s   f %X..O fkk v&((V^ $1(('Z\\crc"F
--
!
!
#
#C-eV<<-7s-C-CMs5zz'')))3=h3L3LV#h----///RV?I(TW?X?Xb#h----//66s;;;^b'/4-5-5/9&#/F/FPF))+++D)1TZjtVV3CVVVVpt-5+G44 ,$$ 1=1C1C1E1EO**,,--4	L$	'	' OOOOOWSWS7HR033dbXbL f 1SbS)//11  	&  	#	 
 	):D"1%%  	!  	'  	'  	#  	  	O  	O//	8DD  	
 
 " 	4# $ 	% & 	T' ( 	) * '88#/%?     CD ;;DKKdOOOJr"   r   c                 f    t                      }|D ]}|d         | k    rt          |          c S  dS )zGet a job by ID.r   N)r   r'   )r   r   r#   s      r    get_jobr   )  sG    ;;D , ,t9&s+++++ 4r"   include_disabledc                 R    d t                      D             }| sd |D             }|S )z2List all jobs, optionally including disabled ones.c                 ,    g | ]}t          |          S r:   r'   r   s     r    r   zlist_jobs.<locals>.<listcomp>4  s!    888q""888r"   c                 >    g | ]}|                     d d          |S )r   T)r&   r   s     r    r   zlist_jobs.<locals>.<listcomp>6  s+    :::a155D#9#9::::r"   )r   )r   r   s     r    	list_jobsr  2  s9    88IKK888D ;::4:::Kr"   updatesc           
         t                      }t          |          D ]\  }}|d         | k    rd|v r$|d         }|dv rd|d<   nt          |          |d<   t          i ||          }d|v }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<   |                    dd          rF|                    d          dk    r-|                    d          st          |d                   |d<   |||<   t          |           t          ||                   c S dS )zCUpdate a job by ID, refreshing derived schedule fields when needed.r   r   )Nr   FNrN   r   r   r   r   rT   r   pausedr   r   T)r   	enumerater   r'   r!   r&   r   r   rs   r   r   )
r   r  r   ir#   _wdupdatedschedule_changedr   updated_schedules
             r    
update_jobr  :  s1   ;;DD// ), ),3t9 )$C'''%)	""%7%<%<	"%&8&8&899%0w'W"4"4 5gkk'6J6JGKKX`LaLa b b 1GH7HR033dGG 	L&z2 *C00 7#12B#C#C &6
#*1++" $$Y<N0O0OPP+ +G&' {{7##x//)9:J)K)K&;;y$'' 	KGKK,@,@H,L,LU\U`U`anUoUo,L%5gj6I%J%JGM"Q$"47+++++4r"   reasonc                 h    t          | ddt                                                      |d          S )z Pause a job without deleting it.Fr  )r   r   r   r   )r  rl   rj   )r   r  s     r    	pause_jobr  j  s=    $0022#		
 	
  r"   c           	      ~    t          |           }|sdS t          |d                   }t          | dddd|d          S )z=Resume a paused job and compute the next future run from now.NrN   Tr   r   r   r   r   r   )r   r   r  )r   r#   r   s      r    
resume_jobr  w  sY    
&//C t"3z?33K !&	
 	
	 	 	r"   c           	          t          |           }|sdS t          | ddddt                                                      d          S )z1Schedule a job to run on the next scheduler tick.NTr   r  )r   r  rl   rj   )r   r#   s     r    trigger_jobr    sX    
&//C t !&==2244	
 	
	 	 	r"   c                      t                      }t          |          } fd|D             }t          |          |k     rt          |           dS dS )zRemove a job by ID.c                 ,    g | ]}|d          k    |S )r   r:   )rW   r   r   s     r    r   zremove_job.<locals>.<listcomp>  s'    111!qw&00A000r"   TF)r   rb   r   )r   r   original_lens   `  r    
remove_jobr    sV    ;;Dt99L1111t111D
4yy<$t5r"   successr   delivery_errorc           
      x   t           5  t                      }t          |          D ]\  }}|d         | k    rt                                                      }||d<   |rdnd|d<   |s|nd|d<   ||d<   |                    d	          r|d	                             d
d          dz   |d	         d
<   |d	                             d          }|d	         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   ry   okr   r   Nr   r   r   r   r   r>   r   rN   r   rR   r   rQ   r   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.r   Fr   r  r   z0mark_job_run: job_id %s not found, skipping save)_jobs_file_lockr   r  rl   rj   r&   popr   r   r   r   r   )r   r  r   r  r   r  r#   r   r   r   rR   s              r    mark_job_runr    s    
 ;S ;S{{oo 7	 7	FAs4yF""!mm--//%(M"-4%ATT'M"18$BEEdL!-;)* 778$$ 
14X1B1B;PQ1R1RUV1VCM+.  M--g66E #Hk :I(UQYY9;M;M!$-;S ;S ;S ;S ;S ;S ;S ;S2 &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$s;S ;S ;S ;S ;S ;S ;S ;S #p 	I6RRRw;S ;S ;S ;S ;S ;S ;S ;S ;S ;S ;S ;S ;S ;S ;S ;S ;S ;Ss   DH/CH/H//H36H3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   rN   rR   r  NFr   T)r  r   r&   rl   rj   r   r   )r   r   r#   rR   r   new_nexts         r    advance_next_runr"    s    
  {{ 	 	C4yF""wwz2..226::333         "mm--//+C
OSAA  CGGM,B,B B B)1C&dOOO                #                  s%   AC2"A&C2C2#C22C69C6c            	      f   t                      } t                      }d t          j        |          D             }g }d}|D ]}|                    dd          s|                    d          }|st          |                    di           | |                    d                    }|sm||d<   |}t                              d	|                    d
|d                   |           |D ]}|d         |d         k    r	||d<   d} nt          t          j
        |                    }	|	| k    r|                    di           }
|
                    d          }t          |
          }|dv r| |	z
                                  |k    r~t          |
|                                           }|rZt                              d|                    d
|d                   |||           |D ]}|d         |d         k    r	||d<   d} n|                    |           |rt!          |           |S )aO  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),
    the job is fast-forwarded to the next future run instead of firing
    immediately.  This prevents a burst of missed jobs on gateway restart.
    c                 ,    g | ]}t          |          S r:   r   r   s     r    r   z get_due_jobs.<locals>.<listcomp>  s!    DDDq""DDDr"   Fr   Tr   rN   ry   rx   z:Job '%s' had no next_run_at; recovering one-shot run at %sr   r   rR   r  zSJob '%s' missed its scheduled time (%s, grace=%ds). Fast-forwarding to next run: %s)rl   r   copydeepcopyr&   r~   r   inforw   r   rf   r   r   r   rj   r   r   )r   raw_jobsr   due
needs_saver#   r   recovered_nextrjnext_run_dtrN   rR   r   r!  s                 r    get_due_jobsr.    s    --C{{HDDDM(,C,CDDDD
CJ 9 9wwy$'' 	77=)) 	8
B''GGM22  N
 " !/C%HKKLD	**  
   d8s4y(((6B}%!%JE )
 $H$:8$D$DEE#wwz2..H<<''D
 +844E+++{1B0Q0Q0S0SV[0[0[ ,HcmmooFF KK:D	22     ' " "d8s4y0008B}-)-J!E 1 JJsOOO (Jr"   c                    t                       t          | z  }|                    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|S )zSave job output to file.Tr4   z%Y-%m-%d_%H-%M-%Sz.mdr   z.output_r   r   r   r   N)r;   r9   r8   r/   rl   rk   r   r   r   r*   r   writer   r   r   r   r2   r   r   r,   )r   r   job_output_dir	timestampoutput_filer   r   r   s           r    save_job_outputr4  [  s   MMM&(N555&&':;;I i#4#4#44K#N(;(;FS]^^^LBYr3111 	!QGGFOOOGGIIIHQXXZZ   	! 	! 	! 	! 	! 	! 	! 	! 	! 	! 	! 	! 	! 	! 	! 	x---[!!!!   	Ih 	 	 	D	 sU   D. 'AD7D. DD. 
D"D. .
E 9EE 
EE EE consolidatedprunedc                 (   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_scannedFr   r   NTr   r   )r   job_namebeforeaftermappeddroppedz2Curator rewrote skill references in %d cron job(s))r%   setkeysr  r   r!   r&   r   r   r   r   r'  rb   )r5  r6  
pruned_setr   r8  changedr#   skills_beforer>  r?  
new_skillsr   targets                r    rewrite_skill_refsrG  {  s   d *++LV\r""J #l''))***J F
 F1EEE	 4
 4
{{)+ #	 #	C1#'''2B2BCGGHDUDUVVM  %'F!#G$&J% 
0 
0<'')$/F#)F4L 2&
":":"))&111Z''NN4((((:--"))$/// ' &CM,6@:a==DCLGOO''$--GGFOO<swwt}}}--j)) "       	dOOOKKDc(mm  
 !MMII
 
a4
 4
 4
 4
 4
 4
 4
 4
 4
 4
 4
 4
 4
 4
 4
 4
 4
 4
s   F"HHH)NN)N)NNNNNNNNNNNNN)F)G__doc__r%  r   loggingr   	threadingr*   rE   r   r   r   pathlibr   hermes_constantsr   typingr   r   r	   r
   r   	getLogger__name__r   hermes_timer   rl   utilsr   r   rd   ImportErrorr   
HERMES_DIRr7   r   Lockr  r9   r|   r   r!   r'   r/   r2   r;   rH   rM   rs   rw   r~   r%   r   r   r   r   r   r   r   boolr  r  r  r  r  r  r  r"  r.  r4  rG  r:   r"   r    <module>rV     s            				 				  ( ( ( ( ( ( ( (       , , , , , , 3 3 3 3 3 3 3 3 3 3 3 3 3 3		8	$	$ * * * * * *            !!!!!!LL   LLL _&&((
{"	
 !)."" 
  # x} X\]`Xa    "T#s(^ S#X    d    t      %c %c % % % %*VS VT#s(^ V V V Vr$h $8 $ $ $ $. "&	  38n	 #	
 c]   6T c    @+ +tCH~ +HSM +U]^aUb + + + +dH4S#X' H H H H:Dc3h(    & (3-    H  !'+"&"" 48,0!@ @@@ 3-@ SM	@
 c]@ T#s(^$@ C=@ T#Y@ C=@ sm@ sm@ SM@ 5d3i01@ tCy)@ c]@  
#s(^!@ @ @ @FC HT#s(^4      d38n1E    -s -T#s(^ -c3h8P - - - -`
 
c 
8C= 
HT#s(^<T 
 
 
 
s xS#X7    & c3h 8    "s t     EI15FS FS FSt FSHSM FS!)#FS FS FS FSRS T    :Ld4S>* L L L L^C     B .2"&o
 o
4S>*o
T#Yo
 
#s(^o
 o
 o
 o
 o
 o
s   "A+ +A54A5