21 KiB
Main-Thread Performance Plan
Status (2026-07-31)
- Item 2 — done.
SessionUIProjector(one actor per live session) reduces each controller stream off-main and commits batched deltas (~11/s open, ~3/s background); wired throughAppStore.observe/ingestUI. Contract tests:SessionUIProjectorTests. - Item 3 — done.
SessionController.ingeststampsupdatedAtonly for user-visible activity or a status transition;lastSeqalone advances on streaming deltas, so summaries are value-identical during a token burst andupsertSummary's no-op guard stops the sidebar/Recents/dashboard invalidation at the source. Regression test:streamingDeltasDoNotRestampUpdatedAt. (The ID-keyed summary index was judged not worth the churn once upserts dropped to one per coalesced batch.) - Item 4 — done, including off-main projection.
IncrementalTranscriptProjection(stable-prefix sealing, live-tail refold, full-fold fallback on any doubt) is owned by a per-open-chatTranscriptProjectionWorkeractor. Projection, display filtering, auth cleanup, and segment-range construction all run away from the main actor; SwiftUI reads only the worker's immutable cached result. A worker is replaced on selection, so an abandoned giant fold cannot serialize the newly opened chat behind it. During startup, the bounded tail preview remains interactive while authoritative full-history projection completes. Prefix-equivalence suite:IncrementalTranscriptProjectionTests; preview-to-full worker replacement is covered byTranscriptRenderSegmenterTests. Extended 2026-07-28: (a) the store's throttled summary pass (AppStore.regenerateToolGroupSummariesand its staleness guards) now derives its units through the same projector (summarizableUnits(events:), a second lazily-primed consumer of the shared seam) instead of refolding the whole open transcript per invocation; (b) the per-read cost during a long subagent run is bounded — the tail's partition is cached and appended to (TailCache), each open scope's closed children seal under a per-scope projector (ScopeChildProjection, recursive, cursor-verified against late events into result-fenced scopes), and a pinned watermark records why it can't advance (WatermarkHint) so quiet ticks validate only the new events instead of rescanning the unsealed region. Previously the open Task's whole scope refolded on every display tick — O(scope²) across the run. Extended 2026-07-31/2026-08-01: render rows use stable two-row batches (reduced from twelve, then six, after cold-open tuning). DirectLazyVStackvirtualization was rejected after completed transcripts demonstrated estimate corrections while scrolling: mixed-height Markdown/tool batches moved the document even with no live output. Extended 2026-08-01: histories beyond 96 projected rows now use those batches as native macOSListrows. Its AppKit-backed reuse removes off-screen SwiftUI/TextKit hierarchies while retaining resolved variable row heights; shorter histories keep exact eager geometry. Segment ranges append incrementally, and Equatable boundaries let settled subtrees and sizes be reused while only the live batch changes. Initial/passive following keys off the rendered tail revision (so autoship notes and other log entries count as the end); the scroll surface's first appearance is gated byAppStore.openTranscriptAvailableSessionID: during cold startup a reverse 48-event/128-KiB cursor can publish the newest canonical tail before the controller's full decode completes;openTranscriptReadySessionIDremains the separate authoritative edge raised after the ordered snapshot→history pull reaches the controller's known tail. Unread-marker writes, Nash viewer hydration, and whole-history tool-summary bookkeeping follow that readiness edge instead of withholding the canvas. Continuous scroll geometry is quantized into three threshold regions before it reaches the main actor. Long-history top-level Markdown warms continuously newest-to-oldest on two bounded utility-QoS lanes. This replaces one detached task, signpost, actor round-trip, and set mutation per two-row segment. Native rows have local readiness state: a visible row performs an independent user-initiated cache fill while the background walk keeps advancing, and completing it does not invalidate the root transcript or every other List row. Recycling cancels only that row's cache fill; there is no cancel/restart priority barrier. Off-main projection likewise continues during interaction/deceleration, while canceled obsolete preview revisions are rejected before they can queue another whole-history fold. After the first real tail chunk exists, an explicit unanimated pin targets the fixed sentinel below all prose, autoship/lock/status rows, and bottom padding rather than relying onList's early estimated bottom. Every segment has a cheap row-countlengthand an immediate transparent slot. A derived, per-session binary sidecar retains measured vertical lengths by content width/filter/revert epoch, but loads opportunistically and never gates first text. Its in-memory form is dictionary-backed and unobserved: height lookup/recording is O(1), and measuring a row no longer writes root@Stateor rebuilds the whole List. Cold misses use content-derived estimates computed with projection off-main, so a multi-page response reserves roughly its actual canvas instead of 48 points and no longer makes the document grow repeatedly during a fast scroll. Unmounted slots draw only a constant-size pair of neutral bars. Placeholder and real content are exclusive, unanimated branches—no fake-text typesetting, gradient mask, blur, infinite sweep, or simultaneous focused/unfocused layers. Collapsed tool/subagent descendants remain outside the mount path and join the background-priority durable snapshot.TranscriptProjectionandMarkdownPrewarmretain coarse instrumentation without per-chunk logging overhead. Contracts:TranscriptRenderSegmenterTests,TranscriptRenderLengthCacheTests,TranscriptScrollFollowPolicyTests,AppStoreTests.transcriptBecomesReadyBeforeNashHistoryFinishesLoading. - Transcript Markdown cold-open cache — done. Assistant prose is prewarmed off-main. Both
layout modes warm only Markdown directly present in their first top-level render set before
layout; long virtualized histories limit that set to the newest two-row segment. Collapsed nested
streams and the full disk snapshot load/refresh at background priority after reveal. A
per-session, schema-versioned binary sidecar under
Application Support stores renderer-neutral inline runs (text, presentation intent, link), keyed
by the message SHA-256; it never archives SwiftUI views, fonts, widths, or row geometry. Files are
atomic, owner-only, excluded from backup, and bounded by a 128 MiB/96 MiB high/low-water budget.
TranscriptFirstPaintspans selection through visible, bottom-pinned text and logs the projected row count/layout mode;MarkdownPrewarmplus structural hit/miss logs split memory hits, disk hits, and real Foundation Markdown parses. Contracts:MarkdownRenderDiskCacheTests. - Item 5 — done. Literal
typepayloads over the host surface are bounded (MacVMEngine.typeLongSurfaceText): text ≤ 64 chars keeps the direct low-latency path; longer text routes through the in-guest agent'stypeop when available (typed in-guest, no host main-actor cost), else is synthesized in 32-character chunks with a 10 ms suspension between them. The chunk loop runs on the (cancellable) MCP handler task, so a stopped action ends at the next chunk boundary; chunks split only between complete strokes, so no modifier is left held. Tests:typeChunksAreBoundedAndLossless,directTypeThresholdKeepsShortTextOnTheDirectPath. - Item 6 — done. The launch
.taskkeeps project/session hydration strictly ordered (loadProjects→loadSessions(deferringStartupMaintenance: true)), then runs activity-feed seeding, todo loading, and provider probing concurrently (async let).loadSessionsPhase 2 now reads transcripts through a three-slot bounded pool and installs each reconstructed controller as soon as its transcript is ready. A chat selected while cold hydration is still running receives a foreground overflow slot and is explicitly reloaded as soon as its controller exists, so it cannot sit behind unrelated long transcripts. Archived transcripts form a deferred tier: none begins while an active transcript is still hydrating, and they run at background QoS unless the user explicitly opens one. Transcript JSONL decoding is chunked to avoid additional whole-file/line-array copies; DB cache writes follow the observable install. Lock/nvrsion reconciliation runs on its own store-owned task; commands —createSessionand all three send seams — gate onawaitStartupLockReconciliation()instead of the reconcile blocking hydration. Container and VM-disk GC reconcile concurrently (reconcileSessionEnvironments), and the rest of the maintenance tail (transfer recovery, Orchestra-orphan + scratch-dir cleanup, keyword backfill, stale-base re-provision) runs exactly once after first paint viarunDeferredStartupMaintenanceOnce()— ordered after the lock gate so an orphan delete can never racereconstructLocks. Non-launch callers (revert/undo reloads, nucleicd, tests) keep the original inline tail via the default parameter. Startup signposts (StartupSignposts, subsystemcom.nucleic, categorystartup):LaunchToFirstFrame,Hydration,LockReconcile,DeferredMaintenance— this is item 9's "startup hydration and maintenance" instrumentation slice. - Item 7 — done.
MacVMEngine.changes()publishes value-deduped, name-sorted[MacVMEntry]snapshots (latest-wins buffering, MergeQueue's subscriber idiom) from every registry mutation — boot, readiness/IP flip, stop, suspend (RAM/disk), resume, guest power-off — re-exposed viaMacVMManager.vmChanges()/AppStore.macVMChanges()and primed on subscription.RootView.watchAutoVMMonitornow for-awaits that stream (chat switches, settings flips, and PiP fan picks re-derive via their own triggers against the cached snapshot) with a 30 s reconciliation backstop replacing the 2 s poll; every derived assignment is compared first. The remaining 2 s loops were left deliberately: the VM-monitor/PiP capture loops are frame-cadence work, and the Settings VM tab / Control panel polls aggregate base-build/lock/autoship state beyond the registry (Control already no-op-guards its snapshot). Contract test:registryChangeStreamPrimesEachSubscriberWithCurrentSnapshot. - Item 8 — done.
BuildRunRunnerdrains/decodes via an off-mainConsoleBufferactor and applies bounded batches (~12/s) with a guaranteed final flush. - Related (hang-report driven): every
NSSavePanel/NSOpenPanelrunModalcall is now async (begin()/beginSheetModal) — the Jul 18 00:39 hang report caughtexportMeshLogswedging the app in a nested modal run loop. The twoDispatchSemaphorewaits (LoginShellPATH,OAuthLoopback) were audited: both are reachable only from actor executors, never the main actor. - Items 1 and 9 remain (item 9's startup-instrumentation half is already covered by item 6's
StartupSignposts; the remaining signposts and the scenario/frame-time measurement pass are still open).
Objective
Keep the macOS desktop UI responsive while several sessions and virtual machines are active. The target is frame-time stability, not merely low aggregate CPU usage: background work must not create long or continuous slices on the main actor.
Completed foundation
The first VM-specific changes landed on dev in merge commit a3cff9613:
- VM boot now registers a host surface without immediately creating a
VZVirtualMachineView, off-screenNSWindow, or WindowServer render workload. - Capture, HID, and operator viewing materialize the AppKit surface on demand.
- An inactive, off-screen surface is released after a five-second grace period while retaining the VM registration and cursor state for later reactivation.
- All VM framebuffer captures share one global 30-grab-per-second budget. A single monitor retains its
current cadence; multiple monitors divide the budget instead of multiplying synchronous
cacheDisplaycalls. - The merge preserved
dev's queued mouse-gesture implementation, which avoids blocking in AppKit's mouse-tracking loop.
Remaining work, in order
1. Verify and instrument the VM surface lifecycle
Before expanding the concurrency changes, establish that the new lifecycle is correct and quantify its effect.
- Build the macOS app and run the full Swift test suite.
- Add debug counters or signposts for registered surfaces, materialized surfaces, framebuffer grabs, and main-thread capture duration.
- Add coverage for these transitions:
- headless VM boot: registered, no AppKit surface;
- first monitor capture: surface materializes and produces a nonblank frame;
- monitor disappears: surface releases after the grace period;
- later capture or HID action: surface rematerializes;
- operator-assist window: never reaped while on screen;
- VM stop/restart during activation or capture: no stale view survives;
- suspended VM: frozen frame remains available without keeping a live surface.
- Manually compare UI frame pacing with zero, one, and several VMs, both with PiP hidden and visible.
Acceptance criteria:
- A VM used only by
mac_vm_exechas no materializedVZVirtualMachineViewafter the grace period. - Aggregate
cacheDisplayfrequency never exceeds the global budget. - Computer-use input, diagnostic viewing, PiP, suspension, and resume continue to work.
- No surface-related main-actor slice exceeds one display-frame budget under normal capture.
2. Move per-session event reduction off the main actor
AppStore.observe currently creates one task per session from an @MainActor context. Every event
from every active session therefore enters the same serialized ingestUI path, even when the session
is in the background.
Implement a SessionUIProjector actor (one per session, or an equivalently isolated keyed service):
- Subscribe to the
SessionControllerstream outsideAppStore's main-actor executor. - Reduce raw events into a small
SendableUI delta containing only changed summary fields and any open-session transcript tail. - Publish immediately for semantic boundaries:
- approval requested or resolved;
- user question;
- error;
- run/session status transition;
- terminal event.
- Coalesce ordinary streaming text, thinking, usage, and progress events:
- open session: target 10–12 UI commits per second;
- background session: target 2–4 commits per second;
- always flush the latest pending delta before a terminal event.
- Apply the resulting batch in one short main-actor transaction.
Keep canonical transcript writing, event order, sync delivery, conflict arbitration, and persistence semantics unchanged. UI coalescing must never discard canonical events.
Required tests:
- event order and terminal flush;
- immediate approval/error delivery during a streaming burst;
- cancellation and session deletion with a pending batch;
- several sessions projecting concurrently without cross-session state leakage;
- open-session switching while old-session work is in flight;
- equivalence of final summaries before and after coalescing.
3. Stop invalidating the whole sidebar for every event
SessionController advances lastSeq and updatedAt for each canonical event. Reconstructing and
writing the full SessionSummary consequently invalidates the observable summaries collection during
ordinary token streaming.
- Separate canonical event freshness from sidebar-visible activity fields.
- Publish sidebar ordering timestamps only for meaningful user-visible activity, not every text delta.
- Replace repeated linear
firstIndexsummary updates with an ID-keyed index or stable per-session row models. - Batch summary mutations from step 2 so one scheduler turn produces one observable update.
- Ensure recents ordering, unread completion, approvals, favorites, archive state, and mesh summaries still update immediately when their visible value changes.
Acceptance criterion: a background session streaming prose must not cause full-sidebar recomputation at token cadence.
4. Incrementalize macOS transcript projection — completed
The open chat currently pulls and reassigns full history, then performs an O(n) projection whenever the transcript version changes. Port the stable-prefix incremental projection already implemented for iOS.
- Seal completed turns and immutable tool/message groups.
- Re-project only the live tail for each streaming delta.
- Keep late lock-note folding correct across the sealed/live boundary.
- Move pure projection work to a non-main actor and return immutable projected segments to SwiftUI.
- Preserve a full-fold fallback when stream identity changes, history shrinks, or a sealed identifier is referenced unexpectedly.
- Add prefix-by-prefix equivalence tests against
TranscriptProjection.itemsfor representative Claude, Codex, ACP, subagent, approval, error, and lock streams.
After projection is incremental, keep stable render batches on both layout paths: exact eager layout through 96 rows, then native macOS list virtualization for longer histories. Do not restore direct lazy-stack virtualization; its changing mixed-content estimates regress inertial scrolling and initial bottom anchoring.
5. Bound main-actor VM text injection
Literal VM typing currently synthesizes every character in one main-actor call.
- Route long text through the guest agent or pasteboard path when available.
- For synthesized input, send bounded chunks and yield between them.
- Keep key chords and short text on the low-latency direct path.
- Add cancellation handling so a stopped computer-use action does not continue typing.
Acceptance criterion: typing a large payload must not block host scrolling or keystrokes for the duration of the payload.
6. Parallelize noncritical startup work
Keep project and session hydration ordered, then split independent work from the first-paint path.
- After sessions are installed, run activity-feed seeding, todo loading, and provider probing concurrently where their data dependencies allow it.
- Run container reconciliation and VM-disk reconciliation concurrently.
- Defer stale-base checks, orphan cleanup, and other maintenance until after the first window paint.
- Keep commands gated until any required lock/nvrsion reconciliation is complete rather than blocking unrelated UI hydration.
- Install reconstructed controllers incrementally on the main actor, with a user-selected queued transcript promoted ahead of background hydration.
Add a startup signpost spanning process launch to first interactive frame, plus separate spans for hydration and deferred maintenance.
7. Replace polling with change streams
- Publish VM registry changes from
MacVMManager/MacVMEnginethrough anAsyncStreamand replace the two-secondRootView.watchAutoVMMonitorpoll. - Reuse the same stream for settings and Control-panel VM lists where possible.
- Keep a low-frequency reconciliation poll only as a defensive backstop.
- Avoid assigning observable state when the derived value is unchanged.
8. Batch high-volume process output
BuildRunRunner appends output to main-actor observable state one line at a time.
- Drain and decode output off-main.
- Publish bounded batches on a short cadence or at process completion.
- Preserve stdout/stderr identity and the existing maximum-line cap.
- Apply the same pattern to any other live console that publishes per-line observable mutations.
9. Measure against frame-time acceptance criteria
Add signposts around:
AppStore.ingestUIand the replacement projector/commit phases;- sidebar summary commits;
- open-transcript projection and row layout;
- VM surface activation/release;
cacheDisplay, encoding, and decode;- VM HID and long-text injection;
- startup hydration and maintenance.
Exercise at least these scenarios:
- One foreground session streaming a long response.
- Four background sessions streaming concurrently.
- Two or more running VMs with monitors hidden.
- Two or more visible VM monitors/PiP cards.
- A long transcript streaming while background sessions and VMs are active.
- Long VM text injection while scrolling and typing in the host app.
Track p50, p95, and maximum main-thread slice duration, dropped frames, event-to-visible latency for approvals, and aggregate framebuffer grabs. Optimize for p95/max frame time rather than total CPU.
Guardrails
- AppKit and SwiftUI mutations remain on the main actor; move reduction and pure computation, not UI object access.
- Canonical event durability and ordering remain lossless even when UI updates are coalesced.
- Approval, question, error, and terminal state must never wait behind a cosmetic throttle window.
- Do not introduce an unbounded task, continuation, frame, or event queue.
- Every cross-actor result must be
Sendableor an explicitly reviewed single-owner handoff. - Preserve
dev's queued mouse-gesture path; synchronouswindow.sendEventfor down/up sequences can reintroduce the AppKit tracking-loop deadlock.