prospero_core/
deadline.rs1use std::collections::HashMap;
14
15use crate::model::Agent;
16
17#[must_use]
28pub fn expired<'a>(
29 now: chrono::DateTime<chrono::Utc>,
30 deadlines: &HashMap<String, String>,
31 agents: impl IntoIterator<Item = &'a Agent>,
32) -> Vec<(String, String)> {
33 agents
34 .into_iter()
35 .filter(|a| !a.status.is_terminal())
36 .filter_map(|a| {
37 let raw = deadlines.get(&a.id)?;
38 let at = chrono::DateTime::parse_from_rfc3339(raw).ok()?;
39 (now >= at.with_timezone(&chrono::Utc)).then(|| (a.id.clone(), raw.clone()))
40 })
41 .collect()
42}
43
44#[must_use]
47pub fn deadline_from(
48 started: chrono::DateTime<chrono::Utc>,
49 timeout_secs: Option<u64>,
50) -> Option<String> {
51 let secs = timeout_secs?;
52 let delta = chrono::Duration::try_seconds(secs as i64)?;
53 Some((started + delta).to_rfc3339())
54}
55
56#[must_use]
61pub fn deadline_in(events: &[crate::event::FleetEvent]) -> Option<String> {
62 events.iter().rev().find_map(|e| match &e.kind {
63 crate::event::EventKind::DeadlineSet { deadline } => Some(deadline.clone()),
64 _ => None,
65 })
66}
67
68#[cfg(test)]
69mod tests {
70 use super::*;
71 use crate::event::{EventKind, FleetEvent};
72 use crate::model::AgentStatus;
73
74 fn agent(id: &str, status: AgentStatus) -> Agent {
75 Agent {
76 id: id.into(),
77 name: id.into(),
78 workspace: "ws".into(),
79 status,
80 started_at: "2026-09-20T00:00:00Z".into(),
81 isolated: false,
82 interactive: false,
83 session_dir: "/s".into(),
84 permission_posture: crate::model::PermissionPosture::Supervised,
85 reason: None,
86 detail: None,
87 }
88 }
89
90 fn at(s: &str) -> chrono::DateTime<chrono::Utc> {
91 chrono::DateTime::parse_from_rfc3339(s)
92 .unwrap()
93 .with_timezone(&chrono::Utc)
94 }
95
96 #[test]
97 fn an_agent_past_its_deadline_is_selected() {
98 let deadlines = HashMap::from([("a".to_string(), "2026-09-20T00:00:10+00:00".to_string())]);
99 let agents = [agent("a", AgentStatus::Running)];
100
101 assert!(
102 expired(at("2026-09-20T00:00:09Z"), &deadlines, &agents).is_empty(),
103 "not yet due"
104 );
105 assert_eq!(
106 expired(at("2026-09-20T00:00:10Z"), &deadlines, &agents),
107 vec![("a".to_string(), "2026-09-20T00:00:10+00:00".to_string())],
108 "due exactly on the deadline"
109 );
110 }
111
112 #[test]
115 fn a_terminal_agent_is_never_timed_out() {
116 let deadlines = HashMap::from([("a".to_string(), "2026-09-20T00:00:00+00:00".to_string())]);
117 for status in [
118 AgentStatus::Done,
119 AgentStatus::Failed,
120 AgentStatus::Killed,
121 AgentStatus::Crashed,
122 ] {
123 let agents = [agent("a", status)];
124 assert!(
125 expired(at("2026-09-21T00:00:00Z"), &deadlines, &agents).is_empty(),
126 "{status:?} must not be timed out"
127 );
128 }
129 }
130
131 #[test]
132 fn an_agent_without_a_deadline_is_left_alone() {
133 let agents = [agent("a", AgentStatus::Running)];
134 assert!(expired(at("2100-01-01T00:00:00Z"), &HashMap::new(), &agents).is_empty());
135 }
136
137 #[test]
139 fn an_unparseable_deadline_never_kills() {
140 let deadlines = HashMap::from([("a".to_string(), "not-a-timestamp".to_string())]);
141 let agents = [agent("a", AgentStatus::Running)];
142 assert!(expired(at("2100-01-01T00:00:00Z"), &deadlines, &agents).is_empty());
143 }
144
145 #[test]
146 fn deadline_from_adds_the_timeout_to_the_start() {
147 let start = at("2026-09-20T00:00:00Z");
148 assert_eq!(deadline_from(start, None), None, "no timeout ⇒ no deadline");
149 let d = deadline_from(start, Some(90)).expect("a timeout yields a deadline");
150 assert_eq!(at(&d), at("2026-09-20T00:01:30Z"));
151 }
152
153 #[test]
155 fn the_newest_deadline_in_history_wins() {
156 let ev = |seq: u64, deadline: &str| FleetEvent {
157 seq,
158 ts: "t".into(),
159 repo: "ws".into(),
160 agent_id: "a".into(),
161 kind: EventKind::DeadlineSet {
162 deadline: deadline.into(),
163 },
164 actor: None,
165 on_behalf_of: None,
166 };
167 let events = vec![
168 ev(1, "2026-09-20T00:00:10+00:00"),
169 FleetEvent {
170 seq: 2,
171 ts: "t".into(),
172 repo: "ws".into(),
173 agent_id: "a".into(),
174 kind: EventKind::AgentSpawned,
175 actor: None,
176 on_behalf_of: None,
177 },
178 ev(3, "2026-09-20T00:00:30+00:00"),
179 ];
180 assert_eq!(
181 deadline_in(&events).as_deref(),
182 Some("2026-09-20T00:00:30+00:00")
183 );
184 assert_eq!(deadline_in(&[]), None);
185 }
186}