Skip to main content

sloop/
coordination.rs

1//! Answers scheduling mutation intents with granted or denied decisions.
2//!
3//! This is the daemon's only path for mutating runtime scheduling state through
4//! claims, leases, and run settlement. Read-only queries are not coordination
5//! and remain on [`Store`]. Rust's sibling-module visibility cannot enforce the
6//! boundary, so daemon code must not call the wrapped store methods directly.
7//!
8//! # Lease invariants
9//!
10//! A lease is time-bounded ownership of a ticket by the daemon, taken
11//! atomically at claim time. In the `leases` table `ticket_id` is the PRIMARY
12//! KEY and `run_id` is UNIQUE, so the database engine enforces at most one
13//! lease per ticket and per run. That is the durable guard against
14//! double-spawn, backstopping the conditional `UPDATE ... WHERE state='ready'`
15//! inside `claim_ticket`.
16//!
17//! Leases are held only by the daemon. `owner_id` records which daemon process
18//! took the claim. Workers never hold, renew, or observe leases: a worker's
19//! only credential is a per-run capability token granting the worker verbs on
20//! its own run. The daemon-to-worker relationship is delegation of access to a
21//! run, never sub-leasing of ownership of a ticket.
22//!
23//! `expires_at_ms` gates renewal only. An expired lease cannot be renewed, so a
24//! revived process cannot resurrect a claim recovery has decided is lost.
25//! Liveness of a run is determined by process identity — pid, pid start time,
26//! and process group id — never by lease expiry.
27//!
28//! A lease is released by deleting its row: on settlement (`finish_run`) or on
29//! claim rollback (`abort_claim`). An expired-but-present lease row is evidence
30//! of an owner that died mid-work.
31//!
32//! The daemon renews the lease of every run it actively supervises, from the
33//! periodic reconcile pass, so `expires_at_ms` is truthful for as long as a run
34//! is alive. Renewal is a statement of fact, not an authority: a renewal denial
35//! is logged and changes no scheduling decision, and recovery still keys off
36//! process identity rather than the clock. Because renewal is strict, a daemon
37//! that was down past the TTL re-arms an adopted run's lapsed lease through
38//! [`Coordination::readopt`] instead.
39
40use crate::outcome::Outcome;
41use crate::store::{
42    ClaimRequest, ClaimedRun, CooldownUpdate, EvidenceRecord, ExitClaim, Store, StoreError,
43};
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum Claim {
47    Granted(ClaimedRun),
48    Denied(ClaimDenial),
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum ClaimDenial {
53    NotReady,
54    ActivationNotQueued,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum Renewal {
59    Granted(i64),
60    Denied(RenewalDenial),
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum RenewalDenial {
65    LeaseNotHeld,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum Start {
70    Granted,
71    Denied(StartDenial),
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub enum StartDenial {
76    /// The run left `claimed` before its process was recorded — it was
77    /// aborted, recovered, or already started. `state` is the run's state as
78    /// stored, absent when the run itself is gone.
79    NotClaimed { state: Option<String> },
80}
81
82/// Ownership of a run's exit processing. Whoever is granted the checkpoint
83/// owns aftercare; the supervisor and crash recovery race for it deliberately.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub enum Exit {
86    Granted,
87    Denied(ExitDenial),
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub enum ExitDenial {
92    /// Another path checkpointed this exit first and owns aftercare.
93    AlreadyClaimed { state: String },
94}
95
96/// Facts about a launched agent process, recorded when the run turns
97/// `running`.
98pub struct RunStart<'a> {
99    pub run_id: &'a str,
100    pub branch: &'a str,
101    pub worktree_path: &'a str,
102    pub pid: u32,
103    pub pid_start_time: Option<i64>,
104    pub process_group_id: u32,
105    pub worker_token: &'a str,
106    pub worker_socket_path: &'a str,
107}
108
109/// Facts about an agent's exit, recorded at the checkpoint that hands the run
110/// to aftercare.
111pub struct RunExit<'a> {
112    pub run_id: &'a str,
113    pub exit_code: Option<i32>,
114    pub capture_complete: bool,
115    pub commits_json: &'a str,
116    pub vendor_error: Option<&'a crate::vendor_error::VendorErrorMatch>,
117    pub cooldown_until_ms: Option<i64>,
118}
119
120pub struct Coordination<'a>(&'a mut Store);
121
122impl<'a> Coordination<'a> {
123    pub fn new(store: &'a mut Store) -> Self {
124        Self(store)
125    }
126
127    pub fn claim(&mut self, claim: &ClaimRequest<'_>, now_ms: i64) -> Result<Claim, StoreError> {
128        match self.0.claim_ticket(claim, now_ms) {
129            Ok(claimed) => Ok(Claim::Granted(claimed)),
130            Err(StoreError::TicketNotReady { .. }) => Ok(Claim::Denied(ClaimDenial::NotReady)),
131            Err(StoreError::ActivationNotQueued { .. }) => {
132                Ok(Claim::Denied(ClaimDenial::ActivationNotQueued))
133            }
134            Err(error) => Err(error),
135        }
136    }
137
138    pub fn renew(
139        &mut self,
140        ticket_id: &str,
141        run_id: &str,
142        lease_ms: i64,
143        now_ms: i64,
144    ) -> Result<Renewal, StoreError> {
145        match self.0.renew_lease(ticket_id, run_id, lease_ms, now_ms) {
146            Ok(expires_at_ms) => Ok(Renewal::Granted(expires_at_ms)),
147            Err(StoreError::LeaseNotHeld { .. }) => {
148                Ok(Renewal::Denied(RenewalDenial::LeaseNotHeld))
149            }
150            Err(error) => Err(error),
151        }
152    }
153
154    /// Re-arms the lease of a run recovery has just adopted, accepting a lease
155    /// that expired while this daemon was down. Denied for a run that has
156    /// already settled.
157    pub fn readopt(
158        &mut self,
159        ticket_id: &str,
160        run_id: &str,
161        lease_ms: i64,
162        now_ms: i64,
163    ) -> Result<Renewal, StoreError> {
164        match self.0.readopt_lease(ticket_id, run_id, lease_ms, now_ms) {
165            Ok(expires_at_ms) => Ok(Renewal::Granted(expires_at_ms)),
166            Err(StoreError::LeaseNotHeld { .. }) => {
167                Ok(Renewal::Denied(RenewalDenial::LeaseNotHeld))
168            }
169            Err(error) => Err(error),
170        }
171    }
172
173    /// Turns a claimed run `running` once its agent process exists.
174    ///
175    /// Takes the store by shared reference: the transition needs no exclusive
176    /// connection state, and the runner's stage hooks that call it hold only a
177    /// shared borrow. It is an associated function for that reason alone —
178    /// the coordination boundary is the same.
179    pub fn start(store: &Store, start: &RunStart<'_>, now_ms: i64) -> Result<Start, StoreError> {
180        match store.mark_run_running(
181            start.run_id,
182            start.branch,
183            start.worktree_path,
184            start.pid,
185            start.pid_start_time,
186            start.process_group_id,
187            start.worker_token,
188            start.worker_socket_path,
189            now_ms,
190        ) {
191            Ok(()) => Ok(Start::Granted),
192            Err(StoreError::RunStateConflict { state, .. }) => {
193                Ok(Start::Denied(StartDenial::NotClaimed { state }))
194            }
195            Err(error) => Err(error),
196        }
197    }
198
199    /// Checkpoints an agent's exit, granting the caller ownership of aftercare.
200    /// The supervisor and crash recovery may both attempt this; exactly one is
201    /// granted.
202    pub fn record_exit(&mut self, exit: &RunExit<'_>, now_ms: i64) -> Result<Exit, StoreError> {
203        match self.0.record_agent_exit(
204            exit.run_id,
205            exit.exit_code,
206            exit.capture_complete,
207            exit.commits_json,
208            exit.vendor_error,
209            exit.cooldown_until_ms,
210            now_ms,
211        ) {
212            Ok(ExitClaim::Claimed) => Ok(Exit::Granted),
213            Ok(ExitClaim::AlreadyClaimed { state }) => {
214                Ok(Exit::Denied(ExitDenial::AlreadyClaimed { state }))
215            }
216            Err(error) => Err(error),
217        }
218    }
219
220    pub fn abandon(
221        &mut self,
222        run_id: &str,
223        ticket_id: &str,
224        now_ms: i64,
225    ) -> Result<(), StoreError> {
226        self.0.abort_claim(run_id, ticket_id, now_ms)
227    }
228
229    #[allow(clippy::too_many_arguments)]
230    pub fn settle(
231        &mut self,
232        run_id: &str,
233        ticket_id: &str,
234        exit_code: Option<i32>,
235        outcome: Outcome,
236        evidence: &[EvidenceRecord],
237        cooldown: Option<&CooldownUpdate<'_>>,
238        now_ms: i64,
239    ) -> Result<bool, StoreError> {
240        self.0.finish_run(
241            run_id, ticket_id, exit_code, outcome, evidence, cooldown, now_ms,
242        )
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use tempfile::TempDir;
249    use tempfile::tempdir;
250
251    use super::{
252        Claim, ClaimDenial, Coordination, Exit, ExitDenial, Renewal, RenewalDenial, RunExit,
253        RunStart, Start, StartDenial,
254    };
255    use crate::domain::ticket::TicketState;
256    use crate::store::{ActivationKind, ClaimRequest, NewActivation, Store};
257
258    fn claim_t1(run_id: &str) -> ClaimRequest<'_> {
259        ClaimRequest {
260            ticket_id: "T1",
261            run_id,
262            activation_id: "A1",
263            owner_id: "daemon-1",
264            lease_ms: 60_000,
265            next_activation_eligible_at_ms: None,
266            flow_json: "{}",
267            ticket_json: "{}",
268        }
269    }
270
271    fn seeded_store(directory: &TempDir) -> Store {
272        let store = Store::open(&directory.path().join("sloop.db"), 1_000).unwrap();
273        store
274            .insert_local_project("default", "projects/default.md", "Default", 1_000)
275            .unwrap();
276        store
277            .insert_local_ticket(
278                "T1",
279                "default",
280                "tickets/T1.md",
281                "Ticket one",
282                &[],
283                "sloop/T1",
284                Some("opencode"),
285                None,
286                None,
287                "default",
288                TicketState::Ready,
289                1_000,
290            )
291            .unwrap();
292        store
293            .insert_activation(
294                &NewActivation {
295                    id: "A1",
296                    kind: ActivationKind::Immediate,
297                    ticket_id: Some("T1"),
298                    project_id: None,
299                    eligible_at_ms: None,
300                    interval_ms: None,
301                },
302                1_000,
303            )
304            .unwrap();
305        store
306    }
307
308    #[test]
309    fn claiming_twice_is_denied_instead_of_failing() {
310        let directory = tempdir().unwrap();
311        let mut store = seeded_store(&directory);
312        let mut coordination = Coordination::new(&mut store);
313
314        assert!(matches!(
315            coordination.claim(&claim_t1("R1"), 2_000).unwrap(),
316            Claim::Granted(_)
317        ));
318        assert_eq!(
319            coordination.claim(&claim_t1("R2"), 2_100).unwrap(),
320            Claim::Denied(ClaimDenial::NotReady)
321        );
322    }
323
324    #[test]
325    fn only_one_caller_is_granted_a_runs_exit_checkpoint() {
326        let directory = tempdir().unwrap();
327        let mut store = seeded_store(&directory);
328        let mut coordination = Coordination::new(&mut store);
329        coordination.claim(&claim_t1("R1"), 2_000).unwrap();
330        let start = RunStart {
331            run_id: "R1",
332            branch: "sloop/T1",
333            worktree_path: "/tmp/w",
334            pid: 4_242,
335            pid_start_time: Some(7),
336            process_group_id: 4_242,
337            worker_token: "token",
338            worker_socket_path: "/tmp/w.sock",
339        };
340        assert_eq!(
341            Coordination::start(&store, &start, 2_100).unwrap(),
342            Start::Granted
343        );
344
345        let exit = RunExit {
346            run_id: "R1",
347            exit_code: Some(0),
348            capture_complete: true,
349            commits_json: "{}",
350            vendor_error: None,
351            cooldown_until_ms: None,
352        };
353        let mut coordination = Coordination::new(&mut store);
354        assert_eq!(
355            coordination.record_exit(&exit, 3_000).unwrap(),
356            Exit::Granted
357        );
358        // The supervisor and crash recovery race here deliberately; the loser
359        // is denied rather than failed, and does not own aftercare.
360        assert_eq!(
361            coordination.record_exit(&exit, 3_100).unwrap(),
362            Exit::Denied(ExitDenial::AlreadyClaimed {
363                state: "aftercare".into()
364            })
365        );
366    }
367
368    #[test]
369    fn starting_a_run_that_left_claimed_is_denied() {
370        let directory = tempdir().unwrap();
371        let mut store = seeded_store(&directory);
372        let mut coordination = Coordination::new(&mut store);
373        coordination.claim(&claim_t1("R1"), 2_000).unwrap();
374        coordination.abandon("R1", "T1", 2_050).unwrap();
375
376        let start = RunStart {
377            run_id: "R1",
378            branch: "sloop/T1",
379            worktree_path: "/tmp/w",
380            pid: 4_242,
381            pid_start_time: Some(7),
382            process_group_id: 4_242,
383            worker_token: "token",
384            worker_socket_path: "/tmp/w.sock",
385        };
386        assert_eq!(
387            Coordination::start(&store, &start, 2_100).unwrap(),
388            Start::Denied(StartDenial::NotClaimed {
389                state: Some("aborted".into())
390            })
391        );
392    }
393
394    #[test]
395    fn readopting_re_arms_an_expired_lease_that_renewal_refuses() {
396        let directory = tempdir().unwrap();
397        let mut store = seeded_store(&directory);
398        let mut coordination = Coordination::new(&mut store);
399        coordination.claim(&claim_t1("R1"), 2_000).unwrap();
400
401        // The claim's lease expired at 62_000.
402        assert_eq!(
403            coordination.renew("T1", "R1", 60_000, 90_000).unwrap(),
404            Renewal::Denied(RenewalDenial::LeaseNotHeld)
405        );
406        assert_eq!(
407            coordination.readopt("T1", "R1", 60_000, 90_000).unwrap(),
408            Renewal::Granted(150_000)
409        );
410        assert_eq!(
411            coordination.renew("T1", "R1", 60_000, 100_000).unwrap(),
412            Renewal::Granted(160_000)
413        );
414    }
415}