agentmux_srv\backend\storage/
dual_write.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Phase 3a — `db_agents` dual-write helpers.
5//!
6//! Every mutation on `db_agent_definitions` / `db_agent_instances`
7//! mirrors into `db_agents` so a later read-migration PR (Phase 3b)
8//! can flip readers over with confidence. Dual-write failures LOG +
9//! CONTINUE — the old tables remain authoritative until Phase 3b.
10//! See `docs/specs/SPEC_AGENT_CONCEPT_CONSOLIDATION_2026_05_24.md`.
11//!
12//! Extracted from `store.rs` in Phase R.5 of the storage
13//! modularization plan
14//! (`docs/specs/SPEC_STORE_MODULARIZATION_2026_05_27.md`). This
15//! whole file goes away in Phase 3c when the legacy tables are
16//! dropped — keeping it isolated makes that future deletion clean.
17
18use rusqlite::{params, Connection};
19
20use super::error::StoreError;
21use super::store::{AgentDefinition, AgentInstance, Store};
22
23impl Store {
24    /// Mirror a `db_agent_definitions` row into `db_agents` as the
25    /// canonical row for that definition.
26    ///
27    /// - `is_seeded = 1` rows become `is_template = 1` (canonical
28    ///   template; bindings stay empty).
29    /// - `is_seeded = 0` rows become `is_template = 0` with
30    ///   `parent_template_id = parent_id` (user-cloned from a template;
31    ///   bindings come from the matching instance, if any — handled by
32    ///   `agents_dual_write_instance_create` updating in place).
33    ///
34    /// Idempotent: uses `INSERT … ON CONFLICT(id) DO UPDATE`. Existing
35    /// bindings on the row (written previously by an instance dual-write)
36    /// are preserved — only definition-side fields are overwritten.
37    ///
38    /// Phase 3b: returns `Err` on failure (previously logged + continued).
39    /// Phase 3b readers see `db_agents`, so a silent dual-write failure
40    /// would leak stale data into the picker.
41    pub(crate) fn agents_dual_write_definition_upsert(
42        &self,
43        def: &AgentDefinition,
44    ) -> Result<(), StoreError> {
45        let conn = self.conn.lock().unwrap();
46        let is_template = if def.is_seeded == 1 { 1_i64 } else { 0_i64 };
47        let parent_template_id = if def.is_seeded == 1 {
48            String::new()
49        } else {
50            def.parent_id.clone()
51        };
52        // Phase 3b: carry the caller's `user_hidden` into the INSERT
53        // (previously hardcoded to 0). The legacy `db_agent_definitions`
54        // INSERT honours it, and now that Phase 3b readers look at
55        // `db_agents`, the projection must agree from row creation —
56        // not just after a subsequent `agent_def_set_hidden` flip.
57        // ON CONFLICT deliberately does NOT update `user_hidden`: hide
58        // is a per-user view-state flag that survives definition
59        // payload edits.
60        conn.execute(
61            "INSERT INTO db_agents (
62                id, name, icon, description,
63                is_template, parent_template_id,
64                provider, provider_flags, shell, environment,
65                agent_type, agent_bus_id, accounts,
66                auto_start, restart_on_crash, idle_timeout_minutes,
67                slug, branch_label, working_directory,
68                created_at, updated_at, is_seeded, user_hidden,
69                container_image, container_volumes, container_name
70             ) VALUES (
71                ?1, ?2, ?3, ?4,
72                ?5, ?6,
73                ?7, ?8, ?9, ?10,
74                ?11, ?12, ?13,
75                ?14, ?15, ?16,
76                ?17, ?18, ?19,
77                ?20, ?21, ?22, ?23,
78                ?24, ?25, ?26
79             )
80             ON CONFLICT(id) DO UPDATE SET
81                name = excluded.name,
82                icon = excluded.icon,
83                description = excluded.description,
84                is_template = excluded.is_template,
85                parent_template_id = excluded.parent_template_id,
86                provider = excluded.provider,
87                provider_flags = excluded.provider_flags,
88                shell = excluded.shell,
89                environment = excluded.environment,
90                agent_type = excluded.agent_type,
91                agent_bus_id = excluded.agent_bus_id,
92                accounts = excluded.accounts,
93                auto_start = excluded.auto_start,
94                restart_on_crash = excluded.restart_on_crash,
95                idle_timeout_minutes = excluded.idle_timeout_minutes,
96                slug = excluded.slug,
97                branch_label = excluded.branch_label,
98                working_directory = excluded.working_directory,
99                updated_at = excluded.updated_at,
100                is_seeded = excluded.is_seeded,
101                container_image = excluded.container_image,
102                container_volumes = excluded.container_volumes,
103                container_name = excluded.container_name",
104            params![
105                def.id,
106                def.name,
107                def.icon,
108                def.description,
109                is_template,
110                parent_template_id,
111                def.provider,
112                def.provider_flags,
113                def.shell,
114                def.environment,
115                def.agent_type,
116                def.agent_bus_id,
117                def.accounts,
118                def.auto_start,
119                def.restart_on_crash,
120                def.idle_timeout_minutes,
121                def.slug,
122                def.branch_label,
123                def.working_directory,
124                def.created_at,
125                def.updated_at,
126                def.is_seeded,
127                def.user_hidden,
128                def.container_image,
129                def.container_volumes,
130                def.container_name,
131            ],
132        )?;
133        Ok(())
134    }
135
136    /// Mirror a `db_agent_definitions` DELETE into `db_agents`. The
137    /// definition row itself is removed; any user-cloned children (rows
138    /// with `parent_template_id = old_id`) are left intact because the
139    /// FK cascade on the OLD schema only deletes instances, not other
140    /// definitions.
141    pub(crate) fn agents_dual_write_definition_delete(
142        &self,
143        def_id: &str,
144    ) -> Result<(), StoreError> {
145        let conn = self.conn.lock().unwrap();
146        // Reagent P2 on #1013 round 3: `id` is the PK so two DELETE
147        // statements scoped by `id = ?1 AND is_template = N` add nothing
148        // over a single PK delete (only one row can match either, and
149        // an early return on the first error would skip the second
150        // cleanup unnecessarily). Collapsed to a single direct PK
151        // delete that handles both template and user-clone projections.
152        conn.execute("DELETE FROM db_agents WHERE id = ?1", params![def_id])?;
153        Ok(())
154    }
155
156    /// Bulk dual-write: mirror `agent_def_delete_seeded`. Deletes:
157    ///   1. every `is_template = 1` row (the template projections), AND
158    ///   2. every `db_agents` row whose `id` is in the
159    ///      `cascaded_inst_ids` set (template-instance projections that
160    ///      were just removed by the FK cascade on `db_agent_instances`).
161    ///
162    /// User-clone DEFINITION projections (`is_template = 0`, `id` is a
163    /// def_id in `db_agent_definitions`) are NOT touched — those rows
164    /// represent persistent user agents and live or die with their
165    /// `db_agent_definitions` row, not with the seeded-template bulk
166    /// delete. Reagent P1 round 4 on #1013: the previous version
167    /// scoped by `parent_template_id` and over-deleted user-clone DEF
168    /// projections too. Idempotent.
169    pub(crate) fn agents_dual_write_seeded_delete(
170        &self,
171        cascaded_inst_ids: &[String],
172    ) -> Result<(), StoreError> {
173        let conn = self.conn.lock().unwrap();
174        conn.execute("DELETE FROM db_agents WHERE is_template = 1", [])?;
175        // Delete cascaded instance projections one by one. Could batch
176        // via `id IN (?1, ?2, ...)` but the seeded delete is rare and
177        // the typical set is small (< 100); per-id loop keeps the SQL
178        // simple and avoids dynamic-parameter expansion.
179        for inst_id in cascaded_inst_ids {
180            conn.execute(
181                "DELETE FROM db_agents WHERE id = ?1 AND is_template = 0",
182                params![inst_id],
183            )?;
184        }
185        Ok(())
186    }
187
188    /// Mirror a `db_agent_instances` INSERT into `db_agents`. Always
189    /// creates a NEW row in `db_agents` whose id == instance id.
190    ///
191    /// The row's identity comes from the instance (id, name, bindings).
192    /// Its template-config fields are copied from the parent definition;
193    /// `parent_template_id` points at that definition.
194    ///
195    /// Continuations (`parent_instance_id` non-empty) mirror their
196    /// new bindings into the chain's existing `db_agents` row rather
197    /// than creating a separate one. The chain root is resolved via
198    /// `agents_projection_key_for_inst`. Codex P2 on PR #1110 — without
199    /// this, a named-agent rebind (continue with different identity,
200    /// memory, cwd, or github context) never reaches the consolidated
201    /// row and Phase 3b readers see stale data.
202    pub(crate) fn agents_dual_write_instance_create(
203        &self,
204        inst: &AgentInstance,
205    ) -> Result<(), StoreError> {
206        let is_continuation = !inst.parent_instance_id.is_empty();
207        let conn = self.conn.lock().unwrap();
208        // Pull the parent definition for the cmd-config fields.
209        let def = match Self::load_definition_for_dual_write(&conn, &inst.definition_id)? {
210            Some(d) => d,
211            None => {
212                // Orphan instance — no matching definition. The legacy
213                // table accepted the row (no FK in the old test stores),
214                // but with nothing to project there's nothing this
215                // helper can mirror. Surfacing this as Err would block
216                // a write the legacy path tolerated; log at error level
217                // and continue with no projection.
218                tracing::error!(
219                    instance_id = %inst.id,
220                    definition_id = %inst.definition_id,
221                    "db_agents dual-write: instance has no matching definition; skipping mirror",
222                );
223                return Ok(());
224            }
225        };
226        let name = if inst.instance_name.is_empty() {
227            def.name.clone()
228        } else {
229            inst.instance_name.clone()
230        };
231        // Reagent P1 on #1013 round 2: match the backfill rule
232        // (`agents_consolidate.rs::backfill_instances`) exactly. When the
233        // parent def is a user-clone (`is_seeded = 0`), the existing
234        // `db_agents` row keyed by `def.id` already represents this
235        // agent — FOLD the instance's bindings into it instead of
236        // inserting a new row keyed by `inst.id` with
237        // `parent_template_id = def.id` (which would point at a
238        // non-template row and produce a shape inconsistent with what
239        // backfill produced for identical data).
240        // Monotonic-bump helper. See the original comment below for
241        // the full reasoning; extracted into a closure so both the
242        // user-clone path and the new continuation-mirror path can
243        // reuse it. Successive fast-fire launches (e.g. test loops on
244        // Windows millisecond-resolution clocks) need the strict
245        // global ordering to survive ORDER BY updated_at.
246        //
247        // Reagent P2 round 3 on #1013: don't write `inst.created_at`
248        // here — the user-clone def may already have a fresher
249        // `updated_at` from a prior `agent_def_update`. Wall-clock
250        // now() is the right monotonic stamp.
251        let now_ms = {
252            let wall_now: i64 = std::time::SystemTime::now()
253                .duration_since(std::time::UNIX_EPOCH)
254                .map(|d| d.as_millis() as i64)
255                .unwrap_or(inst.created_at);
256            let global_prior: i64 = conn
257                .query_row(
258                    "SELECT COALESCE(MAX(updated_at), 0) FROM db_agents",
259                    [],
260                    |row| row.get::<_, i64>(0),
261                )
262                .unwrap_or(0);
263            std::cmp::max(wall_now, global_prior.saturating_add(1))
264        };
265
266        let res = if def.is_seeded == 0 {
267            // User-clone def — one db_agents row per def, keyed by
268            // def.id. Continuations and head launches both UPDATE the
269            // same row. (The chain's identity has nothing to do with
270            // which row gets touched here; it's always def.id.)
271            conn.execute(
272                "UPDATE db_agents SET
273                    name = ?2,
274                    identity_id = ?3,
275                    memory_id = ?4,
276                    working_directory = ?5,
277                    github_context = ?6,
278                    instance_name = ?7,
279                    updated_at = ?8,
280                    user_hidden = ?9,
281                    last_block_id = ?10
282                 WHERE id = ?1",
283                params![
284                    def.id,
285                    name,
286                    inst.identity_id,
287                    inst.memory_id,
288                    def.working_directory,
289                    inst.github_context,
290                    inst.instance_name,
291                    now_ms,
292                    if inst.display_hidden { 1_i64 } else { 0_i64 },
293                    inst.block_id,
294                ],
295            )
296        } else if is_continuation {
297            // Template-instance continuation — UPDATE the chain head's
298            // row with the new bindings. The head's id is found via
299            // `agents_projection_key_for_inst` (which walks parent
300            // _instance_id up to the root). No new row is created;
301            // there's exactly one db_agents row per logical agent.
302            // Codex P2 on PR #1110.
303            let root_id = match Self::agents_projection_key_for_inst(&conn, &inst.id) {
304                Some((k, _)) => k,
305                None => {
306                    tracing::warn!(
307                        instance_id = %inst.id,
308                        parent_instance_id = %inst.parent_instance_id,
309                        "db_agents dual-write: continuation chain has no resolvable root; skipping mirror",
310                    );
311                    return Ok(());
312                }
313            };
314            conn.execute(
315                "UPDATE db_agents SET
316                    name = ?2,
317                    identity_id = ?3,
318                    memory_id = ?4,
319                    working_directory = ?5,
320                    github_context = ?6,
321                    instance_name = ?7,
322                    updated_at = ?8,
323                    user_hidden = ?9,
324                    last_block_id = ?10
325                 WHERE id = ?1 AND is_template = 0",
326                params![
327                    root_id,
328                    name,
329                    inst.identity_id,
330                    inst.memory_id,
331                    def.working_directory,
332                    inst.github_context,
333                    inst.instance_name,
334                    now_ms,
335                    if inst.display_hidden { 1_i64 } else { 0_i64 },
336                    inst.block_id,
337                ],
338            )
339        } else {
340            // Template-instance head — INSERT a new row keyed by inst.id.
341            conn.execute(
342                "INSERT INTO db_agents (
343                    id, name, icon, description,
344                    is_template, parent_template_id,
345                    provider, provider_flags, shell, environment,
346                    agent_type, agent_bus_id, accounts,
347                    auto_start, restart_on_crash, idle_timeout_minutes,
348                    slug, branch_label,
349                    identity_id, memory_id, working_directory, github_context,
350                    instance_name,
351                    created_at, updated_at, is_seeded, user_hidden,
352                    last_block_id,
353                    container_image, container_volumes, container_name
354                 ) VALUES (
355                    ?1, ?2, ?3, ?4,
356                    0, ?5,
357                    ?6, ?7, ?8, ?9,
358                    ?10, ?11, ?12,
359                    ?13, ?14, ?15,
360                    ?16, ?17,
361                    ?18, ?19, ?20, ?21,
362                    ?22,
363                    ?23, ?24, 0, ?25,
364                    ?26,
365                    ?27, ?28, ?29
366                 )
367                 ON CONFLICT(id) DO UPDATE SET
368                    name = excluded.name,
369                    identity_id = excluded.identity_id,
370                    memory_id = excluded.memory_id,
371                    working_directory = excluded.working_directory,
372                    github_context = excluded.github_context,
373                    instance_name = excluded.instance_name,
374                    updated_at = excluded.updated_at,
375                    user_hidden = excluded.user_hidden,
376                    last_block_id = excluded.last_block_id",
377                params![
378                    inst.id,
379                    name,
380                    def.icon,
381                    def.description,
382                    def.id, // parent_template_id = definition_id
383                    def.provider,
384                    def.provider_flags,
385                    def.shell,
386                    def.environment,
387                    def.agent_type,
388                    def.agent_bus_id,
389                    def.accounts,
390                    def.auto_start,
391                    def.restart_on_crash,
392                    def.idle_timeout_minutes,
393                    def.slug,
394                    def.branch_label,
395                    inst.identity_id,
396                    inst.memory_id,
397                    def.working_directory,
398                    inst.github_context,
399                    inst.instance_name,
400                    inst.created_at,
401                    inst.created_at, // updated_at = created_at on insert
402                    if inst.display_hidden { 1_i64 } else { 0_i64 },
403                    inst.block_id,
404                    def.container_image,
405                    def.container_volumes,
406                    def.container_name,
407                ],
408            )
409        };
410        res?;
411        Ok(())
412    }
413
414    /// Mirror a `db_agent_instances` UPDATE into `db_agents`. Touches
415    /// only the fields that `instance_update` writes (block + session +
416    /// status + github_context + ended_at) — name/bindings come from
417    /// the original create.
418    ///
419    /// Continuations flow through here too — `agents_projection_key_for_inst`
420    /// walks up the chain to the head's id, so a continuation's
421    /// `github_context` refresh lands on the canonical row (codex P2 on
422    /// PR #1110, paired with the create-path fix in
423    /// `agents_dual_write_instance_create`).
424    pub(crate) fn agents_dual_write_instance_update(
425        &self,
426        inst: &AgentInstance,
427    ) -> Result<(), StoreError> {
428        let conn = self.conn.lock().unwrap();
429        let now = std::time::SystemTime::now()
430            .duration_since(std::time::UNIX_EPOCH)
431            .unwrap_or_default()
432            .as_millis() as i64;
433        // Reagent P1 round 4 on #1013: route by projection key so the
434        // UPDATE actually hits the folded user-clone-def row when this
435        // instance was folded at create. The previous version keyed by
436        // `inst.id` always and silently no-op'd on every folded
437        // instance's lifecycle event.
438        let key = match Self::agents_projection_key_for_inst(&conn, &inst.id) {
439            Some((k, _)) => k,
440            None => return Ok(()),
441        };
442        // `instance_update` only touches block_id/session_id/status/
443        // github_context/ended_at. Of those, only `github_context` lands
444        // on db_agents (block/session/status/ended_at are not modelled
445        // on the consolidated row — they're block/session-machine
446        // concerns the consolidation deliberately drops). We DO refresh
447        // updated_at so a Phase-3b reader can sort by recency. Apply
448        // the same monotonic-floor trick as the fold branch in
449        // `agents_dual_write_instance_create` — wall clock alone
450        // collides under millisecond resolution on fast successive
451        // mutations.
452        let global_prior: i64 = conn
453            .query_row(
454                "SELECT COALESCE(MAX(updated_at), 0) FROM db_agents",
455                [],
456                |row| row.get::<_, i64>(0),
457            )
458            .unwrap_or(0);
459        let now_monotonic = std::cmp::max(now, global_prior.saturating_add(1));
460        conn.execute(
461            "UPDATE db_agents SET
462                github_context = ?1,
463                updated_at = ?2
464             WHERE id = ?3 AND is_template = 0",
465            params![inst.github_context, now_monotonic, key],
466        )?;
467        Ok(())
468    }
469
470    /// Mirror `instance_set_hidden` into `db_agents.user_hidden`.
471    pub(crate) fn agents_dual_write_instance_set_hidden(
472        &self,
473        id: &str,
474        hidden: bool,
475    ) -> Result<(), StoreError> {
476        let conn = self.conn.lock().unwrap();
477        // Reagent P1 round 4 on #1013: route by projection key (see
478        // `agents_dual_write_instance_update` for context).
479        let key = match Self::agents_projection_key_for_inst(&conn, id) {
480            Some((k, _)) => k,
481            None => return Ok(()),
482        };
483        conn.execute(
484            "UPDATE db_agents SET user_hidden = ?1 WHERE id = ?2 AND is_template = 0",
485            params![if hidden { 1_i64 } else { 0_i64 }, key],
486        )?;
487        Ok(())
488    }
489
490    /// Mirror `instance_repoint_definition` into `db_agents`. The
491    /// `parent_template_id` of every user-clone row that pointed at
492    /// `old_def_id` is updated to `new_def_id`.
493    pub(crate) fn agents_dual_write_instance_repoint(
494        &self,
495        old_def_id: &str,
496        new_def_id: &str,
497    ) -> Result<(), StoreError> {
498        let conn = self.conn.lock().unwrap();
499        conn.execute(
500            // Reagent P1 round 3 on #1013: `instance_repoint_definition`
501            // only rewrites `db_agent_instances.definition_id` — it
502            // does NOT rewrite the parent of a sibling user-cloned
503            // definition that happens to share the same parent template.
504            // Restrict the projection update to rows whose `id` is an
505            // ACTUALLY repointed instance id (i.e. the rows whose
506            // `db_agent_instances.definition_id` was just rewritten to
507            // `new_def_id`). User-clone definition projections, whose
508            // `id` lives in `db_agent_definitions` not `db_agent_instances`,
509            // are untouched.
510            "UPDATE db_agents
511             SET parent_template_id = ?1
512             WHERE is_template = 0
513               AND parent_template_id = ?2
514               AND id IN (SELECT id FROM db_agent_instances WHERE definition_id = ?1)",
515            params![new_def_id, old_def_id],
516        )?;
517        Ok(())
518    }
519
520    /// Mirror `instance_delete` into `db_agents`.
521    pub(crate) fn agents_dual_write_instance_delete(&self, id: &str) -> Result<(), StoreError> {
522        let conn = self.conn.lock().unwrap();
523        // Reagent P1 round 4 on #1013: route by projection key. For a
524        // template-instance projection (`is_folded = false`), the row
525        // lives at `id` and goes when the instance goes. For a
526        // user-clone-def fold (`is_folded = true`), the projection at
527        // `def.id` represents the DEF — the def persists when its
528        // instance ends, so NO-OP. `agents_dual_write_definition_delete`
529        // is the right entry point to remove that row.
530        //
531        // Race fallback: `instance_delete` runs the DELETE on
532        // `db_agent_instances` BEFORE calling this helper, so by now
533        // the instance row is gone and `agents_projection_key_for_inst`
534        // returns None. Fall back to checking `db_agents` directly: if
535        // there's a row at `id` with `is_template = 0`, that's a
536        // non-folded projection — delete it. (Folded projections live
537        // at `def.id`, never at `inst.id`, so this is safe.)
538        let key_info = Self::agents_projection_key_for_inst(&conn, id);
539        let (key, is_folded) = match key_info {
540            Some(info) => info,
541            None => (id.to_string(), false),
542        };
543        if is_folded {
544            return Ok(());
545        }
546        conn.execute(
547            "DELETE FROM db_agents WHERE id = ?1 AND is_template = 0",
548            params![key],
549        )?;
550        Ok(())
551    }
552
553    /// Mirror `instance_backfill_identity_id` into `db_agents`. Same
554    /// filter (empty or `"blank"` identity_id) restricted to user-clone
555    /// rows.
556    pub(crate) fn agents_dual_write_backfill_identity(
557        &self,
558        new_identity_id: &str,
559    ) -> Result<(), StoreError> {
560        let conn = self.conn.lock().unwrap();
561        conn.execute(
562            "UPDATE db_agents
563             SET identity_id = ?1
564             WHERE is_template = 0 AND (identity_id = '' OR identity_id = 'blank')",
565            params![new_identity_id],
566        )?;
567        Ok(())
568    }
569
570    /// Reagent P1 round 4 on #1013 — fold-aware projection key lookup.
571    /// Resolves "for an instance with this id, which `db_agents` row
572    /// represents it post-create?". Returns `Some((key, is_folded))`:
573    ///   - `is_folded = true`  → key is the parent definition id
574    ///                            (the user-clone-def projection absorbs
575    ///                            the instance's bindings).
576    ///   - `is_folded = false` → key is the instance id (template-instance
577    ///                            projection is its own row).
578    /// Returns `None` if the instance no longer exists in
579    /// `db_agent_instances` (e.g. already deleted by FK cascade).
580    fn agents_projection_key_for_inst(
581        conn: &Connection,
582        inst_id: &str,
583    ) -> Option<(String, bool)> {
584        // Codex P2 on PR #1110: continuations of template-instances
585        // must resolve to the chain HEAD's id, not the continuation's
586        // own id — there's exactly one db_agents row per logical
587        // agent, keyed by the head. Walk up `parent_instance_id` via
588        // a recursive CTE; the row whose parent is `''` (or whose
589        // parent no longer exists) is the root.
590        //
591        // For user-clone defs (`is_seeded = 0`) the projection key is
592        // the def.id regardless of where in the chain we are — the
593        // existing one-row-per-def behavior is unchanged. Only the
594        // is_seeded=1 (template) branch needs the chain walk.
595        conn.query_row(
596            "WITH RECURSIVE chain(id, parent_instance_id, definition_id) AS (
597                SELECT id, parent_instance_id, definition_id
598                FROM db_agent_instances WHERE id = ?1
599                UNION ALL
600                SELECT p.id, p.parent_instance_id, p.definition_id
601                FROM db_agent_instances p
602                JOIN chain c ON p.id = c.parent_instance_id
603             )
604             SELECT c.id, c.definition_id, COALESCE(d.is_seeded, 1) AS is_seeded
605             FROM chain c
606             LEFT JOIN db_agent_definitions d ON c.definition_id = d.id
607             WHERE c.parent_instance_id = ''
608                OR NOT EXISTS (
609                    SELECT 1 FROM db_agent_instances q
610                    WHERE q.id = c.parent_instance_id
611                )
612             LIMIT 1",
613            params![inst_id],
614            |row| {
615                let root_id: String = row.get(0)?;
616                let def_id: String = row.get(1)?;
617                let is_seeded: i64 = row.get(2)?;
618                Ok(if is_seeded == 0 {
619                    (def_id, true) // folded into def-projection
620                } else {
621                    (root_id, false) // chain head's row
622                })
623            },
624        )
625        .ok()
626    }
627
628    /// Helper: re-read a definition row from inside an active connection
629    /// lock (used by `agents_dual_write_instance_create` to avoid
630    /// re-locking the mutex recursively).
631    fn load_definition_for_dual_write(
632        conn: &Connection,
633        id: &str,
634    ) -> rusqlite::Result<Option<AgentDefinition>> {
635        let mut stmt = conn.prepare(
636            "SELECT id, slug, name, icon, provider, description,
637                    working_directory, shell, provider_flags, auto_start,
638                    restart_on_crash, idle_timeout_minutes, created_at,
639                    agent_type, environment, agent_bus_id, is_seeded,
640                    accounts, parent_id, branch_label, updated_at,
641                    user_hidden, container_image, container_volumes, container_name
642             FROM db_agent_definitions WHERE id = ?1",
643        )?;
644        let result = stmt.query_row(params![id], |row| {
645            Ok(AgentDefinition {
646                id: row.get(0)?,
647                slug: row.get(1)?,
648                name: row.get(2)?,
649                icon: row.get(3)?,
650                provider: row.get(4)?,
651                description: row.get(5)?,
652                working_directory: row.get(6)?,
653                shell: row.get(7)?,
654                provider_flags: row.get(8)?,
655                auto_start: row.get(9)?,
656                restart_on_crash: row.get(10)?,
657                idle_timeout_minutes: row.get(11)?,
658                created_at: row.get(12)?,
659                agent_type: row.get(13)?,
660                environment: row.get(14)?,
661                agent_bus_id: row.get(15)?,
662                is_seeded: row.get(16)?,
663                accounts: row.get(17)?,
664                parent_id: row.get(18)?,
665                branch_label: row.get(19)?,
666                updated_at: row.get(20)?,
667                user_hidden: row.get(21)?,
668                container_image: row.get(22)?,
669                container_volumes: row.get(23)?,
670                container_name: row.get(24)?,
671            })
672        });
673        match result {
674            Ok(d) => Ok(Some(d)),
675            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
676            Err(e) => Err(e),
677        }
678    }
679}