Skip to main content

agentmux_srv/migrations/
m0027_agents_workspace_backfill.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Backfill `db_agents.working_directory` with the workspace each agent
5//! actually ran in, taken from its latest legacy `db_agent_instances` launch.
6//!
7//! Agent-concept consolidation, Phase 3b
8//! (`SPEC_AGENT_ARCHITECTURE_2026_05_27.md`). Phase 3a's dual-write stored
9//! the DEFINITION's configured cwd on the consolidated row, on the reasoning
10//! that "db_agents holds durable agent config; the per-launch resolved cwd
11//! lives on the block". The launch row held the real one — `agent.open`
12//! resolves it, including slug-collision suffixing — so on an upgraded store
13//! the two disagree for every agent whose workspace was suffixed or
14//! overridden.
15//!
16//! That disagreement was harmless while readers still joined
17//! `db_agent_instances`. It stops being harmless the moment they don't
18//! (Phase 3b PR 2, this release): continuing such an agent would open the
19//! template's default directory instead of the workspace holding its files,
20//! and the cross-version JSON registry mirror silently skips any row whose
21//! `working_directory` is not under the agents root. Codex P1 on #3080.
22//!
23//! Why a separate migration rather than a wider `m0025`: 0025 shipped in
24//! v0.55.39 and is already applied on upgraded installs, so amending it
25//! would fix nobody who most needs it. This runs for everyone, whether or
26//! not 0025 ran.
27//!
28//! Rule: for each projection key (the `db_agents` row an instance chain maps
29//! to) take the most recently created instance in the chain and copy its
30//! non-empty `working_directory` onto the row. Frozen copy of the same v29
31//! projection rule `m0025` uses — deliberately a copy, per that module's
32//! "freeze copies of live logic" policy: the live helper is deleted when the
33//! legacy table is dropped, and this must keep producing the same result on
34//! every machine it ever runs on.
35//!
36//! Tolerates a store that has already dropped `db_agent_instances`
37//! (Phase 3c): nothing to backfill, `Ok`.
38
39use std::collections::{HashMap, HashSet};
40
41use rusqlite::{params, Connection};
42
43use crate::backend::storage::store::Store;
44
45use super::{Migration, MigrationContext, MigrationError, MigrationScope};
46
47pub struct M0027AgentsWorkspaceBackfill;
48
49/// The legacy instance columns this backfill reads.
50struct LegacyLaunch {
51    id: String,
52    parent_instance_id: String,
53    definition_id: String,
54    working_directory: String,
55    created_at: i64,
56}
57
58/// Frozen copy of the v29 projection rule (see `m0025`'s own copy): walk
59/// `parent_instance_id` to the chain root; if the root's definition is a
60/// user clone (`is_seeded = 0`) the key is that definition's id, otherwise
61/// it is the root launch's own id. A missing definition counts as seeded.
62fn projection_key(
63    launch: &LegacyLaunch,
64    by_id: &HashMap<String, &LegacyLaunch>,
65    is_seeded: &HashMap<String, i64>,
66) -> String {
67    let mut root = launch;
68    let mut seen: HashSet<&str> = HashSet::new();
69    while !root.parent_instance_id.is_empty() && seen.insert(root.id.as_str()) {
70        match by_id.get(&root.parent_instance_id) {
71            Some(parent) => root = parent,
72            None => break,
73        }
74    }
75    let seeded = is_seeded.get(&root.definition_id).copied().unwrap_or(1);
76    if seeded == 0 {
77        root.definition_id.clone()
78    } else {
79        root.id.clone()
80    }
81}
82
83/// Rows whose workspace was corrected.
84#[derive(Debug, Default, PartialEq, Eq)]
85pub(super) struct WorkspaceStats {
86    pub updated: usize,
87}
88
89pub(super) fn backfill_workspaces(conn: &Connection) -> Result<WorkspaceStats, String> {
90    let has_legacy: bool = conn
91        .query_row(
92            "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'db_agent_instances')",
93            [],
94            |row| row.get(0),
95        )
96        .map_err(|e| format!("probe db_agent_instances: {e}"))?;
97    if !has_legacy {
98        return Ok(WorkspaceStats::default());
99    }
100
101    let mut stmt = conn
102        .prepare(
103            "SELECT id, parent_instance_id, definition_id, working_directory, created_at
104             FROM db_agent_instances",
105        )
106        .map_err(|e| format!("read db_agent_instances: {e}"))?;
107    let launches: Vec<LegacyLaunch> = stmt
108        .query_map([], |row| {
109            Ok(LegacyLaunch {
110                id: row.get(0)?,
111                parent_instance_id: row.get(1)?,
112                definition_id: row.get(2)?,
113                working_directory: row.get(3)?,
114                created_at: row.get(4)?,
115            })
116        })
117        .and_then(|rows| rows.collect::<Result<Vec<_>, _>>())
118        .map_err(|e| format!("read db_agent_instances: {e}"))?;
119    drop(stmt);
120    if launches.is_empty() {
121        return Ok(WorkspaceStats::default());
122    }
123
124    let mut seeded_stmt = conn
125        .prepare("SELECT id, is_seeded FROM db_agent_definitions")
126        .map_err(|e| format!("read db_agent_definitions: {e}"))?;
127    let is_seeded: HashMap<String, i64> = seeded_stmt
128        .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)))
129        .and_then(|rows| rows.collect::<Result<HashMap<_, _>, _>>())
130        .map_err(|e| format!("read db_agent_definitions: {e}"))?;
131    drop(seeded_stmt);
132
133    let by_id: HashMap<String, &LegacyLaunch> = launches.iter().map(|l| (l.id.clone(), l)).collect();
134    // Latest launch per projection key — `created_at`, then id, so the choice
135    // is deterministic when two rows share a timestamp. Same tie-break as
136    // `m0025`, so both migrations pick the same launch.
137    let mut latest: HashMap<String, &LegacyLaunch> = HashMap::new();
138    for launch in &launches {
139        let key = projection_key(launch, &by_id, &is_seeded);
140        let replace = match latest.get(&key) {
141            None => true,
142            Some(cur) => (launch.created_at, launch.id.as_str()) > (cur.created_at, cur.id.as_str()),
143        };
144        if replace {
145            latest.insert(key, launch);
146        }
147    }
148
149    let mut stats = WorkspaceStats::default();
150    for (key, launch) in latest {
151        // An empty legacy value carries no information — the row keeps
152        // whatever it has rather than being blanked.
153        if launch.working_directory.is_empty() {
154            continue;
155        }
156        let n = conn
157            .execute(
158                "UPDATE db_agents SET working_directory = ?2
159                 WHERE id = ?1 AND is_template = 0 AND working_directory != ?2",
160                params![key, launch.working_directory],
161            )
162            .map_err(|e| format!("update db_agents {key}: {e}"))?;
163        stats.updated += n;
164    }
165    Ok(stats)
166}
167
168impl Migration for M0027AgentsWorkspaceBackfill {
169    fn id(&self) -> &'static str { "0027_agents_workspace_backfill" }
170    fn scope(&self) -> MigrationScope { MigrationScope::Channel }
171    fn description(&self) -> &'static str {
172        "Backfill db_agents.working_directory from the latest legacy launch's resolved workspace"
173    }
174
175    fn up(&self, ctx: &MigrationContext) -> Result<(), MigrationError> {
176        if !ctx.channel_store_path.exists() {
177            return Ok(());
178        }
179        // Store::open lays down the schema before the raw connection below
180        // touches the table.
181        drop(
182            Store::open(&ctx.channel_store_path)
183                .map_err(|e| MigrationError(format!("agents_workspace_backfill: open mstore: {e}")))?,
184        );
185        let conn = Connection::open(&ctx.channel_store_path)
186            .map_err(|e| MigrationError(format!("agents_workspace_backfill: open: {e}")))?;
187        let stats = backfill_workspaces(&conn)
188            .map_err(|e| MigrationError(format!("agents_workspace_backfill: {e}")))?;
189        tracing::info!(
190            updated = stats.updated,
191            "m0027_agents_workspace_backfill: complete"
192        );
193        Ok(())
194    }
195
196    // No `verify()`: the post-condition ("every consolidated row carries its
197    // latest launch's workspace") needs the legacy table to state, and that
198    // table is what Phase 3c removes — a check that can only run before its
199    // own precondition disappears would report NotVerifiable forever after.
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    /// Legacy-shaped fixture: the tables as they exist on a Phase 3a store,
207    /// with `db_agents` already carrying the DEFINITION's cwd (what the
208    /// Phase 3a dual-write wrote) rather than the launch's resolved one.
209    fn legacy_store() -> Connection {
210        let conn = Connection::open_in_memory().unwrap();
211        conn.execute_batch(
212            "CREATE TABLE db_agent_definitions (
213                id TEXT PRIMARY KEY, is_seeded INTEGER NOT NULL DEFAULT 0
214             );
215             CREATE TABLE db_agent_instances (
216                id TEXT PRIMARY KEY,
217                parent_instance_id TEXT NOT NULL DEFAULT '',
218                definition_id TEXT NOT NULL,
219                working_directory TEXT NOT NULL DEFAULT '',
220                created_at INTEGER NOT NULL DEFAULT 0
221             );
222             CREATE TABLE db_agents (
223                id TEXT PRIMARY KEY,
224                is_template INTEGER NOT NULL DEFAULT 0,
225                working_directory TEXT NOT NULL DEFAULT ''
226             );",
227        )
228        .unwrap();
229        conn
230    }
231
232    fn workdir(conn: &Connection, id: &str) -> String {
233        conn.query_row("SELECT working_directory FROM db_agents WHERE id = ?1", params![id], |r| {
234            r.get(0)
235        })
236        .unwrap()
237    }
238
239    /// The case codex P1 named: a template launch whose resolved workspace
240    /// was suffixed for a slug collision. The consolidated row holds the
241    /// template's configured cwd until this pass corrects it — otherwise
242    /// continuing the agent after the read flip opens the wrong directory.
243    #[test]
244    fn a_template_launch_row_takes_the_launchs_resolved_workspace() {
245        let conn = legacy_store();
246        conn.execute_batch(
247            "INSERT INTO db_agent_definitions (id, is_seeded) VALUES ('tpl', 1);
248             INSERT INTO db_agent_instances (id, definition_id, working_directory, created_at)
249                VALUES ('launch-1', 'tpl', '/agents/coder-2', 100);
250             INSERT INTO db_agents (id, is_template, working_directory)
251                VALUES ('tpl', 1, '/agents/coder'),
252                       ('launch-1', 0, '/agents/coder');",
253        )
254        .unwrap();
255
256        assert_eq!(backfill_workspaces(&conn).unwrap(), WorkspaceStats { updated: 1 });
257        assert_eq!(workdir(&conn, "launch-1"), "/agents/coder-2");
258        assert_eq!(workdir(&conn, "tpl"), "/agents/coder", "a template's own cwd is not a launch's");
259    }
260
261    /// A user agent's chain folds onto the DEFINITION's id, and the newest
262    /// launch in that chain is the one whose workspace the agent is really
263    /// using.
264    #[test]
265    fn a_user_agents_row_takes_its_newest_launchs_workspace() {
266        let conn = legacy_store();
267        conn.execute_batch(
268            "INSERT INTO db_agent_definitions (id, is_seeded) VALUES ('agent-a', 0);
269             INSERT INTO db_agent_instances (id, parent_instance_id, definition_id, working_directory, created_at)
270                VALUES ('l1', '', 'agent-a', '/agents/old', 100),
271                       ('l2', 'l1', 'agent-a', '/agents/new', 200);
272             INSERT INTO db_agents (id, is_template, working_directory)
273                VALUES ('agent-a', 0, '/agents/configured');",
274        )
275        .unwrap();
276
277        assert_eq!(backfill_workspaces(&conn).unwrap(), WorkspaceStats { updated: 1 });
278        assert_eq!(workdir(&conn, "agent-a"), "/agents/new");
279    }
280
281    /// Idempotent, and never blanks a row: a second run writes nothing, and
282    /// a launch with no recorded workspace leaves the row's value alone.
283    #[test]
284    fn a_second_run_and_an_empty_legacy_workspace_both_write_nothing() {
285        let conn = legacy_store();
286        conn.execute_batch(
287            "INSERT INTO db_agent_definitions (id, is_seeded) VALUES ('tpl', 1);
288             INSERT INTO db_agent_instances (id, definition_id, working_directory, created_at)
289                VALUES ('launch-1', 'tpl', '/agents/real', 100),
290                       ('launch-2', 'tpl', '', 100);
291             INSERT INTO db_agents (id, is_template, working_directory)
292                VALUES ('launch-1', 0, '/agents/cfg'),
293                       ('launch-2', 0, '/agents/kept');",
294        )
295        .unwrap();
296
297        assert_eq!(backfill_workspaces(&conn).unwrap(), WorkspaceStats { updated: 1 });
298        assert_eq!(workdir(&conn, "launch-2"), "/agents/kept");
299        assert_eq!(
300            backfill_workspaces(&conn).unwrap(),
301            WorkspaceStats::default(),
302            "re-running must be a no-op"
303        );
304    }
305
306    /// A Phase 3c store has no legacy table at all — nothing to read, and
307    /// no error.
308    #[test]
309    fn a_store_without_the_legacy_table_is_a_no_op() {
310        let conn = Connection::open_in_memory().unwrap();
311        conn.execute_batch("CREATE TABLE db_agents (id TEXT PRIMARY KEY);").unwrap();
312        assert_eq!(backfill_workspaces(&conn).unwrap(), WorkspaceStats::default());
313    }
314}