Files
hapi/docs/api/client-contract/sse.md
T
8f9d6bd7f8 fix(hub): keepalive-only sessions reconcile to an idle lifecycle (#1820) (#1821)
* fix(hub): reconcile keepalive-only sessions to an idle lifecycle

`sessionCache.expireInactive` only expires a session when `activeAt` goes
stale, and `session-alive` refreshes `activeAt` every ~2s for as long as the
CLI socket lives. A session whose agent has done nothing for days therefore
stays `active: true` / `lifecycleState: running` forever — socket pinging was
being read as agent health.

Add the missing second signal. `active` keeps its meaning (the CLI is
reachable); `metadata.lifecycleState` moves `running` -> `idle` once the hub
has seen nothing but keepalives for `HAPI_SESSION_IDLE_TIMEOUT_MS` (default
12h, `0` disables), and back to `running` on the next real progress.

`active` is deliberately not flipped: the socket really is up, and a false
`active: false` would let `resumeSession` spawn a second agent against a live
process and would unlock dedup-merge / delete on it — the #1705 class of bug.

- `sessionCache.recordAgentProgress` tracks a progress clock that keepalives
  can never touch: messages in either direction, queued prompts, background
  tasks. Seeded from `updatedAt` so a hub restart re-derives it from disk.
- `reconcileKeepaliveIdle` runs on the existing 5s inactivity tick. Sessions
  that are thinking, running background tasks or holding a pending permission
  request are never marked; `metadata.idleReconcileExempt` opts out entirely.
- Only real progress clears `idle`, never `thinking` — Cursor ACP emits
  ambient idle/running churn (#1553) that would otherwise flap the lifecycle
  every tick.
- `isLiveLifecycleState` replaces the `=== 'running'` literals so an `idle`
  row does not read as dead to the archive route or the Cursor migrator.
- Web splits keepalive-only sessions into their own session-list bucket.

Refs #1820. Distinct from #842 (`active=false` with a stale `running` badge);
this is the inverse.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(hub): seed the idle clock from stored messages across hub restart

PR review on #1821: a cold `SessionCache` had no observed progress and fell
back to `updatedAt` alone. Assistant messages deliberately do not move
`updatedAt`, so a session that was streaming output a minute before a hub
restart read as hours idle and got marked on the very next tick — a false
`idle` that persisted until the next progress event.

Seed the clock once from `getNewestMessagePosition`, which is the durable
record of that output. `updatedAt` stays a floor on top of the seed: todos /
teamState / agentState writes bump it without routing through
`recordAgentProgress`.

Regression test recreates a SessionCache over the same store after recent
assistant output and confirms keepalives no longer cause premature idle
classification — and that the same session is still marked once the output
itself goes stale.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* test: cover the socket wake-up path and the idle session-list bucket

Follow-up review on #1821 reported no findings but flagged two uncovered
paths. Both have bad failure modes, so close them.

- Hub: an assistant message arriving through `registerSessionHandlers` must
  wake an idle session. Assistant messages never bump `updatedAt`, so the
  `onAgentProgress` hook is the only thing that can clear the mark — a
  refactor dropping that wiring would silently false-idle every actively
  working session, and nothing would have caught it.
- Web: extract the inline bucketing from the `runningSessions` useMemo into a
  pure `bucketRunningSessions`, matching how the rest of SessionList's logic
  is exported and tested. Covers idle vs quiet-but-ready separation, no
  double-bucketing, the transition back out of idle on thinking/background
  task/pending request, and the disconnected/pinned skips.

No behaviour change.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* test: cover the idle lifecycle at both isLiveLifecycleState call sites

Third review round reported no findings but named the two guards this PR
changed as uncovered. Both protect against reading a keepalive-idle row as a
dead one:

- Cursor migrator: an idle-marked row still has a live CLI behind it, so
  preflight must refuse it without `forceArchiveRunning`. A regression here
  would let the migrator transplant the ACP session directory out from under
  a running Cursor runner.
- Archive route: once a keepalive-idle session finally loses its socket it
  must stay archivable, rather than being stranded behind a 409.

Both tests were confirmed non-vacuous by reverting each guard to the
`=== 'running'` literal; each then fails.

No behaviour change.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* test: stop the restart regression leaking its temp DB on failure

The file-backed store's `rmSync` was a trailing call, so any failing
assertion above it skipped cleanup and left a temp DB behind — one per failed
run. Noticed while verifying the test was non-vacuous, which deliberately
fails it.

Wrap in try/finally. Verified: a forced failure now leaves zero
`/tmp/hapi-1820-*` directories.

Test-only.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

---------

Co-authored-by: HAPI <noreply@hapi.run>
2026-09-16 08:11:54 +08:00

229 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SSE stream (`GET /api/events`)
**Audience:** Implementers of native HAPI clients (iOS / Android). This page specifies the hub's server-sent-events stream: subscription model, framing, resume protocol, the `SyncEvent` union, and the versioned session-patch algorithm. Companion pages: [auth](./auth.md), [REST](./rest.md), [pagination](./pagination.md), [messages](./messages.md).
Source of truth: `hub/src/web/routes/events.ts`, `hub/src/sse/sseManager.ts`, `hub/src/web/sseCompression.ts`, `shared/src/schemas.ts` (`SyncEventSchema`), reference client `web/src/hooks/useSSE.ts`.
---
## Endpoint
`GET /api/events` — long-lived `text/event-stream` response.
| Query param | Values | Notes |
|---|---|---|
| `token` | JWT | Browser `EventSource` cannot set headers, so the auth middleware accepts `?token=` **on this path only** (`hub/src/web/middleware/auth.ts`). Clients that can set headers may use `Authorization: Bearer` instead. |
| `all` | `true` \| `1` | Global subscription: every event in the token's namespace. |
| `sessionId` | session id | Session-scoped subscription. |
| `machineId` | machine id | Machine-scoped subscription (web does not use this). |
| `visibility` | `visible` \| `hidden` | Initial visibility state. Anything other than the literal `visible` is treated as `hidden` (the default). See [Visibility](#visibility). |
| `lastEventId` | event id | Resume cursor for manually rebuilt connections. The standard `Last-Event-ID` **request header wins** over this param when both are present (auto-reconnecting EventSource implementations send the header). |
Up-front checks, before any bytes stream:
| Condition | Response |
|---|---|
| Hub sync engine not ready | `503 {"error":"Not connected"}` |
| `sessionId` unknown | `404 {"error":"Session not found"}` |
| `sessionId` in another namespace | `403 {"error":"Session access denied"}` |
| `machineId` unknown / foreign namespace | `404` / `403` (same pattern) |
A `sessionId` may resolve to a canonical id (superseded/merged sessions); the subscription binds to the **resolved** id.
---
## Framing
Every frame is a standard SSE message whose `data:` line is one JSON-encoded `SyncEvent`:
```
id: 018f3c2a:412:9b1f00aa
data: {"type":"session-updated","sessionId":"...","data":{...}}
```
- **Broadcast events** (replayed and live) carry an `id:` field.
- **`connection-changed`, `heartbeat`, and `toast` frames carry NO `id`.** SSE cursors are sticky: a frame without `id` must keep the previously seen id (native `EventSource` does this automatically; hand-rolled parsers must replicate it). A heartbeat must never reset or blank your cursor.
- The server never sends `retry:`; reconnect policy is entirely client-owned (see [Reconnect policy](#reconnect-policy-normative-recommendation)).
### Event id format
`{epoch}:{seq}:{nsTag}` — treat as opaque; store and echo it back, never interpret it.
| Part | Meaning |
|---|---|
| `epoch` | 8-char random string, fixed per hub **process**. A cursor from before a hub restart can never match. |
| `seq` | Integer, monotonically increasing per hub process (shared across all namespaces/sessions). |
| `nsTag` | First 8 hex chars of `sha256("{epoch}|{namespace}")` — binds the cursor to the namespace it was issued under. |
### Replay ring
The hub keeps the last **256** broadcast events, capped at **2 MiB** of JSON (oldest evicted first; the byte cap always keeps at least one entry). `toast` frames are not recorded (they are visibility-targeted, not broadcast).
---
## Handshake and resume
On subscribe the hub emits, **in this guaranteed order**:
1. `connection-changed` — `{"type":"connection-changed","data":{"status":"connected","subscriptionId":"<uuid>","resume":"ok"|"gap"}}` (no `id`).
2. Replayed events (each with its `id`), when `resume` is `ok`.
3. Live traffic.
Live broadcasts that occur while the replay is being written are queued server-side and flushed after it, so ordering is preserved.
| `resume` verdict | Meaning | Client action |
|---|---|---|
| `ok` | The replay that follows contains **every** event missed since the cursor. | Skip the REST resync entirely. |
| `gap` (or field absent — older hubs) | The hub cannot prove continuity. | Full refetch: session list, session detail(s), message tail sync, and queued-state reconcile for the open session (see [pagination](./pagination.md#queued-state-recovery)). |
`gap` is returned whenever: no cursor was sent, the cursor is malformed, `epoch` differs (hub restarted), `nsTag` differs (cursor issued under a different namespace — e.g. after a token swap on the same hub), `seq` is out of range, or events between the cursor and the ring's oldest entry were evicted.
### Cursor rules (normative)
- Keep one cursor **per subscription filter set** (the `all` / `sessionId` / `machineId` tuple, plus hub + namespace). Never replay a cursor recorded under a different filter set — the hub would replay against the wrong filter and the `ok` verdict would be wrong for what you actually missed.
- Update the cursor **after** the event is durably handled. If handling throws, leave the cursor behind the event so the hub redelivers it (at-least-once delivery; handlers must be idempotent).
- Send the cursor on reconnect via `Last-Event-ID` header or `?lastEventId`.
---
## Reconnect policy (normative recommendation)
These constants come from the web reference client (`web/src/hooks/useSSE.ts`) and the hub (`hub/src/sse/sseManager.ts`); native clients should adopt them.
| Constant | Value | Notes |
|---|---|---|
| Server heartbeat interval | 30 s | `{"type":"heartbeat","namespace":"…","data":{"timestamp":<ms>}}` |
| Staleness threshold | 90 s | No frames (of any kind) for 90 s ⇒ tear down and reconnect. |
| Watchdog tick | 10 s | Staleness check interval; **skip checks while backgrounded**. |
| Foreground-resume staleness check | 45 s | On app-foreground, if the last frame is older than 45 s, reconnect immediately (an OS suspend can kill the socket without any error ever surfacing; one missed heartbeat interval is already enough to distrust it). |
| Connect timeout | 10 s | An attempt that has not reached OPEN in 10 s is likely hung on a dead pooled socket — abandon it and retry on a fresh connection. |
| Backoff | 1 s base, ×2, cap 30 s | Delay for attempt *n* (n ≥ 1) = `min(cap, 1000 · 2^(n-1))`. **First retry is immediate** (jitter only). |
| Jitter | +0–500 ms | Uniform, added to every delay. |
| Slow ceiling | 300 s after 8 attempts | A hub that stays unreachable is usually down for hours; each retry through a relay costs a TLS handshake. |
| Backgrounded | defer retries | Do not schedule retries while backgrounded; reconnect immediately on foreground. Reset the attempt counter to 0 on every successful open. |
Any received frame — heartbeat included — counts as activity for the staleness clock. Do not rely on a platform SSE library's built-in auto-reconnect: it will not honor the backoff, the background deferral, or the connect timeout.
---
## Dual-subscription model
The reference client holds **two** concurrent connections (`web/src/App.tsx`, `web/src/lib/appSseSubscriptions.ts`):
1. **Global** — `all=true`, alive for the whole app session. Drives the session list, machine list, badges, toasts.
2. **Session** — `sessionId=<open session>`, recreated on every session switch. Drives the open chat.
Hub-side delivery (`SSEManager.shouldSend`):
| Event type | Delivered to |
|---|---|
| `connection-changed` | the connection itself |
| `heartbeat` | every connection |
| `toast` | every **visible** connection in the namespace, regardless of filter (no `id`, never replayed) |
| `message-received`, `scheduled-matured` | `all=true` connections + matching `sessionId` connections |
| `session-added` / `session-updated` / `session-removed` / `session-ended` / `messages-invalidated` / `messages-consumed` / `messages-indeterminate` / `messages-requeued` / `message-cancelled` | `all=true` connections + matching `sessionId` connections |
| `machine-updated`, `machine-agy-models-updated` | `all=true` connections + matching `machineId` connections |
**The global connection must also handle the message-stream events** (`message-received`, `messages-consumed`, `messages-indeterminate`, `messages-requeued`, `message-cancelled`, `scheduled-matured`): while a session connection is down (reconnect gap) or the session isn't open, the global pipe is the only one alive, and it must still keep queued/optimistic bookkeeping correct — mark local messages consumed, remove cancelled rows, and refresh session-list scheduled counts. The session-scoped connection additionally ingests `message-received` into the message window.
The two connections have **no ordering relationship with each other** — the same `session-updated` patch can arrive on both, in either order. That is why the versioned-patch gate below exists.
---
## SyncEvent union (16 types)
Schema: `SyncEventSchema` in `shared/src/schemas.ts` (discriminated on `type`). All events except `connection-changed` carry `namespace?: string`. Ignore unknown event types.
| `type` | Payload (beyond `type`, `namespace?`) | Client handling |
|---|---|---|
| `session-added` | `sessionId`, `data?: unknown` | Handle exactly like `session-updated` (the reference client shares the branch): a full `Session` upserts; anything else falls back to refetching the session list. |
| `session-updated` | `sessionId`, `data?: Session \| SessionPatch` | See [Versioned patch algorithm](#versioned-patch-algorithm). |
| `session-removed` | `sessionId` | Drop the session from the list, drop its detail cache, clear its message window. |
| `message-received` | `sessionId`, `message: DecryptedMessage` | Ingest into the message window; advance the tail cursor (see [pagination](./pagination.md)). Also fired for the caller's own send (the localId echo). |
| `messages-invalidated` | `sessionId`; rewind may also include `reason: 'rewind'` and `truncateFromLocalId` | Message history changed **structurally** (rewind, fork, import, clear). For a rewind, retain only the known prefix through the client boundary before tail-syncing; for every other invalidation, discard the whole window and run a fresh tail sync. Global scope: refetch the session list. |
| `scheduled-matured` | `sessionId` | A scheduled message became due and was handed to the agent. Refetch list/queue indicators. |
| `session-ended` | `sessionId`, `reason?: 'completed'\|'terminated'\|'error'\|'handoff'\|'cleared'` | Session lifecycle signal (the `session-updated` flow still carries the state change). |
| `machine-updated` | `machineId`, `data?: Machine \| MachinePatch \| null` | Full `Machine`: upsert (remove when `active:false`). `null`: machine removed. Patch `{active?, activeAt?, updatedAt?}`: `active:false` ⇒ remove, otherwise refetch machines. `data` absent ⇒ refetch. |
| `machine-agy-models-updated` | `machineId` | The machine's `agy models` listing changed on a background re-check. Refetch `GET /api/machines/:id/agy-models` for that machine (answered from the machine's cache; it starts no `agy` run and produces no further event). Emitted when the re-check changes what that route would answer — a different listing, or a sign-in warning that appeared or cleared — never for the machine's first listing. |
| `toast` | `data: {title, body, sessionId, url}` | Show as in-app toast/banner. Only delivered to visible connections (see [Visibility](#visibility)). |
| `messages-consumed` | `sessionId`, `localIds: string[]`, `invokedAt: number` | The agent consumed queued user messages: stamp `invokedAt`, flip status to `sent`, remove from the queued bar. |
| `messages-indeterminate` | `sessionId`, `localIds: string[]` | A native dispatch/queue mutation has an unknown outcome. Keep the row uninvoked, show an explicit Retry/Cancel resolution, and do not auto-replay it. Shared-engine Retry/Cancel can remain unavailable until reconciliation proves the outcome. |
| `messages-requeued` | `sessionId`, `localIds: string[]` | An explicit Retry restored delivery to the normal queue. Clear the indeterminate marker. |
| `message-cancelled` | `sessionId`, `messageId`, `localId?` | A queued message was cancelled: remove the row (match by `messageId` **or** `localId`). |
| `heartbeat` | `data?: {timestamp}` | Feed the staleness watchdog. No other action. **Carries no `id`.** |
| `connection-changed` | `data?: {status, subscriptionId?, resume?: 'ok'\|'gap'}` | Handshake; see [Handshake and resume](#handshake-and-resume). Store `subscriptionId` for visibility reporting. **Carries no `id`.** |
---
## Versioned patch algorithm
The most bug-prone part of the protocol. `session-updated.data` is either a **full `Session`** or a **`SessionPatch`** (`shared/src/schemas.ts`); reference implementation `applySessionDetailPatch` in `web/src/hooks/useSSE.ts`.
1. **Full session** (validates against `SessionSchema` and `data.id === event.sessionId`): replace the cached session wholesale.
2. **Patch** (validates against the strict `SessionPatchSchema` — unknown keys make it fail — and is non-empty): apply field-by-field as below.
3. **Absent or unparseable `data`**: fall back to refetching the session detail and list over REST.
Patch application, field by field:
- **Flat fields** — `active`, `thinking`, `activeAt`, `model`, `modelReasoningEffort`, `effort`, `serviceTier`, `permissionMode`, `collaborationMode`, `copilotAgentMode`, `backgroundTaskCount`: last-write-wins assignment when present. `activeTurnStartedAt` appears in patches but the reference implementation deliberately never applies it from a patch (`web/src/lib/sessionPatch.ts`) — take it from full-session payloads only; the `sse/` fixtures pin this.
- **`updatedAt`** — max-monotonic: `updatedAt = max(cached.updatedAt, patch.updatedAt)`. A stale replay must never move the clock backward.
- **Versioned sub-patches** — `metadata`, `agentState`, `todos`, `teamState` each arrive as a wrapper `{version: number, value: …}`. Apply `value` and store `version` **only when `version` is strictly greater than the cached watermark**:
| Wrapper | Cached watermark on `Session` | `value` type |
|---|---|---|
| `metadata` | `metadataVersion` | `Metadata \| null` |
| `agentState` | `agentStateVersion` | `AgentState \| null` |
| `todos` | `todosUpdatedAt` (treat absent as 0) | `TodoItem[]` |
| `teamState` | `teamStateUpdatedAt` (treat absent as 0) | `TeamState \| null` — `null` means "team deleted": clear it |
Strictly greater, because the two SSE connections have **no shared ordering** — the same version can arrive twice and an older version can arrive after a newer one. Applying a stale `agentState` would resurrect resolved permission requests; a stale `metadata` would regress the resume/session-id state. (The web session-*list* path tolerates `>=` because re-deriving its summary from an equal version is idempotent; for a single-cache native client, strict `>` is the rule.)
- **Never wholesale-spread the wrapper.** `session.metadata` must become `wrapper.value` — assigning `{version, value}` itself into the session is a classic porting bug.
- **`scratchlistUpdatedAt`** — a bare refetch trigger: the patch carries no entries; its presence means "refetch `GET /api/sessions/:id/scratchlist`". Nothing else to apply.
### Keep-alive noise
The CLI keep-alive makes the hub re-broadcast a patch roughly **every 10 s per active session**, in which typically only `activeAt` moves. Recommendation (web: `isRenderIrrelevantSessionPatch`): treat a patch as render-irrelevant when the only effective change is an `activeAt` delta **< 60 s** (relative-time labels only change at minute boundaries); the session-list path ignores `activeAt` entirely. Apply the data if you like, but do not re-render or re-sort six times a minute for it.
Reference list sort (web): `globalPinned` > `pinned` > `active` > `pendingRequestsCount` (among active) > `updatedAt` desc.
### `active` is transport liveness, not agent health
Two different signals travel on a `Session` and clients routinely conflate them (tiann/hapi#1820):
| Signal | Means | Refreshed by |
|---|---|---|
| `active` / `activeAt` | the CLI socket is connected and reachable | the `session-alive` keep-alive, every ~2 s |
| `metadata.lifecycleState` | what the agent behind that socket is actually doing | lifecycle transitions only |
A session can keep `active: true` indefinitely on keep-alives alone, with no messages and no thinking, for days. That is a live socket in front of an idle agent, not a working session.
`metadata.lifecycleState` is the health signal:
- `running` — live working session (stamped by the CLI at session start)
- `idle` — the hub has seen nothing but keep-alives for `HAPI_SESSION_IDLE_TIMEOUT_MS` (default 12 h). Reversible and non-destructive: `active` stays `true`, and the next message, queued prompt or background task flips it back to `running` within one hub tick
- `archived` — session closed
Client rules:
- Treat both `running` and `idle` as "a CLI still owns this session". Comparing against the `'running'` literal makes an `idle` session read as a dead row.
- Surface `idle` distinctly from `active`-and-recently-working, so an operator can tell a busy fleet from a pile of keep-alive zombies (web: the `idle` bucket in `SessionList`).
- Sessions that are meant to sit quiet indefinitely can set `metadata.idleReconcileExempt: true` to opt out of reconciliation entirely.
---
## Visibility
`POST /api/visibility` with body `{"subscriptionId": "<from connection-changed>", "visibility": "visible" | "hidden"}` → `{"ok": true}`. Errors: `400` invalid body, `404` unknown `subscriptionId` (or namespace mismatch), `503` hub not ready. Each new connection has a **new** `subscriptionId` — re-report after every reconnect (the web reference reports both of its connections on every foreground/background transition and retries a failed report after 2 s).
Semantics (`hub/src/visibility/visibilityTracker.ts`, `hub/src/push/pushNotificationChannel.ts`): Android/iOS native delivery is independent of Web visibility. When a native provider accepts a notification for at least one device, the hub skips the Web Push/toast duplicate for that dispatch. Otherwise, if **any** connection in the namespace is visible, the hub first sends **`toast` SSE frames to visible connections**. Web Push is the fallback when no connection is visible or toast delivery reaches zero connections. Provider acceptance is not a handset delivery receipt — see the [native companion contract](../native-companion-contract.md).
Native rule: report `visible` on foreground and `hidden` on background, every time. A stale `visible` report can divert the namespace's Web Push fallback into unseen toast frames; it does not disable native push. The native app separately suppresses its local notification when the corresponding chat is already open in the foreground.
---
## Gzip
SSE responses are gzip-compressed when `Accept-Encoding` allows it (`hub/src/web/sseCompression.ts`): the hub drives zlib directly and issues a **sync flush after every chunk**, so events arrive immediately despite compression (~75 % ratio on real traffic). Negotiation is q-value-aware (`gzip;q=0` refuses, `*` honored); the response carries `Content-Encoding: gzip` with no `Content-Length`.
Native clients must verify that their HTTP stack **decompresses the stream incrementally** (frames visible per flush, not buffered until EOF). If it does not — or if it only auto-decompresses when it injected `Accept-Encoding` itself — send `Accept-Encoding: identity` and take the uncompressed stream.