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 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}