Skip to content

fix(cron): preserve active turns across gateway restarts - #101877

Closed
jayleaton wants to merge 3 commits into
NousResearch:mainfrom
jayleaton:fix/cron-gateway-restart-durability
Closed

jayleaton wants to merge 3 commits into
NousResearch:mainfrom
jayleaton:fix/cron-gateway-restart-durability

Conversation

@jayleaton

Copy link
Copy Markdown

Summary

  • execute active cron turns in restart-safe transient scopes with durable ownership handoff
  • queue final delivery for the replacement gateway with at-most-once terminal outcomes and durable deduplication
  • fence execution transitions/recovery, profile-scope multiplex drains, and fail closed on genuine crashes or unavailable systemd isolation

Verification

  • scripts/run_tests.sh tests/cron tests/gateway/test_cron_delivery_housekeeping.py tests/hermes_cli/test_kanban_gateway_restart_handoff.py tests/tools/test_cronjob_run_background.py — 1236 passed, 3 skipped
  • focused restart/delivery/ledger suite — 73 passed, 2 skipped
  • live production-like systemd handoff — terminated the managed-gateway fixture parent while the cron worker was active; worker survived, execution completed, side effect occurred once, delivery occurred once, and exactly one real managed gateway service remained active
  • independent security/logic review passed with no findings

This PR is intentionally left unmerged for full maintainer review.

@jayleaton

Copy link
Copy Markdown
Author

Implementation, regression hardening, and production-like restart verification are complete.

  • Focused restart/ledger/delivery suite: 73 passed, 2 skipped.
  • Full cron-related suite: 1236 passed, 3 skipped.
  • Live systemd handoff: active worker survived parent termination, execution completed, side effect occurred once, durable delivery occurred once, and exactly one managed gateway service remained active; no extra gateway was started.
  • Independent final security/logic review passed with no findings.

Please provide a full maintainer review. This PR must remain unmerged.

@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 comp/gateway Gateway runner, session dispatch, delivery sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages labels Sep 3, 2026

@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.

Exact-head review of 74d90c3198736be42e3109e4af845f4c492999d2 against current main / base 4dac5f28af54001b899c9b6fc8ba81cb58da2f0e.

I read the full 17-path change, all three commits, the execution-ledger handoff, restart-safe scope construction, durable delivery queue, gateway profile drain, Kanban reuse, focused/live restart tests, existing PR discussion/reviews, the #60432 → #60612/#60631 → merged salvage #60711 lineage, and the currently-open worker-isolation work in #97739.

The core handoff shape is strong. In particular:

  • the parent fences claimed before launch and the worker must CAS-adopt that exact execution before it acknowledges or runs side effects;
  • in-process execution is also owner-fenced, so a lost claim does not fall through into user work;
  • a dead adopted worker settles unknown rather than pretending the run definitely failed before effects;
  • final delivery is keyed by execution id, claimed before transport I/O, tombstoned after terminal pruning, and an uncertain claimed send is never replayed;
  • terminal payloads are scrubbed and transport errors are redacted;
  • the production-like Linux test exercises the important whole path: kill the managed-gateway parent while the worker is active, keep the same worker alive outside the gateway cgroup, observe the side effect once, drain through the replacement gateway, and deliver once.

I do not see a source-level correctness blocker in that ownership/delivery transaction on this head. There are, however, three repository-level gates that need to close before this is a safe landing object.

1. Reconcile the scope substrate with #97739 before either PR lands

This PR and #97739 both modify cron/scheduler.py, gateway/run.py, and tools/process_registry.py, and they overlap semantically at the most load-bearing seam: how gateway-owned work escapes the gateway cgroup.

This head adds restart_safe_gateway_child_argv() as a user-scope-only contract: a managed Linux gateway directly requires _systemd_run_user_scope_available() and then builds the existing user scope. #97739 is simultaneously replacing that substrate with backend selection across user scope and a same-UID system scope. Its current production evidence is specifically the counterexample this PR needs to compose with: the runtime user has no usable user bus, while a same-UID system scope is available.

