Skip to content

fix(cron): restore activated dependency site-packages for the external worker - #122290

Closed
123456hyu wants to merge 4 commits into
NousResearch:mainfrom
123456hyu:fix/cron-worker-dependency-site-packages
Closed

123456hyu wants to merge 4 commits into
NousResearch:mainfrom
123456hyu:fix/cron-worker-dependency-site-packages

Conversation

@123456hyu

Copy link
Copy Markdown

What does this PR do?

Fixes #122222 — on a self-managed (shell-installer / PM) install, every scheduled
cron job failed before it could run. The restart-safe external worker is spawned
with the store Python, and after the subprocess sanitizer drops the runtime
site-packages, the pin_hermes_tree_on_pythonpath counter-measure re-adds only
the checkout, leaving the worker with no path to its dependencies. It died at its
first import (No module named 'ruamel') before publishing its ownership ack, so
every job landed last_status: failed.

This restores the dependency environment this process actually activated, next to
the tree:

  • cron/scheduler_worker_env.py derives the activated site-packages from this
    process's own sys.path (the same signal
    pm.environments.running_from_selected_environment reads), excluding the
    interpreter's own purelib and gating on a real venv (pyvenv.cfg in an
    ancestor). When none is found the behaviour is unchanged (tree-only), so
    installs that already own their dependencies are unaffected.

Related Issue

Fixes #122222

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)

Changes Made

  • cron/scheduler_worker_env.py: pin_hermes_tree_on_pythonpath now also
    restores the activated dependency site-packages (order: tree, dependency,
    kept entries); new _activated_dependency_site_packages + _in_real_venv
    helpers. Derivation reads only sys.path — no home-scoped filesystem reads,
    so it stays clear of home_io_guard.
  • tests/cron/test_restart_safe_worker.py: covered both directions of the
    derivation (activated env restored; nothing invented when the runner owns its
    deps or the entry is not a real venv) and relaxed the exact-list assertion in
    test_launch_external_worker_pin_extends_the_sanitized_env_not_os_environ to a
    relationship assertion (tree first, sanitizer-kept preserved).

How to Test

  1. pytest tests/cron/test_restart_safe_worker.py
  2. On a self-managed install:
    PYTHONPATH="$HERMES_HOME/hermes-agent" "$STORE" -m cron.scheduler --help
    no longer raises ModuleNotFoundError: No module named 'ruamel'.

Notes

  • I could not run the full pytest tests/ suite: this machine is a Windows dev
    checkout and the target failure mode is the self-managed runtime install shape.
    The changed module's tests were run in the dev venv.
    tests/cron/test_restart_safe_worker.py passes for all pin/derivation tests
    (6 targeted + the existing pin tests). Two unrelated tests fail on Windows and
    fail identically on a clean checkout before this change:
    os.getuid is absent on Windows (a scoped-dispatch test that is Linux-only in
    practice but lacks the platform marker), and home_io_guard rejects the real
    C:\Users\...\hermes home during _resolve_hermes_bin_dir. Neither touches
    the changed code path.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass — not fully (see Notes)
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: Windows 11 (unit tests for the changed module)

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — or N/A (docstrings updated inline)
  • I've updated cli-config.yaml.example if I added/changed config keys — or N/A
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — or N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — or N/A (uses site-packages/dist-packages + a pyvenv.cfg ancestor probe that is layout-agnostic)
  • I've updated tool descriptions/schemas if I changed tool behavior — or N/A

…l 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 NousResearch#122222
@alt-glitch alt-glitch added type/bug Something isn't working P1 High — major feature broken, no workaround comp/cron Cron scheduler and job management sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Sep 25, 2026
@whyyagswhy

Copy link
Copy Markdown

Independent verification on the PR head (0a86331): restart-safe-worker suite 30/30 green on Linux. Restoring activated dependency site-packages for the external worker keeps the restarted worker on the right environment. No findings.

@123456hyu

Copy link
Copy Markdown
Author

Local verification against the fix in this PR

Applied this diff on top of main and ran the cron path end-to-end; the repro from #122529 clears with it:

Before the fix (repo root only on PYTHONPATH, as pin_hermes_tree_on_pythonpath currently produces on a self-managed install):

$ python3 -m cron.scheduler --help
ModuleNotFoundError: No module named 'ruamel'

After the fix (this PR, _activated_dependency_site_packages() pinning the activated dependency env alongside the repo root):

$ python3 -m cron.scheduler --help   # exits 0

End-to-end against a live gateway (before → after):

  • no_agent script job: failed → completed
  • normal cron job: failed → completed

Both were failing with the ruamel traceback before the fix and reaching completed after.

One note for reviewers comparing against #122238: this implementation derives the dependency path from the process's own sys.path (guarded by pyvenv.cfg), rather than re-resolving selected_venv()/site_packages() from pm.environments at spawn time — keeps the child-spawn path free of home-scoped filesystem reads, and matches how pm.environments.running_from_selected_environment actually signals the active generation.

