NousResearch/hermes-agent

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

0open issues
0open PRs
5items grouped
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

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #39060 fix(gateway): clear zombie agent slot when session_reset races in-flight run

    merged to main

    files: gateway/run.py, tests/gateway/test_session_state_cleanup.py

    Show 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.00 means 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 Lab

At a glance

Subsystem
gateway
Merged PRs
#39060
Closed issues
1
Attempts
3 PRs closed without merging
Fix link checked
4 Jun 2026

For your agent

The same facts, typed.