1use std::collections::{HashMap, VecDeque};
45use std::path::{Path, PathBuf};
46use std::sync::{Mutex, MutexGuard, PoisonError};
47use std::time::Duration;
48
49use chrono::{DateTime, Utc};
50use serde::{Deserialize, Serialize};
51use tokio::sync::watch;
52
53use crate::utils::awake_clock::AwakeClock;
54
55#[cfg(unix)]
56pub mod codex_app_server;
57pub mod codex_watcher;
58#[cfg(unix)]
59pub mod journal;
60#[cfg(unix)]
61pub(crate) mod journal_watcher;
62pub(crate) mod pid_liveness;
63pub(crate) mod pid_watcher;
64pub mod relocate;
65pub mod stream;
66pub mod watcher;
67
68const DEFAULT_SESSION_TTL: Duration = Duration::from_secs(300);
93
94const ENDED_SESSION_TTL: Duration = Duration::from_secs(10);
100
101const DEFAULT_WINDOW_TTL: Duration = Duration::from_secs(30);
107
108const MAX_SESSIONS: usize = 512;
114
115const MAX_REPLACED_PIDS: usize = 8;
119
120const MAX_WINDOWS: usize = 256;
122
123const SOCKET_REORDER_WINDOW: chrono::Duration = chrono::Duration::seconds(10);
131
132#[must_use]
138pub fn is_session_uuid(id: &str) -> bool {
139 let mut groups = id.split('-');
140 [8, 4, 4, 4, 12].into_iter().all(|len| {
141 groups
142 .next()
143 .is_some_and(|g| g.len() == len && g.bytes().all(|b| b.is_ascii_hexdigit()))
144 }) && groups.next().is_none()
145}
146
147const MAX_RECENT_SEQS: usize = 32;
152
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162pub struct EventStamp {
163 pub ts: DateTime<Utc>,
165 pub seq: String,
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171pub(crate) enum Origin {
172 Socket,
176 Local,
181 Journal,
185 Replay,
191}
192
193impl Origin {
194 fn is_journal(self) -> bool {
196 matches!(self, Self::Journal | Self::Replay)
197 }
198}
199
200#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
208pub struct DeliveryStats {
209 pub socket: u64,
213 pub recovered: u64,
218 pub superseded: u64,
224 pub replayed: u64,
227}
228
229#[derive(Debug, Clone, Copy, PartialEq, Eq)]
232enum Correction {
233 None,
235 RacedByJournal,
238 LateAfterSuperseded,
241 Superseded,
244}
245
246#[derive(Debug, Default)]
252struct DeliveryCounters {
253 socket: std::sync::atomic::AtomicU64,
254 recovered: std::sync::atomic::AtomicU64,
255 superseded: std::sync::atomic::AtomicU64,
256 replayed: std::sync::atomic::AtomicU64,
257}
258
259impl DeliveryCounters {
260 fn record(&self, origin: Origin, applied: bool, correction: Correction) {
263 use std::sync::atomic::Ordering::Relaxed;
264 let route = match origin {
265 Origin::Socket => Some(&self.socket),
266 Origin::Journal => Some(&self.recovered),
267 Origin::Replay => Some(&self.replayed),
268 Origin::Local => None,
269 };
270 if let (true, Some(counter)) = (applied, route) {
271 counter.fetch_add(1, Relaxed);
272 }
273 match correction {
274 Correction::None => {}
275 Correction::RacedByJournal => saturating_decrement(&self.recovered),
277 Correction::LateAfterSuperseded => saturating_decrement(&self.superseded),
278 Correction::Superseded => {
279 self.superseded.fetch_add(1, Relaxed);
280 }
281 }
282 }
283
284 fn snapshot(&self) -> DeliveryStats {
285 use std::sync::atomic::Ordering::Relaxed;
286 DeliveryStats {
287 socket: self.socket.load(Relaxed),
288 recovered: self.recovered.load(Relaxed),
289 superseded: self.superseded.load(Relaxed),
290 replayed: self.replayed.load(Relaxed),
291 }
292 }
293}
294
295fn saturating_decrement(counter: &std::sync::atomic::AtomicU64) {
301 use std::sync::atomic::Ordering::Relaxed;
302 let mut current = counter.load(Relaxed);
303 while current > 0 {
304 match counter.compare_exchange_weak(current, current - 1, Relaxed, Relaxed) {
305 Ok(_) => break,
306 Err(actual) => current = actual,
307 }
308 }
309}
310
311#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
319#[serde(rename_all = "snake_case")]
320pub enum SessionState {
321 Starting,
323 Working,
326 Idle,
328 WaitingForInput,
330 WaitingForPermission,
332 Ended,
335}
336
337impl SessionState {
338 #[must_use]
370 pub fn for_event(event: &SessionEvent, current: Option<Self>) -> Self {
371 match event {
372 SessionEvent::SessionStart => Self::Starting,
373 SessionEvent::UserPromptSubmit
374 | SessionEvent::PreToolUse
375 | SessionEvent::PostToolUse => Self::Working,
376 SessionEvent::TranscriptGrew | SessionEvent::RolloutTurn(RolloutTurn::Open) => {
405 match current {
406 Some(
407 held @ (Self::Starting
408 | Self::WaitingForInput
409 | Self::WaitingForPermission
410 | Self::Ended),
411 ) => held,
412 _ => Self::Working,
413 }
414 }
415 SessionEvent::RolloutTurn(RolloutTurn::Closed) => match current {
419 Some(Self::Working) | None => Self::Idle,
420 Some(other) => other,
421 },
422 SessionEvent::Stop => Self::Idle,
423 SessionEvent::StreamState(state) => *state,
427 SessionEvent::Notification(NotificationKind::PermissionPrompt) => {
428 Self::WaitingForPermission
429 }
430 SessionEvent::Notification(
431 NotificationKind::IdlePrompt | NotificationKind::AgentNeedsInput,
432 ) => Self::WaitingForInput,
433 SessionEvent::Notification(NotificationKind::Other)
437 | SessionEvent::TranscriptDiscovered => current.unwrap_or(Self::Idle),
438 }
439 }
440}
441
442#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
448#[serde(rename_all = "snake_case")]
449pub enum NotificationKind {
450 PermissionPrompt,
453 IdlePrompt,
455 AgentNeedsInput,
457 Other,
459}
460
461#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
468#[serde(rename_all = "snake_case")]
469pub enum RolloutTurn {
470 Open,
472 Closed,
474}
475
476#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
482#[serde(rename_all = "snake_case")]
483pub enum SessionEvent {
484 SessionStart,
486 UserPromptSubmit,
488 PreToolUse,
490 PostToolUse,
492 Stop,
494 Notification(NotificationKind),
496 TranscriptGrew,
499 TranscriptDiscovered,
502 RolloutTurn(RolloutTurn),
510 StreamState(SessionState),
518}
519
520#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
530#[serde(tag = "kind", rename_all = "snake_case")]
531pub enum Source {
532 Terminal,
534 VsCode {
537 window_key: String,
539 },
540}
541
542#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
557#[serde(rename_all = "snake_case")]
558pub enum Agent {
559 #[default]
561 Claude,
562 Pi,
565 Codex,
568}
569
570impl Agent {
571 #[must_use]
573 pub fn display_name(self) -> &'static str {
574 match self {
575 Self::Claude => "Claude",
576 Self::Pi => "pi",
577 Self::Codex => "Codex",
578 }
579 }
580
581 #[must_use]
583 pub fn is_claude(&self) -> bool {
584 *self == Self::Claude
585 }
586}
587
588#[derive(Debug, Clone, Serialize, Deserialize)]
595pub struct ObserveRequest {
596 #[serde(default, skip_serializing_if = "Option::is_none")]
598 pub agent_id: Option<String>,
599 pub session_id: String,
603 #[serde(default, skip_serializing_if = "Option::is_none")]
605 pub cwd: Option<PathBuf>,
606 #[serde(default, skip_serializing_if = "Option::is_none")]
608 pub transcript_path: Option<PathBuf>,
609 pub event: SessionEvent,
611 #[serde(default, skip_serializing_if = "Option::is_none")]
614 pub repo: Option<String>,
615 #[serde(default, skip_serializing_if = "Option::is_none")]
617 pub model: Option<String>,
618 #[serde(default, skip_serializing_if = "Agent::is_claude")]
620 pub agent: Agent,
621 #[serde(default, skip_serializing_if = "Option::is_none")]
631 pub pid: Option<u32>,
632}
633
634#[derive(Debug, Clone, Serialize, Deserialize)]
641pub struct WindowReport {
642 pub key: String,
644 #[serde(default)]
646 pub folders: Vec<PathBuf>,
647 #[serde(default)]
649 pub tabs: usize,
650 #[serde(default)]
652 pub terminals: usize,
653}
654
655impl WindowReport {
656 #[must_use]
659 fn has_embedding(&self) -> bool {
660 self.tabs > 0 || self.terminals > 0
661 }
662}
663
664#[derive(Debug, Clone, Serialize)]
671pub struct SessionEntry {
672 #[serde(skip)]
674 pub(crate) subagent_base: Option<SessionState>,
675 #[serde(skip)]
677 pub(crate) subagent_waits: HashMap<String, SessionState>,
678 pub session_id: String,
680 #[serde(skip_serializing_if = "Option::is_none")]
682 pub cwd: Option<PathBuf>,
683 #[serde(skip_serializing_if = "Option::is_none")]
685 pub transcript_path: Option<PathBuf>,
686 #[serde(skip_serializing_if = "Option::is_none")]
688 pub repo: Option<String>,
689 #[serde(skip_serializing_if = "Option::is_none")]
691 pub model: Option<String>,
692 pub agent: Agent,
695 pub state: SessionState,
697 pub source: Source,
699 pub last_event: SessionEvent,
701 pub started_at: DateTime<Utc>,
703 pub last_seen: DateTime<Utc>,
706 #[serde(skip)]
710 pub(crate) last_active: Duration,
711 #[serde(skip)]
714 pub(crate) pid: Option<u32>,
715 #[serde(skip)]
723 pub(crate) pid_start: Option<String>,
724 #[serde(skip)]
729 pub(crate) prompted: bool,
730 #[serde(skip)]
739 pub(crate) streamed: bool,
740 #[serde(skip)]
744 pub(crate) replaced_pids: VecDeque<u32>,
745 #[serde(skip)]
749 pub(crate) recent_seqs: VecDeque<(String, Origin)>,
750 #[serde(skip)]
756 pub(crate) superseded_seqs: VecDeque<String>,
757 #[serde(skip)]
760 pub(crate) latest_stamp_ts: Option<DateTime<Utc>>,
761 #[serde(skip)]
768 pub(crate) reported: bool,
769}
770
771#[derive(Debug, Clone, Copy, PartialEq, Eq)]
773enum Skip {
774 Duplicate,
776 JournalStale,
778 SocketReordered,
781}
782
783impl Skip {
784 fn outcome(self) -> &'static str {
786 match self {
787 Self::Duplicate => "duplicate_ignored",
788 Self::JournalStale => "journal_stale_ignored",
789 Self::SocketReordered => "socket_reordered_ignored",
790 }
791 }
792}
793
794impl SessionEntry {
795 fn stamp_skip(&self, stamp: Option<&EventStamp>, origin: Origin) -> Option<Skip> {
799 let stamp = stamp?;
800 if self.seq_origin(&stamp.seq).is_some() {
801 return Some(Skip::Duplicate);
802 }
803 let behind = self.latest_stamp_ts.map(|latest| latest - stamp.ts);
804 match (origin, behind) {
805 (Origin::Journal | Origin::Replay, Some(behind))
806 if behind > chrono::Duration::zero() =>
807 {
808 Some(Skip::JournalStale)
809 }
810 (Origin::Socket, Some(behind))
811 if behind > chrono::Duration::zero() && behind <= SOCKET_REORDER_WINDOW =>
812 {
813 Some(Skip::SocketReordered)
814 }
815 _ => None,
816 }
817 }
818
819 fn seq_origin(&self, seq: &str) -> Option<Origin> {
822 self.recent_seqs
823 .iter()
824 .find(|(recent, _)| recent == seq)
825 .map(|(_, origin)| *origin)
826 }
827
828 fn settle_stamp(
832 &mut self,
833 stamp: &EventStamp,
834 origin: Origin,
835 skip: Option<Skip>,
836 ) -> Correction {
837 let correction =
838 if origin == Origin::Socket && self.seq_origin(&stamp.seq) == Some(Origin::Journal) {
839 self.confirm_by_socket(&stamp.seq);
844 Correction::RacedByJournal
845 } else if origin == Origin::Socket && self.forget_superseded(&stamp.seq) {
846 Correction::LateAfterSuperseded
847 } else if origin == Origin::Journal
848 && skip == Some(Skip::JournalStale)
849 && self.remember_superseded(&stamp.seq)
850 {
851 Correction::Superseded
852 } else {
853 Correction::None
854 };
855 if matches!(skip, None | Some(Skip::SocketReordered)) {
859 self.record_stamp(stamp, origin);
860 }
861 correction
862 }
863
864 fn confirm_by_socket(&mut self, seq: &str) {
866 if let Some((_, origin)) = self.recent_seqs.iter_mut().find(|(s, _)| s == seq) {
867 *origin = Origin::Socket;
868 }
869 }
870
871 fn remember_superseded(&mut self, seq: &str) -> bool {
874 if self.superseded_seqs.iter().any(|s| s == seq) {
875 return false;
876 }
877 if self.superseded_seqs.len() >= MAX_RECENT_SEQS {
878 self.superseded_seqs.pop_front();
879 }
880 self.superseded_seqs.push_back(seq.to_owned());
881 true
882 }
883
884 fn forget_superseded(&mut self, seq: &str) -> bool {
887 let Some(at) = self.superseded_seqs.iter().position(|s| s == seq) else {
888 return false;
889 };
890 self.superseded_seqs.remove(at);
891 true
892 }
893
894 fn record_stamp(&mut self, stamp: &EventStamp, origin: Origin) {
896 if self.recent_seqs.len() >= MAX_RECENT_SEQS {
897 self.recent_seqs.pop_front();
898 }
899 self.recent_seqs.push_back((stamp.seq.clone(), origin));
900 self.latest_stamp_ts = Some(self.latest_stamp_ts.map_or(stamp.ts, |t| t.max(stamp.ts)));
904 }
905}
906
907fn seen_at(stamp: Option<&EventStamp>, origin: Origin, now: DateTime<Utc>) -> DateTime<Utc> {
911 match (origin, stamp) {
912 (Origin::Journal | Origin::Replay, Some(stamp)) => stamp.ts.min(now),
913 _ => now,
914 }
915}
916
917fn asserts_state(event: SessionEvent) -> bool {
921 !matches!(
922 event,
923 SessionEvent::TranscriptDiscovered
924 | SessionEvent::TranscriptGrew
925 | SessionEvent::RolloutTurn(_)
926 )
927}
928
929fn open_turn_over_reported_idle(entry: &SessionEntry, event: SessionEvent) -> bool {
937 event == SessionEvent::RolloutTurn(RolloutTurn::Open)
938 && entry.state == SessionState::Idle
939 && entry.reported
940}
941
942fn subagent_state(
944 entry: &mut SessionEntry,
945 event: SessionEvent,
946 agent_id: Option<&str>,
947) -> SessionState {
948 if let Some(id) = agent_id.filter(|id| !id.trim().is_empty()) {
949 match event {
950 SessionEvent::Notification(
951 NotificationKind::PermissionPrompt
952 | NotificationKind::IdlePrompt
953 | NotificationKind::AgentNeedsInput,
954 ) => {
955 entry.subagent_base.get_or_insert(entry.state);
956 entry
957 .subagent_waits
958 .insert(id.to_owned(), SessionState::for_event(&event, None));
959 }
960 SessionEvent::PostToolUse => {
961 entry.subagent_waits.remove(id);
962 }
963 _ => {}
964 }
965 if entry
966 .subagent_waits
967 .values()
968 .any(|s| *s == SessionState::WaitingForPermission)
969 {
970 return SessionState::WaitingForPermission;
971 }
972 if !entry.subagent_waits.is_empty() {
973 return SessionState::WaitingForInput;
974 }
975 return entry.subagent_base.take().unwrap_or(entry.state);
976 }
977 let next = SessionState::for_event(&event, Some(entry.state));
978 if !matches!(
981 event,
982 SessionEvent::TranscriptDiscovered
983 | SessionEvent::TranscriptGrew
984 | SessionEvent::RolloutTurn(_)
985 | SessionEvent::Notification(NotificationKind::Other)
986 ) {
987 entry.subagent_waits.clear();
988 entry.subagent_base = None;
989 }
990 next
991}
992
993#[derive(Debug, Clone)]
998pub(crate) struct PidCandidate {
999 pub(crate) session_id: String,
1000 pub(crate) pid: u32,
1001 pub(crate) pid_start: Option<String>,
1004 pub(crate) prompted: bool,
1005 pub(crate) streamed: bool,
1008 pub(crate) last_seen: DateTime<Utc>,
1014}
1015
1016#[derive(Debug, Clone)]
1018struct WindowEntry {
1019 report: WindowReport,
1021 last_active: Duration,
1026 registered_at: DateTime<Utc>,
1032}
1033
1034pub struct SessionsRegistry {
1040 sessions: Mutex<HashMap<String, SessionEntry>>,
1042 windows: Mutex<HashMap<String, WindowEntry>>,
1045 session_ttl: Duration,
1047 ended_ttl: Duration,
1049 window_ttl: Duration,
1051 clock: AwakeClock,
1053 changes: watch::Sender<u64>,
1067 delivery: DeliveryCounters,
1069}
1070
1071impl SessionsRegistry {
1072 #[must_use]
1074 pub fn new() -> Self {
1075 Self {
1076 sessions: Mutex::new(HashMap::new()),
1077 windows: Mutex::new(HashMap::new()),
1078 session_ttl: DEFAULT_SESSION_TTL,
1079 ended_ttl: ENDED_SESSION_TTL,
1080 window_ttl: DEFAULT_WINDOW_TTL,
1081 clock: AwakeClock::new(),
1082 changes: watch::channel(0).0,
1083 delivery: DeliveryCounters::default(),
1084 }
1085 }
1086
1087 #[must_use]
1089 pub fn delivery(&self) -> DeliveryStats {
1090 self.delivery.snapshot()
1091 }
1092
1093 #[must_use]
1097 pub(crate) fn scratch() -> Self {
1098 Self {
1099 session_ttl: Duration::MAX,
1100 ended_ttl: Duration::MAX,
1101 window_ttl: Duration::MAX,
1102 ..Self::new()
1103 }
1104 }
1105
1106 pub(crate) fn live_session_ids(&self) -> std::collections::HashSet<String> {
1110 self.lock_sessions()
1111 .values()
1112 .filter(|entry| entry.state != SessionState::Ended)
1113 .map(|entry| entry.session_id.to_ascii_lowercase())
1114 .collect()
1115 }
1116
1117 #[must_use]
1124 pub fn subscribe_changes(&self) -> watch::Receiver<u64> {
1125 self.changes.subscribe()
1126 }
1127
1128 pub(crate) fn bump(&self) {
1144 self.changes.send_modify(|v| *v = v.wrapping_add(1));
1145 }
1146
1147 fn lock_sessions(&self) -> MutexGuard<'_, HashMap<String, SessionEntry>> {
1150 self.sessions.lock().unwrap_or_else(PoisonError::into_inner)
1151 }
1152
1153 fn lock_windows(&self) -> MutexGuard<'_, HashMap<String, WindowEntry>> {
1155 self.windows.lock().unwrap_or_else(PoisonError::into_inner)
1156 }
1157
1158 fn settle_delivery(
1162 &self,
1163 sessions: &mut HashMap<String, SessionEntry>,
1164 session_id: &str,
1165 stamp: Option<&EventStamp>,
1166 origin: Origin,
1167 skip: Option<Skip>,
1168 applied: bool,
1169 ) {
1170 let correction = match (stamp, sessions.get_mut(session_id)) {
1171 (Some(stamp), Some(entry)) => entry.settle_stamp(stamp, origin, skip),
1172 _ => Correction::None,
1173 };
1174 self.delivery.record(origin, applied, correction);
1175 }
1176
1177 pub fn observe(&self, req: ObserveRequest) {
1197 self.observe_stamped(req, None, Origin::Local);
1198 }
1199
1200 pub(crate) fn observe_stamped(
1207 &self,
1208 req: ObserveRequest,
1209 stamp: Option<EventStamp>,
1210 origin: Origin,
1211 ) {
1212 let agent_id = req
1213 .agent_id
1214 .as_deref()
1215 .filter(|id| req.agent == Agent::Claude && !id.trim().is_empty());
1216 let session_id = req.session_id.clone();
1217 let event = req.event;
1218 let agent = req.agent;
1219 let pid = req.pid;
1220 let now = Utc::now();
1221 let seen = seen_at(stamp.as_ref(), origin, now);
1222 let awake = self.clock.now();
1223 let (changed, old_state, new_state, outcome, reaped) = {
1224 let mut sessions = self.lock_sessions();
1225 let reaped = reap_sessions(&mut sessions, self.session_ttl, self.ended_ttl, awake);
1226 let old_state = sessions.get(&session_id).map(|entry| entry.state);
1227 let skip = sessions
1228 .get(&session_id)
1229 .and_then(|entry| entry.stamp_skip(stamp.as_ref(), origin));
1230 let mut outcome = "created";
1231 let mutated = match sessions.get_mut(&req.session_id) {
1232 Some(_) if skip.is_some() => {
1235 outcome = skip.map_or("skipped", Skip::outcome);
1236 false
1237 }
1238 Some(entry)
1242 if entry.state == SessionState::Ended
1243 && (matches!(
1244 req.event,
1245 SessionEvent::TranscriptDiscovered | SessionEvent::RolloutTurn(_)
1246 ) || agent_id.is_some()) =>
1247 {
1248 outcome = "ended_passive_ignored";
1249 false
1250 }
1251 Some(entry)
1257 if req
1258 .pid
1259 .is_some_and(|pid| entry.replaced_pids.contains(&pid)) =>
1260 {
1261 outcome = "replaced_pid_ignored";
1262 false
1263 }
1264 Some(entry) => {
1265 track_pid(entry, req.pid);
1266 entry.prompted |= req.event == SessionEvent::UserPromptSubmit;
1267 entry.streamed |= is_claude_stream_report(&req);
1268 let next = if open_turn_over_reported_idle(entry, req.event) {
1269 entry.state
1270 } else {
1271 subagent_state(entry, req.event, agent_id)
1272 };
1273 let state_changed = next != entry.state;
1274 if asserts_state(req.event) {
1277 entry.reported = true;
1278 } else if state_changed {
1279 entry.reported = false;
1280 }
1281 entry.state = next;
1282 entry.last_event = req.event;
1283 entry.last_seen = if origin.is_journal() {
1284 entry.last_seen.max(seen)
1285 } else {
1286 now
1287 };
1288 entry.last_active = awake;
1289 let filled_cwd = fill(&mut entry.cwd, req.cwd);
1292 let filled_transcript = fill(&mut entry.transcript_path, req.transcript_path);
1293 let filled_repo = fill(&mut entry.repo, req.repo);
1294 let filled_model = fill(&mut entry.model, req.model);
1295 let metadata_changed =
1296 filled_cwd || filled_transcript || filled_repo || filled_model;
1297 outcome = if state_changed {
1298 "state_changed"
1299 } else if metadata_changed {
1300 "metadata_enriched"
1301 } else {
1302 "heartbeat_only"
1303 };
1304 state_changed || metadata_changed
1305 }
1306 None => {
1307 if sessions.len() >= MAX_SESSIONS {
1308 evict_oldest_session(&mut sessions);
1309 }
1310 let state = if agent_id.is_some() {
1311 SessionState::Idle
1312 } else {
1313 SessionState::for_event(&req.event, None)
1314 };
1315 let prompted = req.event == SessionEvent::UserPromptSubmit;
1316 let streamed = is_claude_stream_report(&req);
1317 let session_id = req.session_id.clone();
1318 let mut entry = SessionEntry {
1319 subagent_base: None,
1320 subagent_waits: HashMap::new(),
1321 session_id: req.session_id,
1322 cwd: req.cwd,
1323 transcript_path: req.transcript_path,
1324 repo: req.repo,
1325 model: req.model,
1326 agent: req.agent,
1327 state,
1328 source: Source::Terminal,
1329 last_event: req.event,
1330 started_at: seen,
1331 last_seen: seen,
1332 last_active: awake,
1333 pid: req.pid,
1334 pid_start: None,
1335 prompted,
1336 streamed,
1337 replaced_pids: VecDeque::new(),
1338 recent_seqs: VecDeque::new(),
1339 superseded_seqs: VecDeque::new(),
1340 latest_stamp_ts: None,
1341 reported: asserts_state(req.event),
1342 };
1343 entry.state = subagent_state(&mut entry, req.event, agent_id);
1344 sessions.insert(session_id, entry);
1345 true
1346 }
1347 };
1348 self.settle_delivery(
1349 &mut sessions,
1350 &session_id,
1351 stamp.as_ref(),
1352 origin,
1353 skip,
1354 skip.is_none(),
1355 );
1356 (
1357 mutated || reaped > 0,
1358 old_state,
1359 sessions.get(&session_id).map(|entry| entry.state),
1360 outcome,
1361 reaped,
1362 )
1363 };
1364 if changed {
1365 self.bump();
1366 }
1367 tracing::debug!(%session_id, ?agent, ?pid, ?agent_id, ?event, ?old_state, ?new_state, outcome,
1368 ?origin, reaped, bumped = changed, "session_observed");
1369 }
1370
1371 pub fn end(&self, session_id: &str, reason: Option<&str>, pid: Option<u32>) -> bool {
1388 self.end_stamped(session_id, reason, pid, None, Origin::Local)
1389 }
1390
1391 pub(crate) fn end_stamped(
1395 &self,
1396 session_id: &str,
1397 _reason: Option<&str>,
1398 pid: Option<u32>,
1399 stamp: Option<EventStamp>,
1400 origin: Origin,
1401 ) -> bool {
1402 let now = Utc::now();
1403 let seen = seen_at(stamp.as_ref(), origin, now);
1404 let awake = self.clock.now();
1405 let (known, reaped, old_state, outcome) = {
1406 let mut sessions = self.lock_sessions();
1407 let reaped = reap_sessions(&mut sessions, self.session_ttl, self.ended_ttl, awake);
1408 let old_state = sessions.get(session_id).map(|entry| entry.state);
1409 let skip = sessions
1410 .get(session_id)
1411 .and_then(|entry| entry.stamp_skip(stamp.as_ref(), origin));
1412 let mut outcome = "unknown";
1413 let known = match sessions.get_mut(session_id) {
1414 Some(_) if skip.is_some() => {
1415 outcome = skip.map_or("skipped", Skip::outcome);
1416 (true, false)
1417 }
1418 Some(entry) if entry.state == SessionState::Ended => {
1421 outcome = "already_ended";
1422 (true, false)
1423 }
1424 Some(entry) if pid.is_some_and(|pid| entry.replaced_pids.contains(&pid)) => {
1427 outcome = "replaced_pid_ignored";
1428 (true, false)
1429 }
1430 Some(entry) => {
1431 outcome = "ended";
1432 entry.state = SessionState::Ended;
1433 entry.last_event = SessionEvent::Stop;
1434 entry.last_seen = if origin.is_journal() {
1435 entry.last_seen.max(seen)
1436 } else {
1437 now
1438 };
1439 entry.last_active = awake;
1440 (true, true)
1441 }
1442 None => (false, false),
1443 };
1444 self.settle_delivery(
1448 &mut sessions,
1449 session_id,
1450 stamp.as_ref(),
1451 origin,
1452 skip,
1453 skip.is_none() && (origin == Origin::Socket || known.0),
1454 );
1455 (known, reaped, old_state, outcome)
1456 };
1457 let (known, flipped) = known;
1458 let bumped = flipped || reaped > 0;
1461 if bumped {
1462 self.bump();
1463 }
1464 tracing::debug!(
1465 session_id,
1466 ?pid,
1467 ?old_state,
1468 outcome,
1469 ?origin,
1470 reaped,
1471 bumped,
1472 "session_end"
1473 );
1474 known
1475 }
1476
1477 pub(crate) fn pid_liveness_candidates(&self) -> Vec<PidCandidate> {
1482 self.lock_sessions()
1483 .values()
1484 .filter(|e| e.state != SessionState::Ended)
1485 .filter_map(|e| {
1486 Some(PidCandidate {
1487 session_id: e.session_id.clone(),
1488 pid: e.pid?,
1489 pid_start: e.pid_start.clone(),
1490 prompted: e.prompted,
1491 streamed: e.streamed,
1492 last_seen: e.last_seen,
1493 })
1494 })
1495 .collect()
1496 }
1497
1498 pub(crate) fn confirm_pid_liveness(
1507 &self,
1508 session_id: &str,
1509 now: DateTime<Utc>,
1510 pid_start: &str,
1511 ) {
1512 let awake = self.clock.now();
1513 let mut sessions = self.lock_sessions();
1514 if let Some(entry) = sessions.get_mut(session_id) {
1515 if entry.state != SessionState::Ended {
1516 entry.last_seen = now;
1517 entry.last_active = awake;
1518 if entry.pid_start.is_none() {
1519 entry.pid_start = Some(pid_start.to_string());
1520 }
1521 }
1522 }
1523 }
1524
1525 pub fn report_window(&self, report: WindowReport) {
1534 let window_key = report.key.clone();
1535 let folder_count = report.folders.len();
1536 let now = Utc::now();
1537 let awake = self.clock.now();
1538 let (changed, outcome, reaped) = {
1539 let mut windows = self.lock_windows();
1540 let reaped = reap_windows(&mut windows, self.window_ttl, awake);
1541 let outcome = if windows.contains_key(&report.key) {
1542 "refresh"
1543 } else {
1544 "registered"
1545 };
1546 let (mutated, registered_at) = if let Some(previous) = windows.get(&report.key) {
1547 (
1548 previous.report.folders != report.folders
1549 || previous.report.has_embedding() != report.has_embedding(),
1550 previous.registered_at,
1551 )
1552 } else {
1553 if windows.len() >= MAX_WINDOWS {
1554 evict_oldest_window(&mut windows);
1555 }
1556 (true, now)
1557 };
1558 windows.insert(
1559 report.key.clone(),
1560 WindowEntry {
1561 report,
1562 last_active: awake,
1563 registered_at,
1564 },
1565 );
1566 (
1567 mutated || reaped > 0,
1568 if outcome == "refresh" && mutated {
1569 "embedding_changed"
1570 } else {
1571 outcome
1572 },
1573 reaped,
1574 )
1575 };
1576 if changed {
1577 self.bump();
1578 }
1579 tracing::debug!(%window_key, folder_count, outcome, reaped, bumped = changed, "session_window_reported");
1580 }
1581
1582 pub fn unregister_window(&self, key: &str) -> bool {
1585 let removed = {
1586 let mut windows = self.lock_windows();
1587 windows.remove(key).is_some()
1588 };
1589 if removed {
1590 self.bump();
1591 }
1592 tracing::debug!(
1593 window_key = key,
1594 removed,
1595 bumped = removed,
1596 "session_window_unregistered"
1597 );
1598 removed
1599 }
1600
1601 pub fn credit_window_outage(&self, outage: Duration) {
1623 let awake = self.clock.now();
1624 for entry in self.lock_windows().values_mut() {
1625 entry.last_active = entry
1627 .last_active
1628 .checked_add(outage)
1629 .map_or(awake, |credited| credited.min(awake));
1630 }
1631 }
1632
1633 pub fn list(&self) -> Vec<SessionEntry> {
1650 let awake = self.clock.now();
1651 let mut sessions: Vec<SessionEntry> = {
1652 let mut guard = self.lock_sessions();
1653 reap_sessions(&mut guard, self.session_ttl, self.ended_ttl, awake);
1654 guard.values().cloned().collect()
1655 };
1656 let windows: Vec<WindowEntry> = {
1657 let mut guard = self.lock_windows();
1658 reap_windows(&mut guard, self.window_ttl, awake);
1659 guard
1660 .values()
1661 .filter(|e| e.report.has_embedding())
1662 .cloned()
1663 .collect()
1664 };
1665 for session in &mut sessions {
1666 session.source = resolve_source(session.cwd.as_deref(), &windows);
1667 }
1668 sessions.sort_by(|a, b| {
1669 a.repo
1670 .cmp(&b.repo)
1671 .then_with(|| a.session_id.cmp(&b.session_id))
1672 });
1673 sessions
1674 }
1675
1676 pub fn focus_folder(&self, session_id: &str) -> Option<PathBuf> {
1681 let cwd = {
1682 let sessions = self.lock_sessions();
1683 sessions.get(session_id).and_then(|e| e.cwd.clone())
1684 }?;
1685 let awake = self.clock.now();
1686 let mut windows = self.lock_windows();
1687 reap_windows(&mut windows, self.window_ttl, awake);
1688 pick_window(&cwd, windows.values().filter(|e| e.report.has_embedding()))
1689 .and_then(|w| w.folders.first().cloned())
1690 }
1691}
1692
1693impl Default for SessionsRegistry {
1694 fn default() -> Self {
1695 Self::new()
1696 }
1697}
1698
1699fn track_pid(entry: &mut SessionEntry, pid: Option<u32>) {
1720 let Some(pid) = pid else {
1721 return;
1722 };
1723 if entry.pid == Some(pid) {
1724 return;
1725 }
1726 if let Some(owner) = entry.pid.replace(pid) {
1727 if entry.replaced_pids.len() >= MAX_REPLACED_PIDS {
1728 entry.replaced_pids.pop_front();
1729 }
1730 entry.replaced_pids.push_back(owner);
1731 entry.streamed = false;
1735 }
1736 entry.pid_start = None;
1737}
1738
1739fn fill<T: PartialEq>(slot: &mut Option<T>, incoming: Option<T>) -> bool {
1745 match incoming {
1746 Some(value) if slot.as_ref() != Some(&value) => {
1747 *slot = Some(value);
1748 true
1749 }
1750 _ => false,
1751 }
1752}
1753
1754fn resolve_source(cwd: Option<&Path>, windows: &[WindowEntry]) -> Source {
1761 let Some(cwd) = cwd else {
1762 tracing::trace!(outcome = "missing_cwd", "session_attribution_miss");
1763 return Source::Terminal;
1764 };
1765 if let Some(window) = pick_window(cwd, windows) {
1766 Source::VsCode {
1767 window_key: window.key.clone(),
1768 }
1769 } else {
1770 tracing::debug!(cwd = %cwd.display(), window_count = windows.len(), "session_attribution_miss");
1771 for window in windows {
1772 tracing::trace!(window_key = %window.report.key, folders = ?window.report.folders, "session_attribution_candidate");
1773 }
1774 Source::Terminal
1775 }
1776}
1777
1778fn pick_window<'a>(
1794 cwd: &Path,
1795 windows: impl IntoIterator<Item = &'a WindowEntry>,
1796) -> Option<&'a WindowReport> {
1797 windows
1798 .into_iter()
1799 .filter_map(|e| Some((e, match_depth(cwd, &e.report.folders)?)))
1800 .min_by(|(a, a_depth), (b, b_depth)| {
1801 b_depth
1802 .cmp(a_depth)
1803 .then_with(|| b.registered_at.cmp(&a.registered_at))
1804 .then_with(|| a.report.key.cmp(&b.report.key))
1805 })
1806 .map(|(e, _)| &e.report)
1807}
1808
1809fn match_depth(cwd: &Path, folders: &[PathBuf]) -> Option<usize> {
1812 folders
1813 .iter()
1814 .filter(|f| cwd.starts_with(f))
1815 .map(|f| f.components().count())
1816 .max()
1817}
1818
1819fn is_claude_stream_report(req: &ObserveRequest) -> bool {
1824 req.agent == Agent::Claude && matches!(req.event, SessionEvent::StreamState(_))
1825}
1826
1827fn reap_sessions(
1840 sessions: &mut HashMap<String, SessionEntry>,
1841 session_ttl: Duration,
1842 ended_ttl: Duration,
1843 awake: Duration,
1844) -> usize {
1845 let before = sessions.len();
1846 sessions.retain(|_, e| {
1847 let max_age = if e.state == SessionState::Ended {
1848 ended_ttl
1849 } else {
1850 session_ttl
1851 };
1852 let keep = awake.saturating_sub(e.last_active) <= max_age;
1853 if !keep {
1854 tracing::trace!(session_id = %e.session_id, reason = if e.state == SessionState::Ended { "ended_ttl" } else { "session_ttl" }, "session_reaped");
1855 }
1856 keep
1857 });
1858 let count = before - sessions.len();
1859 if count > 0 {
1860 tracing::debug!(count, "sessions_reaped");
1861 }
1862 count
1863}
1864
1865fn reap_windows(
1868 windows: &mut HashMap<String, WindowEntry>,
1869 ttl: Duration,
1870 awake: Duration,
1871) -> usize {
1872 let before = windows.len();
1873 windows.retain(|key, e| {
1874 let keep = awake.saturating_sub(e.last_active) <= ttl;
1875 if !keep {
1876 tracing::trace!(window_key = %key, reason = "window_ttl", "session_window_reaped");
1877 }
1878 keep
1879 });
1880 let count = before - windows.len();
1881 if count > 0 {
1882 tracing::debug!(count, "session_windows_reaped");
1883 }
1884 count
1885}
1886
1887fn evict_oldest_session(sessions: &mut HashMap<String, SessionEntry>) {
1891 let oldest = sessions
1892 .values()
1893 .min_by(|a, b| {
1894 a.last_active
1895 .cmp(&b.last_active)
1896 .then_with(|| a.session_id.cmp(&b.session_id))
1897 })
1898 .map(|e| e.session_id.clone());
1899 if let Some(key) = oldest {
1900 sessions.remove(&key);
1901 tracing::debug!(session_id = %key, reason = "capacity", "session_evicted");
1902 }
1903}
1904
1905fn evict_oldest_window(windows: &mut HashMap<String, WindowEntry>) {
1908 let oldest = windows
1909 .iter()
1910 .min_by(|a, b| {
1911 a.1.last_active
1912 .cmp(&b.1.last_active)
1913 .then_with(|| a.0.cmp(b.0))
1914 })
1915 .map(|(k, _)| k.clone());
1916 if let Some(key) = oldest {
1917 windows.remove(&key);
1918 tracing::debug!(window_key = %key, reason = "capacity", "session_window_evicted");
1919 }
1920}
1921
1922#[cfg(test)]
1923#[allow(clippy::unwrap_used, clippy::expect_used)]
1924mod tests {
1925 use super::*;
1926
1927 #[test]
1928 fn registry_diagnostics_distinguish_transition_heartbeat_and_unknown_end() {
1929 let registry = SessionsRegistry::new();
1930 let logs = crate::test_support::capture_at(tracing::Level::DEBUG, || {
1931 registry.observe(observe_request(
1932 "diagnostics",
1933 SessionEvent::UserPromptSubmit,
1934 Some("/project"),
1935 ));
1936 registry.observe(observe_request(
1937 "diagnostics",
1938 SessionEvent::UserPromptSubmit,
1939 Some("/project"),
1940 ));
1941 registry.end("missing", None, None);
1942 registry.end("diagnostics", None, None);
1943 });
1944 assert!(logs.contains("state_changed") || logs.contains("created"));
1945 assert!(logs.contains("heartbeat_only"));
1946 assert!(logs.contains("unknown"));
1947 assert!(logs.contains("outcome=\"ended\""), "{logs}");
1948 assert!(logs.contains("bumped=true"));
1949 assert!(logs.contains("bumped=false"));
1950 }
1951
1952 #[test]
1953 fn attribution_misses_are_logged_with_each_candidate_window() {
1954 let windows = vec![window_entry("w1", 20), window_entry("w2", 5)];
1955 let logs = crate::test_support::capture_at(tracing::Level::TRACE, || {
1956 assert_eq!(
1957 resolve_source(Some(Path::new("/elsewhere/x")), &windows),
1958 Source::Terminal
1959 );
1960 assert_eq!(resolve_source(None, &windows), Source::Terminal);
1961 });
1962 assert!(logs.contains("session_attribution_miss"), "{logs}");
1963 assert!(logs.contains("missing_cwd"), "{logs}");
1964 assert_eq!(
1965 logs.matches("session_attribution_candidate").count(),
1966 2,
1967 "{logs}"
1968 );
1969 assert!(
1970 logs.contains("window_key=w1") && logs.contains("window_key=w2"),
1971 "{logs}"
1972 );
1973 }
1974
1975 fn observe_request(session_id: &str, event: SessionEvent, cwd: Option<&str>) -> ObserveRequest {
1976 ObserveRequest {
1977 agent_id: None,
1978 pid: None,
1979 agent: Agent::Claude,
1980 session_id: session_id.to_string(),
1981 cwd: cwd.map(PathBuf::from),
1982 transcript_path: None,
1983 event,
1984 repo: None,
1985 model: None,
1986 }
1987 }
1988
1989 #[test]
1990 fn list_is_empty_initially() {
1991 let reg = SessionsRegistry::new();
1992 assert!(reg.list().is_empty());
1993 }
1994
1995 #[test]
1996 fn observe_then_list_round_trips_and_infers_state() {
1997 let reg = SessionsRegistry::new();
1998 reg.observe(observe_request(
1999 "s1",
2000 SessionEvent::SessionStart,
2001 Some("/tmp/a"),
2002 ));
2003 let sessions = reg.list();
2004 assert_eq!(sessions.len(), 1);
2005 assert_eq!(sessions[0].session_id, "s1");
2006 assert_eq!(sessions[0].state, SessionState::Starting);
2007 assert_eq!(sessions[0].source, Source::Terminal);
2009 }
2010
2011 #[test]
2012 fn observe_is_idempotent_upsert_advancing_state() {
2013 let reg = SessionsRegistry::new();
2014 reg.observe(observe_request(
2015 "s1",
2016 SessionEvent::SessionStart,
2017 Some("/tmp/a"),
2018 ));
2019 reg.observe(observe_request("s1", SessionEvent::PreToolUse, None));
2020 let sessions = reg.list();
2021 assert_eq!(sessions.len(), 1, "same session_id upserts, not duplicates");
2022 assert_eq!(sessions[0].state, SessionState::Working);
2023 assert_eq!(sessions[0].cwd.as_deref(), Some(Path::new("/tmp/a")));
2025 }
2026
2027 #[test]
2028 fn state_machine_covers_every_event() {
2029 use NotificationKind::*;
2030 use SessionEvent::*;
2031 let cases = [
2032 (SessionStart, SessionState::Starting),
2033 (UserPromptSubmit, SessionState::Working),
2034 (PreToolUse, SessionState::Working),
2035 (PostToolUse, SessionState::Working),
2036 (Stop, SessionState::Idle),
2037 (
2038 Notification(PermissionPrompt),
2039 SessionState::WaitingForPermission,
2040 ),
2041 (Notification(IdlePrompt), SessionState::WaitingForInput),
2042 (Notification(AgentNeedsInput), SessionState::WaitingForInput),
2043 (TranscriptGrew, SessionState::Working),
2044 (TranscriptDiscovered, SessionState::Idle),
2045 (RolloutTurn(super::RolloutTurn::Open), SessionState::Working),
2047 (RolloutTurn(super::RolloutTurn::Closed), SessionState::Idle),
2048 (
2050 StreamState(SessionState::WaitingForPermission),
2051 SessionState::WaitingForPermission,
2052 ),
2053 (StreamState(SessionState::Idle), SessionState::Idle),
2054 ];
2055 for (event, expected) in cases {
2056 assert_eq!(
2057 SessionState::for_event(&event, None),
2058 expected,
2059 "event {event:?}"
2060 );
2061 }
2062 assert_eq!(
2064 SessionState::for_event(&Notification(Other), Some(SessionState::Working)),
2065 SessionState::Working
2066 );
2067 assert_eq!(
2069 SessionState::for_event(&TranscriptDiscovered, Some(SessionState::Working)),
2070 SessionState::Working
2071 );
2072 assert_eq!(
2075 SessionState::for_event(
2076 &StreamState(SessionState::Idle),
2077 Some(SessionState::Working)
2078 ),
2079 SessionState::Idle
2080 );
2081 for held in [
2088 SessionState::Starting,
2089 SessionState::WaitingForInput,
2090 SessionState::WaitingForPermission,
2091 SessionState::Ended,
2092 ] {
2093 assert_eq!(
2094 SessionState::for_event(&TranscriptGrew, Some(held)),
2095 held,
2096 "growth must not overwrite {held:?}"
2097 );
2098 }
2099 for other in [SessionState::Working, SessionState::Idle] {
2102 assert_eq!(
2103 SessionState::for_event(&TranscriptGrew, Some(other)),
2104 SessionState::Working,
2105 "growth from {other:?}"
2106 );
2107 }
2108 }
2109
2110 #[test]
2111 fn end_marks_ended_and_reaps_quickly() {
2112 let reg = SessionsRegistry::new();
2113 reg.observe(observe_request(
2114 "s1",
2115 SessionEvent::PreToolUse,
2116 Some("/tmp/a"),
2117 ));
2118 assert!(reg.end("s1", Some("clear"), None));
2119 assert!(!reg.end("ghost", None, None));
2121 let sessions = reg.list();
2122 assert_eq!(sessions.len(), 1);
2123 assert_eq!(sessions[0].state, SessionState::Ended);
2124 reg.clock.advance(Duration::from_secs(30));
2126 assert!(reg.list().is_empty(), "ended entry reaps after ended TTL");
2127 }
2128
2129 #[test]
2132 fn pid_liveness_candidates_lists_only_non_ended_pid_bearing_sessions() {
2133 let reg = SessionsRegistry::new();
2134 reg.observe(observe_from(
2135 "with_pid",
2136 SessionEvent::UserPromptSubmit,
2137 100,
2138 ));
2139 reg.observe(observe_request(
2140 "no_pid",
2141 SessionEvent::UserPromptSubmit,
2142 None,
2143 ));
2144 reg.observe(observe_from("ended", SessionEvent::UserPromptSubmit, 200));
2145 reg.end("ended", None, Some(200));
2146
2147 let candidates = reg.pid_liveness_candidates();
2148 assert_eq!(candidates.len(), 1);
2149 assert_eq!(candidates[0].session_id, "with_pid");
2150 assert_eq!(candidates[0].pid, 100);
2151 assert!(candidates[0].prompted);
2152 assert_eq!(candidates[0].pid_start, None);
2153 }
2154
2155 #[test]
2156 fn only_a_claude_stream_report_marks_a_session_streamed() {
2157 let reg = SessionsRegistry::new();
2159 reg.observe(observe_from("hooked", SessionEvent::UserPromptSubmit, 100));
2160 let candidates = reg.pid_liveness_candidates();
2161 assert!(candidates[0].prompted);
2162 assert!(!candidates[0].streamed);
2163
2164 reg.observe(observe_from(
2167 "hooked",
2168 SessionEvent::StreamState(SessionState::Idle),
2169 100,
2170 ));
2171 reg.observe(observe_from("hooked", SessionEvent::Stop, 100));
2172 let candidates = reg.pid_liveness_candidates();
2173 assert!(candidates[0].prompted && candidates[0].streamed);
2174
2175 let reg = SessionsRegistry::new();
2176 reg.observe(observe_from(
2177 "wrapped",
2178 SessionEvent::StreamState(SessionState::Working),
2179 100,
2180 ));
2181 assert!(reg.pid_liveness_candidates()[0].streamed);
2182
2183 let reg = SessionsRegistry::new();
2186 reg.observe(observe_from(
2187 "resumed",
2188 SessionEvent::StreamState(SessionState::Idle),
2189 100,
2190 ));
2191 assert!(reg.pid_liveness_candidates()[0].streamed);
2192 reg.observe(observe_from("resumed", SessionEvent::SessionStart, 200));
2193 assert!(!reg.pid_liveness_candidates()[0].streamed);
2194
2195 for agent in [Agent::Codex, Agent::Pi] {
2198 let reg = SessionsRegistry::new();
2199 reg.observe(ObserveRequest {
2200 agent,
2201 ..observe_from("other", SessionEvent::StreamState(SessionState::Idle), 100)
2202 });
2203 assert!(!reg.pid_liveness_candidates()[0].streamed, "{agent:?}");
2204 }
2205 }
2206
2207 #[test]
2208 fn confirm_pid_liveness_refreshes_last_seen_and_captures_the_token_once() {
2209 let reg = SessionsRegistry::new();
2210 reg.observe(observe_from("s1", SessionEvent::UserPromptSubmit, 100));
2211 let old = Utc::now() - chrono::Duration::seconds(1000);
2212 let old_active = reg.lock_sessions()["s1"].last_active;
2213 {
2214 let mut guard = reg.lock_sessions();
2215 guard.get_mut("s1").unwrap().last_seen = old;
2216 }
2217 reg.clock.advance(Duration::from_secs(1000));
2218
2219 reg.confirm_pid_liveness("s1", Utc::now(), "tok-1");
2220 {
2221 let guard = reg.lock_sessions();
2222 let entry = &guard["s1"];
2223 assert!(entry.last_seen > old, "last_seen should be refreshed");
2224 assert!(
2225 entry.last_active > old_active + Duration::from_secs(999),
2226 "the awake stamp the TTL reads should be refreshed too"
2227 );
2228 assert_eq!(entry.pid_start.as_deref(), Some("tok-1"));
2229 }
2230
2231 reg.confirm_pid_liveness("s1", Utc::now(), "tok-2");
2234 assert_eq!(
2235 reg.lock_sessions()["s1"].pid_start.as_deref(),
2236 Some("tok-1")
2237 );
2238 }
2239
2240 #[test]
2241 fn confirm_pid_liveness_is_a_noop_for_an_unknown_or_ended_session() {
2242 let reg = SessionsRegistry::new();
2243 reg.confirm_pid_liveness("ghost", Utc::now(), "tok");
2245
2246 reg.observe(observe_from("s1", SessionEvent::UserPromptSubmit, 100));
2247 reg.end("s1", None, Some(100));
2248 let before = reg.lock_sessions()["s1"].last_seen;
2249 let before_active = reg.lock_sessions()["s1"].last_active;
2250 reg.clock.advance(Duration::from_secs(1));
2251 reg.confirm_pid_liveness("s1", Utc::now(), "tok");
2252 assert_eq!(
2253 reg.lock_sessions()["s1"].last_seen,
2254 before,
2255 "an ended session must not be revived by a liveness confirmation"
2256 );
2257 assert_eq!(reg.lock_sessions()["s1"].last_active, before_active);
2258 }
2259
2260 #[test]
2261 fn stale_working_session_reaps_but_recent_survives() {
2262 let reg = SessionsRegistry::new();
2263 reg.observe(observe_request("stale", SessionEvent::PreToolUse, None));
2264 reg.clock.advance(Duration::from_secs(1000));
2265 reg.observe(observe_request("fresh", SessionEvent::PreToolUse, None));
2266 let ids: Vec<String> = reg.list().into_iter().map(|s| s.session_id).collect();
2267 assert_eq!(ids, vec!["fresh".to_string()]);
2268 }
2269
2270 #[test]
2271 fn source_is_vscode_when_cwd_is_under_a_reporting_window() {
2272 let reg = SessionsRegistry::new();
2273 reg.observe(observe_request(
2274 "s1",
2275 SessionEvent::PreToolUse,
2276 Some("/home/me/proj/sub"),
2277 ));
2278 reg.report_window(WindowReport {
2280 key: "w1".to_string(),
2281 folders: vec![PathBuf::from("/home/me/proj")],
2282 tabs: 1,
2283 terminals: 0,
2284 });
2285 let sessions = reg.list();
2286 assert_eq!(
2287 sessions[0].source,
2288 Source::VsCode {
2289 window_key: "w1".to_string()
2290 }
2291 );
2292 }
2293
2294 #[test]
2295 fn source_is_terminal_when_window_has_no_embedding() {
2296 let reg = SessionsRegistry::new();
2297 reg.observe(observe_request(
2298 "s1",
2299 SessionEvent::PreToolUse,
2300 Some("/home/me/proj"),
2301 ));
2302 reg.report_window(WindowReport {
2304 key: "w1".to_string(),
2305 folders: vec![PathBuf::from("/home/me/proj")],
2306 tabs: 0,
2307 terminals: 0,
2308 });
2309 assert_eq!(reg.list()[0].source, Source::Terminal);
2310 }
2311
2312 #[test]
2313 fn window_report_is_upsert_and_unregister_removes() {
2314 let reg = SessionsRegistry::new();
2315 reg.report_window(WindowReport {
2316 key: "w1".to_string(),
2317 folders: vec![PathBuf::from("/p")],
2318 tabs: 1,
2319 terminals: 0,
2320 });
2321 reg.report_window(WindowReport {
2323 key: "w1".to_string(),
2324 folders: vec![PathBuf::from("/p")],
2325 tabs: 2,
2326 terminals: 1,
2327 });
2328 assert!(reg.unregister_window("w1"));
2329 assert!(!reg.unregister_window("w1"));
2330 }
2331
2332 #[test]
2333 fn stale_window_stops_tagging_source() {
2334 let reg = SessionsRegistry::new();
2335 reg.observe(observe_request(
2336 "s1",
2337 SessionEvent::PreToolUse,
2338 Some("/p/sub"),
2339 ));
2340 reg.report_window(WindowReport {
2341 key: "w1".to_string(),
2342 folders: vec![PathBuf::from("/p")],
2343 tabs: 1,
2344 terminals: 0,
2345 });
2346 reg.clock.advance(Duration::from_secs(120));
2348 assert_eq!(reg.list()[0].source, Source::Terminal);
2349 }
2350
2351 #[test]
2352 fn credit_window_outage_keeps_a_report_the_outage_alone_would_have_expired() {
2353 let reg = SessionsRegistry::new();
2354 let report = |key: &str| WindowReport {
2355 key: key.to_string(),
2356 folders: vec![PathBuf::from("/p")],
2357 tabs: 1,
2358 terminals: 0,
2359 };
2360 reg.report_window(report("kept"));
2361 reg.report_window(report("long-gone"));
2362 reg.clock.advance(Duration::from_secs(1000));
2364 {
2365 let awake = reg.clock.now();
2366 let mut guard = reg.lock_windows();
2367 guard.get_mut("kept").unwrap().last_active =
2369 awake.saturating_sub(Duration::from_secs(40));
2370 guard.get_mut("long-gone").unwrap().last_active =
2371 awake.saturating_sub(Duration::from_secs(300));
2372 }
2373 reg.credit_window_outage(Duration::from_secs(25));
2375 reg.list();
2376 let guard = reg.lock_windows();
2377 assert!(guard.contains_key("kept"), "the outage must not age it out");
2378 assert!(
2379 !guard.contains_key("long-gone"),
2380 "a report 300 s silent is dead however long the outage was"
2381 );
2382 }
2383
2384 #[test]
2385 fn credit_window_outage_never_moves_last_active_into_the_future() {
2386 let reg = SessionsRegistry::new();
2387 reg.report_window(WindowReport {
2388 key: "w".to_string(),
2389 folders: vec![PathBuf::from("/p")],
2390 tabs: 1,
2391 terminals: 0,
2392 });
2393 reg.credit_window_outage(Duration::from_secs(3600));
2394 assert!(reg.lock_windows()["w"].last_active <= reg.clock.now());
2395 reg.credit_window_outage(Duration::MAX);
2397 assert!(reg.lock_windows()["w"].last_active <= reg.clock.now());
2398 }
2399
2400 #[test]
2401 fn credit_window_outage_is_not_a_visible_change() {
2402 let reg = SessionsRegistry::new();
2403 reg.report_window(WindowReport {
2404 key: "w".to_string(),
2405 folders: vec![PathBuf::from("/p")],
2406 tabs: 1,
2407 terminals: 0,
2408 });
2409 let rx = reg.subscribe_changes();
2410 reg.credit_window_outage(Duration::from_secs(5));
2411 assert!(!rx.has_changed().unwrap());
2412 }
2413
2414 fn window_entry(key: &str, age_secs: i64) -> WindowEntry {
2416 let now = Utc::now();
2417 WindowEntry {
2418 report: window_report(key, "/p", true),
2419 last_active: Duration::ZERO,
2420 registered_at: now - chrono::Duration::seconds(age_secs),
2421 }
2422 }
2423
2424 fn vscode(key: &str) -> Source {
2425 Source::VsCode {
2426 window_key: key.to_string(),
2427 }
2428 }
2429
2430 #[test]
2431 fn resolve_source_prefers_newest_registration_over_key_order() {
2432 let windows = vec![window_entry("w1", 20), window_entry("w2", 5)];
2434 assert_eq!(
2435 resolve_source(Some(Path::new("/p/x")), &windows),
2436 vscode("w2")
2437 );
2438 assert_eq!(resolve_source(None, &windows), Source::Terminal);
2440 }
2441
2442 #[test]
2443 fn resolve_source_breaks_equal_registrations_by_lowest_key() {
2444 let now = Utc::now();
2445 let at_now = |key: &str| WindowEntry {
2446 report: window_report(key, "/p", true),
2447 last_active: Duration::ZERO,
2448 registered_at: now,
2449 };
2450 let windows = vec![at_now("w2"), at_now("w1")];
2451 assert_eq!(
2452 resolve_source(Some(Path::new("/p/x")), &windows),
2453 vscode("w1")
2454 );
2455 }
2456
2457 #[test]
2458 fn reload_overlap_attributes_to_the_new_window_even_if_its_key_sorts_higher() {
2459 let reg = SessionsRegistry::new();
2462 reg.observe(observe_request(
2463 "s1",
2464 SessionEvent::PreToolUse,
2465 Some("/p/sub"),
2466 ));
2467 reg.report_window(window_report("39-old", "/p", true));
2468 {
2469 let mut guard = reg.lock_windows();
2470 guard.get_mut("39-old").unwrap().registered_at =
2471 Utc::now() - chrono::Duration::seconds(60);
2472 }
2473 reg.report_window(window_report("52-new", "/p", true));
2474 assert_eq!(reg.list()[0].source, vscode("52-new"));
2475 reg.report_window(window_report("39-old", "/p", true));
2477 assert_eq!(reg.list()[0].source, vscode("52-new"));
2478 }
2479
2480 #[test]
2481 fn a_more_specific_folder_outranks_a_newer_parent_window() {
2482 let now = Utc::now();
2483 let entry = |key: &str, folder: &str, age_secs: i64| WindowEntry {
2484 report: window_report(key, folder, true),
2485 last_active: Duration::ZERO,
2486 registered_at: now - chrono::Duration::seconds(age_secs),
2487 };
2488 let windows = vec![
2491 entry("a-parent", "/repo", 1),
2492 entry("z-sub", "/repo/sub", 50),
2493 ];
2494 assert_eq!(
2495 resolve_source(Some(Path::new("/repo/sub/x")), &windows),
2496 vscode("z-sub")
2497 );
2498 assert_eq!(
2500 resolve_source(Some(Path::new("/repo/other")), &windows),
2501 vscode("a-parent")
2502 );
2503 }
2504
2505 #[test]
2506 fn refreshing_a_window_preserves_its_registration_time() {
2507 let reg = SessionsRegistry::new();
2508 reg.report_window(window_report("w1", "/p", true));
2509 let first = reg.lock_windows()["w1"].registered_at;
2510 let first_active = reg.lock_windows()["w1"].last_active;
2511 reg.clock.advance(Duration::from_secs(5));
2512 reg.report_window(window_report("w1", "/p", true));
2513 let guard = reg.lock_windows();
2514 assert_eq!(guard["w1"].registered_at, first);
2515 assert!(guard["w1"].last_active > first_active);
2516 }
2517
2518 #[test]
2519 fn reregistering_after_unregister_gets_a_fresh_registration_time() {
2520 let reg = SessionsRegistry::new();
2521 reg.report_window(window_report("w1", "/p", true));
2522 let first = reg.lock_windows()["w1"].registered_at;
2523 std::thread::sleep(std::time::Duration::from_millis(5));
2524 assert!(reg.unregister_window("w1"));
2525 reg.report_window(window_report("w1", "/p", true));
2526 assert!(reg.lock_windows()["w1"].registered_at > first);
2527 }
2528
2529 #[test]
2530 fn focus_folder_agrees_with_list_on_overlap() {
2531 let reg = SessionsRegistry::new();
2532 reg.observe(observe_request(
2533 "s1",
2534 SessionEvent::PreToolUse,
2535 Some("/p/sub"),
2536 ));
2537 let mut old = window_report("a-old", "/p", true);
2538 old.folders = vec![PathBuf::from("/p/old-root"), PathBuf::from("/p")];
2539 reg.report_window(old);
2540 {
2541 let mut guard = reg.lock_windows();
2542 guard.get_mut("a-old").unwrap().registered_at =
2543 Utc::now() - chrono::Duration::seconds(60);
2544 }
2545 reg.report_window(window_report("z-new", "/p", true));
2546 assert_eq!(reg.list()[0].source, vscode("z-new"));
2547 assert_eq!(reg.focus_folder("s1"), Some(PathBuf::from("/p")));
2549 }
2550
2551 #[test]
2552 fn focus_folder_resolves_matching_window_folder() {
2553 let reg = SessionsRegistry::new();
2554 reg.observe(observe_request(
2555 "s1",
2556 SessionEvent::PreToolUse,
2557 Some("/home/me/proj/sub"),
2558 ));
2559 assert!(reg.focus_folder("s1").is_none(), "no window yet");
2560 reg.report_window(WindowReport {
2561 key: "w1".to_string(),
2562 folders: vec![PathBuf::from("/home/me/proj")],
2563 tabs: 1,
2564 terminals: 0,
2565 });
2566 assert_eq!(reg.focus_folder("s1"), Some(PathBuf::from("/home/me/proj")));
2567 assert!(reg.focus_folder("ghost").is_none());
2569 }
2570
2571 #[test]
2572 fn evict_oldest_session_drops_the_longest_silent() {
2573 let now = Utc::now();
2574 let mut sessions = HashMap::new();
2575 for (id, age) in [("young", 0_u64), ("old", 100), ("older", 200)] {
2576 sessions.insert(
2577 id.to_string(),
2578 SessionEntry {
2579 subagent_base: None,
2580 subagent_waits: HashMap::new(),
2581 pid: None,
2582 pid_start: None,
2583 prompted: false,
2584 streamed: false,
2585 replaced_pids: VecDeque::new(),
2586 recent_seqs: VecDeque::new(),
2587 superseded_seqs: VecDeque::new(),
2588 latest_stamp_ts: None,
2589 reported: false,
2590 agent: Agent::Claude,
2591 session_id: id.to_string(),
2592 cwd: None,
2593 transcript_path: None,
2594 repo: None,
2595 model: None,
2596 state: SessionState::Working,
2597 source: Source::Terminal,
2598 last_event: SessionEvent::PreToolUse,
2599 started_at: now,
2600 last_seen: now,
2601 last_active: Duration::from_secs(1000 - age),
2602 },
2603 );
2604 }
2605 evict_oldest_session(&mut sessions);
2606 assert!(!sessions.contains_key("older"));
2607 assert!(sessions.contains_key("young"));
2608 assert!(sessions.contains_key("old"));
2609 let mut empty: HashMap<String, SessionEntry> = HashMap::new();
2611 evict_oldest_session(&mut empty);
2612 assert!(empty.is_empty());
2613 }
2614
2615 #[test]
2616 fn list_sorts_by_repo_then_session_id() {
2617 let reg = SessionsRegistry::new();
2618 for (id, repo) in [("z", "repo-a"), ("a", "repo-b"), ("m", "repo-a")] {
2619 reg.observe(ObserveRequest {
2620 agent_id: None,
2621 pid: None,
2622 agent: Agent::Claude,
2623 session_id: id.to_string(),
2624 cwd: None,
2625 transcript_path: None,
2626 event: SessionEvent::PreToolUse,
2627 repo: Some(repo.to_string()),
2628 model: None,
2629 });
2630 }
2631 let ordered: Vec<(String, String)> = reg
2632 .list()
2633 .into_iter()
2634 .map(|s| (s.session_id, s.repo.unwrap()))
2635 .collect();
2636 assert_eq!(
2637 ordered,
2638 vec![
2639 ("m".to_string(), "repo-a".to_string()),
2640 ("z".to_string(), "repo-a".to_string()),
2641 ("a".to_string(), "repo-b".to_string()),
2642 ]
2643 );
2644 }
2645
2646 #[test]
2647 fn serialized_session_shapes_are_stable() {
2648 let reg = SessionsRegistry::new();
2651 reg.observe(ObserveRequest {
2652 agent_id: None,
2653 pid: None,
2654 agent: Agent::Claude,
2655 session_id: "s1".to_string(),
2656 cwd: Some(PathBuf::from("/p")),
2657 transcript_path: None,
2658 event: SessionEvent::Notification(NotificationKind::PermissionPrompt),
2659 repo: Some("proj".to_string()),
2660 model: None,
2661 });
2662 let value = serde_json::to_value(®.list()[0]).unwrap();
2663 assert_eq!(value["state"], "waiting_for_permission");
2664 assert_eq!(value["source"]["kind"], "terminal");
2665 assert_eq!(value["repo"], "proj");
2666 assert!(value.get("model").is_none());
2668 assert!(value.get("transcript_path").is_none());
2669 }
2670
2671 #[test]
2672 fn stream_state_is_authoritative_and_round_trips() {
2673 let reg = SessionsRegistry::new();
2676 reg.observe(observe_request("s1", SessionEvent::PreToolUse, Some("/p")));
2677 assert_eq!(reg.list()[0].state, SessionState::Working);
2678 reg.observe(observe_request(
2679 "s1",
2680 SessionEvent::StreamState(SessionState::WaitingForPermission),
2681 None,
2682 ));
2683 assert_eq!(reg.list()[0].state, SessionState::WaitingForPermission);
2684 let value = serde_json::to_value(SessionEvent::StreamState(
2686 SessionState::WaitingForPermission,
2687 ))
2688 .unwrap();
2689 assert_eq!(
2690 value,
2691 serde_json::json!({ "stream_state": "waiting_for_permission" })
2692 );
2693 let event: SessionEvent = serde_json::from_value(value).unwrap();
2695 assert_eq!(
2696 event,
2697 SessionEvent::StreamState(SessionState::WaitingForPermission)
2698 );
2699 }
2700
2701 #[test]
2702 fn default_constructs_an_empty_registry() {
2703 let reg = SessionsRegistry::default();
2704 assert!(reg.list().is_empty());
2705 }
2706
2707 #[test]
2708 fn agent_is_omitted_for_claude_and_tagged_for_pi_on_the_wire() {
2709 let claude = serde_json::to_value(observe_request("s", SessionEvent::Stop, None)).unwrap();
2711 assert!(claude.get("agent").is_none(), "{claude}");
2712 let parsed: ObserveRequest =
2714 serde_json::from_value(serde_json::json!({ "session_id": "s", "event": "stop" }))
2715 .unwrap();
2716 assert_eq!(parsed.agent, Agent::Claude);
2717 let pi: ObserveRequest = serde_json::from_value(serde_json::json!({
2719 "session_id": "019a0000-0000-7000-8000-000000000000",
2720 "cwd": "/work/repo",
2721 "agent": "pi",
2722 "event": { "stream_state": "waiting_for_input" },
2723 }))
2724 .unwrap();
2725 assert_eq!(pi.agent, Agent::Pi);
2726 assert_eq!(
2727 pi.event,
2728 SessionEvent::StreamState(SessionState::WaitingForInput)
2729 );
2730 }
2731
2732 #[test]
2733 fn agents_have_display_names() {
2734 assert_eq!(Agent::Claude.display_name(), "Claude");
2735 assert_eq!(Agent::Pi.display_name(), "pi");
2736 assert_eq!(Agent::Codex.display_name(), "Codex");
2737 }
2738
2739 #[test]
2740 fn an_ended_session_is_not_refreshed_by_a_passive_sighting_or_a_second_end() {
2741 let reg = SessionsRegistry::new();
2742 reg.observe(observe_request("s", SessionEvent::Stop, None));
2743 assert!(reg.end("s", None, None));
2744 let ended_at = reg.list()[0].last_seen;
2745 let mut changes = reg.subscribe_changes();
2746 changes.mark_unchanged();
2747 std::thread::sleep(std::time::Duration::from_millis(5));
2748 reg.observe(observe_request(
2749 "s",
2750 SessionEvent::TranscriptDiscovered,
2751 None,
2752 ));
2753 assert!(reg.end("s", None, None), "still a known session");
2754 let listed = reg.list();
2755 assert_eq!(listed[0].state, SessionState::Ended);
2756 assert_eq!(
2757 listed[0].last_seen, ended_at,
2758 "the linger window did not restart"
2759 );
2760 assert!(
2761 !changes.has_changed().unwrap(),
2762 "no consumer-visible change"
2763 );
2764 reg.observe(observe_request("s", SessionEvent::UserPromptSubmit, None));
2766 assert_eq!(reg.list()[0].state, SessionState::Working);
2767 }
2768
2769 #[test]
2770 fn codex_is_a_tagged_agent_on_the_wire() {
2771 let codex: ObserveRequest = serde_json::from_value(serde_json::json!({
2772 "session_id": "019a0000-0000-7000-8000-000000000001",
2773 "agent": "codex",
2774 "event": { "notification": "permission_prompt" },
2775 }))
2776 .unwrap();
2777 assert_eq!(codex.agent, Agent::Codex);
2778 assert!(!codex.agent.is_claude());
2779 let json = serde_json::to_value(&codex).unwrap();
2780 assert_eq!(json["agent"], "codex");
2781 }
2782
2783 #[test]
2784 fn a_pi_session_is_listed_with_its_agent_and_reported_state() {
2785 let reg = SessionsRegistry::new();
2786 let mut req = observe_request(
2787 "pi-1",
2788 SessionEvent::StreamState(SessionState::Working),
2789 Some("/work/repo"),
2790 );
2791 req.agent = Agent::Pi;
2792 reg.observe(req);
2793 reg.observe(observe_request(
2795 "pi-1",
2796 SessionEvent::StreamState(SessionState::Idle),
2797 None,
2798 ));
2799 let listed = reg.list();
2800 assert_eq!(listed.len(), 1);
2801 assert_eq!(listed[0].agent, Agent::Pi);
2802 assert_eq!(listed[0].state, SessionState::Idle);
2803 let json = serde_json::to_value(&listed[0]).unwrap();
2804 assert_eq!(json["agent"], "pi");
2805 }
2806
2807 #[test]
2808 fn fill_only_overwrites_with_a_present_value() {
2809 let mut slot = Some("keep");
2812 fill(&mut slot, None);
2813 assert_eq!(slot, Some("keep"));
2814 fill(&mut slot, Some("new"));
2815 assert_eq!(slot, Some("new"));
2816 let mut empty: Option<&str> = None;
2818 fill(&mut empty, Some("filled"));
2819 assert_eq!(empty, Some("filled"));
2820 }
2821
2822 #[test]
2823 fn observe_at_session_cap_evicts_the_longest_silent() {
2824 let reg = SessionsRegistry::new();
2825 {
2828 let mut sessions = reg.lock_sessions();
2829 let base = Utc::now();
2830 for i in 0..MAX_SESSIONS {
2831 let id = format!("s{i:04}");
2832 sessions.insert(
2833 id.clone(),
2834 SessionEntry {
2835 subagent_base: None,
2836 subagent_waits: HashMap::new(),
2837 pid: None,
2838 pid_start: None,
2839 prompted: false,
2840 streamed: false,
2841 replaced_pids: VecDeque::new(),
2842 recent_seqs: VecDeque::new(),
2843 superseded_seqs: VecDeque::new(),
2844 latest_stamp_ts: None,
2845 reported: false,
2846 agent: Agent::Claude,
2847 session_id: id.clone(),
2848 cwd: None,
2849 transcript_path: None,
2850 repo: None,
2851 model: None,
2852 state: SessionState::Working,
2853 source: Source::Terminal,
2854 last_event: SessionEvent::PreToolUse,
2855 started_at: base,
2856 last_seen: base,
2857 last_active: Duration::from_millis(1_000_000 - i as u64),
2858 },
2859 );
2860 }
2861 }
2862 reg.observe(observe_request("fresh", SessionEvent::PreToolUse, None));
2864 let sessions = reg.lock_sessions();
2865 assert_eq!(sessions.len(), MAX_SESSIONS);
2866 assert!(sessions.contains_key("fresh"));
2867 assert!(!sessions.contains_key(&format!("s{:04}", MAX_SESSIONS - 1)));
2868 assert!(sessions.contains_key("s0000"));
2869 }
2870
2871 #[test]
2872 fn report_window_at_cap_evicts_the_longest_silent() {
2873 let reg = SessionsRegistry::new();
2874 {
2875 let mut windows = reg.lock_windows();
2876 let base = Utc::now();
2877 for i in 0..MAX_WINDOWS {
2878 let key = format!("w{i:04}");
2879 windows.insert(
2880 key.clone(),
2881 WindowEntry {
2882 report: WindowReport {
2883 key: key.clone(),
2884 folders: vec![],
2885 tabs: 1,
2886 terminals: 0,
2887 },
2888 last_active: Duration::from_millis(1_000_000 - i as u64),
2889 registered_at: base,
2890 },
2891 );
2892 }
2893 }
2894 reg.report_window(WindowReport {
2895 key: "fresh".to_string(),
2896 folders: vec![],
2897 tabs: 1,
2898 terminals: 0,
2899 });
2900 let windows = reg.lock_windows();
2901 assert_eq!(windows.len(), MAX_WINDOWS);
2902 assert!(windows.contains_key("fresh"));
2903 assert!(!windows.contains_key(&format!("w{:04}", MAX_WINDOWS - 1)));
2904 assert!(windows.contains_key("w0000"));
2905 }
2906
2907 #[test]
2908 fn evict_oldest_window_breaks_ties_by_key() {
2909 let now = Utc::now();
2910 let mut windows = HashMap::new();
2911 let at = |key: &str, secs: u64| WindowEntry {
2912 report: WindowReport {
2913 key: key.to_string(),
2914 folders: vec![],
2915 tabs: 1,
2916 terminals: 0,
2917 },
2918 last_active: Duration::from_secs(100 - secs),
2919 registered_at: now,
2920 };
2921 windows.insert("young".to_string(), at("young", 0));
2922 windows.insert("old-b".to_string(), at("old-b", 10));
2923 windows.insert("old-a".to_string(), at("old-a", 10));
2924 evict_oldest_window(&mut windows);
2926 assert!(!windows.contains_key("old-a"));
2927 assert!(windows.contains_key("old-b"));
2928 assert!(windows.contains_key("young"));
2929 let mut empty: HashMap<String, WindowEntry> = HashMap::new();
2931 evict_oldest_window(&mut empty);
2932 assert!(empty.is_empty());
2933 }
2934
2935 fn window_report(key: &str, folder: &str, embedded: bool) -> WindowReport {
2939 WindowReport {
2940 key: key.to_string(),
2941 folders: vec![PathBuf::from(folder)],
2942 tabs: usize::from(embedded),
2943 terminals: 0,
2944 }
2945 }
2946
2947 #[test]
2948 fn subscribe_changes_starts_seen_and_a_new_session_bumps() {
2949 let reg = SessionsRegistry::new();
2950 let mut rx = reg.subscribe_changes();
2951 assert!(!rx.has_changed().unwrap());
2953 reg.observe(observe_request("s1", SessionEvent::SessionStart, None));
2954 assert!(rx.has_changed().unwrap(), "a new session should bump");
2955 rx.borrow_and_update();
2957 assert!(!rx.has_changed().unwrap());
2958 }
2959
2960 #[test]
2961 fn observe_bumps_on_a_state_transition_but_not_on_a_repeat_sighting() {
2962 let reg = SessionsRegistry::new();
2963 reg.observe(observe_request(
2964 "s1",
2965 SessionEvent::PreToolUse,
2966 Some("/tmp/a"),
2967 ));
2968 let mut rx = reg.subscribe_changes();
2970
2971 reg.observe(observe_request(
2975 "s1",
2976 SessionEvent::PreToolUse,
2977 Some("/tmp/a"),
2978 ));
2979 assert!(
2980 !rx.has_changed().unwrap(),
2981 "a repeat sighting with no visible change must not bump"
2982 );
2983
2984 reg.observe(observe_request("s1", SessionEvent::Stop, None));
2986 assert!(rx.has_changed().unwrap(), "a state transition should bump");
2987 rx.borrow_and_update();
2988
2989 reg.observe(observe_request("s1", SessionEvent::Stop, Some("/tmp/b")));
2992 assert!(
2993 rx.has_changed().unwrap(),
2994 "a newly-filled `cwd` should bump"
2995 );
2996 }
2997
2998 #[test]
2999 fn transcript_growth_does_not_clobber_a_waiting_session() {
3000 let reg = SessionsRegistry::new();
3001 reg.observe(observe_request(
3002 "s1",
3003 SessionEvent::Notification(NotificationKind::PermissionPrompt),
3004 Some("/tmp/a"),
3005 ));
3006 let rx = reg.subscribe_changes();
3010
3011 reg.observe(observe_request(
3015 "s1",
3016 SessionEvent::TranscriptGrew,
3017 Some("/tmp/a"),
3018 ));
3019 assert_eq!(reg.list()[0].state, SessionState::WaitingForPermission);
3020 assert!(
3021 !rx.has_changed().unwrap(),
3022 "state did not change, so nothing a consumer renders did either (#1414)"
3023 );
3024
3025 reg.observe(observe_request("s1", SessionEvent::PostToolUse, None));
3028 assert_eq!(reg.list()[0].state, SessionState::Working);
3029 assert!(
3030 rx.has_changed().unwrap(),
3031 "the release is a real transition"
3032 );
3033 }
3034
3035 #[test]
3036 fn transcript_growth_does_not_revive_an_ended_session() {
3037 let reg = SessionsRegistry::new();
3038 reg.observe(observe_request(
3039 "s1",
3040 SessionEvent::PreToolUse,
3041 Some("/tmp/a"),
3042 ));
3043 assert!(reg.end("s1", Some("clear"), None));
3044 let rx = reg.subscribe_changes();
3046
3047 reg.observe(observe_request(
3052 "s1",
3053 SessionEvent::TranscriptGrew,
3054 Some("/tmp/a"),
3055 ));
3056 assert_eq!(reg.list()[0].state, SessionState::Ended);
3057 assert!(
3058 !rx.has_changed().unwrap(),
3059 "state did not change, so nothing a consumer renders did either (#1414)"
3060 );
3061 }
3062
3063 #[test]
3064 fn a_rollout_turn_marker_moves_working_and_idle_and_nothing_else() {
3065 let open = SessionEvent::RolloutTurn(RolloutTurn::Open);
3069 let closed = SessionEvent::RolloutTurn(RolloutTurn::Closed);
3070 for held in [
3071 SessionState::Starting,
3072 SessionState::WaitingForInput,
3073 SessionState::WaitingForPermission,
3074 SessionState::Ended,
3075 ] {
3076 assert_eq!(SessionState::for_event(&open, Some(held)), held, "{held:?}");
3077 assert_eq!(
3078 SessionState::for_event(&closed, Some(held)),
3079 held,
3080 "{held:?}"
3081 );
3082 }
3083 for other in [SessionState::Working, SessionState::Idle] {
3084 assert_eq!(
3085 SessionState::for_event(&open, Some(other)),
3086 SessionState::Working,
3087 "open from {other:?}"
3088 );
3089 assert_eq!(
3090 SessionState::for_event(&closed, Some(other)),
3091 SessionState::Idle,
3092 "closed from {other:?}"
3093 );
3094 }
3095 }
3096
3097 #[test]
3098 fn a_hookless_codex_session_follows_its_rollouts_turns() {
3099 let reg = SessionsRegistry::new();
3100 let turn = |turn| ObserveRequest {
3101 agent: Agent::Codex,
3102 ..observe_request(
3103 "019a0000-0000-7000-8000-00000000c0de",
3104 SessionEvent::RolloutTurn(turn),
3105 Some("/work/repo"),
3106 )
3107 };
3108 reg.observe(turn(RolloutTurn::Open));
3110 assert_eq!(reg.list()[0].state, SessionState::Working);
3111 reg.observe(turn(RolloutTurn::Closed));
3112 assert_eq!(reg.list()[0].state, SessionState::Idle);
3113 reg.observe(turn(RolloutTurn::Open));
3114 assert_eq!(reg.list()[0].state, SessionState::Working);
3115 }
3116
3117 #[test]
3118 fn a_rollout_turn_marker_neither_overrides_nor_releases_a_reported_wait() {
3119 let reg = SessionsRegistry::new();
3120 reg.observe(observe_request(
3121 "s1",
3122 SessionEvent::Notification(NotificationKind::PermissionPrompt),
3123 Some("/tmp/a"),
3124 ));
3125 let rx = reg.subscribe_changes();
3126 for turn in [RolloutTurn::Open, RolloutTurn::Closed] {
3127 reg.observe(observe_request(
3128 "s1",
3129 SessionEvent::RolloutTurn(turn),
3130 Some("/tmp/a"),
3131 ));
3132 assert_eq!(reg.list()[0].state, SessionState::WaitingForPermission);
3133 }
3134 assert!(
3135 !rx.has_changed().unwrap(),
3136 "state did not change, so nothing a consumer renders did either (#1414)"
3137 );
3138 reg.observe(observe_request(
3140 "s1",
3141 SessionEvent::StreamState(SessionState::Idle),
3142 None,
3143 ));
3144 assert_eq!(reg.list()[0].state, SessionState::Idle);
3145 }
3146
3147 #[test]
3148 fn an_open_rollout_turn_does_not_start_working_over_a_reported_idle() {
3149 let open = SessionEvent::RolloutTurn(RolloutTurn::Open);
3153 let closed = SessionEvent::RolloutTurn(RolloutTurn::Closed);
3154 for reported in [
3155 SessionEvent::Stop,
3156 SessionEvent::StreamState(SessionState::Idle),
3157 ] {
3158 let reg = SessionsRegistry::new();
3159 reg.observe(observe_request("s", reported, None));
3160 assert_eq!(reg.list()[0].state, SessionState::Idle);
3161 reg.observe(observe_request(
3164 "s",
3165 SessionEvent::TranscriptDiscovered,
3166 None,
3167 ));
3168 reg.observe(observe_request("s", open, None));
3169 assert_eq!(reg.list()[0].state, SessionState::Idle, "{reported:?}");
3170 reg.observe(observe_request("s", SessionEvent::UserPromptSubmit, None));
3172 assert_eq!(reg.list()[0].state, SessionState::Working);
3173 }
3174 let reg = SessionsRegistry::new();
3178 reg.observe(observe_request("s", SessionEvent::UserPromptSubmit, None));
3179 reg.observe(observe_request("s", closed, None));
3180 assert_eq!(reg.list()[0].state, SessionState::Idle);
3181 reg.observe(observe_request("s", open, None));
3182 assert_eq!(reg.list()[0].state, SessionState::Working);
3183 }
3184
3185 #[test]
3186 fn a_session_first_seen_from_its_rollout_has_no_reported_state_to_defend() {
3187 for first in [
3189 SessionEvent::TranscriptDiscovered,
3190 SessionEvent::RolloutTurn(RolloutTurn::Closed),
3191 ] {
3192 let reg = SessionsRegistry::new();
3193 reg.observe(observe_request("s", first, None));
3194 assert_eq!(reg.list()[0].state, SessionState::Idle);
3195 reg.observe(observe_request(
3196 "s",
3197 SessionEvent::RolloutTurn(RolloutTurn::Open),
3198 None,
3199 ));
3200 assert_eq!(reg.list()[0].state, SessionState::Working, "{first:?}");
3201 }
3202 }
3203
3204 #[test]
3205 fn a_rollout_turn_marker_does_not_revive_an_ended_session() {
3206 let reg = SessionsRegistry::new();
3207 reg.observe(observe_request("s", SessionEvent::Stop, None));
3208 assert!(reg.end("s", None, None));
3209 let ended_at = reg.list()[0].last_seen;
3210 let rx = reg.subscribe_changes();
3211 std::thread::sleep(std::time::Duration::from_millis(5));
3212 reg.observe(observe_request(
3213 "s",
3214 SessionEvent::RolloutTurn(RolloutTurn::Open),
3215 None,
3216 ));
3217 let listed = reg.list();
3218 assert_eq!(listed[0].state, SessionState::Ended);
3219 assert_eq!(
3220 listed[0].last_seen, ended_at,
3221 "the linger window did not restart"
3222 );
3223 assert!(!rx.has_changed().unwrap(), "no consumer-visible change");
3224 }
3225
3226 #[test]
3227 fn a_rollout_turn_marker_is_a_wire_event() {
3228 for (turn, name) in [(RolloutTurn::Open, "open"), (RolloutTurn::Closed, "closed")] {
3229 let event = SessionEvent::RolloutTurn(turn);
3230 let json = serde_json::to_value(event).unwrap();
3231 assert_eq!(json, serde_json::json!({ "rollout_turn": name }));
3232 assert_eq!(serde_json::from_value::<SessionEvent>(json).unwrap(), event);
3233 }
3234 }
3235
3236 #[test]
3237 fn a_resumed_session_stays_starting_until_its_first_prompt() {
3238 let reg = SessionsRegistry::new();
3239 reg.observe(observe_request(
3240 "s1",
3241 SessionEvent::UserPromptSubmit,
3242 Some("/tmp/a"),
3243 ));
3244 assert!(reg.end("s1", Some("other"), None));
3247 reg.observe(observe_request(
3248 "s1",
3249 SessionEvent::SessionStart,
3250 Some("/tmp/a"),
3251 ));
3252 assert_eq!(reg.list()[0].state, SessionState::Starting);
3253
3254 reg.observe(observe_request(
3259 "s1",
3260 SessionEvent::TranscriptGrew,
3261 Some("/tmp/a"),
3262 ));
3263 assert_eq!(reg.list()[0].state, SessionState::Starting);
3264
3265 reg.observe(observe_request("s1", SessionEvent::UserPromptSubmit, None));
3267 assert_eq!(reg.list()[0].state, SessionState::Working);
3268 }
3269
3270 #[test]
3271 fn end_bumps_only_for_a_known_session() {
3272 let reg = SessionsRegistry::new();
3273 reg.observe(observe_request("s1", SessionEvent::PreToolUse, None));
3274 let mut rx = reg.subscribe_changes();
3275
3276 assert!(
3277 !reg.end("ghost", None, None),
3278 "an unknown session is a no-op"
3279 );
3280 assert!(
3281 !rx.has_changed().unwrap(),
3282 "ending an unknown session must not bump"
3283 );
3284
3285 assert!(reg.end("s1", None, None));
3286 assert!(rx.has_changed().unwrap(), "a real end should bump");
3287 rx.borrow_and_update();
3288 }
3289
3290 fn observe_from(session_id: &str, event: SessionEvent, pid: u32) -> ObserveRequest {
3292 ObserveRequest {
3293 agent_id: None,
3294 pid: Some(pid),
3295 ..observe_request(session_id, event, None)
3296 }
3297 }
3298
3299 #[test]
3300 fn a_late_end_from_the_replaced_process_keeps_a_resumed_session_live() {
3301 let reg = SessionsRegistry::new();
3302 reg.observe(observe_from("s1", SessionEvent::SessionStart, 100));
3305 reg.observe(observe_from("s1", SessionEvent::Stop, 100));
3306 reg.observe(observe_from("s1", SessionEvent::SessionStart, 200));
3307 let rx = reg.subscribe_changes();
3308
3309 assert!(reg.end("s1", Some("other"), Some(100)));
3311 assert!(!rx.has_changed().unwrap(), "an ignored end must not bump");
3312 let sessions = reg.list();
3313 assert_eq!(sessions.len(), 1);
3314 assert_eq!(sessions[0].state, SessionState::Starting);
3315
3316 assert!(reg.end("s1", Some("exit"), Some(200)));
3318 assert_eq!(reg.list()[0].state, SessionState::Ended);
3319 }
3320
3321 #[test]
3322 fn a_straggling_hook_from_the_replaced_process_does_not_take_the_session_back() {
3323 let reg = SessionsRegistry::new();
3324 reg.observe(observe_from("s1", SessionEvent::SessionStart, 100));
3325 reg.observe(observe_from("s1", SessionEvent::SessionStart, 200));
3326 reg.observe(observe_from("s1", SessionEvent::PostToolUse, 100));
3330 assert_eq!(reg.list()[0].state, SessionState::Starting);
3331 reg.end("s1", None, Some(100));
3332 assert_ne!(reg.list()[0].state, SessionState::Ended);
3333 }
3334
3335 #[test]
3336 fn a_straggling_non_end_hook_from_the_replaced_process_does_not_change_state() {
3337 let reg = SessionsRegistry::new();
3338 reg.observe(observe_from("s1", SessionEvent::UserPromptSubmit, 100));
3339 assert_eq!(reg.list()[0].state, SessionState::Working);
3340 reg.observe(observe_from("s1", SessionEvent::SessionStart, 200));
3342 assert_eq!(reg.list()[0].state, SessionState::Starting);
3343 let rx = reg.subscribe_changes();
3344
3345 reg.observe(observe_from("s1", SessionEvent::Stop, 100));
3349 assert_eq!(reg.list()[0].state, SessionState::Starting);
3350 assert!(
3351 !rx.has_changed().unwrap(),
3352 "an ignored sighting must not bump"
3353 );
3354 }
3355
3356 #[test]
3357 fn an_end_not_from_the_replaced_process_still_ends_the_session() {
3358 for end_pid in [None, Some(200), Some(999)] {
3362 let reg = SessionsRegistry::new();
3363 reg.observe(observe_from("s1", SessionEvent::SessionStart, 100));
3364 reg.observe(observe_from("s1", SessionEvent::SessionStart, 200));
3365 assert!(reg.end("s1", None, end_pid));
3366 assert_eq!(
3367 reg.list()[0].state,
3368 SessionState::Ended,
3369 "end with pid {end_pid:?}"
3370 );
3371 }
3372 let reg = SessionsRegistry::new();
3374 reg.observe(observe_from("s1", SessionEvent::SessionStart, 100));
3375 assert!(reg.end("s1", None, Some(100)));
3376 assert_eq!(reg.list()[0].state, SessionState::Ended);
3377 }
3378
3379 #[test]
3380 fn every_process_of_a_reload_burst_is_remembered_as_replaced() {
3381 let reg = SessionsRegistry::new();
3382 for pid in [100, 200, 300] {
3383 reg.observe(observe_from("s1", SessionEvent::SessionStart, pid));
3384 }
3385 assert!(reg.end("s1", None, Some(100)));
3387 assert!(reg.end("s1", None, Some(200)));
3388 assert_eq!(reg.list()[0].state, SessionState::Starting);
3389 }
3390
3391 #[test]
3392 fn a_stream_report_from_a_new_process_takes_the_session_over() {
3393 let idle = || SessionEvent::StreamState(SessionState::Idle);
3396 let reg = SessionsRegistry::new();
3397 reg.observe(observe_from("s1", idle(), 100));
3398 reg.observe(observe_from("s1", idle(), 200));
3399 reg.end("s1", None, Some(100));
3400 assert_eq!(reg.list()[0].state, SessionState::Idle);
3401 }
3402
3403 #[test]
3404 fn replaced_pids_are_capped_oldest_first() {
3405 let reg = SessionsRegistry::new();
3406 let last = u32::try_from(MAX_REPLACED_PIDS).unwrap() + 1;
3407 for pid in 0..=last {
3408 reg.observe(observe_from("s1", SessionEvent::PostToolUse, pid));
3409 }
3410 let guard = reg.lock_sessions();
3411 let replaced = &guard["s1"].replaced_pids;
3412 assert_eq!(replaced.len(), MAX_REPLACED_PIDS);
3413 assert_eq!(replaced.front(), Some(&1), "pid 0 was forgotten first");
3414 assert_eq!(replaced.back(), Some(&(last - 1)));
3415 }
3416
3417 #[test]
3418 fn a_sessions_first_pid_sighting_is_not_a_replacement() {
3419 let reg = SessionsRegistry::new();
3420 reg.observe(observe_request(
3423 "s1",
3424 SessionEvent::TranscriptDiscovered,
3425 None,
3426 ));
3427 reg.observe(observe_from("s1", SessionEvent::PostToolUse, 100));
3428 let guard = reg.lock_sessions();
3429 assert_eq!(guard["s1"].pid, Some(100));
3430 assert!(guard["s1"].replaced_pids.is_empty());
3431 }
3432
3433 #[test]
3434 fn end_then_start_resume_ordering_is_unchanged() {
3435 let reg = SessionsRegistry::new();
3437 reg.observe(observe_from("s1", SessionEvent::SessionStart, 100));
3438 assert!(reg.end("s1", None, Some(100)));
3439 reg.observe(observe_from("s1", SessionEvent::SessionStart, 200));
3440 assert_eq!(reg.list()[0].state, SessionState::Starting);
3441 }
3442
3443 #[test]
3444 fn window_report_bumps_only_when_it_changes_the_source_join() {
3445 let reg = SessionsRegistry::new();
3446 reg.report_window(window_report("w1", "/p", true));
3447 let mut rx = reg.subscribe_changes();
3448
3449 reg.report_window(window_report("w1", "/p", true));
3451 assert!(
3452 !rx.has_changed().unwrap(),
3453 "an unchanged window refresh must not bump"
3454 );
3455
3456 reg.report_window(window_report("w1", "/p", false));
3458 assert!(
3459 rx.has_changed().unwrap(),
3460 "an embedding that vanished should bump"
3461 );
3462 rx.borrow_and_update();
3463
3464 reg.report_window(window_report("w1", "/q", false));
3466 assert!(rx.has_changed().unwrap(), "changed folders should bump");
3467 rx.borrow_and_update();
3468
3469 reg.report_window(window_report("w2", "/r", true));
3471 assert!(rx.has_changed().unwrap(), "a new window should bump");
3472 }
3473
3474 #[test]
3475 fn unregister_window_bumps_only_when_it_removes() {
3476 let reg = SessionsRegistry::new();
3477 reg.report_window(window_report("w1", "/p", true));
3478 let rx = reg.subscribe_changes();
3479
3480 assert!(!reg.unregister_window("ghost"));
3481 assert!(
3482 !rx.has_changed().unwrap(),
3483 "a no-op unregister must not bump"
3484 );
3485
3486 assert!(reg.unregister_window("w1"));
3487 assert!(
3488 rx.has_changed().unwrap(),
3489 "a removing unregister should bump"
3490 );
3491 }
3492
3493 #[tokio::test]
3494 async fn a_burst_of_bumps_coalesces_into_one_wakeup() {
3495 let reg = SessionsRegistry::new();
3496 let mut rx = reg.subscribe_changes();
3497 reg.observe(observe_request("s1", SessionEvent::SessionStart, None));
3499 reg.observe(observe_request("s2", SessionEvent::SessionStart, None));
3500 reg.observe(observe_request("s3", SessionEvent::SessionStart, None));
3501 rx.changed().await.unwrap();
3503 assert!(
3505 !rx.has_changed().unwrap(),
3506 "a burst should collapse into a single wakeup"
3507 );
3508 }
3509
3510 #[test]
3511 fn list_does_not_bump_even_when_it_reaps() {
3512 let reg = SessionsRegistry::new();
3516 reg.observe(observe_request("s1", SessionEvent::PreToolUse, None));
3517 reg.clock.advance(Duration::from_secs(600));
3519 let rx = reg.subscribe_changes();
3520 assert!(reg.list().is_empty(), "the stale session should be reaped");
3521 assert!(!rx.has_changed().unwrap(), "`list` must never bump");
3522 }
3523
3524 fn just_inside_the_ttl() -> Duration {
3528 DEFAULT_SESSION_TTL
3529 .checked_sub(Duration::from_secs(1))
3530 .unwrap()
3531 }
3532
3533 fn jump_wall_clock_forward(reg: &SessionsRegistry, by: chrono::Duration) {
3536 for entry in reg.lock_sessions().values_mut() {
3537 entry.last_seen -= by;
3538 }
3539 }
3540
3541 #[test]
3542 fn a_wall_clock_jump_with_no_awake_time_reaps_nothing() {
3543 let reg = SessionsRegistry::new();
3546 reg.observe(observe_request("working", SessionEvent::PreToolUse, None));
3547 reg.observe(observe_request("idle", SessionEvent::Stop, None));
3548 reg.observe(observe_request("ended", SessionEvent::PreToolUse, None));
3549 reg.end("ended", None, None);
3550 reg.report_window(window_report("w1", "/p", true));
3551
3552 jump_wall_clock_forward(®, chrono::Duration::days(3));
3553
3554 let ids: Vec<String> = reg.list().into_iter().map(|s| s.session_id).collect();
3555 assert_eq!(
3556 ids,
3557 vec![
3558 "ended".to_string(),
3559 "idle".to_string(),
3560 "working".to_string()
3561 ],
3562 "sleeping must not age a session, not even an ended one"
3563 );
3564 assert_eq!(reg.lock_windows().len(), 1, "nor a window report");
3565 assert!(reg.list()[0].last_seen < Utc::now() - chrono::Duration::days(2));
3567 }
3568
3569 #[test]
3570 fn awake_time_past_the_ttl_still_reaps() {
3571 let reg = SessionsRegistry::new();
3572 reg.observe(observe_request("s1", SessionEvent::PreToolUse, None));
3573 reg.report_window(window_report("w1", "/p", true));
3574 reg.clock.advance(just_inside_the_ttl());
3575 assert_eq!(reg.list().len(), 1, "inside the TTL it survives");
3576 assert_eq!(reg.lock_windows().len(), 0, "the window TTL is shorter");
3577 reg.clock.advance(Duration::from_secs(2));
3578 assert!(
3579 reg.list().is_empty(),
3580 "past the TTL of awake silence it reaps"
3581 );
3582 }
3583
3584 #[test]
3585 fn activity_after_a_sleep_restarts_the_awake_ttl() {
3586 let reg = SessionsRegistry::new();
3587 reg.observe(observe_request("s1", SessionEvent::PreToolUse, None));
3588 reg.clock.advance(just_inside_the_ttl());
3589 jump_wall_clock_forward(®, chrono::Duration::hours(8));
3590 reg.observe(observe_request("s1", SessionEvent::PostToolUse, None));
3592 reg.clock.advance(just_inside_the_ttl());
3593 assert_eq!(reg.list().len(), 1);
3594 }
3595
3596 fn at(ts: &str) -> DateTime<Utc> {
3599 ts.parse().unwrap()
3600 }
3601
3602 fn stamp(ts: &str, seq: &str) -> EventStamp {
3603 EventStamp {
3604 ts: at(ts),
3605 seq: seq.to_string(),
3606 }
3607 }
3608
3609 fn state_of(reg: &SessionsRegistry, id: &str) -> SessionState {
3610 reg.lock_sessions()[id].state
3611 }
3612
3613 #[test]
3614 fn the_second_copy_of_a_stamped_event_is_dropped_whatever_its_route() {
3615 let reg = SessionsRegistry::new();
3616 let work = stamp("2026-10-03T03:40:00Z", "1-a");
3617 let stop = stamp("2026-10-03T03:40:05Z", "1-b");
3618 reg.observe_stamped(
3619 observe_request("s", SessionEvent::PreToolUse, Some("/p")),
3620 Some(work.clone()),
3621 Origin::Socket,
3622 );
3623 reg.observe_stamped(
3624 observe_request("s", SessionEvent::Stop, None),
3625 Some(stop),
3626 Origin::Socket,
3627 );
3628 assert_eq!(state_of(®, "s"), SessionState::Idle);
3629 for origin in [Origin::Journal, Origin::Socket] {
3632 reg.observe_stamped(
3633 observe_request("s", SessionEvent::PreToolUse, None),
3634 Some(work.clone()),
3635 origin,
3636 );
3637 assert_eq!(state_of(®, "s"), SessionState::Idle, "{origin:?}");
3638 }
3639 }
3640
3641 #[test]
3642 fn a_journal_event_older_than_one_already_applied_is_stale() {
3643 let reg = SessionsRegistry::new();
3644 reg.observe_stamped(
3645 observe_request("s", SessionEvent::Stop, Some("/p")),
3646 Some(stamp("2026-10-03T03:40:05Z", "1-b")),
3647 Origin::Socket,
3648 );
3649 reg.observe_stamped(
3651 observe_request("s", SessionEvent::PreToolUse, None),
3652 Some(stamp("2026-10-03T03:40:00Z", "1-a")),
3653 Origin::Journal,
3654 );
3655 assert_eq!(state_of(®, "s"), SessionState::Idle);
3656 reg.observe_stamped(
3658 observe_request("s", SessionEvent::PreToolUse, None),
3659 Some(stamp("2026-10-03T03:41:00Z", "1-c")),
3660 Origin::Journal,
3661 );
3662 assert_eq!(state_of(®, "s"), SessionState::Working);
3663 }
3664
3665 #[test]
3666 fn a_socket_event_overtaken_by_a_newer_one_is_dropped_but_never_for_a_clock_step() {
3667 let reg = SessionsRegistry::new();
3671 reg.observe_stamped(
3672 observe_request("s", SessionEvent::Stop, Some("/p")),
3673 Some(stamp("2026-10-03T03:40:05Z", "1-b")),
3674 Origin::Journal,
3675 );
3676 reg.observe_stamped(
3677 observe_request("s", SessionEvent::PreToolUse, None),
3678 Some(stamp("2026-10-03T03:40:03Z", "1-a")),
3679 Origin::Socket,
3680 );
3681 assert_eq!(state_of(®, "s"), SessionState::Idle);
3682 reg.observe_stamped(
3685 observe_request("s", SessionEvent::PreToolUse, None),
3686 Some(stamp("2026-10-03T03:30:00Z", "1-c")),
3687 Origin::Socket,
3688 );
3689 assert_eq!(state_of(®, "s"), SessionState::Working);
3690 }
3691
3692 #[test]
3693 fn unstamped_events_are_never_skipped() {
3694 let reg = SessionsRegistry::new();
3695 reg.observe(observe_request("s", SessionEvent::Stop, Some("/p")));
3696 reg.observe(observe_request("s", SessionEvent::PreToolUse, None));
3697 assert_eq!(state_of(®, "s"), SessionState::Working);
3698 reg.observe(observe_request("s", SessionEvent::Stop, None));
3699 assert_eq!(state_of(®, "s"), SessionState::Idle);
3700 }
3701
3702 #[test]
3703 fn a_journal_end_older_than_a_resume_does_not_end_the_resumed_session() {
3704 let reg = SessionsRegistry::new();
3705 reg.observe_stamped(
3706 observe_request("s", SessionEvent::SessionStart, Some("/p")),
3707 Some(stamp("2026-10-03T03:40:00Z", "1-a")),
3708 Origin::Socket,
3709 );
3710 reg.observe_stamped(
3713 observe_request("s", SessionEvent::SessionStart, None),
3714 Some(stamp("2026-10-03T03:42:00Z", "1-c")),
3715 Origin::Socket,
3716 );
3717 let ended = reg.end_stamped(
3718 "s",
3719 Some("clear"),
3720 None,
3721 Some(stamp("2026-10-03T03:41:00Z", "1-b")),
3722 Origin::Journal,
3723 );
3724 assert!(ended, "the session is known");
3725 assert_eq!(state_of(®, "s"), SessionState::Starting);
3726 reg.end_stamped(
3728 "s",
3729 None,
3730 None,
3731 Some(stamp("2026-10-03T03:43:00Z", "1-d")),
3732 Origin::Journal,
3733 );
3734 assert_eq!(state_of(®, "s"), SessionState::Ended);
3735 let ended_at = reg.lock_sessions()["s"].last_seen;
3737 reg.end_stamped(
3738 "s",
3739 None,
3740 None,
3741 Some(stamp("2026-10-03T03:43:00Z", "1-d")),
3742 Origin::Socket,
3743 );
3744 assert_eq!(reg.lock_sessions()["s"].last_seen, ended_at);
3745 }
3746
3747 #[test]
3748 fn a_journal_event_keeps_its_own_timestamp_but_not_its_own_age() {
3749 let reg = SessionsRegistry::new();
3750 let long_ago = Utc::now() - chrono::Duration::hours(2);
3751 let st = EventStamp {
3752 ts: long_ago,
3753 seq: "1-a".to_string(),
3754 };
3755 reg.observe_stamped(
3756 observe_request("s", SessionEvent::UserPromptSubmit, Some("/p")),
3757 Some(st),
3758 Origin::Journal,
3759 );
3760 let listed = reg.list();
3761 assert_eq!(listed.len(), 1, "replay is not reaped on arrival");
3762 assert_eq!(listed[0].last_seen, long_ago, "history must not look fresh");
3763 assert_eq!(listed[0].started_at, long_ago);
3764 reg.clock.advance(just_inside_the_ttl());
3766 assert_eq!(reg.list().len(), 1);
3767 reg.clock.advance(Duration::from_secs(2));
3768 assert!(reg.list().is_empty());
3769 }
3770
3771 #[test]
3772 fn a_journal_timestamp_in_the_future_is_clamped_to_now() {
3773 let reg = SessionsRegistry::new();
3774 let future = Utc::now() + chrono::Duration::hours(1);
3775 reg.observe_stamped(
3776 observe_request("s", SessionEvent::Stop, Some("/p")),
3777 Some(EventStamp {
3778 ts: future,
3779 seq: "1-a".to_string(),
3780 }),
3781 Origin::Journal,
3782 );
3783 assert!(reg.list()[0].last_seen <= Utc::now());
3784 }
3785
3786 #[test]
3787 fn only_the_newest_seqs_are_remembered() {
3788 let reg = SessionsRegistry::new();
3789 for i in 0..=MAX_RECENT_SEQS {
3790 reg.observe_stamped(
3791 observe_request("s", SessionEvent::PreToolUse, Some("/p")),
3792 Some(stamp("2026-10-03T03:40:00Z", &format!("1-{i}"))),
3793 Origin::Socket,
3794 );
3795 }
3796 let guard = reg.lock_sessions();
3797 assert_eq!(guard["s"].recent_seqs.len(), MAX_RECENT_SEQS);
3798 assert!(guard["s"].seq_origin("1-0").is_none());
3799 assert_eq!(
3800 guard["s"].seq_origin(&format!("1-{MAX_RECENT_SEQS}")),
3801 Some(Origin::Socket)
3802 );
3803 }
3804
3805 #[test]
3806 fn a_duplicate_does_not_bump_and_does_not_refresh_liveness() {
3807 let reg = SessionsRegistry::new();
3808 let st = stamp("2026-10-03T03:40:00Z", "1-a");
3809 reg.observe_stamped(
3810 observe_request("s", SessionEvent::PreToolUse, Some("/p")),
3811 Some(st.clone()),
3812 Origin::Socket,
3813 );
3814 let active = reg.lock_sessions()["s"].last_active;
3815 let rx = reg.subscribe_changes();
3816 reg.clock.advance(Duration::from_secs(10));
3817 reg.observe_stamped(
3818 observe_request("s", SessionEvent::PreToolUse, Some("/p")),
3819 Some(st),
3820 Origin::Journal,
3821 );
3822 assert!(!rx.has_changed().unwrap());
3823 assert_eq!(reg.lock_sessions()["s"].last_active, active);
3824 }
3825
3826 #[test]
3829 fn socket_events_are_counted_stamped_or_not() {
3830 let reg = SessionsRegistry::new();
3831 reg.observe_stamped(
3832 observe_request("s", SessionEvent::PreToolUse, Some("/p")),
3833 None,
3834 Origin::Socket,
3835 );
3836 reg.observe_stamped(
3837 observe_request("s", SessionEvent::Stop, None),
3838 Some(stamp("2026-10-03T03:40:00Z", "1-a")),
3839 Origin::Socket,
3840 );
3841 reg.end_stamped("s", None, None, None, Origin::Socket);
3842 assert_eq!(
3843 reg.delivery(),
3844 DeliveryStats {
3845 socket: 3,
3846 recovered: 0,
3847 superseded: 0,
3848 replayed: 0
3849 }
3850 );
3851 }
3852
3853 #[test]
3854 fn the_daemons_own_feeds_are_applied_but_not_counted_as_deliveries() {
3855 let reg = SessionsRegistry::new();
3858 reg.observe(observe_request("s", SessionEvent::PreToolUse, Some("/p")));
3859 reg.observe(observe_request("s", SessionEvent::TranscriptGrew, None));
3860 assert_eq!(reg.lock_sessions()["s"].state, SessionState::Working);
3861 assert!(reg.end("s", None, None));
3862 assert_eq!(reg.lock_sessions()["s"].state, SessionState::Ended);
3863 assert_eq!(reg.delivery(), DeliveryStats::default());
3864 }
3865
3866 #[test]
3867 fn a_journal_event_whose_post_never_came_is_recovered_and_a_replay_is_not() {
3868 let reg = SessionsRegistry::new();
3869 reg.observe_stamped(
3870 observe_request("s", SessionEvent::SessionStart, Some("/p")),
3871 Some(stamp("2026-10-03T03:40:00Z", "1-a")),
3872 Origin::Replay,
3873 );
3874 reg.observe_stamped(
3875 observe_request("s", SessionEvent::PreToolUse, None),
3876 Some(stamp("2026-10-03T03:40:05Z", "1-b")),
3877 Origin::Journal,
3878 );
3879 reg.end_stamped(
3880 "s",
3881 None,
3882 None,
3883 Some(stamp("2026-10-03T03:40:09Z", "1-c")),
3884 Origin::Journal,
3885 );
3886 assert_eq!(
3887 reg.delivery(),
3888 DeliveryStats {
3889 socket: 0,
3890 recovered: 2,
3891 superseded: 0,
3892 replayed: 1
3893 }
3894 );
3895 }
3896
3897 #[test]
3898 fn a_socket_copy_that_lost_the_race_to_the_poll_is_not_a_recovery() {
3899 let reg = SessionsRegistry::new();
3903 let st = stamp("2026-10-03T03:40:00Z", "1-a");
3904 reg.observe_stamped(
3905 observe_request("s", SessionEvent::PreToolUse, Some("/p")),
3906 Some(st.clone()),
3907 Origin::Journal,
3908 );
3909 assert_eq!(reg.delivery().recovered, 1, "provisionally recovered");
3910 reg.observe_stamped(
3911 observe_request("s", SessionEvent::PreToolUse, None),
3912 Some(st),
3913 Origin::Socket,
3914 );
3915 assert_eq!(
3916 reg.delivery(),
3917 DeliveryStats {
3918 socket: 0,
3919 recovered: 0,
3920 superseded: 0,
3921 replayed: 0
3922 }
3923 );
3924 }
3925
3926 #[test]
3927 fn the_journal_copy_of_a_socket_event_counts_for_nothing() {
3928 let reg = SessionsRegistry::new();
3929 let st = stamp("2026-10-03T03:40:00Z", "1-a");
3930 reg.observe_stamped(
3931 observe_request("s", SessionEvent::PreToolUse, Some("/p")),
3932 Some(st.clone()),
3933 Origin::Socket,
3934 );
3935 reg.observe_stamped(
3936 observe_request("s", SessionEvent::PreToolUse, None),
3937 Some(st.clone()),
3938 Origin::Journal,
3939 );
3940 reg.observe_stamped(
3942 observe_request("s", SessionEvent::PreToolUse, None),
3943 Some(st),
3944 Origin::Socket,
3945 );
3946 assert_eq!(
3947 reg.delivery(),
3948 DeliveryStats {
3949 socket: 1,
3950 recovered: 0,
3951 superseded: 0,
3952 replayed: 0
3953 }
3954 );
3955 }
3956
3957 #[test]
3958 fn an_orphan_journal_end_is_not_counted_as_a_recovery() {
3959 let reg = SessionsRegistry::new();
3962 reg.end_stamped(
3963 "ghost",
3964 None,
3965 None,
3966 Some(stamp("2026-10-03T03:41:00Z", "2-a")),
3967 Origin::Journal,
3968 );
3969 assert_eq!(reg.delivery(), DeliveryStats::default());
3970 }
3971
3972 fn session_at_0340_05() -> SessionsRegistry {
3974 let reg = SessionsRegistry::new();
3975 reg.observe_stamped(
3976 observe_request("s", SessionEvent::Stop, Some("/p")),
3977 Some(stamp("2026-10-03T03:40:05Z", "1-b")),
3978 Origin::Socket,
3979 );
3980 reg
3981 }
3982
3983 #[test]
3984 fn a_dropped_post_that_a_newer_event_overtook_is_superseded() {
3985 let reg = session_at_0340_05();
3989 let dropped = stamp("2026-10-03T03:40:00Z", "1-a");
3990 reg.observe_stamped(
3991 observe_request("s", SessionEvent::PreToolUse, None),
3992 Some(dropped.clone()),
3993 Origin::Journal,
3994 );
3995 assert_eq!(
3996 reg.delivery(),
3997 DeliveryStats {
3998 socket: 1,
3999 recovered: 0,
4000 superseded: 1,
4001 replayed: 0
4002 }
4003 );
4004 reg.observe_stamped(
4006 observe_request("s", SessionEvent::PreToolUse, None),
4007 Some(dropped),
4008 Origin::Journal,
4009 );
4010 assert_eq!(reg.delivery().superseded, 1);
4011 assert_eq!(
4012 reg.lock_sessions()["s"].state,
4013 SessionState::Idle,
4014 "and the newer event still stands"
4015 );
4016 }
4017
4018 #[test]
4019 fn a_stale_journal_end_whose_post_never_came_is_superseded() {
4020 let reg = session_at_0340_05();
4023 reg.end_stamped(
4024 "s",
4025 None,
4026 None,
4027 Some(stamp("2026-10-03T03:40:00Z", "1-a")),
4028 Origin::Journal,
4029 );
4030 assert_eq!(reg.delivery().superseded, 1);
4031 assert_eq!(reg.lock_sessions()["s"].state, SessionState::Idle);
4032 }
4033
4034 #[test]
4035 fn a_late_post_takes_a_superseded_event_back_out() {
4036 let reg = session_at_0340_05();
4040 let late = stamp("2026-10-03T03:40:00Z", "1-a");
4041 reg.observe_stamped(
4042 observe_request("s", SessionEvent::PreToolUse, None),
4043 Some(late.clone()),
4044 Origin::Journal,
4045 );
4046 assert_eq!(reg.delivery().superseded, 1, "provisionally dropped");
4047 for _ in 0..2 {
4048 reg.observe_stamped(
4049 observe_request("s", SessionEvent::PreToolUse, None),
4050 Some(late.clone()),
4051 Origin::Socket,
4052 );
4053 }
4054 assert_eq!(
4055 reg.delivery(),
4056 DeliveryStats {
4057 socket: 1,
4058 recovered: 0,
4059 superseded: 0,
4060 replayed: 0
4061 }
4062 );
4063 }
4064
4065 #[test]
4066 fn a_post_late_enough_to_be_applied_is_a_delivery_after_all() {
4067 let reg = session_at_0340_05();
4070 let late = stamp("2026-10-03T03:39:00Z", "1-a");
4071 reg.observe_stamped(
4072 observe_request("s", SessionEvent::PreToolUse, None),
4073 Some(late.clone()),
4074 Origin::Journal,
4075 );
4076 reg.observe_stamped(
4077 observe_request("s", SessionEvent::PreToolUse, None),
4078 Some(late),
4079 Origin::Socket,
4080 );
4081 assert_eq!(
4082 reg.delivery(),
4083 DeliveryStats {
4084 socket: 2,
4085 recovered: 0,
4086 superseded: 0,
4087 replayed: 0
4088 }
4089 );
4090 }
4091
4092 #[test]
4093 fn the_journal_copy_of_a_socket_event_dropped_as_out_of_order_is_not_a_drop() {
4094 let reg = session_at_0340_05();
4098 let reordered = stamp("2026-10-03T03:40:00Z", "1-a");
4099 reg.observe_stamped(
4100 observe_request("s", SessionEvent::PreToolUse, None),
4101 Some(reordered.clone()),
4102 Origin::Socket,
4103 );
4104 reg.observe_stamped(
4105 observe_request("s", SessionEvent::PreToolUse, None),
4106 Some(reordered),
4107 Origin::Journal,
4108 );
4109 assert_eq!(
4110 reg.delivery(),
4111 DeliveryStats {
4112 socket: 1,
4113 recovered: 0,
4114 superseded: 0,
4115 replayed: 0
4116 }
4117 );
4118 }
4119
4120 #[test]
4121 fn a_stale_replayed_event_is_counted_as_nothing() {
4122 let reg = session_at_0340_05();
4125 reg.observe_stamped(
4126 observe_request("s", SessionEvent::PreToolUse, None),
4127 Some(stamp("2026-10-03T03:40:00Z", "1-a")),
4128 Origin::Replay,
4129 );
4130 assert_eq!(
4131 reg.delivery(),
4132 DeliveryStats {
4133 socket: 1,
4134 recovered: 0,
4135 superseded: 0,
4136 replayed: 0
4137 }
4138 );
4139 }
4140
4141 #[test]
4142 fn superseded_events_are_remembered_only_up_to_the_ring_size() {
4143 let reg = session_at_0340_05();
4144 for i in 0..=MAX_RECENT_SEQS {
4145 reg.observe_stamped(
4146 observe_request("s", SessionEvent::PreToolUse, None),
4147 Some(stamp("2026-10-03T03:40:00Z", &format!("9-{i}"))),
4148 Origin::Journal,
4149 );
4150 }
4151 assert_eq!(reg.delivery().superseded, MAX_RECENT_SEQS as u64 + 1);
4152 let guard = reg.lock_sessions();
4153 assert_eq!(guard["s"].superseded_seqs.len(), MAX_RECENT_SEQS);
4154 assert!(!guard["s"].superseded_seqs.contains(&"9-0".to_string()));
4155 }
4156
4157 #[test]
4158 fn delivery_stats_serialize_with_the_documented_names() {
4159 let value = serde_json::to_value(DeliveryStats {
4160 socket: 5,
4161 recovered: 2,
4162 superseded: 3,
4163 replayed: 1,
4164 })
4165 .unwrap();
4166 assert_eq!(
4167 value,
4168 serde_json::json!({ "socket": 5, "recovered": 2, "superseded": 3, "replayed": 1 })
4169 );
4170 }
4171
4172 #[test]
4173 fn the_socket_copy_of_a_replayed_event_takes_nothing_back_from_recoveries() {
4174 let reg = SessionsRegistry::new();
4175 let replayed = stamp("2026-10-03T03:40:00Z", "1-a");
4176 reg.observe_stamped(
4177 observe_request("s", SessionEvent::SessionStart, Some("/p")),
4178 Some(replayed.clone()),
4179 Origin::Replay,
4180 );
4181 reg.observe_stamped(
4182 observe_request("s", SessionEvent::PreToolUse, None),
4183 Some(stamp("2026-10-03T03:40:05Z", "1-b")),
4184 Origin::Journal,
4185 );
4186 reg.observe_stamped(
4188 observe_request("s", SessionEvent::SessionStart, None),
4189 Some(replayed),
4190 Origin::Socket,
4191 );
4192 assert_eq!(
4193 reg.delivery(),
4194 DeliveryStats {
4195 socket: 0,
4196 recovered: 1,
4197 superseded: 0,
4198 replayed: 1
4199 },
4200 "the real recovery is not masked"
4201 );
4202 }
4203
4204 #[test]
4205 fn repeated_socket_copies_of_one_raced_event_take_it_back_only_once() {
4206 let reg = SessionsRegistry::new();
4207 let raced = stamp("2026-10-03T03:40:00Z", "1-a");
4208 reg.observe_stamped(
4209 observe_request("s", SessionEvent::PreToolUse, Some("/p")),
4210 Some(raced.clone()),
4211 Origin::Journal,
4212 );
4213 reg.observe_stamped(
4214 observe_request("s", SessionEvent::PostToolUse, None),
4215 Some(stamp("2026-10-03T03:40:05Z", "1-b")),
4216 Origin::Journal,
4217 );
4218 assert_eq!(reg.delivery().recovered, 2);
4219 for _ in 0..2 {
4221 reg.observe_stamped(
4222 observe_request("s", SessionEvent::PreToolUse, None),
4223 Some(raced.clone()),
4224 Origin::Socket,
4225 );
4226 }
4227 assert_eq!(reg.delivery().recovered, 1, "the other recovery stands");
4228 }
4229
4230 #[test]
4231 fn a_socket_end_counts_even_for_a_session_the_registry_does_not_hold() {
4232 let reg = SessionsRegistry::new();
4233 reg.end_stamped(
4234 "ghost",
4235 None,
4236 None,
4237 Some(stamp("2026-10-03T03:41:00Z", "9-a")),
4238 Origin::Socket,
4239 );
4240 assert_eq!(reg.delivery().socket, 1);
4241 }
4242}