Skip to main content

agentmux_srv/backend/
registry_reconcile.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Startup reconciliation between the two host-global agent trees:
5//! `shared/agents/registry/` (one record per named agent *instance*) and
6//! `shared/agents/definitions/` (one record per agent *definition*).
7//!
8//! The picker's "My Agents" list is built from the instance registry
9//! (`listrecentsessions` reads `Registry::list_active`), with each row's
10//! display fields — name, provider, model vendor, agent type — resolved
11//! from the definition it points at. An active instance record whose
12//! definition has been tombstoned therefore renders as a row nothing can
13//! fill in: no provider means no icon, and the name falls back to
14//! `"(missing definition)"`. Clicking it can only fail. The user sees a
15//! delete that half-worked — the icon vanished, the card stayed.
16//!
17//! Deletion now sweeps every registry record belonging to the agent
18//! (`Registry::hard_delete_for_agent`), so no *new* orphan is created.
19//! This pass exists for the ones already on disk, from every build that
20//! deleted an agent before that fix: the two trees are host-global and
21//! shared across channels, so those records outlive any one install and
22//! nothing else would ever remove them.
23//!
24//! Deliberately narrow: a record is only dropped when its definition is
25//! **provably deleted** — absent from the active definition tree AND
26//! present in that tree's `retired/` tombstones. "Not in the global
27//! definition store" alone is not enough, because a definition can
28//! legitimately live only in some channel's local SQLite (e.g. it was
29//! created while the global store couldn't be opened), and dropping those
30//! would delete real agents rather than ghosts.
31
32use crate::registry::{DefinitionStore, RecordScope, Registry};
33
34/// Drop active instance records whose definition is tombstoned. Returns the
35/// number of files removed. Best-effort: a tree that can't be read is
36/// logged and skipped, never fatal.
37pub fn prune_tombstoned_instance_records(reg: &Registry, defs: &DefinitionStore) -> usize {
38    let records = match reg.list_active() {
39        Ok(v) => v,
40        Err(e) => {
41            tracing::warn!(error = %e, "registry reconcile: list_active failed — skipping prune");
42            return 0;
43        }
44    };
45    // One pass per definition, not per record: several records can share a
46    // definition (a legacy launch-keyed file alongside the agent-keyed one),
47    // and `hard_delete_for_agent` already removes all of them at once.
48    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
49    let mut removed = 0usize;
50    for rec in records {
51        let def_id = rec.data.definition_id;
52        if def_id.is_empty() || !seen.insert(def_id.clone()) {
53            continue;
54        }
55        // Active wins: a definition that was retired and later unretired is
56        // live again, and its instance records must stay.
57        if defs.exists(&def_id) || !defs.exists_anywhere(&def_id) {
58            continue;
59        }
60        match reg.hard_delete_for_agent(&def_id, RecordScope::Agent) {
61            Ok(n) => removed += n,
62            Err(e) => tracing::warn!(
63                definition_id = %def_id,
64                error = %e,
65                "registry reconcile: failed to drop records for a tombstoned definition"
66            ),
67        }
68    }
69    removed
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75    use crate::registry::{
76        DefinitionRecord, DefinitionRecordV1, NamedAgentRecord, NamedAgentRecordV1,
77        DEF_MAX_SUPPORTED_SCHEMA, MAX_SUPPORTED_SCHEMA,
78    };
79
80    fn instance(instance_id: &str, definition_id: &str) -> NamedAgentRecord {
81        NamedAgentRecord {
82            schema_version: MAX_SUPPORTED_SCHEMA,
83            data: NamedAgentRecordV1 {
84                instance_id: instance_id.to_string(),
85                instance_name: "Maks".to_string(),
86                definition_id: definition_id.to_string(),
87                identity_id: None,
88                memory_id: None,
89                session_id: None,
90                working_dir: "maks".to_string(),
91                source_agents_base: None,
92                created_at_ms: 1,
93                last_launched_at_ms: 1,
94                created_by_version: "0.56.1".to_string(),
95                last_launched_by_version: "0.56.1".to_string(),
96            },
97        }
98    }
99
100    fn definition(id: &str) -> DefinitionRecord {
101        DefinitionRecord {
102            schema_version: DEF_MAX_SUPPORTED_SCHEMA,
103            data: DefinitionRecordV1 {
104                id: id.to_string(),
105                name: "Maks".to_string(),
106                ..Default::default()
107            },
108        }
109    }
110
111    fn trees() -> (tempfile::TempDir, Registry, DefinitionStore) {
112        let dir = tempfile::tempdir().unwrap();
113        let reg = Registry::open(dir.path().join("registry")).unwrap();
114        let defs = DefinitionStore::open(dir.path().join("definitions")).unwrap();
115        (dir, reg, defs)
116    }
117
118    /// The shape actually on disk after a pre-fix delete: the agent-keyed
119    /// record went with the definition, the legacy launch-keyed one didn't.
120    #[test]
121    fn a_record_whose_definition_is_tombstoned_is_dropped() {
122        let (_dir, reg, defs) = trees();
123        defs.upsert(&definition("agent-1")).unwrap();
124        reg.upsert(&instance("legacy-launch-id", "agent-1")).unwrap();
125        defs.retire("agent-1").unwrap();
126
127        assert_eq!(prune_tombstoned_instance_records(&reg, &defs), 1);
128        assert!(reg.list_active().unwrap().is_empty());
129    }
130
131    #[test]
132    fn a_record_with_a_live_definition_is_kept() {
133        let (_dir, reg, defs) = trees();
134        defs.upsert(&definition("agent-1")).unwrap();
135        reg.upsert(&instance("legacy-launch-id", "agent-1")).unwrap();
136
137        assert_eq!(prune_tombstoned_instance_records(&reg, &defs), 0);
138        assert_eq!(reg.list_active().unwrap().len(), 1);
139    }
140
141    /// The guard that keeps this from deleting real agents: a definition
142    /// the global store has never heard of is not evidence of deletion —
143    /// it may exist only in some channel's local SQLite.
144    #[test]
145    fn a_record_whose_definition_is_merely_unknown_is_kept() {
146        let (_dir, reg, defs) = trees();
147        reg.upsert(&instance("some-id", "channel-local-only")).unwrap();
148
149        assert_eq!(prune_tombstoned_instance_records(&reg, &defs), 0);
150        assert_eq!(reg.list_active().unwrap().len(), 1);
151    }
152
153    #[test]
154    fn an_unretired_definition_keeps_its_records() {
155        let (_dir, reg, defs) = trees();
156        defs.upsert(&definition("agent-1")).unwrap();
157        reg.upsert(&instance("some-id", "agent-1")).unwrap();
158        defs.retire("agent-1").unwrap();
159        defs.unretire("agent-1").unwrap();
160
161        assert_eq!(prune_tombstoned_instance_records(&reg, &defs), 0);
162        assert_eq!(reg.list_active().unwrap().len(), 1);
163    }
164
165    /// Both files for one agent go in a single pass — the whole point of
166    /// deduping by definition id rather than iterating records.
167    #[test]
168    fn every_record_for_one_tombstoned_definition_goes_at_once() {
169        let (_dir, reg, defs) = trees();
170        defs.upsert(&definition("agent-1")).unwrap();
171        reg.upsert(&instance("agent-1", "agent-1")).unwrap();
172        reg.upsert(&instance("legacy-launch-id", "agent-1")).unwrap();
173        defs.retire("agent-1").unwrap();
174
175        assert_eq!(prune_tombstoned_instance_records(&reg, &defs), 2);
176        assert!(reg.list_active().unwrap().is_empty());
177    }
178
179    #[test]
180    fn running_twice_changes_nothing_the_second_time() {
181        let (_dir, reg, defs) = trees();
182        defs.upsert(&definition("agent-1")).unwrap();
183        reg.upsert(&instance("legacy-launch-id", "agent-1")).unwrap();
184        defs.retire("agent-1").unwrap();
185
186        assert_eq!(prune_tombstoned_instance_records(&reg, &defs), 1);
187        assert_eq!(prune_tombstoned_instance_records(&reg, &defs), 0);
188    }
189}