Skip to content

Commit e8cbb30

Browse files
committed
perf(control): finish streaming and virtualization overhaul
1 parent 61ad98d commit e8cbb30

16 files changed

Lines changed: 5573 additions & 2814 deletions

File tree

backend/PERF_OVERHAUL.md

Lines changed: 79 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -19,20 +19,22 @@ verity fixture missions.
1919
| P1 | #8 tolerant continuation | ✅ shipped #438 |
2020
| P1 | #9 navigation leak | ✅ shipped #438 |
2121
| P1 | #10 markdown size cap | ✅ shipped #438 |
22-
| P2 | #11 virtualize chat | ⏸ deferred — see below |
23-
| P2 | #12 virtualize thoughts sheet | ⏸ deferred — see below |
22+
| P2 | #11 virtualize chat | ✅ implemented in this branch |
23+
| P2 | #12 virtualize thoughts sheet | ✅ implemented in this branch |
2424
| P2 | #13 lazy markdown | ✅ shipped #443 |
2525
| P2 | #14 memoize derived slices | ✅ shipped #442 |
26-
| P2 | #15 split ControlView | ⏸ deferred — see below |
27-
| P2 | #16 worker reducer | ⏸ deferred — see below |
28-
| P3 | #17 delta summarization | ⏸ deferred — backend, large |
26+
| P2 | #15 split ControlView | ✅ implemented in this branch (slice stores) |
27+
| P2 | #16 worker reducer | ✅ implemented in this branch |
28+
| P3 | #17 delta summarization | ✅ implemented in this branch |
2929
| P3 | #18 since_seq cursors | ⏸ deferred — backend, large |
30-
| P3 | #19 WS migration | ⏸ deferred — backend, large |
30+
| P3 | #19 WS migration | ✅ implemented in this branch |
3131
| P3 | #20 per-mission channels | ⏸ deferred — backend, medium |
3232
| P3 | #21 backend text_delta backpressure | ⏸ deferred — backend, medium |
33-
| P4 | #22-24 content model | ⏸ deferred — cross-stack |
33+
| P4 | #22 negotiated `text_op` protocol | ✅ implemented in this branch |
34+
| P4 | #23 canonical assistant rows | ✅ implemented in this branch |
35+
| P4 | #24 tool-output truncation | ⏸ deferred — backend, medium |
3436
| P5 | #25 health budget telemetry | ⏸ deferred — needs ingestion |
35-
| P5 | #26 Playwright perf CI | ⏸ deferred — flaky-risk in CI |
37+
| P5 | #26 Playwright perf CI | ✅ implemented in this branch |
3638
| P5 | #27 STREAMING.md | ✅ shipped (this file's sibling) |
3739

3840
## Before / after (verity mission `3a902278`, 1882 events)
@@ -52,66 +54,90 @@ The original symptom (74-second freezes on opening verity #1884)
5254
disappeared after P1-#4..#10 alone. Subsequent items are
5355
optimisations, not bug fixes.
5456

57+
## New measurements from this branch
58+
59+
| Item | Measurement |
60+
| --- | --- |
61+
| P2-#11/#12 virtualization | `dashboard/tests/control-perf.spec.ts` fixture mission with 500 messages passes DOM `<5k`; local Chromium run completed in 34.0s. |
62+
| P3-#17 summarization | `inactive_stream_summary_reduces_large_payload_by_ten_x` covers the read-side collapse and asserts the synthetic payload reduction is at least 10x. |
63+
| P4-#22 negotiated deltas | `text_op_stream_transform_converts_cumulative_delta_to_insert_then_replace` and `text_op_stream_transform_finalizes_before_assistant_message` cover the `cap=text_op` transport conversion path. |
64+
| P4-#23 canonical rows | `finalized_text_ops_collapse_to_canonical_assistant_row` proves a finalized `text_op` log is replaced by one `assistant_message_canonical` row. |
65+
| P5-#26 perf CI | Playwright `control @perf keeps large mission within browser budgets` asserts heap `<300MB`, max longtask `<500ms`, and DOM `<5k`. |
66+
67+
Validation commands run locally:
68+
69+
```bash
70+
cargo fmt --all --check
71+
cargo check -q
72+
cargo test -q inactive_stream_summary --lib
73+
cargo test -q text_op_stream_transform --lib
74+
cargo test -q finalized_text_ops_collapse_to_canonical_assistant_row --lib
75+
cd dashboard && npx tsc --noEmit
76+
cd dashboard && bun run build
77+
cd dashboard && PLAYWRIGHT_PORT=3001 PLAYWRIGHT_BASE_URL=http://localhost:3001 bunx playwright test tests/control-perf.spec.ts --project=chromium
78+
cd ios_dashboard && xcodebuild -project SandboxedDashboard.xcodeproj -scheme SandboxedDashboard -destination 'platform=iOS Simulator,name=iPhone 17 Pro,OS=26.4' build
79+
```
80+
81+
iOS simulator smoke evidence:
82+
83+
- `ios-control-direct-after-sequential.png` shows historical replay of the
84+
goal fixture mission against the dev backend.
85+
- The first dev-backend run surfaced a Swift concurrency abort in
86+
`loadMission`; the saved-mission metadata/transcript fetch was made
87+
sequential and the fixture rendered after rebuild/reinstall.
88+
5589
## Deferred items, with reasoning
5690

5791
These are intentional decisions to stop work, not abandoned TODOs.
5892

5993
### P2-#11/12: virtualize chat list + thoughts sheet
6094

61-
The chat list already uses CSS `content-visibility: auto` +
62-
`contain-intrinsic-size: auto 140px` on every row, which gives the
63-
browser permission to skip layout and paint for off-screen rows
64-
without any JS-level virtualizer. Combined with P2-#13 (lazy
65-
markdown) and the P2-#14 memoization, the perf overlay no longer
66-
shows DOM-traversal cost in the longtask profile on a 1.8k-event
67-
mission. A `@tanstack/react-virtual` integration would add 30 KB to
68-
the bundle and a non-trivial scroll-anchor refactor; cost > benefit
69-
at current data sizes. Revisit if a mission with >5k visible items
70-
becomes routine.
95+
The main transcript and thoughts sheet now use `@tanstack/react-virtual`
96+
with estimated row heights, mount-time measurement, bottom anchoring,
97+
and a scroll-to-bottom pill when the user is away from the bottom. The
98+
Playwright perf fixture holds the 500-message mission under the DOM
99+
budget.
71100

