1use serde::{Deserialize, Serialize};
25
26use crate::backend::storage::filestore::{FileMeta, FileOpts, FileStore};
27
28pub(crate) const SEGMENTS_FILE: &str = "segments.jsonl";
29const TAIL_WINDOW_BYTES: i64 = 64 * 1024;
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub(crate) enum Rung {
37 Native,
39 Virtualized,
41 Fresh,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub(crate) enum EndReason {
49 Exited,
51 Crashed,
53 Closed,
55 Restarted,
57 Stopped,
59}
60
61#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
62#[serde(tag = "event", rename_all = "snake_case")]
63enum Event {
64 Start(Start),
65 Session { segment_id: String, provider_session_id: String, at_ms: i64 },
66 End {
67 segment_id: String,
68 ended_at_ms: i64,
69 end_reason: EndReason,
70 byte_end: Option<i64>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
74 provider_bytes_end: Option<i64>,
75 },
76}
77
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
80pub(crate) struct Start {
81 pub segment_id: String,
82 pub agent_uid: String,
83 pub definition_id: Option<String>,
86 pub provider: String,
87 pub config_dir: Option<String>,
89 pub provider_session_id: Option<String>,
91 pub cwd: String,
92 pub channel: String,
93 pub agentmux_version: String,
94 pub block_id: String,
95 pub zone: Option<String>,
97 pub byte_start: Option<i64>,
98 pub started_at_ms: i64,
99 pub continuity_rung: Rung,
100 pub predecessor_segment_id: Option<String>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub lease_epoch: Option<u64>,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub identity_key: Option<String>,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub forked_from: Option<String>,
119}
120
121#[derive(Debug, Clone, PartialEq)]
123pub(crate) struct Segment {
124 pub start: Start,
125 pub ended_at_ms: Option<i64>,
126 pub end_reason: Option<EndReason>,
127 pub byte_end: Option<i64>,
128 pub provider_bytes_end: Option<i64>,
129}
130
131pub(crate) fn zone_for(agent_uid: &str) -> Option<String> {
134 crate::backend::agent_session::is_valid_definition_id(agent_uid).then(|| format!("agent-uid:{agent_uid}:segments"))
135}
136
137fn append(fs: &FileStore, zone: &str, event: &Event) -> Result<(), String> {
138 let mut line = serde_json::to_vec(event).map_err(|e| format!("encode segment event: {e}"))?;
139 line.push(b'\n');
140 if fs.stat(zone, SEGMENTS_FILE).map_err(|e| format!("stat segments: {e}"))?.is_none() {
141 let _ = fs.make_file(zone, SEGMENTS_FILE, FileMeta::default(), FileOpts::default());
143 }
144 fs.append_data(zone, SEGMENTS_FILE, &line).map_err(|e| format!("append segment event: {e}"))
145}
146
147fn events_in(bytes: &[u8]) -> impl Iterator<Item = Event> + '_ {
148 bytes.split(|b| *b == b'\n').filter_map(|line| serde_json::from_slice::<Event>(line).ok())
149}
150
151fn newest_segment_id(fs: &FileStore, zone: &str) -> Option<String> {
153 let file = fs.stat(zone, SEGMENTS_FILE).ok()??;
154 let offset = (file.size - TAIL_WINDOW_BYTES).max(0);
155 let (_, data) = fs.read_at(zone, SEGMENTS_FILE, offset, file.size - offset).ok()?;
156 let mut newest: Option<(i64, String)> = None;
157 for event in events_in(&data) {
158 if let Event::Start(s) = event {
159 if newest.as_ref().is_none_or(|(at, _)| s.started_at_ms >= *at) {
160 newest = Some((s.started_at_ms, s.segment_id));
161 }
162 }
163 }
164 newest.map(|(_, id)| id)
165}
166
167pub(crate) fn record_start(fs: &FileStore, mut start: Start) -> Result<String, String> {
170 let zone = zone_for(&start.agent_uid).ok_or("agent uid is not a valid zone key")?;
171 start.segment_id = uuid::Uuid::new_v4().to_string();
172 start.predecessor_segment_id = newest_segment_id(fs, &zone);
173 let id = start.segment_id.clone();
174 append(fs, &zone, &Event::Start(start))?;
175 Ok(id)
176}
177
178pub(crate) fn record_session(fs: &FileStore, agent_uid: &str, segment_id: &str, provider_session_id: &str, at_ms: i64) -> Result<(), String> {
179 let zone = zone_for(agent_uid).ok_or("agent uid is not a valid zone key")?;
180 append(
181 fs,
182 &zone,
183 &Event::Session { segment_id: segment_id.to_string(), provider_session_id: provider_session_id.to_string(), at_ms },
184 )
185}
186
187#[cfg(test)]
188pub(crate) fn record_end(
189 fs: &FileStore,
190 agent_uid: &str,
191 segment_id: &str,
192 end_reason: EndReason,
193 byte_end: Option<i64>,
194 ended_at_ms: i64,
195) -> Result<(), String> {
196 record_end_with_provider_bytes(fs, agent_uid, segment_id, end_reason, byte_end, None, ended_at_ms)
197}
198
199pub(crate) fn record_end_with_provider_bytes(
203 fs: &FileStore,
204 agent_uid: &str,
205 segment_id: &str,
206 end_reason: EndReason,
207 byte_end: Option<i64>,
208 provider_bytes_end: Option<i64>,
209 ended_at_ms: i64,
210) -> Result<(), String> {
211 let zone = zone_for(agent_uid).ok_or("agent uid is not a valid zone key")?;
212 append(fs, &zone, &Event::End { segment_id: segment_id.to_string(), ended_at_ms, end_reason, byte_end, provider_bytes_end })
213}
214
215pub(crate) fn segments(fs: &FileStore, agent_uid: &str) -> Vec<Segment> {
218 let Some(zone) = zone_for(agent_uid) else { return Vec::new() };
219 let Ok(Some(data)) = fs.read_file(&zone, SEGMENTS_FILE) else { return Vec::new() };
220 fold(events_in(&data))
221}
222
223fn fold(events: impl Iterator<Item = Event>) -> Vec<Segment> {
224 let mut out: Vec<Segment> = Vec::new();
225 for event in events {
226 match event {
227 Event::Start(start) => {
228 out.push(Segment { start, ended_at_ms: None, end_reason: None, byte_end: None, provider_bytes_end: None });
229 }
230 Event::Session { segment_id, provider_session_id, .. } => {
231 if let Some(s) = out.iter_mut().rev().find(|s| s.start.segment_id == segment_id) {
232 s.start.provider_session_id = Some(provider_session_id);
233 }
234 }
235 Event::End { segment_id, ended_at_ms, end_reason, byte_end, provider_bytes_end } => {
236 if let Some(s) = out.iter_mut().rev().find(|s| s.start.segment_id == segment_id && s.ended_at_ms.is_none()) {
238 s.ended_at_ms = Some(ended_at_ms);
239 s.end_reason = Some(end_reason);
240 s.byte_end = byte_end;
241 s.provider_bytes_end = provider_bytes_end;
242 }
243 }
244 }
245 }
246 out.sort_by_key(|s| s.start.started_at_ms);
247 out
248}
249
250pub(crate) fn head_session(segments: &[Segment], current: Option<&str>) -> Option<String> {
254 segments
255 .iter()
256 .rev()
257 .filter(|s| Some(s.start.segment_id.as_str()) != current)
258 .find_map(|s| s.start.provider_session_id.clone())
259}
260
261pub(crate) fn recovery_allowed(candidate: &str, head: Option<&str>) -> bool {
270 head.is_none_or(|h| h == candidate)
271}
272
273#[derive(Debug, Clone, PartialEq, Eq)]
276pub(crate) enum ResumeGate {
277 Allow,
280 Redirect { head: String },
282 Refuse { head: String },
285 Relocate { head: String, from_config_dir: String },
289 Fork { head: String },
294 ForkFrom { head: String, from_config_dir: String, from_cwd: String },
301}
302
303#[derive(Debug, Clone, Copy, Default)]
305pub(crate) struct GateInput<'a> {
306 pub candidate: &'a str,
308 pub head: Option<&'a Head>,
309 pub identity: Option<&'a str>,
311 pub poisoned: Option<&'a str>,
313 pub config_dir: &'a str,
315 pub cwd: &'a str,
316 pub fork_source: Option<&'a Head>,
319}
320
321#[derive(Debug, Clone, Default, PartialEq, Eq)]
324pub(crate) struct Head {
325 pub session_id: String,
326 pub identity_key: Option<String>,
327 pub config_dir: Option<String>,
328 pub provider: String,
329 pub cwd: String,
330 pub provider_bytes_end: Option<i64>,
332}
333
334pub(crate) fn resume_gate(
349 input: &GateInput<'_>,
350 size_here: impl Fn(&str) -> Option<u64>,
351 resumable_at: impl Fn(&str, &str, &str) -> bool,
352) -> ResumeGate {
353 let Some(head) = input.head else { return fork_from(input, &resumable_at).unwrap_or(ResumeGate::Allow) };
354 if Some(head.session_id.as_str()) == input.poisoned {
355 return ResumeGate::Allow;
356 }
357 let refuse = || ResumeGate::Refuse { head: head.session_id.clone() };
358 if matches!((head.identity_key.as_deref(), input.identity), (Some(h), Some(s)) if h != s) {
359 return refuse();
360 }
361 let is_candidate = head.session_id == input.candidate;
362 if let Some(size) = size_here(&head.session_id) {
363 if head.provider_bytes_end.is_some_and(|end| i64::try_from(size).unwrap_or(i64::MAX) > end) {
364 return ResumeGate::Fork { head: head.session_id.clone() };
365 }
366 return if is_candidate { ResumeGate::Allow } else { ResumeGate::Redirect { head: head.session_id.clone() } };
367 }
368 if let Some(from) = relocation_source(head, input) {
369 if resumable_at(from, input.cwd, &head.session_id) {
370 return ResumeGate::Relocate { head: head.session_id.clone(), from_config_dir: from.to_string() };
371 }
372 }
373 if head.config_dir.as_deref().is_some_and(|dir| same_dir(dir, input.config_dir)) {
379 return refuse();
380 }
381 if is_candidate {
385 ResumeGate::Allow
386 } else {
387 refuse()
388 }
389}
390
391fn relocation_source<'h>(head: &'h Head, input: &GateInput<'_>) -> Option<&'h str> {
395 if !matches!(head.provider.as_str(), "claude" | "claude-code") {
396 return None;
397 }
398 let from = head.config_dir.as_deref()?;
399 if same_dir(from, input.config_dir) {
400 return None;
401 }
402 if head.identity_key.as_deref()? != input.identity? {
403 return None;
404 }
405 let slug = crate::backend::claude_layout::project_dir_name;
406 (slug(&head.cwd) == slug(input.cwd)).then_some(from)
407}
408
409fn fork_from(input: &GateInput<'_>, resumable_at: &impl Fn(&str, &str, &str) -> bool) -> Option<ResumeGate> {
414 let source = input.fork_source?;
415 if source.session_id != input.candidate || !matches!(source.provider.as_str(), "claude" | "claude-code") {
416 return None;
417 }
418 if source.identity_key.as_deref()? != input.identity? {
419 return None;
420 }
421 let from = source.config_dir.as_deref()?;
422 resumable_at(from, &source.cwd, &source.session_id).then(|| ResumeGate::ForkFrom {
423 head: source.session_id.clone(),
424 from_config_dir: from.to_string(),
425 from_cwd: source.cwd.clone(),
426 })
427}
428
429pub(crate) fn fork_source_head(
434 store: &crate::backend::storage::store::Store,
435 fs: &FileStore,
436 agent_uid: &str,
437) -> Option<Head> {
438 let def = store.agent_def_get(agent_uid).ok()??;
439 if def.branch_label.trim().is_empty() || def.parent_id.trim().is_empty() {
440 return None;
441 }
442 chain_head(fs, def.parent_id.trim()).map(with_legacy_identity)
443}
444
445fn same_dir(a: &str, b: &str) -> bool {
446 let norm = |s: &str| s.replace('\\', "/").trim_end_matches('/').to_lowercase();
447 norm(a) == norm(b)
448}
449
450pub(crate) fn with_legacy_identity(mut head: Head) -> Head {
458 if head.identity_key.is_none() {
459 if let Some(dir) = head.config_dir.as_deref() {
460 head.identity_key = crate::identity::account_email::identity_key_from_oauth_dir(&head.provider, dir);
461 }
462 }
463 head
464}
465
466pub(crate) fn chain_head(fs: &FileStore, agent_uid: &str) -> Option<Head> {
469 segments(fs, agent_uid).into_iter().rev().find_map(|s| {
470 let session_id = s.start.provider_session_id?;
471 Some(Head {
472 session_id,
473 identity_key: s.start.identity_key,
474 config_dir: s.start.config_dir,
475 provider: s.start.provider,
476 cwd: s.start.cwd,
477 provider_bytes_end: s.provider_bytes_end,
478 })
479 })
480}
481
482pub(crate) fn rung_for_spawn(resumed: bool, carries_packet: bool) -> Rung {
485 if resumed {
486 Rung::Native
487 } else if carries_packet {
488 Rung::Virtualized
489 } else {
490 Rung::Fresh
491 }
492}
493
494pub(crate) fn kill_reason(closing_this_generation: bool, restart_pending: bool) -> EndReason {
496 if closing_this_generation {
497 EndReason::Closed
498 } else if restart_pending {
499 EndReason::Restarted
500 } else {
501 EndReason::Stopped
502 }
503}
504
505pub(crate) fn exit_reason(exit_code: i32) -> EndReason {
506 if exit_code == 0 { EndReason::Exited } else { EndReason::Crashed }
507}
508
509#[cfg(test)]
510mod tests {
511 use super::*;
512
513 const UID: &str = "uid-1";
514
515 fn start(at: i64, rung: Rung) -> Start {
516 Start {
517 segment_id: String::new(),
518 agent_uid: UID.into(),
519 definition_id: Some("def-1".into()),
520 provider: "claude".into(),
521 config_dir: Some("C:/cfg/a".into()),
522 provider_session_id: None,
523 cwd: "C:/work".into(),
524 channel: "stable".into(),
525 agentmux_version: "0.57.2".into(),
526 block_id: "block-1".into(),
527 zone: Some("agent:def-1:current".into()),
528 byte_start: Some(100),
529 started_at_ms: at,
530 continuity_rung: rung,
531 predecessor_segment_id: None,
532 lease_epoch: None,
533 identity_key: None,
534 forked_from: None,
535 }
536 }
537
538 #[test]
543 fn segments_record_the_lease_epoch_and_older_records_still_read() {
544 let fs = FileStore::open_in_memory().unwrap();
545 let first = record_start(&fs, Start { lease_epoch: Some(3), ..start(1_000, Rung::Fresh) }).unwrap();
546 record_start(&fs, Start { lease_epoch: Some(4), ..start(2_000, Rung::Native) }).unwrap();
547 let all = segments(&fs, UID);
548 assert_eq!(all.iter().map(|s| s.start.lease_epoch).collect::<Vec<_>>(), vec![Some(3), Some(4)]);
549 assert_eq!(all[0].start.segment_id, first);
550
551 let old = serde_json::json!({
552 "event": "start", "segment_id": "s", "agent_uid": UID, "definition_id": null, "provider": "claude",
553 "config_dir": null, "provider_session_id": null, "cwd": "", "channel": "stable",
554 "agentmux_version": "0.57.0", "block_id": "b", "zone": null, "byte_start": null,
555 "started_at_ms": 1, "continuity_rung": "fresh", "predecessor_segment_id": null,
556 });
557 match serde_json::from_value::<Event>(old).unwrap() {
558 Event::Start(s) => assert_eq!(s.lease_epoch, None),
559 other => panic!("expected a start event, got {other:?}"),
560 }
561 }
562
563 #[test]
564 fn segments_chain_to_their_predecessor_and_fold_their_events() {
565 let fs = FileStore::open_in_memory().unwrap();
566 let first = record_start(&fs, start(1_000, Rung::Fresh)).unwrap();
567 record_session(&fs, UID, &first, "sid-a", 1_001).unwrap();
568 record_end(&fs, UID, &first, EndReason::Crashed, Some(900), 2_000).unwrap();
569 let second = record_start(&fs, start(3_000, Rung::Virtualized)).unwrap();
570
571 let all = segments(&fs, UID);
572 assert_eq!(all.len(), 2);
573 assert_eq!(all[0].start.segment_id, first);
574 assert_eq!(all[0].start.provider_session_id.as_deref(), Some("sid-a"));
575 assert_eq!((all[0].ended_at_ms, all[0].end_reason, all[0].byte_end), (Some(2_000), Some(EndReason::Crashed), Some(900)));
576 assert_eq!(all[0].start.predecessor_segment_id, None);
577 assert_eq!(all[1].start.predecessor_segment_id.as_deref(), Some(first.as_str()));
578 assert_eq!(all[1].start.continuity_rung, Rung::Virtualized);
579 assert!(all[1].ended_at_ms.is_none(), "still running");
580 assert_ne!(first, second);
581 }
582
583 fn head(sid: &str, identity: Option<&str>) -> Head {
586 Head { session_id: sid.into(), identity_key: identity.map(str::to_string), ..Default::default() }
587 }
588
589 fn gate(candidate: &str, head: Option<&Head>, identity: Option<&str>, poisoned: Option<&str>, here: &[&str], elsewhere: &[&str]) -> ResumeGate {
593 let input = GateInput { candidate, head, identity, poisoned, config_dir: "C:/cfg/new", cwd: "C:/work", fork_source: None };
594 resume_gate(&input, |sid| here.contains(&sid).then_some(10), |_, _, sid| elsewhere.contains(&sid))
595 }
596
597 fn fork_source(sid: &str, identity: Option<&str>) -> Head {
600 Head {
601 session_id: sid.into(),
602 identity_key: identity.map(str::to_string),
603 config_dir: Some("C:/cfg/parent".into()),
604 provider: "claude".into(),
605 cwd: "C:/parent".into(),
606 provider_bytes_end: None,
607 }
608 }
609
610 fn fork_gate(candidate: &str, source: &Head, identity: Option<&str>, complete: bool) -> ResumeGate {
613 let input = GateInput {
614 candidate,
615 head: None,
616 identity,
617 poisoned: None,
618 config_dir: "C:/cfg/new",
619 cwd: "C:/work",
620 fork_source: Some(source),
621 };
622 resume_gate(&input, |_| None, |dir, cwd, sid| complete && dir == "C:/cfg/parent" && cwd == "C:/parent" && sid == candidate)
623 }
624
625 #[test]
626 fn a_fork_without_a_chain_forks_from_its_parents_head() {
627 let source = fork_source("p1", Some("k1"));
628 assert_eq!(
629 fork_gate("p1", &source, Some("k1"), true),
630 ResumeGate::ForkFrom { head: "p1".into(), from_config_dir: "C:/cfg/parent".into(), from_cwd: "C:/parent".into() }
631 );
632 }
633
634 #[test]
635 fn a_fork_forks_only_its_parents_complete_head_under_its_own_identity() {
636 let source = fork_source("p1", Some("k1"));
637 assert_eq!(fork_gate("p0", &source, Some("k1"), true), ResumeGate::Allow, "not the parent's head");
638 assert_eq!(fork_gate("p1", &source, Some("k2"), true), ResumeGate::Allow, "another identity (I2)");
639 assert_eq!(fork_gate("p1", &source, None, true), ResumeGate::Allow, "identity unknown");
640 assert_eq!(fork_gate("p1", &source, Some("k1"), false), ResumeGate::Allow, "no complete file there");
641 }
642
643 #[test]
644 fn the_chain_head_itself_is_resumed() {
645 let h = head("s2", None);
646 assert_eq!(gate("s2", Some(&h), None, None, &["s2"], &[]), ResumeGate::Allow);
647 }
648
649 #[test]
650 fn without_a_chain_the_candidate_is_resumed_as_before() {
651 assert_eq!(gate("s1", None, Some("k1"), None, &[], &[]), ResumeGate::Allow);
652 }
653
654 #[test]
656 fn a_stale_candidate_is_redirected_to_a_reachable_head() {
657 let h = head("s2", None);
658 assert_eq!(gate("s1", Some(&h), None, None, &["s1", "s2"], &[]), ResumeGate::Redirect { head: "s2".into() });
659 }
660
661 #[test]
662 fn a_stale_candidate_is_refused_when_the_head_is_out_of_reach() {
663 let h = head("s2", None);
664 assert_eq!(gate("s1", Some(&h), None, None, &["s1"], &[]), ResumeGate::Refuse { head: "s2".into() });
665 }
666
667 #[test]
671 fn an_unreachable_head_as_the_candidate_is_left_to_the_retry_path() {
672 let h = head("s2", None);
673 assert_eq!(gate("s2", Some(&h), None, None, &[], &[]), ResumeGate::Allow);
674 }
675
676 #[test]
680 fn a_head_never_written_under_the_spawns_own_dir_is_not_resumed() {
681 let h = Head { config_dir: Some("c:\\cfg\\new\\".into()), provider: "claude".into(), ..head("s2", Some("k1")) };
682 assert_eq!(gate("s2", Some(&h), Some("k1"), None, &[], &[]), ResumeGate::Refuse { head: "s2".into() });
683 assert_eq!(gate("s2", Some(&h), Some("k1"), None, &["s2"], &[]), ResumeGate::Allow, "written: resumed");
684 }
685
686 #[test]
687 fn a_poisoned_head_never_displaces_the_candidate() {
688 let h = head("s2", None);
689 assert_eq!(gate("s1", Some(&h), None, Some("s2"), &["s2"], &[]), ResumeGate::Allow);
690 }
691
692 #[test]
697 fn the_head_under_another_identity_is_not_resumed() {
698 let h = head("s2", Some("k-old"));
699 assert_eq!(gate("s2", Some(&h), Some("k-new"), None, &["s2"], &[]), ResumeGate::Refuse { head: "s2".into() });
700 assert_eq!(gate("s1", Some(&h), Some("k-new"), None, &["s2"], &[]), ResumeGate::Refuse { head: "s2".into() });
701 }
702
703 #[test]
704 fn the_same_identity_resumes_and_redirects() {
705 let h = head("s2", Some("k1"));
706 assert_eq!(gate("s2", Some(&h), Some("k1"), None, &["s2"], &[]), ResumeGate::Allow);
707 assert_eq!(gate("s1", Some(&h), Some("k1"), None, &["s2"], &[]), ResumeGate::Redirect { head: "s2".into() });
708 }
709
710 #[test]
713 fn an_unknown_identity_on_either_side_changes_nothing() {
714 let known = head("s2", Some("k1"));
715 let unknown = head("s2", None);
716 assert_eq!(gate("s2", Some(&known), None, None, &["s2"], &[]), ResumeGate::Allow);
717 assert_eq!(gate("s2", Some(&unknown), Some("k1"), None, &["s2"], &[]), ResumeGate::Allow);
718 assert_eq!(gate("s1", Some(&known), None, None, &["s2"], &[]), ResumeGate::Redirect { head: "s2".into() });
719 }
720
721 fn elsewhere(sid: &str, identity: Option<&str>) -> Head {
724 Head {
725 session_id: sid.into(),
726 identity_key: identity.map(str::to_string),
727 config_dir: Some("C:/cfg/old".into()),
728 provider: "claude".into(),
729 cwd: "C:/work".into(),
730 provider_bytes_end: None,
731 }
732 }
733
734 #[test]
737 fn the_head_under_another_login_of_the_same_identity_is_relocated() {
738 let h = elsewhere("s2", Some("k1"));
739 let relocate = ResumeGate::Relocate { head: "s2".into(), from_config_dir: "C:/cfg/old".into() };
740 assert_eq!(gate("s2", Some(&h), Some("k1"), None, &[], &["s2"]), relocate);
741 assert_eq!(gate("s1", Some(&h), Some("k1"), None, &["s1"], &["s2"]), relocate, "a stale candidate too");
742 }
743
744 #[test]
745 fn relocation_needs_every_precondition() {
746 let base = elsewhere("s2", Some("k1"));
747 let not_relocated = |h: &Head, identity: Option<&str>, elsewhere: &[&str]| {
748 !matches!(gate("s1", Some(h), identity, None, &[], elsewhere), ResumeGate::Relocate { .. })
749 };
750 assert!(not_relocated(&base, Some("k1"), &[]), "no complete source file");
751 assert!(not_relocated(&base, None, &["s2"]), "spawn identity unknown");
752 assert!(not_relocated(&Head { identity_key: None, ..base.clone() }, Some("k1"), &["s2"]), "head identity unknown");
753 assert!(not_relocated(&Head { provider: "codex".into(), ..base.clone() }, Some("k1"), &["s2"]), "not Claude");
754 assert!(not_relocated(&Head { config_dir: None, ..base.clone() }, Some("k1"), &["s2"]), "no recorded dir");
755 assert!(not_relocated(&Head { config_dir: Some("c:\\cfg\\new\\".into()), ..base.clone() }, Some("k1"), &["s2"]), "the same dir");
756 assert!(not_relocated(&Head { cwd: "C:/elsewhere".into(), ..base.clone() }, Some("k1"), &["s2"]), "another project");
757 assert_eq!(
758 gate("s1", Some(&Head { identity_key: Some("k2".into()), ..base }), Some("k1"), None, &[], &["s2"]),
759 ResumeGate::Refuse { head: "s2".into() },
760 "another identity is refused outright"
761 );
762 }
763
764 #[test]
769 fn a_head_that_grew_outside_agentmux_is_forked() {
770 let h = Head { provider_bytes_end: Some(9), ..head("s2", None) };
771 assert_eq!(gate("s2", Some(&h), None, None, &["s2"], &[]), ResumeGate::Fork { head: "s2".into() });
772 assert_eq!(gate("s1", Some(&h), None, None, &["s2"], &[]), ResumeGate::Fork { head: "s2".into() });
773 }
774
775 #[test]
776 fn a_head_as_agentmux_left_it_resumes_in_place() {
777 let same = Head { provider_bytes_end: Some(10), ..head("s2", None) };
778 let unknown = Head { provider_bytes_end: None, ..head("s2", None) };
779 assert_eq!(gate("s2", Some(&same), None, None, &["s2"], &[]), ResumeGate::Allow);
780 assert_eq!(gate("s2", Some(&unknown), None, None, &["s2"], &[]), ResumeGate::Allow);
781 }
782
783 #[test]
784 fn a_segment_end_records_the_provider_file_size_and_older_ends_still_read() {
785 let fs = FileStore::open_in_memory().unwrap();
786 let a = record_start(&fs, start(1_000, Rung::Fresh)).unwrap();
787 record_session(&fs, UID, &a, "sid-a", 1_001).unwrap();
788 record_end_with_provider_bytes(&fs, UID, &a, EndReason::Exited, None, Some(4_096), 2_000).unwrap();
789 assert_eq!(chain_head(&fs, UID).unwrap().provider_bytes_end, Some(4_096));
790
791 let old = serde_json::json!({"event": "end", "segment_id": "s", "ended_at_ms": 5, "end_reason": "exited", "byte_end": null});
792 match serde_json::from_value::<Event>(old).unwrap() {
793 Event::End { provider_bytes_end, .. } => assert_eq!(provider_bytes_end, None),
794 other => panic!("expected an end event, got {other:?}"),
795 }
796 }
797
798 fn login_dir(account: &str, org: &str) -> tempfile::TempDir {
801 let d = tempfile::tempdir().unwrap();
802 std::fs::write(
803 d.path().join(".claude.json"),
804 format!(r#"{{"oauthAccount":{{"accountUuid":"{account}","organizationUuid":"{org}"}}}}"#),
805 )
806 .unwrap();
807 d
808 }
809
810 #[test]
811 fn a_legacy_head_takes_its_identity_from_its_own_config_dir() {
812 let dir = login_dir("acc-1", "org-1");
813 let path = dir.path().to_string_lossy().to_string();
814 let legacy = Head { config_dir: Some(path.clone()), provider: "claude".into(), ..head("s2", None) };
815 let expected = crate::identity::account_email::identity_key_from_oauth_dir("claude", &path);
816 assert!(expected.is_some());
817 assert_eq!(with_legacy_identity(legacy).identity_key, expected);
818 }
819
820 #[test]
821 fn a_recorded_identity_always_wins_and_an_unreadable_dir_stays_unknown() {
822 let dir = login_dir("acc-1", "org-1");
823 let path = dir.path().to_string_lossy().to_string();
824 let recorded = Head { config_dir: Some(path), provider: "claude".into(), ..head("s2", Some("k-recorded")) };
825 assert_eq!(with_legacy_identity(recorded).identity_key.as_deref(), Some("k-recorded"));
826 let gone = Head { config_dir: Some("/no/such/dir".into()), provider: "claude".into(), ..head("s2", None) };
827 assert_eq!(with_legacy_identity(gone).identity_key, None);
828 let no_dir = Head { provider: "claude".into(), ..head("s2", None) };
829 assert_eq!(with_legacy_identity(no_dir).identity_key, None);
830 }
831
832 #[test]
833 fn the_chain_head_is_the_newest_segment_with_a_session() {
834 let fs = FileStore::open_in_memory().unwrap();
835 assert_eq!(chain_head(&fs, UID), None);
836 let a = record_start(&fs, start(1_000, Rung::Fresh)).unwrap();
837 record_session(&fs, UID, &a, "sid-a", 1_001).unwrap();
838 let b = record_start(&fs, Start { identity_key: Some("k1".into()), ..start(2_000, Rung::Fresh) }).unwrap();
839 record_session(&fs, UID, &b, "sid-b", 2_001).unwrap();
840 record_start(&fs, start(3_000, Rung::Fresh)).unwrap();
842 let h = chain_head(&fs, UID).expect("a head");
843 assert_eq!(h.session_id, "sid-b");
844 assert_eq!(h.identity_key.as_deref(), Some("k1"));
845 assert_eq!(h.config_dir.as_deref(), Some("C:/cfg/a"));
846 assert_eq!(h.provider, "claude");
847 }
848
849 #[test]
851 fn older_records_without_an_identity_still_read() {
852 let old = serde_json::json!({
853 "event": "start", "segment_id": "s", "agent_uid": UID, "definition_id": null, "provider": "claude",
854 "config_dir": null, "provider_session_id": "sid", "cwd": "", "channel": "stable",
855 "agentmux_version": "0.57.5", "block_id": "b", "zone": null, "byte_start": null,
856 "started_at_ms": 1, "continuity_rung": "native", "predecessor_segment_id": null, "lease_epoch": 2,
857 });
858 match serde_json::from_value::<Event>(old).unwrap() {
859 Event::Start(s) => assert_eq!(s.identity_key, None),
860 other => panic!("expected a start event, got {other:?}"),
861 }
862 }
863
864 #[test]
865 fn a_segment_is_closed_exactly_once() {
866 let fs = FileStore::open_in_memory().unwrap();
867 let id = record_start(&fs, start(1, Rung::Fresh)).unwrap();
868 record_end(&fs, UID, &id, EndReason::Closed, None, 10).unwrap();
869 record_end(&fs, UID, &id, EndReason::Crashed, None, 20).unwrap();
870 let s = &segments(&fs, UID)[0];
871 assert_eq!((s.ended_at_ms, s.end_reason), (Some(10), Some(EndReason::Closed)));
872 }
873
874 #[test]
875 fn agents_have_separate_chains() {
876 let fs = FileStore::open_in_memory().unwrap();
877 record_start(&fs, start(1, Rung::Fresh)).unwrap();
878 let mut other = start(2, Rung::Fresh);
879 other.agent_uid = "uid-2".into();
880 let id = record_start(&fs, other).unwrap();
881 assert_eq!(segments(&fs, UID).len(), 1);
882 let theirs = segments(&fs, "uid-2");
883 assert_eq!(theirs[0].start.segment_id, id);
884 assert_eq!(theirs[0].start.predecessor_segment_id, None, "another agent's segment is not a predecessor");
885 }
886
887 #[test]
888 fn a_corrupt_line_is_skipped() {
889 let fs = FileStore::open_in_memory().unwrap();
890 let id = record_start(&fs, start(1, Rung::Fresh)).unwrap();
891 fs.append_data("agent-uid:uid-1:segments", SEGMENTS_FILE, b"{\"event\":\"start\",\"trunc\n").unwrap();
892 let next = record_start(&fs, start(2, Rung::Native)).unwrap();
893 let all = segments(&fs, UID);
894 assert_eq!(all.len(), 2);
895 assert_eq!(all[1].start.segment_id, next);
896 assert_eq!(all[1].start.predecessor_segment_id.as_deref(), Some(id.as_str()));
897 }
898
899 #[test]
900 fn an_unsafe_uid_is_refused() {
901 let fs = FileStore::open_in_memory().unwrap();
902 let mut bad = start(1, Rung::Fresh);
903 bad.agent_uid = "../x:y".into();
904 assert!(record_start(&fs, bad).is_err());
905 assert!(segments(&fs, "../x:y").is_empty());
906 }
907
908 #[test]
909 fn events_are_tagged_json_lines() {
910 let e = Event::End { segment_id: "s".into(), ended_at_ms: 5, end_reason: EndReason::Restarted, byte_end: None, provider_bytes_end: None };
911 let json = serde_json::to_string(&e).unwrap();
912 assert!(json.contains("\"event\":\"end\"") && json.contains("\"end_reason\":\"restarted\""), "{json}");
913 }
914
915 fn seg(id: &str, sid: Option<&str>) -> Segment {
916 let mut s = start(0, Rung::Fresh);
917 s.segment_id = id.into();
918 s.provider_session_id = sid.map(str::to_string);
919 Segment { start: s, ended_at_ms: None, end_reason: None, byte_end: None, provider_bytes_end: None }
920 }
921
922 #[test]
923 fn the_head_is_the_newest_earlier_segment_with_a_session() {
924 let chain = [seg("a", Some("sid-old")), seg("b", Some("sid-head")), seg("c", None), seg("now", Some("sid-attempted"))];
925 assert_eq!(head_session(&chain, Some("now")).as_deref(), Some("sid-head"));
926 assert_eq!(head_session(&chain, None).as_deref(), Some("sid-attempted"));
927 assert_eq!(head_session(&[], Some("now")), None);
928 }
929
930 #[test]
935 fn recovery_only_continues_the_head_of_the_chain() {
936 assert!(!recovery_allowed("sid-old", Some("sid-head")));
937 assert!(recovery_allowed("sid-head", Some("sid-head")), "a stale pane id whose real session is the head");
938 assert!(recovery_allowed("sid-any", None), "no known head (pre-index agent): behave as before the index");
939 }
940
941 #[test]
942 fn rungs_and_reasons() {
943 assert_eq!(rung_for_spawn(true, false), Rung::Native);
944 assert_eq!(rung_for_spawn(true, true), Rung::Native, "a resume never carries a packet, but resume wins");
945 assert_eq!(rung_for_spawn(false, true), Rung::Virtualized);
946 assert_eq!(rung_for_spawn(false, false), Rung::Fresh);
947 assert_eq!(kill_reason(true, true), EndReason::Closed);
948 assert_eq!(kill_reason(false, true), EndReason::Restarted);
949 assert_eq!(kill_reason(false, false), EndReason::Stopped);
950 assert_eq!(exit_reason(0), EndReason::Exited);
951 assert_eq!(exit_reason(1), EndReason::Crashed);
952 assert_eq!(exit_reason(-1), EndReason::Crashed);
953 }
954}