Skip to main content

agentmux_srv/bootstrap/
stores.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Split out of the single-file bootstrap module unchanged (SPEC_LARGE_FILE_MODULE_ANALYSIS_2026_09_30.md §4.3).
5
6use super::*;
7
8/// Step 2: Parse CLI args and build config. Dispatches the `migrate`
9/// subcommand (and exits) before touching AUTH_KEY-gated config.
10pub fn load_config() -> config::Config {
11    let args = CliArgs::parse();
12
13    // Dispatch migrate subcommand before loading config (no AUTH_KEY needed).
14    if let Some(config::SrvCommand::Migrate { dry_run, list, verify }) = &args.command {
15        // Same precedence as `Config::from_env_and_args`: the CLI flag, then
16        // the canonical `AGENTMUX_DATA_DIR` the launcher exports. Without
17        // that fallback a launcher-spawned `migrate --verify` opened a
18        // different objects.db than the daemon's and could "verify" the
19        // wrong channel and exit 0 (codex P1 on #3058). The retired
20        // `AGENTMUX_DATA_HOME` and the bare default stay last for the
21        // standalone / pre-unification shapes.
22        let data_dir: std::path::PathBuf = args.wavedata
23            .clone()
24            .or_else(|| std::env::var_os("AGENTMUX_DATA_DIR").filter(|s| !s.is_empty()).map(std::path::PathBuf::from))
25            .or_else(|| std::env::var_os("AGENTMUX_DATA_HOME").map(std::path::PathBuf::from))
26            .unwrap_or_else(|| std::path::PathBuf::from(base::get_mux_data_dir()));
27        let code = migrations::run_migrate_command(&data_dir, *dry_run, *list, *verify);
28        std::process::exit(code);
29    }
30
31    let config = config::Config::from_env_and_args(&args).unwrap_or_else(|e| {
32        tracing::error!("Failed to load config: {}", e);
33        std::process::exit(1);
34    });
35
36    // Make the per-launch auth_key available to the cross-instance agent
37    // registry writer. Peers performing an HTTP forward of a missed inject
38    // use this to authenticate against the writing instance's sidecar.
39    // Must happen after Config::from_env_and_args (which removes
40    // AGENTMUX_AUTH_KEY from the process env) but before anything calls
41    // `agent_registry::write`.
42    crate::backend::reactive::registry::init_local_auth_key(&config.auth_key);
43
44    config
45}
46
47/// Output of [`open_stores_and_migrate`] — every store/log handle the rest
48/// of bootstrap and `AppState` need.
49pub struct Stores {
50    pub mstore: Arc<Store>,
51    pub filestore: Arc<FileStore>,
52    pub global_transcript_store: Option<Arc<FileStore>>,
53    pub shared_store: Option<Arc<Store>>,
54    pub id_store: Arc<Store>,
55    /// Permanently-global identity store (agent→account links, memory
56    /// bundles, drone definitions, muxbus/agent M2M credentials, native
57    /// memory, cron jobs) — see
58    /// `docs/specs/SPEC_IDENTITY_STORE_SPLIT_2026_08_17.md`. Distinct from
59    /// `id_store`: never redirected by `isolated_auth_enabled()`. Falls
60    /// back to `mstore` (never `None`) on the same best-effort terms as
61    /// `id_store`'s own fallback, so a resolution/open failure degrades to
62    /// today's per-channel behavior instead of panicking.
63    pub identity_store: Arc<Store>,
64    pub saga_log: Arc<crate::sagas::log::SagaLog>,
65    pub saga_id_seed: u64,
66}
67
68/// Step 4: Initialize backend (matching Go cmd/server/main-server.go:374-590).
69/// Sets up the data directory, runs in-process migrations, opens every
70/// SQLite-backed store, attaches the shared/global registries, and performs
71/// the various one-time startup seed/repair passes.
72pub fn open_stores_and_migrate(config: &config::Config, version: &str, build_time: &str) -> Stores {
73    base::set_version(version);
74    base::set_build_time(build_time);
75
76    // Set up data directory (uses AGENTMUX_DATA_HOME or default)
77    if !config.data_home.as_os_str().is_empty() {
78        std::env::set_var("AGENTMUX_DATA_HOME", &config.data_home);
79    }
80    if !config.config_home.as_os_str().is_empty() {
81        std::env::set_var("AGENTMUX_CONFIG_HOME", &config.config_home);
82    }
83    if !config.app_path.is_empty() {
84        std::env::set_var("AGENTMUX_APP_PATH", &config.app_path);
85    }
86
87    base::ensure_mux_data_dir().unwrap_or_else(|e| {
88        tracing::error!("Failed to ensure data dir: {}", e);
89        std::process::exit(1);
90    });
91    // One srv per set of databases, however it was started (headless already
92    // took this in `headless::prepare_env`; that makes this a no-op).
93    base::acquire_data_dir_lock(&base::get_mux_data_dir()).unwrap_or_else(|e| {
94        tracing::error!("{e}");
95        eprintln!("agentmux-srv: {e}");
96        std::process::exit(1);
97    });
98    base::ensure_mux_db_dir().unwrap_or_else(|e| {
99        tracing::error!("Failed to ensure db dir: {}", e);
100        std::process::exit(1);
101    });
102
103    // Startup diagnostics
104    tracing::info!(
105        data_dir = %base::get_mux_data_dir().display(),
106        db_dir = %base::get_mux_db_dir().display(),
107        app_path = %config.app_path,
108        instance_id = %config.instance_id,
109        secret_store = crate::identity::secret_store::backend_name(),
110        "backend directories initialized"
111    );
112
113    // Apply any pending migrations in-process before opening stores.
114    // This ensures 0011_shared_store_backfill (and any future Global migrations)
115    // have run before id_store binds to the shared store — avoiding apparent data
116    // loss on first boot after an upgrade. Fast-path: no-op when already current.
117    //
118    // id_store binding below checks shared_store.migration_is_applied("0011_shared_store_backfill")
119    // directly rather than a coarse migration_ok flag: if an early migration (e.g.
120    // 0011) succeeds but a later one fails, the shared store is already backfilled
121    // and safe to use — falling back to per-channel would strand writes made this
122    // session when the later migration succeeds on next boot.
123    let mux_data_dir = base::get_mux_data_dir();
124
125    // Open databases
126    let db_dir = base::get_mux_db_dir();
127
128    // Pre-migration snapshot (Increment B.2 lean cut from
129    // SPEC_DATA_CHANNELS §3.4). Run BEFORE Store::open AND before
130    // count_pending_migrations/run_pending_migrations — both open
131    // objects.db via Store::open which runs DDL/schema setup, mutating
132    // the file. The snapshot must capture the pre-DDL state so it is a
133    // valid rollback aid for a buggy forward migration.
134    //
135    // Failures are logged and ignored — refusing to boot when the
136    // snapshot can't be written would be worse than booting without a
137    // backup (the safety lock still prevents downgrade corruption).
138    let channel = std::env::var("AGENTMUX_CHANNEL").unwrap_or_else(|_| "stable".to_string());
139    let code_version = std::env::var("AGENTMUX_VERSION")
140        .unwrap_or_else(|_| env!("CARGO_PKG_VERSION").to_string());
141    // Snapshots live under the agentmux home root (sibling of `channels/`)
142    // so they survive channel switches and aren't counted against any one
143    // channel's data dir. Honor AGENTMUX_HOME_OVERRIDE for tests; else
144    // default to the OS-level `~/.agentmux/`. Matches `resolve_root` in
145    // agentmux-common — kept inline here to avoid threading the full
146    // DataPaths plumbing into main.rs for one path.
147    let snapshots_dir = std::env::var_os("AGENTMUX_HOME_OVERRIDE")
148        .filter(|s| !s.is_empty())
149        .map(std::path::PathBuf::from)
150        .unwrap_or_else(base::get_mux_data_dir)
151        .join("snapshots");
152    match maybe_snapshot_pre_migration(
153        &db_dir,
154        &snapshots_dir,
155        &channel,
156        &code_version,
157        OBJECT_SCHEMA_VERSION,
158    ) {
159        Ok(Some(path)) => tracing::info!(snapshot = %path.display(), "pre-migration snapshot written"),
160        Ok(None) => {}
161        Err(e) => tracing::warn!("pre-migration snapshot failed (continuing without backup): {}", e),
162    }
163
164    // Run in-process migrations AFTER snapshot so the rollback aid captures
165    // the pre-DDL state. count_pending_migrations emits AGENTMUXSRV-MIGRATING
166    // first so the launcher/sidecar extend their ESTART deadline before the
167    // (potentially slow) migration work begins.
168    // Strict check, lenient reaction: this line only sizes the supervisors'
169    // ESTART deadline, and the migration batch runs unconditionally right
170    // below, so an unreadable store costs nothing here beyond a less precise
171    // hint — `readable_pending()` reproduces exactly the number this call
172    // site used before. It is logged rather than swallowed because the same
173    // question becomes a real decision point once the boot gate lands:
174    // `SPEC_FAST_STARTUP_UPGRADE_OWNS_MIGRATIONS_AND_UPDATES_2026_09_15.md`
175    // invariant S1b requires that gate to fail closed, and a store that
176    // cannot be read here is precisely the case it must catch.
177    let pre_migration_count = match migrations::try_count_pending_migrations(&mux_data_dir) {
178        Ok(n) => n,
179        Err(e) => {
180            let fallback = e.readable_pending();
181            // Distinguished because they mean different things to whoever
182            // reads the log: one store was readable and the count is
183            // partial, versus nothing was readable and the count is a
184            // placeholder.
185            match &e {
186                migrations::PendingCountError::ChannelStoreUnreadable { .. } => tracing::warn!(
187                    "startup: channel-scoped migrations could not be counted: {} — sizing the ESTART deadline with {} global",
188                    e,
189                    fallback
190                ),
191                _ => tracing::warn!(
192                    "startup: no pending-migration count available: {} — sizing the ESTART deadline as if none were pending",
193                    e
194                ),
195            }
196            fallback
197        }
198    };
199    if pre_migration_count > 0 {
200        eprintln!("AGENTMUXSRV-MIGRATING migrations:{}", pre_migration_count);
201    }
202    match migrations::run_pending_migrations(&mux_data_dir) {
203        Ok(0) => {}
204        Ok(n) => tracing::info!(applied = n, "startup: applied pending migrations"),
205        Err(e) => {
206            // SPEC_MIGRATION_SYSTEM_HARDENING_2026_08_03 Phase 1 (F4): a failed
207            // migration is fatal. This used to be a `warn!` and the server
208            // booted anyway against a store in an unknown state, which turned
209            // any migration bug into a silent one. Note the asymmetry it left:
210            // failing to OPEN the store (just below) always exited 1; failing
211            // to MIGRATE it did not.
212            //
213            // The tagged stderr line lets the launcher / CEF host fail fast
214            // with the real reason instead of timing out on ESTART 30s later
215            // (crates/common/src/srv_stderr.rs). Exit code 1 is what
216            // docs/exe-return-codes.md already documented for this case.
217            tracing::error!("startup: migration failed — refusing to start: {}", e);
218            eprintln!("{}", agentmux_common::srv_stderr::migration_failed_line(&e));
219            std::process::exit(1);
220        }
221    }
222
223    let mstore_raw = Store::open(&db_dir.join("objects.db")).unwrap_or_else(|e| {
224        tracing::error!("Failed to open object store: {}", e);
225        std::process::exit(1);
226    });
227    // Attach the cross-version named-agent registry. Falls back to a
228    // disabled registry when the shared home can't be resolved (CI,
229    // unusual envs); mutations still hit SQLite, just don't mirror.
230    // See docs/specs/SPEC_SHARED_AGENT_REGISTRY_2026_05_12.md.
231    if let Some(root) = registry::resolve_shared_registry_dir() {
232        match registry::Registry::open(root.clone()) {
233            Ok(reg) => {
234                tracing::info!(root = %root.display(), "registry: shared agent registry attached");
235                // Best-effort catch-up pass, run every startup (not just the
236                // one-shot m0010 migration): fills session_id for any agent
237                // record that still lacks one, e.g. one named after m0010
238                // already ran. Idempotent — skips records that already carry
239                // a non-empty session_id. Without this, an agent created
240                // after the migration ran would never get a resumable
241                // session_id in the registry, so a cross-tab/cross-restart
242                // open would silently orphan its conversation (the still-open
243                // half of docs/retro/retro-cross-channel-conversation-continuity-regression-2026-06-16.md).
244                if let Some(shared_dir) = root.parent().and_then(|p| p.parent()) {
245                    let filled = crate::boot_timing::time("registry_session_backfill", || {
246                        backend::session_backfill::backfill_session_ids(&reg, shared_dir)
247                    });
248                    if filled > 0 {
249                        tracing::info!(filled, "registry: backfilled session_id for cross-channel resume (startup pass)");
250                    }
251                }
252                mstore_raw.set_registry(Arc::new(reg));
253                if let Some(base) = std::env::var_os("AGENTMUX_AGENTS_DIR") {
254                    if !base.is_empty() {
255                        mstore_raw
256                            .set_registry_agents_base(std::path::PathBuf::from(base));
257                    }
258                }
259            }
260            Err(e) => tracing::warn!(
261                root = %root.display(),
262                error = %e,
263                "registry: failed to open shared agent registry — SQLite remains authoritative"
264            ),
265        }
266    } else {
267        tracing::warn!("registry: could not resolve shared registry dir — mirror disabled");
268    }
269    // Attach the GLOBAL (cross-channel) agent-definition store. Sibling of the
270    // instance registry above — since P0.3b both live under ~/.agentmux/shared/
271    // (definitions/ and registry/), so user agents created in one channel are
272    // visible in every channel. Best-effort: disabled when the shared dir can't
273    // be resolved. See SPEC_CROSS_CHANNEL_AGENT_PERSISTENCE_2026-06-13.md (P0.2/P0.3).
274    // Captured for the transcript backfill below (after the global transcript
275    // store opens): the user-agent definition ids to seed conversations for.
276    let mut backfill_def_ids: Vec<String> = Vec::new();
277    if let Some(def_dir) = registry::resolve_shared_definitions_dir() {
278        match registry::DefinitionStore::open(def_dir.clone()) {
279            Ok(def_store) => {
280                // Capture user-agent ids for the transcript backfill (below).
281                backfill_def_ids = crate::boot_timing::time("def_store_list_active", || {
282                    def_store
283                        .list_active()
284                        .map(|v| v.into_iter().map(|r| r.data.id).collect())
285                        .unwrap_or_default()
286                });
287                mstore_raw.set_def_registry(Arc::new(def_store));
288                tracing::info!(dir = %def_dir.display(), "def registry: global definition store attached");
289            }
290            Err(e) => tracing::warn!(
291                dir = %def_dir.display(),
292                error = %e,
293                "def registry: failed to open global definition store — definitions stay channel-local"
294            ),
295        }
296    } else {
297        tracing::warn!("def registry: could not resolve shared definitions dir — global definitions disabled");
298    }
299    // Both host-global trees are attached now, so they can be reconciled
300    // against each other. Best-effort catch-up pass run every startup, same
301    // shape as the session_id backfill above: drops instance records whose
302    // definition is tombstoned, which the picker would otherwise keep
303    // rendering as an iconless "(missing definition)" row — a deleted agent
304    // whose card never went away. New deletes sweep the registry themselves
305    // (`Store::agent_def_delete`); this is for the orphans already on disk.
306    if let (Some(reg), Some(defs)) = (
307        mstore_raw.shared_agent_registry(),
308        mstore_raw.shared_def_registry(),
309    ) {
310        let pruned = backend::registry_reconcile::prune_tombstoned_instance_records(&reg, &defs);
311        if pruned > 0 {
312            tracing::info!(pruned, "registry: dropped instance records for deleted agents (startup pass)");
313        }
314    }
315    // Identity M4a: token → UID for request attribution, on this channel's
316    // object store only (spec §6.5.3).
317    match mstore_raw.attach_token_index() {
318        Ok(_) => tracing::info!("identity: agent token index attached"),
319        Err(e) => tracing::warn!(error = %e, "identity: agent token index unavailable — requests stay unattributed"),
320    }
321    // W3-S (SPEC_WAN_JEKT_VERIFICATION_2026_09_24.md §2.2): the channel-wide
322    // WAN identity store. Opened and its instance minted here, once, so every
323    // spawn signs as the same instance. Best-effort and never redirected: if
324    // the file can't be opened or the instance can't be minted, WAN signing
325    // is simply off for this process (agents get no WAN key), which degrades
326    // every WAN jekt to today's unsigned `TRUST=network-claimed`.
327    match backend::storage::wan_identity::resolve_wan_identity_path() {
328        Some(path) => match backend::storage::wan_identity::WanIdentityStore::open(&path).and_then(|store| {
329            let instance = store.instance_ensure(&crate::backend::reactive::registry::local_host_label())?;
330            Ok((store, instance))
331        }) {
332            Ok((store, instance)) => {
333                tracing::info!(
334                    path = %path.display(),
335                    instance = %instance.instance_id,
336                    host_hint = %instance.host_hint,
337                    "wan identity: store attached"
338                );
339                let store = Arc::new(store);
340                backend::storage::wan_identity::install_global(store.clone());
341                mstore_raw.set_wan_identity(store);
342            }
343            Err(e) => tracing::warn!(
344                path = %path.display(),
345                error = %e,
346                "wan identity: store unavailable — WAN jekt signing is off for this process"
347            ),
348        },
349        None => tracing::warn!("wan identity: channel dir unresolved — WAN jekt signing is off for this process"),
350    }
351    let mstore = Arc::new(mstore_raw);
352    // Identity M4a: lets spawn sites tell a row-backed block from a row-less
353    // one when the environment carries no UID.
354    backend::identity_spawn::attach_row_store(mstore.clone());
355    let filestore = Arc::new(FileStore::open(&db_dir.join("filestore.db")).unwrap_or_else(|e| {
356        tracing::error!("Failed to open file store: {}", e);
357        std::process::exit(1);
358    }));
359    // GLOBAL transcript store — backs the `agent:<defId>:current` zone so a
360    // conversation loads when the agent is opened from any build/channel
361    // (finishes the cross-channel arc #1387–#1396). A second FileStore over an
362    // independent SQLite/WAL file is safe alongside the per-channel one. This is
363    // best-effort: if the shared root can't be resolved, or the store can't be
364    // opened, we log and fall back to the per-channel `filestore` (global
365    // transcripts disabled) — never fatal, unlike the per-channel store above.
366    // See `docs/analysis/ANALYSIS_CROSS_CHANNEL_CONVERSATION_HISTORY_2026_06_14.md`.
367    let global_transcript_store: Option<Arc<FileStore>> =
368        match registry::resolve_shared_transcripts_dir() {
369            Some(dir) => {
370                if let Err(e) = std::fs::create_dir_all(&dir) {
371                    tracing::warn!(dir = %dir.display(), error = %e, "global transcripts: failed to create dir — disabled, falling back to per-channel store");
372                    None
373                } else {
374                    match FileStore::open(&dir.join("filestore.db")) {
375                        Ok(fs) => {
376                            tracing::info!(dir = %dir.display(), "global transcripts: store attached");
377                            Some(Arc::new(fs))
378                        }
379                        Err(e) => {
380                            tracing::warn!(dir = %dir.display(), error = %e, "global transcripts: failed to open store — disabled, falling back to per-channel store");
381                            None
382                        }
383                    }
384                }
385            }
386            None => {
387                tracing::warn!("global transcripts: could not resolve shared transcripts dir — disabled, falling back to per-channel store");
388                None
389            }
390        };
391    // GLOBAL shared store — accounts, bundles, drone
392    // definitions, MuxBus credentials. Best-effort: disabled when the shared
393    // root can't be resolved. Falls back to mstore so behavior is unchanged
394    // from today. See SPEC_GLOBAL_IDENTITY_MEMORY_DRONE_2026_06_24.md.
395    let shared_store: Option<Arc<Store>> = match registry::resolve_shared_store_path() {
396        Some(path) => {
397            // Ensure the parent dir (shared/) exists before opening.
398            let parent = path.parent().unwrap_or_else(|| path.as_path());
399            if let Err(e) = std::fs::create_dir_all(parent) {
400                tracing::warn!(path = %path.display(), error = %e, "shared store: failed to create dir — disabled");
401                None
402            } else {
403                match Store::open_shared(&path) {
404                    Ok(s) => {
405                        let reason = agentmux_common::isolated_auth_reason();
406                        if reason.is_isolated() {
407                            tracing::info!(path = %path.display(), reason = reason.as_str(), "shared store: attached (ISOLATED — channel-scoped)");
408                        } else {
409                            tracing::info!(path = %path.display(), reason = reason.as_str(), "shared store: attached");
410                        }
411                        Some(Arc::new(s))
412                    }
413                    Err(e) => {
414                        tracing::warn!(path = %path.display(), error = %e, "shared store: failed to open — identity/memory/drone/muxbus stay per-channel");
415                        None
416                    }
417                }
418            }
419        }
420        None => {
421            tracing::warn!("shared store: could not resolve shared store path — disabled");
422            None
423        }
424    };
425    // id_store: routes identity/memory/drone/muxbus ops to the shared store when
426    // available AND the shared-store backfill migration has been applied.
427    // Checking the specific migration (not a coarse migration_ok flag) avoids
428    // a split-brain when 0011 succeeded but a later migration failed: the shared
429    // store is already backfilled, so using per-channel would strand writes made
430    // this session once the later migration succeeds on next boot.
431    let id_store: Arc<Store> = match shared_store.as_ref() {
432        Some(ss) if ss.migration_is_applied("0011_shared_store_backfill") => ss.clone(),
433        Some(_) => {
434            tracing::warn!("id_store: 0011_shared_store_backfill not yet applied — using per-channel store");
435            mstore.clone()
436        }
437        None => mstore.clone(),
438    };
439
440    // Shared auth: carry what earlier per-channel (isolated) builds kept to
441    // themselves into the shared store, once and without touching the sources
442    // (SPEC_SHARED_AUTH_ACROSS_CHANNELS_2026_10_03.md). Only when this channel
443    // really runs on the shared store; an isolated one never reads another's.
444    if !agentmux_common::isolated_auth_enabled() {
445        if let (Some(ss), Some(path)) = (shared_store.as_ref(), registry::resolve_shared_store_path()) {
446            if Arc::ptr_eq(ss, &id_store) {
447                crate::adoption::run_at_boot(ss, &path);
448            }
449        }
450    }
451
452    // Permanently-global identity store (agent→account links, memory
453    // bundles, drone definitions, muxbus/agent M2M credentials, native
454    // memory, cron jobs) — see
455    // docs/specs/SPEC_IDENTITY_STORE_SPLIT_2026_08_17.md. Deliberately NOT
456    // gated by isolated_auth_enabled() at all, unlike shared_store above:
457    // none of this store's tables have a legitimate per-channel-isolation
458    // use case. Best-effort, same fallback-to-mstore terms as id_store —
459    // a resolution/open failure degrades to today's per-channel behavior
460    // rather than being fatal.
461    let identity_store: Arc<Store> = match registry::resolve_identity_store_path() {
462        Some(path) => {
463            let parent = path.parent().unwrap_or(path.as_path());
464            if let Err(e) = std::fs::create_dir_all(parent) {
465                tracing::warn!(path = %path.display(), error = %e, "identity store: failed to create dir — falling back to per-channel store");
466                mstore.clone()
467            } else {
468                match Store::open_identity_store(&path) {
469                    Ok(s) => {
470                        tracing::info!(path = %path.display(), "identity store: attached (always global)");
471                        Arc::new(s)
472                    }
473                    Err(e) => {
474                        tracing::warn!(path = %path.display(), error = %e, "identity store: failed to open — falling back to per-channel store");
475                        mstore.clone()
476                    }
477                }
478            }
479        }
480        None => {
481            tracing::warn!("identity store: could not resolve path — falling back to per-channel store");
482            mstore.clone()
483        }
484    };
485    // #3603: memory resolution finds the account an agent is linked to (the
486    // directory its spawn runs Claude in) through these, read-only.
487    crate::server::native_memory_handlers::attach_identity_stores(id_store.clone(), identity_store.clone());
488
489    // Install the process-global handle so the block-controller stdout-reader
490    // hot path can mirror agent `output` into the global zone without threading
491    // the store through `resync_controller` and every controller constructor.
492    // Global Memory's own record (SPEC_MEMORY_FOLLOWS_THE_AGENT_2026_09_24.md
493    // §2.1.6): write-through from id_store, attached before this start's
494    // seeding so seeded entries keep their seeder's attribution.
495    let global_memory_recorder = global_transcript_store
496        .as_ref()
497        .map(|fs| crate::backend::global_memory_record::attach(fs.clone(), id_store.clone()));
498    if let Some(ref fs) = global_transcript_store {
499        crate::backend::agent_session::set_global_transcript_store(fs.clone());
500        // Heal global snapshots poisoned before the normalize-on-mirror fix: a
501        // channel-local `sourceBlockId` mirrored into the global zone makes a
502        // cross-channel open render empty (the read fallback can't anchor a block
503        // that doesn't exist in the opening channel). Idempotent + cheap. See
504        // docs/retro/retro-legacy-agent-history-cross-channel-2026-06-16.md.
505        let healed = crate::boot_timing::time("transcript_snapshot_heal", || {
506            backend::agent_session::heal_global_snapshot_source_block_ids(fs, &backfill_def_ids)
507        });
508        if healed > 0 {
509            tracing::info!(healed, "global transcripts: healed poisoned snapshot sourceBlockIds");
510        }
511    }
512
513    // Saga durability — see SPEC_SAGA_DURABILITY_2026-05-01.md.
514    // Backed by its own SQLite file (`sagas.db`) so saga writes
515    // commit independently of the mstore connection. Failure here
516    // is fatal: without the log, a srv crash mid-saga leaves
517    // unrecoverable state divergence.
518    let saga_log = Arc::new(
519        crate::sagas::log::SagaLog::open(&db_dir.join("sagas.db")).unwrap_or_else(|e| {
520            tracing::error!("Failed to open saga log: {}", e);
521            std::process::exit(1);
522        }),
523    );
524    // Seed `saga_id_alloc` from the highest persisted saga_id so
525    // restarts don't reuse IDs from prior runs (reagent P1 + codex
526    // P1 PR #631). With this seed + the plain INSERT (no OR REPLACE)
527    // in `start_saga`, ID collisions become impossible by
528    // construction.
529    let saga_id_seed = crate::boot_timing::time("saga_id_seed_scan", || saga_log.max_saga_id()).unwrap_or_else(|e| {
530        tracing::warn!(
531            "[saga] failed to read MAX(saga_id) for allocator seed: {} — defaulting to 0; ID collisions on restart possible until next successful query",
532            e
533        );
534        0
535    });
536    if saga_id_seed > 0 {
537        tracing::info!(
538            "[saga] seeded saga_id_alloc from durable log: next saga_id = {}",
539            saga_id_seed + 1
540        );
541    }
542
543    // Bootstrap data (creates Client/Window/Workspace/Tab on first launch)
544    let first_launch = wcore::ensure_initial_data(&mstore).unwrap_or_else(|e| {
545        tracing::error!("Failed to ensure initial data: {}", e);
546        std::process::exit(1);
547    });
548    if first_launch {
549        tracing::info!("First launch: created initial data");
550    }
551
552    // Seed ~/.agentmux/.gitignore so accidental git operations inside the
553    // data directory (e.g. an agent running `git init` or `git clone` in its
554    // cwd) don't stage anything by default. Idempotent — written once per
555    // install; we don't overwrite an existing user-customized file.
556    if let Some(home) = dirs::home_dir() {
557        let data_dir = home.join(".agentmux");
558        if data_dir.is_dir() {
559            let gitignore = data_dir.join(".gitignore");
560            if !gitignore.exists() {
561                let _ = std::fs::write(&gitignore, "*\n!.gitignore\n");
562            }
563        }
564    }
565
566    // Session recovery (Phase 4.2): scan for agent blocks that still have
567    // `session:active_pid` from a previous run — those sessions were killed
568    // by a crash/reboot. Transfer to `session:was_interrupted` so the
569    // frontend can show a reconnect banner.
570    let orphan_count = backend::blockcontroller::session_recovery::scan_orphans(&mstore);
571    if orphan_count > 0 {
572        tracing::info!(
573            orphan_count = orphan_count,
574            "session_recovery: flagged {} interrupted sessions for user reconnect",
575            orphan_count
576        );
577    }
578
579    // Auto-seed agent definitions on first launch (or empty DB)
580    crate::boot_timing::time("agent_auto_seed", || backend::agent_seed::auto_seed_on_startup(&mstore));
581
582    // Keep AgentMux's own Operator Config (is_system=1 Global Memory) in
583    // sync with the shipped manifest on every startup — not a one-time
584    // seed, since stale Operator Config content would mislead an agent
585    // rather than merely being absent. See
586    // docs/specs/SPEC_SYSTEM_TIER_GLOBAL_MEMORY_SEEDING_2026_09_15.md.
587    //
588    // Seeded into id_store, NOT mstore: Global Memory reads/writes
589    // (agent_open.rs's bundle_list_global, bundle.rs's handlers via
590    // state.id_store) all route through id_store, which points at the
591    // shared store once 0011_shared_store_backfill has run and only falls
592    // back to mstore before that. Seeding mstore directly would leave these
593    // rows invisible under the normal shared-store configuration once that
594    // migration has applied. Codex P1, PR #3244.
595    backend::operator_config_seed::auto_seed_on_startup(&id_store);
596    // Then record whatever the record doesn't have yet: the baseline the
597    // first time, and what older builds changed since.
598    if let Some(ref recorder) = global_memory_recorder {
599        recorder.sync_all();
600    }
601
602    // The starter Skills catalog is seeded by migrations::m0015_seed_starter_skills
603    // (run once ever per channel, tracked in db_migrations) — not here. See
604    // that migration's doc comment for why catalog-emptiness was dropped as
605    // the gate.
606
607    // Gap-repair (backfilling a db_agent_definitions row missing its
608    // db_agents mirror) is gone as of the definition flip: every
609    // agent_def_* write lands on db_agents directly now, so that gap can no
610    // longer open. Removing the per-boot pass was required, not optional —
611    // once agent_def_delete/instance_delete stopped also clearing
612    // db_agent_definitions, a repair pass still reading it would have
613    // resurrected every deleted agent on the next boot.
614
615    // Let the cross-instance agent registry publish each agent's Ed25519
616    // PUBLIC key alongside its entry, so a peer instance in a *different
617    // channel* can verify that agent's jekts — the one same-machine tier that
618    // had no verification path at all before
619    // `SPEC_JEKT_CROSS_CHANNEL_TRUST_2026_09_02.md`. Host-tier's HMAC key is
620    // symmetric and deliberately NOT published (spec §5).
621    //
622    // Installed here rather than next to `init_local_auth_key` in
623    // `load_config` because that runs before any store is open, and this is a
624    // per-agent lookup rather than a process constant. Registry writes before
625    // this point simply publish an empty key, which readers treat as "cannot
626    // check."
627    crate::backend::reactive::registry::init_jekt_public_key_resolver({
628        let mstore = mstore.clone();
629        move |agent_id| mstore.agent_lan_public_key_load(agent_id).ok().flatten()
630    });
631    // Identity M4d-5: the block's agent UID and that UID's public key, beside
632    // the name-keyed one.
633    crate::backend::reactive::registry::init_uid_key_resolver({
634        let mstore = mstore.clone();
635        move |block_id| {
636            let uid = mstore.instance_get_active_for_block(block_id).ok().flatten()?.id;
637            let key = mstore.agent_uid_lan_public_key_load(&uid).ok().flatten()?;
638            Some((uid, key))
639        }
640    });
641
642    Stores {
643        mstore,
644        filestore,
645        global_transcript_store,
646        shared_store,
647        id_store,
648        identity_store,
649        saga_log,
650        saga_id_seed,
651    }
652}