Skip to main content

agentmux_srv/backend/storage/agents/
instance.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `instance_*`: per-launch instance rows, named-agent continuation, and
5//! identity-bound active-for-block resolution, on `db_agents`.
6
7use super::*;
8
9impl Store {
10
11    // ---- Agent instance CRUD ----
12    //
13    // Agent-concept consolidation Phase 3b, PR 2 of 4
14    // (SPEC_AGENT_ARCHITECTURE_2026_05_27.md): every method in this block
15    // reads and writes `db_agents` only. `db_agent_instances` is no longer
16    // touched by the live instance API; it stays on disk until PR 4 drops it.
17    //
18    // What an "instance" is now: the launch-side view of a non-template
19    // `db_agents` row. One row per agent, so:
20    //   - `id` / `definition_id` are both the row's `id` — the agent's
21    //     identity (the rule `instance_list` adopted in PR #1111).
22    //   - `block_id`/`session_id`/`status`/`started_at`/`ended_at` are the
23    //     row's LATEST launch state (schema v29); a continuation moves them
24    //     to the new launch instead of adding a row, so `parent_instance_id`
25    //     is always empty and chains are pre-collapsed.
26    //   - Launching a TEMPLATE creates a new user-agent row keyed by the
27    //     launch's instance id (`parent_template_id` = the template);
28    //     launching a USER agent folds into its own row.
29    //   - `display_hidden` is `user_hidden` (one flag, one row).
30
31    /// List agents as instances. Both filters are optional — pass `None`
32    /// to scan all. Ordered by `updated_at` descending, `created_at` as a
33    /// tiebreaker (most recent activity first — every launch, continuation
34    /// and lifecycle write bumps `updated_at`).
35    ///
36    /// The `definition_id` filter matches the agent's own `id` only:
37    /// templates are not agents and user agents derived from one template
38    /// are separate agents (see `instance_list_named` for the one caller
39    /// that wants "everything derived from this template"). The `status`
40    /// filter reads the row's latest launch status (v29).
41    pub fn instance_list(
42        &self,
43        definition_id: Option<&str>,
44        status: Option<&str>,
45    ) -> Result<Vec<AgentInstance>, StoreError> {
46        let conn = self.conn.lock().unwrap();
47        let mut sql = format!(
48            "SELECT {INSTANCE_COLUMNS} FROM db_agents WHERE is_template = 0 AND user_hidden = 0"
49        );
50        let mut param_vals: Vec<String> = Vec::new();
51        if let Some(d) = definition_id {
52            sql.push_str(&format!(" AND id = ?{}", param_vals.len() + 1));
53            param_vals.push(d.to_string());
54        }
55        if let Some(s) = status {
56            sql.push_str(&format!(" AND status = ?{}", param_vals.len() + 1));
57            param_vals.push(s.to_string());
58        }
59        sql.push_str(" ORDER BY updated_at DESC, created_at DESC");
60        let mut stmt = conn.prepare(&sql)?;
61        let iter = stmt.query_map(rusqlite::params_from_iter(param_vals.iter()), map_instance_row)?;
62        let mut out = Vec::new();
63        for r in iter {
64            out.push(r?);
65        }
66        Ok(out)
67    }
68
69    /// One agent by id, as an instance. `None` for a template or an
70    /// unknown id.
71    pub fn instance_get(&self, id: &str) -> Result<Option<AgentInstance>, StoreError> {
72        let conn = self.conn.lock().unwrap();
73        let mut stmt = conn.prepare(&format!(
74            "SELECT {INSTANCE_COLUMNS} FROM db_agents WHERE id = ?1 AND is_template = 0"
75        ))?;
76        match stmt.query_row(params![id], map_instance_row) {
77            Ok(a) => Ok(Some(a)),
78            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
79            Err(e) => Err(e.into()),
80        }
81    }
82
83    /// The Runtime menu choices the user last made for agent `id` (JSON, see
84    /// [`normalize_last_runtime`]), or '' when there are none or the agent is
85    /// unknown or a template.
86    pub fn agent_last_runtime_get(&self, id: &str) -> Result<String, StoreError> {
87        let conn = self.conn.lock().unwrap();
88        match conn.query_row(
89            "SELECT last_runtime FROM db_agents WHERE id = ?1 AND is_template = 0",
90            params![id],
91            |row| row.get::<_, String>(0),
92        ) {
93            Ok(v) => Ok(v),
94            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(String::new()),
95            Err(e) => Err(e.into()),
96        }
97    }
98
99    /// Replace agent `id`'s remembered runtime. Returns false for an unknown
100    /// agent or a template (templates are not agents anyone continues, so
101    /// they remember nothing). Deliberately leaves `updated_at` alone: this is
102    /// a note about how the agent was last run, not an edit of its definition.
103    pub fn agent_last_runtime_set(&self, id: &str, runtime: &str) -> Result<bool, StoreError> {
104        let conn = self.conn.lock().unwrap();
105        let rows = conn.execute(
106            "UPDATE db_agents SET last_runtime = ?2 WHERE id = ?1 AND is_template = 0",
107            params![id, runtime],
108        )?;
109        Ok(rows > 0)
110    }
111
112    /// A single `db_agents` row in the `AgentDefinition` shape, no registry
113    /// overlay — the definition a launch resolves against. Templates and
114    /// user agents alike.
115    pub(in crate::backend::storage) fn agent_row_get(&self, id: &str) -> Result<Option<AgentDefinition>, StoreError> {
116        let conn = self.conn.lock().unwrap();
117        let mut stmt = conn.prepare(&format!("{AGENT_DEFINITION_SELECT} WHERE id = ?1"))?;
118        match stmt.query_row(params![id], map_agent_definition_row) {
119            Ok(d) => Ok(Some(d)),
120            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
121            Err(e) => Err(e.into()),
122        }
123    }
124
125    /// Record a launch. Returns the agent row the launch now lives on, as an
126    /// instance — **its `id` may differ from `inst.id`**: a launch of a user
127    /// agent folds into that agent's row, and a continuation (non-empty
128    /// `parent_instance_id` naming an existing row) folds into the row it
129    /// continues. Only a fresh launch of a TEMPLATE creates a new row, keyed
130    /// by `inst.id`. Callers must persist the returned id, not the one they
131    /// passed (the frontend stores it as the block's `agentInstanceId`).
132    ///
133    /// `inst.definition_id` must name a `db_agents` row; when it only exists
134    /// in the global cross-channel definition registry (an agent created in
135    /// another channel), the local definition is backfilled first, exactly
136    /// as before (`REPORT_AGENT_DEFINITION_DB_GAP_2026_07_27.md`). A
137    /// definition that exists nowhere is `StoreError::NotFound` — the error
138    /// the legacy FK used to raise, minus the FK.
139    pub fn instance_create(&self, inst: &AgentInstance) -> Result<AgentInstance, StoreError> {
140        let def = match self.agent_row_get(&inst.definition_id)? {
141            Some(d) => d,
142            None => {
143                if let Some(reg) = self.shared_def_registry() {
144                    match reg.get(&inst.definition_id) {
145                        Ok(Some(record)) => {
146                            if let Err(e) = self.agent_def_backfill_local_from_registry(&record) {
147                                tracing::warn!(
148                                    definition_id = %inst.definition_id,
149                                    error = %e,
150                                    "instance_create: registry backfill failed"
151                                );
152                            }
153                        }
154                        Ok(None) => {}
155                        Err(e) => {
156                            tracing::warn!(
157                                definition_id = %inst.definition_id,
158                                error = %e,
159                                "instance_create: registry lookup failed"
160                            );
161                        }
162                    }
163                }
164                match self.agent_row_get(&inst.definition_id)? {
165                    Some(d) => d,
166                    None => {
167                        tracing::warn!(
168                            definition_id = %inst.definition_id,
169                            instance_id = %inst.id,
170                            "instance_create: definition exists nowhere; refusing the launch"
171                        );
172                        return Err(StoreError::NotFound);
173                    }
174                }
175            }
176        };
177
178        let display_name = if inst.instance_name.is_empty() { def.name.clone() } else { inst.instance_name.clone() };
179        let hidden = if inst.display_hidden { 1_i64 } else { 0_i64 };
180        // The agent's working directory is the launch's RESOLVED path when
181        // the launch supplies one (`agent.open` resolves it, including slug
182        // collision suffixing), falling back to the definition's configured
183        // cwd. Phase 3a's dual-write always wrote the definition's, on the
184        // reasoning that "db_agents holds durable agent config; the
185        // per-launch resolved cwd lives on the block" — that split ends with
186        // the instance table: one row per agent means the row holds the
187        // agent's real workspace, which is also what the cross-version JSON
188        // registry mirror records (and it silently skips any row whose
189        // working_directory is not under the agents root).
190        let working_directory = if inst.working_directory.is_empty() {
191            def.working_directory.clone()
192        } else {
193            inst.working_directory.clone()
194        };
195        // The row this launch lands on. A user agent IS its row; a template
196        // launch is its own row unless it continues one that already exists.
197        let key = if def.is_seeded == 0 {
198            def.id.clone()
199        } else if !inst.parent_instance_id.is_empty() && self.instance_get(&inst.parent_instance_id)?.is_some() {
200            inst.parent_instance_id.clone()
201        } else {
202            inst.id.clone()
203        };
204        {
205            let conn = self.conn.lock().unwrap();
206            let now_ms = Self::monotonic_updated_at(&conn, inst.created_at);
207            if key == inst.id {
208                // Fresh template launch — its own user-agent row, config
209                // copied from the template, bindings + launch state from the
210                // launch. ON CONFLICT covers an id the caller reuses on a
211                // retry (the App-API stub path).
212                //
213                // The slug is collision-resolved rather than copied verbatim
214                // from the template (#3497 §4). `agent_def_insert` has always
215                // done this; this path did not, making it the one writer that
216                // could mint a duplicate slug into a column with no UNIQUE
217                // constraint. In practice the launch flow names its agent and
218                // goes through `agent_def_insert`, so this branch is reached
219                // only by handing `instance_create` a seeded template id
220                // directly — this closes the function's contract rather than an
221                // observed fault (§2.5.2).
222                let launch_slug = resolve_slug_collision(&conn, &def.slug)?;
223                conn.execute(
224                    "INSERT INTO db_agents (
225                        id, name, icon, description,
226                        is_template, parent_template_id,
227                        provider, provider_flags, shell, environment,
228                        agent_type, agent_bus_id, accounts,
229                        auto_start, restart_on_crash, idle_timeout_minutes,
230                        slug, branch_label,
231                        identity_id, memory_id, working_directory, github_context,
232                        instance_name,
233                        created_at, updated_at, is_seeded, user_hidden,
234                        last_block_id,
235                        container_image, container_volumes, container_name,
236                        use_ambient_login, model_vendor_base_url, auto_continue_enabled,
237                        conversation_visibility,
238                        session_id, status, started_at, ended_at
239                     ) VALUES (
240                        ?1, ?2, ?3, ?4,
241                        0, ?5,
242                        ?6, ?7, ?8, ?9,
243                        ?10, ?11, ?12,
244                        ?13, ?14, ?15,
245                        ?16, ?17,
246                        ?18, ?19, ?20, ?21,
247                        ?22,
248                        ?23, ?24, 0, ?25,
249                        ?26,
250                        ?27, ?28, ?29,
251                        ?30, ?31, ?32,
252                        ?33,
253                        ?34, ?35, ?36, ?37
254                     )
255                     ON CONFLICT(id) DO UPDATE SET
256                        name = excluded.name,
257                        identity_id = excluded.identity_id,
258                        memory_id = excluded.memory_id,
259                        working_directory = excluded.working_directory,
260                        github_context = excluded.github_context,
261                        instance_name = excluded.instance_name,
262                        updated_at = excluded.updated_at,
263                        user_hidden = excluded.user_hidden,
264                        last_block_id = CASE WHEN excluded.last_block_id = '' THEN db_agents.last_block_id ELSE excluded.last_block_id END,
265                        session_id = CASE WHEN excluded.session_id = '' THEN db_agents.session_id ELSE excluded.session_id END,
266                        status = excluded.status,
267                        started_at = excluded.started_at,
268                        ended_at = excluded.ended_at",
269                    params![
270                        inst.id,
271                        display_name,
272                        def.icon,
273                        def.description,
274                        def.id,
275                        def.provider,
276                        def.provider_flags,
277                        def.shell,
278                        def.environment,
279                        def.agent_type,
280                        def.agent_bus_id,
281                        def.accounts,
282                        def.auto_start,
283                        def.restart_on_crash,
284                        def.idle_timeout_minutes,
285                        launch_slug,
286                        def.branch_label,
287                        inst.identity_id,
288                        inst.memory_id,
289                        working_directory,
290                        inst.github_context,
291                        inst.instance_name,
292                        inst.created_at,
293                        now_ms,
294                        hidden,
295                        inst.block_id,
296                        def.container_image,
297                        def.container_volumes,
298                        def.container_name,
299                        def.use_ambient_login,
300                        def.model_vendor_base_url,
301                        def.auto_continue_enabled,
302                        def.conversation_visibility,
303                        inst.session_id,
304                        inst.status,
305                        inst.started_at,
306                        inst.ended_at,
307                    ],
308                )?;
309            } else {
310                // Fold: the launch (or continuation) moves the row's bindings
311                // and launch state to this launch. Config stays the row's own.
312                //
313                // An EMPTY `session_id` never clears the row's: a fresh
314                // continuation is created session-less and the CLI only
315                // reports the real session id later, so overwriting here
316                // would drop the pointer `--resume` needs (the same reason
317                // `last_block_id` is guarded). A deliberate clear goes
318                // through `instance_update_partial(Some(""))`, which does
319                // write it — see `poison_resume`.
320                conn.execute(
321                    "UPDATE db_agents SET
322                        name = ?2,
323                        identity_id = ?3,
324                        memory_id = ?4,
325                        working_directory = ?14,
326                        github_context = ?5,
327                        instance_name = ?6,
328                        updated_at = ?7,
329                        user_hidden = ?8,
330                        last_block_id = CASE WHEN ?9 = '' THEN last_block_id ELSE ?9 END,
331                        session_id = CASE WHEN ?10 = '' THEN session_id ELSE ?10 END,
332                        status = ?11,
333                        started_at = ?12,
334                        ended_at = ?13
335                     WHERE id = ?1 AND is_template = 0",
336                    params![
337                        key,
338                        display_name,
339                        inst.identity_id,
340                        inst.memory_id,
341                        inst.github_context,
342                        inst.instance_name,
343                        now_ms,
344                        hidden,
345                        inst.block_id,
346                        inst.session_id,
347                        inst.status,
348                        inst.started_at,
349                        inst.ended_at,
350                        working_directory,
351                    ],
352                )?;
353            }
354        }
355        let canonical = self.instance_get(&key)?.ok_or(StoreError::NotFound)?;
356        self.registry_upsert_if_named(&canonical, !inst.session_id.is_empty());
357        // A freshly created launch's session_id is never a genuine capture
358        // in production (continuations start with ""), so only a non-empty
359        // one is worth propagating to the registry.
360        if !inst.session_id.is_empty() {
361            self.registry_propagate_continuation_session_id(&canonical);
362        }
363        Ok(canonical)
364    }
365
366    /// An `updated_at` strictly greater than any already on `db_agents`, so
367    /// recency ordering survives fast successive writes within one
368    /// millisecond (reagent P2 round 3 on #1013 — the rule the dual-write
369    /// used; inherited here).
370    fn monotonic_updated_at(conn: &rusqlite::Connection, floor: i64) -> i64 {
371        let wall_now: i64 = std::time::SystemTime::now()
372            .duration_since(std::time::UNIX_EPOCH)
373            .map(|d| d.as_millis() as i64)
374            .unwrap_or(floor);
375        let global_prior: i64 = conn
376            .query_row("SELECT COALESCE(MAX(updated_at), 0) FROM db_agents", [], |row| row.get::<_, i64>(0))
377            .unwrap_or(0);
378        std::cmp::max(wall_now, global_prior.saturating_add(1))
379    }
380
381    /// Set the hidden flag on an agent row. Used by the "Forget agent"
382    /// affordance — soft-delete only; the row + working directory remain on
383    /// disk for audit + recovery.
384    ///
385    /// Cross-version case: an agent migrated into the registry from another
386    /// version's SQLite won't have a row in the current version's SQLite.
387    /// The UPDATE returns 0 rows, but the registry still needs to flip —
388    /// otherwise "Forget agent" silently no-ops for those. Returns `true`
389    /// when either side acted.
390    pub fn instance_set_hidden(&self, id: &str, hidden: bool) -> Result<bool, StoreError> {
391        // Read in the same critical section as the UPDATE, so the row can't
392        // change kind underneath the registry call below. See
393        // `scope_for_row`: retiring a TEMPLATE with the wide scope
394        // would hide every real agent launched from it (ReAgent P1 round 4
395        // on PR #3262).
396        let (rows, scope) = {
397            let conn = self.conn.lock().unwrap();
398            let scope = Self::scope_for_row(&conn, id);
399            let rows = conn.execute(
400                "UPDATE db_agents SET user_hidden = ?1 WHERE id = ?2 AND is_template = 0",
401                params![if hidden { 1_i64 } else { 0_i64 }, id],
402            )?;
403            (rows, scope)
404        };
405        let mut registry_acted = false;
406        if let Some(reg) = self.registry() {
407            // Matches by file key OR the record's own `definition_id`.
408            // Retiring by file key alone left any launch-keyed record for
409            // this agent active — literally the "'Forget agent' retires the
410            // NEW key while the stale file stays active, so the forgotten
411            // agent reappears" failure `m0026_registry_agent_id_rekey`'s own
412            // doc comment describes, still reachable on an install where
413            // that migration no-ops (see
414            // `Registry::hard_delete_for_agent`). A count of 0 is a genuine
415            // no-op for an unrelated id, which is what the old
416            // `exists_anywhere` pre-check was guarding against.
417            let res = if hidden {
418                reg.retire_for_agent(id, scope)
419            } else {
420                reg.unretire_for_agent(id, scope)
421            };
422            match res {
423                Ok(moved) => registry_acted = moved > 0,
424                Err(e) => tracing::warn!(
425                    instance_id = %id,
426                    hidden,
427                    error = %e,
428                    "registry: failed to mirror instance_set_hidden"
429                ),
430            }
431        }
432        Ok(rows > 0 || registry_acted)
433    }
434
435    /// Rename the agent's PICKER-VISIBLE display name (`instance_name`),
436    /// in SQLite and across every registry record for it.
437    ///
438    /// `renameagentdefinitiontitle` on its own writes `name` (or
439    /// `branch_label` for a fork) — the fields the pane tab strip's
440    /// `titleOf()` reads. "My Agents" rows render
441    /// `instance_name || definition_name` instead, and `instance_name` is
442    /// non-empty for every launched or registry-backed row, so a rename
443    /// closed the editor, refetched, and redisplayed the OLD name; for a
444    /// fork it changed `branch_label`, which no picker row shows at all
445    /// (codex P1 on PR #3262). Renaming an agent has to move the name the
446    /// user is actually looking at, so the handler calls this too.
447    ///
448    /// Scoped to rows that ALREADY have an `instance_name`: writing one
449    /// onto an agent that has never had a named launch would make it appear
450    /// in `instance_list_named` (which filters `instance_name <> ''`) —
451    /// conjuring a picker row rather than renaming one. Those rows fall
452    /// back to `definition_name`, which the title update already moved.
453    ///
454    /// Returns whether anything changed, counting the registry: a
455    /// cross-channel agent has no local row to update but does have records.
456    pub fn instance_rename(&self, id: &str, instance_name: &str) -> Result<bool, StoreError> {
457        // Same critical-section read as `instance_set_hidden`, for the same
458        // reason: renaming a TEMPLATE with the wide scope would rewrite
459        // `instance_name` on every real agent launched from it (ReAgent P1
460        // round 4 on PR #3262).
461        let (rows, scope) = {
462            let conn = self.conn.lock().unwrap();
463            let scope = Self::scope_for_row(&conn, id);
464            let now_ms = Self::monotonic_updated_at(&conn, 0);
465            let rows = conn.execute(
466                "UPDATE db_agents SET instance_name = ?1, updated_at = ?2
467                 WHERE id = ?3 AND is_template = 0 AND instance_name <> ''",
468                params![instance_name, now_ms, id],
469            )?;
470            (rows, scope)
471        };
472        let mut registry_renamed = 0usize;
473        if let Some(reg) = self.registry() {
474            match reg.set_instance_name_for_agent(id, scope, instance_name) {
475                Ok(n) => registry_renamed = n,
476                Err(e) => tracing::warn!(
477                    agent_id = %id, error = %e,
478                    "registry: failed to mirror instance_rename — picker may keep showing the old name"
479                ),
480            }
481        }
482        Ok(rows > 0 || registry_renamed > 0)
483    }
484
485    /// Named agents for the launch modal's "Continue agent" dropdown and the
486    /// picker's "My Agents" surface: non-hidden rows with an `instance_name`,
487    /// newest launch first, capped by `limit`.
488    ///
489    /// `definition_id` restricts the result to that agent itself OR every
490    /// agent derived from it (`parent_template_id`) — the launch modal opens
491    /// per template, and what a user wants to continue there is any agent
492    /// they launched from it. `identity_id` filters on the row's identity
493    /// binding.
494    ///
495    /// `include_continuations` is accepted for API compatibility but no
496    /// longer changes the query: chains are pre-collapsed (one row per
497    /// agent carrying its latest launch), so the legacy "heads only" and
498    /// "latest per chain" modes both yield exactly this list.
499    pub fn instance_list_named(
500        &self,
501        limit: usize,
502        definition_id: Option<&str>,
503        identity_id: Option<&str>,
504        _include_continuations: bool,
505    ) -> Result<Vec<AgentInstance>, StoreError> {
506        let conn = self.conn.lock().unwrap();
507        let mut sql = format!(
508            "SELECT {INSTANCE_COLUMNS} FROM db_agents
509             WHERE is_template = 0 AND user_hidden = 0 AND instance_name <> ''"
510        );
511        let mut param_vals: Vec<String> = Vec::new();
512        if let Some(id) = identity_id {
513            sql.push_str(&format!(" AND identity_id = ?{}", param_vals.len() + 1));
514            param_vals.push(id.to_string());
515        }
516        if let Some(def) = definition_id {
517            let n = param_vals.len() + 1;
518            sql.push_str(&format!(" AND (id = ?{n} OR parent_template_id = ?{n})"));
519            param_vals.push(def.to_string());
520        }
521        sql.push_str(&format!(
522            " ORDER BY started_at DESC, updated_at DESC, id DESC LIMIT ?{}",
523            param_vals.len() + 1
524        ));
525        param_vals.push(limit.to_string());
526        let mut stmt = conn.prepare(&sql)?;
527        let iter = stmt.query_map(rusqlite::params_from_iter(param_vals.iter()), map_instance_row)?;
528        let mut out = Vec::new();
529        for r in iter {
530            out.push(r?);
531        }
532        Ok(out)
533    }
534
535    /// Force a row's `slug` to an exact value, bypassing collision resolution.
536    ///
537    /// **Tests only, and deliberately awkward to reach for.** Both write paths
538    /// now suffix-resolve (#3497 §4), so a test cannot build two rows sharing a
539    /// slug through the normal API any more — and the read-path guards
540    /// (`instance_get_by_slug`, `agent_resolve::resolve_agent_id`, `check_s1`)
541    /// exist precisely for that state. Without this, those tests still *pass*,
542    /// but vacuously: the lookup returns nothing because nothing matches the
543    /// slug, not because the guard refused an ambiguous one. That is the
544    /// failure mode the guards were written to prevent, reproduced in their own
545    /// tests.
546    ///
547    /// The state is still reachable in production — no `UNIQUE` constraint
548    /// backs the column, so a future writer, a migration, or hand-edited data
549    /// can produce it. Defence in depth: prevent it on write, refuse it on
550    /// read, and test both.
551    /// Make every `db_agents` read fail, to test how a caller treats a store
552    /// fault (as opposed to an empty answer).
553    #[cfg(test)]
554    pub(crate) fn test_break_agents_table(&self) -> Result<(), StoreError> {
555        let conn = self.conn.lock().unwrap();
556        conn.execute_batch("DROP TABLE db_agents")?;
557        Ok(())
558    }
559
560    #[cfg(test)]
561    pub(crate) fn test_force_slug(&self, id: &str, slug: &str) -> Result<(), StoreError> {
562        let conn = self.conn.lock().unwrap();
563        conn.execute("UPDATE db_agents SET slug = ?2 WHERE id = ?1", params![id, slug])?;
564        Ok(())
565    }
566
567    /// Resolve an agent by its persisted `slug` — the App-API self-lookup
568    /// (`AGENTMUX_AGENT_ID` is the slug) — **only if exactly one agent has
569    /// it**. Matches ONLY `slug`; deliberately single-purpose after reagentx
570    /// P1 on PR #2428 (round 2): a lookup that also matched another column
571    /// let a coincidental cross-namespace collision return an unrelated
572    /// agent's memory, accounts or bundle. Hidden rows are excluded.
573    ///
574    /// `None` means "no agent" *or* "more than one agent" — callers already
575    /// treat both as "fall back to another source", so they are not
576    /// distinguished. The fallbacks are themselves fail-closed
577    /// (`agent_registry_lookup::find_active_record_by_slug`), so refusing
578    /// here routes to another refusal rather than to a guess.
579    ///
580    /// **Why ambiguity returns `None` rather than the most recently updated
581    /// row:** `db_agents.slug` has no `UNIQUE` constraint — `migrations.rs`
582    /// declines the index deliberately. Both write paths now suffix-resolve
583    /// (#3497 §4), so neither mints a duplicate, but nothing at the schema
584    /// level prevents one: a future writer, a migration, or hand-edited data
585    /// still can. (An earlier revision of this comment claimed
586    /// `instance_create` copied its template's slug onto every launch, so two
587    /// launches collided. That was never true of the real launch flow — see
588    /// the spec's §2.5.2 — and is no longer true of the function either.) This
589    /// function used to `ORDER BY updated_at DESC LIMIT 1` and hand the
590    /// winner's `definition_id` and `working_directory` to the caller.
591    /// `HistoryService::sessions_for_agent` consults it to resolve an
592    /// agent's *conversation history*, so guessing wrong does not degrade
593    /// gracefully — it discloses one agent's transcripts to another, and
594    /// deterministically: the same wrong agent won every time. A slug simply
595    /// does not identify an agent when it collides, so the honest answer is
596    /// "unknown". Same rule and same reasoning as
597    /// [`crate::backend::agent_registry_lookup::find_active_record_by_slug`]
598    /// (PR #3480), which this mirrors for `db_agents`.
599    pub fn instance_get_by_slug(&self, slug: &str) -> Result<Option<AgentInstance>, StoreError> {
600        if slug.is_empty() {
601            return Ok(None);
602        }
603        let conn = self.conn.lock().unwrap();
604        let mut stmt = conn.prepare(&format!(
605            "SELECT {INSTANCE_COLUMNS} FROM db_agents
606             WHERE slug = ?1 AND is_template = 0 AND user_hidden = 0"
607        ))?;
608        let mut matches = stmt
609            .query_map(params![slug], map_instance_row)?
610            .collect::<Result<Vec<_>, _>>()?;
611        if matches.len() > 1 {
612            tracing::warn!(
613                slug = %slug,
614                matches = matches.len(),
615                "db_agents slug collision: refusing to resolve an agent by slug alone"
616            );
617            return Ok(None);
618        }
619        Ok(matches.pop())
620    }
621
622    /// Every non-template, non-hidden agent a typed name could mean (identity
623    /// M3, spec §5): exact slug, or `name`/`instance_name` case-insensitively.
624    /// Lists rather than picks — the caller decides what to do with several.
625    pub fn agents_matching_name(&self, typed: &str) -> Result<Vec<AgentNameMatch>, StoreError> {
626        let typed = typed.trim();
627        if typed.is_empty() {
628            return Ok(Vec::new());
629        }
630        let conn = self.conn.lock().unwrap();
631        let mut stmt = conn.prepare(
632            "SELECT id, name, slug, instance_name FROM db_agents
633             WHERE is_template = 0 AND user_hidden = 0
634               AND (slug = ?1 OR name = ?1 COLLATE NOCASE OR instance_name = ?1 COLLATE NOCASE)
635             ORDER BY created_at ASC, id ASC",
636        )?;
637        let rows = stmt.query_map(params![typed], |row| {
638            Ok(AgentNameMatch {
639                id: row.get(0)?,
640                name: row.get(1)?,
641                slug: row.get(2)?,
642                instance_name: row.get(3)?,
643            })
644        })?;
645        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
646    }
647
648    /// Every row a name selects, **templates and hidden rows included** — the
649    /// identity M4a-2 ambiguity check (spec §6.5.3). Unlike
650    /// [`Self::agents_matching_name`], which lists only the agents a typed
651    /// name could mean, this asks whether the name is *anyone else's*: slug
652    /// collision suffixing (`resolve_slug_collision`) counts template rows,
653    /// so a "Claude" made from the "Claude" template is `claude-2`, and a name
654    /// its MCP sends as `claude` belongs to the template (#3573).
655    pub fn rows_answering_to(&self, name: &str) -> Result<Vec<AgentNameMatch>, StoreError> {
656        let name = name.trim();
657        if name.is_empty() {
658            return Ok(Vec::new());
659        }
660        let conn = self.conn.lock().unwrap();
661        let mut stmt = conn.prepare(
662            "SELECT id, name, slug, instance_name FROM db_agents
663             WHERE slug = ?1 OR name = ?1 COLLATE NOCASE OR instance_name = ?1 COLLATE NOCASE
664             ORDER BY created_at ASC, id ASC",
665        )?;
666        let rows = stmt.query_map(params![name], |row| {
667            Ok(AgentNameMatch {
668                id: row.get(0)?,
669                name: row.get(1)?,
670                slug: row.get(2)?,
671                instance_name: row.get(3)?,
672            })
673        })?;
674        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
675    }
676
677    /// The names row `id` answers to — the by-id counterpart of
678    /// [`Self::agents_matching_name`], for checking a body's actor name
679    /// against the calling agent's own row (identity M4a-2, spec §6.5.3).
680    /// Any row, template or hidden: the caller's token already names it.
681    pub fn agent_names_by_id(&self, id: &str) -> Result<Option<AgentNameMatch>, StoreError> {
682        let conn = self.conn.lock().unwrap();
683        match conn.query_row(
684            "SELECT id, name, slug, instance_name FROM db_agents WHERE id = ?1",
685            params![id],
686            |row| {
687                Ok(AgentNameMatch {
688                    id: row.get(0)?,
689                    name: row.get(1)?,
690                    slug: row.get(2)?,
691                    instance_name: row.get(3)?,
692                })
693            },
694        ) {
695            Ok(m) => Ok(Some(m)),
696            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
697            Err(e) => Err(e.into()),
698        }
699    }
700
701    /// Whether `id` names an agent that was **deleted**: no local row (template
702    /// or not), and the shared definition registry holds a record for it that
703    /// is retired rather than active — deleting a user agent retires its
704    /// global record (`registry_def_retire`). A template, a removed template
705    /// and a live cross-channel agent all read false: templates are never
706    /// mirrored, and a cross-channel agent's record is active. With no shared
707    /// registry this cannot be told and reads false. For the identity spawn
708    /// gate (#3577).
709    pub fn agent_was_deleted(&self, id: &str) -> bool {
710        let id = id.trim();
711        if id.is_empty() {
712            return false;
713        }
714        match self.agent_row_get(id) {
715            Ok(None) => {}
716            Ok(Some(_)) | Err(_) => return false,
717        }
718        match self.shared_def_registry() {
719            Some(reg) => reg.exists_anywhere(id) && !reg.exists(id),
720            None => false,
721        }
722    }
723
724    /// Ensure a user agent `id` has a local `db_agents` row, backfilling it
725    /// from the shared definition registry when it exists only there (a
726    /// cross-channel agent) — the same backfill `instance_create` runs, without
727    /// its fold. Identity M4b-4 (spec §6.5.8): so a block naming a
728    /// cross-channel agent resolves to a local row at its spawn. Returns
729    /// whether the row exists afterwards.
730    pub fn agent_row_ensure_local(&self, id: &str) -> Result<bool, StoreError> {
731        if self.agent_row_get(id)?.is_some() {
732            return Ok(true);
733        }
734        if let Some(reg) = self.shared_def_registry() {
735            match reg.get(id) {
736                Ok(Some(record)) => {
737                    if let Err(e) = self.agent_def_backfill_local_from_registry(&record) {
738                        tracing::warn!(definition_id = %id, error = %e, "agent_row_ensure_local: registry backfill failed");
739                    }
740                }
741                Ok(None) => {}
742                Err(e) => {
743                    tracing::warn!(definition_id = %id, error = %e, "agent_row_ensure_local: registry lookup failed");
744                }
745            }
746        }
747        Ok(self.agent_row_get(id)?.is_some())
748    }
749
750    /// Record a launch of user agent `id` on `block_id`: a **lifecycle-only**
751    /// update — `last_block_id`, `status = running`, `started_at`,
752    /// `ended_at = 0`, a monotonic `updated_at` and the registry mirror — the
753    /// columns `instance_create`'s fold moves, and none of the ones it would
754    /// overwrite from a caller's record (`name`, `identity_id`, `memory_id`,
755    /// `instance_name`, `github_context`, `user_hidden`). Identity M4b-4 (spec
756    /// §6.5.8), for `agent.open`, which has no such record. Returns false for
757    /// a template or a missing row.
758    pub fn instance_record_launch(
759        &self,
760        id: &str,
761        block_id: &str,
762        started_at: i64,
763    ) -> Result<bool, StoreError> {
764        let rows = {
765            let conn = self.conn.lock().unwrap();
766            let now_ms = Self::monotonic_updated_at(&conn, 0);
767            conn.execute(
768                "UPDATE db_agents
769                 SET last_block_id = ?2, status = 'running', started_at = ?3, ended_at = 0, updated_at = ?4
770                 WHERE id = ?1 AND is_template = 0",
771                params![id, block_id, started_at, now_ms],
772            )?
773        };
774        if rows == 0 {
775            return Ok(false);
776        }
777        if let Some(fresh) = self.instance_get(id)? {
778            self.registry_upsert_if_named(&fresh, false);
779        }
780        Ok(true)
781    }
782
783    /// Partial update of an agent's launch state. Only `Some` fields are
784    /// written; `None` leaves that column untouched. `Some("")` explicitly
785    /// clears (the `updateagentinstance` command contract, and
786    /// `poison_resume`'s deliberate stale-session clear).
787    ///
788    /// Returns the post-update row. `None` is reserved for **not-found**;
789    /// an all-`None` update on an existing id is a no-op that returns the
790    /// unchanged row, so callers can tell "nothing to change" from "no such
791    /// agent". See SPEC_UPDATEAGENTINSTANCE_PARTIAL_UPDATE_2026_05_29.md.
792    pub fn instance_update_partial(
793        &self,
794        id: &str,
795        upd: &InstanceUpdate,
796    ) -> Result<Option<AgentInstance>, StoreError> {
797        use rusqlite::types::ToSql;
798
799        let mut sets: Vec<&str> = Vec::new();
800        let mut vals: Vec<Box<dyn ToSql>> = Vec::new();
801        if let Some(v) = &upd.block_id {
802            sets.push("last_block_id = ?");
803            vals.push(Box::new(v.clone()));
804        }
805        if let Some(v) = &upd.session_id {
806            sets.push("session_id = ?");
807            vals.push(Box::new(v.clone()));
808        }
809        if let Some(v) = &upd.status {
810            sets.push("status = ?");
811            vals.push(Box::new(v.clone()));
812        }
813        if let Some(v) = &upd.github_context {
814            sets.push("github_context = ?");
815            vals.push(Box::new(v.clone()));
816        }
817        if let Some(v) = upd.ended_at {
818            sets.push("ended_at = ?");
819            vals.push(Box::new(v));
820        }
821        if sets.is_empty() {
822            return self.instance_get(id);
823        }
824
825        let rows = {
826            let conn = self.conn.lock().unwrap();
827            let now_ms = Self::monotonic_updated_at(&conn, 0);
828            sets.push("updated_at = ?");
829            vals.push(Box::new(now_ms));
830            let sql = format!("UPDATE db_agents SET {} WHERE id = ? AND is_template = 0", sets.join(", "));
831            vals.push(Box::new(id.to_string()));
832            let params: Vec<&dyn ToSql> = vals.iter().map(|b| b.as_ref()).collect();
833            conn.execute(&sql, params.as_slice())?
834        };
835        if rows == 0 {
836            return Ok(None);
837        }
838        let fresh = self.instance_get(id)?;
839        if let Some(f) = &fresh {
840            self.registry_upsert_if_named(f, upd.session_id.is_some());
841            // Only propagate when THIS call actually targeted session_id —
842            // `Some("")` (a deliberate clear) and `Some(real_sid)` both mean
843            // this call wrote it; `None` means the row's existing value is
844            // not this call's to broadcast. See
845            // registry_propagate_continuation_session_id's doc comment.
846            if upd.session_id.is_some() {
847                self.registry_propagate_continuation_session_id(f);
848            }
849        }
850        Ok(fresh)
851    }
852
853    /// Full-struct update of the mutable launch-state fields (`block_id`,
854    /// `session_id`, `status`, `github_context`, `ended_at`). Retained as a
855    /// convenience for tests + internal callers; the `updateagentinstance`
856    /// handler uses [`Self::instance_update_partial`].
857    #[cfg(test)]
858    pub fn instance_update(&self, inst: &AgentInstance) -> Result<bool, StoreError> {
859        let upd = InstanceUpdate {
860            block_id: Some(inst.block_id.clone()),
861            session_id: Some(inst.session_id.clone()),
862            status: Some(inst.status.clone()),
863            github_context: Some(inst.github_context.clone()),
864            ended_at: Some(inst.ended_at),
865        };
866        Ok(self.instance_update_partial(&inst.id, &upd)?.is_some())
867    }
868
869    /// Repoint every agent derived from `old_def_id` at `new_def_id`. Used by
870    /// the Phase 1 two-tier-picker migration
871    /// (SPEC_AGENT_PICKER_TWO_TIER_2026_05_24.md): when a seeded template has
872    /// been used directly, the migration clones it into a user agent and
873    /// repoints its launches so the existing reattach flow keeps working.
874    /// Returns the number of rows updated.
875    pub fn instance_repoint_definition(&self, old_def_id: &str, new_def_id: &str) -> Result<usize, StoreError> {
876        let conn = self.conn.lock().unwrap();
877        Ok(conn.execute(
878            // `id != ?1` keeps the promote target out of its own result set:
879            // the clone is itself derived from the template, so an unguarded
880            // update would re-parent it to itself and count as a repoint.
881            "UPDATE db_agents SET parent_template_id = ?1
882             WHERE parent_template_id = ?2 AND is_template = 0 AND id != ?1",
883            params![new_def_id, old_def_id],
884        )?)
885    }
886
887    /// Delete an agent row (never a template). The row IS the agent in the
888    /// consolidated model, so this removes the agent — previously a launch
889    /// row of a user-clone definition could be deleted while the definition
890    /// stayed; there is no such split any more. As of the definition flip,
891    /// this and `agent_def_delete` are the same operation reached from two
892    /// names: neither writes `db_agent_definitions`, and nothing reads it
893    /// per-boot any more to resurrect what either one deletes (the startup
894    /// gap-repair that once could is gone — codex P2 on #3080 closed the
895    /// resurrection path this way rather than by re-adding a legacy-table
896    /// delete here).
897    pub fn instance_delete(&self, id: &str) -> Result<bool, StoreError> {
898        let rows = {
899            let conn = self.conn.lock().unwrap();
900            // Refuse a template explicitly, rather than leaning on the
901            // DELETE's own `is_template = 0` guard. The registry sweep below
902            // runs even when the DELETE matches nothing, and a template's id
903            // can appear as the `definition_id` of a legacy, never-re-keyed
904            // launch record — sweeping on it would take records belonging to
905            // agents launched from that template, which
906            // `agent_def_delete_removes_only_its_own_registry_file` exists
907            // to forbid. A missing row reads as "not a template" so the
908            // retry path below still works.
909            if Self::scope_for_row(&conn, id) == RecordScope::FileKeyOnly {
910                return Ok(false);
911            }
912            // Identity M4a: read the names before the row goes.
913            tombstone_key_names(&conn, id)?;
914            purge_name_keyed_keys(&conn, id, self.wan_identity().as_deref())?;
915            let rows =
916                conn.execute("DELETE FROM db_agents WHERE id = ?1 AND is_template = 0", params![id])?;
917            // Same dependent purge `agent_def_delete` runs, and
918            // unconditional for the same reason — this is that deletion
919            // arriving from the launch side, and an agent deleted through
920            // this name used to keep its credentials and signing keys on
921            // disk purely because the cleanup lived in the other function.
922            purge_agent_dependents(&conn, id, self.token_index().as_deref())?;
923            rows
924        };
925        // Unconditional, for the reason `agent_def_delete`'s own sweep is
926        // (ReAgent P1 round 2 on PR #3262): a retry after a previous attempt
927        // removed the `db_agents` row but failed to remove the registry file
928        // sees `rows == 0`, so gating here left the iconless ghost row
929        // behind indefinitely — the exact bug this PR exists to fix, just
930        // unfixed on this entry point. "The same deletion under a second
931        // name" has to mean the same convergence too, not just the same
932        // table list.
933        // Always `Agent`: the template case returned early above, because
934        // this method's contract is "never a template" — it reports no
935        // deletion for one rather than sweeping narrowly the way
936        // `agent_def_delete` does.
937        self.purge_agent_side_effects(id, "instance_delete", RecordScope::Agent);
938        // Tombstone the GLOBAL definition record too. Clearing the local
939        // tables alone leaves an active record in the cross-channel
940        // definition registry, and `agent_def_list` overlays every active
941        // record back onto the local list on every read — so the deleted
942        // agent reappears on the very next My Agents fetch, no restart
943        // needed (reagent P1 round 2 on #3080). Same tombstone
944        // `agent_def_delete` writes; this is that deletion arriving from the
945        // launch side.
946        //
947        // Unconditional, like everything else in this function (ReAgent P2
948        // on PR #3262). This was the last step still gated on `rows > 0`,
949        // on the reasoning that an agent existing only in the global
950        // registry is `agent_def_delete`'s business — but once the purge
951        // above went unconditional, that gate left a genuinely incoherent
952        // outcome reachable here: a cross-channel agent's dependent rows
953        // and registry records swept while its definition stays ACTIVE, so
954        // the overlay keeps serving a definition nothing backs. Retiring an
955        // id with no global record is a no-op, and a template can't reach
956        // this line (early return above), so there is nothing to gate on.
957        let global_retired = self.registry_def_retire(id);
958        Ok(rows > 0 || global_retired)
959    }
960
961    /// Resolve the agent bindings tied to a block.
962    ///
963    /// Resolves through `block.meta.agentId` (or legacy `agent:id`) first —
964    /// the block says which agent it shows — returning that agent's row
965    /// with `block_id` set to the asked-for block. Falls back to the agent
966    /// whose latest launch is on this block and still active, which is what
967    /// a block from before the block meta carried `agentId` needs.
968    ///
969    /// `block.meta.agentInstanceId` never overrides `agentId`: codex P1 on
970    /// PR #1114 round 3 surfaced that pane reuse can leave a stale one
971    /// behind. It is consulted only for a block whose `agentId` names no
972    /// agent row, and only within what that block names, while it is still
973    /// the block's latest launch (see the fallback below).
974    pub fn instance_get_active_for_block(&self, block_id: &str) -> Result<Option<AgentInstance>, StoreError> {
975        let block: crate::backend::obj::Block = match self.get(block_id)? {
976            Some(b) => b,
977            None => return Ok(None),
978        };
979        let agent_id = crate::backend::obj::block_meta_agent_id(&block.meta)
980            .unwrap_or("")
981            .to_string();
982        if !agent_id.is_empty() {
983            // Any non-template row, hidden or not: an existing pane of a
984            // "forgotten" agent must still resolve its bindings.
985            let conn = self.conn.lock().unwrap();
986            let mut stmt = conn.prepare(&format!(
987                "SELECT {INSTANCE_COLUMNS} FROM db_agents WHERE id = ?1 AND is_template = 0"
988            ))?;
989            match stmt.query_row(params![agent_id], map_instance_row) {
990                Ok(mut a) => {
991                    a.block_id = block_id.to_string();
992                    return Ok(Some(a));
993                }
994                Err(rusqlite::Error::QueryReturnedNoRows) => {}
995                Err(e) => return Err(e.into()),
996            }
997            // The block names something that is not an agent row: a
998            // template (a template launch or continuation finds the row it
999            // created or folded into this way), a template since removed
1000            // from the manifest (its launched agents are kept, and their
1001            // blocks still name it), a deleted agent whose pane survived, or
1002            // a provider key. Fall back only to a row *launched from* what
1003            // the block names (`parent_template_id`), and — when the frontend
1004            // has stamped the launched row (`agentInstanceId`) — only to that
1005            // row. Row status is never corrected when a pane is switched or
1006            // a process exits, so the latest launch on the block can be
1007            // another agent's stale `running` row, including a sibling
1008            // launched from the same template; resolving to it handed the
1009            // pane that agent's UID, token, slug and provider credentials.
1010            // A stamped row that no longer exists resolves to nothing.
1011            // "Launched from" reaches one hop: the template-promotion
1012            // migration repointed launch rows' `parent_template_id` from the
1013            // seeded template to its promoted clone (itself launched from
1014            // the template), while pre-migration blocks still name the
1015            // template (Codex P1 on #3576). Neither the row nor the hop may
1016            // be a fork: `forkagentdefinition` also stores its source in
1017            // `parent_template_id`, and `branch_label` (always set on a
1018            // fork, never on a template launch) is what tells them apart
1019            // (`template.rs`; Codex P1 on #3576).
1020            let stamped = block
1021                .meta
1022                .get("agentInstanceId")
1023                .and_then(|v| v.as_str())
1024                .map(str::trim)
1025                .unwrap_or("")
1026                .to_string();
1027            if !stamped.is_empty() {
1028                // The stamp is the frontend's own record of the row it
1029                // launched on this block — stronger evidence than lineage,
1030                // which migrations and deletions rewrite (a removed
1031                // template, a promoted clone deleted after the repoint).
1032                // Accepted only while it is still the block's latest launch
1033                // (by `started_at`, which only a launch or fold moves; a tie
1034                // with another active launch is undecided and rejected —
1035                // Codex P2 on #3576): a
1036                // stale stamp left by pane reuse, with a newer launch since
1037                // folded onto the block, resolves to nothing rather than to
1038                // the older row. Never a fork: a fork is launched under its
1039                // own row id, so a fork reached here is a stale stamp. And
1040                // the row must still relate to what the block names —
1041                // launched from it, or one hop from it — so a stale stamp
1042                // naming an unrelated agent cannot select it (ReAgent P0/P1
1043                // on #3576). Recorded, fail-safe: a launch the promotion
1044                // migration repointed to a clone that was later deleted has
1045                // no lineage left and resolves to nothing until relaunched;
1046                // and `started_at` is wall-clock, so a backward clock step
1047                // between two launches on one block makes the stamped row
1048                // look older — it then resolves to nothing, never to another
1049                // row. (Only an unstamped legacy block, reused, across such a
1050                // step could pick the older launch.)
1051                // Recorded: a legacy continuation stamp naming an id that
1052                // never had a row (m0025 keyed chains to their root) resolves
1053                // to nothing, not to the root — nothing distinguishes it from
1054                // a deleted launch, and no identity is safer than a wrong one.
1055                let mut stmt = conn.prepare(&format!(
1056                    "SELECT {INSTANCE_COLUMNS} FROM db_agents a
1057                     WHERE a.id = ?2 AND a.last_block_id = ?1 AND a.is_template = 0
1058                       AND a.status IN ('running', 'paused') AND a.branch_label = ''
1059                       AND (a.parent_template_id = ?3
1060                            OR a.parent_template_id IN (
1061                                SELECT id FROM db_agents
1062                                WHERE parent_template_id = ?3 AND is_template = 0 AND branch_label = '')
1063                            )
1064                       AND NOT EXISTS (
1065                           SELECT 1 FROM db_agents b
1066                           WHERE b.last_block_id = ?1 AND b.is_template = 0 AND b.id != a.id
1067                             AND b.status IN ('running', 'paused') AND b.started_at >= a.started_at)"
1068                ))?;
1069                return match stmt.query_row(params![block_id, stamped, agent_id], map_instance_row)
1070                {
1071                    Ok(a) => Ok(Some(a)),
1072                    Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1073                    Err(e) => Err(e.into()),
1074                };
1075            }
1076            // Unstamped (a block from before `agentInstanceId`): lineage.
1077            let mut stmt = conn.prepare(&format!(
1078                "SELECT {INSTANCE_COLUMNS} FROM db_agents
1079                 WHERE last_block_id = ?1 AND is_template = 0 AND status IN ('running', 'paused')
1080                   AND branch_label = ''
1081                   AND (parent_template_id = ?2
1082                        OR parent_template_id IN (
1083                            SELECT id FROM db_agents
1084                            WHERE parent_template_id = ?2 AND is_template = 0 AND branch_label = ''))
1085                 ORDER BY started_at DESC, updated_at DESC, id DESC
1086                 LIMIT 1"
1087            ))?;
1088            return match stmt.query_row(params![block_id, agent_id], map_instance_row) {
1089                Ok(a) => Ok(Some(a)),
1090                Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1091                Err(e) => Err(e.into()),
1092            };
1093        }
1094        // A block whose meta names no agent (from before `agentId`): the
1095        // agent whose latest launch is on it — by launch time, which a
1096        // rename or lifecycle write does not move (Codex P1 on #3576).
1097        let conn = self.conn.lock().unwrap();
1098        let mut stmt = conn.prepare(&format!(
1099            "SELECT {INSTANCE_COLUMNS} FROM db_agents
1100             WHERE last_block_id = ?1 AND is_template = 0 AND status IN ('running', 'paused')
1101             ORDER BY started_at DESC, updated_at DESC, id DESC
1102             LIMIT 1"
1103        ))?;
1104        match stmt.query_row(params![block_id], map_instance_row) {
1105            Ok(a) => Ok(Some(a)),
1106            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1107            Err(e) => Err(e.into()),
1108        }
1109    }
1110
1111    /// The agent whose latest launch is on `block_id`, regardless of status
1112    /// — with its real `session_id`, so callers that write back to that
1113    /// exact row (keeping `session_id` live as the CLI emits it — see
1114    /// `persist_session_id` /
1115    /// SPEC_PANE_CLOSE_REOPEN_CONTINUITY_GUARANTEE_2026_07_27.md §4.1) have
1116    /// the right id to pass to `instance_update_partial`.
1117    ///
1118    /// When a pane is reused, several rows can name the block. The one the
1119    /// pane runs is the block's own `agentId` (or legacy `agent:id`), while
1120    /// its latest launch is still this block; otherwise the most recent
1121    /// *launch* (`started_at`). Never the most recently *updated* row: a
1122    /// rename or definition edit bumps `updated_at`, and a session id then
1123    /// landed on the renamed agent instead of the one running (#3580).
1124    pub fn instance_get_by_block_id(&self, block_id: &str) -> Result<Option<AgentInstance>, StoreError> {
1125        let shown = self
1126            .get::<crate::backend::obj::Block>(block_id)?
1127            .and_then(|b| crate::backend::obj::block_meta_agent_id(&b.meta).map(str::to_string))
1128            .filter(|id| !id.is_empty());
1129        let conn = self.conn.lock().unwrap();
1130        if let Some(agent_id) = shown {
1131            let mut stmt = conn.prepare(&format!(
1132                "SELECT {INSTANCE_COLUMNS} FROM db_agents
1133                 WHERE id = ?1 AND last_block_id = ?2 AND is_template = 0"
1134            ))?;
1135            match stmt.query_row(params![agent_id, block_id], map_instance_row) {
1136                Ok(a) => return Ok(Some(a)),
1137                Err(rusqlite::Error::QueryReturnedNoRows) => {}
1138                Err(e) => return Err(e.into()),
1139            }
1140        }
1141        let mut stmt = conn.prepare(&format!(
1142            "SELECT {INSTANCE_COLUMNS} FROM db_agents
1143             WHERE last_block_id = ?1 AND is_template = 0
1144             ORDER BY started_at DESC, updated_at DESC, id DESC
1145             LIMIT 1"
1146        ))?;
1147        match stmt.query_row(params![block_id], map_instance_row) {
1148            Ok(a) => Ok(Some(a)),
1149            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1150            Err(e) => Err(e.into()),
1151        }
1152    }
1153}