72101
### P2-#15: split ControlView into subscribers
73102

74-
The win here is preventing the entire 9k-line component from
75-
re-rendering on every state tick. Half the win has already been
76-
captured: `ChatItemRow` is `memo()`-wrapped, the derived views go
77-
through `useMemo`+`useDeferredValue`, the 1Hz timer is shared
78-
(P1-#7), and SSE bursts coalesce into one commit per frame (P1-#6).
79-
The remaining win comes from migrating to Zustand-or-similar so
80-
unrelated state slices stop triggering global re-renders. That's a
81-
multi-day refactor with high regression risk. Track separately;
82-
don't bundle it into the perf overhaul.
103+
The dashboard now mirrors the iOS-style split with explicit stores for
104+
items, queue, thinking, streaming diagnostics, and the viewing mission.
105+
The layout component owns layout state while panels subscribe to the
106+
slices they render.
83107

84108
### P2-#16: Web Worker for `eventsToItems`
85109

86-
After P0+P1 landed the `replay:apply` reducer runs at most
87-
**65 ms** for a 5000-event replay on the verity fixture (measured
88-
via the `replay:apply` console.time + the metrics overlay's
89-
"Reducers (cum)" panel). Moving it to a worker requires extracting
90-
~250 lines of helpers, building a worker bundle in Next.js, and
91-
paying ~100ms of structured-clone cost on every call to ship a 5k
92-
ChatItem[] back across the boundary. Net: probably break-even at
93-
current sizes, regression on small-list call sites that fire
94-
hundreds of times per session. Deferred until a mission size emerges
95-
where the reducer alone exceeds 200ms.
110+
`eventsToItemsImpl` and its parsing/continuation helpers live in
111+
`events-reducer.ts`; `events-worker.ts` exposes a Promise RPC worker
112+
using Next's `new Worker(new URL(..., import.meta.url))` bundling path.
113+
Existing synchronous `eventsToItems()` call sites remain available, while
114+
initial transcript and `since_seq=0` replays route through the worker
115+
with a sync fallback if worker startup fails.
96116

97117
### P3-#17..#21: backend streaming changes
98118

99-
All five require coordinated dashboard + iOS + backend changes and
100-
substantial test coverage. The current SSE + `/events` shape is
101-
stable across three clients; changing it has high blast radius. The
102-
per-mission broadcast channels (P3-#20) and the text_delta
103-
backpressure (P3-#21) are the cheapest wins remaining; track them as
104-
follow-up issues.
119+
P3-#17 adds a pure read-side summarization pass for inactive missions:
120+
`thinking` and `text_delta` runs are collapsed for `/events`, `/trace`,
121+
and transcript wrappers only when `updated_at` is older than five
122+
minutes. Persisted rows are unchanged, and active missions keep the
123+
incremental path.
124+
125+
P3-#19 adds `/api/control/ws` with 15s heartbeats, client resume, and
126+
dashboard WS-first/SSE-fallback behavior. P3-#18, #20, and #21 remain
127+
deferred follow-ups.
105128

106129
### P4-#22..#24: content model changes
107130

108-
CRDT-style deltas (P4-#22), canonical-bubble persistence (P4-#23),
109-
and tool-output truncation (P4-#24) all imply data-model migrations.
110-
The P1-#8 tolerant continuation heuristic absorbs most of the
111-
duplicate-token symptom that motivated #22. #23 needs a clear
112-
data-loss story before it's worth the risk. #24 should be done but
113-
also needs backend cooperation (the streaming download endpoint
114-
doesn't exist yet).
131+
P4-#22 is implemented as a negotiated transport capability:
132+
dashboard and iOS append `cap=text_op`, the backend converts cumulative
133+
`text_delta` buffers into `TextOp::Insert`/`Replace` events for that
134+
connection, and older clients continue receiving cumulative
135+
`text_delta`.
136+
137+
P4-#23 persists in-flight `text_op` rows and collapses a finalized
138+
`bubble_id` into one `assistant_message_canonical` row. Historical
139+
fetches return that canonical row instead of the delta log. Existing
140+
missions are unchanged. P4-#24 remains a deferred follow-up.
115141

116142
### P5-#25: health budget telemetry
117143

@@ -123,11 +149,9 @@ on where the telemetry should land.
123149

124150
### P5-#26: Playwright perf CI
125151

126-
`@playwright/test --grep perf` runs that load a fixture mission and
127-
assert heap/longtask/DOM budgets are flaky-prone — the Vercel
128-
preview deploy lifecycle alone introduces 30s+ of variance. Better
129-
done as a manual regression script that the perf overlay's
130-
`?debug=perf` already supports.
152+
`dashboard/tests/control-perf.spec.ts` is marked `@perf`, loads the
153+
fixture with `?debug=perf`, waits 30s, and asserts heap, longtask, and
154+
DOM budgets.
131155

132156
## Operational
133157

backend/STREAMING.md

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,21 @@
11
# Streaming contract
22

3-
The control plane emits agent activity to dashboard and iOS clients via a
4-
single SSE stream (`GET /api/control/stream`) plus a database-backed
5-
event log (`GET /api/control/missions/:id/events`, `…/trace`,
6-
`…/transcript`). This document is the canonical contract for what each
3+
The control plane emits agent activity to dashboard and iOS clients via
4+
SSE (`GET /api/control/stream`), WebSocket (`GET /api/control/ws`), and
5+
a database-backed event log (`GET /api/control/missions/:id/events`,
6+
`…/trace`, `…/transcript`). This document is the canonical contract for what each
77
backend emits and what each client expects. It exists because two
88
incidents (the iOS replay-text-delta bug, the verity duplicated-thoughts
99
freeze) traced back to drift between these three implementations.
1010

1111
## SSE channel
1212

13-
`GET /api/control/stream?mission=<uuid>`
13+
`GET /api/control/stream?mission=<uuid>&cap=text_op`
1414

1515
| Param | Meaning |
1616
| --- | --- |
1717
| `mission` | When present, the server only emits events whose `mission_id` matches. `status` and `stream_lagged` (connection-scoped) always pass. Omit the param to receive every event the authenticated user can see (used by the mission list and the `?debug=perf` overlay). |
18+
| `cap` | Optional comma-separated client capabilities. `text_op` asks the transport to convert cumulative `text_delta` events into negotiated CRDT-style `text_op` operations for this connection. Omit it for the cumulative compatibility path. |
1819

1920
Each line is one of:
2021

@@ -26,6 +27,27 @@ Each line is one of:
2627
- `event: <type>\ndata: <json>\n\n` — a single `AgentEvent`. See the
2728
type list below.
2829

30+
## WebSocket channel
31+
32+
`GET /api/control/ws?mission=<uuid>&cap=text_op`
33+
34+
The WebSocket stream carries the same JSON `AgentEvent` payloads as SSE,
35+
including the `type` discriminator. The dashboard attempts WebSocket first
36+
and falls back to SSE if the upgrade fails.
37+
38+
Additional WebSocket-only messages:
39+
40+
- Server heartbeat every 15s: `{"seq": N}` where `N` is the latest stored
41+
event sequence for the filtered mission, or `0` without a mission filter.
42+
- Client resume request: `{"type":"resume","since_seq": N}`. When the
43+
socket has a `mission=<uuid>` filter, the server fetches stored events
44+
with `sequence > N`, converts known rows back into `AgentEvent` shape,
45+
and sends them before continuing live broadcast delivery.
46+
47+
When `mission=<uuid>` is present, WebSocket uses the same per-mission
48+
broadcast channel as SSE. Connection-scoped `status` and FIDO events still
49+
come from the global channel.
50+
2951
### Event types
3052

3153
All events carry `mission_id` (optional for connection-scoped events).
@@ -37,6 +59,7 @@ Listed with the backends that emit them.
3759
| `user_message` | server | `{id, mission_id, content, queued}` | Echoes the user message back after persisting. |
3860
| `assistant_message` | server | `{id, mission_id, content, success, cost_cents?, cost_source?, model?, shared_files?}` | One per completed agent turn. **Cumulative content** — the message is the final consolidated text. |
3961
| `text_delta` | grok, codex | `{mission_id, content, event_id?}` | **Cumulative buffer** — the `content` field contains the *entire* text so far, not the new tokens. Clients must consolidate by replacing, not appending. See "Continuation rule". |
62+
| `text_op` | negotiated streaming backends | `{mission_id, bubble_id, ops}` | CRDT-style delta stream. `ops` entries are `insert`, `replace`, or `finalize`; clients apply them to a local buffer keyed by `bubble_id`. Backends only emit this when the client advertises support; `text_delta` remains the compatibility path. |
4063
| `thinking` | grok, codex | `{mission_id, content, done, goal_role?, event_id?}` | Cumulative buffer. `done: true` finalises the current thought; subsequent non-prefix payloads start a new thought. |
4164
| `tool_call` | all | `{mission_id, tool_call_id, name, args}` | One per tool invocation. |
4265
| `tool_result` | all | `{mission_id, tool_call_id, name, result}` | Pairs with `tool_call` via `tool_call_id`. |
@@ -127,11 +150,20 @@ Events NOT persisted:
127150
- `status`, `stream_lagged`, `fido_sign_request` — connection-scoped.
128151
- `mission_activity` — diagnostic only, intentionally not stored.
129152

153+
For negotiated `text_op` streams, in-flight ops persist as `text_op` rows.
154+
When a `finalize` op arrives, the mission store applies the full op log for
155+
that `bubble_id`, deletes those delta rows, and writes one
156+
`assistant_message_canonical` row. Future `/events` fetches return the
157+
canonical row rather than the op log. Existing missions and cumulative
158+
`text_delta` rows are unchanged.
159+
130160
## Client expectations
131161

132162
### Dashboard (`dashboard/src/app/control/control-client.tsx`)
133163

134-
- Connects with `?mission=<id>` when viewing a specific mission.
164+
- Connects with `?mission=<id>` when viewing a specific mission. The
165+
transport prefers `/api/control/ws` and falls back to `/api/control/stream`
166+
on WebSocket connection error.
135167
- Reconnects whenever the viewing mission changes.
136168
- Coalesces `text_delta` and `thinking` re-renders via
137169
`requestAnimationFrame` — at most one React commit per frame.

dashboard/bun.lock

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dashboard/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"@codemirror/theme-one-dark": "^6.1.3",
2323
"@codemirror/view": "^6.39.12",
2424
"@radix-ui/react-slot": "^1.2.4",
25+
"@tanstack/react-virtual": "^3.13.24",
2526
"@types/prismjs": "^1.26.5",
2627
"@types/react-syntax-highlighter": "^15.5.13",
2728
"@uiw/react-codemirror": "^4.25.4",

dashboard/playwright.config.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { defineConfig, devices } from '@playwright/test';
22

3+
const port = process.env.PLAYWRIGHT_PORT || '3099';
4+
const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://localhost:${port}`;
5+
36
export default defineConfig({
47
testDir: './tests',
58
fullyParallel: true,
@@ -9,7 +12,7 @@ export default defineConfig({
912
reporter: 'html',
1013
timeout: 30000, // 30 seconds per test
1114
use: {
12-
baseURL: 'http://localhost:3099',
15+
baseURL,
1316
trace: 'on-first-retry',
1417
},
1518

@@ -24,8 +27,8 @@ export default defineConfig({
2427
],
2528

2629
webServer: {
27-
command: 'bun dev --port 3099',
28-
url: 'http://localhost:3099',
30+
command: `bunx next dev --port ${port}`,
31+
url: baseURL,
2932
reuseExistingServer: !process.env.CI,
3033
timeout: 120000, // 2 minutes for server to start
3134
env: {

0 commit comments

Comments
 (0)