Commit Graph

790 Commits

Author SHA1 Message Date
unsupportedpastels
5bba024d8d fix(gateway): count restart-wait cron exclusions per profile-scoped run
A scoped or wedged daily-brief in profile A must not hide a degraded or
live daily-brief in profile B. Split the scheduler's in-flight keys before
counting and add awaitable cron runs independently of non-cron work.
Carry the per-run restart_safe flag to the observer and preserve the
existing bare-ID accessors for unrelated callers.

Thanks to @ehz0ah for diagnosing the multiplexed collision and proposing
the profile-keyed accounting and mixed scoped/degraded regression case.
Preserve @Superfly73's source commit separately; map their verified bare
noreply email for the contributor CI gate.

Refs #130987

Co-authored-by: z1gon <130889443+ehz0ah@users.noreply.github.com>
2026-10-01 22:25:21 -04:00
Superfly73
336b557bec fix(gateway): skip cron runs in their own restart-safe scope in the restart wait
A cron run handed to an external worker in its own transient systemd scope
outlives the gateway: the shutdown drain deliberately leaves it unmarked
(``mark_running_jobs_interrupted``) and its final send rides the durable
delivery queue, drained by whichever gateway is live next. The after-turn
restart wait still counted it as active work, so ``/restart``, SIGUSR1 and
``hermes update`` held the gateway in ``draining`` — refusing every new turn —
for up to ``agent.restart_after_turn_timeout`` (30 min by default) while the
worker kept running regardless. Observed in the field: a 100-minute job turned
a restart into a ~30-minute lockout.

- ``cron.scheduler`` records whether an accepted handoff got its own scope
  (``dispatch.mode == "scoped"``) and exposes
  ``get_restart_safe_external_job_ids()``; runs already reported as wedged are
  excluded there too, because the gateway subtracts both counts and a run in
  both sets would be subtracted twice.
- ``_awaitable_work_count()`` subtracts that count as a second, disjoint
  exclusion; ``_describe_active_work()`` marks the unit (``restart_safe``).
- A degraded worker (external, but no reachable user bus, so it shares the
  gateway cgroup) is NOT listed and is still awaited — the restart would kill
  it mid-run.
- The wait and observation log lines now name both exclusion classes instead of
  claiming everything still listed will be interrupted.

Tests: tests/gateway/test_restart_drain.py (skip + control),
tests/cron/test_restart_safe_worker.py (registry contract).
Docs: updating.md restart section, cli-config.yaml.example.

(cherry picked from commit 6a2f0b77cf07a60510430fc4ef8b75539ee422fb)
2026-10-01 22:25:21 -04:00
teknium1
be5e9f72c6 fix(cron): a partly delivered split send is not re-sent by the standalone fallback
The router turned every failed SendResult into a bare RuntimeError, which
dropped raw_response, and the cron live lane falls back to the standalone
sender on any error. A split send that failed after delivering its head
(Telegram, Discord, and now SMS/WeCom) was therefore delivered again from
the start by the standalone lane.

