Skip to content

fix(gateway): notify user when stale-routing self-heal can't recover the old session - #59597

Open
hejuntt1014 wants to merge 1 commit into
NousResearch:mainfrom
hejuntt1014:fix/stale-routing-notify
Open

hejuntt1014 wants to merge 1 commit into
NousResearch:mainfrom
hejuntt1014:fix/stale-routing-notify

Conversation

@hejuntt1014

Copy link
Copy Markdown
Contributor

What does this PR do?

The #54878/#55485 stale-routing self-heal in get_or_create_session correctly detects when a sessions.json routing entry points at a session that state.db already marked ended (e.g. a TUI attached to the same session_key closed and wrote end_reason="tui_shutdown", while the Gateway routing entry was left untouched). It drops the stale entry and falls through to _recover_session_from_db.

But when that recovery attempt also fails — because hermes_state.py::find_latest_gateway_session_for_peer only treats ended_at IS NULL or end_reason='agent_close' as recoverable, and tui_shutdown isn't in that set — a brand-new, empty session is created in total silence. The self-heal branch hardcodes was_auto_reset = False, so not even the generic idle/daily fallback notice (which idle/daily/suspended/resume_pending_expired resets already get) fires. The user has no way to learn their routing silently moved until they notice the missing context.

This PR implements the "safe" half of the two-part fix proposed in #59580 — the notification gap, which is a pure additive fix with no behavior-changing side effects. The second half (whether tui_shutdown should join agent_close in the recoverable end_reason set, i.e. whether the session itself should be reopened instead of replaced) is a design question that needs maintainer input and is intentionally left untouched here.

Related Issue

Addresses #59580 (notification gap only — see the issue for the open design question on recoverability)

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✅ Tests (adding or improving test coverage)

Changes Made

  • gateway/session.py (get_or_create_session):

  • gateway/run.py (_handle_message_with_agent, _was_auto_reset block):

    • Added elif reset_reason == "stale_routing_recovered": for the agent context note.
    • Added "stale_routing_recovered" to the should_notify always-on set (alongside "suspended", "resume_pending_expired").
    • Added elif reset_reason == "stale_routing_recovered": for the user-facing notice reason_text.
  • tests/gateway/test_session_reset_notify.py:

    • Added TestStaleRoutingSelfHealNotify with 5 new tests:
      • test_unrecoverable_end_reason_notifies_and_creates_new_sessionend_reason='tui_shutdown' → new session with auto_reset_reason == "stale_routing_recovered"
      • test_unrecoverable_end_reason_does_not_overwrite_db_end_reasondb.end_session is never called for the old session (its real reason is preserved)
      • test_had_activity_reflects_dropped_sessionreset_had_activity reflects the dropped session's token usage
      • test_recoverable_end_reason_reopens_without_notifying — non-regression: end_reason='agent_close' IS recoverable, silently reopens the SAME session_id, no notification
      • test_brand_new_peer_does_not_trigger_stale_routing_reason — a peer with no prior routing entry is just a normal first session, not a self-heal

