-
-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathscheduler.py
More file actions
5247 lines (4715 loc) · 227 KB
/
Copy pathscheduler.py
File metadata and controls
5247 lines (4715 loc) · 227 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
Cron job scheduler - executes due jobs.
Provides tick() which checks for due jobs and runs them. The gateway
calls this every 60 seconds from a background thread.
Uses a file-based lock (~/.hermes/cron/.tick.lock) so only one tick
runs at a time if multiple processes overlap.
"""
import asyncio
import atexit
import concurrent.futures
import contextvars
import json
import logging
import os
import re
import shutil
import subprocess
import sys
import threading
import time
import traceback
# fcntl is Unix-only; on Windows use msvcrt for file locking
try:
import fcntl
except ImportError:
fcntl = None
try:
import msvcrt
except ImportError:
msvcrt = None
from pathlib import Path
from typing import Dict, List, Optional
# Add parent directory to path for imports BEFORE repo-level imports.
# Without this, standalone invocations (e.g. after `hermes update` reloads
# the module) fail with ModuleNotFoundError for hermes_time et al.
sys.path.insert(0, str(Path(__file__).parent.parent))
import hermes_telemetry
from hermes_constants import get_hermes_home
from hermes_cli._subprocess_compat import windows_hide_flags
from hermes_cli.config import load_config, _expand_env_vars
from hermes_cli.fallback_config import get_fallback_chain
from hermes_time import now as _hermes_now
logger = logging.getLogger(__name__)
def _set_cron_session_title(session_db, session_id, base_title):
"""Robustly title a finished cron session before it is closed.
Centralizes the title write so the cron finally block can guarantee a
non-blank, unique title is persisted before end_session()/close() tear
the connection down (issues #50535, #50536, #50537):
- #50535: never leaves the session blank. base_title already carries a
cron-id fallback for nameless jobs; this also guards a failed write.
- #50537: a duplicate title makes set_session_title raise ValueError (the
unique-title index). Recover by appending a #N suffix via
get_next_title_in_lineage() when supported, instead of swallowing the
error and ending up untitled. If lineage dedup is unavailable, raise.
- #50536: this runs synchronously in the cron finally block ahead of the
session close, so no in-flight title write can race the close.
Returns the title actually persisted, or None if nothing could be set.
"""
if not session_db or not session_id:
return None
title = (base_title or "").strip()
if not title:
return None
try:
session_db.set_session_title(session_id, title)
return title
except ValueError:
# Title collision against the unique-title index. Fall back to the
# next title in the lineage (base #2, base #3, ...) when supported.
next_title_fn = getattr(session_db, "get_next_title_in_lineage", None)
if next_title_fn is None:
raise
deduped = next_title_fn(title)
if not deduped or deduped == title:
raise
session_db.set_session_title(session_id, deduped)
return deduped
def _summarize_cron_failure_for_delivery(
job: dict, error: str | None, failure_category: str | None = None
) -> str:
"""Return a compact one-line failure message for chat delivery.
Full details stay in the cron output directory and the logs. Chat should
show the operator what broke without dumping provider JSON, retry noise, or
stack traces into the delivery channel.
"""
job_name = job.get("name") or job.get("id") or "cron job"
text = (error or "unknown error").strip()
lower = text.lower()
category_tag = f" [{failure_category}]" if failure_category else ""
# Provider/API failures are the common noisy path. Keep these short.
if "429" in text or "rate limit" in lower or "usage limit" in lower:
reason = "rate limit"
if "weekly usage limit" in lower:
reason = "weekly usage limit"
elif "quota" in lower:
reason = "quota limit"
return (
f"⚠️ Cron '{job_name}' failed: provider {reason}{category_tag}. "
"Fallback chain was exhausted or unavailable. "
"Full details saved in cron output / cron/failures."
)
if "readtimeout" in lower or "timed out" in lower or "timeout" in lower:
return (
f"⚠️ Cron '{job_name}' failed: provider timeout{category_tag}. "
"Fallback chain was exhausted or unavailable. "
"Full details saved in cron output / cron/failures."
)
# Match authentication/authorization wording at a word boundary and the
# 401/403 status codes as whole tokens, so "oauth", "4015" and similar do
# not trip a misleading auth message.
if re.search(r"authenticat|authoriz", lower) or re.search(r"\b(401|403)\b", text):
return (
f"⚠️ Cron '{job_name}' failed: provider authentication error. "
"Full details saved in cron output / cron/failures."
)
# Strip common exception wrappers and collapse provider payloads. Bound
# the input first so a multi-KB provider blob cannot slow the
# substitutions.
cleaned = re.sub(
r"^(RuntimeError|Exception|ValueError|HTTPStatusError):\s*",
"",
text[:2000],
)
cleaned = re.sub(r"\s+", " ", cleaned).strip()
if len(cleaned) > 180:
cleaned = cleaned[:177].rstrip() + "..."
return f"⚠️ Cron '{job_name}' failed: {cleaned}"
class CronPromptInjectionBlocked(Exception):
"""Raised by _build_job_prompt when the fully-assembled prompt trips the
injection scanner. Caught in run_job so the operator sees a clean
"job blocked" delivery instead of the scheduler crashing.
Assembled-prompt scanning (including loaded skill content) plugs the
gap from #3968: create-time scanning only covers the user-supplied
prompt field; skill content loaded at runtime was never scanned, so a
malicious skill could carry an injection payload that reached the
non-interactive (auto-approve) cron agent.
"""
def _resolve_cron_disabled_toolsets(cfg: dict) -> list[str]:
"""Toolsets a cron-spawned agent must never receive.
Three protected toolsets are always disabled in cron context:
- ``cronjob`` — would let a cron-spawned agent schedule more cron jobs
- ``messaging`` — interactive, needs a live gateway session
- ``clarify`` — interactive, blocks waiting for user input
User-level ``agent.disabled_toolsets`` from config.yaml is layered on top
so per-job ``enabled_toolsets`` cannot bypass policy that applies to
ordinary agent runs (#25752 — LLM-supplied enabled_toolsets was widening
past config.yaml's denylist).
"""
disabled = ["cronjob", "messaging", "clarify"]
agent_cfg = (cfg or {}).get("agent") or {}
user_disabled = agent_cfg.get("disabled_toolsets") or []
for name in user_disabled:
name = str(name).strip()
if name and name not in disabled:
disabled.append(name)
return disabled
def _merge_mcp_into_per_job_toolsets(per_job: list[str], cfg: dict) -> list[str]:
"""Layer enabled MCP servers onto a per-job ``enabled_toolsets`` allowlist.
A per-job list scopes the *native* toolsets, but on its own it silently
drops every MCP server: ``discover_mcp_tools()`` registers the tools into
the global registry, yet ``get_tool_definitions(enabled_toolsets=...)``
only keeps toolsets named in the list. The agent then rejects every
``mcp_*`` call with "Unknown tool". This restores parity with
``_get_platform_tools`` MCP semantics:
* ``no_mcp`` sentinel present -> no MCP servers (sentinel stripped)
* one or more MCP server names already listed -> treat as an allowlist,
add nothing further (the user named exactly the servers they want)
* otherwise -> union in every globally-enabled MCP server
"""
result = [t for t in per_job if t != "no_mcp"]
if "no_mcp" in per_job:
return result
# lazy import: avoid heavy hermes_cli import at cron module load (matches
# _resolve_cron_enabled_toolsets' fallback) and share one MCP-membership
# computation with the gateway/CLI platform resolver.
from hermes_cli.tools_config import enabled_mcp_server_names
enabled_mcp = enabled_mcp_server_names(cfg)
if set(result) & enabled_mcp:
return result
for name in sorted(enabled_mcp):
if name not in result:
result.append(name)
return result
def _resolve_cron_enabled_toolsets(job: dict, cfg: dict) -> list[str] | None:
"""Resolve the toolset list for a cron job.
Precedence:
1. Per-job ``enabled_toolsets`` (set via ``cronjob`` tool on create/update).
Keeps the agent's job-scoped toolset override intact — #6130. Enabled
MCP servers are layered on per ``_merge_mcp_into_per_job_toolsets`` so a
native-toolset allowlist does not silently strip MCP tools.
2. Per-platform ``hermes tools`` config for the ``cron`` platform.
Mirrors gateway behavior (``_get_platform_tools(cfg, platform_key)``)
so users can gate cron toolsets globally without recreating every job.
3. ``cron.minimal_toolsets`` (bool, default False). When True, cron agents
receive a reduced toolset (#evolution — minimal-toolset profiles for
cron personas). This shrinks the tool schema sent on every API call,
reducing token overhead and latency for non-interactive cron runs.
4. ``None`` on any lookup failure — AIAgent loads the full default set
(legacy behavior before this change, preserved as the safety net).
_DEFAULT_OFF_TOOLSETS ({moa, homeassistant, rl}) are removed by
``_get_platform_tools`` for unconfigured platforms, so fresh installs
get cron WITHOUT ``moa`` by default (issue reported by Norbert —
surprise $4.63 run).
"""
# Minimal toolset for cron personas: only tools useful for automated,
# non-interactive research/analysis. Excludes interactive (clarify,
# cronjob), visual (browser, vision, image/video gen), and platform-
# specific (discord, spotify, homeassistant, computer_use) tools.
_CRON_MINIMAL_TOOLSETS = frozenset({
"web", # web_search, web_extract
"terminal", # terminal, process
"file", # read_file, write_file, patch, search_files
"code_execution", # execute_code
"skills", # skills_list, skill_view, skill_manage
"todo", # todo
"memory", # memory
"session_search", # session_search
"delegation", # delegate_task
})
per_job = job.get("enabled_toolsets")
if per_job:
return _merge_mcp_into_per_job_toolsets(list(per_job), cfg or {})
cron_cfg = cfg.get("cron", {}) if isinstance(cfg, dict) else {}
if cron_cfg.get("minimal_toolsets"):
return sorted(_CRON_MINIMAL_TOOLSETS)
try:
from hermes_cli.tools_config import (
_get_platform_tools,
) # lazy: avoid heavy import at cron module load
return sorted(_get_platform_tools(cfg or {}, "cron"))
except Exception as exc:
logger.warning(
"Cron toolset resolution failed, falling back to full default toolset: %s",
exc,
)
return None
# Valid delivery platforms — used to validate user-supplied platform names
# in cron delivery targets, preventing env var enumeration via crafted names.
_KNOWN_DELIVERY_PLATFORMS = frozenset({
"telegram",
"discord",
"slack",
"whatsapp",
"signal",
"matrix",
"mattermost",
"homeassistant",
"dingtalk",
"feishu",
"wecom",
"wecom_callback",
"weixin",
"sms",
"email",
"webhook",
"bluebubbles",
"qqbot",
"yuanbao",
})
# Platforms that support a configured cron/notification home target, mapped to
# the environment variable used by gateway setup/runtime config.
_HOME_TARGET_ENV_VARS = {
"matrix": "MATRIX_HOME_ROOM",
"telegram": "TELEGRAM_HOME_CHANNEL",
"discord": "DISCORD_HOME_CHANNEL",
"slack": "SLACK_HOME_CHANNEL",
"signal": "SIGNAL_HOME_CHANNEL",
"mattermost": "MATTERMOST_HOME_CHANNEL",
"sms": "SMS_HOME_CHANNEL",
"email": "EMAIL_HOME_ADDRESS",
"dingtalk": "DINGTALK_HOME_CHANNEL",
"feishu": "FEISHU_HOME_CHANNEL",
"wecom": "WECOM_HOME_CHANNEL",
"weixin": "WEIXIN_HOME_CHANNEL",
"bluebubbles": "BLUEBUBBLES_HOME_CHANNEL",
"qqbot": "QQBOT_HOME_CHANNEL",
"whatsapp": "WHATSAPP_HOME_CHANNEL",
"whatsapp_cloud": "WHATSAPP_CLOUD_HOME_CHANNEL",
}
# Legacy env var names kept for back-compat. Each entry is the current
# primary env var → the previous name. _get_home_target_chat_id falls
# back to the legacy name if the primary is unset, so users who set the
# old name before the rename keep working until they migrate.
_LEGACY_HOME_TARGET_ENV_VARS = {
"QQBOT_HOME_CHANNEL": "QQ_HOME_CHANNEL",
}
from cron.jobs import (
get_due_jobs,
load_jobs,
mark_job_run,
mark_job_started,
save_jobs,
save_job_output,
save_job_failure,
list_job_failures,
get_latest_failure,
_jobs_lock,
advance_next_run,
claim_dispatch,
heartbeat_run_claim,
update_job,
DELIVERY_VERBOSITY_LEVELS,
)
from cron.executions import create_execution, finish_execution, mark_execution_running
# Sentinel: when a cron agent has nothing new to report, it can start its
# response with this marker to suppress delivery. Output is still saved
# locally for audit.
SILENT_MARKER = "[SILENT]"
# Canonical silence tokens recognized in cron output. Cron's contract is
# intentionally looser than the gateway's exact-whole-response rule: the cron
# system prompt *instructs* the agent to emit "[SILENT]", and real agents often
# bracket it with a short note or trailing newline. We therefore suppress when
# a marker is the entire response OR appears as its own first/last line — but
# NOT when a token merely appears mid-sentence in a genuine report (e.g.
# "I considered staying [SILENT] but here is the summary…" must deliver).
_CRON_SILENCE_TOKENS = frozenset({
"[SILENT]",
"SILENT",
"[СИЛЕНТ]",
"СИЛЕНТ",
"NO_REPLY",
"NO REPLY",
})
def _is_cron_silence_response(text: str) -> bool:
"""Return True when a cron final response should suppress delivery.
Recognizes the bracketed ``[SILENT]`` sentinel (whole-response, first line,
or last line) plus the bracketless ``SILENT`` / ``NO_REPLY`` / ``NO REPLY``
variants the model emits when it drops the brackets (#51438, #46917).
Whitespace-trimmed and case-insensitive. A token buried mid-sentence is
treated as real content and delivered.
"""
if not isinstance(text, str):
return False
stripped = text.strip()
if not stripped:
return False
def _is_token(line: str) -> bool:
normalized = " ".join(line.strip().upper().split())
# Models occasionally add spaces inside the brackets. Normalize only
# the bracketed token shape; preserve ordinary prose spacing.
normalized = re.sub(r"\[\s*(SILENT|СИЛЕНТ)\s*\]", r"[\1]", normalized)
return normalized in _CRON_SILENCE_TOKENS
# Whole response is exactly a token.
if _is_token(stripped):
return True
# Marker on its own first or last line (trailing/leading note on a
# separate line — e.g. "2 deals filtered\n\n[SILENT]").
lines = [ln for ln in stripped.splitlines() if ln.strip()]
if lines and (_is_token(lines[0]) or _is_token(lines[-1])):
return True
# Bracketed sentinel used as a same-line prefix — the documented cron
# pattern "[SILENT] No changes detected". Restricted to the bracketed
# form so a bare word like "Silent retry succeeded" is NOT swallowed.
upper = stripped.upper()
upper = re.sub(r"\[\s*(SILENT|СИЛЕНТ)\s*\]", r"[\1]", upper, count=1)
if upper.startswith("[SILENT]") or upper.startswith("[СИЛЕНТ]"):
return True
return False
# ---------------------------------------------------------------------------
# Persistent thread pool for parallel cron jobs.
# The tick function submits jobs here and returns immediately so the ticker
# thread is never blocked by long-running jobs (e.g. the fixer running 15+ min).
# ---------------------------------------------------------------------------
_parallel_pool: Optional[concurrent.futures.ThreadPoolExecutor] = None
_parallel_pool_max_workers: Optional[int] = None
_running_job_ids: set = set()
_running_lock = threading.Lock()
# Job IDs the gateway shutdown path force-killed the tool subprocess of
# while still in ``_running_job_ids`` (see ``mark_running_jobs_interrupted``
# below). ``run_one_job``'s own completion path checks this set before
# writing its own ``last_status`` so a cron agent thread that keeps running
# in-process after its tool was killed out from under it — and produces a
# plausible-looking final response from truncated output — can never
# overwrite the interrupted status with a false "ok" (#60432).
_interrupted_job_ids: set = set()
def get_running_job_ids() -> "frozenset[str]":
"""Thread-safe snapshot of cron job IDs currently executing.
A job ID is a member from the moment ``_submit_with_guard`` dispatches
it onto the parallel/sequential pool until ``_process_job`` returns —
i.e. for the job's *entire* run, tool calls included, not just the
ticker's dispatch instant.
The gateway shutdown path (``gateway/run.py::GatewayRunner.
_drain_active_agents``) reads this to treat in-flight cron work as
active the same way it already treats in-flight chat sessions via
``_running_agents`` — cron jobs run through their own thread pool here,
entirely outside that dict, so without this the drain is structurally
blind to them (#60432).
"""
with _running_lock:
return frozenset(_running_job_ids)
def mark_running_jobs_interrupted(reason: str) -> list:
"""Best-effort: mark every currently in-flight cron job interrupted.
Called by the gateway shutdown path immediately after it force-kills
tool subprocesses (``process_registry.kill_all()``). A job whose tool
subprocess was just killed out from under it must never be allowed to
report success — even though its agent thread is still alive in this
same process and may go on to produce a plausible-looking final
response from the now-truncated tool output.
Records the job IDs in ``_interrupted_job_ids`` BEFORE writing
``last_status`` so ``run_one_job``'s own eventual completion for the
same job (racing in its own thread) sees the flag and skips its normal
write instead of clobbering this one — see the check near the end of
``run_one_job``. This does not attempt to correlate the killed
subprocess PID to a specific job ID (the process registry tracks PIDs,
not cron job IDs); any job still dispatched at the moment of a forced
kill is treated as interrupted, matching the coarser precedent already
set by ``GatewayRunner._interrupt_running_agents``, which interrupts
every entry in ``_running_agents`` on a drain timeout without
per-agent correlation either.
Returns the list of job IDs marked, for the caller to log.
"""
with _running_lock:
job_ids = list(_running_job_ids)
_interrupted_job_ids.update(job_ids)
marked = []
for job_id in job_ids:
try:
mark_job_run(job_id, False, reason)
marked.append(job_id)
except Exception as e:
logger.warning("Failed to mark job %s interrupted: %s", job_id, e)
return marked
def _is_interrupted(job_id: str) -> bool:
"""Non-destructive peek at whether the shutdown path has marked
``job_id`` interrupted (see ``mark_running_jobs_interrupted``).
Called by ``run_one_job`` BEFORE it decides what to deliver — a job
whose tool subprocess was killed mid-flight may still produce a
plausible-looking ``final_response`` from the truncated output, and
that must not go out to the user as if it were a normal result.
Unlike ``_consume_interrupted_flag`` below, this does not clear the
flag: the later, authoritative check (right before ``last_status`` is
written) still needs to see it."""
with _running_lock:
return job_id in _interrupted_job_ids
def _consume_interrupted_flag(job_id: str) -> bool:
"""Return True and clear the flag if the shutdown path already marked
``job_id`` interrupted (see ``mark_running_jobs_interrupted``).
Called by ``run_one_job`` right before it would otherwise write its own
``last_status``. Consuming (discarding) rather than just checking keeps
the flag from leaking across a later, unrelated run of the same job ID
(recurring jobs reuse their ID every fire)."""
with _running_lock:
if job_id in _interrupted_job_ids:
_interrupted_job_ids.discard(job_id)
return True
return False
# Sequential (env-mutating) cron jobs — workdir jobs that touch
# process-global runtime state — must run one at a time, but must NOT block the
# ticker thread. A persistent single-thread executor preserves ordering across
# ticks while keeping dispatch fire-and-forget, the same as the parallel pool.
_sequential_pool: Optional[concurrent.futures.ThreadPoolExecutor] = None
class _ReadWriteLock:
"""Writer-preferring readers-writer lock.
Guards the process-global ``os.environ["TERMINAL_CWD"]`` override that a
workdir cron job applies for the whole of its agent run. Workdir jobs are
writers: they mutate the shared env and need exclusive access. Workdir-less
jobs are readers: they only observe ``TERMINAL_CWD`` (indirectly, via the
terminal / file / code-exec tools), so any number of them may run
concurrently with each other, but none may run alongside a writer — that is
exactly what stops a workdir-less job from picking up another job's workdir
override and running its commands in the wrong directory.
Writer preference bounds the wait for a workdir job (dispatched on the
single-thread sequential pool) so a stream of workdir-less readers cannot
starve it.
"""
def __init__(self) -> None:
self._cond = threading.Condition(threading.Lock())
self._readers = 0
self._writer_active = False
self._writers_waiting = 0
def acquire_read(self) -> None:
with self._cond:
while self._writer_active or self._writers_waiting > 0:
self._cond.wait()
self._readers += 1
def release_read(self) -> None:
with self._cond:
self._readers -= 1
if self._readers == 0:
self._cond.notify_all()
def acquire_write(self) -> None:
with self._cond:
self._writers_waiting += 1
try:
while self._writer_active or self._readers > 0:
self._cond.wait()
finally:
self._writers_waiting -= 1
self._writer_active = True
def release_write(self) -> None:
with self._cond:
self._writer_active = False
self._cond.notify_all()
# Serializes the per-job TERMINAL_CWD override against every other concurrently
# running cron job. See _ReadWriteLock and run_job for the usage contract.
_terminal_cwd_lock = _ReadWriteLock()
def _get_parallel_pool(max_workers: Optional[int]) -> concurrent.futures.ThreadPoolExecutor:
"""Return (or create) the persistent parallel pool."""
global _parallel_pool, _parallel_pool_max_workers
if _parallel_pool is None or _parallel_pool_max_workers != max_workers:
if _parallel_pool is not None:
_parallel_pool.shutdown(wait=False, cancel_futures=False)
_parallel_pool = concurrent.futures.ThreadPoolExecutor(
max_workers=max_workers,
thread_name_prefix="cron-parallel",
)
_parallel_pool_max_workers = max_workers
return _parallel_pool
def _get_sequential_pool() -> concurrent.futures.ThreadPoolExecutor:
"""Return (or create) the persistent single-thread sequential pool.
A single worker guarantees env-mutating jobs never overlap, even
across ticks: a job queued by a newer tick waits for the previous tick's
sequential jobs to finish rather than corrupting their os.environ
state.
"""
global _sequential_pool
if _sequential_pool is None:
_sequential_pool = concurrent.futures.ThreadPoolExecutor(
max_workers=1,
thread_name_prefix="cron-seq",
)
return _sequential_pool
def _shutdown_parallel_pool() -> None:
"""Shut down the persistent pools on process exit."""
global _parallel_pool, _parallel_pool_max_workers, _sequential_pool
if _parallel_pool is not None:
_parallel_pool.shutdown(wait=True, cancel_futures=False)
_parallel_pool = None
_parallel_pool_max_workers = None
if _sequential_pool is not None:
_sequential_pool.shutdown(wait=True, cancel_futures=False)
_sequential_pool = None
atexit.register(_shutdown_parallel_pool)
# Backward-compatible module override used by tests and emergency monkeypatches.
_hermes_home: Path | None = None
def _get_hermes_home() -> Path:
"""Resolve Hermes home dynamically while preserving test monkeypatch hooks.
Cron is per-profile by design (#4707): the in-process ticker runs inside a
profile-scoped gateway, so resolving the active HERMES_HOME at call time
means a profile's jobs are stored AND executed under that profile's home
(its .env, config.yaml, scripts, skills). Do not freeze this at import or
anchor it at the shared default root — either re-breaks profile isolation.
"""
return _hermes_home or get_hermes_home()
def _failure_digest_enabled(cfg: dict) -> bool:
"""Return whether ``cron.failure_digest`` is enabled in config.yaml.
The digest surfaces recent cron failures to the user on the next
interaction. Default disabled (False); opt-in via config.yaml.
"""
try:
cron_cfg = cfg.get("cron", {}) if isinstance(cfg, dict) else {}
return bool(cron_cfg.get("failure_digest", False))
except Exception:
return False
def _load_cron_config() -> dict:
"""Load config.yaml, returning an empty dict on any failure."""
try:
from hermes_cli.config import load_config
return load_config() or {}
except Exception:
return {}
def build_cron_failure_digest(adapters=None, loop=None) -> Optional[str]:
"""Build a user-visible digest of recent cron failures.
Scans all jobs and emits a compact message for any job whose latest
failure record reports success=False and is newer than the job's last
acknowledged digest timestamp (stored in ``failure_digest_last_at``).
Updates that timestamp when a failure is included.
Returns the digest text, or None if there is nothing new to surface.
"""
cfg = _load_cron_config()
if not _failure_digest_enabled(cfg):
return None
import datetime as _dt
now = _hermes_now()
cutoff = now - _dt.timedelta(hours=24)
lines: List[str] = []
jobs = load_jobs()
for job in jobs:
if not job.get("enabled", True):
continue
record = get_latest_failure(job["id"])
if not record:
continue
if record.get("success") is True:
continue
try:
ts = _dt.datetime.fromisoformat(str(record.get("timestamp") or ""))
except (TypeError, ValueError):
continue
if ts < cutoff:
continue
last_ack = job.get("failure_digest_last_at")
if last_ack:
try:
last_ack_dt = _dt.datetime.fromisoformat(str(last_ack))
if ts <= last_ack_dt:
continue
except (TypeError, ValueError):
pass
job_name = record.get("job_name") or job.get("name") or job["id"]
err = (record.get("error") or "unknown error")[:120]
lines.append(f"• '{job_name}' failed at {ts.strftime('%Y-%m-%d %H:%M')}: {err}")
if not lines:
return None
digest = (
"⚠️ Cron failure digest (last 24h):\n"
+ "\n".join(lines)
+ "\n\nFull details: ~/.hermes/cron/failures/"
)
# Update ack timestamps so we don't repeat the same failures every turn.
try:
with _jobs_lock():
jobs = load_jobs()
now_iso = now.isoformat()
changed = False
for job in jobs:
record = get_latest_failure(job["id"])
if not record or record.get("success") is True:
continue
try:
ts = _dt.datetime.fromisoformat(str(record.get("timestamp") or ""))
except (TypeError, ValueError):
continue
if ts < cutoff:
continue
job["failure_digest_last_at"] = now_iso
changed = True
if changed:
save_jobs(jobs)
except Exception:
logger.debug("Could not update failure_digest_last_at", exc_info=True)
return digest
def _get_lock_paths() -> tuple[Path, Path]:
"""Resolve cron lock paths at call time so profile/env changes are honored."""
hermes_home = _get_hermes_home()
lock_dir = hermes_home / "cron"
return lock_dir, lock_dir / ".tick.lock"
def _resolve_origin(job: dict) -> Optional[dict]:
"""Extract origin info from a job, preserving any extra routing metadata.
Treats non-dict origins (free-form provenance strings, ints, lists from
migration scripts or hand-edited jobs.json) as missing instead of
crashing with ``AttributeError`` on ``origin.get(...)``. Without this
guard, a job tagged with e.g. ``"combined-digest-replaces-x-and-y"``
crashed every fire attempt with
``'str' object has no attribute 'get'`` — ``mark_job_run`` recorded the
failure, but the next tick re-loaded the same poisoned origin and
crashed identically until the field was patched manually (#18722).
"""
origin = job.get("origin")
if not isinstance(origin, dict):
return None
platform = origin.get("platform")
chat_id = origin.get("chat_id")
if platform and chat_id:
return origin
return None
def _cron_mirror_delivery_enabled(job: dict, cfg: Optional[dict] = None) -> bool:
"""Whether a cron delivery should also be mirrored into the target chat's
gateway session transcript.
Default OFF — preserves the historical isolation guarantee (cron deliveries
live only in the cron job's own session, never the target chat's history)
byte-for-byte for everyone who does not opt in.
Precedence (first decisive value wins):
1. Per-job ``attach_to_session`` (bool) — set via the ``cronjob`` tool,
lets one briefing job opt in without flipping global behaviour.
2. Global ``cron.mirror_delivery`` (bool) in config.yaml.
3. False.
When enabled, the cron's final output is appended to the target session as
an assistant turn via the existing ``gateway.mirror.mirror_to_session`` —
the same primitive ``send_message`` uses — so the next user reply in that
chat sees the brief in context (no "what is Task #2?" amnesia). This is
alternation- and cache-safe: the append lands at a turn boundary between
user turns, never mid-loop, and never mutates the cached system prompt.
"""
per_job = job.get("attach_to_session")
if isinstance(per_job, bool):
return per_job
try:
if cfg is None:
cfg = load_config() or {}
return bool((cfg.get("cron", {}) or {}).get("mirror_delivery", False))
except Exception:
return False
def _target_matches_origin(
origin: dict, platform_name: str, chat_id: str, thread_id: Optional[str]
) -> bool:
"""True when a delivery target is the job's own origin conversation.
Mirroring is scoped to the origin session by design (see
``_maybe_mirror_cron_delivery``). A job created from a live gateway chat
stamps that chat as ``origin`` (``cronjob_tools._origin_from_env``), and
that session is guaranteed to exist — it is the very conversation the user
was in when they scheduled the job. Fan-out targets (``deliver=all``,
explicit ``platform:chat_id`` to some *other* chat, or a home-channel
fallback for an origin-less API/script job) are deliberately NOT mirrored:
they are broadcasts, not a continuation of a conversation, and may point at
a chat the user never opened an agent session in.
This makes the historical "cold-start" worry a non-case: when the mirror
semantically applies (target == origin) the session always exists; when no
session exists, the target was never the origin conversation, so we simply
do not mirror.
"""
if not origin:
return False
if str(origin.get("platform", "")).lower() != str(platform_name).lower():
return False
if str(origin.get("chat_id", "")) != str(chat_id):
return False
# thread_id must match when the origin pins one (topic-scoped chats); a
# target that lost the thread_id is not the same conversation lane.
origin_thread = origin.get("thread_id")
if origin_thread is not None and str(origin_thread) != str(thread_id or ""):
return False
return True
def _maybe_mirror_cron_delivery(
job: dict,
platform_name: str,
chat_id: str,
mirror_text: str,
thread_id: Optional[str] = None,
user_id: Optional[str] = None,
*,
enabled: bool = False,
) -> None:
"""Best-effort mirror of a cron delivery into the origin chat's session.
No-op unless ``enabled`` (resolved once by the caller, and already scoped to
the origin target — see ``_target_matches_origin``). Reuses the shipped
``mirror_to_session`` so cron rides exactly the same path that interactive
``send_message`` mirroring already uses, including passing ``user_id`` so a
per-user-isolated group chat resolves to the exact member who scheduled the
job (parity with ``send_message``). All failures are swallowed — a delivery
that succeeded must never be reported as failed because the transcript
mirror hit a problem.
Because the caller only enables this for the target that equals the job's
origin conversation, the session is expected to exist (the job was born in
that session). A missing session therefore indicates an origin-less /
fan-out delivery that should not have been mirrored anyway, and is treated
as a silent no-op — never a synthetic session is created.
"""
if not enabled:
return
text = (mirror_text or "").strip()
if not text:
return
try:
from gateway.mirror import mirror_to_session
# Mirror as a USER turn with a labelled prefix, NOT an assistant turn.
# The brief is not the agent speaking; an assistant-role mirror lands as
# assistant→assistant after the agent's last turn and breaks strict
# alternation (issue #2221, the exact failure #2313 removed). A
# user-role turn collapses safely via repair_message_sequence's
# consecutive-user merge on every provider, and the prefix preserves the
# "this came from cron" context that the dropped SQLite mirror metadata
# would otherwise lose on replay.
ok = mirror_to_session(
platform_name,
str(chat_id),
f"[Cron delivery: {job.get('name') or job.get('id', 'cron')}]\n{text}",
source_label="cron",
thread_id=thread_id,
user_id=user_id,
role="user",
)
if ok:
logger.info(
"Job '%s': mirrored delivery into %s:%s session transcript",
job.get("id", "?"),
platform_name,
chat_id,
)
else:
logger.debug(
"Job '%s': delivery mirror skipped for %s:%s "
"(no matching gateway session — cold start)",
job.get("id", "?"),
platform_name,
chat_id,
)
except Exception as e:
logger.debug(
"Job '%s': delivery mirror failed for %s:%s: %s",
job.get("id", "?"),
platform_name,
chat_id,
e,
)
def _open_continuable_cron_thread(
job: dict,
adapter,
chat_id: str,
loop,
) -> Optional[str]:
"""Open a dedicated thread for a continuable cron job (thread-preferred).
Returns the new ``thread_id`` on success, or ``None`` when the platform has
no thread primitive (WhatsApp/Signal/SMS) or creation failed — the ``None``
return is the caller's signal to fall back to the origin-DM mirror, the same
open-thread-or-fallback shape as ``GatewayRunner._process_handoff``. Reuses
the shipped ``adapter.create_handoff_thread``; no new adapter surface.
"""
create_thread = getattr(adapter, "create_handoff_thread", None)
if not callable(create_thread) or loop is None:
return None
task_name = job.get("name") or job.get("id", "cron")
thread_name = f"Hermes — {task_name}"
try:
from agent.async_utils import safe_schedule_threadsafe
coro = create_thread(str(chat_id), thread_name)
future = safe_schedule_threadsafe(coro, loop) # type: ignore[arg-type]
if future is None:
return None
new_thread_id = future.result(timeout=30)
return str(new_thread_id) if new_thread_id else None
except Exception as e:
logger.debug(
"Job '%s': create_handoff_thread failed on %s — falling back to "
"DM-session mirror: %s",
job.get("id", "?"),
getattr(adapter, "name", "?"),
e,
)
return None
def _seed_cron_thread_session(
job: dict,
adapter,
platform_name: str,
chat_id: str,
thread_id: str,
mirror_text: str,
chat_name: Optional[str] = None,
) -> None:
"""Seed the freshly-opened cron thread's session with the brief.
Without this the brief is *visible* in the new thread but absent from any
transcript, so the user's first reply in-thread would hit a session with no
record of it ("what is Task #2?"). We create the thread-keyed session (the
same key the user's reply will resolve to — ``build_session_key`` keys
threads as participant-shared, so no ``user_id`` is needed) and append the
brief as an assistant turn via the shipped ``mirror_to_session``.
Mirrors ``GatewayRunner._process_handoff``'s seed step, but standalone:
cron reaches the live ``SessionStore`` through the adapter's
``_session_store`` handle rather than the gateway object. Best-effort — a
delivery that already succeeded is never failed by a seeding problem.
"""
text = (mirror_text or "").strip()
if not text:
return
try:
from gateway.config import Platform
from gateway.session import SessionSource
session_store = getattr(adapter, "_session_store", None)
if session_store is not None:
try:
platform_enum = Platform(platform_name.lower())
except (ValueError, KeyError):
platform_enum = None
if platform_enum is not None:
dest_source = SessionSource(
platform=platform_enum,
chat_id=str(chat_id),
chat_name=chat_name,