@Guerilla-ops

Copy link
Copy Markdown

Independent verification of the same failure mode converged on this same shape — restoring the activated site-packages at the spawn site, derived from this process's sys.path, is the right approach (it answers "what did our own imports actually resolve through", with no home-scoped filesystem reads — preferable to the selected_venv() sketch in the issue for exactly that reason).

Mechanism confirmed at a real boundary: python -S reproduces the store-Python child exactly — ModuleNotFoundError: No module named 'ruamel' — and the same invocation with PYTHONPATH=<venv>/site-packages imports cleanly.

Three edges around the fix, none blocking the reported topology:

  1. Interpreter-capability vs purelib exclusion. The real predicate is "can the spawned interpreter re-derive this dir", and purelib exclusion approximates it — but diverges in the uv-launcher class: Scripts/python.exe re-execs the base console python keyed on VIRTUAL_ENV, which _strip_hermes_owned_pythonpath_and_runtime_markers removes. There purelib == <venv>/Lib/site-packages, the discovery excludes it, nothing is pinned, and the re-exec'd child still can't import deps. A pyvenv.cfg-adjacent-to-sys.executable gate (with the uv cfg marker treated as not-self-supplying — same diagnosis as cron/scheduler_script.py::_windows_cron_python_invocation) covers it. External workers are Linux-gated via restart_safe_gateway_child_argv today, so this doesn't fire yet — worth noting since it lives in the same predicate.

  2. First match only. _activated_dependency_site_packages returns the first venv site-packages on sys.path. If a runtime ever carries more than one activated environment, the later dirs stay lost — collecting all matches costs nothing extra.

  3. Ordering. [root, site_packages, *existing] puts the dep env ahead of kept user PYTHONPATH entries — the reverse of normal sys.path precedence (site-packages sorts last). Appending after existing keeps user entries authoritative; either resolves ruamel, but the shadow direction is worth choosing deliberately.

Also a nit: the diff drops the trailing newline at end of cron/scheduler_worker_env.py.

@andrexibiza andrexibiza left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restoring the dependency path fixes the reported store-Python import failure without rebuilding the child environment from raw os.environ. Keep that, the wheel/pipx early return, and the existing execution-adoption protocol. There is a separate lifetime gap in the handoff: the child receives the dependency directory, but not the protection that keeps that directory available.

Reviewed head: 0a8633160e8b9767823cb2dc1895bf2689910b69.

P2 — protect the dependency generation before the worker starts importing it

The new insertion at scheduler_worker_env.py:105–110 forwards a pathname. It does not acquire or transfer a lifetime lease for its generation.

The actual launch path builds sys.executable -m cron.scheduler, sanitizes the environment, applies this pin, then starts a separate process. That module begins application/dependency imports before normal dependency activation.

The existing PM activation contract does more than add a path: activate_dependencies() resolves the committed environment, leases it under the install-lock/reselection protocol, and processes it with site.addsitedir() before application imports. In runtime_state.py, that lease is a process-owned kernel lock. The collector can delete an unselected, lease-managed generation once it is old enough and no process holds its lease. PYTHONPATH does not keep that lock alive.

The failing sequence is:

  1. A gateway is still running on G1 after PM has selected G2. G1 is older than the collector's normal 86,400-second threshold.
  2. The gateway gives a new external worker G1's dependency path.
  3. The gateway exits while the child is still importing, before the child has performed any later bootstrap activation. Its lease is released.
  4. Collection removes G1 while the child is alive. The child's next import from G1 fails.

I reproduced that sequence with the production pin, lease, collector and lock implementations in an isolated fixture. A genuine child-held lease prevents deletion; merely keeping the child alive does not.

This is conditional, not a claim that ordinary jobs still fail with the reported ruamel error. The direct-entry/bootstrap-order gap predates this PR; restoring import reachability does not close its lifetime requirement. Also, agent workers are not necessarily unleased forever: run_agent.py imports hermes_bootstrap, which may activate dependencies later. That cannot protect imports already attempted before reaching it.

Please establish an installation-bound dependency generation and its lifetime protection at the worker entry, before the first application/dependency import. Reuse PM's activation/lease machinery and keep protection continuous across parent exit and the child's required use. A directory name or pyvenv.cfg is not a retention permit. If the intended behavior is to boot the latest committed generation instead of retaining the parent's generation, select and lease it before importing—not halfway through a dependency graph loaded from G1. Preserve the sanitized environment and the existing handoff, adoption and recovery fences.

The regression needs the real external-worker entry, an old still-running gateway generation, a newly selected generation, controlled parent exit and actual collection. Assert that the required generation remains usable throughout the handoff. Include a child-held lease, a still-selected generation, a younger generation and a legacy unmanaged generation as controls. Exercise pre-ack failure/recovery without turning uncertain execution into a duplicate job.

