Skip to content

Chats: acpmux owns the chat list; the sidebar history holds only its window (P0 stall) - #28055

Merged
lawrencecchen merged 12 commits into
feat-cmux-nextfrom
fix/stall-chats-store-delta
Oct 31, 2026
Merged

lawrencecchen merged 12 commits into
feat-cmux-nextfrom
fix/stall-chats-store-delta

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Cause (base measurement, not a gain of this PR). Lawrence's v2.2.9-opt debug.hangs (2026-10-30) show main-thread hangs of 1,148, 730, 656 and 570 ms in the chat index mirror:

  • AcpmuxChatsStore.apply (Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxChatsStore.swift:93, values[key] = chat) copied the whole [String: AcpmuxChat] dictionary for each chat change, because SidebarChatHistoryFeed.chats held the same copy-on-write storage (ChatsFeed+Start.swift:29).
  • The SidebarChatHistoryFeed.chats setter (SidebarChatHistoryFeed.swift:22) ran the derived AcpmuxChatsStore.== over every chat and freed the old copy.

Fix (P-RUST, Lawrence 2026-10-30: "move as much as possible into Rust"): acpmux owns the chat list that a client shows.

  • Rust: _acpmux/chats_window {window, filter, limit, topLevel, exclude, keys} answers the window's rows, sorted, filtered and paged by acpmux. After each index or chat activity change, _acpmux/chats_window_changed sends the next revision, the window's keys in order and only the rows whose value changed. A window is calculated again from the index as it is now (level-triggered). _acpmux/chat_projects groups the index into each harness's project folders. The connection's window task is owned (R6): it ends with the last window, the connection or the runtime.
  • Swift: AcpmuxChatWindows keeps only the visible rows of each window (value snapshots) and applies a diff in O(window keys + changed rows). A revision gap, an unknown row or _acpmux/lagged asks for the window again, once. The sidebar chat history (Earlier and Pinned) reads two windows. Its whole-index setter is deleted. ProjectsImport relays acpmux's grouped projects and takes no mirror snapshot.
  • plans/cmux-next/layer-ownership.md rules 1-3 and L7: Rust owns every state, list, sort, filter, search and rule. Swift is only the platform shell, and never a relay or a mirror for data that a webview shows.

What is measured. For one chat change, the main-thread work goes from a copy of all N chats plus a deep == over N to O(changed rows). Measured as counts (rows touched, not ms) by the red job fe50d4f83638994ca7517f0d and the green job 6479f2dce62c06698cfa9745 (ChatsWindowMainThreadTests, a 3,000-chat index). No ms before/after is measured yet. When the Austin's-day harness gives a debug.hangs result for the chats sites, it goes in a follow-up comment.

Step 2 (follow-up in this series, R7): the palette chats page, Open Chat, cookie import and the New Tab cards still read the private mirror of ChatsFeed. No other object holds it, so a change applies in place. The ChatsFeed mirror is deleted in #28090.

Changelog

Changed: The sidebar's chat history gets its rows from acpmux, which sorts and filters the list; the app keeps only the rows it shows.

Testing

At head 7cca9aa (merge of feat-cmux-next 98f84fb into 2e0e343). The red commits are the red test b6d2aef cherry-picked onto the base (proof/28055-chats-window-red-98f84fb, proof/28055-chats-window-red-fe8bef7).

  • Rust window: cmux-tui-rust-check.sh test three_thousand_chats.
    • Red on the merge base 98f84fb + test (814fb35): FAIL on its own assertion, "Method not found: _acpmux/chats_window" (job 6a3c3b9d9fbbde5ad39bfc98). Also red on fe8bef7 + test (a3f5bf1, job bc7f21491e46c8967d6d6bc3). Each job ran the test 2 times (one rerun alone); every attempt failed the same way.
    • Green on the head: PASS (job 81e2a6b26dafec20dcf16c99).
  • Swift: package-test-lane.sh suite Packages/macOS/CmuxNext ChatsWindowMainThreadTests.
  • Compile (--package CmuxNext --app --recipe-version 2) of the head: CmuxNext, app and cmux-tui passed, no errors in the log (job 616a151e91e340b5964be06f).
  • Earlier heads: Rust red 1bc0568c5166c48e22cd6dd9 / green a306b4817142e8b5832222f9, R6 red 5bc005704f543058bdccb0fd / green dc205f17ef07c5da5b4d7362, Swift red fe50d4f83638994ca7517f0d / green 6479f2dce62c06698cfa9745.
  • Review: SHIP at the head, Chats: acpmux owns the chat list; the sidebar history holds only its window (P0 stall) #28055 (comment) (3 low findings, follow-ups).

Observed / Not run

Observed: I read the logs of every job above. The two reds failed on their own assertions; the greens passed; the compile passed. The only merge conflict was the acpmux Conn struct (both new fields kept).
Not run: a GUI load with many chats (ms before/after); the Austin's-day harness will give it. R6 (chats_window_owned) was not run again at this head. Web lint on push: the webviews checks bun run check:design-tokens, bun run check:layers, vp fmt --check and vp lint failed only in webviews/src/pages/settings (from #25592). They are red on base fe8bef7, and this PR does not touch those files. The push used CMUX_WEB_LINT_SKIP_REASON with that check list and base sha.

No capture: no visible UI change (the same rows, from a different source).

… rule; Swift is the platform shell

Rules 1-3 and L7: a client asks Rust for its visible window and gets row
diffs with a revision; a native Swift list holds only its visible rows as
value snapshots; Swift is never a relay or a mirror for data a webview shows
(Lawrence 2026-10-30, P-RUST).
…hange; main holds only the window

Rust: acpmux answers _acpmux/chats_window over 3,000 chats with the rows
sorted and filtered, and one change is a diff of one row with the next
revision. Swift: after one chat change among 3,000, the main thread holds
the sidebar history's window, not the whole index (v2.2.9 hangs of 570 to
1,148 ms in AcpmuxChatsStore.apply and SidebarChatHistoryFeed.chats).
…w, never the whole index

v2.2.9 hangs of 570 to 1,148 ms (Lawrence, debug.hangs 2026-10-30):
AcpmuxChatsStore.apply copied every chat because SidebarChatHistoryFeed.chats
held the same storage, and that setter ran the derived == over every chat and
freed the old copy, all on the main thread.

Rust (acpmux) now owns the list a client shows (P-RUST, layer-ownership.md
rules 1-3 and L7):
- _acpmux/chats_window {window, filter, limit, topLevel, exclude, keys}: the
  window's rows, sorted, filtered and paged by acpmux; then, per index or chat
  activity change, _acpmux/chats_window_changed with the next revision, the
  window's keys in order and only the rows whose value changed. Level-triggered:
  each window is recomputed from the index as it is now (chats/window.rs,
  server/chat_windows.rs).
- _acpmux/chat_projects: each harness's project folders grouped by acpmux.

Swift:
- AcpmuxChatWindows holds only each window's visible rows, as value snapshots,
  and applies a diff in O(window keys + changed rows); a revision gap, a
  missing row or _acpmux/lagged asks for the window again (once).
- The sidebar chat history (Earlier and Pinned) reads two windows; its whole-
  index setter is gone.
- ProjectsImport relays acpmux's grouped projects instead of taking a snapshot
  of the mirror (which made the next main-actor change copy every chat).
- ChatsFeed's remaining mirror (palette chats page, Open Chat, cookie import,
  New Tab cards) is private to it: no other holder, so a change applies in
  place. Its removal is P-RUST step 2.

