Two O(N)-per-tick refolds remained on the MainActor after the 0.4s throttle
and structural toolGroupSignature landed:
1. AppStore.regenerateToolGroupSummaries (and its staleness guards +
open-time history baseline) re-derived summarizable units by refolding
the ENTIRE open transcript per invocation — 10-100ms+ on 30k-event
sessions. The store now owns a dedicated IncrementalTranscriptProjection
and derives units via summarizableUnits(events:), a second lazily-primed
consumer of the projector's existing seam: every display-valid seal seam
is units-valid (the units fold breaks tool runs at strictly more rows,
and seams only ever land after hard separators common to both folds).
Each consumer folds only when requested, so the view's instance never
folds units and the store's never folds display items or lock lines.
2. IncrementalTranscriptProjection capped its watermark at an open subagent
Task span's firstRef, so a long run refolded its whole (unbounded) scope
on every display tick — O(scope²) across the run. Three additions bound
the per-read cost to what actually changed:
- TailCache: the unsealed tail's partition (topLevel/byParent/parentOf)
is appended to per read instead of rebuilt.
- ScopeChildProjection: each subagent scope seals its provably closed
children (result-fenced tools, superseded messages) under the same
seam rules, recursively for nested scopes; sealed cards' descendant
scopes are cursor-snapshotted and a late event into a result-fenced
scope triggers a scope-local self-healing refold.
- WatermarkHint: when a full scan seals nothing, it records the pinning
open entities; later reads validate only new events against the pins
(sealed-id fence and seq monotonicity still checked per event), so a
pinned seam costs O(new) instead of O(tail) per tick. The fast path
never seals, so a missed trigger can only delay sealing, never corrupt
output.
Exact-output preserved: the equivalence suite now also asserts
summarizableUnits(prefix) == TranscriptProjection.summarizableUnits(prefix)
for every prefix on both a shared and a units-only instance, plus new
scenarios (long open scope with sealing/full-scan hooks, fan-out straggler,
open grandchild recursion, late-event self-heal, late units priming, root
flip leaving units intact) and open-scope fuzz. 27/27 projection tests green.
Co-Authored-By: Claude Fable 5 <[email protected]>
16 KiB
Main-Thread Performance Plan
Status (2026-07-20)
- 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 — incremental projection done; off-main move deferred.
IncrementalTranscriptProjection(stable-prefix sealing, live-tail refold, full-fold fallback on any doubt) is wired intoSessionDetailView.projectedTranscript(). Prefix-equivalence suite:IncrementalTranscriptProjectionTests. Moving the remaining O(tail) fold off the main actor is deferred until measurement shows it matters. 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. - 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 installs every reconstructed controller and publishes every re-published summary in one synchronous main-actor commit (upsertSummaries; DB writes follow after). 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
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, measure eager VStack layout separately. If settled rows still
dominate frame time, introduce segmented virtualization or cached row heights without regressing
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.
- Batch reconstructed controller installation and summary publication on the main actor.
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.