Verification

I re-ran the retained characterization on Linux / Python 3.13.5 before submitting this review. It uses three complete modules pinned to the reviewed source blobs:

  • cron/scheduler_worker_env.py: 026c3209bb1aab0f653b1513994c10f005667cf9.
  • hermes_cli/runtime_state.py: 7a74b6e9d5fd65cf768f723f6e382ffaa55fe3c1.
  • pm/filesystem.py: f7dc30b48239706f37e94ad9aa8be984d9eeedf0.

The pin, collector, lease functions, kernel locks, child processes, imports and deletion are real. Install-path/selection metadata and the child job body are fixtures. This is not full cron.scheduler/systemd E2E, a complete repository checkout, store-Python 3.14 acceptance, or native Windows/macOS verification.

The five lifetime characterizations confirmed that the parent lease initially retains G1; releasing it allows deletion and a late ModuleNotFoundError in the unleased child; a child lease prevents both; and selected, young and legacy unmanaged generations remain retained. The sixth case confirms the already-discussed .pth limitation: path-only forwarding does not perform site.addsitedir() activation. That is not a second newly discovered security vulnerability.

PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 python -m pytest test_generation_boundary.py -q -s \
  -k 'not required_invariant' --basetemp=<fresh disposable directory>
6 passed, 1 deselected in 4.25s; exit 0

PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 python -m pytest test_generation_boundary.py -q -s \
  -k required_invariant --basetemp=<different disposable directory>
1 failed, 6 deselected in 0.61s; exit 1

The failing assertion is the required property: a live worker must not lose the generation supplied by its pin. The six passing characterizations reproduce and bound the failure; they do not mean it is fixed. Harness SHA-256: 31fd98ccd533469a18de7687d63ab53c8707ebbd801aa52e980cf468acec8bf6.

The added tests cover path derivation and a stubbed launch, not collection during handoff. The 30/30 Linux report and author's live-job verification remain useful evidence of the original fix. I did not independently execute those suites or live jobs, and claim no full adjacent-suite, packaged-runtime or independent 5×2×3 acceptance.

Coordination and acceptance

JoanDuarte's #122222 is the original failure analysis and path-restoration proposal; retain that credit alongside 123456hyu's implementation and verification here.

The comparison with webtecnica's #122238 needs an exact-source qualification. At reviewed head 663403201ba7c9fce7e47a0a72637b0e81238adf, its diff also scans sys.path and checks a pyvenv.cfg ancestor; it does not use the selected_venv() spawn-time strategy described in the comparison comment. Both reviewed diffs change cron/scheduler_worker_env.py and tests/cron/test_restart_safe_worker.py for the same issue. Consolidate that implementation choice and preserve the relevant contributions rather than landing parallel helpers. The lifetime requirement applies whichever import-restoration implementation is retained.

The recorded PR base is 138e33d51f521cde606f7e6a00cdc9a203275ed3. The original review compared against main 59004a62356f3a4697ab0fe8ad5086d2b405e2a6, with merge base 8e3b0d1566d12ca9b593ba6627c0c70ef2359e52; those are comparison pins, not a claim that main has stopped moving. Publication revalidation still finds the same head, open/non-draft/mergeable, with one commit and two changed files. Main movement alone is not a finding or an automatic rebase requirement.

Exact-head workflows were rechecked and remain action_required, not green and not failed executed tests: CI 36095196098, Docker 36095195874, and Nix 36095195853. The sole commit therefore has no green acceptance receipt. I cannot authorize those workflows.

The relevant architecture is generation ownership and exact realization under #93943, #90145 and #92618; PM already owns the activation/retention mechanism. This is not a request to finish unrelated fleet work before fixing cron. The bounded review covers dependency realization, sanitized environment composition, process lifetime, collection and the handoff/recovery path. It does not establish a remote privilege escalation, containment escape or whole-repository security clearance. The actual worker-entry lifetime regression remains the missing proof.

pinned = [root]
dependency = _activated_dependency_site_packages()
if dependency is not None:
pinned.append(str(dependency))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — forwarding the dependency directory does not retain its generation. This child enters cron.scheduler directly and begins dependency imports before normal bootstrap activation. If the parent exits in that interval, its process-owned lease disappears; an old, unselected lease-managed generation can then be collected while the worker remains alive. The isolated production pin/lease/collector probe reproduced deletion followed by ModuleNotFoundError; a child-held lease prevented it. Establish and protect the installation-bound generation before application imports, using the existing PM activation/lease contract. Later run_agent bootstrap does not protect earlier imports. Add actual external-worker parent-exit/collection coverage with child-lease, selected, young and legacy controls. This is a conditional lifetime gap in the restored path, not a claim that all cron runs fail.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in #126344, which carries this PR's pin and lease commits with authorship preserved. The child-held lease is now established by PM's own activate_dependencies in the cron package prelude, before any application import. test_marked_worker_keeps_its_generation_through_a_rotation checks it against the real collector: the worker's generation survives a newer commit while the worker lives, and is collected after it exits.

