Files
hermes-agent/cron/AGENTS.md
mzyas d038b19927 fix(cron): run the interrupted-pull recovery before the external worker's ack
run_agent's import also re-execs the process when it restores a tree a killed
`hermes update` half-wrote (_early_recovery.relaunch_after_restore). The
external worker never loads hermes_cli.main, so that check first ran inside
run_one_job, after the ack: the re-exec found its payload deleted, refused the
execution, and the adopted run was left `unknown`, the same class as the
source-update relaunch. finish_worker_boot() now runs the recovery before the
payload is read, so its re-exec replays the whole worker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit b28779fd3bbaa1b92272448d17ccbb47f2794e0f)
2026-10-07 13:33:18 +05:30

12 KiB
Raw Blame History

cron/ (+ kanban) — scheduled jobs and the multi-agent work queue

Applies on top of the root AGENTS.md. Long-form: website/docs/developer-guide/cron-internals.md; user docs website/docs/user-guide/features/cron.md, kanban.md.

Cron

cron/jobs.py (job store) + cron/scheduler.py (tick loop; scheduler_*.py siblings). Agents schedule via the cronjob tool; users via hermes cron list|add|edit|pause|resume|run|remove or /cron. Schedules: duration ("30m", "2h", "1d"), "every" phrase ("every 2h", "every monday 9am"), 5-field cron ("0 9 * * *"), ISO one-shot ("2026-06-01T09:00:00Z"). Per-job fields: skills, model/provider overrides, script (pre-run data-collection script whose stdout is injected into the prompt; no_agent=True makes the script the whole job), context_from (chain job A's last output into job B's prompt), workdir (run with that directory's AGENTS.md/CLAUDE.md loaded), multi-platform delivery.

Hardening invariants — each guards a real failure; don't weaken without answering for it:

  • Inactivity watchdog on cron agent sessions (_cron_inactivity_seconds()): default 600s idle, HERMES_CRON_TIMEOUT overrides, 0 = unlimited. It is idle time, not wall-clock — a stalled session is hard-interrupted so it cannot monopolise the scheduler, while a long-but-active job is never cut off. Attached scripts (pre-run or no_agent) are bounded separately by the script timeout (_DEFAULT_SCRIPT_TIMEOUT, 3600s).
  • Catch-up window = half the period, clamped to 120s–2h; 120s grace for missed one-shots.
  • Every recurring occurrence is accounted for: tick() advances next_run_at BEFORE dispatch (at-most-once across a mid-run crash) and stamps pending_slot in the same save; a scan that finds the stamp with a dead owner restores the instant ONCE (cron/occurrences.py), the executions ledger's scheduled_instant blocks a second fire, cron.catch_up_missed: false skips past-grace misses with a logged reason. Never drop a slot silently (#107485).
  • Per-home tick lock <home>/cron/.tick.lock prevents duplicate ticks across processes for that profile's store; never a ~/.hermes/... literal.
  • The ticker binds each served profile's scope for the whole tick, including pre-loop code. scheduler_provider.py::_start_multiplex is ONE ticker iterating profiles_to_serve() sequentially under _profile_cron_scope(home) (home + secret scope + terminal scope) — never N threads (module globals race). Everything a tick touches lives inside that guard: store open, lock path, backoff/failure counters (_note_tick_failure), job env construction, and the on_session_end flush of a finished job. Supervision (scheduler_thread.py:: SupervisedTickerThread; start on gateway boot, stand down for homes another gateway already serves, re-enumerate when a profile dir appears or is tombstoned) is per served home, not per process. Why: a store opened before the scope was entered wrote a secondary profile's run records into the launch profile's jobs.json.
  • Cron ownership is not gated on gateway.multiplex_profiles. That flag gates ADAPTERS; one host gateway process ticks EVERY profile's store either way (run.py::_cron_tick_profile_homes). Gating the tick set on it left every non-launch profile's jobs in a store no ticker visited.
  • Per-profile process assumptions are the bug class. One process ticks N homes, so anything keyed on "this process's profile" is wrong: in-flight state (_running_job_ids, _running_since, _running_futures, _running_worker_pids, _running_fire_owners, _interrupted_job_ids) is keyed by _inflight_key(job_id) = (home key, job id) — two profiles legitimately carry a daily-brief; the parallel pool is keyed by home (cron.max_parallel_jobs is per profile); and the stale-code yield gate asks scheduler_ownership.owns_cron_tick_for(home) / live_gateway_ticking(home) instead of the process-global runtime-lock boolean. Public accessors (get_running_job_ids, get_running_job_details, get_wedged_job_ids) still report the host-wide union of bare job ids for the shutdown drain, but LIVENESS consumers (jobs.py::_job_running_in_this_process, tools/cronjob_tools) ask is_job_running(job_id, home=...) — the union made profile A's running daily-brief answer for profile B's idle one.
  • A claim is released under the key it was registered with. The cron scope is a ContextVar: try_register_running_job runs on the ticker thread inside _profile_cron_scope, while the pool worker's finally sits OUTSIDE ctx.run and resolves the LAUNCH home. Pass the registering home (release_running_job(job_id, home=...)), or every secondary profile's claim leaks — the job skips a fire window until the force-release backstop sweeps it, and the drain sees phantom work. Never rebuild a home from a key half (Path(key[0])): hermes_home_key normcases, so use _inflight_home_path.
  • Ticked-home state is reclaimed when a home leaves the set. register_ticked_homes is republished every cycle and reaps the departed homes' parallel pools; pools used to live until atexit, so each home ever ticked kept a ThreadPoolExecutor and its worker threads forever.
  • The host gateway stands down for a profile that runs its OWN gateway. run.py:: _cron_profile_gate (the same gate hermes_cli/web_server.py passes) keeps the launch process and a per-profile gateway off one store: the tick lock stops a simultaneous double-run but not the race, and when the launch process wins, delivery goes through SharedRouteAdapters/ fail-closed instead of that profile's live adapters. The gate compares the liveness PID against os.getpid() — this process holds the launch gateway.pid AND publishes every served profile in served_profiles, so a bare liveness answer would stand cron down host-wide.
  • The restart-safe external worker boots itself. _launch_external_cron_worker pins the checkout — and, on a PM install, the committed generation's site-packages — on the child's PYTHONPATH and marks it _HERMES_CRON_WORKER_BOOT; the child's package entry (cron/__init__.py → cron/worker_bootstrap.py, ahead of the cron.jobs import — -m cron.scheduler runs the package first) then runs PM's activate_dependencies, which leases the committed generation for the worker's lifetime, runs its .pth files and activates it before the first third-party import. A failed activation is fatal: the worker exits before its ownership ack (a reported dispatch failure) rather than run on an unleased generation the collector may delete. The gateway never re-runs the boot — hermes_bootstrap already did at its own launch (#122222). The worker's __main__ imports hermes_bootstrap (finish_worker_boot) and runs run_agent's interrupted-pull recovery before it reads the payload, and only then drops the marker: a source-update relaunch (-I, no PYTHONPATH) or a post-restore re-exec must replay the whole worker, never land after the ack with the payload gone and no dependency boot.
  • Cron sessions pass skip_memory=True; memory providers intentionally do not run during cron.
  • Cron execution has its own session. Eligible continuable deliveries may mirror or seed the reply-facing conversation: origin, origin-less home fallback, user-written bare-platform home, or opted-in explicit targets. all expansions do not gain home mirror eligibility. Mirrored briefs are labelled user turns appended at a turn boundary, preserving role alternation.
  • The cron ticker runs in the desktop-spawned backend when HERMES_DESKTOP=1 — that env var means "spawned by the app", not "a GUI is watching" (root: capability is a property of the session).
  • Background delegate_task is process-local; work that must survive restarts is a cron job or a terminal(background=True, notify_on_complete=True) process.

Kanban (multi-agent work queue)

Durable SQLite-backed board letting multiple profiles/workers collaborate. Users: hermes kanban <verb>; dispatcher-spawned workers use a dedicated kanban_* toolset so their schema footprint is zero outside a kanban task (footprint ladder rung 3).

  • CLI: hermes_cli/kanban.py facade + 14 kanban_*.py siblings (boards, db, db_connect, db_dispatch, db_notify, db_graph (task initialization and decomposition), workspace, ...). Verbs: init, create, list (ls), show, assign, link, unlink, comment, attach, attachments, attach-rm, complete, request-review, request-changes, reopen-review, block, unblock, archive, tail, plus watch, stats, runs, log, assignees, heartbeat, notify-*, dispatch, daemon, gc. Argparse alias dispatch must accept both list and ls (root).
  • Toolset: tools/kanban_tools.py — kanban_show, kanban_complete, kanban_request_review, kanban_request_changes, kanban_block, kanban_schedule, kanban_heartbeat, kanban_comment, kanban_create, kanban_link, kanban_attach, kanban_attach_url, kanban_attachments; platforms whose saved selection enables kanban (hermes tools enable kanban --platform <p>; default-off, in CONFIGURABLE_TOOLSETS) get the full set plus kanban_list/kanban_unblock for board routing. The check_fn reads the schema build's own selection (tools/kanban_toolset_context.py), never the legacy top-level toolsets key alone.
  • Dispatcher: long-lived loop (default 60s) that reclaims stale claims, promotes ready tasks, atomically claims, and spawns assigned profiles. Runs inside the gateway by default (kanban.dispatch_in_gateway: true). Standalone: plugins/kanban/systemd/hermes-kanban-dispatcher.service.
  • Plugin assets: plugins/kanban/dashboard/ (web UI) + systemd unit. kanban_db.connect is its own connection helper — do not alias it to projects_db.connect (a path-proximity generator did).

Isolation: board is the hard boundary — workers get HERMES_KANBAN_BOARD pinned in their env and cannot see other boards; tenant is a soft namespace within a board (workspace-path + memory-key isolation, one fleet serving several businesses). After kanban.failure_limit consecutive non-success attempts on a task (default 2) the dispatcher auto-blocks it to stop spin loops; a worker exit of KANBAN_TERMINAL_PROVIDER_EXIT_CODE (78 — credential revoked, model gone; the worker's own failure_reason classification via cli._TERMINAL_PROVIDER_REASONS) trips it on the first attempt, sticky, because no retry can heal it (#114587). Process-identity note: kanban --preserve-cache contains "serve" — never classify processes by argv substring (root). Worker liveness is (worker_pid, worker_started_at) — the start-time fingerprint (gateway.status.get_process_start_time) recorded at claim time — never bare PID existence, or a recycled PID gets killed on reclaim.

  • Notifications leave through the task's owning profile. hermes_cli/kanban_db_notify.py subscriptions carry the profile; gateway/kanban_watchers_notifier.py delivers via THAT profile's adapter under its scope (_notify_profile_filter), never the multiplexer's launch adapter; a fail-closed skip logs once at WARNING with the remedy, never a bare continue. Dispatched workers get HERMES_KANBAN_BOARD and the assignee's HERMES_HOME pinned in a scrubbed child env (build_subprocess_env + strip_launch_profile_env); they never inherit the default profile's .env.
  • Prompt injection sites gate on ownership, not tool access. Tool access (kanban_show visible via a profile's toolset) and an inherited HERMES_KANBAN_TASK (delegate children, cron runs beside a worker) are not ownership. The kanban guidance (agent_init, system_prompt fallback) and the stop nudge resolve the task via agent/delegation_context.py::owned_kanban_task(); other readers pair their env read with is_dispatcher_owned_worker_context().
  • Descendant fence is a path, not a flag. A delegated child's Kanban marker (agent/delegation_context.py::DELEGATED_CHILD_ENV_MARKER) carries the fenced board ROOT; kanban_path_is_fenced(path) denies mutations only on the dispatcher-pinned HERMES_KANBAN_DB or under that root, so a child working against a scratch HERMES_HOME keeps a writable board.

Tests

tests/cron/, tests/hermes_cli/test_kanban*.py, tests/tools/test_kanban*.py. Schedule parsing and catch-up windows are pure functions — test them as data. Never assert on the verb list or toolset size (root: no change-detectors). Time-based tests use loose bounds (≥ 2s) and event sync.