Conversation
… policy
Two processes can resume one stored session and both run turns in it. Neither is
refused. The second loads its own snapshot of the transcript, reasons from a
history that does not contain the first one's in-flight turn, and appends anyway
-- so the stored conversation ends up holding two replies that never saw each
other. Observed in practice with two TUI gateway processes.
The registry that would have prevented this already exists, with a file lock,
pid + process-start identity, dead-owner pruning and release. It was simply
never consulted unless an operator had configured a capacity cap:
max_sessions = resolve_max_concurrent_sessions(config)
if max_sessions is None:
return ActiveSessionLease(..., enabled=False), None # registry untouched
So "no cap configured" silently meant "concurrent writers to one session are
fine". They never are. The two concerns are now uncoupled:
per-session exclusivity always enforced correctness
max_concurrent_sessions optional capacity policy
Both decided under the existing lock, immediately after the existing prune, so a
dead owner is never mistaken for a live one and a live one is never overlooked.
RE-ENTRANCY, NOT A HOLE
A live session whose record is rebuilt in place loses its reference to the lease
it already holds, and would then be fenced out of its own session by its own leak
-- permanently, because pruning only removes entries whose PROCESS is dead and
that one is alive. Identity is therefore (pid, live session id): another process
differs by pid, another tab in this process differs by live id, and only the same
writer re-acquiring its own session matches.
A session with no stored id yet is exempt. Treating "" as an identity would make
the first unsaved composer refuse every other one.
THE REFUSAL IS MACHINE-READABLE
An automated client must tell "the machine is at capacity, retry later" from
"this session has a live owner and your write would interleave with theirs".
Those call for different behaviour, and a client forced to match prose changes
behaviour silently whenever the wording improves. prompt.submit now carries
data.reason of SESSION_NOT_OWNED or MAX_CONCURRENT_SESSIONS. The refusal happens
before the busy-queue check, before _ensure_session_db_row and before
_start_agent_build: no user row is persisted and no model turn begins.
gateway.capabilities advertises per_session_exclusive_submit so a client can tell
a build that enforces this from one that does not -- sourced from the module that
performs the check rather than from config, because a capability an operator can
switch on without the mechanism is worse than none, since it is believed.
One existing test released its session by popping it from _sessions without
finalizing, leaking the lease its first turn claimed and then fencing itself out
of the same session_key. It now releases the slot the way _finalize_session does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
00a2799 to
a2fc214
Compare
The unit tests share one interpreter, so they cannot exercise the failure the
fence exists to prevent: two SEPARATE gateway processes, each holding its own
snapshot of a conversation, both writing to it. That is how the defect was found
and it is the only way to show it is closed.
This drives two real `python -m tui_gateway.entry` processes over stdio and
checks the whole sequence, including the parts that are easy to get wrong:
session.create claims nothing an idle composer must not hold a session
the lease keys on the STORED id a lease keyed on the runtime handle would
fence nothing, since two processes
resuming one conversation have different
runtime ids by construction
B may still RESUME reading is never fenced; only writing is
B's submit -> SESSION_NOT_OWNED typed, and the registry is unchanged
A killed, B retries -> accepted a dead owner is pruned, not permanent
No provider is needed. The fence is checked before the agent is built, so a
submit that later fails for want of a model still proves who owns the session --
which keeps the probe free of credentials and of inference cost.
Against the parent commit it stops at the second check with an empty registry,
which is the defect stated exactly: with no cap configured, nothing was recorded
and therefore nothing could be refused.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a2fc214 to
def3573
Compare
This PR fixes a real correctness bug: without Two concerns:
The fix to existing tests in |
|
Review of exact head Blocker 1 — the fence is not on the common turn-admission path; crash auto-continue bypasses itThis PR puts _emit("message.start", sid)
_run_prompt_submit(rid, sid, session, text, display_kind="auto_continue")That path never goes through This is no longer hypothetical repository context. #94778 reports the exact opposite-side failure: two live backends share one Required fix: put the ownership admission at a turn-start chokepoint that every fresh turn source must cross, or explicitly acquire/fail before auto-continue can emit Blocker 2 — a capacity registry that fails open cannot become an advertised correctness authority without changing its error semantics
That behavior was defensible when this registry only enforced an optional resource cap. It is not compatible with this PR's new contract that per-session exclusivity is “always enforced” correctness, and it makes Required fix: ownership uncertainty needs a distinct fail-closed result (for example Interlocks / supersession map
Exact-head readinessAt review time After the two correctness gaps above are fixed: rebase/materialize onto current main, run the cross-process auto-continue + manual-submit cases, and obtain fresh exact-head CI. Until then I would not merge this carrier, because its advertised invariant is stronger than the paths it actually fences. |
|
Maintainer-side review (triage sweep). Verdict: right direction, not salvageable as-is — keeping open for revision rather than closing. Premise confirmed on current main: What blocks a straight salvage today (largely echoing @andrexibiza's review, which we verified):
If you rebase onto current main and address 1–2 (or narrow the claim: enforce at a shared chokepoint, fail closed on registry uncertainty), this looks salvageable — the core change and the test/probe approach are solid. Happy to re-review a revised head. |
… policy Cherry-picked from PR #94595 (author: Futahua) onto current main, with the maintainer-review revision points folded in during the rebase: - the lease engages UNCONDITIONALLY: try_acquire_active_session no longer returns a disabled no-op lease when max_concurrent_sessions is unset; the concurrency cap stays an orthogonal, optional policy checked second - ownership uncertainty fails CLOSED (SESSION_COORDINATION_UNAVAILABLE) instead of degrading to an untracked go-ahead: a corrupt/unreadable registry must not be collapsed into 'no owner exists' (review blocker 2) - the ownership admission sits at the _run_prompt_submit chokepoint that EVERY fresh turn source crosses, and crash auto-continue acquires (or bails) BEFORE emitting message.start — closing the #94778 bypass where backend B's auto-continue ran a duplicate turn while backend A was live (review blocker 1) - the TUI gateway claim helper fails closed on claim exceptions for every surface, not just desktop - CLI and messaging-gateway call sites pass live_session_id metadata so the (pid, live id) re-entrancy identity protects them from self-fencing on a leaked lease Co-authored-by: teknium1 <teknium1@users.noreply.github.com>
…urn source Follow-ups on top of the #94595 cherry-pick, implementing the maintainer review's two blockers: Blocker 1 (turn-admission chokepoint): _run_prompt_submit itself now runs the ownership admission, so synthesized turns that never pass through the prompt.submit RPC handler (crash auto-continue from cold session.resume, wake-ups) are fenced too. Auto-continue additionally checks ownership BEFORE emitting message.start and leaves the marker in place, closing the #94778 shape where backend B resumed a session backend A was actively running and auto-continued A's fresh interrupted-turn marker into a duplicate concurrent turn. Blocker 2 (fail-closed registry semantics): try_acquire_active_session no longer converts an unreadable/corrupt registry into an untracked go-ahead. Ownership uncertainty is a distinct typed refusal — SESSION_COORDINATION_UNAVAILABLE — because "could not prove ownership" must never be collapsed into "no owner exists". The TUI gateway claim helper fails closed on claim exceptions for every surface, not just desktop. Also: empty session ids short-circuit to a no-op lease (nothing to fence, and the strict registry schema rejects empty ids), and the existing fail-open tests were updated to assert the new fail-closed contract.
|
Salvaged via PR #99719. Both of your commits were cherry-picked onto current main with your authorship preserved in git log, and the two review blockers were implemented on top in a follow-up commit:
Your cross-process probe was re-run live on both sides: on main, B's submit was accepted (double-writer confirmed); on the salvage head, all 9 checks pass with B refused |
… policy Cherry-picked from PR #94595 (author: Futahua) onto current main, with the maintainer-review revision points folded in during the rebase: - the lease engages UNCONDITIONALLY: try_acquire_active_session no longer returns a disabled no-op lease when max_concurrent_sessions is unset; the concurrency cap stays an orthogonal, optional policy checked second - ownership uncertainty fails CLOSED (SESSION_COORDINATION_UNAVAILABLE) instead of degrading to an untracked go-ahead: a corrupt/unreadable registry must not be collapsed into 'no owner exists' (review blocker 2) - the ownership admission sits at the _run_prompt_submit chokepoint that EVERY fresh turn source crosses, and crash auto-continue acquires (or bails) BEFORE emitting message.start — closing the #94778 bypass where backend B's auto-continue ran a duplicate turn while backend A was live (review blocker 1) - the TUI gateway claim helper fails closed on claim exceptions for every surface, not just desktop - CLI and messaging-gateway call sites pass live_session_id metadata so the (pid, live id) re-entrancy identity protects them from self-fencing on a leaked lease Co-authored-by: teknium1 <teknium1@users.noreply.github.com>
…urn source Follow-ups on top of the #94595 cherry-pick, implementing the maintainer review's two blockers: Blocker 1 (turn-admission chokepoint): _run_prompt_submit itself now runs the ownership admission, so synthesized turns that never pass through the prompt.submit RPC handler (crash auto-continue from cold session.resume, wake-ups) are fenced too. Auto-continue additionally checks ownership BEFORE emitting message.start and leaves the marker in place, closing the #94778 shape where backend B resumed a session backend A was actively running and auto-continued A's fresh interrupted-turn marker into a duplicate concurrent turn. Blocker 2 (fail-closed registry semantics): try_acquire_active_session no longer converts an unreadable/corrupt registry into an untracked go-ahead. Ownership uncertainty is a distinct typed refusal — SESSION_COORDINATION_UNAVAILABLE — because "could not prove ownership" must never be collapsed into "no owner exists". The TUI gateway claim helper fails closed on claim exceptions for every surface, not just desktop. Also: empty session ids short-circuit to a no-op lease (nothing to fence, and the strict registry schema rejects empty ids), and the existing fail-open tests were updated to assert the new fail-closed contract.
… policy Cherry-picked from PR NousResearch#94595 (author: Futahua) onto current main, with the maintainer-review revision points folded in during the rebase: - the lease engages UNCONDITIONALLY: try_acquire_active_session no longer returns a disabled no-op lease when max_concurrent_sessions is unset; the concurrency cap stays an orthogonal, optional policy checked second - ownership uncertainty fails CLOSED (SESSION_COORDINATION_UNAVAILABLE) instead of degrading to an untracked go-ahead: a corrupt/unreadable registry must not be collapsed into 'no owner exists' (review blocker 2) - the ownership admission sits at the _run_prompt_submit chokepoint that EVERY fresh turn source crosses, and crash auto-continue acquires (or bails) BEFORE emitting message.start — closing the NousResearch#94778 bypass where backend B's auto-continue ran a duplicate turn while backend A was live (review blocker 1) - the TUI gateway claim helper fails closed on claim exceptions for every surface, not just desktop - CLI and messaging-gateway call sites pass live_session_id metadata so the (pid, live id) re-entrancy identity protects them from self-fencing on a leaked lease Co-authored-by: teknium1 <teknium1@users.noreply.github.com>
…urn source Follow-ups on top of the NousResearch#94595 cherry-pick, implementing the maintainer review's two blockers: Blocker 1 (turn-admission chokepoint): _run_prompt_submit itself now runs the ownership admission, so synthesized turns that never pass through the prompt.submit RPC handler (crash auto-continue from cold session.resume, wake-ups) are fenced too. Auto-continue additionally checks ownership BEFORE emitting message.start and leaves the marker in place, closing the NousResearch#94778 shape where backend B resumed a session backend A was actively running and auto-continued A's fresh interrupted-turn marker into a duplicate concurrent turn. Blocker 2 (fail-closed registry semantics): try_acquire_active_session no longer converts an unreadable/corrupt registry into an untracked go-ahead. Ownership uncertainty is a distinct typed refusal — SESSION_COORDINATION_UNAVAILABLE — because "could not prove ownership" must never be collapsed into "no owner exists". The TUI gateway claim helper fails closed on claim exceptions for every surface, not just desktop. Also: empty session ids short-circuit to a no-op lease (nothing to fence, and the strict registry schema rejects empty ids), and the existing fail-open tests were updated to assert the new fail-closed contract.
…, not merely until a bounded close Under dashboard.turn_isolation a turn is admitted ONCE in the serving process, which claims the one real active-session lease; the turn then runs in a shared compute-host CHILD that carries only a disabled sentinel. The child's write-exclusivity rests ENTIRELY on the serving process holding that real lease for the whole turn. On explicit close, _teardown_popped_session waited a bounded 5s for the child to settle and then _finalize_session released the lease UNCONDITIONALLY even when the child was still running -- dropping the registry entry out from under a live writer and reopening the per-session double-writer window that NousResearch#99719 (salvaged Futahua/NousResearch#94595) closed. Split "detach from the live map / stop the UI" from "release the real lease". When ownership is still owed to a live child (compute-host isolated AND running AND lease enabled), the still-enabled lease is DETACHED into a process-global _detached_leases table instead of released. The detach is one critical section under the existing _sessions_lock that inserts before removing from the session dict, so the idle reaper (which reads _sessions under the same lock) can never observe the lease in neither map. The reaper unions the detached lease_ids into its `live` set so it SKIPS detached leases. The deferred release fires on the per-turn done receipt (_on_compute_host_turn_done, including the crash path via _fail_pending_turns' on_complete) and on child death: host_supervisor releases detached leases on every observed child exit AHEAD of the _closing guard in _wait_for_exit and again from shutdown(), so a graceful supervisor close cannot strand the id. The UI/RPC close stays bounded; only OWNERSHIP becomes unbounded, bounded in turn by child lifetime and the child-death fallback. Tests: invert the cementing bounded-close test to assert the lease is HELD (a distinct-pid second acquirer is refused) until the simulated child exit; add real-registry lease-held-until-child-death, closing-shutdown release, reaper-does-not-drop, detach single-critical-section, and crash-path coverage.
… policy Cherry-picked from PR NousResearch#94595 (author: Futahua) onto current main, with the maintainer-review revision points folded in during the rebase: - the lease engages UNCONDITIONALLY: try_acquire_active_session no longer returns a disabled no-op lease when max_concurrent_sessions is unset; the concurrency cap stays an orthogonal, optional policy checked second - ownership uncertainty fails CLOSED (SESSION_COORDINATION_UNAVAILABLE) instead of degrading to an untracked go-ahead: a corrupt/unreadable registry must not be collapsed into 'no owner exists' (review blocker 2) - the ownership admission sits at the _run_prompt_submit chokepoint that EVERY fresh turn source crosses, and crash auto-continue acquires (or bails) BEFORE emitting message.start — closing the NousResearch#94778 bypass where backend B's auto-continue ran a duplicate turn while backend A was live (review blocker 1) - the TUI gateway claim helper fails closed on claim exceptions for every surface, not just desktop - CLI and messaging-gateway call sites pass live_session_id metadata so the (pid, live id) re-entrancy identity protects them from self-fencing on a leaked lease Co-authored-by: teknium1 <teknium1@users.noreply.github.com>
…urn source Follow-ups on top of the NousResearch#94595 cherry-pick, implementing the maintainer review's two blockers: Blocker 1 (turn-admission chokepoint): _run_prompt_submit itself now runs the ownership admission, so synthesized turns that never pass through the prompt.submit RPC handler (crash auto-continue from cold session.resume, wake-ups) are fenced too. Auto-continue additionally checks ownership BEFORE emitting message.start and leaves the marker in place, closing the NousResearch#94778 shape where backend B resumed a session backend A was actively running and auto-continued A's fresh interrupted-turn marker into a duplicate concurrent turn. Blocker 2 (fail-closed registry semantics): try_acquire_active_session no longer converts an unreadable/corrupt registry into an untracked go-ahead. Ownership uncertainty is a distinct typed refusal — SESSION_COORDINATION_UNAVAILABLE — because "could not prove ownership" must never be collapsed into "no owner exists". The TUI gateway claim helper fails closed on claim exceptions for every surface, not just desktop. Also: empty session ids short-circuit to a no-op lease (nothing to fence, and the strict registry schema rejects empty ids), and the existing fail-open tests were updated to assert the new fail-closed contract.
`hermes status` gated its whole active-session readout on `max_concurrent_sessions` being set. That made sense when the registry was only written under a cap, but NousResearch#94595 made per-session exclusivity unconditional: `try_acquire_active_session` now records an entry for any session with a stored id, capped or not. So for an operator who never set a cap the entries exist and `hermes status` still prints nothing -- the data is there and only this readout was still asking about the cap. With a cap set, the output is unchanged (`Slots: n/max in use`); without one there are no slots to report, so it prints `Live: n session(s)` and the same per-session lines. That is the concurrent-session awareness signal NousResearch#46303 asks for: "is another session live right now?" answerable without reading `runtime/active_sessions.json` by hand. The listing also needs to say *where* each session is working -- NousResearch#46303 is about two sessions colliding in one git worktree, and surface plus session id do not carry that. `_lease_entry` now records the enclosing checkout as `metadata["repo_root"]` (walking up for `.git`, which is a file inside linked worktrees, so both worktrees of one repo attribute correctly), and the status lines mark entries as `this repo` or name the other checkout. It is advisory: a session started outside any checkout records no field at all, and a session-id transfer carries the existing attribution forward rather than dropping it when the caller rewrites identity metadata.
…, not merely until a bounded close Under dashboard.turn_isolation a turn is admitted ONCE in the serving process, which claims the one real active-session lease; the turn then runs in a shared compute-host CHILD that carries only a disabled sentinel. The child's write-exclusivity rests ENTIRELY on the serving process holding that real lease for the whole turn. On explicit close, _teardown_popped_session waited a bounded 5s for the child to settle and then _finalize_session released the lease UNCONDITIONALLY even when the child was still running -- dropping the registry entry out from under a live writer and reopening the per-session double-writer window that NousResearch#99719 (salvaged Futahua/NousResearch#94595) closed. Split "detach from the live map / stop the UI" from "release the real lease". When ownership is still owed to a live child (compute-host isolated AND running AND lease enabled), the still-enabled lease is DETACHED into a process-global _detached_leases table instead of released. The detach is one critical section under the existing _sessions_lock that inserts before removing from the session dict, so the idle reaper (which reads _sessions under the same lock) can never observe the lease in neither map. The reaper unions the detached lease_ids into its `live` set so it SKIPS detached leases. The deferred release fires on the per-turn done receipt (_on_compute_host_turn_done, including the crash path via _fail_pending_turns' on_complete) and on child death: host_supervisor releases detached leases on every observed child exit AHEAD of the _closing guard in _wait_for_exit and again from shutdown(), so a graceful supervisor close cannot strand the id. The UI/RPC close stays bounded; only OWNERSHIP becomes unbounded, bounded in turn by child lifetime and the child-death fallback. Tests: invert the cementing bounded-close test to assert the lease is HELD (a distinct-pid second acquirer is refused) until the simulated child exit; add real-registry lease-held-until-child-death, closing-shutdown release, reaper-does-not-drop, detach single-critical-section, and crash-path coverage.
…, not merely until a bounded close Under dashboard.turn_isolation a turn is admitted ONCE in the serving process, which claims the one real active-session lease; the turn then runs in a shared compute-host CHILD that carries only a disabled sentinel. The child's write-exclusivity rests ENTIRELY on the serving process holding that real lease for the whole turn. On explicit close, _teardown_popped_session waited a bounded 5s for the child to settle and then _finalize_session released the lease UNCONDITIONALLY even when the child was still running -- dropping the registry entry out from under a live writer and reopening the per-session double-writer window that NousResearch#99719 (salvaged Futahua/NousResearch#94595) closed. Split "detach from the live map / stop the UI" from "release the real lease". When ownership is still owed to a live child (compute-host isolated AND running AND lease enabled), the still-enabled lease is DETACHED into a process-global _detached_leases table instead of released. The detach is one critical section under the existing _sessions_lock that inserts before removing from the session dict, so the idle reaper (which reads _sessions under the same lock) can never observe the lease in neither map. The reaper unions the detached lease_ids into its `live` set so it SKIPS detached leases. The deferred release fires on the per-turn done receipt (_on_compute_host_turn_done, including the crash path via _fail_pending_turns' on_complete) and on child death: host_supervisor releases detached leases on every observed child exit AHEAD of the _closing guard in _wait_for_exit and again from shutdown(), so a graceful supervisor close cannot strand the id. The UI/RPC close stays bounded; only OWNERSHIP becomes unbounded, bounded in turn by child lifetime and the child-death fallback. Tests: invert the cementing bounded-close test to assert the lease is HELD (a distinct-pid second acquirer is refused) until the simulated child exit; add real-registry lease-held-until-child-death, closing-shutdown release, reaper-does-not-drop, detach single-critical-section, and crash-path coverage.
…, not merely until a bounded close Under dashboard.turn_isolation a turn is admitted ONCE in the serving process, which claims the one real active-session lease; the turn then runs in a shared compute-host CHILD that carries only a disabled sentinel. The child's write-exclusivity rests ENTIRELY on the serving process holding that real lease for the whole turn. On explicit close, _teardown_popped_session waited a bounded 5s for the child to settle and then _finalize_session released the lease UNCONDITIONALLY even when the child was still running -- dropping the registry entry out from under a live writer and reopening the per-session double-writer window that NousResearch#99719 (salvaged Futahua/NousResearch#94595) closed. Split "detach from the live map / stop the UI" from "release the real lease". When ownership is still owed to a live child (compute-host isolated AND running AND lease enabled), the still-enabled lease is DETACHED into a process-global _detached_leases table instead of released. The detach is one critical section under the existing _sessions_lock that inserts before removing from the session dict, so the idle reaper (which reads _sessions under the same lock) can never observe the lease in neither map. The reaper unions the detached lease_ids into its `live` set so it SKIPS detached leases. The deferred release fires on the per-turn done receipt (_on_compute_host_turn_done, including the crash path via _fail_pending_turns' on_complete) and on child death: host_supervisor releases detached leases on every observed child exit AHEAD of the _closing guard in _wait_for_exit and again from shutdown(), so a graceful supervisor close cannot strand the id. The UI/RPC close stays bounded; only OWNERSHIP becomes unbounded, bounded in turn by child lifetime and the child-death fallback. Tests: invert the cementing bounded-close test to assert the lease is HELD (a distinct-pid second acquirer is refused) until the simulated child exit; add real-registry lease-held-until-child-death, closing-shutdown release, reaper-does-not-drop, detach single-critical-section, and crash-path coverage.
…, not merely until a bounded close Under dashboard.turn_isolation a turn is admitted ONCE in the serving process, which claims the one real active-session lease; the turn then runs in a shared compute-host CHILD that carries only a disabled sentinel. The child's write-exclusivity rests ENTIRELY on the serving process holding that real lease for the whole turn. On explicit close, _teardown_popped_session waited a bounded 5s for the child to settle and then _finalize_session released the lease UNCONDITIONALLY even when the child was still running -- dropping the registry entry out from under a live writer and reopening the per-session double-writer window that NousResearch#99719 (salvaged Futahua/NousResearch#94595) closed. Split "detach from the live map / stop the UI" from "release the real lease". When ownership is still owed to a live child (compute-host isolated AND running AND lease enabled), the still-enabled lease is DETACHED into a process-global _detached_leases table instead of released. The detach is one critical section under the existing _sessions_lock that inserts before removing from the session dict, so the idle reaper (which reads _sessions under the same lock) can never observe the lease in neither map. The reaper unions the detached lease_ids into its `live` set so it SKIPS detached leases. The deferred release fires on the per-turn done receipt (_on_compute_host_turn_done, including the crash path via _fail_pending_turns' on_complete) and on child death: host_supervisor releases detached leases on every observed child exit AHEAD of the _closing guard in _wait_for_exit and again from shutdown(), so a graceful supervisor close cannot strand the id. The UI/RPC close stays bounded; only OWNERSHIP becomes unbounded, bounded in turn by child lifetime and the child-death fallback. Tests: invert the cementing bounded-close test to assert the lease is HELD (a distinct-pid second acquirer is refused) until the simulated child exit; add real-registry lease-held-until-child-death, closing-shutdown release, reaper-does-not-drop, detach single-critical-section, and crash-path coverage.
`hermes status` gated its whole active-session readout on `max_concurrent_sessions` being set. That made sense when the registry was only written under a cap, but NousResearch#94595 made per-session exclusivity unconditional: `try_acquire_active_session` now records an entry for any session with a stored id, capped or not. So for an operator who never set a cap the entries exist and `hermes status` still prints nothing -- the data is there and only this readout was still asking about the cap. With a cap set, the output is unchanged (`Slots: n/max in use`); without one there are no slots to report, so it prints `Live: n session(s)` and the same per-session lines. That is the concurrent-session awareness signal NousResearch#46303 asks for: "is another session live right now?" answerable without reading `runtime/active_sessions.json` by hand. The listing also needs to say *where* each session is working -- NousResearch#46303 is about two sessions colliding in one git worktree, and surface plus session id do not carry that. `_lease_entry` now records the enclosing checkout as `metadata["repo_root"]` (walking up for `.git`, which is a file inside linked worktrees, so both worktrees of one repo attribute correctly), and the status lines mark entries as `this repo` or name the other checkout. It is advisory: a session started outside any checkout records no field at all, and a session-id transfer carries the existing attribution forward rather than dropping it when the caller rewrites identity metadata.
…, not merely until a bounded close Under dashboard.turn_isolation a turn is admitted ONCE in the serving process, which claims the one real active-session lease; the turn then runs in a shared compute-host CHILD that carries only a disabled sentinel. The child's write-exclusivity rests ENTIRELY on the serving process holding that real lease for the whole turn. On explicit close, _teardown_popped_session waited a bounded 5s for the child to settle and then _finalize_session released the lease UNCONDITIONALLY even when the child was still running -- dropping the registry entry out from under a live writer and reopening the per-session double-writer window that NousResearch#99719 (salvaged Futahua/NousResearch#94595) closed. Split "detach from the live map / stop the UI" from "release the real lease". When ownership is still owed to a live child (compute-host isolated AND running AND lease enabled), the still-enabled lease is DETACHED into a process-global _detached_leases table instead of released. The detach is one critical section under the existing _sessions_lock that inserts before removing from the session dict, so the idle reaper (which reads _sessions under the same lock) can never observe the lease in neither map. The reaper unions the detached lease_ids into its `live` set so it SKIPS detached leases. The deferred release fires on the per-turn done receipt (_on_compute_host_turn_done, including the crash path via _fail_pending_turns' on_complete) and on child death: host_supervisor releases detached leases on every observed child exit AHEAD of the _closing guard in _wait_for_exit and again from shutdown(), so a graceful supervisor close cannot strand the id. The UI/RPC close stays bounded; only OWNERSHIP becomes unbounded, bounded in turn by child lifetime and the child-death fallback. Tests: invert the cementing bounded-close test to assert the lease is HELD (a distinct-pid second acquirer is refused) until the simulated child exit; add real-registry lease-held-until-child-death, closing-shutdown release, reaper-does-not-drop, detach single-critical-section, and crash-path coverage.
…, not merely until a bounded close Under dashboard.turn_isolation a turn is admitted ONCE in the serving process, which claims the one real active-session lease; the turn then runs in a shared compute-host CHILD that carries only a disabled sentinel. The child's write-exclusivity rests ENTIRELY on the serving process holding that real lease for the whole turn. On explicit close, _teardown_popped_session waited a bounded 5s for the child to settle and then _finalize_session released the lease UNCONDITIONALLY even when the child was still running -- dropping the registry entry out from under a live writer and reopening the per-session double-writer window that NousResearch#99719 (salvaged Futahua/NousResearch#94595) closed. Split "detach from the live map / stop the UI" from "release the real lease". When ownership is still owed to a live child (compute-host isolated AND running AND lease enabled), the still-enabled lease is DETACHED into a process-global _detached_leases table instead of released. The detach is one critical section under the existing _sessions_lock that inserts before removing from the session dict, so the idle reaper (which reads _sessions under the same lock) can never observe the lease in neither map. The reaper unions the detached lease_ids into its `live` set so it SKIPS detached leases. The deferred release fires on the per-turn done receipt (_on_compute_host_turn_done, including the crash path via _fail_pending_turns' on_complete) and on child death: host_supervisor releases detached leases on every observed child exit AHEAD of the _closing guard in _wait_for_exit and again from shutdown(), so a graceful supervisor close cannot strand the id. The UI/RPC close stays bounded; only OWNERSHIP becomes unbounded, bounded in turn by child lifetime and the child-death fallback. Tests: invert the cementing bounded-close test to assert the lease is HELD (a distinct-pid second acquirer is refused) until the simulated child exit; add real-registry lease-held-until-child-death, closing-shutdown release, reaper-does-not-drop, detach single-critical-section, and crash-path coverage.
…, not merely until a bounded close Under dashboard.turn_isolation a turn is admitted ONCE in the serving process, which claims the one real active-session lease; the turn then runs in a shared compute-host CHILD that carries only a disabled sentinel. The child's write-exclusivity rests ENTIRELY on the serving process holding that real lease for the whole turn. On explicit close, _teardown_popped_session waited a bounded 5s for the child to settle and then _finalize_session released the lease UNCONDITIONALLY even when the child was still running -- dropping the registry entry out from under a live writer and reopening the per-session double-writer window that NousResearch#99719 (salvaged Futahua/NousResearch#94595) closed. Split "detach from the live map / stop the UI" from "release the real lease". When ownership is still owed to a live child (compute-host isolated AND running AND lease enabled), the still-enabled lease is DETACHED into a process-global _detached_leases table instead of released. The detach is one critical section under the existing _sessions_lock that inserts before removing from the session dict, so the idle reaper (which reads _sessions under the same lock) can never observe the lease in neither map. The reaper unions the detached lease_ids into its `live` set so it SKIPS detached leases. The deferred release fires on the per-turn done receipt (_on_compute_host_turn_done, including the crash path via _fail_pending_turns' on_complete) and on child death: host_supervisor releases detached leases on every observed child exit AHEAD of the _closing guard in _wait_for_exit and again from shutdown(), so a graceful supervisor close cannot strand the id. The UI/RPC close stays bounded; only OWNERSHIP becomes unbounded, bounded in turn by child lifetime and the child-death fallback. Tests: invert the cementing bounded-close test to assert the lease is HELD (a distinct-pid second acquirer is refused) until the simulated child exit; add real-registry lease-held-until-child-death, closing-shutdown release, reaper-does-not-drop, detach single-critical-section, and crash-path coverage.
… policy Cherry-picked from PR NousResearch#94595 (author: Futahua) onto current main, with the maintainer-review revision points folded in during the rebase: - the lease engages UNCONDITIONALLY: try_acquire_active_session no longer returns a disabled no-op lease when max_concurrent_sessions is unset; the concurrency cap stays an orthogonal, optional policy checked second - ownership uncertainty fails CLOSED (SESSION_COORDINATION_UNAVAILABLE) instead of degrading to an untracked go-ahead: a corrupt/unreadable registry must not be collapsed into 'no owner exists' (review blocker 2) - the ownership admission sits at the _run_prompt_submit chokepoint that EVERY fresh turn source crosses, and crash auto-continue acquires (or bails) BEFORE emitting message.start — closing the NousResearch#94778 bypass where backend B's auto-continue ran a duplicate turn while backend A was live (review blocker 1) - the TUI gateway claim helper fails closed on claim exceptions for every surface, not just desktop - CLI and messaging-gateway call sites pass live_session_id metadata so the (pid, live id) re-entrancy identity protects them from self-fencing on a leaked lease Co-authored-by: teknium1 <teknium1@users.noreply.github.com>
…ferent session's claim `_prune_dead(strict=True)` raised "active session owner liveness is unknown" for ANY registry entry whose owner pid exists but whose start time cannot be read (LXC /proc after `hermes update` restarts the backend), so a Desktop claim for a brand-new session id — and the release/transfer of the caller's own lease — failed with "Hermes could not safely reserve this session. Try again." `_prune_dead` / `_read_live_entries` now take the caller's `target_session_id`: an unrelated sibling of unknown liveness stays in the live set (it still fences its own session and still counts toward `max_concurrent_sessions`, so #94595's fail-closed guarantee is intact) while only the target session's owner has to be provable. Callers without a target (snapshots, the orphan sweep) keep the strict behaviour. Ported from #107031 by @KoNit-K, narrowed to the prune seam and widened to release/transfer of the caller's own lease. Fixes #113683
The defect
Two processes can resume the same stored session and both run turns in it. Neither is refused.
The second process loads its own snapshot of the transcript, reasons from a history that does not contain the first one's in-flight turn, and appends anyway — so the stored conversation ends up holding two replies that never saw each other. Reproduced with two
python -m tui_gateway.entryprocesses.The registry that would prevent this already exists, with a file lock, pid + process-start identity, dead-owner pruning, lease ids and release. It simply was not consulted unless an operator had configured a capacity cap:
So "no cap configured" silently meant "concurrent writers to one session are fine". With the cap unset — the default — nothing is recorded, so nothing can be refused.
The change
The two concerns are uncoupled:
max_concurrent_sessionsBoth are decided inside the existing lock, immediately after the existing prune, so a dead owner is never mistaken for a live one and a live one is never overlooked.
prompt.submitis unchanged as the enforcement point: the check already happens there, before the busy-queue check, before_ensure_session_db_rowand before_start_agent_build, so a refusal persists no user row and starts no model turn.Re-entrancy, not a hole. A live session whose record is rebuilt in place loses its reference to the lease it already holds, and would then be fenced out of its own session by its own leak — permanently, since pruning only removes entries whose process is dead and that one is alive. Identity is therefore
(pid, live_session_id): another process differs by pid, another tab in this process differs by live id, and only the same writer re-acquiring its own session matches.A session with no stored id yet is exempt; treating
""as an identity would make the first unsaved composer refuse every other one.The refusal is machine-readable. An automated client has to tell "the machine is at capacity, retry later" from "this session has a live owner and your write would interleave with theirs". Those call for different behaviour, and a client forced to match prose changes behaviour silently whenever the wording improves.
prompt.submitnow returnsdata.reasonofSESSION_NOT_OWNEDorMAX_CONCURRENT_SESSIONS. The refusal is carried by astrsubclass, so all existing call sites — which format it, hand it back as a JSON-RPC message, or test it forNone— keep working untouched.gateway.capabilitiesadvertisesper_session_exclusive_submitso a client can distinguish a build that enforces this from one that does not, sourced from the module that performs the check rather than from config: a capability an operator can switch on without the mechanism is worse than none, because it is believed.Tests
tests/test_active_session_exclusivity.py— 11 cases: cap unset still fences one session; different sessions still run concurrently; capacity still applies independently and refuses for its own reason; dead owner pruned and successor acquires; recycled pid does not keep a lease alive; release lets the next owner in; release is idempotent; empty id exempt; same live session may re-acquire.scripts/probe_active_session_exclusivity.py— the unit tests share one interpreter and so cannot exercise the actual failure. This drives two real gateway processes over stdio and checks the whole sequence, including the parts easy to get wrong:Against the parent commit the probe stops at the second check with an empty registry — the defect stated exactly. No provider or credentials are needed: the fence is checked before the agent is built, so a submit that later fails for want of a model still proves who owns the session.
Compatibility
The registry is now written whenever a turn claims a session, including when no cap is configured; previously it was only written under a configured cap. Leases are correspondingly always releasable rather than sometimes being disabled no-ops.
One existing test in
tests/test_tui_gateway_server.pyreleased its session by popping it from_sessionswithout finalizing, leaking the lease its first turn claimed and then fencing itself out of the samesession_key. It now releases the slot the way_finalize_sessiondoes.Verified no regressions against this branch's merge base on the same machine: identical failure sets before and after across
tests/test_tui_gateway_server.py,tests/gateway/test_max_concurrent_sessions.py,tests/gateway/test_clarify_active_session_bypass.pyandtests/gateway/test_command_bypass_active_session.py.