How to Test

  1. Route a session to session_id = A via any Gateway platform.
  2. End A in state.db with end_reason='tui_shutdown' (e.g. attach and cleanly close a TUI against the same session_key) while sessions.json still points at A.
  3. Send a new inbound message on the same routing key.
  4. Before this fix: gateway.session: routing key ... is ended in state.db ... (#54878) is logged, a new empty session is created, and the user receives no notice at all.
  5. After this fix: the user receives ◐ Session automatically reset (previous session ended and could not be auto-resumed). ..., and A's end_reason in state.db is untouched.

Or run the new unit tests:

pytest tests/gateway/test_session_reset_notify.py -v

Checklist

Code

Documentation & Housekeeping

  • I've updated cli-config.yaml.example if I added/changed config keys — or N/A (no new config keys)
  • I've considered cross-platform impact — N/A (pure Python logic, no platform-specific code)
  • I've updated tool descriptions/schemas if I changed tool behavior — N/A

Screenshots / Logs

Field incident from #59580 (profile dingding, 2026-07-05 → 2026-07-06):

23:30:09  User /resume's back into session 57182b via WeChat — history=2146.
00:01:19  Session 57182b ends in state.db: end_reason="tui_shutdown"
          (a TUI attached to the same profile/session_key closed).
          sessions.json is NOT updated — still routes WeChat -> 57182b.
00:02:43  WeChat inbound message.
  WARNING gateway.session: routing key ... -> 57182b is ended in state.db
    but still live in sessions.json; dropping stale entry and
    recovering/recreating the session (#54878)
  -> NO user-facing notice sent.
00:04:11  New session 2717754c created, sessions.json now routes here.
09:21:22  User sends a message, unaware the routing switched 9 hours
          earlier — lands in the new session, not the thread they expected.

After this fix, the user would have received:

◐ Session automatically reset (previous session ended and could not be auto-resumed).
Conversation history cleared.
Use /resume to browse and restore a previous session.
Adjust reset timing in config.yaml under session_reset.

Made with Cursor

@alt-glitch alt-glitch added type/bug Something isn't working 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 P2 Medium — degraded but workaround exists labels Jul 6, 2026
jcjc81 added a commit to jcjc81/hermes-agent that referenced this pull request Jul 14, 2026
…ead sessions

The NousResearch#54878 self-heal (SessionStore.get_or_create_session) drops a routing
key pointing at a session already ended in state.db and recovers/recreates
a fresh session_id under the same session_key. The NousResearch#54947 fix (agent-cache
cache-hit guard in gateway/run.py) treats a cached agent whose snapshot
session_id differs from the current session_id, under the same
session_key, as an intentional /resume-/branch-style switch between two
live sibling conversations, and reuses it unchanged to protect the prompt
cache.

These two fixes compose incorrectly: when the NousResearch#54878 self-heal just fired,
the cached agent's session_id is not a live sibling — it's the dead session
just routed away from. NousResearch#54947's "different session_id -> reuse freely" rule
reuses it anyway. The stale agent runs the turn, and the post-run "session
split" sync (agent.session_id != session_id) then writes the routing key
straight back onto the dead session_id, undoing the self-heal. This repeats
on every subsequent message until an interrupt (e.g. /stop) happens to race
in before that post-run sync, silently discarding conversation context.

Reproduced live on the engineering gateway (2026-07-12, routing key
agent:main:telegram:dm:170829464:544520): 5 consecutive self-heal log lines
over ~40 minutes, each followed by the dead session_id being reused and
re-synced back, until an interrupted /stop finally let a fresh session
stick — at which point all prior context was gone.

No open upstream issue tracks this specific interaction as of 2026-07-12
(checked NousResearch#54878, NousResearch#54947, NousResearch#59580, NousResearch#59597, NousResearch#61220 — all cover adjacent but
distinct edges of the self-heal / agent-cache system).

Fix: before applying NousResearch#54947's reuse-on-mismatch rule, check (outside the
cache lock, via SessionStore._is_session_ended_in_db) whether the cached
snapshot's session_id is itself ended in state.db. If so, treat it as a
stale self-heal artifact and evict/rebuild fresh -- same as a genuine
cross-process write -- instead of reusing it. Re-validates the peeked
verdict against the tuple actually held under the lock so a race can't
apply a stale verdict to a different (possibly live) cache entry.

Tests: tests/gateway/test_stale_self_heal_agent_cache_eviction.py (5 new
cases: dead-session eviction, live-sibling reuse preserved [NousResearch#54947 intact],
cross-process invalidation preserved [NousResearch#45966 intact], same-session_id dead
edge case, lock-race re-validation). Full tests/gateway/ suite: 14 failed,
9040 passed, 11 skipped -- all 14 failures verified pre-existing on
unpatched main (confirmed via git stash + re-run), unrelated to this
change.

(cherry picked from commit cb055f54850b30f1e956a87c4ddbfd2560d51f8b)
jcjc81 added a commit to jcjc81/hermes-agent that referenced this pull request Jul 15, 2026
…ead sessions

The NousResearch#54878 self-heal (SessionStore.get_or_create_session) drops a routing
key pointing at a session already ended in state.db and recovers/recreates
a fresh session_id under the same session_key. The NousResearch#54947 fix (agent-cache
cache-hit guard in gateway/run.py) treats a cached agent whose snapshot
session_id differs from the current session_id, under the same
session_key, as an intentional /resume-/branch-style switch between two
live sibling conversations, and reuses it unchanged to protect the prompt
cache.

These two fixes compose incorrectly: when the NousResearch#54878 self-heal just fired,
the cached agent's session_id is not a live sibling — it's the dead session
just routed away from. NousResearch#54947's "different session_id -> reuse freely" rule
reuses it anyway. The stale agent runs the turn, and the post-run "session
split" sync (agent.session_id != session_id) then writes the routing key
straight back onto the dead session_id, undoing the self-heal. This repeats
on every subsequent message until an interrupt (e.g. /stop) happens to race
in before that post-run sync, silently discarding conversation context.

Reproduced live on the engineering gateway (2026-07-12, routing key
agent:main:telegram:dm:170829464:544520): 5 consecutive self-heal log lines
over ~40 minutes, each followed by the dead session_id being reused and
re-synced back, until an interrupted /stop finally let a fresh session
stick — at which point all prior context was gone.

No open upstream issue tracks this specific interaction as of 2026-07-12
(checked NousResearch#54878, NousResearch#54947, NousResearch#59580, NousResearch#59597, NousResearch#61220 — all cover adjacent but
distinct edges of the self-heal / agent-cache system).

Fix: before applying NousResearch#54947's reuse-on-mismatch rule, check (outside the
cache lock, via SessionStore._is_session_ended_in_db) whether the cached
snapshot's session_id is itself ended in state.db. If so, treat it as a
stale self-heal artifact and evict/rebuild fresh -- same as a genuine
cross-process write -- instead of reusing it. Re-validates the peeked
verdict against the tuple actually held under the lock so a race can't
apply a stale verdict to a different (possibly live) cache entry.

