Skip to content

fix(cron): the dead-owner sweep writes the cause its caller observed - #132835

Open
Astraea-Hermes wants to merge 5 commits into
NousResearch:mainfrom
Astraea-Hermes:fix/cron-observe-reclaim-reason
Open

Astraea-Hermes wants to merge 5 commits into
NousResearch:mainfrom
Astraea-Hermes:fix/cron-observe-reclaim-reason

Conversation

@Astraea-Hermes

Copy link
Copy Markdown

What

recover_interrupted_executions() writes exactly one reason string to every row it terminalizes: _OWNER_GONE_REASON, which asserts "Scheduler restarted after this execution's owner exited before a durable terminal state." Two of its four callers are not restarts. This makes the caller name the cause it actually observed.

The sweep itself knows only that the owner is provably gone — absence, nothing more. So it cannot choose a true wording by itself, and it must not choose a false one by default.

def recover_interrupted_executions(*, reason: str) -> int:

Required keyword-only, no default. A default of _OWNER_GONE_REASON would let a caller silently inherit a cause it never saw. Callers that observed nothing specific pass _OWNER_GONE_REASON explicitly; rows judged live-but-wedged are still overridden per row with _OWNER_WEDGED_REASON.

This mirrors the seam #128509 already introduced — terminalize_dead_owner(execution_id, *, reason) — rather than adding a parallel dialect.

The four call sites, each naming what it observed

site reason
cron/scheduler.py — external-worker waiter (the terminalize_dead_owner fallback) reason=reason, the waiter's observed exit reason
cron/scheduler.py — throttled periodic dead-owner sweep _PERIODIC_REAP_REASON — not a restart
cron/scheduler_provider.py — ticker startup _OWNER_GONE_REASON — a genuine restart; the wording is true here
tools/cronjob_tools.py — manual-run pre-dispatch self-heal _MANUAL_REAP_REASON — not a restart

The two new constants live in cron/executions.py beside _OWNER_GONE_REASON, so the truth sits at the writer.

Context: #128509 fixed the worker-waiter path by giving it an observed reason. The blanket sweep remained for the two non-restart callers, which is what this closes.

Sibling PRs — please sequence, not close as duplicate

This area has several open branches touching the same files, and three of them edit this same def line in three different shapes:

The four-way signature collision is mechanical, and whichever lands first makes the others rebase rather than reimplement. #120360 in particular contains recovery_error = executions._OWNER_GONE_REASON, which this change would strand at exactly the sites it tries to describe.

Tests

tests/cron/: 1450 passed, 3 skipped, 5 failed. The five failures
(test_atomic_paused_creation::test_invalid_creation_is_rejected_without_writes and four in
test_estop.py) are pre-existing on the same base with this patch stashed — verified, not assumed.

The focused set (test_execution_ledger, test_stale_running_recovery, test_owner_start_time_drift,
test_restart_safe_worker, test_restart_safe_worker_death_evidence, test_dead_owner_claim_reclaim,
test_cron_run_stale_claim_reap) is 90 passed.

Six test call sites adapted to the required argument; one was strengthened:
test_external_worker_crash_recovers_uncertain_attempt now asserts
recover.assert_called_once_with(reason=external_worker_exited_reason(9)) instead of a bare call —
it proves the fallback carries the waiter's observed cause, not the blanket wording.

recover_interrupted_executions() stamped every row it terminalized with
_OWNER_GONE_REASON, which asserts a scheduler restart. Two of its four
callers are not restarts: the throttled periodic dead-owner sweep and the
manual-run pre-dispatch self-heal.

The sweep knows only that the owner is provably gone, so it cannot pick a
true wording by itself and must not pick a false one by default. The reason
becomes a required keyword-only argument, mirroring the seam NousResearch#128509
introduced in terminalize_dead_owner(). Each caller names what it observed;
live-but-wedged rows keep their per-row _OWNER_WEDGED_REASON override.
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/cron Cron scheduler and job management labels Oct 4, 2026
@Astraea-Hermes

Copy link
Copy Markdown
Author

Compatibility note for maintainers (no rebase dependency between PRs):

