Skip to content

fix(gateway): reserve launchd shutdown teardown headroom - #821

Merged
Kyzcreig merged 4 commits into
mainfrom
wt/t_942691a4
Sep 21, 2026
Merged

Kyzcreig merged 4 commits into
mainfrom
wt/t_942691a4

Conversation

@Kyzcreig

Copy link
Copy Markdown
Collaborator

Summary

  • cap signal-driven launchd drains at ExitTimeOut - max(15s, last measured post-drain teardown)
  • persist each completed teardown timing to state/gateway.teardown.json and append gateway.shutdown_teardown_timing to gateway-exit-diag.log
  • force the shutdown watchdog to os._exit by ExitTimeOut - 10s, even when persistence/teardown wedges
  • count restart-interrupted boots once per gateway process and label the breaker reason class

Incident attribution

The supposed breaker boots 2 and 3 were not KeepAlive respawns. The one replacement process started at 02:51:34; its phased resume scanner recorded the same boot at 02:52:02, 02:54:06, and 02:55:17. The per-process dedup fixes that false chain.

Verification

  • RED: 3/3 new drain-headroom cases failed before implementation
  • GREEN: 401 focused tests passed, 1 skipped across launchd drain, lifecycle ledger, process exit, auto-continue, and restart-loop suites
  • Ruff: all changed Python files clean
  • git diff --check: clean

Acceptance note

The hardware safe-restart under load is intentionally left to post-review deployment: this PR changes shutdown semantics and must not be deployed from an unreviewed worker branch.

@Kyzcreig

Copy link
Copy Markdown
Collaborator Author

FleetReview

Reviewed with 2 of 3 model families — anthropic unavailable.

Confidence: 2/5

Findings

  • P1 gateway/restart.py:173 — Truncated teardown
  • P1 gateway/restart.py:203 — Immediate hard-exit
  • P1 gateway/run.py:20265 — Stale Timing
  • P1 gateway/lifecycle_ledger.py:102 — Infinite duration
  • P1 tests/gateway/test_launchd_exit_timeout_drain_cap.py:168 — Conflicting Budgets

FleetReview provenance · models: B=gpt-5.6-sol, D=grok-4.6 · cost: $0.00 · duration: 11m 08s · rounds: 1 · files examined: 7

@Kyzcreig

Copy link
Copy Markdown
Collaborator Author

FleetReview

Reviewed with 2 of 3 model families — anthropic unavailable.

Confidence: 2/5

Findings

  • P1 gateway/restart.py:173 — Cleanup Truncation
  • P1 gateway/restart.py:203 — Immediate Exit
  • P1 gateway/run.py:19593 — Premature Exit

FleetReview provenance · models: B=gpt-5.6-sol, D=grok-4.6, F=gpt-5.6-sol, G=grok-4.6 · cost: $1.82 · duration: 13m 20s · rounds: 1 · files examined: 7

@Kyzcreig
Kyzcreig added this pull request to the merge queue Sep 21, 2026
@Kyzcreig
Kyzcreig removed this pull request from the merge queue due to a manual request Sep 21, 2026
@Kyzcreig
Kyzcreig added this pull request to the merge queue Sep 21, 2026
@Kyzcreig
Kyzcreig removed this pull request from the merge queue due to a manual request Sep 21, 2026
@Kyzcreig
Kyzcreig added this pull request to the merge queue Sep 21, 2026
@Kyzcreig
Kyzcreig removed this pull request from the merge queue due to a manual request Sep 21, 2026
Measure and persist post-drain teardown time, cap launchd drains by at least 15 seconds or the prior measurement, and hard-exit before launchd's SIGKILL deadline. Deduplicate restart-loop boot accounting and label its reason class.\n\nVerified: scripts/run_tests.sh over focused restart/lifecycle suites (379 passed, 1 skipped), plus the per-boot dedup regression.
The drain cap sized the teardown window against launchd's SIGKILL wall,
but the shutdown watchdog hard-exits LAUNCHD_HARD_EXIT_RESERVE_S earlier.
The two reserves therefore overlapped: at clamp 60 a 45s drain left only
5s before os._exit for a teardown allocated 15s, and a recorded 22s
teardown got 12s. A busy stop could hard-exit mid-persistence, which is
the state.db corruption class this work exists to close.

resolve_launchd_capped_drain now subtracts the hard-exit reserve as well,
so the drain ends a full teardown reserve before the watchdog that
actually fires. Measured (clamp/cfg/last_teardown -> drain, watchdog,
teardown window): 60/50/None -> 35, 50, 15s; 60/50/22 -> 28, 50, 22s;
50/50/None -> 25, 40, 15s; 30/50/None -> 5, 20, 15s. SIGKILL slack stays
10s in every row.

A live ExitTimeOut at or below the fixed reserve (e.g. 8s) previously
produced a zero-second watchdog, hard-exiting the instant shutdown began
and skipping all drain AND persistence. Short budgets now fall back to a
proportional reserve: clamp 8 -> watchdog 6.0, clamp 4 -> 3.0.

