Skip to main content

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/session.rs`) but was **never written** by production
11//! code, so it was always `null`. A fresh-build / cross-channel open therefore
12//! had no sid to resume and spawned a brand-new session, which then shadowed
13//! the original.
14//! See docs/retro/retro-cross-channel-conversation-continuity-regression-2026-06-16.md.
15//!
16//! Production code now also writes it on every capture (`core::persist_session_id`,
17//! mirrored to the registry by the instance store); this idempotent startup pass
18//! backfills records that predate that write.
19
20use crate::backend::claude_layout::project_dir_name;
21use crate::registry::Registry;
22use std::path::{Path, PathBuf};
23
24/// The largest `(size, session-id)` directly under `projects_dir/<slug>`, or
25/// `None` if the dir doesn't exist / has no session files.
26fn largest_with_size(projects_dir: &Path, slug: &str) -> Option<(u64, String)> {
27    let dir = projects_dir.join(slug);
28    let mut best: Option<(u64, String)> = None;
29    for entry in std::fs::read_dir(&dir).ok()?.flatten() {
30        let p = entry.path();
31        if p.extension().map_or(false, |e| e == "jsonl") {
32            if let (Ok(meta), Some(stem)) = (entry.metadata(), p.file_stem()) {
33                let stem = stem.to_string_lossy().to_string();
34                if best.as_ref().map_or(true, |(sz, _)| meta.len() > *sz) {
35                    best = Some((meta.len(), stem));
36                }
37            }
38        }
39    }
40    best
41}
42
43/// The provider session id (jsonl stem) of the agent's **largest** session across
44/// the candidate project roots (the account-wide default and, for identity-bound
45/// agents, the account's home). We pick the largest, not the newest, on purpose:
46/// an accidental fresh session (one started when a blank cross-channel open failed
47/// to resume) must never win over the real conversation. `None` when there are no
48/// session files under any root.
49pub fn largest_session_id(projects_dirs: &[PathBuf], slug: &str) -> Option<String> {
50    let mut best: Option<(u64, String)> = None;
51    for d in projects_dirs {
52        if let Some((sz, stem)) = largest_with_size(d, slug) {
53            if best.as_ref().map_or(true, |(b, _)| sz > *b) {
54                best = Some((sz, stem));
55            }
56        }
57    }
58    best.map(|(_, stem)| stem)
59}
60
61/// Recovery lookup for a confirmed-stale `--resume` failure
62/// (`docs/status/STATUS_CROSS_CHANNEL_RESUME_STALE_SESSION_ID_2026_08_20.md`):
63/// rather than giving up and starting a blank conversation the moment a
64/// registry/inherited `session_id` turns out to be unreachable, look for
65/// the largest session actually on disk under this exact `config_dir` (the
66/// `CLAUDE_CONFIG_DIR` the CLI process is about to run with) for
67/// `working_dir`. `None` when `config_dir` is empty/unset (nothing to
68/// search) or no session exists — callers fall back to starting blank in
69/// either case, same as before this existed.
70///
71/// `config_dir` is expanded the same way the actual spawn path expands
72/// every env var (`core::apply_working_dir` -> `expand_home_dir_safe`)
73/// before applying it to the child process — a `CLAUDE_CONFIG_DIR` of
74/// `~/.claude` must resolve to the real home directory here too, or this
75/// searches a literal, nonexistent `~/.claude/projects` while the CLI
76/// itself reads the real expanded path, silently defeating recovery for
77/// every `~`-shorthand config dir (Codex P2 on PR #2693).
78///
79/// `working_dir` gets the same treatment, via `core.rs`'s OWN
80/// `expand_home_dir` — not `base::expand_home_dir_safe` above, a
81/// different function; `apply_working_dir` uses each for a different
82/// input and this must mirror both exactly. This one matters even more
83/// than the config-dir case: `agent_open.rs`'s default cwd for any agent
84/// without an explicit `working_directory` is the literal string
85/// `~/.agentmux/agents/<slug>` — the single most common case, not an
86/// edge case — so without this, `project_dir_name` would name the
87/// wrong (literal-tilde) path and recovery would silently fail for
88/// nearly every default-configured agent (Codex P1 on PR #2693, found
89/// after #2693 had already merged — the config-dir half of this same
90/// class of bug was fixed pre-merge, this half wasn't caught in time).
91pub fn find_largest_session_for_working_dir(config_dir: &str, working_dir: &str) -> Option<String> {
92    if config_dir.is_empty() {
93        return None;
94    }
95    let expanded = crate::backend::base::expand_home_dir_safe(config_dir);
96    let projects_dir = expanded.join("projects");
97    let expanded_working_dir = crate::backend::blockcontroller::core::expand_home_dir(working_dir);
98    let slug = project_dir_name(&expanded_working_dir);
99    largest_session_id(&[projects_dir], &slug)
100}
101
102/// Absolute path of the transcript file `--resume <sid>` would need to find:
103/// `<config_dir>/projects/<slug(working_dir)>/<sid>.jsonl`.
104///
105/// Shares [`find_largest_session_for_working_dir`]'s expansion rules exactly —
106/// `expand_home_dir_safe` for the config dir, `core::expand_home_dir` for the
107/// working dir — because the two answer the same question from opposite ends
108/// ("which session is there?" vs "is THIS session there?"), and a preflight
109/// that expanded paths differently from the recovery scan would contradict it.
110/// `None` when `config_dir` or `sid` is empty (nothing to look for).
111pub fn session_file_path(config_dir: &str, working_dir: &str, sid: &str) -> Option<PathBuf> {
112    if config_dir.is_empty() || sid.is_empty() {
113        return None;
114    }
115    let expanded = crate::backend::base::expand_home_dir_safe(config_dir);
116    let expanded_working_dir = crate::backend::blockcontroller::core::expand_home_dir(working_dir);
117    let slug = project_dir_name(&expanded_working_dir);
118    Some(
119        expanded
120            .join("projects")
121            .join(slug)
122            .join(format!("{sid}.jsonl")),
123    )
124}
125
126/// Would `--resume <sid>` find its conversation under this exact
127/// `CLAUDE_CONFIG_DIR`? This is the whole of what the CLI checks before
128/// answering "No conversation found with session ID" — so asking it up front
129/// predicts a resume's success without spawning anything.
130pub fn session_is_reachable(config_dir: &str, working_dir: &str, sid: &str) -> bool {
131    session_file_path(config_dir, working_dir, sid)
132        .map(|p| p.is_file())
133        .unwrap_or(false)
134}
135
136/// Populate `session_id` for registry records that lack one, from the agent's
137/// largest provider session. Idempotent (skips records that already carry a
138/// non-empty id). Returns the number populated. Best-effort per record — a
139/// record with no `source_agents_base` or no transcript is skipped, never fatal.
140pub fn backfill_session_ids(reg: &Registry, shared_dir: &Path) -> usize {
141    let records = match reg.list_active() {
142        Ok(r) => r,
143        Err(_) => return 0,
144    };
145    let default_projects = shared_dir
146        .join("providers")
147        .join("claude")
148        .join("projects");
149    let mut count = 0;
150    for mut rec in records {
151        if rec.data.session_id.as_deref().map_or(false, |s| !s.is_empty()) {
152            continue; // already wired
153        }
154        let Some(base) = rec.data.source_agents_base.as_deref() else {
155            continue;
156        };
157        let base = base.trim_end_matches(['/', '\\']);
158        let workspace = format!("{base}/{}", rec.data.working_dir);
159        let slug = project_dir_name(&workspace);
160        // Candidate project roots: the account-wide default, plus the agent's
161        // account's home when it's bound to an account — account-bound
162        // agents write sessions under `identities/<id>/claude/projects` (per
163        // `history::claude_adapter` discovery). [reagent #1479 P2]
164        let mut dirs = vec![default_projects.clone()];
165        if let Some(id) = rec.data.identity_id.as_deref() {
166            if !id.is_empty() && id != "default" {
167                dirs.push(
168                    shared_dir
169                        .join("identities")
170                        .join(id)
171                        .join("claude")
172                        .join("projects"),
173                );
174            }
175        }
176        let Some(sid) = largest_session_id(&dirs, &slug) else {
177            continue;
178        };
179        rec.data.session_id = Some(sid.clone());
180        if reg.upsert(&rec).is_ok() {
181            count += 1;
182            tracing::info!(
183                instance = %rec.data.instance_name,
184                session_id = %sid,
185                "registry: backfilled session_id for cross-channel resume"
186            );
187        }
188    }
189    count
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::registry::{NamedAgentRecord, NamedAgentRecordV1};
196    use std::fs;
197
198    /// The folder name below is what the Claude Code CLI itself computes for
199    /// this cwd (its `RC()`, run under Node against the CLI bundled with
200    /// AgentMux 0.57.0), not what any AgentMux function computes. A resume
201    /// check that derives a different name reports a present transcript as
202    /// unreachable, and the agent loses its native resume.
203    #[test]
204    fn session_is_reachable_in_the_folder_the_cli_names_for_an_underscore_and_a_space() {
205        let tmp = tempfile::tempdir().unwrap();
206        let dir = tmp.path().join("projects").join("-home-u-my-project-dir");
207        fs::create_dir_all(&dir).unwrap();
208        fs::write(dir.join("sid-1.jsonl"), b"{}").unwrap();
209
210        assert!(session_is_reachable(
211            &tmp.path().to_string_lossy(),
212            "/home/u/my_project dir",
213            "sid-1",
214        ));
215    }
216
217    #[test]
218    fn largest_session_beats_a_fresh_short_one() {
219        let tmp = tempfile::tempdir().unwrap();
220        let projects = tmp.path().to_path_buf();
221        let slug = "C--Users-x--agentmux-agents-naki";
222        let dir = projects.join(slug);
223        fs::create_dir_all(&dir).unwrap();
224        // The original long conversation...
225        fs::write(dir.join("91f26930-long.jsonl"), vec![b'x'; 6_000_000]).unwrap();
226        // ...and the accidental fresh short one started after the bug.
227        fs::write(dir.join("e96ed91b-short.jsonl"), vec![b'x'; 15_000]).unwrap();
228        // The recovery guarantee: pick the LARGE original, never the tiny recent.
229        assert_eq!(
230            largest_session_id(&[projects.clone()], slug).as_deref(),
231            Some("91f26930-long")
232        );
233        // Missing project dir is graceful.
234        assert_eq!(largest_session_id(&[projects], "nope"), None);
235    }
236
237    #[test]
238    fn find_largest_session_for_working_dir_locates_the_real_transcript() {
239        let tmp = tempfile::tempdir().unwrap();
240        let config_dir = tmp.path();
241        let working_dir = r"C:\Users\user\.agentmux\agents\agentx-0623n";
242        let slug = project_dir_name(working_dir);
243        let dir = config_dir.join("projects").join(&slug);
244        fs::create_dir_all(&dir).unwrap();
245        fs::write(dir.join("972a6a4f-live.jsonl"), vec![b'x'; 2_800_000]).unwrap();
246
247        assert_eq!(
248            find_largest_session_for_working_dir(&config_dir.to_string_lossy(), working_dir).as_deref(),
249            Some("972a6a4f-live")
250        );
251    }
252
253    #[test]
254    fn find_largest_session_for_working_dir_is_none_for_an_empty_config_dir() {
255        // An unset CLAUDE_CONFIG_DIR (empty string) must not be treated as
256        // "search the current directory" — nothing to recover from.
257        assert_eq!(
258            find_largest_session_for_working_dir("", r"C:\Users\user\.agentmux\agents\agentx-0623n"),
259            None
260        );
261    }
262
263    #[test]
264    fn find_largest_session_for_working_dir_is_none_when_nothing_exists_there() {
265        let tmp = tempfile::tempdir().unwrap();
266        assert_eq!(
267            find_largest_session_for_working_dir(
268                &tmp.path().to_string_lossy(),
269                r"C:\Users\user\.agentmux\agents\nobody-here",
270            ),
271            None
272        );
273    }
274
275    /// Codex P2 on PR #2693: `config_dir` must be expanded the same way the
276    /// actual spawn path expands every env var (`core::apply_working_dir`'s
277    /// `expand_home_dir_safe`) — a literal, un-expanded `~/...` must not be
278    /// searched as-is, or recovery silently fails for every config dir that
279    /// uses `~`-shorthand. Writes into a uniquely-named folder under the
280    /// REAL home directory (there's no way to test `~` resolution without
281    /// one) and removes it afterward regardless of outcome.
282    #[test]
283    fn find_largest_session_for_working_dir_expands_a_tilde_config_dir() {
284        let home = dirs::home_dir().expect("test requires a resolvable home dir");
285        let rel = format!(".agentmux-test-tilde-expansion-{}", std::process::id());
286        let config_dir_abs = home.join(&rel);
287        let working_dir = r"C:\Users\test\.agentmux\agents\tilde-test";
288        let slug = project_dir_name(working_dir);
289        let dir = config_dir_abs.join("projects").join(&slug);
290        fs::create_dir_all(&dir).unwrap();
291        fs::write(dir.join("abc123-session.jsonl"), b"{}").unwrap();
292
293        let result = find_largest_session_for_working_dir(&format!("~/{rel}"), working_dir);
294
295        fs::remove_dir_all(&config_dir_abs).ok();
296
297        assert_eq!(
298            result.as_deref(),
299            Some("abc123-session"),
300            "a ~-prefixed config_dir must resolve to the real home directory, not be searched literally"
301        );
302    }
303
304    /// Codex P1 on PR #2693 (found post-merge, in a follow-up review pass
305    /// against the fix commit): `working_dir` needs the exact same
306    /// expansion `config_dir` got above — via `core.rs`'s OWN
307    /// `expand_home_dir`, a different function from `base::
308    /// expand_home_dir_safe` used for `config_dir`, since
309    /// `apply_working_dir` uses one for each. This is the MORE important
310    /// half: `agent_open.rs`'s default cwd for any agent with no explicit
311    /// `working_directory` is literally `~/.agentmux/agents/<slug>` — not
312    /// an edge case, the single most common configuration — so without
313    /// this, recovery silently fails for nearly every default agent.
314    #[test]
315    fn find_largest_session_for_working_dir_expands_a_tilde_working_dir() {
316        let home = dirs::home_dir().expect("test requires a resolvable home dir");
317        let config_rel = format!(".agentmux-test-tilde-workdir-config-{}", std::process::id());
318        let config_dir_abs = home.join(&config_rel);
319        // Mirrors agent_open.rs's actual default cwd format exactly.
320        let working_dir_rel = format!(".agentmux-test-tilde-workdir-agent-{}", std::process::id());
321        let working_dir_tilde = format!("~/{working_dir_rel}");
322        let expanded_working_dir = home.join(&working_dir_rel);
323
324        let slug = project_dir_name(&expanded_working_dir.to_string_lossy());
325        let dir = config_dir_abs.join("projects").join(&slug);
326        fs::create_dir_all(&dir).unwrap();
327        fs::write(dir.join("def456-session.jsonl"), b"{}").unwrap();
328
329        let result = find_largest_session_for_working_dir(&config_dir_abs.to_string_lossy(), &working_dir_tilde);
330
331        fs::remove_dir_all(&config_dir_abs).ok();
332
333        assert_eq!(
334            result.as_deref(),
335            Some("def456-session"),
336            "a ~-prefixed working_dir (agent_open.rs's actual default cwd shape) must resolve to \
337             the real home directory, not be encoded literally with the tilde"
338        );
339    }
340
341    fn rec(id: &str, base: Option<&str>, wd: &str, sid: Option<&str>) -> NamedAgentRecord {
342        NamedAgentRecord {
343            schema_version: 3,
344            data: NamedAgentRecordV1 {
345                instance_id: id.to_string(),
346                instance_name: id.to_string(),
347                definition_id: "claude-code".to_string(),
348                identity_id: Some("default".to_string()),
349                memory_id: None,
350                session_id: sid.map(String::from),
351                working_dir: wd.to_string(),
352                source_agents_base: base.map(String::from),
353                created_at_ms: 1,
354                last_launched_at_ms: 1,
355                created_by_version: "(legacy)".to_string(),
356                last_launched_by_version: "(legacy)".to_string(),
357            },
358        }
359    }
360
361    #[test]
362    fn backfill_fills_empties_picks_largest_and_is_idempotent() {
363        let tmp = tempfile::tempdir().unwrap();
364        let shared = tmp.path();
365        let projects = shared.join("providers").join("claude").join("projects");
366        let base = r"C:\agents";
367        // Provider sessions for agent "naki" (working_dir naki-0612a).
368        let slug = project_dir_name(&format!("{base}/naki-0612a"));
369        let dir = projects.join(&slug);
370        fs::create_dir_all(&dir).unwrap();
371        fs::write(dir.join("LONG.jsonl"), vec![b'x'; 1_000_000]).unwrap();
372        fs::write(dir.join("short.jsonl"), vec![b'x'; 1_000]).unwrap();
373
374        let reg = Registry::open(shared.join("registry")).unwrap();
375        reg.upsert(&rec("naki", Some(base), "naki-0612a", None)).unwrap();
376        // Already-wired record must be left untouched.
377        reg.upsert(&rec("keep", Some(base), "keep-x", Some("EXISTING"))).unwrap();
378        // No transcript on disk → skipped, not an error.
379        reg.upsert(&rec("notx", Some(base), "ghost-x", None)).unwrap();
380
381        let n = backfill_session_ids(&reg, shared);
382        assert_eq!(n, 1, "only the one empty record with a transcript is filled");
383
384        let by = |id: &str| {
385            reg.list_active()
386                .unwrap()
387                .into_iter()
388                .find(|r| r.data.instance_id == id)
389                .unwrap()
390        };
391        assert_eq!(by("naki").data.session_id.as_deref(), Some("LONG"), "largest session");
392        assert_eq!(by("keep").data.session_id.as_deref(), Some("EXISTING"), "untouched");
393        assert_eq!(by("notx").data.session_id, None, "no transcript → left null");
394
395        // Idempotent: a second pass fills nothing.
396        assert_eq!(backfill_session_ids(&reg, shared), 0);
397    }
398
399    #[test]
400    fn backfill_resolves_account_home_sessions() {
401        // An identity-bound agent writes sessions under
402        // identities/<id>/claude/projects, NOT the default root. [reagent #1479 P2]
403        let tmp = tempfile::tempdir().unwrap();
404        let shared = tmp.path();
405        let base = r"C:\agents";
406        let slug = project_dir_name(&format!("{base}/bound-0612a"));
407        let idir = shared
408            .join("identities")
409            .join("bundle1")
410            .join("claude")
411            .join("projects")
412            .join(&slug);
413        fs::create_dir_all(&idir).unwrap();
414        fs::write(idir.join("BOUND.jsonl"), vec![b'x'; 500_000]).unwrap();
415
416        let reg = Registry::open(shared.join("registry")).unwrap();
417        let mut r = rec("bound", Some(base), "bound-0612a", None);
418        r.data.identity_id = Some("bundle1".to_string());
419        reg.upsert(&r).unwrap();
420
421        assert_eq!(backfill_session_ids(&reg, shared), 1);
422        let got = reg
423            .list_active()
424            .unwrap()
425            .into_iter()
426            .find(|x| x.data.instance_id == "bound")
427            .unwrap();
428        assert_eq!(
429            got.data.session_id.as_deref(),
430            Some("BOUND"),
431            "identity-bound session resolved from the bundle dir"
432        );
433    }
434}