Gateway session_reset zombie slot cleanup fixes
Cluster 28686 · gateway. Data as of
Nothing open on GitHub. Fix merged in #39060. Check it covers your case.
The short answer
- Known?
- Yes. 5 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #39060. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
unresolved. That is separate from the fix event. - Still open
- Nothing open on GitHub.
A bug and several fixes addressing zombie agent slots left behind when session_reset races with in-flight runs or credential pool exhaustion, causing subsequent messages to be silently dropped. Related cleanup includes consolidating per-session teardown, closing SessionDB on shutdown, and stripping a stray environment variable from the Windows restart helper.
Open issues0
Open PRs0
Fixes on record
-
#39060 fix(gateway): clear zombie agent slot when session_reset races in-flight run
files:
gateway/run.py,tests/gateway/test_session_state_cleanup.pyShow the diff
-
gateway/run.py@@ -8470,18 +8470,14 @@ async def _do_undo(): logger.debug("goal continuation hook failed: %s", _goal_exc) return _agent_result finally: - # If _run_agent replaced the sentinel with a real agent and - # then cleaned it up, this is a no-op. If we exited early - # (exception, command fallthrough, etc.) the sentinel must - # not linger or the session would be permanently locked out. - if self._running_agents.get(_quick_key) is _AGENT_PENDING_SENTINEL: - self._release_running_agent_state(_quick_key) - else: - # Agent path already cleaned _running_agents; make sure - # the paired metadata dicts are gone too. - self._running_agents_ts.pop(_quick_key, None) - if hasattr(self, "_busy_ack_ts"): - self._busy_ack_ts.pop(_quick_key, None) + # Unconditional release covers every exit path. _release_running_agent_state + # is idempotent (pop-on-absent is harmless) and, called without a + # run_generation guard, always clears the slot regardless of which + # generation it holds. This evicts the zombie left when session_reset + # bumps the generation (N -> N+1) mid-flight: gen-N's guarded release + # inside _run_agent returns False, and the old sentinel-only check here + # missed the leftover real agent — locking the session out forever (#28686). + self._release_running_agent_state(_quick_key) async def _prepare_inbound_message_text( self, @@ -9978,6 +9974,12 @@ async def _handle_reset_command(self, event: MessageEvent) -> Union[str, Ephemer # Get existing session key session_key = self._session_key_for_source(source) self._invalidate_session_run_generation(session_key, reason="session_reset") + # Evict the running-agent slot now that the generation is bumped. The + # in-flight run's own guarded release (run_generation=old) will return + # False and leave its dead agent behind; clearing here keeps the slot + # from becoming a zombie that silently drops all later messages (#28686). + # Idempotent, so the run's finally calling it again is harmless. + self._release_running_agent_state(session_key) # Snapshot the old entry so on_session_finalize can report the # expiring session id before reset_session() rotates it. -
tests/gateway/test_session_state_cleanup.py@@ -228,3 +228,54 @@ def test_shutdown_tolerates_close_raising(self): flaky_db.close.assert_called_once() healthy_db.close.assert_called_once() + + +class TestSessionResetZombieRace: + """Regression for #28686 — a session_reset racing the in-flight run's + guarded release must not leave a dead agent locking the slot forever. + """ + + def test_generation_guard_blocks_then_unconditional_release_evicts(self): + runner = _make_runner() + runner._session_run_generation = {} + key = "agent:main:telegram:private:1" + + gen_n = runner._begin_session_run_generation(key) + dead_agent = MagicMock() + runner._running_agents[key] = dead_agent + runner._running_agents_ts[key] = 1.0 + runner._busy_ack_ts[key] = 1.0 + + # session_reset bumps the generation while gen-N is still in flight. + runner._invalidate_session_run_generation(key, reason="session_reset") + + # gen-N's own guarded release is correctly blocked — slot would be a + # zombie if nothing else cleared it (the pre-fix behaviour). + assert runner._release_running_agent_state(key, run_generation=gen_n) is False + assert runner._running_agents.get(key) is dead_agent + + # The fix: unconditional release (no run_generation) always clears it. + assert runner._release_running_agent_state(key) is True + assert key not in runner._running_agents + assert key not in runner._running_agents_ts + assert key not in runner._busy_ack_ts + + def test_normal_completion_is_not_evicted_by_outer_release(self): + """Guarded release with the current generation succeeds; the outer + unconditional release that follows is a harmless no-op. + """ + runner = _make_runner() + runner._session_run_generation = {} + key = "agent:main:telegram:private:2" + + gen = runner._begin_session_run_generation(key) + runner._running_agents[key] = MagicMock() + runner._running_agents_ts[key] = 1.0 + runner._busy_ack_ts[key] = 1.0 + + assert runner._release_running_agent_state(key, run_generation=gen) is True + assert key not in runner._running_agents + # Outer finally runs the unconditional release after — nothing stranded. + assert runner._release_running_agent_state(key) is True + assert key not in runner._running_agents_ts + assert key not in runner._busy_ack_ts
-
3 PRs closed without merging
From the close comments: done elsewhere 1 · merged via another change 1 · other 1
-
#28689 fix(gateway): clear stale agent slot after session_reset to prevent zombie thread · done elsewhere →
693f4c7,ae29484,#39060,#28686 -
#30834 fix(gateway): clear zombie agent slot when session_reset races credential pool exhaustion · merged via another change →
693f4c7,#39060 - #40059 fix(gateway): strip _HERMES_GATEWAY from Windows detached restart helper env · other
Closed issues · 1
- Closed#28686Bug: session_reset + credential pool exhaustion leaves thread session in zombie state — subsequent messages silently dropped
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.40: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- gateway
- Merged PRs
- #39060
- Closed issues
- 1
- Attempts
- 3 PRs closed without merging
- Fix link checked
- 4 Jun 2026