Tests: tests/gateway/test_stale_self_heal_agent_cache_eviction.py (5 new
cases: dead-session eviction, live-sibling reuse preserved [NousResearch#54947 intact],
cross-process invalidation preserved [NousResearch#45966 intact], same-session_id dead
edge case, lock-race re-validation). Full tests/gateway/ suite: 14 failed,
9040 passed, 11 skipped -- all 14 failures verified pre-existing on
unpatched main (confirmed via git stash + re-run), unrelated to this
change.
kshitijk4poor pushed a commit that referenced this pull request Jul 15, 2026
…ead sessions

The #54878 self-heal (SessionStore.get_or_create_session) drops a routing
key pointing at a session already ended in state.db and recovers/recreates
a fresh session_id under the same session_key. The #54947 fix (agent-cache
cache-hit guard in gateway/run.py) treats a cached agent whose snapshot
session_id differs from the current session_id, under the same
session_key, as an intentional /resume-/branch-style switch between two
live sibling conversations, and reuses it unchanged to protect the prompt
cache.

These two fixes compose incorrectly: when the #54878 self-heal just fired,
the cached agent's session_id is not a live sibling — it's the dead session
just routed away from. #54947's "different session_id -> reuse freely" rule
reuses it anyway. The stale agent runs the turn, and the post-run "session
split" sync (agent.session_id != session_id) then writes the routing key
straight back onto the dead session_id, undoing the self-heal. This repeats
on every subsequent message until an interrupt (e.g. /stop) happens to race
in before that post-run sync, silently discarding conversation context.

Reproduced live on the engineering gateway (2026-07-12, routing key
agent:main:telegram:dm:170829464:544520): 5 consecutive self-heal log lines
over ~40 minutes, each followed by the dead session_id being reused and
re-synced back, until an interrupted /stop finally let a fresh session
stick — at which point all prior context was gone.

No open upstream issue tracks this specific interaction as of 2026-07-12
(checked #54878, #54947, #59580, #59597, #61220 — all cover adjacent but
distinct edges of the self-heal / agent-cache system).

Fix: before applying #54947's reuse-on-mismatch rule, check (outside the
cache lock, via SessionStore._is_session_ended_in_db) whether the cached
snapshot's session_id is itself ended in state.db. If so, treat it as a
stale self-heal artifact and evict/rebuild fresh -- same as a genuine
cross-process write -- instead of reusing it. Re-validates the peeked
verdict against the tuple actually held under the lock so a race can't
apply a stale verdict to a different (possibly live) cache entry.