Verified: 7 new cases RED on f075f2d, GREEN after. Each arithmetic
change mutation-checked independently -- reverting the additive cap
reddens 6 cases, reverting the short-budget fallback reddens 2.
Focused suites 47 passed / 1 skipped; broader shutdown+restart surface
(10 files) 111 passed / 4 skipped. Ruff clean.
read_last_teardown_seconds validated only `value >= 0.0`, which `inf`
satisfies. The value is read off disk at boot and flows straight into the
teardown reserve, so a corrupt or hand-edited gateway.teardown.json made
that reserve unbounded and silently drove the next shutdown's drain
budget to zero (measured: last_teardown=inf -> drain 0.0).

Rejecting non-finite input is input validation, not a minimum-drain
floor -- the floor was explicitly withdrawn by review as contrary to the
specified arithmetic, and this does not reintroduce it. A real completed
teardown can never measure inf; finite values are untouched (22.0 still
round-trips to 22.0).

Tests exercise the real file path rather than the parameter, which was
the gap called out in review. Mutation-checked: removing the isfinite
guard reddens the Infinity case (NaN/-Infinity were already caught by the
existing comparison). Focused suites: 51 passed, 1 skipped.
FleetReview round 3 on PR #821 raised three P1s against 8cead1e.

P1 #1 "watchdog starves teardown" — REFUTED BY MEASUREMENT, with a
regression test so it cannot become true silently. The premise is that
the inner leash is "drain plus a small grace", so a 35s cap would arm a
~40s watchdog and leave persistence ~5s. DEFAULT_SHUTDOWN_WATCHDOG_GRACE_S
is 60.0, not small, so under launchd the inner term never wins the min()
and the armed deadline is clamp - LAUNCHD_HARD_EXIT_RESERVE_S. Measured
at the production clamp (60): drain 35 -> armed 50 (window 15, need 15);
drain 28 with a 22s record -> armed 50 (window 22, need 22). The P1's own
proposed assertion passes by construction, both sides being clamp - 10.

The arming arithmetic is extracted to resolve_armed_shutdown_watchdog_delay
so the invariant is measured against the expression gateway.run really
arms with, at both arming sites (live arm + diagnostic snapshot).
test_stop_arms_the_watchdog_at_the_hard_exit_deadline drives the real
stop() and reads the wall-clock delay handed to arm_shutdown_watchdog —
not a call count, not re-derived arithmetic. The grace branch does bind on
a clamp far above the drain (clamp > drain + 70); clamp=300 is pinned too,
so shrinking the grace to the "small grace" the P1 assumed turns the
production row red instead of flipping it quietly.

