feat(service,host): a running agent's transcript is held open on Neon Realtime rather than polled

Merged
CH
5d ago
+2125-86

Stacked on #3232 (luke/neon-realtime-scope-inbox). This is the first PR of the Neon Realtime transcript work: it removes the service's 500 ms poll for a shown running agent's transcript. The renderer doesn't change.

What it does

The window still sends codingAgents.messages with its cursor. The host now answers it the way scope-inbox-follow.ts answers an inbox:

  1. On the first read, the host seals the transcript's live statements (GET /api/live/agent-transcript?id=[&call=]) and subscribes over Realtime. Subscriptions are kept in an RcMap keyed by agent and call, and let go 30 s after the last read.
  2. It reads the page through the existing route with wait=none. That returns at once with holds, which says whether today's held read would have kept waiting on that page.
  3. If holds is false, the host answers the window. If it's true, the host reads again only when the statements move, up to the same 20 s hold. At the end of the hold it reads one last time, as the service does today.

The page itself, including the usage priced from the AI Gateway catalog, is still assembled by the service.

Live statements (coder-host/transcript-live.ts) are deterministic, aliased with liveSelection, and read only the owner's rows:

  • messages: count and max seq.
  • turns: grouped by status, with count, stopped count, and when each last changed.
  • conversation: journal revision and title.
  • agent: the agent's title.
  • workers: each child conversation with its title and delegation call.

Together these move whenever the held read would wake: a message added or amended, a status change (including a line awaiting its turn), a title landing, or a delegation being recorded.

Realtime alone, with no held-read fallback:

  • If sealing doesn't answer, the host asks again every second until it's sealed or not found; a read waiting meanwhile is answered with the page as it stands when its hold ends.
  • If a subscription stops, it's marked broken, which has the waiting read read at once, then sealed and opened again after a second.
  • If the account doesn't reach the agent, the answer is not found.
  • Sealed statements stay in the host.

Behaviour changes worth knowing

  • Two status changes come from the clock alone, with no row changing: an agent passing STARTING_GRACE (5 min), and the title window (TITLE_TIMEOUT) closing. Today the service sees these within 500 ms. Here they're seen at the end of the hold, up to 20 s later. This only affects the last empty page, never a message.
  • An ended agent's tab also opens a short-lived subscription, because the host has to seal before it knows the service takes wait=none.

Tests

  • coder-host.test.ts (PGlite): a real coding turn is relayed event by event. After every event where the page past the cursor gains messages or changes status, the live rows have moved. holds matches the turn's state. Titles and delegations move them too, for the agent's own transcript and a worker's. Another account's statements read nothing.
  • live-app.test.ts: the transcript is sealed for the owner, a worker's under the child conversation its call opens. A stranger gets not found. With no Realtime, the endpoint answers unavailable.
  • coding-agents-app.test.ts: wait=none answers at once with holds, for the agent and for a worker whose delegation isn't recorded yet.
  • compose-coding-agents.test.ts (scripted Realtime): covers the at-once answer, a read held until the statements move, the empty page at the end of the hold, a stopped subscription read at once and resealed, not found, an unsealed transcript sealed again then read live, and sign-out.
  • Wire goldens for the three new live-wire schemas were recorded under LUKE_UPDATE_FIXTURES=1, and rewrites were regenerated.

pnpm check:affected passes apart from apps/desktop/src/main/app-menu.test.ts, which needs an Electron binary the sandbox doesn't have.

Next, if it stays clean

Assemble pages on the desktop from live rows, as pure functions in packages/features, and keep usage fetched.

Made with Luke


Summary by cubic

The host now answers a window's read of a running agent's transcript through Neon Realtime subscriptions instead of the service polling every 500 ms, while the window's read and the assembled page stay unchanged.

Behavior changes

  • Two status changes come from the clock alone (the 5-minute start grace, and the title-timeout window closing) and are now seen up to 20 s later at the end of the hold instead of within 500 ms; this only affects the last empty page, never a message.
  • An ended agent's tab still opens a short-lived subscription, because the host must seal before it knows the service takes wait=none.

Fallbacks

  • If sealing is unavailable or comes from an older service, the old held read answers as before; a subscription stopping partway through a read hands that read to the held read.
  • An account that doesn't reach the agent answers not found; sealed statements stay on the host.