Skip to content

fix(gateway): prevent parallel conversation loops on restart mid-turn - #85285

Open
webtecnica wants to merge 1 commit into
NousResearch:mainfrom
webtecnica:fix/85207-restart-single-loop
Open

fix(gateway): prevent parallel conversation loops on restart mid-turn#85285
webtecnica wants to merge 1 commit into
NousResearch:mainfrom
webtecnica:fix/85207-restart-single-loop

Conversation

@webtecnica

Copy link
Copy Markdown
Contributor

Summary

A gateway restart mid-turn spawned two parallel conversation loops on the same session — duplicate (divergent) final responses delivered. The race is cross-process (restart detached): the new gateway comes up while the old process is still draining its turn; the old loop keeps running (making API calls, delivering the final response) while the new boot promotes the durable active_turn_token to resume_pending, clears it, and spawns an auto-resume → 2 loops, 2 divergent finals. The turn lease (#64934) is process-local and doesn't see the other process.

Change (fail-closed: never 2 loops on the same session)

  • gateway/session.py (+67): the durable active_turn_token becomes a lease with an owner — it now carries the gateway PID that wrote it (mark_turn_active); the resume path refuses to start a new loop while the owner is alive (gateway.status._pid_exists, Windows-safe, no os.kill(pid,0)).
  • gateway/run.py (+28): resume/auto-resume consults the owner lease before spawning.
  • tests/gateway/test_active_turn_recovery.py (+81) + test_restart_resume_pending.py (+122): owner-alive → resume refused; owner-dead → resume proceeds; restart-mid-turn → single loop.

Verification

  • tests/gateway/test_active_turn_recovery.py + test_restart_resume_pending.py: 56 passed. Residual 1.7s overlap between detached helper launch and old-process death cannot create a 2nd loop (auto-resume refused while owner alive; bounded by drain timeout + shutdown watchdog).

Closes #85207

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists 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 labels Aug 13, 2026
@ryantuc

ryantuc commented Aug 14, 2026

Copy link
Copy Markdown

The change looks right to me for the gateway stack. A marker with no owner on it cannot tell a resume apart from a second loop, so recording the pid that wrote the lease and refusing to start while that pid is alive is the correct shape.

Before this merges with Closes #85207 on it, I would check whether the reported incident came from this code path at all. The log line quoted in the issue starts with [System note: Your previous turn was interrupted mid-run, and at f52feed1e that string lives in one non-test file: tui_gateway/server.py defines the prefix at :7172 and emits it only from the auto-continue path, _auto_continue_note() at :7490. The occurrence at gateway/run.py:1575 is a shorter prefix constant that _is_auto_continue_noise() uses for detection, and the messaging gateway writes its own recovery note at gateway/run.py:1161 with different wording (The previous turn was interrupted by {reason_phrase}; the gateway is now back online). The emitting path keeps its own durable marker at <HERMES_HOME>/desktop/interrupted_turns.json (tui_gateway/turn_marker.py, from #71184), which is a different file from the SessionStore marker this PR hardens, and nothing under tui_gateway/ can see the new lease: the only gateway.* modules imported anywhere under tui_gateway/ are gateway.config, gateway.run (one log-redaction helper), and gateway.session_context, and git grep -E "active_turn_token|active_turn_pid|mark_turn_active|recover_interrupted_turns|resume_pending" tui_gateway/ returns nothing. hermes serve also dispatches agents through tui_gateway.ws → server._make_agent rather than the GatewayRunner boot path this PR gates, and the issue's environment line (Hermes Desktop, desktop platform (gateway backend)) has no counterpart in the Platform enum in gateway/config.py. If the duplicate reproduces against the desktop marker file, the auto-close is fine and I have misread it. If it does not, dropping Closes would leave the desktop-side hole open in the tracker rather than recorded as fixed by a change that cannot reach it.

Minor, and only a suggestion: a bare pid survives recycling, so a later probe can find an unrelated process wearing the same number and refuse a resume that should have run. hermes_cli/active_sessions.py:206 pairs the pid with psutil.Process(pid).create_time() for that reason, and the same pairing would drop in cleanly here. Separately, I have opened #86190 to cover the cross-process desktop side, which is where that path can be tracked if it turns out to be a separate hole.

@Enough1122

Copy link
Copy Markdown
Contributor

AI code review — automated review for reference, author can ignore or act on any point.

fix(gateway): prevent parallel conversation loops on restart mid-turn

  • gateway/session.py active_turn_owner_alive: PID reuse is the main hazard. After a crash the OS can recycle the PID to an unrelated process; _pid_exists(pid) then returns True and the session's marker is preserved indefinitely, permanently disabling auto-resume for that session (fail-closed in the wrong direction — a stuck session instead of a duplicate response). Consider bounding liveness by the marker's age (e.g. a live owner whose active_turn_started_at is older than the drain window is treated as stale) or recording process start time alongside the PID.
  • active_turn_owner_alive imports the private gateway.status._pid_exists across modules. Promoting it to a public helper (or moving the liveness probe into session.py) avoids a fragile private-name dependency.
  • Minor: a session with resume_pending=True (drain-timeout marker) whose owner is alive stays pending until the old process dies; if that process is stuck/zombie, the session is stuck until manual intervention. The warning log helps, but a short note in the docs about the recovery path (next boot after owner death) would be useful.

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: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.

Bug: gateway restart mid-turn spawns two parallel conversation loops on the same session — duplicate (divergent) final responses delivered

4 participants