summaryrefslogtreecommitdiffhomepage
path: root/notes
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-12 02:25:57 +0900
committerAdam Malczewski <[email protected]>2026-06-12 02:25:57 +0900
commit86b5137c4f7f2bcc08f0580f1edaa05d14015e63 (patch)
tree20215a5dccf1b76cf9cf95fceec1eca450aa8559 /notes
parent839f5c02676a0b7def27ace7125fbec3aa08bda5 (diff)
downloaddispatch-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.md161
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`.