Skip to main content

agentmux_srv/backend/storage/
registry_mirror.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Cross-version named-agent registry mirror.
5//!
6//! Mirrors `db_agent_instances` mutations into the JSON registry at
7//! `~/.agentmux/agents/registry/` so other AgentMux versions running
8//! on the same machine see each other's named agents in their launch
9//! modal dropdowns. SQLite is authoritative for the local version;
10//! the registry is a best-effort cross-version view.
11//!
12//! Extracted from `store.rs` in Phase R.6 of the storage
13//! modularization plan
14//! (`docs/specs/SPEC_STORE_MODULARIZATION_2026_05_27.md`). This whole
15//! file retires when Phase R sunsets the JSON registry entirely;
16//! isolating it now makes that future deletion a clean unit.
17
18use std::path::Path;
19
20use super::store::{AgentInstance, Store};
21
22/// Build a registry record from an `AgentInstance`. Returns an error
23/// if the working directory can't be expressed as a path relative to
24/// the canonical shared agents root (e.g. user pointed an agent at
25/// `~/projects/foo`, which would also fail a naive `"agents"`
26/// segment-scan that happened to match `~/projects/agents/foo`).
27/// Caller logs + skips — agent stays in SQLite, just not in the
28/// cross-version dropdown.
29fn agent_instance_to_record(
30    inst: &AgentInstance,
31    global_agents_root: Option<&Path>,
32    channel_agents_base: &Path,
33) -> Result<crate::registry::NamedAgentRecord, String> {
34    use crate::registry::{NamedAgentRecord, NamedAgentRecordV1};
35    // Agent workspaces are GLOBAL (`<home>/agents/<name>`), so anchor on that
36    // global root FIRST, falling back to this channel's agents dir only for a
37    // legacy in-channel workspace. This MUST match `registry/migrate.rs`'s
38    // `row_to_record` — the one-shot migration and this live mirror have to stamp
39    // the same `source_agents_base` for a given agent or a reader sees two
40    // different bases. Without the global branch, every real (global) workspace
41    // failed strip_prefix and the agent was dropped from the registry ("not
42    // representable") — the live-write twin of the bug #1393 fixed in the
43    // migration.
44    let (rel, base): (String, &Path) = global_agents_root
45        .and_then(|g| relative_workdir(&inst.working_directory, g).map(|r| (r, g)))
46        .or_else(|| {
47            relative_workdir(&inst.working_directory, channel_agents_base)
48                .map(|r| (r, channel_agents_base))
49        })
50        .ok_or_else(|| {
51            format!(
52                "working_directory {:?} is under neither the global agents root {:?} nor the channel base {:?}",
53                inst.working_directory,
54                global_agents_root.map(|p| p.display().to_string()),
55                channel_agents_base.display()
56            )
57        })?;
58    let version = env!("CARGO_PKG_VERSION").to_string();
59    let data = NamedAgentRecordV1 {
60        instance_id: inst.id.clone(),
61        instance_name: inst.instance_name.clone(),
62        definition_id: inst.definition_id.clone(),
63        identity_id: empty_to_none(&inst.identity_id),
64        memory_id: empty_to_none(&inst.memory_id),
65        // v2: carry the provider session id so a global/cross-channel
66        // record can `--resume` without joining the current channel's SQLite.
67        session_id: empty_to_none(&inst.session_id),
68        working_dir: rel,
69        // v3: the agents dir `working_dir` is relative to — the GLOBAL workspace
70        // root for a normal agent, or this channel's agents dir for a legacy
71        // in-channel workspace. Stored absolute so any channel reconstructs the
72        // path via `source_agents_base.join(working_dir)` (P0.4).
73        source_agents_base: Some(base.to_string_lossy().to_string()),
74        created_at_ms: inst.created_at,
75        last_launched_at_ms: inst.started_at,
76        created_by_version: version.clone(),
77        last_launched_by_version: version,
78    };
79    // Stamp the lowest envelope schema that faithfully represents the payload
80    // (schema::min_schema_version). Since P0.4 always sets source_agents_base,
81    // that is v3 for every live-mirrored record — a pre-v3 reader rejects it
82    // (intended: better to hide than mis-resolve a cross-channel workdir; the
83    // only pre-v3 readers of the GLOBAL registry are pre-P0.4 builds, which
84    // ship together with this).
85    Ok(NamedAgentRecord {
86        schema_version: data.min_schema_version(),
87        data,
88    })
89}
90
91fn empty_to_none(s: &str) -> Option<String> {
92    if s.is_empty() {
93        None
94    } else {
95        Some(s.to_string())
96    }
97}
98
99/// Express `abs` as a path relative to `agents_root`. Returns `None`
100/// when `abs` is empty, not under `agents_root`, or after stripping
101/// resolves to an empty path. Anchors against the **resolved** shared
102/// root (passed in by the caller) — never scans for a path segment
103/// named "agents", which would match unrelated user directories like
104/// `/home/me/projects/agents/foo`.
105fn relative_workdir(abs: &str, agents_root: &Path) -> Option<String> {
106    if abs.is_empty() {
107        return None;
108    }
109    let p = std::path::Path::new(abs);
110    let rel = p.strip_prefix(agents_root).ok()?;
111    // Reject empties + traversals (defense in depth — strip_prefix
112    // already rules out `..` escapes, but the registry's own validator
113    // re-checks).
114    let s = rel.to_string_lossy().to_string();
115    if s.is_empty() || s == "." {
116        return None;
117    }
118    Some(s)
119}
120
121impl Store {
122    /// Mirror a `db_agent_instances` mutation into the cross-version
123    /// registry. Only fires for **named** rows. Routes by
124    /// `display_hidden` so the registry file ends up in the tree
125    /// matching SQLite's dropdown filter:
126    ///
127    /// - hidden = true  → upsert (atomic write to active/) then
128    ///   retire (atomic rename to retired/). Net: file lives in
129    ///   `retired/<id>.json` with the freshest content. Prevents
130    ///   `instance_update` on a previously-hidden row from
131    ///   resurrecting an active registry file, AND keeps the
132    ///   retired tombstone's content current.
133    /// - hidden = false → unretire (no-op if not retired) then
134    ///   upsert. Net: file in `active/<id>.json`, no orphan retired.
135    ///
136    /// `wrote_session` says whether the caller's own write set the row's
137    /// `session_id`. When it didn't (a launch), the row's session id may be
138    /// older than one another channel has since mirrored, so a session id
139    /// the registry already holds is kept (#3586).
140    ///
141    /// Failures are logged, never propagated: SQLite remains
142    /// authoritative.
143    pub(super) fn registry_upsert_if_named(&self, inst: &AgentInstance, wrote_session: bool) {
144        // Mirror filter: only registers named rows. Pre-Option-E this
145        // also excluded continuation rows (parent_instance_id != '')
146        // so the registry-sourced read path wouldn't surface chained
147        // resumes as duplicate dropdown rows. Under Option E, the
148        // session zone is anchored on definition_id, so a continuation
149        // row IS the most-recent named instance — exactly what we want
150        // visible. `instance_list_named` (the SQLite-sourced read
151        // path) dropped its parent_instance_id filter in the
152        // 2026-05-24 picker-visibility fix; the registry mirror keeps
153        // its filter here for now since the registry-sourced read path
154        // doesn't have the dedup-by-(definition_id, instance_name)
155        // affordance the SQLite ORDER BY/LIMIT provides. Follow-up
156        // PR will land cross-version dedup so this filter can drop too.
157        if inst.instance_name.is_empty() || !inst.parent_instance_id.is_empty() {
158            return;
159        }
160        let Some(reg) = self.shared_agent_registry() else {
161            return;
162        };
163        // Anchor the relative working_directory on the CURRENT channel's
164        // agents dir, not the registry's own parent — once P0.3 re-roots the
165        // registry under ~/.agentmux/shared/, its parent no longer coincides
166        // with channels/<ch>/agents/. `registry_agents_base()` returns the
167        // explicit channel dir (AGENTMUX_AGENTS_DIR) and falls back to the
168        // registry parent for tests / pre-re-root layouts.
169        let Some(agents_root) = self.registry_agents_base() else {
170            tracing::warn!("registry: no agents base — skipping mirror");
171            return;
172        };
173        // Agent workspaces are GLOBAL (`<home>/agents/<name>`). Derive `<home>`
174        // from the registry root exactly as main.rs does for the one-shot
175        // migration (registry = `<home>/shared/agents/registry` → `nth(3)` strips
176        // registry→agents→shared = `<home>`), so the live mirror and the migration
177        // anchor identically. `None` in shallow test/pre-re-root layouts → the
178        // mirror falls back to the per-channel base.
179        let global_agents_root = reg.root().ancestors().nth(3).map(|h| h.join("agents"));
180        let rec = match agent_instance_to_record(inst, global_agents_root.as_deref(), &agents_root) {
181            Ok(rec) => rec,
182            Err(e) => {
183                tracing::warn!(
184                    instance_id = %inst.id,
185                    error = %e,
186                    "registry: instance not representable as record, skipping mirror"
187                );
188                return;
189            }
190        };
191
192        // If the row was previously hidden, the file lives in
193        // `retired/`. Move it back to active before upserting so
194        // upsert's merge-preserves-unknown-fields path operates on
195        // the right file (and we never leave dangling retired files).
196        if let Err(e) = reg.unretire(&inst.id) {
197            tracing::warn!(
198                instance_id = %inst.id,
199                error = %e,
200                "registry: failed to unretire row before upsert"
201            );
202        }
203
204        let upserted = if wrote_session {
205            reg.upsert(&rec)
206        } else {
207            reg.upsert_keeping_session(&rec)
208        };
209        if let Err(e) = upserted {
210            tracing::warn!(
211                instance_id = %inst.id,
212                error = %e,
213                "registry: failed to mirror instance_create/update"
214            );
215            return;
216        }
217
218        // After the upsert, move into retired/ if the row is hidden.
219        // Combined: hidden row's tombstone always has up-to-date
220        // content, and active/ never carries a hidden row.
221        if inst.display_hidden {
222            if let Err(e) = reg.retire(&inst.id) {
223                tracing::warn!(
224                    instance_id = %inst.id,
225                    error = %e,
226                    "registry: failed to retire hidden row post-upsert"
227                );
228            }
229        }
230    }
231
232    /// Propagate a fresh `session_id` onto the chain-root registry record
233    /// when `inst` is a **continuation** row (`parent_instance_id` set).
234    ///
235    /// `registry_upsert_if_named` above deliberately excludes continuation
236    /// rows from the mirror (see its filter comment — kept symmetric with
237    /// `instance_list_named`'s legacy dropdown mode so a resume chain
238    /// doesn't fragment into N registry files). That's correct for most
239    /// fields, but it leaves the registry's `session_id` — the ONE field a
240    /// cross-channel/cross-build reader consults to `--resume` this agent —
241    /// stuck at whatever the chain head had when first mirrored, never
242    /// updated by any later resume. Root cause of
243    /// docs/retro/retro-agent-resumed-9-day-stale-session-2026-08-22.md:
244    /// reopening the agent in a different channel silently resumed a
245    /// stale-but-valid session instead of the live one.
246    ///
247    /// Read-modify-write against the existing root record (never
248    /// constructs a partial one — `Registry::upsert`'s merge overwrites
249    /// every field present in the struct). Best-effort: logs and returns
250    /// on any lookup/write failure, since the SQLite write already
251    /// succeeded and remains authoritative.
252    ///
253    /// Callers decide WHETHER to invoke this at all based on whether
254    /// `inst.session_id` reflects a genuine write, not just this
255    /// function — an empty `session_id` is ambiguous in isolation: it can
256    /// mean "never captured yet" (a fresh continuation — must NOT
257    /// propagate, would clobber a still-valid root pointer, reagent P1 on
258    /// PR #2755) or "deliberately cleared" (`poison_resume`'s stale-resume
259    /// handling, via `persist_session_id(block_id, "", ...)` on an
260    /// EXISTING row — MUST propagate, otherwise the shared registry keeps
261    /// serving a session_id already proven dead, reagent P1 round 2). Only
262    /// the caller knows which case it's in (create vs. an explicit
263    /// `InstanceUpdate.session_id` write), so this function itself never
264    /// special-cases emptiness — it always mirrors whatever it's given.
265    pub(super) fn registry_propagate_continuation_session_id(&self, inst: &AgentInstance) {
266        if inst.parent_instance_id.is_empty() {
267            return; // chain head — already covered by registry_upsert_if_named.
268        }
269        let Some(reg) = self.shared_agent_registry() else {
270            return;
271        };
272        let root_id = self.find_chain_root_id(inst);
273        if root_id == inst.id {
274            return;
275        }
276        let mut rec = match reg.get(&root_id) {
277            Ok(Some(rec)) => rec,
278            Ok(None) => return, // root was never named/mirrored — nothing to update.
279            Err(e) => {
280                tracing::warn!(
281                    instance_id = %inst.id,
282                    root_id = %root_id,
283                    error = %e,
284                    "registry: failed to read chain-root record for session_id propagation"
285                );
286                return;
287            }
288        };
289        let fresh_session_id = empty_to_none(&inst.session_id);
290        if rec.data.session_id == fresh_session_id {
291            return; // already current — skip the write.
292        }
293        rec.data.session_id = fresh_session_id;
294        rec.schema_version = rec.data.min_schema_version().max(rec.schema_version);
295        if let Err(e) = reg.upsert(&rec) {
296            tracing::warn!(
297                instance_id = %inst.id,
298                root_id = %root_id,
299                error = %e,
300                "registry: failed to propagate continuation session_id to chain root"
301            );
302        }
303    }
304
305    /// Walk `parent_instance_id` upward from `inst` to find the chain
306    /// root — the row with no parent. Bounded to guard against a
307    /// corrupted cyclic chain; real chains are a handful of user-driven
308    /// resumes deep.
309    ///
310    /// When a lookup misses locally, returns `current.parent_instance_id`
311    /// (the id we couldn't resolve), NOT `current.id`. Two cases produce a
312    /// local miss and both want that: (1) the id is a genuinely orphaned
313    /// continuation (its head was hard-deleted, no FK cascade) — the
314    /// registry has no file for it either, so the caller's `reg.get`
315    /// harmlessly no-ops; (2) the id is the chain root living in a
316    /// DIFFERENT channel's local SQLite — this store never has that row,
317    /// but the shared registry still does, under exactly this id. That
318    /// second case is the primary scenario this propagation exists for
319    /// (reopening an agent in another channel/build): `current.id` is a
320    /// continuation's own id, which never has a registry file (reagent P1
321    /// on PR #2755 — the original fallback made cross-channel propagation
322    /// always no-op, defeating the fix for its own target case).
323    fn find_chain_root_id(&self, inst: &AgentInstance) -> String {
324        let mut current = inst.clone();
325        for _ in 0..64 {
326            if current.parent_instance_id.is_empty() {
327                return current.id;
328            }
329            match self.instance_get(&current.parent_instance_id) {
330                Ok(Some(parent)) => current = parent,
331                _ => return current.parent_instance_id.clone(),
332            }
333        }
334        current.id
335    }
336}