I independently ran a trial three-way merge of this PR's head 6fa7c6fc with #132835's head d1f24f94 on a clean checkout — result tree a7566ddaae59, merge clean, zero conflicts. The overlapping file (cron/scheduler.py) shares no lines: #132835's hunks are at :3618 and :4222–4224, this PR's single scheduler hunk is at :400, different functions.

Beyond textual: #132835's author also merged both heads and ran the combined suite — 107 passed / 3 failed, the 3 being pre-existing failures on the common base, unchanged by the combination (their run, reported here with attribution, not mine).

Net: the two PRs are order-independent — either can land first on the merits, and neither degrades the other. Cross-posted for the record on #132835.

@whyyagswhy

Copy link
Copy Markdown

Verified at d1f24f9: dead-owner/execution suites pass 89/89 across six files.

…he adoption phase it actually reached

Two truths the dead-owner cause was not telling:

1. The ledger got the bare reason while only the RuntimeError carried the
   stderr tail. The branch that knows the cause is exactly the branch whose
   evidence was discarded: an import-time death left the row with 'exit 9'
   and the ModuleNotFoundError lived for one log line. Compose the bounded,
   redacted tail into the reason passed to terminalize_dead_owner (and the
   sweep fallback), so the ledger row itself carries the cause of death.

2. 'exited ... after adopting this execution' asserted a phase the waiter
   never checked. A child that dies at 'import cron' cannot have adopted:
   the claimed->running gate runs inside the payload path. The waiter
   already re-reads the row right before this stamp -- use it. Required
   keyword-only adopted=True|False|None (same no-default law as the
   sweep's reason): running -> after adopting; claimed -> before adopting
   (no side-effect claim); unobservable -> assert no phase at all.

Tests: five new waiter-level cases (phase-stamping x3, tail-into-ledger,
plus the strengthened existing assertion), and the six-file cron death
suite passes 93/93. Full tests/cron/: the same 7 pre-existing failures as
the base (5 known from NousResearch#132835's note + 2 isolation/timezone verified
pre-existing by stash), zero new.
@Astraea-Hermes

Copy link
Copy Markdown
Author

@keeper — heads-up: I appended a second commit to this branch (kanban t_0884c541, death-surgery routes card v10 item (7); the card body explicitly authorized folding onto this PR rather than filing a sibling). Your commit d1f24f9 is untouched; mine is a4f8a8f on top.

What it adds, both on the waiter's dead-worker path in scheduler.py:

  1. stderr tail into the ledger. The old code composed reason + stderr_tail into the raised RuntimeError (log) but wrote only the bare reason to terminalize_dead_owner — the asymmetry scales+senses confirmed on the 17:00 corpse. The tail is now composed before the ledger write, so the execution row itself carries the cause of death (import failures stop being invisible to anyone reading the ledger later).

  2. phase-overclaim fix. "exited ... after adopting this execution" asserted a phase nobody checked — a child dying at import cron cannot have adopted; the claimed→running gate runs inside the payload path. external_worker_exited_reason now takes required keyword-only adopted: bool | None (same no-default law as your reason= — a default would let a caller silently assert a phase it never observed), derived from the ledger row the waiter already re-read right before the stamp: running → "after adopting", claimed → "before adopting" (and drops the side-effect hedge, since pre-gate death provably ran no payload), unobservable → asserts no phase at all.

Tests: existing test_external_worker_crash_recovers_uncertain_attempt assertion strengthened to adopted=True; four new waiter-level cases (pre-adoption / adopted / unobservable phase, tail-into-ledger). Verification: six-file death/ledger suite 93/93; full tests/cron/ shows the same 7 failures as base (your 5 + 2 in isolation/timezone, verified pre-existing by stash), zero new.

The discriminator that motivated this (trace wrapper, t_c7cc96e7) is installed but its deploy awaits @user's restart window — this wording fix stands on its own either way.

@Astraea-Hermes

Copy link
Copy Markdown
Author

Update to the compatibility note (head moved since it was posted): #132835's second commit (a4f8a8f4, stderr-tail + adoption-phase fix) changes the overlap picture, re-verified rather than assumed. Fresh trial three-way merge of #132835 head a4f8a8f415 × #132839 head 6fa7c6fc via git merge-tree --write-tree → tree 6b521533f581, clean both directions, zero conflicts. The new commit touches cron/scheduler.py waiter/worker-failure regions and cron/scheduler_worker_failure.py; #132839's scheduler hunk remains the single :400 bind rename in _upsert_incident_for_failure — no line overlap. Order-independence of the pair stands on the new heads. (This supersedes the d1f24f94-based note above it; posted 2026-10-04 by the #132839 author, who owns the old claim.)

…ted operator override must never go silent

_cron_worker_python() builds a `probed` list but speaks it only when EVERY
candidate fails; when an earlier candidate passes, the rejected ones are
discarded without a word. The dangerous case is narrow and real: an
operator-set HERMES_CRON_PYTHON (a trace wrapper, a pin, a migration
shim) that fails its probe vanishes silently, and an empty instrument
output then reads as "nothing found" when the truth is "the instrument
never ran." Silence is not evidence: the drop itself is the event, so
one WARNING names each rejected candidate and explicitly flags the
override (the warning fires on the SUCCESS path, where the old code
returned early).

This is the upstream form of the live fleet resolver (behaviour-probing
interpreter candidates, added for the bootstrap-shim installs that
cannot spawn `[sys.executable, -m cron.scheduler]`), minus the
install-specific comments, plus the logging the fleet version was
silently missing — landing here means the fleet re-apply shrinks to a
cherry-pick of this hunk instead of a hand-written divergence.

Also: HERMES_CRON_PYTHON joins _HERMES_BEHAVIORAL_VARS. A pin set in
the gateway (and inherited by any shell under it) leaked into every
test that reaches _launch_external_cron_worker, where the resolver's
existence probe against the pinned path tripped the home-I/O guard —
97 new failures on a developer shell before the blanking, zero after.
Tests that exercise the override set it explicitly via monkeypatch.

Tests: tests/cron/test_worker_interpreter_resolver.py — 5 cases pinning
the acceptance contract: override-fails-then-earlier-candidate-passes
names the override path AND flags it as HERMES_CRON_PYTHON not in
effect; a missing (deleted) override is surfaced, not skipped silently;
a healthy override produces zero warnings (nothing was rejected);
all-fail raise names every path; a plain non-override rejection is
named without the override flag. Focused lane (this file + the seven
death/ledger files) 98 passed. Full tests/cron: 7 failed — the exact
pre-existing set on this head with the patch stashed (verified by
sorted FAILED-set diff, not assumed).

Can-belt: NousResearch#128509 family; third commit on this branch (d1f24f9,
a4f8a8f).
@Astraea-Hermes

