fix(cron): stop one-shot CLI cron run from orphaning the job; reap dead-owner claims on tick - #86853
Merged
Merged
Conversation
…dead-owner claims on tick
`hermes cron run <job_id>` from a one-shot CLI invocation could
background-dispatch the run onto a daemon thread of the calling process
(when the CLI inherited a gateway/desktop session env and resolved a
session key). The CLI printed "Triggered job: ..." and exited instantly,
killing the runner mid-LLM-call: the async delegation died with
state='unknown' and the job's row in cron/executions.db stayed
status='claimed' forever, blocking every subsequent run of that job.
Two-part fix:
1. hermes_cli/cron.py: `_job_action("run", ...)` declares the delivery
channel stateless (scoped ContextVar set/reset around the call) before
invoking the cron API, so `async_delivery_supported()` gates off
`_try_dispatch_background_run` and the run executes synchronously to
completion in the CLI process — the same behavior `hermes -z` already
gets via declare_stateless_channel().
2. cron/scheduler.py: tick() now periodically invokes
recover_interrupted_executions() (previously only run at scheduler
startup), so execution rows whose exact owner process is provably dead
(pid + process start time check in _owner_is_live) are reaped to
'unknown' by the long-lived gateway ticker without a restart.
Throttled to once per 300s so idle 60s ticks don't pay a ledger
connection every cycle.
Tests: tests/cron/test_dead_owner_claim_reclaim.py covers the dead-owner
reap (real dead pid via a finished subprocess), live-owner rows surviving
the reap, throttle behavior, reap-failure isolation, the CLI stateless
gate (including restoration after the call), and the end-to-end refusal
of background dispatch under a stateless channel.
Fixes #86721
Contributor
૮ >ﻌ< ა ci reviewran on e415c8b — fix(cron): stop one-shot CLI
|
This was referenced Aug 15, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes #86721 —
hermes cron run <job_id>from a one-shot CLI invocation orphaned the job: the run was background-dispatched onto a daemon thread of the calling process (when the CLI inherited a gateway/desktop session env and resolved a session key), the process exited immediately after printingTriggered job: ..., the runner died mid-LLM-call, the async delegation endedstate='unknown', and the job'scron/executions.dbrow stayedstatus='claimed'forever — blocking every subsequent run of that job.Fix
Two parts, matching the issue's expected behaviors (2) and (3):
One-shot CLI
cron runexecutes synchronously to completion —hermes_cli/cron.py::_job_action("run", ...)now declares the delivery channel stateless (scoped_SESSION_ASYNC_DELIVERYset/reset around the call) before invoking the cron API.async_delivery_supported()then gates off_try_dispatch_background_run, so the run takes the existing synchronous path and the CLI blocks until the job finishes — the same treatmenthermes -zalready gets viadeclare_stateless_channel(). The declaration is token-scoped so in-process callers (tests, embedding apps) aren't tainted.Dead-owner claim reclaim on the scheduler tick — execution rows already carry their owner pid + process start time, but
recover_interrupted_executions()previously ran only at scheduler startup, so a claim orphaned while the gateway ticker was already running was never reaped.cron/scheduler.py::tick()now runs the recovery periodically (throttled to once per 300s so idle 60s ticks don't pay a ledger connection each cycle — same concern as fix(cron): skip idle config loads in scheduler tick #33612). Only rows whose exact owner process is provably dead (_owner_is_live: pid liveness + process start time match) are transitioned tounknown; live runs in other processes are never rewritten.Tests
tests/cron/test_dead_owner_claim_reclaim.py(8 behavioral tests):claimedrow owned by a real dead pid (finished subprocess) is cleared tounknownby a tickrunningrow from a dead owner is also reclaimedrunaction declares the channel stateless during the call and restores it after; non-run actions leave it alone_try_dispatch_background_runrefuses background dispatch under a stateless channel even with an inheritedHERMES_SESSION_KEYSabotage-verified: with the two fix files reverted to
origin/main, all 8 tests fail; re-applying the fix greens them. Fulltests/cron/+tests/hermes_cli/test_cron.py+tests/tools/test_cronjob_run_background.py: 733 passed, 1 skipped.Infographic