CODERAVEN · PLAN · REVIEW COPY · 39 tasks · 5 phases · 21 files · 2026-09-02 · implemented, manual checks open

Store Preload Symmetry and AgentRunner Main-Actor Isolation

Three stores read synchronously on the main actor while writing on a background queue, and AgentRunner publishes six properties with no isolation. Apply the existing async-preload pattern to the stragglers, put AgentRunner on the main actor, and confirm the two findings that already look done.

39Tasks
5Phases
21Files touched
6Manual checks
The gist

Three stores read from disk synchronously on the main actor at first access while already writing on a background queue, and AgentRunner drives six @Published properties with no actor isolation. Every other store in the launch pre-warm group already follows one pattern: a detached decode, a MainActor assign that skips anything already cached, and the sync read kept as the startup-race fallback. This plan applies that pattern to the three stragglers, puts AgentRunner on the main actor, and adds one DEBUG print per fallback so the two findings that look already done can be confirmed at runtime instead of assumed.

The problem

MiraThreadStore and MiraParkedTopicStore write on Self.ioQueue but read with Data(contentsOf:) plus decode on the main actor the first time a project is touched (MiraThreadStore.swift:134, MiraParkedTopicStore.swift:105).

FeatureLabSessionStore has no queue in either direction. save encodes and writes on main; init() scans and decodes every session file on main, so the first .shared access pays the whole directory. It was modeled on IdeationSessionStore, which has the same shape.

AgentRunner mutates its published state from inside DispatchQueue.main.async and await MainActor.run everywhere, so it is correct by convention. Nothing makes the compiler hold that line.

Two handoff claims did not survive verification. The pre-warm wiring is at Code_RavenApp.swift:397-398, not 379-380. More importantly, the target builds in Swift language mode 5.0 with no strict-concurrency flag, so a plain closure formed inside a @MainActor method is inferred main-isolated and compiles even when Combine or ClaudeDirectInvoker calls it on a background thread. The annotation alone does not check the observers the handoff cares about. A spike build with the annotation applied and reverted measured the first error wave at 13 sites in 6 files; implementation found 21 across five waves.

Approach

Copy the pattern the tree already has

FindingsStore.swift:130-151 and MiraEditorialService.swift:439-464 are the reference: Task.detached(priority: .utility), return the decoded dictionary, assign on MainActor only where nothing is cached, keep the sync path as fallback. Phases 2 and 3 are that shape applied three times.

Keep the sync fallback, so first-access semantics do not change

For the two Mira stores thread(for:) and topics(for:) still return real data on a cold cache. For FeatureLabSessionStore the same holds through a hasLoaded flag and an ensureLoaded() guard at the top of every accessor. The alternative in the handoff, an empty-state path at each of eight call sites for a one-frame condition, was rejected as more surface for less certainty.

Make AgentRunner's hops structural, not inferred

The ProjectSwitched observer becomes for await over NotificationCenter.notifications(named:) inside a Task { @MainActor in }, so the body is main-isolated by construction. The onOutput closure passed to ClaudeDirectInvoker is marked @Sendable explicitly so the compiler stops inferring isolation for a closure that is called off-main. TerminalSessionDelegate becomes @MainActor; AgentRunner is its only conformer.

Instrument before confirming

The handoff wants the FindingsStore and MiraEditorialService findings closed on runtime evidence. One DEBUG-only print in each fallback branch is that evidence. Phases 2 and 3 reuse the same line, and every Manual Testing check reads it.

Decisions made for you

Judgment call

DEBUG print is the verification instrument

The handoff asked for no code change on the two stale findings. Five lines of #if DEBUG print are what turn "I think the preload wins" into something you can see in Console. Reversible by deleting them.

Judgment call

FeatureLabSessionStore keeps a sync fallback

Instead of giving eight call sites an empty-state path, the store gets one ensureLoaded() guard, matching how every sibling store already behaves.

Judgment call

delete(_:) moves onto the queue too

The handoff names only save. A synchronous delete beside a queued save lets the queued save re-create a file the user just removed. Same serial queue for both keeps ordering.

Judgment call

The await MainActor.run blocks stay

Once the class is main-isolated the enclosing Task {} inherits that, and the blocks at lines 303 and 429 become no-op hops. Unindenting roughly 150 lines is diff noise with merge risk and no behavior change.

Judgment call

IdeationSessionStore keeps its computed properties

The plan sketch proposed converting activeSession and pausedSessions to methods. The real count was 25 readers in ten files, so ensureLoaded() sits in the getters instead and no call site changes.

Judgment call

Protocol goes @MainActor, not the conformance nonisolated

One conformer, and every TerminalSession call site but line 411 is already inside DispatchQueue.main.async. Annotating the protocol means any future conformer inherits the check.

The work

Phase 1 — Instrument the fallback paths