So these are complementary PRs, not duplicates: #101877 owns restart durability / execution transfer / delivery settlement; #97739 owns generalized worker-resource isolation and backend selection. But the shared seam needs one owner.

Required before merge:

  1. choose the backend-neutral scope-construction seam as the single authority;
  2. if #97739 lands first, rebase this PR and make cron + Kanban consume that generalized seam instead of reintroducing a direct user-scope check;
  3. if this PR lands first, #97739 must explicitly absorb this restart-survival contract when it replaces the helper layer;
  4. add a composition regression for user scope unavailable + system scope available proving both the cron external worker and the Kanban worker still receive an independent restart-safe scope; and
  5. rerun the focused restart/ownership/delivery suites on the actual candidate merge graph.

Without that reconciliation, merge order can turn a valid system-scope deployment into a fail-closed cron/kanban dispatch even though an isolation backend is available.

2. Hosted CI has not executed on any of the three commits

The local and live receipts in the PR body are useful, but the hosted exact-object gate is currently empty:

  • 4b6553150d9a9c184f935e2ac4521d55716d6061: no workflow runs;
  • 9c9bc68cc4781c788ed8405ad7fe2c4af344c7e2: no workflow runs;
  • 74d90c3198736be42e3109e4af845f4c492999d2: CI 33717412891, Docker 33717412230, and Nix 33717412239 all ended action_required; the CI run contains zero jobs.

That is an unexecuted fork-workflow gate, not evidence of a code failure. It is still not green evidence. Please approve/retrigger the fork workflows and get the required matrix green for every commit, with the final required checks green again on the exact landing head after any interlock rebase/fix.

3. The live PR topology/credit needs to describe the system this code actually changes

The shutdown side here is a refinement of behavior already landed through #60711. That merged salvage preserved the work from #60612 by HexLab98 and #60631 by JoaoMarcos44: cron became visible to gateway drain, and forced shutdown marks still-running cron work interrupted so it cannot later publish a false success. This PR intentionally changes the restart-safe subset of that contract by excluding the parent waiter from forced interruption once durable ownership has moved to the external worker. That relationship should be explicit so a future reviewer does not read this as a replacement or regression of #60711.

The body also currently reads as cron-only, but this head changes Kanban worker spawning to use the same restart-safe scope. And the three commits are authored/committed by Brooklyn Nicholson (OutThisLife) while jayleaton is the PR carrier. Preserve both facts rather than flattening them. I also do not find a contributors/emails/brooklyn.bb.nicholson@gmail.com mapping on current main; run the repository attribution audit and add the mapping if the audit requires it before CI is considered complete.

Required body/interlock cleanup:

  • link #60432 and merged #60711 as historical predecessor/ownership context, without claiming to re-fix the already-closed class;
  • link #97739 as complementary overlapping scope infrastructure and record the merge-order rule above;
  • call out the Kanban worker change as intentional scope;
  • credit the salvaged predecessor authors and the implementation author on this carrier; and
  • identify/link the canonical current residual issue if this restart-survival behavior is a new defect class rather than only a refinement of #60432.

This is a COMMENT review, not an approval. The hard engineering here is good—especially the ownership CAS, unknown settlement, and live parent-death test. Close the scope-backend interlock, provenance graph, and exact-object CI gates and this becomes a much easier object to trust.

@kshitijk4poor

Copy link
Copy Markdown

Salvaged via #101940 — all three of @OutThisLife's commits cherry-picked with authorship preserved (carrier @jayleaton credited in the body). Thanks for the careful ownership-CAS / at-most-once design; the parent-death live test is exactly the right shape and it reproduced cleanly here.