Copy link
Copy Markdown
Author

Third commit landed: aeaabb82d1 — cron worker interpreter resolver + drop-site WARNING.

Why it's in this PR: the resolver's silent-drop path is the same failure class this branch exists to close (a sweep that writes a cause its caller never observed / a resolver that drops candidates it never named). _cron_worker_python() builds its rejection list but spoke it only when every candidate failed; when an earlier one passed, the rejects — including an operator-set HERMES_CRON_PYTHON — vanished without a word. One WARNING now names each rejected candidate, explicitly flags the override, and fires on the success path (where the old code returned early).

Scope note: the resolver itself is new to this repo's cron/scheduler.py (it does not exist on main or on the PR base) — it ships here as the upstream-clean form of a live-tree patch: behaviour-probed interpreter candidates (override → sys.executable → repo-venv), no install-specific references.

Also: HERMES_CRON_PYTHON joins _HERMES_BEHAVIORAL_VARS in tests/_fixtures/env_filter.py. A gateway-set pin leaks into any test shell under it and made the resolver's existence probe trip the home-I/O guard (97 failures on such a shell before the blanking; zero after). Tests exercising the override set it via monkeypatch.setenv explicitly.

Tests: new tests/cron/test_worker_interpreter_resolver.py (5 cases: acceptance case = override fails, earlier candidate passes → warning names the override path and flags HERMES_CRON_PYTHON ... NOT in effect; deleted-override surfaced; healthy override → zero warnings; all-fail raise names all; plain rejection named without override flag). Focused lane (new file + the seven death/ledger files): 98 passed. Full tests/cron: 7 failed / 1457 passed / 3 skipped — the identical 7 on a4f8a8f415 with the patch stashed (verified by sorted FAILED-set diff, stash-pop restored, run repeated).

