Replace artifact polling with SSE push for sub-second new-artifact toasts #34

Open
opened 2026-05-28 22:28:47 +02:00 by lz · 0 comments
Owner

Problem

When the agent calls notify-artifact from a session pane, the operator (sitting on the Session Terminal tab) sees nothing happen for up to 7 seconds. That's the upper bound of the current architecture:

So in the median case it takes ~3.5s, worst case ~7s. Operators tested it under 10s and concluded "nothing happens" — silence is bad UX even when the system is technically working.

Proposed fix

Replace polling with server-sent events (SSE) for the foreground per-session artifact stream:

  1. Add an SSE endpoint at /api/workers/:id/sessions/:sid/artifacts/stream that yields a JSON-line event whenever an artifact for that session is created/updated/deleted.
  2. Hook the agent endpoint at /api/agent/artifacts (and the operator endpoints under /api/workers/:id/sessions/:sid/artifacts*) to emit on a per-session in-process EventEmitter (or a singleton Map<sessionId, Set<sseClient>>).
  3. Replace ArtifactsStore.start()'s setInterval with an EventSource subscription. Fall back to polling if the SSE connection fails (keeps the contract simple — onNew callback unchanged).
  4. Burst-windowing in toastQueue.enqueue can stay at 2s, OR drop to ~200ms now that the inbound stream is real-time. The window exists to collapse rapid bursts of notify-artifact calls into one toast; with SSE the actual time-to-toast becomes burst-window + 1 network hop (~250ms).

End result: notify-artifact → toast appears in ~250ms.

Scope notes

  • Foreground only. MainSplit's background poller can keep polling at 5s — it's already a "is something new while you're not looking" signal, and the operator isn't in a tight feedback loop with it.
  • Stay in-process. A Map<sessionId, Set<Response>> in nexus/src/lib/server/singletons.ts is plenty for the single-operator model. No Redis pubsub or external broker needed.
  • Heartbeat the channel — send a comment line (:\n\n) every ~30s to keep proxies from idling the connection. SvelteKit's adapter-node should pass through SSE cleanly; the custom nexus/src/server.ts we already maintain (for the WebSocket upgrade listener) gives us flexibility here.
  • Auth. All /api/* is gated globally in hooks.server.ts — the SSE endpoint inherits the same operator-session check.
  • Reconnect. EventSource auto-reconnects with backoff; send a Last-Event-ID header for replay? Probably YAGNI for v1: the next reconnect's poll-fallback or initial list refetch closes any window we miss.

Why open this instead of fix-now

The two-system change (server emitter + client EventSource + fallback) is wider than the polish in PR #31. The companion survey issue (linked once filed) explores whether the same SSE infrastructure should also cover previews, worker/session health, file-explorer changes, etc. — better to design the channel once than to bolt on a one-off endpoint just for artifacts.

Acceptance criteria

  • notify-artifact → toast visible within 1s on the in-app Artifacts tab AND on Files/Terminal tabs (whichever has the bgStore equivalent).
  • Connection survives proxy idle timeouts (tested with 60s idle).
  • Falls back to polling within 5s if the SSE connection fails to establish.
  • No regression in vitest test suite.
  • Existing ArtifactsStore.onNew callback contract unchanged — MainSplit and ArtifactsPanel should not need to change beyond replacing store.start().
## Problem When the agent calls `notify-artifact` from a session pane, the operator (sitting on the Session Terminal tab) sees nothing happen for up to 7 seconds. That's the upper bound of the current architecture: - `ArtifactsStore` polls `/api/workers/:id/sessions/:sid/artifacts` every 5 seconds (`POLL_MS` in [`nexus/src/lib/artifacts-store.svelte.ts`](nexus/src/lib/artifacts-store.svelte.ts)). - `toastQueue.enqueue` then waits 2 seconds for a burst window (`burstMs` in [`nexus/src/lib/artifacts-toast.svelte.ts`](nexus/src/lib/artifacts-toast.svelte.ts)) before flushing the toast. So in the median case it takes ~3.5s, worst case ~7s. Operators tested it under 10s and concluded "nothing happens" — silence is bad UX even when the system is technically working. ## Proposed fix Replace polling with **server-sent events (SSE)** for the foreground per-session artifact stream: 1. Add an SSE endpoint at `/api/workers/:id/sessions/:sid/artifacts/stream` that yields a JSON-line event whenever an artifact for that session is created/updated/deleted. 2. Hook the agent endpoint at [`/api/agent/artifacts`](nexus/src/routes/api/agent/artifacts/+server.ts) (and the operator endpoints under `/api/workers/:id/sessions/:sid/artifacts*`) to emit on a per-session in-process EventEmitter (or a singleton `Map<sessionId, Set<sseClient>>`). 3. Replace `ArtifactsStore.start()`'s `setInterval` with an `EventSource` subscription. Fall back to polling if the SSE connection fails (keeps the contract simple — `onNew` callback unchanged). 4. Burst-windowing in `toastQueue.enqueue` can stay at 2s, OR drop to ~200ms now that the inbound stream is real-time. The window exists to collapse rapid bursts of `notify-artifact` calls into one toast; with SSE the actual time-to-toast becomes burst-window + 1 network hop (~250ms). End result: notify-artifact → toast appears in ~250ms. ## Scope notes - **Foreground only.** MainSplit's background poller can keep polling at 5s — it's already a "is something new while you're not looking" signal, and the operator isn't in a tight feedback loop with it. - **Stay in-process.** A `Map<sessionId, Set<Response>>` in [`nexus/src/lib/server/singletons.ts`](nexus/src/lib/server/singletons.ts) is plenty for the single-operator model. No Redis pubsub or external broker needed. - **Heartbeat the channel** — send a comment line (`:\n\n`) every ~30s to keep proxies from idling the connection. SvelteKit's adapter-node should pass through SSE cleanly; the custom [`nexus/src/server.ts`](nexus/src/server.ts) we already maintain (for the WebSocket upgrade listener) gives us flexibility here. - **Auth.** All `/api/*` is gated globally in `hooks.server.ts` — the SSE endpoint inherits the same operator-session check. - **Reconnect.** EventSource auto-reconnects with backoff; send a `Last-Event-ID` header for replay? Probably YAGNI for v1: the next reconnect's poll-fallback or initial list refetch closes any window we miss. ## Why open this instead of fix-now The two-system change (server emitter + client EventSource + fallback) is wider than the polish in [PR #31](https://x.lck.sh/lz/agent-nexus/pulls/31). The companion survey issue (linked once filed) explores whether the same SSE infrastructure should also cover previews, worker/session health, file-explorer changes, etc. — better to design the channel once than to bolt on a one-off endpoint just for artifacts. ## Acceptance criteria - [ ] `notify-artifact` → toast visible within 1s on the in-app Artifacts tab AND on Files/Terminal tabs (whichever has the bgStore equivalent). - [ ] Connection survives proxy idle timeouts (`tested with 60s idle`). - [ ] Falls back to polling within 5s if the SSE connection fails to establish. - [ ] No regression in `vitest` test suite. - [ ] Existing `ArtifactsStore.onNew` callback contract unchanged — `MainSplit` and `ArtifactsPanel` should not need to change beyond replacing `store.start()`.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lz/agent-nexus#34
No description provided.