Two follow-up commits fold in the review:

  • A pending delivery row at the worker's 300s wait timeout was marked failed and never drained, so any gateway outage longer than the wait budget (e.g. a restart that includes hermes update) silently lost the message. Unclaimed rows are certainly unsent, not uncertain — they now stay queued for the next gateway and are reported as deferred (not delivery_failed).
  • _deliver_result queued whenever _HERMES_CRON_EXTERNAL_WORKER was set, regardless of which job was delivering; a nested in-process dispatch inside a worker would have been keyed under the outer execution id and dropped by INSERT OR IGNORE. Now matches the delivering job's own execution_id, like run_one_job already did.
  • Parent waited on the worker by re-opening the executions ledger every 50ms for the whole run; now process.wait(timeout=1) with a ledger read on wakeup. Prune moved out of every _transaction; apply_wal_with_fallback + sqlite_util.add_column_if_missing reused; stranded payload/ack files reaped; worker entrypoint gets setup_logging; housekeeping drain gated on the queue file existing so non-systemd gateways never open it.
  • test_lost_fire_claim_stops_stale_delivery had become vacuous under the new claimed→running CAS (mock returned None → body returned before run_job); fixed and mutation-checked.

Re @andrexibiza's gates: #60432/#60711 lineage, the #97739 scope-seam overlap + merge-order rule, and the intentional Kanban scope change are recorded in the #101940 body; brooklyn.bb.nicholson@gmail.com is already in AUTHOR_MAP. CI runs on the salvage head.

kshitijk4poor added a commit that referenced this pull request Sep 3, 2026
Follow-up to the salvaged restart-safe worker (#101877):

- delivery_queue: a row still `pending` at the worker's wait timeout was
  marked `failed` and never drained, so any gateway outage longer than the
  300s budget (e.g. a restart that runs `hermes update`) silently lost the
  delivery. Unclaimed rows are certainly unsent, not uncertain — leave them
  queued for the next gateway; only mid-send rows are fenced `unknown`.
- delivery_queue: stop running the full-table prune UPDATE+COUNT inside
  every transaction (each `get_status` poll paid for it; terminalizing
  paths already prune explicitly); poll at 1s instead of 250ms.
- delivery_queue/executions: use `hermes_state.apply_wal_with_fallback`
  (bare `journal_mode=WAL` raises on NFS/SMB homes) and the race-safe
  `hermes_cli.sqlite_util.add_column_if_missing`; drop the copied
  owner-liveness helpers in favour of the ones in cron.executions.
- scheduler: the parent waited on the worker by re-opening the executions
  ledger every 50ms for the whole run (~20 opens/s, hours). Wait on the
  process with a 1s timeout instead — the worker commits its terminal row
  before exiting — and reap stranded payload/ack files once terminal.
- scheduler: skip the housekeeping drain until a worker has actually
  created deliveries.db, so non-systemd gateways never open it.
- scheduler: set up hermes logging in the detached worker entrypoint; it
  runs with stdout/stderr on DEVNULL and previously logged nowhere.
- tests: test_lost_fire_claim_stops_stale_delivery still mocked
  `mark_execution_running -> None`, which now means "ownership lost, return
  before run_job" — the test passed without ever reaching the path it
  names. Mocking `{}` restores it (mutation-checked).
kshitijk4poor added a commit that referenced this pull request Sep 3, 2026
…end is not a failure

Review fold-in on the salvage of #101877:

- `_deliver_result` routed to the durable queue whenever the worker's
  `_HERMES_CRON_EXTERNAL_WORKER` marker was set, regardless of WHICH job was
  delivering. A worker whose script dispatches another job in-process
  (`hermes cron run <other>`) inherits that env and would have queued the
  nested job's message under the outer execution id — `INSERT OR IGNORE`
  then drops it silently. Match the marker against the delivering job's own
  `execution_id`, as `run_one_job` already does. Regression test added
  (mutation-checked: fails with the guard removed).
- A `pending` row left queued at the worker's wait timeout was still
  reported as a delivery error, so `mark_job_run` recorded
  `last_status=delivery_failed` for a message the next gateway's drain goes
  on to send, and nothing ever corrects the job record. Log and return
  success instead; the deliveries row is the authority for the send.
- Reuse `cron.executions._TERMINAL_STATES` in the parent wait loop instead
  of a second hardcoded terminal set.
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 comp/gateway Gateway runner, session dispatch, delivery P1 High — major feature broken, no workaround sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants