1use std::fmt;
2use std::path::{Path, PathBuf};
3
4use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params};
5
6pub const SCHEMA_VERSION: u32 = 7;
7
8const CONNECTION_PRAGMAS: &str = "
9PRAGMA foreign_keys = ON;
10PRAGMA journal_mode = WAL;
11PRAGMA busy_timeout = 5000;
12";
13
14const SCHEMA_V1: &str = "
15CREATE TABLE projects (
16 id TEXT PRIMARY KEY,
17 file_path TEXT UNIQUE,
18 source TEXT NOT NULL DEFAULT 'local',
19 source_ref TEXT,
20 title TEXT NOT NULL,
21 created_at_ms INTEGER NOT NULL,
22 updated_at_ms INTEGER NOT NULL,
23 UNIQUE (source, source_ref),
24 CHECK (file_path IS NOT NULL OR source_ref IS NOT NULL)
25);
26
27CREATE TABLE tickets (
28 id TEXT PRIMARY KEY,
29 project_id TEXT NOT NULL REFERENCES projects(id),
30 file_path TEXT UNIQUE,
31 source TEXT NOT NULL DEFAULT 'local',
32 source_ref TEXT,
33 state TEXT NOT NULL,
34 attempts INTEGER NOT NULL DEFAULT 0,
35 content_hash TEXT,
36 name TEXT NOT NULL DEFAULT '',
37 worktree TEXT,
38 target TEXT,
39 model TEXT,
40 effort TEXT,
41 flow TEXT,
42 missing_at_ms INTEGER,
43 created_at_ms INTEGER NOT NULL,
44 updated_at_ms INTEGER NOT NULL,
45 UNIQUE (source, source_ref),
46 CHECK (file_path IS NOT NULL OR source_ref IS NOT NULL)
47);
48
49CREATE INDEX tickets_by_project_state
50ON tickets(project_id, state);
51
52-- Dependencies are normalized so references are foreign-key checked and
53-- graph reads do not require decoding serialized ticket data.
54CREATE TABLE ticket_blockers (
55 ticket_id TEXT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
56 blocker_id TEXT NOT NULL REFERENCES tickets(id),
57 position INTEGER NOT NULL,
58 PRIMARY KEY (ticket_id, blocker_id)
59);
60
61CREATE TABLE activations (
62 id TEXT PRIMARY KEY,
63 kind TEXT NOT NULL,
64 state TEXT NOT NULL,
65 ticket_id TEXT REFERENCES tickets(id),
66 project_id TEXT REFERENCES projects(id),
67 eligible_at_ms INTEGER,
68 interval_ms INTEGER,
69 created_at_ms INTEGER NOT NULL,
70 updated_at_ms INTEGER NOT NULL,
71 CHECK (ticket_id IS NULL OR project_id IS NULL)
72);
73
74CREATE TABLE activation_filters (
75 activation_id TEXT NOT NULL REFERENCES activations(id) ON DELETE CASCADE,
76 ticket_id TEXT NOT NULL REFERENCES tickets(id),
77 PRIMARY KEY (activation_id, ticket_id)
78);
79
80CREATE TABLE runs (
81 id TEXT PRIMARY KEY,
82 activation_id TEXT NOT NULL REFERENCES activations(id),
83 ticket_id TEXT NOT NULL REFERENCES tickets(id),
84 state TEXT NOT NULL,
85 attempt INTEGER NOT NULL,
86 branch TEXT,
87 worktree_path TEXT,
88 pid INTEGER,
89 pid_start_time INTEGER,
90 process_group_id INTEGER,
91 worker_token TEXT,
92 worker_socket_path TEXT,
93 started_at_ms INTEGER,
94 exited_at_ms INTEGER,
95 exit_code INTEGER,
96 created_at_ms INTEGER NOT NULL,
97 updated_at_ms INTEGER NOT NULL
98);
99
100CREATE INDEX runs_by_ticket ON runs(ticket_id, created_at_ms);
101CREATE INDEX runs_by_activation ON runs(activation_id, created_at_ms);
102
103CREATE TABLE leases (
104 ticket_id TEXT PRIMARY KEY REFERENCES tickets(id),
105 run_id TEXT NOT NULL UNIQUE REFERENCES runs(id),
106 owner_id TEXT NOT NULL,
107 acquired_at_ms INTEGER NOT NULL,
108 renewed_at_ms INTEGER NOT NULL,
109 expires_at_ms INTEGER NOT NULL
110);
111
112CREATE INDEX leases_by_expiry ON leases(expires_at_ms);
113
114CREATE TABLE run_evidence (
115 sequence INTEGER PRIMARY KEY AUTOINCREMENT,
116 run_id TEXT NOT NULL REFERENCES runs(id),
117 kind TEXT NOT NULL,
118 observed_at_ms INTEGER NOT NULL,
119 dedupe_key TEXT UNIQUE,
120 data_json TEXT NOT NULL
121);
122
123CREATE INDEX evidence_by_run ON run_evidence(run_id, sequence);
124
125CREATE TABLE aftercare_stages (
126 run_id TEXT NOT NULL REFERENCES runs(id),
127 stage_index INTEGER NOT NULL,
128 stage TEXT NOT NULL,
129 state TEXT NOT NULL,
130 attempt INTEGER NOT NULL DEFAULT 1,
131 started_at_ms INTEGER,
132 finished_at_ms INTEGER,
133 exit_code INTEGER,
134 evidence_json TEXT,
135 PRIMARY KEY (run_id, stage_index, attempt)
136);
137
138CREATE TABLE cooldowns (
139 key TEXT PRIMARY KEY,
140 until_ms INTEGER NOT NULL,
141 reason TEXT NOT NULL,
142 source_run_id TEXT REFERENCES runs(id),
143 updated_at_ms INTEGER NOT NULL
144);
145
146CREATE TABLE budget_reservations (
147 run_id TEXT PRIMARY KEY REFERENCES runs(id),
148 reserved_tokens INTEGER NOT NULL,
149 actual_tokens INTEGER,
150 state TEXT NOT NULL,
151 created_at_ms INTEGER NOT NULL,
152 reconciled_at_ms INTEGER
153);
154
155CREATE TABLE scheduler_state (
156 singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
157 paused INTEGER NOT NULL CHECK (paused IN (0, 1)),
158 updated_at_ms INTEGER NOT NULL
159);
160
161CREATE TABLE notes (
162 id TEXT PRIMARY KEY,
163 run_id TEXT NOT NULL REFERENCES runs(id),
164 text TEXT NOT NULL,
165 recorded_at_ms INTEGER NOT NULL
166);
167";
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170pub enum TicketState {
171 Ready,
172 Held,
173 Blocked,
174 Claimed,
175 Merged,
176 Failed,
177 NeedsReview,
178}
179
180impl TicketState {
181 pub fn as_str(self) -> &'static str {
182 match self {
183 Self::Ready => "ready",
184 Self::Held => "held",
185 Self::Blocked => "blocked",
186 Self::Claimed => "claimed",
187 Self::Merged => "merged",
188 Self::Failed => "failed",
189 Self::NeedsReview => "needs_review",
190 }
191 }
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
195pub enum RunState {
196 Claimed,
197 Running,
198}
199
200impl RunState {
201 pub fn as_str(self) -> &'static str {
202 match self {
203 Self::Claimed => "claimed",
204 Self::Running => "running",
205 }
206 }
207}
208
209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210pub enum ActivationKind {
211 Immediate,
212 Auto,
213 At,
214 Every,
215 Overnight,
216}
217
218impl ActivationKind {
219 pub fn as_str(self) -> &'static str {
220 match self {
221 Self::Immediate => "immediate",
222 Self::Auto => "auto",
223 Self::At => "at",
224 Self::Every => "every",
225 Self::Overnight => "overnight",
226 }
227 }
228}
229
230#[derive(Debug, Clone, Copy, PartialEq, Eq)]
231pub enum ActivationState {
232 Queued,
233 Completed,
234 Cancelled,
235}
236
237impl ActivationState {
238 pub fn as_str(self) -> &'static str {
239 match self {
240 Self::Queued => "queued",
241 Self::Completed => "completed",
242 Self::Cancelled => "cancelled",
243 }
244 }
245}
246
247#[derive(Debug, Clone, PartialEq, Eq)]
248pub struct NewActivation<'a> {
249 pub id: &'a str,
250 pub kind: ActivationKind,
251 pub ticket_id: Option<&'a str>,
252 pub project_id: Option<&'a str>,
253 pub eligible_at_ms: Option<i64>,
254 pub interval_ms: Option<i64>,
255}
256
257#[derive(Debug, Clone, PartialEq, Eq)]
258pub struct ClaimRequest<'a> {
259 pub ticket_id: &'a str,
260 pub run_id: &'a str,
261 pub activation_id: &'a str,
262 pub owner_id: &'a str,
263 pub lease_ms: i64,
264}
265
266#[derive(Debug, Clone, PartialEq, Eq)]
267pub struct ClaimedRun {
268 pub run_id: String,
269 pub attempt: i64,
270 pub lease_expires_at_ms: i64,
271}
272
273#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
274pub struct TicketCounts {
275 pub ready: u64,
276 pub held: u64,
277 pub blocked: u64,
278 pub claimed: u64,
279 pub merged: u64,
280 pub failed: u64,
281 pub needs_review: u64,
282}
283
284#[derive(Debug, Clone, PartialEq, Eq)]
286pub struct EvidenceRecord {
287 pub kind: &'static str,
288 pub data_json: String,
289}
290
291#[derive(Debug, Clone, PartialEq, Eq)]
293pub struct StageRecord {
294 pub stage: &'static str,
295 pub state: &'static str,
296 pub started_at_ms: i64,
297 pub finished_at_ms: i64,
298 pub exit_code: Option<i32>,
299}
300
301#[derive(Debug, Clone, PartialEq, Eq)]
302pub struct QueuedActivation {
303 pub id: String,
304 pub kind: String,
305 pub ticket_id: Option<String>,
306 pub project_id: Option<String>,
307}
308
309#[derive(Debug, Clone, PartialEq, Eq)]
310pub struct ActiveRun {
311 pub id: String,
312 pub ticket_id: String,
313 pub project_id: String,
314 pub state: String,
315}
316
317#[derive(Debug, Clone, PartialEq, Eq)]
318pub struct RunRecord {
319 pub id: String,
320 pub ticket_id: String,
321 pub state: String,
322 pub branch: Option<String>,
323 pub worktree_path: Option<String>,
324 pub pid: Option<i64>,
325 pub pid_start_time: Option<i64>,
326 pub process_group_id: Option<i64>,
327 pub exit_code: Option<i64>,
328 pub exited_at_ms: Option<i64>,
329}
330
331#[derive(Debug, Clone, PartialEq, Eq)]
334pub(crate) struct RecoverableRun {
335 pub(crate) id: String,
336 pub(crate) ticket_id: String,
337 pub(crate) state: String,
338 pub(crate) branch: Option<String>,
339 pub(crate) worktree_path: Option<String>,
340 pub(crate) pid: Option<i64>,
341 pub(crate) pid_start_time: Option<i64>,
342 pub(crate) process_group_id: Option<i64>,
343 pub(crate) worker_token: Option<String>,
344 pub(crate) worker_socket_path: Option<String>,
345 pub(crate) exit_code: Option<i64>,
346 pub(crate) lease_expires_at_ms: i64,
347}
348
349#[derive(Debug, Clone, PartialEq, Eq)]
350pub struct ProjectRecord {
351 pub id: String,
352 pub file_path: Option<String>,
353 pub title: String,
354}
355
356#[derive(Debug, Clone, PartialEq, Eq)]
357pub struct ProjectNote {
358 pub id: String,
359 pub run_id: String,
360 pub ticket_id: String,
361 pub text: String,
362 pub recorded_at_ms: i64,
363}
364
365#[derive(Debug, Clone, PartialEq, Eq)]
366pub struct ProjectCommitEvidence {
367 pub run_id: String,
368 pub ticket_id: String,
369 pub data_json: String,
370}
371
372#[derive(Debug, Clone, PartialEq, Eq)]
373pub struct LocalTicketFile {
374 pub id: String,
375 pub file_path: String,
376 pub state: String,
377 pub missing_at_ms: Option<i64>,
378}
379
380#[derive(Debug, Clone, PartialEq, Eq)]
381pub struct TicketRecord {
382 pub id: String,
383 pub project_id: String,
384 pub file_path: Option<String>,
385 pub state: String,
386 pub name: String,
387 pub blocked_by: Vec<String>,
388 pub worktree: Option<String>,
389 pub target: Option<String>,
390 pub model: Option<String>,
391 pub effort: Option<String>,
392 pub flow: Option<String>,
393 pub attempts: i64,
394}
395
396fn ticket_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<TicketRecord> {
397 Ok(TicketRecord {
398 id: row.get(0)?,
399 project_id: row.get(1)?,
400 file_path: row.get(2)?,
401 state: row.get(3)?,
402 name: row.get(4)?,
403 blocked_by: Vec::new(),
404 worktree: row.get(5)?,
405 target: row.get(6)?,
406 model: row.get(7)?,
407 effort: row.get(8)?,
408 flow: row.get(9)?,
409 attempts: row.get(10)?,
410 })
411}
412
413fn replace_ticket_blockers(
414 transaction: &rusqlite::Transaction<'_>,
415 ticket_id: &str,
416 blocked_by: &[String],
417) -> rusqlite::Result<()> {
418 transaction.execute(
419 "DELETE FROM ticket_blockers WHERE ticket_id = ?1",
420 params![ticket_id],
421 )?;
422 for (position, blocker_id) in blocked_by.iter().enumerate() {
423 transaction.execute(
424 "INSERT OR IGNORE INTO ticket_blockers (ticket_id, blocker_id, position)
425 VALUES (?1, ?2, ?3)",
426 params![ticket_id, blocker_id, position as i64],
427 )?;
428 }
429 Ok(())
430}
431
432pub struct Store {
433 connection: Connection,
434}
435
436impl Store {
437 pub fn open(path: &Path, now_ms: i64) -> Result<Self, StoreError> {
441 let connection = Connection::open(path).map_err(|source| StoreError::Open {
442 path: path.to_path_buf(),
443 source,
444 })?;
445 connection.execute_batch(CONNECTION_PRAGMAS)?;
446
447 let mut store = Self { connection };
448 store.migrate(now_ms)?;
449 Ok(store)
450 }
451
452 fn migrate(&mut self, now_ms: i64) -> Result<(), StoreError> {
453 let version: u32 = self
454 .connection
455 .query_row("PRAGMA user_version", [], |row| row.get(0))?;
456 match version {
457 0 => {
458 let transaction = self
459 .connection
460 .transaction_with_behavior(TransactionBehavior::Immediate)?;
461 transaction.execute_batch(SCHEMA_V1)?;
462 transaction.execute(
463 "INSERT INTO scheduler_state (singleton, paused, updated_at_ms)
464 VALUES (1, 0, ?1)",
465 params![now_ms],
466 )?;
467 transaction.pragma_update(None, "user_version", SCHEMA_VERSION)?;
468 transaction.commit()?;
469 Ok(())
470 }
471 1 => {
472 let transaction = self
473 .connection
474 .transaction_with_behavior(TransactionBehavior::Immediate)?;
475 transaction.execute_batch(
476 "ALTER TABLE tickets ADD COLUMN model TEXT;
477 ALTER TABLE tickets ADD COLUMN effort TEXT;
478 ALTER TABLE tickets ADD COLUMN target TEXT;
479 ALTER TABLE tickets ADD COLUMN name TEXT NOT NULL DEFAULT '';
480 ALTER TABLE tickets ADD COLUMN worktree TEXT;
481 ALTER TABLE tickets ADD COLUMN flow TEXT;
482 ALTER TABLE tickets ADD COLUMN missing_at_ms INTEGER;
483 ALTER TABLE runs ADD COLUMN worker_socket_path TEXT;
484 CREATE TABLE ticket_blockers (
485 ticket_id TEXT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
486 blocker_id TEXT NOT NULL REFERENCES tickets(id),
487 position INTEGER NOT NULL,
488 PRIMARY KEY (ticket_id, blocker_id)
489 );",
490 )?;
491 transaction.pragma_update(None, "user_version", SCHEMA_VERSION)?;
492 transaction.commit()?;
493 Ok(())
494 }
495 2 => {
496 let transaction = self
497 .connection
498 .transaction_with_behavior(TransactionBehavior::Immediate)?;
499 transaction.execute_batch(
500 "ALTER TABLE tickets ADD COLUMN target TEXT;
501 ALTER TABLE tickets ADD COLUMN name TEXT NOT NULL DEFAULT '';
502 ALTER TABLE tickets ADD COLUMN worktree TEXT;
503 ALTER TABLE tickets ADD COLUMN flow TEXT;
504 ALTER TABLE tickets ADD COLUMN missing_at_ms INTEGER;
505 ALTER TABLE runs ADD COLUMN worker_socket_path TEXT;
506 CREATE TABLE ticket_blockers (
507 ticket_id TEXT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
508 blocker_id TEXT NOT NULL REFERENCES tickets(id),
509 position INTEGER NOT NULL,
510 PRIMARY KEY (ticket_id, blocker_id)
511 );",
512 )?;
513 transaction.pragma_update(None, "user_version", SCHEMA_VERSION)?;
514 transaction.commit()?;
515 Ok(())
516 }
517 3 => {
518 let transaction = self
519 .connection
520 .transaction_with_behavior(TransactionBehavior::Immediate)?;
521 transaction.execute_batch(
522 "ALTER TABLE tickets ADD COLUMN name TEXT NOT NULL DEFAULT '';
523 ALTER TABLE tickets ADD COLUMN worktree TEXT;
524 ALTER TABLE tickets ADD COLUMN flow TEXT;
525 ALTER TABLE tickets ADD COLUMN missing_at_ms INTEGER;
526 ALTER TABLE runs ADD COLUMN worker_socket_path TEXT;
527 CREATE TABLE ticket_blockers (
528 ticket_id TEXT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
529 blocker_id TEXT NOT NULL REFERENCES tickets(id),
530 position INTEGER NOT NULL,
531 PRIMARY KEY (ticket_id, blocker_id)
532 );",
533 )?;
534 transaction.pragma_update(None, "user_version", SCHEMA_VERSION)?;
535 transaction.commit()?;
536 Ok(())
537 }
538 4 => {
539 let transaction = self
540 .connection
541 .transaction_with_behavior(TransactionBehavior::Immediate)?;
542 transaction.execute_batch(
543 "ALTER TABLE tickets ADD COLUMN flow TEXT;
544 ALTER TABLE tickets ADD COLUMN missing_at_ms INTEGER;
545 ALTER TABLE runs ADD COLUMN worker_socket_path TEXT;",
546 )?;
547 transaction.pragma_update(None, "user_version", SCHEMA_VERSION)?;
548 transaction.commit()?;
549 Ok(())
550 }
551 5 => {
552 let transaction = self
553 .connection
554 .transaction_with_behavior(TransactionBehavior::Immediate)?;
555 transaction.execute_batch(
556 "ALTER TABLE tickets ADD COLUMN missing_at_ms INTEGER;
557 ALTER TABLE runs ADD COLUMN worker_socket_path TEXT;",
558 )?;
559 transaction.pragma_update(None, "user_version", SCHEMA_VERSION)?;
560 transaction.commit()?;
561 Ok(())
562 }
563 6 => {
564 let transaction = self
565 .connection
566 .transaction_with_behavior(TransactionBehavior::Immediate)?;
567 transaction
568 .execute_batch("ALTER TABLE runs ADD COLUMN worker_socket_path TEXT;")?;
569 transaction.pragma_update(None, "user_version", SCHEMA_VERSION)?;
570 transaction.commit()?;
571 Ok(())
572 }
573 SCHEMA_VERSION => Ok(()),
574 newer => Err(StoreError::UnsupportedSchemaVersion(newer)),
575 }
576 }
577
578 pub fn insert_local_project(
579 &self,
580 id: &str,
581 file_path: &str,
582 title: &str,
583 now_ms: i64,
584 ) -> Result<(), StoreError> {
585 self.connection.execute(
586 "INSERT INTO projects (id, file_path, source, title, created_at_ms, updated_at_ms)
587 VALUES (?1, ?2, 'local', ?3, ?4, ?4)",
588 params![id, file_path, title, now_ms],
589 )?;
590 Ok(())
591 }
592
593 pub fn upsert_local_project(
597 &self,
598 id: &str,
599 file_path: &str,
600 title: &str,
601 now_ms: i64,
602 ) -> Result<(), StoreError> {
603 self.connection.execute(
604 "INSERT INTO projects (id, file_path, source, title, created_at_ms, updated_at_ms)
605 VALUES (?1, ?2, 'local', ?3, ?4, ?4)
606 ON CONFLICT(id) DO UPDATE SET
607 file_path = excluded.file_path,
608 title = excluded.title,
609 updated_at_ms = excluded.updated_at_ms",
610 params![id, file_path, title, now_ms],
611 )?;
612 Ok(())
613 }
614
615 pub fn project_exists(&self, id: &str) -> Result<bool, StoreError> {
616 let found: Option<i64> = self
617 .connection
618 .query_row("SELECT 1 FROM projects WHERE id = ?1", params![id], |row| {
619 row.get(0)
620 })
621 .optional()?;
622 Ok(found.is_some())
623 }
624
625 pub fn project(&self, id: &str) -> Result<Option<ProjectRecord>, StoreError> {
626 self.connection
627 .query_row(
628 "SELECT id, file_path, title FROM projects WHERE id = ?1",
629 params![id],
630 |row| {
631 Ok(ProjectRecord {
632 id: row.get(0)?,
633 file_path: row.get(1)?,
634 title: row.get(2)?,
635 })
636 },
637 )
638 .optional()
639 .map_err(StoreError::from)
640 }
641
642 #[allow(clippy::too_many_arguments)]
643 pub fn insert_local_ticket(
644 &self,
645 id: &str,
646 project_id: &str,
647 file_path: &str,
648 name: &str,
649 blocked_by: &[String],
650 worktree: &str,
651 target: Option<&str>,
652 model: Option<&str>,
653 effort: Option<&str>,
654 flow: &str,
655 state: TicketState,
656 now_ms: i64,
657 ) -> Result<(), StoreError> {
658 let transaction = self.connection.unchecked_transaction()?;
659 transaction.execute(
660 "INSERT INTO tickets
661 (id, project_id, file_path, source, state, name, worktree, target, model, effort,
662 flow, created_at_ms, updated_at_ms)
663 VALUES (?1, ?2, ?3, 'local', ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11)",
664 params![
665 id,
666 project_id,
667 file_path,
668 state.as_str(),
669 name,
670 worktree,
671 target,
672 model,
673 effort,
674 flow,
675 now_ms
676 ],
677 )?;
678 replace_ticket_blockers(&transaction, id, blocked_by)?;
679 transaction.commit()?;
680 Ok(())
681 }
682
683 #[allow(clippy::too_many_arguments)]
684 pub fn update_local_ticket(
685 &self,
686 id: &str,
687 name: &str,
688 blocked_by: &[String],
689 worktree: &str,
690 target: Option<&str>,
691 model: Option<&str>,
692 effort: Option<&str>,
693 flow: &str,
694 now_ms: i64,
695 ) -> Result<(), StoreError> {
696 let transaction = self.connection.unchecked_transaction()?;
697 transaction.execute(
698 "UPDATE tickets
699 SET name = ?2, worktree = ?3, target = ?4, model = ?5, effort = ?6, flow = ?7,
700 missing_at_ms = NULL, updated_at_ms = ?8
701 WHERE id = ?1",
702 params![id, name, worktree, target, model, effort, flow, now_ms],
703 )?;
704 replace_ticket_blockers(&transaction, id, blocked_by)?;
705 transaction.commit()?;
706 Ok(())
707 }
708
709 pub fn update_ticket_execution(
710 &self,
711 id: &str,
712 target: Option<&str>,
713 model: Option<&str>,
714 effort: Option<&str>,
715 now_ms: i64,
716 ) -> Result<(), StoreError> {
717 self.connection.execute(
718 "UPDATE tickets SET target = ?2, model = ?3, effort = ?4, updated_at_ms = ?5 WHERE id = ?1",
719 params![id, target, model, effort, now_ms],
720 )?;
721 Ok(())
722 }
723
724 pub fn backfill_ticket_targets(
728 &self,
729 default_target: &str,
730 now_ms: i64,
731 ) -> Result<usize, StoreError> {
732 self.connection
733 .execute(
734 "UPDATE tickets SET target = ?1, updated_at_ms = ?2 WHERE target IS NULL",
735 params![default_target, now_ms],
736 )
737 .map_err(StoreError::from)
738 }
739
740 pub fn ticket(&self, id: &str) -> Result<Option<TicketRecord>, StoreError> {
741 let mut ticket = self
742 .connection
743 .query_row(
744 "SELECT id, project_id, file_path, state, name, worktree, target, model, effort, flow, attempts
745 FROM tickets WHERE id = ?1",
746 params![id],
747 ticket_record,
748 )
749 .optional()?;
750 if let Some(ticket) = ticket.as_mut() {
751 ticket.blocked_by = self.ticket_blockers(&ticket.id)?;
752 }
753 Ok(ticket)
754 }
755
756 pub fn ticket_by_file(&self, file_path: &str) -> Result<Option<TicketRecord>, StoreError> {
757 let mut ticket = self
758 .connection
759 .query_row(
760 "SELECT id, project_id, file_path, state, name, worktree, target, model, effort, flow, attempts
761 FROM tickets WHERE file_path = ?1",
762 params![file_path],
763 ticket_record,
764 )
765 .optional()?;
766 if let Some(ticket) = ticket.as_mut() {
767 ticket.blocked_by = self.ticket_blockers(&ticket.id)?;
768 }
769 Ok(ticket)
770 }
771
772 pub fn tickets(&self) -> Result<Vec<TicketRecord>, StoreError> {
773 let mut statement = self.connection.prepare(
774 "SELECT id, project_id, file_path, state, name, worktree, target, model, effort, flow, attempts
775 FROM tickets ORDER BY project_id, id",
776 )?;
777 let mut tickets = statement
778 .query_map([], ticket_record)?
779 .collect::<Result<Vec<_>, _>>()?;
780 for ticket in &mut tickets {
781 ticket.blocked_by = self.ticket_blockers(&ticket.id)?;
782 }
783 Ok(tickets)
784 }
785
786 pub fn tickets_for_project(&self, project_id: &str) -> Result<Vec<TicketRecord>, StoreError> {
787 let mut statement = self.connection.prepare(
788 "SELECT id, project_id, file_path, state, name, worktree, target, model, effort, flow, attempts
789 FROM tickets WHERE project_id = ?1 ORDER BY id",
790 )?;
791 let mut tickets = statement
792 .query_map(params![project_id], ticket_record)?
793 .collect::<Result<Vec<_>, _>>()?;
794 for ticket in &mut tickets {
795 ticket.blocked_by = self.ticket_blockers(&ticket.id)?;
796 }
797 Ok(tickets)
798 }
799
800 pub fn ticket_dependencies(
801 &self,
802 ) -> Result<std::collections::BTreeMap<String, Vec<String>>, StoreError> {
803 let mut dependencies = std::collections::BTreeMap::new();
804 for ticket in self.tickets()? {
805 dependencies.insert(ticket.id, ticket.blocked_by);
806 }
807 Ok(dependencies)
808 }
809
810 fn ticket_blockers(&self, id: &str) -> Result<Vec<String>, StoreError> {
811 let mut statement = self.connection.prepare(
812 "SELECT blocker_id FROM ticket_blockers
813 WHERE ticket_id = ?1 ORDER BY position, blocker_id",
814 )?;
815 statement
816 .query_map(params![id], |row| row.get(0))?
817 .collect::<Result<Vec<_>, _>>()
818 .map_err(StoreError::from)
819 }
820
821 pub fn ticket_ids(&self) -> Result<Vec<String>, StoreError> {
822 let mut statement = self.connection.prepare("SELECT id FROM tickets")?;
823 let rows = statement.query_map([], |row| row.get(0))?;
824 rows.collect::<Result<Vec<_>, _>>()
825 .map_err(StoreError::from)
826 }
827
828 pub fn local_ticket_files(&self) -> Result<Vec<LocalTicketFile>, StoreError> {
829 let mut statement = self.connection.prepare(
830 "SELECT id, file_path, state, missing_at_ms FROM tickets
831 WHERE source = 'local' AND file_path IS NOT NULL
832 ORDER BY id",
833 )?;
834 let rows = statement.query_map([], |row| {
835 Ok(LocalTicketFile {
836 id: row.get(0)?,
837 file_path: row.get(1)?,
838 state: row.get(2)?,
839 missing_at_ms: row.get(3)?,
840 })
841 })?;
842 rows.collect::<Result<Vec<_>, _>>()
843 .map_err(StoreError::from)
844 }
845
846 pub fn ticket_is_referenced(&self, id: &str) -> Result<bool, StoreError> {
850 let referenced = self.connection.query_row(
851 "SELECT EXISTS (SELECT 1 FROM runs WHERE ticket_id = ?1)
852 OR EXISTS (SELECT 1 FROM leases WHERE ticket_id = ?1)
853 OR EXISTS (SELECT 1 FROM activations WHERE ticket_id = ?1)
854 OR EXISTS (SELECT 1 FROM activation_filters WHERE ticket_id = ?1)
855 OR EXISTS (SELECT 1 FROM ticket_blockers WHERE blocker_id = ?1)",
856 params![id],
857 |row| row.get(0),
858 )?;
859 Ok(referenced)
860 }
861
862 pub fn delete_ticket(&self, id: &str) -> Result<(), StoreError> {
863 self.connection
864 .execute("DELETE FROM tickets WHERE id = ?1", params![id])?;
865 Ok(())
866 }
867
868 pub fn mark_ticket_missing(&self, id: &str, now_ms: i64) -> Result<(), StoreError> {
872 self.connection.execute(
873 "UPDATE tickets SET missing_at_ms = ?2, updated_at_ms = ?2
874 WHERE id = ?1 AND missing_at_ms IS NULL",
875 params![id, now_ms],
876 )?;
877 Ok(())
878 }
879
880 pub fn clear_ticket_missing(&self, id: &str, now_ms: i64) -> Result<(), StoreError> {
881 self.connection.execute(
882 "UPDATE tickets SET missing_at_ms = NULL, updated_at_ms = ?2
883 WHERE id = ?1 AND missing_at_ms IS NOT NULL",
884 params![id, now_ms],
885 )?;
886 Ok(())
887 }
888
889 pub fn ticket_state(&self, id: &str) -> Result<Option<String>, StoreError> {
890 let state = self
891 .connection
892 .query_row(
893 "SELECT state FROM tickets WHERE id = ?1",
894 params![id],
895 |row| row.get(0),
896 )
897 .optional()?;
898 Ok(state)
899 }
900
901 pub fn set_ticket_hold(
905 &self,
906 id: &str,
907 state: TicketState,
908 now_ms: i64,
909 ) -> Result<String, StoreError> {
910 debug_assert!(matches!(state, TicketState::Ready | TicketState::Held));
911 let requested = state.as_str();
912 let previous = self
913 .ticket_state(id)?
914 .ok_or_else(|| StoreError::TicketNotFound {
915 ticket_id: id.into(),
916 })?;
917 if previous == requested {
918 return Ok(previous);
919 }
920 let allowed_previous = match state {
921 TicketState::Ready => TicketState::Held.as_str(),
922 TicketState::Held => TicketState::Ready.as_str(),
923 _ => unreachable!("hold transitions only use ready and held"),
924 };
925 let changed = self.connection.execute(
926 "UPDATE tickets SET state = ?2, updated_at_ms = ?3
927 WHERE id = ?1 AND state = ?4",
928 params![id, requested, now_ms, allowed_previous],
929 )?;
930 if changed != 1 {
931 return Err(StoreError::TicketStateConflict {
932 ticket_id: id.into(),
933 state: previous,
934 requested: requested.into(),
935 });
936 }
937 Ok(previous)
938 }
939
940 pub fn retry_ticket(&self, id: &str, now_ms: i64) -> Result<String, StoreError> {
943 let previous = self
944 .ticket_state(id)?
945 .ok_or_else(|| StoreError::TicketNotFound {
946 ticket_id: id.into(),
947 })?;
948 let changed = self.connection.execute(
949 "UPDATE tickets SET state = 'ready', attempts = 0, updated_at_ms = ?2
950 WHERE id = ?1 AND state = 'failed'",
951 params![id, now_ms],
952 )?;
953 if changed != 1 {
954 return Err(StoreError::TicketStateConflict {
955 ticket_id: id.into(),
956 state: previous,
957 requested: TicketState::Ready.as_str().into(),
958 });
959 }
960 Ok(previous)
961 }
962
963 pub fn insert_activation(
964 &self,
965 activation: &NewActivation<'_>,
966 now_ms: i64,
967 ) -> Result<(), StoreError> {
968 self.connection.execute(
969 "INSERT INTO activations
970 (id, kind, state, ticket_id, project_id, eligible_at_ms, interval_ms,
971 created_at_ms, updated_at_ms)
972 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8)",
973 params![
974 activation.id,
975 activation.kind.as_str(),
976 ActivationState::Queued.as_str(),
977 activation.ticket_id,
978 activation.project_id,
979 activation.eligible_at_ms,
980 activation.interval_ms,
981 now_ms,
982 ],
983 )?;
984 Ok(())
985 }
986
987 pub fn insert_activation_filter(
988 &self,
989 activation_id: &str,
990 ticket_id: &str,
991 ) -> Result<(), StoreError> {
992 self.connection.execute(
993 "INSERT OR IGNORE INTO activation_filters (activation_id, ticket_id) VALUES (?1, ?2)",
994 params![activation_id, ticket_id],
995 )?;
996 Ok(())
997 }
998
999 pub fn queued_dispatchable_activations(&self) -> Result<Vec<QueuedActivation>, StoreError> {
1003 let mut statement = self.connection.prepare(
1004 "SELECT id, kind, ticket_id, project_id FROM activations
1005 WHERE state = 'queued' AND kind IN ('immediate', 'auto')
1006 ORDER BY created_at_ms, id",
1007 )?;
1008 let activations = statement
1009 .query_map([], |row| {
1010 Ok(QueuedActivation {
1011 id: row.get(0)?,
1012 kind: row.get(1)?,
1013 ticket_id: row.get(2)?,
1014 project_id: row.get(3)?,
1015 })
1016 })?
1017 .collect::<Result<Vec<_>, _>>()?;
1018 Ok(activations)
1019 }
1020
1021 pub fn select_ready_ticket(
1025 &self,
1026 activation: &QueuedActivation,
1027 ) -> Result<Option<String>, StoreError> {
1028 let ticket = self
1029 .connection
1030 .query_row(
1031 "SELECT t.id FROM tickets t
1032 WHERE t.state = 'ready'
1033 AND t.missing_at_ms IS NULL
1034 AND (?1 IS NULL OR t.project_id = ?1)
1035 AND (NOT EXISTS (SELECT 1 FROM activation_filters f
1036 WHERE f.activation_id = ?2)
1037 OR EXISTS (SELECT 1 FROM activation_filters f
1038 WHERE f.activation_id = ?2 AND f.ticket_id = t.id))
1039 ORDER BY t.created_at_ms, t.id
1040 LIMIT 1",
1041 params![activation.project_id, activation.id],
1042 |row| row.get(0),
1043 )
1044 .optional()?;
1045 Ok(ticket)
1046 }
1047
1048 pub fn complete_activation(&self, id: &str, now_ms: i64) -> Result<(), StoreError> {
1049 self.connection.execute(
1050 "UPDATE activations SET state = 'completed', updated_at_ms = ?2 WHERE id = ?1",
1051 params![id, now_ms],
1052 )?;
1053 Ok(())
1054 }
1055
1056 pub fn next_run_ordinal(&self) -> Result<i64, StoreError> {
1059 let count: i64 = self
1060 .connection
1061 .query_row("SELECT COUNT(*) FROM runs", [], |row| row.get(0))?;
1062 Ok(count + 1)
1063 }
1064
1065 #[allow(clippy::too_many_arguments)]
1068 pub fn mark_run_running(
1069 &self,
1070 run_id: &str,
1071 branch: &str,
1072 worktree_path: &str,
1073 pid: u32,
1074 pid_start_time: Option<i64>,
1075 process_group_id: u32,
1076 worker_token: &str,
1077 worker_socket_path: &str,
1078 now_ms: i64,
1079 ) -> Result<(), StoreError> {
1080 let changed = self.connection.execute(
1081 "UPDATE runs
1082 SET state = 'running', branch = ?2, worktree_path = ?3, pid = ?4,
1083 pid_start_time = ?5, process_group_id = ?6, worker_token = ?7,
1084 worker_socket_path = ?8, started_at_ms = ?9, updated_at_ms = ?9
1085 WHERE id = ?1 AND state = 'claimed' AND exited_at_ms IS NULL",
1086 params![
1087 run_id,
1088 branch,
1089 worktree_path,
1090 i64::from(pid),
1091 pid_start_time,
1092 i64::from(process_group_id),
1093 worker_token,
1094 worker_socket_path,
1095 now_ms,
1096 ],
1097 )?;
1098 if changed != 1 {
1099 let state = self
1100 .connection
1101 .query_row(
1102 "SELECT state FROM runs WHERE id = ?1",
1103 params![run_id],
1104 |row| row.get(0),
1105 )
1106 .optional()?;
1107 return Err(StoreError::RunStateConflict {
1108 run_id: run_id.into(),
1109 state,
1110 requested: "running".into(),
1111 });
1112 }
1113 Ok(())
1114 }
1115
1116 #[allow(clippy::too_many_arguments)]
1121 pub fn finish_run(
1122 &mut self,
1123 run_id: &str,
1124 ticket_id: &str,
1125 exit_code: Option<i32>,
1126 outcome: crate::outcome::Outcome,
1127 evidence: &[EvidenceRecord],
1128 stage: Option<&StageRecord>,
1129 now_ms: i64,
1130 ) -> Result<(), StoreError> {
1131 use crate::outcome::Outcome;
1132
1133 let transaction = self
1134 .connection
1135 .transaction_with_behavior(TransactionBehavior::Immediate)?;
1136 let changed = transaction.execute(
1137 "UPDATE runs
1138 SET state = ?2, exited_at_ms = ?3, exit_code = ?4, updated_at_ms = ?3
1139 WHERE id = ?1 AND exited_at_ms IS NULL",
1140 params![run_id, outcome.as_str(), now_ms, exit_code],
1141 )?;
1142 if changed == 0 {
1143 let existing: Option<(String, Option<i64>)> = transaction
1144 .query_row(
1145 "SELECT state, exited_at_ms FROM runs WHERE id = ?1",
1146 params![run_id],
1147 |row| Ok((row.get(0)?, row.get(1)?)),
1148 )
1149 .optional()?;
1150 match existing {
1151 Some((_, Some(_))) => {
1152 transaction.commit()?;
1153 return Ok(());
1154 }
1155 Some((state, None)) => {
1156 return Err(StoreError::RunStateConflict {
1157 run_id: run_id.into(),
1158 state: Some(state),
1159 requested: outcome.as_str().into(),
1160 });
1161 }
1162 None => {
1163 return Err(StoreError::RunNotFound {
1164 run_id: run_id.into(),
1165 });
1166 }
1167 }
1168 }
1169 transaction.execute("DELETE FROM leases WHERE run_id = ?1", params![run_id])?;
1170
1171 let ticket_state = match outcome {
1172 Outcome::Merged => TicketState::Merged,
1173 Outcome::Failed => TicketState::Failed,
1174 Outcome::NeedsReview => TicketState::NeedsReview,
1175 Outcome::Cancelled => TicketState::Ready,
1176 Outcome::Orphaned => TicketState::Ready,
1177 };
1178 transaction.execute(
1179 "UPDATE tickets SET state = ?2, updated_at_ms = ?3
1180 WHERE id = ?1 AND state = 'claimed'",
1181 params![ticket_id, ticket_state.as_str(), now_ms],
1182 )?;
1183
1184 for record in evidence {
1185 transaction.execute(
1186 "INSERT OR IGNORE INTO run_evidence
1187 (run_id, kind, observed_at_ms, dedupe_key, data_json)
1188 VALUES (?1, ?2, ?3, 'settlement:' || ?1 || ':' || ?2, ?4)",
1189 params![run_id, record.kind, now_ms, record.data_json],
1190 )?;
1191 }
1192 if let Some(stage) = stage {
1193 transaction.execute(
1194 "INSERT INTO aftercare_stages
1195 (run_id, stage_index, stage, state, started_at_ms, finished_at_ms, exit_code)
1196 VALUES (?1, 0, ?2, ?3, ?4, ?5, ?6)
1197 ON CONFLICT(run_id, stage_index, attempt) DO UPDATE SET
1198 stage = excluded.stage,
1199 state = excluded.state,
1200 started_at_ms = excluded.started_at_ms,
1201 finished_at_ms = excluded.finished_at_ms,
1202 exit_code = excluded.exit_code",
1203 params![
1204 run_id,
1205 stage.stage,
1206 stage.state,
1207 stage.started_at_ms,
1208 stage.finished_at_ms,
1209 stage.exit_code,
1210 ],
1211 )?;
1212 }
1213 transaction.commit()?;
1214 Ok(())
1215 }
1216
1217 pub(crate) fn record_agent_exit(
1221 &mut self,
1222 run_id: &str,
1223 exit_code: Option<i32>,
1224 capture_complete: bool,
1225 commits_json: &str,
1226 now_ms: i64,
1227 ) -> Result<(), StoreError> {
1228 let transaction = self
1229 .connection
1230 .transaction_with_behavior(TransactionBehavior::Immediate)?;
1231 transaction.execute(
1232 "UPDATE runs
1233 SET state = 'aftercare', exit_code = ?2, updated_at_ms = ?3
1234 WHERE id = ?1 AND state = 'running' AND exited_at_ms IS NULL",
1235 params![run_id, exit_code, now_ms],
1236 )?;
1237 for (kind, data_json) in [
1238 (
1239 "exit_classified",
1240 serde_json::json!({"exit_code": exit_code}).to_string(),
1241 ),
1242 ("commits_observed", commits_json.to_owned()),
1243 ] {
1244 transaction.execute(
1245 "INSERT OR IGNORE INTO run_evidence
1246 (run_id, kind, observed_at_ms, dedupe_key, data_json)
1247 VALUES (?1, ?2, ?3, 'settlement:' || ?1 || ':' || ?2, ?4)",
1248 params![run_id, kind, now_ms, data_json],
1249 )?;
1250 }
1251 if !capture_complete {
1252 transaction.execute(
1253 "INSERT OR IGNORE INTO run_evidence
1254 (run_id, kind, observed_at_ms, dedupe_key, data_json)
1255 VALUES (?1, 'capture_incomplete', ?2,
1256 'settlement:' || ?1 || ':capture_incomplete', '{}')",
1257 params![run_id, now_ms],
1258 )?;
1259 }
1260 transaction.commit()?;
1261 Ok(())
1262 }
1263
1264 pub(crate) fn record_aftercare_evidence(
1265 &self,
1266 run_id: &str,
1267 kind: &'static str,
1268 data_json: &str,
1269 now_ms: i64,
1270 ) -> Result<(), StoreError> {
1271 self.connection.execute(
1272 "INSERT INTO run_evidence
1273 (run_id, kind, observed_at_ms, dedupe_key, data_json)
1274 VALUES (?1, ?2, ?3, 'settlement:' || ?1 || ':' || ?2, ?4)
1275 ON CONFLICT(dedupe_key) DO UPDATE SET
1276 observed_at_ms = excluded.observed_at_ms,
1277 data_json = excluded.data_json",
1278 params![run_id, kind, now_ms, data_json],
1279 )?;
1280 Ok(())
1281 }
1282
1283 pub fn record_cancel_requested(&self, run_id: &str, now_ms: i64) -> Result<(), StoreError> {
1286 self.connection.execute(
1287 "INSERT OR IGNORE INTO run_evidence
1288 (run_id, kind, observed_at_ms, dedupe_key, data_json)
1289 VALUES (?1, 'cancel_requested', ?2, 'cancel_requested:' || ?1, '{}')",
1290 params![run_id, now_ms],
1291 )?;
1292 Ok(())
1293 }
1294
1295 pub fn cancellation_requested(&self, run_id: &str) -> Result<bool, StoreError> {
1298 let found: Option<i64> = self
1299 .connection
1300 .query_row(
1301 "SELECT 1 FROM run_evidence
1302 WHERE run_id = ?1 AND kind = 'cancel_requested'",
1303 params![run_id],
1304 |row| row.get(0),
1305 )
1306 .optional()?;
1307 Ok(found.is_some())
1308 }
1309
1310 pub fn insert_note(
1313 &self,
1314 id: &str,
1315 run_id: &str,
1316 text: &str,
1317 now_ms: i64,
1318 ) -> Result<(), StoreError> {
1319 self.connection.execute(
1320 "INSERT INTO notes (id, run_id, text, recorded_at_ms)
1321 VALUES (?1, ?2, ?3, ?4)",
1322 params![id, run_id, text, now_ms],
1323 )?;
1324 Ok(())
1325 }
1326
1327 pub fn notes_for_run(&self, run_id: &str) -> Result<Vec<String>, StoreError> {
1329 let mut statement = self
1330 .connection
1331 .prepare("SELECT text FROM notes WHERE run_id = ?1 ORDER BY recorded_at_ms, id")?;
1332 let rows = statement
1333 .query_map(params![run_id], |row| row.get(0))?
1334 .collect::<Result<Vec<_>, _>>()?;
1335 Ok(rows)
1336 }
1337
1338 pub fn notes_for_project(&self, project_id: &str) -> Result<Vec<ProjectNote>, StoreError> {
1339 let mut statement = self.connection.prepare(
1340 "SELECT n.id, n.run_id, r.ticket_id, n.text, n.recorded_at_ms
1341 FROM notes n
1342 JOIN runs r ON r.id = n.run_id
1343 JOIN tickets t ON t.id = r.ticket_id
1344 WHERE t.project_id = ?1
1345 ORDER BY r.ticket_id, n.recorded_at_ms, n.id",
1346 )?;
1347 statement
1348 .query_map(params![project_id], |row| {
1349 Ok(ProjectNote {
1350 id: row.get(0)?,
1351 run_id: row.get(1)?,
1352 ticket_id: row.get(2)?,
1353 text: row.get(3)?,
1354 recorded_at_ms: row.get(4)?,
1355 })
1356 })?
1357 .collect::<Result<Vec<_>, _>>()
1358 .map_err(StoreError::from)
1359 }
1360
1361 pub fn commit_evidence_for_project(
1362 &self,
1363 project_id: &str,
1364 ) -> Result<Vec<ProjectCommitEvidence>, StoreError> {
1365 let mut statement = self.connection.prepare(
1366 "SELECT r.id, r.ticket_id, e.data_json
1367 FROM run_evidence e
1368 JOIN runs r ON r.id = e.run_id
1369 JOIN tickets t ON t.id = r.ticket_id
1370 WHERE t.project_id = ?1 AND e.kind = 'commits_observed'
1371 ORDER BY r.ticket_id, r.created_at_ms, r.id, e.sequence",
1372 )?;
1373 statement
1374 .query_map(params![project_id], |row| {
1375 Ok(ProjectCommitEvidence {
1376 run_id: row.get(0)?,
1377 ticket_id: row.get(1)?,
1378 data_json: row.get(2)?,
1379 })
1380 })?
1381 .collect::<Result<Vec<_>, _>>()
1382 .map_err(StoreError::from)
1383 }
1384
1385 pub fn next_note_ordinal(&self) -> Result<i64, StoreError> {
1386 let count: i64 = self
1387 .connection
1388 .query_row("SELECT COUNT(*) FROM notes", [], |row| row.get(0))?;
1389 Ok(count + 1)
1390 }
1391
1392 pub fn run_evidence(&self, run_id: &str) -> Result<Vec<(String, String)>, StoreError> {
1394 let mut statement = self.connection.prepare(
1395 "SELECT kind, data_json FROM run_evidence WHERE run_id = ?1 ORDER BY sequence",
1396 )?;
1397 let rows = statement
1398 .query_map(params![run_id], |row| Ok((row.get(0)?, row.get(1)?)))?
1399 .collect::<Result<Vec<_>, _>>()?;
1400 Ok(rows)
1401 }
1402
1403 pub fn abort_claim(
1407 &mut self,
1408 run_id: &str,
1409 ticket_id: &str,
1410 now_ms: i64,
1411 ) -> Result<(), StoreError> {
1412 let transaction = self
1413 .connection
1414 .transaction_with_behavior(TransactionBehavior::Immediate)?;
1415 transaction.execute("DELETE FROM leases WHERE run_id = ?1", params![run_id])?;
1416 transaction.execute(
1417 "UPDATE runs
1418 SET state = 'aborted', exited_at_ms = ?2, updated_at_ms = ?2
1419 WHERE id = ?1 AND exited_at_ms IS NULL",
1420 params![run_id, now_ms],
1421 )?;
1422 transaction.execute(
1423 "UPDATE tickets SET state = 'ready', updated_at_ms = ?2
1424 WHERE id = ?1 AND state = 'claimed'",
1425 params![ticket_id, now_ms],
1426 )?;
1427 transaction.commit()?;
1428 Ok(())
1429 }
1430
1431 pub fn run(&self, id: &str) -> Result<Option<RunRecord>, StoreError> {
1432 let run = self
1433 .connection
1434 .query_row(
1435 "SELECT id, ticket_id, state, branch, worktree_path, pid,
1436 pid_start_time, process_group_id, exit_code, exited_at_ms
1437 FROM runs WHERE id = ?1",
1438 params![id],
1439 |row| {
1440 Ok(RunRecord {
1441 id: row.get(0)?,
1442 ticket_id: row.get(1)?,
1443 state: row.get(2)?,
1444 branch: row.get(3)?,
1445 worktree_path: row.get(4)?,
1446 pid: row.get(5)?,
1447 pid_start_time: row.get(6)?,
1448 process_group_id: row.get(7)?,
1449 exit_code: row.get(8)?,
1450 exited_at_ms: row.get(9)?,
1451 })
1452 },
1453 )
1454 .optional()?;
1455 Ok(run)
1456 }
1457
1458 pub fn active_run_for_ticket(&self, ticket_id: &str) -> Result<Option<String>, StoreError> {
1459 let run = self
1460 .connection
1461 .query_row(
1462 "SELECT id FROM runs
1463 WHERE ticket_id = ?1 AND state IN ('claimed', 'running', 'aftercare')
1464 AND exited_at_ms IS NULL
1465 ORDER BY created_at_ms DESC, id DESC LIMIT 1",
1466 params![ticket_id],
1467 |row| row.get(0),
1468 )
1469 .optional()?;
1470 Ok(run)
1471 }
1472
1473 pub fn active_runs(&self) -> Result<Vec<ActiveRun>, StoreError> {
1475 let mut statement = self.connection.prepare(
1476 "SELECT r.id, r.ticket_id, t.project_id, r.state FROM runs r
1477 JOIN tickets t ON t.id = r.ticket_id
1478 WHERE r.exited_at_ms IS NULL AND r.state IN ('running', 'aftercare')
1479 ORDER BY r.created_at_ms, r.id",
1480 )?;
1481 let runs = statement
1482 .query_map([], |row| {
1483 Ok(ActiveRun {
1484 id: row.get(0)?,
1485 ticket_id: row.get(1)?,
1486 project_id: row.get(2)?,
1487 state: row.get(3)?,
1488 })
1489 })?
1490 .collect::<Result<Vec<_>, _>>()?;
1491 Ok(runs)
1492 }
1493
1494 pub(crate) fn recoverable_runs(&self) -> Result<Vec<RecoverableRun>, StoreError> {
1497 let mut statement = self.connection.prepare(
1498 "SELECT r.id, r.ticket_id, r.state, r.branch, r.worktree_path,
1499 r.pid, r.pid_start_time, r.process_group_id, r.worker_token,
1500 r.worker_socket_path, r.exit_code, l.expires_at_ms
1501 FROM runs r
1502 JOIN leases l ON l.run_id = r.id
1503 WHERE r.exited_at_ms IS NULL
1504 AND r.state IN ('claimed', 'running', 'aftercare')
1505 ORDER BY r.created_at_ms, r.id",
1506 )?;
1507 statement
1508 .query_map([], |row| {
1509 Ok(RecoverableRun {
1510 id: row.get(0)?,
1511 ticket_id: row.get(1)?,
1512 state: row.get(2)?,
1513 branch: row.get(3)?,
1514 worktree_path: row.get(4)?,
1515 pid: row.get(5)?,
1516 pid_start_time: row.get(6)?,
1517 process_group_id: row.get(7)?,
1518 worker_token: row.get(8)?,
1519 worker_socket_path: row.get(9)?,
1520 exit_code: row.get(10)?,
1521 lease_expires_at_ms: row.get(11)?,
1522 })
1523 })?
1524 .collect::<Result<Vec<_>, _>>()
1525 .map_err(StoreError::from)
1526 }
1527
1528 pub fn queued_ticket_activation(
1531 &self,
1532 ticket_id: &str,
1533 kind: ActivationKind,
1534 ) -> Result<Option<String>, StoreError> {
1535 let id = self
1536 .connection
1537 .query_row(
1538 "SELECT id FROM activations
1539 WHERE ticket_id = ?1 AND kind = ?2 AND state = 'queued'
1540 ORDER BY created_at_ms LIMIT 1",
1541 params![ticket_id, kind.as_str()],
1542 |row| row.get(0),
1543 )
1544 .optional()?;
1545 Ok(id)
1546 }
1547
1548 pub fn next_activation_ordinal(&self) -> Result<i64, StoreError> {
1552 let count: i64 =
1553 self.connection
1554 .query_row("SELECT COUNT(*) FROM activations", [], |row| row.get(0))?;
1555 Ok(count + 1)
1556 }
1557
1558 pub fn claim_ticket(
1562 &mut self,
1563 claim: &ClaimRequest<'_>,
1564 now_ms: i64,
1565 ) -> Result<ClaimedRun, StoreError> {
1566 let transaction = self
1567 .connection
1568 .transaction_with_behavior(TransactionBehavior::Immediate)?;
1569
1570 let changed = transaction.execute(
1571 "UPDATE tickets
1572 SET state = 'claimed', attempts = attempts + 1, updated_at_ms = ?2
1573 WHERE id = ?1 AND state = 'ready' AND missing_at_ms IS NULL",
1574 params![claim.ticket_id, now_ms],
1575 )?;
1576 if changed != 1 {
1577 let state: Option<String> = transaction
1578 .query_row(
1579 "SELECT CASE WHEN missing_at_ms IS NOT NULL THEN 'missing' ELSE state END
1580 FROM tickets WHERE id = ?1",
1581 params![claim.ticket_id],
1582 |row| row.get(0),
1583 )
1584 .optional()?;
1585 return Err(StoreError::TicketNotReady {
1586 ticket_id: claim.ticket_id.into(),
1587 state,
1588 });
1589 }
1590
1591 let attempt: i64 = transaction.query_row(
1592 "SELECT attempts FROM tickets WHERE id = ?1",
1593 params![claim.ticket_id],
1594 |row| row.get(0),
1595 )?;
1596
1597 transaction.execute(
1598 "INSERT INTO runs
1599 (id, activation_id, ticket_id, state, attempt, created_at_ms, updated_at_ms)
1600 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6)",
1601 params![
1602 claim.run_id,
1603 claim.activation_id,
1604 claim.ticket_id,
1605 RunState::Claimed.as_str(),
1606 attempt,
1607 now_ms,
1608 ],
1609 )?;
1610
1611 let expires_at_ms = now_ms + claim.lease_ms;
1612 transaction.execute(
1613 "INSERT INTO leases
1614 (ticket_id, run_id, owner_id, acquired_at_ms, renewed_at_ms, expires_at_ms)
1615 VALUES (?1, ?2, ?3, ?4, ?4, ?5)",
1616 params![
1617 claim.ticket_id,
1618 claim.run_id,
1619 claim.owner_id,
1620 now_ms,
1621 expires_at_ms,
1622 ],
1623 )?;
1624
1625 transaction.commit()?;
1626 Ok(ClaimedRun {
1627 run_id: claim.run_id.into(),
1628 attempt,
1629 lease_expires_at_ms: expires_at_ms,
1630 })
1631 }
1632
1633 pub fn renew_lease(
1638 &mut self,
1639 ticket_id: &str,
1640 run_id: &str,
1641 lease_ms: i64,
1642 now_ms: i64,
1643 ) -> Result<i64, StoreError> {
1644 let expires_at_ms = now_ms + lease_ms;
1645 let changed = self.connection.execute(
1646 "UPDATE leases
1647 SET renewed_at_ms = ?3, expires_at_ms = ?4
1648 WHERE ticket_id = ?1 AND run_id = ?2 AND expires_at_ms > ?3",
1649 params![ticket_id, run_id, now_ms, expires_at_ms],
1650 )?;
1651 if changed != 1 {
1652 return Err(StoreError::LeaseNotHeld {
1653 ticket_id: ticket_id.into(),
1654 run_id: run_id.into(),
1655 });
1656 }
1657 Ok(expires_at_ms)
1658 }
1659
1660 pub fn paused(&self) -> Result<bool, StoreError> {
1661 let paused: i64 = self.connection.query_row(
1662 "SELECT paused FROM scheduler_state WHERE singleton = 1",
1663 [],
1664 |row| row.get(0),
1665 )?;
1666 Ok(paused != 0)
1667 }
1668
1669 pub fn set_paused(&self, paused: bool, now_ms: i64) -> Result<(), StoreError> {
1670 self.connection.execute(
1671 "UPDATE scheduler_state SET paused = ?1, updated_at_ms = ?2 WHERE singleton = 1",
1672 params![i64::from(paused), now_ms],
1673 )?;
1674 Ok(())
1675 }
1676
1677 pub fn ticket_counts(&self) -> Result<TicketCounts, StoreError> {
1678 let mut statement = self
1679 .connection
1680 .prepare("SELECT state, COUNT(*) FROM tickets GROUP BY state")?;
1681 let mut rows = statement.query([])?;
1682 let mut counts = TicketCounts::default();
1683 while let Some(row) = rows.next()? {
1684 let state: String = row.get(0)?;
1685 let count = row.get::<_, i64>(1)?.max(0) as u64;
1686 match state.as_str() {
1687 "ready" => counts.ready = count,
1688 "held" => counts.held = count,
1689 "blocked" => counts.blocked = count,
1690 "claimed" => counts.claimed = count,
1691 "merged" => counts.merged = count,
1692 "failed" => counts.failed = count,
1693 "needs_review" => counts.needs_review = count,
1694 _ => {}
1695 }
1696 }
1697 Ok(counts)
1698 }
1699}
1700
1701#[derive(Debug)]
1702pub enum StoreError {
1703 Open {
1704 path: PathBuf,
1705 source: rusqlite::Error,
1706 },
1707 Sqlite(rusqlite::Error),
1708 UnsupportedSchemaVersion(u32),
1709 TicketNotReady {
1710 ticket_id: String,
1711 state: Option<String>,
1712 },
1713 TicketNotFound {
1714 ticket_id: String,
1715 },
1716 TicketStateConflict {
1717 ticket_id: String,
1718 state: String,
1719 requested: String,
1720 },
1721 LeaseNotHeld {
1722 ticket_id: String,
1723 run_id: String,
1724 },
1725 RunNotFound {
1726 run_id: String,
1727 },
1728 RunStateConflict {
1729 run_id: String,
1730 state: Option<String>,
1731 requested: String,
1732 },
1733}
1734
1735impl From<rusqlite::Error> for StoreError {
1736 fn from(source: rusqlite::Error) -> Self {
1737 Self::Sqlite(source)
1738 }
1739}
1740
1741impl fmt::Display for StoreError {
1742 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1743 match self {
1744 Self::Open { path, source } => {
1745 write!(formatter, "cannot open {}: {source}", path.display())
1746 }
1747 Self::Sqlite(source) => write!(formatter, "database error: {source}"),
1748 Self::UnsupportedSchemaVersion(version) => {
1749 write!(formatter, "unsupported database schema version {version}")
1750 }
1751 Self::TicketNotReady { ticket_id, state } => match state {
1752 Some(state) => write!(formatter, "ticket `{ticket_id}` is `{state}`, not `ready`"),
1753 None => write!(formatter, "ticket `{ticket_id}` does not exist"),
1754 },
1755 Self::TicketNotFound { ticket_id } => {
1756 write!(formatter, "ticket `{ticket_id}` does not exist")
1757 }
1758 Self::TicketStateConflict {
1759 ticket_id,
1760 state,
1761 requested,
1762 } => write!(
1763 formatter,
1764 "ticket `{ticket_id}` is `{state}` and cannot be changed to `{requested}`"
1765 ),
1766 Self::LeaseNotHeld { ticket_id, run_id } => write!(
1767 formatter,
1768 "run `{run_id}` does not hold the lease on ticket `{ticket_id}`"
1769 ),
1770 Self::RunNotFound { run_id } => write!(formatter, "run `{run_id}` does not exist"),
1771 Self::RunStateConflict {
1772 run_id,
1773 state,
1774 requested,
1775 } => match state {
1776 Some(state) => write!(
1777 formatter,
1778 "run `{run_id}` is `{state}` and cannot be changed to `{requested}`"
1779 ),
1780 None => write!(formatter, "run `{run_id}` does not exist"),
1781 },
1782 }
1783 }
1784}
1785
1786impl std::error::Error for StoreError {}
1787
1788#[cfg(test)]
1789mod tests {
1790 use tempfile::tempdir;
1791
1792 use super::{ActivationKind, ClaimRequest, NewActivation, Store, StoreError, TicketState};
1793
1794 fn open_seeded(path: &std::path::Path) -> Store {
1795 let store = Store::open(path, 1_000).unwrap();
1796 store
1797 .insert_local_project(
1798 "default",
1799 ".agents/sloop/projects/default.md",
1800 "Default",
1801 1_000,
1802 )
1803 .unwrap();
1804 store
1805 .insert_local_ticket(
1806 "T1",
1807 "default",
1808 ".agents/sloop/tickets/t1.md",
1809 "Ticket one",
1810 &[],
1811 "sloop/T1",
1812 Some("claude"),
1813 Some("sonnet"),
1814 Some("medium"),
1815 "default",
1816 TicketState::Ready,
1817 1_000,
1818 )
1819 .unwrap();
1820 store
1821 .insert_activation(
1822 &NewActivation {
1823 id: "A1",
1824 kind: ActivationKind::Immediate,
1825 ticket_id: Some("T1"),
1826 project_id: None,
1827 eligible_at_ms: None,
1828 interval_ms: None,
1829 },
1830 1_000,
1831 )
1832 .unwrap();
1833 store
1834 }
1835
1836 fn claim_t1<'a>(run_id: &'a str) -> ClaimRequest<'a> {
1837 ClaimRequest {
1838 ticket_id: "T1",
1839 run_id,
1840 activation_id: "A1",
1841 owner_id: "daemon-1",
1842 lease_ms: 60_000,
1843 }
1844 }
1845
1846 #[test]
1847 fn missing_tickets_are_not_selected_and_cannot_be_claimed() {
1848 let directory = tempdir().unwrap();
1849 let mut store = open_seeded(&directory.path().join("sloop.db"));
1850 store.mark_ticket_missing("T1", 2_000).unwrap();
1851
1852 let activation = super::QueuedActivation {
1853 id: "A1".into(),
1854 kind: "immediate".into(),
1855 ticket_id: None,
1856 project_id: None,
1857 };
1858 assert_eq!(store.select_ready_ticket(&activation).unwrap(), None);
1859 match store.claim_ticket(&claim_t1("R1"), 2_000).unwrap_err() {
1860 StoreError::TicketNotReady { state, .. } => {
1861 assert_eq!(state.as_deref(), Some("missing"));
1862 }
1863 other => panic!("unexpected error: {other:?}"),
1864 }
1865
1866 store.mark_ticket_missing("T1", 5_000).unwrap();
1868 assert_eq!(
1869 store.local_ticket_files().unwrap()[0].missing_at_ms,
1870 Some(2_000)
1871 );
1872
1873 store.clear_ticket_missing("T1", 6_000).unwrap();
1874 assert_eq!(
1875 store.select_ready_ticket(&activation).unwrap().as_deref(),
1876 Some("T1")
1877 );
1878 }
1879
1880 #[test]
1881 fn state_survives_reopening_the_database() {
1882 let directory = tempdir().unwrap();
1883 let path = directory.path().join("sloop.db");
1884
1885 let mut store = open_seeded(&path);
1886 store.claim_ticket(&claim_t1("R1"), 2_000).unwrap();
1887 drop(store);
1888
1889 let store = Store::open(&path, 3_000).unwrap();
1890 assert_eq!(store.ticket_state("T1").unwrap().unwrap(), "claimed");
1891 assert_eq!(store.ticket_counts().unwrap().claimed, 1);
1892 let ticket = store.ticket("T1").unwrap().unwrap();
1893 assert_eq!(ticket.target.as_deref(), Some("claude"));
1894 assert_eq!(ticket.model.as_deref(), Some("sonnet"));
1895 assert_eq!(ticket.effort.as_deref(), Some("medium"));
1896 assert_eq!(ticket.name, "Ticket one");
1897 assert!(ticket.blocked_by.is_empty());
1898 assert_eq!(ticket.worktree.as_deref(), Some("sloop/T1"));
1899 }
1900
1901 #[test]
1902 fn blocked_by_and_worktree_round_trip() {
1903 let directory = tempdir().unwrap();
1904 let path = directory.path().join("sloop.db");
1905 let store = open_seeded(&path);
1906 store
1907 .insert_local_ticket(
1908 "T2",
1909 "default",
1910 ".agents/sloop/tickets/t2.md",
1911 "Ticket two",
1912 &["T1".to_owned()],
1913 "feature/t2",
1914 None,
1915 None,
1916 None,
1917 "default",
1918 TicketState::Ready,
1919 2_000,
1920 )
1921 .unwrap();
1922 drop(store);
1923
1924 let store = Store::open(&path, 3_000).unwrap();
1925 let ticket = store.ticket("T2").unwrap().unwrap();
1926 assert_eq!(ticket.name, "Ticket two");
1927 assert_eq!(ticket.blocked_by, ["T1"]);
1928 assert_eq!(ticket.worktree.as_deref(), Some("feature/t2"));
1929 }
1930
1931 #[test]
1932 fn a_claimed_ticket_cannot_be_claimed_again() {
1933 let directory = tempdir().unwrap();
1934 let mut store = open_seeded(&directory.path().join("sloop.db"));
1935
1936 let claimed = store.claim_ticket(&claim_t1("R1"), 2_000).unwrap();
1937 assert_eq!(claimed.attempt, 1);
1938 assert_eq!(claimed.lease_expires_at_ms, 62_000);
1939
1940 let error = store.claim_ticket(&claim_t1("R2"), 2_100).unwrap_err();
1941 assert!(matches!(
1942 error,
1943 StoreError::TicketNotReady { state: Some(ref state), .. } if state == "claimed"
1944 ));
1945 }
1946
1947 #[test]
1948 fn tickets_are_ordered_by_project_and_id_and_include_attempts() {
1949 let directory = tempdir().unwrap();
1950 let mut store = open_seeded(&directory.path().join("sloop.db"));
1951 store
1952 .insert_local_project("alpha", ".agents/sloop/projects/alpha.md", "Alpha", 1_000)
1953 .unwrap();
1954 store
1955 .insert_local_ticket(
1956 "T0",
1957 "alpha",
1958 ".agents/sloop/tickets/t0.md",
1959 "Ticket zero",
1960 &[],
1961 "sloop/T0",
1962 None,
1963 None,
1964 None,
1965 "default",
1966 TicketState::Held,
1967 1_000,
1968 )
1969 .unwrap();
1970 store
1971 .insert_local_ticket(
1972 "T2",
1973 "default",
1974 ".agents/sloop/tickets/t2.md",
1975 "Ticket two",
1976 &[],
1977 "sloop/T2",
1978 None,
1979 None,
1980 None,
1981 "default",
1982 TicketState::Ready,
1983 1_000,
1984 )
1985 .unwrap();
1986 store.claim_ticket(&claim_t1("R1"), 2_000).unwrap();
1987
1988 let tickets = store.tickets().unwrap();
1989 assert_eq!(
1990 tickets
1991 .iter()
1992 .map(|ticket| ticket.id.as_str())
1993 .collect::<Vec<_>>(),
1994 ["T0", "T1", "T2"]
1995 );
1996 assert_eq!(tickets[0].attempts, 0);
1997 assert_eq!(tickets[1].attempts, 1);
1998 assert_eq!(tickets[2].attempts, 0);
1999 }
2000
2001 #[test]
2002 fn active_run_for_ticket_tracks_claimed_and_running_runs_only() {
2003 use crate::outcome::Outcome;
2004
2005 let directory = tempdir().unwrap();
2006 let mut store = open_seeded(&directory.path().join("sloop.db"));
2007 assert_eq!(store.active_run_for_ticket("T1").unwrap(), None);
2008
2009 store.claim_ticket(&claim_t1("R1"), 2_000).unwrap();
2010 assert_eq!(
2011 store.active_run_for_ticket("T1").unwrap().as_deref(),
2012 Some("R1")
2013 );
2014 store
2015 .mark_run_running(
2016 "R1",
2017 "branch",
2018 "/tmp/worktree",
2019 1,
2020 Some(1),
2021 1,
2022 "token",
2023 "/runtime/R1.sock",
2024 2_100,
2025 )
2026 .unwrap();
2027 assert_eq!(
2028 store.active_run_for_ticket("T1").unwrap().as_deref(),
2029 Some("R1")
2030 );
2031
2032 store
2033 .finish_run("R1", "T1", Some(1), Outcome::Failed, &[], None, 2_200)
2034 .unwrap();
2035 assert_eq!(store.active_run_for_ticket("T1").unwrap(), None);
2036 }
2037
2038 #[test]
2039 fn aborted_claims_are_closed_and_no_longer_active() {
2040 let directory = tempdir().unwrap();
2041 let mut store = open_seeded(&directory.path().join("sloop.db"));
2042 store.claim_ticket(&claim_t1("R1"), 2_000).unwrap();
2043
2044 store.abort_claim("R1", "T1", 2_100).unwrap();
2045
2046 assert_eq!(store.run("R1").unwrap().unwrap().state, "aborted");
2047 assert_eq!(store.active_run_for_ticket("T1").unwrap(), None);
2048 assert_eq!(store.ticket_state("T1").unwrap().as_deref(), Some("ready"));
2049 }
2050
2051 #[test]
2052 fn recoverable_runs_round_trip_process_identity_and_lease() {
2053 let directory = tempdir().unwrap();
2054 let path = directory.path().join("sloop.db");
2055 let mut store = open_seeded(&path);
2056 store.claim_ticket(&claim_t1("R1"), 2_000).unwrap();
2057 store
2058 .mark_run_running(
2059 "R1",
2060 "sloop/T1-a1-R1",
2061 "/worktrees/R1",
2062 123,
2063 Some(456),
2064 123,
2065 "worker-token",
2066 "/runtime/R1.sock",
2067 2_100,
2068 )
2069 .unwrap();
2070 drop(store);
2071
2072 let store = Store::open(&path, 3_000).unwrap();
2073 let runs = store.recoverable_runs().unwrap();
2074 assert_eq!(runs.len(), 1);
2075 assert_eq!(runs[0].id, "R1");
2076 assert_eq!(runs[0].ticket_id, "T1");
2077 assert_eq!(runs[0].pid, Some(123));
2078 assert_eq!(runs[0].pid_start_time, Some(456));
2079 assert_eq!(runs[0].process_group_id, Some(123));
2080 assert_eq!(runs[0].worker_token.as_deref(), Some("worker-token"));
2081 assert_eq!(
2082 runs[0].worker_socket_path.as_deref(),
2083 Some("/runtime/R1.sock")
2084 );
2085 assert_eq!(runs[0].exit_code, None);
2086 assert_eq!(runs[0].lease_expires_at_ms, 62_000);
2087 }
2088
2089 #[test]
2090 fn agent_exit_and_aftercare_results_are_checkpointed_idempotently() {
2091 let directory = tempdir().unwrap();
2092 let mut store = open_seeded(&directory.path().join("sloop.db"));
2093 store.claim_ticket(&claim_t1("R1"), 2_000).unwrap();
2094 store
2095 .mark_run_running(
2096 "R1",
2097 "branch",
2098 "/worktree",
2099 123,
2100 Some(456),
2101 123,
2102 "token",
2103 "/runtime/R1.sock",
2104 2_100,
2105 )
2106 .unwrap();
2107
2108 store
2109 .record_agent_exit("R1", Some(0), true, r#"{"count":1,"oids":["abc"]}"#, 2_200)
2110 .unwrap();
2111 store
2112 .record_aftercare_evidence(
2113 "R1",
2114 "test_result",
2115 r#"{"passed":true,"exit_code":0}"#,
2116 2_300,
2117 )
2118 .unwrap();
2119 store
2120 .record_aftercare_evidence(
2121 "R1",
2122 "test_result",
2123 r#"{"passed":true,"exit_code":0}"#,
2124 2_400,
2125 )
2126 .unwrap();
2127
2128 let run = store.run("R1").unwrap().unwrap();
2129 assert_eq!(run.state, "aftercare");
2130 assert_eq!(run.exit_code, Some(0));
2131 assert_eq!(store.recoverable_runs().unwrap()[0].state, "aftercare");
2132 let evidence = store.run_evidence("R1").unwrap();
2133 assert_eq!(
2134 evidence
2135 .iter()
2136 .filter(|(kind, _)| kind == "test_result")
2137 .count(),
2138 1
2139 );
2140 }
2141
2142 #[test]
2143 fn operator_hold_transitions_are_narrow_and_idempotent() {
2144 let directory = tempdir().unwrap();
2145 let store = open_seeded(&directory.path().join("sloop.db"));
2146
2147 assert_eq!(
2148 store
2149 .set_ticket_hold("T1", TicketState::Held, 2_000)
2150 .unwrap(),
2151 "ready"
2152 );
2153 assert_eq!(store.ticket_counts().unwrap().held, 1);
2154 assert_eq!(
2155 store
2156 .set_ticket_hold("T1", TicketState::Held, 2_100)
2157 .unwrap(),
2158 "held"
2159 );
2160 assert_eq!(
2161 store
2162 .set_ticket_hold("T1", TicketState::Ready, 2_200)
2163 .unwrap(),
2164 "held"
2165 );
2166 }
2167
2168 #[test]
2169 fn operator_hold_cannot_steal_a_claim() {
2170 let directory = tempdir().unwrap();
2171 let mut store = open_seeded(&directory.path().join("sloop.db"));
2172 store.claim_ticket(&claim_t1("R1"), 2_000).unwrap();
2173
2174 assert!(matches!(
2175 store.set_ticket_hold("T1", TicketState::Held, 2_100),
2176 Err(StoreError::TicketStateConflict { state, .. }) if state == "claimed"
2177 ));
2178 }
2179
2180 #[test]
2181 fn retry_only_requeues_failed_tickets_and_resets_attempts() {
2182 use crate::outcome::Outcome;
2183
2184 let directory = tempdir().unwrap();
2185 let mut store = open_seeded(&directory.path().join("sloop.db"));
2186
2187 let first = store.claim_ticket(&claim_t1("R1"), 2_000).unwrap();
2188 assert_eq!(first.attempt, 1);
2189 store
2190 .finish_run("R1", "T1", Some(0), Outcome::Failed, &[], None, 2_100)
2191 .unwrap();
2192
2193 assert_eq!(store.retry_ticket("T1", 2_200).unwrap(), "failed");
2194 assert_eq!(store.ticket_state("T1").unwrap().as_deref(), Some("ready"));
2195 let retried = store.claim_ticket(&claim_t1("R2"), 2_300).unwrap();
2196 assert_eq!(retried.attempt, 1);
2197
2198 assert!(matches!(
2199 store.retry_ticket("T1", 2_400),
2200 Err(StoreError::TicketStateConflict { state, .. }) if state == "claimed"
2201 ));
2202 assert!(matches!(
2203 store.retry_ticket("missing", 2_400),
2204 Err(StoreError::TicketNotFound { .. })
2205 ));
2206 }
2207
2208 #[test]
2209 fn claiming_an_unknown_ticket_reports_it_missing() {
2210 let directory = tempdir().unwrap();
2211 let mut store = open_seeded(&directory.path().join("sloop.db"));
2212
2213 let error = store
2214 .claim_ticket(
2215 &ClaimRequest {
2216 ticket_id: "missing",
2217 ..claim_t1("R1")
2218 },
2219 2_000,
2220 )
2221 .unwrap_err();
2222 assert!(matches!(
2223 error,
2224 StoreError::TicketNotReady { state: None, .. }
2225 ));
2226 }
2227
2228 #[test]
2229 fn concurrent_connections_cannot_both_claim_one_ticket() {
2230 let directory = tempdir().unwrap();
2231 let path = directory.path().join("sloop.db");
2232 open_seeded(&path);
2233
2234 let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
2235 let claims: Vec<_> = ["R1", "R2"]
2236 .into_iter()
2237 .map(|run_id| {
2238 let path = path.clone();
2239 let barrier = barrier.clone();
2240 std::thread::spawn(move || {
2241 let mut store = Store::open(&path, 2_000).unwrap();
2242 barrier.wait();
2243 store.claim_ticket(&claim_t1(run_id), 2_000).is_ok()
2244 })
2245 })
2246 .collect();
2247
2248 let successes = claims
2249 .into_iter()
2250 .map(|handle| handle.join().unwrap())
2251 .filter(|claimed| *claimed)
2252 .count();
2253 assert_eq!(successes, 1);
2254 }
2255
2256 #[test]
2257 fn renewing_a_held_lease_extends_its_expiry() {
2258 let directory = tempdir().unwrap();
2259 let mut store = open_seeded(&directory.path().join("sloop.db"));
2260 store.claim_ticket(&claim_t1("R1"), 2_000).unwrap();
2261
2262 let expires = store.renew_lease("T1", "R1", 60_000, 10_000).unwrap();
2263 assert_eq!(expires, 70_000);
2264 }
2265
2266 #[test]
2267 fn a_run_cannot_renew_a_lease_it_does_not_hold() {
2268 let directory = tempdir().unwrap();
2269 let mut store = open_seeded(&directory.path().join("sloop.db"));
2270 store.claim_ticket(&claim_t1("R1"), 2_000).unwrap();
2271
2272 let error = store.renew_lease("T1", "R2", 60_000, 10_000).unwrap_err();
2273 assert!(matches!(error, StoreError::LeaseNotHeld { .. }));
2274 }
2275
2276 #[test]
2277 fn an_expired_lease_cannot_be_renewed() {
2278 let directory = tempdir().unwrap();
2279 let mut store = open_seeded(&directory.path().join("sloop.db"));
2280 store.claim_ticket(&claim_t1("R1"), 2_000).unwrap();
2281
2282 let error = store.renew_lease("T1", "R1", 60_000, 62_000).unwrap_err();
2284 assert!(matches!(error, StoreError::LeaseNotHeld { .. }));
2285 }
2286
2287 #[test]
2288 fn ready_work_selection_is_deterministic_and_respects_filters() {
2289 let directory = tempdir().unwrap();
2290 let store = open_seeded(&directory.path().join("sloop.db"));
2291 store
2292 .insert_local_ticket(
2293 "T0",
2294 "default",
2295 ".agents/sloop/tickets/t0.md",
2296 "Ticket zero",
2297 &[],
2298 "sloop/T0",
2299 None,
2300 None,
2301 None,
2302 "default",
2303 TicketState::Ready,
2304 2_000,
2305 )
2306 .unwrap();
2307 store
2308 .insert_activation(
2309 &NewActivation {
2310 id: "A2",
2311 kind: ActivationKind::Immediate,
2312 ticket_id: None,
2313 project_id: None,
2314 eligible_at_ms: None,
2315 interval_ms: None,
2316 },
2317 2_000,
2318 )
2319 .unwrap();
2320 let activation = super::QueuedActivation {
2321 id: "A2".into(),
2322 kind: "immediate".into(),
2323 ticket_id: None,
2324 project_id: None,
2325 };
2326
2327 assert_eq!(
2329 store.select_ready_ticket(&activation).unwrap().as_deref(),
2330 Some("T1")
2331 );
2332
2333 store.insert_activation_filter("A2", "T0").unwrap();
2334 assert_eq!(
2335 store.select_ready_ticket(&activation).unwrap().as_deref(),
2336 Some("T0")
2337 );
2338
2339 let scoped = super::QueuedActivation {
2340 project_id: Some("elsewhere".into()),
2341 ..activation
2342 };
2343 assert_eq!(store.select_ready_ticket(&scoped).unwrap(), None);
2344 }
2345
2346 #[test]
2347 fn notes_round_trip_in_arrival_order() {
2348 let directory = tempdir().unwrap();
2349 let mut store = open_seeded(&directory.path().join("sloop.db"));
2350 store.claim_ticket(&claim_t1("R1"), 2_000).unwrap();
2351
2352 assert_eq!(store.next_note_ordinal().unwrap(), 1);
2353 store.insert_note("N1", "R1", "first", 3_000).unwrap();
2354 store.insert_note("N2", "R1", "second", 3_000).unwrap();
2355 assert_eq!(store.next_note_ordinal().unwrap(), 3);
2356
2357 assert_eq!(
2358 store.notes_for_run("R1").unwrap(),
2359 vec!["first".to_owned(), "second".to_owned()]
2360 );
2361 assert!(store.notes_for_run("R2").unwrap().is_empty());
2362 }
2363
2364 #[test]
2365 fn version_three_migrates_ticket_metadata_and_newer_schemas_are_rejected() {
2366 let directory = tempdir().unwrap();
2367 let path = directory.path().join("sloop.db");
2368 drop(Store::open(&path, 1_000).unwrap());
2369
2370 let connection = rusqlite::Connection::open(&path).unwrap();
2371 connection
2372 .execute_batch(
2373 "DROP TABLE ticket_blockers;
2374 ALTER TABLE tickets DROP COLUMN name;
2375 ALTER TABLE tickets DROP COLUMN worktree;
2376 ALTER TABLE tickets DROP COLUMN flow;
2377 ALTER TABLE tickets DROP COLUMN missing_at_ms;
2378 ALTER TABLE runs DROP COLUMN worker_socket_path;",
2379 )
2380 .unwrap();
2381 connection.pragma_update(None, "user_version", 3).unwrap();
2382 drop(connection);
2383
2384 let store = Store::open(&path, 2_000).unwrap();
2385 assert!(!store.paused().unwrap());
2386 store
2387 .insert_local_project(
2388 "default",
2389 ".agents/sloop/projects/default.md",
2390 "Default",
2391 2_000,
2392 )
2393 .unwrap();
2394 store
2395 .insert_local_ticket(
2396 "T1",
2397 "default",
2398 ".agents/sloop/tickets/t1.md",
2399 "Ticket one",
2400 &[],
2401 "sloop/T1",
2402 Some("codex"),
2403 None,
2404 None,
2405 "default",
2406 TicketState::Ready,
2407 2_000,
2408 )
2409 .unwrap();
2410 assert_eq!(
2411 store.ticket("T1").unwrap().unwrap().target.as_deref(),
2412 Some("codex")
2413 );
2414 drop(store);
2415
2416 let connection = rusqlite::Connection::open(&path).unwrap();
2417 connection.pragma_update(None, "user_version", 99).unwrap();
2418 drop(connection);
2419
2420 assert!(matches!(
2421 Store::open(&path, 3_000),
2422 Err(StoreError::UnsupportedSchemaVersion(99))
2423 ));
2424 }
2425
2426 #[test]
2427 fn configured_default_backfills_tickets_that_predate_target_snapshots() {
2428 let directory = tempdir().unwrap();
2429 let store = open_seeded(&directory.path().join("sloop.db"));
2430 store
2431 .update_ticket_execution("T1", None, Some("sonnet"), Some("medium"), 2_000)
2432 .unwrap();
2433
2434 assert_eq!(store.backfill_ticket_targets("codex", 3_000).unwrap(), 1);
2435 assert_eq!(
2436 store.ticket("T1").unwrap().unwrap().target.as_deref(),
2437 Some("codex")
2438 );
2439 assert_eq!(store.backfill_ticket_targets("claude", 4_000).unwrap(), 0);
2440 assert_eq!(
2441 store.ticket("T1").unwrap().unwrap().target.as_deref(),
2442 Some("codex")
2443 );
2444 }
2445
2446 #[test]
2447 fn finishing_a_run_settles_ticket_lease_and_evidence_atomically() {
2448 use crate::outcome::Outcome;
2449 let directory = tempdir().unwrap();
2450 let mut store = open_seeded(&directory.path().join("sloop.db"));
2451 store.claim_ticket(&claim_t1("R1"), 2_000).unwrap();
2452
2453 store
2454 .finish_run(
2455 "R1",
2456 "T1",
2457 Some(0),
2458 Outcome::Merged,
2459 &[super::EvidenceRecord {
2460 kind: "commits_observed",
2461 data_json: "{\"count\":2}".into(),
2462 }],
2463 Some(&super::StageRecord {
2464 stage: "test",
2465 state: "passed",
2466 started_at_ms: 2_500,
2467 finished_at_ms: 2_900,
2468 exit_code: Some(0),
2469 }),
2470 3_000,
2471 )
2472 .unwrap();
2473
2474 assert_eq!(store.ticket_state("T1").unwrap().unwrap(), "merged");
2475 let run = store.run("R1").unwrap().unwrap();
2476 assert_eq!(run.state, "merged");
2477 assert_eq!(run.exit_code, Some(0));
2478 assert_eq!(run.exited_at_ms, Some(3_000));
2479 let evidence = store.run_evidence("R1").unwrap();
2480 assert_eq!(evidence[0].0, "commits_observed");
2481 assert!(store.renew_lease("T1", "R1", 60_000, 3_100).is_err());
2483 }
2484
2485 #[test]
2486 fn finishing_a_run_is_idempotent() {
2487 use crate::outcome::Outcome;
2488 let directory = tempdir().unwrap();
2489 let mut store = open_seeded(&directory.path().join("sloop.db"));
2490 store.claim_ticket(&claim_t1("R1"), 2_000).unwrap();
2491 let evidence = [super::EvidenceRecord {
2492 kind: "exit_classified",
2493 data_json: "{\"exit_code\":1}".into(),
2494 }];
2495
2496 store
2497 .finish_run("R1", "T1", Some(1), Outcome::Failed, &evidence, None, 3_000)
2498 .unwrap();
2499 store
2500 .finish_run("R1", "T1", Some(1), Outcome::Failed, &evidence, None, 3_100)
2501 .unwrap();
2502
2503 assert_eq!(store.run_evidence("R1").unwrap().len(), 1);
2504 assert_eq!(store.run("R1").unwrap().unwrap().exited_at_ms, Some(3_000));
2505 }
2506
2507 #[test]
2508 fn orphaning_a_run_releases_the_ticket_without_failing_it() {
2509 use crate::outcome::Outcome;
2510 let directory = tempdir().unwrap();
2511 let mut store = open_seeded(&directory.path().join("sloop.db"));
2512 store.claim_ticket(&claim_t1("R1"), 2_000).unwrap();
2513
2514 store
2515 .finish_run("R1", "T1", None, Outcome::Orphaned, &[], None, 3_000)
2516 .unwrap();
2517
2518 assert_eq!(store.run("R1").unwrap().unwrap().state, "orphaned");
2519 assert_eq!(store.ticket_state("T1").unwrap().as_deref(), Some("ready"));
2520 }
2521
2522 #[test]
2523 fn a_cancelled_outcome_returns_the_ticket_to_ready() {
2524 use crate::outcome::Outcome;
2525 let directory = tempdir().unwrap();
2526 let mut store = open_seeded(&directory.path().join("sloop.db"));
2527 store.claim_ticket(&claim_t1("R1"), 2_000).unwrap();
2528
2529 assert!(!store.cancellation_requested("R1").unwrap());
2530 store.record_cancel_requested("R1", 2_500).unwrap();
2531 store.record_cancel_requested("R1", 2_600).unwrap();
2532 assert!(store.cancellation_requested("R1").unwrap());
2533
2534 store
2535 .finish_run("R1", "T1", None, Outcome::Cancelled, &[], None, 3_000)
2536 .unwrap();
2537 assert_eq!(store.ticket_state("T1").unwrap().unwrap(), "ready");
2538 assert_eq!(store.ticket_counts().unwrap().ready, 1);
2539
2540 let cancels = store
2542 .run_evidence("R1")
2543 .unwrap()
2544 .into_iter()
2545 .filter(|(kind, _)| kind == "cancel_requested")
2546 .count();
2547 assert_eq!(cancels, 1);
2548 }
2549
2550 #[test]
2551 fn paused_state_persists() {
2552 let directory = tempdir().unwrap();
2553 let path = directory.path().join("sloop.db");
2554
2555 let store = Store::open(&path, 1_000).unwrap();
2556 store.set_paused(true, 2_000).unwrap();
2557 drop(store);
2558
2559 assert!(Store::open(&path, 3_000).unwrap().paused().unwrap());
2560 }
2561}