Skip to content

perf(hub,web): emit structured patches for session todos/teamState/metadata/agentState writes (closes #895, second half of #884)#897

Open
heavygee wants to merge 7 commits into
tiann:mainfrom
heavygee:feat/sse-patch-extend-session-state
Open

perf(hub,web): emit structured patches for session todos/teamState/metadata/agentState writes (closes #895, second half of #884)#897
heavygee wants to merge 7 commits into
tiann:mainfrom
heavygee:feat/sse-patch-extend-session-state

Conversation

@heavygee

Copy link
Copy Markdown
Collaborator

Summary

Closes #895. Second half of #884 (paired with #885 for the first half — staleTime suppression for navigation thrash).

hub/src/socket/handlers/cli/sessionHandlers.ts has four CLI emit-sites that fire session-updated SSE events with no data payload when todos / teamState / metadata / agentState change. On the web side, useSSE.ts parses missing/empty data as "no patch I can apply" and falls through to per-session REST invalidation (queueSessionDetailInvalidation + queueSessionListInvalidation at web/src/hooks/useSSE.ts:509-512). On a 130-session install that's O(N) REST refetches every time any one session writes a todo or pings keep-alive — the dominant component of the storm described in #884.

This PR closes the gap end-to-end:

  1. shared/src/schemas.ts — extends SessionPatchSchema with optional todos / teamState / metadata / agentState fields. Versioned wrappers ({ version, value }) for metadata and agentState mirror the existing socket.io update-session broadcast shape so downstream caches can reject stale patches.
  2. hub/src/socket/handlers/cli/sessionHandlers.ts — the four emit-sites now carry structured payload data instead of firing data: undefined. Web hits getSessionPatch's truthy branch and patches the React Query cache in place; no REST refetch.
  3. hub/src/sync/sessionCache.ts — new applySessionPatch() that mirrors the patch into the in-memory cache (no DB re-read). Keeps NotificationHub.getSession, sidebar dedup, and other in-process consumers coherent with what just landed in the DB. Returns false for empty {} patches and cross-namespace mismatches so the caller falls back to the legacy refresh path safely.
  4. hub/src/sync/syncEngine.tshandleRealtimeEvent fast-path: if the event has data and applySessionPatch succeeds, forward the patch as-is without refreshSession's DB read + full-Session SSE broadcast. Falls back to legacy refresh on empty/unparseable patches.
  5. web/src/hooks/useSSE.tspatchSessionDetail and patchSessionSummary unwrap the versioned metadata / agentState wrappers before assigning to the cached Session. Without unwrapping, session.metadata would silently become { version, value } and corrupt every consumer that reads metadata.flavor, metadata.cursorSessionId, etc.

Subtle bug fixed in commit 2

applySessionPatch MUTATES the cached Session in place (reassigns session.metadata = patch.metadata.value). handleRealtimeEvent has a dedup-on-agent-session-id-change trigger that compares before.metadata vs after.metadata — both reads were going through sessionCache.getSession(id) which returns the SAME object reference, so the mutation overwrote before.metadata and hasSameAgentSessionIds always returned true. Dedup silently never fired on the fast path.

The legacy refreshSession path got dedup for free because it REPLACES the cache map entry with a new Session object, leaving the pre-refresh reference intact for the comparator.

Fix: snapshot the metadata reference before applySessionPatch runs and use the snapshot for both branches so the comparison contract is identical. Three regression tests in hub/src/sync/syncEngineHandleRealtimeEvent.test.ts — verified they FAIL on the un-fixed code and PASS on the fixed.

Commits

  1. 05147a6a — schema extension + emit-sites + cache patch path + web unwrap + tests.
  2. eaa0281f — snapshot metadata before mutation; regression tests for dedup fast-path.
  3. f261a790 — pure cosmetic reorder of SessionPatchSchema field order so soup-merge with the codex-usage-indicator fork branch (which adds a flat metadata field at the same place upstream/main has the model fields) raises an explicit conflict instead of silently producing a duplicate-key object. No upstream impact.

Downstream compatibility

SessionPatchSchema is .strict(). Older web clients connecting to a newer hub will fail to parse patches carrying the new todos / teamState / wrapped metadata / wrapped agentState fields and fall back to the very REST invalidation this PR closes. The fix needs hub + web to ship together. Newer web clients against an older hub work fine — the hub just keeps firing the no-data events as before.

Test plan

  • bun typecheck clean across cli / web / hub.
  • bun run test — hub 449 / 449 pass; the one CLI failure (runner.integration.test.ts > should detect version mismatch and kill old runner) is a pre-existing flake on the version-comparison assertion and is unrelated.
  • New tests:
    • shared/src/schemas.sessionPatch.test.ts — 7 cases (bare todos, bare teamState, versioned metadata, versioned agentState with null, rejects metadata-without-version, rejects unknown keys, rejects full-Session payload).
    • hub/src/sync/sessionCache.applySessionPatch.test.ts — 7 cases (todos / metadata-wrapped / agentState-wrapped-null / unknown session / bad schema / empty {} / cross-namespace).
    • hub/src/sync/syncEngineHandleRealtimeEvent.test.ts — 3 cases (metadata-change fires dedup on fast path, todos patch does NOT fire dedup, legacy refresh path still fires dedup).
    • hub/src/socket/handlers/cli/sessionHandlers.test.ts — 3 cases extending the existing suite for the four emit-sites' structured payloads.
    • shared/src/sessionSummary.test.tstoSessionSummaryMetadata extraction tests.
  • Operator empirical verification (in progress): branch is the topmost layer on the daily-driver soup; awaiting activation on the reporter's :3006. Will report the before/after journalctl -u hapi-hub.service REST hit counts on the same 5-min idle window used in Performance: web client useSession refetch storm dominates hub access logs (~95% of syslog volume on busy installs) #884.

Related

Made with Cursor

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] TeamDelete patches no longer clear cached team state — applyTeamStateDelta returns null for TeamDelete, but this new payload converts it to undefined at hub/src/socket/handlers/cli/sessionHandlers.ts:135. The DB row is cleared by setSessionTeamState(..., null, ...), while the new fast path treats { teamState: undefined } as an applied patch and never clears session.teamState in hub cache; the SSE JSON payload also collapses to an empty object, so web clients fall back to invalidation instead of receiving the clear. This leaves stale team panels until a later full refresh.
    Suggested fix:
    // shared/src/schemas.ts
    teamState: TeamStateSchema.nullable().optional(),
    
    // hub/src/socket/handlers/cli/sessionHandlers.ts
    data: { teamState: newTeamState }
    
    // hub/src/sync/sessionCache.ts and web/src/hooks/useSSE.ts
    if (Object.prototype.hasOwnProperty.call(patch, 'teamState')) {
        session.teamState = patch.teamState ?? undefined
    }

Summary

  • Review mode: initial
  • One cache-coherency regression found in the new structured teamState patch path.

Testing

  • Not run (automation); bun is not available on PATH in this runner.

HAPI Bot

Comment thread hub/src/socket/handlers/cli/sessionHandlers.ts Outdated
heavygee added a commit to heavygee/hapi that referenced this pull request Jun 16, 2026
Closes PR tiann#897 Major review (HAPI Bot, 2026-06-13): TeamDelete events
drove `applyTeamStateDelta` to return `null`, but the emit-site
coalesced that to `undefined`. JSON serialization then dropped the key,
the hub cache skipped its assignment branch (`patch.teamState !==
undefined` was false), and the web client saw an empty patch and fell
back to REST invalidation — exactly the storm path this PR was supposed
to close. Sidebar / NotificationHub / dedup all served stale team state
until the next full refresh.

Fix in four coordinated places (wire ↔ cache contract):

- shared/src/schemas.ts: `teamState: TeamStateSchema.nullable().optional()`
  so `null` is a valid wire shape meaning "cleared". Comment documents
  the discriminator contract for consumers.
- hub/src/socket/handlers/cli/sessionHandlers.ts: drop the
  `?? undefined` coalesce so `null` survives JSON serialization.
- hub/src/sync/sessionCache.ts (applySessionPatch): use
  `Object.prototype.hasOwnProperty.call(patch, 'teamState')` to
  discriminate "field absent" from "field is null", then map null →
  undefined to match the cached `Session.teamState` type.
- web/src/hooks/useSSE.ts (patchSessionDetail): same
  hasOwnProperty discriminator + null → undefined mapping.

Regression tests:

- schemas.sessionPatch.test.ts: `{ teamState: null }` parses
  successfully (locks the wire contract).
- sessionCache.applySessionPatch.test.ts: TeamDelete clears cached
  teamState; todos-only patch leaves teamState untouched (guards the
  hasOwnProperty branch against a regression back to `!== undefined`).

Co-authored-by: Cursor <cursoragent@cursor.com>
@heavygee

Copy link
Copy Markdown
Collaborator Author

Addressed in f80d7bd - thanks for catching this; you're right, both the hub cache and the web fallback were broken on TeamDelete.

Applied your suggested fix in all four coordinated spots (wire ↔ cache contract):

  • shared/src/schemas.ts: teamState: TeamStateSchema.nullable().optional() so null is the explicit clear signal on the wire (with a comment documenting the discriminator contract).
  • hub/src/socket/handlers/cli/sessionHandlers.ts: dropped the ?? undefined coalesce so null survives JSON serialization.
  • hub/src/sync/sessionCache.ts (applySessionPatch): Object.prototype.hasOwnProperty.call(patch, 'teamState') discriminates "absent" vs "null clear"; null → undefined to match the cached Session.teamState shape.
  • web/src/hooks/useSSE.ts (patchSessionDetail): same hasOwnProperty + null → undefined pattern.

Regression tests:

  • shared/src/schemas.sessionPatch.test.ts: locks { teamState: null } as a valid wire shape.
  • hub/src/sync/sessionCache.applySessionPatch.test.ts: TeamDelete clears cached teamState; todos-only patch leaves teamState untouched (the second test guards the hasOwnProperty branch against a regression back to !== undefined).

bun typecheck + bun run test both clean for the affected packages (the one failure in the wider run was an unrelated flaky CLI runner integration test, no CLI code in this PR).

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Stale metadata/agentState patches can overwrite newer session cache state — these fields are explicitly versioned in SessionPatchSchema, but the web SSE applier unwraps every arriving patch without checking it against the cached metadataVersion/agentStateVersion. SSE reconnects and the existing per-query invalidation path can leave a detail cache newer than an older buffered structured patch; applying that older patch regresses resume/session IDs or pending requests. The CLI room handler already guards this by requiring incoming.version > currentVersion, so the web cache should do the same. Evidence: web/src/hooks/useSSE.ts:410.
    Suggested fix:
    if (patch.metadata !== undefined && patch.metadata.version > nextSession.metadataVersion) {
        nextSession.metadata = patch.metadata.value
        nextSession.metadataVersion = patch.metadata.version
    }
    if (patch.agentState !== undefined && patch.agentState.version > nextSession.agentStateVersion) {
        nextSession.agentState = patch.agentState.value
        nextSession.agentStateVersion = patch.agentState.version
    }

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • One remaining cache-coherency issue found in the new structured SSE patch path. The previous TeamDelete/null-clear issue is not found in the current head.

Testing

  • Not run (automation); bun is not installed on PATH in this runner.

HAPI Bot

Comment thread web/src/hooks/useSSE.ts Outdated
heavygee added a commit to heavygee/hapi that referenced this pull request Jun 16, 2026
…ession

Closes PR tiann#897 follow-up Major review (HAPI Bot, 2026-06-16): the new
structured SSE patch path unwraps versioned metadata/agentState fields
without checking the cached metadataVersion/agentStateVersion. SSE
reconnects + the existing per-query invalidation can leave a detail
cache repopulated by a fresh REST refetch BEFORE a buffered older patch
replays. Without the gate the older patch overwrites the newer cache,
regressing resume / session-id / pending-requests state.

Mirrors the hub-side CLI room handler contract (`incoming.version >
currentVersion`, `web/src/hooks/useSSE.ts`):

- `patchSessionDetail`: gate metadata/agentState assignment behind
  `isNewerVersionedPatch(patch.version, nextSession.<field>Version)`.
  The pre-patch version is captured by `{ ...previous.session }` so
  the comparison is against the cache-at-write-time.
- `patchSessionSummary`: read the detail cache (via queryClient) for
  the canonical metadataVersion / agentStateVersion. Use `>=` (not `>`)
  because the callsite runs `patchSessionDetail` first — when detail
  accepts a newer patch the cache already holds the new version, so
  matching `>=` keeps summary aligned with detail's acceptance; when
  detail rejects, `>=` aligns summary with detail's rejection.
- Exported `isNewerVersionedPatch(patchVersion, currentVersion)` as a
  pure helper so the rule is unit-testable in isolation.
- Test: `useSSE.test.ts` pins the 4 cases (newer ✓ / older ✗ /
  same-version ✗ / first-write currentVersion=0 ✓).

Hub-side `applySessionPatch` does NOT need the same gate: in-process
events from `handleUpdateMetadata` / `handleUpdateState` are emitted
only AFTER the optimistic-concurrency check at the store layer
succeeds, and `syncEngine.handleRealtimeEvent` consumes them
synchronously in order. The vulnerability is the SSE
reconnect/replay window on the web client.

Co-authored-by: Cursor <cursoragent@cursor.com>
@heavygee

Copy link
Copy Markdown
Collaborator Author

Addressed in 1f84514 - thanks, you're right about the reconnect-window vulnerability.

Mirrored the hub CLI room handler's incoming.version > currentVersion contract on the web side:

  • patchSessionDetail: gated metadata/agentState assignment behind isNewerVersionedPatch(patch.version, nextSession.<field>Version). The pre-patch version is captured by the { ...previous.session } spread, so the comparison is against cache-at-write-time.
  • patchSessionSummary: read the detail cache (via queryClient.getQueryData) for the canonical metadataVersion / agentStateVersion. Uses >= (not >) because the callsite runs patchSessionDetail first - when detail accepts a newer patch the cache already holds the new version, so >= keeps summary aligned with detail's acceptance; when detail rejects, >= aligns summary with detail's rejection. Defaults to 0 when detail isn't cached (allows first-write through).
  • Exported isNewerVersionedPatch(patchVersion, currentVersion) as a pure helper so the rule is unit-testable.
  • Test: useSSE.test.ts pins the 4 cases (strictly newer ✓ / older ✗ / same-version ✗ / first-write currentVersion=0 ✓).

Hub side: I considered adding the same gate to sessionCache.applySessionPatch but didn't, because the in-process events from handleUpdateMetadata / handleUpdateState are emitted only AFTER the store's optimistic-concurrency check succeeds, and syncEngine.handleRealtimeEvent consumes them synchronously in order. The vulnerability is specifically the SSE reconnect/replay window on the web client. Happy to add the symmetric guard if you'd prefer defense-in-depth there too.

bun typecheck clean. Hub (431 pass), shared (94 pass), web useSSE.test.ts (7 pass) all green.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • No issues found.

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • The prior stale metadata/agentState cache regression is not found in the current head. Residual risk: I did not execute the test suite, and the web SSE cache behavior is still mostly covered by helper-level tests rather than an integrated React Query cache test.

Testing

  • Not run (review only; PR content not executed).

HAPI Bot

heavygee added a commit to heavygee/hapi that referenced this pull request Jun 16, 2026
Soup-local fixup for the integration of feat/codex-usage-indicator-rebased
(this branch's parent, adds toSummaryMetadata reading patch.metadata.name
directly) with feat/sse-patch-extend-session-state (wraps SessionPatch.metadata
in a {version, value} envelope). After both merge into driver/integration the
codex helper still references the unwrapped shape, but the sse-patch path at
line ~390 (toSessionSummaryMetadata(patch.metadata.value)) already supersedes
it. The codex assignment is dead but breaks typecheck because the unwrapped
properties no longer exist on the envelope type.

Standalone fix: removes the helper and its single call site. Both metadata
paths in patchSessionSummary collapse to the sse-patch envelope-aware code.
Layer applied AFTER sse-patch in soup so the dead code exists at merge time.

Not appropriate upstream as-is: each parent branch (codex tiann#847, sse tiann#897)
works fine standalone. Merge-only fixup belongs in the soup until one of
those PRs lands and the dependency tree gets re-baselined.

Co-authored-by: Cursor <cursoragent@cursor.com>
heavygee and others added 5 commits June 18, 2026 10:43
…tadata/agentState writes (tiann#895, closes second half of tiann#884)

Today the four CLI handlers in `sessionHandlers.ts` that write session-scoped
state (TodoWrite messages -> setSessionTodos; team-state deltas ->
setSessionTeamState; update-metadata RPC; update-state RPC) emit
`session-updated` events with no `data` payload. `syncEngine.handleRealtimeEvent`
intercepts each one, re-reads the row from SQLite, and broadcasts the entire
~5KB Session via SSE. That works (the web client's `isSessionRecord` shortcut
keeps the cache patched), but it costs a DB read and a full-payload SSE
fan-out per write, and any failure mode that drops the broadcast data falls
through to `useSSE.ts:509-512` and triggers per-session REST refetches - the
storm vector documented in tiann#884.

This is the architectural follow-up to tiann#885. tiann#885 added `staleTime` on the
detail query (eliminates focus / mount refetches inside a 30s window). This
PR removes the structural reason these four writes touch the REST path at all.

`SessionPatchSchema` learns four optional structured fields:
- `todos` (array)
- `teamState` (object)
- `metadata` (versioned `{ version, value }` wrapper)
- `agentState` (versioned `{ version, value }` wrapper)

`.strict()` preserved so unknown keys still throw. The versioned wrappers
mirror the existing socket.io `update-session` broadcast at lines 211 / 259 so
metadata and agentState always travel as an atomic (version, value) pair -
caches need the version to reject stale patches.

Each of the four emit-sites now carries a structured `data` payload with the
delta it just wrote. `syncEngine.handleRealtimeEvent` for `session-updated`
events with non-empty patch data: applies the patch to the in-memory Session
in place via the new `sessionCache.applySessionPatch`, then forwards the event
as-is. Empty patches, no-data events, and patches against uncached sessions
all fall back to the legacy `refreshSession` path so behavior for other
emitters (e.g. `cursor/codexDesktop.ts`) is unchanged. Dedup hook against
agent-session-id changes preserved on the fast path.

`patchSessionDetail` is no longer a blanket spread - it enumerates each field
explicitly so the versioned metadata / agentState patches can be unwrapped
into the Session's flat (metadata, metadataVersion) and (agentState,
agentStateVersion) pairs. Spreading the patch wholesale would have written a
`{ version, value }` object into `session.metadata` and corrupted the cache.

`patchSessionSummary` recomputes the touched derivations - `todoProgress` from
todos, `pendingRequestsCount` / `pendingRequestKinds` from agentState,
SessionSummaryMetadata from metadata - via three new pure helpers exposed
from `shared/src/sessionSummary.ts` (`computeTodoProgress`,
`computePendingRequestKinds`, `toSessionSummaryMetadata`). `toSessionSummary`
is refactored to use these helpers - identical output, single source of
truth.

- shared: `SessionPatchSchema` parses each new patch shape, stays strict,
  rejects empty metadata without `version`, rejects full Session payloads
  (those go through `isSessionRecord`).
- shared: summary derivation helpers covered against bare AgentState /
  Metadata inputs (the shape the SSE patch path provides).
- hub: each emit-site asserted to carry the expected structured payload.
- hub: `applySessionPatch` unit-tests cover todos / metadata / agentState
  application, empty-patch rejection (forces caller back to refreshSession),
  cross-namespace guard, and missing-session fallback.

Empirical wire round-trip verifies each patch shape survives `JSON.stringify`
intact and routes the web client through `getSessionPatch` (non-empty result)
instead of the REST invalidation fallback.

Per tiann#884 expectation: with this fix on top of tiann#885, idle GET /api/sessions/<id>
rate is expected to drop to near-zero on the reporter's 100+ session install.
Operator (heavygee) will attach the live-measured before / after to the PR
post-merge.

Co-authored-by: Cursor <cursoragent@cursor.com>
…on so dedup-on-id-change fires

The structured-patch fast path added in 05147a6 (closes tiann#884 second half)
broke the dedup-on-metadata-change trigger in handleRealtimeEvent.

Root cause: applySessionPatch MUTATES the cached Session in place
(reassigns session.metadata = patch.metadata.value). The dedup check
compares before vs after agent session IDs, but `before = getSession(id)`
and `after = getSession(id)` returned the SAME object reference, so
before.metadata had already been overwritten by the time the check ran.
hasSameAgentSessionIds always returned true and dedup silently never
fired on the fast path.

The legacy refreshSession path got dedup for free because it REPLACES
the cache map entry with a new Session object, leaving the pre-refresh
reference intact for the comparator.

Fix: capture beforeMetadata before applySessionPatch runs; use it for
both branches so the comparison contract is identical.

Adds syncEngineHandleRealtimeEvent.test.ts with three regression guards:
- structured metadata patch with changed cursorSessionId fires dedup
- todos-only patch does NOT fire dedup (no false positives)
- legacy refresh path (no patch data) still fires dedup

Co-authored-by: Cursor <cursoragent@cursor.com>
…dex-usage layer conflicts cleanly

Pure reorder (no semantic change). feat/codex-usage-indicator-rebased adds
a flat `metadata: MetadataSchema.nullable().optional()` + `metadataVersion`
to SessionPatchSchema in the same line range upstream/main has the model/
modelReasoningEffort fields. My branch added the versioned `metadata` field
at the END of the object, so git 3-way merge silently auto-merged both,
producing an invalid object literal with duplicate `metadata` keys.

By placing my `metadata` / `agentState` / `todos` / `teamState` insertions
in the SAME line range codex inserts (between updatedAt and model), git
now raises an explicit CONFLICT during the soup merge, which can be
resolved correctly once and replayed by rerere. No behavior change on a
clean upstream/main merge.

Pure cosmetic; no test or runtime impact.

Co-authored-by: Cursor <cursoragent@cursor.com>
Closes PR tiann#897 Major review (HAPI Bot, 2026-06-13): TeamDelete events
drove `applyTeamStateDelta` to return `null`, but the emit-site
coalesced that to `undefined`. JSON serialization then dropped the key,
the hub cache skipped its assignment branch (`patch.teamState !==
undefined` was false), and the web client saw an empty patch and fell
back to REST invalidation — exactly the storm path this PR was supposed
to close. Sidebar / NotificationHub / dedup all served stale team state
until the next full refresh.

Fix in four coordinated places (wire ↔ cache contract):

- shared/src/schemas.ts: `teamState: TeamStateSchema.nullable().optional()`
  so `null` is a valid wire shape meaning "cleared". Comment documents
  the discriminator contract for consumers.
- hub/src/socket/handlers/cli/sessionHandlers.ts: drop the
  `?? undefined` coalesce so `null` survives JSON serialization.
- hub/src/sync/sessionCache.ts (applySessionPatch): use
  `Object.prototype.hasOwnProperty.call(patch, 'teamState')` to
  discriminate "field absent" from "field is null", then map null →
  undefined to match the cached `Session.teamState` type.
- web/src/hooks/useSSE.ts (patchSessionDetail): same
  hasOwnProperty discriminator + null → undefined mapping.

Regression tests:

- schemas.sessionPatch.test.ts: `{ teamState: null }` parses
  successfully (locks the wire contract).
- sessionCache.applySessionPatch.test.ts: TeamDelete clears cached
  teamState; todos-only patch leaves teamState untouched (guards the
  hasOwnProperty branch against a regression back to `!== undefined`).

Co-authored-by: Cursor <cursoragent@cursor.com>
…ession

Closes PR tiann#897 follow-up Major review (HAPI Bot, 2026-06-16): the new
structured SSE patch path unwraps versioned metadata/agentState fields
without checking the cached metadataVersion/agentStateVersion. SSE
reconnects + the existing per-query invalidation can leave a detail
cache repopulated by a fresh REST refetch BEFORE a buffered older patch
replays. Without the gate the older patch overwrites the newer cache,
regressing resume / session-id / pending-requests state.

Mirrors the hub-side CLI room handler contract (`incoming.version >
currentVersion`, `web/src/hooks/useSSE.ts`):

- `patchSessionDetail`: gate metadata/agentState assignment behind
  `isNewerVersionedPatch(patch.version, nextSession.<field>Version)`.
  The pre-patch version is captured by `{ ...previous.session }` so
  the comparison is against the cache-at-write-time.
- `patchSessionSummary`: read the detail cache (via queryClient) for
  the canonical metadataVersion / agentStateVersion. Use `>=` (not `>`)
  because the callsite runs `patchSessionDetail` first — when detail
  accepts a newer patch the cache already holds the new version, so
  matching `>=` keeps summary aligned with detail's acceptance; when
  detail rejects, `>=` aligns summary with detail's rejection.
- Exported `isNewerVersionedPatch(patchVersion, currentVersion)` as a
  pure helper so the rule is unit-testable in isolation.
- Test: `useSSE.test.ts` pins the 4 cases (newer ✓ / older ✗ /
  same-version ✗ / first-write currentVersion=0 ✓).

Hub-side `applySessionPatch` does NOT need the same gate: in-process
events from `handleUpdateMetadata` / `handleUpdateState` are emitted
only AFTER the optimistic-concurrency check at the store layer
succeeds, and `syncEngine.handleRealtimeEvent` consumes them
synchronously in order. The vulnerability is the SSE
reconnect/replay window on the web client.

Co-authored-by: Cursor <cursoragent@cursor.com>
@heavygee heavygee force-pushed the feat/sse-patch-extend-session-state branch from 1f84514 to 291bef2 Compare June 18, 2026 16:29

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Structured patches drop the session timestamp — update-metadata, update-state, TodoWrite, and team-state writes all still update sessions.updated_at, but the new fast path forwards only the field delta, so hub/web caches no longer learn the new updatedAt. That leaves session ordering and last-activity UI stale until another full refresh. Evidence: hub/src/socket/handlers/cli/sessionHandlers.ts:127.
    Suggested fix:
    data: { todos, updatedAt: msg.createdAt }
    // teamState: data: { teamState: newTeamState, updatedAt: msg.createdAt }
    // metadata / agentState: include the stored updatedAt returned or reloaded after the DB write.
  • [Minor] Agent-state summary patches leave pendingRequests stale — the summary cache updates pendingRequestsCount and pendingRequestKinds, but not the capped pendingRequests array that powers the session attention tooltip. After a structured agentState patch, the badge can reflect new requests while the tooltip still lists old tools or nothing. Evidence: web/src/hooks/useSSE.ts:390.
    Suggested fix:
    if (patch.agentState !== undefined && patch.agentState.version >= detailAgentStateVersion) {
        nextSummary.pendingRequestsCount = computePendingRequestsCount(patch.agentState.value)
        nextSummary.pendingRequestKinds = computePendingRequestKinds(patch.agentState.value)
        nextSummary.pendingRequests = computePendingRequests(patch.agentState.value, nextSummary.updatedAt)
    }

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • Found two cache consistency regressions introduced by the structured session patch fast path. Both should be covered with focused tests around handleRealtimeEvent/useSSE patch application.

Testing

  • Not run (review only; PR content not executed).

HAPI Bot

Comment thread hub/src/socket/handlers/cli/sessionHandlers.ts Outdated
Comment thread web/src/hooks/useSSE.ts Outdated
…ummary

Closes PR tiann#897 post-rebase bot review (HAPI Bot, 2026-06-18):

Major — structured patches dropped session.updatedAt. TodoWrite,
teamState, metadata, and agentState DB writes all touch sessions.updated_at,
but the fast path forwarded only field deltas. Hub/web caches and session
list ordering stayed stale until a full refresh. All four emit-sites in
sessionHandlers now reload the stored row after a successful write and
include updatedAt in the SSE patch payload (applySessionPatch already
applies it via Math.max).

Minor — agentState summary patches updated pendingRequestsCount/kinds but
left pendingRequests stale, so SessionAttentionIndicator tooltips showed
old request tools after an SSE patch. patchSessionSummary now uses
computePendingRequestsCount + computePendingRequests alongside the
existing kinds helper.

Tests: sessionHandlers.test.ts asserts updatedAt on todos/metadata/agentState
patches.

Co-authored-by: Cursor <cursoragent@cursor.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Minor] Service tier patches are dropped by the new manual detail patcher — SessionPatchSchema still accepts serviceTier, and applySessionConfig() emits a full session after /api/sessions/:id/service-tier, but the refactor from spreading ...patch to field-by-field assignment no longer copies that field into the detail cache. After changing Codex fast mode, props.session.serviceTier can remain stale until a full refetch/remount, so the Fast/Standard control can show the wrong selected tier. Evidence: web/src/hooks/useSSE.ts:421.
    Suggested fix:
    if (Object.prototype.hasOwnProperty.call(patch, 'serviceTier')) {
        nextSession.serviceTier = patch.serviceTier ?? null
    }

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • One remaining cache consistency regression found in the latest diff. The prior timestamp and pending-request findings appear addressed.

Testing

  • Not run: bun is not installed in this runner (/bin/bash: bun: command not found).

HAPI Bot

Comment thread web/src/hooks/useSSE.ts
Closes PR tiann#897 bot Minor (2026-06-18): field-by-field patchSessionDetail
stopped copying serviceTier after the spread refactor, so Codex Fast/
Standard could show stale tier until a full refetch. Mirror nullable
hasOwnProperty handling in patchSessionDetail and hub applySessionPatch.

Co-authored-by: Cursor <cursoragent@cursor.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • None.

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • No issues found in the latest head. The prior serviceTier cache patching issue is addressed in both hub and web structured patch paths.
  • Residual risk: focused runtime validation could not be executed in this runner.

Testing

  • Not run (automation): bun is not installed in this runner (/bin/bash: line 1: bun: command not found).

HAPI Bot

iridite pushed a commit to iridite/hapi that referenced this pull request Jun 20, 2026
Closes PR tiann#897 Major review (HAPI Bot, 2026-06-13): TeamDelete events
drove `applyTeamStateDelta` to return `null`, but the emit-site
coalesced that to `undefined`. JSON serialization then dropped the key,
the hub cache skipped its assignment branch (`patch.teamState !==
undefined` was false), and the web client saw an empty patch and fell
back to REST invalidation — exactly the storm path this PR was supposed
to close. Sidebar / NotificationHub / dedup all served stale team state
until the next full refresh.

Fix in four coordinated places (wire ↔ cache contract):

- shared/src/schemas.ts: `teamState: TeamStateSchema.nullable().optional()`
  so `null` is a valid wire shape meaning "cleared". Comment documents
  the discriminator contract for consumers.
- hub/src/socket/handlers/cli/sessionHandlers.ts: drop the
  `?? undefined` coalesce so `null` survives JSON serialization.
- hub/src/sync/sessionCache.ts (applySessionPatch): use
  `Object.prototype.hasOwnProperty.call(patch, 'teamState')` to
  discriminate "field absent" from "field is null", then map null →
  undefined to match the cached `Session.teamState` type.
- web/src/hooks/useSSE.ts (patchSessionDetail): same
  hasOwnProperty discriminator + null → undefined mapping.

Regression tests:

- schemas.sessionPatch.test.ts: `{ teamState: null }` parses
  successfully (locks the wire contract).
- sessionCache.applySessionPatch.test.ts: TeamDelete clears cached
  teamState; todos-only patch leaves teamState untouched (guards the
  hasOwnProperty branch against a regression back to `!== undefined`).

Co-authored-by: Cursor <cursoragent@cursor.com>
iridite pushed a commit to iridite/hapi that referenced this pull request Jun 20, 2026
…ession

Closes PR tiann#897 follow-up Major review (HAPI Bot, 2026-06-16): the new
structured SSE patch path unwraps versioned metadata/agentState fields
without checking the cached metadataVersion/agentStateVersion. SSE
reconnects + the existing per-query invalidation can leave a detail
cache repopulated by a fresh REST refetch BEFORE a buffered older patch
replays. Without the gate the older patch overwrites the newer cache,
regressing resume / session-id / pending-requests state.

Mirrors the hub-side CLI room handler contract (`incoming.version >
currentVersion`, `web/src/hooks/useSSE.ts`):

- `patchSessionDetail`: gate metadata/agentState assignment behind
  `isNewerVersionedPatch(patch.version, nextSession.<field>Version)`.
  The pre-patch version is captured by `{ ...previous.session }` so
  the comparison is against the cache-at-write-time.
- `patchSessionSummary`: read the detail cache (via queryClient) for
  the canonical metadataVersion / agentStateVersion. Use `>=` (not `>`)
  because the callsite runs `patchSessionDetail` first — when detail
  accepts a newer patch the cache already holds the new version, so
  matching `>=` keeps summary aligned with detail's acceptance; when
  detail rejects, `>=` aligns summary with detail's rejection.
- Exported `isNewerVersionedPatch(patchVersion, currentVersion)` as a
  pure helper so the rule is unit-testable in isolation.
- Test: `useSSE.test.ts` pins the 4 cases (newer ✓ / older ✗ /
  same-version ✗ / first-write currentVersion=0 ✓).

Hub-side `applySessionPatch` does NOT need the same gate: in-process
events from `handleUpdateMetadata` / `handleUpdateState` are emitted
only AFTER the optimistic-concurrency check at the store layer
succeeds, and `syncEngine.handleRealtimeEvent` consumes them
synchronously in order. The vulnerability is the SSE
reconnect/replay window on the web client.

Co-authored-by: Cursor <cursoragent@cursor.com>
iridite pushed a commit to iridite/hapi that referenced this pull request Jun 20, 2026
…ummary

Closes PR tiann#897 post-rebase bot review (HAPI Bot, 2026-06-18):

Major — structured patches dropped session.updatedAt. TodoWrite,
teamState, metadata, and agentState DB writes all touch sessions.updated_at,
but the fast path forwarded only field deltas. Hub/web caches and session
list ordering stayed stale until a full refresh. All four emit-sites in
sessionHandlers now reload the stored row after a successful write and
include updatedAt in the SSE patch payload (applySessionPatch already
applies it via Math.max).

Minor — agentState summary patches updated pendingRequestsCount/kinds but
left pendingRequests stale, so SessionAttentionIndicator tooltips showed
old request tools after an SSE patch. patchSessionSummary now uses
computePendingRequestsCount + computePendingRequests alongside the
existing kinds helper.

Tests: sessionHandlers.test.ts asserts updatedAt on todos/metadata/agentState
patches.

Co-authored-by: Cursor <cursoragent@cursor.com>
iridite pushed a commit to iridite/hapi that referenced this pull request Jun 20, 2026
Closes PR tiann#897 bot Minor (2026-06-18): field-by-field patchSessionDetail
stopped copying serviceTier after the spread refactor, so Codex Fast/
Standard could show stale tier until a full refetch. Mirror nullable
hasOwnProperty handling in patchSessionDetail and hub applySessionPatch.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf(hub,web): emit structured patches for session todos/teamState/metadata/agentState changes (closes the second half of #884)

1 participant