fix(tui_gateway): isolated turns no longer fence themselves out of their own session lease (#101416, salvage #103737) - #113974
Merged
Merged
Conversation
…n lease With dashboard.turn_isolation enabled, every NEW desktop session routes its turns through the compute-host child process. The parent claims the session's active-session lease in prompt.submit, but the child's freshly built session record carried no lease — so _admit_prompt_turn re-claimed from the child's pid and was fenced out by the parent's own registry entry (_is_same_writer requires the same pid AND the same live_session_id). Result: 'Session ... already has a live owner (desktop, pid N, running 0m)' on the first message of every new session, plus one unreclaimable lease leaked per attempt (the owner pid is the immortal dashboard process, so _prune_dead never reclaims it). Fix: the parent vouches on the turn frame (parent_owns_active_session_lease, derived from the session's actual lease state) and the child installs an inert borrow — ActiveSessionLease(enabled=False, released=True) — before the turn pipeline runs. Admission sees the slot as held upstream; the child can never release or transfer the parent's slot; _is_same_writer and the fail-closed refusal are untouched. Without the vouch (parent predates the field) the child keeps the legacy self-claim path. Fixes the compute-host (turn_isolation) variant of #101416; the cross-process route-split variant in that thread is a separate Desktop bug. Related #101416.
૮ >ﻌ< ა ci reviewran on 1c60aab — chore: map contributor email for @jakobbjelver debug infoCI timingsCI timings · View report · View jobWall time 8m40s vs 46m37s (-81.4%). 5 job(s) slower, 6 faster, 1 unchanged.
|
… compression rotation The borrowed token from #103737 was released=True, so when the child compressed and rotated the stored id A->B, transfer_active_session() returned False and _transfer_active_session_slot fell through to a REAL registry claim under the child pid: parent owned A, child owned B (split authority). A bare-boolean vouch then let a replacement child borrow B on the strength of the stale A lease. - The borrow is enabled=False but NOT released: release() is a no-op and transfer only retargets the token locally, so the child never writes the registry. - The parent vouches with {lease_id, session_id} and the child installs the borrow only when that session_id is the admitted stored id. - The parent re-anchors its real lease A->B where it already adopts the child's rotated session_key (_compute_host_adopt_frame_meta: turn.end and compress acks), so authority stays singular and keyed on the live continuation. Owner-side re-anchor + qualified admission identity follow the design in Bergmann89's #101501. Co-authored-by: Bergmann89 <info@bergmann89.de> Co-authored-by: Jakob Bjelvér <jakobbjelver@gmail.com>
…solated turn settles _teardown_popped_session only waited for session["_run_thread"]; an isolated turn runs in the compute-host child, so session.close reached _finalize_session and released the parent's REAL lease while the child was still writing — a second backend could acquire the stored session mid-turn (the double-writer state #99719 closed). Close now interrupts the child turn and waits the same grace; if the turn has not settled, the real lease is moved out of the session (finalize releases nothing) and released by the turn.end/turn.error completion callback — which child death also fires via _fail_pending_turns. Deferred leases stay live authority for the orphan sweep (_own_live_lease_ids). The RPC close stays bounded; ownership ends with the child's last write. Deferred canonical-lease lifetime follows Bergmann89's #101501. Co-authored-by: Bergmann89 <info@bergmann89.de>
Replaces the 383-line suite from #103737 with four invariants: the real child turn path admits under the parent's lease (registry unchanged) and still fails closed on a vouch for another stored id; a child-side A->B rotation never claims and the parent re-anchors its real lease; session.close keeps the lease refusing a distinct writer until the isolated turn settles.
teknium1
force-pushed
the
fix/sno-isolated-lease
branch
from
September 17, 2026 08:25
9bb904f to
1c60aab
Compare
This was referenced Sep 17, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
With
dashboard.turn_isolation: true, a new Desktop session's first message no longer fails with "This chat is open in another Hermes window/terminal" — the compute-host child now runs under the parent's lease, and that lease stays singular through compression rotation and throughsession.close.Fixes the compute-host variant of #101416 (the child fenced out by its own parent's registry entry). VIPKaiser's cross-process route-split variant in that thread is a different bug and is handled by another lane, so this PR does not close the issue.
Changes
turn.startframe and the child installs an inertActiveSessionLease(enabled=False)before_run_prompt_submit, so_admit_prompt_turnsees the slot as held upstream instead of re-claiming from the child pid (session_lifecycle._install_borrowed_lease,compute_host.ComputeHost._run_real_turn).released=True(a released token madetransfer_active_sessionreturnFalseand the fallback claimed a REAL lease for B under the child pid). The vouch is qualified —{lease_id, session_id}— and the child only borrows when it names the admitted stored id; the parent re-anchors its real lease A→B where it adopts the child's rotatedsession_key(compute_host_bridge._compute_host_adopt_frame_meta, onturn.endand compress acks). A stale A lease never vouches for B (compute_host_bridge._active_session_lease_vouch).session_lifecycle._teardown_popped_sessionnow interrupts the isolated turn, waits the same close grace, and if the child has not settled moves the real lease out of finalize's reach; the correlatedturn.end/turn.error(child death fires it via_fail_pending_turns) releases it (_settle_isolated_turn_before_close,_release_deferred_active_session_lease). Deferred leases stay live authority for the orphan sweep (_own_live_lease_ids).origin/main, 4 green here.Root cause
The parent claims the registry lease in
prompt.submitbefore routing, but the child's freshly built session record had no lease, so it re-claimed from its own pid and_is_same_writer(same pid AND same live id) refused it.Live evidence (real compute-host child, temp
HERMES_HOME,HERMES_ISO_CERTIFY_SYNTH_TURN=1)origin/main@ bbaf7afRefused active session stored-A: already held by pid=<parent> surface=desktop→ client getsThis chat is open in another Hermes window/terminal…on every attemptmessage.complete '[synthetic heavy turn] …'; registry = exactly one entry{pid: <parent>, session_id: stored-A}across 2 turns[{parent, stored-A}, {child, stored-B}]; replacement child ran under B on the stale A vouch[{parent, stored-B}]after rotation, after a second turn, and after a child restart;parent lease.session_id=stored-Bsession.closewhile child turn live (B1)[], foreigntry_acquire_active_sessionsucceeded mid-turnholding lease for stored-A until the child settles; foreign acquire refused (SESSION_NOT_OWNED); afterturn.endregistry[]and foreign acquire succeedsturn_isolation: falseSuite:
scripts/run_tests.sh tests/tui_gateway tests/hermes_cli→ 14020 passed, 5 failed —test_dashboard_auth_gate(4, also red on bareorigin/mainon this host) and onetest_profiles_sidebar_cache/test_auth_commandstiming flake under 40 workers that passes in isolation; none touch this PR's files.Not covered
interruptframe does not stop an isolated synthetic turn in the child (session["running"]readsFalsein the child mid-turn, so_interrupt_session_turnno-ops there) — pre-existing onorigin/main, orthogonal to lease ownership; the deferred-release path is what guarantees ownership here.agent.session_idbefore the post-turn_sync_session_key_after_compress; the window untilturn.endis the same one the in-process path has today.Credits
Co-authored-byon the two follow-up commits).Infographic
Independent review (pre-merge) — dispositions
jakobbjelver@gmail.comunmapped → mapping file added (1c60aab). The inheritedCloses #101416trailer was reworded to a Related reference (tree byte-identical) so this PR does not close the issue's route-split half (Desktop: pooled-profile chats no longer land on the local primary and get refused SESSION_NOT_OWNED after reload (#101416 class) #113956).session.closeholds the deferred lease until the parent exits (no supervisor stall-kill exists today); a same-window re-open during that window is refused with the "open in another Hermes window" copy. Bounded second grace + force-terminate is a follow-up, not folded in here.session_keyadvances while the lease stays on A; the next turn has no vouch and takes the legacy self-claim path (fail-closed refusal, never a silent second writer)._fail_pending_turns→_on_compute_host_turn_done→ lease released (registry empty, foreign acquire succeeds); no double release on the ws-orphan path; 806 lifecycle/orphan tests + 71 sibling tests green on head.