@YogaSakti

Copy link
Copy Markdown

Independent verification from a real self-managed install, in case it helps this land.

Install shape (Ubuntu 24.04.5): PM store Python (~/.hermes/tools/python-3.14.7*/bin/python3) plus a staged runtime generation (~/.hermes/installs/<key>/environments/<gen>/venv), gateway started with no PYTHONPATH in /proc/<pid>/environ. Three scheduled jobs, every fire failing at dispatch with No module named 'ruamel' (exit 1 before the ownership ack).

A/B, gateway-like env — parent carries the activated generation on sys.path exactly as activate_dependencies leaves it, worker env built without PYTHONPATH:

  • main @ 59004a6: ModuleNotFoundError: No module named 'ruamel', rc=1
  • this branch: pinned PYTHONPATH = [<repo>, <gen>/venv/lib/python3.14/site-packages], worker rc=0, import ruamel.yaml resolves

After applying it, that install's next fires complete through the external worker path (completed, source=builtin), so this does fix #122222 end to end on a self-managed install.

One caveat worth checking before merge. Running the suite from that same install — the test process carries the generation on PYTHONPATH, which is how it has to run when the interpreter has no pytest of its own — gives 7 failed, 23 passed in tests/cron/test_restart_safe_worker.py, and the cause is tests/home_io_guard.py:

AssertionError: TEST BUG: file I/O against the REAL hermes home:
/root/.hermes/installs/<key>/environments/<gen>/venv/lib/python3.14/pyvenv.cfg

That is _in_real_venv()'s ancestor pyvenv.cfg probe: when the activated generation lives under the home (where PM puts it), the probe is a home-scoped read, the guard refuses it, and every test that reaches the spawn path fails. It will not fire on CI, where the test process carries no activated generation — but it does mean the derivation is not "sys.path only" as the PR notes describe: the ancestor probe reads the real home.

If you want the spawn path to stay clear of home reads entirely, the committed selection already names the environment (pm.environments.selected_venv(root) + site_packages()), and tools.environments.local_pythonpath._get_hermes_site_packages() is the exact ownership set the sanitizer strips — i.e. the set the pin is restoring.

Failing set, for reference: test_scoped_wrapper_exit_without_user_bus_names_the_cause_and_invalidates_probe, test_launch_external_worker_uses_restart_safe_scope_and_acknowledges, test_launch_external_worker_honors_ack_within_adoption_grace, test_worker_dying_before_ack_names_its_stderr_cause, test_launch_external_worker_degrades_by_default_with_real_helper, test_launch_external_worker_pins_the_gateways_tree_on_pythonpath, test_launch_external_worker_pin_extends_the_sanitized_env_not_os_environ.

… entry (NousResearch#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 NousResearch#122290.
@123456hyu

Copy link
Copy Markdown
Author

P2 lifetime finding — addressed in commit a9b6a8

Thanks for the careful characterization. The lease gap is now closed at the worker entry:

New cron/worker_bootstrap.py runs before the first application/dependency import in cron/scheduler.py:

  • lease_dependencies() — acquires the committed generation's kernel lease via hermes_cli.runtime_state.lease_generation (the same primitive activate_dependencies uses internally), so the collector's "no process holds the lease" safety condition now keeps the generation alive for the worker's whole lifetime. Unconditional and idempotent; released by atexit on normal exit or crash.
  • activate_worker_dependencies() — delegates to pm.environments.activate_dependencies (same committed_venv 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.

Both degrade to a logged no-op when no dependency generation is committed.

New tests in tests/cron/test_restart_safe_worker.py covering the lifecycle directly:

  • test_worker_bootstrap_lease_and_activation — worker flag present: lease + activation both fire.
  • test_worker_bootstrap_leases_but_skips_activation_in_gateway — no flag: lease only.
  • test_worker_bootstrap_no_committed_venv_is_noop — no committed venv: neither fires.
  • test_cron_scheduler_module_hooks_worker_bootstrap — import cron.scheduler with the worker flag set does not raise (graceful degradation on a dev checkout).

Two notes for the next review pass:

  1. Lease target. lease_dependencies() passes the committed venv path to lease_generation(), which internally leases venv.parent (the generation directory carrying .lease-managed). This is the same call activate_dependencies makes, so the lease is on the same directory the collector checks via leases_held(). A genuinely child-held lease on that generation now prevents collection during the handoff — the required_invariant case in your fixture.
  2. Consolidation with fix(cron): restore activated dependency site-packages in external worker PYTHONPATH #122238. Acknowledged — at 663403 its diff also scans sys.path and checks a pyvenv.cfg ancestor, not the selected_venv() sketch I described in my earlier comparison comment. Happy to consolidate the import-restoration choice into one PR; the P2 lease layer applies whichever shape is kept and is orthogonal to that decision.

The two home_io_guard failures in this suite (test_scoped_wrapper_exit_without_user_bus… and test_launch_external_worker_uses_restart_safe_scope…) are pre-existing on a clean checkout of this branch and unrelated to this change.

@123456hyu

Copy link
Copy Markdown
Author

CI: re-run needed on head a9b6a8 (3 workflows stuck action_required)

The P2 lease fix landed as commit a9b6a8 ("hold the committed dependency generation's lease at worker entry"). The three exact-head workflows are currently in action_required state, so the new commit has no green acceptance receipt:

  • CI run 36157714845
  • Docker Build, Test, and Publish run 36157713939
  • Nix flake check run 36157714189

@andrexibiza — you did the P2 review and the independent characterization; could you re-run (or ping whoever has repo admin to re-run) the three action_required workflows on this head so the lease commit gets a green receipt? The lease layer is orthogonal to the #122238 consolidation choice, so whichever import-restoration shape ultimately lands, these CI runs still need to pass on a9b6a8.

@alt-glitch alt-glitch added the needs-decision Awaiting maintainer decision before any implementation label Sep 25, 2026
@andrexibiza

Copy link
Copy Markdown

@123456hyu Patience. Pinging people for this does not help your cause.

@123456hyu

Copy link
Copy Markdown
Author

Noted — dropping the CI ping. The P2 lease fix is in a9b6a8; I'll hold off until repo maintainers re-run the action_required workflows and pick it up at their own pace. No further pinging.

@alt-glitch alt-glitch removed the needs-decision Awaiting maintainer decision before any implementation label Sep 25, 2026
ethernet8023 pushed a commit that referenced this pull request Sep 25, 2026
… 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.
ethernet8023 added a commit that referenced this pull request Sep 25, 2026
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.
ethernet8023 added a commit that referenced this pull request Sep 25, 2026
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>
@s011076

s011076 commented Sep 26, 2026

Copy link
Copy Markdown

Independent verification on macOS (arm64) — same failure mode, and the first non-Linux data point in this thread

Every report on #122222 / #122529 so far is Linux (Ubuntu, Arch, Debian/LXC, aarch64). Adding a Darwin/arm64 row: it reproduces identically, and restoring the activated generation's site-packages clears it at the spawn boundary.

Install shape

  • macOS 27.0, Apple Silicon (Darwin arm64), shell-installer / PM self-managed, single profile
  • store Python 3.14.7; deps under ~/.hermes/installs/<install-id>/environments/<gen>/venv/lib/python3.14/site-packages
  • Two generations committed on this host (545d21a5…, 8a535501b…); hermes doctor reports 8a535501b… as the active one — relevant to the lifetime discussion below

Spawn-boundary A/B

Exact child command ([sys.executable, "-m", "cron.scheduler"]) with the sanitizer's env shape. -S is used only to stop unsolicited .pth files from masking the pre-fix state:

# (A) repo root only — unchanged behaviour
PYTHONPATH=$REPO_ROOT python3 -S -m cron.scheduler --help
  cron/__init__.py -> cron/jobs.py -> cron/env_settings.py -> agent/secret_scope.py
    -> utils.py -> hermes_yaml.py:10
  ModuleNotFoundError: No module named 'ruamel'   (exit 1)

# (B) same PYTHONPATH + activated generation's site-packages
PYTHONPATH=$REPO_ROOT:$GEN/venv/lib/python3.14/site-packages python3 -S -m cron.scheduler --help
  exit 0

Line-for-line the same chain as the existing reports, on a platform they don't cover.

End-to-end, not just the import: on this host, before the dependency path was restored, the job store showed scheduled jobs landing with the worker-stderr traceback and never reaching their ownership ack; after restoring the activated generation's site-packages, the same script job and a normal cron job both went failed → completed. That is the signal @liuhao1024 asked to attach to an existing PR instead of a third implementation — I am not proposing code here, only confirming the mechanism on another platform.

Supporting the P2 (generation lifetime) with a field observation

We run a local workaround on this same seam, and it hit this class of problem directly:

  • a statically pinned dependency path is a snapshot — correct only until the next generation is committed
  • with two generations on disk, mtime is not a reliable way to pick the active one (hermes doctor is; a stale-by-luck pick is exactly how the workaround breaks silently)
  • simulating the stale case (pinned path pointing at a removed generation) fails at the same import line, so resolution has to happen per spawn, not once at handoff

The lease_dependencies() + per-spawn activation added in a9b6a8 (cron/worker_bootstrap.py) is the right shape for both halves: the lease closes the "collected between gateway exit and first import" window, and per-spawn resolution closes the "the path was true when pinned, not when used" window. A regression test for the static-pin/stale-generation case would pin the second half down.

Not re-verified here: Windows, and the in-tree venv/ two-lineage shape from #122395 (this host is store-Python only).

Happy to re-run both legs against a new head on request.

@alt-glitch alt-glitch added the needs-decision Awaiting maintainer decision before any implementation label Sep 26, 2026
@Enough1122

Copy link
Copy Markdown

AI code review — automated follow-up for reference; not a maintainer.

This PR makes the restart-safe cron external worker survive on a self-managed (shell-installer / PM) install in two parts. cron/worker_bootstrap.py is new and adds a lease-then-activate bootstrap; cron/scheduler.py:38-44 calls it at module scope, before the first Hermes package import; cron/scheduler_worker_env.py gains _in_real_venv / _activated_dependency_site_packages so the spawned child's PYTHONPATH also carries the activated dependency site-packages. Both halves address real failures (No module named 'ruamel', #122222, and collector removal of a generation between gateway exit and worker import). The lease direction is right; three things around the lease look incorrect or unverified.

  1. lease_dependencies reads the generation outside runtime_lock, so the protection it buys is racy — the collector's own condition makes this matter. cron/worker_bootstrap.py:75-97 resolves the venv in _activate_generation() and then calls lease_generation(venv) at line 95 with no runtime_lock held. But the collector's safety condition is evaluated under that same lock: hermes_cli/runtime_state.py:180-196 takes runtime_lock(project), computes the selected generation, and only then checks leases_held(generation) at line 194. The docstring of lease_generation (runtime_state.py:137-143) says this explicitly — "Call under runtime_lock at boot. Without the lock the caller must re-read the selection after leasing: an installer may have moved it in between, and an unselected, unleased generation is exactly what the collector removes." — and activate_dependencies obeys it (pm/environments.py:308-320: lock, recover_publication, re-read-and-re-lease loop). worker_bootstrap takes neither the lock nor the re-read loop, so the window it closes is the same window lease_generation warns about: between line 95's committed_venv() and the lease landing, an installer can commit a newer generation, leaving this worker holding a lease on exactly the unselected generation the collector is about to rmtree. Repro shape: start the worker, and in parallel commit a new dependency generation and run collect_generations with min_age_seconds=0 on the old one. Suggestion: mirror activate_dependencies — wrap the resolve+lease in runtime_lock(project_root), and if the lock was not obtained, re-read committed_venv and re-lease, as pm/environments.py:319 does.

  2. The lease taken at line 95 is the wrong generation relative to what the worker then imports, and the two steps can resolve different trees. worker_bootstrap (worker_bootstrap.py:120-141) leases via committed_venv(_root) and then, separately, activate_worker_dependencies() calls activate_dependencies(_root), which again resolves committed_venv and takes its own lease (pm/environments.py:313-314). So on a normal run the process holds two leases on two independently-resolved generations. If the second resolution returns a different generation than the first, the worker keeps site-packages for generation B (activate_dependencies rewrites sys.path, pm/environments.py:331-337) while the lease from step 1 protects generation A — the retention guarantee is then on a tree the worker no longer imports. The lease would also be effectively redundant with the one activate_dependencies takes anyway, which is the lease that actually matters. Suggestion: since activate_dependencies already leases whatever it activates, consider dropping the separate pre-lease and relying on it, or if the early lease is required by the "before the first import" ordering, lease the exact value activation will use by calling the two under one lock and asserting the generations agree.

  3. The module-scope hook at cron/scheduler.py:43-44 runs in the gateway too, and the claim in its own comment about ordering is not true for the module-scope position. The comment at lines 38-42 says the call is *before* any Hermes package import, and it is — relative to the imports that follow on line 46 onward. But the gateway also imports this module, and the PR's own design acknowledges the gateway does not need it: _is_restart_safe_worker() (line 144) gates activation on "--external-worker-file" in sys.argv, while leasing is unconditional by design (worker_bootstrap.py:137, "Leasing is unconditional"). That makes every import cron.scheduler — including the long-lived gateway, and including any CLI hermes cron invocation that touches this module — acquire a kernel lease and create .leases/<uuid> on an install-state directory. This is normally harmless, but note two consequences worth confirming: (a) lease_directory creates the .leases directory with exist_ok=True and does not fsync the parent, so a gateway crash between os.open and _lock can leave a lease file that leases_held (line 200-208) treats as held if the fd happens to be locked — low probability, but it is a new persistent artifact on the install path; (b) the new unconditional lease is taken without runtime_lock, which is the same issue as item 1 and applies to the gateway path as well. Also, test_cron_scheduler_module_hooks_worker_bootstrap (added in tests/cron/test_restart_safe_worker.py) only asserts that importing cron.scheduler does not raise — it never asserts that the hook ran before the first Hermes import, which is the invariant the comment claims, so a future edit that moves line 43-44 below the hermes_constants import would keep the suite green.

Minor:

  • cron/scheduler_worker_env.py now ends without a trailing newline (\ No newline at end of file). Please add one; some linters and the docs build flag it.
  • pin_hermes_tree_on_pythonpath appends the activated dependency site-packages to the child PYTHONPATH after repo_root. The child is Hermes, so leaking its dependency paths to a user child is not a concern here, but the function's sibling path in the same file keeps the "pin the tree only" invariant documented — worth a one-line note that this entry is dependency-scoped and not propagated to non-Hermes children, so a future refactor does not re-generalize it.
  • The two monkeypatch.setattr("hermes_cli.runtime_state.lease_generation", ...) / monkeypatch.setattr(_rs, "lease_generation", ...) calls in test_worker_bootstrap_lease_and_activation patch the same target twice; the string form and the object form are redundant. Harmless, but dropping the first keeps the intent clearer.
  • On the test at tests/cron/test_restart_safe_worker.py that previously asserted entries == [...] exactly and now asserts relationship instead — that relaxation is justified and documented in the comment (the count really is runner-dependent), and the new dedicated test pins the exact ordering, so coverage is not lost. Just noting the exact-list assertion loss is intentional and covered.

Unverified / please confirm:

  • I reviewed against pm/environments.py and hermes_cli/runtime_state.py on main, not at this PR's head, since neither file is touched by the diff. If this PR is stacked on another that changes those two, the item-1 and item-2 analysis should be re-run against that base.

@123456hyu

Copy link
Copy Markdown
Author

Handoff note: the lease findings on a9b6a8 are structurally resolved by the consolidation

The three lease observations from the 05:29 AI review target the bootstrap shape in this PR's commit a9b6a8. Verified against the diff of the consolidation at #122936 (rework commit 7c5571):

  1. Out-of-lock lease race — the separate lease_dependencies() step was dropped; boot is now a single pm.environments.activate_dependencies() call, which runs resolve + lease under runtime_lock with the re-read/re-lease loop.
  2. Two independently-resolved leases — resolution, leasing and activation now happen in one place; there is no separate pre-lease that can protect a different generation than the one actually imported.
  3. Gateway-path .leases/ artifact — lease and activation are gated on the HERMES_CRON_EXTERNAL_WORKER env marker (set at the spawn site, consumed via os.environ.pop on read, not propagated to the worker's children), so a gateway-side import cron.scheduler takes no lease at all.

The macOS arm64 field data in this thread (two committed generations; a stale pinned path failing at the same import line) supports the per-spawn resolution the consolidation implements; the suggested stale-generation regression test belongs on #122936.

One heads-up for maintainers before closing this thread as superseded: #122936's CI (run 36173674318 on 25c1dc5) currently shows two failing e2e jobs — Linux tests/e2e/core/delivery/test_cron_virtual_clock_soak.py (900s subprocess cap, SIGKILL'd) and Windows tests/e2e/core/windows/test_boot_lifecycle.py::test_gateway_stop_drains_gracefully ("No gateway running for this profile"). Unverified whether these are flakes or rework-adjacent; flagging so they are not missed.

This PR's two commits are carried verbatim into #122936 as the salvaged base with authorship intact.

kshitijk4poor pushed a commit to kshitijk4poor/hermes-agent that referenced this pull request Sep 28, 2026
… entry (NousResearch#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 NousResearch#122290.

(cherry picked from commit 429f0dd)
kshitijk4poor pushed a commit to kshitijk4poor/hermes-agent that referenced this pull request Sep 28, 2026
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'`, NousResearch#122222) before it could publish its ownership
acknowledgement -- every scheduled job landed `last_status: failed`.

Salvaged from NousResearch#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: NousResearch#122238 (@webtecnica, earliest), NousResearch#122382 (@Sakujakira), NousResearch#122685
(@etrisnanto), NousResearch#122719 (@aglowinthefield) and NousResearch#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 7c55715)
kshitijk4poor added a commit to kshitijk4poor/hermes-agent that referenced this pull request Sep 28, 2026
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 (NousResearch#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 NousResearch#122290
(dff16e1); this carries them without the argv gate and source-order test.

Co-authored-by: 123456hyu <123456hyu@users.noreply.github.com>
OutThisLife pushed a commit that referenced this pull request Sep 28, 2026
… 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.
OutThisLife pushed a commit that referenced this pull request Sep 28, 2026
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>
OutThisLife pushed a commit that referenced this pull request Sep 28, 2026
… 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.
OutThisLife pushed a commit that referenced this pull request Sep 28, 2026
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>
kshitijk4poor pushed a commit to kshitijk4poor/hermes-agent that referenced this pull request Sep 28, 2026
… entry (NousResearch#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 NousResearch#122290.

(cherry picked from commit 429f0dd)
kshitijk4poor pushed a commit to kshitijk4poor/hermes-agent that referenced this pull request Sep 28, 2026
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'`, NousResearch#122222) before it could publish its ownership
acknowledgement -- every scheduled job landed `last_status: failed`.

Salvaged from NousResearch#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: NousResearch#122238 (@webtecnica, earliest), NousResearch#122382 (@Sakujakira), NousResearch#122685
(@etrisnanto), NousResearch#122719 (@aglowinthefield) and NousResearch#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 7c55715)
kshitijk4poor added a commit to kshitijk4poor/hermes-agent that referenced this pull request Sep 28, 2026
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 (NousResearch#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 NousResearch#122290
(dff16e1); this carries them without the argv gate and source-order test.

Co-authored-by: 123456hyu <123456hyu@users.noreply.github.com>
kshitijk4poor pushed a commit to kshitijk4poor/hermes-agent that referenced this pull request Sep 28, 2026
… entry (NousResearch#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 NousResearch#122290.

(cherry picked from commit 429f0dd)
kshitijk4poor pushed a commit to kshitijk4poor/hermes-agent that referenced this pull request Sep 28, 2026
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'`, NousResearch#122222) before it could publish its ownership
acknowledgement -- every scheduled job landed `last_status: failed`.

Salvaged from NousResearch#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: NousResearch#122238 (@webtecnica, earliest), NousResearch#122382 (@Sakujakira), NousResearch#122685
(@etrisnanto), NousResearch#122719 (@aglowinthefield) and NousResearch#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 7c55715)
kshitijk4poor added a commit to kshitijk4poor/hermes-agent that referenced this pull request Sep 28, 2026
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 (NousResearch#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 NousResearch#122290
(dff16e1); this carries them without the argv gate and source-order test.

Co-authored-by: 123456hyu <123456hyu@users.noreply.github.com>
kshitijk4poor pushed a commit that referenced this pull request Sep 28, 2026
… 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 429f0dd)
kshitijk4poor pushed a commit that referenced this pull request Sep 28, 2026
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 7c55715)
kshitijk4poor added a commit that referenced this pull request Sep 28, 2026
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
(dff16e1); this carries them without the argv gate and source-order test.

Co-authored-by: 123456hyu <123456hyu@users.noreply.github.com>
krishkumar added a commit to krishkumar/hermes-agent that referenced this pull request Sep 28, 2026
* upstream/main:
  fix(desktop): keep inherited NODE_OPTIONS intact when adding the builder heap
  fix(desktop): pack no longer runs through cross-env (NousResearch#110121, NousResearch#103010)
  fix(cron): stop the external worker when PM refuses its dependency boot
  refactor(cron): rename the worker boot marker and correct the pin's docs
  fix(cron): boot the external worker's PM dependencies before cron.jobs
  docs(cron): record the external worker's self-boot contract
  fix(cron): boot the external worker through PM's committed selection
  fix(cron): hold the committed dependency generation's lease at worker entry (NousResearch#122290 P2)
  fix(cron): restore activated dependency site-packages for the external worker
  chore: map contributor emails
aryn-lacy pushed a commit to aryn-lacy/hermes-agent that referenced this pull request Sep 30, 2026
… entry (NousResearch#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 NousResearch#122290.

(cherry picked from commit 429f0dd)
aryn-lacy pushed a commit to aryn-lacy/hermes-agent that referenced this pull request Sep 30, 2026
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'`, NousResearch#122222) before it could publish its ownership
acknowledgement -- every scheduled job landed `last_status: failed`.

Salvaged from NousResearch#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: NousResearch#122238 (@webtecnica, earliest), NousResearch#122382 (@Sakujakira), NousResearch#122685
(@etrisnanto), NousResearch#122719 (@aglowinthefield) and NousResearch#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 7c55715)
kshitijk4poor pushed a commit that referenced this pull request Oct 7, 2026
…e its ack

The external worker first imported hermes_bootstrap through run_agent inside
run_one_job, after it had read (and deleted) its payload and published its
ownership ack. When the dependency inputs were not current, prepare_launch
synced and relaunched the worker into an -I interpreter that ignores the
pinned PYTHONPATH, and worker_bootstrap had already popped the boot marker:
the new process died on `import ruamel` and the adopted run was left
`unknown` (#122290 follow-up).

The worker's __main__ now runs hermes_bootstrap (finish_worker_boot) before
it touches the payload, and the marker is dropped only after that, so a
relaunch replays the whole worker and boots its dependencies again. The
call cannot live in worker_bootstrap(): during `-m cron.scheduler`'s package
import sys.argv[0] is still "-m" and __main__ has no spec, so the relaunch
command could not name the module. Covers the POSIX execv and Windows
subprocess.call relaunch branches alike.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit b537b59)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/cron Cron scheduler and job management needs-decision Awaiting maintainer decision before any implementation P1 High — major feature broken, no workaround sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: cron external worker cannot import dependencies on self-managed installs — every scheduled job fails before its ownership ack

10 participants