Tests: tests/gateway/test_stale_self_heal_agent_cache_eviction.py (5 new
cases: dead-session eviction, live-sibling reuse preserved [#54947 intact],
cross-process invalidation preserved [#45966 intact], same-session_id dead
edge case, lock-race re-validation). Full tests/gateway/ suite: 14 failed,
9040 passed, 11 skipped -- all 14 failures verified pre-existing on
unpatched main (confirmed via git stash + re-run), unrelated to this
change.
@teknium1

Copy link
Copy Markdown
Collaborator

Thanks for isolating the notification-only half of this failure mode. The premise remains valid on current main: gateway/session.py:1903-1920 drops a routing entry whose DB row is ended, hermes_state.py:2128 still excludes tui_shutdown from recovery, and a fresh SessionEntry is then built with default non-reset metadata at gateway/session.py:1957-1973. Since gateway/run.py:11092-11149 only emits the context note and user notice when was_auto_reset is true, this can still be silent.

Problems

  • Current routing code was substantially refactored in b196ce80c897d08e56982dbc88336b028285adf9; the PR is DIRTY and its old get_or_create_session edits need a focused port to _get_or_create_session_impl.
  • The proposed tests do not exercise adapter delivery in gateway/run.py:11120-11149, so they do not verify the promised user-visible notification.

Suggested changes

  • Snapshot stale-entry metadata before its removal at gateway/session.py:1918; set reset metadata only after _query_recoverable_session fails, immediately before the fresh candidate is built.
  • Add a GatewayRunner/recording-adapter regression for delivery plus recovery and first-peer non-regressions.

Automated hermes-sweeper review.

@teknium1 teknium1 added the sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform label Jul 15, 2026
Port NousResearch#59597 onto current main's refactored SessionStore: when the NousResearch#54878
self-heal drops an ended routing entry and recovery declines to reopen it
(e.g. tui_shutdown), mark the fresh session as stale_routing_recovered so
GatewayRunner always notifies instead of silently replacing the thread.

Co-authored-by: Cursor <cursoragent@cursor.com>
@hejuntt1014
hejuntt1014 force-pushed the fix/stale-routing-notify branch from 078b059 to 497426e Compare July 19, 2026 14:37
@hejuntt1014

Copy link
Copy Markdown
Contributor Author

Reimplemented on current main (post-b196ce80 SessionStore refactor) rather than rebasing the dirty old branch.

Changes

  • gateway/session.py: snapshot dropped stale-entry metadata before pop; after _query_recoverable_session fails, set was_auto_reset=True / auto_reset_reason="stale_routing_recovered" immediately before the fresh candidate is built. Does not overwrite the already-finalized DB end_reason.
  • gateway/run.py: extracted _auto_reset_context_note + _maybe_send_auto_reset_notice; stale_routing_recovered always notifies (same as suspended / resume_pending_expired) with a resume-hinted context note and user-facing reason text.
  • Tests: ported TestStaleRoutingSelfHealNotify (create/notify, no DB overwrite, had_activity, recoverable reopen, first-peer non-regression) plus sweeper-requested TestStaleRoutingAdapterDelivery (recording-adapter delivery even when policy.notify=False; idle still respects notify=False).

venv/bin/pytest tests/gateway/test_session_reset_notify.py31 passed.

Gravezzz pushed a commit to Gravezzz/hermes-agent that referenced this pull request Jul 21, 2026
…ead sessions

The NousResearch#54878 self-heal (SessionStore.get_or_create_session) drops a routing
key pointing at a session already ended in state.db and recovers/recreates
a fresh session_id under the same session_key. The NousResearch#54947 fix (agent-cache
cache-hit guard in gateway/run.py) treats a cached agent whose snapshot
session_id differs from the current session_id, under the same
session_key, as an intentional /resume-/branch-style switch between two
live sibling conversations, and reuses it unchanged to protect the prompt
cache.

These two fixes compose incorrectly: when the NousResearch#54878 self-heal just fired,
the cached agent's session_id is not a live sibling — it's the dead session
just routed away from. NousResearch#54947's "different session_id -> reuse freely" rule
reuses it anyway. The stale agent runs the turn, and the post-run "session
split" sync (agent.session_id != session_id) then writes the routing key
straight back onto the dead session_id, undoing the self-heal. This repeats
on every subsequent message until an interrupt (e.g. /stop) happens to race
in before that post-run sync, silently discarding conversation context.

Reproduced live on the engineering gateway (2026-07-12, routing key
agent:main:telegram:dm:170829464:544520): 5 consecutive self-heal log lines
over ~40 minutes, each followed by the dead session_id being reused and
re-synced back, until an interrupted /stop finally let a fresh session
stick — at which point all prior context was gone.

No open upstream issue tracks this specific interaction as of 2026-07-12
(checked NousResearch#54878, NousResearch#54947, NousResearch#59580, NousResearch#59597, NousResearch#61220 — all cover adjacent but
distinct edges of the self-heal / agent-cache system).

Fix: before applying NousResearch#54947's reuse-on-mismatch rule, check (outside the
cache lock, via SessionStore._is_session_ended_in_db) whether the cached
snapshot's session_id is itself ended in state.db. If so, treat it as a
stale self-heal artifact and evict/rebuild fresh -- same as a genuine
cross-process write -- instead of reusing it. Re-validates the peeked
verdict against the tuple actually held under the lock so a race can't
apply a stale verdict to a different (possibly live) cache entry.

