Follow-up on the #58818 cron-drain fix. The housekeeping ticker uses the
same loop-scheduled-future pattern as cron — it refreshes the channel
directory via safe_schedule_threadsafe(build_channel_directory(...), loop)
and blocks on fut.result(timeout=30). The original fix swapped its
join(5) for _await_thread_exit(5), which is a strict improvement (the loop
stays alive so the future can run) but the 5s bound is shorter than the
30s future, so a refresh in flight at shutdown was still abandoned. Bound
the housekeeping drain at 35s (30s future + margin) via a dedicated
_HOUSEKEEPING_SHUTDOWN_DRAIN_TIMEOUT constant. Not user-facing (self-heals
next tick) but keeps the cooperative drain honest across both threads.
A cron delivery uses the live adapter by scheduling the send coroutine onto the
gateway event loop (safe_schedule_threadsafe) and blocking the ticker thread on
future.result(). On shutdown/restart the cleanup ran a synchronous
cron_thread.join(timeout=5), which blocks the event loop — so the pending
delivery coroutine could never execute, the join always timed out, and the
message was silently dropped (#58818). The default agent.restart_drain_timeout
is 0, so this fired on every restart with an in-flight delivery.
Replace the blocking joins with _await_thread_exit(), which polls is_alive()
via await asyncio.sleep so the loop keeps running and finishes the queued
delivery before teardown. The cron wait is bounded by the delivery future's own
60s ceiling (plus margin); housekeeping keeps a short bound. When no delivery is
in flight the ticker exits on stop_event immediately, so shutdown stays snappy.
Inspired by Claude Code v2.1.199 (July 2, 2026): stacked slash-skill
invocations load all leading skills (up to 5), not just the first.
- agent/skill_commands.py: split_stacked_skill_commands() consumes leading
/skill tokens (stops at the first non-skill token so slash-path arguments
are never swallowed); build_stacked_skill_invocation_message() composes
the multi-skill turn reusing the existing bundle scaffolding markers so
extract_user_instruction_from_skill_message() keeps memory providers
storing the user's instruction, not N skill bodies.
- cli.py + gateway/run.py: dispatch the stacked path on both surfaces.
- 11 new tests + docs section in skills.md.
Both gateway compression entry points (session-hygiene auto-compress in
run.py; manual /compress in slash_commands.py) filtered the transcript
to user/assistant-only, content-bearing messages before calling
_compress_context. That starved the compressor:
- tool results are usually the bulk of the context, and
_prune_old_tool_results never saw them
- short filtered histories tripped the protect-first/last early-return,
so compression became a no-op even on huge sessions
- assistant tool_calls stubs (content=None) were dropped, so even the
summary lost the tool activity
Pass user/assistant/tool messages through intact, matching what the
agent loop itself feeds _compress_context.
Port of PR #3854 onto current main (the manual-compress handler moved
from run.py to slash_commands.py since the PR branched); regression test
asserts tool messages reach the compressor.
Authored-by: David Zhang <david.d.zhang@gmail.com> (@Git-on-my-level)
Co-authored-by: David Zhang <david.d.zhang@gmail.com>
Self-review (3-agent + codex) findings on the async QueueListener change:
1. (HIGH) The os._exit shutdown backstop called flush_log_queue(), whose
stop() joins the listener thread unbounded. If that thread is wedged on
the rotation lock — the exact failure this change survives — shutdown
re-freezes. Add drain_log_queue(timeout): stop-only, bounded via a
throwaway joiner thread. Also release PID/runtime locks BEFORE the drain
so a slow drain can't strand them.
2. (MED) _log_queue/_queue_listener/_queued_file_handlers were read-modify-
written without a lock across register/stop/flush/reset; a gateway-init
race with a plugin/CLI path could leave two live listeners. Guard all
four globals with a single _queue_state_lock.
3. (MED) _NonFormattingQueueHandler.prepare() enqueued the same LogRecord a
synchronous handler on the emitting thread may still format/mutate.
Return copy.copy(record) (preserves msg/args/exc_info for deferred
RedactingFormatter) to remove the cross-thread mutation race.
E2E-verified: bounded drain returns in ~500ms on a permanently-wedged
listener; 4x20 concurrent flushes single-listener no-crash; args still
format and secrets still redact through the copied record.
The QueueListener change routes rotating file handlers through an
in-memory queue drained on a dedicated thread, with an atexit hook to
flush on shutdown. But _exit_after_graceful_shutdown() uses os._exit,
which bypasses atexit — so on the early-exit and #53107 hard-exit paths
the queued records (including the shutdown reason) were silently lost.
Explicitly flush_log_queue() before os._exit, and correct the now-stale
comment that claimed handlers are synchronous with nothing pending.
After a config change (e.g. switching model provider), the /new command
must clear the per-session _last_resolved_model cache so the next turn
resolves the model from the updated config instead of falling back to
the stale cached value.
Without this fix, if a transient config-cache miss occurs on the first
post-/new turn, the #35314 recovery path serves the old model from the
cache — the user sees the old model being used even though they changed
config.yaml and explicitly ran /new.
Fix applies to both call sites that reset session model state:
- GatewaySlashCommandsMixin._handle_reset_command (slash_commands.py)
- GatewayRunner compression-exhausted auto-reset (run.py)
Fixes#58403
Per-session /model overrides supplied api_key and provider but omitted
credential_pool, so billing rotation never ran on HTTP 402. Wire the pool
on fast override, rehydrate, and apply paths; backfill from provider for
legacy persisted overrides. Regression tests in tests/gateway/.
Split placeholder TERMINAL_CWD resolution into three cases: local falls
back to MESSAGING_CWD/home; docker without workspace mount leaves cwd unset;
docker with mount enabled preserves an explicit host MESSAGING_CWD path for
terminal_tool's /workspace mapping. Stops leaking host Path.home() into
containers without breaking the mount contract.
Co-authored-by: Cursor <cursoragent@cursor.com>
Follow-up to the salvaged SSH-tilde-cwd fix. The predicate
"backend == ssh and (cwd == ~ or cwd.startswith(~/))" was inlined at
each expanduser guard site, which is how the test simulator drifted from
production (it grew an SSH guard on a top-level-alias branch that has no
production counterpart).
- Add tools/terminal_tool._is_ssh_remote_tilde_cwd(backend, cwd) as the
single source of truth (case/whitespace-tolerant).
- Use it in _get_env_config and the gateway config bridge.
- Test simulator imports the real helper instead of re-implementing the
predicate; revert the phantom SSH guard on the top-level-alias branch
(production maps top-level cwd: to a plain env var, not TERMINAL_CWD via
an SSH-guarded path — that branch tested nothing real).
The cherry-picked #48919 fix resolved next_session_key AFTER
_prepare_inbound_message_text had already buffered native image paths
under the stale key. Reorder so the write key and the consume key are
the same resolved key.
Follow-up to the cherry-picked #31856 fix. The contributor's guard defers
idle-TTL eviction until the session store reports the session expired, so the
expiry watcher can tear the agent down and fire MemoryProvider.on_session_end()
with the live transcript. Two gaps remained:
1. Memory-leak regression for mode='none' sessions. _is_session_expired()
returns False forever for the 'none' reset policy, so the naive guard would
never idle-evict those agents — reopening the unbounded-cache leak the idle
sweep (#11565) exists to relieve. Added SessionStore.is_session_finalizable()
(a public predicate: will the expiry watcher EVER finalize this session?) and
gate the deferral on it. mode='none' agents fall through to soft eviction as
before.
2. on_session_end still dropped on the LRU-cap path. Both cache-pressure paths
(_enforce_agent_cache_cap and _sweep_idle_cached_agents) soft-evict via
_release_evicted_agent_soft, which by design does NOT fire on_session_end.
If cache pressure evicts a finalizable-but-not-yet-expired agent before it
expires, the watcher later finds no cached agent and the hook is skipped.
Added _commit_memory_before_soft_evict(): at LRU eviction, if the session is
finalizable and not yet expired, commit end-of-session extraction via the
live agent's own (fully-scoped) memory manager using commit_memory_session()
— extraction WITHOUT provider teardown, so the eviction stays soft and a
resumed turn keeps working. Skipped for mode='none' (no missed boundary to
compensate) and expired sessions (the watcher tears those down directly).
This closes#11205 for ALL eviction paths and reset policies, not just the
idle-sweep + finite-policy case, while preserving the soft-eviction
resumability contract (never calls close() on a live session).
Tests: 5 new cases in test_agent_cache.py (mode='none' still reaped, LRU-cap
commits for finalizable / skips for none, real is_session_finalizable
predicate); all mutation-checked. Contributor's original 2 tests updated to
assert the finalizable path explicitly.
The idle-TTL sweep (_sweep_idle_cached_agents) was evicting agents
as soon as they passed _AGENT_CACHE_IDLE_TTL_SECS, even when the
session hadn't expired yet. In daily-reset mode the reset can fire
hours after the last user message — evicting the agent early means
the session-expiry watcher has no agent in cache to call
on_session_end() with, so memory providers miss the live transcript.
Now the sweep checks the session store before evicting: if the
session still exists and hasn't expired, the agent stays in cache
so the expiry watcher can tear it down properly later.
When the session store is unavailable or throws, falls back to the
original eviction behavior (safe default).
Fixes: #11205
A Z.ai desktop user reported thinking reverting to medium after one turn,
burning ~200% of a week's credits in 4 days despite reasoning_effort: false
in config.yaml. Four compounding bugs:
- _session_info reported reasoning_effort "" for disabled reasoning,
indistinguishable from unset — the desktop adopted it after the first
turn, wiping its sticky "thinking off" pick so every later chat
reverted to the default effort.
- config.set key=reasoning always wrote agent.reasoning_effort to global
config.yaml, so every desktop model-menu selection (preset.effort ??
'medium') clobbered the user's configured value. Now session-scoped
like the messaging gateway's /reasoning, landing on
create_reasoning_override so lazily-built sessions keep it too.
- YAML `reasoning_effort: false`/`off`/`no` (boolean False) was coerced
to "" by every loader's `str(x or "")`, silently re-enabling thinking.
parse_reasoning_effort now treats False/"false"/"disabled" as
{"enabled": False}; loaders (tui gateway, gateway, cli, cron,
delegate) pass the raw value through. The desktop config reader also
crashed on the boolean (false.trim()), aborting voice/STT settings.
- The zai provider profile never sent thinking on the wire, and GLM-4.5+
defaults to thinking ON server-side — so disabling reasoning was a
silent no-op on direct Z.ai, the actual token burner. The profile now
emits extra_body.thinking {"type": "enabled"|"disabled"} for
thinking-capable GLM models, mirroring the DeepSeek profile.
Also: /new (session reset) now carries reasoning_config across the
rebuild like model_override; config.get reasoning prefers the session's
live value and maps a config False to "none"; Settings shows "Off"
instead of a blank select for hand-written false.
Per-session /model overrides (_session_model_overrides) were in-memory only,
so a gateway restart silently reverted every session to the global default
model. Persist the non-secret parts (model/provider/base_url ONLY — never
api_key) into the session entry in sessions.json and lazily rehydrate them
on first use after a restart, re-resolving credentials through the normal
runtime provider resolution.
- gateway/session.py: SessionEntry.model_override field with
sanitize_model_override() (allowlist: model/provider/base_url) applied on
both serialization and deserialization; SessionStore.set_model_override /
get_model_override accessors. reset_session() already creates a fresh entry,
so /new keeps its clear-on-reset semantics — a restart cannot resurrect an
override the user reset away.
- gateway/slash_commands.py: write-through at both /model set sites (text
command + picker) after storing the in-memory override.
- gateway/run.py: _rehydrate_session_model_override() called from
_resolve_session_agent_runtime(); in-memory state always wins, credentials
are re-resolved per provider (credential-less fallback on failure). Session
expiry finalization also drops the persisted override.
- tests/gateway/test_session_model_override_persistence.py: restart
round-trip, /new clearing, api_key-never-serialized (including tampered
sessions.json), rehydration + live-state precedence + credential-failure
degradation.
Salvaged from #3659 by @Git-on-my-level, narrowed to the restart-persistence
gap confirmed in triage.
Salvage of #3459 by @keslerm, reimplemented against the restructured
progress-callback block in gateway/run.py (resolve_display_setting,
needs_progress_queue, thinking-relay). Duplicate PR #3458 by @dlkakbs was
submitted 4 minutes earlier with the same feature — both credited.
Co-authored-by: Dilee <uzmpsk.dilekakbas@gmail.com>
tool_progress: log keeps the chat silent and appends timestamped tool-call
lines to ~/.hermes/logs/tool_calls.log via a dedicated queue drained by an
async writer (RotatingFileHandler 5MB x 3, RedactingFormatter so secrets
never land on disk). Gateway-only by design; thinking_progress relaying and
the webhook gate are unaffected. /verbose now cycles
off -> new -> all -> verbose -> log.
- ChannelOverride + channel_overrides on PlatformConfig
- Resolve model/runtime: session /model, then channel_overrides, then global
- Thread/parent channel lookup; bridge discord.channel_overrides from YAML
- Drop unrelated test and delegate_tool changes from PR scope
Three connected changes that fix kanban notifications in multiplex_profile
gateways and enable event-driven agent collaboration:
1. Session profile propagation
- Add HERMES_SESSION_PROFILE ContextVar (session_context.py)
- Gateway stamps source.profile at dispatch time (run.py)
- _maybe_auto_subscribe reads profile from ContextVar instead of
os.environ which is unset in the gateway main process (kanban_tools.py)
2. Notifier profile-aware routing (kanban_watchers.py)
- Adapter selection: prefer _profile_adapters[sub.notifier_profile]
so each profile's bot delivers its own task notifications
- Relax profile skip-filter: process cross-profile subscriptions when
the gateway has an adapter for the owning profile
- Extend TERMINAL_KINDS with status/archived/unblocked
3. Creator agent wakeup on terminal events (kanban_watchers.py)
- After delivering completed/blocked/gave_up/crashed/timed_out
notifications, inject a synthetic MessageEvent into the creator's
session via adapter.handle_message to trigger their agent loop
- SessionSource built from subscription metadata — no session_store
lookup needed
With the default busy_input_mode=interrupt, a burst of rapid gateway
messages arriving while context compression is in flight could interrupt
the current turn and start a fresh turn against the pre-rotation parent
session. Because compression is interrupt-immune (#23975), the still-
running compression later rotates the id out from under that new turn,
and if the new turn also grew past the compression threshold it started
its own uncancellable compression on the same stale parent — forking
multiple orphaned one-shot sibling continuations (#56391).
While a state.db compression lock is held for the session, demote
'interrupt' busy-input mode to 'queue' semantics (mirroring the subagent
protection in #30170), so the follow-up message waits for the in-flight
compression + its id rotation to land instead of racing a new turn
against the stale parent. Ack copy explains the compression demotion.
Fixes#56391.
- Correct the exit-75 comment: Hermes-generated units set
StartLimitIntervalSec=0 (rate limiting disabled), so StartLimitBurst
does not bound loops. The real bound is that genuine crashes exit
non-zero-but-not-75, and RestartForceExitStatus=75 only whitelists
the planned code.
- Add randomuser2026x AUTHOR_MAP entry (CI blocks unmapped emails).
The in-chat /restart command was leaving the gateway dead on systemd
deployments using Restart=on-failure (the default for many
operator-managed and tutorial-style unit files). The gateway drained,
exited cleanly (code 0), and was never revived — the only recovery was
a host reboot.
Root cause was a multi-layer assumption mismatch:
1. gateway/run.py:_stop_impl assumed all systemd units use
Restart=always, so the Linux/systemd branch returned exit code 0
and relied on a `systemd-run` transient helper to restart the unit
immediately. Units with Restart=on-failure never see a clean exit
as a trigger, so nothing revived the process.
2. gateway/run.py:_launch_systemd_restart_shortcut hardcoded
`--user` scope, so it could not even locate the unit PID on
system-level deployments (the common case for
/etc/systemd/system/hermes-gateway.service). It silently returned
without launching the helper.
3. Even after the scope detection was fixed, the helper could not
actually start: non-root gateway units (User=ubunutu) hit a Polkit
denial on `systemd-run --system` ("Interactive authentication
required"), and `--user` requires a D-Bus user session that is
typically absent on headless servers.
The fix is two-fold:
* `_stop_impl` now always exits with GATEWAY_SERVICE_RESTART_EXIT_CODE
(75 / EX_TEMPFAIL) on service-managed restarts, regardless of
platform. Combined with RestartForceExitStatus=75 in the unit file,
systemd treats the planned restart as a controlled failure and
revives the gateway via Restart=on-failure, with RestartSec as the
only delay. The planned-restart helper is still attempted (for
RestartSec=0 setups that want sub-second restarts) but is no longer
load-bearing.
* `_launch_systemd_restart_shortcut` now probes both system and user
scopes via MainPID equality and uses whichever scope actually owns
the gateway process. It bails out safely if neither matches.
StartLimitBurst in the unit file still bounds accidental restart
loops, and the macOS launchd path is unchanged.
Verified end-to-end on Ubuntu 24.04 with hermes-gateway as a
/etc/systemd/system/... service running under User=ubunutu. The
unit uses Restart=on-failure, RestartSec=30, RestartForceExitStatus=75,
StartLimitIntervalSec=600, StartLimitBurst=5. /restart from Feishu now
drains cleanly, exits 75, and the gateway is back online ~30s later
without manual intervention.
Tests: tests/gateway/test_gateway_shutdown.py renamed the affected
case to test_gateway_stop_systemd_service_restart_uses_tempfail and
now asserts exit_code == GATEWAY_SERVICE_RESTART_EXIT_CODE.
14/14 tests in this module pass.
The salvaged _sync_session_model_from_agent reached into
self._session_db._execute_write with a duplicate inline read-modify-write
and a comment claiming SessionDB had no metadata updater — but
update_session_meta already exists for exactly this. It also called the
AsyncSessionDB forwarder synchronously (via _execute_write), which returns
an un-awaited coroutine, so the write silently never ran.
Route through the synchronous SessionDB (self._session_db._db) — the same
pattern the surrounding run_sync closure already uses (it runs off the
event loop in the executor) — and use the existing update_session_meta /
get_session helpers instead of raw SQL.
- Track auth store source path on Nous state reads and write rotated
OAuth refresh tokens back to the same store, preventing stale-token
replays when Hermes falls back to a global/root auth.json.
- Skip Nous fallback entries locally when no access/refresh token is
present, suppressing repeated failed resolution attempts within a
session.
- Sync session model metadata after fallback switches so the gateway
DB reflects the backend that actually served the latest turn.
Auto-resume of restart-interrupted sessions bypassed auth checks.
The session owner was never validated against TELEGRAM_ALLOWED_USERS
(or equivalent) before the synthetic resume event was dispatched. An
attacker with an active session before the allowlist was configured
could receive a full agent response on gateway restart (issue #23778).
Clean rebase of #23800 onto current main (egilewski flagged a merge
conflict in gateway/run.py on the old branch).
Fix: check _is_user_authorized() for the session owner before
scheduling auto-resume. Unauthorized sessions are skipped with a
warning log instead of silently resuming.
Fixes#23778 (partial - auto-resume auth bypass)
Follow-up correcting the salvaged fix's persistence approach to avoid a
duplicate user-message write (verified via E2E — the #860/#42039 bug class
the original diff aimed to avoid).
Root cause: in gateway mode the AIAgent is built WITH a session_db, so the
inbound user turn is already flushed at turn start (turn_context.
_persist_session). The original fix returned agent_persisted=False, making the
gateway re-write the whole new-message slice via append_to_transcript ->
append_message (a raw INSERT with no dedup), duplicating the already-flushed
user turn.
Corrected approach (single writer): run_codex_app_server_turn now flushes its
OWN projected assistant/tool messages via _flush_messages_to_session_db (which
dedups the already-persisted user turn through _DB_PERSISTED_MARKER) and
returns agent_persisted=True so the gateway skips its write. Net result:
session_search/distill see the full codex conversation, each message persisted
exactly once.
Adds regression coverage asserting exactly-once persistence on a real
SessionDB, agent_persisted=True, FTS visibility, and standard-runtime skip-db
behaviour preserved.
Co-authored-by: Lubos Buracinsky <lubos@komfi.health>
The codex_app_server runtime path (run_codex_app_server_turn in
agent/codex_runtime.py) is an early-return that bypasses
conversation_loop and never calls _flush_messages_to_session_db().
Meanwhile, gateway/run.py sets:
agent_persisted = self._session_db is not None # always True
and passes skip_db=agent_persisted to every append_to_transcript call,
assuming the agent self-persisted (correct for the standard runtime,
wrong for codex). The result: codex turn messages are persisted nowhere.
state.db accumulates only session_meta rows; session_search (full-text
search over state.db) and conversation-distill are blind to real gateway
conversations, causing 'the agent has no memory of what we discussed'.
Fix (three-part, all backward-compatible):
1. agent/codex_runtime.py — run_codex_app_server_turn success return
now includes 'agent_persisted': False, signalling that the codex path
did NOT self-persist its turn.
2. gateway/run.py — the agent_persisted assignment now reads:
agent_result.get('agent_persisted', self._session_db is not None)
For the standard runtime (which does not set the key) the default
(self._session_db is not None) preserves the existing skip-db
behaviour so no duplicate-write regression (#860 / #42039) occurs.
For the codex runtime the flag is False, so the gateway writes the
new turn's messages to state.db and FTS index.
3. gateway/run.py — the rebuilt result dict (run_agent return, which
becomes agent_result upstream) now includes agent_persisted passed
through from result_holder[0], with a safe True default. Without
this passthrough the flag set in step 1 was discarded when the result
was reconstructed, causing agent_result.get('agent_persisted', ...)
to always see the default True and never write codex turns.
Follow-up on the salvaged resume_pending fix: the empty-turn safety net
now emits the same reason-aware recovery note as the _is_resume_pending
branch (reason phrase + 'session restored' guidance + no-re-execute
instruction) instead of a second, differently-worded note. Also adds the
AUTHOR_MAP entry for the salvaged commit.
A session interrupted by a gateway restart is flagged resume_pending and
auto-continued on startup via _schedule_resume_pending_sessions(), which
dispatches an empty-text internal MessageEvent. The recovery system note
that should fill that empty turn is gated, in _run_agent(), on
_interruption_is_fresh — the age of the LAST PERSISTED TRANSCRIPT ROW.
For an active thread returned to after >1h of silence, that transcript
clock is stale even though the interruption (last_resume_marked_at) is
seconds old. The gate evaluates False, the note is not prepended, and the
model receives a genuinely blank user turn — replying with confused
'that message came through blank' noise.
Fix (two parts, both default-on, behavior unchanged for healthy turns):
1. resume_pending freshness now also considers last_resume_marked_at (the
restart watchdog's own stamp). The branch fires when EITHER the
transcript clock OR the resume mark is fresh, so the startup scheduler's
freshness decision and the per-turn injection agree.
2. Empty-turn safety net: if the user turn is still blank after all
injections AND the session is resume_pending, backfill a recovery note
so a blank turn can never reach the model. Scoped to resume_pending so
ordinary empty turns (e.g. uncaptioned image) are untouched.
Adds 3 regression tests; the two core ones fail on the pre-fix logic.
Aligns runtime behaviour with SECURITY.md 2.6: externally reachable
messaging adapters must fail closed unless access is explicitly
configured. Closes the confirmed multiplex authorization bypass a
secondary profile's open dm/group policy no longer inherits the default
profile's allowlist trust.
- Own-policy adapters (WhatsApp, WeCom, Weixin, QQBot, Yuanbao) default
dm_policy/group_policy to pairing/allowlist instead of open; open now
requires an explicit GATEWAY_ALLOW_ALL_USERS or per-platform allow-all.
- Startup guard (_own_policy_open_startup_violation) refuses to boot when
an enabled adapter is open without the allow-all opt-in; the guard now
runs for every secondary profile in multiplex mode too.
- Profile-aware own-policy authorization: _authorization_adapter /
_adapter_for_source resolve the live adapter via SessionSource.profile,
so _is_user_authorized and the ingress/pairing/busy/queue paths read the
originating profile's adapter policy, not the default profile's.
- Fail-closed intake for Email, Feishu P2P, and Discord (blank-principal
denial, empty-allowlist deny, missing-interaction.user deny).
Salvaged from #44073 (external-surface hardening), split into a focused
gateway-authz PR per maintainer request. Follow-up fix by Hermes Agent:
the Discord slash-auth channel bypass now matches DISCORD_ALLOWED_CHANNELS
by the same name-inclusive keys (id + name + #name + parent) the on_message
scope gate uses, so a name-form channel allowlist authorizes slash
interactions consistently (was id-only, breaking #name matching).
Co-authored-by: Hermes Agent <agent@nousresearch.com>
Follow-up to #54111. That PR routed the early SystemExit exit paths
(clean-fatal-config #51228, startup-aborted-before-running) through
_exit_after_graceful_shutdown / os._exit. Those paths raise right after
runner.start() without going through _stop_impl, so they relied on atexit
to release the PID file + runtime lock — and os._exit bypasses atexit,
leaking both.
Release them explicitly in the backstop (the single guaranteed cleanup
chokepoint). Both calls are idempotent: no-op on the normal _stop_impl
path, actual cleanup on the early-exit paths. Corrects the now-inaccurate
docstring claim that teardown always ran first. Adds a guard test plus the
missing str-code->1 coverage.
E2E: real PID file written + lock acquired, _exit_after_graceful_shutdown(78)
exits code 78 AND removes the PID file (leak confirmed closed).