fix(cron): stop multiplex ticker recreating archived profile homes - #94604
chelsealong wants to merge 2 commits into
Conversation
_start_multiplex() ticks a fixed profile_homes list captured once when the ticker thread starts. When an admin archives a profile (moves its directory out from under profiles/) while the ticker keeps running, the stale entry's heartbeat write still calls ensure_dirs()'s mkdir(parents=True, exist_ok=True), which silently recreates the directory the admin just removed, within one tick interval (NousResearch#94590). Skip an entry whose home no longer exists on disk before ticking or heartbeating it, in all three loops that walk profile_homes. Self-heals without a restart once the directory is gone.
This PR addresses a real bug (#94590): when an admin archives a profile by removing its directory while the multiplex cron ticker is running, the ticker's heartbeat path calls Concern 1 — TOCTOU race on Concern 2 — No cleanup of Concern 3 — Test only covers one archive scenario: The test archives The |
Adds test_multiplex_ticker_resumes_after_profile_unarchived per review feedback on NousResearch#94604, covering the self-heal claim in _start_multiplex's docstring: once an archived profile's directory reappears, the next tick writes its heartbeat again.
|
Added |
|
Thanks @chelsealong — correct diagnosis, and the Closing as redundant. #94590 stays open for the maintainer to reconcile against that commit. |
Fixes #94590.
Root cause
InProcessCronScheduler._start_multiplex()(cron/scheduler_provider.py)ticks the
profile_homeslist it was handed once, when the ticker threadstarts (
hermes_cli/web_server.py::_start_desktop_cron_tickerbuilds thatlist from a single
profiles_to_serve(multiplex=True)scan and neverrefreshes it for the life of the desktop backend).
When an admin archives a profile —
mv profiles/research profiles/_archived/research-...— while the ticker thread is alreadyrunning, the stale
("research", <path>)entry stays in that capturedlist. Every subsequent tick still does:
record_ticker_heartbeat→_atomic_write_epoch→ensure_dirs()callsstore.cron_dir.mkdir(parents=True, exist_ok=True), wherecron_dir = home / "cron".mkdir(parents=True)happily recreateshomeitself if it'smissing — so the very directory the admin just moved away reappears within
one tick interval (60s), containing only
cron/ticker_heartbeat+ticker_last_success, exactly as reported.This hits all three places in
_start_multiplexthat walkprofile_homes:the initial recovery+heartbeat pass, the per-tick
cron_tickloop, and thepost-tick heartbeat loop.
Fix
Before scoping to a profile's home in each of those three loops, skip the
entry if
Path(home).is_dir()is nowFalse. This is a cheap check (noextra I/O beyond a stat) and self-heals on the very next tick once a
profile directory is gone — no ticker restart required. It does not touch
profiles_to_serve/directory scanning at all, since the recreation happensdownstream of that scan, in the ticker's per-iteration store access.
Test
Added
test_multiplex_ticker_does_not_recreate_archived_profileintests/cron/test_scheduler_provider.py. It drivesInProcessCronScheduler._start_multiplexsynchronously (no backgroundthread) via a fake
stop_eventwhosewait()steps one loop iteration at atime, so a profile's directory can be removed deterministically between
iteration 1 and iteration 2 with no race against a live ticker thread. It
then asserts the archived directory stays gone through two more iterations.
Confirmed the test fails without the fix and passes with it:
Also ran the wider
tests/cron/suite (939 passed, 4 pre-existing failuresunrelated to this change — confirmed identical on unmodified
main:test_media_send_timeout.pyassertion-count mismatch andtest_script_claim_heartbeat.py's process-group kill guard, both artifactsof the sandboxed process-group environment this was validated in rather
than this repo's own
scripts/run_tests_parallel.pyisolation).ruff check cron/scheduler_provider.py tests/cron/test_scheduler_provider.pypasses clean.
AI assistance disclosure
This change was written by an autonomous coding agent (Claude), with the
root cause traced to a specific line (
ensure_dirs()'smkdir(parents=True, exist_ok=True)) and the fix/test verified locally as shown above beforepushing.