agentmux_srv/backend/agent_resolve.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The one place a human-readable agent slug becomes a canonical
5//! `db_agents.id`.
6//!
7//! **Why this exists.** `AGENTMUX_AGENT_ID` is a model-and-human-chosen slug,
8//! not a key: `db_agents.slug` carries no `UNIQUE` constraint, deliberately
9//! (`storage::migrations` declines the index because template launches copy
10//! their template's slug). Treating it as a primary key has produced the same
11//! defect four times — Codex P1 on #2901, reagentx on #2428, reagentx on
12//! #3480, and `instance_get_by_slug` itself on #3500 — each found and fixed
13//! separately because the resolution logic existed in three places that drifted
14//! apart.
15//!
16//! Phase 0 of `SPEC_CANONICAL_AGENT_ID_MIGRATION_2026_09_21.md`. Later phases
17//! call this instead of inventing a fourth resolver.
18//!
19//! **Resolve once, at the trust boundary.** Callers pass the slug they already
20//! have — MCP arguments, audit-log display and agent-facing docs keep the
21//! readable name. Everything downstream of this function uses the id.
22
23use crate::backend::agent_registry_lookup::find_active_record_by_slug;
24use crate::backend::storage::store::Store;
25
26/// Resolve `slug_or_id` to a canonical `db_agents.id`.
27///
28/// Tried in order, first hit wins:
29///
30/// 1. **`db_agents` by slug** — the persisted row. Fails closed when two rows
31/// share the slug (#3500), so an ambiguous slug falls through rather than
32/// resolving to an arbitrary winner.
33/// 2. **`db_agents` by id** — idempotent passthrough. Phases land in sequence,
34/// so a caller may hold a value that is already resolved; requiring each one
35/// to know which it has would defeat the point of a shared resolver.
36/// 3. **The named-agent registry** — a live agent with no `db_agents` row yet,
37/// which is the common case (launching an agent does not create one). Also
38/// fails closed on a colliding slug (#3480).
39///
40/// `Err` means "no agent" *or* "more than one agent". They are deliberately
41/// not distinguished yet: both of the underlying lookups already collapse
42/// ambiguity into "not found", and surfacing `Ambiguous` separately is a
43/// user-facing error-message decision the spec (§4) flags as needing UX input
44/// rather than a choice to make here. Nothing about this function's shape
45/// prevents adding it later.
46pub(crate) fn resolve_agent_id(store: &Store, slug_or_id: &str) -> Result<String, String> {
47 if slug_or_id.is_empty() {
48 return Err("cannot resolve an empty agent id".to_string());
49 }
50
51 // A store error is reported, not swallowed. `resolve_agent_uuid` already
52 // propagated it; `resolve_agent_definition_id` silently treated it as "not
53 // this tier" and tried the registry, which turns a database fault into a
54 // confident "unknown agent". The stricter of the two behaviors wins.
55 match store.instance_get_by_slug(slug_or_id) {
56 // `map_instance_row` populates `definition_id` from the same column as
57 // `id` — in the consolidated `db_agents` table they are the same value,
58 // which is why the three resolvers this replaces could return either
59 // and still agree here.
60 Ok(Some(instance)) if !instance.id.is_empty() => return Ok(instance.id),
61 Ok(_) => {}
62 Err(e) => return Err(format!("resolve_agent_id: store: {e}")),
63 }
64
65 match store.agent_def_get(slug_or_id) {
66 Ok(Some(_)) => return Ok(slug_or_id.to_string()),
67 Ok(None) => {}
68 Err(e) => return Err(format!("resolve_agent_id: store: {e}")),
69 }
70
71 if let Some(rec) = find_active_record_by_slug(slug_or_id) {
72 if !rec.data.definition_id.is_empty() {
73 return Ok(rec.data.definition_id);
74 }
75 }
76
77 Err(format!(
78 "unknown agent '{slug_or_id}': no agent row, definition or active registry record resolves it"
79 ))
80}
81
82/// Process-wide fallback counters, keyed by call site (spec §9.2). In-memory
83/// only, same lifecycle as the reactive handler's audit log; a phase's exit
84/// criterion is read from these, not assumed.
85static UID_FALLBACK_COUNTS: std::sync::OnceLock<
86 std::sync::Mutex<std::collections::HashMap<&'static str, u64>>,
87> = std::sync::OnceLock::new();
88
89/// The agent UID (`db_agents.id`) for the agent shown on `block_id`, if it
90/// has a row — the same block → row resolution `build_persistent_spawn_env`
91/// uses, without minting a token. For the frontend presence path
92/// (`handle_reactive_register`), which does not trust the name it is sent
93/// (spec §0.1) and resolves identity by block instead. A synchronous store
94/// read: call it on `spawn_blocking` from async handlers.
95pub(crate) fn uid_for_block(store: &Store, block_id: &str) -> Option<String> {
96 match store.instance_get_active_for_block(block_id) {
97 Ok(Some(instance)) if !instance.id.trim().is_empty() => Some(instance.id),
98 Ok(_) => None,
99 Err(e) => {
100 tracing::warn!(block_id, error = %e, "uid_for_block: store read failed — registering without a uid");
101 None
102 }
103 }
104}
105
106/// Bump the counter for `site` (spec §9.2). Shared by the resolver's
107/// dual-write misses and, since identity M2, the registry's own
108/// registration/delivery/lookup counters — one snapshot for all of them.
109pub(crate) fn record_uid_fallback(site: &'static str) {
110 let counts = UID_FALLBACK_COUNTS.get_or_init(Default::default);
111 let mut guard = counts.lock().unwrap_or_else(|e| e.into_inner());
112 *guard.entry(site).or_insert(0) += 1;
113}
114
115/// Snapshot of every fallback counter, for the exit-criterion check (spec
116/// §9.2). Sorted by site so output is stable.
117pub(crate) fn uid_fallback_counts() -> Vec<(&'static str, u64)> {
118 let counts = UID_FALLBACK_COUNTS.get_or_init(Default::default);
119 let guard = counts.lock().unwrap_or_else(|e| e.into_inner());
120 let mut out: Vec<(&'static str, u64)> = guard.iter().map(|(k, v)| (*k, *v)).collect();
121 out.sort();
122 out
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128 use crate::backend::storage::agents::{test_agent_def, AgentInstance};
129
130 // Tier 3 reads the global registry via `resolve_shared_registry_dir`, which
131 // resolves the developer's real `~/.agentmux` unless `AGENTMUX_HOME_OVERRIDE`
132 // is set — so a not-found assertion would depend on whichever agents happen
133 // to be running on the machine. Every test here runs against an isolated
134 // empty home, under the crate-wide lock that env var's other consumers take.
135 use crate::test_support::ISOLATED_AUTH_ENV_LOCK as ENV_GUARD;
136
137 fn with_isolated_home<T>(f: impl FnOnce() -> T) -> T {
138 let _guard = ENV_GUARD.lock().unwrap_or_else(|e| e.into_inner());
139 let home = tempfile::tempdir().unwrap();
140 std::env::set_var("AGENTMUX_HOME_OVERRIDE", home.path().to_str().unwrap());
141 let out = f();
142 std::env::remove_var("AGENTMUX_HOME_OVERRIDE");
143 out
144 }
145
146 fn instance(id: &str, definition_id: &str, name: &str) -> AgentInstance {
147 AgentInstance {
148 id: id.to_string(),
149 definition_id: definition_id.to_string(),
150 parent_instance_id: String::new(),
151 block_id: String::new(),
152 session_id: String::new(),
153 status: "init".to_string(),
154 github_context: String::new(),
155 started_at: 1,
156 ended_at: 0,
157 created_at: 1,
158 identity_id: String::new(),
159 memory_id: String::new(),
160 instance_name: name.to_string(),
161 working_directory: String::new(),
162 display_hidden: false,
163 }
164 }
165
166 #[test]
167 fn resolves_a_slug_to_the_agents_canonical_id() {
168 with_isolated_home(|| {
169 let store = Store::open_in_memory().unwrap();
170 let mut def = test_agent_def("def-1", "Solo Agent", "claude", "agent", 1, "");
171 def.slug = "solo".to_string();
172 store.agent_def_insert(&mut def).unwrap();
173
174 assert_eq!(resolve_agent_id(&store, "solo").unwrap(), "def-1");
175 });
176 }
177
178 // Idempotency (spec §4): phases land in sequence, so a caller may already
179 // hold a resolved id. Tier 1 misses here because the id is not the slug —
180 // it is tier 2 that answers.
181 #[test]
182 fn passes_through_an_id_that_is_already_canonical() {
183 with_isolated_home(|| {
184 let store = Store::open_in_memory().unwrap();
185 let mut def = test_agent_def("def-1", "Solo Agent", "claude", "agent", 1, "");
186 def.slug = "solo".to_string();
187 store.agent_def_insert(&mut def).unwrap();
188
189 assert_eq!(resolve_agent_id(&store, "def-1").unwrap(), "def-1");
190 });
191 }
192
193 #[test]
194 fn errors_for_an_agent_that_exists_nowhere() {
195 with_isolated_home(|| {
196 let store = Store::open_in_memory().unwrap();
197 assert!(resolve_agent_id(&store, "nobody").is_err());
198 });
199 }
200
201 #[test]
202 fn errors_on_an_empty_id_without_touching_the_store() {
203 with_isolated_home(|| {
204 let store = Store::open_in_memory().unwrap();
205 assert!(resolve_agent_id(&store, "").is_err());
206 });
207 }
208
209 // The §7 case the spec calls out as having no coverage anywhere.
210 //
211 // The duplicate is forced rather than launched: both write paths
212 // suffix-resolve since #3497 §4, so two `instance_create` calls no longer
213 // collide. Forcing it one row at a time keeps the assertion honest — the
214 // single-row case must RESOLVE, so the refusal below is caused by the
215 // second row and not by the slug matching nothing.
216 //
217 // Tier 1 fails closed (#3500), tier 2 misses because a slug is not an id,
218 // tier 3 finds nothing in the isolated registry — so the whole resolver
219 // refuses rather than naming an arbitrary winner.
220 #[test]
221 fn refuses_a_slug_two_rows_share() {
222 with_isolated_home(|| {
223 let store = Store::open_in_memory().unwrap();
224 let mut tmpl = test_agent_def("tmpl-1", "Shared Template", "claude", "agent", 1, "");
225 tmpl.slug = "shared-template".to_string();
226 tmpl.is_seeded = 1;
227 store.agent_def_insert(&mut tmpl).unwrap();
228
229 store.instance_create(&instance("launch-a", "tmpl-1", "Launch A")).unwrap();
230 store.instance_create(&instance("launch-b", "tmpl-1", "Launch B")).unwrap();
231
232 // Both write paths suffix-resolve now (§4), so force the duplicate.
233 // One row first: resolving it proves the fixture is live, so the
234 // refusal below is the second row's doing and not "nothing matched".
235 store.test_force_slug("launch-a", "collide-me").unwrap();
236 assert_eq!(
237 resolve_agent_id(&store, "collide-me").unwrap(),
238 "launch-a",
239 "one row with the slug must resolve — otherwise the check below is vacuous"
240 );
241
242 store.test_force_slug("launch-b", "collide-me").unwrap();
243 assert!(
244 resolve_agent_id(&store, "collide-me").is_err(),
245 "adding a second row with the same slug must flip resolve to refusal"
246 );
247 });
248 }
249
250 /// The §9.2 counters are per site and read back sorted. Site names are
251 /// unique to this test: the counters are process-wide and tests run in
252 /// parallel.
253 #[test]
254 fn fallback_counters_are_recorded_per_site() {
255 let count = |site: &str| {
256 uid_fallback_counts()
257 .into_iter()
258 .find(|(s, _)| *s == site)
259 .map_or(0, |(_, n)| n)
260 };
261 let before = count("test.counters.b");
262 record_uid_fallback("test.counters.b");
263 record_uid_fallback("test.counters.b");
264 record_uid_fallback("test.counters.a");
265 assert_eq!(count("test.counters.b"), before + 2);
266 let sites: Vec<&str> = uid_fallback_counts().into_iter().map(|(s, _)| s).collect();
267 let mut sorted = sites.clone();
268 sorted.sort();
269 assert_eq!(sites, sorted);
270 }
271}