You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
rpc host: a superseded generation never announces the handoff to attached clients, never parks their sessions, and never exits while they stay connected #1933
When a generation of the shared multi-session RPC host is superseded (a successor takes the public socket; watchForSupersession or SIGUSR1 puts the predecessor into drainForHandoff), the predecessor keeps serving the connections it already proxies and parks only the sessions nobody is attached to. A client that stays attached is told nothing: no wire record announces the supersession, its sessions are never parked, and IdleExitDecider (connections must be zero) keeps the superseded process alive for as long as that client holds its socket. Meanwhile any traffic that client sends over a NEW connection lands on the successor, whose registry has never seen those handles, and answers unknown_session. Observed on one machine 2026-09-21: a desktop client attached to generation A kept a multi-GB superseded host alive after generation B took the socket, and its next set_model/prompt on the thread answered unknown_session from generation B while A still held the session.
Reproduction
Start a multi-session host on a public socket; open one session from a client and keep the connection attached and idle.
Start a second supervisor that takes the public socket (an upgrade handoff, or simply a newer build's host ensure).
Observe: the first supervisor logs "draining into the next generation" / "another generation owns the public socket"; the attached client receives no record; senpi host status on the new socket lists none of the old sessions; the old process never exits while the client stays connected; a command from the client on a fresh connection to the public path answers unknown_session.
Expected (ideal state / acceptance criteria)
On supersession the predecessor sends every attached connection an unsolicited record — proposed { "type": "host_superseded", "instanceId", "generation", "successor": { "socket" } | null } — before anything else changes for that connection.
Attached sessions are parked too, not only unattached ones: once a session's current turn settles (or immediately when idle, and always within a bounded grace the supervisor owns), the predecessor sends session_closed { reason: "handoff_parked", sessionPath } and closes the connection, so the client reattaches by open_session { sessionPath } on the successor — exactly the recovery rpc-types.ts already documents for handoff_parked. No turn is cut short; no session is ended.
A client that ignores host_superseded still gets the guarantee above (the session_closed + close is what moves it); a client that honours it can reattach early.
docs/rpc.md documents the record and the grace; CHANGELOG.md [Unreleased] and src/changes.md carry entries; regression tests in packages/coding-agent/test cover: attached-idle session parked and announced; attached mid-turn session parked only after its turn settles; superseded generation exits with a still-attached client; a command sent after the announce on the OLD connection is served or answered by the close, never by unknown_session.
Actual
host-lifecycle.tsdrainForHandoff(): "stop accepting, keep every connection already proxied, and tell the host child to park its retained sessions as their turns settle" — attached sessions are excluded from parking; the accept guard destroys only NEW connections; nothing is written to existing clients. IdleExitDecider needs connections == 0 continuously, so an attached client pins the generation forever.
Evidence
packages/coding-agent/src/modes/rpc/host-lifecycle.ts — drainForHandoff() (keeps proxied connections, SIGUSR1 to the child), the accept guard (if (shuttingDown || draining) client.destroy()), IdleExitDecider (connections gate), watchForSupersession wiring ("Losing the public entry IS a drain request").
packages/coding-agent/src/modes/rpc/host-supersession.ts — detection only; no client notification.
packages/coding-agent/src/modes/rpc/rpc-types.ts — session_closed.reason union already has handoff_parked ("open_session { sessionPath } reopens it in the new generation"); instanceId "changes when the host process does, including on a handoff"; no host_superseded-style record exists in the event union.
packages/coding-agent/src/modes/rpc/session-registry.tsgetForCommand — the successor's registry is empty for the predecessor's handles, hence unknown_session.
In: the host_superseded record, parking + announcing attached sessions after settle within a bounded grace, exit of the superseded generation, docs, changelog, tests. Out: the client-side reattach (the desktop tracks it separately), the takeover decision policy (host-decision.ts already refuses silent-but-live hosts and requires a strictly newer build for upgrade).
Related
#1893 (superseded generation never drains), #1805-era handoff, #1782 (drain-and-exit). Desktop counterpart: code-yeongyu/omo-desktop-app#849 (reactive recovery on unknown_session) and #850 (proactive reconciliation, which will consume host_superseded and handoff_parked).
Summary
When a generation of the shared multi-session RPC host is superseded (a successor takes the public socket;
watchForSupersessionor SIGUSR1 puts the predecessor intodrainForHandoff), the predecessor keeps serving the connections it already proxies and parks only the sessions nobody is attached to. A client that stays attached is told nothing: no wire record announces the supersession, its sessions are never parked, andIdleExitDecider(connections must be zero) keeps the superseded process alive for as long as that client holds its socket. Meanwhile any traffic that client sends over a NEW connection lands on the successor, whose registry has never seen those handles, and answersunknown_session. Observed on one machine 2026-09-21: a desktop client attached to generation A kept a multi-GB superseded host alive after generation B took the socket, and its nextset_model/prompton the thread answeredunknown_sessionfrom generation B while A still held the session.Reproduction
upgradehandoff, or simply a newer build'shost ensure).senpi host statuson the new socket lists none of the old sessions; the old process never exits while the client stays connected; a command from the client on a fresh connection to the public path answersunknown_session.Expected (ideal state / acceptance criteria)
{ "type": "host_superseded", "instanceId", "generation", "successor": { "socket" } | null }— before anything else changes for that connection.session_closed { reason: "handoff_parked", sessionPath }and closes the connection, so the client reattaches byopen_session { sessionPath }on the successor — exactly the recoveryrpc-types.tsalready documents forhandoff_parked. No turn is cut short; no session is ended.host_supersededstill gets the guarantee above (thesession_closed+ close is what moves it); a client that honours it can reattach early.docs/rpc.mddocuments the record and the grace;CHANGELOG.md [Unreleased]andsrc/changes.mdcarry entries; regression tests inpackages/coding-agent/testcover: attached-idle session parked and announced; attached mid-turn session parked only after its turn settles; superseded generation exits with a still-attached client; a command sent after the announce on the OLD connection is served or answered by the close, never byunknown_session.Actual
host-lifecycle.tsdrainForHandoff(): "stop accepting, keep every connection already proxied, and tell the host child to park its retained sessions as their turns settle" — attached sessions are excluded from parking; the accept guard destroys only NEW connections; nothing is written to existing clients.IdleExitDeciderneedsconnections == 0continuously, so an attached client pins the generation forever.Evidence
packages/coding-agent/src/modes/rpc/host-lifecycle.ts—drainForHandoff()(keeps proxied connections, SIGUSR1 to the child), the accept guard (if (shuttingDown || draining) client.destroy()),IdleExitDecider(connections gate),watchForSupersessionwiring ("Losing the public entry IS a drain request").packages/coding-agent/src/modes/rpc/host-supersession.ts— detection only; no client notification.packages/coding-agent/src/modes/rpc/rpc-types.ts—session_closed.reasonunion already hashandoff_parked("open_session { sessionPath } reopens it in the new generation");instanceId"changes when the host process does, including on a handoff"; nohost_superseded-style record exists in the event union.packages/coding-agent/src/modes/rpc/session-registry.tsgetForCommand— the successor's registry is empty for the predecessor's handles, henceunknown_session.CHANGELOG.md [2026.9.21]— the shared RPC host: a superseded generation never drains — keeps ~6 GB RSS, retained sessions and 59 path reservations, so the current generation refuses opens with session_path_in_use; hosts.json lists dead pids #1893 entry: "parks the sessions nobody is attached to, releases their files and exits" — the attached case was left out by design and is what this issue closes.Scope
In: the
host_supersededrecord, parking + announcing attached sessions after settle within a bounded grace, exit of the superseded generation, docs, changelog, tests. Out: the client-side reattach (the desktop tracks it separately), the takeover decision policy (host-decision.tsalready refuses silent-but-live hosts and requires a strictly newer build forupgrade).Related
#1893 (superseded generation never drains), #1805-era handoff, #1782 (drain-and-exit). Desktop counterpart: code-yeongyu/omo-desktop-app#849 (reactive recovery on
unknown_session) and #850 (proactive reconciliation, which will consumehost_supersededandhandoff_parked).