# Turn continuity — detached turns + multi-client live view > Status: DESIGN (locked) → backend implementation in progress. FE work couriered > to `../dispatch-web`. See ORCHESTRATOR §7 (cross-repo). ## Problem (confirmed by code trace) A chat turn's lifetime is bound to the **WebSocket connection**. `transport-ws` creates one `AbortController` per connection (`extension.ts:142`), passes its signal into the turn (`extension.ts:121` → `orchestrator.handleMessage` → `runTurn`), and **aborts it on socket close** (`extension.ts:279`). When a mobile browser backgrounds / reloads, the socket closes → the signal fires → `runTurn` breaks at the next step/stream boundary (`kernel/run-turn.ts:490,529`) with `finishReason:"aborted"`, returns the partial result, and the orchestrator persists that partial turn (`orchestrator.ts:198`). Symptom: refresh shows "a bit more" (the persisted partial), generation is stopped, user must send "continue". This violates the product principle: **the frontend is only a control interface; the AI must keep running independent of it.** ## Principle / requirements 1. A turn, once started, **runs to completion regardless of any connection** — including zero connected clients. 2. **Multiple clients may view the same conversation simultaneously** (multi-device handoff). All subscribers receive the same live event stream. 3. A client that connects/reloads **mid-turn** can attach and see the in-flight turn (late-join), then watch it finish live. 4. **Concurrent SENDS are out of scope** (user will not send from two devices at once) — no send arbitration / locking beyond the existing single-flight guard. ## Decisions (locked) - **D1 — The turn-broadcast hub lives in the CORE (`session-orchestrator`), not in a transport.** "Independent of the interface" means turn ownership must not sit in any transport. Transports become thin subscribers. (Rejected: hub in transport-ws — would re-couple turns to the WS layer and exclude other transports.) - **D2 — Additive handle (small blast radius).** Keep `handleMessage` (HTTP/CLI one shot) working; ADD a detached broadcast API to the same `SessionOrchestrator` interface. `handleMessage` becomes a thin convenience over it. - **D3 — Persist at turn-seal (unchanged); incremental per-step persistence (R1, `restructure-plan.md:712`) is DEFERRED.** Late-join is served by an in-memory **in-flight buffer**, not by partial DB reads. The in-flight turn lives entirely in the buffer until seal; sealed turns live entirely in history → a clean disjoint boundary, no seq-overlap, no double-apply. (Cost: a backend *crash* mid-turn still loses the in-flight turn — the pre-existing R1 gap, separately deferred.) - **D4 — Drop caller-driven turn cancellation.** The per-connection AbortController no longer touches turns. There is no caller `signal` on the turn path. A future explicit "stop generating" is a deliberate `chat.stop` op, not a disconnect — out of scope now. - **D5 — Subscription is decoupled from sending.** A client can watch a conversation it did not send to, via a new `chat.subscribe` WS op. ## Target contract — `SessionOrchestrator` (owned by session-orchestrator) ```ts interface StartTurnInput { conversationId: string; text: string; modelName?: string; cwd?: string; } type StartTurnResult = { started: true; turnId: string } | { started: false; reason: "already-active" }; type TurnEventListener = (event: AgentEvent) => void; interface SessionOrchestrator { /** Start a turn DETACHED from any caller/connection. Runs to completion regardless * of subscribers (incl. zero). Broadcasts every AgentEvent to all current + future * subscribers (buffered for late-join). Rejected ("already-active") if a turn is * already in-flight for the conversation (single-flight; no send arbitration). */ startTurn(input: StartTurnInput): StartTurnResult; /** Subscribe to a conversation's turn events. On subscribe, the current in-flight * turn's events SO FAR are replayed to `listener` synchronously (late-joiner sees * the whole running turn), then live events follow. Returns unsubscribe. Does NOT * start or affect a turn. Replay-then-attach must be atomic (no gap/dup): snapshot * buffer → deliver → add listener, all synchronously (safe in single-threaded JS; * emits only occur at turn await-points). */ subscribe(conversationId: string, listener: TurnEventListener): () => void; /** Whether a turn is currently in-flight for the conversation. */ isActive(conversationId: string): boolean; /** Convenience one-shot (HTTP/CLI): subscribe + startTurn + await terminal, via * onEvent. Same observable behavior as before for a single caller. NO `signal`. */ handleMessage(input: { conversationId: string; text: string; onEvent: (event: AgentEvent) => void; modelName?: string; cwd?: string; }): Promise; } ``` **Hub internals (session-orchestrator) — subscribers OUTLIVE turns.** This is the load-bearing invariant: a client subscribes to a CONVERSATION (and watches every turn on it), NOT to a single turn. So the subscriber set must be **persistent and independent of any turn's lifecycle** — the normal flow is `subscribe` (no turn yet) → `startTurn`, and the subscriber MUST receive that turn's events. Keep TWO separate maps: - `subscribers: Map>` — persistent. `subscribe` adds to it (creating the set if absent) and returns an unsubscribe that removes from it. NEVER cleared by turn start/seal. A conversation may have subscribers with no active turn (idle, waiting) — that's normal. - `activeTurns: Map` — per in-flight turn only. Created by `startTurn`, deleted on seal. The buffer is ONLY for late-join replay. `isActive` = this map has the conversation. `startTurn` runs the existing pipeline detached (async IIFE); each emitted event is appended to the active turn's `buffer` AND broadcast to **`subscribers.get(cid)`** (the persistent set — do NOT reset/replace it). On terminal (`turn-sealed`, or error) persist as today, then in a `finally` delete the `activeTurns` entry + the `activeConversations` entry — but LEAVE `subscribers` intact. `subscribe(cid, listener)`: add `listener` to `subscribers.get(cid)` (create set if needed); THEN, if a turn is active, synchronously replay its `buffer` to `listener` (snapshot → deliver → it is already in the live set, so no further attach needed; take care not to double-deliver — add to the set first, then replay the buffer snapshot taken at that instant, OR replay then add, but pick one ordering and prove no gap/dup). If no turn is active, just retain the listener for the next turn. Keep `activeConversations` (warm service depends on it) = the set with a live `activeTurns` entry. `handleMessage` rejection (already-active) must emit an error event to its own `onEvent` and resolve (never await another turn). > **Wave-1 bug fixed in revision:** the first implementation stored listeners INSIDE > the per-turn hub and had `startTurn` create a fresh empty-listener hub, so a listener > that subscribed before the turn (the normal path) was discarded — live multi-client > test received zero deltas though the turn ran + persisted. The two-map model above is > the fix. ## WS protocol additions (`@dispatch/transport-contract`, orchestrator-authored) Additive to `WsClientMessage`: - `ChatSubscribeMessage { type: "chat.subscribe"; conversationId: string }` - `ChatUnsubscribeMessage { type: "chat.unsubscribe"; conversationId: string }` No new server message: replayed + live events both arrive as the existing `chat.delta { event: AgentEvent }`. A client infers "running" from a replayed `turn-start` with no matching `done`/`turn-sealed` yet. `chat.send` continues to start a turn; the sending socket is auto-subscribed by transport-ws. ## Units & waves - **Contracts (orchestrator):** transport-contract WS ops + version bump. - **Wave 1 — `session-orchestrator`:** the hub + new interface methods + buffer; refactor `handleMessage` to the convenience wrapper; keep persist-at-seal, metrics, lifecycle hooks (`turnStarted`/`turnSettled`/`warmCompleted`), `activeConversations`. - **Wave 2 (parallel, disjoint pkgs) — depends on Wave 1's handle:** - `transport-ws`: per-connection set of subscribed conversations (store each `unsubscribe` fn); `chat.send` → auto-subscribe sender + `startTurn`; `chat.subscribe`/`chat.unsubscribe` → orchestrator.subscribe/unsubscribe; `close` → call all stored unsubscribes, **do NOT abort any turn** (remove the turn AbortController); route the new ops in pure `router.ts`. - `transport-http`: runtime UNCHANGED (still uses `handleMessage`); only update its test fakes to implement the 3 new `SessionOrchestrator` methods. - **host-bin:** expected UNCHANGED (orchestrator factory + service wiring unchanged); verify post-wave. ## Out of scope (explicit) - Per-step incremental persistence (R1) / crash-resume mid-generation. - Concurrent-send arbitration / multi-writer locking. - Explicit "stop generating" op. - Frontend changes (couriered): on (re)connect, `chat.subscribe` each open conversation + re-sync history; render a real "running" state; recover a missed `turn-sealed`.