Skip to main content

prospero_core/
deadline.rs

1//! Per-spawn wall-clock deadlines (#221).
2//!
3//! A runaway agent otherwise runs until someone notices and kills it. A spawn
4//! may carry a timeout; prospero records the resulting deadline in the **event
5//! log** and enforces it from the poll loop.
6//!
7//! The log is what makes the deadline survive a replica failover: a local timer
8//! dies with the replica that set it, while `DeadlineSet` is in the shared store
9//! and any replica that takes the agent over can read it back. Enforcement runs
10//! under the same lifecycle lease as the loop's other emissions (#59), so
11//! exactly one replica acts on an expiry.
12
13use std::collections::HashMap;
14
15use crate::model::Agent;
16
17/// Which agents have passed their deadline and should be killed now.
18///
19/// Pure so the decision is testable without sleeping: callers pass `now` and
20/// the deadlines they know about.
21///
22/// - A terminal agent is never selected: it has already stopped, and emitting a
23///   timeout for it would add a lie to the log.
24/// - An agent with no deadline is never selected — a timeout is opt-in.
25/// - An unparseable deadline is ignored rather than treated as expired. Failing
26///   the other way would kill a healthy agent because of a bad string.
27#[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/// The deadline a spawn's timeout implies, as RFC-3339, or `None` when the
45/// spawn asked for no timeout.
46#[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/// The newest deadline in an agent's stored history, if it ever had one.
57///
58/// This is the failover path: a replica that did not spawn the agent has no
59/// in-memory deadline for it, and reads it back from the log instead.
60#[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    /// Killing something that already stopped would write a timeout into the
113    /// log for a run that ended on its own.
114    #[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    /// Fail safe: a malformed deadline must not read as "infinitely expired".
138    #[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    /// The failover path: the deadline is recovered from the log, newest wins.
154    #[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}