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}