2 tasks Adds the diagnostic every later check reads.
  • Add a DEBUG-only cold sync load print to the fallback branch of FindingsStore.findings(for:) so a lost preload race is visible in Console
  • Add the same DEBUG-only cold sync load print to MiraEditorialService.ensureLoaded on the branch where the file exists on disk

Phase 2 — Mira stores get the async preload

5 tasks Independent of Phase 3.
  • Add loadAllProjectsAsync(projectIds:) to MiraThreadStore: capture Self.threadsDirectory, decode every {projectId}.json into [UUID: MiraThread] inside Task.detached(priority: .utility), then assign on MainActor only where threadsByProject[pid] == nil
  • Add loadAllProjectsAsync(projectIds:) to MiraParkedTopicStore with the same shape, decoding [MiraParkedTopic] per project and guarding on topicsByProject[pid] == nil
  • Register both stores in the launch pre-warm task group in Code_RavenApp.swift directly after the MiraEditorialService line (currently 398)
  • Keep both sync loadFromDisk paths as the startup-race fallback and add the Phase 1 DEBUG-only cold sync load print to each
  • Build with xcodebuild and verify the artifact with scripts/verify-build-product.sh

Phase 3 — FeatureLabSessionStore, both directions

10 tasks Independent of Phase 2.
  • Add private static let ioQueue with label com.coderaven.featurelab.sessionstore.io and qos .utility, following MiraThreadStore.swift:27
  • Move the directory creation, encode, and atomic write in save(_:) onto ioQueue, capturing the updated value; the in-memory sessions update stays synchronous ahead of the dispatch
  • Move the removeItem in delete(_:) onto the same ioQueue so a save immediately followed by a delete cannot re-create the file out of order
  • Extract the directory scan, decode, and newest-wins dedup from loadAll() into nonisolated static func readSessionsFromDisk() -> [FeatureLabSession] so the sync fallback and the async preload share one decode path
  • Replace the loadAll() call in init() with a hasLoaded flag plus a private ensureLoaded() sync fallback invoked at the top of every public accessor and mutator: activeSession(for:), pausedSessions(for:), completedSessions(for:), sessions(for:), session(for:), save(_:), delete(_:), pauseActiveSessions(for:), pruneStaleSessions(), pruneOrphanedSessions(validProjectIds:)
  • Add loadAllAsync(): run readSessionsFromDisk inside Task.detached(priority: .utility), then on MainActor assign sessions and set hasLoaded only if the sync fallback has not already won; discard the preload result otherwise
  • Change @Published var sessions to private(set) so the hasLoaded guard holds; no external writer exists today
  • Register FeatureLabSessionStore.shared.loadAllAsync() in the launch pre-warm task group
  • Add the Phase 1 DEBUG-only cold sync load print to ensureLoaded()
  • Build with xcodebuild and verify the artifact with scripts/verify-build-product.sh

Phase 4 — AgentRunner on the main actor

10 tasks Lands last, on its own commit.
  • Add @MainActor to class AgentRunner at AgentRunner.swift:15
  • Mark the extension Agent computed properties at AgentRunner.swift:1594-1611 (isRunning, latestRun, latestOutput, terminalSession) @MainActor; they read runner state from a nonisolated model extension and are the first four errors the build reports
  • Mark TerminalSessionDelegate at TerminalSession.swift:40 @MainActor, and give the one non-main call site at TerminalSession.swift:411 an explicit Task { @MainActor in } hop
  • Replace the Combine .sink observer for ProjectSwitched at AgentRunner.swift:39-47 with for await over NotificationCenter.default.notifications(named:) inside a stored Task { @MainActor in }, so the handler body is main-isolated by construction
  • Delete the DispatchQueue.main.async wrapper at AgentRunner.swift:97 in cleanupSessionsForProjectSwitch; the method is now on main and the wrapper only defers the publish by one runloop turn
  • Rewrite the onOutput closure at AgentRunner.swift:278 as an explicit @Sendable closure that hops with Task { @MainActor in }, so the compiler stops inferring main isolation for a closure the invoker calls off-main
  • Mark the static prompt-file helpers loadPromptTemplate, initializeGlobalPrompts, hasGlobalPrompts, initializeGlobalPromptsFromBundle, and getSchemaDefinedScope as nonisolated; they touch no instance state and are called from Code_RavenApp.swift:522 and :543 and ProjectOverviewView.swift:1981 and :1991
  • Fix the nine measured caller errors by annotating the enclosing method @MainActor: ActivityDigestStore.runDailyReportIfNeeded (lines 1014-1040, four sites), AgentTriggerSystem.executeTrigger (line 193), CheckInCoordinator.init (line 40), CheckpointValidationService.runValidation (lines 158-161), ContextualGuidanceSystem.assessCurrentWorkload (line 191); use Task { @MainActor in } only where the call is genuinely on a background path
  • Follow the four smaller waves the first fix surfaced, 8 more sites: AgentTriggerSystem file-watcher, git-hook and Combine callbacks (task hops), ContextualGuidanceSystem.generateSmartRecommendations and checkForSmartNotifications (@MainActor) plus its timer (task hop), TerminalSessionManager.stopSession and stopAllSessions (@MainActor), IntelligentHandoffSystem.executeAgentFromHandoff (task hop)
  • Build with xcodebuild and verify the artifact with scripts/verify-build-product.sh; confirm the new warnings in AgentRunner.swift are limited to Sendable notes on Notification and Project

Phase 5 — IdeationSessionStore

6 tasks Added after Phases 1 to 4 landed; same day.
  • Give IdeationSessionStore the same shape as FeatureLabSessionStore: serial ioQueue for save and delete, atomic writes, nonisolated static readSessionsFromDisk() shared by loadAllAsync() and a hasLoaded-guarded ensureLoaded() fallback, sessions made private(set)
  • Keep activeSession and pausedSessions as computed properties with ensureLoaded() in the getter; the 25 readers across ten files stay untouched
  • Move the legacy single-file migration into the nonisolated read path and write its file synchronously there, since routing it through the queued save would race the scan that follows
  • Add allSessions as the guarded accessor and route the eight direct sessions readers through it (IdeationStudioContainerView, IdeationSessionPersistence, IdeationDesignStudioMigration, CreativeJourneyMigration, CreativeJourneyStore)
  • Await IdeationSessionStore.shared.loadAllAsync() in the launch task ahead of the ideation migrations and the journey health check, which iterate every session on each launch and would otherwise always win the race
  • Build with xcodebuild and verify the artifact with scripts/verify-build-product.sh

Manual Testing

6 tasks Console filtered on cold sync load.
  • Launch Code Raven in DEBUG, wait for the ⏱️ [BridgeImporters] line in the console, then open Mira, the Parked Topics list, a Findings surface, and the Feature Lab; confirm no cold sync load line appears for any storeyou
  • Relaunch and open the Feature Lab within two seconds of the window appearing; confirm a cold sync load line appears for FeatureLabSessionStore and the session list is still fully populatedyou
  • In the Feature Lab, start a session, pause it, delete it, then relaunch; confirm the deleted session does not come backyou
  • Run an agent from the Experts view, switch projects mid-run, switch back; confirm the running badge survives and Xcode shows no purple "Publishing changes from background threads" runtime warningyou
  • Relaunch and open the Ideation Studio; confirm no cold sync load line appears for IdeationSessionStore and that the resume list and paused sessions match the previous launchyou
  • Close the FindingsStore and MiraEditorialService findings in Mira triage once the first check passes; they need no code change beyond the DEBUG printyou

Risks & edge cases

Limitation

Swift 5 mode caps the compiler check

The restructured sites are checked structurally. A full guarantee needs SWIFT_STRICT_CONCURRENCY = complete, which will raise a codebase-wide warning wave. Out of scope here, named so the gap is visible.

Edge case

The fallback can publish during a view update

MiraRoomView.swift:60 calls store.thread(for:) from a body, and after Phase 3 the Feature Lab accessors can trigger ensureLoaded() from a body. When the fallback fires there SwiftUI logs "Publishing changes from within view updates." FindingsStore and MiraThreadStore already do this today. The preload makes it rare; it does not remove it.

Edge case

Preload timing

The pre-warm group runs after a two-second settle, the Spark importer, the mockup importer, the workbench sync, the corpus refresh and the drift check. Opening Mira or the Feature Lab in the first few seconds hits the fallback. That is the accepted startup race, and the second Manual Testing check exercises it on purpose.

Lesson

The spike undercounted

Even with -continue-building-after-errors, the batch compiler skipped files whose batch never ran, so the 13 measured sites became 21 across five build rounds. Each later wave was small (3, 2, 2, 1) and every site fit one of the two prescribed fixes, but a spike build is a floor, not a count.

Edge case

Sendable warnings

Task.detached returning [UUID: MiraThread] or [FeatureLabSession] warns if the element types are not Sendable. Both are Codable value types; in Swift 5 mode this is a warning, not an error.

Out of scope

IdeationSessionStore was out of scope in the first draft and is now Phase 5, landed the same day.

Raising SWIFT_STRICT_CONCURRENCY for the target. Removing the legacy PTY activeSessions path in AgentRunner.

Final review

The plan is strongest where it copies an established pattern: Phases 2 and 3 are mechanical and each has a working reference implementation in the same tree. Phase 4 is the weak point, though a measured one now. The handoff assumed the compiler would do the work after one annotation, and in Swift 5 mode it will not at the sites that matter most, so the phase has grown from one line to a restructuring of three observer sites plus 13 measured caller fixes. If pushed, land Phases 1 to 3 as one commit and Phase 4 as a second, so a Phase 4 revert does not take the store work with it.

84/100

The store phases are near-certain: each copies a reference implementation that already runs in this tree. Phase 4's first error wave was measured at 13 sites by a spike build on 2026-09-02; a smaller second wave when the enclosing methods are annotated, and the runtime behavior of the restructured observer, remain open.