Tests: tests/gateway/test_stale_self_heal_agent_cache_eviction.py (5 new
cases: dead-session eviction, live-sibling reuse preserved [NousResearch#54947 intact],
cross-process invalidation preserved [NousResearch#45966 intact], same-session_id dead
edge case, lock-race re-validation). Full tests/gateway/ suite: 14 failed,
9040 passed, 11 skipped -- all 14 failures verified pre-existing on
unpatched main (confirmed via git stash + re-run), unrelated to this
change.
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
…ead sessions

The NousResearch#54878 self-heal (SessionStore.get_or_create_session) drops a routing
key pointing at a session already ended in state.db and recovers/recreates
a fresh session_id under the same session_key. The NousResearch#54947 fix (agent-cache
cache-hit guard in gateway/run.py) treats a cached agent whose snapshot
session_id differs from the current session_id, under the same
session_key, as an intentional /resume-/branch-style switch between two
live sibling conversations, and reuses it unchanged to protect the prompt
cache.

These two fixes compose incorrectly: when the NousResearch#54878 self-heal just fired,
the cached agent's session_id is not a live sibling — it's the dead session
just routed away from. NousResearch#54947's "different session_id -> reuse freely" rule
reuses it anyway. The stale agent runs the turn, and the post-run "session
split" sync (agent.session_id != session_id) then writes the routing key
straight back onto the dead session_id, undoing the self-heal. This repeats
on every subsequent message until an interrupt (e.g. /stop) happens to race
in before that post-run sync, silently discarding conversation context.

Reproduced live on the engineering gateway (2026-07-12, routing key
agent:main:telegram:dm:170829464:544520): 5 consecutive self-heal log lines
over ~40 minutes, each followed by the dead session_id being reused and
re-synced back, until an interrupted /stop finally let a fresh session
stick — at which point all prior context was gone.

No open upstream issue tracks this specific interaction as of 2026-07-12
(checked NousResearch#54878, NousResearch#54947, NousResearch#59580, NousResearch#59597, NousResearch#61220 — all cover adjacent but
distinct edges of the self-heal / agent-cache system).

Fix: before applying NousResearch#54947's reuse-on-mismatch rule, check (outside the
cache lock, via SessionStore._is_session_ended_in_db) whether the cached
snapshot's session_id is itself ended in state.db. If so, treat it as a
stale self-heal artifact and evict/rebuild fresh -- same as a genuine
cross-process write -- instead of reusing it. Re-validates the peeked
verdict against the tuple actually held under the lock so a race can't
apply a stale verdict to a different (possibly live) cache entry.

Tests: tests/gateway/test_stale_self_heal_agent_cache_eviction.py (5 new
cases: dead-session eviction, live-sibling reuse preserved [NousResearch#54947 intact],
cross-process invalidation preserved [NousResearch#45966 intact], same-session_id dead
edge case, lock-race re-validation). Full tests/gateway/ suite: 14 failed,
9040 passed, 11 skipped -- all 14 failures verified pre-existing on
unpatched main (confirmed via git stash + re-run), unrelated to this
change.
prmartinow pushed a commit to prmartinow/hermes-agent that referenced this pull request Aug 26, 2026
…ead sessions

The NousResearch#54878 self-heal (SessionStore.get_or_create_session) drops a routing
key pointing at a session already ended in state.db and recovers/recreates
a fresh session_id under the same session_key. The NousResearch#54947 fix (agent-cache
cache-hit guard in gateway/run.py) treats a cached agent whose snapshot
session_id differs from the current session_id, under the same
session_key, as an intentional /resume-/branch-style switch between two
live sibling conversations, and reuses it unchanged to protect the prompt
cache.

These two fixes compose incorrectly: when the NousResearch#54878 self-heal just fired,
the cached agent's session_id is not a live sibling — it's the dead session
just routed away from. NousResearch#54947's "different session_id -> reuse freely" rule
reuses it anyway. The stale agent runs the turn, and the post-run "session
split" sync (agent.session_id != session_id) then writes the routing key
straight back onto the dead session_id, undoing the self-heal. This repeats
on every subsequent message until an interrupt (e.g. /stop) happens to race
in before that post-run sync, silently discarding conversation context.

Reproduced live on the engineering gateway (2026-07-12, routing key
agent:main:telegram:dm:170829464:544520): 5 consecutive self-heal log lines
over ~40 minutes, each followed by the dead session_id being reused and
re-synced back, until an interrupted /stop finally let a fresh session
stick — at which point all prior context was gone.

No open upstream issue tracks this specific interaction as of 2026-07-12
(checked NousResearch#54878, NousResearch#54947, NousResearch#59580, NousResearch#59597, NousResearch#61220 — all cover adjacent but
distinct edges of the self-heal / agent-cache system).

Fix: before applying NousResearch#54947's reuse-on-mismatch rule, check (outside the
cache lock, via SessionStore._is_session_ended_in_db) whether the cached
snapshot's session_id is itself ended in state.db. If so, treat it as a
stale self-heal artifact and evict/rebuild fresh -- same as a genuine
cross-process write -- instead of reusing it. Re-validates the peeked
verdict against the tuple actually held under the lock so a race can't
apply a stale verdict to a different (possibly live) cache entry.