Tests: chats_window.rs (3,000 chats: sorted, filtered window; one change is
one row; a change outside the filter sends nothing; chat_projects groups),
window.rs unit tests, ChatsWindowMainThreadTests (one change among 3,000
touches one row on main; missed diffs ask again once).
…d state

The guardrails count observations and main hops per file: the specs and the
state now come from one ObservationStream, as before the windows.
@lawrencecchen lawrencecchen added the risk:normal Risk tier (docs/ci/risk-tiers.md) label Oct 30, 2026
@github-actions

github-actions Bot commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

cmux status (one comment, edited in place; each line is updated by the job that owns it)

Review 7cca9aa: HQ feed card · gallery before/after runs · checks

App of this head (app.zip, kept 1 day)

gh workflow run reload-build.yml -R manaflow-ai/cmux --ref feat-cmux-next -f tag=pr28055 -f ref=fix/stall-chats-store-delta -f platform=macos -f backend_mode=local

Gallery before/after for 7cca9aa: 16 states rendered, 0 images differ, 2554 more touched states not rendered. Images are in the run's artifact, not on this public PR.

gh run download 41156193141 -R manaflow-ai/cmux -n gallery-advisory-28055-7cca9aa2618f712f033a6192309e7c6387e896bb (then read gallery-review.json)

States
State Theme Status Diff Picked by
agent-pane.live-chat-watch/choice-alone Apple System Colors Light unchanged 0% imports methods.ts (1 hops)
agent-pane.live-chat-watch/choice-alone Apple System Colors unchanged 0% imports methods.ts (1 hops)
agent-pane.live-chat-watch/view-only Apple System Colors Light unchanged 0% imports methods.ts (1 hops)
agent-pane.live-chat-watch/view-only Apple System Colors unchanged 0% imports methods.ts (1 hops)
agent-pane.auth-health/signed-in Apple System Colors Light unchanged 0% imports methods.ts (2 hops)
agent-pane.auth-health/signed-in Apple System Colors unchanged 0% imports methods.ts (2 hops)
agent-pane.auth-health/signed-out Apple System Colors Light unchanged 0% imports methods.ts (2 hops)
agent-pane.auth-health/signed-out Apple System Colors unchanged 0% imports methods.ts (2 hops)
agent-pane.auth-health/expired Apple System Colors Light unchanged 0% imports methods.ts (2 hops)
agent-pane.auth-health/expired Apple System Colors unchanged 0% imports methods.ts (2 hops)
agent-pane.auth-health/unknown Apple System Colors Light unchanged 0% imports methods.ts (2 hops)
agent-pane.auth-health/unknown Apple System Colors unchanged 0% imports methods.ts (2 hops)
agent-pane.chat-width/standard Apple System Colors Light unchanged 0% imports methods.ts (2 hops)
agent-pane.chat-width/standard Apple System Colors unchanged 0% imports methods.ts (2 hops)
agent-pane.chat-width/wide Apple System Colors Light unchanged 0% imports methods.ts (2 hops)
agent-pane.chat-width/wide Apple System Colors unchanged 0% imports methods.ts (2 hops)
agent-pane.chat-width/wide-blocks Apple System Colors Light unchanged 0% imports methods.ts (2 hops)
agent-pane.chat-width/wide-blocks Apple System Colors unchanged 0% imports methods.ts (2 hops)
agent-pane.composer-edge/short-edge Apple System Colors Light unchanged 0% imports methods.ts (2 hops)
agent-pane.composer-edge/short-edge Apple System Colors unchanged 0% imports methods.ts (2 hops)
agent-pane.composer-edge/wide-edge Apple System Colors Light unchanged 0% imports methods.ts (2 hops)
agent-pane.composer-edge/wide-edge Apple System Colors unchanged 0% imports methods.ts (2 hops)
agent-pane.composer-edge/no-edge Apple System Colors Light unchanged 0% imports methods.ts (2 hops)
agent-pane.composer-edge/no-edge Apple System Colors unchanged 0% imports methods.ts (2 hops)
agent-pane.composer-floats/scrolled-to-end Apple System Colors Light unchanged 0% imports methods.ts (2 hops)
agent-pane.composer-floats/scrolled-to-end Apple System Colors unchanged 0% imports methods.ts (2 hops)
agent-pane.composer-floats/composer-grown Apple System Colors Light unchanged 0% imports methods.ts (2 hops)
agent-pane.composer-floats/composer-grown Apple System Colors unchanged 0% imports methods.ts (2 hops)
agent-pane.continuation-spacing/shipped Apple System Colors Light unchanged 0% imports methods.ts (2 hops)
agent-pane.continuation-spacing/shipped Apple System Colors unchanged 0% imports methods.ts (2 hops)
agent-pane.continuation-spacing/two-thirds Apple System Colors Light unchanged 0% imports methods.ts (2 hops)
agent-pane.continuation-spacing/two-thirds Apple System Colors unchanged 0% imports methods.ts (2 hops)

Pick other states with a Gallery: entry.id, entry.id/variant line in the PR body.

Not verified: macOS jobs did not run: compile and app tests were skipped on 7cca9aa261; ci-status is green without them.

CI passes on 7cca9aa261 (run 41161238482 attempt 1).
macOS jobs did not run: compile and app tests were skipped: Fast static checks skipped (the macos job needs both to succeed). Re-run CI once they pass, before merging.

Written by scripts/ci/classify_failures.py (ci-failure-attribution.yml); signatures are its SIGNATURES table. A machine verdict is the runner's fault, not this PR's; yours means the failing file is one this PR changes, also red on main that main's latest full suite fails the same way, seen on other PRs that it failed on another pull request's run lately.

web and size checks: green

Default changes

This pull request changes 1 default at 7cca9aa2618f (what everyone gets without configuring anything):

  • tunable-source.CmuxNextAgentLanes/SidebarChatHistoryFeed.swift:state: "de0095b237f759159ad951553a49c7f63076464f84536151f82a247471c30338" -> "7b4670dd8d5aca2be507282b056aaee300e852748a55687a80c8e91692f88534"

Signed off on this commit by review (independent review subagent, Claude Opus).

Merge receipt: merged as 9962e57684c3 with these defaults changed, signed off by review (independent review subagent, Claude Opus).

Regenerate the snapshot with python3 scripts/ci/defaults_guard.py snapshot.

Shipped in:

…nd its connection (R6)

chats_window_owned.rs counts live window tasks (a process-wide drop-guard count).
Before the fix the task runs until the connection closes, so it outlives the
client's last chats_window_close.
…d its connection (R6)

The connection's one chats-window task ends when the client closes its last
window (a later open starts a new one), when the connection closes, and with
the runtime at daemon exit. Its running flag is set and cleared under the
windows lock, so an open never misses a task that is ending.
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

For PL-acpmux (owner of acpmux), what this PR changes in acpmux:

New methods (local unix socket only, like every _acpmux/chat* method; listed in spec/acpmux-protocol.json and the generated web types):

  • _acpmux/chats_window {window, query?, harness?, folder?, account?, limit?, topLevel?, exclude?, keys?} opens a client's window over the chat index or changes its spec. It answers {window, ready}. The rows come as _acpmux/chats_window_changed.
  • _acpmux/chats_window_changed {window, revision, full, keys, rows, hasMore} (notification): the first message is full. Each later index or chat activity change sends the window's keys in order and only the rows whose value changed, with revision + 1. A window is computed again from the index as it is now.
  • _acpmux/chats_window_close {window}.
  • _acpmux/chat_projects: {ready, enabled, projects: [{harness, path, lastUsedMs}]}, each harness's project folders, grouped in acpmux.

