Skip to main content

agentmux_srv/migrations/
m0025_agents_launch_state_backfill.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Backfill `db_agents`' latest-launch state (schema v29:
5//! `session_id`, `status`, `started_at`, `ended_at`, and `last_block_id`
6//! where still empty) from the legacy `db_agent_instances` rows.
7//!
8//! Agent-concept consolidation, Phase 3b
9//! (`SPEC_AGENT_ARCHITECTURE_2026_05_27.md`): these four columns are what
10//! kept the legacy table alive — the recent-sessions picker, the pane
11//! close/reopen continuity write-back and the status-filtered instance
12//! list all read them from `db_agent_instances`. Once every consolidated
13//! row carries its own launch state, those reads can flip and the table
14//! can go.
15//!
16//! Rule: for each projection key (the `db_agents` row an instance chain
17//! maps to — the chain head's own id for a template launch, the user-clone
18//! definition's id for a launch of a user agent) take the most recently
19//! created instance in the chain and copy its launch state, but only into
20//! a row whose launch state is still entirely default. That makes the
21//! migration idempotent and means it never overwrites state the dual-write
22//! has written since v29 shipped. Channel-scoped: each channel's
23//! `objects.db` has its own rows.
24//!
25//! Frozen copy of `dual_write::agents_projection_key_for_inst`'s rule
26//! (as of v29) — deliberately not a call into it, per this module's "freeze
27//! copies of live logic" doc: `dual_write` is deleted when the legacy
28//! table is dropped, and this migration must keep producing the same rows
29//! on every machine it ever runs on.
30//!
31//! Tolerates a store that has already dropped `db_agent_instances`
32//! (Phase 3c): nothing to backfill, `Ok`.
33
34use std::collections::{HashMap, HashSet};
35
36use rusqlite::{params, Connection};
37
38use crate::backend::storage::store::Store;
39
40use super::{Migration, MigrationContext, MigrationError, MigrationScope};
41
42pub struct M0025AgentsLaunchStateBackfill;
43
44/// The legacy instance columns the backfill reads.
45struct LegacyInstance {
46    id: String,
47    parent_instance_id: String,
48    definition_id: String,
49    block_id: String,
50    session_id: String,
51    status: String,
52    started_at: i64,
53    ended_at: i64,
54    created_at: i64,
55}
56
57/// Frozen copy of the v29 projection rule: walk `parent_instance_id` to the
58/// chain root; if the root's definition is a user-clone (`is_seeded = 0`)
59/// the key is that definition's id (the launch was folded into the def's
60/// row), otherwise it is the root instance's own id (a template launch is
61/// its own row). A missing definition counts as seeded, matching the
62/// `COALESCE(d.is_seeded, 1)` in the live helper. A chain whose parent
63/// pointer no longer resolves is rooted at the last instance that does.
64fn projection_key(
65    inst: &LegacyInstance,
66    by_id: &HashMap<String, &LegacyInstance>,
67    is_seeded: &HashMap<String, i64>,
68) -> String {
69    let mut root = inst;
70    let mut seen: HashSet<&str> = HashSet::new();
71    while !root.parent_instance_id.is_empty() && seen.insert(root.id.as_str()) {
72        match by_id.get(&root.parent_instance_id) {
73            Some(parent) => root = parent,
74            None => break,
75        }
76    }
77    let seeded = is_seeded.get(&root.definition_id).copied().unwrap_or(1);
78    if seeded == 0 {
79        root.definition_id.clone()
80    } else {
81        root.id.clone()
82    }
83}
84
85/// What the backfill did. `projected` counts template-launch chains whose
86/// consolidated row was missing and had to be created first; `updated`
87/// counts rows that received launch state.
88#[derive(Debug, Default, PartialEq, Eq)]
89pub(super) struct BackfillStats {
90    pub definitions_repaired: usize,
91    pub projected: usize,
92    pub updated: usize,
93    pub missing_definition: usize,
94}
95
96/// Create the consolidated row for a template-launch chain whose head has
97/// no `db_agents` row yet — the "Phase 3a stamped, db_agents partially
98/// populated" upgrade shape (codex P1 on #3075). Without this the UPDATE
99/// below affects zero rows, the migration is still recorded applied, and
100/// the chain's session/status is unreachable once readers flip. Frozen
101/// copy of the consolidate pass-3 / dual-write head-INSERT column mapping
102/// as of v29, `INSERT OR IGNORE` so it can never clobber a row that
103/// appeared in between. `Ok(false)` when the definition itself is gone —
104/// nothing to project against (the legacy FK cascade would have removed
105/// such an instance; if it survived it is an orphan).
106fn project_template_launch_row(conn: &Connection, root: &LegacyInstance) -> Result<bool, String> {
107    let n = conn
108        .execute(
109            "INSERT OR IGNORE INTO db_agents (
110                id, name, icon, description,
111                is_template, parent_template_id,
112                provider, provider_flags, shell, environment,
113                agent_type, agent_bus_id, accounts,
114                auto_start, restart_on_crash, idle_timeout_minutes,
115                slug, branch_label,
116                identity_id, memory_id, working_directory, github_context,
117                instance_name,
118                created_at, updated_at, is_seeded, user_hidden,
119                container_image, container_volumes, container_name,
120                use_ambient_login, model_vendor_base_url, auto_continue_enabled,
121                conversation_visibility
122             )
123             SELECT i.id,
124                    CASE WHEN i.instance_name = '' THEN d.name ELSE i.instance_name END,
125                    d.icon, d.description,
126                    0, d.id,
127                    d.provider, d.provider_flags, d.shell, d.environment,
128                    d.agent_type, d.agent_bus_id, d.accounts,
129                    d.auto_start, d.restart_on_crash, d.idle_timeout_minutes,
130                    d.slug, d.branch_label,
131                    i.identity_id, i.memory_id, d.working_directory, i.github_context,
132                    i.instance_name,
133                    i.created_at, i.created_at, 0, i.display_hidden,
134                    d.container_image, d.container_volumes, d.container_name,
135                    d.use_ambient_login, d.model_vendor_base_url, d.auto_continue_enabled,
136                    d.conversation_visibility
137             FROM db_agent_instances i
138             JOIN db_agent_definitions d ON d.id = i.definition_id
139             WHERE i.id = ?1",
140            params![root.id],
141        )
142        .map_err(|e| format!("project template launch {}: {e}", root.id))?;
143    Ok(n > 0)
144}
145
146/// Frozen copy of `agents_consolidate::repair_def_gaps` as it existed when
147/// this migration was written (deleted from live code by the definition
148/// flip — nothing writes `db_agent_definitions` without also
149/// writing `db_agents` any more, so the gap this backfills can no longer
150/// open going forward; it can still exist on an install upgrading THROUGH
151/// this exact migration from a pre-flip version, which is what this
152/// migration must keep repairing on every machine it ever runs on).
153/// Backfill any `db_agent_definitions` row missing from `db_agents`.
154/// `INSERT OR IGNORE`, so idempotent. Returns the number of rows inserted.
155fn frozen_repair_def_gaps(conn: &mut Connection) -> Result<usize, String> {
156    let mut stmt = conn
157        .prepare(
158            "SELECT d.id, d.name, d.icon, d.provider, d.description,
159                    d.working_directory, d.shell, d.provider_flags, d.auto_start,
160                    d.restart_on_crash, d.idle_timeout_minutes, d.created_at,
161                    d.agent_type, d.environment, d.agent_bus_id, d.is_seeded,
162                    d.accounts, d.parent_id, d.branch_label, d.updated_at,
163                    d.slug, d.user_hidden
164             FROM db_agent_definitions d
165             LEFT JOIN db_agents a ON a.id = d.id
166             WHERE a.id IS NULL",
167        )
168        .map_err(|e| format!("gap-repair: prepare: {e}"))?;
169    #[allow(clippy::type_complexity)]
170    let missing: Vec<(
171        String, String, String, String, String, String, String, String,
172        i64, i64, i64, i64, String, String, String, i64, String, String,
173        String, i64, String, i64,
174    )> = stmt
175        .query_map([], |row| {
176            Ok((
177                row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?,
178                row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, String>(5)?,
179                row.get::<_, String>(6)?, row.get::<_, String>(7)?, row.get::<_, i64>(8)?,
180                row.get::<_, i64>(9)?, row.get::<_, i64>(10)?, row.get::<_, i64>(11)?,
181                row.get::<_, String>(12)?, row.get::<_, String>(13)?, row.get::<_, String>(14)?,
182                row.get::<_, i64>(15)?, row.get::<_, String>(16)?, row.get::<_, String>(17)?,
183                row.get::<_, String>(18)?, row.get::<_, i64>(19)?, row.get::<_, String>(20)?,
184                row.get::<_, i64>(21)?,
185            ))
186        })
187        .and_then(|rows| rows.collect::<Result<Vec<_>, _>>())
188        .map_err(|e| format!("gap-repair: read db_agent_definitions: {e}"))?;
189    drop(stmt);
190    if missing.is_empty() {
191        return Ok(0);
192    }
193
194    let tx = conn.transaction().map_err(|e| format!("gap-repair: begin: {e}"))?;
195    let mut inserted = 0usize;
196    for (
197        id, name, icon, provider, description, working_directory, shell,
198        provider_flags, auto_start, restart_on_crash, idle_timeout_minutes,
199        created_at, agent_type, environment, agent_bus_id, is_seeded,
200        accounts, parent_id, branch_label, updated_at, slug, user_hidden,
201    ) in &missing
202    {
203        let is_template = if *is_seeded == 1 { 1_i64 } else { 0_i64 };
204        let parent_template_id = if *is_seeded == 1 { String::new() } else { parent_id.clone() };
205        let affected = tx
206            .execute(
207                "INSERT OR IGNORE INTO db_agents (
208                    id, name, icon, description,
209                    is_template, parent_template_id,
210                    provider, provider_flags, shell, environment,
211                    agent_type, agent_bus_id, accounts,
212                    auto_start, restart_on_crash, idle_timeout_minutes,
213                    slug, branch_label,
214                    identity_id, memory_id, working_directory, github_context,
215                    instance_name,
216                    created_at, updated_at, is_seeded, user_hidden
217                 ) VALUES (
218                    ?1, ?2, ?3, ?4,
219                    ?5, ?6,
220                    ?7, ?8, ?9, ?10,
221                    ?11, ?12, ?13,
222                    ?14, ?15, ?16,
223                    ?17, ?18,
224                    '', '', ?19, '',
225                    '',
226                    ?20, ?21, ?22, ?23
227                 )",
228                params![
229                    id, name, icon, description,
230                    is_template, parent_template_id,
231                    provider, provider_flags, shell, environment,
232                    agent_type, agent_bus_id, accounts,
233                    auto_start, restart_on_crash, idle_timeout_minutes,
234                    slug, branch_label,
235                    working_directory,
236                    created_at, updated_at, is_seeded, user_hidden,
237                ],
238            )
239            .map_err(|e| format!("gap-repair: insert {id}: {e}"))?;
240        if affected > 0 {
241            inserted += 1;
242        }
243    }
244    tx.commit().map_err(|e| format!("gap-repair: commit: {e}"))?;
245    Ok(inserted)
246}
247
248/// The backfill proper, on an open connection.
249///
250/// Order matters: first every definition missing from `db_agents` is
251/// repaired (`frozen_repair_def_gaps`, above), then every
252/// template-launch chain head missing its projection row is projected,
253/// and only then is launch state copied. Otherwise a missing row would be
254/// skipped here, created later with default launch state, and never
255/// revisited (codex P1 on #3075).
256pub(super) fn backfill_launch_state(conn: &mut Connection) -> Result<BackfillStats, String> {
257    let has_legacy: bool = conn
258        .query_row(
259            "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'db_agent_instances')",
260            [],
261            |row| row.get(0),
262        )
263        .map_err(|e| format!("probe db_agent_instances: {e}"))?;
264    if !has_legacy {
265        return Ok(BackfillStats::default());
266    }
267    let mut stats = BackfillStats {
268        definitions_repaired: frozen_repair_def_gaps(conn)
269            .map_err(|e| format!("repair definition gaps: {e}"))?,
270        ..Default::default()
271    };
272
273    let mut stmt = conn
274        .prepare(
275            "SELECT id, parent_instance_id, definition_id, block_id, session_id, status,
276                    started_at, ended_at, created_at
277             FROM db_agent_instances",
278        )
279        .map_err(|e| format!("read db_agent_instances: {e}"))?;
280    let instances: Vec<LegacyInstance> = stmt
281        .query_map([], |row| {
282            Ok(LegacyInstance {
283                id: row.get(0)?,
284                parent_instance_id: row.get(1)?,
285                definition_id: row.get(2)?,
286                block_id: row.get(3)?,
287                session_id: row.get(4)?,
288                status: row.get(5)?,
289                started_at: row.get(6)?,
290                ended_at: row.get(7)?,
291                created_at: row.get(8)?,
292            })
293        })
294        .and_then(|rows| rows.collect::<Result<Vec<_>, _>>())
295        .map_err(|e| format!("read db_agent_instances: {e}"))?;
296    drop(stmt);
297    if instances.is_empty() {
298        return Ok(stats);
299    }
300
301    let mut seeded_stmt = conn
302        .prepare("SELECT id, is_seeded FROM db_agent_definitions")
303        .map_err(|e| format!("read db_agent_definitions: {e}"))?;
304    let is_seeded: HashMap<String, i64> = seeded_stmt
305        .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)))
306        .and_then(|rows| rows.collect::<Result<HashMap<_, _>, _>>())
307        .map_err(|e| format!("read db_agent_definitions: {e}"))?;
308    drop(seeded_stmt);
309
310    let by_id: HashMap<String, &LegacyInstance> = instances.iter().map(|i| (i.id.clone(), i)).collect();
311    // Latest instance per projection key — `created_at`, then id, so the
312    // choice is deterministic when two rows share a timestamp.
313    let mut latest: HashMap<String, &LegacyInstance> = HashMap::new();
314    for inst in &instances {
315        let key = projection_key(inst, &by_id, &is_seeded);
316        let replace = match latest.get(&key) {
317            None => true,
318            Some(cur) => (inst.created_at, inst.id.as_str()) > (cur.created_at, cur.id.as_str()),
319        };
320        if replace {
321            latest.insert(key, inst);
322        }
323    }
324
325    // A template-launch key IS the chain head's instance id; if the head has
326    // no consolidated row, project one before the state copy below.
327    for key in latest.keys() {
328        let Some(root) = by_id.get(key) else { continue };
329        let exists: bool = conn
330            .query_row("SELECT EXISTS(SELECT 1 FROM db_agents WHERE id = ?1)", params![key], |r| r.get(0))
331            .map_err(|e| format!("probe db_agents {key}: {e}"))?;
332        if exists {
333            continue;
334        }
335        if project_template_launch_row(conn, root)? {
336            stats.projected += 1;
337        } else {
338            stats.missing_definition += 1;
339            tracing::warn!(
340                instance_id = %root.id,
341                definition_id = %root.definition_id,
342                "m0025_agents_launch_state_backfill: template launch has no definition; cannot project"
343            );
344        }
345    }
346
347    for (key, inst) in latest {
348        let n = conn
349            .execute(
350                "UPDATE db_agents SET
351                    session_id = ?2,
352                    status = ?3,
353                    started_at = ?4,
354                    ended_at = ?5,
355                    last_block_id = CASE WHEN last_block_id = '' THEN ?6 ELSE last_block_id END
356                 WHERE id = ?1
357                   AND is_template = 0
358                   AND session_id = '' AND status = '' AND started_at = 0 AND ended_at = 0",
359                params![key, inst.session_id, inst.status, inst.started_at, inst.ended_at, inst.block_id],
360            )
361            .map_err(|e| format!("update db_agents {key}: {e}"))?;
362        stats.updated += n;
363    }
364    Ok(stats)
365}
366
367impl Migration for M0025AgentsLaunchStateBackfill {
368    fn id(&self) -> &'static str { "0025_agents_launch_state_backfill" }
369    fn scope(&self) -> MigrationScope { MigrationScope::Channel }
370    fn description(&self) -> &'static str {
371        "Backfill db_agents launch state (session/status/started/ended) from db_agent_instances"
372    }
373
374    fn up(&self, ctx: &MigrationContext) -> Result<(), MigrationError> {
375        if !ctx.channel_store_path.exists() {
376            return Ok(());
377        }
378        // Store::open lays down schema v29 (the columns this writes) before
379        // the raw connection below touches the table.
380        drop(
381            Store::open(&ctx.channel_store_path)
382                .map_err(|e| MigrationError(format!("agents_launch_state_backfill: open mstore: {e}")))?,
383        );
384        let mut conn = Connection::open(&ctx.channel_store_path)
385            .map_err(|e| MigrationError(format!("agents_launch_state_backfill: open: {e}")))?;
386        let stats = backfill_launch_state(&mut conn)
387            .map_err(|e| MigrationError(format!("agents_launch_state_backfill: {e}")))?;
388        tracing::info!(
389            definitions_repaired = stats.definitions_repaired,
390            projected = stats.projected,
391            updated = stats.updated,
392            missing_definition = stats.missing_definition,
393            "m0025_agents_launch_state_backfill: complete"
394        );
395        Ok(())
396    }
397
398    // No `verify()`: the post-condition ("every consolidated row whose
399    // legacy chain had launch state now carries it") needs the legacy table
400    // to state, and the legacy table is what Phase 3c removes — a check
401    // that can only run before its own precondition disappears would report
402    // `error` on every post-3c install. `NotVerifiable` is the honest
403    // default here; the dual-write and the Phase 3b readers' own tests are
404    // what pin the live behaviour.
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410    use crate::backend::storage::store::AgentDefinition;
411
412    fn def(id: &str, is_seeded: i64, parent_id: &str) -> AgentDefinition {
413        AgentDefinition {
414            conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
415            id: id.to_string(),
416            slug: id.to_string(),
417            name: id.to_string(),
418            icon: String::new(),
419            provider: "claude".to_string(),
420            description: String::new(),
421            working_directory: String::new(),
422            shell: "bash".to_string(),
423            provider_flags: String::new(),
424            auto_start: 0,
425            restart_on_crash: 0,
426            idle_timeout_minutes: 0,
427            created_at: 1000,
428            agent_type: "standalone".to_string(),
429            environment: String::new(),
430            agent_bus_id: String::new(),
431            is_seeded,
432            accounts: String::new(),
433            parent_id: parent_id.to_string(),
434            branch_label: String::new(),
435            updated_at: 1000,
436            user_hidden: 0,
437            container_image: String::new(),
438            container_volumes: "[]".to_string(),
439            container_name: String::new(),
440            use_ambient_login: 0,
441            auto_continue_enabled: 0,
442            model_vendor_base_url: String::new(),
443            memory_id: String::new(),
444        }
445    }
446
447    fn launch_state(conn: &Connection, id: &str) -> (String, String, i64, String) {
448        conn.query_row(
449            "SELECT session_id, status, started_at, last_block_id FROM db_agents WHERE id = ?1",
450            [id],
451            |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
452        )
453        .unwrap()
454    }
455
456    /// A pre-v29 store: legacy rows in place, db_agents rows present (the
457    /// dual-write wrote them) but with the launch-state columns at their
458    /// defaults — exactly what an upgraded install looks like the first time
459    /// v29's ALTERs run.
460    fn pre_v29_store() -> (tempfile::TempDir, std::path::PathBuf) {
461        let dir = tempfile::tempdir().unwrap();
462        let path = dir.path().join("objects.db");
463        let store = Store::open(&path).unwrap();
464        let mut tpl = def("tpl", 1, "");
465        store.agent_def_insert(&mut tpl).unwrap();
466        let mut clone = def("clone", 0, "tpl");
467        store.agent_def_insert(&mut clone).unwrap();
468        drop(store);
469
470        // The pre-upgrade shape, written the only way it can still be
471        // written: the live instance API stopped touching
472        // `db_agent_instances` in Phase 3b, so an upgraded install's legacy
473        // rows come from an older build, not from this code. Raw SQL is what
474        // that looks like — one launch row per launch, plus the `db_agents`
475        // projections the Phase 3a dual-write left with default launch state.
476        let conn = Connection::open(&path).unwrap();
477
478        // Neither legacy table exists at all any more (v32,
479        // `OBJECT_SCHEMA_VERSION`'s v32 doc comment) — this fixture stands
480        // up the pre-drop shape by hand.
481        conn.execute_batch(
482            "CREATE TABLE db_agent_definitions (
483                id TEXT PRIMARY KEY, slug TEXT NOT NULL DEFAULT '', name TEXT NOT NULL,
484                icon TEXT NOT NULL DEFAULT '', provider TEXT NOT NULL, description TEXT NOT NULL DEFAULT '',
485                working_directory TEXT NOT NULL DEFAULT '', shell TEXT NOT NULL DEFAULT '',
486                provider_flags TEXT NOT NULL DEFAULT '', auto_start INTEGER NOT NULL DEFAULT 0,
487                restart_on_crash INTEGER NOT NULL DEFAULT 0, idle_timeout_minutes INTEGER NOT NULL DEFAULT 0,
488                created_at INTEGER NOT NULL DEFAULT 0, agent_type TEXT NOT NULL DEFAULT 'standalone',
489                environment TEXT NOT NULL DEFAULT '', agent_bus_id TEXT NOT NULL DEFAULT '',
490                is_seeded INTEGER NOT NULL DEFAULT 0, accounts TEXT NOT NULL DEFAULT '',
491                parent_id TEXT NOT NULL DEFAULT '', branch_label TEXT NOT NULL DEFAULT '',
492                updated_at INTEGER NOT NULL DEFAULT 0, user_hidden INTEGER NOT NULL DEFAULT 0,
493                container_image TEXT NOT NULL DEFAULT '', container_volumes TEXT NOT NULL DEFAULT '[]',
494                container_name TEXT NOT NULL DEFAULT '', use_ambient_login INTEGER NOT NULL DEFAULT 0,
495                model_vendor_base_url TEXT NOT NULL DEFAULT '', auto_continue_enabled INTEGER NOT NULL DEFAULT 0,
496                memory_id TEXT NOT NULL DEFAULT '', conversation_visibility TEXT NOT NULL DEFAULT 'private'
497             );
498             CREATE TABLE db_agent_instances (
499                id TEXT PRIMARY KEY, definition_id TEXT NOT NULL,
500                parent_instance_id TEXT NOT NULL DEFAULT '', block_id TEXT NOT NULL DEFAULT '',
501                session_id TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'running',
502                github_context TEXT NOT NULL DEFAULT '', identity_id TEXT NOT NULL DEFAULT '',
503                memory_id TEXT NOT NULL DEFAULT '', instance_name TEXT NOT NULL DEFAULT '',
504                working_directory TEXT NOT NULL DEFAULT '', display_hidden INTEGER NOT NULL DEFAULT 0,
505                started_at INTEGER NOT NULL DEFAULT 0, ended_at INTEGER NOT NULL DEFAULT 0,
506                created_at INTEGER NOT NULL DEFAULT 0,
507                FOREIGN KEY (definition_id) REFERENCES db_agent_definitions(id) ON DELETE CASCADE
508             );",
509        )
510        .unwrap();
511
512        // `db_agent_instances.definition_id` still FKs to
513        // `db_agent_definitions(id)` — un-repointed, since that table is
514        // dropped whole rather than having its FK moved. `agent_def_insert`
515        // above no longer writes it (definition flip), so this fixture must
516        // add the row itself, standing in for "a definition an older build
517        // wrote the only way it still could."
518        let add_legacy_def = |id: &str, is_seeded: i64, parent_id: &str| {
519            conn.execute(
520                "INSERT INTO db_agent_definitions (
521                    id, slug, name, icon, provider, description, working_directory, shell,
522                    provider_flags, auto_start, restart_on_crash, idle_timeout_minutes,
523                    created_at, agent_type, environment, agent_bus_id, is_seeded, accounts,
524                    parent_id, branch_label, updated_at, user_hidden
525                 ) VALUES (?1, ?1, ?1, '', 'claude', '', '', 'bash', '', 0, 0, 0,
526                           1000, 'standalone', '', '', ?2, '', ?3, '', 1000, 0)",
527                params![id, is_seeded, parent_id],
528            )
529            .unwrap();
530        };
531        add_legacy_def("tpl", 1, "");
532        add_legacy_def("clone", 0, "tpl");
533        let add_legacy = |id: &str, def_id: &str, parent: &str, block: &str, session: &str, status: &str, created: i64| {
534            conn.execute(
535                "INSERT INTO db_agent_instances
536                    (id, definition_id, parent_instance_id, block_id, session_id, status,
537                     github_context, started_at, ended_at, created_at,
538                     identity_id, memory_id, instance_name, working_directory, display_hidden)
539                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, '', ?7, 0, ?7, '', '', ?1, '', 0)",
540                params![id, def_id, parent, block, session, status, created],
541            )
542            .unwrap();
543        };
544        // A template launch with one continuation: head → child.
545        add_legacy("head", "tpl", "", "b1", "s1", "stopped", 2000);
546        add_legacy("child", "tpl", "head", "b2", "s2", "running", 3000);
547        // A launch of the user clone (its projection is the clone's own row).
548        add_legacy("clone-run", "clone", "", "b3", "s3", "paused", 2500);
549        // The template-launch chain's projection row, as the dual-write wrote
550        // it: keyed by the chain head, launch state still at its defaults.
551        conn.execute(
552            "INSERT INTO db_agents (id, name, provider, is_template, parent_template_id, is_seeded, created_at, updated_at)
553             VALUES ('head', 'head', 'claude', 0, 'tpl', 0, 2000, 2000)",
554            [],
555        )
556        .unwrap();
557        conn.execute_batch(
558            "UPDATE db_agents SET session_id = '', status = '', started_at = 0, ended_at = 0, last_block_id = '';",
559        )
560        .unwrap();
561        drop(conn);
562        (dir, path)
563    }
564
565    #[test]
566    fn backfills_the_latest_launch_of_each_chain_into_its_projection_row() {
567        let (_dir, path) = pre_v29_store();
568        let mut conn = Connection::open(&path).unwrap();
569        let stats = backfill_launch_state(&mut conn).unwrap();
570        assert_eq!(stats.updated, 2, "one template chain + one clone: {stats:?}");
571        assert_eq!((stats.projected, stats.definitions_repaired, stats.missing_definition), (0, 0, 0));
572        // The template chain's head row carries the CHILD's (latest) state.
573        assert_eq!(launch_state(&conn, "head"), ("s2".into(), "running".into(), 3000, "b2".into()));
574        // The clone's own row carries its launch.
575        assert_eq!(launch_state(&conn, "clone"), ("s3".into(), "paused".into(), 2500, "b3".into()));
576        // The template row itself is untouched.
577        assert_eq!(launch_state(&conn, "tpl"), (String::new(), String::new(), 0, String::new()));
578    }
579
580    #[test]
581    fn is_idempotent_and_never_overwrites_state_written_since() {
582        let (_dir, path) = pre_v29_store();
583        let mut conn = Connection::open(&path).unwrap();
584        // Live state already on the head row (the dual-write got there first).
585        conn.execute("UPDATE db_agents SET session_id = 'live', status = 'running' WHERE id = 'head'", []).unwrap();
586        assert_eq!(backfill_launch_state(&mut conn).unwrap().updated, 1, "only the still-default clone row");
587        assert_eq!(launch_state(&conn, "head").0, "live");
588        assert_eq!(backfill_launch_state(&mut conn).unwrap().updated, 0, "second run writes nothing");
589    }
590
591    #[test]
592    fn projects_a_template_launch_whose_consolidated_row_is_missing_before_copying_state() {
593        // The "Phase 3a stamped, db_agents partially populated" upgrade
594        // shape (codex P1 on #3075): the chain head has no db_agents row at
595        // all. Skipping it would record the migration applied while the
596        // chain's session stays unreachable forever.
597        let (_dir, path) = pre_v29_store();
598        let mut conn = Connection::open(&path).unwrap();
599        conn.execute("DELETE FROM db_agents WHERE id = 'head'", []).unwrap();
600        let stats = backfill_launch_state(&mut conn).unwrap();
601        assert_eq!(stats.projected, 1, "{stats:?}");
602        assert_eq!(stats.updated, 2, "{stats:?}");
603        assert_eq!(launch_state(&conn, "head"), ("s2".into(), "running".into(), 3000, "b2".into()));
604        let (is_template, parent, name): (i64, String, String) = conn
605            .query_row("SELECT is_template, parent_template_id, name FROM db_agents WHERE id = 'head'", [], |r| {
606                Ok((r.get(0)?, r.get(1)?, r.get(2)?))
607            })
608            .unwrap();
609        assert_eq!((is_template, parent.as_str(), name.as_str()), (0, "tpl", "head"), "projected as a user agent of its template");
610    }
611
612    #[test]
613    fn repairs_a_definition_missing_from_db_agents_before_copying_state() {
614        let (_dir, path) = pre_v29_store();
615        let mut conn = Connection::open(&path).unwrap();
616        conn.execute("DELETE FROM db_agents WHERE id = 'clone'", []).unwrap();
617        let stats = backfill_launch_state(&mut conn).unwrap();
618        assert_eq!(stats.definitions_repaired, 1, "{stats:?}");
619        assert_eq!(launch_state(&conn, "clone").0, "s3");
620    }
621
622    #[test]
623    fn tolerates_a_store_without_the_legacy_table() {
624        // A plain `Store::open` already has neither legacy table as of v32
625        // (`OBJECT_SCHEMA_VERSION`'s v32 doc comment) — no DROP needed to
626        // construct this scenario any more; it's the default shape.
627        let dir = tempfile::tempdir().unwrap();
628        let path = dir.path().join("objects.db");
629        drop(Store::open(&path).unwrap());
630        let mut conn = Connection::open(&path).unwrap();
631        assert_eq!(backfill_launch_state(&mut conn).unwrap(), BackfillStats::default());
632    }
633
634    #[test]
635    fn up_runs_end_to_end_against_a_channel_store_and_skips_a_missing_one() {
636        let (dir, path) = pre_v29_store();
637        let ctx = MigrationContext {
638            home: dir.path().to_path_buf(),
639            data_dir: dir.path().to_path_buf(),
640            shared_store_path: dir.path().join("shared.db"),
641            channel_store_path: path.clone(),
642        };
643        M0025AgentsLaunchStateBackfill.up(&ctx).unwrap();
644        let conn = Connection::open(&path).unwrap();
645        assert_eq!(launch_state(&conn, "clone").0, "s3");
646
647        let missing = MigrationContext { channel_store_path: dir.path().join("nope.db"), ..ctx };
648        M0025AgentsLaunchStateBackfill.up(&missing).unwrap();
649        assert!(!missing.channel_store_path.exists(), "a missing channel store is left missing");
650    }
651}