Skip to content

Cap sidebar status/metadata dictionaries to bound the sidebar view-graph livelock (#5845) - #5855

Merged
austinywang merged 19 commits into
mainfrom
issue-5845-main-thread-hang-lazystack
Jun 11, 2026
Merged

austinywang merged 19 commits into
mainfrom
issue-5845-main-thread-hang-lazystack

Conversation

@austinywang

@austinywang austinywang commented Jun 10, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes the unbounded-growth amplifier behind the recurring main-thread hang in #5845 (run loop stuck in LazyStack/ForEachList view-graph updates with ~37 workspaces + ~30 live agent sessions, footprint climbing to 6–8 GB before force-quit).

The sidebar status/metadata socket API upserts entries under arbitrary caller-chosen keys. logEntries was already capped, but statusEntries and metadataBlocks (@Published [String: …] on Workspace) had no bound — they only shrank via explicit key removal or full reset. Under ~30 long-running agent/CI integrations over hours, an integration that uses ever-distinct keys grows these dictionaries without limit. That hurts in three compounding ways, all on the main thread:

  1. Memory — the dictionaries are retained forever (contributes to the 6–8 GB footprint growth).
  2. Per-tick equality — Workspace.sidebarObservationPublisher rebuilds a SidebarObservationState and runs .removeDuplicates() (a deep Equatable compare of the full dicts) on every upstream emission, for every active workspace. Cost scales with dictionary size.
  3. Per-refresh sort — every sidebar refresh sorts all entries via sidebarStatusEntriesInDisplayOrder() / sidebarMetadataBlocksInDisplayOrder() (O(n log n)) to feed the sidebar LazyVStack view graph.

As these grow, the per-update work feeding the view graph grows super-linearly, compounding the never-draining NSHostingView.beginTransaction → … → LazyStack.place(subviews:) → ForEachList.applyNodes transactions captured in the .hang/.cpu_resource samples.

Fix

Add a generous per-workspace cap (200) to statusEntries and metadataBlocks, enforced from a didSet on each @Published dictionary so every write path (socket commands, feed coordinator, panel lifecycle, restore) is bounded — not just today's call sites. Eviction drops lowest-priority then oldest entries, following the same priority/timestamp ranking as the display-order sort. The structured-agent-hook status keys are a fixed 16-key allowlist that can occupy at most a handful of the 200 slots, and the display re-applies its visibility filter to the survivors, so the shown entries are unaffected in practice. The cap is far above any realistic integration (the collapsed sidebar shows a handful of pills), so it only clamps pathological growth. This mirrors the existing logEntries cap.

Reproduction / evidence

A full live repro needs ~37 workspaces + ~30 agents over hours, so this is driven from the issue's .hang/.cpu_resource stack signature and a static analysis of the workspace-sidebar LazyVStack path (Sources/ContentView.swift workspaceRows → TabItemView). That path is already heavily guarded against the #2586 class of livelock (Equatable rows + .equatable(), snapshot boundary, dragState made @Observable, gated virtualization-defeating frame readers, 0.5 pt height-jitter tolerance, batched mutations via TerminalMutationBus). The remaining un-bounded amplifier feeding that graph was the two telemetry dictionaries fixed here.

Tests

Two-commit red/green (cmuxTests/WorkspaceSidebarObservationTests.swift):

  • Commit 1 (red): testStatusEntriesStayBoundedUnderUnboundedDistinctKeys / testMetadataBlocksStayBoundedUnderUnboundedDistinctKeys insert 3× the cap of distinct keys and assert the dictionary stays bounded and keeps the newest / evicts the oldest. Fails before the fix (count grows to 3×).
  • Commit 2 (green): the didSet cap.

Added to an already-wired test file (no pbxproj changes). Swift file-length budget refreshed for the added lines. No user-facing strings changed, so no localization audit was required.

Honest scope note / precise follow-ups

This removes a concrete unbounded amplifier (memory + per-tick main-thread cost), but a genuine 30-agent workload can still saturate the main thread through legitimate per-row sidebar refreshes. Two precise, lower-confidence follow-up candidates surfaced during the investigation, deliberately not included here to keep this change low-risk and reviewable:

  1. Narrow the observation trigger for logs. SidebarObservationState carries the full logEntries array, but the row only renders logEntries.last. Replacing it with latestLogEntry would cut the per-tick compare from O(50) to O(1) and stop firing on eviction-only mutations.
  2. The whole-content-height preference (SidebarWorkspaceRowsHeightPreferenceKey → host @State workspaceRowsMeasurement) is the last preference-driven layout-feedback path on the workspace LazyVStack (sibling of the one removed in Remove sidebar row-ids preference aggregation feeding the layout livelock (#2586) #5708 / Nightly freezes: sidebar LazyVStack layout loop pegs main thread at 100% CPU, deadlocks CLI #2586). Genuine row-height changes from agent telemetry re-run the sidebar host body. Isolating the empty-area sizing into a child view so its @State write no longer invalidates the row-building body is the architectural next step, but it touches delicate scroll/drag/sidebar scrollbar always visible — should only appear when content overflows #3241 code and warrants its own change with a live build to verify.

The 6–8 GB footprint is likely also driven by agent-session output buffers / web renderers independent of the sidebar; that should be tracked separately.

🤖 Generated with Claude Code


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Summary by cubic

Caps per-workspace statusEntries and metadataBlocks at 200 to stop sidebar-driven main-thread hangs and memory spikes (#5845). Retains reserved cmux keys and live agent-backed statuses, and prevents orphaned PID/lifecycle state (including during detached-agent adoption).

  • Bug Fixes
    • Enforce 200-item caps via didSet on both dictionaries (mirrors the logEntries cap).
    • Eviction order: reserved cmux keys → live agent-backed (PID or lifecycle) → just-inserted keys → higher priority/newer timestamp → tie-break by key, all keyed by the storage key.
    • Keep agent runtime bounded: purge PID/ownership/lifecycle for evicted status keys; record PIDs only when the status survived; during detached-agent adoption, merge statuses once and adopt PIDs/ownership only if the adopted status survives (PID-only keys still adopted).
    • Tests cover caps, ordering, live/lifecycle retention, self-eviction guard, eviction cleanup, and adoption paths; assertions use pre-existing APIs for a clean red/green proof against main.

Written for commit 8f357b5. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Sidebar status entries and metadata blocks enforce a 200-item cap and auto-trim to retain higher-priority and newer entries; statuses backed by live agents are preferentially retained.
  • Bug Fixes

    • Evicted status entries now clear associated runtime PID/lifecycle state; PID recording is prevented for entries that self-evict. Metadata block evictions do not retain runtime state.
  • Tests

    • Added regression tests for caps, eviction ordering, PID coupling, and live-agent retention.

austinywang and others added 2 commits June 10, 2026 16:39
The sidebar status/metadata socket API lets agents and CI scripts insert
entries under arbitrary caller-chosen keys. Under many long-running agent
sessions these @published dictionaries grow without bound, leaking memory and
making the per-tick removeDuplicates equality check and the display-order sort
that feed the sidebar view graph progressively more expensive on the main
thread (#5845). logEntries is already
capped; statusEntries and metadataBlocks are not.

This test fails without the cap (count grows to 3x the bound).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…5845)

The sidebar status/metadata socket API upserts entries under arbitrary
caller-chosen keys. logEntries was already capped, but statusEntries and
metadataBlocks could grow without bound: under ~30 long-running agent sessions
over hours they accumulate memory and make every per-tick removeDuplicates
equality check and sidebarStatus/MetadataBlocksInDisplayOrder() sort that feeds
the sidebar LazyVStack view graph progressively more expensive on the main
thread — compounding the never-draining view-graph transactions seen in the
.hang/.cpu_resource samples.

Add a generous per-workspace cap (200) enforced from a didSet on each
@published dictionary, evicting lowest-priority then oldest entries so
retention matches the existing display-order sort. The cap is far above any
realistic integration (the collapsed sidebar shows a handful of pills), so it
only clamps pathological growth. Mirrors the logEntries cap.

Refreshes the Swift file-length budget for the added lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 10, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Jun 11, 2026 10:31am
cmux-staging Building Building Preview, Comment Jun 11, 2026 10:31am

@coderabbitai

coderabbitai Bot commented Jun 10, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Makes Workspace sidebar dictionaries writable and adds didSet trimming that enforces 200-entry caps; trimming keeps highest-priority and newest entries, evicts the rest, and evictions for status entries purge related agent PID runtime state. Tests validate bounding and PID-cleanup behavior.

Changes

Sidebar Memory Bounding

Layer / File(s) Summary
Property access and didSet trimming hooks
Sources/Workspace.swift
statusEntries and metadataBlocks gain didSet hooks that call trimming functions.
Trimming implementations for status and metadata
Sources/Workspace.swift
Defines maxSidebarStatusEntries and maxSidebarMetadataBlocks and implements trimming: sort by live-agent precedence, priority desc, timestamp desc, key asc; compute keep-set; purge runtime PID state for evicted status keys, then reassign retained dictionaries.
Panel lifecycle helpers (PID gating & purge)
Sources/Workspace+PanelLifecycle.swift
Adds recordAgentPIDForSurvivingStatusKey(_:,pid:panelId:) which only records PIDs when the status key currently exists, statusKeysWithCoupledAgentRuntime() to enumerate backed keys, and purgeAgentRuntimeState(forEvictedStatusKeys:) to clear PID/ownership/runtime state for evicted status keys and refresh tracked agent ports if any were cleared.
Terminal PID recording change
Sources/TerminalController.swift
Switches recordAgentPID calls to recordAgentPIDForSurvivingStatusKey in status-update flows so PID state is only recorded for status keys that survive the retention cap.
Regression tests for bounding and purge behavior
cmuxTests/WorkspaceSidebarObservationTests.swift
Adds tests that insert many distinct keys into statusEntries and metadataBlocks to ensure each stays capped at 200; tests also verify eviction clears associated agentPIDs, that PIDs are not recorded for statuses that immediately self-evict, and that live-agent-backed statuses are retained.

Sequence Diagram

sequenceDiagram
  participant Trimmer as trimSidebarStatusEntriesIfNeeded
  participant Purger as purgeAgentRuntimeState
  participant Clear as clearAgentPID
  participant Ports as refreshTrackedAgentPorts
  Trimmer->>Purger: evictedStatusKeys
  Purger->>Clear: clearAgentPID(... clearStatus:false, refreshPorts:false) for matching PID keys
  Clear-->>Purger: cleared?
  Purger->>Ports: refreshTrackedAgentPorts() if any cleared
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

I'm a rabbit who trims down the list,
Two hundred hops keep things brisk,
Old keys softly fall,
PIDs cleared one and all,
Sidebar neat with every whisk. 🐇✨

🚥 Pre-merge checks | ✅ 20 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 58.82% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (20 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: capping sidebar status/metadata dictionaries to resolve a view-graph livelock issue. It is concise, directly references the affected components, and identifies the problem being solved.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed All production changes are within @MainActor Workspace class or Workspace+PanelLifecycle extension, properly maintaining actor isolation. No implicit MainActor types, mutable Sendable without isola...
Cmux Swift Blocking Runtime ✅ Passed PR introduces no blocking/timing-based synchronization in production code. New methods use synchronous sorting/filtering; all semaphores/locks/sleeps are pre-existing.
Cmux Expensive Synchronous Load ✅ Passed No expensive synchronous loaders added to main actor or interactive paths; didSet handlers contain only O(n) dictionary/string operations (n≤200) with async PortScanner dispatch.
Cmux Cache Substitution Correctness ✅ Passed statusEntries and metadataBlocks are ephemeral, in-memory collections cleared during session restore (never restored from snapshot). The capping logic uses fresh reads of current state (statusEntri...
Cmux No Hacky Sleeps ✅ Passed PR contains only Swift code changes (.swift files). The "cmux no hacky sleeps" check explicitly covers TypeScript, JavaScript, shell, and build/runtime scripts only—Swift is explicitly out of scope...
Cmux Algorithmic Complexity ✅ Passed PR introduces explicit bounds (200 items) for statusEntries/metadataBlocks with documented trim logic, mirrors existing logEntries cap, improves from unbounded prior behavior, sorts bounded collect...
Cmux Swift Concurrency ✅ Passed PR introduces only synchronous methods with didSet observers on existing @Published properties; no legacy async patterns (DispatchQueue.global, new Combine, completion handlers, fire-and-forget T...
Cmux Swift @Concurrent ✅ Passed All new Swift functions are synchronous methods on @MainActor class; no nonisolated async, @concurrent, or CPU-heavy async work that requires actor hopping was introduced.
Cmux Swift File And Package Boundaries ✅ Passed PR adds 72 net lines to Workspace.swift (20,027 lines, within 20,027 budget) and 3 lines to TerminalController.swift (22,101 lines, within 22,101 budget)—both under the 250-line threshold for >800-...
Cmux Swift Logging ✅ Passed All 4 NSLog statements in Workspace.swift are guarded with #if DEBUG per swift-logging.md; no other logging violations found in production code changes.
Cmux User-Facing Error Privacy ✅ Passed No user-facing errors, alerts, or messages were added. All changes are internal implementation (private trim/purge functions) and test code with no exposure of prohibited information per user-facin...
Cmux Full Internationalization ✅ Passed PR contains only internal code changes, tests, and developer comments. No new user-facing Swift text, string catalogs, or localization requirements introduced.
Cmux Swiftui State Layout ✅ Passed PR modifies existing @Published properties in legacy ObservableObject (Workspace) only incidentally by adding didSet observers for memory bounds; does not introduce new @Published/@observable patte...
Cmux Architecture Rethink ✅ Passed PR implements deterministic dictionary capping via didSet observers with clear ownership and invariants. No timing repairs, locks, polling, or duplicate state owners. Follows existing logEntries pa...
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR does not introduce or materially change standalone cmux-owned windows; changes focus on data-management for sidebar dictionaries (statusEntries, metadataBlocks trimming), agent PID lifecycle tra...
Cmux Source Artifacts ✅ Passed All 4 changed files (Sources/Workspace.swift, cmuxTests/WorkspaceSidebarObservationTests.swift, Sources/Workspace+PanelLifecycle.swift, Sources/TerminalController.swift) are legitimate hand-written...
Description check ✅ Passed The PR description is comprehensive and detailed, covering the problem, fix, testing approach, and scope considerations.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-5845-main-thread-hang-lazystack

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps

greptile-apps Bot commented Jun 10, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR caps statusEntries and metadataBlocks at 200 entries per workspace via didSet observers, mirroring the existing logEntries cap. The fix bounds per-tick removeDuplicates equality cost and the sidebarStatusEntriesInDisplayOrder/sidebarMetadataBlocksInDisplayOrder sort work that was amplifying the LazyVStack view-graph livelock reported in #5845.

  • Workspace.swift: Adds trimSidebarStatusEntriesIfNeeded and trimSidebarMetadataBlocksIfNeeded with four-tier eviction (reserved cmux keys → live agent-backed → just-inserted grace tier → priority/timestamp). Reserved keys (remote.error, remote.port_conflicts) are pinned so the remote-connection state machine can never be corrupted by a status flood.
  • Workspace+PanelLifecycle.swift: Adds purgeAgentRuntimeState(forEvictedStatusKeys:) to clear coupled agentPIDs/ownership/lifecycle state when an entry is evicted; and recordAgentPIDForSurvivingStatusKey to prevent orphaned PID state when a low-priority set_status --pid key self-evicts on insert. The adoptDetachedAgentRuntimeState path is updated to bulk-merge adopted statuses and skip PID adoption for self-evicted entries.
  • Tests: Red/green two-commit proof with nine scenarios covering the cap, eviction order, live/lifecycle retention, self-eviction guard, coupled-state cleanup, and adoption paths.

Confidence Score: 5/5

Safe to merge. The cap is enforced on every write path via didSet, reserved cmux application-state keys are pinned, coupled agent PID/lifecycle state is purged before eviction, and the set_status --pid multi-step race is handled by the just-inserted grace tier. Recursion in didSet terminates at depth two.

The change is narrowly scoped to the two dictionary properties causing unbounded growth. The tiered eviction logic is well-reasoned and matches the existing display-order sort, reserved keys cannot be lost, and coupled runtime state stays correctly bounded. Nine regression tests cover all edge cases.

No files require special attention.

Important Files Changed

Filename Overview
Sources/Workspace.swift Adds didSet cap enforcement on statusEntries and metadataBlocks with tiered eviction, reserved-key pinning, and clean recursion termination at depth two.
Sources/Workspace+PanelLifecycle.swift New helpers correctly bound coupled PID/lifecycle state during eviction, adoption, and the multi-step set_status --pid path.
Sources/TerminalController.swift Two recordAgentPID call sites replaced with recordAgentPIDForSurvivingStatusKey to prevent orphaned PID state when a key self-evicts.
cmuxTests/WorkspaceSidebarObservationTests.swift Nine new tests cover bounded growth, eviction ordering, live/lifecycle retention, self-eviction guard, coupled-state cleanup, and adoption paths.
.github/swift-file-length-budget.tsv Budget refreshed to match the actual line delta in both touched files.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["statusEntries write via didSet"] --> B{"count > 200?"}
    B -->|No| C["Return — no-op"]
    B -->|Yes| D["Compute liveAgentStatusKeys and justInsertedKeys"]
    D --> E["Sort: Reserved > Live > Fresh > Priority/Timestamp"]
    E --> F["kept = top 200 keys"]
    F --> G["evictedKeys = all keys minus kept"]
    G --> H{"evictedKeys empty?"}
    H -->|Yes| I["Return"]
    H -->|No| J["purgeAgentRuntimeState: clearAgentPID + clearAgentLifecycle"]
    J --> K["statusEntries = filter kept (re-enters didSet)"]
    K --> L{"count > 200?"}
    L -->|No| M["Return — depth 2 terminus"]
    L -->|Yes| D
Loading

Reviews (12): Last reviewed commit: "Refresh Swift file-length budget after m..." | Re-trigger Greptile

Comment thread Sources/Workspace.swift Outdated
Comment on lines +14480 to +14493
private func trimSidebarStatusEntriesIfNeeded() {
guard statusEntries.count > Self.maxSidebarStatusEntries else { return }
let keptKeys = statusEntries.values
.sorted { lhs, rhs in
if lhs.priority != rhs.priority { return lhs.priority > rhs.priority }
if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp }
return lhs.key < rhs.key
}
.prefix(Self.maxSidebarStatusEntries)
.map(\.key)
let kept = Set(keptKeys)
// Reassigning re-enters didSet, but the next pass sees count <= cap and
// returns immediately, so recursion terminates at depth two.
statusEntries = statusEntries.filter { kept.contains($0.key) }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Eviction ignores the visibility filter applied by the display path

sidebarStatusEntriesInDisplayOrder() first calls sidebarStatusEntriesVisibleForDisplay() — which hides "structured agent hook status" entries whose panel is no longer active — then sorts. trimSidebarStatusEntriesIfNeeded() skips that filter and sorts all entries, including invisible structured-agent entries. An invisible entry with a high-priority or recent timestamp will claim one of the 200 slots, potentially evicting a visible non-structured entry. The PR description says "entries that are actually shown always survive," but that guarantee only holds when every entry above the cap threshold is non-structured or already visible.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Practical impact is negligible, so I left the ranking as priority/timestamp. The structured-agent-hook keys are a fixed 16-key allowlist (AgentHibernationLifecycleStatusKeys.allowedStatusKeys) — they cannot grow, so they can occupy at most ~16 of the 200 slots. The flood that actually triggers the cap is always non-structured (always-visible) keys, and the display path re-applies sidebarStatusEntriesVisibleForDisplay() to the survivors before showing the collapsed top entries, so what the user sees is unaffected. I softened the 'always survive' wording in the PR description to reflect this bound.

— Claude Code

Comment thread Sources/Workspace.swift Outdated
Comment on lines +14490 to +14493
let kept = Set(keptKeys)
// Reassigning re-enters didSet, but the next pass sees count <= cap and
// returns immediately, so recursion terminates at depth two.
statusEntries = statusEntries.filter { kept.contains($0.key) }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Double objectWillChange / publisher notification on every overflow insert

When an insert pushes statusEntries above the cap, didSet reassigns the whole dictionary via statusEntries = statusEntries.filter {…}. Because statusEntries is @Published, this second assignment fires objectWillChange a second time in the same synchronous call. Any Combine subscriber that has already received the over-cap value (e.g. sidebarObservationPublisher → .removeDuplicates()) will receive a second, distinct emission for what is logically one write. On a workload with persistent overflow, this doubles the emission rate into the chains the PR is trying to quiet. The same issue exists in trimSidebarMetadataBlocksIfNeeded(). A cheaper fix is to mutate with removeValue(forKey:) per evicted key (one notification per eviction, coalesced), or to stage the removal outside didSet entirely.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping the single whole-dictionary reassignment. The second objectWillChange fires only on the overflow path (count > cap), i.e. exactly the pathological flood the cap exists to bound — never in normal operation where the dicts stay well under 200. The suggested per-key removeValue is not cheaper for the common case (a flood evicts one entry per insert → one extra notification either way) and is strictly worse for the bulk path (restore assigns the whole dict once → N notifications vs the reassign's 1), so I left the reassign as the lower-amplitude option. The doubled emission on the already-degenerate overflow path is bounded and far below the unbounded growth it replaces.

— Claude Code

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your analysis is correct on all three counts. The reassign and per-key paths produce the same notification count on the single-entry overflow path (1 insert notification + 1 trim notification either way), the reassign is strictly fewer notifications than per-key removal on the bulk-assign path, and the overflow path itself is the bounded pathological case the cap exists to contain.

I'll retract the concern — the current approach is the right choice here.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 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.

Inline comments:
In `@Sources/Workspace.swift`:
- Around line 14482-14490: The current trimming logic builds keptKeys from
statusEntries.values and then uses those to filter the dictionary, which can
misalign if keys and values diverge; change the selection to iterate the
dictionary pairs so the ranking is done on (dictKey, value) tuples: for
statusEntries use statusEntries.map { (key, value) in ... } or
statusEntries.sorted(by:) over entries, compare value.priority and
value.timestamp, and return the dictKey for the top Self.maxSidebarStatusEntries
into keptKeys/kept; apply the same change to the other trim path that uses the
same pattern (the block that computes keptKeys/kept near lines 14500-14509) so
both trimming branches compare and keep by the actual dictionary key.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4a367d0d-4cb0-4a6a-9da0-75933fae4843

📥 Commits

Reviewing files that changed from the base of the PR and between 8d15475 and 8a12328.

⛔ Files ignored due to path filters (1)
  • .github/swift-file-length-budget.tsv is excluded by !**/*.tsv
📒 Files selected for processing (2)
  • Sources/Workspace.swift
  • cmuxTests/WorkspaceSidebarObservationTests.swift

Comment thread Sources/Workspace.swift Outdated
austinywang and others added 2 commits June 10, 2026 16:56
…5845)

Autoreview caught that set_status --pid couples a status key to agent PID
runtime state (agentPIDs / agentPIDPanelIdsByKey / agentPIDKeysByPanelId and the
port-scan tags keyed off them). The new sidebar status cap removed the status
entry but left that coupled state behind, so the same ever-distinct-key workload
with PIDs could still grow those maps without bound — the exact hot path the cap
is meant to contain.

trimSidebarStatusEntriesIfNeeded now purges the agent runtime state for every
evicted status key (via clearAgentPID, status removal handled by the cap filter)
before the entries leave statusEntries, so dotted status keys still resolve.
Adds a regression test for the set_status --pid eviction path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rt (#5845)

Autoreview follow-up: set_status --pid inserts the status entry first, then
records the PID. If the workspace is already at the status cap with
higher-priority entries, a new low-priority status self-evicts on insert, so the
earlier eviction-purge sees no PID yet and the PID is then recorded against an
absent status key — leaving agentPIDs / port-scan tags growing under a flood of
distinct low-priority keys.

Route both set_status --pid call sites through recordAgentPIDForSurvivingStatusKey,
which records the PID only if the status entry survived the cap. set_agent_pid
(intentionally PID-without-status) keeps using recordAgentPID directly. Adds a
regression test for the self-eviction path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
austinywang and others added 5 commits June 10, 2026 17:34
CodeRabbit: derive the keep-set ranking from the dictionary's (key, value)
pairs and keep by storage key in both trim paths, so the ranking key and the
filter key can never diverge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…#5845 review)

Autoreview (codex): set_status --pid re-pings with unchanged display fields are
no-ops, so a live agent status keeps its original insertion timestamp. A pure
timestamp/priority cap could then evict that active status under a flood of newer
distinct keys, hiding a live agent and purging its PID.

Rank statuses with a coupled agent PID (statusKeysWithCoupledAgentRuntime) ahead
of plain telemetry in the eviction sort, so live agent statuses survive without
bumping the display timestamp (which would reintroduce per-ping row churn). The
coupled-PID purge still fires only when live statuses themselves overflow.
Updates the eviction regression test to the all-live scenario and adds a test
that a PID-backed status with an old timestamp survives a newer-key flood.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…d-hang-lazystack

# Conflicts:
#	.github/swift-file-length-budget.tsv
… review)

Autoreview (codex): some active agent statuses are lifecycle-backed without a
PID — e.g. FeedCoordinator records a needs-input badge via setAgentLifecycle and
writes statusEntries[statusKey] with no agent PID. The PID-only protected set let
a telemetry flood evict that pending needs-input status, hiding an agent decision
while leaving lifecycle state behind.

statusKeysWithCoupledAgentRuntime now also includes the keys of
agentLifecycleStatesByPanelId, so lifecycle-backed statuses are retained too.
Adds a regression test for a PID-less needs-input status surviving a flood.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Autoreview (codex): remote.error and remote.port_conflicts are app-owned status
entries used as application state (hasProxyOnlyRemoteSidebarError reads
remote.error to preserve connected state during proxy-only reconnects), not
external telemetry. The cap ranked them like any set_status key, so a flood of
newer/higher-priority keys could evict active SSH/port-conflict error state.

Add reservedSidebarStatusKeys and rank them as the top retention tier (above
live-agent and priority/timestamp), so cmux-owned state is never evicted. Adds a
regression test covering the worst-case ranking inputs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Autoreview (codex): the cap protects lifecycle-backed status keys, but with more
than 200 distinct lifecycle-backed keys the oldest are still evicted — and the
purge only cleaned PID-coupled state, so agentLifecycleStatesByPanelId kept every
evicted key. statusKeysWithCoupledAgentRuntime() then re-traverses that
ever-growing set on each trim, reintroducing the memory/CPU growth class.

purgeAgentRuntimeState now also clears lifecycle state (clearAgentLifecycle) for
every evicted status key, idempotent with the PID path. Adds a regression test
inserting 2x the cap of lifecycle-backed keys and asserting the lifecycle map
stays bounded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
austinywang and others added 3 commits June 10, 2026 23:35
…n trim (#5845 review)

Autoreview (codex): trimming synchronously from the statusEntries didSet makes
multi-step status+runtime updates non-atomic. set_status --pid (and detached-agent
adoption) insert the visible status first and record the coupled PID/lifecycle
afterward, so when the workspace is at cap with higher-priority telemetry the new
agent status self-evicted before it was marked live, and the follow-up PID was
then dropped — breaking core agent sidebar/PID behavior under the flood.

Rank keys added by the triggering write (diffed from didSet oldValue) above plain
telemetry but below reserved/live, so a just-inserted status survives its own trim
long enough for recordAgentPIDForSurvivingStatusKey to mark it live. It's a tier,
not an absolute pin: a bulk insert above the cap still ranks within the tier by
priority/timestamp, so the cap stays enforced. Applied to both status and metadata
trims. Updates/adds regression tests for the survive-over-telemetry and
self-evict-against-live-statuses cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…d-hang-lazystack

# Conflicts:
#	.github/swift-file-length-budget.tsv
…review)

Autoreview (codex): adoptDetachedAgentRuntimeState writes the transferred status
into statusEntries and then records every transferred PID unconditionally. With
the cap, an adopted status can self-evict when the destination is already full of
higher-ranked live/reserved entries, so the unconditional record recreated an
agentPIDs/ownership/port-scan record with no surviving status — the orphan class
this patch bounds.

Merge adopted statuses in one assignment (so they share the just-inserted grace
tier in a single trim), then adopt the coupled PID/ownership only for status-backed
keys whose status survived the cap. PID-only keys (set_agent_pid, no transferred
status) are still adopted unconditionally. Adds regression tests for the evicted
and surviving adoption cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
austinywang and others added 2 commits June 11, 2026 03:23
…review)

Autoreview (codex): several review-driven tests referenced fix-only symbols
(recordAgentPIDForSurvivingStatusKey, Workspace.reservedSidebarStatusKeys), so a
test-only commit against origin/main would fail to compile rather than fail an
assertion, weakening the red/green proof.

Rewrite those cases to assert the observable behavior through pre-existing APIs
(statusEntries plus literals): the grace tier keeps a just-inserted status, a new
non-live status self-evicts against a full set of live statuses, and reserved
keys (literal remote.error/remote.port_conflicts) survive. All tests now compile
against main and fail there for the right reason.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…d-hang-lazystack

# Conflicts:
#	.github/swift-file-length-budget.tsv
@austinywang
austinywang merged commit 48dd82b into main Jun 11, 2026
29 of 30 checks passed
azooz2003-bit added a commit that referenced this pull request Jun 13, 2026
…021 + 6026) (#6033)

* Sidebar: remove whole-content rows-height measurement (fixes layout livelock)

Replace the LazyVStack background GeometryReader ->
SidebarWorkspaceRowsHeightPreferenceKey -> @State workspaceRowsMeasurement ->
emptyAreaHeight round trip with SidebarRowsFillLayout, a custom Layout that
places the rows at their natural height and stretches the empty drop/tap area
to fill the remaining viewport from its own concrete bounds, in one geometry
pass with no state writes.

The preference write during layout fed a non-converging relayout transaction:
main thread pinned 100%+ in GraphHost.flushTransactions ->
LazySubviewPlacements.placeSubviews -> LazyStack.place ->
ForEachList.applyNodes. A fresh 2026-06-12 capture on stable 0.64.15 (which
already contains the mitigations from
#5708,
#5846,
#5855, and
#5859) shows the identical signature:
128% CPU, 400 threads, debug socket refusing connections, 3344/3715 main-thread
samples inside flushTransactions. The rows-height key is the last live
write-during-layout edge in the sidebar after
#5325 (frame anchors) and
#5708 (row IDs) removed their siblings.

Same approach as #5852, re-ported on
top of the #5846 pixel-alignment work
(contentMinHeight flooring is kept; only the empty-area math moves into the
Layout).

Fixes #5764.
Helps #2586,
#5570,
#5845.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: create bundled helper directory before install

* Sidebar: replace render-item String id with an allocation-free Hashable enum

ForEach(renderItems, id: \.id) gathers every row's identifier on each list
diff, and the sidebar re-diffs all rows per update. The previous computed
String id ("workspace.\(uuid.uuidString)") allocated and formatted a fresh
36-char string per access; SidebarWorkspaceRenderItem.id.getter was the
hottest app-owned frame in the
#5764 livelock spindump.

SidebarWorkspaceRenderItemID is a two-case enum over UUID: identity compare
and hash with zero heap allocation, and group headers can never collide with
workspace rows on the same UUID (same guarantee the string prefixes gave).
Identity values are unchanged in meaning, so row lifetime and animations are
unaffected; nothing persisted the string form (the only consumers are the
ForEach key path and scrollTo, which targets the explicit inner .id(tab.id)
UUIDs, not the ForEach identity).

Pure per-pass cost cut for #5764,
#5845,
#2586; complements the structural
loop fix in #6019.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Sidebar: make rows height-stable under agent churn (no height animation, eager markdown)

Two changes that stop agent activity from continuously varying sidebar row
heights, which kept re-feeding the sidebar-wide layout/measurement cycle at
animation frame rate (#5764,
#5845):

1. Remove the three implicit .animation(value:) modifiers on agent-mutable
   snapshot fields (latestLog, progress, metadataBlocks.count) and reduce the
   four height-moving .transition(.opacity.combined(.move(edge: .top)))
   modifiers in TabItemView's log/progress/metadata sections to
   .transition(.opacity). While a row-height animation runs, every frame
   produces a different LazyVStack content height; with dozens of agent
   sessions some row is always animating. Content changes now apply in one
   discrete layout pass.

2. SidebarMetadataMarkdownBlockRow parsed its markdown in onAppear into
   @State: a guaranteed nil -> attributed swap (and height change) on every
   first appearance of every block scrolling in. It now renders inline via a
   new SidebarMetadataMarkdownRenderer with a bounded (512-entry) memo cache,
   so the FIRST render is already attributed and appearance performs no state
   write and no height change. Matches the SidebarWorkspaceDescriptionText
   sibling, plus memoization to keep repeat body evals cheap and growth
   bounded.

WWDC backing: lazy rows must be height-stable after appearing; initialize row
state in the initializer, not onAppear (WWDC26 "Dive into lazy stacks", 321);
keep body cheap / precompute (WWDC23 10160, WWDC25 306).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Cache failed parses with updateValue (subscript assignment drops nil values)

With [String: AttributedString?], `cache[markdown] = parsed` removes the key
when parsed is nil, so unparseable blocks re-parsed on every body eval and
appended phantom keys to insertionOrder, mis-evicting valid entries once at
capacity. Caught by Greptile on the PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Byte-bound the metadata markdown cache (autoreview P1)

The 512-entry cap bounded entry count but not retained bytes. Metadata blocks
are agent/control-socket supplied and uncapped at this boundary, so a key
churning large unique markdown could keep hundreds of big payloads alive after
the workspace metadata was overwritten or cleared (worse than the old
row-local @State, which released on update).

Skip caching blocks over 4096 UTF-8 bytes: they parse inline each eval (rare,
still attributed from the first frame), and total retained cache bytes are now
bounded by capacity * maxCacheableBytes regardless of churn.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Plain-text fallback for oversized metadata blocks (autoreview P1)

Parsing >4KB blocks inline (previous commit) removed the retention but moved
the cost to CPU: TabItemView.body re-runs on snapshot changes under agent
churn, so a large block reparsed each time. Return nil for oversized blocks
instead, so the row falls back to the existing Text(block.markdown) plain
path: no parse, no retention, and height-stable (the result never changes for
a given block, so no nil->attributed swap). Small blocks still cache and
render as markdown.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Size the sidebar empty area from an explicit viewport, not the layout proposal (autoreview P2)

SidebarRowsFillLayout derived its container height from
proposal.replacingUnspecifiedDimensions(). A vertical ScrollView leaves the
scroll-axis height unspecified, so that fell back to a 10pt placeholder and the
empty area collapsed to 0 whenever the rows fit the viewport — dropping the
blank area below the last row out of the double-click/drop target.

Pass the viewport height (minHeight, the floored content height the call site
already computes from the scroll geometry) into the layout explicitly and size
the empty area from it. New emptyAreaFillHeight(viewportHeight:rowsHeight:)
overload encodes container = max(viewport, rows).

Verified at runtime via temporary instrumentation (since removed): rows fit ->
viewport=628 rows=421 empty=207 and rows=370 empty=258; rows overflow ->
viewport=628 rows=676 empty=0. Added unit coverage for both the fit and
overflow viewport paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai coderabbitai Bot mentioned this pull request Jun 26, 2026
3 tasks done
ShubhamPatilsd pushed a commit to emergent-inc/mosaic that referenced this pull request Jul 9, 2026
…021 + 6026) (#6033)

* Sidebar: remove whole-content rows-height measurement (fixes layout livelock)

Replace the LazyVStack background GeometryReader ->
SidebarWorkspaceRowsHeightPreferenceKey -> @State workspaceRowsMeasurement ->
emptyAreaHeight round trip with SidebarRowsFillLayout, a custom Layout that
places the rows at their natural height and stretches the empty drop/tap area
to fill the remaining viewport from its own concrete bounds, in one geometry
pass with no state writes.

The preference write during layout fed a non-converging relayout transaction:
main thread pinned 100%+ in GraphHost.flushTransactions ->
LazySubviewPlacements.placeSubviews -> LazyStack.place ->
ForEachList.applyNodes. A fresh 2026-06-12 capture on stable 0.64.15 (which
already contains the mitigations from
manaflow-ai/cmux#5708,
manaflow-ai/cmux#5846,
manaflow-ai/cmux#5855, and
manaflow-ai/cmux#5859) shows the identical signature:
128% CPU, 400 threads, debug socket refusing connections, 3344/3715 main-thread
samples inside flushTransactions. The rows-height key is the last live
write-during-layout edge in the sidebar after
manaflow-ai/cmux#5325 (frame anchors) and
manaflow-ai/cmux#5708 (row IDs) removed their siblings.

Same approach as manaflow-ai/cmux#5852, re-ported on
top of the manaflow-ai/cmux#5846 pixel-alignment work
(contentMinHeight flooring is kept; only the empty-area math moves into the
Layout).

Fixes manaflow-ai/cmux#5764.
Helps manaflow-ai/cmux#2586,
manaflow-ai/cmux#5570,
manaflow-ai/cmux#5845.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: create bundled helper directory before install

* Sidebar: replace render-item String id with an allocation-free Hashable enum

ForEach(renderItems, id: \.id) gathers every row's identifier on each list
diff, and the sidebar re-diffs all rows per update. The previous computed
String id ("workspace.\(uuid.uuidString)") allocated and formatted a fresh
36-char string per access; SidebarWorkspaceRenderItem.id.getter was the
hottest app-owned frame in the
manaflow-ai/cmux#5764 livelock spindump.

SidebarWorkspaceRenderItemID is a two-case enum over UUID: identity compare
and hash with zero heap allocation, and group headers can never collide with
workspace rows on the same UUID (same guarantee the string prefixes gave).
Identity values are unchanged in meaning, so row lifetime and animations are
unaffected; nothing persisted the string form (the only consumers are the
ForEach key path and scrollTo, which targets the explicit inner .id(tab.id)
UUIDs, not the ForEach identity).

Pure per-pass cost cut for manaflow-ai/cmux#5764,
manaflow-ai/cmux#5845,
manaflow-ai/cmux#2586; complements the structural
loop fix in manaflow-ai/cmux#6019.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Sidebar: make rows height-stable under agent churn (no height animation, eager markdown)

Two changes that stop agent activity from continuously varying sidebar row
heights, which kept re-feeding the sidebar-wide layout/measurement cycle at
animation frame rate (manaflow-ai/cmux#5764,
manaflow-ai/cmux#5845):

1. Remove the three implicit .animation(value:) modifiers on agent-mutable
   snapshot fields (latestLog, progress, metadataBlocks.count) and reduce the
   four height-moving .transition(.opacity.combined(.move(edge: .top)))
   modifiers in TabItemView's log/progress/metadata sections to
   .transition(.opacity). While a row-height animation runs, every frame
   produces a different LazyVStack content height; with dozens of agent
   sessions some row is always animating. Content changes now apply in one
   discrete layout pass.

2. SidebarMetadataMarkdownBlockRow parsed its markdown in onAppear into
   @State: a guaranteed nil -> attributed swap (and height change) on every
   first appearance of every block scrolling in. It now renders inline via a
   new SidebarMetadataMarkdownRenderer with a bounded (512-entry) memo cache,
   so the FIRST render is already attributed and appearance performs no state
   write and no height change. Matches the SidebarWorkspaceDescriptionText
   sibling, plus memoization to keep repeat body evals cheap and growth
   bounded.

WWDC backing: lazy rows must be height-stable after appearing; initialize row
state in the initializer, not onAppear (WWDC26 "Dive into lazy stacks", 321);
keep body cheap / precompute (WWDC23 10160, WWDC25 306).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Cache failed parses with updateValue (subscript assignment drops nil values)

With [String: AttributedString?], `cache[markdown] = parsed` removes the key
when parsed is nil, so unparseable blocks re-parsed on every body eval and
appended phantom keys to insertionOrder, mis-evicting valid entries once at
capacity. Caught by Greptile on the PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Byte-bound the metadata markdown cache (autoreview P1)

The 512-entry cap bounded entry count but not retained bytes. Metadata blocks
are agent/control-socket supplied and uncapped at this boundary, so a key
churning large unique markdown could keep hundreds of big payloads alive after
the workspace metadata was overwritten or cleared (worse than the old
row-local @State, which released on update).

Skip caching blocks over 4096 UTF-8 bytes: they parse inline each eval (rare,
still attributed from the first frame), and total retained cache bytes are now
bounded by capacity * maxCacheableBytes regardless of churn.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Plain-text fallback for oversized metadata blocks (autoreview P1)

Parsing >4KB blocks inline (previous commit) removed the retention but moved
the cost to CPU: TabItemView.body re-runs on snapshot changes under agent
churn, so a large block reparsed each time. Return nil for oversized blocks
instead, so the row falls back to the existing Text(block.markdown) plain
path: no parse, no retention, and height-stable (the result never changes for
a given block, so no nil->attributed swap). Small blocks still cache and
render as markdown.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Size the sidebar empty area from an explicit viewport, not the layout proposal (autoreview P2)

SidebarRowsFillLayout derived its container height from
proposal.replacingUnspecifiedDimensions(). A vertical ScrollView leaves the
scroll-axis height unspecified, so that fell back to a 10pt placeholder and the
empty area collapsed to 0 whenever the rows fit the viewport — dropping the
blank area below the last row out of the double-click/drop target.

Pass the viewport height (minHeight, the floored content height the call site
already computes from the scroll geometry) into the layout explicitly and size
the empty area from it. New emptyAreaFillHeight(viewportHeight:rowsHeight:)
overload encodes container = max(viewport, rows).

Verified at runtime via temporary instrumentation (since removed): rows fit ->
viewport=628 rows=421 empty=207 and rows=370 empty=258; rows overflow ->
viewport=628 rows=676 empty=0. Added unit coverage for both the fit and
overflow viewport paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview – cmux — 8f357b5d Deployed Jun 11, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant