1use 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 NotClaimed { state: Option<String> },
80}
81
82#[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 AlreadyClaimed { state: String },
94}
95
96pub 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
109pub 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 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 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 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 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 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}