diff options
| author | Adam Malczewski <[email protected]> | 2026-06-12 02:25:57 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-12 02:25:57 +0900 |
| commit | 86b5137c4f7f2bcc08f0580f1edaa05d14015e63 (patch) | |
| tree | 20215a5dccf1b76cf9cf95fceec1eca450aa8559 /notes | |
| parent | 839f5c02676a0b7def27ace7125fbec3aa08bda5 (diff) | |
| download | dispatch-86b5137c4f7f2bcc08f0580f1edaa05d14015e63.tar.gz dispatch-86b5137c4f7f2bcc08f0580f1edaa05d14015e63.zip | |
feat(turns): detached turns + multi-client live view
A turn no longer dies when its WebSocket connection closes. The turn-broadcast
hub moves into the core (session-orchestrator): turns run detached, persist at
seal regardless of clients, and fan out AgentEvents to N subscribers per
conversation with in-flight buffer replay for late-joiners. transport-ws stops
aborting turns on socket close and gains chat.subscribe/chat.unsubscribe so a
second device (or a reloaded browser) can watch a running turn.
- @dispatch/transport-contract 0.6.0->0.7.0: chat.subscribe/chat.unsubscribe WS ops
- session-orchestrator: startTurn/subscribe/isActive; persistent subscribers +
per-turn buffer (two-map model); handleMessage = convenience wrapper (no signal)
- transport-ws: per-connection chat-subscription fan-out; no turn-abort-on-close
- transport-http: test fakes updated for the widened interface (runtime unchanged)
- design notes/turn-continuity-design.md; FE courier frontend-turn-continuity-handoff.md
Live-verified vs flash (2-client WS): sender disconnect mid-turn -> other client
streams to done + turn persists; late-join replays turn from turn-start. 891 vitest
+ transport bun green; tsc -b EXIT 0; biome clean.
Diffstat (limited to 'notes')
| -rw-r--r-- | notes/turn-continuity-design.md | 161 |
1 files changed, 161 insertions, 0 deletions
diff --git a/notes/turn-continuity-design.md b/notes/turn-continuity-design.md new file mode 100644 index 0000000..29ec10d --- /dev/null +++ b/notes/turn-continuity-design.md @@ -0,0 +1,161 @@ +# 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<void>; +} +``` + +**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<conversationId, Set<TurnEventListener>>` — 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<conversationId, { buffer: AgentEvent[]; turnId }>` — 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`. |
