Skip to main content

team_core/
session.rs

1//! T-118: deterministic Claude Code session id derivation.
2//!
3//! Every claude-code agent spawn passes `--session-id <uuid>` so the
4//! conversation persists across `teamctl down`/`up` cycles, crash
5//! recovery, and host reboots. The id is a UUIDv5 derived from a
6//! baked-in namespace plus the canonical agent string
7//! `teamctl:<project>:<agent>`.
8//!
9//! Self-healing by construction: if the session-file at that UUID is
10//! deleted (manual cleanup, claude session-dir reset, etc.), the next
11//! spawn passes the same UUID and claude creates a fresh session at
12//! it. No operator action required.
13//!
14//! Stable across rename of *external* things (claude version, host
15//! machine, tmux session) but evicts cleanly when the project or
16//! agent name changes — that's the right semantics: a renamed agent
17//! is a new agent.
18
19use std::ffi::OsString;
20use std::fs;
21use std::io;
22use std::path::{Path, PathBuf};
23
24use uuid::Uuid;
25
26/// Frozen UUIDv5 namespace for teamctl session-id derivation.
27/// Generated once on 2026-05-10 and pinned forever — changing this
28/// would invalidate every previously-resumed session across every
29/// dogfooding installation. Treat as a constant.
30///
31/// Provenance: `uuid::Uuid::new_v4()` on 2026-05-10 in this repo's
32/// development environment, pasted as a literal.
33pub const TEAMCTL_SESSION_NAMESPACE: Uuid = Uuid::from_bytes([
34    0x6d, 0xd6, 0xc8, 0xa3, 0x44, 0xb6, 0x4a, 0x18, 0x9b, 0x05, 0x91, 0xc1, 0xe2, 0x57, 0xfb, 0x3d,
35]);
36
37/// Compose the canonical name string for a given project/agent pair.
38/// Used as the UUIDv5 input AND surfaced verbatim to claude via the
39/// `-n <name>` flag so the operator sees the agent identity in
40/// claude's session picker / prompt box.
41pub fn session_name(project: &str, agent: &str) -> String {
42    format!("teamctl:{project}:{agent}")
43}
44
45/// Derive the deterministic Claude Code session id for a given
46/// project/agent pair. Same inputs always yield the same UUID;
47/// different agent or project yields a different UUID.
48pub fn derive_session_id(project: &str, agent: &str) -> Uuid {
49    Uuid::new_v5(
50        &TEAMCTL_SESSION_NAMESPACE,
51        session_name(project, agent).as_bytes(),
52    )
53}
54
55/// `~/.claude`, derived from `$HOME` — the same base the agent wrapper
56/// probes (`$HOME/.claude/projects/*/<uuid>.jsonl`, agent-wrapper.sh:166).
57/// `None` when `$HOME` is unset, so callers can warn-and-skip rather than
58/// guess a path.
59pub fn claude_home() -> Option<PathBuf> {
60    std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".claude"))
61}
62
63/// T-352: move aside the on-disk Claude session JSONL for `(project, agent)`
64/// so the wrapper's resume-probe misses on the next spawn and Claude opens a
65/// brand-new conversation at the *same* deterministic UUID (re-running
66/// `BOOTSTRAP_PROMPT`). The `--fresh` escape hatch from always-on resume
67/// (T-118); durable on-disk files are never touched — only the session JSONL.
68///
69/// `claude_home` is `~/.claude` (injected so tests don't touch the real home).
70/// Globs `projects/*/<uuid>.jsonl` exactly like the wrapper, because Claude's
71/// cwd→project-dir slug is observed-not-documented; the UUIDv5 is globally
72/// unique so at most one file ever matches.
73///
74/// The move is a single `rename(2)` to `<uuid>.jsonl.bak` within the same
75/// directory — atomic, with no half-moved state. The prior conversation is
76/// preserved (not deleted) as a one-slot recovery; a subsequent `--fresh`
77/// replaces it. A crash after the rename but before respawn is fail-safe: the
78/// next boot finds no JSONL and comes up fresh anyway, which is exactly the
79/// requested intent. A non-`--fresh` boot never moves anything, so a session
80/// the operator wanted to keep can never be lost by this path.
81///
82/// Returns the `.bak` path on a successful move, or `None` when there was no
83/// session on disk (agent never ran, or already fresh).
84pub fn freshen_session(
85    claude_home: &Path,
86    project: &str,
87    agent: &str,
88) -> io::Result<Option<PathBuf>> {
89    let filename = format!("{}.jsonl", derive_session_id(project, agent));
90    let projects_dir = claude_home.join("projects");
91    let entries = match fs::read_dir(&projects_dir) {
92        Ok(e) => e,
93        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
94        Err(e) => return Err(e),
95    };
96    for entry in entries.flatten() {
97        let candidate = entry.path().join(&filename);
98        if candidate.is_file() {
99            // Append `.bak` to the full filename — `with_extension` would
100            // drop `.jsonl` and produce `<uuid>.bak`.
101            let mut bak: OsString = candidate.clone().into_os_string();
102            bak.push(".bak");
103            let bak = PathBuf::from(bak);
104            fs::rename(&candidate, &bak)?;
105            return Ok(Some(bak));
106        }
107    }
108    Ok(None)
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn same_inputs_yield_same_uuid() {
117        // Determinism is the core invariant — every claude-code spawn
118        // for `(hello, mgr)` must hit the same on-disk session file.
119        let a = derive_session_id("hello", "mgr");
120        let b = derive_session_id("hello", "mgr");
121        assert_eq!(a, b);
122    }
123
124    #[test]
125    fn different_agents_yield_different_uuids() {
126        // Agents within the same project must not collide on
127        // session-id; if they did, two agents would share a single
128        // claude conversation and stomp each other's context.
129        let mgr = derive_session_id("hello", "mgr");
130        let dev = derive_session_id("hello", "dev");
131        assert_ne!(mgr, dev);
132    }
133
134    #[test]
135    fn different_projects_yield_different_uuids() {
136        // Cross-project isolation: same agent name in two different
137        // projects must resolve to two different sessions.
138        let a = derive_session_id("alpha", "mgr");
139        let b = derive_session_id("beta", "mgr");
140        assert_ne!(a, b);
141    }
142
143    #[test]
144    fn namespace_constant_is_stable() {
145        // Pin the namespace bytes so an accidental edit (anyone
146        // tempted to "regenerate the constant for cleanliness")
147        // surfaces here instead of silently invalidating every
148        // existing session across every install.
149        assert_eq!(
150            TEAMCTL_SESSION_NAMESPACE.to_string(),
151            "6dd6c8a3-44b6-4a18-9b05-91c1e257fb3d"
152        );
153    }
154
155    #[test]
156    fn derived_uuid_is_v5_and_uses_namespace() {
157        // Cross-check against an independent re-computation so a
158        // refactor of `derive_session_id` that accidentally changes
159        // the namespace or the input shape can't pass.
160        let derived = derive_session_id("hello", "mgr");
161        let expected = Uuid::new_v5(&TEAMCTL_SESSION_NAMESPACE, b"teamctl:hello:mgr");
162        assert_eq!(derived, expected);
163        assert_eq!(derived.get_version_num(), 5);
164    }
165
166    #[test]
167    fn session_name_format_is_canonical() {
168        // The `-n <name>` value the operator sees in claude's session
169        // picker should mirror the UUID input shape so a session-id
170        // collision (would be astronomical, but) is debuggable from
171        // the human-readable name alone.
172        assert_eq!(session_name("hello", "mgr"), "teamctl:hello:mgr");
173    }
174
175    // ── T-352: freshen_session ───────────────────────────────────────
176
177    /// Stage `<claude_home>/projects/<slug>/<uuid>.jsonl` for an agent and
178    /// return its path. Mirrors Claude's on-disk layout the wrapper probes.
179    fn stage_session(claude_home: &Path, slug: &str, project: &str, agent: &str) -> PathBuf {
180        let dir = claude_home.join("projects").join(slug);
181        std::fs::create_dir_all(&dir).unwrap();
182        let jsonl = dir.join(format!("{}.jsonl", derive_session_id(project, agent)));
183        std::fs::write(&jsonl, "session-bytes").unwrap();
184        jsonl
185    }
186
187    #[test]
188    fn freshen_moves_existing_session_aside() {
189        let home = tempfile::tempdir().unwrap();
190        let jsonl = stage_session(home.path(), "-Users-x-proj", "hello", "mgr");
191
192        let bak = freshen_session(home.path(), "hello", "mgr").unwrap();
193
194        let bak = bak.expect("a staged session is reported moved");
195        assert!(!jsonl.exists(), "original JSONL is gone after freshen");
196        assert!(bak.exists(), "the .bak recovery copy exists");
197        assert_eq!(bak.extension().unwrap(), "bak");
198        // `.jsonl` is preserved before `.bak` (not clobbered by with_extension).
199        assert!(bak.to_string_lossy().ends_with(".jsonl.bak"));
200        assert_eq!(std::fs::read_to_string(&bak).unwrap(), "session-bytes");
201    }
202
203    #[test]
204    fn freshen_is_noop_when_no_matching_session() {
205        let home = tempfile::tempdir().unwrap();
206        // A different agent's session is present; ours is not.
207        stage_session(home.path(), "-Users-x-proj", "hello", "other");
208
209        let bak = freshen_session(home.path(), "hello", "mgr").unwrap();
210        assert!(bak.is_none(), "no move when our UUID has no JSONL on disk");
211    }
212
213    #[test]
214    fn freshen_is_noop_when_projects_dir_absent() {
215        let home = tempfile::tempdir().unwrap();
216        // `~/.claude/projects` never created — agent never ran.
217        let bak = freshen_session(home.path(), "hello", "mgr").unwrap();
218        assert!(bak.is_none());
219    }
220
221    #[test]
222    fn freshen_only_touches_the_session_jsonl() {
223        // Durable on-disk state must survive --fresh. Stage a sibling file
224        // next to the session and assert freshen leaves it alone.
225        let home = tempfile::tempdir().unwrap();
226        let jsonl = stage_session(home.path(), "-Users-x-proj", "hello", "mgr");
227        let sibling = jsonl.with_file_name("task.md");
228        std::fs::write(&sibling, "durable").unwrap();
229
230        freshen_session(home.path(), "hello", "mgr")
231            .unwrap()
232            .unwrap();
233
234        assert!(!jsonl.exists());
235        assert_eq!(std::fs::read_to_string(&sibling).unwrap(), "durable");
236    }
237}