# JIT Executor — Phase 3 plan and state

Living plan for Phase 3, "SwiftUI session-lifecycle integration + hot-reload."
Resume from here across sessions. Update it as work lands. Phase 1 (issue #183)
is on `main` via PR #185; Phase 2 via PR #186. Phase 3 tracks issue #189 on
branch `jit-phase3-session-integration` (PR #190).

## Sources of truth

- `docs/jit-executor-phase1-plan.md`, `docs/jit-executor-phase2-plan.md`
  (landed state, discoveries, gotchas).
- `prompts/jit-executor-design.md` §§2, 4, 5, 6, 7, 8.Phase-3 (on
  `previews-research`).
- Auto-memory: `project_jit_inprocess_harness`, `project_jit_dynamic_replacement`,
  `project_jit_spike_outcome`.
- Branch: `jit-phase3-session-integration` (off `main` at `4de49e2`).

## Goal

Wire the JIT executor into PreviewsMCP's session lifecycle. The existing
FileWatcher + Compiler + SessionResolver pipeline routes **structural** edits to
JIT-link instead of thunk-rebuild; **literal-only** edits stay on the
thunk/`DesignTimeStore` path (`LiteralRegionClassifier` already classifies
them). Run a real SwiftUI `View` body in the agent and render it, then drive
edits without restarting the daemon. The jump from Phase 2: Phase 2 only ran
nullary `@_cdecl` functions via `runAsMain`; Phase 3 runs a real `View` body and
renders, so the agent needs a SwiftUI/render harness and a calling surface
beyond `runAsMain`. The deferred SP5 (richer `SessionResolver`/JIT API) lands
here.

## Key decision (amended 2026-06-04): respawn-on-cap (capped-persistent)

Updates use **one persistent agent + a fresh `JITDylib` per edit**, with a
**background respawn every ~100 generations** (the cap). Not in-place
`write_mem` patching. This amends the original **respawn-first** decision
(below) after the generation-soak (`research/jit-poc/data/
run-soak-20260604T024308Z.log`, previews-research): across 500 generations
latency stays flat (link/render medians ~0.4ms; conformance scans do not slow),
while RSS leaks linearly ~87KB/generation because `__swift5_*` metadata cannot
deregister. The cap bounds the leak at ~+9MB and amortizes the ~70ms respawn
warmup to ~0.7ms/edit. Net per-edit: **~167ms capped-persistent vs ~233ms
respawn-per-edit**. Respawn remains the cleanup mechanism — just on the cap,
not on every edit. The original rationale and evidence:

- **Why respawn.** The Swift runtime has no deregister for `__swift5_proto` /
  `__swift5_types` (Phase 1 SP0d-D). A long-lived agent that JIT-links a new
  image per edit leaks metadata registrations permanently. Respawn (kill the
  agent, spawn a fresh one that loads the new image) is the clean teardown for
  exactly that leak.
- **Matches Apple.** The W3 empirical capture (design §2 "Per-edit address-list
  capture — RESOLVED") found Apple respawns `XCPreviewAgent` for every edit kind
  tested, with zero `write_mem`. The §2 patch-point table is the
  static-analysis universe for a future in-place path, not the Phase 3 path.
- **State.** Preserving runtime `@State` across a structural edit is **not** a
  Phase 3 goal (acceptance is latency-only). `DesignTimeStore` holds only
  design-time literal values, not runtime `@State`, so literal-state continuity
  is kept by re-seeding the store after respawn; runtime `@State` is lost on
  structural edits, same as Apple.
- **Provenance of the alternative.** The §5/§6 in-place `write_mem` +
  Begin/End/cancelUpdate handshake is the doc's *original* design, written
  before the W3 respawn evidence landed (git: design doc `c8056de` precedes the
  capture `76a7b34`). It is kept as a later, clearly-scoped chunk (P3.3), added
  only if it earns its keep. Phase 2 already proved the `write_mem` publish
  mechanism (P2.5).

## Assumptions

- The Phase 2 remote `JITSession` (socketpair + `SimpleRemoteEPC` +
  `SwiftEntrySectionPlugin` + per-session agent) is reused unchanged in shape.
- `Compiler.compileObject(source:moduleName:extraFlags:)` already emits the `.o`
  the JIT path needs.
- The macOS preview entry symbol is a `createPreviewView`-style `@_cdecl`
  returning a retained `NSHostingView` (see `BridgeGenerator`, the daemon render
  path in `PreviewsMacOS/HostApp.swift`).

## Unknowns (each a verification gate)

- **U-A:** can a spawned headless agent host `NSApplication` (`.accessory`) and
  render an `NSHostingView` offscreen to a bitmap, the way the daemon does
  today? (Gate for P3.1b.)
- **U-B (= Phase 1 U2):** does the Swift calling convention hold when we call a
  real `View`-body entry that returns a retained pointer, marshaled onto the
  agent's main thread, versus the trivial nullary functions Phase 2 ran? (Gate
  for P3.1b.)
- **U-C:** does respawn within a live daemon session re-establish the agent and
  re-seed `DesignTimeStore` fast enough to hit the design's <200ms structural
  target on a small module? (Gate for P3.2/P3.4.)
- **U-D:** what is the minimal seam in `PreviewSession`/`HostApp` to route
  structural edits to the JIT path without disturbing the literal-only
  `DesignTimeStore` fast path? (Gate for P3.4.)

## Subproblems and verification criteria

### P3.1 — Agent SwiftUI render harness

The big-jump risk. Split in two.

#### P3.1a — run a real SwiftUI `View` body in the agent — DONE
The agent `dlopen`ed only Core/Concurrency/Foundation/Dispatch, so a JIT-linked
object referencing SwiftUI failed to materialize ("Symbols not found" for
`SwiftUI.View` / `SwiftUI.Text`). Added a SwiftUI `dlopen`
(`/System/Library/Frameworks/SwiftUI.framework/SwiftUI`, `RTLD_GLOBAL`; AppKit
pulled transitively by dyld) so the process-symbol generator resolves SwiftUI
symbols. Fixture `swiftui_probe.swift` is a trivial `View` whose `body` builds a
`Text`; the remote test links it in the agent, evaluates the body, returns 7.
AppKit-free and main-thread-free on purpose, to isolate one unknown.
- **Verify (met):** `runsSwiftUIViewBodyRemotely` returns 7. 26 tests green in
  parallel, zero orphan agents. Commit `1fe37b3`.

#### P3.1b-i — main-thread invoke surface + `NSHostingView` construction — DONE
Restructured the agent so the EPC server runs on a background thread and the
main thread runs a CoreFoundation run loop (`CFRunLoopRunInMode` after
`NSApplicationLoad()`), freeing the main thread for AppKit. Added an executor
wrapper `__previewsmcp_run_on_main` (a bootstrap symbol) that `dispatch_sync`s a
JIT'd function onto the main queue and returns its `int32_t`, mirroring Apple's
`run_program_on_main_thread`. Host side: `previewsmcp_jit_session_run_on_main`
fetches that bootstrap symbol and calls it via
`callSPSWrapper<int32_t(SPSExecutorAddr)>`; Swift `runOnMain(symbol:)`. Fixture
`hosting_probe.swift` builds an `NSHostingView` on the main thread and returns 1
if its `fittingSize.width > 0`.
- **Verify (met):** `buildsHostingViewOnMainThreadRemotely` returns 1. Full
  suite 27 tests green, 12/12 parallel runs clean, zero orphan agents. U-A
  (Cocoa hosts in a headless agent) and U-B (Swift `View` entry on the main
  thread) retired for the construction case.

**Discovery (pre-existing race, fixed here):** moving the server off the main
thread changed timing enough to surface a latent data race. Native-target init
(`LLVMInitializeNativeTarget` / `…AsmPrinter`) ran under two separate
`std::call_once` flags, one in the in-process `makeJIT` and one in
`remote_session_create`. A concurrent first-time in-process + remote session
create then mutated LLVM's global `TargetRegistry` linked list from two threads,
corrupting it: `lookupTarget` either span forever (a hang, surfaced as agents
never torn down holding the test's inherited stdout pipe open, the P2.4 EOF
failure mode) or tripped a `SmallVector` assert. Fixed by unifying both paths
behind one shared `initNativeTargetOnce()` once-flag. Teardown is unchanged
(`session_destroy` resets the `LLJIT` then `SIGKILL`s the agent).

#### P3.1b-ii — render the view to a bitmap — DONE
Fixture `render_probe.swift` renders a known-color SwiftUI view via
`ImageRenderer` (`MainActor.assumeIsolated`, since `ImageRenderer.cgImage` is
main-actor), samples the center pixel from the `CGImage`, and returns it packed
as `Int32`; the test drives it through `runOnMain` and asserts the channels are
red. `ImageRenderer` was chosen over an `NSHostingView`-in-window snapshot
because it rasterizes headless without a window. Retires the rendering half of
U-A: the agent rasterizes a JIT-linked SwiftUI view to pixels.

**Discovery (the load-bearing one): U-A finally bit, and the slab is the fix.**
Bringing real SwiftUI into the agent surfaced three `EXC_BAD_ACCESS` crash modes,
all reading ~4 KB **past a mapped region** while the Swift/SwiftUI runtime walks
JIT'd type metadata (`swift_conformsToProtocol…`, AttributeGraph
`LayoutDescriptor` building, `NSHostingView` teardown). Root cause: the **remote**
path built `ObjectLinkingLayer(es)` with the **default per-allocation mmap**
memory manager, which scatters each section into its own page-rounded mmap;
reading one entry past a section lands in the unmapped gap right after it. This
is exactly Phase 2's deferred unknown **U-A** ("revisit only if a future large
object trips it") and the same class as Phase 1's unwind-slab gotcha. The six POC
scenarios were small enough to dodge it; SwiftUI's heavy metadata walking trips
it. **Fix (first attempt, REVERTED — see below):** a **contiguous slab** via the
shared-memory mapper (`SharedMemoryMapper` + `MapperJITLinkMemoryManager`); the
agent already hosted `ExecutorSharedMemoryMapperService`. With the slab the
section walks stay in-bounds and the suite was 40/40 green across parallel runs.

#### P3.1b-iii — the shared-memory slab is macOS-incompatible; anonymous mapper instead — DONE
The shared-memory slab worked for ~80 runs, then **every** remote session began
failing with `Failed to materialize symbols { (<Platform>,
{ ___mh_executable_header, ___dso_handle }) }: Permission denied`, and it did not
recover across a reboot. Root cause: the orc-rt `ExecutorSharedMemoryMapperService`
makes JIT memory executable with `mprotect(...PROT_EXEC)` on `MAP_SHARED` memory,
which **macOS denies (EACCES)**. This is standard macOS hardening, confirmed with
a standalone C program: `mmap` exec-from-start on shared memory is allowed, the
`mprotect` transition is not, in every context (unsigned, signed with
`allow-jit` / `allow-unsigned-executable-memory` / `get-task-allow`, under lldb).
The earlier 80 green runs were the permissive window; it closed mid-session and
stayed closed. **This was an expensive diagnosis** (it also masqueraded as a
"Compiler flags" problem during a confounded P3.2 attempt, since every variant
failed for the same shared-exec reason). NOTE: do not `pkill -9` loop hundreds of
JIT agents while debugging.

How Xcode gets around it: Apple's `XCPreviewAgent` carries **no** JIT entitlement
(only `get-task-allow`, same as ours), and `XOJITExecutor` populates the agent's
**anonymous** executable memory over the wire (`___xojit_executor_write_mem`),
never shared memory. `mprotect`-exec on anonymous memory is permitted.

**Fix (final):** replace the shared-memory slab with a **contiguous slab backed
by anonymous executor memory**, matching Apple. New code, no `third_party` patch:
- Agent: a mapper service (`__previewsmcp_anon_reserve` = anonymous `mmap`;
  `__previewsmcp_anon_initialize` = `memcpy` the wire-transferred content +
  `mprotect` + `InvalidateInstructionCache` + run finalize actions;
  `deinitialize` = no-op; `release` = `munmap`).
- Host: `PreviewsAnonymousMapper` (a `MemoryMapper` that keeps a local working
  buffer and ships segment content over the wire) driving
  `MapperJITLinkMemoryManager` for one contiguous reservation per session.

Two load-bearing details: (1) the agent must `InvalidateInstructionCache` after
writing exec segments, or ARM64 executes stale icache (intermittent garbage-
execution crash); (2) it **discards deallocation actions** — the JIT image is
process-lived in the respawn model (Phase 1 D3), so running JIT'd destructors at
teardown is unnecessary and was crashing (`mutex` EINVAL in a JIT'd dealloc
action). With both, U-A is fixed via anonymous contiguity and **the prior
post-result SwiftUI-teardown crashes also vanished**.
- **Verify (met):** suite **15/15 green** across parallel runs, zero agent
  crashes, zero orphans. Commit `a8d5909`.

**Two supporting fixes surfaced while diagnosing (asserts build, parallel
runner):**
- **Host target-init race (pre-existing).** `LLVMInitializeNativeTarget` ran under
  two separate `call_once` flags (in-process `makeJIT` and `remote_session_create`).
  A concurrent first-time in-process + remote create corrupted LLVM's global
  `TargetRegistry` (hang via `lookupTarget` spin, or `SmallVector` assert). Unified
  behind one `initNativeTargetOnce`. (Also serialized the in-process
  `LLJIT::initialize` with a mutex; `ORCPlatformSupport::initialize` raced across
  the shared in-process `LLJIT`.)
- **Agent teardown use-after-free (my regression from P3.1b-i).** The restructure
  destroyed the `Server` on the background thread right after `waitForDisconnect`,
  while the transport's listen thread was still in `handleDisconnect`. Fixed by
  `std::_Exit(0)` on disconnect (the host `SIGKILL`s the agent anyway, so clean
  unwinding has no value and is racy).

**Verify (met):** `rendersViewToBitmapOnMainThreadRemotely` returns a red pixel;
suite 40/40 green across parallel runs, zero orphan agents.

**Teardown crashes — RESOLVED by the anonymous mapper.** The shared-memory-slab
era left post-result teardown crashes (`NSHostingView` deinit, AttributeGraph
`Node::destroy`) that wrote crash reports without failing tests. Those are **gone**
with the anonymous mapper (P3.1b-iii): the contiguity removed the metadata
over-reads, and discarding deallocation actions removed the JIT'd-destructor
teardown path. The suite now runs with zero agent crash reports.

### P3.2 — Hot update via agent respawn — DONE
Edit the preview body, recompile to a new `.o`, respawn the agent, the new
render reflects the change. No in-place patching. "Respawn" at the `JITSession`
layer = destroy the old session (kills its agent) + create a new one (spawns a
fresh agent); the daemon orchestration (FileWatcher → recompile → respawn) is
P3.4.
- **Test** (`CompilerObjectTests.rerendersAfterRecompileInFreshAgent`, mirrors
  `reResolvesSymbolAfterRecompile`, **no new production code**): compile a
  render-probe source set to color A (red) via `Compiler.compileObject`, link in
  a remote session, `runOnMain` the render entry, assert the sampled pixel is
  red; then compile the **same source edited to color B** (blue), link in a
  **fresh** remote session, render, assert blue. Proves recompile → respawn →
  re-render with the real `Compiler`.
- **Confounded finding RESOLVED — the default target works.** The earlier claim
  that `Compiler.compileObject`'s default `-target arm64-apple-macosx14.0` fails
  in the JIT (minos 14 → Swift back-deployment referencing
  `___mh_executable_header`/`___dso_handle`) was an artifact of the macOS
  shared-exec outage, where every compile variant failed for the unrelated shm
  reason. Re-verified cleanly on the anonymous mapper: the default `Compiler`
  target links **and renders** in the agent. **No host-OS `target:` option was
  added** — `Compiler.compileObject` is unchanged.
- **Verify (met):** render v1 = red; recompile edited source; render v2 = blue,
  in a fresh agent. Suite 29/29, 10/10 parallel runs green, zero orphan agents,
  zero crash reports. Commit pending.

### P3.3 — Begin/End/cancelUpdate handshake (§5/§6) — CONDITIONAL
Only if no-restart with `@State` preservation later earns its keep. Bracket an
update so an in-flight render never observes a half-applied edit; cancel reverts
from the redo log. New wire verbs.
- **Verify (planned):** BeginUpdate → writes → EndUpdate → UpdateComplete drives
  a render; CancelUpdate mid-stream leaves the prior image rendering.

### P3.4 — Daemon session-lifecycle integration (pulls in SP5) — IN PROGRESS
Route structural edits from `PreviewSession`/FileWatcher to the JIT path instead
of dylib rebuild; literal-only edits stay on `DesignTimeStore`. The richer
`SessionResolver`/JIT API (Phase 1 SP5) lands here.

**Render-surface decision (agreed): agent-rendered bitmaps (model A).** The
structural path today renders **in the daemon process** (`loadPreview` dlopens
the dylib, calls `@_cdecl("createPreviewView")`, hosts the returned
`NSHostingView` in the daemon's window, snapshots via `cacheDisplay`,
HostApp.swift:80-153, :209). The JIT respawn path renders **inside the agent
process** instead, and the daemon serves `preview_snapshot` from the agent's
bitmap. Chosen over in-process JIT-in-daemon because in-process linking
reintroduces the per-edit Swift-metadata leak that respawn-first exists to avoid
(no `__swift5_proto`/`__swift5_types` deregister; Phase 1 SP0d-D). The cost of
model A is small here because **macOS is snapshot-only**: there is no macOS
touch/interaction tool to re-route (`preview_touch` is iOS-simulator-only and
already runs in its own on-device host-app process over a TCP socket via
`IOHIDEvent` injection; it is out of P3.4 scope). So only `preview_snapshot`
moves to the agent bitmap; interaction is untouched.

**New unknowns under model A (both de-riskable in the JIT module before any
`HostApp` change):**
- **U-E:** the agent must return a **variable-size bitmap** to the host, but
  `runOnMain` only returns an `Int32`. Needs a buffer-return surface (a new EPC
  byte-returning wrapper, or the agent writes the bitmap to a host-supplied path
  and the host reads the file).
- **U-F:** the agent must render the **real `Compiler` bridge** output (a
  `createPreviewView`-style entry returning a retained `NSHostingView`), not a
  self-contained `ImageRenderer` probe.

**Chunking (de-risk first):**
- **P3.4a — agent bitmap-return surface (U-E) — DONE.** File transport chosen:
  the agent renders and writes the bitmap to a host-supplied path, the host reads
  it. Needs **zero new EPC/C++ wire code** — it reuses the `runOnMain` `Int32`
  status surface and the existing bridge-source-templating pattern. Test
  `CompilerObjectTests.rendersBitmapToFileFromAgent`: template a render source
  with a unique temp PNG path, compile via `Compiler.compileObject`, link
  remotely, `runOnMain` render-and-write, host reads + decodes the PNG, center
  pixel is red. The EPC byte-return wrapper stays the fallback if file transport
  proves inadequate (it won't for snapshots). *Verify (met):* 30/30 green, 3/3
  parallel runs, zero orphans, zero crash reports. Commit pending.
- **P3.4b — real bridge renders in the agent (U-F) — DONE.** Added the render
  seam to `BridgeGenerator` (model A): `generateCombinedSource(renderOutputPath:)`
  emits a nullary `@_cdecl("renderPreviewToFile")` for macOS that builds the
  **same** `viewCode` as `createPreviewView`, rasterizes it headless via
  `ImageRenderer`, and writes a PNG to the baked path. Nullary keeps it on the
  `runOnMain` surface; the path is baked because the daemon recompiles per
  structural edit. `createPreviewView` is untouched, so the in-daemon path is
  undisturbed. Test `CompilerObjectTests.rendersRealBridgeToFileFromAgent` drives
  a real combined bridge (DesignTimeStore + `__PreviewBridge` + thunk'd user
  `#Preview`) through the agent and asserts the host-decoded PNG is green. No
  extra agent `dlopen` was needed (Observation comes in transitively via SwiftUI).
  *Verify (met):* 31/31 JIT green, 69/69 `BridgeGenerator` core green, zero
  orphan agents. Commit pending.
- **P3.4c — daemon seam (U-D).** Route structural edits to
  recompile → respawn → agent-render → serve `preview_snapshot`; literal path
  unchanged. Split:
  - **P3.4c-i — protocol seam + structural→agent snapshot.** Define a
    `StructuralReloader` protocol in `PreviewsCore` (JIT-free); `PreviewsJITLink`
    implements it; the executable composes them, injecting the real reloader only
    when `jitEnabled`. **Chosen on layering merit, not to appease CI** (base owns
    the abstraction, JIT module owns the mechanism, app wires them) — the `#if`
    alternative was rejected as the actual workaround. As a side effect the
    non-JIT build still compiles. A session's first `compile()` keeps the existing
    in-daemon dylib + `NSHostingView`; the first **structural** edit switches the
    session to agent-rendered and `preview_snapshot` serves the agent's PNG.
    Three steps:
    - **c-i-1 — protocol + `PreviewSession.compileObjectForJIT()` — DONE.** The
      protocol is `renderObject(at:entrySymbol:)`, agnostic to
      respawn-vs-capped-persistent (the impl picks). `compileObjectForJIT` emits a
      render-bridge `.o` (baked PNG path) returning a `JITRenderBuild`. *Verify
      (met):* `StructuralReloaderTests` (mock reloader) — the `.o` exports
      `_renderPreviewToFile` and the plumbing routes; 305/305 `PreviewsCore` green;
      Core stays JIT-free so the non-JIT build compiles by construction.
    - **c-i-2 — real `JITStructuralReloader` in `PreviewsJITLink` — DONE.**
      Implements the protocol over a remote `JITSession` (spawn agent →
      `addObject` → `runOnMain(entrySymbol)`, throws on non-zero status);
      respawn-per-edit via `JITSession` `deinit`. Required adding `PreviewsCore`
      as a `PreviewsJITLink` dependency (the JIT module now implements a Core
      protocol; also fixed a transitive `_SwiftSyntaxCShims` module error). Test
      `JITStructuralReloaderTests` drives a real `compileObjectForJIT` `.o` through
      the reloader and asserts the agent-written PNG is green. *Verify (met):*
      32/32 JIT green, 3/3 parallel, zero orphans, zero crash reports.
    - **c-i-3 — host wiring — DONE (pending CI non-JIT check).** (a) `PreviewHost`
      gained `structuralReloader: (any StructuralReloader)?` + an
      `agentImagePaths` map; `jitStructuralReload(sessionID:session:)` compiles the
      `.o`, calls `renderObject`, records the PNG; `watchFile`'s structural branch
      prefers it when injected, else the existing dylib path. (b)
      `MacOSPreviewHandle.snapshot` returns the agent PNG (via new
      `Snapshot.encode(imageAt:format:)`, PNG passthrough / JPEG transcode) when
      the session is agent-backed. (c) Composition root: one `#if PREVIEWSMCP_JIT`
      in `PreviewsMCPApp` injects `JITStructuralReloader()`; `Package.swift` adds
      `PreviewsJITLink` to `PreviewsCLI` and defines `PREVIEWSMCP_JIT` **only when
      `jitEnabled`**. Daemon logic stays `#if`-free (Core protocol only); the one
      conditional is at the app entry where composition belongs. *Verify (met):*
      `PreviewHostJITReloadTests` (host records the agent image; nil reloader falls
      through) + `MacOSPreviewHandleAgentSnapshotTests` (snapshot returns the agent
      PNG); macOS 3 / engine 9 / JIT 32 green; full JIT `swift build` green. The
      non-JIT build (`jitEnabled=false`) is verified by CI on push.
  - **P3.4c-ii — literal-after-structural.** Once a session is agent-rendered the
    view lives in the agent, so the in-daemon `DesignTimeStore` literal path can no
    longer update it (`applyLiteralChanges` would no-op for lack of a loader).
    - **Fallback — DONE.** `watchFile` gates the literal fast path on
      `agentSnapshotPath(for:) == nil`, so an agent-backed session routes **any**
      edit through the structural JIT reload. Correct but full-recompile.
    - **Proper path (value file-transport, mirrors the PNG choice).** The agent
      runs only nullary entries, so instead of calling the `designTimeSet*` setters
      over the wire, the render bridge **seeds `DesignTimeStore` from a baked JSON
      path** before rendering; a literal edit rewrites that JSON and re-renders the
      **same `.o`** (no recompile; ~62ms relink+render vs 281ms). Caveat: with the
      respawn-per-edit reloader each call still relinks; true ~10ms needs the
      capped-persistent agent (re-seed + re-render in place).
      - **c-ii-1 — DONE.** `BridgeGenerator.renderToFileEntryPoint` seeds
        `DesignTimeStore.shared.values` from `designTimeValuesPath` (JSON) before
        building the view; `compileObjectForJIT` bakes the path, writes the
        literals' initial values (`writeDesignTimeValues`), and `JITRenderBuild`
        gains `valuesPath` + `literals`. *Verify (met):* values JSON written with
        the literals (`StructuralReloaderTests.writesDesignTimeValues`); the agent
        render still produces the correct color through the seeded path; JIT 33 /
        BridgeGenerator 69 / PreviewsCore 306 green.
      - **c-ii-2 — DONE.** Test `literalRewriteReRendersSameObjectNoRecompile`:
        compile a `Color(white: 0.2)` preview once, render (dark), rewrite the
        white literal in the values JSON to 0.9, re-run `renderObject` on the
        **same `.o`** → brightness flips dark→light, no second compile.
      - **c-ii-3 — DONE.** `PreviewSession` caches the last `JITRenderBuild`
        (`lastJITBuild`) and adds `applyLiteralValuesForJIT(_:)` (merge the changed
        literals into the values JSON, return the build to re-render). `PreviewHost`
        gains `jitLiteralReload(sessionID:session:changes:)`; `watchFile`'s literal
        branch routes agent-backed sessions there (rewrite values + re-render, no
        recompile) instead of the fallback full reload, while non-agent sessions
        keep the in-daemon `DesignTimeStore` path. *Verify (met):*
        `PreviewHostJITReloadTests.literalReloadRewritesValuesAndRecordsImage`
        (values JSON updated, image recorded) + the c-ii-2 pixel flip; PreviewsCore
        306 / macOS 4 / engine 9 / JIT 34 green.
- **P3.4d — latency (U-C) — DONE (measured; compile-bound).** Integrated
  `compileObjectForJIT()` + `JITStructuralReloader.renderObject()` on a small
  module, steady-state (warm module cache), respawn-per-edit:
  **compile ≈ 218ms, link+respawn+agent-render ≈ 62ms, total ≈ 281ms**
  (`StructuralReloadLatencyTests`, machine-specific but indicative). The
  render/respawn half is well within budget; the **whole-module compile dominates
  and is the entire gap** to the <200ms target — exactly the "recompile-narrowing
  gaps" finding: respawn + JIT-link removes the *relink*, not the *compile*.
  Closing it needs the stable-module/editable-unit split (single-file `@testable`
  compile against prebuilt `.swiftmodule`s; the manager's W4-W7 research measured
  ~0.14s relink at 1000 files), which is the deferred compile-side lever, not a
  JIT-executor change. Capped-persistent (the ratified executor shape) further
  trims the respawn warmup from the 62ms (~70ms amortized to ~0.7ms/edit in the
  soak), but compile still gates until narrowed.

**Deferred infra (separate from architecture): JIT-in-CI.** CI skips the JIT
path only because the targets need `third_party/llvm-build` (multi-GB prebuilt
LLVM + orc-rt) that CI does not have; the `jitEnabled` gate makes the non-JIT
build green. Making CI **run** the JIT tests means caching or building that
artifact once and reusing it — a self-contained infra task that does **not**
shape the daemon design. Deferred (Phase 3 infra / Phase 4); tracked here. Until
then JIT tests stay local-only and the protocol seam keeps non-JIT CI green.

- **Verify (planned):** an `examples/` project: a literal edit hot-reloads via
  `DesignTimeStore` (existing path, ~10ms); a structural edit reloads via the
  JIT path (respawn), same daemon session, no daemon restart.

### P3.5 — Plan doc + PR — IN PROGRESS
This document, mirroring the Phase 1/2 plan docs, updated as work lands. PR #190
(draft), watched to green. CI does not build the JIT targets, so JIT tests are
local-only; the non-JIT build must stay green.

## Recompile-narrowing gaps (uncaptured — these gate the latency target)

P3.4 routes structural edits to the JIT respawn path, but as planned it still
recompiles the **whole module** every edit (`PreviewSession.compile()` passes the
full `buildContext.sourceFiles` to swiftc; `Compiler.swift:141`). Respawn +
JIT-link removes the full *relink*, not the full *compile*. On a 1000-file module
the compile dominates, so the design's <200ms target is unreachable until the
recompile is narrowed to the changed file. The design names the prerequisite
(`prompts/jit-executor-design.md:260`, "incremental swiftc") but neither piece
below is specified or built.

### G1 — Incremental compile (refined by W4)
**Missing.** A way to re-emit only the edited file's object. W4
(`research/scripts/analysis/w4-compile-side.md`) captured how Apple does this and
it splits by module boundary, because Swift compiles a module as a unit and a
module cannot import itself:
- **Cross-module (size-independent).** Dependency modules are built once to a
  `.swiftmodule` and consumed as prebuilt inputs (`-experimental-emit-module-
  separately`, `-I` + explicit `.swiftmodule`). An edit never re-parses them.
  This is the real "compile against a prebuilt module" shape.
- **Same module (size-dependent).** There is no prebuilt-interface trick. The
  frontend is handed the **full filelist** every edit to parse and type-check;
  `-incremental` + the `-output-file-map` then restrict the back end
  (SILgen/IRGen) to the changed file's `.o`. So per-edit cost = parse/bind all N
  files + codegen one file. Apple measured ~1.3s on 61 files; this grows with N.
  An edit to a referenced public declaration also re-emits its dependents, so the
  JIT must relink the **set** of changed objects, not always one.

`Compiler.compileObject` takes one source today and does neither: no persistent
`-incremental`/output-file-map state, no prebuilt-`.swiftmodule` inputs. Two
implementation paths were considered: (a) adopt swiftc incremental honestly
(persistent per-session build dir, full filelist, re-emit changed objects);
(b) the stable-module/editable-unit split — put the editable preview in a
**separate** module that imports the bulk as a prebuilt `.swiftmodule`.

**Verdict (W5, `research/scripts/analysis/w5-scaling.md`, CLOSED): path (b) is
mandatory.** Path (a) does not scale. Same-module incremental re-emits only 1
object but pays whole-module front-end (parse+bind all N files) every edit:
0.65 / 1.41 / 2.82 / 6.06 s at N = 100 / 250 / 500 / 1000 (~45ms + 6ms/file), so
the 200ms budget breaks at ~25 files — a conservative lower bound on trivial
bodies, real SwiftUI breaks sooner. The split holds per-edit time **flat
~0.144s** from N=100 to 1000 (bulk built once, not per edit). This is what Apple
already does (W4 thunk: one `-primary-file` against prebuilt `.swiftmodule`s).
Fan-out (W5 M2): a body edit is **1 object** regardless of dependents; an
interface edit to a decl referenced by K files is **1+K** objects — the JIT must
budget relinking that set. The auto-split mechanism is W7; W5 is its
justification.
- **Verify (met by W5; split feasibility met by W7):** the same-module-vs-split
  curves above. W7 (`research/scripts/analysis/w7-autosplit.md`, CLOSED) proves
  the split is **feasible for the common case**: an `internal` SwiftUI view
  compiles in a separate editable unit via `@testable import` against a stable
  module built `-enable-testing` (free — previews are Debug), edit→relink flat
  ~0.14s at stable N=200 and 1000. Four break-cases: (1) preview touches
  `private`/`fileprivate` bulk decls (invisible — promote to `internal` or
  co-locate); (2) `@_spi` decls (need a generated matching `@_spi` import);
  (3) editing the stable module's **own interface** (re-emits the `.swiftmodule`
  ⇒ W5 same-module cost + 1+K relink — only preview-side edits stay flat);
  (4) `package` decls (need a shared `-package-name`). Both soft spots are now
  closed by the **integrated POC — PASSED** (`cfe9bda`,
  `research/jit-poc/build-split.sh`): the full chain (split → `@testable`
  single-file compile → JIT-link → render) proven end-to-end, v1 renders red and
  edited v2 renders blue, so S2 symbol override is demonstrated, not cited.
  Numbers: edit→pixels **~233ms** with respawn semantics (compile ~165ms +
  spawn/dlopen/link/render ~69ms); a **persistent agent** re-linking each
  generation into a fresh `JITDylib` pays ~2ms after compile ⇒ **~167ms**, under
  the 200ms budget. Load-bearing implementation findings: the agent **must** call
  `LLJIT::initialize(JD)` per generation (runs `jit_dlopen`, registers
  `__swift5_*` metadata — SwiftUI conformance lookups segfault without it), and
  the ObjCSelrefPlugin + `ExecutorNativePlatform` stack is required (SwiftUI is
  selref-heavy). Break-case (1) is **impossible by construction at file
  granularity** (a moved file's `private` decls move with it; zero
  `private`/`fileprivate` decls in any preview-bearing `examples/` file) — rule:
  the executor always splits at file granularity.

### G2 — A file-identifying FileWatcher
**Missing.** `FileWatcher` signals only "something in the watched set changed",
not which file (`FileWatcher.swift`). G1 needs the changed path to pick the file
to recompile. The watch scope is already ~"project sources minus dependencies"
(deps arrive prebuilt via `-I`/`-L`), so the gap is identity, not scope.
- **Verify:** editing file X in a multi-file target delivers X's path to the
  recompile; an edit to unrelated file Y recompiles Y, not X.

### Capped-persistent reloader (#191 item 2) — IN PROGRESS
One agent serves many edits, each in a fresh `JITDylib`, respawning at a cap.
- **Chunk 1 — DONE (`1392dd3`).** `previewsmcp_jit_session_new_generation`
  (fresh JD on the same LLJIT + reset `initialized` so the next run re-runs
  `LLJIT::initialize`), `JITSession.newGeneration`; `CappedPersistentTests` reuses
  one agent across 5 generations rendering distinct colors.
- **Chunk 2 — DONE (commit pending).** `JITStructuralReloader` is now an `actor`
  holding a persistent `JITSession` + a generation counter; `nextSession()` calls
  `newGeneration` under the cap and respawns (replacing the session, whose `deinit`
  kills the old agent) at `generationCap` (default 100). All reloader/latency/E2E
  tests pass unchanged; `reloaderRespawnsAtGenerationCap` (cap=2, 5 renders) and
  `splitRendersRealPreviewAcrossGenerations` (the real Summary preview rendered
  twice → gen2 re-links ToDoExtras/Lottie/builtins) are green, so U2 (duplicate dep
  registration) does not crash at low generation counts.
- **Chunk 2b — DONE (commit pending).** The duplicate-class warning had two causes,
  both fixed. (i) Distinct structural compiles reused the same editable module name;
  fix = a **unique `-module-name` per compile** (`PreviewEdit_<moduleName>_<token>` /
  `<standalone>_<token>`, token = `PreviewSession.uniqueModuleToken()` = `"g"` + a
  fresh UUID hex prefix; the stable module stays `ctx.moduleName` for `@testable`).
  (ii) The **literal** re-render re-linked the *same* object into a new generation;
  fix = the reloader re-runs the entry **in place** when `objectPath == lastObjectPath`
  (no `newGeneration`, no re-link) — also the design's "literal path is in-place ~10ms".
  Verified: `editableModuleNameIsUniquePerCompile` (two compiles → disjoint `_$s…
  DesignTimeStore` symbols) + the full JIT suite renders across generations with zero
  `objc[...] implemented in both` warnings.
  - **Layout-sensitive crash (worth knowing).** The token is computed statelessly *on
    purpose*. The first attempt stored a `jitCompileCounter` property on the
    `PreviewSession` actor; adding that stored field shifted the actor's layout and
    deterministically surfaced a latent `EXC_ARM_DA_ALIGN` (SIGBUS) in the Swift async
    main-queue drain at process exit of `PreviewHostJITReloadTests` (a mock-reloader
    test that never runs JIT'd code). A trivial whitespace recompile did NOT trigger it,
    so it is layout-sensitivity surfacing a pre-existing UAF/over-release near
    PreviewSession's async teardown, not the counter logic. The stateless UUID token adds
    no stored property and sidesteps it (and is globally unique, fixing a cross-session
    `g1` collision the per-session counter would have had). The underlying latent
    corruption is unfixed and uncharacterized — flagged for a future pass.

### JIT-in-CI (#191 item 4) — IN PROGRESS (commit pending)
CI skipped the JIT tests because the targets are gated on `third_party/llvm-build`
(+ `llvm-build-rt` + the `llvm-project` source headers), which the runners lack.
- **`cache-warm.yml` `warm-jit-cache` job** builds the pinned Swift-fork LLVM via
  `scripts/build-jit-llvm.sh` and caches the three `third_party/` dirs, keyed on
  `hashFiles('scripts/build-jit-llvm.sh')` (changes only when the recipe / pinned
  SHA changes). Weekly cron + `workflow_dispatch` keep it warm; the build step
  skips on a cache hit (guard on the orc archive).
- **`ci.yml` `jit-tests` job** (gated on `changes.src`) restores that cache,
  **builds from source on a miss** (90m job timeout absorbs it), `swift build`s
  the package + the SPM example, then runs `swift test --filter PreviewsJITLinkTests`.
- **First-run / rollout:** the very first PR run cache-misses and pays the full
  libLLVM build (~tens of min). After merge, dispatch `warm-jit-cache` once so
  `main`'s cache is warm; subsequent PRs just restore. Verify by watching the
  `jit-tests` job on the PR. Risk: the JIT suite occasionally `SIGABRT`s under heavy
  agent spawning (flaky); if it bites CI, add a retry or split the heavy E2E out.

### Model mismatch — RESOLVED (by W5+W7)
The design's fast path assumes edits land in the **editable/preview layer** on
top of a rarely-rebuilt **stable module**; an edit to an arbitrary stable-module
file falls to the slower path. Evidence settled the choice: target
**preview-layer edits** for the flat ~0.14s fast path (W7), and accept that
edits to the stable module's own interface fall back to W5 same-module cost
(+1+K relink) — the rare hot-path case. "Any edited file reloads sub-second" is
not achievable at scale (W5: whole-module front-end breaks 200ms at ~25 files)
and is no longer a goal.

### Apple-evidence status (W3/W4/W5 — CLOSED)
W3 verified Apple's respawn-only *dispatch* (8 edit kinds, zero `write_mem`;
`w3-empirical-capture.md`). W4 (`w4-compile-side.md`, `w4-thunk-argv.txt`) closes
the compile side: Apple recompiles **one file**, confirmed by object-mtime diff
(1 of 61) **and a live capture of the preview thunk `swift-frontend` argv** —
exactly one `-primary-file` (the edited file), `-vfsoverlay` thunk substitution,
prebuilt-module reuse via `-disable-implicit-swift-modules` +
`-explicit-swift-module-map-file` + `-I` (the G1 cross-module shape, confirmed).
W5 (`w5-scaling.md`) measured the scaling and **makes the stable-module/editable-
unit split mandatory** (numbers in G1).

**Mechanism correction (W4):** Apple does **not** use Swift dynamic replacement
for previews on Xcode 26.x — no `-enable-implicit-dynamic`, zero `__swift5_replace`
sections in thunk objects. It recompiles the edited file into a fresh
`PreviewRegistry` entry and **respawns** (PreviewRegistry-reentry, matching W3
respawn-only dispatch). This corrects the prior `project_jit_dynamic_replacement`
assumption; our respawn-first decision is unaffected (only the rationale changes).

**Bonus (W4):** Apple's literal fast-path is **data injection**
(`__designTimeString`/`Integer` + fallback), not recompilation — the same idea as
this project's `DesignTimeStore`. **W7 — CLOSED:**
auto-split is feasible for the common case (matrix + break-cases in G1).
**Integrated POC — PASSED** (`cfe9bda`): the full split → `@testable` →
JIT-link → render chain is proven; numbers and findings in G1.
**Generation-soak — DONE, decision ratified.** 500 generations, one persistent
host, fresh JD each: latency FLAT (link ~0.37-0.47ms, render ~0.33-0.43ms
medians across all windows; `swift_conformsToProtocol` does not slow), RSS
linear ~87KB/generation (unreclaimable — `__swift5_*` cannot deregister), zero
mprotect/MAP_JIT failures, zero wrong pixels. Verdict: **capped-persistent**
(see "Key decision", amended to respawn-on-cap).

**W6 — CLOSED** (`research/scripts/analysis/w6-designtime.md`). Two results.
*Canvas-is-split (refines G1):* Apple's canvas thunk compile has **no**
`-filelist`/`-incremental`/batch-mode — it is single `-primary-file` +
`-vfsoverlay` + explicit module map, i.e. **already the W5/W7 split shape**, so
the canvas latency number is the split number, not the same-module number.
*Injection lifecycle:* `#salt_n` IDs generated at thunk compile → runtime value
table keyed by ID, read via `__designTime{String,Integer,Float,Boolean}` → on a
literal edit, re-inject by ID via the `PreviewsInjection` `EntryPoint`
`UpdatePayload` stream — **no recompile, no respawn**. Structural edits take
`PerformFirstJITLink`/`JITLinkEntrypoint` instead. Our `DesignTimeStore`
(`@Observable` + `@_cdecl designTimeSet*`) is a faithful mirror. The boundary is
`LiteralDiffer` skeleton-equality, including the UIKit-region downgrade (#160).
Minor open: Apple's `UpdatePayload` wire format read from symbol names, not a
decoded live XPC dump.

**Executor edit-tier model (research arc complete — W3-W7 all CLOSED):**
1. **Literal-only SwiftUI edit** → value push by ID into the running agent
   (`DesignTimeStore` path) — no compile, no respawn. Cheapest.
2. **Structural edit** → W7 split compile (one file vs prebuilt
   `.swiftmodule`s) + JIT-link into a fresh `JITDylib` under capped-persistent
   (respawn-on-cap). ~167ms.
3. **UIKit-region literal edit** → tier 2 (UIKit captures the value once and
   never observes, #160).
Classify edits exactly as `LiteralDiffer` does (skeleton or literal-count change
⇒ tier 2; literal value change in a SwiftUI region ⇒ tier 1).

## Phase 3 status: CORE COMPLETE (P3.1–P3.4 landed; P3.3 deferred, examples E2E pending)

P3.1, P3.2, and **P3.4 (a/b/c-i/c-ii/d)** are done on branch
`jit-phase3-session-integration` (PR #190), CI green (non-JIT build + lint +
ios-tests). The agent links a real SwiftUI preview and renders it to a bitmap on
its main thread over a contiguous **anonymous** executor-memory slab (P3.1).
P3.2 proved recompile → respawn → re-render through the real `Compiler`. P3.4
wired it into the daemon behind a `StructuralReloader` protocol (JIT-free Core,
implemented in `PreviewsJITLink`, injected at the executable via one
`#if PREVIEWSMCP_JIT`): structural edits render in the agent and `preview_snapshot`
serves the agent PNG (file transport); literal edits on an agent-backed session
rewrite a baked design-time-values JSON and re-render the **same `.o`** with no
recompile (value file-transport). Measured structural latency **≈281ms**
(compile ≈218ms + link/respawn/render ≈62ms) — the render half is within budget,
the **whole-module compile is the whole gap**.

**Remaining (next phase, see the new follow-up issue):** (1) **recompile-narrowing**
— the stable-module/editable-unit split (single-file `@testable` compile against
prebuilt `.swiftmodule`s) to get under the <200ms target; this is the dominant
lever and is coupled to the compile-strategy research (W4–W7 on
`previews-research`). (2) **capped-persistent reloader** — replace the
respawn-per-edit `JITStructuralReloader` body with one persistent agent +
fresh `JITDylib` per edit + respawn-on-cap (~100), to make the literal path truly
in-place (~10ms) without touching the protocol. (3) the `examples/` E2E verify.
(4) JIT-in-CI infra (cache the prebuilt LLVM so CI runs the JIT tests). P3.3
(in-place `write_mem` + Begin/End/cancelUpdate) stays conditional.

## Scope boundaries

- **Phase 3 (this branch):** P3.1–P3.5. Respawn-first; local unix-socket
  transport (inherited from Phase 2).
- **Deferred Phase 4+:** in-place `write_mem` fast path + the handshake (P3.3 if
  not pulled in); large-module scaling; XPC/gRPC transports; iOS device agent;
  LLVM bundling; crash recovery; multi-session.

## Immediate next step (resume pointer for a fresh session)

**State:** P3.1, P3.2, P3.4 (a/b/c-i/c-ii/d) DONE on PR #190, CI green. Branch
`jit-phase3-session-integration`, tip **`70e8f0d`**. Working tree clean. Run JIT
tests with `swift test --filter PreviewsJITLinkTests` (builds `PreviewAgent`;
expect 34 green, zero orphan `PreviewAgent`). The seam/host tests are non-JIT:
`swift test --filter "StructuralReloaderTests|PreviewHostJITReloadTests|MacOSPreviewHandleAgentSnapshotTests"`.
If `third_party/llvm-build` is missing, run the bootstrap skill with `--jit` (do
NOT rebuild LLVM if it exists). **Before committing edited Swift, format with
`swift-format format -i --recursive <file>`** — plain `-i` uses defaults and
fails CI's `swift-format lint --strict --recursive` (see
[[project_swiftformat_recursive]]).

**Key files (P3.4 seam):** `Sources/PreviewsCore/StructuralReloader.swift`
(protocol), `PreviewSession.compileObjectForJIT()` + `applyLiteralValuesForJIT()`
+ `JITRenderBuild`, `BridgeGenerator.renderToFileEntryPoint` (PNG + JSON seed),
`Sources/PreviewsJITLink/JITStructuralReloader.swift` (impl, **respawn-per-edit**
— this is the body to swap for capped-persistent), `PreviewHost.jitStructuralReload`/
`jitLiteralReload`/`agentImagePaths` + `watchFile` routing,
`MacOSPreviewHandle.snapshot` reroute, `PreviewsMCPApp.swift` `#if PREVIEWSMCP_JIT`
injection + `Package.swift` `jitCLIDependencies`/`jitCLISwiftSettings`.

**Next (next phase / follow-up issue), in priority order:**
1. **Recompile-narrowing** (the <200ms lever; compile is 218ms of 281ms): wire
   `compileObjectForJIT` to the stable-module/editable-unit split — single-file
   `@testable` compile against prebuilt `.swiftmodule`s (manager's W4–W7 research
   on `previews-research`, ~0.14s relink at 1000 files). This is the dominant work.
2. **Capped-persistent reloader**: swap `JITStructuralReloader`'s respawn-per-edit
   body for one persistent agent + fresh `JITDylib`/edit + respawn-on-cap (~100);
   the protocol does not change. Makes the literal path truly in-place (~10ms).
   Note: the agent must call `LLJIT::initialize` per generation (registers
   `__swift5_*`; segfaults without) — satisfied under respawn, must be added for
   persistent.
3. `examples/` E2E verify (literal ~10ms via `DesignTimeStore`; structural via JIT
   respawn; same daemon session, no restart).
4. JIT-in-CI infra (cache prebuilt LLVM/orc-rt so CI runs the JIT tests).
P3.3 (in-place `write_mem` + Begin/End/cancelUpdate handshake) stays conditional.

**Pitfalls carried forward:**
- macOS denies `mprotect`-to-exec on `MAP_SHARED` memory (no entitlement / reboot
  helps). The slab is anonymous now; never reintroduce the shared-memory mapper.
- When debugging, do NOT `pkill -9` loop hundreds of JIT agents. Use modest
  verification loops (10–15 runs). Clean session teardown reclaims memory; only
  kill stray agents at the end.
- The agent's anonymous mapper must `InvalidateInstructionCache` on exec segments
  and discards deallocation actions (process-lived image, D3). Don't re-add
  dealloc-action running.
- macOS crash reports for the agent live in `~/Library/Logs/DiagnosticReports/
  PreviewAgent-*.ips` (parseable JSON after the first line); the faulting-thread
  backtrace is the fastest way to localize an agent crash.
- CI `lint` is `swift-format lint --strict --recursive`. Format edited Swift with
  `swift-format format -i --recursive <file>` (plain `-i` uses tool defaults and
  leaves files CI rejects; a file list after `--recursive` only formats the first
  arg, so loop). build-and-test can pass while `lint` fails — check all four jobs.

## Update 2026-06-09: non-leaf hot-reload fix, CI removed, merge status

This section SUPERSEDES the "Immediate next step" pointer above and every "CI
green" claim in this doc. PR #190 merged to `main` (`a5b58cc`), GitHub Actions
CI is disabled (the `CI` and `Cache warmer` workflows are off; the
`required_status_checks` rule was removed), and the merge bar is now LOCAL:
all unit tests plus the example integration tests via the `integration-test`
skill (AGENTS.md). The CI-replacement design is `docs/local-merge-queue.md`.

### The non-leaf duplicate/missing-symbol bug (the fix this branch lands)

The recompile-narrowing split as merged on `main` handled only the LEAF case:
it prebuilds a stable module from the target's other sources and the edited
file `@testable import`s it. When the edited file is NON-LEAF — another file in
the target references it (e.g. `examples/spm` `ToDoView.swift` is used by
`ToDoProviderPreview.swift`) — the stable compile excludes the hot file but the
bulk still references it, so it fails with `cannot find 'ToDoView' in scope` and
the hot reload times out. (This is a missing symbol, not a duplicate; a true
duplicate-in-both would require a build system whose `sourceFiles` does NOT
exclude the hot file. SPM excludes it in `collectSourceFiles`.)

**Fix (branch `jit-nonleaf-hotreload`, PR #194):** detect the non-leaf case
(the stable compile throws → `bulkIsNonLeaf` latches for the session) and
compile the whole target module INCREMENTALLY instead — the edited file as an
overlay plus the other sources under the target's own module name, where the
Swift driver recompiles only the hot file and reuses the bulk objects.
References resolve both directions and the hot file lives in exactly one module.
Because the overlay then uses the real module name, its `@Observable`
`DesignTimeStore` would re-register across generations, so non-leaf builds
respawn the agent each structural edit (`requiresFreshAgent`).

Code: `Compiler.compileModuleIncremental`, `PreviewSession.compileObjectForJIT()`
non-leaf branch + `stableModuleIfLeaf`/`bulkIsNonLeaf`,
`JITStructuralReloader`/`render(_:)`, plus the C++ `lookupInitialized` lock and
anon-mapper bounds fix in `PreviewsJITLinkCxx.cpp`.

**Known tradeoffs (performance, not correctness; follow-up candidates):**
- Non-leaf edits lose capped-persistent and respawn the agent each edit. Leaf
  edits keep the persistent path via a unique module token.
- Leaf vs non-leaf is detected by compile-and-catch and `bulkIsNonLeaf` latches
  per session. A real error in another bulk file is misread as non-leaf and pins
  the session to the slower whole-module path. Degrades speed, not correctness;
  the error still surfaces. Static reference analysis would avoid this but is
  itself a compile (Swift same-module refs carry no `import`), so compile-and-
  catch was chosen.
- The first non-leaf edit compiles the bulk twice (once to fail-detect).

### Verification (local, this branch, worktree with `third_party` symlinked)

| Check | Result |
| --- | --- |
| `hotReloadStructural` (edits `ToDoView.swift`, the non-leaf file), JIT daemon | green, ~24s (on `main` it times out at 90s) |
| `PreviewsJITLinkTests` isolated, `--no-parallel` | 50/50 |
| macOS CLI suites (Snapshot/Configure/Switch/Elements/Variants/Stop), serial | 46/46 |
| Non-JIT unit suite, default PARALLEL | 6–30 nondeterministic issues — ALL parallel session contention ("multiple sessions are running" / "No session found") or missing iOS example artifacts |
| iOS suites (`IOSMCPTests`, `IOSCLIWorkflowTests`) | FAIL — missing iOS-sim example build (`ToDo.swiftmodule`, `ToDoPreviewSetup`); environment, not code |
| Whole suite in ONE `swift test --no-parallel` | crashes (`SmallVector set_size`) — known shared in-process LLJIT flakiness; why the doc runs JIT separately |

Conclusion: nothing the three commits touch is failing. The CLI integration
suites are not parallel-safe with each other (shared daemon); run them serial.

### JIT is macOS-only — iOS is NOT covered

The JIT executor renders on macOS only: `JITStructuralReloader` is wired solely
onto the macOS `PreviewHost` (`PreviewsMCPApp.swift`), and `PreviewAgent`
`dlopen`s AppKit/SwiftUI and renders with `ImageRenderer` on the Mac. The iOS
path is separate (`IOSPreviewSession` + `IOSHostBuilder`): a structural edit
does a FULL host-app recompile reloaded on the simulator
(`IOSPreviewSession.swift`), not the JIT split/agent. An iOS device/simulator
JIT agent is a DEFERRED line item in all three phase docs (Phase 1 §356, Phase 2
§193/199, Phase 3 §662) with no design, subproblems, or verification criteria.
Fixing iOS JIT is net-new planning work, not a bug in this fix.

### Outstanding

- Run the `integration-test` skill (the other half of the merge bar). Not yet run.
- iOS unit suites need the iOS-sim example built first (`bootstrap --examples`).
- PR #193 (`docs-no-ci-verification`) still carries these same three commits; once
  #194 lands, rebase #193 down to just its docs commit.
- Decide whether to merge #194 once the full local bar is met.

## P4.1 recompile-narrowing (#191 item 1) — IN PROGRESS

**Decision: Fork B (project/Tier-2 split), file-granularity, hot-leaf heuristic.**
The split puts the edited preview file (the "hot" file) in a single editable unit
that `@testable import`s a stable module built from the rest of the project's
sources. The W5/W7 lever: the per-edit compile recompiles only the hot file, so it
stays flat (~158ms measured) while the whole-module baseline grows with module
size. Sound only when the hot file is a **leaf** the bulk does not depend on
(preview-layer edits fast; edits to the stable module's own interface fall back to
the slow whole-module path — the rare hot-path case, accepted per W5/W7).

**The hot-leaf heuristic.** The edited file cannot live in both the stable module
and the editable unit (duplicate symbols at JIT-link). So per edit: pick the hot
file, build `stable = projectSources − hotFile` once with `-enable-testing`,
compile the hot file as the editable unit `@testable import`ing it, JIT-link
stable.o + editable.o. Repeated edits to the **same** hot file reuse the cached
stable module → flat fast path. An edit to a **different** file changes which file
is hot → rebuild the stable module and reset. Fan-out (W5 M2): a body edit is 1
editable object; an interface edit to a decl K files reference is 1+K objects.

**Done (P4.1-a, commit 34613af):** `Compiler.emitStableModule` (whole-module `.o` +
`-enable-testing .swiftmodule`) + `Tests/PreviewsJITLinkTests/SplitCompileTests.swift`
proving the mechanism at the unit layer (cross-module `@testable` render, stable
reuse across edits, flat 180ms vs 312ms whole at N=24). The editable unit reuses
`Compiler.compileObject(..., extraFlags: ["-I", modulesDir])`.

**Avenues / order (recommendation: B1→B2→B3, then G2):**
- **B1 — DONE (commit pending).** `PreviewSession.compileObjectForJIT()` now splits
  in Tier-2 mode: hot file = `self.sourceFile`, stable bulk = `ctx.sourceFiles`
  (the build systems already exclude the preview file — SPM `getOtherSourceFiles`,
  confirmed). The editable unit is a separate module `PreviewEdit_<moduleName>` that
  `@testable import`s the stable module (`generateCombinedSource(stableModuleImport:)`),
  compiled with `-I <stable.modulesDir>` + `ctx.compilerFlags`. `JITRenderBuild`
  gained `supportObjectPaths` (the stable `.o`, linked before the editable object);
  standalone leaves it empty so the daemon path is unchanged.
  `Tests/PreviewsJITLinkTests/PreviewSessionSplitTests.swift` proves it: hot file
  consumes a bulk `Palette` cross-module, renders red, structural edit re-renders
  blue. Leaf assumption holds for the fixture (no bulk file references the hot view).
- **B2 — DONE (commit pending).** `PreviewSession` caches the stable module
  (`cachedStableModule`) keyed by the bulk files' modification dates. Repeated edits
  to the hot file reuse it (flat fast path); a bulk-file change invalidates and
  rebuilds. Within a session the hot file is fixed (`self.sourceFile`), so the
  "different hot file" case is a different session, not handled here. Verified by
  `stableModuleCachedAcrossHotEditsAndInvalidatedByBulkChange` (same
  `supportObjectPaths` across two hot edits; different after touching a bulk file).
- **B3 — DONE (commit pending).** `StructuralReloader.renderObject` gained a
  `supportObjectPaths: [URL]` parameter; `JITStructuralReloader` adds those objects
  before the editable object, so the agent links stable.o then editable.o. The daemon
  callers (`HostApp.jitStructuralReload`/`jitLiteralReload`) pass
  `build.supportObjectPaths`. Standalone passes `[]` (unchanged). Verified by
  `reloaderRendersSplitBuildThroughBothObjects` (a project-mode split build renders
  red through the real reloader). Test mocks updated for the new signature.
- **examples E2E (#191 item 3) — PARTIAL (commit pending).**
  `Tests/PreviewsJITLinkTests/ExamplesSplitE2ETests.swift` drives a real
  `SPMBuildSystem` `BuildContext` for `examples/spm` (`ToDo` target). PASSING: the
  split **compiles** a real cross-file preview (`Summary.swift`, references
  same-module `Item` + sibling `ToDoExtras` package-scoped decls) — real
  `-I`/`-package-name`/`@testable` flags flow correctly and both objects emit; the
  literal path classifies a real preview (`BadgePreview.swift`) and reuses the same
  object. BLOCKED on G3: the in-agent **render** is `.disabled` (see G3).
- **G3 — JIT agent must load the target's full link-dependency closure (DISCOVERED by
  the E2E).** The non-JIT Tier-2 path links a dylib with `-L/-l` (+ `-F/-framework`) so
  dyld + autolink resolve sibling/cross-package/binary deps; the JIT path emits `.o`s and
  the agent JIT-links only our stable.o + editable.o, so dep symbols are unresolved at
  JIT-link time (materialization fails; the agent then `SIGABRT`s on a dangling
  `SymbolStringPool` — a secondary teardown robustness bug). Note the split makes this
  unavoidable: the **stable module bundles every other target file**, so even a
  dep-free hot file pulls the whole target's deps (e.g. editing `Summary.swift` still
  pulls `Lottie` via `ToDoView.swift`). Three layers, peeled by the E2E:
  - **G3-a — static dependency archives — DONE (commit pending).** `SPMBuildSystem`
    archives sibling/cross-package targets into `lib<Dep>.a`. Added
    `previewsmcp_jit_session_add_archive` → `StaticLibraryDefinitionGenerator::Load`
    on the session JD (lazy archive linking), Swift `JITSession.addArchive`,
    `JITRenderBuild.archivePaths` (parsed from `ctx.compilerFlags` `-L`/`-l` by
    `PreviewSession.dependencyArchives`), and the reloader adds archives before objects
    (`renderObject(at:supportObjectPaths:archivePaths:entrySymbol:)`). Verified by
    `ArchiveLoadingTests.resolvesSymbolFromStaticArchiveInAgent` (a JIT object resolves
    a symbol from a `libtool` archive in the agent) — and the real E2E now resolves
    `ToDoExtras`/`LocalDep`.
  - **G3-b — binary frameworks — DONE (commit pending).** `-F/-framework` deps (e.g.
    `Lottie.framework`) are binary dylibs the agent `dlopen`s. Added
    `previewsmcp_jit_session_add_dylib` → `EPCDynamicLibrarySearchGenerator::Load`
    (loads the lib in the agent over EPC), Swift `JITSession.addDylib`,
    `JITRenderBuild.dylibPaths` (parsed from `-F`/`-framework` to
    `<F-dir>/<name>.framework/<name>` by `PreviewSession.dependencyDylibs`), reloader
    adds dylibs before archives/objects (`renderObject(...dylibPaths...)`). Verified by
    `ArchiveLoadingTests.resolvesSymbolFromDylibInAgent`; in the real E2E the Lottie
    symbols now resolve, leaving **only** `___isPlatformVersionAtLeast` (G3-c).
  - **G3-c — compiler-rt builtins — DONE (commit pending).** `___isPlatformVersionAtLeast`
    (emitted by `#available`) is a defined symbol in the toolchain's `libclang_rt.osx.a`.
    `Toolchain.compilerRuntimeArchivePath()` (`xcrun clang -print-runtime-dir` +
    `libclang_rt.osx.a`) locates it; the split branch appends it to `archivePaths`, so it
    loads via the G3-a archive mechanism (lazy, pulls only the referenced builtins).
  **G3 COMPLETE — a real Tier-2 preview renders end to end.**
  `ExamplesSplitE2ETests.splitRendersRealPreviewInAgent` is enabled and renders
  `Summary.swift` (with `ToDoExtras` + `Lottie` deps) to a non-empty PNG. The earlier
  `NSForwarding ... JSONObjectWithData` abort was teardown noise after the failing link;
  it clears once the link succeeds.
- **G2 (deferred, separable)** — `FileWatcher` delivers the **changed path**, not
  just "something changed". Feeds `hotFile` so the live daemon picks the hot file
  itself. Verify: editing file X delivers X's path; editing Y recompiles Y not X.
- **Fork A (not chosen, fallback)** — standalone single-file split where the stable
  module is only the `DesignTimeStore` + `PreviewBridge` boilerplate. Modest win
  (boilerplate is tiny); kept as a note only.

**Open questions for B1+:** (1) editable unit is a **separate** module name that
`@testable import`s the stable module — confirm no bulk file references the hot
view (leaf assumption) in the `examples/` projects; (2) where the daemon currently
gets `buildContext.sourceFiles` and whether the edited file is reliably in that
set; (3) the existing `compileObjectForJIT` standalone path stays as the no-build-
context fallback (stable module = boilerplate or skip split).

## Update 2026-06-09 (evening): agent-render fixes landed, #194/#193 merged

Running the full local merge bar (the `integration-test` skill) against the
non-leaf branch surfaced two agent-render bugs that `main` shared, both fixed
in PR #194 alongside the non-leaf split:

- **`ImageRenderer` cannot rasterize AppKit-backed SwiftUI.** Nil `cgImage`
  with `NavigationStack { List }` at the root (agent entry status -1, hit by
  any cross-file edit that was a session's first structural reload), tiny
  blank raster otherwise. `renderPreviewToFile` now hosts the view in a
  borderless off-screen `NSWindow` via `NSHostingView` and captures at 1x like
  `Snapshot.capture`. Guarded by
  `ExamplesSplitE2ETests.nonLeafRendersUneditedPreviewInAgent`.
- **Stale agent PNG shadowed the window.** `snapshot()` prefers the agent PNG
  once a session is agent-backed, but the dylib window paths
  (`preview_switch`, `preview_configure`) never cleared it. `loadPreview` now
  drops the session's `agentImagePaths` entry.

Both slipped through because existing assertions only checked "snapshot
changed" / "PNG non-empty", and the session-start render goes through the
dylib window, so the agent had never rendered an unedited body.

Merge bar at merge time: JIT 51/51, touched-module units 320/320, CLI command
suites 46/46, MCP macOS 7/7, `integration-test` macOS scope green across all
four examples, daemon log clean. PR #194 and PR #193 are squash-merged
(`main` = `907764c`); stale branches and worktrees are cleaned. Only
`previews-research` remains as a long-lived reference branch.

**Next step: single-renderer consolidation.** The stale-PNG bug is a symptom
of a session having two renderers (dylib window + agent). Route session
start, `preview_switch`, and `preview_configure` through the agent render
path so the agent is the single source of truth, demoting the window to a
viewer of agent output. Design and subproblems tracked on the
`jit-single-renderer` branch.

## Update 2026-06-10: single-renderer PR #196, review outcome, multi-session next

Branch `jit-single-renderer` (PR #196) landed consolidation steps 1-3: the
agent hosts one persistent window (found by identifier, content swapped per
generation), the daemon bakes its window frame/title into the bridge and
orders its dylib window out after the first agent render, the agent main
thread runs the real AppKit event loop (window is interactive), switch /
configure / variants route through the agent for agent-backed sessions via
one `reload(jit:dylib:)` seam, and the setup plugin (wrap + `previewSetUp` +
dylib + SDK override) carries through `compileObjectForJIT` — fixing a bug
`main` had where structural reloads silently stripped the plugin.

A subagent code review of the PR confirmed and we fixed on-branch: shared
setup validation (`BridgeGenerator.isUsableSetup`), stable-module SDK
consistency + the issue-#170 guard in all compile paths
(`Compiler.resolveSDK`), control-character escaping for generated string
literals, an Aqua-session guard before `[NSApp run]`, single bridge
generation per edit, and shared mutation helpers so dylib/JIT semantics
cannot diverge.

Deferred with tracking issues:
- **#195** — frame handover across agent respawns (observer-sidecar design).
- **#197** — multi-session: shared agent window identity, zombie window on
  stop, per-process `didRunSetUp`, shared `lastObjectPath`. Design into the
  next phases, not piecemeal.

**Remaining consolidation order:** (1) session start through the agent — no
dylib compile at start, agent window is the session surface from the first
render; this step must pick the multi-session topology (#197). (2) Retire
the dylib machinery for JIT builds (loaders, dlsym literal setters,
`agentImagePaths` dance). Then the broader roadmap: latency (<200ms,
non-leaf respawn/double-compile/sticky-latch), agent render size from
session size (400x600 is baked today), content-based test assertions, dylib
fallback decision, iOS JIT design, local merge queue (no GH issue exists
yet; user may want one).

## Update 2026-06-10 (2): start through the agent, one agent per session

Consolidation step (1) landed: `startMacOSPreview` no longer compiles a
dylib for JIT builds. `PreviewHost.jitStart` stores the requested
title/size as the session's `JITRenderWindow` spec (centered on the main
screen, nil for headless) and the agent window is the session surface from
the first render. The chosen multi-session topology is **one agent per
session**: `makeStructuralReloader` replaces the host-level reloader, each
session lazily gets its own `JITStructuralReloader` (own agent process,
own window, own `didRunSetUp`/`lastObjectPath`), and `closePreview` drops
the reloader, killing the agent and closing its window. That resolves all
four #197 items; #195 (frame handover across respawns) remains.

Verified live: two concurrent sessions render distinct content in two
agent processes, stopping one closes only its window. Cost moved, not
added: the stable-module bulk compile that start used to pay now lands on
the first non-leaf edit (~20s on the SPM example), which sharpens the
existing latency roadmap item.

A 7-angle subagent review of PR #198 confirmed four findings, fixed
on-branch before merge (`290782b`, red-green verified for the first):
`reloader(for:)` creates only for registered sessions so a reload in
flight when `closePreview` runs cannot resurrect an agent (`jitStart` now
registers the session and tears down on a failed first render); a newly
created visible agent window takes key status and activates the agent
once, at creation, restoring the focus the dylib start gave (re-renders
keep `orderFrontRegardless`); `jitStart` stores no spec when no screen
exists instead of baking negative origins; and the daemon-window branch
of `agentWindowSpec(for:)` was unreachable everywhere and is removed.
Merged as `e14ef41` (PR #198).

**Next:** retire the dylib machinery for JIT builds (loaders, dlsym
literal setters, `agentImagePaths` dance, the `loadPreview` fallback in
`watchFile`), then the broader roadmap above — latency first, which now
owns the first-non-leaf-edit ~20s (respawn + bulk compile + sticky
`bulkIsNonLeaf` latch; candidate fix: warm the stable module in the
background right after start).

## Update 2026-06-10 (3): dylib machinery retired for JIT builds (routing)

Consolidation step (2) landed as the routing retirement chosen with the
user (over spreading `#if PREVIEWSMCP_JIT` beyond the composition root or
an injection refactor): since #198 a session never switches surface
mid-life, so "agent-backed" is a property of the build, not the session.
`PreviewHost.agentBacked` (`makeStructuralReloader != nil`) now routes
`watchFile`'s literal path, `MacOSPreviewHandle.reload`, and `snapshot`;
the per-session `agentImagePaths` presence check is no longer a routing
signal and the map is purely the snapshot source. `startMacOSPreview`'s
dylib branch is compile-time gated behind the existing `#if` in
PreviewsCLI. Cross-path hygiene that can no longer fire is gone: the
agent-state clearing in `loadPreview` and the daemon-window `orderOut` in
`jitRender`.

The dylib machinery itself (`loadPreview`, `loaders`, dlsym literal
setters, `DylibLoader`, `PreviewSession.compile/setTraits/reconfigure/
switchPreview`) stays compiled: it is the live path for non-JIT builds
(no `third_party`) until the dylib fallback decision later in the
roadmap, at which point it deletes cleanly — no call site routes to it
in a JIT build any more. Verified non-JIT still compiles by forcing
`jitEnabled = false` in the manifest (note: removing the `third_party`
symlink is NOT enough — the content-hash-cached manifest bakes in
`#filePath` from the main checkout, where `third_party` exists).

**Next:** latency (<200ms), which owns the first-non-leaf-edit ~20s
(respawn + bulk compile + sticky `bulkIsNonLeaf` latch; candidate fix:
warm the stable module in the background right after start). Then #195,
session-sized agent renders, content-based assertions, dylib fallback
decision, iOS JIT.
