1use std::collections::HashMap;
2
3use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params};
4
5use super::RunStore;
6use crate::db::StoreError;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum RunState {
11 Claimed,
12 Running,
14 Driving,
18 Aborted,
20 Merged,
21 Failed,
22 NeedsReview,
23 Cancelled,
24 RateLimited,
25 Orphaned,
26}
27
28const NONTERMINAL_RUN_STATES: [RunState; 3] =
29 [RunState::Claimed, RunState::Running, RunState::Driving];
30
31impl RunState {
32 pub fn as_str(self) -> &'static str {
33 match self {
34 Self::Claimed => "claimed",
35 Self::Running => "running",
36 Self::Driving => "driving",
37 Self::Aborted => "aborted",
38 Self::Merged => "merged",
39 Self::Failed => "failed",
40 Self::NeedsReview => "needs_review",
41 Self::Cancelled => "cancelled",
42 Self::RateLimited => "rate_limited",
43 Self::Orphaned => "orphaned",
44 }
45 }
46
47 pub(crate) fn from_stored(value: &str) -> Option<Self> {
48 match value {
49 "claimed" => Some(Self::Claimed),
50 "running" => Some(Self::Running),
51 "driving" => Some(Self::Driving),
52 "aborted" => Some(Self::Aborted),
53 "merged" => Some(Self::Merged),
54 "failed" => Some(Self::Failed),
55 "needs_review" => Some(Self::NeedsReview),
56 "cancelled" => Some(Self::Cancelled),
57 "rate_limited" => Some(Self::RateLimited),
58 "orphaned" => Some(Self::Orphaned),
59 _ => None,
60 }
61 }
62
63 pub fn parse(value: &str) -> Result<Self, StoreError> {
66 Self::from_stored(value).ok_or_else(|| StoreError::UnknownRunState {
67 state: value.into(),
68 })
69 }
70
71 pub fn is_terminal(self) -> bool {
72 !NONTERMINAL_RUN_STATES.contains(&self)
73 }
74
75 pub(crate) fn outcome(self) -> Option<crate::outcome::Outcome> {
76 use crate::outcome::Outcome;
77 match self {
78 Self::Merged => Some(Outcome::Merged),
79 Self::Failed => Some(Outcome::Failed),
80 Self::NeedsReview => Some(Outcome::NeedsReview),
81 Self::Cancelled => Some(Outcome::Cancelled),
82 Self::RateLimited => Some(Outcome::RateLimited),
83 Self::Orphaned => Some(Outcome::Orphaned),
84 Self::Claimed | Self::Running | Self::Driving | Self::Aborted => None,
85 }
86 }
87}
88
89impl rusqlite::types::FromSql for RunState {
90 fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult<Self> {
91 let text = value.as_str()?;
92 Self::from_stored(text).ok_or_else(|| {
93 rusqlite::types::FromSqlError::Other(Box::new(std::io::Error::new(
94 std::io::ErrorKind::InvalidData,
95 format!("unrecognized run state `{text}`"),
96 )))
97 })
98 }
99}
100
101impl From<crate::outcome::Outcome> for RunState {
102 fn from(outcome: crate::outcome::Outcome) -> Self {
103 use crate::outcome::Outcome;
104 match outcome {
105 Outcome::Merged => Self::Merged,
106 Outcome::Failed => Self::Failed,
107 Outcome::NeedsReview => Self::NeedsReview,
108 Outcome::Cancelled => Self::Cancelled,
109 Outcome::RateLimited => Self::RateLimited,
110 Outcome::Orphaned => Self::Orphaned,
111 }
112 }
113}
114
115fn nonterminal_state_params() -> [&'static str; 3] {
116 [
117 NONTERMINAL_RUN_STATES[0].as_str(),
118 NONTERMINAL_RUN_STATES[1].as_str(),
119 NONTERMINAL_RUN_STATES[2].as_str(),
120 ]
121}
122
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct ActiveRun {
125 pub id: String,
126 pub ticket_id: String,
127 pub attempt: i64,
128 pub ticket_name: String,
129 pub project_id: String,
130 pub state: String,
131}
132
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct RunRecord {
135 pub id: String,
136 pub ticket_id: String,
137 pub attempt: i64,
138 pub state: String,
139 pub branch: Option<String>,
140 pub worktree_path: Option<String>,
141 pub pid: Option<i64>,
142 pub pid_start_time: Option<i64>,
143 pub process_group_id: Option<i64>,
144 pub exit_code: Option<i64>,
145 pub exited_at_ms: Option<i64>,
146 pub flow_json: Option<String>,
147 pub ticket_json: Option<String>,
148}
149
150pub struct RunAdmission<'a> {
151 pub run_id: &'a str,
152 pub trigger_id: &'a str,
153 pub ticket_id: &'a str,
154 pub flow_json: &'a str,
155 pub ticket_json: &'a str,
156}
157
158pub struct AdmittedRun {
159 pub run_id: String,
160 pub attempt: i64,
161}
162
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct NeedsReviewBranch {
165 pub ticket_id: String,
166 pub run_id: String,
167 pub branch: String,
168}
169
170#[derive(Debug, Clone, PartialEq, Eq)]
171pub struct WorktreeCleanupCandidate {
172 pub run_id: String,
173 pub ticket_id: String,
174 pub branch: String,
175 pub worktree_path: String,
176 pub cleanup_eligible_at_ms: i64,
177}
178
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct RecoverableRun {
181 pub id: String,
182 pub ticket_id: String,
183 pub target: String,
184 pub state: RunState,
185 pub branch: Option<String>,
186 pub worktree_path: Option<String>,
187 pub pid: Option<i64>,
188 pub pid_start_time: Option<i64>,
189 pub process_group_id: Option<i64>,
190 pub worker_token: Option<String>,
191 pub worker_socket_path: Option<String>,
192 pub exit_code: Option<i64>,
193 pub lease_expires_at_ms: i64,
194 pub flow_json: Option<String>,
195 pub ticket_json: Option<String>,
196}
197
198const RUN_RECORD_SELECT: &str = "SELECT id, ticket_id, attempt, state, branch, worktree_path, pid,
199 pid_start_time, process_group_id, exit_code, exited_at_ms,
200 flow_json, ticket_json
201 FROM runs";
202
203fn run_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<RunRecord> {
204 Ok(RunRecord {
205 id: row.get(0)?,
206 ticket_id: row.get(1)?,
207 attempt: row.get(2)?,
208 state: row.get(3)?,
209 branch: row.get(4)?,
210 worktree_path: row.get(5)?,
211 pid: row.get(6)?,
212 pid_start_time: row.get(7)?,
213 process_group_id: row.get(8)?,
214 exit_code: row.get(9)?,
215 exited_at_ms: row.get(10)?,
216 flow_json: row.get(11)?,
217 ticket_json: row.get(12)?,
218 })
219}
220
221#[derive(Debug, Clone, PartialEq, Eq)]
223pub struct EventRecord {
224 pub sequence: i64,
225 pub occurred_at_ms: i64,
226 pub kind: String,
227 pub run_id: Option<String>,
228 pub ticket_id: Option<String>,
229 pub data_json: String,
230}
231
232#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
236pub struct RunTimeline {
237 pub claimed_at_ms: Option<i64>,
238 pub started_at_ms: Option<i64>,
239 pub finished_at_ms: Option<i64>,
240}
241
242#[derive(Debug, Clone, PartialEq, Eq)]
243pub struct ProjectNote {
244 pub id: String,
245 pub run_id: String,
246 pub ticket_id: String,
247 pub text: String,
248 pub recorded_at_ms: i64,
249}
250
251pub(crate) mod tx {
252 use rusqlite::{Transaction, params};
253
254 use super::{RunState, WorktreeCleanupCandidate};
255
256 pub(crate) fn mark_driving(
261 transaction: &Transaction<'_>,
262 run_id: &str,
263 branch: &str,
264 worktree_path: &str,
265 now_ms: i64,
266 ) -> rusqlite::Result<usize> {
267 transaction.execute(
268 "UPDATE runs
269 SET state = ?5, branch = ?2, worktree_path = ?3,
270 started_at_ms = ?4, updated_at_ms = ?4
271 WHERE id = ?1 AND state = ?6 AND exited_at_ms IS NULL",
272 params![
273 run_id,
274 branch,
275 worktree_path,
276 now_ms,
277 RunState::Driving.as_str(),
278 RunState::Claimed.as_str(),
279 ],
280 )
281 }
282
283 #[allow(clippy::too_many_arguments)]
287 pub(crate) fn mark_running(
288 transaction: &Transaction<'_>,
289 run_id: &str,
290 branch: &str,
291 worktree_path: &str,
292 pid: u32,
293 pid_start_time: Option<i64>,
294 process_group_id: u32,
295 worker_token: &str,
296 worker_socket_path: &str,
297 now_ms: i64,
298 ) -> rusqlite::Result<usize> {
299 transaction.execute(
300 "UPDATE runs
301 SET state = ?10, branch = ?2, worktree_path = ?3, pid = ?4,
302 pid_start_time = ?5, process_group_id = ?6, worker_token = ?7,
303 worker_socket_path = ?8,
304 started_at_ms = COALESCE(started_at_ms, ?9), updated_at_ms = ?9
305 WHERE id = ?1 AND state IN (?11, ?12) AND exited_at_ms IS NULL",
306 params![
307 run_id,
308 branch,
309 worktree_path,
310 i64::from(pid),
311 pid_start_time,
312 i64::from(process_group_id),
313 worker_token,
314 worker_socket_path,
315 now_ms,
316 RunState::Running.as_str(),
317 RunState::Claimed.as_str(),
318 RunState::Driving.as_str(),
319 ],
320 )
321 }
322
323 pub(crate) fn state(
324 transaction: &Transaction<'_>,
325 run_id: &str,
326 ) -> rusqlite::Result<Option<String>> {
327 use rusqlite::OptionalExtension;
328
329 transaction
330 .query_row(
331 "SELECT state FROM runs WHERE id = ?1",
332 params![run_id],
333 |row| row.get(0),
334 )
335 .optional()
336 }
337
338 pub(crate) fn ticket_id(
339 transaction: &Transaction<'_>,
340 run_id: &str,
341 ) -> rusqlite::Result<String> {
342 transaction.query_row(
343 "SELECT ticket_id FROM runs WHERE id = ?1",
344 params![run_id],
345 |row| row.get(0),
346 )
347 }
348
349 pub(crate) fn finish(
350 transaction: &Transaction<'_>,
351 run_id: &str,
352 state: RunState,
353 exit_code: Option<i32>,
354 now_ms: i64,
355 ) -> rusqlite::Result<usize> {
356 transaction.execute(
357 "UPDATE runs
358 SET state = ?2, exited_at_ms = ?3, exit_code = ?4, updated_at_ms = ?3,
359 cleanup_eligible_at_ms = CASE WHEN ?2 = ?5 THEN ?3 ELSE NULL END
360 WHERE id = ?1 AND exited_at_ms IS NULL",
361 params![
362 run_id,
363 state.as_str(),
364 now_ms,
365 exit_code,
366 RunState::Merged.as_str(),
367 ],
368 )
369 }
370
371 pub(crate) fn state_and_exit(
372 transaction: &Transaction<'_>,
373 run_id: &str,
374 ) -> rusqlite::Result<Option<(String, Option<i64>)>> {
375 use rusqlite::OptionalExtension;
376
377 transaction
378 .query_row(
379 "SELECT state, exited_at_ms FROM runs WHERE id = ?1",
380 params![run_id],
381 |row| Ok((row.get(0)?, row.get(1)?)),
382 )
383 .optional()
384 }
385
386 pub(crate) fn abort(
387 transaction: &Transaction<'_>,
388 run_id: &str,
389 now_ms: i64,
390 ) -> rusqlite::Result<usize> {
391 transaction.execute(
392 "UPDATE runs
393 SET state = ?3, exited_at_ms = ?2, updated_at_ms = ?2
394 WHERE id = ?1 AND exited_at_ms IS NULL",
395 params![run_id, now_ms, RunState::Aborted.as_str()],
396 )
397 }
398
399 pub(crate) fn mark_cleanup_eligible(
400 transaction: &Transaction<'_>,
401 run_id: &str,
402 now_ms: i64,
403 ) -> rusqlite::Result<usize> {
404 transaction.execute(
405 "UPDATE runs SET cleanup_eligible_at_ms = ?2
406 WHERE id = ?1 AND cleanup_eligible_at_ms IS NULL AND cleaned_at_ms IS NULL",
407 params![run_id, now_ms],
408 )
409 }
410
411 pub(crate) fn ids_for_ticket(
412 transaction: &Transaction<'_>,
413 ticket_id: &str,
414 ) -> rusqlite::Result<Vec<String>> {
415 let mut statement = transaction.prepare("SELECT id FROM runs WHERE ticket_id = ?1")?;
416 statement
417 .query_map(params![ticket_id], |row| row.get(0))?
418 .collect::<Result<Vec<_>, _>>()
419 }
420
421 pub(crate) fn ids_for_trigger(
422 transaction: &Transaction<'_>,
423 trigger_id: &str,
424 ) -> rusqlite::Result<Vec<String>> {
425 let mut statement = transaction.prepare("SELECT id FROM runs WHERE trigger_id = ?1")?;
426 statement
427 .query_map(params![trigger_id], |row| row.get(0))?
428 .collect::<Result<Vec<_>, _>>()
429 }
430
431 pub(crate) fn delete(transaction: &Transaction<'_>, run_id: &str) -> rusqlite::Result<usize> {
432 transaction.execute("DELETE FROM runs WHERE id = ?1", params![run_id])
433 }
434
435 pub(crate) fn mark_ticket_runs_cleanup_eligible(
436 transaction: &Transaction<'_>,
437 ticket_id: &str,
438 state: RunState,
439 now_ms: i64,
440 ) -> rusqlite::Result<usize> {
441 transaction.execute(
442 "UPDATE runs SET cleanup_eligible_at_ms = ?3
443 WHERE ticket_id = ?1 AND state = ?2
444 AND cleanup_eligible_at_ms IS NULL AND cleaned_at_ms IS NULL",
445 params![ticket_id, state.as_str(), now_ms],
446 )
447 }
448
449 pub(crate) fn mark_failed_or_review_runs_cleanup_eligible(
450 transaction: &Transaction<'_>,
451 ticket_id: &str,
452 now_ms: i64,
453 ) -> rusqlite::Result<usize> {
454 transaction.execute(
455 "UPDATE runs SET cleanup_eligible_at_ms = ?2
456 WHERE ticket_id = ?1 AND state IN ('failed', 'needs_review')
457 AND cleanup_eligible_at_ms IS NULL AND cleaned_at_ms IS NULL",
458 params![ticket_id, now_ms],
459 )
460 }
461
462 pub(crate) fn claim_agent_exit(
463 transaction: &Transaction<'_>,
464 run_id: &str,
465 exit_code: Option<i32>,
466 now_ms: i64,
467 ) -> rusqlite::Result<usize> {
468 transaction.execute(
469 "UPDATE runs
470 SET state = ?4, exit_code = ?2, updated_at_ms = ?3
471 WHERE id = ?1 AND state = ?5 AND exited_at_ms IS NULL",
472 params![
473 run_id,
474 exit_code,
475 now_ms,
476 RunState::Driving.as_str(),
477 RunState::Running.as_str(),
478 ],
479 )
480 }
481
482 pub(crate) fn release_agent(
483 transaction: &Transaction<'_>,
484 run_id: &str,
485 now_ms: i64,
486 ) -> rusqlite::Result<usize> {
487 transaction.execute(
488 "UPDATE runs
489 SET state = ?3, updated_at_ms = ?2
490 WHERE id = ?1 AND state = ?4 AND exited_at_ms IS NULL",
491 params![
492 run_id,
493 now_ms,
494 RunState::Driving.as_str(),
495 RunState::Running.as_str(),
496 ],
497 )
498 }
499
500 pub(crate) fn next_attempt(
501 transaction: &Transaction<'_>,
502 ticket_id: &str,
503 ) -> rusqlite::Result<i64> {
504 transaction.query_row(
505 "SELECT COALESCE(MAX(attempt), 0) + 1 FROM runs WHERE ticket_id = ?1",
506 params![ticket_id],
507 |row| row.get(0),
508 )
509 }
510
511 #[allow(clippy::too_many_arguments)]
512 pub(crate) fn insert_claimed(
513 transaction: &Transaction<'_>,
514 run_id: &str,
515 trigger_id: &str,
516 ticket_id: &str,
517 attempt: i64,
518 flow_json: &str,
519 ticket_json: &str,
520 now_ms: i64,
521 ) -> rusqlite::Result<usize> {
522 transaction.execute(
523 "INSERT INTO runs
524 (id, trigger_id, ticket_id, state, attempt, flow_json, ticket_json,
525 created_at_ms, updated_at_ms)
526 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8)",
527 params![
528 run_id,
529 trigger_id,
530 ticket_id,
531 RunState::Claimed.as_str(),
532 attempt,
533 flow_json,
534 ticket_json,
535 now_ms,
536 ],
537 )
538 }
539
540 pub(crate) fn record_event(
543 transaction: &Transaction<'_>,
544 now_ms: i64,
545 kind: &str,
546 run_id: Option<&str>,
547 ticket_id: Option<&str>,
548 data_json: &str,
549 ) -> rusqlite::Result<()> {
550 transaction.execute(
551 "INSERT INTO events (occurred_at_ms, kind, run_id, ticket_id, data_json)
552 VALUES (?1, ?2, ?3, ?4, ?5)",
553 params![now_ms, kind, run_id, ticket_id, data_json],
554 )?;
555 Ok(())
556 }
557
558 pub(crate) fn insert_note(
559 transaction: &Transaction<'_>,
560 id: &str,
561 run_id: &str,
562 text: &str,
563 now_ms: i64,
564 ) -> rusqlite::Result<()> {
565 transaction.execute(
566 "INSERT INTO notes (id, run_id, text, recorded_at_ms)
567 VALUES (?1, ?2, ?3, ?4)",
568 params![id, run_id, text, now_ms],
569 )?;
570 Ok(())
571 }
572
573 pub(crate) fn delete_notes_for_run(
574 transaction: &Transaction<'_>,
575 run_id: &str,
576 ) -> rusqlite::Result<usize> {
577 transaction.execute("DELETE FROM notes WHERE run_id = ?1", params![run_id])
578 }
579
580 pub(crate) fn trim_events(transaction: &Transaction<'_>, keep: i64) -> rusqlite::Result<()> {
581 transaction.execute(
582 "DELETE FROM events
583 WHERE sequence <= (SELECT COALESCE(MAX(sequence), 0) FROM events) - ?1",
584 params![keep],
585 )?;
586 Ok(())
587 }
588
589 pub(crate) fn mark_worktree_cleaned(
590 transaction: &Transaction<'_>,
591 candidate: &WorktreeCleanupCandidate,
592 now_ms: i64,
593 ) -> rusqlite::Result<bool> {
594 let changed = transaction.execute(
595 "UPDATE runs SET cleaned_at_ms = ?2, updated_at_ms = ?2
596 WHERE id = ?1 AND cleanup_eligible_at_ms IS NOT NULL
597 AND cleaned_at_ms IS NULL
598 AND NOT EXISTS (SELECT 1 FROM leases l WHERE l.run_id = runs.id)",
599 params![candidate.run_id, now_ms],
600 )?;
601 if changed == 1 {
602 record_event(
603 transaction,
604 now_ms,
605 "run_worktree_cleaned",
606 Some(&candidate.run_id),
607 Some(&candidate.ticket_id),
608 &serde_json::json!({
609 "branch": candidate.branch,
610 "worktree": candidate.worktree_path,
611 })
612 .to_string(),
613 )?;
614 }
615 Ok(changed == 1)
616 }
617}
618
619pub(crate) fn notes_for_run(
620 connection: &Connection,
621 run_id: &str,
622) -> rusqlite::Result<Vec<String>> {
623 let mut statement = connection
624 .prepare("SELECT text FROM notes WHERE run_id = ?1 ORDER BY recorded_at_ms, id")?;
625 statement
626 .query_map(params![run_id], |row| row.get(0))?
627 .collect::<Result<Vec<_>, _>>()
628}
629
630pub(crate) fn notes_for_project(
631 connection: &Connection,
632 project_id: &str,
633) -> rusqlite::Result<Vec<ProjectNote>> {
634 let mut statement = connection.prepare(
635 "SELECT n.id, n.run_id, r.ticket_id, n.text, n.recorded_at_ms
636 FROM notes n
637 JOIN runs r ON r.id = n.run_id
638 JOIN tickets t ON t.id = r.ticket_id
639 WHERE t.project_id = ?1
640 ORDER BY r.ticket_id, n.recorded_at_ms, n.id",
641 )?;
642 statement
643 .query_map(params![project_id], |row| {
644 Ok(ProjectNote {
645 id: row.get(0)?,
646 run_id: row.get(1)?,
647 ticket_id: row.get(2)?,
648 text: row.get(3)?,
649 recorded_at_ms: row.get(4)?,
650 })
651 })?
652 .collect::<Result<Vec<_>, _>>()
653}
654
655pub(crate) fn events_after(
656 connection: &Connection,
657 after: i64,
658 limit: usize,
659) -> rusqlite::Result<Vec<EventRecord>> {
660 let mut statement = connection.prepare(
661 "SELECT sequence, occurred_at_ms, kind, run_id, ticket_id, data_json
662 FROM events WHERE sequence > ?1 ORDER BY sequence LIMIT ?2",
663 )?;
664 statement
665 .query_map(params![after, limit as i64], |row| {
666 Ok(EventRecord {
667 sequence: row.get(0)?,
668 occurred_at_ms: row.get(1)?,
669 kind: row.get(2)?,
670 run_id: row.get(3)?,
671 ticket_id: row.get(4)?,
672 data_json: row.get(5)?,
673 })
674 })?
675 .collect::<Result<Vec<_>, _>>()
676}
677
678pub(crate) fn run_timelines(
679 connection: &Connection,
680 run_ids: &[&str],
681) -> rusqlite::Result<HashMap<String, RunTimeline>> {
682 if run_ids.is_empty() {
683 return Ok(HashMap::new());
684 }
685 let placeholders = std::iter::repeat_n("?", run_ids.len())
686 .collect::<Vec<_>>()
687 .join(", ");
688 let mut statement = connection.prepare(&format!(
689 "SELECT run_id,
690 MIN(CASE WHEN kind = 'run_claimed' THEN occurred_at_ms END),
691 MIN(CASE WHEN kind = 'run_started' THEN occurred_at_ms END),
692 MAX(CASE WHEN kind IN ('run_finished', 'run_aborted')
693 THEN occurred_at_ms END)
694 FROM events
695 WHERE run_id IN ({placeholders})
696 GROUP BY run_id"
697 ))?;
698 statement
699 .query_map(rusqlite::params_from_iter(run_ids), |row| {
700 Ok((
701 row.get::<_, String>(0)?,
702 RunTimeline {
703 claimed_at_ms: row.get(1)?,
704 started_at_ms: row.get(2)?,
705 finished_at_ms: row.get(3)?,
706 },
707 ))
708 })?
709 .collect::<Result<HashMap<_, _>, _>>()
710}
711
712pub(crate) fn latest_event_sequence(connection: &Connection) -> rusqlite::Result<i64> {
713 connection.query_row("SELECT COALESCE(MAX(sequence), 0) FROM events", [], |row| {
714 row.get(0)
715 })
716}
717
718pub(crate) fn ticket_is_referenced(
719 connection: &Connection,
720 ticket_id: &str,
721) -> rusqlite::Result<bool> {
722 connection.query_row(
723 "SELECT EXISTS (SELECT 1 FROM runs WHERE ticket_id = ?1)",
724 params![ticket_id],
725 |row| row.get(0),
726 )
727}
728
729pub(crate) fn run(connection: &Connection, id: &str) -> rusqlite::Result<Option<RunRecord>> {
730 connection
731 .query_row(
732 &format!("{RUN_RECORD_SELECT} WHERE id = ?1"),
733 params![id],
734 run_record,
735 )
736 .optional()
737}
738
739pub(crate) fn run_for_ticket_attempt(
740 connection: &Connection,
741 ticket_id: &str,
742 attempt: i64,
743) -> rusqlite::Result<Option<RunRecord>> {
744 connection
745 .query_row(
746 &format!(
747 "{RUN_RECORD_SELECT} WHERE ticket_id = ?1 AND attempt = ?2
748 ORDER BY created_at_ms DESC LIMIT 1"
749 ),
750 params![ticket_id, attempt],
751 run_record,
752 )
753 .optional()
754}
755
756pub(crate) fn runs_for_ticket(
757 connection: &Connection,
758 ticket_id: &str,
759) -> rusqlite::Result<Vec<RunRecord>> {
760 let mut statement = connection.prepare(&format!(
761 "{RUN_RECORD_SELECT} WHERE ticket_id = ?1 ORDER BY attempt DESC, created_at_ms DESC"
762 ))?;
763 statement
764 .query_map(params![ticket_id], run_record)?
765 .collect::<Result<Vec<_>, _>>()
766}
767
768pub(crate) fn runs_with_id_prefix(
769 connection: &Connection,
770 prefix: &str,
771) -> rusqlite::Result<Vec<RunRecord>> {
772 let mut statement = connection.prepare(&format!(
773 "{RUN_RECORD_SELECT} WHERE SUBSTR(id, 1, ?2) = ?1 ORDER BY created_at_ms, id"
774 ))?;
775 statement
776 .query_map(params![prefix, prefix.len() as i64], run_record)?
777 .collect::<Result<Vec<_>, _>>()
778}
779
780pub(crate) fn needs_review_branches(
781 connection: &Connection,
782) -> rusqlite::Result<Vec<NeedsReviewBranch>> {
783 let mut statement = connection.prepare(
784 "SELECT t.id, r.id, r.branch
785 FROM tickets t
786 JOIN runs r ON r.id = (
787 SELECT r2.id FROM runs r2
788 WHERE r2.ticket_id = t.id
789 AND r2.state = 'needs_review'
790 AND r2.branch IS NOT NULL
791 ORDER BY r2.created_at_ms DESC, r2.id DESC
792 LIMIT 1
793 )
794 WHERE t.state = 'needs_review'",
795 )?;
796 statement
797 .query_map([], |row| {
798 Ok(NeedsReviewBranch {
799 ticket_id: row.get(0)?,
800 run_id: row.get(1)?,
801 branch: row.get(2)?,
802 })
803 })?
804 .collect::<Result<Vec<_>, _>>()
805}
806
807pub(crate) fn worktree_cleanup_candidates(
808 connection: &Connection,
809) -> rusqlite::Result<Vec<WorktreeCleanupCandidate>> {
810 let mut statement = connection.prepare(
811 "SELECT r.id, r.ticket_id, r.branch, r.worktree_path, r.cleanup_eligible_at_ms
812 FROM runs r
813 WHERE r.cleanup_eligible_at_ms IS NOT NULL
814 AND r.cleaned_at_ms IS NULL
815 AND r.branch IS NOT NULL
816 AND r.worktree_path IS NOT NULL
817 AND NOT EXISTS (SELECT 1 FROM leases l WHERE l.run_id = r.id)
818 ORDER BY r.cleanup_eligible_at_ms, r.id",
819 )?;
820 statement
821 .query_map([], |row| {
822 Ok(WorktreeCleanupCandidate {
823 run_id: row.get(0)?,
824 ticket_id: row.get(1)?,
825 branch: row.get(2)?,
826 worktree_path: row.get(3)?,
827 cleanup_eligible_at_ms: row.get(4)?,
828 })
829 })?
830 .collect::<Result<Vec<_>, _>>()
831}
832
833pub(crate) fn next_worktree_cleanup_at_ms(
834 connection: &Connection,
835 retention_ms: i64,
836 now_ms: i64,
837) -> rusqlite::Result<Option<i64>> {
838 let eligible_at: Option<i64> = connection.query_row(
839 "SELECT MIN(r.cleanup_eligible_at_ms)
840 FROM runs r
841 WHERE r.cleanup_eligible_at_ms IS NOT NULL
842 AND r.cleaned_at_ms IS NULL
843 AND r.branch IS NOT NULL
844 AND r.worktree_path IS NOT NULL
845 AND NOT EXISTS (SELECT 1 FROM leases l WHERE l.run_id = r.id)",
846 [],
847 |row| row.get(0),
848 )?;
849 Ok(eligible_at.and_then(|value| {
850 let deadline = value.saturating_add(retention_ms);
851 (deadline > now_ms).then_some(deadline)
852 }))
853}
854
855pub(crate) fn active_run_for_ticket(
856 connection: &Connection,
857 ticket_id: &str,
858) -> rusqlite::Result<Option<(String, i64)>> {
859 connection
860 .query_row(
861 "SELECT r.id, r.attempt FROM runs r
862 JOIN leases l ON l.run_id = r.id
863 WHERE r.ticket_id = ?1
864 AND r.state IN (?2, ?3, ?4)
865 AND r.exited_at_ms IS NULL
866 ORDER BY r.created_at_ms DESC, r.id DESC LIMIT 1",
867 params![
868 ticket_id,
869 NONTERMINAL_RUN_STATES[0].as_str(),
870 NONTERMINAL_RUN_STATES[1].as_str(),
871 NONTERMINAL_RUN_STATES[2].as_str(),
872 ],
873 |row| Ok((row.get(0)?, row.get(1)?)),
874 )
875 .optional()
876}
877
878pub(crate) fn active_runs(connection: &Connection) -> rusqlite::Result<Vec<ActiveRun>> {
879 let mut statement = connection.prepare(
880 "SELECT r.id, r.ticket_id, r.attempt, t.name, t.project_id, r.state FROM runs r
881 JOIN leases l ON l.run_id = r.id
882 JOIN tickets t ON t.id = r.ticket_id
883 WHERE r.exited_at_ms IS NULL
884 AND r.state IN (?1, ?2, ?3)
885 ORDER BY r.created_at_ms, r.id",
886 )?;
887 statement
888 .query_map(nonterminal_state_params(), |row| {
889 Ok(ActiveRun {
890 id: row.get(0)?,
891 ticket_id: row.get(1)?,
892 attempt: row.get(2)?,
893 ticket_name: row.get(3)?,
894 project_id: row.get(4)?,
895 state: row.get(5)?,
896 })
897 })?
898 .collect::<Result<Vec<_>, _>>()
899}
900
901pub(crate) fn recoverable_runs(connection: &Connection) -> rusqlite::Result<Vec<RecoverableRun>> {
902 let mut statement = connection.prepare(
903 "SELECT r.id, r.ticket_id, t.target, r.state, r.branch, r.worktree_path,
904 r.pid, r.pid_start_time, r.process_group_id, r.worker_token,
905 r.worker_socket_path, r.exit_code, l.expires_at_ms, r.flow_json,
906 r.ticket_json
907 FROM runs r
908 JOIN leases l ON l.run_id = r.id
909 JOIN tickets t ON t.id = r.ticket_id
910 WHERE r.exited_at_ms IS NULL
911 AND r.state IN (?1, ?2, ?3)
912 ORDER BY r.created_at_ms, r.id",
913 )?;
914 statement
915 .query_map(nonterminal_state_params(), |row| {
916 Ok(RecoverableRun {
917 id: row.get(0)?,
918 ticket_id: row.get(1)?,
919 target: row.get(2)?,
920 state: row.get(3)?,
921 branch: row.get(4)?,
922 worktree_path: row.get(5)?,
923 pid: row.get(6)?,
924 pid_start_time: row.get(7)?,
925 process_group_id: row.get(8)?,
926 worker_token: row.get(9)?,
927 worker_socket_path: row.get(10)?,
928 exit_code: row.get(11)?,
929 lease_expires_at_ms: row.get(12)?,
930 flow_json: row.get(13)?,
931 ticket_json: row.get(14)?,
932 })
933 })?
934 .collect::<Result<Vec<_>, _>>()
935}
936
937impl RunStore {
938 pub(crate) fn ticket_is_referenced(&self, id: &str) -> Result<bool, StoreError> {
939 ticket_is_referenced(&self.db.lock(), id).map_err(StoreError::from)
940 }
941
942 pub fn insert_claimed_run(
943 &self,
944 claim: &RunAdmission<'_>,
945 now_ms: i64,
946 ) -> Result<AdmittedRun, StoreError> {
947 self.write(TransactionBehavior::Immediate, |transaction| {
948 let attempt = tx::next_attempt(transaction, claim.ticket_id)?;
949 tx::insert_claimed(
950 transaction,
951 claim.run_id,
952 claim.trigger_id,
953 claim.ticket_id,
954 attempt,
955 claim.flow_json,
956 claim.ticket_json,
957 now_ms,
958 )?;
959 tx::record_event(
960 transaction,
961 now_ms,
962 "run_claimed",
963 Some(claim.run_id),
964 Some(claim.ticket_id),
965 &serde_json::json!({"attempt": attempt}).to_string(),
966 )?;
967 Ok(AdmittedRun {
968 run_id: claim.run_id.into(),
969 attempt,
970 })
971 })
972 }
973
974 pub(crate) fn insert_note(
975 &self,
976 id: &str,
977 run_id: &str,
978 text: &str,
979 now_ms: i64,
980 ) -> Result<(), StoreError> {
981 self.write(TransactionBehavior::Deferred, |transaction| {
982 tx::insert_note(transaction, id, run_id, text, now_ms)
983 })
984 }
985
986 pub fn notes_for_run(&self, run_id: &str) -> Result<Vec<String>, StoreError> {
987 notes_for_run(&self.db.lock(), run_id).map_err(StoreError::from)
988 }
989
990 pub(crate) fn notes_for_project(
991 &self,
992 project_id: &str,
993 ) -> Result<Vec<ProjectNote>, StoreError> {
994 notes_for_project(&self.db.lock(), project_id).map_err(StoreError::from)
995 }
996
997 pub(crate) fn events_after(
998 &self,
999 after: i64,
1000 limit: usize,
1001 ) -> Result<Vec<EventRecord>, StoreError> {
1002 events_after(&self.db.lock(), after, limit).map_err(StoreError::from)
1003 }
1004
1005 pub(crate) fn run_timelines(
1006 &self,
1007 run_ids: &[&str],
1008 ) -> Result<HashMap<String, RunTimeline>, StoreError> {
1009 if run_ids.is_empty() {
1010 return Ok(HashMap::new());
1011 }
1012 run_timelines(&self.db.lock(), run_ids).map_err(StoreError::from)
1013 }
1014
1015 pub(crate) fn latest_event_sequence(&self) -> Result<i64, StoreError> {
1016 latest_event_sequence(&self.db.lock()).map_err(StoreError::from)
1017 }
1018
1019 pub(crate) fn trim_events(&self, keep: i64) -> Result<(), StoreError> {
1020 self.write(TransactionBehavior::Deferred, |transaction| {
1021 tx::trim_events(transaction, keep)
1022 })
1023 }
1024
1025 pub(crate) fn run(&self, id: &str) -> Result<Option<RunRecord>, StoreError> {
1026 run(&self.db.lock(), id).map_err(StoreError::from)
1027 }
1028
1029 pub(crate) fn run_for_ticket_attempt(
1030 &self,
1031 ticket_id: &str,
1032 attempt: i64,
1033 ) -> Result<Option<RunRecord>, StoreError> {
1034 run_for_ticket_attempt(&self.db.lock(), ticket_id, attempt).map_err(StoreError::from)
1035 }
1036
1037 pub(crate) fn runs_for_ticket(&self, ticket_id: &str) -> Result<Vec<RunRecord>, StoreError> {
1038 runs_for_ticket(&self.db.lock(), ticket_id).map_err(StoreError::from)
1039 }
1040
1041 pub(crate) fn runs_with_id_prefix(&self, prefix: &str) -> Result<Vec<RunRecord>, StoreError> {
1042 runs_with_id_prefix(&self.db.lock(), prefix).map_err(StoreError::from)
1043 }
1044
1045 pub(crate) fn needs_review_branches(&self) -> Result<Vec<NeedsReviewBranch>, StoreError> {
1046 needs_review_branches(&self.db.lock()).map_err(StoreError::from)
1047 }
1048
1049 pub(crate) fn worktree_cleanup_candidates(
1050 &self,
1051 ) -> Result<Vec<WorktreeCleanupCandidate>, StoreError> {
1052 worktree_cleanup_candidates(&self.db.lock()).map_err(StoreError::from)
1053 }
1054
1055 pub(crate) fn next_worktree_cleanup_at_ms(
1056 &self,
1057 retention_ms: i64,
1058 now_ms: i64,
1059 ) -> Result<Option<i64>, StoreError> {
1060 next_worktree_cleanup_at_ms(&self.db.lock(), retention_ms, now_ms).map_err(StoreError::from)
1061 }
1062
1063 pub(crate) fn mark_run_worktree_cleaned(
1064 &self,
1065 candidate: &WorktreeCleanupCandidate,
1066 now_ms: i64,
1067 ) -> Result<bool, StoreError> {
1068 self.write(TransactionBehavior::Immediate, |transaction| {
1069 tx::mark_worktree_cleaned(transaction, candidate, now_ms)
1070 })
1071 }
1072
1073 pub(crate) fn active_run_for_ticket(
1074 &self,
1075 ticket_id: &str,
1076 ) -> Result<Option<(String, i64)>, StoreError> {
1077 active_run_for_ticket(&self.db.lock(), ticket_id).map_err(StoreError::from)
1078 }
1079
1080 pub(crate) fn active_runs(&self) -> Result<Vec<ActiveRun>, StoreError> {
1081 active_runs(&self.db.lock()).map_err(StoreError::from)
1082 }
1083
1084 pub(crate) fn recoverable_runs(&self) -> Result<Vec<RecoverableRun>, StoreError> {
1085 recoverable_runs(&self.db.lock()).map_err(StoreError::from)
1086 }
1087}
1088
1089#[cfg(test)]
1090mod tests {
1091 use rusqlite::Connection;
1092 use tempfile::tempdir;
1093
1094 use super::{RunState, RunStore};
1095 use crate::db::{Db, LEGACY_STAGE_TABLE, REVERT_TRIGGER_RENAME, SCHEMA_VERSION, StoreError};
1096 use crate::domain::ticket::TicketSnapshot;
1097 use crate::flow::{Actor, Builtin, Check, FailAction, Flow, Stage};
1098 use crate::outcome::Outcome;
1099 use crate::run_store::test_support::{abort_run, claim_run, open_seeded, settle_run};
1100 use crate::run_store::{RunAdmission, RunStart, Start, StartDenial};
1101
1102 fn open(path: &std::path::Path, now_ms: i64) -> Result<RunStore, StoreError> {
1103 Db::open(path, now_ms)
1104 .map(RunStore::from_db)
1105 .map_err(StoreError::from)
1106 }
1107
1108 fn claim_t1(run_id: &str) -> RunAdmission<'_> {
1109 RunAdmission {
1110 ticket_id: "T1",
1111 run_id,
1112 trigger_id: "TR1",
1113 flow_json: "{}",
1114 ticket_json: "{}",
1115 }
1116 }
1117
1118 fn admit(store: &RunStore, claim: &RunAdmission<'_>, now_ms: i64) {
1119 claim_run(
1120 store,
1121 claim.run_id,
1122 claim.flow_json,
1123 claim.ticket_json,
1124 now_ms,
1125 );
1126 }
1127
1128 fn start_run(store: &RunStore, start: &RunStart<'_>, now_ms: i64) {
1129 assert_eq!(
1130 store
1131 .begin(start.run_id, start.branch, start.worktree_path, now_ms)
1132 .unwrap(),
1133 Start::Granted
1134 );
1135 assert_eq!(store.start(start, now_ms).unwrap(), Start::Granted);
1136 }
1137
1138 fn running_r1(store: &RunStore) {
1139 admit(store, &claim_t1("R1"), 2_000);
1140 start_run(
1141 store,
1142 &RunStart {
1143 run_id: "R1",
1144 branch: "branch",
1145 worktree_path: "/worktree",
1146 pid: 123,
1147 pid_start_time: Some(456),
1148 process_group_id: 123,
1149 worker_token: "token",
1150 worker_socket_path: "/runtime/R1.sock",
1151 },
1152 2_100,
1153 );
1154 }
1155
1156 #[test]
1157 fn every_run_state_round_trips_through_its_stored_string() {
1158 let states = [
1159 RunState::Claimed,
1160 RunState::Running,
1161 RunState::Driving,
1162 RunState::Aborted,
1163 RunState::Merged,
1164 RunState::Failed,
1165 RunState::NeedsReview,
1166 RunState::Cancelled,
1167 RunState::RateLimited,
1168 RunState::Orphaned,
1169 ];
1170 for state in states {
1171 assert_eq!(RunState::parse(state.as_str()).unwrap(), state);
1172 }
1173 for outcome in [
1174 Outcome::Merged,
1175 Outcome::Failed,
1176 Outcome::NeedsReview,
1177 Outcome::Cancelled,
1178 Outcome::RateLimited,
1179 Outcome::Orphaned,
1180 ] {
1181 assert_eq!(RunState::from(outcome).as_str(), outcome.as_str());
1182 assert!(RunState::from(outcome).is_terminal());
1183 }
1184 for state in [RunState::Claimed, RunState::Running, RunState::Driving] {
1185 assert!(!state.is_terminal());
1186 }
1187 assert!(RunState::Aborted.is_terminal());
1188 }
1189
1190 #[test]
1191 fn an_unknown_stored_run_state_is_an_error_not_a_fallback() {
1192 let error = RunState::parse("half_running").unwrap_err();
1193 assert!(matches!(error, StoreError::UnknownRunState { state } if state == "half_running"));
1194 }
1195
1196 #[test]
1197 fn claims_persist_flow_and_ticket_snapshots() {
1198 let directory = tempdir().unwrap();
1199 let store = open_seeded(&directory.path().join("sloop.db"));
1200 let flow = Flow {
1201 name: "default".into(),
1202 stages: vec![
1203 Stage {
1204 name: "build".into(),
1205 action: Actor::Agent,
1206 result_check: Check::Actor(Actor::Builtin(Builtin::Commits)),
1207 fail_action: FailAction::Halt,
1208 ff_only: false,
1209 },
1210 Stage {
1211 name: "check".into(),
1212 action: Actor::Exec {
1213 cmd: vec!["cargo".into(), "test".into()],
1214 },
1215 result_check: Check::None,
1216 fail_action: FailAction::Halt,
1217 ff_only: false,
1218 },
1219 ],
1220 };
1221 let ticket = TicketSnapshot {
1222 id: "T1".into(),
1223 name: "Ticket one".into(),
1224 blocked_by: vec![],
1225 worktree: Some("sloop/T1".into()),
1226 target: Some("claude".into()),
1227 model: Some("sonnet".into()),
1228 effort: Some("medium".into()),
1229 body: "# Original body\n".into(),
1230 };
1231 let flow_json = serde_json::to_string(&flow).unwrap();
1232 let ticket_json = serde_json::to_string(&ticket).unwrap();
1233
1234 admit(
1235 &store,
1236 &RunAdmission {
1237 flow_json: &flow_json,
1238 ticket_json: &ticket_json,
1239 ..claim_t1("R1")
1240 },
1241 2_000,
1242 );
1243
1244 let run = store.run("R1").unwrap().unwrap();
1245 assert_eq!(
1246 serde_json::from_str::<Flow>(run.flow_json.as_deref().unwrap()).unwrap(),
1247 flow
1248 );
1249 assert_eq!(
1250 serde_json::from_str::<TicketSnapshot>(run.ticket_json.as_deref().unwrap()).unwrap(),
1251 ticket
1252 );
1253 }
1254
1255 #[test]
1256 fn active_run_for_ticket_tracks_claimed_and_running_runs_only() {
1257 let directory = tempdir().unwrap();
1258 let store = open_seeded(&directory.path().join("sloop.db"));
1259 assert_eq!(store.active_run_for_ticket("T1").unwrap(), None);
1260
1261 admit(&store, &claim_t1("R1"), 2_000);
1262 assert_eq!(
1263 store.active_run_for_ticket("T1").unwrap(),
1264 Some(("R1".into(), 1))
1265 );
1266 start_run(
1267 &store,
1268 &RunStart {
1269 run_id: "R1",
1270 branch: "branch",
1271 worktree_path: "/tmp/worktree",
1272 pid: 1,
1273 pid_start_time: Some(1),
1274 process_group_id: 1,
1275 worker_token: "token",
1276 worker_socket_path: "/runtime/R1.sock",
1277 },
1278 2_100,
1279 );
1280 assert_eq!(
1281 store.active_run_for_ticket("T1").unwrap(),
1282 Some(("R1".into(), 1))
1283 );
1284
1285 settle_run(&store, "R1", Some(1), Outcome::Failed, &[], None, 2_200);
1286 assert_eq!(store.active_run_for_ticket("T1").unwrap(), None);
1287 }
1288
1289 #[test]
1290 fn starting_a_run_that_left_claimed_is_denied() {
1291 let directory = tempdir().unwrap();
1292 let store = open_seeded(&directory.path().join("sloop.db"));
1293 admit(&store, &claim_t1("R1"), 2_000);
1294 abort_run(&store, "R1", 2_050);
1295
1296 let result = store
1297 .start(
1298 &RunStart {
1299 run_id: "R1",
1300 branch: "branch",
1301 worktree_path: "/tmp/worktree",
1302 pid: 1,
1303 pid_start_time: Some(1),
1304 process_group_id: 1,
1305 worker_token: "token",
1306 worker_socket_path: "/runtime/R1.sock",
1307 },
1308 2_100,
1309 )
1310 .unwrap();
1311 assert_eq!(
1312 result,
1313 Start::Denied(StartDenial::NotClaimed {
1314 state: Some("aborted".into()),
1315 })
1316 );
1317 }
1318
1319 #[test]
1320 fn recoverable_runs_round_trip_process_identity_and_lease() {
1321 let directory = tempdir().unwrap();
1322 let path = directory.path().join("sloop.db");
1323 let store = open_seeded(&path);
1324 admit(&store, &claim_t1("R1"), 2_000);
1325 start_run(
1326 &store,
1327 &RunStart {
1328 run_id: "R1",
1329 branch: "sloop/T1-a1-R1",
1330 worktree_path: "/worktrees/R1",
1331 pid: 123,
1332 pid_start_time: Some(456),
1333 process_group_id: 123,
1334 worker_token: "worker-token",
1335 worker_socket_path: "/runtime/R1.sock",
1336 },
1337 2_100,
1338 );
1339 drop(store);
1340
1341 let store = open(&path, 3_000).unwrap();
1342 let runs = store.recoverable_runs().unwrap();
1343 assert_eq!(runs.len(), 1);
1344 assert_eq!(runs[0].id, "R1");
1345 assert_eq!(runs[0].ticket_id, "T1");
1346 assert_eq!(runs[0].pid, Some(123));
1347 assert_eq!(runs[0].pid_start_time, Some(456));
1348 assert_eq!(runs[0].process_group_id, Some(123));
1349 assert_eq!(runs[0].worker_token.as_deref(), Some("worker-token"));
1350 assert_eq!(
1351 runs[0].worker_socket_path.as_deref(),
1352 Some("/runtime/R1.sock")
1353 );
1354 assert_eq!(runs[0].exit_code, None);
1355 assert_eq!(runs[0].lease_expires_at_ms, 62_000);
1356 }
1357
1358 #[test]
1359 fn writable_probe_commits_without_changing_pause_state() {
1360 let directory = tempdir().unwrap();
1361 let store = open_seeded(&directory.path().join("sloop.db"));
1362
1363 store.probe_writable(2_000).unwrap();
1364
1365 assert!(!store.paused().unwrap());
1366 let updated_at_ms: i64 = store
1367 .db()
1368 .lock()
1369 .query_row(
1370 "SELECT updated_at_ms FROM scheduler_state WHERE singleton = 1",
1371 [],
1372 |row| row.get(0),
1373 )
1374 .unwrap();
1375 assert_eq!(updated_at_ms, 2_000);
1376 }
1377
1378 #[test]
1379 fn lifecycle_transitions_append_ordered_events() {
1380 let directory = tempdir().unwrap();
1381 let store = open_seeded(&directory.path().join("sloop.db"));
1382 running_r1(&store);
1383 settle_run(&store, "R1", Some(0), Outcome::Merged, &[], None, 2_300);
1384
1385 let events = store.events_after(0, 10).unwrap();
1386 let kinds: Vec<&str> = events.iter().map(|event| event.kind.as_str()).collect();
1387 assert_eq!(kinds, ["run_claimed", "run_started", "run_finished"]);
1388 assert!(events.iter().all(|event| {
1389 event.run_id.as_deref() == Some("R1") && event.ticket_id.as_deref() == Some("T1")
1390 }));
1391 let finished: serde_json::Value = serde_json::from_str(&events[2].data_json).unwrap();
1392 assert_eq!(finished["outcome"], "merged");
1393 assert_eq!(finished["ticket_state"], "merged");
1394
1395 settle_run(&store, "R1", Some(1), Outcome::Failed, &[], None, 2_400);
1396 assert_eq!(store.latest_event_sequence().unwrap(), events[2].sequence);
1397
1398 let rest = store.events_after(events[0].sequence, 10).unwrap();
1399 assert_eq!(rest.len(), 2);
1400 assert_eq!(rest[0].kind, "run_started");
1401
1402 store.trim_events(1).unwrap();
1403 let kept = store.events_after(0, 10).unwrap();
1404 assert_eq!(kept.len(), 1);
1405 assert_eq!(kept[0].sequence, events[2].sequence);
1406 }
1407
1408 #[test]
1409 fn abandoned_claims_append_an_abort_event() {
1410 let directory = tempdir().unwrap();
1411 let store = open_seeded(&directory.path().join("sloop.db"));
1412 admit(&store, &claim_t1("R1"), 2_000);
1413 abort_run(&store, "R1", 2_100);
1414
1415 let kinds: Vec<String> = store
1416 .events_after(0, 10)
1417 .unwrap()
1418 .into_iter()
1419 .map(|event| event.kind)
1420 .collect();
1421 assert_eq!(kinds, ["run_claimed", "run_aborted"]);
1422 }
1423
1424 #[test]
1425 fn notes_round_trip_in_arrival_order() {
1426 let directory = tempdir().unwrap();
1427 let store = open_seeded(&directory.path().join("sloop.db"));
1428 admit(&store, &claim_t1("R1"), 2_000);
1429
1430 assert_eq!(store.next_note_ordinal().unwrap(), 1);
1431 store.insert_note("N1", "R1", "first", 3_000).unwrap();
1432 store.insert_note("N2", "R1", "second", 3_000).unwrap();
1433 assert_eq!(store.next_note_ordinal().unwrap(), 3);
1434
1435 assert_eq!(
1436 store.notes_for_run("R1").unwrap(),
1437 vec!["first".to_owned(), "second".to_owned()]
1438 );
1439 assert!(store.notes_for_run("R2").unwrap().is_empty());
1440 }
1441
1442 #[test]
1443 fn version_three_migrates_ticket_metadata_and_newer_schemas_are_rejected() {
1444 let directory = tempdir().unwrap();
1445 let path = directory.path().join("sloop.db");
1446 drop(open(&path, 1_000).unwrap());
1447
1448 let connection = Connection::open(&path).unwrap();
1449 connection
1450 .execute_batch(
1451 "DROP TABLE ticket_blockers;
1452 ALTER TABLE tickets DROP COLUMN name;
1453 ALTER TABLE tickets DROP COLUMN worktree;
1454 ALTER TABLE tickets DROP COLUMN flow;
1455 ALTER TABLE tickets DROP COLUMN body;
1456 ALTER TABLE tickets DROP COLUMN held_reason;
1457 ALTER TABLE tickets DROP COLUMN missing_at_ms;
1458 ALTER TABLE scheduler_state DROP COLUMN draining;
1459 ALTER TABLE runs DROP COLUMN worker_socket_path;
1460 ALTER TABLE runs DROP COLUMN flow_json;
1461 ALTER TABLE runs DROP COLUMN ticket_json;
1462 ALTER TABLE runs DROP COLUMN cleanup_eligible_at_ms;
1463 ALTER TABLE runs DROP COLUMN cleaned_at_ms;",
1464 )
1465 .unwrap();
1466 connection
1467 .execute_batch(&format!(
1468 "ALTER TABLE stage_runs RENAME TO {LEGACY_STAGE_TABLE};"
1469 ))
1470 .unwrap();
1471 connection.execute_batch(REVERT_TRIGGER_RENAME).unwrap();
1472 connection.pragma_update(None, "user_version", 3).unwrap();
1473 drop(connection);
1474
1475 let store = open(&path, 2_000).unwrap();
1476 assert!(!store.paused().unwrap());
1477 store
1478 .db()
1479 .lock()
1480 .execute_batch(
1481 "INSERT INTO projects
1482 (id, file_path, source, title, created_at_ms, updated_at_ms)
1483 VALUES ('default', 'projects/default.md', 'local', 'Default', 2000, 2000);
1484 INSERT INTO tickets
1485 (id, project_id, file_path, source, state, name, worktree, target, flow,
1486 created_at_ms, updated_at_ms)
1487 VALUES ('T1', 'default', 'tickets/t1.md', 'local', 'ready', 'Ticket one',
1488 'sloop/T1', 'codex', 'default', 2000, 2000);",
1489 )
1490 .unwrap();
1491 let target: String = store
1492 .db()
1493 .lock()
1494 .query_row("SELECT target FROM tickets WHERE id = 'T1'", [], |row| {
1495 row.get(0)
1496 })
1497 .unwrap();
1498 assert_eq!(target, "codex");
1499 drop(store);
1500
1501 let connection = Connection::open(&path).unwrap();
1502 connection.pragma_update(None, "user_version", 99).unwrap();
1503 drop(connection);
1504
1505 assert!(matches!(
1506 open(&path, 3_000),
1507 Err(StoreError::UnsupportedSchemaVersion(99))
1508 ));
1509 }
1510
1511 #[test]
1512 fn version_eight_migrates_existing_runs_with_null_snapshots() {
1513 let directory = tempdir().unwrap();
1514 let path = directory.path().join("sloop.db");
1515 let store = open_seeded(&path);
1516 admit(&store, &claim_t1("R1"), 2_000);
1517 drop(store);
1518
1519 let connection = Connection::open(&path).unwrap();
1520 connection
1521 .execute_batch(
1522 "ALTER TABLE runs DROP COLUMN flow_json;
1523 ALTER TABLE runs DROP COLUMN ticket_json;
1524 ALTER TABLE tickets DROP COLUMN body;
1525 ALTER TABLE tickets DROP COLUMN held_reason;
1526 ALTER TABLE scheduler_state DROP COLUMN draining;
1527 ALTER TABLE runs DROP COLUMN cleanup_eligible_at_ms;
1528 ALTER TABLE runs DROP COLUMN cleaned_at_ms;",
1529 )
1530 .unwrap();
1531 connection
1532 .execute_batch(&format!(
1533 "ALTER TABLE stage_runs RENAME TO {LEGACY_STAGE_TABLE};"
1534 ))
1535 .unwrap();
1536 connection.execute_batch(REVERT_TRIGGER_RENAME).unwrap();
1537 connection.pragma_update(None, "user_version", 8).unwrap();
1538 drop(connection);
1539
1540 let store = open(&path, 3_000).unwrap();
1541 let run = store.run("R1").unwrap().unwrap();
1542 assert_eq!(run.flow_json, None);
1543 assert_eq!(run.ticket_json, None);
1544 }
1545
1546 #[test]
1547 fn version_ten_adds_source_metadata_without_disturbing_ticket_state() {
1548 let directory = tempdir().unwrap();
1549 let path = directory.path().join("sloop.db");
1550 let store = open_seeded(&path);
1551 store
1552 .db()
1553 .lock()
1554 .execute(
1555 "UPDATE tickets SET state = 'held', attempts = 3 WHERE id = 'T1'",
1556 [],
1557 )
1558 .unwrap();
1559 drop(store);
1560
1561 let connection = Connection::open(&path).unwrap();
1562 connection
1563 .execute_batch(
1564 "ALTER TABLE tickets DROP COLUMN body;
1565 ALTER TABLE tickets DROP COLUMN held_reason;
1566 ALTER TABLE scheduler_state DROP COLUMN draining;
1567 ALTER TABLE runs DROP COLUMN cleanup_eligible_at_ms;
1568 ALTER TABLE runs DROP COLUMN cleaned_at_ms;",
1569 )
1570 .unwrap();
1571 connection
1572 .execute_batch(&format!(
1573 "ALTER TABLE stage_runs RENAME TO {LEGACY_STAGE_TABLE};"
1574 ))
1575 .unwrap();
1576 connection.execute_batch(REVERT_TRIGGER_RENAME).unwrap();
1577 connection.pragma_update(None, "user_version", 10).unwrap();
1578 drop(connection);
1579
1580 let store = open(&path, 3_000).unwrap();
1581 let ticket = store
1582 .db()
1583 .lock()
1584 .query_row(
1585 "SELECT state, attempts, body, held_reason FROM tickets WHERE id = 'T1'",
1586 [],
1587 |row| {
1588 Ok((
1589 row.get::<_, String>(0)?,
1590 row.get::<_, i64>(1)?,
1591 row.get::<_, Option<String>>(2)?,
1592 row.get::<_, Option<String>>(3)?,
1593 ))
1594 },
1595 )
1596 .unwrap();
1597 assert_eq!(ticket, ("held".into(), 3, None, None));
1598 }
1599
1600 #[test]
1601 fn paused_state_persists() {
1602 let directory = tempdir().unwrap();
1603 let path = directory.path().join("sloop.db");
1604
1605 let store = open(&path, 1_000).unwrap();
1606 store.set_paused(true, 2_000).unwrap();
1607 drop(store);
1608
1609 assert!(open(&path, 3_000).unwrap().paused().unwrap());
1610 }
1611
1612 #[test]
1613 fn restart_draining_is_durable_idempotent_and_cancelled_by_resume() {
1614 let directory = tempdir().unwrap();
1615 let path = directory.path().join("sloop.db");
1616 let store = open(&path, 1_000).unwrap();
1617
1618 assert!(store.begin_restart_draining(2, 2_000).unwrap());
1619 assert!(!store.begin_restart_draining(2, 2_100).unwrap());
1620 assert!(store.restart_draining().unwrap());
1621 assert_eq!(
1622 store
1623 .events_after(0, 10)
1624 .unwrap()
1625 .iter()
1626 .filter(|event| event.kind == "daemon_restart_requested")
1627 .count(),
1628 1
1629 );
1630 drop(store);
1631
1632 let reopened = open(&path, 3_000).unwrap();
1633 assert!(reopened.restart_draining().unwrap());
1634 assert!(reopened.resume_scheduler(4_000).unwrap());
1635 assert!(!reopened.restart_draining().unwrap());
1636 }
1637
1638 #[test]
1639 fn version_eleven_adds_restart_draining_state() {
1640 let directory = tempdir().unwrap();
1641 let path = directory.path().join("sloop.db");
1642 drop(open(&path, 1_000).unwrap());
1643 let connection = Connection::open(&path).unwrap();
1644 connection
1645 .execute_batch(&format!(
1646 "ALTER TABLE scheduler_state DROP COLUMN draining;
1647 ALTER TABLE runs DROP COLUMN cleanup_eligible_at_ms;
1648 ALTER TABLE runs DROP COLUMN cleaned_at_ms;
1649 ALTER TABLE stage_runs RENAME TO {LEGACY_STAGE_TABLE};
1650 PRAGMA user_version = 11;"
1651 ))
1652 .unwrap();
1653 connection.execute_batch(REVERT_TRIGGER_RENAME).unwrap();
1654 drop(connection);
1655
1656 let store = open(&path, 2_000).unwrap();
1657 assert!(!store.restart_draining().unwrap());
1658 assert_eq!(
1659 store
1660 .db()
1661 .lock()
1662 .query_row("PRAGMA user_version", [], |row| row.get::<_, u32>(0))
1663 .unwrap(),
1664 SCHEMA_VERSION
1665 );
1666 }
1667}