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 uuid::Uuid;
20
21/// Frozen UUIDv5 namespace for teamctl session-id derivation.
22/// Generated once on 2026-05-10 and pinned forever — changing this
23/// would invalidate every previously-resumed session across every
24/// dogfooding installation. Treat as a constant.
25///
26/// Provenance: `uuid::Uuid::new_v4()` on 2026-05-10 in this repo's
27/// development environment, pasted as a literal.
28pub const TEAMCTL_SESSION_NAMESPACE: Uuid = Uuid::from_bytes([
29 0x6d, 0xd6, 0xc8, 0xa3, 0x44, 0xb6, 0x4a, 0x18, 0x9b, 0x05, 0x91, 0xc1, 0xe2, 0x57, 0xfb, 0x3d,
30]);
31
32/// Compose the canonical name string for a given project/agent pair.
33/// Used as the UUIDv5 input AND surfaced verbatim to claude via the
34/// `-n <name>` flag so the operator sees the agent identity in
35/// claude's session picker / prompt box.
36pub fn session_name(project: &str, agent: &str) -> String {
37 format!("teamctl:{project}:{agent}")
38}
39
40/// Derive the deterministic Claude Code session id for a given
41/// project/agent pair. Same inputs always yield the same UUID;
42/// different agent or project yields a different UUID.
43pub fn derive_session_id(project: &str, agent: &str) -> Uuid {
44 Uuid::new_v5(
45 &TEAMCTL_SESSION_NAMESPACE,
46 session_name(project, agent).as_bytes(),
47 )
48}
49
50#[cfg(test)]
51mod tests {
52 use super::*;
53
54 #[test]
55 fn same_inputs_yield_same_uuid() {
56 // Determinism is the core invariant — every claude-code spawn
57 // for `(hello, mgr)` must hit the same on-disk session file.
58 let a = derive_session_id("hello", "mgr");
59 let b = derive_session_id("hello", "mgr");
60 assert_eq!(a, b);
61 }
62
63 #[test]
64 fn different_agents_yield_different_uuids() {
65 // Agents within the same project must not collide on
66 // session-id; if they did, two agents would share a single
67 // claude conversation and stomp each other's context.
68 let mgr = derive_session_id("hello", "mgr");
69 let dev = derive_session_id("hello", "dev");
70 assert_ne!(mgr, dev);
71 }
72
73 #[test]
74 fn different_projects_yield_different_uuids() {
75 // Cross-project isolation: same agent name in two different
76 // projects must resolve to two different sessions.
77 let a = derive_session_id("alpha", "mgr");
78 let b = derive_session_id("beta", "mgr");
79 assert_ne!(a, b);
80 }
81
82 #[test]
83 fn namespace_constant_is_stable() {
84 // Pin the namespace bytes so an accidental edit (anyone
85 // tempted to "regenerate the constant for cleanliness")
86 // surfaces here instead of silently invalidating every
87 // existing session across every install.
88 assert_eq!(
89 TEAMCTL_SESSION_NAMESPACE.to_string(),
90 "6dd6c8a3-44b6-4a18-9b05-91c1e257fb3d"
91 );
92 }
93
94 #[test]
95 fn derived_uuid_is_v5_and_uses_namespace() {
96 // Cross-check against an independent re-computation so a
97 // refactor of `derive_session_id` that accidentally changes
98 // the namespace or the input shape can't pass.
99 let derived = derive_session_id("hello", "mgr");
100 let expected = Uuid::new_v5(&TEAMCTL_SESSION_NAMESPACE, b"teamctl:hello:mgr");
101 assert_eq!(derived, expected);
102 assert_eq!(derived.get_version_num(), 5);
103 }
104
105 #[test]
106 fn session_name_format_is_canonical() {
107 // The `-n <name>` value the operator sees in claude's session
108 // picker should mirror the UUID input shape so a session-id
109 // collision (would be astronomical, but) is debuggable from
110 // the human-readable name alone.
111 assert_eq!(session_name("hello", "mgr"), "teamctl:hello:mgr");
112 }
113}