Agent pane: a chat opened in a new tab or after a relaunch draws its kept tail from disk

Merged
TE
1d ago
+319-12

Leo, 2026-11-05: "there's stuff opening up subagents or opening up other sessions. Those need to be fast. There's no reason for them to have a delay, even under load."

This builds on #36226, which makes the subagent pane draw its transcript in the pane's first frame. That PR leaves sidebar opens and reopens cold. This PR covers part of that gap: a chat opened in a new tab from the sidebar or from a link, when the app holds nothing of it in memory. That is the case for every chat after a relaunch, local chats included.

What was slow

AgentPaneTailCache keeps what each chat's page last read, so the next tab that opens the chat draws it before acpmux's attach answers. The cache was memory only. After a relaunch it was empty. A miss went to acpmux's peer snapshot, which only peer chats have. So a local chat opened after a relaunch drew nothing until its attach answered, and under load that took about 160 ms longer than reopening a chat the app still held.

The fix

  • Disk tier under the memory tier (AgentPaneTailDisk). Each tail the cache keeps is also saved to <launch snapshot folder>/agent-chat-tails/<sha256(sessionId)>.json.
    • Saves run on one serial utility queue, com.cmuxterm.app.next.agent-pane.tail-disk, which is registered in threading-model.md.
    • Saves of one chat coalesce to the newest one.
    • All file access goes through the checked blocking seams. Nothing runs on the main thread.
  • chat.tail.get on a memory miss reads the disk tier, off the main thread, before asking acpmux for a peer snapshot. A tail read from disk is then held in memory as well.
    • The read is fenced by the cache's sign-out generation, so a read from before a sign-out cannot answer after it.
    • A newer tail handed over while the read ran wins over the disk copy.
  • No webview change. direct.ts already asks the host for the tail before every cold open and every chat switch (readHostTail), so the disk tier serves both paths.
  • Bounds:
    • At most 20 files, the same number as the memory tier. The least recently written file goes first.
    • At most 1 MiB per file, the memory tier's per-chat bound. The page sends at most 256 KiB.
    • A file over the bound, or one that doesn't decode as a tail, is removed and read as none.
    • File names are hashes, so a session id never names a path.
  • Sign-out (AgentPaneTailCache.clear()) removes the folder along with the memory tier.

Cost, following Leo's speed/memory rule: at most about 20 MiB of disk, and in practice far less (ordinary chats are a few KiB to tens of KiB). One coalesced utility-QoS write per tail hand-over (when a chat closes, or when a page reads ahead on hover). No main-thread work beyond the hand-off that already existed.

The folder comes from AgentPaneScrollAnchors.shared's file, beside the launch snapshot, so CmuxNextApp gains no lines.

Red / green

The red commit 075cb865 holds the tests and a stub that keeps nothing. The fix is 84977d2d. 7f1c6a31 corrects one assertion in the test file only (it compared URLs with and without a trailing slash).

  • cmux-ci run --class light --script scripts/ci/run-swift-testing-suites.sh --arg=Packages/macOS/CmuxNext --ref <sha> --env CMUX_SWIFT_TEST_SUITES=ChatTailDiskTests,ChatTailCacheTests
    • Red at 075cb865: ChatTailDiskTests fails four behaviour tests, each on its own expectation:
      • aChatAnEarlierLaunchReadAnswersFromDisk: chat.tail.get answers nothing.
      • aHandedOverTailIsSavedForTheNextLaunch: nothing is saved.
      • theNewestSaveWins: nothing is saved.
      • theDiskIsBounded: no file is written.
    • ChatTailCacheTests passes, 14/14.
    • Green at 7f1c6a31: 2 suites passed, 0 failed (ChatTailDiskTests 6/6, ChatTailCacheTests 14/14).

Performance

scripts/measure/chat-open.sh gets a new case, relaunched: read a chat, leave it, quit, relaunch, then open it again in a new workspace. The bench script is the same on both sides:

Each side ran 10 opens per case. Idle is light; load is STRESS: CPU spinners on every core, disk writers and metadata walks. The numbers are ms from the action to the first frame of chat content (debug.chat_open_trace).

caseconditionbefore p50 / p95after p50 / p95
relaunchedlight (idle)757 / 790779 / 877
relaunchedSTRESS (load)999 / 1095890 / 982
reopen (memory hit, control)light (idle)737 / 785792 / 821
reopen (memory hit, control)STRESS (load)840 / 935849 / 946

The two sides ran on different minis: before on one (load average 12 idle), after on another (load average 7 idle). The controlled number is the gap between relaunched and reopen within the same run:

gap at p50beforeafter
light+20 ms−13 ms
STRESS+159 ms+40 ms

So a chat opened after a relaunch now opens as fast as one the app still held, and that difference shows under load. 0 flashes on both sides.

What this doesn't fix. Every one of these opens spends about 600 ms making a new Chromium page: pane_made → browser_created → committed → first_contentful_paint → pane_painted. The trace marks show spare_unready and no spare adopted. The prewarmed spare page is still off for existing chats (AgentPaneSpareReadiness.adoptsExistingChats = false, HQ#5914). #36226 turns it on for subagent panes only. Bringing sidebar opens to the first frame needs the same adoption for them, with its tail carried in the adopt event as #36226 does. That is the next step.

Not in this PR

  • _acpmux/watch bound. The daemon already pages a whole list (server/list_reply.rs), but at REPLY_BYTES (30 MiB), so the 24 MB list seen after Leo's restart goes in one message. AgentTurnStateFeed also ignores nextCursor on its watch page. That's a separate daemon + app change, a smaller page budget plus following the cursor, and it is a follow-up.

Observed / Not run

  • Observed: the fleet Swift suites red and green above, and the before/after bench above (artifact steps 868905ec5307b7d1ee6701ac and 714e39c547436e6b4cfa87a0).
  • python3 scripts/cmux-next/prepush-gate.py: no new violations.
  • Not run: the bench's Python unit tests (scripts/cmux-next/tests/test_chat_open_bench.py). The new case is opt-in (--cases=relaunched) and not in the default list.
  • Not run: a same-mini before/after.
  • Not run: the app compile beyond the package build the suites ran.
  • No capture: no visible UI change. The chat draws the same rows, earlier.

Changelog

Fixed: a chat opened in a new tab after cmux NEXT relaunched draws its last-read messages before it reconnects, instead of a blank pane.

🤖 Generated with Claude Code

Agent session: agent session


Autofix with [code]smith
Open in [code]smith · Need help on this PR? Tag @codesmith-bot with what you need.


Summary by cubic

Makes a chat opened in a new tab from the sidebar or a link, including after a relaunch, draw its last-read tail from disk instead of opening blank while it waits on acpmux's attach.

AgentPaneTailCache was memory only, so after a relaunch a local chat (which has no peer snapshot) had nothing to draw and opened about 160 ms slower than one the app still held. Each kept tail is now also saved by AgentPaneTailDisk on a serial utility queue, and chat.tail.get reads the disk tier on a memory miss before asking acpmux for a peer snapshot. No webview change is needed: the page already asks for the tail before every cold open and chat switch.

  • Saves of one chat coalesce to the newest; reads wait only behind the saves queued before them, all off the main thread.
  • Bounded like the memory tier: at most 20 files of 1 MiB each, least recently written trimmed first; files over the bound or not decoding as a tail are removed. File names are hashes of the session id.
  • Sign-out clears the disk tier with the memory one.
  • Under load, the p50 gap between opening a chat after relaunch and reopening one still held drops from +159 ms to +40 ms. The bench script gains a relaunched case.