Skip to main content

agentmux_srv/migrations/
runner.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Migration runner — invoked by `agentmux-srv migrate` (a CLI subcommand,
5//! and the now-dead `agentmux-launcher::srv_spawner::run_migrate` subprocess
6//! path) and, separately, in-process at every srv boot via
7//! `run_pending_migrations`.
8//!
9//! Both paths report the SAME per-migration begin/done progress
10//! (`ApplyProgress`) but over different channels: the CLI path emits
11//! newline-delimited JSON to stdout (read by the dead subprocess path, kept
12//! for that reason); `run_pending_migrations` emits `agentmux_common::
13//! srv_stderr`'s `AGENTMUXSRV-MIGRATION-BEGIN`/`-END` lines to stderr — the
14//! real, in-process boot path's own protocol, so the launcher's splash
15//! screen can show live per-migration sub-rows instead of a silent clock.
16//! Failures are written to `<home>/logs/migration-error.log`; the
17//! process exits non-zero so the launcher can surface the error rather than
18//! booting with a half-migrated data dir.
19
20use std::collections::HashSet;
21use std::path::{Path, PathBuf};
22use std::time::Instant;
23
24use crate::backend::storage::store::Store;
25use crate::registry::{resolve_global_shared_root, resolve_shared_store_path};
26
27use super::{MigrationContext, MigrationScope, REGISTRY};
28
29// ── Progress events (newline-delimited JSON → launcher reads these) ───────────
30
31fn emit(event: &str, id: &str, extra: &str) {
32    if extra.is_empty() {
33        println!("{{\"event\":\"{}\",\"id\":\"{}\"}}", event, id);
34    } else {
35        println!("{{\"event\":\"{}\",\"id\":\"{}\",{}}}", event, id, extra);
36    }
37}
38
39fn emit_summary(applied: usize, skipped: usize) {
40    println!(
41        "{{\"event\":\"complete\",\"applied\":{},\"skipped\":{}}}",
42        applied, skipped
43    );
44}
45
46/// The true, unconditional `~/.agentmux` root — `MigrationContext.home`.
47///
48/// Deliberately NOT derived from `resolve_shared_store_path()` (which
49/// varies under isolated-auth mode, resolving to a channel-scoped path
50/// instead of the global one) — nearly every registered migration builds
51/// paths directly off `ctx.home` (registry/definitions/transcripts dirs),
52/// and `backup_stores`/`write_error_log` below use it too. All of those
53/// must stay anchored to the real global root regardless of isolation;
54/// only `ctx.shared_store_path` itself is meant to vary. A single shared
55/// helper (not one inline derivation per call site) so the two can't
56/// drift apart. See `registry::resolve_global_shared_root`'s doc comment
57/// and `docs/specs/SPEC_ISOLATED_AUTH_DEV_TESTING_2026_07_27.md`.
58fn resolve_home() -> Option<PathBuf> {
59    resolve_global_shared_root().and_then(|p| p.parent().map(Path::to_path_buf))
60}
61
62// ── Entry point ───────────────────────────────────────────────────────────────
63
64/// Exit code of `migrate --verify` when at least one applied migration's
65/// post-condition is a mismatch or its check errored. Distinct from 1 (a
66/// migration RUN failed) so a caller can tell "your data is inconsistent"
67/// from "the migrate command itself broke", and from 2, which clap uses for
68/// a CLI usage error (a typoed flag must not read as a data finding —
69/// reagent P2 on #3058). See docs/exe-return-codes.md.
70pub const VERIFY_FAILED_EXIT_CODE: i32 = 3;
71
72/// Called from `main.rs` when the `migrate` subcommand is active.
73/// Returns the exit code (0 = success, 1 = failure, 3 = `--verify` found a
74/// mismatch — see `VERIFY_FAILED_EXIT_CODE`).
75pub fn run_migrate_command(data_dir: &Path, dry_run: bool, list: bool, verify: bool) -> i32 {
76    // Unresolvable shared store path means we're in CI or an unusual env that
77    // has no AGENTMUX_SHARED_DIR. Mirror the daemon's behaviour: treat as a
78    // no-op and exit 0 so the launcher proceeds normally.
79    let shared_store_path = match resolve_shared_store_path() {
80        Some(p) => p,
81        None => {
82            emit_summary(0, 0);
83            return 0;
84        }
85    };
86
87    let home = match resolve_home() {
88        Some(p) => p,
89        None => {
90            emit_summary(0, 0);
91            return 0;
92        }
93    };
94
95    // `--verify` is read-only by contract, so it dispatches BEFORE the store
96    // opens below: `Store::open*` run schema setup, WAL configuration and
97    // legacy-table adoption, and create the database file when it is missing
98    // (codex P2 on #3058). A doctor must not do any of that — least of all
99    // turn a typoed data dir into a fresh empty store that then "verifies".
100    if verify {
101        return cmd_verify(data_dir, &home, &shared_store_path);
102    }
103
104    // Ensure the shared/ directory exists — on a fresh install it has not been
105    // created yet (the daemon does create_dir_all at startup, but migrate runs
106    // before the daemon). SQLite cannot create parent directories itself.
107    if let Some(parent) = shared_store_path.parent() {
108        if let Err(e) = std::fs::create_dir_all(parent) {
109            eprintln!("migration: failed to create shared dir {}: {}", parent.display(), e);
110            return 1;
111        }
112    }
113
114    // `--list` and `--dry-run` are read-only previews and deliberately do NOT
115    // take the migration lock: an operator uses them precisely while another
116    // process may be mid-migration, and a 30-minute wait would defeat that
117    // (reagent P1 on #3062). They open the stores the ordinary way (schema
118    // setup and all — the same thing the daemon does on every boot).
119    if list || dry_run {
120        let (shared_store, channel_store) = match open_stores_for_preview(&shared_store_path, data_dir) {
121            Ok(pair) => pair,
122            Err(msg) => {
123                eprintln!("migration: {}", msg);
124                return 1;
125            }
126        };
127        if list {
128            return cmd_list(&shared_store, channel_store.as_ref());
129        }
130        let pending = pending_of(REGISTRY, &shared_store, channel_store.as_ref());
131        if pending.is_empty() {
132            emit_summary(0, REGISTRY.len());
133        }
134        for m in &pending {
135            println!("pending: {} — {}", m.id(), m.description());
136        }
137        return 0;
138    }
139
140    // Apply: the SAME locked batch the daemon runs (`apply_pending`), so the
141    // CLI can never race a booting srv — and the lock is taken before the
142    // stores are opened, so a daemon holding a write transaction longer than
143    // `Store::open*`'s own 5 s busy timeout waits on the lock instead of
144    // failing with a spurious "database is locked" (reagent P1 on #3062).
145    // The only CLI-specific parts are the NDJSON progress lines the
146    // launcher's `run_migrate` reader expects and the error log file.
147    let progress = ApplyProgress {
148        on_start: &|id, description| {
149            emit("migration_start", id, &format!("\"description\":\"{}\"", description));
150        },
151        on_done: &|id, ms| emit("migration_done", id, &format!("\"duration_ms\":{}", ms)),
152    };
153    match apply_pending(REGISTRY, &home, &shared_store_path, data_dir, Some(&progress)) {
154        Ok(outcome) => {
155            emit_summary(outcome.applied, outcome.skipped);
156            0
157        }
158        Err(msg) => {
159            write_error_log(&home, &msg);
160            eprintln!("{}", msg);
161            1
162        }
163    }
164}
165
166/// Return the store that tracks applied state for a migration of the given scope.
167/// Global migrations → shared store; Channel migrations → channel store.
168fn tracking_store<'a>(
169    scope: super::MigrationScope,
170    shared: &'a Store,
171    channel: Option<&'a Store>,
172) -> Option<&'a Store> {
173    match scope {
174        super::MigrationScope::Global => Some(shared),
175        super::MigrationScope::Channel => channel,
176    }
177}
178
179fn cmd_list(shared_store: &Store, channel_store: Option<&Store>) -> i32 {
180    let shared_applied = shared_store.migrations_list_applied().unwrap_or_default();
181    let channel_applied = channel_store
182        .and_then(|s| s.migrations_list_applied().ok())
183        .unwrap_or_default();
184    for m in REGISTRY.iter() {
185        let applied_ids = match m.scope() {
186            super::MigrationScope::Global => &shared_applied,
187            super::MigrationScope::Channel => &channel_applied,
188        };
189        let status = if applied_ids.contains(&m.id().to_string()) { "applied" } else { "pending" };
190        println!("{} [{}] [{}] — {}", m.id(), m.scope().as_str(), status, m.description());
191    }
192    0
193}
194
195// ── Backup ────────────────────────────────────────────────────────────────────
196
197fn backup_stores(home: &Path, shared_store_path: &Path, data_dir: &Path) -> std::io::Result<()> {
198    let version = env!("CARGO_PKG_VERSION");
199    let ts = std::time::SystemTime::now()
200        .duration_since(std::time::UNIX_EPOCH)
201        .map(|d| d.as_secs())
202        .unwrap_or(0);
203    let backup_dir = home
204        .join("shared")
205        .join("backups")
206        .join(format!("pre-migration-{}-{}", version, ts));
207    std::fs::create_dir_all(&backup_dir)?;
208
209    // shared store.db
210    if shared_store_path.exists() {
211        std::fs::copy(shared_store_path, backup_dir.join("store.db"))?;
212    }
213
214    // channel objects.db
215    let objects_db = data_dir.join("db").join("objects.db");
216    if objects_db.exists() {
217        std::fs::copy(&objects_db, backup_dir.join("objects.db"))?;
218    }
219
220    prune_old_backups(home);
221    Ok(())
222}
223
224fn prune_old_backups(home: &Path) {
225    let backups_dir = home.join("shared").join("backups");
226    let cutoff = std::time::SystemTime::now()
227        .duration_since(std::time::UNIX_EPOCH)
228        .map(|d| d.as_secs())
229        .unwrap_or(0)
230        .saturating_sub(30 * 24 * 60 * 60); // 30 days
231
232    let Ok(entries) = std::fs::read_dir(&backups_dir) else { return };
233    for entry in entries.flatten() {
234        let path = entry.path();
235        if !path.is_dir() { continue; }
236        let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
237        // parse timestamp from "pre-migration-<version>-<ts>"
238        if let Some(ts_str) = name.rsplit('-').next() {
239            if let Ok(ts) = ts_str.parse::<u64>() {
240                if ts < cutoff {
241                    let _ = std::fs::remove_dir_all(&path);
242                }
243            }
244        }
245    }
246}
247
248// ── In-process migration runner (used by srv startup) ────────────────────────
249
250/// Apply all pending migrations in-process before srv opens its stores.
251///
252/// Called unconditionally at srv startup so the shared store is fully backfilled
253/// before `id_store` binds to it. This prevents apparent data loss (empty shared
254/// store) on first boot after an upgrade. Fast-path: returns `Ok(0)` immediately
255/// when all migrations are already applied.
256///
257/// Unlike `run_migrate_command` this writes progress via `tracing` rather than
258/// stdout JSON and is meant to be called from within the daemon process rather
259/// than a subprocess. Returns the number of migrations applied. On error, returns
260/// `Err` — callers should fall back to the per-channel store rather than binding
261/// `id_store` to an un-backfilled shared store.
262///
263/// `data_dir` must be the wave data dir (parent of `db/`), not the db dir.
264pub fn run_pending_migrations(data_dir: &Path) -> Result<usize, String> {
265    let shared_store_path = match resolve_shared_store_path() {
266        Some(p) => p,
267        None => {
268            tracing::info!("run_pending_migrations: shared store path unresolvable — nothing to do");
269            return Ok(0);
270        }
271    };
272
273    let home = match resolve_home() {
274        Some(p) => p,
275        None => return Err("run_pending_migrations: cannot resolve global shared root".to_string()),
276    };
277
278    // Per-migration BEGIN/END stderr lines, inside the AGENTMUXSRV-MIGRATING
279    // window `bootstrap/stores.rs` already opens. Without this, srv's launcher-
280    // supervised splash shows a bare, silently-running "Backend startup"
281    // clock for the whole migration batch — up to 30 minutes
282    // (MIGRATION_LOCK_WAIT) — with no indication anything unusual is
283    // happening inside it. See
284    // docs/reports/REPORT_SPLASH_SCREEN_ARCHITECTURE_RETHINK_2026_09_14.md
285    // §2.1. Unlike `run_migrate_command`'s stdout JSON (read by the
286    // now-dead subprocess path), this goes to STDERR — the channel every
287    // real daemon-boot marker (ESTART, MIGRATING, MIGRATION-FAILED) already
288    // uses — via `agentmux_common::srv_stderr`, which both real consumers
289    // (the launcher and, in `task dev:standalone`, the CEF host sidecar)
290    // already parse the rest of this protocol from. The launcher parses
291    // these two lines (`srv_spawner.rs`); a supervisor that doesn't (the
292    // CEF host sidecar today) just logs them like any other stderr line.
293    let progress = ApplyProgress {
294        on_start: &|id, description| {
295            eprintln!("{}", agentmux_common::srv_stderr::migration_begin_line(id, description));
296        },
297        on_done: &|id, ms| {
298            eprintln!("{}", agentmux_common::srv_stderr::migration_end_line(id, ms));
299        },
300    };
301
302    apply_pending(REGISTRY, &home, &shared_store_path, data_dir, Some(&progress)).map(|o| o.applied)
303}
304
305// ── Cross-process migration lock (Phase 3, F6) ────────────────────────────────
306//
307// Two `agentmux-srv` processes against the same data dir (a crash-restart
308// race, or a misconfigured channel) could both see a migration as pending
309// and both run `up()`: SQLite's own locking serializes individual statements,
310// not the runner's check → run → mark-applied sequence (classic TOCTOU).
311//
312// The lock is a dedicated one-table SQLite file next to the shared store
313// with `BEGIN IMMEDIATE` held for the whole batch. That IS a cross-process
314// advisory lock — SQLite takes an OS-level file lock — with three properties
315// a hand-rolled lockfile does not get for free: it blocks (busy_timeout)
316// instead of failing, it is released by the OS when the holder dies (no
317// stale-lockfile cleanup), and it is portable to Windows. It is NOT taken on
318// the real stores because migrations open their own connections to those
319// (m0007 opens the channel store itself); a transaction held there would
320// deadlock them.
321
322/// File name of the lock database, created beside the shared store.
323pub(super) const MIGRATION_LOCK_FILE: &str = "migrations.lock.db";
324
325/// How long a second runner waits for the first to finish before giving up.
326/// Matches the launcher's extended ESTART deadline for a long migration
327/// batch (`srv_spawner.rs` / `sidecar.rs`: 30 minutes).
328const MIGRATION_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(30 * 60);
329
330/// Held for the duration of a migration batch. Dropping it releases the lock.
331pub(super) struct MigrationLock {
332    _conn: rusqlite::Connection,
333}
334
335impl MigrationLock {
336    /// Acquire the batch lock at `path`, waiting up to `wait` for another
337    /// holder. `Err` only if the lock database cannot be opened or the wait
338    /// expires — both fatal for a migration run, by design.
339    pub(super) fn acquire(path: &Path, wait: std::time::Duration) -> Result<Self, String> {
340        let conn = rusqlite::Connection::open(path)
341            .map_err(|e| format!("open migration lock {}: {}", path.display(), e))?;
342        conn.busy_timeout(wait)
343            .map_err(|e| format!("migration lock busy_timeout: {}", e))?;
344        // The table exists only so the file is a well-formed database on
345        // first use; the lock is the RESERVED lock BEGIN IMMEDIATE takes.
346        conn.execute_batch("CREATE TABLE IF NOT EXISTS lock (id INTEGER PRIMARY KEY); BEGIN IMMEDIATE;")
347            .map_err(|e| format!("acquire migration lock {} (another runner may still hold it): {}", path.display(), e))?;
348        Ok(Self { _conn: conn })
349    }
350}
351
352/// Open the shared + channel stores the way every writer does (creating the
353/// channel dir/db on a fresh install — see the comment inside). Shared by the
354/// locked batch and the lock-free `--list`/`--dry-run` previews.
355fn open_stores_for_preview(shared_store_path: &Path, data_dir: &Path) -> Result<(Store, Option<Store>), String> {
356    let shared_store = Store::open_shared(shared_store_path)
357        .map_err(|e| format!("open shared store: {}", e))?;
358    let channel_store_path = data_dir.join("db").join("objects.db");
359    if let Some(parent) = channel_store_path.parent() {
360        std::fs::create_dir_all(parent).map_err(|e| format!("create channel db dir: {}", e))?;
361    }
362    // Channel store (objects.db) tracks channel-scoped migrations independently
363    // per channel — MigrationScope::Channel migrations record here, not in shared.
364    // Always open/create it so that seed migrations (e.g. m0008_default_bundle)
365    // run on fresh install. Skipping it on a fresh install once left the
366    // Channel-scoped migrations (0002/0003/0007) out of the pending list, so
367    // they were never marked applied; when mstore then created objects.db,
368    // count_pending_migrations at ESTART reported them pending and fired the
369    // "Migration failed" UI warning on every fresh first launch.
370    // Data-transformation migrations guard themselves with
371    // `if !ctx.channel_store_path.exists()` and are no-ops on an empty db.
372    let channel_store = Store::open(&channel_store_path)
373        .map(Some)
374        .map_err(|e| format!("open channel store: {}", e))?;
375    Ok((shared_store, channel_store))
376}
377
378/// The migrations in `regs` whose tracking store does not record them applied.
379fn pending_of<'r>(
380    regs: &'r [&'r (dyn super::Migration + Sync)],
381    shared: &Store,
382    channel: Option<&Store>,
383) -> Vec<&'r (dyn super::Migration + Sync)> {
384    regs.iter()
385        .copied()
386        .filter(|m| {
387            tracking_store(m.scope(), shared, channel).map_or(false, |s| !s.migration_is_applied(m.id()))
388        })
389        .collect()
390}
391
392/// Per-migration progress hooks. Two real implementations: the CLI's NDJSON
393/// stdout lines (`run_migrate_command`), and — since this PR —
394/// `run_pending_migrations`'s `AGENTMUXSRV-MIGRATION-BEGIN`/`-END` stderr
395/// lines, which the launcher turns into live splash sub-rows. Both also
396/// still get the unconditional `tracing` lines `apply_pending` emits
397/// regardless of whether a progress hook is passed at all.
398pub(super) struct ApplyProgress<'a> {
399    pub on_start: &'a dyn Fn(&str, &str),
400    pub on_done: &'a dyn Fn(&str, u64),
401}
402
403/// What a batch did. `skipped` = already applied at the time of the run.
404#[derive(Debug, Clone, Copy, PartialEq, Eq)]
405pub(super) struct ApplyOutcome {
406    pub applied: usize,
407    pub skipped: usize,
408}
409
410/// The locked migration batch — the ONE apply path, used by the daemon
411/// (`run_pending_migrations`) and by `agentmux-srv migrate`. Generic over the
412/// registry so the concurrency contract can be tested with a fake migration.
413///
414/// Order matters and is the point of Phase 3: the cross-process lock is
415/// taken FIRST, before the stores are even opened, so that check → run →
416/// mark is one critical section and a second runner blocks on the lock
417/// (30 min) rather than tripping `Store::open*`'s 5 s busy timeout against
418/// a migrating peer.
419pub(super) fn apply_pending(
420    regs: &[&(dyn super::Migration + Sync)],
421    home: &Path,
422    shared_store_path: &Path,
423    data_dir: &Path,
424    progress: Option<&ApplyProgress<'_>>,
425) -> Result<ApplyOutcome, String> {
426    if let Some(parent) = shared_store_path.parent() {
427        if let Err(e) = std::fs::create_dir_all(parent) {
428            return Err(format!("run_pending_migrations: create shared dir: {}", e));
429        }
430    }
431
432    // Two locks, one beside each store this batch can write, always in the
433    // same order (shared, then channel) so two runners can never deadlock
434    // on each other. One lock beside the shared store is not enough: two
435    // processes can share a `data_dir` (hence the same `objects.db`) while
436    // resolving DIFFERENT shared stores — isolated-auth instances with
437    // different `AGENTMUX_INSTANCE_DIR`s do exactly that — and would then
438    // hold different shared-side locks while racing on the same
439    // channel-scoped migrations (codex P1 on #3062).
440    let shared_lock_path = shared_store_path
441        .parent()
442        .map(|p| p.join(MIGRATION_LOCK_FILE))
443        .unwrap_or_else(|| PathBuf::from(MIGRATION_LOCK_FILE));
444    let _shared_lock = MigrationLock::acquire(&shared_lock_path, MIGRATION_LOCK_WAIT)
445        .map_err(|e| format!("run_pending_migrations: {}", e))?;
446    let channel_db_dir = data_dir.join("db");
447    if let Err(e) = std::fs::create_dir_all(&channel_db_dir) {
448        return Err(format!("run_pending_migrations: create channel db dir: {}", e));
449    }
450    let _channel_lock = MigrationLock::acquire(&channel_db_dir.join(MIGRATION_LOCK_FILE), MIGRATION_LOCK_WAIT)
451        .map_err(|e| format!("run_pending_migrations: {}", e))?;
452
453    let (shared_store, channel_store) = open_stores_for_preview(shared_store_path, data_dir)
454        .map_err(|e| format!("run_pending_migrations: {}", e))?;
455    let channel_store_path = channel_db_dir.join("objects.db");
456
457    let pending = pending_of(regs, &shared_store, channel_store.as_ref());
458    if pending.is_empty() {
459        return Ok(ApplyOutcome { applied: 0, skipped: regs.len() });
460    }
461    let skipped = regs.len() - pending.len();
462
463    let ctx = super::MigrationContext {
464        home: home.to_path_buf(),
465        data_dir: data_dir.to_path_buf(),
466        shared_store_path: shared_store_path.to_path_buf(),
467        channel_store_path: channel_store_path.clone(),
468    };
469
470    if let Err(e) = backup_stores(home, shared_store_path, data_dir) {
471        return Err(format!("run_pending_migrations: backup failed: {}", e));
472    }
473
474    let mut applied = 0;
475    for m in &pending {
476        let t = Instant::now();
477        tracing::info!(id = m.id(), description = m.description(), "run_pending_migrations: applying");
478        if let Some(p) = progress {
479            (p.on_start)(m.id(), m.description());
480        }
481        match m.up(&ctx) {
482            Ok(()) => {
483                let ms = t.elapsed().as_millis() as u64;
484                let scope = m.scope().as_str();
485                let tracking = tracking_store(m.scope(), &shared_store, channel_store.as_ref())
486                    .ok_or_else(|| format!("run_pending_migrations: no tracking store for {} (channel store missing)", m.id()))?;
487                if let Err(e) = tracking.migration_mark_applied(m.id(), scope, ms) {
488                    return Err(format!("run_pending_migrations: failed to record {} as applied: {}", m.id(), e));
489                }
490                tracing::info!(id = m.id(), duration_ms = ms, "run_pending_migrations: applied");
491                if let Some(p) = progress {
492                    (p.on_done)(m.id(), ms);
493                }
494                applied += 1;
495            }
496            Err(e) => {
497                return Err(format!("run_pending_migrations: migration {} failed: {}", m.id(), e));
498            }
499        }
500    }
501
502    Ok(ApplyOutcome { applied, skipped })
503}
504
505// ── Pending count (used by srv startup before migration and for ESTART) ──────
506
507/// Why a pending count could not be determined. Distinguished from "zero
508/// pending" on purpose: a caller deciding whether the schema is current
509/// must be able to tell "nothing to do" from "I could not tell", because
510/// the safe reaction to each is opposite.
511#[derive(Debug)]
512pub enum PendingCountError {
513    /// No shared store path resolves (CI, or an env with no
514    /// `AGENTMUX_SHARED_DIR`). Global-scope migrations cannot be counted.
515    SharedStoreUnresolvable,
516    /// The shared store exists but would not open.
517    SharedStoreUnreadable { path: PathBuf, error: String },
518    /// The channel store would not open. Channel-scoped migrations cannot be
519    /// counted. Carries the global-scope count that WAS readable, so the
520    /// lenient wrapper can keep reporting it — but it is still an error,
521    /// because a partial count must not be mistaken for a complete one.
522    ChannelStoreUnreadable { path: PathBuf, error: String, global_pending: usize },
523}
524
525impl PendingCountError {
526    /// The pending count that WAS readable despite this error — the
527    /// global-scope half when only the channel store failed, otherwise none
528    /// at all.
529    ///
530    /// For callers that must not block on an unreadable store (the
531    /// `AGENTMUXSRV-MIGRATING` hint, which only sizes a timeout, and the
532    /// `ESTART` report, which runs after the migration batch). A caller
533    /// *deciding* anything on the count must handle the error instead — see
534    /// [`try_count_pending_migrations`].
535    pub fn readable_pending(&self) -> usize {
536        match self {
537            Self::ChannelStoreUnreadable { global_pending, .. } => *global_pending,
538            Self::SharedStoreUnresolvable | Self::SharedStoreUnreadable { .. } => 0,
539        }
540    }
541}
542
543impl std::fmt::Display for PendingCountError {
544    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
545        match self {
546            Self::SharedStoreUnresolvable => write!(f, "could not resolve the shared store path"),
547            Self::SharedStoreUnreadable { path, error } => {
548                write!(f, "failed to open the shared store at {}: {}", path.display(), error)
549            }
550            Self::ChannelStoreUnreadable { path, error, .. } => {
551                write!(f, "failed to open the channel store at {}: {}", path.display(), error)
552            }
553        }
554    }
555}
556
557/// Number of REGISTRY migrations not yet applied, or why that could not be
558/// determined.
559///
560/// Opens stores read-write (SQLite has no read-only open for WAL mode); this
561/// may create `objects.db` if it does not exist. `data_dir` must be the wave
562/// data dir (parent of `db/`), not the db dir itself.
563///
564/// WHY THIS EXISTS ALONGSIDE [`count_pending_migrations`]. That function
565/// reports `0` on every error, which is the right default for its caller:
566/// the migration run is still ahead of it, so a bad count costs nothing.
567/// It is the wrong default for a caller using the count to decide whether
568/// the schema is current — there, a transient open failure reading as
569/// "nothing pending" means proceeding against a store that may be one or
570/// many migrations behind. `SPEC_FAST_STARTUP_UPGRADE_OWNS_MIGRATIONS_AND_UPDATES_2026_09_15.md`
571/// §4.1 (invariant S1b) requires that gate to fail closed, which it cannot
572/// do without a fallible check to fail closed *on*.
573pub fn try_count_pending_migrations(data_dir: &Path) -> Result<usize, PendingCountError> {
574    let shared_store_path =
575        resolve_shared_store_path().ok_or(PendingCountError::SharedStoreUnresolvable)?;
576    let shared_store = Store::open_shared(&shared_store_path).map_err(|e| {
577        PendingCountError::SharedStoreUnreadable {
578            path: shared_store_path.clone(),
579            error: e.to_string(),
580        }
581    })?;
582    let pending_in = |channel: Option<&Store>| {
583        REGISTRY
584            .iter()
585            .filter(|m| {
586                let tracking = tracking_store(m.scope(), &shared_store, channel);
587                tracking.map_or(false, |s| !s.migration_is_applied(m.id()))
588            })
589            .count()
590    };
591    let channel_store_path = data_dir.join("db").join("objects.db");
592    let channel_store = Store::open(&channel_store_path).map_err(|e| {
593        PendingCountError::ChannelStoreUnreadable {
594            path: channel_store_path.clone(),
595            error: e.to_string(),
596            // Global-scope migrations track in the shared store, which opened
597            // fine — that half of the count is still valid.
598            global_pending: pending_in(None),
599        }
600    })?;
601    Ok(pending_in(Some(&channel_store)))
602}
603
604/// Return the number of REGISTRY migrations that have not yet been applied,
605/// reporting 0 on any error so startup is never blocked.
606///
607/// **A `0` from this function does not mean the schema is current** — it may
608/// equally mean the stores could not be read. Callers that need to tell those
609/// apart must use [`try_count_pending_migrations`]; this one exists for the
610/// reporting path (`AGENTMUXSRV-ESTART`'s `pending_migrations:` field) where
611/// the migration run has already happened and an unreadable store is not a
612/// decision point. `data_dir` must be the wave data dir (parent of `db/`),
613/// not the db dir itself.
614pub fn count_pending_migrations(data_dir: &Path) -> usize {
615    match try_count_pending_migrations(data_dir) {
616        Ok(n) => n,
617        Err(e) => {
618            // Unchanged from before this function grew a fallible sibling:
619            // an unreadable channel store still reports the global-scope
620            // count rather than dropping to 0.
621            let fallback = e.readable_pending();
622            tracing::warn!("count_pending_migrations: {} — reporting {}", e, fallback);
623            fallback
624        }
625    }
626}
627
628// ── Error log ─────────────────────────────────────────────────────────────────
629
630fn write_error_log(home: &Path, msg: &str) {
631    let log_dir = home.join("logs");
632    let _ = std::fs::create_dir_all(&log_dir);
633    let path = log_dir.join("migration-error.log");
634    let content = format!("{}\n", msg);
635    let _ = std::fs::write(path, content);
636}
637
638// ── Verify (doctor pass) ──────────────────────────────────────────────────────
639//
640// SPEC_MIGRATION_SYSTEM_HARDENING_2026_08_03 Phase 1, second bullet: "applied"
641// in `db_migrations` means `up()` returned Ok — nothing re-checks that it
642// wrote anything. `migrate --verify` asks each APPLIED migration for its own
643// post-condition (`Migration::verify`) and reports per migration. Unapplied
644// migrations are skipped: there is nothing to verify yet, and `--list`
645// already shows them.
646//
647// Read-only by contract (codex P2 on #3058): nothing on this path calls
648// `Store::open*`. Those run schema setup, WAL configuration and legacy-table
649// adoption, and create the file when missing. This path opens plain SQLite
650// connections with SQLITE_OPEN_READ_ONLY, so a typoed data dir yields "no
651// store here", never a freshly created empty database that then "verifies".
652
653/// One line of the verify report.
654#[derive(Debug, Clone, PartialEq, Eq)]
655pub struct VerifyRecord {
656    pub id: &'static str,
657    pub scope: &'static str,
658    pub outcome: super::VerifyOutcome,
659}
660
661/// Which migration ids each tracking store records as applied, read through
662/// a FALLIBLE path. `Err` means the store exists but its tracking state
663/// could not be read (not a database, malformed table, locked): every
664/// migration of that scope then reports `VerifyOutcome::Error` instead of
665/// being silently skipped as "unapplied" and letting the pass exit 0 —
666/// codex P1 on #3058, which `Store::migration_is_applied`'s
667/// `unwrap_or(false)` would have caused. A store file that does not exist,
668/// or one predating the `db_migrations` table, has nothing recorded and is
669/// `Ok(empty)`.
670pub struct AppliedIds {
671    pub global: Result<HashSet<String>, String>,
672    pub channel: Result<HashSet<String>, String>,
673}
674
675impl AppliedIds {
676    fn for_scope(&self, scope: MigrationScope) -> &Result<HashSet<String>, String> {
677        match scope {
678            MigrationScope::Global => &self.global,
679            MigrationScope::Channel => &self.channel,
680        }
681    }
682}
683
684/// Open an existing SQLite file read-only. `Ok(None)` when the file is
685/// absent — never creates one (the whole point of this path).
686pub(super) fn open_readonly(path: &Path) -> Result<Option<rusqlite::Connection>, String> {
687    if !path.exists() {
688        return Ok(None);
689    }
690    rusqlite::Connection::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)
691        .map(Some)
692        .map_err(|e| format!("open {} read-only: {}", path.display(), e))
693}
694
695/// `SELECT id FROM db_migrations` through a read-only connection. A missing
696/// file or a missing table is `Ok(empty)` — nothing was ever recorded there.
697/// Any other failure is `Err`, which `verify_applied` turns into an
698/// `Error` outcome for every migration of that scope.
699pub(super) fn read_applied_ids(path: &Path) -> Result<HashSet<String>, String> {
700    let Some(conn) = open_readonly(path)? else {
701        return Ok(HashSet::new());
702    };
703    let mut stmt = match conn.prepare("SELECT id FROM db_migrations") {
704        Ok(s) => s,
705        Err(rusqlite::Error::SqliteFailure(_, Some(msg))) if msg.contains("no such table") => {
706            return Ok(HashSet::new());
707        }
708        Err(e) => return Err(format!("read db_migrations in {}: {}", path.display(), e)),
709    };
710    stmt.query_map([], |row| row.get::<_, String>(0))
711        .and_then(|rows| rows.collect::<Result<HashSet<String>, _>>())
712        .map_err(|e| format!("read db_migrations in {}: {}", path.display(), e))
713}
714
715/// Run `verify()` for every migration in `regs` whose tracking store records
716/// it as applied. Generic over the registry slice so the harness can be
717/// tested with fakes; production passes `REGISTRY`.
718pub fn verify_applied(
719    regs: &[&(dyn super::Migration + Sync)],
720    ctx: &MigrationContext,
721    applied: &AppliedIds,
722) -> Vec<VerifyRecord> {
723    let mut out = Vec::new();
724    for m in regs {
725        let outcome = match applied.for_scope(m.scope()) {
726            Err(e) => super::VerifyOutcome::Error(format!("cannot read applied state: {}", e)),
727            Ok(ids) if ids.contains(m.id()) => m.verify(ctx),
728            Ok(_) => continue,
729        };
730        out.push(VerifyRecord { id: m.id(), scope: m.scope().as_str(), outcome });
731    }
732    out
733}
734
735/// Exit code for a verify report: 0 unless any record is a mismatch or an
736/// error. `NotVerifiable` is not a failure — it is the honest default.
737pub fn verify_exit_code(records: &[VerifyRecord]) -> i32 {
738    if records.iter().any(|r| r.outcome.is_failure()) {
739        VERIFY_FAILED_EXIT_CODE
740    } else {
741        0
742    }
743}
744
745/// One `migration_verify` NDJSON line. `detail` goes through serde_json so
746/// control characters (a multi-line SQLite error, say) cannot break the
747/// line-delimited stream a consumer tails (reagent P2 on #3058).
748fn verify_event_line(r: &VerifyRecord) -> String {
749    let detail = serde_json::to_string(r.outcome.detail()).unwrap_or_else(|_| "\"\"".to_string());
750    format!(
751        "{{\"event\":\"migration_verify\",\"id\":\"{}\",\"scope\":\"{}\",\"status\":\"{}\",\"detail\":{}}}",
752        r.id,
753        r.scope,
754        r.outcome.label(),
755        detail
756    )
757}
758
759// ── Doctor report ─────────────────────────────────────────────────────────────
760//
761// SPEC_MIGRATION_SYSTEM_HARDENING_2026_08_03.md Phase 1c: the `--verify` pass
762// and the applied/pending listing, as ONE machine-readable structure that both
763// `agentmux-srv migrate --verify` (printed below) and the live instance's
764// `GET /api/v1/muxspect/migrations` (JSON) are built from. One producer, so
765// the CLI you run against a stopped install and the `muxspect migrations`
766// you run from inside a running one can never disagree about what
767// "verified" means. Read-only end to end — same `open_readonly` path as
768// `--verify`, never opens a `Store`, never creates a file.
769
770/// The complete migration doctor report for one data dir.
771#[derive(Debug, Clone, serde::Serialize)]
772pub struct DoctorReport {
773    pub data_dir: String,
774    pub shared_store: String,
775    pub channel_store: String,
776    /// One entry per tracking store whose `db_migrations` could not be read
777    /// (not a database, malformed, locked). Every migration of that scope
778    /// reads `unknown` / `error` below; this names the cause once.
779    pub tracking_errors: Vec<String>,
780    pub migrations: Vec<DoctorRow>,
781    pub summary: DoctorSummary,
782    /// What `migrate --verify` exits with for this state: `0`, or
783    /// [`VERIFY_FAILED_EXIT_CODE`] when any check is a mismatch or error.
784    pub exit_code: i32,
785}
786
787/// One registry migration, in registry order.
788#[derive(Debug, Clone, serde::Serialize)]
789pub struct DoctorRow {
790    pub id: &'static str,
791    pub scope: &'static str,
792    pub description: &'static str,
793    /// `applied` | `pending` | `unknown` — the last when the scope's
794    /// tracking store could not be read (see `tracking_errors`).
795    pub state: &'static str,
796    /// [`super::VerifyOutcome::label`] for an applied migration: `ok` |
797    /// `MISMATCH` | `error` | `n/a`. Absent for a pending one — there is no
798    /// post-condition to check until it has run.
799    #[serde(skip_serializing_if = "Option::is_none")]
800    pub verify: Option<&'static str>,
801    /// The verdict's evidence or reason (counts, the SQLite error, …).
802    #[serde(skip_serializing_if = "String::is_empty")]
803    pub detail: String,
804}
805
806#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
807pub struct DoctorSummary {
808    pub total: usize,
809    pub applied: usize,
810    pub pending: usize,
811    pub unknown: usize,
812    /// Applied migrations whose `verify()` ran — equals `applied` unless a
813    /// tracking store was unreadable.
814    pub checked: usize,
815    pub ok: usize,
816    pub mismatch: usize,
817    pub error: usize,
818    pub not_verifiable: usize,
819}
820
821/// Paths and tracking state the doctor works from. Reads both tracking
822/// stores through the fallible read-only path; never opens a `Store`.
823fn doctor_inputs(data_dir: &Path, home: &Path, shared_store_path: &Path) -> (MigrationContext, AppliedIds) {
824    let channel_store_path = data_dir.join("db").join("objects.db");
825    let ctx = MigrationContext {
826        home: home.to_path_buf(),
827        data_dir: data_dir.to_path_buf(),
828        shared_store_path: shared_store_path.to_path_buf(),
829        channel_store_path: channel_store_path.clone(),
830    };
831    let applied = AppliedIds {
832        global: read_applied_ids(shared_store_path),
833        channel: read_applied_ids(&channel_store_path),
834    };
835    (ctx, applied)
836}
837
838/// Assemble the report from an already-run verify pass, so `cmd_verify` can
839/// print the per-record NDJSON lines from the same `records` the report
840/// summarises rather than verifying twice.
841fn build_doctor_report(
842    regs: &[&(dyn super::Migration + Sync)],
843    ctx: &MigrationContext,
844    applied: &AppliedIds,
845    records: &[VerifyRecord],
846) -> DoctorReport {
847    let mut summary = DoctorSummary { total: regs.len(), ..Default::default() };
848    let mut migrations = Vec::with_capacity(regs.len());
849    for m in regs {
850        let record = records.iter().find(|r| r.id == m.id());
851        let state = match applied.for_scope(m.scope()) {
852            Err(_) => "unknown",
853            Ok(ids) if ids.contains(m.id()) => "applied",
854            Ok(_) => "pending",
855        };
856        match state {
857            "applied" => summary.applied += 1,
858            "pending" => summary.pending += 1,
859            _ => summary.unknown += 1,
860        }
861        let (verify, detail) = match record {
862            Some(r) => {
863                summary.checked += 1;
864                match r.outcome {
865                    super::VerifyOutcome::Ok(_) => summary.ok += 1,
866                    super::VerifyOutcome::Mismatch(_) => summary.mismatch += 1,
867                    super::VerifyOutcome::Error(_) => summary.error += 1,
868                    super::VerifyOutcome::NotVerifiable => summary.not_verifiable += 1,
869                }
870                (Some(r.outcome.label()), r.outcome.detail().to_string())
871            }
872            None => (None, String::new()),
873        };
874        migrations.push(DoctorRow {
875            id: m.id(),
876            scope: m.scope().as_str(),
877            description: m.description(),
878            state,
879            verify,
880            detail,
881        });
882    }
883    let tracking_errors = [&applied.global, &applied.channel]
884        .into_iter()
885        .filter_map(|r| r.as_ref().err().cloned())
886        .collect();
887    DoctorReport {
888        data_dir: ctx.data_dir.display().to_string(),
889        shared_store: ctx.shared_store_path.display().to_string(),
890        channel_store: ctx.channel_store_path.display().to_string(),
891        tracking_errors,
892        migrations,
893        summary,
894        exit_code: verify_exit_code(records),
895    }
896}
897
898/// The doctor report for `data_dir`, against an explicit registry (tests
899/// pass fakes; production passes `REGISTRY`). Read-only.
900pub fn doctor_report(
901    regs: &[&(dyn super::Migration + Sync)],
902    data_dir: &Path,
903    home: &Path,
904    shared_store_path: &Path,
905) -> DoctorReport {
906    let (ctx, applied) = doctor_inputs(data_dir, home, shared_store_path);
907    let records = verify_applied(regs, &ctx, &applied);
908    build_doctor_report(regs, &ctx, &applied, &records)
909}
910
911/// [`doctor_report`] for the running instance's own registry and resolved
912/// shared store / home — what `GET /api/v1/muxspect/migrations` serves.
913/// `Err` when the shared root can't be resolved at all (no
914/// `AGENTMUX_SHARED_DIR`, CI), the same condition under which
915/// `run_migrate_command` treats migrations as a no-op.
916pub fn doctor_report_for_instance(data_dir: &Path) -> Result<DoctorReport, String> {
917    let shared_store_path = resolve_shared_store_path()
918        .ok_or_else(|| "shared store path is unresolvable (AGENTMUX_SHARED_DIR / home not set)".to_string())?;
919    let home = resolve_home().ok_or_else(|| "AgentMux home is unresolvable".to_string())?;
920    Ok(doctor_report(REGISTRY, data_dir, &home, &shared_store_path))
921}
922
923fn cmd_verify(data_dir: &Path, home: &Path, shared_store_path: &Path) -> i32 {
924    let (ctx, applied) = doctor_inputs(data_dir, home, shared_store_path);
925    let records = verify_applied(REGISTRY, &ctx, &applied);
926    for r in &records {
927        let detail = r.outcome.detail();
928        if detail.is_empty() {
929            println!("verify: {} [{}] [{}]", r.id, r.scope, r.outcome.label());
930        } else {
931            println!("verify: {} [{}] [{}] — {}", r.id, r.scope, r.outcome.label(), detail);
932        }
933        println!("{}", verify_event_line(r));
934    }
935    let report = build_doctor_report(REGISTRY, &ctx, &applied, &records);
936    let s = &report.summary;
937    println!(
938        "verify: {} applied checked — {} ok, {} mismatch, {} error, {} not verifiable (data dir {})",
939        s.checked,
940        s.ok,
941        s.mismatch,
942        s.error,
943        s.not_verifiable,
944        data_dir.display()
945    );
946    println!(
947        "{{\"event\":\"verify_complete\",\"checked\":{},\"ok\":{},\"mismatch\":{},\"error\":{},\"not_verifiable\":{}}}",
948        s.checked, s.ok, s.mismatch, s.error, s.not_verifiable
949    );
950    report.exit_code
951}
952
953/// `SELECT COUNT(*) FROM <table>` on a (read-only) connection, treating a
954/// missing table as 0 rows — a store from before the table existed has
955/// nothing in it, which is exactly the answer a post-condition wants. Any
956/// other SQLite error is surfaced. `table` must be a literal identifier from
957/// the calling migration, never user input (it is interpolated, not bound).
958pub(super) fn table_count(conn: &rusqlite::Connection, table: &str) -> Result<i64, String> {
959    match conn.query_row(&format!("SELECT COUNT(*) FROM {}", table), [], |row| row.get::<_, i64>(0)) {
960        Ok(n) => Ok(n),
961        Err(rusqlite::Error::SqliteFailure(_, Some(msg))) if msg.contains("no such table") => Ok(0),
962        Err(e) => Err(format!("count {}: {}", table, e)),
963    }
964}
965
966#[cfg(test)]
967mod lock_tests {
968    use super::*;
969    use crate::migrations::{Migration, MigrationError};
970    use std::sync::atomic::{AtomicUsize, Ordering};
971
972    #[test]
973    fn lock_is_exclusive_across_connections_and_released_on_drop() {
974        let dir = tempfile::tempdir().unwrap();
975        let path = dir.path().join(MIGRATION_LOCK_FILE);
976        let first = MigrationLock::acquire(&path, std::time::Duration::from_millis(100)).expect("first holder");
977        // A second acquirer waits out its budget and fails while the first holds.
978        let err = MigrationLock::acquire(&path, std::time::Duration::from_millis(200))
979            .err()
980            .expect("second acquire must fail while the first is held");
981        assert!(err.contains("another runner may still hold it"), "{}", err);
982        drop(first);
983        MigrationLock::acquire(&path, std::time::Duration::from_millis(100)).expect("free after drop");
984    }
985
986    /// A Global migration that records how many times `up()` ran and is slow
987    /// enough that two runners started together both pass the "is it
988    /// pending?" check before either can mark it applied — unless the
989    /// critical section holds.
990    static UP_RUNS: AtomicUsize = AtomicUsize::new(0);
991    struct SlowOnce;
992    impl Migration for SlowOnce {
993        fn id(&self) -> &'static str { "9900_slow_once" }
994        fn scope(&self) -> MigrationScope { MigrationScope::Global }
995        fn description(&self) -> &'static str { "counts its own runs" }
996        fn up(&self, _ctx: &MigrationContext) -> Result<(), MigrationError> {
997            UP_RUNS.fetch_add(1, Ordering::SeqCst);
998            std::thread::sleep(std::time::Duration::from_millis(300));
999            Ok(())
1000        }
1001    }
1002    static SLOW_ONCE: SlowOnce = SlowOnce;
1003
1004    /// Channel-scoped twin of `SlowOnce`, for the different-shared-store case.
1005    static CHANNEL_UP_RUNS: AtomicUsize = AtomicUsize::new(0);
1006    struct SlowOnceChannel;
1007    impl Migration for SlowOnceChannel {
1008        fn id(&self) -> &'static str { "9901_slow_once_channel" }
1009        fn scope(&self) -> MigrationScope { MigrationScope::Channel }
1010        fn description(&self) -> &'static str { "counts its own runs (channel scope)" }
1011        fn up(&self, _ctx: &MigrationContext) -> Result<(), MigrationError> {
1012            CHANNEL_UP_RUNS.fetch_add(1, Ordering::SeqCst);
1013            std::thread::sleep(std::time::Duration::from_millis(300));
1014            Ok(())
1015        }
1016    }
1017    static SLOW_ONCE_CHANNEL: SlowOnceChannel = SlowOnceChannel;
1018
1019    #[test]
1020    fn runners_with_different_shared_stores_but_one_data_dir_still_serialize() {
1021        // codex P1 on #3062: isolated-auth instances resolve different shared
1022        // stores yet can share a data dir, i.e. the same objects.db and the
1023        // same channel-scoped migrations. The shared-side lock alone would let
1024        // both through; the channel-side lock must catch it.
1025        let dir = tempfile::tempdir().unwrap();
1026        let home = dir.path().to_path_buf();
1027        let data_dir = home.join("data");
1028        let shared_a = home.join("iso-a").join("shared").join("store.db");
1029        let shared_b = home.join("iso-b").join("shared").join("store.db");
1030        CHANNEL_UP_RUNS.store(0, Ordering::SeqCst);
1031
1032        let results: Vec<Result<ApplyOutcome, String>> = std::thread::scope(|s| {
1033            let handles: Vec<_> = [shared_a.clone(), shared_b.clone()]
1034                .into_iter()
1035                .map(|shared| {
1036                    let (home, data_dir) = (home.clone(), data_dir.clone());
1037                    s.spawn(move || {
1038                        let regs: Vec<&(dyn Migration + Sync)> = vec![&SLOW_ONCE_CHANNEL];
1039                        apply_pending(&regs, &home, &shared, &data_dir, None)
1040                    })
1041                })
1042                .collect();
1043            handles.into_iter().map(|h| h.join().expect("runner thread")).collect()
1044        });
1045
1046        for r in &results {
1047            assert!(r.is_ok(), "both runners must succeed: {:?}", results);
1048        }
1049        let applied: usize = results.iter().map(|r| r.as_ref().unwrap().applied).sum();
1050        assert_eq!(applied, 1, "exactly one runner applied it: {:?}", results);
1051        assert_eq!(CHANNEL_UP_RUNS.load(Ordering::SeqCst), 1, "channel-scoped up() ran once despite two shared stores");
1052    }
1053
1054    #[test]
1055    fn previews_do_not_wait_on_the_migration_lock() {
1056        // reagent P1 on #3062: `--list` / `--dry-run` must stay usable while
1057        // another process is mid-migration. Hold the lock, then take the same
1058        // read path the previews use and require it to come back at once.
1059        let dir = tempfile::tempdir().unwrap();
1060        let shared = dir.path().join("shared").join("store.db");
1061        std::fs::create_dir_all(shared.parent().unwrap()).unwrap();
1062        let data_dir = dir.path().join("data");
1063        let _held = MigrationLock::acquire(&shared.parent().unwrap().join(MIGRATION_LOCK_FILE), std::time::Duration::from_millis(100))
1064            .expect("hold the lock");
1065
1066        let before = UP_RUNS.load(Ordering::SeqCst);
1067        let started = std::time::Instant::now();
1068        let (shared_store, channel_store) = open_stores_for_preview(&shared, &data_dir).expect("preview opens stores");
1069        let regs: Vec<&(dyn Migration + Sync)> = vec![&SLOW_ONCE];
1070        let pending = pending_of(&regs, &shared_store, channel_store.as_ref());
1071        assert_eq!(pending.len(), 1, "nothing applied yet, so the fake is pending");
1072        assert!(started.elapsed() < std::time::Duration::from_secs(5), "preview must not block on the lock");
1073        assert_eq!(UP_RUNS.load(Ordering::SeqCst), before, "preview never runs up()");
1074    }
1075
1076    #[test]
1077    fn two_concurrent_runners_on_one_data_dir_apply_a_migration_exactly_once() {
1078        // Phase 3 acceptance (SPEC_MIGRATION_SYSTEM_HARDENING_2026_08_03):
1079        // "two concurrent run_pending_migrations calls against the same
1080        // fresh data dir; assert the migration's up() body executes exactly
1081        // once." Both runners must also succeed: the loser sees the winner's
1082        // db_migrations row once it gets the lock and applies nothing.
1083        let dir = tempfile::tempdir().unwrap();
1084        let home = dir.path().to_path_buf();
1085        let shared = home.join("shared").join("store.db");
1086        let data_dir = home.join("data");
1087        UP_RUNS.store(0, Ordering::SeqCst);
1088
1089        let results: Vec<Result<usize, String>> = std::thread::scope(|s| {
1090            let handles: Vec<_> = (0..2)
1091                .map(|_| {
1092                    let (home, shared, data_dir) = (home.clone(), shared.clone(), data_dir.clone());
1093                    s.spawn(move || {
1094                        let regs: Vec<&(dyn Migration + Sync)> = vec![&SLOW_ONCE];
1095                        apply_pending(&regs, &home, &shared, &data_dir, None).map(|o| o.applied)
1096                    })
1097                })
1098                .collect();
1099            handles.into_iter().map(|h| h.join().expect("runner thread")).collect()
1100        });
1101
1102        for r in &results {
1103            assert!(r.is_ok(), "both runners must succeed: {:?}", results);
1104        }
1105        let applied: usize = results.iter().map(|r| *r.as_ref().unwrap()).sum();
1106        assert_eq!(applied, 1, "exactly one runner applied it: {:?}", results);
1107        assert_eq!(UP_RUNS.load(Ordering::SeqCst), 1, "up() body must execute exactly once");
1108
1109        // And the record is durable: a third run has nothing to do.
1110        let regs: Vec<&(dyn Migration + Sync)> = vec![&SLOW_ONCE];
1111        assert_eq!(apply_pending(&regs, &home, &shared, &data_dir, None).map(|o| o.applied).unwrap(), 0);
1112        assert_eq!(UP_RUNS.load(Ordering::SeqCst), 1);
1113    }
1114}
1115
1116#[cfg(test)]
1117mod verify_tests {
1118    use super::*;
1119    use crate::migrations::{Migration, MigrationError, VerifyOutcome};
1120
1121    /// A migration whose `verify()` returns a canned outcome. `up()` is never
1122    /// called by the verify harness.
1123    struct Fake {
1124        id: &'static str,
1125        scope: MigrationScope,
1126        outcome: VerifyOutcome,
1127    }
1128    impl Migration for Fake {
1129        fn id(&self) -> &'static str { self.id }
1130        fn scope(&self) -> MigrationScope { self.scope }
1131        fn description(&self) -> &'static str { "fake" }
1132        fn up(&self, _ctx: &MigrationContext) -> Result<(), MigrationError> { Ok(()) }
1133        fn verify(&self, _ctx: &MigrationContext) -> VerifyOutcome { self.outcome.clone() }
1134    }
1135
1136    /// Default `verify()` — a migration that never opted in.
1137    struct Silent;
1138    impl Migration for Silent {
1139        fn id(&self) -> &'static str { "9998_silent" }
1140        fn scope(&self) -> MigrationScope { MigrationScope::Global }
1141        fn description(&self) -> &'static str { "no verify impl" }
1142        fn up(&self, _ctx: &MigrationContext) -> Result<(), MigrationError> { Ok(()) }
1143    }
1144
1145    fn ctx_in(dir: &tempfile::TempDir) -> MigrationContext {
1146        MigrationContext {
1147            home: dir.path().to_path_buf(),
1148            data_dir: dir.path().join("data"),
1149            shared_store_path: dir.path().join("shared").join("store.db"),
1150            channel_store_path: dir.path().join("data").join("db").join("objects.db"),
1151        }
1152    }
1153
1154    fn ids(list: &[&str]) -> Result<HashSet<String>, String> {
1155        Ok(list.iter().map(|s| s.to_string()).collect())
1156    }
1157
1158    #[test]
1159    fn only_applied_migrations_are_verified_and_unapplied_are_skipped() {
1160        let dir = tempfile::tempdir().unwrap();
1161        let applied = Fake { id: "9001_applied", scope: MigrationScope::Global, outcome: VerifyOutcome::Ok("fine".into()) };
1162        let pending = Fake { id: "9002_pending", scope: MigrationScope::Global, outcome: VerifyOutcome::Mismatch("would fail".into()) };
1163        let state = AppliedIds { global: ids(&["9001_applied"]), channel: ids(&[]) };
1164
1165        let regs: Vec<&(dyn Migration + Sync)> = vec![&applied, &pending];
1166        let records = verify_applied(&regs, &ctx_in(&dir), &state);
1167        assert_eq!(records.len(), 1, "the pending one has nothing to verify yet");
1168        assert_eq!(records[0].id, "9001_applied");
1169        assert_eq!(records[0].outcome, VerifyOutcome::Ok("fine".into()));
1170        assert_eq!(verify_exit_code(&records), 0);
1171    }
1172
1173    #[test]
1174    fn channel_scoped_migrations_consult_the_channel_state_not_the_global_one() {
1175        let dir = tempfile::tempdir().unwrap();
1176        let m = Fake { id: "9003_channel", scope: MigrationScope::Channel, outcome: VerifyOutcome::Ok("ok".into()) };
1177        let regs: Vec<&(dyn Migration + Sync)> = vec![&m];
1178        // Recorded in the GLOBAL set only: wrong tracking store for a Channel
1179        // migration, so it must read as unapplied.
1180        let wrong = AppliedIds { global: ids(&["9003_channel"]), channel: ids(&[]) };
1181        assert!(verify_applied(&regs, &ctx_in(&dir), &wrong).is_empty());
1182        let right = AppliedIds { global: ids(&[]), channel: ids(&["9003_channel"]) };
1183        assert_eq!(verify_applied(&regs, &ctx_in(&dir), &right).len(), 1);
1184    }
1185
1186    #[test]
1187    fn unreadable_tracking_state_is_an_error_for_every_migration_of_that_scope() {
1188        // codex P1 on #3058: a broken db_migrations must not read as "nothing
1189        // applied" and let the pass exit 0.
1190        let dir = tempfile::tempdir().unwrap();
1191        let g = Fake { id: "9004_global", scope: MigrationScope::Global, outcome: VerifyOutcome::Ok("never reached".into()) };
1192        let c = Fake { id: "9005_channel", scope: MigrationScope::Channel, outcome: VerifyOutcome::Ok("fine".into()) };
1193        let regs: Vec<&(dyn Migration + Sync)> = vec![&g, &c];
1194        let state = AppliedIds { global: Err("file is not a database".into()), channel: ids(&["9005_channel"]) };
1195        let records = verify_applied(&regs, &ctx_in(&dir), &state);
1196        assert_eq!(records.len(), 2);
1197        assert!(matches!(&records[0].outcome, VerifyOutcome::Error(e) if e.contains("not a database")));
1198        assert_eq!(records[1].outcome, VerifyOutcome::Ok("fine".into()));
1199        assert_eq!(verify_exit_code(&records), VERIFY_FAILED_EXIT_CODE);
1200    }
1201
1202    #[test]
1203    fn a_migration_without_a_verify_impl_reports_not_verifiable_and_passes() {
1204        let dir = tempfile::tempdir().unwrap();
1205        let regs: Vec<&(dyn Migration + Sync)> = vec![&Silent];
1206        let state = AppliedIds { global: ids(&["9998_silent"]), channel: ids(&[]) };
1207        let records = verify_applied(&regs, &ctx_in(&dir), &state);
1208        assert_eq!(records[0].outcome, VerifyOutcome::NotVerifiable);
1209        assert_eq!(verify_exit_code(&records), 0, "not verifiable is honest, not a failure");
1210    }
1211
1212    #[test]
1213    fn mismatch_or_error_fails_the_pass_with_a_code_distinct_from_run_failure_and_clap() {
1214        let mk = |o: VerifyOutcome| VerifyRecord { id: "x", scope: "global", outcome: o };
1215        assert_eq!(verify_exit_code(&[mk(VerifyOutcome::Ok("a".into())), mk(VerifyOutcome::NotVerifiable)]), 0);
1216        assert_eq!(verify_exit_code(&[mk(VerifyOutcome::Ok("a".into())), mk(VerifyOutcome::Mismatch("gone".into()))]), VERIFY_FAILED_EXIT_CODE);
1217        assert_eq!(verify_exit_code(&[mk(VerifyOutcome::Error("locked".into()))]), VERIFY_FAILED_EXIT_CODE);
1218        assert_ne!(VERIFY_FAILED_EXIT_CODE, 1, "1 = a migration run failed");
1219        assert_ne!(VERIFY_FAILED_EXIT_CODE, 2, "2 = clap usage error (bad flag)");
1220    }
1221
1222    #[test]
1223    fn read_applied_ids_reads_real_tracking_state_and_never_creates_a_store() {
1224        let dir = tempfile::tempdir().unwrap();
1225        let path = dir.path().join("shared").join("store.db");
1226        // Absent file: nothing recorded, and still absent afterwards.
1227        assert_eq!(read_applied_ids(&path).unwrap(), HashSet::new());
1228        assert!(!path.exists(), "verify must never create a database");
1229
1230        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1231        let store = Store::open_shared(&path).expect("open shared");
1232        store.migration_mark_applied("9010_a", "global", 1).unwrap();
1233        store.migration_mark_applied("9011_b", "global", 1).unwrap();
1234        drop(store);
1235        let got = read_applied_ids(&path).unwrap();
1236        assert!(got.contains("9010_a") && got.contains("9011_b"));
1237        assert_eq!(got.len(), 2);
1238    }
1239
1240    #[test]
1241    fn read_applied_ids_errors_on_a_file_that_is_not_a_database() {
1242        let dir = tempfile::tempdir().unwrap();
1243        let path = dir.path().join("objects.db");
1244        std::fs::write(&path, b"definitely not sqlite\n").unwrap();
1245        let err = read_applied_ids(&path).unwrap_err();
1246        assert!(err.contains("objects.db"), "names the store: {}", err);
1247    }
1248
1249    #[test]
1250    fn table_count_treats_a_missing_table_as_zero_rows() {
1251        let dir = tempfile::tempdir().unwrap();
1252        let path = dir.path().join("objects.db");
1253        drop(Store::open(&path).expect("create channel store"));
1254        let conn = open_readonly(&path).unwrap().expect("exists");
1255        assert_eq!(table_count(&conn, "db_table_that_never_existed").unwrap(), 0);
1256        assert!(table_count(&conn, "db_migrations").is_ok());
1257    }
1258
1259    // ---- Doctor report (Phase 1c — the producer `muxspect migrations` serves) ----
1260
1261    #[test]
1262    fn doctor_report_on_a_fresh_data_dir_lists_everything_pending_and_creates_nothing() {
1263        let dir = tempfile::tempdir().unwrap();
1264        let data_dir = dir.path().join("data");
1265        let shared = dir.path().join("shared").join("store.db");
1266        let report = doctor_report(REGISTRY, &data_dir, dir.path(), &shared);
1267
1268        assert_eq!(report.migrations.len(), REGISTRY.len());
1269        assert!(report.migrations.iter().all(|r| r.state == "pending" && r.verify.is_none()));
1270        assert_eq!(report.summary.total, REGISTRY.len());
1271        assert_eq!(report.summary.pending, REGISTRY.len());
1272        assert_eq!(report.summary.checked, 0);
1273        assert!(report.tracking_errors.is_empty());
1274        assert_eq!(report.exit_code, 0);
1275        // Read-only, like --verify: a doctor must never conjure a store.
1276        assert!(!shared.exists() && !data_dir.exists(), "doctor created files");
1277        // Serialises — the handler returns this as JSON.
1278        let json = serde_json::to_value(&report).unwrap();
1279        assert_eq!(json["summary"]["pending"], REGISTRY.len());
1280        assert!(json["migrations"][0].get("verify").is_none(), "pending rows omit `verify`");
1281    }
1282
1283    #[test]
1284    fn doctor_report_reports_applied_rows_with_their_verdict_and_counts_them() {
1285        let dir = tempfile::tempdir().unwrap();
1286        let data_dir = dir.path().join("data");
1287        let channel = data_dir.join("db").join("objects.db");
1288        std::fs::create_dir_all(channel.parent().unwrap()).unwrap();
1289        let shared = dir.path().join("shared").join("store.db");
1290        {
1291            // A fresh channel store with 0007 recorded applied: no legacy
1292            // rows, empty target — consistent, so the verdict is `ok`.
1293            let store = Store::open(&channel).expect("create channel store");
1294            store.migration_mark_applied("0007_agents_consolidate", "channel", 1).unwrap();
1295        }
1296        let report = doctor_report(REGISTRY, &data_dir, dir.path(), &shared);
1297        let row = report.migrations.iter().find(|r| r.id == "0007_agents_consolidate").unwrap();
1298        assert_eq!(row.state, "applied");
1299        assert_eq!(row.verify, Some("ok"));
1300        assert!(row.detail.contains("no legacy rows"), "{}", row.detail);
1301        assert_eq!(report.summary.applied, 1);
1302        assert_eq!(report.summary.checked, 1);
1303        assert_eq!(report.summary.ok, 1);
1304        assert_eq!(report.summary.pending, REGISTRY.len() - 1);
1305        assert_eq!(report.exit_code, 0);
1306    }
1307
1308    #[test]
1309    fn doctor_report_marks_a_scope_unknown_when_its_tracking_store_is_unreadable_and_exits_3() {
1310        let dir = tempfile::tempdir().unwrap();
1311        let data_dir = dir.path().join("data");
1312        let shared = dir.path().join("shared").join("store.db");
1313        std::fs::create_dir_all(shared.parent().unwrap()).unwrap();
1314        std::fs::write(&shared, b"definitely not sqlite\n").unwrap();
1315        let report = doctor_report(REGISTRY, &data_dir, dir.path(), &shared);
1316
1317        let global_rows: Vec<_> = report.migrations.iter().filter(|r| r.scope == "global").collect();
1318        assert!(!global_rows.is_empty());
1319        assert!(global_rows.iter().all(|r| r.state == "unknown" && r.verify == Some("error")));
1320        assert!(report.migrations.iter().filter(|r| r.scope == "channel").all(|r| r.state == "pending"));
1321        assert_eq!(report.summary.unknown, global_rows.len());
1322        assert_eq!(report.summary.error, global_rows.len());
1323        // The cause is named once, not once per migration.
1324        assert_eq!(report.tracking_errors.len(), 1, "{:?}", report.tracking_errors);
1325        assert!(report.tracking_errors[0].contains("store.db"));
1326        assert_eq!(report.exit_code, VERIFY_FAILED_EXIT_CODE);
1327    }
1328
1329    #[test]
1330    fn verify_event_line_is_valid_json_even_with_control_characters_in_detail() {
1331        let r = VerifyRecord {
1332            id: "9020_x",
1333            scope: "channel",
1334            outcome: VerifyOutcome::Error("line one\nline \"two\"\t\\end".into()),
1335        };
1336        let line = verify_event_line(&r);
1337        let parsed: serde_json::Value = serde_json::from_str(&line).expect("one JSON object per line");
1338        assert_eq!(parsed["event"], "migration_verify");
1339        assert_eq!(parsed["status"], "error");
1340        assert_eq!(parsed["detail"], "line one\nline \"two\"\t\\end");
1341        assert!(!line.contains('\n'), "must stay on one line");
1342    }
1343}
1344
1345#[cfg(test)]
1346mod tests {
1347    use super::*;
1348
1349    // Process-global env access — shared with registry::paths and
1350    // migrations::m0011_shared_store_backfill's tests, which mutate the SAME
1351    // AGENTMUX_ISOLATED_AUTH/AGENTMUX_INSTANCE_DIR vars. A module-local lock
1352    // only serializes tests within this file; Cargo runs a crate's tests in
1353    // one multi-threaded process, so a local-only lock still let this
1354    // module's tests race against those two (reagent/codex on PR #2318).
1355    use crate::test_support::ISOLATED_AUTH_ENV_LOCK as ENV_LOCK;
1356
1357    fn clear() {
1358        std::env::remove_var("AGENTMUX_HOME_OVERRIDE");
1359        std::env::remove_var("AGENTMUX_SHARED_DIR");
1360        std::env::remove_var("AGENTMUX_ISOLATED_AUTH");
1361        std::env::remove_var("AGENTMUX_INSTANCE_DIR");
1362        std::env::remove_var("AGENTMUX_CHANNEL");
1363    }
1364
1365    // ── Pending count: lenient vs. fail-closed ───────────────────────────
1366
1367    #[test]
1368    fn an_unreadable_shared_store_is_an_error_not_a_zero() {
1369        // The trap this pair of functions exists to separate: a caller
1370        // deciding whether the schema is current cannot act on `0`, because
1371        // `0` is also what an unreadable store produces.
1372        // SPEC_FAST_STARTUP_UPGRADE_OWNS_MIGRATIONS_AND_UPDATES_2026_09_15 S1b.
1373        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1374        clear();
1375        // A home with no `shared/` directory: SQLite will not create the
1376        // parent, so the shared store cannot be opened.
1377        let home = tempfile::tempdir().expect("tempdir");
1378        std::env::set_var("AGENTMUX_HOME_OVERRIDE", home.path());
1379        let data_dir = home.path().join("data");
1380
1381        let strict = try_count_pending_migrations(&data_dir);
1382        let lenient = count_pending_migrations(&data_dir);
1383        clear();
1384
1385        match strict {
1386            Err(PendingCountError::SharedStoreUnreadable { .. }) => {}
1387            Err(other) => panic!("expected SharedStoreUnreadable, got {other}"),
1388            Ok(n) => panic!("an unreadable shared store must not read as {n} pending"),
1389        }
1390        assert_eq!(lenient, 0, "the lenient wrapper still reports 0, as it always did");
1391    }
1392
1393    #[test]
1394    fn the_lenient_wrapper_keeps_reporting_the_global_half_it_could_read() {
1395        // An unreadable CHANNEL store is the partial case: global-scope
1396        // migrations track in the shared store, which opened fine. The
1397        // fallible check still refuses to answer (the total is incomplete);
1398        // the lenient one reports the half it could read, exactly as it did
1399        // before it grew a fallible sibling.
1400        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1401        clear();
1402        let home = tempfile::tempdir().expect("tempdir");
1403        std::fs::create_dir_all(home.path().join("shared")).expect("shared dir");
1404        std::env::set_var("AGENTMUX_HOME_OVERRIDE", home.path());
1405        // No `db/` under it, so the channel store cannot open.
1406        let data_dir = home.path().join("no-such-data-dir");
1407
1408        let strict = try_count_pending_migrations(&data_dir);
1409        let lenient = count_pending_migrations(&data_dir);
1410        let global_pending = match &strict {
1411            Err(PendingCountError::ChannelStoreUnreadable { global_pending, .. }) => *global_pending,
1412            other => {
1413                let msg = format!("precondition: expected ChannelStoreUnreadable, got {other:?}");
1414                clear();
1415                panic!("{msg}");
1416            }
1417        };
1418        clear();
1419
1420        assert_eq!(
1421            lenient, global_pending,
1422            "the lenient wrapper must keep reporting the readable global-scope count"
1423        );
1424    }
1425
1426    #[test]
1427    fn readable_pending_keeps_the_global_half_but_claims_nothing_otherwise() {
1428        // What the non-blocking callers fall back to. A shared-store failure
1429        // has no readable half at all — reporting anything but 0 there would
1430        // be inventing a number.
1431        assert_eq!(
1432            PendingCountError::ChannelStoreUnreadable {
1433                path: std::path::PathBuf::from("/tmp/objects.db"),
1434                error: "locked".into(),
1435                global_pending: 3,
1436            }
1437            .readable_pending(),
1438            3
1439        );
1440        assert_eq!(PendingCountError::SharedStoreUnresolvable.readable_pending(), 0);
1441        assert_eq!(
1442            PendingCountError::SharedStoreUnreadable {
1443                path: std::path::PathBuf::from("/tmp/store.db"),
1444                error: "locked".into(),
1445            }
1446            .readable_pending(),
1447            0
1448        );
1449    }
1450
1451    #[test]
1452    fn a_pending_count_error_says_which_store_and_why() {
1453        // The blocking upgrade state shows this text to a human, so it has to
1454        // name the store and carry the underlying reason rather than being a
1455        // bare "could not count".
1456        let e = PendingCountError::SharedStoreUnreadable {
1457            path: std::path::PathBuf::from("/tmp/store.db"),
1458            error: "disk I/O error".to_string(),
1459        };
1460        let msg = e.to_string();
1461        assert!(msg.contains("store.db"), "{msg}");
1462        assert!(msg.contains("disk I/O error"), "{msg}");
1463    }
1464
1465    /// The regression test that would have caught the `ctx.home` /
1466    /// `shared_store_path` coupling bug before it shipped: `resolve_home()`
1467    /// must return the SAME value whether or not isolated-auth is active,
1468    /// even though `resolve_shared_store_path()` itself deliberately
1469    /// returns a DIFFERENT (channel-scoped) path under isolation. Every
1470    /// other Global migration, plus backups and the error log, anchor to
1471    /// `home` and must never silently move just because one channel opted
1472    /// into isolated auth.
1473    #[test]
1474    fn home_is_invariant_to_isolated_auth() {
1475        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1476        clear();
1477        std::env::set_var("AGENTMUX_HOME_OVERRIDE", "/tmp/test-home");
1478
1479        let home_default = resolve_home().unwrap();
1480        assert_eq!(home_default, PathBuf::from("/tmp/test-home"));
1481
1482        let shared_store_path_default = resolve_shared_store_path().unwrap();
1483
1484        std::env::set_var("AGENTMUX_ISOLATED_AUTH", "1");
1485        std::env::set_var("AGENTMUX_INSTANCE_DIR", "/tmp/test-home/dev/some-branch");
1486        let home_isolated = resolve_home().unwrap();
1487        let shared_store_path_isolated = resolve_shared_store_path().unwrap();
1488
1489        assert_eq!(
1490            home_default, home_isolated,
1491            "resolve_home() must be invariant to AGENTMUX_ISOLATED_AUTH/AGENTMUX_INSTANCE_DIR"
1492        );
1493        // Meanwhile resolve_shared_store_path() DOES vary — confirms the two
1494        // functions have genuinely diverged as designed, not that isolation
1495        // silently does nothing.
1496        assert_ne!(
1497            shared_store_path_default, shared_store_path_isolated,
1498            "resolve_shared_store_path() must actually change under isolation"
1499        );
1500
1501        clear();
1502    }
1503
1504    /// Same invariant as `home_is_invariant_to_isolated_auth`, with a
1505    /// non-"stable" channel and an instance dir present. Auth is shared by
1506    /// default on every channel now (SPEC_SHARED_AUTH_ACROSS_CHANNELS_2026_10_03.md),
1507    /// so the channel alone changes nothing: neither the home nor the store path.
1508    #[test]
1509    fn home_and_store_are_invariant_to_the_channel_when_auth_is_shared() {
1510        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1511        clear();
1512        std::env::set_var("AGENTMUX_HOME_OVERRIDE", "/tmp/test-home");
1513
1514        let home_default = resolve_home().unwrap();
1515        let shared_store_path_default = resolve_shared_store_path().unwrap();
1516
1517        // No AGENTMUX_ISOLATED_AUTH set at all — only a non-"stable" channel.
1518        std::env::set_var("AGENTMUX_CHANNEL", "dev-some-branch");
1519        std::env::set_var("AGENTMUX_INSTANCE_DIR", "/tmp/test-home/dev/some-branch");
1520        let home_isolated = resolve_home().unwrap();
1521        let shared_store_path_isolated = resolve_shared_store_path().unwrap();
1522
1523        assert_eq!(
1524            home_default, home_isolated,
1525            "resolve_home() must not depend on the channel"
1526        );
1527        assert_eq!(
1528            shared_store_path_default, shared_store_path_isolated,
1529            "a non-stable channel alone must not move the shared store"
1530        );
1531
1532        // An explicit opt-in still isolates, and still leaves the home alone.
1533        std::env::set_var("AGENTMUX_ISOLATED_AUTH", "1");
1534        assert_eq!(resolve_home().unwrap(), home_default);
1535        assert_ne!(resolve_shared_store_path().unwrap(), shared_store_path_default);
1536
1537        clear();
1538    }
1539}