Files:

  • cmux-tui/crates/acpmux/src/chats/window.rs (new, pure: spec, rows, diff state; unit tests)
  • cmux-tui/crates/acpmux/src/server/chat_windows.rs (new: one owned task per connection; it ends with the last window, the connection or the runtime)
  • cmux-tui/crates/acpmux/src/chats/mod.rs (ChatService::window_rows, ChatService::projects), src/chats/activity.rs (ChatActivity::has_parent)
  • cmux-tui/crates/acpmux/src/server/chats.rs (routes; push_activity also wakes the connection's windows), src/server/mod.rs (Conn.chat_windows), src/protocol/mod.rs, src/protocol/tests.rs, spec/acpmux-protocol.json
  • Tests: cmux-tui/crates/acpmux/tests/chats_window.rs, tests/chats_window_owned.rs

_acpmux/chats_watch and _acpmux/chats have no change. The Mac app still uses chats_watch until step 2 deletes its mirror.

@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Swift proofs (read from the step logs):

  • Red at b6d2aef, job fe50d4f83638994ca7517f0d: FAILED on the row-count check: ChatsWindowMainThreadTests.swift:32: (held → 3000) <= 21, "the main thread holds 3000 chats for a window of 20". 1 test, 1 issue; no compile error.
  • Green at 77cd2dd, job 6479f2dce62c06698cfa9745: ChatsWindowMainThreadTests 3/3 passed (oneChatChangeAmongThreeThousandTouchesOneRowOnMain, aMissedDiffAsksForTheWindowAgainOnce, anUnchangedSpecSendsNothingAndAReconnectAsksAgain); SidebarChatHistoryTests 14/14 passed. No skipped test.
    Measured as counts (rows held and touched on main), not ms.

@lawrencecchen

Copy link
Copy Markdown
Contributor Author

All proofs, read from the step logs:

What Red (base of the fix) Green
Rust window over 3,000 chats 1bc0568c5166c48e22cd6dd9 at b6d2aef: FAILED, Method not found: _acpmux/chats_window (chats_window.rs:97) a306b4817142e8b5832222f9 at 77cd2dd: PASS
Rust window task owned (R6) 5bc005704f543058bdccb0fd at 1b09dc3: FAILED, chats_window_owned.rs:159 "a chat window task outlived the last window closed" dc205f17ef07c5da5b4d7362 at 29d8e4a: PASS, both a_window_task_ends_with_its_last_window_and_with_its_connection and a_window_over_three_thousand_chats_is_sorted_filtered_and_one_change_is_one_row (5 run, 5 passed)
Swift main-thread rows fe50d4f83638994ca7517f0d at b6d2aef: FAILED, (held → 3000) <= 21 6479f2dce62c06698cfa9745 at 77cd2dd: ChatsWindowMainThreadTests 3/3, SidebarChatHistoryTests 14/14

Compile (--app --package CmuxNext --recipe-version 2): 77cd2dd e4bbd14825b7dfab35768ac0 ok; head 29d8e4a 33a8fea6437eed628efb16b4 ok (CmuxNext, app, cmux-tui). The commits after 77cd2dd change only Rust.

…ux runs the requests in

Review F1 of #28055: the daemon runs each request as its own task, so two
specs of one window sent back to back may apply out of order, and the client
then accepts the older spec's full message. Rust: 20 rounds of five specs in
one burst must end on the last (limit 5); an older spec or close is refused as
stale. Swift: each spec names a newer generation, and a message for an older
one is not applied.
…28055

F1: the daemon runs each request as its own task, so two specs of one window
sent back to back could apply out of order and leave the window on the older
spec. Each open and close now names the window's generation (`gen`); acpmux
keeps the highest per window, refuses an older one as `stale` and echoes
`gen` in every message, and the client applies only its current spec's rows.
F4: the windows lock is no longer held across an index read: rows are read off
the executor, and each unchanged spec takes its diff under the lock (one
recompute at a time, an async lock); an activity change takes no lock.
F5: the batches waiting when a recompute starts are drained first, so one
recompute follows a burst.
F8: a recompute that fails sends `_acpmux/chats_lagged`, so the client asks
again.
F3: an error answer to a window request names its window (JSON-RPC id); the
window is asked again at the next index change or reconnect, not left waiting.
F7: an `_acpmux/lagged` for a message too large is not answered by asking again
(it would loop).
F2: `_acpmux/chat_projects` has no app reply cap (one row per chat folder); a
failed read is logged and relayed again at the next index change (F6).
F10: AcpmuxChatsStore.newestFirst and Window.hasMore (no readers) removed.
F9 (one copy of the mirror per page) is left for step 2, which deletes it.
lawrencecchen added a commit that referenced this pull request Oct 30, 2026
…ux runs the requests in

Review F1 of #28055: the daemon runs each request as its own task, so two
specs of one window sent back to back may apply out of order, and the client
then accepts the older spec's full message. Rust: 20 rounds of five specs in
one burst must end on the last (limit 5); an older spec or close is refused as
stale. Swift: each spec names a newer generation, and a message for an older
one is not applied.
lawrencecchen added a commit that referenced this pull request Oct 30, 2026
…28055

F1: the daemon runs each request as its own task, so two specs of one window
sent back to back could apply out of order and leave the window on the older
spec. Each open and close now names the window's generation (`gen`); acpmux
keeps the highest per window, refuses an older one as `stale` and echoes
`gen` in every message, and the client applies only its current spec's rows.
F4: the windows lock is no longer held across an index read: rows are read off
the executor, and each unchanged spec takes its diff under the lock (one
recompute at a time, an async lock); an activity change takes no lock.
F5: the batches waiting when a recompute starts are drained first, so one
recompute follows a burst.
F8: a recompute that fails sends `_acpmux/chats_lagged`, so the client asks
again.
F3: an error answer to a window request names its window (JSON-RPC id); the
window is asked again at the next index change or reconnect, not left waiting.
F7: an `_acpmux/lagged` for a message too large is not answered by asking again
(it would loop).
F2: `_acpmux/chat_projects` has no app reply cap (one row per chat folder); a
failed read is logged and relayed again at the next index change (F6).
F10: AcpmuxChatsStore.newestFirst and Window.hasMore (no readers) removed.
F9 (one copy of the mirror per page) is left for step 2, which deletes it.
@blacksmith-sh

blacksmith-sh Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Found 6 test failures on Blacksmith runners:

Failures

Test View Logs
main.DeveloperIdAppTests.test_download_prints_the_active_profile_only/
test_download_prints_the_active_profile_only
View Logs
stubs: /System/Library/Frameworks/Security.framework/Versions/A/Security has no stub/
Security has no stub
View Logs
stubs: CoreFoundation.tbd: install-name /System/Library/Frameworks/
CoreFoundation.framework/Versions/A/CoreFoundation, missing 1: ['_CFRetain']/
CoreFoundation, missing 1: ['_CFRetain']
View Logs
stubs: CoreFoundation.tbd: install-name /System/Library/Frameworks/
CoreFoundation.framework/Versions/A/CoreFoundation, missing 1: ['_log2']/
CoreFoundation, missing 1: ['_log2']
View Logs
test_every_listed_hook_is_one_glaeda_released_with_the_check (main.SigningHooksInSt
epWithGlaedaTests.test_every_listed_hook_is_one_glaeda_released_with_the_check)/
test_every_listed_hook_is_one_glaeda_released_with_the_check (main.SigningHooksInSt
epWithGlaedaTests.test_every_listed_hook_is_one_glaeda_released_with_the_check)
View Logs
test_the_ten_first_party_gpl_crates_are_named_exactly (main.AppCrateLicenseGate.tes
t_the_ten_first_party_gpl_crates_are_named_exactly)/
test_the_ten_first_party_gpl_crates_are_named_exactly (main.AppCrateLicenseGate.tes
t_the_ten_first_party_gpl_crates_are_named_exactly)
View Logs

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

@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Review (independent review subagent, Claude Opus): SHIP at 0bb76eb
R1: pass Each window has one recompute path (refresh) for open, index change and activity change; no second implementation.
R5: pass No timer or delay decides correctness; a failed request is retried on the next event (index change, page, lag, reconnect).
R6: pass The window task has one owner and ends with its connection, its last window or the runtime (chats_window_owned.rs, red 5bc00570 / green dc205f17, 8db9d3b0).
R7: pass The native lists use only the Rust window path; the remaining ChatsFeed mirror is deleted in #28090 (stacked on this PR); no switch keeps two paths.
R9: pass chat_projects has no app reply cap (the daemon's frame limit holds) and its failures are logged.
perf claim: measured 6479f2dce62c06698cfa9745 (counts, not ms)

Round 1 at 29d8e4a was FIX FIRST (F1 out-of-order window specs, F2 reply cap); both are fixed at this head. Proofs at this head: compile 6682213462e5f8fabf70a6d3 ok; Rust F1 red 4df558fb (at 7800991) / green 279802c5; Rust window + ownership 8db9d3b0 5/5; Swift F1 red a9cd168a (at 7800991) / green 2a7a40b9 (ChatsWindowMainThreadTests 5/5, SidebarChatHistoryTests 14/14).
Non-blocking notes for a follow-up: N1 the lag on a failed refresh is sent before the windows clear; N2 a window message over 15 MiB is not shown as lost; N3 a refused window is retried only on an index change, with no budget; N4 an answer dropped by an overflow leaves its id in windowRequests; N5 the docs do not name gen and stale; N6 the per-connection generations map has no bound; N7 the 5 s projects deadline is fixed; earlier code: an error answer to a page request leaves pageInFlight set.

# Conflicts:
#	cmux-tui/crates/acpmux/src/server/mod.rs
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Review (independent review subagent, Claude Opus): SHIP at 7cca9aa

No blocking findings. The merge resolution is correct, and I found no semantic conflict between feat's new code and the PR.

Findings

None block the merge. Three low-severity notes. Each can be a follow-up:

  1. Doc and cost mismatch in window::rows (cmux-tui/crates/acpmux/src/chats/window.rs:216-236). The doc says "one walk of the index in its order ... that stops once the window is full". But ChatIndex::chats() (cmux-tui/crates/cmux-chat-index/src/index.rs:175) collects and sorts the whole index first (O(n log n)). This runs under the index lock, for each window, on each refresh, and a refresh comes after each index or activity change. It runs in spawn_blocking and does not block the main thread, so it is only a CPU cost. Suggested fix: correct the comment, or add a sorted iterator over the index that stops early.
  2. A full message that cannot decode loops with no backoff (Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxChatWindows.swift:177-180). On a full message, awaitingFull is cleared first. If a key in keys has no row that decodes (AcpmuxChat.init(json:) returns nil, for example when sessionId is empty), ask(spec) sends again at once. acpmux then sends the same full message, and the loop continues. I found no path where today's chat_value gives a row that does not decode, so this is latent. Suggested fix: on a full message, drop the keys whose rows did not decode. Do not ask again.
  3. ProjectsImport opens a new acpmux connection for each publish (Packages/macOS/CmuxNext/Sources/CmuxNextApp/Projects/ProjectsImport.swift:106-114). Each ChatsFeed publish, including activity-only changes, does connect, initialize and _acpmux/chat_projects, and Rust groups the whole index for that request. sending/sendAgain serializes the requests, and none of the work is on the main thread. The cost is a socket round trip and an index walk for each burst. Suggested fix: relay only on index changes (seq advanced), not on activity changes.

What I checked

  • Merge resolution. git show --cc 7cca9aa2618: Conn keeps both chat_windows: chat_windows::ConnWindows and cancel_refusals: Arc<requests::cancel_refused::Waiting>. The only Conn { initializer (server/mod.rs:1433) sets both with Default::default(). The session_list_pages_tests.rs Conn is a different type. OnboardingHandlers.swift combined its imports correctly: feat removed CmuxNextAccounts and the PR added CmuxNextAgentLanes. methods.ts keeps feat's _acpmux/message.
  • True merge base. git merge-base HEAD origin/feat-cmux-next is 98f84fb96a8, one commit after fe8bef7c44cf. The difference is acpmux: a DEV build's daemon stops once its app is gone, also when its binary is rebuilt (carries #23279) #27801 (dev_orphan.rs, daemon.rs, Cargo.toml, dev_orphan_exit.rs, hermetic_daemon_guard.rs), which is feat's code. The PR's own diff is 98f84fb96a8..HEAD: 31 files.
  • Rust APIs that feat changed since e8630779176c. when_chats_ready (it runs its waiters inside the tokio context of start_chats, so the waiter's tokio::spawn is safe), chat_activity, ChatService::subscribe, ChatQuery (it derives PartialEq, Eq for WindowSpec), ChatIndex::chats/get, Conn::closed/send, RpcError::{internal,invalid_params}, serve_connection_with, ChatSources fields, list_reply::scope. All are compatible. The new methods begin with _acpmux/chat, so they go through chats::route and only the local socket serves them. The registry, spec/acpmux-protocol.json (same order, no duplicate names) and methods.ts all list them. protocol/tests.rs includes chat_windows.rs. No new test calls the acpmux binary, so hermetic_daemon_guard does not apply to them.
  • Swift: removed and moved APIs. git grep finds no remaining use of AcpmuxChatsStore.newestFirst, SidebarChatHistoryFeed.chats, or the old ProjectsImport.batches([AcpmuxChat], …) / batches(of:). Each source file that names ChatsFeed or reads services.chatsFeed members imports CmuxNextAgentLanes. The two files that do not are AgentRecentsFeed.swift and DebugTimings.swift, and they name it only in comments. MemberImportVisibility is not enabled. The CmuxNextAgentLanes target already depends on CmuxNextAgentActivity, CmuxNextAgentPane and CmuxNextWakeups. feat's change to Backoff (fullJitter) is additive. AcpmuxStatusClient.call(…, deadline:, limit:) matches the call in AcpmuxChatProjects.read. The tests use @testable imports for Inputs and state(earlier:pinned:inputs:). The auto-merged lines from feat in ChatsFeed+Start.swift, ChatsOpenCoordinator.swift and AppServices.swift do not touch the moved API.
  • Protocol between Rust and Swift. Both sides use the same names: window, gen, revision, full, keys, rows. Swift sends gen as UInt64 and decodes it as NSNumber. Window answers contain window, so ChatsFeed.Message decodes them as .answered, not as a page. Error answers to chats_window go to requestFailed, which retries at the next index change. _acpmux/lagged and chats_lagged, and a drain that overflowed, make Swift resync every window. message_too_large is ignored and does not cause a loop.
  • Generation and revision. Rust keeps the highest gen for each window across opens and closes and refuses an older one as stale. Swift increments gen for each open and each close and drops any message whose gen is not the current one. Each Rust message for a window gets the next revision, also when the spec is replaced. Swift accepts any full and applies a diff only at revision + 1. A gap triggers one ask until the full message arrives. A resync sends the same gen again, Rust accepts it, and it gets a full message. A reconnect sets revisions back to 0, and each new connection has new Rust state.
  • Races and task ownership in chat_windows.rs. The refreshing async mutex serializes recomputes. A recompute skips a window whose spec changed while it ran, and the open for the new spec recomputes it next. task is set and cleared under the windows lock, so an open never misses a task that is ending. The task ends at connection close, at the last close, or when the broadcast closes. Live counts it out on every path. No MutexGuard is held across an .await. The waiter of the not-ready path checks conn.out.is_closed().
  • Main thread. AcpmuxChatWindows.apply is O(keys + changed rows). Rows decode on the socket queue. SidebarChatHistoryFeed keeps only the rows of the two windows. Its observation loop changes only state that is not observed (specs, generations). close sets the rows to [], and this runs only while the setting is off. ProjectsImport does the socket read in @concurrent code.
  • Repository files. main-actor-work-baseline.json, perf-hot-benches.json and the protocol spec are valid JSON. The baseline entry moved with ChatsFeed. The perf-hot marker has its bench entry.

Not run

I read the code and did not build or run it. I ran no Swift or Rust build and no tests on this machine (repository rule). A separate fleet compile of this head is running, and this receipt does not show that it passed.

@lawrencecchen lawrencecchen added ready-to-land Reviewed at this head; next-autoland merges it into feat-cmux-next when its only reds are base reds and removed ready-to-land Reviewed at this head; next-autoland merges it into feat-cmux-next when its only reds are base reds labels Oct 31, 2026
@lawrencecchen
lawrencecchen merged commit 9962e57 into feat-cmux-next Oct 31, 2026
247 of 268 checks passed
@lawrencecchen
lawrencecchen deleted the fix/stall-chats-store-delta branch October 31, 2026 02:44
teamleaderleo added a commit that referenced this pull request Oct 31, 2026
Conflicts with #28055 (chats_window, chat_projects) in chats/mod.rs and
server/chats.rs: both sides kept. Protocol IR re-blessed, methods.ts
regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent-Session: https://cmux.com/deeplink/agent?id=aid-80fea2c04bdd31e52544
lawrencecchen added a commit that referenced this pull request Oct 31, 2026
#28055 fixed the chat index stall the layer-ownership way (acpmux owns
the list, the sidebar holds a window), and 643cd1a reverted this
crate's app FFI link. With no consumer, and as a client-side mirror that
layer-ownership.md rule 2 forbids, the crate goes (aaa3118,
123cfaa). The audit's plan now targets daemon windows and intents,
with FFI only for pure optimistic reducers; item 1 is P-RUST step 2
(remove ChatsFeed's private mirror).
lawrencecchen added a commit that referenced this pull request Oct 31, 2026
Conflicts came from #28055 (step 1) landing as a squash: for every
add/add file feat's version equals step 1's, so the PR's step-2 version
stands (ChatsFeed, AcpmuxChatWindows, ChatsWindowMainThreadTests,
chats/window.rs, server/chats.rs, tests/chats_window.rs; AcpmuxChatsStore
stays deleted). server/mod.rs takes feat (the PR does not change it).
AgentActivityLane keeps both the PR's preview and feat's usageWarnings.
OnboardingHandlers keeps feat's shape (team.invite moved to
home.inviteToTeam on feat) with the PR's AgentLaneStore lookup.
lawrencecchen added a commit that referenced this pull request Nov 1, 2026
…pp passes its target, socket and connection

The merge of feat (acpmux groups the projects, #28055) with this PR's retry
backoff left CmuxNextApp 86 lines over its ratchet (arch-ratchet app_lines).
ProjectsImport now knows no app type: AppServices passes the local daemon as a
Target (Target(daemon:)), the acpmux socket path and the connection it follows.
Behavior is unchanged; ProjectsImportRetryTests builds it with the same fake
target, a nil socket and a nil connection.
lawrencecchen added a commit that referenced this pull request Nov 1, 2026
…mux (step 2 of #28055) (#28090)

* docs(layer-ownership): Rust owns every list, sort, filter, search and rule; Swift is the platform shell

Rules 1-3 and L7: a client asks Rust for its visible window and gets row
diffs with a revision; a native Swift list holds only its visible rows as
value snapshots; Swift is never a relay or a mirror for data a webview shows
(Lawrence 2026-10-30, P-RUST).

* test(chats): red: a chats window over 3,000 chats sends one row per change; main holds only the window

Rust: acpmux answers _acpmux/chats_window over 3,000 chats with the rows
sorted and filtered, and one change is a diff of one row with the next
revision. Swift: after one chat change among 3,000, the main thread holds
the sidebar history's window, not the whole index (v2.2.9 hangs of 570 to
1,148 ms in AcpmuxChatsStore.apply and SidebarChatHistoryFeed.chats).

* fix(chats): acpmux owns the chat list: native lists hold a Rust window, never the whole index

v2.2.9 hangs of 570 to 1,148 ms (Lawrence, debug.hangs 2026-10-30):
AcpmuxChatsStore.apply copied every chat because SidebarChatHistoryFeed.chats
held the same storage, and that setter ran the derived == over every chat and
freed the old copy, all on the main thread.

Rust (acpmux) now owns the list a client shows (P-RUST, layer-ownership.md
rules 1-3 and L7):
- _acpmux/chats_window {window, filter, limit, topLevel, exclude, keys}: the
  window's rows, sorted, filtered and paged by acpmux; then, per index or chat
  activity change, _acpmux/chats_window_changed with the next revision, the
  window's keys in order and only the rows whose value changed. Level-triggered:
  each window is recomputed from the index as it is now (chats/window.rs,
  server/chat_windows.rs).
- _acpmux/chat_projects: each harness's project folders grouped by acpmux.

Swift:
- AcpmuxChatWindows holds only each window's visible rows, as value snapshots,
  and applies a diff in O(window keys + changed rows); a revision gap, a
  missing row or _acpmux/lagged asks for the window again (once).
- The sidebar chat history (Earlier and Pinned) reads two windows; its whole-
  index setter is gone.
- ProjectsImport relays acpmux's grouped projects instead of taking a snapshot
  of the mirror (which made the next main-actor change copy every chat).
- ChatsFeed's remaining mirror (palette chats page, Open Chat, cookie import,
  New Tab cards) is private to it: no other holder, so a change applies in
  place. Its removal is P-RUST step 2.

Tests: chats_window.rs (3,000 chats: sorted, filtered window; one change is
one row; a change outside the filter sends nothing; chat_projects groups),
window.rs unit tests, ChatsWindowMainThreadTests (one change among 3,000
touches one row on main; missed diffs ask again once).

* fix(chats): one observation for the sidebar history's window specs and state

The guardrails count observations and main hops per file: the specs and the
state now come from one ObservationStream, as before the windows.

* test(chats): red: a chats window task must end with its last window and its connection (R6)

chats_window_owned.rs counts live window tasks (a process-wide drop-guard count).
Before the fix the task runs until the connection closes, so it outlives the
client's last chats_window_close.

* fix(chats): the window task is owned: it ends with its last window and its connection (R6)

The connection's one chats-window task ends when the client closes its last
window (a later open starts a new one), when the connection closes, and with
the runtime at daemon exit. Its running flag is set and cleared under the
windows lock, so an open never misses a task that is ending.

* test(chats): red: the last window spec sent wins, whatever order acpmux runs the requests in

Review F1 of #28055: the daemon runs each request as its own task, so two
specs of one window sent back to back may apply out of order, and the client
then accepts the older spec's full message. Rust: 20 rounds of five specs in
one burst must end on the last (limit 5); an older spec or close is refused as
stale. Swift: each spec names a newer generation, and a message for an older
one is not applied.

* fix(chats): window specs carry a generation; review fixes F2-F10 of #28055

F1: the daemon runs each request as its own task, so two specs of one window
sent back to back could apply out of order and leave the window on the older
spec. Each open and close now names the window's generation (`gen`); acpmux
keeps the highest per window, refuses an older one as `stale` and echoes
`gen` in every message, and the client applies only its current spec's rows.
F4: the windows lock is no longer held across an index read: rows are read off
the executor, and each unchanged spec takes its diff under the lock (one
recompute at a time, an async lock); an activity change takes no lock.
F5: the batches waiting when a recompute starts are drained first, so one
recompute follows a burst.
F8: a recompute that fails sends `_acpmux/chats_lagged`, so the client asks
again.
F3: an error answer to a window request names its window (JSON-RPC id); the
window is asked again at the next index change or reconnect, not left waiting.
F7: an `_acpmux/lagged` for a message too large is not answered by asking again
(it would loop).
F2: `_acpmux/chat_projects` has no app reply cap (one row per chat folder); a
failed read is logged and relayed again at the next index change (F6).
F10: AcpmuxChatsStore.newestFirst and Window.hasMore (no readers) removed.
F9 (one copy of the mirror per page) is left for step 2, which deletes it.

* test(chats): red: no chat of the index reaches main; chat_open names its chat

Step 2 of P-RUST (R7): the app's Chats feed must hold no copy of acpmux's
index. ChatsFeedHoldsNoIndexTests: a 3,000-chat watch page and one index
change reach the main actor with no AcpmuxChat in the message (today: 3,000
and 1). chats_open.rs: _acpmux/chat_open also answers the chat itself, so
Open Chat names it without the mirror.

* fix(chats): delete the ChatsFeed index mirror; every reader asks acpmux (P-RUST step 2, R7)

Step 2 of #28055: the app holds no copy of acpmux's chat index. The mirror
(AcpmuxChatsStore) is deleted with its last readers:
- New Tab cards: a window over the index (`newtab.cards`, the newest 8
  top-level chats); only that window's messages push to the pages.
- Command palette chats page: a window (`palette.chats`) whose spec carries
  the query; acpmux searches its own index (title or folder) and answers its
  newest 200 matches, then their changes while the page shows. The window
  closes with the page. No main-actor task: the rows arrive as window
  messages.
- Open Chat: `_acpmux/chat_open` answers the chat with its plan; a running
  chat is found by its key (`harness:sessionId`).
- Cookie import's requester: the caller's acpmux session lane by id, O(1)
  (the lane now carries the summary's `preview`).
- ChatsFeed keeps the one connection, the windows and an index-change signal
  for the projects relay; the watch asks for a one-chat page, and its lines
  reach the main actor with no chat in them. This also removes review F9 of
  #28055 (one copy of the mirror per page).

Behavior change: the palette's chats page lists acpmux's newest 200 matches
of title or folder per query, instead of every chat filtered in the app by
title, key, harness, folder, accounts and roots.

* test(chats): red: the palette chats query stream ends after acpmux's first answer

Review finding 1 of #28090: the page's queryResults stream never ended, so the
palette spinner ran for as long as the page showed and an empty answer never
showed the empty state.

* fix(chats): the palette chats query stream ends after acpmux's first answer

Review finding 1 of #28090: the palette runs its spinner until a query's
stream ends; the stream now ends after acpmux's first answer for the query,
so an empty answer shows the empty state. The window stays open until the
page closes; an edit opens the next query's spec. Also removes the unused
AgentPaneChatsPage.read (finding 3) and a stale test comment.

* test(chats): red: a query window ranks like the palette and reports how many matched

No-regression gate (lead, review of #28090): the palette's chats page found a
chat by its harness ("codex", "claude") and by words that are not next to
each other, with the palette ranker; the window's substring search found
neither. 30 Claude chats: "claude" finds them all (total 30, five shown,
hasMore), "fix build" finds the 15 whose title holds both words.

* test(chats): red: the palette chats page names the matches past its page

No-regression gate (review of #28090): acpmux reports how many chats matched;
when more match than the page shows, the page's last row says so and runs
nothing. The spec also sends the page's untitled word, so acpmux finds an
untitled chat by it.

* fix(chats): a query window ranks with the palette's ranker and reports the total (no regression)

Review of #28090 under the no-regression gate: the palette's chats page found a
chat by its harness ("codex") and by words anywhere in its title or folder,
with the palette ranker; the window's substring search found neither.

- acpmux: a window with a `query` ranks every chat its filter and exclusions
  pass with cmux-palette-ranker (the ranker the palette itself runs through
  its C ABI): the same entry shape the page built (title or the client's
  `untitled` word, the folder with ~ as subtitle, the harness and folder as
  keywords), best match first; it sends the first `limit` (200) and `total`,
  how many matched. A window with no query is unchanged (newest first).
- Swift: the spec sends `untitled`; a window keeps `total`; the palette page
  adds a last, disabled row "The best 200 of N matching chats. Type more to
  narrow." when more match than it shows (all nine macOS locales).

Not the same as before: no frecency (the page passed none it had for chats),
and the ranker runs over the chats' metadata in acpmux, off the main thread.

* refactor(chats): ChatsFeed moves out of CmuxNextApp into CmuxNextAgentLanes (SW14)

The perf train refused the port of this fix: app_lines rose (plans/cmux-next/
code-quality.md SW14; feat reported +18). ChatsFeed is the acpmux chat index
connection: it moves next to AgentTurnStateFeed, its sibling watch, in
CmuxNextAgentLanes. Its App-only debug timing is injected through init (no
static hook). The App files that name the feed import CmuxNextAgentLanes. No
behavior change.

* chore(chats): the main-actor work baseline follows ChatsFeed's move

* fix(chats): the palette chats page's answers take an open closure, not AppServices

Swift green 461f8727 at ff90606: AgentChatsPaletteAnswerTests built a whole
AppServices in a test process and trapped at AppServices.swift:87 (an
implicitly unwrapped value set only in a running app). The page's answers need
only the windows and the Open Chat path, so they take an open closure; the
page passes services.chatsOpener. The tests pass a no-op. No behavior change.

* test(chats): the palette stream-end test reads two answers, so a stream with no end fails, not hangs

The red proof of the stream-end fix needs a failure on the assertion: with no
end, the second read gets acpmux's second message instead of nil.

* fix(chats): a closure with no parameters takes no `in` (compile b8576df3: AgentChatsPalettePage.swift:86:60 expected expression)

* test(chats): red: a query window ranks past choices first; a counted window reports its total

Lead (no-regression gate, #28090): the palette ranked a chat the person chose
before above one with an equal match (frecency), and the empty query must say
how many more chats there are. 30 chats titled alike: the oldest, chosen
lately, ranks first for "fix build"; a no-query window with count: true
reports total 30.

* fix(chats): the palette chats window ranks with the person's choice history and counts the empty query (no regression)

Lead decisions on #28090 (no-regression gate):
- Frecency: the palette ranked a chat the person chose before above an equal
  match. The page now sends its choice history of chats with each query
  (FrecencyStore.rankerHistory: the palette ranker's own {now, frecency}
  payload, only `chat:` keys and the picks that begin the query); acpmux gives
  each chat the frecency key `chat:<key>` (the palette row's id, which the
  palette records on a choice) and ranks with it, so the same history gives
  the same order as before.
- Empty query: a window with `count` reports the total of chats it could show
  (counted without building values past the page); the page's last row says
  "N more chats. Type to search." (plural, all macOS locales).
- Relative-date text ("yesterday") is a known difference (PR body,
  follow-up 2).

* test(chats): red: the "N more chats" row picks each language's plural form

Lead (review of #28090): the row's string was formatted with String(format:)
over the catalog's value, and its test compared the function with itself, so
it could never fail. AgentChatsMoreChatsPluralsTests reads English (1, 2,
2,999), French (1, 2) and Russian (1, 3, 5) from their tables. moreChats takes
a bundle and locale so a test can pick a language; its body is unchanged.
Also: the stream-end test gets a one-minute .timeLimit (a stream with no end
fails at once), and the empty-query test asserts the count in the shown text.

* fix(chats): the "N more chats" row uses the plural pattern; acpmux parses the history once

Lead (review of #28090): moreChats puts the count in the localized default
value ("\(more) more chats. Type to search."), the repo's plural pattern, so
Foundation picks each language's form from the catalog (all locales as
before). acpmux parses a window spec's history once when it reads the spec
(two specs stay equal by the text the client sent), not at each recompute.

* Chats palette page lives in CmuxNextAgentLanes; the App only wires it

The page (its acpmux window query, rows and notices) and its strings move out of
CmuxNextApp into CmuxNextAgentLanes, next to ChatsFeed. The App builds it from
AppServices: the feed's windows, the palette's choice history and Open Chat.
The New Tab cards' window spec moves next to AcpmuxChatWindows.Spec.

* Cookie import requester reads the lane store AppServices holds, not AgentLaneStore.shared

AppServices holds this Mac's acpmux session lanes and passes them to the
turn-state watch it starts; the requester looks the caller's session up there.
The palette page tests import the page's new module.

* test(palette): Search and the Chats & agents scope list the chats acpmux answers (red)

Review of #28090 at 63702fa (F1): the chats page answers each query through acpmux
(queryResults) and has no providers, and Search reads a contributed page only through its
providers, so Search and the Chats & agents scope listed no chats. The suite opens both with
the real AgentChatsPalettePage, contributed as PaletteSourcesBridge contributes it.

* fix(palette): Search and the Chats & agents scope ask a contributed page that answers its own queries

A contributed page with queryResults (the agent chats page asks acpmux for each query) had no
providers, and Search and the Chats & agents scope read contributions only through providers,
so neither listed a chat. They now ask such a page for each typed query (the Chats scope also
for the empty one) through one shared path, PaletteContributedAnswers: the answer lands in a
later turn under the contribution's section beside the ranked rows, a new query cancels the old
stream, and leaving the page closes the page's source (acpmux's window). An answer never holds
the rows from being final, so Return never waits on acpmux. sidebar-search.md says so.

* bench(palette): --search-keys times typed keys on Search (Cmd-Shift-A), the palette-key path

palette-open-stages.py timed only palette opens. With --search-keys WORD it opens Search from the
terminal tab and types WORD a key at a time through debug.key (target palette): each key's synchronous
round trip (the main thread's whole handling of the key, PaletteModel.search's turn included) as
p50/p95/max, and the longest main-thread stall while typing. Report only; no CI.

* test(palette): contributed answers keep the new query's rows, the selection and the chats scope's Return (red)

Review 2 of #28090 (issuecomment-6542219906): an answer that lands before the new query's rank
showed the old ranked rows as final, so a waiting Return ran an old row (F1); a late chats block went
above the selected best row and dropped out at each key (F2); in the Chats & agents scope a Return
before acpmux answered ran a session or nothing (F3); and after a reconnect an unchanged window
counted as answered with the old connection's rows.

* fix(palette): a contributed answer never stands for another query's rows, keeps the selected row in view, and leads the Chats scope

Review 2 of #28090 (issuecomment-6542219906):
- F1: an answer shows again with the ranked rows only when they are the current query's
  (rankedGeneration); one that lands first waits for the rank, whose delivery adds it.
- F2: an answer that does not lead goes after the best ranked section (where the ranker put the
  best row), capped to the rows the page's 400-row limit leaves; a new query keeps the old answer
  until its own replaces it; when rows above the selected one grow, the list scrolls to it.
- F3: in the Chats & agents scope the chats lead the page and hold its query's final mark until they
  answer (the rows stay provisional), so a Return waits for the best chat, as on the chats page.
- F4: the page is asked in a later turn than the key's; the chats' choice history is encoded off the
  main thread (FrecencyStore.encodedRankerHistory) and ChatsFeed encodes each request on the
  connection's serial queue (in order), so search() does no JSON work.
- AcpmuxChatWindows.isAnswered: a window asked again (reconnect, lag, broken diff) is not answered
  until its full answer comes.

* fix(palette): the chats' choice history is built off the main actor (nonisolated rankerHistory)

FrecencyStore+RankerHistory sits under the package's main-actor default isolation, so the detached
task in encodedRankerHistory could not call rankerHistory (fleet compile 336252fce1b5). The builder
reads only its Sendable store value and the nonisolated bridge payload types.

* bench(palette): time each typed query to the frames that show its rows and its contributed answer, on STRESS with seeded chats

palette-open-stages.py --search-keys timed only the debug.key round trip, which ends before the
later-turn work. Two always-on signposts (CmuxNextSignposts, key category, Release too, not mirrored to
MetricKit): key.palette_query_rows, the search field's edit to the commit of the frame that first shows
the query's final ranked rows, and key.palette_query_answer, to the one that first shows a contributed
answer for it (end message rows=<n>). The palette's view ends them at the commit of the frame it applies.
The bench reads them with perf-signposts.py's log stream recording and reports key rpc, key-to-rows-frame
and key-to-answer-frame as p50/p95/p99/max with the answers that landed, seeds the app's acpmux store with
--seed-chats N chats (idle_cost_load) and builds the STRESS session with --stress. Report only; no CI.

* test(palette): an old query's chats are stand-ins, a short query keeps the best chats, no answer never holds Return (red)

Review 3 of #28090 (issuecomment-6543785296): F1 an old query's chat ran on Return under a query with
no ranked rows, and stayed under later queries while acpmux did not answer; F2 a one-letter query that
filled the page's 400 rows showed no chats; M1 the Chats & agents scope's Return waited without end when
acpmux did not answer; M2 a refused request after a reconnect counted as answered.

* fix(palette): an older query's answer is a stand-in only; chats keep a share of a full Search page; no answer never holds Return

Review 3 of #28090 (issuecomment-6543785296):
- F1: each answer section records the query it answers. An older query's answer shows only as a
  stand-in: navigation never selects it (the reducer's rows and the keyboard moves leave it out), and
  it is never the default row or final. While an answer is awaited and the query has no ranked row, the
  rows are not final, so Return waits for the current answer; an answer that ends empty (refused, not
  connected, no match) takes the stand-ins off at once.
- F2: an answer that does not lead shows its best 10 rows in Search, taken from the weakest ranked
  rows when the 400-row page is full, so a one-letter query still shows the best chats.
- M1: the chats page's answer ends at once with no rows when acpmux is not connected or refuses the
  request (AcpmuxChatWindows refusal followers, isConnected, isRefused); a request in flight on a
  dropped connection counts as refused. Any wait is bounded at 1.5 s.
- M2: isAnswered is false while acpmux refused the window's request.
- M3: sidebar-search.md says what runs on the main thread and when Return waits.

* test(palette): the no-acpmux test's expectations compile (a closure, not an ambiguous key path in #expect)

* bench(palette): palette-open-stages.py prints why it stopped (the finally block's exit hid the exception) and logs the STRESS build

* test(palette): Search's chats share is a minimum with a more row; a stale refusal, a not-ready index and a reconnect (red)

Review 4 of #28090 (issuecomment-6546769110): F1 Search cut every chats answer to 10 rows with no row
saying more match (30 chats on a page with room showed 10); note 2 a ready:false answer left Return on a
timer; note 3 a refusal of an older spec ended the current query; note 4 End stopped on a stand-in on a
full page (selectableRows compared counts); note 6 the chats page did not ask again when acpmux came.
The seams these call (PalettePageSpec.moreRow and onSourceChange, AcpmuxChatWindows.requestFailed(_:generation:),
requestNotReady, followAvailability) are inert here.

* fix(palette): Search's chats share is a minimum with a more row; no timer: not-ready ends the wait, a stale refusal is ignored, a reconnect asks again

Review 4 of #28090 (issuecomment-6546769110):
- F1: a non-leading answer gets every row the ranked rows leave under the 400-row page and at least
  10, taken from the weakest ranked rows. A cut answer ends with its page's more row
  (PalettePageSpec.moreRow): the chats page's says how many more chats match (the cut rows plus those past
  acpmux's 200) and opens the Agent Chats page with the same query. The page's own notice rows stay
  while nothing is cut. New string handlers.agentChats.moreInSearch, 21 locales.
- Note 2: no hold timer. acpmux's ready:false answer ends the query's answer at once (requestNotReady),
  as a refusal does; every request ends with rows, none, or the query moving on.
- Note 3: ChatsFeed keeps each window request's generation; requestFailed and requestNotReady ignore an
  older spec's answer.
- Note 4: selectableRows filters by row id, so End and Down never stop on a stand-in on a full page.
- Note 5: follow and followRefusal drop the entries of dead owners as they add one.
- Note 6: PalettePageSpec.onSourceChange: the chats page asks the shown query again when acpmux connects
  or its not-ready index fills the window (AcpmuxChatWindows.followAvailability); the subscription lives
  while the page is open.
- Note 7: the PaletteContributedAnswers doc and sidebar-search.md say when an answer holds the rows.

* test(palette): the more-row test waits for the opened page without settling on its query; End must move off the first row

* bench(palette): palette-open-stages.py --vdisplay HZ runs on a 1920x1200 virtual main display, so STRESS's 1600x1000 window fits (cmux23's 1920x1080 display left 963 pt)

* bench(palette): palette-open-stages.py --search-keys asks the app's own acpmux for the typed word and prints its index state and matches (why no chats answer landed)

* test(palette): an empty answer delivers nothing; nav rows stay on screen with stand-ins; the more row keeps its count; ChatsFeed not-ready and generation (red)

Review 5 of #28090 (issuecomment-6556223157): F2 an empty answer ran two full deliveries (list reload and
reducer pass) that changed nothing; note 3 with stand-ins on a full page the selectable list kept ranked rows
the shown list had cut; note 2 the more row read the live window, which the pushed page's cancel closes;
note 4 ChatsFeed's not-ready and stale decoding and the generation it keeps had no test.
ChatsFeed.windowRequest(method:params:) is an inert seam here.

* fix(palette): an answer that changes nothing delivers nothing; one cut for shown and selectable; the more row counts from the answer

Review 5 of #28090 (issuecomment-6556223157):
- F2: answer changes are redelivered once per main-thread turn (scheduleRedelivery), and only when what a
  delivery would show or hold changed (answerFingerprint vs the last delivery's): an empty answer with no
  stand-ins costs no list reload and no reducer pass; a yield and the stream's end no longer deliver twice.
- Note 3: the selectable rows are the shown rows without the stand-in sections, so both cut the ranked rows
  at the same row and the selection is never off screen.
- Note 2: the chats page records how many matches each answer left past its page (PastPageCounts) and the
  more row reads that, not the live window, which the pushed page's cancel closes.
- Note 4: ChatsFeed.windowRequest(method:params:) is the one place a request's window and generation are kept.
- Note 1: the awaitedAnswers doc no longer names the deleted holdLimit.

* bench(palette): seed chats where acpmux's chat index reads them; check rows before typing; count chat rows per key

Review 5 F1: --seed-chats wrote acpmux's own session store, which the chat index never scans, so no answer
landed on either side. It now writes Claude Code transcripts in <HOME>/.claude/projects (a default root of
cmux-chat-index's roots table) of a HOME the app gets of its own under /tmp; the account's ~/.claude is never
touched. Before typing, the bench asks the app's acpmux for the word until the index answers with rows, and
fails the run otherwise. After each key it reads the chat rows on screen (debug.palette.capture rows:true, a
DEBUG and bench-hooks route), so the base, whose chats are ranked rows, is measured on the same check:
key.palette_query_rows is its frame that first shows chat rows.

* bench(palette): the per-key chat-row count reads debug.palette.capture's rows (one App line, no new route branch)

* refactor(palette): PaletteModel.rankerHistory(keyPrefix:query:) encodes the choice history; the App's chats page only wires it

* test(palette): split #require from the call of its result (the type checker crashed on the chained form, signal 6)

* test(palette): explicit closure types in AgentChatsPaletteAnswerTests (Swift 6.3.3 type-checker crash)

The merge-head compile 5d75c0a66d245630f25fea38 crashed swift-frontend 6.3.3 (signal 6,
ConstraintSystem.cpp:4138 recordArgumentList) in theMoreRowKeepsItsCountAfterTheWindowCloses.
Every windows.connected closure states (String, [String: any Sendable]), and the heterogeneous
row literal's map closure states (Int) -> [String: Any]. Same assertions.

* test(palette): an answer with the same rows retitled reaches the screen (red)

Review 6 of #28090: the skip of a no-change delivery compared only answer row ids, so a re-answer
with the same chats under new titles was dropped.

* fix(palette): skip a redelivery only when what it shows and holds is the same

Review 6 of #28090: the answer fingerprint held the raw awaited set, so an empty answer ending under
ranked rows still reloaded the list and ran the reducer (anEmptyAnswerDeliversNothing failed, step
9e19c1b1878a14e485b60be6). It now holds whether the rows are held (one shared rule, isHeld, which
joined() uses too) and each answer row as it shows, so a retitled chat with the same id still reaches
the screen. Builds on the fork's 7e82205 (bench/pr28090-fp-fix).

This branch was previously deployed

3 inactive deployments
artifacts — 7cca9aa2 Deployed Oct 31, 2026 by lawrencecchen via add the Windows daemon to the tree #86437
sentry-dsyms — 7cca9aa2 Deployed Oct 31, 2026 by lawrencecchen via upload cmux-tui dSYMs to Sentry #86437
ci-routing-agent — 7cca9aa2 Deployed Oct 31, 2026 by lawrencecchen via cmux-tui tree preflight #86437
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-to-land Reviewed at this head; next-autoland merges it into feat-cmux-next when its only reds are base reds risk:normal Risk tier (docs/ci/risk-tiers.md)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant