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
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.
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.
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.
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.
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.
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 loadprint to the fallback branch ofFindingsStore.findings(for:)so a lost preload race is visible in Console - Add the same DEBUG-only
cold sync loadprint toMiraEditorialService.ensureLoadedon 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: captureSelf.threadsDirectory, decode every{projectId}.jsoninto[UUID: MiraThread]insideTask.detached(priority: .utility), then assign on MainActor only wherethreadsByProject[pid] == nil - Add
loadAllProjectsAsync(projectIds:)to MiraParkedTopicStore with the same shape, decoding[MiraParkedTopic]per project and guarding ontopicsByProject[pid] == nil - Register both stores in the launch pre-warm task group in
Code_RavenApp.swiftdirectly after the MiraEditorialService line (currently 398) - Keep both sync
loadFromDiskpaths as the startup-race fallback and add the Phase 1 DEBUG-onlycold sync loadprint 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 ioQueuewith labelcom.coderaven.featurelab.sessionstore.ioand qos.utility, followingMiraThreadStore.swift:27 - Move the directory creation, encode, and atomic write in
save(_:)onto ioQueue, capturing theupdatedvalue; the in-memorysessionsupdate stays synchronous ahead of the dispatch - Move the
removeItemindelete(_:)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()intononisolated static func readSessionsFromDisk() -> [FeatureLabSession]so the sync fallback and the async preload share one decode path - Replace the
loadAll()call ininit()with ahasLoadedflag plus a privateensureLoaded()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(): runreadSessionsFromDiskinsideTask.detached(priority: .utility), then on MainActor assignsessionsand sethasLoadedonly if the sync fallback has not already won; discard the preload result otherwise - Change
@Published var sessionstoprivate(set)so thehasLoadedguard 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 loadprint toensureLoaded() - 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
@MainActortoclass AgentRunneratAgentRunner.swift:15 - Mark the
extension Agentcomputed properties atAgentRunner.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
TerminalSessionDelegateatTerminalSession.swift:40@MainActor, and give the one non-main call site atTerminalSession.swift:411an explicitTask { @MainActor in }hop - Replace the Combine
.sinkobserver forProjectSwitchedatAgentRunner.swift:39-47withfor awaitoverNotificationCenter.default.notifications(named:)inside a storedTask { @MainActor in }, so the handler body is main-isolated by construction - Delete the
DispatchQueue.main.asyncwrapper atAgentRunner.swift:97incleanupSessionsForProjectSwitch; the method is now on main and the wrapper only defers the publish by one runloop turn - Rewrite the
onOutputclosure atAgentRunner.swift:278as an explicit@Sendableclosure that hops withTask { @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, andgetSchemaDefinedScopeasnonisolated; they touch no instance state and are called fromCode_RavenApp.swift:522and:543andProjectOverviewView.swift:1981and: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); useTask { @MainActor in }only where the call is genuinely on a background path - Follow the four smaller waves the first fix surfaced, 8 more sites:
AgentTriggerSystemfile-watcher, git-hook and Combine callbacks (task hops),ContextualGuidanceSystem.generateSmartRecommendationsandcheckForSmartNotifications(@MainActor) plus its timer (task hop),TerminalSessionManager.stopSessionandstopAllSessions(@MainActor),IntelligentHandoffSystem.executeAgentFromHandoff(task hop) - Build with xcodebuild and verify the artifact with
scripts/verify-build-product.sh; confirm the new warnings inAgentRunner.swiftare limited to Sendable notes onNotificationandProject
Phase 5 — IdeationSessionStore
6 tasks Added after Phases 1 to 4 landed; same day.- Give IdeationSessionStore the same shape as FeatureLabSessionStore: serial
ioQueueforsaveanddelete, atomic writes,nonisolated static readSessionsFromDisk()shared byloadAllAsync()and ahasLoaded-guardedensureLoaded()fallback,sessionsmadeprivate(set) - Keep
activeSessionandpausedSessionsas computed properties withensureLoaded()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
savewould race the scan that follows - Add
allSessionsas the guarded accessor and route the eight directsessionsreaders 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 oncold 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 nocold sync loadline appears for any storeyou - Relaunch and open the Feature Lab within two seconds of the window appearing; confirm a
cold sync loadline 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 loadline 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
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.
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.
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.
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.
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.
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.