feat(host-service): nudge clients with chat:sessions-changed on /events

Merged
HA
1mo ago
+252-16

Summary

The host-service /events bus now sends chat:sessions-changed whenever a workspace's chat-v3 session listing changes. Remote clients such as Roster can refetch listSessions for that workspace when it arrives, instead of polling each host.

Message

{ type: "chat:sessions-changed"; workspaceId: string; occurredAt: number }

This is a nudge, like agent:bindings-changed and page-watch:changed. It carries no session state, so it cannot disagree with listSessions. The client refetches listSessions({ workspaceId }) for the current state.

When it fires

  • The journal writes a change to a session row.
    • A session is created, or re-opened with a new epoch.
    • Its status, title or queued count changes.
    • It is discarded (harness start failed).
  • closeSession stops a live agent. That changes liveness without a journal write, so closeSession reports it after disposal, even if disposal throws.

It does not fire on items, deltas, or session events that leave the listing unchanged.

A host restart sends nothing, because no client is connected while the host is down. A client refetches when its /events socket reconnects.

occurredAt on appends is the journal's own ts, so it matches the row's updatedAt.

Wiring

  • ChatJournal takes onSessionChanged. createChatRuntime and createChatV3Mount pass it through, and app.ts maps it to EventBus.broadcastChatSessionsChanged.
  • A listener that throws, or returns a promise that rejects, is caught and logged. A durable write never fails because of an observer.
  • workspace-client routes the message by workspaceId and exports ChatSessionsChangedPayload ({ occurredAt }).
  • Stored session status is now typed SessionStatus all the way through. On the column this is $type only, so there is no migration.

Compatibility

The change is additive. No tRPC procedure, input or output changed. Released clients drop message types they have no listener for. Hosts without this change never send the message, so clients should keep a listSessions fallback.

Test plan

  • Live, against the real chat-v3 routes and EventBus with a real claude-acp agent. The run used an in-process host-service wired like app.ts, under Electron's Node, with chat-v3 called over HTTP:
    • createSession → nudge
    • starting → idle → nudge; listSessions returned idle
    • prompt → nudge on running, then nudge on idle; the agent replied OK
    • closeSession → one nudge; getSession.live became false
    • An earlier run hit a harness start failure. The status went to dead and that also sent a nudge.
  • packages/chat-runtime: bun test --isolate: 193 pass. New tests cover listing-only changes, a throwing listener, and closeSession reporting once.
  • packages/host-service: bun test --isolate src/events/event-bus.test.ts src/chat-v3: 26 pass.
  • packages/workspace-client: bun test src/lib/eventBus.test.ts: 12 pass.
  • Typecheck: chat-runtime and chat are clean. host-service and workspace-client show only the existing 22 auth/trpc errors.
  • bunx biome check: clean.

Review follow-ups

  • SDK removed from this PR, as asked. Roster calls the HTTP endpoints directly. Host chat calls through the relay work since #8159.
  • open() uses openEpoch's minted instead of reading the row three times.
  • occurredAt comes from the journal's ts.

Summary by cubic

Emits chat:sessions-changed on the host-service /events bus so remote clients like Roster stop polling listSessions per host. The SDK changes are not part of this PR.

The event is a workspace-wide nudge carrying only { workspaceId, occurredAt }; clients refetch listSessions for that workspace, so the event can never disagree with the listing.

  • ChatJournal fires on session creation, re-open with a changed status, status/title/queued-count changes, and discard.
  • closeSession announces once a live agent stops, since closing changes liveness without a journal write.
  • A throwing onSessionChanged listener is caught and logged so it can't fail a committed append; stored status is typed SessionStatus end to end.
  • workspace-client routes the event to listeners scoped by workspace or "*"; released clients ignore it and the listSessions fallback stays.
  • setModel is left for a follow-up since the claude-code and codex adapters have no mid-session model-change path.

Summary by CodeRabbit

  • New Features
    • Chat session changes now trigger workspace-scoped notifications, so connected clients can refresh session lists when sessions are opened, updated, closed, or discarded.
  • Bug Fixes
    • Closing a live session now notifies clients after shutdown, including when shutdown encounters an error. Repeated close requests do not send duplicate notifications.