Repository navigation
fix(cron): a restart-safe worker that dies before its ack reports its own stderr instead of a bare exit 1 (#112729) - #113219
Merged
Conversation
… own stderr `_launch_external_cron_worker` spawned the worker with `stderr=DEVNULL`, so a worker that crashed before publishing its acknowledgement — an import error, a module missing from the interpreter it was handed, a bad payload — surfaced only as "exited before ownership acknowledgement (exit 1)". #112729 shows the cost: the reporter, a claimant and a reviewer spent three rounds guessing at the mechanism (sys.executable symlinks — refuted) because the worker's actual traceback was thrown away. The worker's own log handler is installed only after `cron.scheduler` imports, so an import-time failure never reaches agent.log either. Capture stderr into `<home>/cron/external-workers/<execution_id>.stderr` (0o600), and on a pre-ack exit append a redacted tail of it to the dispatch error (which lands in `last_error`, the executions ledger and the gateway log). The file rides in `handoff_files` so every exit path removes it; the tail helper lives in `scheduler_diagnostics` next to the run-error redaction. Part of #112729
૮ >ﻌ< ა ci reviewran on 44030f6 — fix(cron): acknowledged worker removes its own stderr captur
|
…ts match captured stderr The `<execution_id>.stderr` capture was unlinked only by the launching gateway (pre-ack exit branch and the `handoff_files` finally). In the restart-safe topology the worker deliberately outlives a gateway restart, so a gateway that died mid-run left one orphan file per surviving run under cron/external-workers/. After the ack the gateway never reads the capture (it only serves the pre-ack death report), so the worker now unlinks the sibling derived from its ack path in the post-run finally; the pre-ack paths are unchanged so a worker dying before its ack still gets its stderr reported. Two comments still described stderr as DEVNULL (the systemd-run failure branch and the `__main__` setup_logging motivation); reword them to the captured-stderr behaviour while keeping the WHY.
16 tasks done
This was referenced Sep 17, 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.
When a restart-safe cron worker dies before it acknowledges the handoff, the dispatch error now carries the worker's own stderr tail (e.g. the
ModuleNotFoundErrortraceback) instead of justexit 1.cron/scheduler.py::_launch_external_cron_worker: workerstderrgoes to<home>/cron/external-workers/<execution_id>.stderr(0o600) instead ofDEVNULL; on a pre-ack exit the redacted tail is appended to theRuntimeError, which already flows intolast_error, the executions ledger and the gateway log (Restart-safe cron worker dispatch failed: …). The file rides inhandoff_files, so every exit path removes it.cron/scheduler_diagnostics.py::external_worker_stderr_tail: 1500-char tail throughredact_sensitive_text(force=True, redact_url_credentials=True).tests/cron/test_restart_safe_worker.py(real subprocess that fails at import; asserts the cause is in the message and noexec-1.*handoff file survives).Validation
Live probe: real
_launch_external_cron_workerunder a tempHERMES_HOME, dispatch argv rewritten to an interpreter that fails at import and exits 1 before writing the ack:before (origin/main 9796235):
cron external worker exited before ownership acknowledgement (exit 1)after:
cron external worker exited before ownership acknowledgement (exit 1); worker stderr: Traceback (most recent call last): … ModuleNotFoundError: No module named 'cron_missing_module'Both:
leftover handoff files: [].scripts/run_tests.sh tests/cron/test_restart_safe_worker.py→ 24 passed; the new test is red with the source swapped back to origin/main (23 passed, 1 failed).Why this and not the proposed interpreter change
The issue's mechanism does not hold: CPython does not resolve symlinks in
sys.executable(measured: a venv whosebin/python3is a symlink reportssys.executable=<venv>/bin/python3,sys.prefix=<venv>, and a child spawned withsys.executablekeeps the venv — matching @KeyArgo's analysis and @holny's withdrawal on the thread). The reporter'spython -m cron.scheduler --helpcontrol is not the worker's launch path either. What IS real is that the worker's actual exit-1 cause was unrecoverable because its stderr went toDEVNULLand its own log handler installs only aftercron.schedulerimports — so this PR makes the next such report self-diagnosing. #112731 (@KoNit-K,$VIRTUAL_ENV/bin/pythonpreference) is not salvaged: it is a no-op whereversys.executableis already the venv interpreter andVIRTUAL_ENVneed not name the gateway's venv under systemd.Part of #112729 — the reporter's install still needs the captured stderr from a re-run to identify the real failure.
Infographic
Review follow-up
<execution_id>.stderrcapture when the gateway dies mid-run in the restart-safe topology) → fixed @44030f683cbda: the acknowledged worker unlinksack_path.with_suffix(".stderr")in its post-runfinally; pre-ack paths are unchanged so a worker dying before its ack still gets its stderr reported.test_external_worker_adopts_execution_and_runs_payload_oncenow seeds the sibling and asserts it is gone.__main__setup_logging motivation) → fixed @44030f683cbda;git grep DEVNULL -- cron/scheduler.pynow matches only the realstdin/stdoutDEVNULL args and the reworded comment.