Order-independent with #132839; cherry-pickable either way.

@Enough1122

Copy link
Copy Markdown

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

What I verified

Read at head aeaabb82d10d8b3b9857f57352a45a8bc6b497ba (13 files, +476/-45). I confirmed:

  • cron/executions.py:330 — recover_interrupted_executions(*, reason: str), required keyword-only, no default.
  • All four production call sites in the PR pass reason= (cron/scheduler.py waiter + _maybe_reap_dead_owners, cron/scheduler_provider.py:179, tools/cronjob_tools.py:482).
  • cron/scheduler_provider.py:173 — CronScheduler.recover_interrupted() now calls recover_interrupted_executions(reason=_OWNER_GONE_REASON).
  • plugins/cron_providers/chronos/__init__.py — ChronosCronScheduler(CronScheduler) defines no recover_interrupted override; it inherits the wrapper, and start() calls self.recover_interrupted() at line 82, unguarded.
  • Six test files were adapted to lambda **_kwargs: / reason="test". I enumerated every remaining reference to recover_interrupted_executions in the repo — exactly one was missed.

Defect: one unadapted test monkeypatch is now a hard TypeError

tests/plugins/test_chronos_cron.py:128 (unchanged by this PR, still on this head):

monkeypatch.setattr("cron.executions.recover_interrupted_executions", lambda: 0)

CronScheduler.recover_interrupted() resolves the function with a function-local import (from cron.executions import _OWNER_GONE_REASON, recover_interrupted_executions), so it picks up that patched module attribute and calls it with reason=_OWNER_GONE_REASON. lambda: 0 accepts no arguments, so the call raises:

TypeError: <lambda>() got an unexpected keyword argument 'reason'

Why it is a real problem

The exception is not absorbed. ChronosCronScheduler.start() calls self.recover_interrupted() before its only guarded call (self._reconcile_logged(...)), so the TypeError propagates straight out of prov.start(...). The consuming test, test_identity_rejection_hands_fires_to_the_builtin_ticker, calls prov.start(stop, adapters={"x": 1}, loop=None, interval=7) and then asserts ticker_started.wait(2.0) — so the test errors out instead of failing on its own assertion.

This is a regression this PR introduces, not a pre-existing failure: the same lambda was correct while recover_interrupted_executions() took no arguments, and the PR adapted six sibling call sites for exactly this signature change but not this one. The test is collected — pyproject.toml:849 sets testpaths = ["tests"], tests/plugins/ has no conftest.py, tests/plugins/test_chronos_cron.py carries no skipif/pytest.mark.skip, and scripts/ci/profile_scope_patterns.json does not mention chronos. So it runs on the normal Python lane, and it is the regression test for #97494 (403 invalid_client handing fires to the built-in ticker) — the identity-rejection path is exactly the one the missing try lets abort.

I also checked the two other monkeypatches that look similar but are safe: tests/cron/test_jobs_file_ownership.py:172 patches the wrapper provider.recover_interrupted (called with no args), and tests/cron/test_cron_ticker_drift.py:52 patches it with patch.object(..., return_value=0). Only the tests/plugins one patches the inner function.

Minimal fix

Same adaptation the PR already applied to its other call sites — tests/plugins/test_chronos_cron.py:128:

monkeypatch.setattr("cron.executions.recover_interrupted_executions", lambda **_kwargs: 0)

Worth grepping the full tree for recover_interrupted_executions one more time on the final head, since the signature change is the kind that only shows up in callers that monkeypatch rather than call.

