Skip to main content

sloop/
store.rs

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/// One appended `run_evidence` row: a kind plus kind-specific JSON facts.
285#[derive(Debug, Clone, PartialEq, Eq)]
286pub struct EvidenceRecord {
287    pub kind: &'static str,
288    pub data_json: String,
289}
290
291/// One executed aftercare stage, persisted alongside the run's outcome.
292#[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/// One lease that must be classified when a daemon starts. Process identity
332/// and worker credentials are returned only to the daemon recovery path.
333#[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    /// Opens (creating if needed) the database and migrates it to the current
438    /// schema version. The daemon is the only writer; `now_ms` is injected so
439    /// decision-adjacent timestamps never read the wall clock here.
440    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    /// Inserts or refreshes a project indexed from a committed file. Startup
594    /// and reindex call this for every configured project file, so it must tolerate
595    /// rows that already exist.
596    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    /// Version-two rows predate target snapshots. Once a repository has a
725    /// target configuration, persist its default before dispatch can observe
726    /// those rows.
727    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    /// Whether run history, a lease, an activation, or another ticket's
847    /// blocker list still points at this row; deleting it would then violate
848    /// a foreign key or orphan run evidence.
849    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    /// Stamps a ticket whose committed file has disappeared. The stamp keeps
869    /// the row out of selection without disturbing its state; an existing
870    /// stamp is preserved so the deletion clock starts at the first pass.
871    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    /// Applies the operator-controlled ready/held side-state transition. The
902    /// conditional update prevents an override from stealing a live claim or
903    /// rewriting an evidence-derived outcome.
904    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    /// Returns a failed ticket to the ready queue and starts its attempt
941    /// counter over. Other states remain evidence-derived and immutable here.
942    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    /// Queued activations the dispatcher may act on right now, oldest first.
1000    /// Time-gated kinds (`at`, `every`, `overnight`) stay invisible until the
1001    /// scheduler grows clock support.
1002    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    /// Deterministic ready-work selection within an activation's scope:
1022    /// oldest registration first, ticket ID as the tiebreak. `--only` filters
1023    /// apply when the activation has filter rows.
1024    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    /// Returns the next ordinal for allocating a run ID; runs are never
1057    /// deleted and only the dispatcher allocates.
1058    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    /// Records a successful launch: the run turns `running` and carries the
1066    /// worktree, branch, and durable process identity.
1067    #[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    /// Terminates a run in one transaction: the raw exit and derived outcome
1117    /// land on the run, evidence and stage rows are appended, the lease is
1118    /// freed, and the ticket moves to its terminal state or back to `ready`
1119    /// when cancellation or recovery releases it.
1120    #[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    /// Checkpoints the agent's exit before aftercare starts. The lease and
1218    /// ticket remain claimed until final settlement, but recovery can now
1219    /// resume with the exact exit and commit facts.
1220    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    /// Durably records an operator's cancellation intent, idempotently: the
1284    /// dedupe key makes a repeated `cancel` a no-op rather than new evidence.
1285    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    /// Whether cancellation intent was recorded for the run, so an exit event
1296    /// racing the cancel still resolves to `Cancelled`.
1297    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    /// Appends a worker's advisory note. The agent's only write: it records
1311    /// text against the run and moves nothing.
1312    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    /// Notes recorded against one run, in the order they arrived.
1328    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    /// Evidence rows for one run in observation order, as (kind, data_json).
1393    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    /// Rolls back a claim whose launch failed before a process existed: the
1404    /// lease is released, the run is closed, and the ticket returns to
1405    /// `ready`. The consumed attempt is kept as evidence of the try.
1406    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    /// Runs that have started and not yet exited, oldest first.
1474    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    /// Every nonterminal run that still owns a lease, oldest first. Startup
1495    /// must classify all of these before making another spawn decision.
1496    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    /// Finds a still-queued activation of `kind` scoped to one ticket, used
1529    /// to keep reposting the same file idempotent.
1530    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    /// Returns the next ordinal for allocating an activation ID. Only the
1549    /// single-threaded dispatcher allocates IDs, so count-plus-one cannot
1550    /// race with itself, and activations are never deleted.
1551    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    /// Claims a ready ticket for one run in a single transaction. The
1559    /// conditional update plus the primary key on `leases.ticket_id` are the
1560    /// durable guards against a double claim.
1561    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    /// Renews the lease that `run_id` holds on `ticket_id`, returning the new
1634    /// expiry. Renewal is strict: an expired lease cannot be renewed, so once
1635    /// recovery treats expiry as "run is lost" a revived run can never
1636    /// resurrect a lease that recovery may be reclaiming.
1637    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        // A second stamp must not restart the deletion clock.
1867        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        // The lease expires at 62_000; renewal at or after that must fail.
2283        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        // T1 was registered first, so it wins despite T0 sorting lower.
2328        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        // The lease is gone: the same run cannot renew it.
2482        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        // Intent stayed deduplicated to one evidence row.
2541        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}