conversation-update-error.tsfix: close the stale-state holes in the AOP-async and mail perf changes Review follow-ups on the detached AOP refresh and the three caches it shipped alongside. Each one leaves state that outlives the thing it describes. Orphaned execution rows. The refresh row is the only signal telling the client whether the agent is still working, and three paths could strand it in `executing` forever: handleConversationRefresh adopted `existingRunId` after its first await, so an early throw closed nothing; handleChangeAopForConversation discarded the handler's return value, and that handler reports failure by returning rather than throwing; and a deploy mid-refresh killed the work with no one to close the row. Fixed by adopting the runId up front, adding a `refreshError` channel that does not fold into `success` (the AOP change itself did commit), and draining in-flight refreshes from gracefulShutdown. Connection-record cache. Its own header said every writer of the token columns must invalidate; `clearInvalidConnectionTokens` and `resetConnection` did not, and no delete path did at all — so "Disconnect mailbox" kept serving that row's OAuth tokens for the rest of the TTL. Covered those, made the generic `updateConnection` setter invalidate unconditionally, froze the cached row (every hit shares one reference), and rewrote the header to state exactly what is and is not covered, and that it is per-process rather than fleet-wide. Skipped authorization guard. Passing `ctx.activeConnection` as `record` bypassed getConnectionRecord, and with it the only place ConnectionNotAuthorizedError is raised — which queue.ts classifies as fatal. That context is not guaranteed to carry tokens: getActiveConnection validates them on the default-connection branch but its findFirstConnection fallback does not. Take the shortcut only for a record that would have passed anyway. Gmail freshness probe. Its catch sat inside withGmailCall, eating errors before the rate-limit bookkeeping ran, so a 429 on the probe never tripped the per-mailbox breaker and the caller then spent a second request on the full get. Moved the swallow outside, and added the probe to IDEMPOTENT_GMAIL_OPERATIONS — it was the only pure read in the driver getting maxAttempts=1. Also: the update-error toast classified any message containing 502/503/504 as a dropped connection, telling users a real server rejection "may have saved". Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 10, 2026, 12:39 PMpending-conversation-field-writes.tsperf(crm): give conversation rows their own event projection A row draws a dot timeline and three "last <type> at" columns, and nothing else — but crm.listConversations was building up to seven nested sub-objects per event through eight LEFT JOINs to feed it. Rows now get five scalars and a flat threadId, ~3x smaller per event, so the cap can move 15 -> 60 and draw three times the history for no more bytes: measured on a 50-row page, 547KB over 641 events before, 538KB over 1,919 now. That cap was also quietly wrong. "Last meeting" was derived by scanning whatever events the list happened to ship, so a deal whose last meeting fell outside its 15 most recent rendered blank — 53 of the 304 conversations with a meeting, in the org this was measured on — while SORTING by the same field used an unbounded MAX(occurred_at) and placed it somewhere the column contradicted. The three columns now read scalars computed over the whole history, from the same expressions the ORDER BY builder already used. Splitting the projections needed guards the single shared type had been hiding: - setConversations takes an explicit `projection`. Both queries write one store key and replace wholesale, so a list refetch would otherwise empty the timeline of whatever deal was open — the same bug that previously just shrank it to 15 (client-data-architecture.md §1.2). - getEventType/getEventTitle read `eventType` rather than sniffing which sub-object came back from a join, which equated "this join returned nothing" with "this is a note" and would have greyed out every dot. - a dot click opens EventDetailDialog on an id and fetches the full event, since the row no longer carries one. Also carries the in-flight pending-conversation-field-writes mask and its tests: it edits the same setConversations body, so it cannot be split out. Co-Authored-By: Claude Opus 5 <<email>>Sep 2, 2026, 11:01 PM