refactor(x): one delivery path for turn events — chat, channels, deltas

Completes the turn-spine unification. Chat's SessionChatStore now
consumes turns:events like every other surface, joining live turns by
file offset (drop covered, append contiguous, refetch on gap) instead
of blind-appending session-bus events. Text/reasoning deltas cross IPC
only for turns a window subscribed to (turns:subscribe/unsubscribe, a
per-webContents registry that also survives window teardown), so
headless pipeline chatter never reaches windows that aren't watching.
The channels bridge settles turns off the turn event bus instead of
filtering the session broadcast. With no turn-event consumers left,
SessionsImpl stops forwarding entirely (it drains its execution streams
and keeps outcome/index handling) and sessions:events shrinks to
index-changed entries — one channel for turn events, one for session
metadata.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ramnique Singh 2026-07-09 15:47:21 +05:30
parent f916cb1d70
commit 876bc35e9e
15 changed files with 284 additions and 115 deletions

View file

@ -461,7 +461,7 @@ const ipcSchemas = {
},
// ── New runtime: sessions + turns (session-design.md) ────────────────────
// Turn-mutating calls return quickly; the renderer follows progress through
// the sessions:events feed and the shared reduceTurn reducer.
// the turns:events feed and the shared reduceTurn reducer.
'sessions:create': {
req: z.object({ title: z.string().optional() }),
res: z.object({ sessionId: z.string() }),
@ -545,12 +545,24 @@ const ipcSchemas = {
},
// Process-wide turn event spine: every turn's durable events (with file
// offsets), regardless of who started the turn — session chat, headless
// background/knowledge runners, spawned sub-agents. Deltas are not
// broadcast here in v1; session chat streams them via sessions:events.
// background/knowledge runners, spawned sub-agents. Text/reasoning deltas
// ride the same channel but only reach windows that subscribed to that
// turn via turns:subscribe.
'turns:events': {
req: z.custom<TurnBusEvent>(),
res: z.null(),
},
// Per-window delta subscription: deltas are high-volume and ephemeral, so
// they cross IPC only for turns this window declared it is watching.
// Durable events are always broadcast regardless.
'turns:subscribe': {
req: z.object({ turnId: z.string() }),
res: z.object({ success: z.literal(true) }),
},
'turns:unsubscribe': {
req: z.object({ turnId: z.string() }),
res: z.object({ success: z.literal(true) }),
},
'services:events': {
req: ServiceEvent,
res: z.null(),

View file

@ -1,9 +1,5 @@
import { z } from "zod";
import {
ModelDescriptor,
type TurnStatus,
type TurnStreamEvent,
} from "./turns.js";
import { ModelDescriptor, type TurnStatus } from "./turns.js";
// Durable session contract for the session layer (see
// packages/core/docs/session-design.md). A session is an append-only chain
@ -171,21 +167,14 @@ export interface SessionIndexEntry {
error?: string;
}
// What the renderer's single feed consumer receives over IPC: live turn
// stream events (durable events + ephemeral deltas) and index updates.
// entry: null signals deletion.
export type SessionBusEvent =
| {
kind: "turn-event";
sessionId: string;
turnId: string;
event: TurnStreamEvent;
}
| {
kind: "index-changed";
sessionId: string;
entry: SessionIndexEntry | null;
};
// What the renderer's session-feed consumer receives over IPC: session index
// updates only. Turn events (durable + deltas) travel on the turns:events
// spine (TurnBusEvent in turns.ts). entry: null signals deletion.
export type SessionBusEvent = {
kind: "index-changed";
sessionId: string;
entry: SessionIndexEntry | null;
};
export function sessionIndexEntry(
state: SessionState,