perf(tui): refresh architecture v2 — adaptive tick + generation skip (#1753) - #1765
perf(tui): refresh architecture v2 — adaptive tick + generation skip (#1753)#1765asheshgoplani wants to merge 2 commits into
Conversation
…ck (#1753) The refresh tick did work proportional to ALL live sessions. #1756 fixed the render half; this fixes the status sweep, which is the structurally wrong shape: it re-derives state for sessions that provably did not change, using the most expensive mechanism available. Three facts compose badly. Only livePipeLRUCapacity (3) + attached sessions hold a control pipe, so the sweep's existing "PipeManager saw no output for 5s" skip covers ~4 rows out of 70. Without a pipe, CapturePane is a `tmux capture-pane` subprocess serialized by the tmux server. And the most common steady state on a large deck — a Claude session parked in hook "waiting" — takes the hook fast path, which calls BackgroundWorkPending() and therefore captures the pane every bgWorkCacheTTL (3s). At ~60 quiescent sessions that is tens of subprocesses per sweep to re-derive statuses identical to the ones already on screen. Adds an adaptive gate (internal/ui/refresh_policy.go). A per-session fingerprint is built entirely from caches the sweep already refreshed once — window_activity, pane title/command/dead, hook UpdatedAt, SSE UpdatedAt — so building it for the whole deck is a handful of map lookups and spawns nothing. UpdateStatus is skipped only when EVERY one of these holds: the row is off-screen, the status is running/waiting/idle, the fingerprint is usable and identical to the last polled one, and fewer than maxSkips consecutive skips have happened. Any veto polls; nothing is ever skipped on absent information. Unchanged window_activity as proof of "no new pane output" is not a new assumption — Session.GetStatus already gates its CapturePane on exactly that comparison, and UpdateStatus gates its idle tier on it. The gate is strictly more conservative than both, because those skip for as long as activity is unchanged while this one is bounded: after maxSkips consecutive skips a session is polled regardless, so the time-based transitions inside UpdateStatus (acknowledged -> idle, hook-freshness expiry, debounce confirmation) are delayed by at most maxSkips sweeps rather than suppressed. In practice they are not delayed at all: anything that actually happens moves the fingerprint on the very next sweep (Stop hook -> hookAt, output -> activity, spinner -> title, exit -> dead, vanished session -> unusable fingerprint -> forced poll). Viewport publication is O(visible rows) — it walks only the viewport slice of flatItems — and fails open: with no snapshot, or one older than 10s (event loop suspended by tea.Exec or wedged), the gate is bypassed and every session is polled, exactly as before. Also generation-skips refreshSessionRenderSnapshot, which is called from six sweep/tick paths and used to allocate and publish a fresh N-entry map every time. It now compares first and returns without allocating or storing when nothing changed. The per-state derivation moved into computeSessionRenderState so the compare pass and the build pass cannot drift. Kill switch: [ui] adaptive_refresh_max_skips. 0/unset = default 2 (poll every quiescent off-screen session at least every 3rd sweep, ~6s); >0 = custom ceiling; <0 = disable the policy entirely, byte-identical to pre-policy behaviour. docs/design/refresh-architecture-v2.md carries the full target architecture: event-driven first (hook events already land via fsnotify but StatusFileWatcher is constructed with a nil onChange, so the TUI polls a push pipeline instead of listening to it), adaptive second (this slice), remote-ready third (host as the unit of cost, one persistent connection and one bulk fingerprint query per remote, so the Mac's cost is O(hosts) not O(sessions)) — plus the invariants any future change to this layer must preserve. Tests: pure unit coverage of every veto and both fail-open paths, the staleness ceiling pattern, fingerprint evidence/no-evidence cases, viewport publication, and the render-snapshot skip asserted by map identity. No tmux server, no HOME. Committed by Ashesh Goplani
|
👋 Thanks for opening this — a couple of intake fields would help the maintainer's validation pipeline (and any reviewer) understand it faster. Nothing here blocks you and nothing is closing: this is a heads-up, and editing the description re-runs this check automatically.
The full field-level contract is in .github/INTAKE.md. AI-authored PRs are welcome here with equal standing. |
📝 WalkthroughWalkthroughAdds configurable adaptive refresh skipping for unchanged sessions, with viewport-aware polling budgets, cache-derived fingerprints, refresh ledgers, fail-open visibility handling, and render snapshot reuse. Tests cover configuration, policy decisions, ledger behavior, evidence, wiring, and rendering. ChangesAdaptive status refresh
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Home
participant VisibleSnapshot
participant TmuxCache
participant RefreshLedger
participant Session
Home->>VisibleSnapshot: publish visible session IDs
Home->>TmuxCache: read cached session and pane evidence
Home->>RefreshLedger: evaluate fingerprint and polling budget
alt poll required
Home->>Session: call UpdateStatus
Home->>RefreshLedger: record refreshed baseline
else unchanged session
RefreshLedger-->>Home: skip or hold refresh
end
Possibly related PRs
🚥 Pre-merge checks | ✅ 5 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
internal/ui/refresh_policy_test.go (1)
286-332: 📐 Maintainability & Code Quality | 🔵 TrivialNo RemoteSession coverage or documented skip for adaptive-refresh viewport tests.
This test file changes TUI behavior in
internal/ui/(viewport publication for the adaptive sweep gate) but has no test exercisingItemTypeRemoteSessionrows inpublishVisibleSessions, nor an explicitt.Skipdocumenting why it's excluded.The adaptive sweep only iterates local
*session.InstanceinbackgroundStatusUpdate, so RemoteSession rows are likely structurally out of scope for the skip decision (similar to the prior finding that RemoteSession is structurally inapplicable to fork-dispatch helpers) — but the viewport/visible-set publication itself walksflatItems, which can containItemTypeRemoteSessionrows, so a short comment or explicit skip clarifying the exclusion would satisfy the guideline.As per coding guidelines,
internal/ui/**/*_test.go: "When a PR changes TUI behavior in internal/ui/ or cmd/agent-deck/, verify it also handles RemoteSession or include an explicit t.Skip documenting why." Based on learnings, RemoteSession is treated as structurally inapplicable to similar internal/ui dispatch/gating helpers where the localInstanceis required.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/ui/refresh_policy_test.go` around lines 286 - 332, The adaptive-refresh viewport tests need explicit RemoteSession coverage or a documented exclusion. In TestPublishVisibleSessions_CoversViewportAndCursor, add a focused RemoteSession case if publishVisibleSessions should include those rows; otherwise add an explicit t.Skip explaining that RemoteSession rows are outside the local *session.Instance sweep scope while flatItems publication remains structurally covered.Sources: Coding guidelines, Learnings
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@internal/ui/refresh_policy_test.go`:
- Around line 286-332: The adaptive-refresh viewport tests need explicit
RemoteSession coverage or a documented exclusion. In
TestPublishVisibleSessions_CoversViewportAndCursor, add a focused RemoteSession
case if publishVisibleSessions should include those rows; otherwise add an
explicit t.Skip explaining that RemoteSession rows are outside the local
*session.Instance sweep scope while flatItems publication remains structurally
covered.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 59a0df42-4d51-42aa-9643-27673113187b
⛔ Files ignored due to path filters (1)
docs/design/refresh-architecture-v2.mdis excluded by!**/*.md
📒 Files selected for processing (5)
internal/session/userconfig.gointernal/tmux/seed_test_helper.gointernal/ui/home.gointernal/ui/refresh_policy.gointernal/ui/refresh_policy_test.go
…ts (#1753) Second pass on the adaptive refresh policy, closing the three review items on the PR and the new bar recorded on the issue (the live-fleet diagnostic: lag reproduces with a large group EXPANDED, where visible ≈ fleet and an off-screen-only skip degenerates to refreshing everything). Visible rows now get the same fingerprint treatment, shaped for what the user sees. A visible row with a settled status and an unchanged fingerprint is held under the same maxSkips ceiling as an off-screen row (refreshLedger.holdVisible), so an expanded quiescent group costs ~zero. Visible rows that DO need a poll compete for a per-sweep budget (visiblePollBudgetPerSweep = 10, the sweep's worker-pool width); deferred rows age a starvation counter and are admitted first on later sweeps, so the budget cycles round-robin and no row starves. The cursor row is always admitted — it drives the preview pane. The incremental visible-first pass in processStatusUpdate, which used to poll EVERY visible row per pass, now holds steady rows read-only (heldSteady — no ceiling, because the background sweep owns freshness) and budgets the rest the same way. Group expand republishes the viewport immediately and is otherwise only a flat-list rebuild: rows render from the existing snapshot and fill in amortized, with no per-row tmux work between the keypress and the first frame. The config knob is now clamped: UISettings.GetAdaptiveRefreshMaxSkips resolves [ui] adaptive_refresh_max_skips with the GetRemoteSessionRefreshSecs pattern — 0/unset → 2, negative → kill switch, positive clamped to 30 (it previously accepted 100000 verbatim, a ~55h staleness ceiling). The knob resolves once at TUI construction, so changes — including the kill switch — need a TUI restart; documented in the struct comment and the design doc. The safety wiring is now itself under test, not just the policy functions. internal/ui/refresh_wiring_test.go drives the real backgroundStatusUpdate on a constructed Home and pins: (a) a nil / wrong-type / >10s-stale viewport snapshot forces maxSkips=0 so every row polls — asserted via a seeded ledger entry that must be left byte-identical, which fails if the fail-open line is deleted because entering the gate always rewrites the entry; (b) a fresh snapshot consults the gate (positive control); (c) a due visible row is admitted, rebased, and polled; (d) refreshAttachedSessionStatus drops the ledger baseline on attach-return — fails if the forget() line is deleted. tmux.SeedServerAliveForTest pins the server-alive probe so these run deterministically with or without a tmux server. The hook/SSE fingerprint inputs (hookAt/sseAt) — what makes "real transitions are not delayed" true — get direct coverage via new watcher test seams. Measured on the sandboxed #1753 harness (throwaway HOME, isolated tmux socket, 60 synthetic sleep sessions, 220x50, 240 j/k events; same binary, kill switch vs default): pre-policy polls 60/60 sessions every sweep (zero skip lines in ~90 sweeps, one 639ms slow_status_loop); with the policy the median sweep polls 10 of 60 (mean 16.5, adaptive_skipped mean 32.2, visible_deferred mean 11.3) — an 83% median reduction in UpdateStatus calls with no slow sweeps. Both passes produced identical status transitions (120 each) and no flicker. Committed by Ashesh Goplani
|
Second pass pushed — all three review items closed, plus the new bar recorded on #1753 (visible-row budget + group-expand as a trigger case) since the live-fleet diagnostic landed mid-review. Head: 1. The safety wiring is now under test (not just the policy functions)
Per the ground rules I did not run 2. The knob is clamped
3. Measured, not predictedRan the sandboxed #1753 harness on this branch (throwaway
~83% median reduction in The hook/SSE fingerprint inputs also got the requested coverage: New since the verdict: visible rows + group expand (#1753 diagnostic)
Design doc gained §3.2b covering all of this. NOT merging — awaiting re-review at the new head. |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
internal/ui/home.go (1)
8017-8022: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDeduplicate the group-expand refresh block.
The zero-argument
publishVisibleSessions()calls are valid because the method is variadic, but the double-click, Enter, and Tab/right group paths all repeat the same toggle/rebuild cursor-publish/save logic. Extract a small helper and parameterize per-toggle semantics instead of keeping the blocks in sync manually.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/ui/home.go` around lines 8017 - 8022, Deduplicate the repeated group-expand refresh logic in the double-click, Enter, and Tab/right paths by extracting a small helper near the relevant UI handlers. Parameterize the helper with each toggle’s distinct semantics, while centralizing the shared toggle, cursor rebuild, immediate publishVisibleSessions(), and save behavior; update all three paths to use it.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@internal/ui/home.go`:
- Around line 8017-8022: Deduplicate the repeated group-expand refresh logic in
the double-click, Enter, and Tab/right paths by extracting a small helper near
the relevant UI handlers. Parameterize the helper with each toggle’s distinct
semantics, while centralizing the shared toggle, cursor rebuild, immediate
publishVisibleSessions(), and save behavior; update all three paths to use it.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 28796a04-8ef7-4fca-8b64-c78240849700
⛔ Files ignored due to path filters (1)
docs/design/refresh-architecture-v2.mdis excluded by!**/*.md
📒 Files selected for processing (8)
internal/session/userconfig.gointernal/session/userconfig_adaptive_refresh_test.gointernal/session/watcher_seed_test_helper.gointernal/tmux/seed_test_helper.gointernal/ui/home.gointernal/ui/refresh_policy.gointernal/ui/refresh_policy_test.gointernal/ui/refresh_wiring_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
- internal/ui/refresh_policy.go
The problem
The refresh tick does work proportional to all live sessions. #1756 fixed the
render half of #1753 (full-frame
MaxWidthrebuild → measure-then-rebuild, CPU4.84% → 2.04%). What remains is the status sweep, and it is the structurally
wrong shape: it re-derives state for sessions that provably did not change, using
the most expensive mechanism available.
Three facts compose badly:
livePipeLRUCapacity = 3+ attachedsessions — deliberate, since pipes cost a tmux client each. So the sweep's
existing "PipeManager saw no output for 5s → skip" fast path covers ~4 rows
out of 70. Everyone else falls through.
CapturePaneis a subprocess.tmux capture-pane -p -e,3s timeout, serialized by the tmux server, cached 500ms.
session parked in hook
waiting(done, needs you) takes the hook fast path,which calls
BackgroundWorkPending()to check for in-flightrun_in_backgroundwork — a capture, cachedbgWorkCacheTTL = 3s. With a 2ssweep that is a capture roughly every other sweep, per waiting session.
At ~60 quiescent sessions that is tens of
capture-panesubprocesses per sweep,queued behind one tmux server, to re-derive statuses identical to the ones
already on screen. And when the sweep overruns,
nextStatusIntervalbacks off —so the system degrades by getting less fresh, not by getting cheaper.
And the live-fleet diagnostic reframed the requirement: the lag reproduces
when a group containing many sessions is expanded, and collapsing it makes
it fine. With a large group expanded, visible-row count approaches fleet size —
so any design that treats visible rows as must-refresh-every-tick degenerates
back to the full per-row storm exactly when the user is looking at the most
rows. Visible rows need a cheap-evidence skip and a per-tick budget too.
The design (
docs/design/refresh-architecture-v2.md)Event-driven first. Claude/Codex/Gemini/Hermes/Cursor already push
lifecycle events into hook files, and
session.StatusFileWatcheralreadywatches that directory with fsnotify and maintains a live status map. But it is
constructed as
NewStatusFileWatcher(nil)—onChangeis nil. Nothingwakes the TUI; the sweep polls the watcher's map on its own cadence. We pay for
a push pipeline and consume it by polling. Target: hooks and tmux control-mode
notifications cause updates; the periodic sweep demotes to a reconciliation
safety net for what events cannot report and can then run at 10–30s.
Adaptive second. Rows refresh when there is evidence they changed;
generation counters make unchanged sessions cost zero; work per tick is
budgeted rather than unbounded — for visible rows too, because the group-expand
case makes "visible" as large as the fleet.
Remote-ready third. The roadmap is fleets on cloud machines with the Mac as
a thin control surface. "Subprocess per session per tick" is not merely slower
over SSH, it is a different order of magnitude — and both principles above
carry over: an event crossing an SSH connection costs one multiplexed message,
and a generation counter lets a remote answer "nothing changed" once for the
whole host. So the refresh layer should treat a host as the unit of cost:
Mac cost O(hosts), independent of session count. Existing
--sshsupport isthe seed; v2 generalizes that and makes local tmux one implementation of a
session host behind it.
The doc also records the invariants any future change to this layer must
preserve (never skip on absent information; never skip without a bound; fail
open; never infer death from silence; always ship a kill switch).
The slice in this PR
Per-session fingerprint (
internal/ui/refresh_policy.go), built entirelyfrom caches the sweep already refreshed once per tick —
window_activityfromthe
list-sessionssnapshot, pane title/command/dead from thelist-panessnapshot, hook
UpdatedAt, SSEUpdatedAt. Building it for the whole deck is ahandful of map lookups and spawns nothing. Incomplete evidence sets
ok = false,which always forces a poll.
The off-screen gate, right after the existing PipeManager idle-skip.
UpdateStatusis skipped only when every one of these holds:running/waiting/idle(error/stoppedkeep their ownrecheck timers,
startingmust converge fast);maxSkipsconsecutive skips have happened.Any veto polls. No path skips on absent information.
Visible rows: hold + budget (the group-expand case):
under the same
maxSkipsceiling (refreshLedger.holdVisible) — an expandedquiescent group costs ~zero;
(
visiblePollBudgetPerSweep = 10, the sweep's worker-pool width); deferredrows age a starvation counter and are admitted first on later sweeps, so the
budget cycles round-robin and every due row is polled within
ceil(due/budget)sweeps;processStatusUpdate, which used topoll every visible row per pass, now holds steady rows read-only
(
heldSteady; no ceiling, because the background sweep owns freshness) andbudgets + round-robins the rest;
flat list (rows render instantly from the existing render snapshot) and
republishes the viewport immediately, so the next sweep amortizes the
catch-up through the budget. No path between the keypress and the first
frame spawns per-row tmux work.
Why this is safe
Session.GetStatusalready gates itsCapturePaneon exactly "did
window_activitychange" (needsBusyCheck), andUpdateStatusgates its idle tier on it. This gate reuses that invariant andis strictly more conservative, because those skip for as long as activity
is unchanged while this one is bounded by the ceiling.
UpdateStatushas time-basedtransitions with no external evidence (
acknowledged → idle, hook-freshnessexpiry,
debounceFlipFromRunningconfirmation, Hermes gateway recheck).The ceiling delays those by at most
maxSkipssweeps (~6s at default)instead of suppressing them — where the existing
needsBusyCheckhas nobound at all. The same bound now covers visible rows; anything the user can
see is at most ~3 sweeps stale, and only if nothing observable moved.
fingerprint on the next sweep: Stop hook →
hookAt, any output →activity, spinner start/stop →title(Claude's OSC marker), exit underremain-on-exit →
dead, session gone from tmux →ok=false→ forced poll,OpenCode SSE →
sseAt. A row with a moved fingerprint is polled — forvisible rows within the budget, cursor first, starved rows first.
suspended by
tea.Exec, or wedged), gate and budget are bypassed andevery session is polled, exactly as before. A nil ledger also polls.
refreshAttachedSessionStatusalreadyclears hook status and forces a check; it also drops the fingerprint
baseline — and that wiring is now pinned by a test that fails if the line is
deleted.
Render-snapshot generation skip.
refreshSessionRenderSnapshotis calledfrom six sweep/tick paths and used to allocate and publish a fresh N-entry map
every time. It now compares first and returns without allocating or storing when
nothing changed.
Kill switch —
[ui] adaptive_refresh_max_skips, resolved throughUISettings.GetAdaptiveRefreshMaxSkips(same clamp pattern asGetRemoteSessionRefreshSecs):0/ unset2— poll every quiescent session at least every 3rd sweep (~6s)>0MaxAdaptiveRefreshMaxSkips); it previously accepted100000verbatim, a ~55h staleness ceiling<0The knob resolves once, at TUI construction — changing it (including the
kill switch) takes effect on the next TUI restart, not on config reload.
Measured effect
Sandboxed #1753 harness, run on this branch: throwaway
HOME, blank XDG, ownprofile, own tmux socket, 60 synthetic
--cmd "sleep 3600"sessions, terminal220×50 (~44 of 60 rows visible — i.e. the group-expanded shape), 60s warmup +
240 single-key
j/kevents + 60s settle. Same binary both passes; before =kill switch (
adaptive_refresh_max_skips = -1, byte-identical to pre-policy),after = default. Counters from the
idle_sessions_skippeddebug line this PRadded:
UpdateStatuscalls per sweep (median)UpdateStatuscalls per sweep (mean)adaptive_skippedper sweep (mean)visible_deferredper sweep (mean)slow_status_loop(>500ms) eventsThe median sweep now polls 10 sessions instead of 60 — an ~83% reduction in
UpdateStatuscalls (mean ~72%) — while both passes converged through theidentical 120 status transitions, i.e. the policy changed cost, not outcomes.
Tests
internal/ui/refresh_policy_test.go— pure, no tmux server and noHOME:skip, skip, poll) and counter reset onfingerprint change;
forget()/prune()/nil-receiver behaviour;fingerprint, unsettled status, kill switch, no baseline, nil ledger), and
refused holds not consuming the ceiling;
heldSteady): 50 held passes consume none of the sweepceiling;
sweeps (starvation-counter aging), and the cursor row is never deferred;
the hook/SSE inputs: a delivered hook or SSE sample moves
hookAt/sseAtand breaks the match even with unchanged tmux evidence (this is what makes
"real transitions are not delayed" true), via new watcher test seams;
cursorID) and both fail-open paths;internal/ui/refresh_wiring_test.go— drives the realbackgroundStatusUpdate/refreshAttachedSessionStatuson a constructedHome, so the wiring lines themselves are pinned (deleting either safety line
turns the suite red — see the review-response comment for the exact mapping).
internal/session/userconfig_adaptive_refresh_test.go— the clamp:default / passthrough / max / above-max / 100000 / kill-switch cases.
tmux.SeedServerAliveForTestwas added alongside the existing seed helpers sointernal/uican drive the sweep deterministically with or without a tmuxserver.
Follow-ups (roadmap in the doc, each independently shippable)
onChangeonStatusFileWatcher→ single-session refresh on hookdelivery. First genuinely event-driven step, and it feeds the TUI-truth
waiting = donevsneeds-yousplit.Refs #1753. Follow-up to #1756.
Summary by CodeRabbit
ui.adaptive_refresh_max_skipssetting to control the adaptive skip ceiling, with0using defaults and negative values disabling the policy.