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(®, 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(®, &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}