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