1pub(crate) mod evidence;
12pub(crate) mod limits;
13pub(crate) mod runs;
14
15pub use evidence::{
16 EvidenceRecord, OutputStallEvidence, PanelReportRecord, StagePhase, StageRecord,
17};
18pub use limits::{CooldownRecord, CooldownUpdate};
19pub use runs::{ActiveRun, EventRecord, ProjectNote, RunRecord, RunState, RunTimeline};
20pub use runs::{AdmittedRun, RunAdmission};
21pub(crate) use runs::{NeedsReviewBranch, RecoverableRun, WorktreeCleanupCandidate};
22
23use rusqlite::{OptionalExtension, TransactionBehavior, params};
24
25use crate::db::{Db, StoreError};
26use crate::domain::ticket::TicketState;
27use crate::domain::work::{OwnerId, WorkOutcome};
28use crate::ids::NOTE_ID_PREFIX;
29use crate::outcome::Outcome;
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct ProjectCommitEvidence {
33 pub run_id: String,
34 pub ticket_id: String,
35 pub data_json: String,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum ExitClaim {
42 Claimed,
43 AlreadyClaimed { state: String },
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum Start {
48 Granted,
49 Denied(StartDenial),
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum StartDenial {
54 NotClaimed { state: Option<String> },
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum Exit {
60 Granted,
61 Denied(ExitDenial),
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum ExitDenial {
66 AlreadyClaimed { state: String },
68}
69
70pub struct RunStart<'a> {
72 pub run_id: &'a str,
73 pub branch: &'a str,
74 pub worktree_path: &'a str,
75 pub pid: u32,
76 pub pid_start_time: Option<i64>,
77 pub process_group_id: u32,
78 pub worker_token: &'a str,
79 pub worker_socket_path: &'a str,
80}
81
82pub struct RunExit<'a> {
85 pub run_id: &'a str,
86 pub attempt: u32,
91 pub exit_code: Option<i32>,
92 pub capture_complete: bool,
93 pub commits_json: &'a str,
94 pub vendor_error: Option<&'a crate::vendor::VendorErrorMatch>,
95 pub cooldown_until_ms: Option<i64>,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct RecordedOutcome {
100 pub work: WorkOutcome,
101 pub not_before_ms: Option<i64>,
102}
103
104pub struct RunStore {
105 db: Db,
106}
107
108impl RunStore {
109 pub fn from_db(db: Db) -> Self {
110 Self { db }
111 }
112
113 #[cfg(test)]
114 pub(crate) fn db(&self) -> Db {
115 self.db.clone()
116 }
117
118 fn write<T>(
119 &self,
120 behavior: TransactionBehavior,
121 operation: impl FnOnce(&rusqlite::Transaction<'_>) -> rusqlite::Result<T>,
122 ) -> Result<T, StoreError> {
123 let mut connection = self.db.lock();
124 let transaction = connection.transaction_with_behavior(behavior)?;
125 let result = operation(&transaction)?;
126 transaction.commit()?;
127 Ok(result)
128 }
129
130 pub(crate) fn next_note_ordinal(&self) -> Result<i64, StoreError> {
131 self.reserve_ordinal("note", "notes", NOTE_ID_PREFIX)
132 }
133
134 fn reserve_ordinal(&self, kind: &str, table: &str, prefix: &str) -> Result<i64, StoreError> {
135 self.write(TransactionBehavior::Immediate, |transaction| {
136 tx::reserve_ordinal(transaction, kind, table, prefix)
137 })
138 }
139
140 pub(crate) fn paused(&self) -> Result<bool, StoreError> {
141 paused(&self.db.lock()).map_err(StoreError::from)
142 }
143
144 pub(crate) fn clear_restart_draining(&self, now_ms: i64) -> Result<(), StoreError> {
145 self.write(TransactionBehavior::Deferred, |transaction| {
146 tx::clear_restart_draining(transaction, now_ms)
147 })
148 }
149
150 #[cfg(test)]
151 pub(crate) fn restart_draining(&self) -> Result<bool, StoreError> {
152 restart_draining(&self.db.lock()).map_err(StoreError::from)
153 }
154
155 pub(crate) fn set_paused(&self, paused: bool, now_ms: i64) -> Result<(), StoreError> {
156 self.write(TransactionBehavior::Deferred, |transaction| {
157 tx::set_paused(transaction, paused, now_ms)
158 })
159 }
160
161 pub(crate) fn begin_restart_draining(
162 &self,
163 active_runs: usize,
164 now_ms: i64,
165 ) -> Result<bool, StoreError> {
166 self.write(TransactionBehavior::Immediate, |transaction| {
167 tx::begin_restart_draining(transaction, active_runs, now_ms)
168 })
169 }
170
171 pub(crate) fn resume_scheduler(&self, now_ms: i64) -> Result<bool, StoreError> {
172 self.write(TransactionBehavior::Immediate, |transaction| {
173 tx::resume_scheduler(transaction, now_ms)
174 })
175 }
176
177 pub(crate) fn probe_writable(&self, now_ms: i64) -> Result<(), StoreError> {
178 self.write(TransactionBehavior::Deferred, |transaction| {
179 tx::probe_writable(transaction, now_ms)
180 })
181 }
182
183 #[allow(clippy::too_many_arguments)]
184 pub fn settle(
185 &self,
186 run_id: &str,
187 exit_code: Option<i32>,
188 outcome: Outcome,
189 records: &[EvidenceRecord],
190 cooldown: Option<&CooldownUpdate<'_>>,
191 now_ms: i64,
192 ) -> Result<(RecordedOutcome, bool), StoreError> {
193 let mut connection = self.db.lock();
194 let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
195 let run_state = RunState::from(outcome);
196 let changed = runs::tx::finish(&transaction, run_id, run_state, exit_code, now_ms)?;
197 if changed == 0 {
198 match runs::tx::state_and_exit(&transaction, run_id)? {
199 Some((_, Some(_))) => {}
200 Some((state, None)) => {
201 return Err(StoreError::RunStateConflict {
202 run_id: run_id.into(),
203 state: Some(state),
204 requested: run_state.as_str().into(),
205 });
206 }
207 None => {
208 return Err(StoreError::RunNotFound {
209 run_id: run_id.into(),
210 });
211 }
212 }
213 } else {
214 if let Some(cooldown) = cooldown {
215 limits::tx::upsert_cooldown(&transaction, run_id, cooldown, now_ms)?;
216 }
217 evidence::tx::record_settlement(&transaction, run_id, records, now_ms)?;
218 let ticket_id = runs::tx::ticket_id(&transaction, run_id)?;
219 runs::tx::record_event(
220 &transaction,
221 now_ms,
222 "run_finished",
223 Some(run_id),
224 Some(&ticket_id),
225 &serde_json::json!({
226 "outcome": outcome.as_str(),
227 "exit_code": exit_code,
228 "ticket_state": TicketState::after_outcome(outcome).as_str(),
229 })
230 .to_string(),
231 )?;
232 }
233 let recorded = recorded_outcome(&transaction, run_id)?.ok_or_else(|| {
234 StoreError::RunStateConflict {
235 run_id: run_id.into(),
236 state: runs::tx::state(&transaction, run_id).ok().flatten(),
237 requested: run_state.as_str().into(),
238 }
239 })?;
240 transaction.commit()?;
241 Ok((recorded, changed != 0))
242 }
243
244 pub fn abort(&self, run_id: &str, ticket_id: &str, now_ms: i64) -> Result<bool, StoreError> {
245 let mut connection = self.db.lock();
246 let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
247 let changed = runs::tx::abort(&transaction, run_id, now_ms)?;
248 if changed != 0 {
249 runs::tx::record_event(
250 &transaction,
251 now_ms,
252 "run_aborted",
253 Some(run_id),
254 Some(ticket_id),
255 "{}",
256 )?;
257 } else {
258 let state = runs::tx::state(&transaction, run_id)?;
259 if state.as_deref() != Some(RunState::Aborted.as_str()) {
260 return Err(StoreError::RunStateConflict {
261 run_id: run_id.into(),
262 state,
263 requested: RunState::Aborted.as_str().into(),
264 });
265 }
266 }
267 transaction.commit()?;
268 Ok(changed != 0)
269 }
270
271 pub(crate) fn record_external_merge(
272 &self,
273 run_id: &str,
274 ticket_id: &str,
275 branch: &str,
276 branch_tip: &str,
277 observed_default_tip: &str,
278 now_ms: i64,
279 ) -> Result<bool, StoreError> {
280 self.write(TransactionBehavior::Immediate, |transaction| {
281 runs::tx::mark_cleanup_eligible(transaction, run_id, now_ms)?;
282 let data_json = serde_json::json!({
283 "branch": branch,
284 "branch_tip": branch_tip,
285 "observed_default_tip": observed_default_tip,
286 })
287 .to_string();
288 let inserted =
289 evidence::tx::record_external_merge(transaction, run_id, &data_json, now_ms)?;
290 if inserted {
291 runs::tx::record_event(
292 transaction,
293 now_ms,
294 "external_merge_reconciled",
295 Some(run_id),
296 Some(ticket_id),
297 &data_json,
298 )?;
299 }
300 Ok(inserted)
301 })
302 }
303
304 pub(crate) fn recorded_outcome(
305 &self,
306 run_id: &str,
307 ) -> Result<Option<RecordedOutcome>, StoreError> {
308 recorded_outcome(&self.db.lock(), run_id).map_err(StoreError::from)
309 }
310
311 pub fn begin(
315 &self,
316 run_id: &str,
317 branch: &str,
318 worktree_path: &str,
319 now_ms: i64,
320 ) -> Result<Start, StoreError> {
321 let mut connection = self.db.lock();
322 let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
323 let changed = runs::tx::mark_driving(&transaction, run_id, branch, worktree_path, now_ms)?;
324 if changed != 1 {
325 let state = runs::tx::state(&transaction, run_id)?;
326 return Ok(Start::Denied(StartDenial::NotClaimed { state }));
327 }
328 let ticket_id = runs::tx::ticket_id(&transaction, run_id)?;
329 runs::tx::record_event(
330 &transaction,
331 now_ms,
332 "run_started",
333 Some(run_id),
334 Some(&ticket_id),
335 "{}",
336 )?;
337 transaction.commit()?;
338 Ok(Start::Granted)
339 }
340
341 pub fn start(&self, start: &RunStart<'_>, now_ms: i64) -> Result<Start, StoreError> {
344 let mut connection = self.db.lock();
345 let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
346 let changed = runs::tx::mark_running(
347 &transaction,
348 start.run_id,
349 start.branch,
350 start.worktree_path,
351 start.pid,
352 start.pid_start_time,
353 start.process_group_id,
354 start.worker_token,
355 start.worker_socket_path,
356 now_ms,
357 )?;
358 if changed != 1 {
359 let state = runs::tx::state(&transaction, start.run_id)?;
360 return Ok(Start::Denied(StartDenial::NotClaimed { state }));
361 }
362 transaction.commit()?;
363 Ok(Start::Granted)
364 }
365
366 #[allow(clippy::too_many_arguments)]
367 pub(crate) fn record_agent_exit(
368 &self,
369 run_id: &str,
370 attempt: u32,
371 exit_code: Option<i32>,
372 capture_complete: bool,
373 commits_json: &str,
374 vendor_error: Option<&crate::vendor::VendorErrorMatch>,
375 cooldown_until_ms: Option<i64>,
376 now_ms: i64,
377 ) -> Result<ExitClaim, StoreError> {
378 let mut connection = self.db.lock();
379 let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
380 let changed = runs::tx::claim_agent_exit(&transaction, run_id, exit_code, now_ms)?;
381 if changed == 0 {
382 let state = runs::tx::state(&transaction, run_id)?;
383 return match state {
384 Some(state) => Ok(ExitClaim::AlreadyClaimed { state }),
385 None => Err(StoreError::RunNotFound {
386 run_id: run_id.into(),
387 }),
388 };
389 }
390 evidence::tx::record_agent_exit(
391 &transaction,
392 run_id,
393 attempt,
394 exit_code,
395 capture_complete,
396 commits_json,
397 vendor_error,
398 cooldown_until_ms,
399 now_ms,
400 )?;
401 transaction.commit()?;
402 Ok(ExitClaim::Claimed)
403 }
404
405 pub fn release_agent(&self, run_id: &str, now_ms: i64) -> Result<Exit, StoreError> {
410 let mut connection = self.db.lock();
411 let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
412 let changed = runs::tx::release_agent(&transaction, run_id, now_ms)?;
413 if changed == 0 {
414 let state = runs::tx::state(&transaction, run_id)?;
415 return match state {
416 Some(state) => Ok(Exit::Denied(ExitDenial::AlreadyClaimed { state })),
417 None => Err(StoreError::RunNotFound {
418 run_id: run_id.into(),
419 }),
420 };
421 }
422 transaction.commit()?;
423 Ok(Exit::Granted)
424 }
425
426 pub fn record_exit(&self, exit: &RunExit<'_>, now_ms: i64) -> Result<Exit, StoreError> {
428 match self.record_agent_exit(
429 exit.run_id,
430 exit.attempt,
431 exit.exit_code,
432 exit.capture_complete,
433 exit.commits_json,
434 exit.vendor_error,
435 exit.cooldown_until_ms,
436 now_ms,
437 )? {
438 ExitClaim::Claimed => Ok(Exit::Granted),
439 ExitClaim::AlreadyClaimed { state } => {
440 Ok(Exit::Denied(ExitDenial::AlreadyClaimed { state }))
441 }
442 }
443 }
444}
445
446fn recorded_outcome(
447 connection: &rusqlite::Connection,
448 run_id: &str,
449) -> rusqlite::Result<Option<RecordedOutcome>> {
450 let row = connection
451 .query_row(
452 "SELECT ticket_id, state, branch, attempt, exited_at_ms,
453 (SELECT data_json FROM run_evidence
454 WHERE run_id = runs.id AND kind = 'commits_observed'
455 ORDER BY sequence DESC LIMIT 1),
456 (SELECT data_json FROM run_evidence
457 WHERE run_id = runs.id AND kind = 'vendor_error_classified'
458 ORDER BY sequence DESC LIMIT 1),
459 (SELECT until_ms FROM cooldowns WHERE source_run_id = runs.id
460 ORDER BY until_ms DESC LIMIT 1),
461 EXISTS(SELECT 1 FROM run_evidence
462 WHERE run_id = runs.id AND kind = 'external_merge_observed')
463 FROM runs WHERE id = ?1",
464 params![run_id],
465 |row| {
466 Ok((
467 row.get::<_, String>(0)?,
468 row.get::<_, RunState>(1)?,
469 row.get::<_, Option<String>>(2)?,
470 row.get::<_, i64>(3)?,
471 row.get::<_, Option<i64>>(4)?,
472 row.get::<_, Option<String>>(5)?,
473 row.get::<_, Option<String>>(6)?,
474 row.get::<_, Option<i64>>(7)?,
475 row.get::<_, bool>(8)?,
476 ))
477 },
478 )
479 .optional()?;
480 let Some((
481 ticket_id,
482 state,
483 branch,
484 attempt,
485 finished_at_ms,
486 commits,
487 vendor_error,
488 cooldown,
489 externally_merged,
490 )) = row
491 else {
492 return Ok(None);
493 };
494 let Some(verdict) = externally_merged
495 .then_some(Outcome::Merged)
496 .or_else(|| state.outcome())
497 else {
498 return Ok(None);
499 };
500 let Some(finished_at_ms) = finished_at_ms else {
501 return Ok(None);
502 };
503 let commit_count = commits
504 .as_deref()
505 .and_then(|value| serde_json::from_str::<serde_json::Value>(value).ok())
506 .and_then(|value| value["oids"].as_array().map(Vec::len))
507 .unwrap_or(0)
508 .min(u32::MAX as usize) as u32;
509 let not_before_ms = vendor_error
510 .as_deref()
511 .and_then(|value| serde_json::from_str::<serde_json::Value>(value).ok())
512 .and_then(|value| value["cooldown_until_ms"].as_i64())
513 .or(cooldown);
514 Ok(Some(RecordedOutcome {
515 work: WorkOutcome {
516 ticket_id,
517 owner: OwnerId(run_id.into()),
518 verdict,
519 branch,
520 commit_count,
521 attempt: attempt.clamp(0, i64::from(u32::MAX)) as u32,
522 finished_at_ms,
523 },
524 not_before_ms,
525 }))
526}
527
528fn paused(connection: &rusqlite::Connection) -> rusqlite::Result<bool> {
529 let paused: i64 = connection.query_row(
530 "SELECT paused FROM scheduler_state WHERE singleton = 1",
531 [],
532 |row| row.get(0),
533 )?;
534 Ok(paused != 0)
535}
536
537#[cfg(test)]
538fn restart_draining(connection: &rusqlite::Connection) -> rusqlite::Result<bool> {
539 let draining: i64 = connection.query_row(
540 "SELECT draining FROM scheduler_state WHERE singleton = 1",
541 [],
542 |row| row.get(0),
543 )?;
544 Ok(draining != 0)
545}
546
547pub(crate) mod tx {
548 use rusqlite::{Transaction, params};
549
550 pub(crate) fn reserve_ordinal(
555 transaction: &Transaction<'_>,
556 kind: &str,
557 table: &str,
558 prefix: &str,
559 ) -> rusqlite::Result<i64> {
560 let reserved: i64 = transaction.query_row(
561 "SELECT next_ordinal FROM id_counters WHERE kind = ?1",
562 params![kind],
563 |row| row.get(0),
564 )?;
565 let ordinal_start = prefix.len() + 1;
566 let existing: i64 = transaction.query_row(
567 &format!(
568 "SELECT COALESCE(MAX(CAST(SUBSTR(id, {ordinal_start}) AS INTEGER)), 0) + 1
569 FROM {table}"
570 ),
571 [],
572 |row| row.get(0),
573 )?;
574 let ordinal = reserved.max(existing);
575 transaction.execute(
576 "UPDATE id_counters SET next_ordinal = ?2 WHERE kind = ?1",
577 params![kind, ordinal + 1],
578 )?;
579 Ok(ordinal)
580 }
581
582 pub(crate) fn clear_restart_draining(
583 transaction: &Transaction<'_>,
584 now_ms: i64,
585 ) -> rusqlite::Result<()> {
586 transaction.execute(
587 "UPDATE scheduler_state SET draining = 0, updated_at_ms = ?1 WHERE singleton = 1",
588 params![now_ms],
589 )?;
590 Ok(())
591 }
592
593 pub(crate) fn set_paused(
594 transaction: &Transaction<'_>,
595 paused: bool,
596 now_ms: i64,
597 ) -> rusqlite::Result<()> {
598 transaction.execute(
599 "UPDATE scheduler_state SET paused = ?1, updated_at_ms = ?2 WHERE singleton = 1",
600 params![i64::from(paused), now_ms],
601 )?;
602 Ok(())
603 }
604
605 pub(crate) fn begin_restart_draining(
606 transaction: &Transaction<'_>,
607 active_runs: usize,
608 now_ms: i64,
609 ) -> rusqlite::Result<bool> {
610 let changed = transaction.execute(
611 "UPDATE scheduler_state SET draining = 1, updated_at_ms = ?1
612 WHERE singleton = 1 AND draining = 0",
613 params![now_ms],
614 )? != 0;
615 if changed {
616 super::runs::tx::record_event(
617 transaction,
618 now_ms,
619 "daemon_restart_requested",
620 None,
621 None,
622 &serde_json::json!({"active_runs": active_runs}).to_string(),
623 )?;
624 }
625 Ok(changed)
626 }
627
628 pub(crate) fn resume_scheduler(
629 transaction: &Transaction<'_>,
630 now_ms: i64,
631 ) -> rusqlite::Result<bool> {
632 let was_draining: bool = transaction.query_row(
633 "SELECT draining FROM scheduler_state WHERE singleton = 1",
634 [],
635 |row| row.get::<_, i64>(0).map(|value| value != 0),
636 )?;
637 transaction.execute(
638 "UPDATE scheduler_state
639 SET paused = 0, draining = 0, updated_at_ms = ?1
640 WHERE singleton = 1",
641 params![now_ms],
642 )?;
643 Ok(was_draining)
644 }
645
646 pub(crate) fn probe_writable(
647 transaction: &Transaction<'_>,
648 now_ms: i64,
649 ) -> rusqlite::Result<()> {
650 transaction.execute(
651 "UPDATE scheduler_state SET updated_at_ms = ?1 WHERE singleton = 1",
652 params![now_ms],
653 )?;
654 Ok(())
655 }
656}
657
658#[cfg(test)]
659pub(crate) mod test_support {
660 use std::path::Path;
661
662 use rusqlite::{TransactionBehavior, params};
663
664 use super::{CooldownUpdate, EvidenceRecord, RunAdmission, RunStore};
665 use crate::db::Db;
666 use crate::outcome::Outcome;
667
668 pub(crate) fn open_seeded(path: &Path) -> RunStore {
669 let db = Db::open(path, 1_000).unwrap();
670 db.lock()
671 .execute_batch(
672 "INSERT INTO projects
673 (id, file_path, source, source_ref, title, created_at_ms, updated_at_ms)
674 VALUES
675 ('default', '.agents/sloop/projects/default.md', 'local', NULL,
676 'Default', 1000, 1000);
677 INSERT INTO tickets
678 (id, project_id, file_path, source, source_ref, state, attempts,
679 content_hash, name, worktree, target, model, effort, flow, body,
680 created_at_ms, updated_at_ms)
681 VALUES
682 ('T1', 'default', '.agents/sloop/tickets/t1.md', 'local', NULL,
683 'ready', 0, '', 'Ticket one', 'sloop/T1', 'claude', 'sonnet',
684 'medium', 'default', '', 1000, 1000);",
685 )
686 .unwrap();
687 crate::work_state::trigger::insert(
688 &db.lock(),
689 &crate::work_state::trigger::NewTrigger {
690 id: "TR1",
691 kind: crate::domain::trigger::TriggerKind::Immediate,
692 ticket_id: Some("T1"),
693 project_id: None,
694 eligible_at_ms: None,
695 interval_ms: None,
696 },
697 1_000,
698 )
699 .unwrap();
700 RunStore::from_db(db)
701 }
702
703 pub(crate) fn claim_run(
704 store: &RunStore,
705 run_id: &str,
706 flow_json: &str,
707 ticket_json: &str,
708 now_ms: i64,
709 ) {
710 store
711 .insert_claimed_run(
712 &RunAdmission {
713 run_id,
714 trigger_id: "TR1",
715 ticket_id: "T1",
716 flow_json,
717 ticket_json,
718 },
719 now_ms,
720 )
721 .unwrap();
722 let db = store.db();
723 let mut connection = db.lock();
724 let transaction = connection
725 .transaction_with_behavior(TransactionBehavior::Immediate)
726 .unwrap();
727 transaction
728 .execute(
729 "UPDATE tickets SET state = 'claimed', attempts = attempts + 1,
730 updated_at_ms = ?1 WHERE id = 'T1'",
731 params![now_ms],
732 )
733 .unwrap();
734 transaction
735 .execute(
736 "INSERT INTO leases
737 (ticket_id, run_id, owner_id, acquired_at_ms, renewed_at_ms, expires_at_ms)
738 VALUES ('T1', ?1, 'daemon-1', ?2, ?2, ?3)",
739 params![run_id, now_ms, now_ms + 60_000],
740 )
741 .unwrap();
742 transaction.commit().unwrap();
743 }
744
745 pub(crate) fn settle_run(
746 store: &RunStore,
747 run_id: &str,
748 exit_code: Option<i32>,
749 outcome: Outcome,
750 evidence: &[EvidenceRecord],
751 cooldown: Option<&CooldownUpdate<'_>>,
752 now_ms: i64,
753 ) -> bool {
754 let (_, applied) = store
755 .settle(run_id, exit_code, outcome, evidence, cooldown, now_ms)
756 .unwrap();
757 let ticket_state = match outcome {
758 Outcome::Merged => "merged",
759 Outcome::Failed => "failed",
760 Outcome::NeedsReview => "needs_review",
761 Outcome::Cancelled | Outcome::RateLimited | Outcome::Orphaned => "ready",
762 };
763 let db = store.db();
764 let mut connection = db.lock();
765 let transaction = connection
766 .transaction_with_behavior(TransactionBehavior::Immediate)
767 .unwrap();
768 transaction
769 .execute("DELETE FROM leases WHERE run_id = ?1", params![run_id])
770 .unwrap();
771 transaction
772 .execute(
773 "UPDATE tickets SET state = ?1, updated_at_ms = ?2 WHERE id = 'T1'",
774 params![ticket_state, now_ms],
775 )
776 .unwrap();
777 transaction.commit().unwrap();
778 applied
779 }
780
781 pub(crate) fn abort_run(store: &RunStore, run_id: &str, now_ms: i64) -> bool {
782 let applied = store.abort(run_id, "T1", now_ms).unwrap();
783 let db = store.db();
784 let mut connection = db.lock();
785 let transaction = connection
786 .transaction_with_behavior(TransactionBehavior::Immediate)
787 .unwrap();
788 transaction
789 .execute("DELETE FROM leases WHERE run_id = ?1", params![run_id])
790 .unwrap();
791 transaction
792 .execute(
793 "UPDATE tickets SET state = 'ready', updated_at_ms = ?1 WHERE id = 'T1'",
794 params![now_ms],
795 )
796 .unwrap();
797 transaction.commit().unwrap();
798 applied
799 }
800}