Tests: tests/gateway/test_stale_self_heal_agent_cache_eviction.py (5 new
cases: dead-session eviction, live-sibling reuse preserved [NousResearch#54947 intact],
cross-process invalidation preserved [NousResearch#45966 intact], same-session_id dead
edge case, lock-race re-validation). Full tests/gateway/ suite: 14 failed,
9040 passed, 11 skipped -- all 14 failures verified pre-existing on
unpatched main (confirmed via git stash + re-run), unrelated to this
change.
melon-xf added a commit to melon-xf/hermes-agent that referenced this pull request Sep 3, 2026
…ead sessions

The NousResearch#54878 self-heal (SessionStore.get_or_create_session) drops a routing
key pointing at a session already ended in state.db and recovers/recreates
a fresh session_id under the same session_key. The NousResearch#54947 fix (agent-cache
cache-hit guard in gateway/run.py) treats a cached agent whose snapshot
session_id differs from the current session_id, under the same
session_key, as an intentional /resume-/branch-style switch between two
live sibling conversations, and reuses it unchanged to protect the prompt
cache.

These two fixes compose incorrectly: when the NousResearch#54878 self-heal just fired,
the cached agent's session_id is not a live sibling — it's the dead session
just routed away from. NousResearch#54947's "different session_id -> reuse freely" rule
reuses it anyway. The stale agent runs the turn, and the post-run "session
split" sync (agent.session_id != session_id) then writes the routing key
straight back onto the dead session_id, undoing the self-heal. This repeats
on every subsequent message until an interrupt (e.g. /stop) happens to race
in before that post-run sync, silently discarding conversation context.

Reproduced live on the engineering gateway (2026-07-12, routing key
agent:main:telegram:dm:170829464:544520): 5 consecutive self-heal log lines
over ~40 minutes, each followed by the dead session_id being reused and
re-synced back, until an interrupted /stop finally let a fresh session
stick — at which point all prior context was gone.

No open upstream issue tracks this specific interaction as of 2026-07-12
(checked NousResearch#54878, NousResearch#54947, NousResearch#59580, NousResearch#59597, NousResearch#61220 — all cover adjacent but
distinct edges of the self-heal / agent-cache system).

Fix: before applying NousResearch#54947's reuse-on-mismatch rule, check (outside the
cache lock, via SessionStore._is_session_ended_in_db) whether the cached
snapshot's session_id is itself ended in state.db. If so, treat it as a
stale self-heal artifact and evict/rebuild fresh -- same as a genuine
cross-process write -- instead of reusing it. Re-validates the peeked
verdict against the tuple actually held under the lock so a race can't
apply a stale verdict to a different (possibly live) cache entry.

Tests: tests/gateway/test_stale_self_heal_agent_cache_eviction.py (5 new
cases: dead-session eviction, live-sibling reuse preserved [NousResearch#54947 intact],
cross-process invalidation preserved [NousResearch#45966 intact], same-session_id dead
edge case, lock-race re-validation). Full tests/gateway/ suite: 14 failed,
9040 passed, 11 skipped -- all 14 failures verified pre-existing on
unpatched main (confirmed via git stash + re-run), unrelated to this
change.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform 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.

3 participants