1use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params};
2use serde::{Deserialize, Serialize};
3
4use super::{ProjectCommitEvidence, RunStore};
5use crate::db::StoreError;
6
7#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct EvidenceRecord {
10 pub kind: &'static str,
11 pub data_json: String,
12}
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum StagePhase {
20 Action,
21 Check,
22}
23
24impl StagePhase {
25 pub fn as_str(self) -> &'static str {
26 match self {
27 Self::Action => "action",
28 Self::Check => "check",
29 }
30 }
31
32 fn parse(value: &str) -> Self {
35 match value {
36 "check" => Self::Check,
37 _ => Self::Action,
38 }
39 }
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct StageRecord {
51 pub stage_index: usize,
52 pub stage: String,
53 pub attempt: u32,
56 pub phase: StagePhase,
57 pub state: Option<String>,
60 pub started_at_ms: i64,
61 pub finished_at_ms: i64,
62 pub exit_code: Option<i32>,
63 pub output_ref: String,
64 pub verdict_source: Option<String>,
65 pub reason: Option<String>,
66}
67
68#[derive(Debug, Clone, Copy)]
72pub struct PanelReportRecord<'a> {
73 pub stage: &'a str,
74 pub stage_index: usize,
75 pub attempt: u32,
76 pub reviewer_index: usize,
77 pub verdict: &'a str,
78 pub confidence: &'a str,
79 pub reason: &'a str,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84pub struct OutputStallEvidence {
85 pub stage: String,
86 pub last_output_at_ms: i64,
87 pub threshold_ms: i64,
88 pub last_output_sequence: Option<u64>,
89}
90
91pub(crate) mod tx {
92 use rusqlite::{Transaction, params};
93
94 use super::{EvidenceRecord, OutputStallEvidence, PanelReportRecord, StageRecord};
95
96 pub(crate) fn delete_for_run(
97 transaction: &Transaction<'_>,
98 run_id: &str,
99 ) -> rusqlite::Result<usize> {
100 let evidence = transaction.execute(
101 "DELETE FROM run_evidence WHERE run_id = ?1",
102 params![run_id],
103 )?;
104 let stages =
105 transaction.execute("DELETE FROM stage_runs WHERE run_id = ?1", params![run_id])?;
106 Ok(evidence + stages)
107 }
108
109 pub(crate) fn record_settlement(
110 transaction: &Transaction<'_>,
111 run_id: &str,
112 evidence: &[EvidenceRecord],
113 now_ms: i64,
114 ) -> rusqlite::Result<()> {
115 for record in evidence {
116 transaction.execute(
117 "INSERT OR IGNORE INTO run_evidence
118 (run_id, kind, observed_at_ms, dedupe_key, data_json)
119 VALUES (?1, ?2, ?3, 'settlement:' || ?1 || ':' || ?2, ?4)",
120 params![run_id, record.kind, now_ms, record.data_json],
121 )?;
122 }
123 Ok(())
124 }
125
126 #[allow(clippy::too_many_arguments)]
139 pub(crate) fn record_agent_exit(
140 transaction: &Transaction<'_>,
141 run_id: &str,
142 attempt: u32,
143 exit_code: Option<i32>,
144 capture_complete: bool,
145 commits_json: &str,
146 vendor_error: Option<&crate::vendor_error::VendorErrorMatch>,
147 cooldown_until_ms: Option<i64>,
148 now_ms: i64,
149 ) -> rusqlite::Result<()> {
150 let evidence = [
151 EvidenceRecord {
152 kind: "exit_classified",
153 data_json: serde_json::json!({"exit_code": exit_code, "attempt": attempt})
154 .to_string(),
155 },
156 EvidenceRecord {
157 kind: "commits_observed",
158 data_json: commits_json.to_owned(),
159 },
160 ];
161 for record in evidence {
162 record_stage_evidence(transaction, run_id, record.kind, &record.data_json, now_ms)?;
163 }
164 match vendor_error {
165 Some(vendor_error) => record_stage_evidence(
166 transaction,
167 run_id,
168 "vendor_error_classified",
169 &vendor_error.evidence_json(cooldown_until_ms),
170 now_ms,
171 )?,
172 None => delete_settlement(transaction, run_id, "vendor_error_classified")?,
173 }
174 if capture_complete {
175 delete_settlement(transaction, run_id, "capture_incomplete")?;
176 } else {
177 record_stage_evidence(transaction, run_id, "capture_incomplete", "{}", now_ms)?;
178 }
179 Ok(())
180 }
181
182 fn delete_settlement(
183 transaction: &Transaction<'_>,
184 run_id: &str,
185 kind: &str,
186 ) -> rusqlite::Result<()> {
187 transaction.execute(
188 "DELETE FROM run_evidence
189 WHERE run_id = ?1 AND dedupe_key = 'settlement:' || ?1 || ':' || ?2",
190 params![run_id, kind],
191 )?;
192 Ok(())
193 }
194
195 pub(crate) fn append_stage_rows(
199 transaction: &Transaction<'_>,
200 run_id: &str,
201 rows: &[StageRecord],
202 ) -> rusqlite::Result<()> {
203 for row in rows {
204 let evidence_json = serde_json::json!({
205 "output": row.output_ref,
206 "verdict_source": row.verdict_source,
207 "reason": row.reason,
208 })
209 .to_string();
210 transaction.execute(
214 "INSERT INTO stage_runs
215 (run_id, seq, stage_index, stage, state, attempt, phase,
216 started_at_ms, finished_at_ms, exit_code, evidence_json)
217 VALUES (?1,
218 (SELECT COALESCE(MAX(seq), 0) + 1
219 FROM stage_runs WHERE run_id = ?1),
220 ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
221 ON CONFLICT(run_id, stage_index, attempt, phase) DO UPDATE SET
222 stage = excluded.stage,
223 state = excluded.state,
224 started_at_ms = excluded.started_at_ms,
225 finished_at_ms = excluded.finished_at_ms,
226 exit_code = excluded.exit_code,
227 evidence_json = excluded.evidence_json",
228 params![
229 run_id,
230 row.stage_index as i64,
231 row.stage,
232 row.state,
233 row.attempt,
234 row.phase.as_str(),
235 row.started_at_ms,
236 row.finished_at_ms,
237 row.exit_code,
238 evidence_json,
239 ],
240 )?;
241 }
242 Ok(())
243 }
244
245 pub(crate) fn record_stage_evidence(
246 transaction: &Transaction<'_>,
247 run_id: &str,
248 kind: &str,
249 data_json: &str,
250 now_ms: i64,
251 ) -> rusqlite::Result<()> {
252 transaction.execute(
253 "INSERT INTO run_evidence
254 (run_id, kind, observed_at_ms, dedupe_key, data_json)
255 VALUES (?1, ?2, ?3, 'settlement:' || ?1 || ':' || ?2, ?4)
256 ON CONFLICT(dedupe_key) DO UPDATE SET
257 observed_at_ms = excluded.observed_at_ms,
258 data_json = excluded.data_json",
259 params![run_id, kind, now_ms, data_json],
260 )?;
261 Ok(())
262 }
263
264 pub(crate) fn clear_stage_process(
265 transaction: &Transaction<'_>,
266 run_id: &str,
267 ) -> rusqlite::Result<()> {
268 transaction.execute(
269 "DELETE FROM run_evidence
270 WHERE run_id = ?1 AND dedupe_key = 'settlement:' || ?1 || ':stage_process'",
271 params![run_id],
272 )?;
273 Ok(())
274 }
275
276 pub(crate) fn record_cancel_requested(
277 transaction: &Transaction<'_>,
278 run_id: &str,
279 now_ms: i64,
280 ) -> rusqlite::Result<()> {
281 transaction.execute(
282 "INSERT OR IGNORE INTO run_evidence
283 (run_id, kind, observed_at_ms, dedupe_key, data_json)
284 VALUES (?1, 'cancel_requested', ?2, 'cancel_requested:' || ?1, '{}')",
285 params![run_id, now_ms],
286 )?;
287 Ok(())
288 }
289
290 pub(crate) fn record_output_stall(
291 transaction: &Transaction<'_>,
292 run_id: &str,
293 evidence: &OutputStallEvidence,
294 now_ms: i64,
295 ) -> rusqlite::Result<bool> {
296 let inserted = transaction.execute(
297 "INSERT OR IGNORE INTO run_evidence
298 (run_id, kind, observed_at_ms, dedupe_key, data_json)
299 SELECT id, 'output_stall', ?2, 'output_stall:' || id, ?3
300 FROM runs
301 WHERE id = ?1 AND state = 'running' AND exited_at_ms IS NULL",
302 params![run_id, now_ms, serde_json::to_string(evidence).unwrap()],
303 )?;
304 Ok(inserted == 1)
305 }
306
307 #[allow(clippy::too_many_arguments)]
317 pub(crate) fn record_stage_verdict(
318 transaction: &Transaction<'_>,
319 run_id: &str,
320 stage: &str,
321 attempt: u32,
322 verdict: &str,
323 confidence: &str,
324 reason: Option<&str>,
325 now_ms: i64,
326 ) -> rusqlite::Result<bool> {
327 let dedupe_key = format!("verdict:{run_id}:{stage}:{attempt}");
328 let data_json = serde_json::json!({
329 "stage": stage,
330 "attempt": attempt,
331 "verdict": verdict,
332 "confidence": confidence,
333 "reason": reason,
334 })
335 .to_string();
336 let inserted = transaction.execute(
337 "INSERT OR IGNORE INTO run_evidence
338 (run_id, kind, observed_at_ms, dedupe_key, data_json)
339 VALUES (?1, 'stage_verdict', ?2, ?3, ?4)",
340 params![run_id, now_ms, dedupe_key, data_json],
341 )?;
342 Ok(inserted == 1)
343 }
344
345 #[allow(clippy::too_many_arguments)]
354 pub(crate) fn record_panel_report(
355 transaction: &Transaction<'_>,
356 run_id: &str,
357 report: &PanelReportRecord<'_>,
358 now_ms: i64,
359 ) -> rusqlite::Result<bool> {
360 let dedupe_key = format!(
361 "panel:{run_id}:{}:{}:{}",
362 report.stage_index, report.attempt, report.reviewer_index
363 );
364 let data_json = serde_json::json!({
365 "stage": report.stage,
366 "stage_index": report.stage_index,
367 "attempt": report.attempt,
368 "reviewer": report.reviewer_index,
369 "verdict": report.verdict,
370 "confidence": report.confidence,
371 "reason": report.reason,
372 })
373 .to_string();
374 let inserted = transaction.execute(
375 "INSERT OR IGNORE INTO run_evidence
376 (run_id, kind, observed_at_ms, dedupe_key, data_json)
377 VALUES (?1, 'panel_report', ?2, ?3, ?4)",
378 params![run_id, now_ms, dedupe_key, data_json],
379 )?;
380 Ok(inserted == 1)
381 }
382
383 pub(crate) fn record_external_merge(
384 transaction: &Transaction<'_>,
385 run_id: &str,
386 data_json: &str,
387 now_ms: i64,
388 ) -> rusqlite::Result<bool> {
389 let inserted = transaction.execute(
390 "INSERT OR IGNORE INTO run_evidence
391 (run_id, kind, observed_at_ms, dedupe_key, data_json)
392 VALUES (?1, 'external_merge_observed', ?2, 'external_merge:' || ?1, ?3)",
393 params![run_id, now_ms, data_json],
394 )?;
395 Ok(inserted == 1)
396 }
397}
398
399fn stage_log(connection: &Connection, run_id: &str) -> rusqlite::Result<Vec<StageRecord>> {
402 let mut statement = connection.prepare(
403 "SELECT stage_index, stage, attempt, phase, state, started_at_ms, finished_at_ms,
404 exit_code, evidence_json
405 FROM stage_runs WHERE run_id = ?1 ORDER BY seq",
406 )?;
407 statement
408 .query_map(params![run_id], |row| {
409 let evidence = row
410 .get::<_, Option<String>>(8)?
411 .as_deref()
412 .and_then(|value| serde_json::from_str::<serde_json::Value>(value).ok());
413 let field = |name: &str| {
414 evidence
415 .as_ref()
416 .and_then(|value| value[name].as_str())
417 .map(str::to_owned)
418 };
419 let state: Option<String> = row.get(4)?;
420 Ok(StageRecord {
421 stage_index: row.get::<_, i64>(0)? as usize,
422 stage: row.get(1)?,
423 attempt: row.get(2)?,
424 phase: StagePhase::parse(&row.get::<_, String>(3)?),
425 started_at_ms: row.get(5)?,
426 finished_at_ms: row.get(6)?,
427 exit_code: row.get(7)?,
428 output_ref: field("output").unwrap_or_default(),
429 verdict_source: field("verdict_source")
432 .or_else(|| state.is_some().then(|| "exit_code".to_owned())),
433 reason: field("reason"),
434 state,
435 })
436 })?
437 .collect::<Result<Vec<_>, _>>()
438}
439
440fn cancellation_requested(connection: &Connection, run_id: &str) -> rusqlite::Result<bool> {
441 let found: Option<i64> = connection
442 .query_row(
443 "SELECT 1 FROM run_evidence
444 WHERE run_id = ?1 AND kind = 'cancel_requested'",
445 params![run_id],
446 |row| row.get(0),
447 )
448 .optional()?;
449 Ok(found.is_some())
450}
451
452fn output_stall(
453 connection: &Connection,
454 run_id: &str,
455) -> rusqlite::Result<Option<OutputStallEvidence>> {
456 let data = connection
457 .query_row(
458 "SELECT data_json FROM run_evidence
459 WHERE run_id = ?1 AND kind = 'output_stall'
460 ORDER BY sequence DESC LIMIT 1",
461 params![run_id],
462 |row| row.get::<_, String>(0),
463 )
464 .optional()?;
465 Ok(data.and_then(|data| serde_json::from_str(&data).ok()))
466}
467
468fn commit_evidence_for_project(
469 connection: &Connection,
470 project_id: &str,
471) -> rusqlite::Result<Vec<ProjectCommitEvidence>> {
472 let mut statement = connection.prepare(
473 "SELECT r.id, r.ticket_id, e.data_json
474 FROM run_evidence e
475 JOIN runs r ON r.id = e.run_id
476 JOIN tickets t ON t.id = r.ticket_id
477 WHERE t.project_id = ?1 AND e.kind = 'commits_observed'
478 ORDER BY r.ticket_id, r.created_at_ms, r.id, e.sequence",
479 )?;
480 statement
481 .query_map(params![project_id], |row| {
482 Ok(ProjectCommitEvidence {
483 run_id: row.get(0)?,
484 ticket_id: row.get(1)?,
485 data_json: row.get(2)?,
486 })
487 })?
488 .collect::<Result<Vec<_>, _>>()
489}
490
491fn run_evidence(connection: &Connection, run_id: &str) -> rusqlite::Result<Vec<(String, String)>> {
492 let mut statement = connection
493 .prepare("SELECT kind, data_json FROM run_evidence WHERE run_id = ?1 ORDER BY sequence")?;
494 statement
495 .query_map(params![run_id], |row| Ok((row.get(0)?, row.get(1)?)))?
496 .collect::<Result<Vec<_>, _>>()
497}
498
499fn vendor_error_for_run(connection: &Connection, run_id: &str) -> rusqlite::Result<Option<String>> {
500 connection
501 .query_row(
502 "SELECT data_json FROM run_evidence
503 WHERE run_id = ?1 AND kind = 'vendor_error_classified'
504 ORDER BY sequence DESC LIMIT 1",
505 params![run_id],
506 |row| row.get(0),
507 )
508 .optional()
509}
510
511fn latest_vendor_error_for_ticket(
512 connection: &Connection,
513 ticket_id: &str,
514) -> rusqlite::Result<Option<String>> {
515 connection
516 .query_row(
517 "SELECT e.data_json FROM run_evidence e
518 JOIN runs r ON r.id = e.run_id
519 WHERE r.id = (SELECT latest.id FROM runs latest
520 WHERE latest.ticket_id = ?1
521 ORDER BY latest.created_at_ms DESC, latest.id DESC LIMIT 1)
522 AND e.kind = 'vendor_error_classified'
523 ORDER BY e.sequence DESC LIMIT 1",
524 params![ticket_id],
525 |row| row.get(0),
526 )
527 .optional()
528}
529
530impl RunStore {
531 pub(crate) fn append_stage_rows(
534 &self,
535 run_id: &str,
536 rows: &[StageRecord],
537 ) -> Result<(), StoreError> {
538 self.write(TransactionBehavior::Deferred, |transaction| {
539 tx::append_stage_rows(transaction, run_id, rows)
540 })
541 }
542
543 pub(crate) fn stage_log(&self, run_id: &str) -> Result<Vec<StageRecord>, StoreError> {
544 stage_log(&self.db.lock(), run_id).map_err(StoreError::from)
545 }
546
547 pub(crate) fn record_stage_evidence(
548 &self,
549 run_id: &str,
550 kind: &str,
551 data_json: &str,
552 now_ms: i64,
553 ) -> Result<(), StoreError> {
554 self.write(TransactionBehavior::Deferred, |transaction| {
555 tx::record_stage_evidence(transaction, run_id, kind, data_json, now_ms)
556 })
557 }
558
559 pub(crate) fn clear_stage_process(&self, run_id: &str) -> Result<(), StoreError> {
560 self.write(TransactionBehavior::Deferred, |transaction| {
561 tx::clear_stage_process(transaction, run_id)
562 })
563 }
564
565 pub(crate) fn record_cancel_requested(
566 &self,
567 run_id: &str,
568 now_ms: i64,
569 ) -> Result<(), StoreError> {
570 self.write(TransactionBehavior::Deferred, |transaction| {
571 tx::record_cancel_requested(transaction, run_id, now_ms)
572 })
573 }
574
575 pub(crate) fn cancellation_requested(&self, run_id: &str) -> Result<bool, StoreError> {
576 cancellation_requested(&self.db.lock(), run_id).map_err(StoreError::from)
577 }
578
579 pub(crate) fn record_output_stall(
580 &self,
581 run_id: &str,
582 evidence: &OutputStallEvidence,
583 now_ms: i64,
584 ) -> Result<bool, StoreError> {
585 self.write(TransactionBehavior::Immediate, |transaction| {
586 tx::record_output_stall(transaction, run_id, evidence, now_ms)
587 })
588 }
589
590 pub(crate) fn output_stall(
591 &self,
592 run_id: &str,
593 ) -> Result<Option<OutputStallEvidence>, StoreError> {
594 output_stall(&self.db.lock(), run_id).map_err(StoreError::from)
595 }
596
597 #[allow(clippy::too_many_arguments)]
598 pub(crate) fn record_stage_verdict(
599 &self,
600 run_id: &str,
601 stage: &str,
602 attempt: u32,
603 verdict: &str,
604 confidence: &str,
605 reason: Option<&str>,
606 now_ms: i64,
607 ) -> Result<bool, StoreError> {
608 self.write(TransactionBehavior::Deferred, |transaction| {
609 tx::record_stage_verdict(
610 transaction,
611 run_id,
612 stage,
613 attempt,
614 verdict,
615 confidence,
616 reason,
617 now_ms,
618 )
619 })
620 }
621
622 pub(crate) fn record_panel_report(
623 &self,
624 run_id: &str,
625 report: &PanelReportRecord<'_>,
626 now_ms: i64,
627 ) -> Result<bool, StoreError> {
628 self.write(TransactionBehavior::Deferred, |transaction| {
629 tx::record_panel_report(transaction, run_id, report, now_ms)
630 })
631 }
632
633 pub(crate) fn commit_evidence_for_project(
634 &self,
635 project_id: &str,
636 ) -> Result<Vec<ProjectCommitEvidence>, StoreError> {
637 commit_evidence_for_project(&self.db.lock(), project_id).map_err(StoreError::from)
638 }
639
640 pub(crate) fn run_evidence(&self, run_id: &str) -> Result<Vec<(String, String)>, StoreError> {
641 run_evidence(&self.db.lock(), run_id).map_err(StoreError::from)
642 }
643
644 pub(crate) fn vendor_error_for_run(
645 &self,
646 run_id: &str,
647 ) -> Result<Option<crate::vendor_error::VendorErrorMatch>, StoreError> {
648 let data = vendor_error_for_run(&self.db.lock(), run_id)?;
649 Ok(data.and_then(|data| serde_json::from_str(&data).ok()))
650 }
651
652 pub(crate) fn latest_vendor_error_for_ticket(
653 &self,
654 ticket_id: &str,
655 ) -> Result<Option<crate::vendor_error::VendorErrorMatch>, StoreError> {
656 let data = latest_vendor_error_for_ticket(&self.db.lock(), ticket_id)?;
657 Ok(data.and_then(|data| serde_json::from_str(&data).ok()))
658 }
659}
660
661#[cfg(test)]
662mod tests {
663 use std::path::Path;
664
665 use tempfile::tempdir;
666
667 use crate::db::{Db, LEGACY_STAGE_TABLE, REVERT_TRIGGER_RENAME, StoreError};
668 use crate::outcome::Outcome;
669 use crate::run_store::test_support::{claim_run, open_seeded, settle_run};
670 use crate::run_store::{
671 Exit, ExitClaim, ExitDenial, RunExit, RunStart, RunState, RunStore, StagePhase,
672 StageRecord, Start,
673 };
674 use crate::vendor_error::{VendorErrorClass, VendorErrorMatch};
675
676 fn stage_row(
677 stage_index: usize,
678 stage: &str,
679 attempt: u32,
680 phase: StagePhase,
681 state: Option<&str>,
682 ) -> StageRecord {
683 StageRecord {
684 stage_index,
685 stage: stage.to_owned(),
686 attempt,
687 phase,
688 state: state.map(str::to_owned),
689 started_at_ms: 3_000,
690 finished_at_ms: 3_100,
691 exit_code: Some(0),
692 output_ref: "runs/R1/output.ndjson".into(),
693 verdict_source: state.map(|_| "exit_code".to_owned()),
694 reason: None,
695 }
696 }
697
698 fn log_shape(store: &RunStore) -> Vec<(usize, String, u32, StagePhase, Option<String>)> {
701 store
702 .stage_log("R1")
703 .unwrap()
704 .into_iter()
705 .map(|row| {
706 (
707 row.stage_index,
708 row.stage,
709 row.attempt,
710 row.phase,
711 row.state,
712 )
713 })
714 .collect()
715 }
716
717 fn reopen(path: &Path) -> RunStore {
718 RunStore::from_db(Db::open(path, 4_000).unwrap())
719 }
720
721 fn running_r1(store: &RunStore) {
722 claim_run(store, "R1", "{}", "{}", 2_000);
723 assert_eq!(
724 store.begin("R1", "branch", "/worktree", 2_050).unwrap(),
725 Start::Granted
726 );
727 assert_eq!(
728 store
729 .start(
730 &RunStart {
731 run_id: "R1",
732 branch: "branch",
733 worktree_path: "/worktree",
734 pid: 123,
735 pid_start_time: Some(456),
736 process_group_id: 123,
737 worker_token: "token",
738 worker_socket_path: "/runtime/R1.sock",
739 },
740 2_100,
741 )
742 .unwrap(),
743 Start::Granted
744 );
745 }
746
747 #[test]
748 fn agent_exit_and_stage_results_are_checkpointed_idempotently() {
749 let directory = tempdir().unwrap();
750 let store = open_seeded(&directory.path().join("sloop.db"));
751 running_r1(&store);
752
753 assert_eq!(
754 store
755 .record_agent_exit(
756 "R1",
757 1,
758 Some(0),
759 true,
760 r#"{"count":1,"oids":["abc"]}"#,
761 None,
762 None,
763 2_200,
764 )
765 .unwrap(),
766 ExitClaim::Claimed
767 );
768 store
769 .record_stage_evidence(
770 "R1",
771 "test_result",
772 r#"{"passed":true,"exit_code":0}"#,
773 2_300,
774 )
775 .unwrap();
776 store
777 .record_stage_evidence(
778 "R1",
779 "test_result",
780 r#"{"passed":true,"exit_code":0}"#,
781 2_400,
782 )
783 .unwrap();
784
785 let run = store.run("R1").unwrap().unwrap();
786 assert_eq!(run.state, "driving");
787 assert_eq!(run.exit_code, Some(0));
788 assert_eq!(
789 store.recoverable_runs().unwrap()[0].state,
790 RunState::Driving
791 );
792 let evidence = store.run_evidence("R1").unwrap();
793 assert_eq!(
794 evidence
795 .iter()
796 .filter(|(kind, _)| kind == "test_result")
797 .count(),
798 1
799 );
800 }
801
802 #[test]
803 fn agent_exit_checkpoint_is_an_exclusive_ownership_handoff() {
804 let directory = tempdir().unwrap();
805 let store = open_seeded(&directory.path().join("sloop.db"));
806 running_r1(&store);
807
808 let exit = RunExit {
809 run_id: "R1",
810 attempt: 1,
811 exit_code: Some(0),
812 capture_complete: true,
813 commits_json: r#"{"oids":["abc"]}"#,
814 vendor_error: None,
815 cooldown_until_ms: None,
816 };
817 assert_eq!(store.record_exit(&exit, 2_200).unwrap(), Exit::Granted);
818 assert_eq!(store.run("R1").unwrap().unwrap().state, "driving");
819 let evidence = store.run_evidence("R1").unwrap();
820 assert!(evidence.iter().any(|(kind, _)| kind == "exit_classified"));
821 assert!(evidence.iter().any(|(kind, _)| kind == "commits_observed"));
822
823 assert_eq!(
824 store.record_exit(&exit, 2_300).unwrap(),
825 Exit::Denied(ExitDenial::AlreadyClaimed {
826 state: "driving".into(),
827 })
828 );
829 let run = store.run("R1").unwrap().unwrap();
830 assert_eq!(run.state, "driving");
831 assert_eq!(run.exit_code, Some(0));
832 assert_eq!(store.run_evidence("R1").unwrap(), evidence);
833 }
834
835 #[test]
836 fn agent_exit_checkpoint_reports_terminal_and_missing_runs() {
837 let directory = tempdir().unwrap();
838 let store = open_seeded(&directory.path().join("sloop.db"));
839 running_r1(&store);
840 settle_run(&store, "R1", Some(0), Outcome::Merged, &[], None, 2_200);
841
842 let claim = store
843 .record_agent_exit(
844 "R1",
845 1,
846 Some(0),
847 true,
848 r#"{"count":0,"oids":[]}"#,
849 None,
850 None,
851 2_300,
852 )
853 .unwrap();
854 assert_eq!(
855 claim,
856 ExitClaim::AlreadyClaimed {
857 state: "merged".into()
858 }
859 );
860 assert_eq!(store.run("R1").unwrap().unwrap().state, "merged");
861
862 let missing = store.record_agent_exit(
863 "R9",
864 1,
865 Some(0),
866 true,
867 r#"{"count":0,"oids":[]}"#,
868 None,
869 None,
870 2_300,
871 );
872 assert!(matches!(missing, Err(StoreError::RunNotFound { .. })));
873 }
874
875 #[test]
879 fn the_stage_log_reads_back_in_append_order() {
880 let directory = tempdir().unwrap();
881 let store = open_seeded(&directory.path().join("sloop.db"));
882 running_r1(&store);
883
884 store
885 .append_stage_rows(
886 "R1",
887 &[stage_row(0, "build", 1, StagePhase::Action, Some("passed"))],
888 )
889 .unwrap();
890 store
893 .append_stage_rows(
894 "R1",
895 &[
896 stage_row(1, "verify", 1, StagePhase::Action, None),
897 stage_row(1, "verify", 1, StagePhase::Check, Some("failed")),
898 ],
899 )
900 .unwrap();
901 store
902 .append_stage_rows(
903 "R1",
904 &[stage_row(0, "build", 2, StagePhase::Action, Some("passed"))],
905 )
906 .unwrap();
907
908 assert_eq!(
909 log_shape(&store),
910 [
911 (
912 0,
913 "build".into(),
914 1,
915 StagePhase::Action,
916 Some("passed".into())
917 ),
918 (1, "verify".into(), 1, StagePhase::Action, None),
919 (
920 1,
921 "verify".into(),
922 1,
923 StagePhase::Check,
924 Some("failed".into())
925 ),
926 (
927 0,
928 "build".into(),
929 2,
930 StagePhase::Action,
931 Some("passed".into())
932 ),
933 ]
934 );
935 let unresolved = &store.stage_log("R1").unwrap()[1];
936 assert_eq!(unresolved.verdict_source, None);
937 assert_eq!(unresolved.output_ref, "runs/R1/output.ndjson");
938 }
939
940 #[test]
944 fn rewriting_a_stage_row_keeps_the_position_it_first_took() {
945 let directory = tempdir().unwrap();
946 let store = open_seeded(&directory.path().join("sloop.db"));
947 running_r1(&store);
948
949 store
950 .append_stage_rows(
951 "R1",
952 &[
953 stage_row(0, "build", 1, StagePhase::Action, Some("passed")),
954 stage_row(1, "test", 1, StagePhase::Action, Some("failed")),
955 ],
956 )
957 .unwrap();
958 store
959 .append_stage_rows(
960 "R1",
961 &[stage_row(0, "build", 1, StagePhase::Action, Some("failed"))],
962 )
963 .unwrap();
964
965 assert_eq!(
966 log_shape(&store),
967 [
968 (
969 0,
970 "build".into(),
971 1,
972 StagePhase::Action,
973 Some("failed".into())
974 ),
975 (
976 1,
977 "test".into(),
978 1,
979 StagePhase::Action,
980 Some("failed".into())
981 ),
982 ]
983 );
984 }
985
986 #[test]
990 fn version_thirteen_backfills_the_stage_log_in_walk_order() {
991 let directory = tempdir().unwrap();
992 let path = directory.path().join("sloop.db");
993 let store = open_seeded(&path);
994 running_r1(&store);
995 drop(store);
996
997 let connection = rusqlite::Connection::open(&path).unwrap();
998 connection
999 .execute_batch(&format!(
1000 "DROP TABLE stage_runs;
1001 CREATE TABLE {LEGACY_STAGE_TABLE} (
1002 run_id TEXT NOT NULL REFERENCES runs(id),
1003 stage_index INTEGER NOT NULL,
1004 stage TEXT NOT NULL,
1005 state TEXT NOT NULL,
1006 attempt INTEGER NOT NULL DEFAULT 1,
1007 started_at_ms INTEGER,
1008 finished_at_ms INTEGER,
1009 exit_code INTEGER,
1010 evidence_json TEXT,
1011 PRIMARY KEY (run_id, stage_index, attempt)
1012 );
1013 INSERT INTO {LEGACY_STAGE_TABLE}
1014 (run_id, stage_index, stage, state, attempt, started_at_ms,
1015 finished_at_ms, exit_code, evidence_json)
1016 VALUES
1017 ('R1', 1, 'test', 'passed', 1, 3000, 3100, 0, NULL),
1018 ('R1', 0, 'build', 'passed', 1, 2900, 3000, 0,
1019 '{{\"output\":\"runs/R1/output.ndjson\",\"verdict_source\":\"reported\"}}');
1020 PRAGMA user_version = 13;"
1021 ))
1022 .unwrap();
1023 connection.execute_batch(REVERT_TRIGGER_RENAME).unwrap();
1024 drop(connection);
1025
1026 let store = reopen(&path);
1027 assert_eq!(
1028 log_shape(&store),
1029 [
1030 (
1031 0,
1032 "build".into(),
1033 1,
1034 StagePhase::Action,
1035 Some("passed".into())
1036 ),
1037 (
1038 1,
1039 "test".into(),
1040 1,
1041 StagePhase::Action,
1042 Some("passed".into())
1043 ),
1044 ]
1045 );
1046 let migrated = store.stage_log("R1").unwrap();
1047 assert_eq!(migrated[0].verdict_source.as_deref(), Some("reported"));
1048 assert_eq!(migrated[1].verdict_source.as_deref(), Some("exit_code"));
1051
1052 store
1055 .append_stage_rows(
1056 "R1",
1057 &[stage_row(2, "merge", 1, StagePhase::Action, Some("passed"))],
1058 )
1059 .unwrap();
1060 assert_eq!(
1061 store
1062 .db()
1063 .lock()
1064 .query_row(
1065 "SELECT seq FROM stage_runs WHERE run_id = 'R1' AND stage = 'merge'",
1066 [],
1067 |row| row.get::<_, i64>(0)
1068 )
1069 .unwrap(),
1070 3
1071 );
1072 }
1073
1074 #[test]
1075 fn run_store_deserializes_vendor_errors_and_returns_typed_project_evidence() {
1076 let directory = tempdir().unwrap();
1077 let store = open_seeded(&directory.path().join("sloop.db"));
1078 running_r1(&store);
1079 let vendor_error = VendorErrorMatch {
1080 class: VendorErrorClass::RateLimited,
1081 vendor: "claude".into(),
1082 rule_id: "capacity".into(),
1083 diagnostic: "try later".into(),
1084 };
1085
1086 store
1087 .record_agent_exit(
1088 "R1",
1089 1,
1090 Some(1),
1091 true,
1092 r#"{"oids":["abc"]}"#,
1093 Some(&vendor_error),
1094 Some(9_000),
1095 2_200,
1096 )
1097 .unwrap();
1098
1099 assert_eq!(
1100 store.vendor_error_for_run("R1").unwrap(),
1101 Some(vendor_error.clone())
1102 );
1103 assert_eq!(
1104 store.latest_vendor_error_for_ticket("T1").unwrap(),
1105 Some(vendor_error)
1106 );
1107 let records = store.commit_evidence_for_project("default").unwrap();
1108 assert_eq!(records.len(), 1);
1109 assert_eq!(records[0].run_id, "R1");
1110 assert_eq!(records[0].ticket_id, "T1");
1111 assert_eq!(records[0].data_json, r#"{"oids":["abc"]}"#);
1112 }
1113}