fix(cli): let /session detach from a running Turn instead of trapping the client - #3498
Conversation
|
Working on this PR — happy to iterate on review feedback. Local verification: CLI suite 360/360, biome + typecheck clean. |
… the client A second TUI that switched onto a Session with an in-flight Turn was trapped: every exit path was destructive. /session was intercepted with 'Cannot run /session while a turn is running', and following the hint (Esc/Ctrl+C) or quitting aborted the shared Turn via driver.stop() — killing work another client was watching (apache#3380). Multi-client attach is a designed Runtime Host capability: the Turn is Host-owned and the TUI is only its viewport, so switching Sessions mid-turn is view navigation, not a session mutation. - new 'switch' mid-turn slash disposition alongside 'local': routed through like 'local', but its handler must use the busy-aware goToSession/openSessionPicker wrappers (runControl's serial lock is held by the running Turn mid-turn) - switchAwayMidTurn adopts the next Session without ever calling driver.stop(); a turnEpoch fence orphans the in-flight drain after the switch is confirmed so late events, synthesized stream failures, and old-session queue flushes can never reach the adopted transcript; the orphaned runAgentTurn tail releases busy/activity and hands the freshly attached Turn its start exactly once - requestTurnInterrupt is swallowed while a detach handoff is in flight: the driver already points at the next Session, so a stop there would abort whatever that Session has attached - Escape closes the mid-turn session picker instead of arming the double-Escape interrupt for the Turn being left running - foreign-session import rows are hidden from the picker mid-turn (the import flow starts a new Session; it cannot detach) Generated-by: Maka
…switch recovery - '/session <id>' mid-turn: switches without driver.stop(), replaces the transcript with the adopted Session's history, fences late events from the abandoned drain (no content leak, no synthesized 'ended without a completion event' failure), starts the freshly attached Turn only after the orphaned drain unwinds, and lands follow-up prompts on the adopted Session - '/session' mid-turn opens the picker; Escape closes it and must not arm the double-Escape interrupt (stopCalls stays 0) - a rejected switch leaves the running Turn fully live: error notice, no stop, subsequent events still render into the same transcript Generated-by: Maka
21e2611 to
24a3a67
Compare
Astro-Han
left a comment
There was a problem hiding this comment.
Thanks — this is a well-argued fix and the framing is right: in Runtime Host mode the Turn belongs to the Host and this TUI is only a viewport, so refusing /session mid-turn was trapping the user in a view rather than protecting anything. The monotonic turnEpoch fence is the correct shape for it — one counter makes every callback of the abandoned drain a no-op, instead of trying to unsubscribe them individually. Fencing only after switchSession resolves, so a failed switch leaves the drain fully live, is a detail that is easy to get backwards and you got it right.
Reviewed at exact head 24a3a6711870a427bd82f2b8f03b86b4f52b37c4 against base 8b60ddffa89682c03238ddce6595f531eb2b6f29. One P2 inline, plus two P3 verification gaps below. No checks have run on this head yet.
P3 — switching to the session you are already on. goToSession has no same-session short-circuit, so /session <current-id> mid-turn bumps the epoch, orphans the live drain, and prints Detached from the running Turn while the user is still on the same session. Whether this is harmless depends on what driver.switchSession does with its own session id, which we could not settle from the client side. A test pinning the intended behaviour would close it either way.
P3 — re-attaching to the same still-running Turn. The new tests cover attaching to a different session's live turn. Detaching and immediately re-attaching to the same one is the case where a double consumer would show up if the orphan tail and the new attach ever overlapped; the startPendingAttachedTurn no-op guards look like they prevent it, but nothing pins it.
Neither P3 blocks. The P2 does, and it is small to fix.
This review was AI-assisted. Findings were verified against the exact head listed above; any mistakes are ours to correct — please push back where we got it wrong.
| // tail unwinds through the superseded branch and releases busy/activity, | ||
| // then either that tail or the startPendingAttachedTurn below starts the | ||
| // freshly attached Turn, whichever observes an idle runner first. | ||
| const switchAwayMidTurn = async (sessionId: string) => { |
There was a problem hiding this comment.
[P2] switchAwayMidTurn is re-entrant, and a second detach clears detaching while the first is still in flight.
Mid-turn, both /session entry points deliberately skip runControl's serial lock — that is the point of the change, since the lock is held by the running Turn. Both are also invoked fire-and-forget: void goToSession(sessionId) and void openSessionPicker(). So nothing serializes switchAwayMidTurn against itself, and it has an await boundary in the middle of it (await input.driver.switchSession(sessionId)).
Sequence: the user types /session s2. While that request is in flight they type /session s3 — normal impatience, and the window is as long as the round trip. Now two switchAwayMidTurn calls are live.
The consequence we care about is the finally block. detaching is a boolean, so when the first call settles it runs detaching = false while the second is still awaiting its switch. That reopens requestTurnInterrupt, whose new guard exists precisely for this window — your own comment there says "a stop here would abort whatever that Session has attached." A Ctrl+C landing in that reopened window calls driver.stop() against the freshly adopted session, which is the exact outcome this PR promises cannot happen. The invariant is stated correctly in the code and then broken by re-entry.
Two smaller effects ride along: applySwitchResult runs twice in whatever order the responses return, so the user can end up on s2 after asking for s3; and two Detached from the running Turn notices land in the adopted transcript.
The mechanism to fix it already exists — detaching is shaped like a re-entrancy flag, it just is not read at the entry points. Rejecting the second detach is enough:
const goToSession = async (sessionId: string): Promise<void> => {
if (!turnRunning) { await runControl(() => switchSession(sessionId)); return; }
if (detaching) return; // a detach is already handing this view over
await switchAwayMidTurn(sessionId).catch(reportError);
};with the same guard on openSessionPicker. Turning detaching into a counter would also work but is more machinery than the situation needs — a second detach during a handoff has no meaningful semantics, so dropping it is the honest behaviour.
A regression test would issue a second /session before the first switchSession promise resolves and assert that driver.stop() is still never called and that exactly one Detached notice appears. The existing failNextSwitch test already shows the harness can control that promise's timing.
There was a problem hiding this comment.
Confirmed both mechanisms locally before fixing (reproduced the double switchSession and the early detaching clear with a parked first switch), fixed at 332404759:
goToSessionnow rejects whiledetachingis held. The picker path needed no separate guard — its selection routes throughgoToSession(:2179), so one guard covers both entry points.- Pinned by a test: second mid-turn
/sessionduring a parked switch yields exactly one detach notice, no extrastop(), single adoption.
One nuance worth flagging: after the blocked re-entry, the queued command text can replay through the idle path once the adopted turn settles, producing a benign second switchSession to the same id — that is pre-existing queue behavior outside this window, not a reopen of it.
| // A mid-turn /session switch-away (#3380) bumps turnEpoch and orphans this | ||
| // drain: from that point every callback below must stop touching shared | ||
| // runner state — the adopted Session owns it now. | ||
| const superseded = () => epoch !== turnEpoch; |
There was a problem hiding this comment.
[P2] This comment says every callback below stops touching shared runner state once the epoch is bumped, but two of them do not check.
superseded() is consulted at :1226 (onEvent), :1263 and :1278. It is not consulted in:
onPrepared(:1197-1210) — the non-attached branch ends inif (turn.summary) adoptSessionMetadata(turn.summary), which writes title/cwd/model on the runneronSkillInvocation(:1211-1222) — splicesstate.entriesand callsshowSkillInvocation
Both are reachable after a switch-away, because turnRunning is set at the top of runAgentTurn while pi-tui-turn.ts:87-88 only invokes these two after await preparePrompt resolves. So the whole preparePrompt window is a period where /session can already enter switchAwayMidTurn, and neither callback has returned yet.
Concrete sequence:
- a turn stalls in
preparePrompt(slow or hung) /session <other>—switchSessionsucceeds,turnEpochbumps,applySwitchResultreplaces the transcript with the adopted sessionpreparePromptfinally resolvesonPreparedruns with the abandoned turn's summary and callsadoptSessionMetadataon it
The screen now describes the old session while the driver is on the new one, and the next prompt goes to the new one. The skill callback has the same shape: an overlay belonging to the abandoned session appearing over the adopted viewport.
shouldAbort doesn't help — it only reads closed, not the epoch.
Fix looks like the same one-liner already used in the three fenced callbacks: if (superseded()) return; at the top of both, including the onSkillInvocation on the SkillInvocationBlockedError path. A test that switches while preparePrompt is still unresolved, then asserts the metadata still belongs to the adopted session, would pin it.
This is separate from the re-entrant detaching P2 already reported inline on this head — that one is about entering the path twice, this one is about callbacks that were already in flight when it was entered once.
There was a problem hiding this comment.
Confirmed before fixing: reproduced an abandoned Turn adopting its summary over the adopted session when preparePrompt resolved after a completed switch-away. Fixed at 332404759 — onPrepared and onSkillInvocation now open with the same superseded() fence as the other callbacks, which also covers the SkillInvocationBlockedError invocation since both call sites share the callback. New test parks preparePrompt, detaches mid-park, then resolves: the abandoned title/cwd never reach the runner metadata and the skill card cannot land on the adopted viewport. CLI suite 362/362, biome clean.
|
Second review pass on The mechanism itself holds up, and we checked the thing that usually goes wrong with an escape path — what happens to the thing you escaped from:
On structure: one epoch counter, not a second authority over Turn lifetime. That's the right shape for this. Two things noted without grading, since neither is this PR's job to fix:
The re-entrant On CI: the run on this head was sitting at |
Two review findings on the apache#3380 detach path: - switchAwayMidTurn was re-entrant: a second mid-turn /session while the first was still handing the view over cleared the detaching flag early, reopening the interrupt window and double-applying adoption. The entry now rejects while a detach is in flight (the picker selection routes through the same guard). - onPrepared and onSkillInvocation ran without the superseded() fence the other callbacks use. Both are reachable after a switch-away because they fire only after preparePrompt resolves, so an abandoned Turn could still adopt its metadata onto the adopted Session's view and surface its skill card over the adopted viewport. Both fixes are pinned by tests: a parked second /session yields exactly one detach notice, and a Turn prepared across a detach can no longer overwrite the adopted session's title/cwd. Generated-by: maka
Astro-Han
left a comment
There was a problem hiding this comment.
Approving 3324047598651d6b725bdb579cb66c3b0eeb99cc. Required test is completed / success bound to that exact SHA. No P0–P2.
Re-review at the current head. Both earlier findings were re-derived from the code rather than taken as fixed.
Both [P2]s are closed.
The re-entrant detach is guarded at the entry point, and — importantly — the guard is not cleared early: if (detaching) return; at pi-tui-runner.ts:1625 sits behind a comment explaining that a second detach arriving while the first is still handing the view over would otherwise reset the flag and reopen the path. That is the actual failure mode, closed at the right place.
The epoch fence is now complete. superseded() is consulted at five call sites (:1201, :1219, :1234, :1271, :1286) rather than three, so the two callbacks that previously kept touching shared runner state after the epoch bump no longer do. The comment claiming every callback below the fence checks it is now true — before, the comment described an intent the code did not implement, which is the more dangerous of the two states.
The production increment is +12 lines. It fixes exactly these two holes and introduces nothing else.
Residual, non-blocking: the same-session detach [P3] stands; detaching and immediately reattaching to the same running Turn produces a two-consumer situation that no test currently pins; and the /transcript dead-module follow-up is unchanged.
Stated rather than implied: the local CLI suite and a real multi-client Host were not exercised here — the evidence is the hosted test on this exact head plus code-level verification of both fixes.
Disclosure, because it changes what this approval is worth: this is an AI review. Under CONTRIBUTING.md §Review it does not count as the required independent human review — merge still needs a committer other than the author to give LGTM and to decide.
Closes #3380
Problem
A second TUI that switched onto a Session with an in-flight Turn (via
/sessionor the picker) was trapped:/sessionwas intercepted with "Cannot run /session while a turn is running", and every apparent way out was destructive — following the hint (Esc/Ctrl+C) or quitting calleddriver.stop(), aborting the shared Turn another client was watching. The only clean escape waskill -9.Multi-client attach is a designed Runtime Host capability: the Turn is Host-owned and each TUI is only its viewport. Desktop already treats session switching as pure view navigation; this fix brings the CLI to the same model.
Fix: let
/sessiondetach from a running Turn instead of touching it'switch'mid-turn slash disposition (alongside'local'from refactor(cli): move the mid-turn slash disposition onto the command spec #3379). Routed through like'local', but its handler must use busy-aware wrappers: idle it runs underrunControl's serial lock as before; mid-turn the switch goes through the new detach path instead of silently no-oping onrunControl's busy gate.switchAwayMidTurnnever callsdriver.stop(). Afterdriver.switchSessionconfirms, a monotonicturnEpochfence orphans the in-flight drain:runAgentTurntail skips all old-session continuations (queue flushes would steer the NEW Session), releasesbusy/activity, and starts the freshly attached Turn exactly once — whichever of the detach path or the orphan tail observes an idle runner first.requestTurnInterruptis swallowed while a detach is in flight — the driver already points at the next Session, so a stop there would abort whatever that Session has attached.Scope notes
Testing
pi-tui-runner.test.tswith a parking-turn driver:/session <id>: switches with zerostop()calls, replaces the transcript, fences leaked events, starts the attached Turn after the orphan unwinds, lands follow-ups on the adopted Session;/sessionopens the picker mid-turn and Escape closes it without arming an interrupt;AI use
Tool(s) and scope: Maka (AI coding agent) authored the implementation and tests; the diff was human-reviewed before push.
Generated-by: Makatrailers are present on the branch commits.