Files
nucleic/docs/MAIN_THREAD_PERFORMANCE_PLAN.md
T
abkslmandClaude Fable 5 b925c29df8 Incrementalize the summary pass and bound per-tick cost of long subagent runs
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]>
2026-07-28 16:55:17 -07:00

16 KiB
Raw Blame History

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 through AppStore.observe/ingestUI. Contract tests: SessionUIProjectorTests.
  • Item 3 — done. SessionController.ingest stamps updatedAt only for user-visible activity or a status transition; lastSeq alone advances on streaming deltas, so summaries are value-identical during a token burst and upsertSummary'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 into SessionDetailView.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.regenerateToolGroupSummaries and 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 type payloads 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's type op 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 .task keeps project/session hydration strictly ordered (loadProjectsloadSessions(deferringStartupMaintenance: true)), then runs activity-feed seeding, todo loading, and provider probing concurrently (async let). loadSessions Phase 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 — createSession and all three send seams — gate on awaitStartupLockReconciliation() 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 via runDeferredStartupMaintenanceOnce() — ordered after the lock gate so an orphan delete can never race reconstructLocks. Non-launch callers (revert/undo reloads, nucleicd, tests) keep the original inline tail via the default parameter. Startup signposts (StartupSignposts, subsystem com.nucleic, category startup): 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 via MacVMManager.vmChanges() / AppStore.macVMChanges() and primed on subscription. RootView.watchAutoVMMonitor now 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. BuildRunRunner drains/decodes via an off-main ConsoleBuffer actor and applies bounded batches (~12/s) with a guaranteed final flush.
  • Related (hang-report driven): every NSSavePanel/NSOpenPanel runModal call is now async (begin() / beginSheetModal) — the Jul 18 00:39 hang report caught exportMeshLogs wedging the app in a nested modal run loop. The two DispatchSemaphore waits (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-screen NSWindow, 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 cacheDisplay calls.
  • 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_exec has no materialized VZVirtualMachineView after the grace period.
  • Aggregate cacheDisplay frequency 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):

  1. Subscribe to the SessionController stream outside AppStore's main-actor executor.
  2. Reduce raw events into a small Sendable UI delta containing only changed summary fields and any open-session transcript tail.
  3. Publish immediately for semantic boundaries:
    • approval requested or resolved;
    • user question;
    • error;
    • run/session status transition;
    • terminal event.
  4. Coalesce ordinary streaming text, thinking, usage, and progress events:
    • open session: target 1012 UI commits per second;
    • background session: target 24 commits per second;
    • always flush the latest pending delta before a terminal event.
  5. 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 firstIndex summary 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.items for 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/MacVMEngine through an AsyncStream and replace the two-second RootView.watchAutoVMMonitor poll.
  • 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.ingestUI and 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:

  1. One foreground session streaming a long response.
  2. Four background sessions streaming concurrently.
  3. Two or more running VMs with monitors hidden.
  4. Two or more visible VM monitors/PiP cards.
  5. A long transcript streaming while background sessions and VMs are active.
  6. 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 Sendable or an explicitly reviewed single-owner handoff.
  • Preserve dev's queued mouse-gesture path; synchronous window.sendEvent for down/up sequences can reintroduce the AppKit tracking-loop deadlock.