From 9e146788440cfb344abda35902743d1b301e3189 Mon Sep 17 00:00:00 2001 From: Muhammad Ali Hashar Date: Sat, 17 Oct 2026 12:54:22 -0400 Subject: [PATCH] fix(local): resolve a duplicated session id to its most recently modified copy --- CHANGELOG.md | 4 + cli/tests/duplicate_ids.rs | 156 +++++++++++++++++++++++++++++++++++++ docs/usage.md | 2 +- src/local.rs | 97 ++++++++++++++++++++++- tests/README.md | 3 + 5 files changed, 259 insertions(+), 3 deletions(-) create mode 100644 cli/tests/duplicate_ids.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 107c4d4..b4fae10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ Each release is published to [crates.io](https://crates.io/crates/txcript), ## Unreleased +### Fixed + +- Resolve a session id that exists in several stores to the most recently modified copy. `view`, `export`, `continue` and `crop` used to load whichever copy the file system listed first; discovery now breaks start-time ties by file mtime. + ### Added - Add read-side session lineage (`Meta::lineage`): fork/continue/spawn relationships read from diff --git a/cli/tests/duplicate_ids.rs b/cli/tests/duplicate_ids.rs new file mode 100644 index 0000000..7903bef --- /dev/null +++ b/cli/tests/duplicate_ids.rs @@ -0,0 +1,156 @@ +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +//! Copies of one session id (Claude Code writes a second copy under another +//! project folder when a session is resumed from another cwd) must resolve to +//! the most recently modified copy, whichever order the file system walks the +//! project folders in. Runs the real `txcript` binary against a synthetic +//! Claude Code store in a temp dir; every other store is pointed at an empty +//! temp dir so no real history is read. + +use std::fs; +use std::path::Path; +use std::process::Command; +use std::time::{Duration, SystemTime}; + +const ID: &str = "11111111-aaaa-4aaa-8aaa-000000000001"; +const OTHER_ID: &str = "11111111-bbbb-4bbb-8bbb-000000000002"; + +fn line(id: &str, n: usize, text: &str) -> String { + format!( + r#"{{"type":"user","uuid":"u{n}","parentUuid":null,"sessionId":"{id}","cwd":"/work/proj","timestamp":"2026-01-02T03:04:{n:02}.000Z","message":{{"role":"user","content":"{text}"}}}}"# + ) +} + +/// Write a copy of session `id` into project folder `dir` with `messages` +/// lines and a file mtime `mtime` seconds after a fixed epoch. The last message of +/// a copy names the copy, so output shows which one was loaded. +fn write_copy(projects: &Path, dir: &str, id: &str, messages: usize, tag: &str, mtime: u64) { + let folder = projects.join(dir); + fs::create_dir_all(&folder).unwrap(); + let mut body = String::new(); + for n in 1..messages { + body.push_str(&line(id, n, "shared history")); + body.push('\n'); + } + body.push_str(&line(id, messages, tag)); + body.push('\n'); + let path = folder.join(format!("{id}.jsonl")); + fs::write(&path, body).unwrap(); + let file = fs::File::options().write(true).open(&path).unwrap(); + file.set_modified(SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000 + mtime)) + .unwrap(); +} + +struct Env { + home: tempfile::TempDir, +} + +impl Env { + fn new() -> Self { + Self { + home: tempfile::tempdir().unwrap(), + } + } + + fn projects(&self) -> std::path::PathBuf { + self.home.path().join(".claude").join("projects") + } + + fn run(&self, args: &[&str]) -> (bool, String) { + let empty = self.home.path().join("empty"); + let mut cmd = Command::new(env!("CARGO_BIN_EXE_txcript")); + cmd.env_clear() + .env("HOME", self.home.path()) + .env("USERPROFILE", self.home.path()) + .env("CLAUDE_CONFIG_DIR", self.home.path().join(".claude")); + for var in [ + "CODEX_HOME", + "XDG_DATA_HOME", + "XDG_CONFIG_HOME", + "PI_CODING_AGENT_DIR", + "OPENCODE_DB", + "HERMES_HOME", + "FX_HOME", + "GROK_HOME", + "COWORK_SESSIONS_DIR", + "AMP_THREADS_DIR", + "CURSOR_DESKTOP_USER_DIR", + "TXCRIPT_GROK_BOT_ROOT", + ] { + cmd.env(var, &empty); + } + let out = cmd + .args(args) + .args(["--from", "claude-code"]) + .output() + .unwrap(); + let text = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + (out.status.success(), text) + } +} + +/// Four copies whose mtime order is unrelated to their folder names or +/// creation order, so neither can pick the newest by accident. +fn four_copies(env: &Env) { + let p = env.projects(); + write_copy(&p, "m-dir", ID, 4, "COPY-OLDEST", 10); + write_copy(&p, "a-dir", ID, 5, "COPY-NEWEST", 40); + write_copy(&p, "z-dir", ID, 4, "COPY-MIDDLE", 30); + write_copy(&p, "b-dir", ID, 4, "COPY-OLD", 20); +} + +#[test] +fn view_loads_the_newest_copy_of_a_duplicated_id() { + let env = Env::new(); + four_copies(&env); + let (ok, out) = env.run(&["view", ID]); + assert!(ok, "{out}"); + assert!(out.contains("COPY-NEWEST"), "{out}"); + assert!( + !out.contains("COPY-OLD") && !out.contains("COPY-MIDDLE"), + "{out}" + ); +} + +#[test] +fn export_loads_the_newest_copy_of_a_duplicated_id() { + let env = Env::new(); + four_copies(&env); + let (ok, out) = env.run(&["export", ID]); + assert!(ok, "{out}"); + assert!(out.contains("COPY-NEWEST"), "{out}"); +} + +#[test] +fn an_id_prefix_resolves_to_the_newest_copy() { + let env = Env::new(); + four_copies(&env); + let (ok, out) = env.run(&["view", "11111111-aaaa"]); + assert!(ok, "{out}"); + assert!(out.contains("COPY-NEWEST"), "{out}"); +} + +#[test] +fn different_ids_sharing_a_prefix_stay_ambiguous() { + let env = Env::new(); + four_copies(&env); + write_copy(&env.projects(), "a-dir", OTHER_ID, 4, "OTHER", 50); + let (ok, out) = env.run(&["view", "11111111"]); + assert!(!ok, "{out}"); + assert!(out.contains("prefixes 2 session ids"), "{out}"); +} + +#[test] +fn copies_with_the_same_mtime_resolve_to_one_of_them_without_error() { + let env = Env::new(); + let p = env.projects(); + write_copy(&p, "a-dir", ID, 4, "COPY-A", 10); + write_copy(&p, "b-dir", ID, 4, "COPY-B", 10); + let (ok, out) = env.run(&["view", ID]); + assert!(ok, "{out}"); + assert!(out.contains("COPY-A") ^ out.contains("COPY-B"), "{out}"); +} diff --git a/docs/usage.md b/docs/usage.md index e9ad6c8..c1c71cc 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -30,7 +30,7 @@ txcript export [#range] # write a session as a Simple document [--out ] # write to instead of stdout ``` -A session id is any unambiguous prefix of the full id, or the session's exact title. `txcript resume` is an alias for `continue`. `--since` and `--until` take RFC 3339 timestamps or bare `YYYY-MM-DD` dates. +A session id is any unambiguous prefix of the full id, or the session's exact title. When several copies share one id (Claude Code writes a second copy when a session is resumed from another directory), the most recently modified copy is used. `txcript resume` is an alias for `continue`. `--since` and `--until` take RFC 3339 timestamps or bare `YYYY-MM-DD` dates. `continue` writes the session where the target harness keeps its sessions, then launches that harness on it, handing over the terminal: diff --git a/src/local.rs b/src/local.rs index 4c8dd57..916a4fa 100644 --- a/src/local.rs +++ b/src/local.rs @@ -68,7 +68,8 @@ enum Locator { } /// Every local session, newest first. Unreadable stores and sessions are -/// skipped. +/// skipped. Sessions that started at the same instant (such as copies of one +/// id) are ordered by [`Session::updated_at`], newest first. #[must_use] pub fn discover() -> Vec { discover_with(|_, _| {}) @@ -192,10 +193,24 @@ pub fn discover_with(mut on_store: impl FnMut(HarnessId, usize)) -> Vec } } - out.sort_by_key(|s| std::cmp::Reverse(s.meta.timestamp)); + sort_newest_first(&mut out); out } +/// Newest start time first. Copies of one session (Claude Code writes a +/// second store when a session is resumed from another cwd) share a start +/// time, so ties go to the most recently modified file: the first copy of an +/// id is the newest. A missing `updated_at` sorts after a known one; remaining +/// ties keep discovery order (the sort is stable). +fn sort_newest_first(sessions: &mut [Session]) { + sessions.sort_by_key(|s| { + ( + std::cmp::Reverse(s.meta.timestamp), + std::cmp::Reverse(s.updated_at), + ) + }); +} + /// Discover one harness, surfacing remote errors when a live source is /// explicitly requested. Other harnesses retain the tolerant aggregate scan. /// @@ -1256,3 +1271,81 @@ mod resume_template_tests { assert_eq!(session.location(), "hermes db session sess-1"); } } + +#[cfg(test)] +mod sort_tests { + use super::{HarnessId, Locator, Meta, PathBuf, Session, sort_newest_first}; + use chrono::{DateTime, Utc}; + + fn at(s: &str) -> DateTime { + s.parse().unwrap_or_else(|e| panic!("{e}")) + } + + /// A file-backed session; `tag` marks the copy in its path. + fn session(id: &str, started: &str, updated: Option<&str>, tag: &str) -> Session { + Session { + harness: HarnessId::ClaudeCode, + meta: Meta { + id: id.to_string(), + timestamp: at(started), + cwd: None, + git_branch: None, + title: None, + cli_version: None, + model: None, + lineage: None, + }, + updated_at: updated.map(at), + locator: Locator::Path(PathBuf::from(tag)), + } + } + + fn order(sessions: &[Session]) -> Vec { + sessions.iter().map(Session::location).collect() + } + + const START: &str = "2026-01-02T03:04:05Z"; + + #[test] + fn copies_of_one_id_sort_by_newest_file_first_whatever_the_walk_order() { + let mut sessions = vec![ + session("s", START, Some("2026-01-01T00:00:00Z"), "oldest"), + session("s", START, Some("2026-01-04T00:00:00Z"), "newest"), + session("s", START, Some("2026-01-03T00:00:00Z"), "middle"), + ]; + sort_newest_first(&mut sessions); + assert_eq!(order(&sessions), ["newest", "middle", "oldest"]); + } + + #[test] + fn start_time_still_outranks_file_mtime() { + let mut sessions = vec![ + session( + "old", + "2026-01-01T00:00:00Z", + Some("2026-02-01T00:00:00Z"), + "old", + ), + session( + "new", + "2026-01-02T00:00:00Z", + Some("2026-01-02T00:00:00Z"), + "new", + ), + ]; + sort_newest_first(&mut sessions); + assert_eq!(order(&sessions), ["new", "old"]); + } + + #[test] + fn a_known_mtime_beats_a_missing_one_and_ties_keep_discovery_order() { + let mut sessions = vec![ + session("s", START, None, "none-1"), + session("s", START, Some("2026-01-01T00:00:00Z"), "tie-1"), + session("s", START, None, "none-2"), + session("s", START, Some("2026-01-01T00:00:00Z"), "tie-2"), + ]; + sort_newest_first(&mut sessions); + assert_eq!(order(&sessions), ["tie-1", "tie-2", "none-1", "none-2"]); + } +} diff --git a/tests/README.md b/tests/README.md index a01d08c..cb06c99 100644 --- a/tests/README.md +++ b/tests/README.md @@ -70,5 +70,8 @@ can compare across machines. - Every test file opens with `#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]`; the production lint set (workspace `Cargo.toml`) denies all three. +- CLI behavior that needs the real binary (id resolution, for one) is tested + in `cli/tests/`: the `txcript` binary runs against a synthetic store in a + temp dir, with every store environment variable pointed at an empty one. - Feature-gated surfaces gate at the module declaration in `main.rs` (`search`) or on the test (`opencode` store).