Skip to main content

agentmux_srv/backend/
agent_bundle_sidecar.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Which bundle is an agent's own bundle (its ABF bundle), across channels
5//! (SPEC_MEMORY_FOLLOWS_THE_AGENT_2026_09_24.md §2.1.6, phase M4).
6//!
7//! `db_agents.default_memory_id` lives in each channel's object store, so
8//! migration m0021 minted a **new** bundle for an agent in every channel it
9//! ran in — "Name — ABF", "Name — ABF (2)", … in the one shared store. The
10//! id belongs with the agent, but not in `DefinitionRecordV1`: that would
11//! need a schema bump older builds reject. So it goes in a sidecar zone in
12//! the global store, `agent-uid:<uid>:bundle`, holding `{memory_id}`: the
13//! first channel to provision the agent records it, and m0021 reads it
14//! before minting.
15
16use serde::{Deserialize, Serialize};
17
18use crate::backend::storage::error::StoreError;
19use crate::backend::storage::filestore::FileStore;
20
21const FILE: &str = "bundle.json";
22
23#[derive(Debug, Default, Serialize, Deserialize)]
24struct Sidecar {
25    #[serde(default)]
26    memory_id: String,
27    #[serde(flatten)]
28    extra: serde_json::Map<String, serde_json::Value>,
29}
30
31fn zone(uid: &str) -> Option<String> {
32    crate::backend::agent_session::is_valid_definition_id(uid).then(|| format!("agent-uid:{uid}:bundle"))
33}
34
35/// The agent's recorded bundle id, if any.
36pub(crate) fn get(fs: &FileStore, uid: &str) -> Result<Option<String>, StoreError> {
37    let Some(zone) = zone(uid) else { return Ok(None) };
38    let Some(bytes) = fs.read_files_consistent(&zone, &[FILE])?.pop().flatten() else { return Ok(None) };
39    let s: Sidecar = serde_json::from_slice(&bytes).map_err(|e| StoreError::Other(format!("bundle sidecar unreadable: {e}")))?;
40    Ok(Some(s.memory_id).filter(|m| !m.is_empty()))
41}
42
43/// Record `memory_id` as the agent's bundle unless one is recorded already.
44/// Returns the id now recorded — the caller's, or the one another channel
45/// recorded first.
46pub(crate) fn claim(fs: &FileStore, uid: &str, memory_id: &str) -> Result<String, StoreError> {
47    claim_unless_live(fs, uid, memory_id, &|_| true)
48}
49
50/// [`claim`], but a recorded bundle `is_live` says doesn't exist (for the
51/// caller) is replaced by `memory_id`. A channel whose bundles live in a store
52/// others can't see (an isolated dev channel) could record an id first; with
53/// plain first-wins every other channel then failed to find it, minted its
54/// own bundle, lost the claim to the invisible one, and couldn't bind its
55/// own — 17 orphaned bundles per fresh channel on one host
56/// (AgentA had 11).
57pub(crate) fn claim_unless_live(
58    fs: &FileStore,
59    uid: &str,
60    memory_id: &str,
61    is_live: &dyn Fn(&str) -> bool,
62) -> Result<String, StoreError> {
63    let Some(zone) = zone(uid) else { return Ok(memory_id.to_string()) };
64    fs.zone_txn(&zone, |z| {
65        let mut s: Sidecar = match z.read(FILE)? {
66            Some(b) => serde_json::from_slice(&b).map_err(|e| StoreError::Other(format!("bundle sidecar unreadable: {e}")))?,
67            None => Sidecar::default(),
68        };
69        if !s.memory_id.is_empty() && is_live(&s.memory_id) {
70            return Ok(s.memory_id);
71        }
72        s.memory_id = memory_id.to_string();
73        z.put(FILE, &serde_json::to_vec(&s).map_err(|e| StoreError::Other(e.to_string()))?)?;
74        Ok(s.memory_id)
75    })
76}
77
78/// Record a freshly provisioned bundle at runtime, best-effort (the global
79/// store may be off).
80pub(crate) fn record(uid: &str, memory_id: &str) {
81    let Some(fs) = crate::backend::agent_session::global_transcript_store() else { return };
82    if let Err(e) = claim(fs, uid, memory_id) {
83        tracing::warn!(uid, error = %e, "agent bundle sidecar not recorded");
84    }
85}
86
87/// The global store, opened directly — for migrations, which run before
88/// srv attaches it. `None` when it can't be resolved or opened.
89pub(crate) fn open_global() -> Option<FileStore> {
90    let dir = crate::registry::resolve_shared_transcripts_dir()?;
91    std::fs::create_dir_all(&dir).ok()?;
92    FileStore::open(&dir.join("filestore.db")).ok()
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn the_first_recorded_bundle_wins() {
101        let fs = FileStore::open_in_memory().unwrap();
102        assert_eq!(get(&fs, "agent-a").unwrap(), None);
103        assert_eq!(claim(&fs, "agent-a", "b1").unwrap(), "b1");
104        assert_eq!(claim(&fs, "agent-a", "b2").unwrap(), "b1", "another channel's later bundle doesn't replace it");
105        assert_eq!(get(&fs, "agent-a").unwrap().as_deref(), Some("b1"));
106        assert_eq!(get(&fs, "agent-b").unwrap(), None);
107    }
108
109    #[test]
110    fn a_recorded_bundle_the_caller_cant_find_is_replaced() {
111        let fs = FileStore::open_in_memory().unwrap();
112        assert_eq!(claim(&fs, "agent-a", "invisible").unwrap(), "invisible");
113        let live = |id: &str| id != "invisible";
114        assert_eq!(claim_unless_live(&fs, "agent-a", "b2", &live).unwrap(), "b2");
115        assert_eq!(get(&fs, "agent-a").unwrap().as_deref(), Some("b2"));
116        // A live record still wins.
117        assert_eq!(claim_unless_live(&fs, "agent-a", "b3", &live).unwrap(), "b2");
118    }
119}