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}