Skip to main content

agentmux_srv/migrations/
m0021_backfill_agent_bundles.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Backfill a dedicated ABF bundle for every existing agent definition that
5//! doesn't have one — the data-migration half of
6//! `docs/specs/ARCHITECTURE_MANDATORY_ABF_RETHINK_2026_08_14.md` §3/§7:
7//! "every agent must have an ABF," and, at the time, that ABF carried its
8//! own provider/model (readonly once set by a bundle-level guard). Since
9//! SPEC_AGENT_BUNDLE_FORMAT_V0_3_2026_10_05.md §3 the bundle carries no
10//! harness: the agent owns its provider, locked by
11//! `app_api::check_provider_unchanged`, and `m0035` reconciles agents with
12//! the bundles this migration wrote.
13//!
14//! For every definition with an empty `memory_id`: create a fresh
15//! `db_bundles` row (empty instructions/context — a legitimate starting
16//! state, not an error; `bundle.validate` already treats an empty bundle as
17//! valid) with the definition's OWN `provider` (harness) carried onto the
18//! bundle, and `model` defaulted from that provider's first declared
19//! `supported_vendors` entry (same derivation
20//! `Store::bundle_provision_for_new_agent` uses for brand-new agents) —
21//! and point the definition's `memory_id` at it.
22//!
23//! P0 fix (2026-08-15, ReAgent review on PR #2587): this used to hardcode
24//! `provider='claude'`, `model='anthropic'` for every backfilled agent
25//! regardless of `def.provider`, reasoning from §7.3's operator-confirmed
26//! "every agent on this instance is Claude Code + OAuth Anthropic." That's
27//! true today, but baking it into the migration is unsafe: combined with
28//! the then bundle-level read-only lock and `agent_open.rs`'s
29//! spawn-time bundle-provider preference, hardcoding here would silently
30//! reassign any non-Claude agent (codex, gemini, ...) — present now or
31//! added before this migration runs on a given install — to spawn as
32//! Claude afterward, permanently (readonly-once-set). Deriving from the
33//! definition's own already-known `provider` costs nothing extra and has
34//! no such failure mode; falls back to `"claude"`/`"anthropic"` only when
35//! `def.provider` is itself empty (a definition with no harness set at
36//! all — degenerate case, but still needs *some* value to backfill to).
37//!
38//! **Local-channel only, deliberately** — same pattern this migration's own
39//! module doc for `agent_def_set_memory_id_if_empty` documents: a
40//! cross-channel (global-registry-only) definition can't be reached today
41//! because `DefinitionRecordV1` doesn't carry `memory_id` yet (same
42//! accepted gap as `model_vendor_base_url`). The global registry is still
43//! attached (mirrors `m0020`'s pattern) so this migration at least SEES
44//! every agent and can log which ones it had to skip, rather than silently
45//! processing only whatever happens to be in local SQLite.
46//!
47//! **The bundle itself, though, is NOT channel-local** — it's written into
48//! the effective identity/memory store (shared store when resolvable,
49//! same as `AppState.id_store` at runtime), never the channel's own
50//! `objects.db`. P1 fix (2026-08-15, Codex review on PR #2587): this used
51//! to write bundles straight into `mstore` (the channel store this
52//! migration already has open for the agent-definition side) — every real
53//! bundle-read path (`listmemories`/`getmemory`/the Memory editor/the
54//! bundle-summary panel) reads through `id_store` instead, so a
55//! channel-local bundle would be silently invisible everywhere else, the
56//! exact same bug the runtime provisioning-path fix
57//! (`agent_def_provision_and_bind_bundle`) addresses for new agents.
58//!
59//! Idempotent: re-running only ever touches definitions still at
60//! `memory_id=''`; already-backfilled or already-bound rows are untouched
61//! (both by the `agent_def_list()` filter here and by
62//! `agent_def_set_memory_id_if_empty`'s own `WHERE memory_id = ''` guard).
63
64use std::sync::Arc;
65
66use crate::backend::storage::store::{Bundle, Store};
67use crate::registry::{resolve_shared_definitions_dir, DefinitionStore};
68
69use super::{Migration, MigrationContext, MigrationError, MigrationScope};
70
71pub struct M0021BackfillAgentBundles;
72
73/// Fallback provider/model ONLY for the degenerate case of a definition
74/// whose own `provider` column is itself empty — see the module doc's P0
75/// fix note. Every normal backfill derives from `def.provider` instead;
76/// this is not the common path.
77const FALLBACK_PROVIDER: &str = "claude";
78const FALLBACK_MODEL: &str = "anthropic";
79
80/// Same derivation `Store::bundle_provision_for_new_agent`
81/// (`backend/storage/agents/definition.rs`) uses for brand-new agents — delegates to
82/// `Store::resolve_effective_vendor` so both paths agree on the
83/// `model_vendor_base_url` → `"custom"` rule (P2 fix, Codex review on PR
84/// #2587) rather than maintaining two copies of the same logic. Falls
85/// back to `FALLBACK_PROVIDER`/`FALLBACK_MODEL` when `provider` is empty
86/// — the one case `resolve_effective_vendor` can't help with, since it
87/// still needs a real provider id to look up.
88fn resolve_backfill_provider_and_model(provider: &str, model_vendor_base_url: &str) -> (String, String) {
89    if provider.is_empty() {
90        return (FALLBACK_PROVIDER.to_string(), FALLBACK_MODEL.to_string());
91    }
92    let vendor = Store::resolve_effective_vendor(provider, model_vendor_base_url);
93    (provider.to_string(), vendor)
94}
95
96/// Resolve the store bundles must be written into — mirrors
97/// `AppState.id_store`'s own fallback (shared store when resolvable, else
98/// the channel store) so a migration-time bundle always lands exactly
99/// where the runtime RPC layer will look for it. Best-effort or degrade,
100/// never hard-fail: an unusable shared store means "not today," same
101/// posture the global-registry attach above already has.
102pub(super) fn resolve_bundle_store(ctx: &MigrationContext, mstore: &Arc<Store>) -> Arc<Store> {
103    match Store::open_shared(&ctx.shared_store_path) {
104        Ok(shared) => Arc::new(shared),
105        Err(e) => {
106            tracing::warn!(
107                error = %e,
108                path = %ctx.shared_store_path.display(),
109                "backfill_agent_bundles: shared store unavailable, falling back to channel store for bundle writes"
110            );
111            mstore.clone()
112        }
113    }
114}
115
116impl Migration for M0021BackfillAgentBundles {
117    fn id(&self) -> &'static str { "0021_backfill_agent_bundles" }
118    fn scope(&self) -> MigrationScope { MigrationScope::Channel }
119    fn description(&self) -> &'static str {
120        "Provision a dedicated ABF bundle for every agent definition lacking one"
121    }
122
123    fn up(&self, ctx: &MigrationContext) -> Result<(), MigrationError> {
124        if !ctx.channel_store_path.exists() {
125            return Ok(());
126        }
127        let mstore = Arc::new(
128            Store::open(&ctx.channel_store_path)
129                .map_err(|e| MigrationError(format!("backfill_agent_bundles: open mstore: {}", e)))?,
130        );
131        // Attach the global registry (see module doc) purely for VISIBILITY
132        // — this migration cannot write memory_id for a global-only
133        // definition (DefinitionRecordV1 doesn't carry the field), but
134        // attaching it means agent_def_list() surfaces those agents so we
135        // can at least log which ones were skipped, matching m0020's
136        // best-effort handling if the registry can't be resolved/opened.
137        if let Some(def_dir) = resolve_shared_definitions_dir() {
138            match DefinitionStore::open(def_dir) {
139                Ok(def_store) => mstore.set_def_registry(Arc::new(def_store)),
140                Err(e) => tracing::warn!(error = %e, "backfill_agent_bundles: failed to open global def registry, backfilling local-only"),
141            }
142        } else {
143            tracing::warn!("backfill_agent_bundles: could not resolve global def registry dir, backfilling local-only");
144        }
145
146        let bundle_store = resolve_bundle_store(ctx, &mstore);
147        // The agent's bundle recorded by another channel
148        // (SPEC_MEMORY_FOLLOWS_THE_AGENT_2026_09_24.md §2.1.6): bound here too
149        // instead of a new one per channel. Best-effort: without the global
150        // store every channel still gets a bundle, as before.
151        let sidecar = crate::backend::agent_bundle_sidecar::open_global();
152
153        let defs = mstore
154            .agent_def_list()
155            .map_err(|e| MigrationError(format!("backfill_agent_bundles: list defs: {}", e)))?;
156        let now = std::time::SystemTime::now()
157            .duration_since(std::time::UNIX_EPOCH)
158            .map(|d| d.as_millis() as i64)
159            .unwrap_or(0);
160
161        for def in defs {
162            if !def.memory_id.is_empty() {
163                continue;
164            }
165            // Only in the global registry: there's no row here to bind a
166            // bundle to, so a bundle minted now would be orphaned. Every
167            // fresh channel used to mint one per such agent into the shared
168            // store ("AgentA — ABF (11)" on one host). The agent's bundle is
169            // provisioned in a channel that has its row.
170            let local = mstore
171                .agent_def_has_local_row(&def.id)
172                .map_err(|e| MigrationError(format!("backfill_agent_bundles: look up {}: {}", def.id, e)))?;
173            if !local {
174                tracing::debug!(agent_id = %def.id, "backfill_agent_bundles: definition only in the global registry; nothing here to bind a bundle to");
175                continue;
176            }
177            use crate::backend::agent_bundle_sidecar as sc;
178            let recorded = sidecar.as_ref().and_then(|fs| sc::get(fs, &def.id).ok().flatten());
179            if let Some(existing) = recorded.filter(|id| matches!(bundle_store.bundle_get(id), Ok(Some(_)))) {
180                mstore
181                    .agent_def_set_memory_id_if_empty(&def.id, &existing)
182                    .map_err(|e| MigrationError(format!("backfill_agent_bundles: bind {}: {}", def.id, e)))?;
183                continue;
184            }
185            let bundle_id = uuid::Uuid::new_v4().to_string();
186            let (provider, model) = resolve_backfill_provider_and_model(&def.provider, &def.model_vendor_base_url);
187            // P1 fix (ReAgent review on PR #2587 round 4): db_bundles.name
188            // is UNIQUE but only AgentDefinition.slug is guaranteed
189            // unique, not the display name — a naive "{name} — ABF" on
190            // two same-named pre-existing agents used to abort this
191            // ENTIRE backfill loop via the `?` below on the very first
192            // collision, permanently stalling backfill for every agent
193            // processed after it. resolve_unique_bundle_name (shared with
194            // the runtime provisioning path) disambiguates instead.
195            let name = bundle_store
196                .resolve_unique_bundle_name(&format!("{} — ABF", def.name))
197                .map_err(|e| MigrationError(format!("backfill_agent_bundles: resolve unique bundle name for {}: {}", def.id, e)))?;
198            let bundle = Bundle {
199                id: bundle_id.clone(),
200                name,
201                description: String::new(),
202                is_blank: false,
203                is_global: false,
204                provider,
205                model,
206                instructions: String::new(),
207                instructions_by_provider: "{}".to_string(),
208                context_files: "[]".to_string(),
209                mcp_servers: "[]".to_string(),
210                skills: "[]".to_string(),
211                sort_order: 0,
212                created_at: now,
213                updated_at: now,
214                is_system: false,
215            };
216            bundle_store
217                .bundle_upsert(&bundle)
218                .map_err(|e| MigrationError(format!("backfill_agent_bundles: create bundle for {}: {}", def.id, e)))?;
219            // Record it, unless another channel just recorded one first — then
220            // that one is the agent's, and this one goes.
221            let mut bundle_id = bundle_id;
222            if let Some(fs) = sidecar.as_ref() {
223                // A recorded bundle this channel can't find is replaced, not
224                // obeyed (see `claim_unless_live`).
225                let live = |id: &str| matches!(bundle_store.bundle_get(id), Ok(Some(_)));
226                match sc::claim_unless_live(fs, &def.id, &bundle_id, &live) {
227                    Ok(winner) if winner != bundle_id && matches!(bundle_store.bundle_get(&winner), Ok(Some(_))) => {
228                        if let Err(e) = bundle_store.bundle_delete(&bundle_id) {
229                            tracing::warn!(
230                                agent_id = %def.id,
231                                bundle_id = %bundle_id,
232                                error = %e,
233                                "backfill_agent_bundles: another channel's bundle won; this duplicate could not be deleted and is left unbound"
234                            );
235                        }
236                        bundle_id = winner;
237                    }
238                    Ok(_) => {}
239                    Err(e) => tracing::warn!(agent_id = %def.id, error = %e, "backfill_agent_bundles: bundle sidecar not recorded"),
240                }
241            }
242
243            let applied = mstore
244                .agent_def_set_memory_id_if_empty(&def.id, &bundle_id)
245                .map_err(|e| MigrationError(format!("backfill_agent_bundles: bind {}: {}", def.id, e)))?;
246            if !applied {
247                // No local row for this id — it only resolved via the
248                // global registry overlay. Known gap (see module doc); the
249                // freshly-created bundle is orphaned (nothing points at it
250                // yet) rather than left dangling silently — log it so it's
251                // discoverable, matching this codebase's "no silent gaps"
252                // convention elsewhere in the migration set.
253                tracing::warn!(
254                    agent_id = %def.id,
255                    bundle_id = %bundle_id,
256                    "backfill_agent_bundles: definition only exists in the global registry — \
257                     cannot bind memory_id there yet (DefinitionRecordV1 gap); bundle created but unbound"
258                );
259            }
260        }
261        Ok(())
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use crate::backend::storage::agents::AgentDefinition;
269
270    // Same isolation discipline as m0020_agent_color_backfill's test module
271    // (see its own doc comment for the full rationale): every test here
272    // calls `up()`, which always tries to resolve+attach the global
273    // registry via the process-global `AGENTMUX_HOME_OVERRIDE` env var.
274    // Left unset, that would merge in every real agent on the developer's
275    // machine. Uses the CRATE-WIDE `test_support::ISOLATED_AUTH_ENV_LOCK`
276    // (see its own doc comment), not a mutex local to this module — see
277    // m0020_agent_color_backfill's identical comment for why a per-file
278    // mutex isn't sufficient (confirmed live, 2026-08-15: these two
279    // modules' tests raced each other, and would still race
280    // registry/paths.rs's and friends', under any mutex narrower than the
281    // crate-wide one every other consumer of this env var already uses).
282    use crate::test_support::ISOLATED_AUTH_ENV_LOCK as ENV_GUARD;
283
284    fn with_isolated_home<T>(f: impl FnOnce(&std::path::Path) -> T) -> T {
285        let _guard = ENV_GUARD.lock().unwrap_or_else(|e| e.into_inner());
286        let home_tmp = tempfile::tempdir().unwrap();
287        std::env::set_var("AGENTMUX_HOME_OVERRIDE", home_tmp.path().to_str().unwrap());
288        let result = f(home_tmp.path());
289        std::env::remove_var("AGENTMUX_HOME_OVERRIDE");
290        result
291    }
292
293    // `shared_path` MUST be a fresh per-test tempfile, never a shared
294    // constant path — the migration now genuinely writes bundles there
295    // (P1 fix, Codex/ReAgent review on PR #2587), so a shared literal path
296    // across tests would leak real bundle rows between parallel test runs
297    // and across repeated `cargo test` invocations.
298    fn ctx_for(channel_path: &std::path::Path, shared_path: &std::path::Path) -> MigrationContext {
299        MigrationContext {
300            home: std::env::temp_dir(),
301            data_dir: std::env::temp_dir(),
302            shared_store_path: shared_path.to_path_buf(),
303            channel_store_path: channel_path.to_path_buf(),
304        }
305    }
306
307    fn insert_def(mstore: &Store, name: &str) -> String {
308        let mut def = AgentDefinition {
309            conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
310            id: format!("test-{name}"),
311            slug: String::new(),
312            name: name.to_string(),
313            icon: String::new(),
314            provider: "claude".to_string(),
315            description: String::new(),
316            working_directory: String::new(),
317            shell: String::new(),
318            provider_flags: String::new(),
319            auto_start: 0,
320            restart_on_crash: 0,
321            idle_timeout_minutes: 0,
322            created_at: 1,
323            agent_type: "host".to_string(),
324            environment: String::new(),
325            agent_bus_id: String::new(),
326            is_seeded: 0,
327            accounts: String::new(),
328            parent_id: String::new(),
329            branch_label: String::new(),
330            updated_at: 1,
331            user_hidden: 0,
332            container_image: String::new(),
333            container_volumes: "[]".to_string(),
334            container_name: String::new(),
335            use_ambient_login: 0,
336            auto_continue_enabled: 0,
337            model_vendor_base_url: String::new(),
338            memory_id: String::new(),
339        };
340        mstore.agent_def_insert(&mut def).unwrap();
341        def.id
342    }
343
344    fn insert_def_with_provider(mstore: &Store, name: &str, provider: &str) -> String {
345        let mut def = AgentDefinition {
346            conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
347            id: format!("test-{name}"),
348            slug: String::new(),
349            name: name.to_string(),
350            icon: String::new(),
351            provider: provider.to_string(),
352            description: String::new(),
353            working_directory: String::new(),
354            shell: String::new(),
355            provider_flags: String::new(),
356            auto_start: 0,
357            restart_on_crash: 0,
358            idle_timeout_minutes: 0,
359            created_at: 1,
360            agent_type: "host".to_string(),
361            environment: String::new(),
362            agent_bus_id: String::new(),
363            is_seeded: 0,
364            accounts: String::new(),
365            parent_id: String::new(),
366            branch_label: String::new(),
367            updated_at: 1,
368            user_hidden: 0,
369            container_image: String::new(),
370            container_volumes: "[]".to_string(),
371            container_name: String::new(),
372            use_ambient_login: 0,
373            auto_continue_enabled: 0,
374            model_vendor_base_url: String::new(),
375            memory_id: String::new(),
376        };
377        mstore.agent_def_insert(&mut def).unwrap();
378        def.id
379    }
380
381    /// SPEC_MEMORY_FOLLOWS_THE_AGENT_2026_09_24.md §2.1.6: the agent's
382    /// second channel binds the bundle its first recorded, instead of minting
383    /// "Name — ABF (2)" in the same shared store.
384    #[test]
385    fn a_second_channel_binds_the_bundle_the_first_recorded() {
386        with_isolated_home(|_home| {
387            let shared_tmp = tempfile::NamedTempFile::new().unwrap();
388            let (ch1, ch2) = (tempfile::NamedTempFile::new().unwrap(), tempfile::NamedTempFile::new().unwrap());
389            let (s1, s2) = (Store::open(ch1.path()).unwrap(), Store::open(ch2.path()).unwrap());
390            let id = insert_def(&s1, "RoamingAgent");
391            assert_eq!(insert_def(&s2, "RoamingAgent"), id, "same agent in both channels");
392
393            M0021BackfillAgentBundles.up(&ctx_for(ch1.path(), shared_tmp.path())).unwrap();
394            M0021BackfillAgentBundles.up(&ctx_for(ch2.path(), shared_tmp.path())).unwrap();
395
396            let b1 = s1.agent_def_get(&id).unwrap().unwrap().memory_id;
397            let b2 = s2.agent_def_get(&id).unwrap().unwrap().memory_id;
398            assert!(!b1.is_empty());
399            assert_eq!(b1, b2, "both channels bind one bundle");
400            let shared = Store::open_shared(shared_tmp.path()).unwrap();
401            let abf: Vec<String> =
402                shared.bundle_list().unwrap().into_iter().map(|b| b.name).filter(|n| n.contains("RoamingAgent")).collect();
403            assert_eq!(abf, vec!["RoamingAgent — ABF".to_string()], "no second bundle minted");
404        });
405    }
406
407    /// A definition that only resolves through the global registry gets no
408    /// bundle here: nothing could point at it.
409    #[test]
410    fn a_global_only_definition_gets_no_orphaned_bundle() {
411        with_isolated_home(|home| {
412            use crate::registry::{DefinitionRecord, DefinitionRecordV1, DEF_MAX_SUPPORTED_SCHEMA};
413            let def_dir = home.join("shared").join("agents").join("definitions");
414            crate::registry::DefinitionStore::open(def_dir)
415                .unwrap()
416                .upsert(&DefinitionRecord {
417                    schema_version: DEF_MAX_SUPPORTED_SCHEMA,
418                    data: DefinitionRecordV1 {
419                        id: "test-GlobalOnly".to_string(),
420                        name: "GlobalOnly".to_string(),
421                        provider: "claude".to_string(),
422                        ..Default::default()
423                    },
424                })
425                .unwrap();
426            let shared_tmp = tempfile::NamedTempFile::new().unwrap();
427            let ch = tempfile::NamedTempFile::new().unwrap();
428            let s = Store::open(ch.path()).unwrap();
429            assert!(!s.agent_def_has_local_row("test-GlobalOnly").unwrap());
430
431            M0021BackfillAgentBundles.up(&ctx_for(ch.path(), shared_tmp.path())).unwrap();
432
433            let shared = Store::open_shared(shared_tmp.path()).unwrap();
434            let abf: Vec<String> =
435                shared.bundle_list().unwrap().into_iter().map(|b| b.name).filter(|n| n.contains("GlobalOnly")).collect();
436            assert!(abf.is_empty(), "minted an orphan: {abf:?}");
437        });
438    }
439
440    /// The sidecar recorded a bundle this channel can't see (it lived in an
441    /// isolated channel's store): the channel's new bundle replaces the
442    /// record, and the next channel binds that one instead of minting again.
443    #[test]
444    fn a_recorded_bundle_missing_here_is_replaced_then_reused() {
445        with_isolated_home(|_home| {
446            let shared_tmp = tempfile::NamedTempFile::new().unwrap();
447            let (ch1, ch2) = (tempfile::NamedTempFile::new().unwrap(), tempfile::NamedTempFile::new().unwrap());
448            let (s1, s2) = (Store::open(ch1.path()).unwrap(), Store::open(ch2.path()).unwrap());
449            let id = insert_def(&s1, "PinnedAgent");
450            insert_def(&s2, "PinnedAgent");
451            let global = crate::backend::agent_bundle_sidecar::open_global().unwrap();
452            crate::backend::agent_bundle_sidecar::claim(&global, &id, "bundle-in-an-isolated-store").unwrap();
453
454            M0021BackfillAgentBundles.up(&ctx_for(ch1.path(), shared_tmp.path())).unwrap();
455            let b1 = s1.agent_def_get(&id).unwrap().unwrap().memory_id;
456            assert!(!b1.is_empty() && b1 != "bundle-in-an-isolated-store");
457            assert_eq!(crate::backend::agent_bundle_sidecar::get(&global, &id).unwrap().as_deref(), Some(b1.as_str()));
458
459            M0021BackfillAgentBundles.up(&ctx_for(ch2.path(), shared_tmp.path())).unwrap();
460            assert_eq!(s2.agent_def_get(&id).unwrap().unwrap().memory_id, b1, "the second channel reuses it");
461            let shared = Store::open_shared(shared_tmp.path()).unwrap();
462            let abf = shared.bundle_list().unwrap().into_iter().filter(|b| b.name.contains("PinnedAgent")).count();
463            assert_eq!(abf, 1, "one bundle, not one per channel");
464        });
465    }
466
467    #[test]
468    fn backfills_a_definition_with_no_bundle() {
469        with_isolated_home(|_home| {
470            let tmp = tempfile::NamedTempFile::new().unwrap();
471            let shared_tmp = tempfile::NamedTempFile::new().unwrap();
472            let mstore = Store::open(tmp.path()).unwrap();
473            let id = insert_def(&mstore, "Plain Agent");
474
475            M0021BackfillAgentBundles.up(&ctx_for(tmp.path(), shared_tmp.path())).unwrap();
476
477            let def = mstore.agent_def_get(&id).unwrap().unwrap();
478            assert!(!def.memory_id.is_empty(), "expected memory_id to be backfilled");
479            let shared = Store::open_shared(shared_tmp.path()).unwrap();
480            let bundle = shared.bundle_get(&def.memory_id).unwrap().unwrap();
481            // insert_def hardcodes provider="claude" — derived, not the
482            // fallback path (see resolve_backfill_provider_and_model).
483            assert_eq!(bundle.provider, "claude");
484            assert_eq!(bundle.model, "anthropic");
485            assert!(bundle.name.contains("Plain Agent"));
486            assert!(!bundle.is_blank);
487        });
488    }
489
490    // P1 regression test (Codex + ReAgent review on PR #2587): the bundle
491    // must land in the EFFECTIVE identity/memory store (shared store,
492    // mirroring AppState.id_store), never the channel-local store the
493    // agent definition itself lives in — a channel-local bundle is
494    // invisible to bundle.list/bundle.get/the Memory editor.
495    #[test]
496    fn backfilled_bundle_lands_in_the_shared_store_not_the_channel_store() {
497        with_isolated_home(|_home| {
498            let tmp = tempfile::NamedTempFile::new().unwrap();
499            let shared_tmp = tempfile::NamedTempFile::new().unwrap();
500            let mstore = Store::open(tmp.path()).unwrap();
501            let id = insert_def(&mstore, "Shared Store Agent");
502
503            M0021BackfillAgentBundles.up(&ctx_for(tmp.path(), shared_tmp.path())).unwrap();
504
505            let def = mstore.agent_def_get(&id).unwrap().unwrap();
506            assert!(!def.memory_id.is_empty());
507
508            // Absent from the channel store — Store::open always seeds the
509            // "blank" singleton, so a fresh channel store already has 1
510            // bundle; the backfilled one must NOT also land here.
511            let channel_bundle_count = mstore.bundle_list().unwrap().iter().filter(|b| !b.is_blank).count();
512            assert_eq!(channel_bundle_count, 0, "bundle must not be written into the channel store");
513
514            // Present in the shared store.
515            let shared = Store::open_shared(shared_tmp.path()).unwrap();
516            assert!(shared.bundle_get(&def.memory_id).unwrap().is_some(), "bundle must be reachable via the shared store");
517        });
518    }
519
520    // P0 regression test (ReAgent review on PR #2587): the migration must
521    // derive each bundle's provider from THAT definition's own
522    // `provider`, never a hardcoded value — a hardcoded claude/anthropic
523    // default would silently reassign a non-Claude agent's harness on
524    // backfill, permanently, while the bundle's provider was read-only and
525    // preferred over the definition's own at spawn (as it was until
526    // SPEC_AGENT_BUNDLE_FORMAT_V0_3_2026_10_05.md §3; `m0035` relies on
527    // these values to reconcile agents).
528    #[test]
529    fn backfill_derives_provider_and_model_from_the_definitions_own_provider_not_a_hardcoded_default() {
530        with_isolated_home(|_home| {
531            let tmp = tempfile::NamedTempFile::new().unwrap();
532            let shared_tmp = tempfile::NamedTempFile::new().unwrap();
533            let mstore = Store::open(tmp.path()).unwrap();
534            let id = insert_def_with_provider(&mstore, "Codex Agent", "codex");
535
536            M0021BackfillAgentBundles.up(&ctx_for(tmp.path(), shared_tmp.path())).unwrap();
537
538            let def = mstore.agent_def_get(&id).unwrap().unwrap();
539            let shared = Store::open_shared(shared_tmp.path()).unwrap();
540            let bundle = shared.bundle_get(&def.memory_id).unwrap().unwrap();
541            assert_eq!(bundle.provider, "codex", "must carry the agent's OWN provider, not claude");
542            assert_eq!(bundle.model, "openai", "vendor derived from codex's supported_vendors[0]");
543        });
544    }
545
546    // P2 regression test (Codex review on PR #2587): a definition with a
547    // custom model_vendor_base_url override must backfill to model="custom",
548    // matching Store::resolve_effective_vendor / the frontend's
549    // resolveEffectiveVendor — not the provider's bare default vendor.
550    #[test]
551    fn backfill_uses_custom_vendor_when_the_definition_has_a_model_vendor_base_url_override() {
552        with_isolated_home(|_home| {
553            let tmp = tempfile::NamedTempFile::new().unwrap();
554            let shared_tmp = tempfile::NamedTempFile::new().unwrap();
555            let mstore = Store::open(tmp.path()).unwrap();
556            let id = insert_def_with_provider(&mstore, "Custom Vendor Agent", "claude");
557            {
558                let mut def = mstore.agent_def_get(&id).unwrap().unwrap();
559                def.model_vendor_base_url = "https://my-proxy.example.com".to_string();
560                mstore.agent_def_update(&mut def).unwrap();
561            }
562
563            M0021BackfillAgentBundles.up(&ctx_for(tmp.path(), shared_tmp.path())).unwrap();
564
565            let def = mstore.agent_def_get(&id).unwrap().unwrap();
566            let shared = Store::open_shared(shared_tmp.path()).unwrap();
567            let bundle = shared.bundle_get(&def.memory_id).unwrap().unwrap();
568            assert_eq!(bundle.provider, "claude");
569            assert_eq!(bundle.model, "custom", "a vendor override must backfill to \"custom\", not the provider default");
570        });
571    }
572
573    #[test]
574    fn backfill_falls_back_to_claude_anthropic_only_when_the_definition_has_no_provider_at_all() {
575        with_isolated_home(|_home| {
576            let tmp = tempfile::NamedTempFile::new().unwrap();
577            let shared_tmp = tempfile::NamedTempFile::new().unwrap();
578            let mstore = Store::open(tmp.path()).unwrap();
579            let id = insert_def_with_provider(&mstore, "No Provider Agent", "");
580
581            M0021BackfillAgentBundles.up(&ctx_for(tmp.path(), shared_tmp.path())).unwrap();
582
583            let def = mstore.agent_def_get(&id).unwrap().unwrap();
584            let shared = Store::open_shared(shared_tmp.path()).unwrap();
585            let bundle = shared.bundle_get(&def.memory_id).unwrap().unwrap();
586            assert_eq!(bundle.provider, FALLBACK_PROVIDER);
587            assert_eq!(bundle.model, FALLBACK_MODEL);
588        });
589    }
590
591    #[test]
592    fn leaves_an_already_bound_definition_untouched() {
593        with_isolated_home(|_home| {
594            let tmp = tempfile::NamedTempFile::new().unwrap();
595            let shared_tmp = tempfile::NamedTempFile::new().unwrap();
596            let mstore = Store::open(tmp.path()).unwrap();
597            let id = insert_def(&mstore, "Already Bound");
598            mstore.agent_def_set_memory_id_if_empty(&id, "pre-existing-bundle").unwrap();
599
600            M0021BackfillAgentBundles.up(&ctx_for(tmp.path(), shared_tmp.path())).unwrap();
601
602            let def = mstore.agent_def_get(&id).unwrap().unwrap();
603            assert_eq!(def.memory_id, "pre-existing-bundle", "must not overwrite an existing binding");
604        });
605    }
606
607    #[test]
608    fn rerun_is_idempotent_no_duplicate_bundles() {
609        with_isolated_home(|_home| {
610            let tmp = tempfile::NamedTempFile::new().unwrap();
611            let shared_tmp = tempfile::NamedTempFile::new().unwrap();
612            let mstore = Store::open(tmp.path()).unwrap();
613            let id = insert_def(&mstore, "Rerun Agent");
614
615            M0021BackfillAgentBundles.up(&ctx_for(tmp.path(), shared_tmp.path())).unwrap();
616            let first_bundle_id = mstore.agent_def_get(&id).unwrap().unwrap().memory_id;
617
618            M0021BackfillAgentBundles.up(&ctx_for(tmp.path(), shared_tmp.path())).unwrap();
619            let second_bundle_id = mstore.agent_def_get(&id).unwrap().unwrap().memory_id;
620
621            assert_eq!(first_bundle_id, second_bundle_id, "rerun must not replace the bound bundle");
622            // Store::open_shared also seeds the "blank" singleton (see
623            // bundles.rs), so a fresh shared store already has 1
624            // bundle before this migration ever runs — count non-blank
625            // bundles in the store bundles actually land in now.
626            let shared = Store::open_shared(shared_tmp.path()).unwrap();
627            let non_blank_count = shared
628                .bundle_list()
629                .unwrap()
630                .iter()
631                .filter(|b| !b.is_blank)
632                .count();
633            assert_eq!(non_blank_count, 1, "rerun must not create a second orphaned bundle");
634        });
635    }
636
637    #[test]
638    fn backfills_multiple_definitions_with_distinct_bundles() {
639        with_isolated_home(|_home| {
640            let tmp = tempfile::NamedTempFile::new().unwrap();
641            let shared_tmp = tempfile::NamedTempFile::new().unwrap();
642            let mstore = Store::open(tmp.path()).unwrap();
643            let a = insert_def(&mstore, "Agent A");
644            let b = insert_def(&mstore, "Agent B");
645
646            M0021BackfillAgentBundles.up(&ctx_for(tmp.path(), shared_tmp.path())).unwrap();
647
648            let bundle_a = mstore.agent_def_get(&a).unwrap().unwrap().memory_id;
649            let bundle_b = mstore.agent_def_get(&b).unwrap().unwrap().memory_id;
650            assert!(!bundle_a.is_empty());
651            assert!(!bundle_b.is_empty());
652            assert_ne!(bundle_a, bundle_b, "each agent must get its OWN dedicated bundle");
653        });
654    }
655
656    // P1 regression test (ReAgent review on PR #2587 round 4): two
657    // pre-existing agent definitions sharing the same display name (only
658    // `slug` is guaranteed unique, not `name`) used to abort this ENTIRE
659    // migration on the second agent's bundle-name collision — every
660    // definition processed after the collision stayed unbound
661    // permanently, on every subsequent boot, until manually resolved.
662    #[test]
663    fn backfills_both_agents_when_two_definitions_share_a_display_name() {
664        with_isolated_home(|_home| {
665            let tmp = tempfile::NamedTempFile::new().unwrap();
666            let shared_tmp = tempfile::NamedTempFile::new().unwrap();
667            let mstore = Store::open(tmp.path()).unwrap();
668            let mut def_a = AgentDefinition {
669                conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
670                id: "test-twin-a".to_string(),
671                slug: String::new(),
672                name: "Twin".to_string(),
673                icon: String::new(),
674                provider: "claude".to_string(),
675                description: String::new(),
676                working_directory: String::new(),
677                shell: String::new(),
678                provider_flags: String::new(),
679                auto_start: 0,
680                restart_on_crash: 0,
681                idle_timeout_minutes: 0,
682                created_at: 1,
683                agent_type: "host".to_string(),
684                environment: String::new(),
685                agent_bus_id: String::new(),
686                is_seeded: 0,
687                accounts: String::new(),
688                parent_id: String::new(),
689                branch_label: String::new(),
690                updated_at: 1,
691                user_hidden: 0,
692                container_image: String::new(),
693                container_volumes: "[]".to_string(),
694                container_name: String::new(),
695                use_ambient_login: 0,
696                auto_continue_enabled: 0,
697                model_vendor_base_url: String::new(),
698                memory_id: String::new(),
699            };
700            let mut def_b = def_a.clone();
701            def_b.id = "test-twin-b".to_string();
702            mstore.agent_def_insert(&mut def_a).unwrap();
703            mstore.agent_def_insert(&mut def_b).unwrap();
704
705            M0021BackfillAgentBundles.up(&ctx_for(tmp.path(), shared_tmp.path())).unwrap();
706
707            let bound_a = mstore.agent_def_get(&def_a.id).unwrap().unwrap().memory_id;
708            let bound_b = mstore.agent_def_get(&def_b.id).unwrap().unwrap().memory_id;
709            assert!(!bound_a.is_empty(), "first same-named agent must be backfilled");
710            assert!(!bound_b.is_empty(), "second same-named agent must NOT be left unbound by a collision");
711            assert_ne!(bound_a, bound_b, "each agent still gets its own distinct bundle");
712        });
713    }
714
715    #[test]
716    fn missing_channel_store_is_a_noop() {
717        with_isolated_home(|_home| {
718            let shared_tmp = tempfile::NamedTempFile::new().unwrap();
719            let ctx = ctx_for(std::path::Path::new("Z:/does/not/exist/objects.db"), shared_tmp.path());
720            M0021BackfillAgentBundles.up(&ctx).unwrap();
721        });
722    }
723
724    // Fallback regression test: when the shared store path itself is
725    // unusable (parent directory doesn't exist), the migration must still
726    // succeed by falling back to the channel store — mirroring
727    // AppState.id_store's own degrade-to-mstore behavior — rather than
728    // hard-failing the whole migration.
729    #[test]
730    fn falls_back_to_the_channel_store_when_the_shared_store_path_is_unusable() {
731        with_isolated_home(|_home| {
732            let tmp = tempfile::NamedTempFile::new().unwrap();
733            let mstore = Store::open(tmp.path()).unwrap();
734            let id = insert_def(&mstore, "Fallback Agent");
735            let bad_shared_path = std::path::Path::new("Z:/does/not/exist/shared-store.db");
736
737            M0021BackfillAgentBundles.up(&ctx_for(tmp.path(), bad_shared_path)).unwrap();
738
739            let def = mstore.agent_def_get(&id).unwrap().unwrap();
740            assert!(!def.memory_id.is_empty(), "must still backfill even when the shared store is unreachable");
741            assert!(mstore.bundle_get(&def.memory_id).unwrap().is_some(), "falls back to writing the bundle in the channel store");
742        });
743    }
744}