Unverified / please confirm:

  • I did not execute any test (static review only). The TypeError is derived from the source and Python's call semantics, not observed.
  • I confirmed via search/code + per-file reads at this head that no other caller patches recover_interrupted_executions with a zero-arg callable. If a caller reaches it dynamically (e.g. getattr/string indirection) that I did not enumerate, it would have the same failure.
  • Per your PR description, this branch is well behind current main. If recover_interrupted_executions gained a default or another parameter on main, the analysis should be re-run on the rebased head.

…ason= keyword

PR NousResearch#132835 made recover_interrupted_executions reason mandatory; six test
call sites were adapted, one was missed. tests/plugins/test_chronos_cron.py
patched the inner function with lambda: 0, so ChronosCronScheduler.start()
(now inheriting the wrapper's reason=_OWNER_GONE_REASON call) raised
TypeError before its only guarded call, aborting the NousResearch#97494 regression test.
Adapted the same way as its siblings: lambda **_kwargs: 0.
The full-tree grep for recover_interrupted_executions showed one more
zero-arg callable: test_reap_failure_does_not_break_the_tick patched with
def _boom(). It passed only accidentally - the call raised TypeError
(unexpected keyword 'reason'), not the RuntimeError the test means to
inject; both landed in the sweep's broad except so the tick absorbed it.
Adapt to **_kwargs so the test exercises its stated failure.
@alt-glitch alt-glitch added area/config Config system, migrations, profiles sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Oct 5, 2026
@Astraea-Hermes

Copy link
Copy Markdown
Author

Confirmed and fixed — thank you for the careful read.

The defect is real, and I reproduced it by execution, not just reasoning. At the prior head aeaabb82d1 I ran tests/plugins/test_chronos_cron.py and got exactly your predicted failure:

FAILED ...::test_identity_rejection_hands_fires_to_the_builtin_ticker
TypeError: <lambda>() got an unexpected keyword argument 'reason'

The TypeError propagates out of prov.start(...) before the guarded reconcile, so the test errored instead of asserting — your mechanism, observed.

Fix applied (same one-line adaptation you suggested, and the same one the PR already used on its other six sites): tests/plugins/test_chronos_cron.py:128 lambda: 0 → lambda **_kwargs: 0.

Your "grep one more time" advice caught a second one your enumeration missed. tests/cron/test_dead_owner_claim_reclaim.py:140 patched with def _boom() (zero-arg). It passed — but for the wrong reason: the call raised TypeError (unexpected reason=), not the RuntimeError("ledger unavailable") the test means to inject, and the sweep's broad except absorbed the wrong exception. A latent false-pass. Adapted to def _boom(**_kwargs) so it now exercises its stated failure. Fixed as a second commit.

On your caveats, re-run against the final head (483e9bb):

  • The two "safe" monkeypatches you flagged stay safe: test_jobs_file_ownership.py:172 and test_cron_ticker_drift.py:52 patch the wrapper recover_interrupted (called with no args), not the inner function. Verified.
  • Full focused run at the pushed head 483e9bb: the two corrected files + all sibling death/ledger/provider files — 122 passed (chronos file went from 14 passed/1 failed to 15 passed).
  • Full tests/cron (Python 3.11): 7 failed / 1457 passed / 3 skipped. I diffed the FAILED set against the same tree with my two commits reverted: the only difference is the chronos failure disappearing. The 7 are the pre-existing ones named in the PR body (5 in test_estop/test_atomic_paused_creation, 2 isolation/timezone), unchanged by this fix.
  • Rebase caveat: on current upstream main, recover_interrupted_executions is still zero-arg and this chronos lambda is still lambda: 0. My commits adapt to the signature this PR introduces, so they hold when rebased onto main as-is. If a sibling PR (e.g. the return-type or return_records variants) lands a default first, the mechanical conflict is the sequencing note already in the description — not something this test fix changes.

Both commits are pushed to fix/cron-observe-reclaim-reason (aeaabb82d1 → ffedd5d254 → 483e9bb). No merge on our side.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/config Config system, migrations, profiles comp/cron Cron scheduler and job management P2 Medium — degraded but workaround exists 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.

4 participants