The router raises PartialDeliveryError for a partial_overflow result; the
live lane records "delivered N of M chunks, then failed" on the run and
does not fall back.
2026-10-01 17:18:56 -07:00
teknium1
e7b68e4a4a fix(cron): a live send that outlasts the 60s wait keeps running instead of being killed
_live_send_text used future.cancel() to tell a send that never started
(wedged loop: fall back to standalone) from one in flight (assume delivered:
a resend would duplicate). A run_coroutine_threadsafe future stays PENDING
until the coroutine finishes, so cancel() returned True mid-send: the
in-flight send was cancelled part-way and the standalone lane then sent the
whole payload again. Paced multi-chunk sends (WeCom's 30 msgs/min bucket)
outlast 60s routinely; a 55-chunk cron payload arrived as 25 chunks and was
reported failed.

The send now records its own start under a lock; on timeout it is
abandoned (and the standalone fallback runs) only when it never began.

The existing in-flight test faked the state with a mocked cancel()
returning False, the exact assumption that does not hold; it is rewritten
against a real event loop, with the never-started (wedged loop) case it
never covered alongside.
2026-10-01 17:18:56 -07:00
dhruv.kejriwal
164a697d0c fix(cron): ignore measured durations in the incident error signature
The inactivity watchdog polls every 5s, so the same stall reports
"idle for 601s" on one run and "idle for 604s" on the next. The incident
signature hashed that raw number, so each run minted a new incident: an
acked timeout pinged again and the repeat-alert cooldown never applied.
Mask duration tokens before hashing; status codes and other numbers still
split incidents.

Fixes #129587
2026-10-01 20:14:31 -04:00
kshitijk4poor
8a1efff136 fix(cron): keep caching bad-expr cron cadences; skip only import-failure None
e33181e883 stopped caching every None from _cron_interval_minutes, so a
malformed cron expression (croniter loaded, expression invalid) was
re-parsed on every in-flight-guard check. Track whether croniter actually
loaded and cache unless the None came from a failed import: a bad-expr
None is stable, an ImportError None is transient. The kept #127182
regression test now asserts the import-failure None stays uncached.

Also drop the redundant recovery_kind operand in
_recover_missing_next_run (_is_recoverable_error_job already requires a
cron/interval schedule) and tighten two comments.
2026-10-02 01:33:04 +05:30
kshitijk4poor
dfc289bc28 fix(cron): back off croniter re-probes for 60s after an ImportError
Not latching a failed croniter import fixed #127182, but with nothing
cached every _ensure_croniter() call re-ran the full finder walk when the
package is genuinely absent - several times per cron job on every
due-scan tick, under _jobs_lock. Remember a monotonic retry-at 60s out
instead: a transient failure still recovers within one tick, explicit
True/False overrides still win, and a missing package costs one probe a
minute.

Also tidy the error->scheduled re-arm: reuse _is_recoverable_error_job()
for the condition and drop the redundant next_run_at assignment that
job.update(fields) already performs; fix the test comment (the patched
import raises for as long as it is patched in, not once).
2026-10-02 01:33:04 +05:30
kshitijk4poor
8efccd6590 fix(cron): reset a re-armed recurring job from error to scheduled
Once croniter imports again, due-scan recovery re-arms a recurring job's
missing next_run_at, but the job kept state=error until it next fired, so
`hermes cron list` kept reporting a healthy, due job as broken. Flip it
back to scheduled in the same persist, and correct the docstring: recurring
jobs now also land here after a transient croniter ImportError (#127182),
not only after a hand-edited jobs.json.

Co-authored-by: itsflownium <157689911+itsflownium@users.noreply.github.com>
2026-10-02 01:33:04 +05:30
liuhao1024
a46ebee6f2 fix(cron): re-probe croniter after a transient ImportError instead of latching
A single transient croniter ImportError (wrong interpreter, shadowed path)
was cached as HAS_CRONITER=False for the whole process lifetime. While
latched, compute_next_run() returns None for every recurring job, so jobs
stay enabled with next_run_at=None and state=error — never rescheduled
until a gateway restart, despite the due-scan recovery path existing
exactly for that case (#127182).

Keep HAS_CRONITER None on failure so the next call re-probes; an explicit
monkeypatched value still wins. Also log sys.executable with the warning so
a scheduler running under the wrong interpreter is diagnosable from
errors.log.

Fixes #127182

(cherry picked from commit bb48a7b48fb28d45d63c47b59a1cf0c5049efb87)
2026-10-02 01:33:04 +05:30
kshitijk4poor
e00649864f test(utils): pin the mkstemp_beside contract for both symlink-into-other-FS outcomes
Replaces the utils-only rename test with one parametrized over a writable
target dir (publish by rename, no copy) and a read-only target dir (save still
succeeds via the copy fallback). Since all five writers stage through the
helper, this covers the shared rule instead of one call site. The cron test now
patches the utils seam (os.replace via utils, utils._copy_fallback) that
jobs.json actually publishes through.
2026-10-02 01:02:00 +05:30
kshitijk4poor
773b1118ad refactor(cron): resolve the jobs.json symlink inside _stage_jobs_payload
_stage_jobs_payload is the only stager, so it owns the "temp beside the
resolved store" rule instead of its caller wrapping the path. If the link
target's directory is not writable to us (file writable, dir not), fall
back to staging beside the link: saves keep working via atomic_replace's
copy fallback, exactly the pre-fix behaviour, instead of failing outright.

Move the symlink regression test next to the other save-path tests in
test_jobs_shrink_merge.py with module-level imports.
2026-10-02 01:02:00 +05:30
ayushmanpadhi
b0c29fa03a fix(cron): stage jobs.json temp beside the resolved store
When jobs.json is a symlink into another directory (e.g. a different
filesystem), the temp file was created next to the symlink. The rename
onto the real target then failed with EXDEV and atomic_replace fell back
to copying in place, so a crash mid-copy left a torn, unparseable store.

Stage the temp beside os.path.realpath(jobs_file) so the publish stays a
same-directory atomic rename. atomic_replace, its EXDEV/EBUSY fallback
(still needed for single-file bind mounts) and the #60703 lock-timeout
degrade are unchanged.

Salvaged from #126237 (narrow hunk only).
2026-10-02 01:02:00 +05:30
kshitijk4poor
dad9c56748 refactor(cron): build run documents from the shared frame constants
The context_from reader in cron/scheduler_prompt.py validates archives
against _PROMPT_HEADING/_PROMPT_SEPARATOR/_RESPONSE_HEADING/
_RESPONSE_TERMINATOR, but the writer in cron/scheduler.py still spelled
the same bytes as literals. If one side changed, framed archives would
silently stop validating. The writer now composes the header and body
from those constants; output is byte-identical (checked by rendering
documents with the old and new writer and cmp-ing them).

Also collapse `not candidate or candidate.isspace()` to the equivalent
`not candidate.strip()`, and drop the two `assert final == answer`
lines in the extraction tests: they only checked that the stubbed agent
echoed its own mock response, not any cron behaviour.
2026-10-02 01:00:00 +05:30
kshitijk4poor
3114c52701 fix(cron): trim script-mode context_from archives and share frame labels
The framed-archive change moved the caller's .strip() into
_archive_answer's framed/legacy branches only, so headingless
(script-mode / FAILED) archives reached _prepend_context_block with their
padding, putting stray blank lines inside the fence and spending clip
budget on whitespace. Return archive.strip() in that branch; the
script-mode test now asserts the exact trimmed fence.

Fold the review cleanups for the frame format so writer and reader
cannot drift: the stamp labels live once in scheduler_prompt.py (it
binds _sched only at import tail, so module-level patterns cannot be
built from scheduler attributes) and are imported by scheduler.py;
_PROMPT_FRAME_RE/_RESPONSE_FRAME_RE are compiled once; the +2/+1 magic
offsets are named separators; run_job normalizes newlines once via
_normalize_newlines and builds both stamps (header takes prompt_stamp
instead of a bool flag); the empty check uses isspace() instead of a
stripped copy; the docstring is rewrapped; both writer tests share a
_run_stub_job helper.
2026-10-02 01:00:00 +05:30
kshitijk4poor
8bd0528ec4 test(cron): keep two invariant tests for framed context_from archives
The salvaged stack added three tests for response framing. Keep the
writer->reader round trip with nested frames (red on main: rpartition
injects only the embedded subsection) and the truncated-outer-frame
case. Drop the test and the in-test block that exercise "framed archive
without a prompt length": that shape was only ever produced by the PR's
own intermediate commits, never by a writer on main, and the follow-up
removes the reader branch for it.
2026-10-02 01:00:00 +05:30
itsflownium
00bbc5b64f fix(cron): reject invalid enclosing response frames
(cherry picked from commit f4400384828349f759d6f2e8c0b6c0989cf51cac)
2026-10-02 01:00:00 +05:30
itsflownium
39e046e5e4 fix(cron): validate complete archive frames before normalization
(cherry picked from commit 4f663b3ea7fce7d76fd7b60c1f859ed7f2fb3ae3)
2026-10-02 01:00:00 +05:30
Yuan Li
6d67aec10b test(cron): cover framed embedded response headings
(cherry picked from commit 98e07978d11f4f4d0fab0fbc5225850f1e8c2bc1)
2026-10-02 01:00:00 +05:30
unsupportedpastels
dddad05a47 fix(cron): remove profile scan ambiguity without dropping long commands
Preserve the optional-value disambiguation from #129282 while removing its flag-count cutoff. Make token and whitespace runs possessive, and retain standalone dash values. Bound regression probes in subprocesses so a GIL-holding regex cannot wedge the test runner.

Co-authored-by: Yuan Li <dskwelmcy@163.com>
2026-09-30 21:33:08 -04:00
unsupportedpastels
96982d6284 fix(cron): retain bounded history after targeted worker recovery 2026-09-30 20:57:21 -04:00
unsupportedpastels
a70056c73a fix(cron): preserve diagnostics when recovery wins a wait timeout 2026-09-30 20:57:21 -04:00
unsupportedpastels
9830058c00 fix(cron): report acknowledged worker failures without losing diagnostics
Extend Finn763's dead-owner recovery with owner-fenced failure delivery based on PR #120391 and retain stderr until the parent consumes an escaping worker traceback. Preserve unknown side-effect state and do not retry automatically. Cover both shared entry paths, an exception through the payload lifecycle, abrupt death, and concurrent recovery.

Co-authored-by: Denis Dresvyanskiy <dresvyanskiydenis@gmail.com>
2026-09-30 20:57:21 -04:00
Finn763
ef64034005 fix(cron): record a lost manual run instead of reporting a restart that never happened (#128509)
A restart-safe cron worker that dies AFTER adopting its attempt was reported as
"Scheduler restarted after this execution's owner exited" and treated as success.

recover_interrupted_executions() only knows that an owner is gone, so that is all it
can write -- but the waiter holding the worker's Popen knows the owner was that
worker and knows its exit status. Worse, the sweep leaves the row terminal, so the
waiter returned True, run_one_job short-circuited, and mark_job_run never ran: the
job's fire claim outlived its lost execution and refused the next manual fire with
"Job is already being fired" for the whole 300s lease, with an empty last_error.

- executions.terminalize_dead_owner(): terminalize ONE attempt with an observed
  cause. Refuses a live owner, this process, and a handoff inside the adoption
  grace, so it can never steal a running worker's attempt; returns False so the
  caller keeps the generic sweep.
- The waiter records that cause and RAISES, routing the existing post-handoff
  bookkeeping: mark_job_run records the run and retires the claim, no dispatch
  incident and no false crash notice. Also folds the worker's captured stderr into
  the error, which the pre-ack path already did (#112729) and this path dropped.

(cherry picked from commit d3f3571f8c6f4d2f188e90151d8f8393f049a573)
2026-09-30 20:57:21 -04:00
Austin Pickett
69a96a7311 fix(cron): detect a routed fire from its home, not a ticker-only marker
The dashboard's manual Run now binds the sibling profile via
web_server_cron._cron_store_scope, not the ticker's _profile_cron_scope,
so the marker-based routed_profile_fire() stayed False there: the routed
profile's .env was reloaded into the shared os.environ and the managed
external-worker handoff serialized multiplex_active=False.

routed_profile_fire() now compares the fire's active HERMES_HOME with
get_routing_process_hermes_home(), so every entry point (ticker,
dashboard Run now, CLI, gateway forward) and the external-worker handoff
share one answer. Regression test drives the real dashboard fire path
A -> B -> A with no_agent jobs.
2026-09-29 22:57:48 -04:00
Austin Pickett
f6250192c2 test(cron): use env bash shebang in the restored routed no_agent tests 2026-09-29 22:57:48 -04:00
kshitijk4poor
b543b1053c fix(secret-scope): compose the managed .env into every profile secret scope
Answers the P1 review on #111187: build_profile_secret_scope() held only
<profile>/.env plus that profile's external-source snapshot, never the
administrator-managed .env. The launch process applies that file LAST with
override (_apply_managed_env), so a managed key beats the user's own value in
os.environ. Under multiplex semantics get_secret() stops falling back to
os.environ on a scope miss, so inside a routed cron fire (and equally inside a
real multiplex gateway turn, which builds its scope through the same function
via gateway/run.py::_load_profile_secret_scope) a managed-only credential
resolved as absent and a managed-vs-user collision resolved to the USER value:
reversed precedence.

Fix at the source: build_profile_secret_scope() overlays load_managed_env()
last, after the profile .env and external sources, skipping process-global
names exactly as it does for the other two layers. Every multiplex-authoritative
scope (gateway turn, routed desktop fire, external worker env build) is built
here, so managed authority is composed once instead of restored per consumer.
No generic ambient-env fallback is reintroduced: only the managed file's own
keys enter the scope, and only with the managed file's values.

Regression (parametrized, two invariants): inside a routed fire a managed-only
key resolves through get_secret(); a managed-vs-user collision yields the
managed value.

(cherry picked from commit cedf4a3d78)
(cherry picked from commit 4a750b4022ff321863d6746ec39ec5ca67885c16)
2026-09-29 22:57:48 -04:00
kshitijk4poor
3b3b61ff4c test(cron): restore the routed-fire handoff test for _launch_external_cron_worker
The earlier trim dropped the only test of the `routed_profile_fire() and
not is_multiplex_active()` branch in the external-worker handoff: the
process flag is OFF (a desktop tick is not a multiplexer) yet a fire routed
to a sibling profile must serialize `multiplex_active=True` and hand the
worker an env without the launch profile's residue, and the context must
not outlive the handoff span. Without it that branch could regress to the
pre-fix behaviour unnoticed.

(cherry picked from commit 5850a50a81)
(cherry picked from commit 440bee6ccf9d48eecf91769787f6daa3fd3ea00c)
2026-09-29 22:57:48 -04:00
kshitijk4poor
27bbbda5c3 test(cron): keep three invariants for the routed-fire isolation
The PR shipped nineteen tests across six files, most of them variations of
one boundary. Keep the three that pin distinct behaviour:

- a routed desktop-ticker fire runs under multiplex semantics for exactly
  its scope: a scope miss returns None instead of the launch credential and
  the parent os.environ is byte-identical afterwards;
- a routed no_agent child never sees a launch-only name, whether the launch
  .env defined it or a launch external source supplied it (applied or lost
  to a pre-existing process value), while its own values come through;
- administrator-managed keys keep policy precedence over the routed
  profile's own value.

Everything else was either a positive control of the same seam, a
set-membership check on a module-level constant, or a re-statement through
a different entry point.

(cherry picked from commit 042b9830b4)
(cherry picked from commit 0b82557fbdbfa6968b2b795e4e63d484ffb582b1)
2026-09-29 22:57:48 -04:00
John Paul Soliva
65bd3eb336 fix(cron): routed fires are multiplexed at the worker handoff; managed keys keep policy precedence
Review findings on f5f88d5058. Three are defects the previous round introduced.

Managed keys were stripped as launch residue. Recording every dotenv load as
residue swept in the administrator-managed `.env`, which `_apply_managed_env`
applies LAST with override precisely so it beats the user's own `.env`. A
routed child then lost `ORG_POLICY_FLAG=managed-value` to the routed user's
`user-value`. Managed keys are now recorded separately, never enter the
residue set, and are re-applied over the routed scope in both child builders
(`scheduler_script`, the restart-safe handoff) so the child sees the same
precedence the launch process does. `kanban_db_dispatch` and
`scheduler_delivery` strip without any overlay, so for them the exclusion
alone is the guarantee; the test pins the case that exercises it — the same
key defined in both the user and the managed file.

Private hydration did not record supplied names. `_hydrate_profile_secret_sources`
now feeds `provenance` plus `skipped_existing` into the same ownership set the
process-global path uses; the provenance label map stays applied-only.

Removal cleanup cleared its marker before the fallible work. A raising
reload left the removed plugin's credential active with no retry, because the
next no-source discovery saw the flag already false. The marker is cleared
only after reset, reload and installed-scope refresh succeed.

Routed fire not multiplexed at the handoff. `run_one_job` enables the
context in `_install_fire_secret_scope`, which runs AFTER
`_launch_external_cron_worker`, so a routed desktop fire on the managed path
serialized `multiplex_active=False` and built the worker env with launch
residue and no scrub. The handoff now treats `routed_profile_fire()` as
multiplexed for exactly its own span; the worker re-establishes the state from
the payload as before.

Each fix was checked by reverting it and confirming its regression fails,
including the overlay half and the exclusion half of the managed fix
separately.

(cherry picked from commit 329cbd8963d68c45b425e95a5b11ade59f513960)
(cherry picked from commit 62b4488cb5)
(cherry picked from commit d3319102a7e4dcce35ec2676fe22aed41613e893)
2026-09-29 22:57:48 -04:00
John Paul Soliva
66e3090a4f fix(cron): close three launch-residue leaks into a routed no_agent child
Review findings on d8c467f223, each reproduced through its production path.

Stale launch key. `strip_launch_profile_env` built its residue set from a
re-parse of the launch `.env`. A key removed or renamed in that file after
boot is still in `os.environ` with the old value (dotenv never unsets), and
the current file no longer names it, so it survived into the routed child.
`_load_dotenv_with_fallback` — the one chokepoint every dotenv load goes
through — now records the KEY names it put into the process env, additive for
the process lifetime (`launch_dotenv_keys()`), and the strip unions that record
with the current file.

Source name that lost to the process env. `_apply_external_secret_sources`
snapshots every name a source SUPPLIED (`provenance` + `skipped_existing`),
but `secret_source_names()` only exposed `_SECRET_SOURCES`, which is
provenance metadata and names applied values alone. A launch-profile source
that supplied `CUSTOM_VAULT_SECRET` while the process already had it was
therefore invisible to the scrub, and a routed child with an empty scope got
the launch value. Supplied names are tracked separately
(`_SOURCE_SUPPLIED_NAMES`) so the provenance labels stay honest, and
`secret_source_names()` returns the union.

Last plugin source removed. `_refresh_secret_sources_after_discovery`
returned before the cache reset and the installed-scope refresh whenever no
plugin source was enabled — and `discover_and_load(force=True)` unloads the
old registration first, so removing the final plugin source hit exactly that
return with the removed plugin's names still in the per-home snapshot and the
current scope. The manager now remembers that a discovery re-applied plugin
sources and, on the next discovery that finds none, reconciles once. A home
that never had a plugin source is still a no-op (pinned by the existing tests).

Regressions: the stale-key lifecycle and the skipped-existing case through
`_run_job_script` against a real routed child, and the removal case through
the manager. Each checked by reverting its fix and confirming the test fails.

(cherry picked from commit d464f5f6126a394cfb47937f683d3a5e2f141840)
(cherry picked from commit 9d7de6c140)
(cherry picked from commit b0beb731b18edd884e36a30ab91c91687bc7a965)
2026-09-29 22:57:48 -04:00
John Paul Soliva
48a49220ee fix(cron): only strip launch external-source names when multiplexing
The source-name strip added alongside the external-source fix ran
unconditionally. Outside multiplexing there is no other profile to leak from --
os.environ IS this profile's environment -- so popping those names relied on the
routed scope overlay putting each one back, which in turn relies on the
per-home snapshot recorded at boot. Correct today, but it made a single-profile
child's credentials depend on bookkeeping that has nothing to do with isolation.

Guard it the way strip_launch_profile_env guards itself: no multiplexing, no
strip. A single-profile no_agent child now keeps a byte-identical env even if a
source's snapshot were ever missing. Pinned by a regression that runs a real
child with a source-owned name in os.environ and no multiplex context; making
the strip unconditional fails it.

(cherry picked from commit afa429b30a1c9c9b4c011f7d2426a9f099911596)
(cherry picked from commit 3dedff6a12)
(cherry picked from commit 1cb2a8e6eb9a1848fa758fcaef7dce531532258c)
2026-09-29 22:57:48 -04:00
John Paul Soliva
e4bb0ed6a9 fix(cron): strip launch external-source names too, and make the scope refresh replace
Two credential-isolation gaps found in review of the previous head.

1. strip_launch_profile_env() only knows dotenv- and terminal-config-owned names,
but external secret sources (vault, 1Password, ...) also write their names into
the shared os.environ and are tracked in secret_source_names(). A name the LAUNCH
profile's source supplied therefore still reached a routed no_agent child. Drop
every non-global source-owned name from the base; the routed scope overlay that
follows puts back exactly the ones that profile's OWN sources supply, since
build_profile_secret_scope folds get_secret_source_values(home) in.

2. refresh_installed_secret_scope() merged the rebuild with dict.update(), so a
name a source had stopped supplying -- rotated, revoked, source removed -- kept
its old value for the rest of the fire. Replace the mapping contents instead: the
rebuild is the profile's current truth.

Regressions: a routed child sees <unset> for a launch-source name while its own
source value comes through, and a refresh whose rebuild omits a name drops it.
Both fail if the corresponding change is reverted.

(cherry picked from commit ecd51517c4828a75acb5f458ed99aea0bc3e5e9f)
(cherry picked from commit 0943e77136)
(cherry picked from commit 68ab0384e17e06e8eca684c4d8eac15543dc3943)
2026-09-29 22:57:48 -04:00
John Paul Soliva
71303c7082 fix(cron): drop the launch profile's dotenv residue before overlaying a routed no_agent scope
The routed no_agent child env started from all of os.environ and only overwrote the
names present in the installed scope. A name defined only by the LAUNCH profile's .env
and absent from the routed profile therefore reached the routed child with the launch
value instead of unset -- the secret scrub only knows classified names, so a custom or
unclassified secret crossed the profile boundary (review finding on the first head).

strip_launch_profile_env (main, 284d220ba4) is the primitive the external-worker path
already uses for exactly this: it drops the launch profile's dotenv-owned keys and the
bridged TERMINAL_* settings, and is a no-op outside multiplex or when the target IS the
launch profile. Apply it to the base BEFORE the scope overlay (so a shared name keeps its
routed value) and BEFORE the sanitizer (so routed values still pass the scrub and
passthrough rules). Pinned by a child-process negative control: the launch-only name
arrives <unset>, the shared name arrives routed, and the parent process is unchanged.

(cherry picked from commit 69349b527b5144c1f1540a4c10035d5ec798c1db)
(cherry picked from commit 023e4f997f)
(cherry picked from commit cace4f3f2013e9dc60356add245b2bad4b56ab08)
2026-09-29 22:57:48 -04:00
John Paul Soliva
3833773f7f fix(cron): a routed profile's cron fire in the desktop backend runs under multiplex semantics
The desktop backend ticks EVERY local profile's cron store from one process — its own docstring
says "like a multiplex gateway" (hermes_cli/web_server.py) — but never sets the process-global
multiplex flag, and cannot: its own chat turns are unscoped and would fail closed. Every
isolation in the tree keys on that flag — the guard that keeps a routed `.env` out of the shared
`os.environ`, `get_secret`'s fail-closed miss, passthrough resolution, the MCP and kanban
subprocess scrubs — so all of it was inert for a sibling profile's fire. Verified: a secondary
profile's API keys replaced the launch profile's in `os.environ` with `override=True` and stayed
there after the tick, and a scope miss read the launch profile's tokens (#107692).

Give multiplex mode a context-local counterpart. `set_multiplex_context` (agent/secret_scope.py)
is OR'd into `is_multiplex_active()`. `_profile_cron_scope` only MARKS a fire whose home is not
the process's own (`routed_profile_fire`, decided against `get_process_hermes_home()`, the
override-immune resolver); `_install_fire_secret_scope` in cron/scheduler.py installs the
profile's hydrated secret scope and, for a marked fire, the multiplex context — for exactly that
span, dropped again before the scope by `_reset_fire_secret_scope`. Multiplex semantics are
therefore never active in cron without a scope to read: `run_one_job`'s restart-safe handoff runs
before the body's scope and keeps today's semantics (its own scope is #107413 / #106050's seam,
left untouched so this composes with whichever lands). Every existing multiplex-keyed isolation
applies inside the routed fire with no per-site patching; the launch profile's own fires and the
backend's turns keep single-profile semantics; marker and override both reach the pool worker via
`copy_context()`. `get_secret` read the raw global in its miss branch; it now goes through
`is_multiplex_active()`. The dotenv guard keeps its pinned flag-only form (#77970).

Two consequences of suppressing the write are handled rather than left as regressions:
- a `no_agent` script's env is `os.environ.copy()`, which no longer carries the routed `.env`;
  the runner overlays the installed scope onto the base BEFORE sanitizing, so the same scrub /
  passthrough rules apply to those values and the parent process is never mutated;
- plugin secret sources are discovered on the fire's first agent build, after the scope froze,
  and the post-discovery reload is hydrate-only under multiplex semantics; the refresh now folds
  the values into the installed scope in place (`refresh_installed_secret_scope`, the pattern
  `_publish_env_value` already uses for `.env` writes under multiplex).
And the profile's external secret sources are hydrated before the scope is frozen, the order
gateway/run.py and the external cron worker already use.

Tests pin each direction: the marker without the semantics before the scope, the semantics on and
off exactly with it, the marker reaching a copy_context worker; the process's own profile staying
single-profile; the restart-safe handoff's child env building without raising under a routed tick
with a passthrough key registered; a real child process receiving the routed values while
`os.environ` keeps the launch value; a source registered after the freeze reaching the fire
through the real PluginManager refresh. Reverting any one direction fails a distinct test.

(cherry picked from commit 2f87677425d2cca19286ac83bc45cab23e546669)
(cherry picked from commit dbede34f6e)
(cherry picked from commit a7ac197ff12710e045e3f75d960b20be952960f3)
2026-09-29 22:57:48 -04:00
kshitijk4poor
9ee791eef1 test: drop tests that cannot fail or duplicate a same-file keeper
Each removed test either asserts nothing reachable or repeats another
test's exact body:

- auxiliary_client empty-base_url: body only writes config.yaml; a merge
  (890bbbda1f) restored the header of a test b3d4f67b20 had purged. The
  non-Anthropic-host test beside it drives the same default fallback.
- background_review no-session-db: _record_review_usage_to_parent swallows
  every exception and there is no DB to observe, so removing the guard
  still passes; the path has no observable effect to pin.
- code_execution Windows "would have failed" pair: every branch returns,
  skips or passes; they exercise CPython defaults, not Hermes code.
- cron test_should_use_direct_api_call_*: byte-identical copy of the test in
  tests/agent/test_cron_inline_api_call.py (0213b8b77b had to edit both).
- cron liveness no-multiplexer: identical body to test_no_lock_no_pids_is_false;
  its multiplexer patch is never reached.
- browser console/get_images "private URLs allowed": identical to the
  local-backend test in each file; both mock the predicate the name promises.
- hermes_state TestTitleUniqueness: the duplicate-title case is a strict
  subset of TestSessionTitleLineage::test_unrelated_session_still_conflicts;
  the NULL-title case never writes a title.
2026-09-29 15:58:55 +05:30
kshitijk4poor
744f05e220 fix(cron): stop the external worker when PM refuses its dependency boot
worker_bootstrap() logged a failed activate_dependencies() and let the worker
carry on through the inherited PYTHONPATH pin. That path is not leased: once
the gateway releases its generation the collector deletes it under the live
worker and the next dependency import dies mid-job (#122936 review, reproduced
with a committed generation that has no site-packages).

The failure now propagates, so the worker exits before its ownership ack and
_launch_external_cron_worker reports the dispatch failure with the worker's
stderr. PM's own no-op cases (venv, wheel, Nix, nothing committed under a venv)
still return normally.

Tests drive the real PM boot in a marked child: it imports a sentinel that
exists only in the committed generation, and when an update commits a newer
one mid-job the real collector keeps the worker's generation until the worker
exits. A damaged selection exits before cron.jobs is imported.

123456hyu made the same prelude move and fail-closed change on #122290
(dff16e12); this carries them without the argv gate and source-order test.

Co-authored-by: 123456hyu <123456hyu@users.noreply.github.com>
2026-09-29 03:32:36 +05:30
kshitijk4poor
eb65fff285 refactor(cron): rename the worker boot marker and correct the pin's docs
- Rename the boot marker HERMES_CRON_EXTERNAL_WORKER to _HERMES_CRON_WORKER_BOOT.
  The old name differed by one leading underscore from
  _HERMES_CRON_EXTERNAL_WORKER, the owning execution id read in the same worker.
- The subprocess probe now gets the marker name from WORKER_MARKER instead of
  a literal, so a future rename cannot silently skip the consumed-marker check.
- Resolve the repo root inside worker_bootstrap(): only the marked worker uses it.
- Correct the docs to match the boot order: the boot re-selects and leases the
  generation before any third-party import, and the PYTHONPATH pin is the
  fallback for a failed boot. They are not two load-bearing halves, and the pin
  does not resolve modules exactly as the gateway did.
2026-09-29 03:32:36 +05:30
kshitijk4poor
308072df89 fix(cron): boot the external worker's PM dependencies before cron.jobs
`-m cron.scheduler` runs `cron/__init__.py` before the module itself. That
file's first import, `cron.jobs` -> `hermes_yaml` -> `ruamel`, is already a
third-party dependency. With the boot at the top of `cron/scheduler.py`, the
worker imported through the pinned PYTHONPATH alone:
  - no lease on the generation was held yet;
  - no `.pth` files had run;
  - with the dependency pin removed, it still died with
    "No module named 'ruamel'".

Run the marked-worker boot at the top of `cron/__init__.py`, under a private
alias, so the package attribute `cron.worker_bootstrap` stays the module. Drop
the now-redundant call in `scheduler.py`. Replace the in-process marker test
with a subprocess test: the boot runs before `cron.jobs` is imported, runs
exactly once, consumes the marker, and never fires for an unmarked importer.
Also add the missing trailing newlines on the two new files.
2026-09-29 03:32:36 +05:30
ethernet
9ec6b94eca fix(cron): boot the external worker through PM's committed selection
The restart-safe cron worker is spawned as `sys.executable -m cron.scheduler`, a Hermes
entry point that never goes through `hermes_bootstrap`. On a self-managed (PM) install its
interpreter is the store Python, which owns no dependencies and whose PYTHONPATH the
subprocess sanitizer has just stripped, so the worker died at its first dependency import
(`No module named 'ruamel'`, #122222) before it could publish its ownership
acknowledgement -- every scheduled job landed `last_status: failed`.

Salvaged from #122290 (thanks @123456hyu), whose two commits pinned the dependency
site-packages and leased the generation at the worker entry. Reworked onto PM's own
machinery:

* `pin_hermes_tree_on_pythonpath` asks `pm.environments.committed_venv` +
  `site_packages()` for the generation, instead of deriving one from this process's
  `sys.path` behind a `pyvenv.cfg` ancestor probe. The record is scoped to the active
  home, so the child-spawn path no longer reads the real home -- that probe tripped
  `tests/home_io_guard.py` when the suite runs from a self-managed install -- and the
  uv-launcher, first-match-only and ordering edge cases of the old derivation go with it.
* `cron/worker_bootstrap.py` is now the PM call the worker actually needs: lease +
  `site.addsitedir` + `sys.path`/`PYTHONPATH`/`PATH` are all
  `pm.environments.activate_dependencies`, the same boot `hermes_bootstrap` runs, and it
  holds the generation's lease for the process's life. It is gated on the
  `HERMES_CRON_EXTERNAL_WORKER` marker the spawn site sets (consumed on read) rather than
  sniffing `--external-worker-file` out of `sys.argv`, so the gateway keeps its launch
  contract without argv identity inference.

Verified live on PM's store Python against a real generation (A/B, same child): tree-only
PYTHONPATH -> `ModuleNotFoundError: No module named 'ruamel'`, rc=1; pinned env -> imports,
`running_from_selected_environment` True, and the worker holds a real kernel lease while it
runs (released on exit). The real `-m cron.scheduler --external-worker-file ...` entry now
reaches its ownership handoff instead of dying at the import. `tests/cron/`: 1376 passed.

Tests: the two invariants that carry the contract -- the pin restores the committed
generation (and invents nothing without one), and the boot runs only in the marked worker,
once. The six construction tests that drove the old derivation are dropped with it.

Duplicate-cluster credit: #122238 (@webtecnica, earliest), #122382 (@Sakujakira), #122685
(@etrisnanto), #122719 (@aglowinthefield) and #122738 (@GabboAlarcon) diagnosed and fixed
this same worker-dependency failure in parallel while this branch was rebuilt. None of their
diffs is contained here -- the co-author trailers below record direction and duplicate-cluster
credit, not lifted code; their roles are listed in the PR body.

Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com>
Co-authored-by: Manuel S. <3703103+Sakujakira@users.noreply.github.com>
Co-authored-by: Eko Trisnanto <4469382+etrisnanto@users.noreply.github.com>
Co-authored-by: aglowinthefield <146008217+aglowinthefield@users.noreply.github.com>
Co-authored-by: GabboAlarcon <40816412+GabboAlarcon@users.noreply.github.com>
(cherry picked from commit 7c55715c0478280adf4e492b24edf331342470f7)
2026-09-29 03:32:36 +05:30
123456hyu
94f83d972d fix(cron): hold the committed dependency generation's lease at worker entry (#122290 P2)
The pin in scheduler_worker_env.py restores the site-packages *directory* into the
child's PYTHONPATH, but a directory name is not a retention permit. The PM collector
removes an unselected, lease-managed generation once it is old enough and no process
holds its lease, so between the gateway's exit and the worker's next import the very
generation the worker needs can be collected away.

New cron/worker_bootstrap.py closes that gap at the worker entry, before the first
application/dependency import:

- lease_dependencies(): acquire the committed generation's kernel lease (the same
  hermes_cli.runtime_state.lease_generation primitive activate_dependencies uses),
  so the collector's 'no process holds the lease' safety condition keeps the
  generation alive for the worker's whole lifetime. Unconditional and idempotent.
- activate_worker_dependencies(): delegate to pm.environments.activate_dependencies
  (the same resolution + site.addsitedir + sys.path reordering the gateway's boot
  uses) so the worker's own imports resolve without waiting for the gateway's
  handoff. Gated on --external-worker-file in argv, so the in-process gateway path
  keeps its exact launch contract and the runner-owns-its-own-deps case stays a
  no-op.

Hooked into the top of cron/scheduler.py, after the stdlib-only sys.path pin and
before any Hermes package import, so hermes_yaml -> ruamel.yaml succeeds. Both
degrade to a logged no-op when no dependency generation is committed.

Tests: test_worker_bootstrap_lease_and_activation, _leases_but_skips_activation_in_gateway,
_no_committed_venv_is_noop, and a scheduler-import smoke test. The two home_io_guard
failures in this suite are pre-existing on a clean checkout and unrelated.

Fixes the P2 lifetime finding in @andrexibiza's review of #122290.

(cherry picked from commit 429f0dd191dbf719d05cbbbe3cda6546893b97d0)
2026-09-29 03:32:36 +05:30
123456hyu
f57d235748 fix(cron): restore activated dependency site-packages for the external worker
On a self-managed install the restart-safe cron worker is spawned with the store Python, which owns no third-party dependencies. The sanitizer drops the runtime site-packages and the tree pin only re-adds the checkout, so the worker dies at its first import (No module named 'ruamel') before publishing its ownership ack.

Restore the dependency environment this process actually activated, next to the tree, derived from sys.path (never a home-scoped read) and gated on a real venv so nothing is invented for installs that already own their dependencies.

Fixes #122222

(cherry picked from commit 8403cd5681b8da2a52061e3ae16c5533ef3b5ff3)
2026-09-29 03:32:36 +05:30
teknium1
f4075a20d6 feat(telemetry): gateway platform health, delivery, first-reply latency and cron run metrics
The gateway and cron ticker were blind spots in shared metrics: we could not
tell which messaging platforms fail to connect or drop, how often replies fail
to reach users, how long users wait for a first reply, or whether scheduled
jobs run, fail, get skipped or get missed while Hermes was down.

New opt-in counters (all through the existing enabled() / record_process_mark
gate, recorded fire-and-forget on one background worker that runs in a copy of
the caller's context so the owning profile gets the row):

- hermes.platform.health: platform, event (connect_ok/connect_failed/
  reconnect/disconnect), error_class. One seam in the runner
  (_connect_adapter_with_timeout, used by cold start, multiplex secondaries and
  the reconnect watcher) plus the fatal-error handler; classified from
  exception types, HTTP statuses and Hermes's own fatal codes, never text.
- hermes.platform.delivery: platform, outcome, failure_class. One row per
  logical BasePlatformAdapter._send_with_retry call (retries and plain-text
  fallback included), from SendResult's typed fields or the exception type.
- hermes.gateway.reply_latency: platform, first_response_bucket. Clock starts
  when _handle_message accepts a non-internal turn; stops on the stream
  consumer's first delivered text or send_final_ledgered. Busy acks and
  command replies never stop it.
- hermes.cron.run: outcome (success/failed/missed/skipped), delivery_kind,
  duration_bucket. Recorded at the write-once terminal execution row
  (finish_execution) and where the due scan drops an occurrence (catch-up
  disabled, expired one-shot). Job names, prompts and targets never leave.

Platform names: platforms Hermes ships under plugins/platforms/ now report by
name, and a plugin-catalog platform reports its catalog entry name only when
the installer-owned .install-metadata.json record proves a catalog install of
the plugin dir that defines the registered adapter factory (a URL install
cannot claim a catalog name via its tree or manifest). Everything else stays
"plugin". GATEWAY_PLATFORMS becomes catalog-backed (_CatalogValues), and the
v3 schema's platform fields accept published catalog identifiers.
2026-09-28 12:43:03 -07:00
teknium1
1548bd36da test: estop gateway stub answers get_command like a real MessageEvent 2026-09-28 12:43:03 -07:00
alt-glitch
318f6ac63f fix(cron): order execution, delivery and incident history by instant, not by text
The cron SQLite stores write hermes_time.now().isoformat(), an ISO string
that carries the local UTC offset. That offset changes at a DST transition
and after a timezone config change, so the text order of these columns is
not their time order. Across a fall-back, 01:10-05:00 is 20 minutes after
01:50-04:00 but sorts before it. latest_execution(), latest_executions()
(which fills job["latest_execution"] for `hermes cron list` and the
dashboard), list_executions() and its before_claimed_at cursor, the
delivery queue's pending-claim order, delivery and execution pruning, and
list_incidents() all returned the older row first.

Every ORDER BY and the cursor comparison on these columns now use
julianday(col), which SQLite resolves to the same instant for any offset.
The raw text stays as the next sort key, so rows within the same
millisecond (julianday's resolution) keep their microsecond order, and the
cursor compares the same (instant, text) key on both sides. Stored values
and the schema are unchanged, so existing rows with mixed offsets order
correctly without a migration.

latest_executions() moves from a correlated per-row subquery to one
ROW_NUMBER() window. With julianday() in the ORDER BY the index on
claimed_at no longer serves the per-row LIMIT 1, and the correlated form
took about 90 ms at 1000 rows; the window form takes about 1 ms.

Refs #86520
Thanks to #86889 (zhao0112) for the executions-ledger diagnosis.
2026-09-28 19:07:43 +05:30
teknium1
50b4c07c98 fix(cron): trim external-worker plugin discovery salvage to the bar (#121929, salvage #121950)
Keeps #121950's one-line fix — `discover_plugins()` under the payload's home override before
`hydrate_profile_secret_sources()` in `_run_external_worker_payload` (L3 ticker -> L5 child
process; T2/T4) — and trims around it: the comment keeps only the WHY, and the two salvaged
tests collapse into one invariant that proves the owning-profile scoping the issue asks for:
the worker's launch HERMES_HOME has no plugins, the payload's profile home ships a
`register_secret_source` plugin, and the plugin's value reaches the secret scope seen by
`run_one_job`. The bare-profile control case was already covered by
`tests/cron/test_restart_safe_worker.py::test_external_worker_adopts_execution_and_runs_payload_once`.
2026-09-28 05:28:05 -07:00
teknium1
b9372c409f fix(cron): satellite route lookup reads the primary's layered config (#121212, salvage #121595)
Trim of #121595: instead of consulting the managed file first and the raw user file second
through two new helpers, `_primary_profile_routes_for_current_home` now reads the primary home
through `gateway.config_loader.read_yaml_layers` — the exact user-file + managed-overlay layering
`load_gateway_config()` uses, so the satellite side cannot drift from the gateway's own routing
view (L8: behavioural read through the surface's loader, never `read_user_config_raw`). The
absent-user-file early return goes too: a fleet host with only `/etc/hermes/config.yaml` must
still see its routes, exactly as the gateway loader does.

Both consumers ride the one helper — preflight rescue (`_delivery_platform_routed_from_primary_gateway`)
and the delivery-time `SharedRouteAdapters` fallback in `scheduler_provider.tick_adapters_for` and
the `gateway/run.py` delivery-queue drain — so managed-scope routes stop false-blocking and stop
failing delivery closed on every satellite (T2/T4).

Tests trimmed to two invariants on real files: managed-only routes reach both preflight and the
delivery adapter map; managed routes replace user-file routes (list precedence).
2026-09-28 05:28:05 -07:00
D Yiapanis
411c0405b6 fix(cron): discover plugins in external workers so plugin secret sources hydrate
External cron workers start with the builtin secret-source registry alone —
plugin-registered sources (the documented path for third-party vaults,
developer-guide/secret-source-plugin) only exist after plugin discovery runs.
Since the restart-safe topology hands every scheduled agent-mode job to a
worker process, jobs on secondary profiles under a multiplexed gateway died
at runtime resolution with 'No usable credentials found for provider ...'
while identical direct runs in the gateway succeeded.

Run the idempotent discover_plugins() under the profile home override before
hydrating: discovery registers the profile's plugin sources and re-pulls
enabled ones (#64177 bootstrap parity), the same lazy-load pattern the
delivery path already uses for plugin platforms.

Fixes #121929

(cherry picked from commit 248d441559478978cbdae1cc2178856997bef5f3)
2026-09-28 05:28:05 -07:00
ahisblessed
40715d95a0 fix(cron): read managed-scope gateway.profile_routes in preflight (#121212)
_primary_profile_routes_for_current_home() read only the primary home's
raw config.yaml, so routes pinned in the managed scope (e.g.
gateway.profile_routes in /etc/hermes/config.yaml) never reached it:
preflight false-blocked satellite agent jobs and routed delivery failed
closed. The managed overlay is now consulted first (it wins over the user
file, as in the merged config), with the raw user read as fallback.

Fixes #121212

(cherry picked from commit 7b05243220e98a4c8e22587d51ca786492cb148b)
2026-09-28 05:28:05 -07:00
teknium1
8e9470cc61 fix(cron): trim the reconnect-only retention salvage to two router-driven invariants (#125363, salvage #125455)
Drop the four unit tests that pre-set `live_error` on the fixture (they never
exercised the population path the issue is about) and keep two invariants,
both driving the real DeliveryRouter so the rejection arrives on the EXCEPTION
arm of `_deliver_via_live_adapter` exactly as `send_path_degraded` does in
production (`DeliveryRouter._deliver_to_platform` raises the SendResult error):

- a reconnect-only rejection whose standalone fallback then fails on a
  token-less satellite worker is retained as a failed ledger row that
  `sweep_failed_for_runtime` claims for that adapter's profile;
- any other live failure still falls through to standalone as before and
  queues nothing.
2026-09-28 05:28:05 -07:00
Jay Zhou
8a33e1f61a test(cron): drive a reconnect-only rejection through the live router
(cherry picked from commit c0b8a209bd788ad649f1187a25429fa9e35922b0)
2026-09-28 05:28:05 -07:00