P1 #3 "unbounded teardown reserve" — FIXED at the read/record boundary,
not with a drain floor (Argus's floor withdrawal stands). Two bounds:

  * record_teardown_timing(budgeted=...) marks a stop that actually ran
    under a supervisor deadline. An unconstrained stop (hermes gateway
    stop, Ctrl+C, foreground) can legitimately take far longer than any
    launchd budget; reading that back as the reserve zeroes the next
    drain. Unbudgeted samples are still written for diagnostics but never
    read back. Legacy records with no provenance field are not trusted.
  * read_last_teardown_seconds(max_seconds=...) rejects a sample larger
    than resolve_max_actionable_teardown_reserve_s(clamp) — the window
    that exists before the hard exit. At clamp 60 a 55s record drove the
    drain to 0.0 and stayed poisoned if that stop hard-exited before
    recording a new sample; it now degrades to "no measurement" (drain
    35.0) and is replaced by the next real measurement.

P1 #2 "cron drain eats teardown" is DUPLICATE-OF t_f753b2b5 / PR #835 and
is not fixed here; the stale `# 45` comment in the cron-leash test is
corrected to `# 35` and cites the card.

Also replaces test_stop_path_arms_via_the_shared_resolver, which read
GatewayRunner.stop's source with inspect.getsource — banned outright by
AGENTS.md ("Never read source code in tests") — with the behavioral
stop()-driving test above.

Verified:
- Mutation-checked, each applied to source and reverted:
  arm from resolve_shutdown_watchdog_delay(drain, grace_s=5.0) -> armed
  33.0 vs 50.0, 1 failed; drop the budgeted guard -> 2 failed; drop the
  max_seconds ceiling -> 2 failed.
- Invariant sweep over clamp {None,1,4,8,10,20,30,45,50,60,90,120,300,600}
  x configured {5,20,30,50,180,600} x last_teardown {None,0,5,22,45,58,
  1e9,inf} = 672 combos through the production boot read: 0 violations of
  window >= max(15, measured) wherever the drain is non-zero; the 240
  residuals are all the specified nonnegative saturation (drain == 0);
  armed < clamp everywhere; non-launchd passthrough intact.
- Focused: 46 + 18 pass. Broad shutdown/restart surface, 19 files:
  383 passed, 0 failed, 4 skipped.
- ruff clean on all changed files; git diff --check clean.

Not verified and not claimed: the live safe-restart at host load >= 20
with 5 active turns ending in a clean exit status, the gateway-exit-diag
teardown line on a real shutdown, merge-queue landing, deployment, the
external fleet-config-lint headroom assertion, and the upstream PR.
@Kyzcreig

Copy link
Copy Markdown
Collaborator Author

FleetReview

Reviewed with 2 of 3 model families — openai unavailable.

Confidence: 3/5

Findings

  • P2 gateway/run.py:20384 — budgeted does not actually exclude hermes gateway stop / Ctrl+C, contradicting the guard it documents
  • P1 gateway/lifecycle_ledger.py:141 — Ceiling zeros drain
  • P2 gateway/run.py:19695 — persistence_complete is a hardcoded literal False in the hard-exit forensic snapshot
  • P3 gateway/run.py:3313 — resolve_launchd_shutdown_watchdog_delay imported into gateway.run but never used
  • P1 gateway/run.py:19812 — Cron drain leash still sized against launchd's SIGKILL wall, so the new hard exit can fire with zero teardown window
  • P1 gateway/run.py:19772 — Teardown reserve is not preserved end-to-end: the capped drain ignores pre-drain elapsed time
  • P2 tests/gateway/test_launchd_exit_timeout_drain_cap.py:573 — 1.0s wait for the hard-exit backstop is too tight, and a miss lets the real os._exit kill the pytest worker
  • P2 tests/gateway/test_launchd_exit_timeout_drain_cap.py:521 — The only end-to-end stop test cannot observe the teardown window it claims to measure
  • P1 gateway/lifecycle_ledger.py:176 — Unbudgeted clobber
  • P1 gateway/restart.py:196 — Drain starved
  • P1 gateway/restart.py:275 — New hard-exit deadline can fire with ~0s teardown when the cron drain floor extends the drain
  • P1 gateway/restart.py:240 — Teardown ceiling ignores the grace-bound watchdog, so a large sample starves persistence
  • P1 tests/gateway/test_launchd_exit_timeout_drain_cap.py:192 — Reserve-invariant parametrization is constructed to avoid the region where the invariant actually fails
  • P1 gateway/restart.py:233 — Short-budget fallback is non-monotonic: an ExitTimeOut just above the reserve arms a ~1s hard exit that skips drain and persistence

FleetReview provenance · models: C=claude-code-opus-5, D=grok-4.6, G=grok-4.6 · cost: $26.02 · duration: 28m 30s · rounds: 1 · files examined: 7

@Kyzcreig
Kyzcreig added this pull request to the merge queue Sep 21, 2026
Merged via the queue into main with commit d6897c6 Sep 21, 2026
54 checks passed
@Kyzcreig
Kyzcreig deleted the wt/t_942691a4 branch September 21, 2026 19:57
Kyzcreig added a commit that referenced this pull request Sep 23, 2026
…not per resume scan

The 2026-09-21 restart_loop.json entries at 02:52:02, 02:54:06 and 02:55:17
were not three gateway boots. The watchdog recorded PID 99910 continuously
across that whole span (02:50:41 through 02:56:59). What actually happened is
that _schedule_resume_pending_sessions calls restart_loop_guard.check_and_record
whenever candidates exist, and the startup platform pass, the startup-wide pass
and a Discord reconnect each recorded the SAME process as a fresh boot --
falsely tripping the threshold inside a single boot.

Fix at the module layer: the guard now derives a stable per-process boot
identity (pid + process-local monotonic import stamp, refreshed after a fork)
and persists it alongside the timestamps as boot_ids. A repeated scan carrying
an identity already in the ledger returns the existing chain without appending.
That covers every caller of check_and_record, survives into the state file, and
makes restart_loop.json forensically honest about what was one boot.

Removes the per-runner _restart_loop_guard_recorded_this_boot flag added in
 #821. It lived on the GatewayRunner instance, so it only deduplicated scans
routed through that one object and never reached the disk ledger. Keeping both
mechanisms double-suppressed the scans: with the instance flag in place, the
module dedupe could be deleted outright and the regression tests stayed green
(measured -- mutation left both files passing). The guard now owns the dedupe
and the caller invokes it unconditionally.

 #821's test is re-pointed rather than dropped: it previously mocked out
check_and_record / is_restart_loop_tripped and asserted the call pattern, which
could not observe the ledger at all. It now asserts the real observable -- one
entry on disk after repeated scans.

Verified:
  * scripts/run_tests.sh over tests/gateway/test_restart_cascade.py,
    tests/hermes_cli/test_gateway_restart_loop.py and
    tests/gateway/test_auto_continue_interrupted_turns.py:
    431 passed, 0 failed, exit 0.
  * Mutation proof that both regression tests actually gate the bug: disabling
    the dedupe (`if False and identity in stored_ids`) turns
    test_resume_rescans_record_one_gateway_boot RED at `assert 3 == 1` and
    test_restart_loop_guard_records_at_most_once_per_gateway_boot RED at
    `assert 2 == 1`.
  * Cross-process control on the real default identity (no boot_id injection),
    3 interpreters x 3 resume scans each:
      pid=71517 scans=[False,False,False] ledger=1
      pid=71634 scans=[False,False,False] ledger=2
      pid=71662 scans=[True,True,True]    ledger=3
    9 scans -> 3 entries, 3 unique boot ids, trips on the 3rd genuine boot.
  * ruff: All checks passed. git diff --check: clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant