Skip to main content

agentmux_srv/backend/agent_session/migrations/
v1_templates.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Two-tier picker — Phase 1 migration (seeded-def → user-agent promote).
5
6use std::collections::HashMap;
7use std::path::Path;
8use std::sync::Arc;
9
10use crate::backend::storage::filestore::FileStore;
11use crate::backend::storage::store::Store;
12
13use super::super::helpers::{now_ms, write_zone_file};
14use super::super::zone_naming::is_valid_definition_id;
15
16/// Marker file name for the Phase 1 two-tier-picker migration.
17///
18/// **Vestigial.** Originally gated `migrate_promote_template_sessions_v1`
19/// as a one-shot. The 2026-05-24 self-idempotency rework moved gating
20/// to the data invariant ("no seeded def has a session zone"), so the
21/// migration runs on every startup and is a no-op when the invariant
22/// already holds. The constant + `data_dir` parameter on the migration
23/// function are kept for API/import compatibility and so the legacy
24/// marker file (if present from an earlier portable run) isn't
25/// resurrected. Operators may delete the file; the migration ignores
26/// it either way.
27#[cfg(test)]
28pub const TEMPLATE_PROMOTE_MARKER_V1: &str = "migration_template_promote_v1.flag";
29
30/// Stats from `migrate_promote_template_sessions_v1`. Logged at INFO.
31#[derive(Debug, Clone, Default)]
32pub struct TemplatePromoteStats {
33    pub templates_scanned: usize,
34    pub templates_promoted: usize,
35    /// Total archive zones moved across all promotions.
36    pub archives_moved: usize,
37    /// Total instances repointed via
38    /// `mstore.instance_repoint_definition`.
39    pub instances_repointed: usize,
40    pub failures: usize,
41}
42
43/// Phase 1 two-tier picker migration: promote any seeded template that
44/// carries a session zone into a fresh user-owned definition, then move
45/// its `:current` + `:archive:*` zones onto the new definition_id.
46///
47/// Why this exists (Q1 = Option C in
48/// `docs/specs/SPEC_AGENT_PICKER_TWO_TIER_2026_05_24.md`):
49/// after the picker UI split, clicking a "template" card in the
50/// Templates section MUST create a new agent — not silently append to
51/// whatever session the user previously ran against that template
52/// directly (e.g. "Maks's conversation" living at `agent:claude:current`).
53/// Without this migration the template card would either reattach to
54/// the existing session (broken — wrong intent) or be effectively
55/// non-functional. The migration moves any such pre-existing session
56/// out of the template namespace onto a new user-owned definition so
57/// the template is pristine post-migration.
58///
59/// Algorithm:
60/// 1. List zone ids; partition the `agent:<id>:current` and
61///    `agent:<id>:archive:*` zones by definition id.
62/// 2. For each definition id with at least one zone, look up the
63///    matching `db_agent_definitions` row.
64///    - Skip if missing (zone refers to a deleted definition).
65///    - Skip if `is_seeded = 0` (already user-owned — no work).
66///    - Otherwise: clone the template into a new user definition
67///      (mirrors the `agentdefcreatefromtemplate` handler's semantics).
68/// 3. Pick the new name: most-recently-active named instance's
69///    `instance_name` if any exists, else fall back to the template's
70///    own `name`.
71/// 4. Move every matching zone (`:current` + every `:archive:*`)
72///    from the old defId to the new defId via FileStore's existing
73///    write-then-delete pattern.
74/// 5. Repoint every `db_agent_instances` row that referenced the old
75///    defId to point at the new defId (preserves the
76///    `continueOfInstanceId` reattach flow).
77///
78/// Idempotency: the migration is **self-gated on the data invariant**
79/// ("no seeded def has a session zone"). It runs on every startup;
80/// when the invariant already holds the inner loop has zero
81/// iterations and returns the default stats in sub-ms. There used to
82/// be a marker-file gate (`TEMPLATE_PROMOTE_MARKER_V1`), but it
83/// produced an "early-marker" failure mode: a portable launched at v
84/// N had no seeded-def zones, set the marker, and on v N+1 startups
85/// (when seeded-def zones DID exist from prior real use) the marker
86/// caused the migration to skip. The 2026-05-24 rework dropped the
87/// marker check; this is safe because the seeded-def-with-zone
88/// invariant is detectable per-startup at constant cost. `data_dir`
89/// is retained for API compatibility.
90///
91/// Failure mode: per-template errors are logged + counted; we DO NOT
92/// abort startup. Errors that prevent a template from being promoted
93/// leave its zones in place; the next startup retries (no marker
94/// gate to block retry).
95pub fn migrate_promote_template_sessions_v1(
96    mstore: &Arc<Store>,
97    filestore: &Arc<FileStore>,
98    _data_dir: &Path,
99) -> TemplatePromoteStats {
100
101    let mut stats = TemplatePromoteStats::default();
102
103    let all_zones = match filestore.get_all_zone_ids() {
104        Ok(v) => v,
105        Err(e) => {
106            tracing::warn!(
107                error = %e,
108                "template_promote migration: get_all_zone_ids failed; aborting (will retry next start)"
109            );
110            return stats;
111        }
112    };
113
114    // Group zone ids by definition id. A zone counts if it matches
115    // `agent:<id>:current` OR `agent:<id>:archive:<ts>`. Anything else
116    // (e.g. legacy per-block zones the prior migration didn't sweep)
117    // is ignored by this migration.
118    let mut per_def_zones: HashMap<String, Vec<String>> = HashMap::new();
119    for zone in &all_zones {
120        let rest = match zone.strip_prefix("agent:") {
121            Some(r) => r,
122            None => continue,
123        };
124        // `<defId>:current` or `<defId>:archive:<ts>`
125        let (def_id, tail) = match rest.split_once(':') {
126            Some(p) => p,
127            None => continue,
128        };
129        if !is_valid_definition_id(def_id) {
130            continue;
131        }
132        let is_current = tail == "current";
133        let is_archive = tail.starts_with("archive:");
134        if !is_current && !is_archive {
135            continue;
136        }
137        per_def_zones
138            .entry(def_id.to_string())
139            .or_default()
140            .push(zone.clone());
141    }
142
143    // Fetch all definitions ONCE so per-template lookups don't re-hit
144    // SQLite in a loop.
145    let defs = match mstore.agent_def_list() {
146        Ok(v) => v,
147        Err(e) => {
148            tracing::warn!(
149                error = %e,
150                "template_promote migration: agent_def_list failed; aborting (will retry next start)"
151            );
152            return stats;
153        }
154    };
155
156    for (old_def_id, zones) in per_def_zones {
157        // Look up the definition row this zone is bound to.
158        let template = match defs.iter().find(|d| d.id == old_def_id) {
159            Some(d) => d,
160            None => {
161                // Zone points at a deleted definition — leave it
162                // alone; a future GC pass can clean orphans.
163                continue;
164            }
165        };
166        // Only seeded templates need promotion. User-owned defs are
167        // already on the new model.
168        if template.is_seeded != 1 {
169            continue;
170        }
171        stats.templates_scanned += 1;
172
173        // Pick the new agent name: most-recently-active named instance
174        // for this template, else fall back to the template's own name.
175        // `instance_list_named` already filters to non-hidden + named
176        // rows + sorts by `started_at DESC`, so the first row is the
177        // pick.
178        // Include continuations: a user who clicked Maks today and
179        // resumed three times has only continuation rows for that
180        // definition; the head row is whatever they originally
181        // named the agent. Picking the most-recent continuation
182        // surfaces the same `instance_name` they used last.
183        let new_name = match mstore.instance_list_named(
184            1,
185            Some(&old_def_id),
186            /* identity_id */ None,
187            /* include_continuations */ true,
188        ) {
189            Ok(rows) => rows
190                .into_iter()
191                .next()
192                .map(|i| i.instance_name)
193                .filter(|n| !n.is_empty())
194                .unwrap_or_else(|| template.name.clone()),
195            Err(e) => {
196                tracing::warn!(
197                    template_id = %old_def_id,
198                    error = %e,
199                    "template_promote migration: instance_list_named failed; using template name"
200                );
201                template.name.clone()
202            }
203        };
204
205        // Idempotency: the migration uses a DETERMINISTIC clone id
206        // (`template-promote-v1-<template_id>`) so every retry of
207        // every partial-failure scenario targets the same clone.
208        // Successful prior steps (zone moves, instance repoints)
209        // are reused; failed steps re-attempt against the same
210        // destination. There is no way to "fork" the migration
211        // into a different clone id, so the unbounded-duplicate
212        // failure modes from codex P1 rounds 1+2 cannot recur:
213        //
214        //   1. Insert def: idempotent via `SELECT WHERE id = ?1`
215        //      first; new row only on absence. PK uniqueness on
216        //      the deterministic id catches any race.
217        //   2. move_zone: write-then-delete; replay copies the
218        //      same content to the same destination (no-op when
219        //      already moved), retries the source delete.
220        //   3. instance_repoint_definition: UPDATE on rows whose
221        //      definition_id = old; rows already at new are a
222        //      no-op SET.
223        //
224        // The deterministic id also distinguishes the migration's
225        // own clone from any user-created "+ New from template"
226        // clone (which lives under a fresh UUID), so we never
227        // clobber a user's live session.
228        let promote_target_id =
229            format!("template-promote-v1-{}", template.id);
230        debug_assert!(
231            is_valid_definition_id(&promote_target_id),
232            "deterministic promote-target id must satisfy the zone-id charset"
233        );
234
235        let existing_target = match mstore.agent_def_get(&promote_target_id) {
236            Ok(Some(def)) => Some(def),
237            Ok(None) => None,
238            Err(e) => {
239                tracing::warn!(
240                    template_id = %old_def_id,
241                    promote_target_id = %promote_target_id,
242                    error = %e,
243                    "template_promote migration: agent_def_get failed; aborting this template"
244                );
245                stats.failures += 1;
246                continue;
247            }
248        };
249        let new_def = if let Some(existing) = existing_target {
250            tracing::info!(
251                template_id = %old_def_id,
252                promote_target_id = %promote_target_id,
253                "template_promote migration: reusing prior promote-target clone (idempotent retry)"
254            );
255            existing
256        } else {
257            // Clone the template into a new user-owned definition
258            // at the deterministic id. Field copies mirror the
259            // `agentdefcreatefromtemplate` handler
260            // (server/agent_handlers/template.rs).
261            let now = now_ms() as i64;
262            // The shared resolver (#2594, same as
263            // `agentdefcreatefromtemplate`/`forkagentdefinition`): the
264            // template's own provider, or its bundle's when it has none
265            // (SPEC_AGENT_BUNDLE_FORMAT_V0_3_2026_10_05.md §3.1). Only `mstore` is available this early in boot, so a
266            // bundle that lives in the shared store isn't found here.
267            let effective_provider = mstore.resolve_effective_provider_id(template);
268            let mut new_def = crate::backend::storage::store::AgentDefinition {
269                id: promote_target_id.clone(),
270                slug: String::new(),
271                name: new_name.clone(),
272                icon: template.icon.clone(),
273                provider: effective_provider,
274                description: template.description.clone(),
275                working_directory: String::new(),
276                shell: template.shell.clone(),
277                provider_flags: template.provider_flags.clone(),
278                auto_start: 0,
279                restart_on_crash: template.restart_on_crash,
280                idle_timeout_minutes: template.idle_timeout_minutes,
281                created_at: now,
282                agent_type: template.agent_type.clone(),
283                environment: template.environment.clone(),
284                agent_bus_id: String::new(),
285                is_seeded: 0,
286                accounts: String::new(),
287                parent_id: template.id.clone(),
288                branch_label: String::new(),
289                updated_at: now,
290                user_hidden: 0,
291                container_image: template.container_image.clone(),
292                container_volumes: template.container_volumes.clone(),
293                container_name: String::new(),
294                use_ambient_login: 0,
295                model_vendor_base_url: template.model_vendor_base_url.clone(),
296                auto_continue_enabled: 0,
297                memory_id: String::new(),
298                // Safe fail-closed default, not inherited from the template —
299                // same convention as auto_continue_enabled/use_ambient_login
300                // just above (opt-in settings reset, not carried over, on a
301                // fresh instantiation).
302                conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
303            };
304            if let Err(e) = mstore.agent_def_insert(&mut new_def) {
305                tracing::warn!(
306                    template_id = %old_def_id,
307                    promote_target_id = %promote_target_id,
308                    error = %e,
309                    "template_promote migration: agent_def_insert failed; skipping this template"
310                );
311                stats.failures += 1;
312                continue;
313            }
314            new_def
315        };
316
317        // Move every matching zone (current + archives) onto the new
318        // definition id. Per-zone failures are logged but don't abort
319        // the whole template — best-effort.
320        let mut archives_for_this_def: usize = 0;
321        for old_zone in &zones {
322            // Build the new zone id by swapping the def-id segment.
323            // We know `old_zone` starts with `agent:<old_def_id>:`
324            // (per the bucketing above), so substring-replace is safe.
325            let suffix = match old_zone.strip_prefix(&format!("agent:{}:", old_def_id)) {
326                Some(s) => s,
327                None => continue,
328            };
329            let new_zone = format!("agent:{}:{}", new_def.id, suffix);
330            let is_archive = suffix.starts_with("archive:");
331
332            if let Err(e) = move_zone(filestore, old_zone, &new_zone) {
333                tracing::warn!(
334                    template_id = %old_def_id,
335                    old_zone = %old_zone,
336                    new_zone = %new_zone,
337                    error = %e,
338                    "template_promote migration: move_zone failed"
339                );
340                stats.failures += 1;
341                continue;
342            }
343            if is_archive {
344                archives_for_this_def += 1;
345            }
346        }
347
348        // Repoint any in-DB instances referencing this template at
349        // the new user-owned definition. Without this, the existing
350        // continueOfInstanceId reattach flow would still look up the
351        // template and pass through the un-promoted definition_id.
352        let repointed = match mstore.instance_repoint_definition(&old_def_id, &new_def.id) {
353            Ok(n) => n,
354            Err(e) => {
355                tracing::warn!(
356                    template_id = %old_def_id,
357                    new_definition_id = %new_def.id,
358                    error = %e,
359                    "template_promote migration: instance_repoint_definition failed"
360                );
361                stats.failures += 1;
362                0
363            }
364        };
365        stats.instances_repointed += repointed;
366        stats.archives_moved += archives_for_this_def;
367        stats.templates_promoted += 1;
368        tracing::info!(
369            template_id = %old_def_id,
370            template_name = %template.name,
371            new_definition_id = %new_def.id,
372            new_name = %new_def.name,
373            archives_moved = archives_for_this_def,
374            instances_repointed = repointed,
375            "template_promote migration: promoted template into user agent"
376        );
377    }
378
379    // Marker write removed in the 2026-05-24 self-idempotency rework
380    // (see doc comment above). The invariant "no seeded def carries a
381    // session zone" is checked on every startup; when it already holds
382    // this function is a sub-ms no-op.
383
384    tracing::info!(
385        templates_scanned = stats.templates_scanned,
386        templates_promoted = stats.templates_promoted,
387        archives_moved = stats.archives_moved,
388        instances_repointed = stats.instances_repointed,
389        failures = stats.failures,
390        "template_promote migration: complete"
391    );
392
393    stats
394}
395
396/// Per-file decision inside `move_zone`'s retry-aware loop. See the
397/// doc comment in `move_zone` for which round each variant addresses.
398#[derive(Debug, Clone, Copy, PartialEq, Eq)]
399enum CopyAction {
400    /// Destination missing the file (R5 partial-copy fill).
401    Copy,
402    /// Source strictly newer than destination (R6 newer-source promotion).
403    Overwrite,
404    /// Destination strictly newer than source (R4 user-continuation
405    /// on destination clone) — or equal-modts + equal bytes.
406    Preserve,
407    /// Equal modts; need to read both sides and compare bytes.
408    TieBreakByBytes,
409    /// Equal modts but bytes differ — neither side is canonical
410    /// (R7 same-ms conflict). Preserve destination, leave source.
411    Conflict,
412}
413
414/// Move every file in `old_zone` to `new_zone`, preserving names + bytes.
415/// Implemented as read-write-delete because FileStore doesn't expose a
416/// native rename; the cost is bounded by the per-zone file count (1-2
417/// in practice — `output.state.json` + `output`).
418fn move_zone(
419    filestore: &FileStore,
420    old_zone: &str,
421    new_zone: &str,
422) -> Result<(), String> {
423    let files = filestore
424        .list_files(old_zone)
425        .map_err(|e| format!("list_files: {e}"))?;
426    if files.is_empty() {
427        return Ok(());
428    }
429    // Per-file recency-aware copy (codex P1 rounds 4 + 5 + 6 on
430    // PR #1017). Three retry shapes need to coexist on the same
431    // retry path:
432    //
433    //   R4 — partial-failure, user continued on the destination
434    //        clone (`:current` of the new def). Destination has
435    //        NEWER bytes than source. Keep destination; drop
436    //        source.
437    //   R5 — partial-failure, prior `move_zone` wrote SOME of the
438    //        destination files before crashing. Destination has
439    //        only some files; the missing ones must be copied
440    //        from source. Don't drop source until every source
441    //        file has a counterpart at the destination.
442    //   R6 — partial-failure, `instance_repoint_definition` was
443    //        the step that failed. Instances still point at the
444    //        seeded def, user continued — SOURCE bytes are newer
445    //        than destination's stale copy. Source must NOT be
446    //        dropped without first promoting its newer content
447    //        to the destination.
448    //
449    // Resolve all three via a per-file recency-aware copy:
450    //   - destination missing the file → COPY (R5).
451    //   - destination has the file, src.modts ≤ dest.modts → keep
452    //     destination, no copy (R4).
453    //   - destination has the file, src.modts > dest.modts → copy
454    //     source over destination (R6).
455    // After the loop, every source file has a counterpart at the
456    // destination; source can be safely deleted.
457    //
458    // `modts` ties (or zero on either side) are resolved in favor
459    // of keeping the destination, matching the R4 semantics — the
460    // common case for a clean first-time retry where both sides
461    // hold identical bytes.
462    let dest_meta: std::collections::HashMap<String, crate::backend::storage::filestore::MuxFile> = filestore
463        .list_files(new_zone)
464        .map_err(|e| format!("list_files (new): {e}"))?
465        .into_iter()
466        .map(|f| (f.name.clone(), f))
467        .collect();
468    let mut copied = 0usize;
469    let mut overwritten = 0usize;
470    let mut preserved = 0usize;
471    let mut conflicts = 0usize;
472    for f in &files {
473        let dest = dest_meta.get(&f.name);
474        let action = match dest {
475            None => CopyAction::Copy, // R5: destination missing
476            Some(d) if f.modts > d.modts => CopyAction::Overwrite, // R6
477            Some(d) if d.modts > f.modts => CopyAction::Preserve, // R4
478            Some(_) => CopyAction::TieBreakByBytes, // R7: equal modts
479        };
480        let resolved = match action {
481            CopyAction::Copy | CopyAction::Overwrite => action,
482            CopyAction::Preserve => action,
483            CopyAction::Conflict => action, // unreachable from the matcher above; explicit for exhaustiveness
484            CopyAction::TieBreakByBytes => {
485                // R7 — equal modts (millisecond-granular filestore
486                // can write source + destination within the same
487                // ms on a real retry). Read both sides and
488                // disambiguate by bytes.
489                let src_bytes = filestore
490                    .read_file(old_zone, &f.name)
491                    .map_err(|e| format!("read_file {}: {e}", f.name))?
492                    .unwrap_or_default();
493                let dest_bytes = filestore
494                    .read_file(new_zone, &f.name)
495                    .map_err(|e| format!("read_file (dest) {}: {e}", f.name))?
496                    .unwrap_or_default();
497                if src_bytes == dest_bytes {
498                    CopyAction::Preserve
499                } else {
500                    // Conflict: can't tell which side is canonical.
501                    // Preserve destination (matches the round-4
502                    // semantics — keep what the user might be
503                    // looking at), but refuse to delete source so
504                    // the operator (or a future GC pass that can
505                    // compare timestamps at a higher resolution)
506                    // can resolve. The post-loop missing-files
507                    // check would still pass, so we signal the
508                    // conflict via a separate counter.
509                    CopyAction::Conflict
510                }
511            }
512        };
513        match resolved {
514            CopyAction::Copy => {
515                let bytes = filestore
516                    .read_file(old_zone, &f.name)
517                    .map_err(|e| format!("read_file {}: {e}", f.name))?
518                    .unwrap_or_default();
519                write_zone_file(filestore, new_zone, &f.name, &bytes)?;
520                copied += 1;
521            }
522            CopyAction::Overwrite => {
523                let bytes = filestore
524                    .read_file(old_zone, &f.name)
525                    .map_err(|e| format!("read_file {}: {e}", f.name))?
526                    .unwrap_or_default();
527                write_zone_file(filestore, new_zone, &f.name, &bytes)?;
528                overwritten += 1;
529            }
530            CopyAction::Preserve => {
531                preserved += 1;
532            }
533            CopyAction::Conflict => {
534                conflicts += 1;
535                tracing::warn!(
536                    old_zone = %old_zone,
537                    new_zone = %new_zone,
538                    file = %f.name,
539                    modts = f.modts,
540                    "template_promote migration: same-ms conflict — bytes differ at equal modts; preserving destination + leaving source for manual recovery"
541                );
542            }
543            CopyAction::TieBreakByBytes => unreachable!("resolved above"),
544        }
545    }
546    if preserved > 0 || overwritten > 0 || conflicts > 0 {
547        tracing::info!(
548            old_zone = %old_zone,
549            new_zone = %new_zone,
550            copied,
551            overwritten,
552            preserved,
553            conflicts,
554            "template_promote migration: per-file move (R4 user-continuation, R5 partial-copy fill, R6 newer-source promotion, R7 same-ms conflict)"
555        );
556    }
557    if conflicts > 0 {
558        // R7: an equal-modts byte-diff was detected. We don't know
559        // which side is canonical, so we preserve both: destination
560        // keeps its content, source is left in place for operator
561        // / GC recovery. Migration converges next run only if the
562        // operator resolves the conflict externally.
563        return Ok(());
564    }
565    // Verify every source file has a counterpart at the
566    // destination before dropping source — protects against the
567    // R5 partial-write case where write_zone_file silently leaves
568    // a file absent at the destination despite returning Ok (no
569    // current call path does so, but defending the invariant here
570    // is cheap and future-proofs the helper).
571    let post_dest: std::collections::HashSet<String> = filestore
572        .list_files(new_zone)
573        .map_err(|e| format!("list_files (new, post): {e}"))?
574        .into_iter()
575        .map(|f| f.name)
576        .collect();
577    let missing: Vec<&str> = files
578        .iter()
579        .map(|f| f.name.as_str())
580        .filter(|n| !post_dest.contains(*n))
581        .collect();
582    if !missing.is_empty() {
583        tracing::warn!(
584            old_zone = %old_zone,
585            new_zone = %new_zone,
586            missing = ?missing,
587            "template_promote migration: destination missing files post-copy; leaving source in place for retry"
588        );
589        return Ok(());
590    }
591    // Delete the source files only after every write has succeeded.
592    // delete_zone wipes the whole zone in one transaction.
593    if let Err(e) = filestore.delete_zone(old_zone) {
594        // Source delete failure is non-fatal — the new zone has the
595        // data; the old zone is now stale duplicate, GC concern.
596        tracing::warn!(
597            old_zone = %old_zone,
598            error = %e,
599            "template_promote migration: delete_zone failed after copy; source remains"
600        );
601    }
602    Ok(())
603}