perf(hub,web): emit structured patches for session todos/teamState/metadata/agentState writes (closes #895, second half of #884)#897
Conversation
There was a problem hiding this comment.
Findings
- [Major] TeamDelete patches no longer clear cached team state —
applyTeamStateDeltareturnsnullforTeamDelete, but this new payload converts it toundefinedathub/src/socket/handlers/cli/sessionHandlers.ts:135. The DB row is cleared bysetSessionTeamState(..., null, ...), while the new fast path treats{ teamState: undefined }as an applied patch and never clearssession.teamStatein 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
teamStatepatch path.
Testing
- Not run (automation);
bunis not available on PATH in this runner.
HAPI Bot
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>
|
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):
Regression tests:
|
There was a problem hiding this comment.
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 cachedmetadataVersion/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 requiringincoming.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);
bunis not installed on PATH in this runner.
HAPI Bot
…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>
|
Addressed in 1f84514 - thanks, you're right about the reconnect-window vulnerability. Mirrored the hub CLI room handler's
Hub side: I considered adding the same gate to
|
There was a problem hiding this comment.
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
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>
…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>
1f84514 to
291bef2
Compare
There was a problem hiding this comment.
Findings
- [Major] Structured patches drop the session timestamp —
update-metadata,update-state, TodoWrite, and team-state writes all still updatesessions.updated_at, but the new fast path forwards only the field delta, so hub/web caches no longer learn the newupdatedAt. 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
pendingRequestsstale — the summary cache updatespendingRequestsCountandpendingRequestKinds, but not the cappedpendingRequestsarray 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/useSSEpatch application.
Testing
- Not run (review only; PR content not executed).
HAPI Bot
…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>
There was a problem hiding this comment.
Findings
- [Minor] Service tier patches are dropped by the new manual detail patcher —
SessionPatchSchemastill acceptsserviceTier, andapplySessionConfig()emits a full session after/api/sessions/:id/service-tier, but the refactor from spreading...patchto field-by-field assignment no longer copies that field into the detail cache. After changing Codex fast mode,props.session.serviceTiercan 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:
bunis not installed in this runner (/bin/bash: bun: command not found).
HAPI Bot
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>
There was a problem hiding this comment.
Findings
- None.
Questions
- None.
Summary
- Review mode: follow-up after new commits
- No issues found in the latest head. The prior
serviceTiercache 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):
bunis not installed in this runner (/bin/bash: line 1: bun: command not found).
HAPI Bot
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>
…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>
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>
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.tshas four CLI emit-sites that firesession-updatedSSE events with nodatapayload when todos / teamState / metadata / agentState change. On the web side,useSSE.tsparses missing/emptydataas "no patch I can apply" and falls through to per-session REST invalidation (queueSessionDetailInvalidation+queueSessionListInvalidationatweb/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:
shared/src/schemas.ts— extendsSessionPatchSchemawith optionaltodos/teamState/metadata/agentStatefields. Versioned wrappers ({ version, value }) formetadataandagentStatemirror the existing socket.ioupdate-sessionbroadcast shape so downstream caches can reject stale patches.hub/src/socket/handlers/cli/sessionHandlers.ts— the four emit-sites now carry structured payload data instead of firingdata: undefined. Web hitsgetSessionPatch's truthy branch and patches the React Query cache in place; no REST refetch.hub/src/sync/sessionCache.ts— newapplySessionPatch()that mirrors the patch into the in-memory cache (no DB re-read). KeepsNotificationHub.getSession, sidebar dedup, and other in-process consumers coherent with what just landed in the DB. Returnsfalsefor empty{}patches and cross-namespace mismatches so the caller falls back to the legacy refresh path safely.hub/src/sync/syncEngine.ts—handleRealtimeEventfast-path: if the event has data andapplySessionPatchsucceeds, forward the patch as-is withoutrefreshSession's DB read + full-Session SSE broadcast. Falls back to legacy refresh on empty/unparseable patches.web/src/hooks/useSSE.ts—patchSessionDetailandpatchSessionSummaryunwrap the versionedmetadata/agentStatewrappers before assigning to the cached Session. Without unwrapping,session.metadatawould silently become{ version, value }and corrupt every consumer that readsmetadata.flavor,metadata.cursorSessionId, etc.Subtle bug fixed in commit 2
applySessionPatchMUTATES the cached Session in place (reassignssession.metadata = patch.metadata.value).handleRealtimeEventhas a dedup-on-agent-session-id-change trigger that comparesbefore.metadatavsafter.metadata— both reads were going throughsessionCache.getSession(id)which returns the SAME object reference, so the mutation overwrotebefore.metadataandhasSameAgentSessionIdsalways returned true. Dedup silently never fired on the fast path.The legacy
refreshSessionpath 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
applySessionPatchruns and use the snapshot for both branches so the comparison contract is identical. Three regression tests inhub/src/sync/syncEngineHandleRealtimeEvent.test.ts— verified they FAIL on the un-fixed code and PASS on the fixed.Commits
05147a6a— schema extension + emit-sites + cache patch path + web unwrap + tests.eaa0281f— snapshot metadata before mutation; regression tests for dedup fast-path.f261a790— pure cosmetic reorder ofSessionPatchSchemafield order so soup-merge with the codex-usage-indicator fork branch (which adds a flatmetadatafield 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
SessionPatchSchemais.strict(). Older web clients connecting to a newer hub will fail to parse patches carrying the newtodos/teamState/ wrappedmetadata/ wrappedagentStatefields 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 typecheckclean 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.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.ts—toSessionSummaryMetadataextraction tests.:3006. Will report the before/afterjournalctl -u hapi-hub.serviceREST 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
staleTimefor navigation thrash). Non-overlapping diff; this PR is independent.Made with Cursor