Skip to main content

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}