agentmux_srv\backend/
session_backfill.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Backfill the registry record `session_id` from each agent's provider
5//! transcript, so a cross-channel open `--resume`s the original conversation
6//! instead of starting a fresh session.
7//!
8//! Background: the registry `session_id` is **read** on launch (surfaced to the
9//! picker, which passes it as `--resume <sid>` on the first turn of a reattached
10//! block — `agent_handlers.rs`) but was **never written** by production code, so
11//! it was always `null`. A fresh-build / cross-channel open therefore had no sid
12//! to resume and spawned a brand-new session, which then shadowed the original.
13//! See docs/retro/retro-cross-channel-conversation-continuity-regression-2026-06-16.md.
14//!
15//! Once populated, `--resume` keeps the same session id across turns, so a single
16//! idempotent startup pass keeps continuity solid without per-turn wiring.
17
18use crate::registry::Registry;
19use std::path::{Path, PathBuf};
20
21/// Encode an absolute workspace path to a Claude project-dir slug. Claude Code
22/// replaces each of `/ \ : .` with `-` (lossy — a literal `-` inside a segment is
23/// indistinguishable from a separator; this matches the CLI's own scheme and the
24/// `decode_project_path` direction in `history::claude_adapter`).
25pub fn encode_project_slug(path: &str) -> String {
26    path.chars()
27        .map(|c| if matches!(c, '/' | '\\' | ':' | '.') { '-' } else { c })
28        .collect()
29}
30
31/// The largest `(size, session-id)` directly under `projects_dir/<slug>`, or
32/// `None` if the dir doesn't exist / has no session files.
33fn largest_with_size(projects_dir: &Path, slug: &str) -> Option<(u64, String)> {
34    let dir = projects_dir.join(slug);
35    let mut best: Option<(u64, String)> = None;
36    for entry in std::fs::read_dir(&dir).ok()?.flatten() {
37        let p = entry.path();
38        if p.extension().map_or(false, |e| e == "jsonl") {
39            if let (Ok(meta), Some(stem)) = (entry.metadata(), p.file_stem()) {
40                let stem = stem.to_string_lossy().to_string();
41                if best.as_ref().map_or(true, |(sz, _)| meta.len() > *sz) {
42                    best = Some((meta.len(), stem));
43                }
44            }
45        }
46    }
47    best
48}
49
50/// The provider session id (jsonl stem) of the agent's **largest** session across
51/// the candidate project roots (the account-wide default and, for identity-bound
52/// agents, the identity bundle). We pick the largest, not the newest, on purpose:
53/// an accidental fresh session (one started when a blank cross-channel open failed
54/// to resume) must never win over the real conversation. `None` when there are no
55/// session files under any root.
56pub fn largest_session_id(projects_dirs: &[PathBuf], slug: &str) -> Option<String> {
57    let mut best: Option<(u64, String)> = None;
58    for d in projects_dirs {
59        if let Some((sz, stem)) = largest_with_size(d, slug) {
60            if best.as_ref().map_or(true, |(b, _)| sz > *b) {
61                best = Some((sz, stem));
62            }
63        }
64    }
65    best.map(|(_, stem)| stem)
66}
67
68/// Populate `session_id` for registry records that lack one, from the agent's
69/// largest provider session. Idempotent (skips records that already carry a
70/// non-empty id). Returns the number populated. Best-effort per record — a
71/// record with no `source_agents_base` or no transcript is skipped, never fatal.
72pub fn backfill_session_ids(reg: &Registry, shared_dir: &Path) -> usize {
73    let records = match reg.list_active() {
74        Ok(r) => r,
75        Err(_) => return 0,
76    };
77    let default_projects = shared_dir
78        .join("providers")
79        .join("claude")
80        .join("projects");
81    let mut count = 0;
82    for mut rec in records {
83        if rec.data.session_id.as_deref().map_or(false, |s| !s.is_empty()) {
84            continue; // already wired
85        }
86        let Some(base) = rec.data.source_agents_base.as_deref() else {
87            continue;
88        };
89        let base = base.trim_end_matches(['/', '\\']);
90        let workspace = format!("{base}/{}", rec.data.working_dir);
91        let slug = encode_project_slug(&workspace);
92        // Candidate project roots: the account-wide default, plus the agent's
93        // identity bundle when bound to a non-default identity — identity-bound
94        // agents write sessions under `identities/<id>/claude/projects` (per
95        // `history::claude_adapter` discovery). [reagent #1479 P2]
96        let mut dirs = vec![default_projects.clone()];
97        if let Some(id) = rec.data.identity_id.as_deref() {
98            if !id.is_empty() && id != "default" {
99                dirs.push(
100                    shared_dir
101                        .join("identities")
102                        .join(id)
103                        .join("claude")
104                        .join("projects"),
105                );
106            }
107        }
108        let Some(sid) = largest_session_id(&dirs, &slug) else {
109            continue;
110        };
111        rec.data.session_id = Some(sid.clone());
112        if reg.upsert(&rec).is_ok() {
113            count += 1;
114            tracing::info!(
115                instance = %rec.data.instance_name,
116                session_id = %sid,
117                "registry: backfilled session_id for cross-channel resume"
118            );
119        }
120    }
121    count
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use crate::registry::{NamedAgentRecord, NamedAgentRecordV1};
128    use std::fs;
129
130    #[test]
131    fn encode_slug_matches_claude_convention() {
132        // Verified against a real on-disk slug (Naki).
133        assert_eq!(
134            encode_project_slug(r"C:\Users\asafe\.agentmux\agents\naki-0612a"),
135            "C--Users-asafe--agentmux-agents-naki-0612a"
136        );
137        // POSIX path; a literal '-' inside a segment is preserved.
138        assert_eq!(
139            encode_project_slug("/home/u/.agentmux/agents/foo-bar"),
140            "-home-u--agentmux-agents-foo-bar"
141        );
142    }
143
144    #[test]
145    fn largest_session_beats_a_fresh_short_one() {
146        let tmp = tempfile::tempdir().unwrap();
147        let projects = tmp.path().to_path_buf();
148        let slug = "C--Users-x--agentmux-agents-naki";
149        let dir = projects.join(slug);
150        fs::create_dir_all(&dir).unwrap();
151        // The original long conversation...
152        fs::write(dir.join("91f26930-long.jsonl"), vec![b'x'; 6_000_000]).unwrap();
153        // ...and the accidental fresh short one started after the bug.
154        fs::write(dir.join("e96ed91b-short.jsonl"), vec![b'x'; 15_000]).unwrap();
155        // The recovery guarantee: pick the LARGE original, never the tiny recent.
156        assert_eq!(
157            largest_session_id(&[projects.clone()], slug).as_deref(),
158            Some("91f26930-long")
159        );
160        // Missing project dir is graceful.
161        assert_eq!(largest_session_id(&[projects], "nope"), None);
162    }
163
164    fn rec(id: &str, base: Option<&str>, wd: &str, sid: Option<&str>) -> NamedAgentRecord {
165        NamedAgentRecord {
166            schema_version: 3,
167            data: NamedAgentRecordV1 {
168                instance_id: id.to_string(),
169                instance_name: id.to_string(),
170                definition_id: "claude-code".to_string(),
171                identity_id: Some("default".to_string()),
172                memory_id: None,
173                session_id: sid.map(String::from),
174                working_dir: wd.to_string(),
175                source_agents_base: base.map(String::from),
176                created_at_ms: 1,
177                last_launched_at_ms: 1,
178                created_by_version: "(legacy)".to_string(),
179                last_launched_by_version: "(legacy)".to_string(),
180            },
181        }
182    }
183
184    #[test]
185    fn backfill_fills_empties_picks_largest_and_is_idempotent() {
186        let tmp = tempfile::tempdir().unwrap();
187        let shared = tmp.path();
188        let projects = shared.join("providers").join("claude").join("projects");
189        let base = r"C:\agents";
190        // Provider sessions for agent "naki" (working_dir naki-0612a).
191        let slug = encode_project_slug(&format!("{base}/naki-0612a"));
192        let dir = projects.join(&slug);
193        fs::create_dir_all(&dir).unwrap();
194        fs::write(dir.join("LONG.jsonl"), vec![b'x'; 1_000_000]).unwrap();
195        fs::write(dir.join("short.jsonl"), vec![b'x'; 1_000]).unwrap();
196
197        let reg = Registry::open(shared.join("registry")).unwrap();
198        reg.upsert(&rec("naki", Some(base), "naki-0612a", None)).unwrap();
199        // Already-wired record must be left untouched.
200        reg.upsert(&rec("keep", Some(base), "keep-x", Some("EXISTING"))).unwrap();
201        // No transcript on disk → skipped, not an error.
202        reg.upsert(&rec("notx", Some(base), "ghost-x", None)).unwrap();
203
204        let n = backfill_session_ids(&reg, shared);
205        assert_eq!(n, 1, "only the one empty record with a transcript is filled");
206
207        let by = |id: &str| {
208            reg.list_active()
209                .unwrap()
210                .into_iter()
211                .find(|r| r.data.instance_id == id)
212                .unwrap()
213        };
214        assert_eq!(by("naki").data.session_id.as_deref(), Some("LONG"), "largest session");
215        assert_eq!(by("keep").data.session_id.as_deref(), Some("EXISTING"), "untouched");
216        assert_eq!(by("notx").data.session_id, None, "no transcript → left null");
217
218        // Idempotent: a second pass fills nothing.
219        assert_eq!(backfill_session_ids(&reg, shared), 0);
220    }
221
222    #[test]
223    fn backfill_resolves_identity_bundle_sessions() {
224        // An identity-bound agent writes sessions under
225        // identities/<id>/claude/projects, NOT the default root. [reagent #1479 P2]
226        let tmp = tempfile::tempdir().unwrap();
227        let shared = tmp.path();
228        let base = r"C:\agents";
229        let slug = encode_project_slug(&format!("{base}/bound-0612a"));
230        let idir = shared
231            .join("identities")
232            .join("bundle1")
233            .join("claude")
234            .join("projects")
235            .join(&slug);
236        fs::create_dir_all(&idir).unwrap();
237        fs::write(idir.join("BOUND.jsonl"), vec![b'x'; 500_000]).unwrap();
238
239        let reg = Registry::open(shared.join("registry")).unwrap();
240        let mut r = rec("bound", Some(base), "bound-0612a", None);
241        r.data.identity_id = Some("bundle1".to_string());
242        reg.upsert(&r).unwrap();
243
244        assert_eq!(backfill_session_ids(&reg, shared), 1);
245        let got = reg
246            .list_active()
247            .unwrap()
248            .into_iter()
249            .find(|x| x.data.instance_id == "bound")
250            .unwrap();
251        assert_eq!(
252            got.data.session_id.as_deref(),
253            Some("BOUND"),
254            "identity-bound session resolved from the bundle dir"
255        );
256    }
257}