1use std::{
4 fs,
5 io::{self, Write},
6 path::{Path, PathBuf},
7 process::{Command, ExitCode, Stdio},
8 time::{SystemTime, UNIX_EPOCH},
9};
10
11use anyhow::Result;
12use serde::{Deserialize, Serialize};
13use thiserror::Error;
14
15use crate::{
16 claim::{Claim, EvidenceRef},
17 cli::{self, Agent, ReviewScope, ReviewerHarness},
18 config::{self, Effort},
19 ledger::{
20 LedgerEntry, LedgerStore, MemorySkillClassification, ReviewerConfig, StructuredFinding,
21 Verdict,
22 },
23 provenance::{self, EntireCheckpointRef},
24 surface,
25 time::unix_now,
26};
27
28pub const REVIEW_QUEUE_FILE: &str = "review-queue.jsonl";
29pub const REVIEW_RUNS_DIR: &str = "runs";
30const MAX_INLINE_DIFF_FILES: usize = 2;
31const MAX_INLINE_DIFF_BYTES: usize = 256 * 1024;
32const MAX_UNTRACKED_FILE_BYTES: u64 = 16 * 1024;
33
34#[derive(Clone, Debug, Eq, PartialEq)]
35pub struct ReviewRequest {
36 pub watched_agent: Agent,
37 pub watched_model: String,
38 pub reviewer_harness: ReviewerHarness,
39 pub reviewer_model: String,
40 pub reviewer_effort: Effort,
41 pub allow_same_model: bool,
42 pub prompt: String,
43}
44
45impl ReviewRequest {
46 pub fn new(
47 watched_agent: Agent,
48 watched_model: impl Into<String>,
49 reviewer_harness: ReviewerHarness,
50 reviewer_model: impl Into<String>,
51 allow_same_model: bool,
52 prompt: impl Into<String>,
53 ) -> Self {
54 Self {
55 watched_agent,
56 watched_model: watched_model.into(),
57 reviewer_harness,
58 reviewer_model: reviewer_model.into(),
59 reviewer_effort: Effort::highest(),
60 allow_same_model,
61 prompt: prompt.into(),
62 }
63 }
64
65 pub fn with_effort(mut self, effort: Effort) -> Self {
66 self.reviewer_effort = effort;
67 self
68 }
69}
70
71#[derive(Clone, Debug, Eq, PartialEq)]
74pub struct ReviewSelection {
75 pub watched_agent: Agent,
76 pub watched_model: String,
77 pub reviewer_harness: ReviewerHarness,
78 pub reviewer_model: String,
79 pub reviewer_effort: Effort,
80 pub allow_same_model: bool,
81 pub strict: Option<StrictReviewConfig>,
82}
83
84impl ReviewSelection {
85 #[allow(clippy::too_many_arguments)]
88 pub fn resolve(
89 watched_agent: Option<Agent>,
90 watched_model: Option<String>,
91 reviewer_harness: Option<ReviewerHarness>,
92 reviewer_model: Option<String>,
93 reviewer_effort: Option<Effort>,
94 allow_same_model: bool,
95 config: &config::TruthMirrorConfig,
96 ) -> Result<Self, ReviewerError> {
97 let watched_agent = match watched_agent {
98 Some(agent) => agent,
99 None => agent_from_slug(&config.default_writer)?,
100 };
101 let writer_slug = surface::agent_slug(watched_agent);
102 let pair = config.pair_for(writer_slug);
103
104 let harness_from_cli = reviewer_harness.is_some();
105 let reviewer_harness = match reviewer_harness {
106 Some(harness) => harness,
107 None => {
108 let slug = pair
109 .map(|pair| pair.reviewer.harness.as_str())
110 .ok_or_else(|| ReviewerError::NoPairForWriter {
111 writer: writer_slug.to_owned(),
112 })?;
113 harness_from_slug(slug)?
114 }
115 };
116 let reviewer_model = match reviewer_model {
117 Some(model) => model,
118 None => {
119 let pair = pair.ok_or_else(|| ReviewerError::NoPairForWriter {
120 writer: writer_slug.to_owned(),
121 })?;
122 if harness_from_cli
125 && !pair
126 .reviewer
127 .harness
128 .eq_ignore_ascii_case(harness_slug(reviewer_harness))
129 {
130 return Err(ReviewerError::OverrideNeedsModel {
131 role: "reviewer".to_owned(),
132 harness: harness_slug(reviewer_harness).to_owned(),
133 });
134 }
135 pair.reviewer.model.clone()
136 }
137 };
138 let reviewer_effort = reviewer_effort
139 .or_else(|| pair.map(|pair| pair.reviewer.effort))
140 .unwrap_or_else(Effort::highest);
141
142 Ok(Self {
143 watched_agent,
144 watched_model: watched_model.unwrap_or_default(),
145 reviewer_harness,
146 reviewer_model,
147 reviewer_effort,
148 allow_same_model: allow_same_model || config.allow_same_model,
150 strict: None,
151 })
152 }
153
154 pub fn resolve_arbiter(
157 watched_agent: Agent,
158 arbiter_harness: Option<ReviewerHarness>,
159 arbiter_model: Option<String>,
160 arbiter_effort: Option<Effort>,
161 config: &config::TruthMirrorConfig,
162 ) -> Result<StrictReviewConfig, ReviewerError> {
163 let pair_arbiter = config
164 .pair_for(surface::agent_slug(watched_agent))
165 .and_then(|pair| pair.arbiter.clone());
166
167 let harness_from_cli = arbiter_harness.is_some();
168 let harness = match arbiter_harness {
169 Some(harness) => harness,
170 None => {
171 let slug = pair_arbiter
172 .as_ref()
173 .map(|arbiter| arbiter.harness.as_str())
174 .ok_or(ReviewerError::MissingArbiter)?;
175 harness_from_slug(slug)?
176 }
177 };
178 let model = match arbiter_model {
179 Some(model) => model,
180 None => {
181 let arbiter = pair_arbiter.as_ref().ok_or(ReviewerError::MissingArbiter)?;
182 if harness_from_cli && !arbiter.harness.eq_ignore_ascii_case(harness_slug(harness))
183 {
184 return Err(ReviewerError::OverrideNeedsModel {
185 role: "arbiter".to_owned(),
186 harness: harness_slug(harness).to_owned(),
187 });
188 }
189 arbiter.model.clone()
190 }
191 };
192 let effort = arbiter_effort
193 .or_else(|| pair_arbiter.as_ref().map(|arbiter| arbiter.effort))
194 .unwrap_or_else(Effort::highest);
195
196 Ok(StrictReviewConfig {
197 arbiter_harness: harness,
198 arbiter_model: model,
199 arbiter_effort: effort,
200 })
201 }
202
203 fn request_for(&self, prompt: String) -> ReviewRequest {
204 ReviewRequest::new(
205 self.watched_agent,
206 self.watched_model.clone(),
207 self.reviewer_harness,
208 self.reviewer_model.clone(),
209 self.allow_same_model,
210 prompt,
211 )
212 .with_effort(self.reviewer_effort)
213 }
214}
215
216#[derive(Clone, Debug, Eq, PartialEq)]
217pub struct ReviewPlan {
218 pub watched_agent: Agent,
219 pub watched_model: String,
220 pub reviewer_harness: ReviewerHarness,
221 pub reviewer_model: String,
222 pub allow_same_model: bool,
223 pub invocation: InvocationPlan,
224}
225
226impl ReviewPlan {
227 pub fn build(request: ReviewRequest) -> Result<Self, ReviewerError> {
228 validate_model_present("reviewer", &request.reviewer_model)?;
229
230 if request.watched_model.trim().is_empty() {
233 if !request.allow_same_model {
234 eprintln!(
237 "{} warning: writer model is unknown; model opposition is only pair-based \
238 for this review. Pass --watched-model to enforce per-model opposition; \
239 truth-mirror has no persistent writer-model config key yet.",
240 crate::messages::diagnostic_prefix()
241 );
242 }
243 } else if !request.allow_same_model
244 && normalized_model(&request.watched_model) == normalized_model(&request.reviewer_model)
245 {
246 return Err(ReviewerError::SameModelWithoutWaiver {
247 watched_model: request.watched_model,
248 reviewer_model: request.reviewer_model,
249 });
250 }
251
252 let invocation = InvocationPlan::for_harness(
253 request.reviewer_harness,
254 &request.reviewer_model,
255 request.reviewer_effort,
256 )?;
257
258 Ok(Self {
259 watched_agent: request.watched_agent,
260 watched_model: request.watched_model,
261 reviewer_harness: request.reviewer_harness,
262 reviewer_model: request.reviewer_model,
263 allow_same_model: request.allow_same_model,
264 invocation,
265 })
266 }
267
268 pub fn run_with<R: ProcessRunner>(
269 &self,
270 prompt: &str,
271 runner: &R,
272 ) -> Result<ProcessOutput, ReviewerError> {
273 runner.run(&self.invocation, prompt)
274 }
275
276 fn reviewer_config(&self) -> ReviewerConfig {
277 ReviewerConfig::new(
278 harness_slug(self.reviewer_harness),
279 self.reviewer_model.clone(),
280 self.allow_same_model,
281 )
282 }
283}
284
285#[derive(Clone, Debug, Eq, PartialEq)]
286pub struct InvocationPlan {
287 pub program: String,
288 pub args: Vec<String>,
289 pub prompt_delivery: PromptDelivery,
290}
291
292impl InvocationPlan {
293 pub fn for_harness(
294 harness: ReviewerHarness,
295 model: &str,
296 effort: Effort,
297 ) -> Result<Self, ReviewerError> {
298 validate_model_present("reviewer", model)?;
299 let model = model.trim();
300 let e = effort.as_str();
301
302 let plan = match harness {
306 ReviewerHarness::Claude => Self {
307 program: "claude".to_owned(),
308 args: vec![
309 "--print".to_owned(),
310 "--model".to_owned(),
311 model.to_owned(),
312 "--effort".to_owned(),
313 effort.claude_value().to_owned(),
315 ],
316 prompt_delivery: PromptDelivery::Stdin,
317 },
318 ReviewerHarness::Codex => Self {
319 program: "codex".to_owned(),
320 args: vec![
321 "exec".to_owned(),
322 "-m".to_owned(),
323 model.to_owned(),
324 "-c".to_owned(),
325 format!("model_reasoning_effort={e}"),
326 ],
327 prompt_delivery: PromptDelivery::PositionalArgument,
328 },
329 ReviewerHarness::Pi => Self {
330 program: "pi".to_owned(),
331 args: vec![
332 "--model".to_owned(),
333 model.to_owned(),
334 "--thinking".to_owned(),
335 e.to_owned(),
336 "--tools".to_owned(),
339 "read,grep,find,ls".to_owned(),
340 "-p".to_owned(),
341 ],
342 prompt_delivery: PromptDelivery::Stdin,
343 },
344 ReviewerHarness::Gemini => Self {
345 program: "gemini".to_owned(),
346 args: vec!["-m".to_owned(), model.to_owned()],
347 prompt_delivery: PromptDelivery::FlagValue("-p".to_owned()),
348 },
349 ReviewerHarness::Opencode => Self {
350 program: "opencode".to_owned(),
351 args: vec!["run".to_owned(), "--model".to_owned(), model.to_owned()],
352 prompt_delivery: PromptDelivery::PositionalArgument,
353 },
354 ReviewerHarness::Custom => return Err(ReviewerError::UnsupportedCustomHarness),
355 };
356
357 Ok(plan)
358 }
359
360 pub fn args_for_prompt(&self, prompt: &str) -> Vec<String> {
361 let mut args = self.args.clone();
362 match &self.prompt_delivery {
363 PromptDelivery::Stdin => {}
364 PromptDelivery::PositionalArgument => args.push(prompt.to_owned()),
365 PromptDelivery::FlagValue(flag) => {
366 args.push(flag.clone());
367 args.push(prompt.to_owned());
368 }
369 }
370 args
371 }
372}
373
374#[derive(Clone, Debug, Eq, PartialEq)]
375pub enum PromptDelivery {
376 Stdin,
377 PositionalArgument,
378 FlagValue(String),
379}
380
381#[derive(Clone, Debug, Eq, PartialEq)]
382pub struct ProcessOutput {
383 pub status_code: Option<i32>,
384 pub stdout: String,
385 pub stderr: String,
386}
387
388pub trait ProcessRunner {
389 fn run(
390 &self,
391 invocation: &InvocationPlan,
392 prompt: &str,
393 ) -> Result<ProcessOutput, ReviewerError>;
394}
395
396#[derive(Clone, Copy, Debug, Default)]
397pub struct StdProcessRunner;
398
399impl ProcessRunner for StdProcessRunner {
400 fn run(
401 &self,
402 invocation: &InvocationPlan,
403 prompt: &str,
404 ) -> Result<ProcessOutput, ReviewerError> {
405 let mut command = Command::new(&invocation.program);
406 command.args(invocation.args_for_prompt(prompt));
407 command.stdout(Stdio::piped()).stderr(Stdio::piped());
408
409 if invocation.prompt_delivery == PromptDelivery::Stdin {
410 command.stdin(Stdio::piped());
411 }
412
413 let mut child = command.spawn().map_err(ReviewerError::Spawn)?;
414 if invocation.prompt_delivery == PromptDelivery::Stdin {
415 let mut stdin = child.stdin.take().ok_or(ReviewerError::MissingStdinPipe)?;
416 stdin
417 .write_all(prompt.as_bytes())
418 .map_err(ReviewerError::WritePrompt)?;
419 }
420
421 let output = child.wait_with_output().map_err(ReviewerError::Wait)?;
422 Ok(ProcessOutput {
423 status_code: output.status.code(),
424 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
425 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
426 })
427 }
428}
429
430#[derive(Clone, Debug, Eq, PartialEq)]
431pub struct ReviewJob {
432 pub commit_sha: String,
433 pub claim: Claim,
434 pub diff: String,
435 pub context: String,
437 pub request: ReviewRequest,
438 pub strict: Option<StrictReviewConfig>,
439 pub petition: Option<PetitionContext>,
446}
447
448#[derive(Clone, Debug, Eq, PartialEq)]
450pub struct PetitionContext {
451 pub original_sha: String,
452 pub fix_sha: String,
453 pub original_claim: String,
454 pub original_summary: String,
455 pub original_findings: Vec<String>,
456 pub original_structured_findings: Vec<StructuredFinding>,
457 pub original_reviewer_model: String,
458 pub attempts_so_far: u32,
459}
460
461#[derive(Clone, Debug, Eq, PartialEq)]
462pub struct StrictReviewConfig {
463 pub arbiter_harness: ReviewerHarness,
464 pub arbiter_model: String,
465 pub arbiter_effort: Effort,
466}
467
468#[derive(Clone, Debug, Eq, PartialEq)]
469pub struct ReviewExecution {
470 pub entries: Vec<LedgerEntry>,
471}
472
473fn materialize_petition_prompt(mut job: ReviewJob) -> ReviewJob {
478 let Some(petition) = &job.petition else {
479 return job;
480 };
481 job.request.prompt = petition_prompt(petition, &job.claim, &job.diff, &job.context);
482 job
483}
484
485pub fn execute_review_job<R: ProcessRunner>(
486 job: ReviewJob,
487 runner: &R,
488 store: &LedgerStore,
489) -> Result<ReviewExecution, ReviewerError> {
490 let job = materialize_petition_prompt(job);
494 let first_plan = ReviewPlan::build(job.request.clone())?;
495 let first_output = first_plan.run_with(&job.request.prompt, runner)?;
496 ensure_process_success(&first_output)?;
497 let first_verdict = ParsedVerdict::parse(&first_output.stdout)?;
498 let first_entry = entry_from_verdict(&job, &first_plan, &first_verdict);
499 store.append_entry(&first_entry)?;
500
501 let mut entries = vec![first_entry];
502 if let Some(strict) = &job.strict
503 && first_verdict.verdict == Verdict::Pass
504 && first_verdict.findings.is_empty()
505 {
506 validate_strict_arbiter(&job.request, strict)?;
507 let strict_prompt = strict_second_pass_prompt(&job, &first_output.stdout);
508 let strict_request = ReviewRequest::new(
509 job.request.watched_agent,
510 job.request.watched_model.clone(),
511 strict.arbiter_harness,
512 strict.arbiter_model.clone(),
513 false,
514 strict_prompt,
515 )
516 .with_effort(strict.arbiter_effort);
517 let strict_plan = ReviewPlan::build(strict_request.clone())?;
518 let strict_output = strict_plan.run_with(&strict_request.prompt, runner)?;
519 ensure_process_success(&strict_output)?;
520 let strict_verdict = ParsedVerdict::parse(&strict_output.stdout)?;
521 let strict_entry = entry_from_verdict(&job, &strict_plan, &strict_verdict);
522 store.append_entry(&strict_entry)?;
523 entries.push(strict_entry);
524 }
525
526 if let Some(last) = entries.last() {
530 apply_petition_transition(&job, last, store)?;
531 }
532
533 Ok(ReviewExecution { entries })
534}
535
536fn apply_petition_transition(
541 job: &ReviewJob,
542 entry: &LedgerEntry,
543 store: &LedgerStore,
544) -> Result<(), ReviewerError> {
545 let Some(petition) = &job.petition else {
546 return Ok(());
547 };
548 let max_attempts = crate::resolve::MAX_PETITION_ATTEMPTS;
549 let next_attempts = entry.petition_attempts;
550 let reason = format!(
551 "petition review of fix {} against rejection {} (attempts {})",
552 petition.fix_sha, petition.original_sha, next_attempts
553 );
554
555 if crate::resolve::petition_accepts(entry.verdict) {
556 store
560 .append_petition_transition(
561 &petition.original_sha,
562 crate::ledger::Disposition::Resolved,
563 crate::ledger::ResolutionKind::Resolved,
564 &reason,
565 next_attempts,
566 )
567 .map_err(ReviewerError::Ledger)?;
568 } else {
569 {
570 if next_attempts >= max_attempts {
572 store
573 .escalate_to_needs_human_with_attempts(
574 &petition.original_sha,
575 &reason,
576 next_attempts,
577 )
578 .map_err(ReviewerError::Ledger)?;
579 } else {
580 store
581 .append_petition_transition(
582 &petition.original_sha,
583 crate::ledger::Disposition::Open,
584 crate::ledger::ResolutionKind::Resolved,
585 &reason,
586 next_attempts,
587 )
588 .map_err(ReviewerError::Ledger)?;
589 }
590 }
591 }
592 Ok(())
593}
594
595fn petition_context_from_ledger(
601 original_sha: &str,
602 fix_sha: &str,
603 store: &LedgerStore,
604) -> Result<PetitionContext, ReviewerError> {
605 let original = store.show(original_sha).map_err(ReviewerError::Ledger)?;
606 let attempts_so_far = store
607 .petition_attempts_for(original_sha)
608 .map_err(ReviewerError::Ledger)?;
609 Ok(PetitionContext {
610 original_sha: original_sha.to_owned(),
611 fix_sha: fix_sha.to_owned(),
612 original_claim: original.claim.clone(),
613 original_summary: original.summary.clone(),
614 original_findings: original.findings.clone(),
615 original_structured_findings: original.structured_findings.clone(),
616 original_reviewer_model: original.reviewer.model.clone(),
617 attempts_so_far,
618 })
619}
620
621#[derive(Clone, Debug, Eq, PartialEq)]
622pub struct ParsedVerdict {
623 pub verdict: Verdict,
624 pub summary: String,
625 pub findings: Vec<String>,
626 pub structured_findings: Vec<StructuredFinding>,
627 pub next_steps: Vec<String>,
628 pub memory_skill_classification: MemorySkillClassification,
629 pub raw: String,
630}
631
632impl ParsedVerdict {
633 pub fn parse(output: &str) -> Result<Self, ReviewerError> {
634 let candidate = extract_verdict_json(output);
641 let parsed: ReviewerJsonOutput =
642 serde_json::from_str(candidate).map_err(|source| ReviewerError::VerdictJson {
643 source,
644 output: output.to_owned(),
645 })?;
646 parsed.validate()?;
647 let findings = parsed
648 .findings
649 .iter()
650 .map(StructuredFinding::display_line)
651 .collect();
652
653 Ok(Self {
654 verdict: parsed.verdict,
655 summary: parsed.summary,
656 findings,
657 structured_findings: parsed.findings,
658 next_steps: parsed.next_steps,
659 memory_skill_classification: parsed.memory_skill,
660 raw: output.to_owned(),
661 })
662 }
663}
664
665fn extract_verdict_json(output: &str) -> &str {
680 fn parses(candidate: &str) -> bool {
681 serde_json::from_str::<serde_json::Value>(candidate).is_ok()
682 }
683 fn json_prefix_len(text: &str) -> Option<usize> {
686 let mut stream = serde_json::Deserializer::from_str(text).into_iter::<serde_json::Value>();
687 match stream.next() {
688 Some(Ok(_)) => Some(stream.byte_offset()),
689 _ => None,
690 }
691 }
692
693 let trimmed = output.trim();
694 if trimmed.starts_with('{') {
695 if parses(trimmed) {
696 return trimmed;
697 }
698 if let Some(end) = json_prefix_len(trimmed) {
699 return trimmed[..end].trim();
700 }
701 }
702
703 let mut search_from = 0;
706 while let Some(open_rel) = output[search_from..].find("```") {
707 let after_open = search_from + open_rel + 3;
708 let body_start = match output[after_open..].find('\n') {
709 Some(newline_rel) => after_open + newline_rel + 1,
710 None => break,
711 };
712 let Some(close_rel) = output[body_start..].find("```") else {
713 break;
714 };
715 let body = output[body_start..body_start + close_rel].trim();
716 if body.starts_with('{') && parses(body) {
717 return body;
718 }
719 search_from = body_start + close_rel + 3;
720 }
721
722 if let Some(first) = output.find('{') {
725 let tail = &output[first..];
726 if let Some(end) = json_prefix_len(tail) {
727 return tail[..end].trim();
728 }
729 }
730
731 trimmed
732}
733
734#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
735struct ReviewerJsonOutput {
736 verdict: Verdict,
737 summary: String,
738 #[serde(default)]
739 findings: Vec<StructuredFinding>,
740 #[serde(default)]
741 next_steps: Vec<String>,
742 memory_skill: MemorySkillClassification,
743}
744
745impl ReviewerJsonOutput {
746 fn validate(&self) -> Result<(), ReviewerError> {
747 if self.summary.trim().is_empty() {
748 return Err(ReviewerError::VerdictSchema {
749 message: "summary must not be empty".to_owned(),
750 });
751 }
752 self.memory_skill
753 .validate_for_verdict(self.verdict)
754 .map_err(|message| ReviewerError::VerdictSchema { message })?;
755
756 for finding in &self.findings {
757 if finding.title.trim().is_empty() {
758 return Err(ReviewerError::VerdictSchema {
759 message: "finding title must not be empty".to_owned(),
760 });
761 }
762 if finding.body.trim().is_empty() {
763 return Err(ReviewerError::VerdictSchema {
764 message: "finding body must not be empty".to_owned(),
765 });
766 }
767 if finding.file.trim().is_empty() {
768 return Err(ReviewerError::VerdictSchema {
769 message: "finding file must not be empty".to_owned(),
770 });
771 }
772 if finding.line_start == 0 || finding.line_end == 0 {
773 return Err(ReviewerError::VerdictSchema {
774 message: "finding lines must be one-based".to_owned(),
775 });
776 }
777 if finding.line_end < finding.line_start {
778 return Err(ReviewerError::VerdictSchema {
779 message: "finding line_end must be greater than or equal to line_start"
780 .to_owned(),
781 });
782 }
783 if finding.confidence > 100 {
784 return Err(ReviewerError::VerdictSchema {
785 message: "finding confidence must be between 0 and 100".to_owned(),
786 });
787 }
788 if finding.recommendation.trim().is_empty() {
789 return Err(ReviewerError::VerdictSchema {
790 message: "finding recommendation must not be empty".to_owned(),
791 });
792 }
793 }
794
795 if self.verdict == Verdict::Pass && !self.findings.is_empty() {
796 return Err(ReviewerError::VerdictSchema {
797 message: "PASS verdict must not include findings".to_owned(),
798 });
799 }
800 if self.verdict == Verdict::Reject && self.findings.is_empty() {
801 return Err(ReviewerError::VerdictSchema {
802 message: "REJECT verdict must include at least one finding".to_owned(),
803 });
804 }
805 if self.verdict == Verdict::Flag && self.findings.is_empty() {
806 return Err(ReviewerError::VerdictSchema {
807 message: "FLAG verdict must include at least one finding (the debt being surfaced, in the findings array)"
808 .to_owned(),
809 });
810 }
811
812 Ok(())
813 }
814}
815
816#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
817#[serde(rename_all = "kebab-case")]
818pub enum ReviewRunStatus {
819 Queued,
820 Running,
821 Completed,
822 Failed,
823 Cancelled,
824}
825
826impl std::fmt::Display for ReviewRunStatus {
827 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
828 match self {
829 Self::Queued => formatter.write_str("queued"),
830 Self::Running => formatter.write_str("running"),
831 Self::Completed => formatter.write_str("completed"),
832 Self::Failed => formatter.write_str("failed"),
833 Self::Cancelled => formatter.write_str("cancelled"),
834 }
835 }
836}
837
838#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
839pub struct ReviewRun {
840 pub id: String,
841 pub commit_sha: String,
842 pub target: String,
843 pub status: ReviewRunStatus,
844 pub phase: String,
845 pub ledger_entries: usize,
846 pub error: Option<String>,
847 #[serde(default)]
852 pub worker_pid: Option<u32>,
853 pub created_at_unix: u64,
854 pub updated_at_unix: u64,
855 pub started_at_unix: Option<u64>,
856 pub completed_at_unix: Option<u64>,
857 #[serde(default, skip_serializing_if = "Option::is_none")]
858 pub entire_checkpoint: Option<EntireCheckpointRef>,
859}
860
861#[derive(Clone, Debug, Default, Eq, PartialEq)]
862pub struct ReviewRunStatusCounts {
863 pub queued: usize,
864 pub running: usize,
865 pub completed: usize,
866 pub failed: usize,
867 pub cancelled: usize,
868 pub skipped_records: usize,
869}
870
871impl ReviewRunStatusCounts {
872 fn add(&mut self, status: ReviewRunStatus) {
873 match status {
874 ReviewRunStatus::Queued => self.queued += 1,
875 ReviewRunStatus::Running => self.running += 1,
876 ReviewRunStatus::Completed => self.completed += 1,
877 ReviewRunStatus::Failed => self.failed += 1,
878 ReviewRunStatus::Cancelled => self.cancelled += 1,
879 }
880 }
881}
882
883#[derive(Deserialize)]
884struct ReviewRunStatusRecord {
885 status: ReviewRunStatus,
886}
887
888impl ReviewRun {
889 #[cfg(test)]
890 fn queued(
891 id: impl Into<String>,
892 commit_sha: impl Into<String>,
893 target: impl Into<String>,
894 ) -> Self {
895 Self::queued_with_provenance(id, commit_sha, target, None)
896 }
897
898 fn queued_with_provenance(
899 id: impl Into<String>,
900 commit_sha: impl Into<String>,
901 target: impl Into<String>,
902 entire_checkpoint: Option<EntireCheckpointRef>,
903 ) -> Self {
904 let timestamp = unix_now();
905 Self {
906 id: id.into(),
907 commit_sha: commit_sha.into(),
908 target: target.into(),
909 status: ReviewRunStatus::Queued,
910 phase: "queued".to_owned(),
911 ledger_entries: 0,
912 error: None,
913 worker_pid: None,
914 created_at_unix: timestamp,
915 updated_at_unix: timestamp,
916 started_at_unix: None,
917 completed_at_unix: None,
918 entire_checkpoint,
919 }
920 }
921
922 fn mark_running(&mut self, phase: impl Into<String>) {
923 let timestamp = unix_now();
924 self.status = ReviewRunStatus::Running;
925 self.phase = phase.into();
926 self.error = None;
927 self.worker_pid = Some(std::process::id());
928 self.updated_at_unix = timestamp;
929 self.started_at_unix = Some(timestamp);
930 self.completed_at_unix = None;
931 }
932
933 fn mark_completed(&mut self, ledger_entries: usize) {
934 let timestamp = unix_now();
935 self.status = ReviewRunStatus::Completed;
936 self.phase = "completed".to_owned();
937 self.ledger_entries = ledger_entries;
938 self.error = None;
939 self.worker_pid = None;
940 self.updated_at_unix = timestamp;
941 self.completed_at_unix = Some(timestamp);
942 }
943
944 fn mark_failed(&mut self, error: impl Into<String>) {
945 let timestamp = unix_now();
946 self.status = ReviewRunStatus::Failed;
947 self.phase = "failed".to_owned();
948 self.error = Some(error.into());
949 self.worker_pid = None;
950 self.updated_at_unix = timestamp;
951 self.completed_at_unix = Some(timestamp);
952 }
953
954 fn mark_cancelled(&mut self) {
955 let timestamp = unix_now();
956 self.status = ReviewRunStatus::Cancelled;
957 self.phase = "cancelled".to_owned();
958 self.error = None;
959 self.worker_pid = None;
960 self.updated_at_unix = timestamp;
961 self.completed_at_unix = Some(timestamp);
962 }
963
964 fn reconcile_liveness(&mut self, is_alive: impl Fn(u32) -> bool) -> bool {
972 if self.status != ReviewRunStatus::Running {
973 return false;
974 }
975 match self.worker_pid {
976 Some(pid) if !is_alive(pid) => {
977 self.mark_failed(stale_worker_reason(pid));
978 true
979 }
980 _ => false,
981 }
982 }
983}
984
985fn stale_worker_reason(pid: u32) -> String {
987 format!("worker process {pid} exited without recording a verdict (stale run)")
988}
989
990fn kill_pid(pid: u32) -> Result<(), ReviewerError> {
993 let status = Command::new("kill")
994 .arg("-KILL")
995 .arg(pid.to_string())
996 .stdout(Stdio::null())
997 .stderr(Stdio::null())
998 .status()
999 .map_err(ReviewerError::KillWorker)?;
1000 if status.success() || !crate::watcher::pid_is_alive(pid) {
1001 Ok(())
1002 } else {
1003 Err(ReviewerError::KillWorkerFailed { pid })
1004 }
1005}
1006
1007#[derive(Clone, Debug)]
1008pub struct ReviewRunStore {
1009 root: PathBuf,
1010}
1011
1012impl ReviewRunStore {
1013 pub fn new(root: impl Into<PathBuf>) -> Self {
1014 Self { root: root.into() }
1015 }
1016
1017 pub fn runs_dir(&self) -> PathBuf {
1018 self.root.join(REVIEW_RUNS_DIR)
1019 }
1020
1021 pub fn path(&self, id: &str) -> PathBuf {
1022 self.runs_dir().join(format!("{id}.json"))
1023 }
1024
1025 pub fn create_queued(
1026 &self,
1027 commit_sha: &str,
1028 target: impl Into<String>,
1029 ) -> Result<ReviewRun, ReviewerError> {
1030 let checkpoint = entire_checkpoint_for_current_repo(commit_sha);
1031 let run = ReviewRun::queued_with_provenance(
1032 generate_run_id(commit_sha),
1033 commit_sha,
1034 target,
1035 checkpoint,
1036 );
1037 self.write(&run)?;
1038 Ok(run)
1039 }
1040
1041 fn ensure_queued(
1042 &self,
1043 run_id: &str,
1044 commit_sha: &str,
1045 target: &str,
1046 ) -> Result<ReviewRun, ReviewerError> {
1047 match self.read(run_id) {
1048 Ok(run) => Ok(run),
1049 Err(ReviewerError::ReviewRunNotFound { .. }) => {
1050 let checkpoint = entire_checkpoint_for_current_repo(commit_sha);
1051 let run = ReviewRun::queued_with_provenance(run_id, commit_sha, target, checkpoint);
1052 self.write(&run)?;
1053 Ok(run)
1054 }
1055 Err(error) => Err(error),
1056 }
1057 }
1058
1059 pub fn read(&self, id: &str) -> Result<ReviewRun, ReviewerError> {
1060 let path = self.path(id);
1061 let contents = fs::read_to_string(&path).map_err(|source| match source.kind() {
1062 io::ErrorKind::NotFound => ReviewerError::ReviewRunNotFound { id: id.to_owned() },
1063 _ => ReviewerError::RunIo(source),
1064 })?;
1065 serde_json::from_str(&contents).map_err(ReviewerError::RunJson)
1066 }
1067
1068 pub fn list(&self) -> Result<Vec<ReviewRun>, ReviewerError> {
1069 let dir = self.runs_dir();
1070 let entries = match fs::read_dir(&dir) {
1071 Ok(entries) => entries,
1072 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
1073 Err(error) => return Err(ReviewerError::RunIo(error)),
1074 };
1075 let mut runs: Vec<ReviewRun> = Vec::new();
1076 for entry in entries {
1077 let entry = entry.map_err(ReviewerError::RunIo)?;
1078 if entry
1079 .path()
1080 .extension()
1081 .is_none_or(|extension| extension != "json")
1082 {
1083 continue;
1084 }
1085 let contents = fs::read_to_string(entry.path()).map_err(ReviewerError::RunIo)?;
1086 runs.push(serde_json::from_str(&contents).map_err(ReviewerError::RunJson)?);
1087 }
1088 runs.sort_by(|left, right| {
1089 right
1090 .updated_at_unix
1091 .cmp(&left.updated_at_unix)
1092 .then_with(|| right.id.cmp(&left.id))
1093 });
1094 Ok(runs)
1095 }
1096
1097 pub fn status_counts(&self) -> Result<ReviewRunStatusCounts, ReviewerError> {
1098 let dir = self.runs_dir();
1099 let entries = match fs::read_dir(&dir) {
1100 Ok(entries) => entries,
1101 Err(error) if error.kind() == io::ErrorKind::NotFound => {
1102 return Ok(ReviewRunStatusCounts::default());
1103 }
1104 Err(error) => return Err(ReviewerError::RunIo(error)),
1105 };
1106 let mut counts = ReviewRunStatusCounts::default();
1107 for entry in entries {
1108 let Ok(entry) = entry else {
1109 counts.skipped_records += 1;
1110 continue;
1111 };
1112 let path = entry.path();
1113 if path.extension().is_none_or(|extension| extension != "json") {
1114 continue;
1115 }
1116 let Ok(contents) = fs::read_to_string(&path) else {
1117 counts.skipped_records += 1;
1118 continue;
1119 };
1120 let Ok(record) = serde_json::from_str::<ReviewRunStatusRecord>(&contents) else {
1121 counts.skipped_records += 1;
1122 continue;
1123 };
1124 counts.add(record.status);
1125 }
1126 Ok(counts)
1127 }
1128
1129 pub fn latest_result(&self) -> Result<ReviewRun, ReviewerError> {
1130 self.list()?
1131 .into_iter()
1132 .find(|run| {
1133 matches!(
1134 run.status,
1135 ReviewRunStatus::Completed
1136 | ReviewRunStatus::Failed
1137 | ReviewRunStatus::Cancelled
1138 )
1139 })
1140 .ok_or(ReviewerError::NoReviewRuns)
1141 }
1142
1143 pub fn mark_running(&self, id: &str, phase: &str) -> Result<ReviewRun, ReviewerError> {
1144 let mut run = self.read(id)?;
1145 run.mark_running(phase);
1146 self.write(&run)?;
1147 Ok(run)
1148 }
1149
1150 pub fn mark_completed(
1151 &self,
1152 id: &str,
1153 ledger_entries: usize,
1154 ) -> Result<ReviewRun, ReviewerError> {
1155 let mut run = self.read(id)?;
1156 run.mark_completed(ledger_entries);
1157 self.write(&run)?;
1158 Ok(run)
1159 }
1160
1161 pub fn mark_failed(
1162 &self,
1163 id: &str,
1164 error: impl Into<String>,
1165 ) -> Result<ReviewRun, ReviewerError> {
1166 let mut run = self.read(id)?;
1167 run.mark_failed(error);
1168 self.write(&run)?;
1169 Ok(run)
1170 }
1171
1172 pub fn cancel_queued(&self, id: &str) -> Result<ReviewRun, ReviewerError> {
1173 let mut run = self.read(id)?;
1174 if run.status != ReviewRunStatus::Queued {
1175 return Err(ReviewerError::CannotCancelReview {
1176 id: id.to_owned(),
1177 status: run.status,
1178 });
1179 }
1180 run.mark_cancelled();
1181 self.write(&run)?;
1182 Ok(run)
1183 }
1184
1185 pub fn read_reconciled(&self, id: &str) -> Result<ReviewRun, ReviewerError> {
1189 let mut run = self.read(id)?;
1190 if run.reconcile_liveness(crate::watcher::pid_is_alive) {
1191 self.write(&run)?;
1192 }
1193 Ok(run)
1194 }
1195
1196 pub fn list_reconciled(&self) -> Result<Vec<ReviewRun>, ReviewerError> {
1199 let mut runs = self.list()?;
1200 for run in &mut runs {
1201 if run.reconcile_liveness(crate::watcher::pid_is_alive) {
1202 self.write(run)?;
1203 }
1204 }
1205 Ok(runs)
1206 }
1207
1208 pub fn cancel(&self, id: &str, force: bool) -> Result<ReviewRun, ReviewerError> {
1218 let mut run = self.read(id)?;
1219 match run.status {
1220 ReviewRunStatus::Queued => run.mark_cancelled(),
1221 ReviewRunStatus::Running => match run.worker_pid {
1222 Some(pid) if crate::watcher::pid_is_alive(pid) => {
1223 if !force {
1224 return Err(ReviewerError::ReviewRunStillAlive {
1225 id: id.to_owned(),
1226 pid,
1227 });
1228 }
1229 kill_pid(pid)?;
1230 run.mark_cancelled();
1231 }
1232 Some(pid) => run.mark_failed(stale_worker_reason(pid)),
1233 None => {
1234 if !force {
1235 return Err(ReviewerError::ReviewRunLivenessUnknown { id: id.to_owned() });
1236 }
1237 run.mark_failed("worker liveness could not be verified; force-cancelled");
1238 }
1239 },
1240 terminal => {
1241 return Err(ReviewerError::CannotCancelReview {
1242 id: id.to_owned(),
1243 status: terminal,
1244 });
1245 }
1246 }
1247 self.write(&run)?;
1248 Ok(run)
1249 }
1250
1251 fn write(&self, run: &ReviewRun) -> Result<(), ReviewerError> {
1252 fs::create_dir_all(self.runs_dir()).map_err(ReviewerError::RunIo)?;
1253 let bytes = serde_json::to_vec_pretty(run).map_err(ReviewerError::RunJson)?;
1254 fs::write(self.path(&run.id), bytes).map_err(ReviewerError::RunIo)
1255 }
1256}
1257
1258fn entire_checkpoint_for_current_repo(commit_sha: &str) -> Option<EntireCheckpointRef> {
1259 let repo_root = current_repo_root().unwrap_or_else(|| PathBuf::from("."));
1260 provenance::entire_checkpoint_for_commit(&repo_root, commit_sha)
1261}
1262
1263fn current_repo_root() -> Option<PathBuf> {
1264 let output = Command::new("git")
1265 .args(["rev-parse", "--show-toplevel"])
1266 .output()
1267 .ok()?;
1268 output
1269 .status
1270 .success()
1271 .then(|| PathBuf::from(String::from_utf8_lossy(&output.stdout).trim()))
1272}
1273
1274#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1275pub struct QueuedReview {
1276 #[serde(default)]
1277 pub run_id: String,
1278 pub commit_sha: String,
1279 pub enqueued_at_unix: u64,
1280 #[serde(default, skip_serializing_if = "Option::is_none")]
1285 pub petition_for: Option<String>,
1286}
1287
1288#[derive(Clone, Debug)]
1289pub struct ReviewQueue {
1290 root: PathBuf,
1291}
1292
1293#[derive(Clone, Debug, Default, Eq, PartialEq)]
1294pub struct ReviewQueueSummary {
1295 pub pending_count: usize,
1296 pub oldest_enqueued_at_unix: Option<u64>,
1297}
1298
1299impl ReviewQueueSummary {
1300 pub fn oldest_age_secs_at(&self, now: u64) -> Option<u64> {
1301 self.oldest_enqueued_at_unix
1302 .map(|oldest| now.saturating_sub(oldest))
1303 }
1304}
1305
1306impl ReviewQueue {
1307 pub fn new(root: impl Into<PathBuf>) -> Self {
1308 Self { root: root.into() }
1309 }
1310
1311 pub fn path(&self) -> PathBuf {
1312 self.root.join(REVIEW_QUEUE_FILE)
1313 }
1314
1315 pub fn enqueue(&self, commit_sha: impl Into<String>) -> Result<QueuedReview, ReviewerError> {
1316 self.enqueue_item(commit_sha.into(), None)
1317 }
1318
1319 pub fn enqueue_petition(
1324 &self,
1325 fix_sha: impl Into<String>,
1326 original_sha: impl Into<String>,
1327 ) -> Result<QueuedReview, ReviewerError> {
1328 self.enqueue_item(fix_sha.into(), Some(original_sha.into()))
1329 }
1330
1331 fn enqueue_item(
1332 &self,
1333 commit_sha: String,
1334 petition_for: Option<String>,
1335 ) -> Result<QueuedReview, ReviewerError> {
1336 fs::create_dir_all(&self.root).map_err(ReviewerError::QueueIo)?;
1337 let target = if petition_for.is_some() {
1338 "petition"
1339 } else {
1340 "commit"
1341 };
1342 let run = ReviewRunStore::new(&self.root).create_queued(&commit_sha, target)?;
1343 let item = QueuedReview {
1344 run_id: run.id,
1345 commit_sha,
1346 enqueued_at_unix: unix_now(),
1347 petition_for,
1348 };
1349 let mut file = fs::OpenOptions::new()
1350 .create(true)
1351 .append(true)
1352 .open(self.path())
1353 .map_err(ReviewerError::QueueIo)?;
1354 serde_json::to_writer(&mut file, &item).map_err(ReviewerError::QueueJson)?;
1355 writeln!(file).map_err(ReviewerError::QueueIo)?;
1356 Ok(item)
1357 }
1358
1359 pub fn pending(&self) -> Result<Vec<QueuedReview>, ReviewerError> {
1360 let contents = match fs::read_to_string(self.path()) {
1361 Ok(contents) => contents,
1362 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
1363 Err(error) => return Err(ReviewerError::QueueIo(error)),
1364 };
1365
1366 contents
1367 .lines()
1368 .filter(|line| !line.trim().is_empty())
1369 .map(|line| serde_json::from_str(line).map_err(ReviewerError::QueueJson))
1370 .collect()
1371 }
1372
1373 pub fn summary(&self) -> Result<ReviewQueueSummary, ReviewerError> {
1374 let pending = self.pending()?;
1375 Ok(ReviewQueueSummary {
1376 pending_count: pending.len(),
1377 oldest_enqueued_at_unix: pending.iter().map(|item| item.enqueued_at_unix).min(),
1378 })
1379 }
1380
1381 pub fn remove_sha(&self, sha: &str) -> Result<(), ReviewerError> {
1384 let remaining: Vec<QueuedReview> = self
1385 .pending()?
1386 .into_iter()
1387 .filter(|item| item.commit_sha != sha)
1388 .collect();
1389 self.rewrite(&remaining)
1390 }
1391
1392 fn rewrite(&self, items: &[QueuedReview]) -> Result<(), ReviewerError> {
1393 if items.is_empty() {
1394 return match fs::remove_file(self.path()) {
1395 Ok(()) => Ok(()),
1396 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
1397 Err(error) => Err(ReviewerError::QueueIo(error)),
1398 };
1399 }
1400
1401 let mut file = fs::File::create(self.path()).map_err(ReviewerError::QueueIo)?;
1402 for item in items {
1403 serde_json::to_writer(&mut file, item).map_err(ReviewerError::QueueJson)?;
1404 writeln!(file).map_err(ReviewerError::QueueIo)?;
1405 }
1406 Ok(())
1407 }
1408
1409 pub fn remove_item(&self, sha: &str, petition_for: Option<&str>) -> Result<(), ReviewerError> {
1413 let remaining: Vec<QueuedReview> = self
1414 .pending()?
1415 .into_iter()
1416 .filter(|item| {
1417 !(item.commit_sha == sha && item.petition_for.as_deref() == petition_for)
1418 })
1419 .collect();
1420 self.rewrite(&remaining)
1421 }
1422
1423 pub fn remove_run_id(&self, run_id: &str) -> Result<(), ReviewerError> {
1424 let remaining: Vec<QueuedReview> = self
1425 .pending()?
1426 .into_iter()
1427 .filter(|item| item.run_id != run_id)
1428 .collect();
1429 self.rewrite(&remaining)
1430 }
1431}
1432
1433pub trait MaterialLoader {
1436 fn load(&self, sha: &str) -> Result<(Claim, String), ReviewerError>;
1437}
1438
1439#[derive(Clone, Debug, Default)]
1440pub struct GitMaterialLoader {
1441 pub evidence_patterns: Vec<String>,
1444}
1445
1446impl GitMaterialLoader {
1447 pub fn from_config(config: &config::TruthMirrorConfig) -> Self {
1448 Self::with_patterns(config.gates.to_policy().evidence_patterns)
1449 }
1450
1451 pub fn with_patterns(evidence_patterns: Vec<String>) -> Self {
1452 Self { evidence_patterns }
1453 }
1454}
1455
1456impl MaterialLoader for GitMaterialLoader {
1457 fn load(&self, sha: &str) -> Result<(Claim, String), ReviewerError> {
1458 let message = git_output(["show", "--format=%B", "--no-patch", sha])?;
1459 let diff = git_output(["show", "--format=", "--patch", sha])?;
1460 let claim = if self.evidence_patterns.is_empty() {
1461 Claim::parse(&message)?
1462 } else {
1463 Claim::parse_with(&message, &self.evidence_patterns)?
1464 };
1465 Ok((claim, diff))
1466 }
1467}
1468
1469#[derive(Clone, Debug, Default, Eq, PartialEq)]
1470pub struct DrainReport {
1471 pub reviewed: Vec<String>,
1472 pub failed: Vec<String>,
1474 pub ledger_entries: usize,
1475}
1476
1477pub fn drain_once<R: ProcessRunner, L: MaterialLoader>(
1483 queue: &ReviewQueue,
1484 loader: &L,
1485 selection: &ReviewSelection,
1486 context: &str,
1487 runner: &R,
1488 store: &LedgerStore,
1489 config: &config::TruthMirrorConfig,
1490) -> Result<DrainReport, ReviewerError> {
1491 let pending = queue.pending()?;
1492 let run_store = ReviewRunStore::new(&queue.root);
1493 let mut seen = std::collections::BTreeSet::new();
1498 let mut order = Vec::new();
1499 for item in &pending {
1500 let identity = (item.commit_sha.clone(), item.petition_for.clone());
1501 if seen.insert(identity) {
1502 order.push(item.clone());
1503 } else if !item.run_id.trim().is_empty() {
1504 match run_store.read(&item.run_id) {
1505 Ok(run) if run.status == ReviewRunStatus::Queued => {
1506 run_store.cancel_queued(&item.run_id)?;
1507 }
1508 _ => {}
1509 }
1510 }
1511 }
1512
1513 let mut report = DrainReport::default();
1514 for item in order {
1515 let sha = item.commit_sha;
1516 let run_id = if item.run_id.trim().is_empty() {
1517 generate_run_id(&sha)
1518 } else {
1519 item.run_id
1520 };
1521 let target = if item.petition_for.is_some() {
1522 "petition"
1523 } else {
1524 "commit"
1525 };
1526 let run = run_store.ensure_queued(&run_id, &sha, target)?;
1527 if run.status == ReviewRunStatus::Cancelled {
1528 queue.remove_item(&sha, item.petition_for.as_deref())?;
1529 continue;
1530 }
1531 run_store.mark_running(&run_id, "reviewing")?;
1532 let (claim, diff) = match loader.load(&sha) {
1533 Ok(material) => material,
1534 Err(error) => {
1535 run_store.mark_failed(&run_id, error.to_string())?;
1536 queue.remove_item(&sha, item.petition_for.as_deref())?;
1537 report.failed.push(sha);
1538 continue;
1539 }
1540 };
1541 let petition = match &item.petition_for {
1545 Some(original_sha) => Some(petition_context_from_ledger(original_sha, &sha, store)?),
1546 None => None,
1547 };
1548 let prompt = first_pass_prompt(&claim, &diff, context);
1549 let job = ReviewJob {
1550 commit_sha: sha.clone(),
1551 claim,
1552 diff,
1553 context: context.to_owned(),
1554 request: selection.request_for(prompt),
1555 strict: selection.strict.clone(),
1556 petition,
1557 };
1558 let execution = match execute_review_job(job, runner, store) {
1559 Ok(execution) => execution,
1560 Err(error) => {
1561 let _ = run_store.mark_failed(&run_id, error.to_string());
1562 return Err(error);
1563 }
1564 };
1565 record_memory_skill_outcome(
1566 &queue.root,
1567 config,
1568 &run_id,
1569 &sha,
1570 "watch-drain",
1571 &execution.entries,
1572 );
1573 report.ledger_entries += execution.entries.len();
1574 run_store.mark_completed(&run_id, execution.entries.len())?;
1575 queue.remove_item(&sha, item.petition_for.as_deref())?;
1578 report.reviewed.push(sha);
1579 }
1580
1581 Ok(report)
1582}
1583
1584fn record_memory_skill_outcome(
1585 state_dir: &Path,
1586 config: &config::TruthMirrorConfig,
1587 run_id: &str,
1588 commit_sha: &str,
1589 phase: &str,
1590 entries: &[LedgerEntry],
1591) {
1592 if !is_full_git_sha(commit_sha) {
1593 tracing::debug!(
1594 run_id = %run_id,
1595 commit_sha = %commit_sha,
1596 phase = %phase,
1597 "memory-skill extraction skipped for non-commit review target"
1598 );
1599 return;
1600 }
1601 match crate::memory_skill::evaluate_review_completion(state_dir, config, run_id, entries) {
1602 Ok(_) => {}
1603 Err(crate::memory_skill::MemorySkillError::ScanRejected { reason }) => {
1604 tracing::info!(
1605 run_id = %run_id,
1606 commit_sha = %commit_sha,
1607 phase = %phase,
1608 memory_skill_outcome = "scan_rejected",
1609 reason = %reason,
1610 "memory-skill extraction skipped by scan gate"
1611 );
1612 }
1613 Err(error) => {
1614 let error_kind = memory_skill_error_kind(&error);
1615 tracing::warn!(
1616 run_id = %run_id,
1617 commit_sha = %commit_sha,
1618 phase = %phase,
1619 memory_skill_outcome = "failed",
1620 memory_skill_error_kind = error_kind,
1621 error = %error,
1622 "memory-skill extraction failed after review completion"
1623 );
1624 }
1625 }
1626}
1627
1628fn memory_skill_error_kind(error: &crate::memory_skill::MemorySkillError) -> &'static str {
1629 match error {
1630 crate::memory_skill::MemorySkillError::Io(_) => "io",
1631 crate::memory_skill::MemorySkillError::Json(_) => "json",
1632 crate::memory_skill::MemorySkillError::Ledger(_) => "ledger",
1633 crate::memory_skill::MemorySkillError::CandidateNotFound { .. } => "candidate_not_found",
1634 crate::memory_skill::MemorySkillError::AdvisoryNotFound { .. } => "advisory_not_found",
1635 crate::memory_skill::MemorySkillError::EmptyRejectReason => "empty_reject_reason",
1636 crate::memory_skill::MemorySkillError::EmptySupersedeReason => "empty_supersede_reason",
1637 crate::memory_skill::MemorySkillError::SelfSupersede { .. } => "self_supersede",
1638 crate::memory_skill::MemorySkillError::InvalidSupersedeReplacement { .. } => {
1639 "invalid_supersede_replacement"
1640 }
1641 crate::memory_skill::MemorySkillError::InvalidTransition { .. } => "invalid_transition",
1642 crate::memory_skill::MemorySkillError::TransitionLocked { .. } => "transition_locked",
1643 crate::memory_skill::MemorySkillError::UnsafeGlobalWrite { .. } => "unsafe_global_write",
1644 crate::memory_skill::MemorySkillError::UnsafeApprovedPath { .. } => "unsafe_approved_path",
1645 crate::memory_skill::MemorySkillError::UnsafeStatePath { .. } => "unsafe_state_path",
1646 crate::memory_skill::MemorySkillError::ScanRejected { .. } => "scan_rejected",
1647 crate::memory_skill::MemorySkillError::RenderedSkillTooLarge { .. } => {
1648 "rendered_skill_too_large"
1649 }
1650 }
1651}
1652
1653fn review_context(config: &config::TruthMirrorConfig) -> String {
1656 let repo_root = match git_output(["rev-parse", "--show-toplevel"]) {
1657 Ok(root) => PathBuf::from(root.trim()),
1658 Err(_) => return String::new(),
1659 };
1660 let provider = crate::context::trajectory_provider(&repo_root, &config.history);
1661 crate::context::build_review_context(
1662 &repo_root,
1663 &config.ground_truth,
1664 &config.history,
1665 Some(provider.as_ref()),
1666 )
1667 .unwrap_or_default()
1668}
1669
1670pub fn run_watch_command(
1671 args: cli::WatchArgs,
1672 state_dir: &Path,
1673 config: &config::TruthMirrorConfig,
1674) -> Result<ExitCode> {
1675 let selection = ReviewSelection::resolve(
1676 args.watched_agent,
1677 args.watched_model,
1678 args.reviewer_harness,
1679 args.reviewer_model,
1680 args.reviewer_effort,
1681 args.allow_same_model,
1682 config,
1683 )?;
1684 let queue = ReviewQueue::new(state_dir);
1685 let store = LedgerStore::new(state_dir);
1686 let loader = GitMaterialLoader::from_config(config);
1687 let runner = StdProcessRunner;
1688
1689 if args.once {
1690 let context = review_context(config);
1691 let report = drain_once(
1692 &queue, &loader, &selection, &context, &runner, &store, config,
1693 )?;
1694 println!(
1695 "truth-mirror watch: reviewed {} commit(s), skipped {} failed item(s), wrote {} ledger entrie(s)",
1696 report.reviewed.len(),
1697 report.failed.len(),
1698 report.ledger_entries
1699 );
1700 return Ok(ExitCode::SUCCESS);
1701 }
1702
1703 let interval = std::time::Duration::from_secs(args.poll_secs.max(1));
1704
1705 if args.until_empty {
1706 return run_until_empty(
1707 &queue, &loader, &selection, &runner, &store, config, state_dir, args.grace, interval,
1708 );
1709 }
1710
1711 loop {
1712 let context = review_context(config);
1714 let report = drain_once(
1715 &queue, &loader, &selection, &context, &runner, &store, config,
1716 )?;
1717 if !report.reviewed.is_empty() {
1718 println!(
1719 "truth-mirror watch: reviewed {} commit(s)",
1720 report.reviewed.len()
1721 );
1722 }
1723 if !report.failed.is_empty() {
1724 eprintln!(
1725 "truth-mirror watch: skipped {} failed queue item(s)",
1726 report.failed.len()
1727 );
1728 }
1729 std::thread::sleep(interval);
1730 }
1731}
1732
1733#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1741pub enum UntilEmptyDecision {
1742 KeepWatching,
1745 Exit,
1747}
1748
1749pub fn until_empty_decision(empty_for_secs: Option<u64>, grace_secs: u64) -> UntilEmptyDecision {
1750 match empty_for_secs {
1751 Some(elapsed) if elapsed >= grace_secs => UntilEmptyDecision::Exit,
1752 _ => UntilEmptyDecision::KeepWatching,
1753 }
1754}
1755
1756#[derive(Clone, Debug, Eq, PartialEq)]
1757enum QueueFingerprint {
1758 Missing,
1759 Present {
1760 len: u64,
1761 modified: Option<SystemTime>,
1762 },
1763}
1764
1765#[derive(Clone, Debug, Default)]
1766struct QueueEmptyCache {
1767 fingerprint: Option<QueueFingerprint>,
1768 pending_count: Option<usize>,
1769}
1770
1771fn queue_fingerprint(queue: &ReviewQueue) -> Result<QueueFingerprint, ReviewerError> {
1772 match fs::metadata(queue.path()) {
1773 Ok(metadata) => Ok(QueueFingerprint::Present {
1774 len: metadata.len(),
1775 modified: metadata.modified().ok(),
1776 }),
1777 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(QueueFingerprint::Missing),
1778 Err(error) => Err(ReviewerError::QueueIo(error)),
1779 }
1780}
1781
1782fn queue_has_pending_cached(
1783 queue: &ReviewQueue,
1784 cache: &mut QueueEmptyCache,
1785) -> Result<bool, ReviewerError> {
1786 let fingerprint = queue_fingerprint(queue)?;
1787 if matches!(
1788 fingerprint,
1789 QueueFingerprint::Missing | QueueFingerprint::Present { len: 0, .. }
1790 ) {
1791 cache.fingerprint = Some(fingerprint);
1792 cache.pending_count = Some(0);
1793 return Ok(false);
1794 }
1795
1796 if let (true, Some(pending_count)) = (
1797 cache.fingerprint.as_ref() == Some(&fingerprint),
1798 cache.pending_count,
1799 ) {
1800 return Ok(pending_count > 0);
1801 }
1802
1803 let pending_count = queue.summary()?.pending_count;
1804 cache.fingerprint = Some(fingerprint);
1805 cache.pending_count = Some(pending_count);
1806 Ok(pending_count > 0)
1807}
1808
1809#[allow(clippy::too_many_arguments)]
1816fn run_until_empty<R: ProcessRunner, L: MaterialLoader>(
1817 queue: &ReviewQueue,
1818 loader: &L,
1819 selection: &ReviewSelection,
1820 runner: &R,
1821 store: &LedgerStore,
1822 config: &config::TruthMirrorConfig,
1823 state_dir: &Path,
1824 grace_secs: u64,
1825 interval: std::time::Duration,
1826) -> Result<ExitCode> {
1827 match crate::watcher::try_acquire_lock(state_dir) {
1831 Ok(crate::watcher::LockClaim::Acquired) => {}
1832 Ok(crate::watcher::LockClaim::HeldByLiveWatcher) => {
1833 eprintln!(
1834 "truth-mirror watch: watcher lock is already held; continuing without lock ownership"
1835 );
1836 }
1837 Err(error) => {
1838 eprintln!(
1839 "truth-mirror watch: failed to acquire watcher lock ({error}); continuing without lock ownership"
1840 );
1841 }
1842 }
1843
1844 let outcome = (|| -> Result<()> {
1845 let mut empty_since: Option<u64> = None;
1846 let mut queue_empty_cache = QueueEmptyCache::default();
1847 loop {
1848 let context = review_context(config);
1849 let report = drain_once(queue, loader, selection, &context, runner, store, config)?;
1850 if !report.reviewed.is_empty() {
1851 println!(
1852 "truth-mirror watch: reviewed {} commit(s)",
1853 report.reviewed.len()
1854 );
1855 }
1856 if !report.failed.is_empty() {
1857 eprintln!(
1858 "truth-mirror watch: skipped {} failed queue item(s)",
1859 report.failed.len()
1860 );
1861 }
1862
1863 let now = unix_now();
1864 if !queue_has_pending_cached(queue, &mut queue_empty_cache)? {
1865 let started = *empty_since.get_or_insert(now);
1866 let empty_for = now.saturating_sub(started);
1867 if until_empty_decision(Some(empty_for), grace_secs) == UntilEmptyDecision::Exit {
1868 return Ok(());
1869 }
1870 } else {
1871 empty_since = None;
1873 }
1874
1875 std::thread::sleep(interval);
1876 }
1877 })();
1878
1879 let _ = crate::watcher::release_lock_if_owned(state_dir);
1882 outcome?;
1883 Ok(ExitCode::SUCCESS)
1884}
1885
1886pub fn run_ensure_watcher_command(
1892 _args: cli::EnsureWatcherArgs,
1893 state_dir: &Path,
1894) -> Result<ExitCode> {
1895 match crate::watcher::ensure_watcher(state_dir)? {
1896 crate::watcher::LockClaim::Acquired => {
1897 println!("truth-mirror: watcher started");
1898 }
1899 crate::watcher::LockClaim::HeldByLiveWatcher => {
1900 tracing::debug!("ensure-watcher: live watcher already owns the state dir");
1901 }
1902 }
1903 Ok(ExitCode::SUCCESS)
1904}
1905
1906#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1907pub struct StrictGoalPolicy {
1908 pub stop_after_lies: u32,
1909 pub stop_after_fuckups: u32,
1910}
1911
1912#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1913pub struct StrictGoalCounters {
1914 pub lies_exposed: u32,
1915 pub fuckups_registered: u32,
1916}
1917
1918#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1919pub enum StrictGoalDecision {
1920 Continue,
1921 Stop { reason: StrictGoalStopReason },
1922}
1923
1924#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1925pub enum StrictGoalStopReason {
1926 LiesExposed,
1927 FuckupsRegistered,
1928}
1929
1930impl StrictGoalPolicy {
1931 pub fn decide(&self, counters: StrictGoalCounters) -> StrictGoalDecision {
1932 if self.stop_after_lies > 0 && counters.lies_exposed >= self.stop_after_lies {
1933 return StrictGoalDecision::Stop {
1934 reason: StrictGoalStopReason::LiesExposed,
1935 };
1936 }
1937
1938 if self.stop_after_fuckups > 0 && counters.fuckups_registered >= self.stop_after_fuckups {
1939 return StrictGoalDecision::Stop {
1940 reason: StrictGoalStopReason::FuckupsRegistered,
1941 };
1942 }
1943
1944 StrictGoalDecision::Continue
1945 }
1946}
1947
1948#[derive(Clone, Debug, Eq, PartialEq)]
1949pub struct StrictGoalOutcome {
1950 pub passes: u32,
1951 pub counters: StrictGoalCounters,
1952 pub stop_reason: Option<StrictGoalStopReason>,
1955 pub entries: Vec<LedgerEntry>,
1956}
1957
1958impl StrictGoalOutcome {
1959 pub fn stop_reason_suffix(&self) -> &'static str {
1960 match self.stop_reason {
1961 Some(StrictGoalStopReason::LiesExposed) => " (stopped: lies exposed)",
1962 Some(StrictGoalStopReason::FuckupsRegistered) => " (stopped: fuckups registered)",
1963 None => " (stopped: max passes)",
1964 }
1965 }
1966}
1967
1968#[allow(clippy::too_many_arguments)]
1973pub fn run_strict_goal_loop<R: ProcessRunner>(
1974 commit_sha: &str,
1975 claim: &Claim,
1976 diff: &str,
1977 context: &str,
1978 selection: &ReviewSelection,
1979 policy: StrictGoalPolicy,
1980 max_passes: u32,
1981 runner: &R,
1982 store: &LedgerStore,
1983) -> Result<StrictGoalOutcome, ReviewerError> {
1984 let ceiling = max_passes.max(1);
1985 let mut outcome = StrictGoalOutcome {
1986 passes: 0,
1987 counters: StrictGoalCounters {
1988 lies_exposed: 0,
1989 fuckups_registered: 0,
1990 },
1991 stop_reason: None,
1992 entries: Vec::new(),
1993 };
1994
1995 while outcome.passes < ceiling {
1996 let prompt = strict_goal_prompt(claim, diff, context, outcome.passes + 1, &outcome.entries);
1997 let request = selection.request_for(prompt);
1998 let plan = ReviewPlan::build(request.clone())?;
1999 let output = plan.run_with(&request.prompt, runner)?;
2000 ensure_process_success(&output)?;
2001 let verdict = ParsedVerdict::parse(&output.stdout)?;
2002
2003 let job = ReviewJob {
2004 commit_sha: commit_sha.to_owned(),
2005 claim: claim.clone(),
2006 diff: diff.to_owned(),
2007 context: context.to_owned(),
2008 request,
2009 strict: None,
2010 petition: None,
2011 };
2012 let entry = entry_from_verdict(&job, &plan, &verdict);
2013 store.append_entry(&entry)?;
2014 outcome.entries.push(entry);
2015
2016 outcome.passes += 1;
2017 if verdict.verdict == Verdict::Reject {
2018 outcome.counters.lies_exposed += 1;
2019 }
2020 outcome.counters.fuckups_registered = outcome
2021 .counters
2022 .fuckups_registered
2023 .saturating_add(u32::try_from(verdict.findings.len()).unwrap_or(u32::MAX));
2024
2025 if let StrictGoalDecision::Stop { reason } = policy.decide(outcome.counters) {
2026 outcome.stop_reason = Some(reason);
2027 break;
2028 }
2029 }
2030
2031 Ok(outcome)
2032}
2033
2034fn strict_goal_prompt(
2035 claim: &Claim,
2036 diff: &str,
2037 context: &str,
2038 pass: u32,
2039 prior: &[LedgerEntry],
2040) -> String {
2041 let prior_findings: Vec<String> = prior
2042 .iter()
2043 .flat_map(|entry| entry.findings.clone())
2044 .collect();
2045 let prior_block = if prior_findings.is_empty() {
2046 "(none)".to_owned()
2047 } else {
2048 prior_findings.join("\n")
2049 };
2050 format!(
2051 "{ADVERSARIAL_PREAMBLE}\n\nStrict-goal loop, pass {pass}. Keep hunting for any lie the claim hides; do not repeat prior findings verbatim.{}\n\nCLAIM:\n{}\n\nPRIOR FINDINGS:\n{prior_block}\n\nDIFF:\n{}",
2052 context_block(context),
2053 claim.to_line(),
2054 diff
2055 )
2056}
2057
2058pub fn run_review_command(
2059 args: cli::ReviewArgs,
2060 state_dir: &Path,
2061 config: &config::TruthMirrorConfig,
2062) -> Result<ExitCode> {
2063 if let Some(command) = args.command {
2064 return run_review_run_command(command, state_dir);
2065 }
2066
2067 let material = ReviewMaterial::load(
2068 &args,
2069 state_dir,
2070 &config.gates.to_policy().evidence_patterns,
2071 )?;
2072
2073 let mut selection = ReviewSelection::resolve(
2074 args.watched_agent,
2075 args.watched_model,
2076 args.reviewer_harness,
2077 args.reviewer_model,
2078 args.reviewer_effort,
2079 args.allow_same_model,
2080 config,
2081 )?;
2082
2083 if args.strict_two_pass {
2084 selection.strict = Some(ReviewSelection::resolve_arbiter(
2085 selection.watched_agent,
2086 args.arbiter_harness,
2087 args.arbiter_model,
2088 args.arbiter_effort,
2089 config,
2090 )?);
2091 }
2092 let store = LedgerStore::new(state_dir);
2093 let run_store = ReviewRunStore::new(state_dir);
2094 let context = review_context(config);
2095 let run = run_store.create_queued(&material.commit_sha, material.target_label.clone())?;
2096 run_store.mark_running(&run.id, "reviewing")?;
2097
2098 if args.strict_goal {
2099 let policy = config
2100 .strict
2101 .goal_policy(args.stop_after_lies, args.stop_after_fuckups);
2102 let max_passes = args.max_passes.unwrap_or(config.strict.max_passes);
2103 let outcome = match run_strict_goal_loop(
2104 &material.commit_sha,
2105 &material.claim,
2106 &material.diff,
2107 &context,
2108 &selection,
2109 policy,
2110 max_passes,
2111 &StdProcessRunner,
2112 &store,
2113 ) {
2114 Ok(outcome) => outcome,
2115 Err(error) => {
2116 let _ = run_store.mark_failed(&run.id, error.to_string());
2117 return Err(error.into());
2118 }
2119 };
2120 record_memory_skill_outcome(
2121 state_dir,
2122 config,
2123 &run.id,
2124 &material.commit_sha,
2125 "strict-goal",
2126 &outcome.entries,
2127 );
2128 run_store.mark_completed(&run.id, outcome.entries.len())?;
2129 println!(
2130 "truth-mirror strict-goal: run {}, {} pass(es), {} lie(s), {} fuckup(s){}",
2131 run.id,
2132 outcome.passes,
2133 outcome.counters.lies_exposed,
2134 outcome.counters.fuckups_registered,
2135 outcome.stop_reason_suffix(),
2136 );
2137 return Ok(ExitCode::SUCCESS);
2138 }
2139
2140 let prompt = first_pass_prompt(&material.claim, &material.diff, &context);
2141 let commit_sha = material.commit_sha.clone();
2142 let job = ReviewJob {
2143 commit_sha: material.commit_sha,
2144 claim: material.claim,
2145 diff: material.diff,
2146 context,
2147 request: selection.request_for(prompt),
2148 strict: selection.strict.clone(),
2149 petition: None,
2150 };
2151
2152 let execution = match execute_review_job(job, &StdProcessRunner, &store) {
2153 Ok(execution) => execution,
2154 Err(error) => {
2155 let _ = run_store.mark_failed(&run.id, error.to_string());
2156 return Err(error.into());
2157 }
2158 };
2159 record_memory_skill_outcome(
2160 state_dir,
2161 config,
2162 &run.id,
2163 &commit_sha,
2164 "manual-review",
2165 &execution.entries,
2166 );
2167 run_store.mark_completed(&run.id, execution.entries.len())?;
2168 println!(
2169 "truth-mirror review: run {}, wrote {} ledger entrie(s)",
2170 run.id,
2171 execution.entries.len()
2172 );
2173 Ok(ExitCode::SUCCESS)
2174}
2175
2176fn run_review_run_command(command: cli::ReviewCommand, state_dir: &Path) -> Result<ExitCode> {
2177 let runs = ReviewRunStore::new(state_dir);
2178 match command {
2179 cli::ReviewCommand::Status { run_id } => {
2180 if let Some(run_id) = run_id {
2181 print_run(&runs.read_reconciled(&run_id)?);
2182 } else {
2183 let all = runs.list_reconciled()?;
2184 if all.is_empty() {
2185 println!("No review runs.");
2186 } else {
2187 for run in all {
2188 print_run_summary(&run);
2189 }
2190 }
2191 }
2192 }
2193 cli::ReviewCommand::Result { run_id } => {
2194 let run = match run_id {
2195 Some(run_id) => runs.read(&run_id)?,
2196 None => runs.latest_result()?,
2197 };
2198 print_run(&run);
2199 print_run_ledger_entries(state_dir, &run)?;
2200 }
2201 cli::ReviewCommand::Cancel { run_id, force } => {
2202 let run = runs.cancel(&run_id, force)?;
2203 ReviewQueue::new(state_dir).remove_run_id(&run_id)?;
2204 match run.status {
2205 ReviewRunStatus::Failed => println!(
2206 "reaped stale review run {} ({}): {}",
2207 run.id,
2208 run.commit_sha,
2209 run.error.as_deref().unwrap_or("worker was not alive"),
2210 ),
2211 _ => println!("cancelled review run {} ({})", run.id, run.commit_sha),
2212 }
2213 }
2214 }
2215 Ok(ExitCode::SUCCESS)
2216}
2217
2218fn print_run_summary(run: &ReviewRun) {
2219 println!(
2220 "{} {} {} {} entries={} updated={}",
2221 run.id, run.status, run.commit_sha, run.phase, run.ledger_entries, run.updated_at_unix
2222 );
2223}
2224
2225fn print_run(run: &ReviewRun) {
2226 println!("run: {}", run.id);
2227 println!("status: {}", run.status);
2228 println!("commit: {}", run.commit_sha);
2229 println!("target: {}", run.target);
2230 println!("phase: {}", run.phase);
2231 println!("ledger_entries: {}", run.ledger_entries);
2232 if let Some(pid) = run.worker_pid {
2233 println!("worker_pid: {pid}");
2234 }
2235 println!("created_at_unix: {}", run.created_at_unix);
2236 println!("updated_at_unix: {}", run.updated_at_unix);
2237 if let Some(started) = run.started_at_unix {
2238 println!("started_at_unix: {started}");
2239 }
2240 if let Some(completed) = run.completed_at_unix {
2241 println!("completed_at_unix: {completed}");
2242 }
2243 if let Some(error) = &run.error {
2244 println!("error: {error}");
2245 }
2246 if let Some(checkpoint) = &run.entire_checkpoint {
2247 println!("entire_ref: {}", checkpoint.ref_name);
2248 println!("entire_sha: {}", checkpoint.object_sha);
2249 }
2250}
2251
2252fn print_run_ledger_entries(state_dir: &Path, run: &ReviewRun) -> Result<(), ReviewerError> {
2253 let store = LedgerStore::new(state_dir);
2254 let entries: Vec<LedgerEntry> = store
2255 .read_history()?
2256 .into_iter()
2257 .filter(|entry| entry.commit_sha == run.commit_sha)
2258 .collect();
2259 if entries.is_empty() {
2260 println!("ledger_entries: none");
2261 return Ok(());
2262 }
2263 println!("ledger_entries:");
2264 for entry in entries {
2265 println!(
2266 "- {} {} {} findings={}",
2267 entry.commit_sha,
2268 entry.verdict,
2269 entry.disposition,
2270 entry.findings.len()
2271 );
2272 }
2273 Ok(())
2274}
2275
2276#[derive(Clone, Debug, Eq, PartialEq)]
2277struct ReviewMaterial {
2278 commit_sha: String,
2279 target_label: String,
2280 claim: Claim,
2281 diff: String,
2282}
2283
2284impl ReviewMaterial {
2285 fn load(
2286 args: &cli::ReviewArgs,
2287 state_dir: &Path,
2288 evidence_patterns: &[String],
2289 ) -> Result<Self, ReviewerError> {
2290 let parse = |text: &str| {
2291 if evidence_patterns.is_empty() {
2292 Claim::parse(text)
2293 } else {
2294 Claim::parse_with(text, evidence_patterns)
2295 }
2296 };
2297
2298 let scope = if args.staged {
2299 ReviewScope::Staged
2300 } else {
2301 args.scope
2302 };
2303
2304 match scope {
2305 ReviewScope::Commit => {
2306 let target = args
2307 .target
2308 .clone()
2309 .ok_or(ReviewerError::MissingReviewTarget)?;
2310 let sha = resolve_commit_target(&target)?;
2311 let message = git_output(["show", "--format=%B", "--no-patch", sha.as_str()])?;
2312 let diff = git_output(["show", "--format=", "--patch", sha.as_str()])?;
2313 let claim = parse(&message)?;
2314 Ok(Self {
2315 commit_sha: sha.clone(),
2316 target_label: format!("commit:{target}"),
2317 claim,
2318 diff,
2319 })
2320 }
2321 ReviewScope::Staged => Self::load_staged(state_dir, &parse),
2322 ReviewScope::Auto => {
2323 reject_target_with_scope(args)?;
2324 if working_tree_dirty()? {
2325 Self::load_working_tree(state_dir, &parse)
2326 } else {
2327 Self::load_branch(args.base.as_deref(), &parse)
2328 }
2329 }
2330 ReviewScope::WorkingTree => {
2331 reject_target_with_scope(args)?;
2332 Self::load_working_tree(state_dir, &parse)
2333 }
2334 ReviewScope::Branch => {
2335 reject_target_with_scope(args)?;
2336 Self::load_branch(args.base.as_deref(), &parse)
2337 }
2338 }
2339 }
2340
2341 fn load_staged<F>(state_dir: &Path, parse: &F) -> Result<Self, ReviewerError>
2342 where
2343 F: Fn(&str) -> Result<Claim, crate::claim::ClaimError>,
2344 {
2345 let raw = git_output(["diff", "--cached"])?;
2346 let files = git_output(["diff", "--cached", "--name-only"])?;
2347 let diff = materialize_diff("staged", &raw, &files);
2348 let claim = parse(&read_claim_file(state_dir)?)?;
2349 Ok(Self {
2350 commit_sha: "STAGED".to_owned(),
2351 target_label: "staged".to_owned(),
2352 claim,
2353 diff,
2354 })
2355 }
2356
2357 fn load_working_tree<F>(state_dir: &Path, parse: &F) -> Result<Self, ReviewerError>
2358 where
2359 F: Fn(&str) -> Result<Claim, crate::claim::ClaimError>,
2360 {
2361 let status = git_output(["status", "--porcelain"])?;
2362 let tracked = git_output(["diff", "HEAD", "--patch"])?;
2363 let files = git_output(["diff", "HEAD", "--name-only"])?;
2364 let untracked = untracked_file_context()?;
2365 let raw = format!(
2366 "WORKING TREE STATUS:\n{status}\n\nTRACKED DIFF AGAINST HEAD:\n{tracked}\n\nUNTRACKED FILES:\n{untracked}"
2367 );
2368 let diff = materialize_diff("working-tree", &raw, &files);
2369 let claim = parse(&read_claim_file(state_dir)?)?;
2370 Ok(Self {
2371 commit_sha: "WORKING_TREE".to_owned(),
2372 target_label: "working-tree".to_owned(),
2373 claim,
2374 diff,
2375 })
2376 }
2377
2378 fn load_branch<F>(base: Option<&str>, parse: &F) -> Result<Self, ReviewerError>
2379 where
2380 F: Fn(&str) -> Result<Claim, crate::claim::ClaimError>,
2381 {
2382 let base = match base {
2383 Some(base) => base.to_owned(),
2384 None => default_branch_ref()?,
2385 };
2386 let merge_base = git_output_slice(&["merge-base", "HEAD", &base])?;
2387 let merge_base = merge_base.trim().to_owned();
2388 let range = format!("{merge_base}..HEAD");
2389 let message = git_output(["show", "--format=%B", "--no-patch", "HEAD"])?;
2390 let log = git_output_slice(&["log", "--oneline", &range])?;
2391 let stat = git_output_slice(&["diff", "--stat", &range])?;
2392 let raw_patch = git_output_slice(&["diff", "--patch", &range])?;
2393 let files = git_output_slice(&["diff", "--name-only", &range])?;
2394 let raw = format!(
2395 "BRANCH BASE: {base}\nMERGE BASE: {merge_base}\nCOMMITS:\n{log}\n\nDIFF STAT:\n{stat}\n\nDIFF:\n{raw_patch}"
2396 );
2397 let diff = materialize_diff(&format!("branch:{base}"), &raw, &files);
2398 let claim = parse(&message)?;
2399 Ok(Self {
2400 commit_sha: "HEAD".to_owned(),
2401 target_label: format!("branch:{base}"),
2402 claim,
2403 diff,
2404 })
2405 }
2406}
2407
2408fn resolve_commit_target(target: &str) -> Result<String, ReviewerError> {
2409 let rev = format!("{target}^{{commit}}");
2410 Ok(
2411 git_output_slice(&["rev-parse", "--verify", "--quiet", "--end-of-options", &rev])?
2412 .trim()
2413 .to_owned(),
2414 )
2415}
2416
2417fn reject_target_with_scope(args: &cli::ReviewArgs) -> Result<(), ReviewerError> {
2418 if let Some(target) = &args.target {
2419 return Err(ReviewerError::UnexpectedReviewTarget {
2420 scope: args.scope,
2421 target: target.clone(),
2422 });
2423 }
2424 Ok(())
2425}
2426
2427fn read_claim_file(state_dir: &Path) -> Result<String, ReviewerError> {
2428 let claim_path = state_dir.join("claim.txt");
2429 fs::read_to_string(&claim_path).map_err(|source| ReviewerError::ClaimFileRead {
2430 path: claim_path,
2431 source,
2432 })
2433}
2434
2435fn working_tree_dirty() -> Result<bool, ReviewerError> {
2436 Ok(!git_output(["status", "--porcelain"])?.trim().is_empty())
2437}
2438
2439fn default_branch_ref() -> Result<String, ReviewerError> {
2440 if let Ok(symbolic) = git_output([
2441 "symbolic-ref",
2442 "--quiet",
2443 "--short",
2444 "refs/remotes/origin/HEAD",
2445 ]) {
2446 let trimmed = symbolic.trim();
2447 if !trimmed.is_empty() {
2448 return Ok(trimmed.to_owned());
2449 }
2450 }
2451
2452 for candidate in [
2453 "origin/main",
2454 "origin/master",
2455 "origin/trunk",
2456 "main",
2457 "master",
2458 "trunk",
2459 ] {
2460 if git_output_slice(&["rev-parse", "--verify", "--quiet", candidate]).is_ok() {
2461 return Ok(candidate.to_owned());
2462 }
2463 }
2464
2465 Err(ReviewerError::DefaultBranchNotFound)
2466}
2467
2468fn materialize_diff(label: &str, raw: &str, files: &str) -> String {
2469 let file_list: Vec<&str> = files
2470 .lines()
2471 .filter(|line| !line.trim().is_empty())
2472 .collect();
2473 let bytes = raw.len();
2474 if bytes <= MAX_INLINE_DIFF_BYTES && file_list.len() <= MAX_INLINE_DIFF_FILES {
2475 return raw.to_owned();
2476 }
2477
2478 format!(
2479 "Diff for {label} is too large to inline safely.\ninline_limit_bytes={MAX_INLINE_DIFF_BYTES}\nactual_bytes={bytes}\ninline_file_limit={MAX_INLINE_DIFF_FILES}\nactual_files={}\n\nChanged files:\n{}\n\nReviewer must inspect the repository directly with read/grep tools before returning a verdict.",
2480 file_list.len(),
2481 if file_list.is_empty() {
2482 "(none)".to_owned()
2483 } else {
2484 file_list.join("\n")
2485 }
2486 )
2487}
2488
2489fn is_full_git_sha(value: &str) -> bool {
2490 matches!(value.len(), 40 | 64) && value.bytes().all(|byte| byte.is_ascii_hexdigit())
2491}
2492
2493fn untracked_file_context() -> Result<String, ReviewerError> {
2494 let files = git_output(["ls-files", "--others", "--exclude-standard"])?;
2495 let mut output = String::new();
2496 for file in files.lines().filter(|line| !line.trim().is_empty()) {
2497 let path = Path::new(file);
2498 let metadata = match fs::metadata(path) {
2499 Ok(metadata) => metadata,
2500 Err(_) => continue,
2501 };
2502 if !metadata.is_file() {
2503 continue;
2504 }
2505 if metadata.len() > MAX_UNTRACKED_FILE_BYTES {
2506 output.push_str(&format!(
2507 "\n--- {file} omitted: {} bytes exceeds {MAX_UNTRACKED_FILE_BYTES} byte inline limit ---\n",
2508 metadata.len()
2509 ));
2510 continue;
2511 }
2512 let bytes = match fs::read(path) {
2513 Ok(bytes) => bytes,
2514 Err(_) => continue,
2515 };
2516 if bytes.contains(&0) {
2517 output.push_str(&format!("\n--- {file} omitted: binary file ---\n"));
2518 continue;
2519 }
2520 output.push_str(&format!(
2521 "\n--- {file} ---\n{}",
2522 String::from_utf8_lossy(&bytes)
2523 ));
2524 }
2525
2526 if output.is_empty() {
2527 Ok("(none)".to_owned())
2528 } else {
2529 Ok(output)
2530 }
2531}
2532
2533#[derive(Debug, Error)]
2534pub enum ReviewerError {
2535 #[error("missing {role} model")]
2536 MissingModel { role: String },
2537 #[error(
2538 "same reviewer model is disallowed without --allow-same-model: watched={watched_model}, reviewer={reviewer_model}"
2539 )]
2540 SameModelWithoutWaiver {
2541 watched_model: String,
2542 reviewer_model: String,
2543 },
2544 #[error("strict arbiter model must differ from watched and first reviewer models")]
2545 StrictArbiterModelNotDistinct,
2546 #[error("no adversarial pair configured for writer harness {writer:?}")]
2547 NoPairForWriter { writer: String },
2548 #[error(
2549 "strict review requires an arbiter (pair.arbiter or --arbiter-harness/--arbiter-model)"
2550 )]
2551 MissingArbiter,
2552 #[error(
2553 "--{role}-harness={harness:?} was overridden without a matching --{role}-model; the pair's model is for a different harness"
2554 )]
2555 OverrideNeedsModel { role: String, harness: String },
2556 #[error("custom reviewer harness requires explicit command configuration")]
2557 UnsupportedCustomHarness,
2558 #[error("unknown watched agent {value:?}")]
2559 UnknownAgent { value: String },
2560 #[error("unknown reviewer harness {value:?}")]
2561 UnknownHarness { value: String },
2562 #[error("missing review target")]
2563 MissingReviewTarget,
2564 #[error("--scope={scope:?} does not accept positional target {target:?}")]
2565 UnexpectedReviewTarget { scope: ReviewScope, target: String },
2566 #[error("could not determine default branch; pass --base explicitly")]
2567 DefaultBranchNotFound,
2568 #[error("failed to read staged claim file {path}: {source}")]
2569 ClaimFileRead {
2570 path: PathBuf,
2571 #[source]
2572 source: io::Error,
2573 },
2574 #[error("reviewer output was not valid structured JSON verdict: {source}: {output:?}")]
2575 VerdictJson {
2576 source: serde_json::Error,
2577 output: String,
2578 },
2579 #[error("reviewer structured verdict violated schema: {message}")]
2580 VerdictSchema { message: String },
2581 #[error("reviewer process exited with status {status:?}: {stderr}")]
2582 ReviewerProcessFailed { status: Option<i32>, stderr: String },
2583 #[error("git command failed: git {args:?}: {stderr}")]
2584 GitFailed { args: Vec<String>, stderr: String },
2585 #[error("failed to spawn git command: {0}")]
2586 GitSpawn(io::Error),
2587 #[error("failed to spawn reviewer process: {0}")]
2588 Spawn(io::Error),
2589 #[error("failed to open reviewer stdin pipe")]
2590 MissingStdinPipe,
2591 #[error("failed to write reviewer prompt: {0}")]
2592 WritePrompt(io::Error),
2593 #[error("failed to wait for reviewer process: {0}")]
2594 Wait(io::Error),
2595 #[error("review queue IO failed: {0}")]
2596 QueueIo(io::Error),
2597 #[error("review queue JSON failed: {0}")]
2598 QueueJson(serde_json::Error),
2599 #[error("review run IO failed: {0}")]
2600 RunIo(io::Error),
2601 #[error("review run JSON failed: {0}")]
2602 RunJson(serde_json::Error),
2603 #[error("review run not found: {id}")]
2604 ReviewRunNotFound { id: String },
2605 #[error("no review runs found")]
2606 NoReviewRuns,
2607 #[error("cannot cancel review run {id} with status {status}; it has already finished")]
2608 CannotCancelReview { id: String, status: ReviewRunStatus },
2609 #[error(
2610 "review run {id} is still running (worker pid {pid} is alive); pass --force to kill it"
2611 )]
2612 ReviewRunStillAlive { id: String, pid: u32 },
2613 #[error(
2614 "review run {id} is running but records no worker pid; pass --force to reap it if it is stuck"
2615 )]
2616 ReviewRunLivenessUnknown { id: String },
2617 #[error("failed to spawn kill for stale worker: {0}")]
2618 KillWorker(io::Error),
2619 #[error("failed to kill worker process {pid}")]
2620 KillWorkerFailed { pid: u32 },
2621 #[error(transparent)]
2622 Claim(#[from] crate::claim::ClaimError),
2623 #[error(transparent)]
2624 Ledger(#[from] crate::ledger::LedgerError),
2625}
2626
2627const ADVERSARIAL_PREAMBLE: &str = r#"You are an ADVERSARIAL reviewer. Your job is not to review the diff neutrally; it is to PROVE THIS CLAIM FALSE. Assume the author over-rates their own work. A claim is only PASS if the diff and the cited evidence actually substantiate it AND the change does not violate any inviolable constraint. If the evidence is vague, missing, unverifiable, or the change drifts from the stated direction, default to REJECT.
2628
2629Attack the change for auth and permission holes, data loss, rollback gaps, races, stale state, version skew, observability gaps, missing evidence, fake evidence, broad matchers, gates that fail open, and code that only fixes the instance instead of the defect class.
2630
2631GREP THE CLASS, NOT THE INSTANCE. For every problem you find, do NOT stop at the one occurrence: name the general CLASS of the defect (for example, config value loaded then ignored, comment contradicts code, gate fails open, matcher too broad), then use your read/grep/find tools to sweep the WHOLE repository for every other instance of that class and report them all. One instance is a symptom; the class is the bug. Check each inviolable constraint against every changed file, and state what you searched for in finding bodies when relevant.
2632
2633Return valid JSON only. Do not wrap it in Markdown. The schema is:
2634{
2635 "verdict": "PASS" | "REJECT" | "FLAG",
2636 "summary": "one concise sentence explaining why the claim passes or fails",
2637 "findings": [
2638 {
2639 "severity": "critical" | "high" | "medium" | "low",
2640 "title": "short defect title",
2641 "body": "what can go wrong, why this code is vulnerable, and what evidence proves it",
2642 "file": "repo-relative file path",
2643 "line_start": 1,
2644 "line_end": 1,
2645 "confidence": 0,
2646 "recommendation": "concrete change required"
2647 }
2648 ],
2649 "next_steps": ["short concrete follow-up commands or edits"],
2650 "memory_skill": {
2651 "kind": "none" | "how_to_skill" | "anti_pattern_skill" | "remediation_skill",
2652 "learning_source": "short reusable procedure or failure class; empty only when kind is none",
2653 "reasoning": "why this memory-skill classification applies or why no candidate should be proposed"
2654 }
2655}
2656
2657For "memory_skill", propose "how_to_skill" only for reusable verified PASS procedures, "anti_pattern_skill" for repeated false-claim or evidence failure classes, "remediation_skill" for repeated repair workflows, and "none" when the reviewed change is not reusable procedural memory. Do not classify by filename alone; use the diff, claim, evidence, and review findings.
2658
2659Verdict semantics:
2660- "PASS": the claim is substantiated AND there are no findings. Never pair PASS with findings.
2661- "REJECT": at least one finding is materially false / unverified / unaddressed. Must include at least one finding.
2662- "FLAG": the claim substantively holds but the reviewer wants to surface non-blocking process / evidence / provenance debt alongside the verdict. FLAGs never block push or reinject; humans track them via `truth-mirror debt`. Must include at least one finding in the "findings" array — the debt being surfaced is expressed as findings; there is no separate advisory-note field."#;
2663
2664fn context_block(context: &str) -> String {
2665 if context.trim().is_empty() {
2666 String::new()
2667 } else {
2668 format!("\n\n{context}")
2669 }
2670}
2671
2672fn first_pass_prompt(claim: &Claim, diff: &str, context: &str) -> String {
2673 format!(
2674 "{ADVERSARIAL_PREAMBLE}{}\n\nCLAIM:\n{}\n\nDIFF:\n{}",
2675 context_block(context),
2676 claim.to_line(),
2677 diff
2678 )
2679}
2680
2681const PETITION_PREAMBLE: &str = r#"You are an ADVERSARIAL petition reviewer. The agent has submitted a fix commit and is asking you to decide whether it materially addresses every finding raised against the original rejection. Your job is not to re-evaluate the original claim from scratch; it is to read each original finding, inspect the fix, and judge whether the fix actually addresses it (instance) and the broader class the finding represents.
2682
2683Return valid JSON only. Do not wrap it in Markdown. The schema is:
2684{
2685 "verdict": "PASS" | "REJECT" | "FLAG",
2686 "summary": "one concise sentence explaining whether the fix materially addresses each finding",
2687 "findings": [
2688 {
2689 "severity": "critical" | "high" | "medium" | "low",
2690 "title": "short defect title",
2691 "body": "what still fails after the fix, why this code is still vulnerable, and what evidence proves it",
2692 "file": "repo-relative file path",
2693 "line_start": 1,
2694 "line_end": 1,
2695 "confidence": 0,
2696 "recommendation": "concrete change required"
2697 }
2698 ],
2699 "next_steps": ["short concrete follow-up commands or edits"],
2700 "memory_skill": {
2701 "kind": "none" | "how_to_skill" | "anti_pattern_skill" | "remediation_skill",
2702 "learning_source": "short reusable procedure or failure class; empty only when kind is none",
2703 "reasoning": "why this memory-skill classification applies or why no candidate should be proposed"
2704 }
2705}
2706
2707Verdict semantics:
2708- "PASS" means the fix materially addresses every original finding. Return PASS only when findings is empty.
2709- "REJECT" means at least one finding is still materially unaddressed by the fix.
2710- "FLAG" means the fix materially addresses the findings but you want to surface process / evidence / provenance debt alongside the acceptance. Use FLAG for non-blocking advisory debt, never for unaddressed findings. Must include at least one finding in the "findings" array (the debt being surfaced) — a FLAG with an empty findings array fails schema validation.
2711
2712For "memory_skill", propose "how_to_skill" only for reusable verified PASS procedures, "anti_pattern_skill" for repeated false-claim or evidence failure classes, "remediation_skill" for repeated repair workflows, and "none" when the reviewed change is not reusable procedural memory."#;
2713
2714fn petition_prompt(petition: &PetitionContext, claim: &Claim, diff: &str, context: &str) -> String {
2715 let findings_block = if petition.original_structured_findings.is_empty() {
2716 petition.original_findings.join("\n")
2717 } else {
2718 petition
2719 .original_structured_findings
2720 .iter()
2721 .map(StructuredFinding::display_line)
2722 .collect::<Vec<_>>()
2723 .join("\n")
2724 };
2725 format!(
2726 "{PETITION_PREAMBLE}{}\n\nPETITION:\n- original rejection SHA: {}\n- fix commit SHA: {}\n- petition attempts so far: {}\n- original reviewer model: {}\n\nORIGINAL CLAIM:\n{}\n\nORIGINAL SUMMARY:\n{}\n\nORIGINAL FINDINGS:\n{}\n\nFIX COMMIT DIFF:\n{}\n\nCLAIM (for ledger):\n{}",
2727 context_block(context),
2728 petition.original_sha,
2729 petition.fix_sha,
2730 petition.attempts_so_far,
2731 petition.original_reviewer_model,
2732 petition.original_claim,
2733 petition.original_summary,
2734 if findings_block.trim().is_empty() {
2735 "(none recorded)".to_owned()
2736 } else {
2737 findings_block
2738 },
2739 diff,
2740 claim.to_line(),
2741 )
2742}
2743
2744fn strict_second_pass_prompt(job: &ReviewJob, first_output: &str) -> String {
2745 if let Some(petition) = &job.petition {
2750 let petition_question = petition_prompt(petition, &job.claim, &job.diff, &job.context);
2751 return format!(
2752 "{petition_question}\n\nSTRICT SECOND PASS (COMPLETENESS CRITIC): the first petition reviewer ACCEPTED that the fix materially addresses each original finding. Assume it verified some findings but not ALL of them. Re-check every original finding against the fix diff and prove the first reviewer INCOMPLETE.\n\nFIRST REVIEW:\n{first_output}"
2753 );
2754 }
2755 format!(
2756 "{ADVERSARIAL_PREAMBLE}\n\nStrict second pass (COMPLETENESS CRITIC): the first reviewer returned a CLEAN verdict. Assume it found a symptom but failed to generalize it to the full CLASS and enumerate every instance. Re-derive the classes of defect this change could contain, grep the repo for each, and prove the first reviewer INCOMPLETE.{}\n\nCLAIM:\n{}\n\nFIRST REVIEW:\n{}\n\nDIFF:\n{}",
2757 context_block(&job.context),
2758 job.claim.to_line(),
2759 first_output,
2760 job.diff
2761 )
2762}
2763
2764fn entry_from_verdict(job: &ReviewJob, plan: &ReviewPlan, verdict: &ParsedVerdict) -> LedgerEntry {
2765 let mut entry = LedgerEntry::new(
2766 job.commit_sha.clone(),
2767 verdict.verdict,
2768 job.claim.to_line(),
2769 job.claim
2770 .evidence
2771 .iter()
2772 .map(EvidenceRef::as_str)
2773 .map(str::to_owned)
2774 .collect(),
2775 plan.reviewer_config(),
2776 verdict.findings.clone(),
2777 )
2778 .with_structured_review(
2779 verdict.summary.clone(),
2780 verdict.structured_findings.clone(),
2781 verdict.next_steps.clone(),
2782 verdict.raw.clone(),
2783 )
2784 .with_memory_skill_classification(verdict.memory_skill_classification.clone());
2785 if let Some(petition) = &job.petition {
2786 entry.petition_for = Some(petition.original_sha.clone());
2787 entry.petition_attempts = petition.attempts_so_far;
2791 }
2792 entry
2793}
2794
2795fn ensure_process_success(output: &ProcessOutput) -> Result<(), ReviewerError> {
2796 if output.status_code == Some(0) {
2797 return Ok(());
2798 }
2799
2800 Err(ReviewerError::ReviewerProcessFailed {
2801 status: output.status_code,
2802 stderr: output.stderr.clone(),
2803 })
2804}
2805
2806fn validate_strict_arbiter(
2807 request: &ReviewRequest,
2808 strict: &StrictReviewConfig,
2809) -> Result<(), ReviewerError> {
2810 let arbiter = normalized_model(&strict.arbiter_model);
2811 if arbiter == normalized_model(&request.watched_model)
2812 || arbiter == normalized_model(&request.reviewer_model)
2813 {
2814 return Err(ReviewerError::StrictArbiterModelNotDistinct);
2815 }
2816 Ok(())
2817}
2818
2819fn validate_model_present(role: &str, model: &str) -> Result<(), ReviewerError> {
2820 if model.trim().is_empty() {
2821 return Err(ReviewerError::MissingModel {
2822 role: role.to_owned(),
2823 });
2824 }
2825 Ok(())
2826}
2827
2828fn git_output<const N: usize>(args: [&str; N]) -> Result<String, ReviewerError> {
2829 git_output_slice(&args)
2830}
2831
2832fn git_output_slice(args: &[&str]) -> Result<String, ReviewerError> {
2833 let output = Command::new("git")
2834 .args(args)
2835 .output()
2836 .map_err(ReviewerError::GitSpawn)?;
2837 if !output.status.success() {
2838 return Err(ReviewerError::GitFailed {
2839 args: args.iter().map(|arg| (*arg).to_owned()).collect(),
2840 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
2841 });
2842 }
2843
2844 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
2845}
2846
2847fn agent_from_slug(value: &str) -> Result<Agent, ReviewerError> {
2848 match value.trim().to_ascii_lowercase().as_str() {
2849 "claude" => Ok(Agent::Claude),
2850 "codex" => Ok(Agent::Codex),
2851 "pi" => Ok(Agent::Pi),
2852 "grok" => Ok(Agent::Grok),
2853 _ => Err(ReviewerError::UnknownAgent {
2854 value: value.to_owned(),
2855 }),
2856 }
2857}
2858
2859fn harness_from_slug(value: &str) -> Result<ReviewerHarness, ReviewerError> {
2860 match value.trim().to_ascii_lowercase().as_str() {
2861 "claude" => Ok(ReviewerHarness::Claude),
2862 "codex" => Ok(ReviewerHarness::Codex),
2863 "pi" => Ok(ReviewerHarness::Pi),
2864 "gemini" => Ok(ReviewerHarness::Gemini),
2865 "opencode" => Ok(ReviewerHarness::Opencode),
2866 "custom" => Ok(ReviewerHarness::Custom),
2867 _ => Err(ReviewerError::UnknownHarness {
2868 value: value.to_owned(),
2869 }),
2870 }
2871}
2872
2873fn harness_slug(harness: ReviewerHarness) -> &'static str {
2874 match harness {
2875 ReviewerHarness::Claude => "claude",
2876 ReviewerHarness::Codex => "codex",
2877 ReviewerHarness::Pi => "pi",
2878 ReviewerHarness::Gemini => "gemini",
2879 ReviewerHarness::Opencode => "opencode",
2880 ReviewerHarness::Custom => "custom",
2881 }
2882}
2883
2884fn normalized_model(model: &str) -> String {
2890 config::normalized_model(model)
2891}
2892
2893fn generate_run_id(commit_sha: &str) -> String {
2894 let nanos = SystemTime::now()
2895 .duration_since(UNIX_EPOCH)
2896 .map_or(0, |duration| duration.as_nanos());
2897 let short_sha: String = commit_sha
2898 .chars()
2899 .filter(|character| character.is_ascii_alphanumeric())
2900 .take(12)
2901 .collect();
2902 if short_sha.is_empty() {
2903 format!("{nanos}-{}", std::process::id())
2904 } else {
2905 format!("{nanos}-{}-{short_sha}", std::process::id())
2906 }
2907}
2908
2909#[cfg(test)]
2910mod tests {
2911 use std::{cell::RefCell, collections::VecDeque, fs, process::Command};
2912
2913 use super::normalized_model;
2914
2915 use proptest::prelude::*;
2916
2917 use super::{
2918 InvocationPlan, MaterialLoader, ParsedVerdict, ProcessOutput, ProcessRunner,
2919 PromptDelivery, ReviewJob, ReviewPlan, ReviewQueue, ReviewRequest, ReviewRun,
2920 ReviewRunStatus, ReviewRunStore, ReviewSelection, ReviewerError, StrictGoalCounters,
2921 StrictGoalDecision, StrictGoalPolicy, StrictGoalStopReason, StrictReviewConfig,
2922 UntilEmptyDecision, drain_once, execute_review_job, is_full_git_sha,
2923 run_review_run_command, run_strict_goal_loop, until_empty_decision,
2924 };
2925 use crate::{
2926 claim::{Claim, EvidenceRef},
2927 cli::{Agent, ReviewerHarness},
2928 config::{Effort, TruthMirrorConfig},
2929 ledger::{FindingSeverity, LedgerStore, StructuredFinding, Verdict},
2930 watcher::pid_is_alive,
2931 };
2932
2933 fn pass_json() -> String {
2934 serde_json::json!({
2935 "verdict": "PASS",
2936 "summary": "The claim is substantiated by the diff and evidence.",
2937 "findings": [],
2938 "next_steps": [],
2939 "memory_skill": {
2940 "kind": "how_to_skill",
2941 "learning_source": "run reusable review workflow",
2942 "reasoning": "The passing claim describes a reusable verified procedure."
2943 }
2944 })
2945 .to_string()
2946 }
2947
2948 fn reject_json(title: &str) -> String {
2949 serde_json::json!({
2950 "verdict": "REJECT",
2951 "summary": "The claim is not substantiated.",
2952 "findings": [{
2953 "severity": "high",
2954 "title": title,
2955 "body": "The cited evidence does not prove the claimed behavior.",
2956 "file": "src/lib.rs",
2957 "line_start": 1,
2958 "line_end": 1,
2959 "confidence": 95,
2960 "recommendation": "Provide executable evidence that proves the claim."
2961 }],
2962 "next_steps": ["Run the relevant verification command."],
2963 "memory_skill": {
2964 "kind": "anti_pattern_skill",
2965 "learning_source": title,
2966 "reasoning": "The rejection identifies a reusable false-claim failure class."
2967 }
2968 })
2969 .to_string()
2970 }
2971
2972 #[test]
2973 fn same_harness_different_model_is_valid() {
2974 let request = ReviewRequest::new(
2975 Agent::Codex,
2976 "gpt-5.4",
2977 ReviewerHarness::Codex,
2978 "gpt-5.5",
2979 false,
2980 "review this",
2981 );
2982
2983 let plan = ReviewPlan::build(request).unwrap();
2984
2985 assert_eq!(plan.watched_agent, Agent::Codex);
2986 assert_eq!(plan.reviewer_harness, ReviewerHarness::Codex);
2987 assert_eq!(plan.invocation.program, "codex");
2988 }
2989
2990 #[test]
2991 fn same_model_is_blocked_by_default() {
2992 let request = ReviewRequest::new(
2993 Agent::Codex,
2994 " GPT-5.5 ",
2995 ReviewerHarness::Claude,
2996 "gpt-5.5",
2997 false,
2998 "review this",
2999 );
3000
3001 let error = ReviewPlan::build(request).unwrap_err();
3002
3003 assert!(matches!(
3004 error,
3005 ReviewerError::SameModelWithoutWaiver { .. }
3006 ));
3007 }
3008
3009 #[test]
3010 fn allow_same_model_override_is_deliberate() {
3011 let request = ReviewRequest::new(
3012 Agent::Codex,
3013 "gpt-5.5",
3014 ReviewerHarness::Codex,
3015 "gpt-5.5",
3016 true,
3017 "review this",
3018 );
3019
3020 let plan = ReviewPlan::build(request).unwrap();
3021
3022 assert!(plan.allow_same_model);
3023 assert_eq!(plan.reviewer_model, "gpt-5.5");
3024 }
3025
3026 #[test]
3027 fn provider_mapping_uses_verified_prompt_shapes_and_effort() {
3028 let codex =
3029 InvocationPlan::for_harness(ReviewerHarness::Codex, "gpt-5.5", Effort::Xhigh).unwrap();
3030 assert_eq!(codex.program, "codex");
3031 assert_eq!(
3032 codex.args_for_prompt("prompt"),
3033 [
3034 "exec",
3035 "-m",
3036 "gpt-5.5",
3037 "-c",
3038 "model_reasoning_effort=xhigh",
3039 "prompt"
3040 ]
3041 );
3042
3043 let claude =
3044 InvocationPlan::for_harness(ReviewerHarness::Claude, "opus", Effort::High).unwrap();
3045 assert_eq!(claude.program, "claude");
3046 assert_eq!(claude.prompt_delivery, PromptDelivery::Stdin);
3047 assert_eq!(
3048 claude.args_for_prompt("prompt"),
3049 ["--print", "--model", "opus", "--effort", "high"]
3050 );
3051
3052 let gemini =
3053 InvocationPlan::for_harness(ReviewerHarness::Gemini, "gemini-pro", Effort::Xhigh)
3054 .unwrap();
3055 assert_eq!(
3056 gemini.args_for_prompt("prompt"),
3057 ["-m", "gemini-pro", "-p", "prompt"]
3058 );
3059
3060 let pi = InvocationPlan::for_harness(ReviewerHarness::Pi, "openai/gpt-5.5", Effort::Xhigh)
3061 .unwrap();
3062 assert_eq!(pi.prompt_delivery, PromptDelivery::Stdin);
3063 assert_eq!(
3064 pi.args_for_prompt("prompt"),
3065 [
3066 "--model",
3067 "openai/gpt-5.5",
3068 "--thinking",
3069 "xhigh",
3070 "--tools",
3071 "read,grep,find,ls",
3072 "-p"
3073 ]
3074 );
3075 }
3076
3077 #[test]
3078 fn custom_harness_requires_explicit_configuration() {
3079 let error = InvocationPlan::for_harness(ReviewerHarness::Custom, "model", Effort::Xhigh)
3080 .unwrap_err();
3081
3082 assert!(matches!(error, ReviewerError::UnsupportedCustomHarness));
3083 }
3084
3085 #[test]
3086 fn effort_maps_to_each_harness_flag() {
3087 for effort in [
3088 Effort::Minimal,
3089 Effort::Low,
3090 Effort::Medium,
3091 Effort::High,
3092 Effort::Xhigh,
3093 ] {
3094 let e = effort.as_str();
3095
3096 let codex = InvocationPlan::for_harness(ReviewerHarness::Codex, "m", effort).unwrap();
3097 assert!(codex.args.contains(&format!("model_reasoning_effort={e}")));
3098
3099 let claude = InvocationPlan::for_harness(ReviewerHarness::Claude, "m", effort).unwrap();
3100 let claude_idx = claude.args.iter().position(|a| a == "--effort").unwrap();
3101 assert_eq!(claude.args[claude_idx + 1], effort.claude_value());
3103 assert_ne!(claude.args[claude_idx + 1], "minimal");
3104
3105 let pi = InvocationPlan::for_harness(ReviewerHarness::Pi, "m", effort).unwrap();
3106 let pi_idx = pi.args.iter().position(|a| a == "--thinking").unwrap();
3107 assert_eq!(pi.args[pi_idx + 1], e);
3108 }
3109 }
3110
3111 #[test]
3112 fn resolve_picks_configured_reviewer_for_every_writer() {
3113 let config = crate::config::TruthMirrorConfig::default();
3114
3115 let cases = [
3116 (Agent::Codex, ReviewerHarness::Claude, "claude-opus-4-8"),
3117 (Agent::Claude, ReviewerHarness::Codex, "gpt-5.5"),
3118 (Agent::Pi, ReviewerHarness::Codex, "gpt-5.5"),
3119 (Agent::Grok, ReviewerHarness::Claude, "claude-opus-4-8"),
3120 ];
3121
3122 for (writer, reviewer_harness, reviewer_model) in cases {
3123 let selection =
3124 ReviewSelection::resolve(Some(writer), None, None, None, None, false, &config)
3125 .unwrap();
3126
3127 assert_eq!(selection.reviewer_harness, reviewer_harness);
3128 assert_eq!(selection.reviewer_model, reviewer_model);
3129 assert_eq!(selection.reviewer_effort, Effort::Xhigh);
3130 }
3131 }
3132
3133 #[test]
3134 fn overriding_reviewer_harness_without_model_is_rejected() {
3135 let config = crate::config::TruthMirrorConfig::default();
3138 let error = ReviewSelection::resolve(
3139 Some(Agent::Codex),
3140 None,
3141 Some(ReviewerHarness::Pi),
3142 None,
3143 None,
3144 false,
3145 &config,
3146 )
3147 .unwrap_err();
3148
3149 assert!(matches!(error, ReviewerError::OverrideNeedsModel { .. }));
3150 }
3151
3152 #[test]
3153 fn overriding_reviewer_harness_matching_pair_is_ok() {
3154 let config = crate::config::TruthMirrorConfig::default();
3155 let selection = ReviewSelection::resolve(
3156 Some(Agent::Codex),
3157 None,
3158 Some(ReviewerHarness::Claude),
3159 None,
3160 None,
3161 false,
3162 &config,
3163 )
3164 .unwrap();
3165
3166 assert_eq!(selection.reviewer_harness, ReviewerHarness::Claude);
3167 assert_eq!(selection.reviewer_model, "claude-opus-4-8");
3168 }
3169
3170 #[test]
3171 fn config_allow_same_model_waives_opposition() {
3172 let config = crate::config::TruthMirrorConfig {
3173 allow_same_model: true,
3174 ..crate::config::TruthMirrorConfig::default()
3175 };
3176
3177 let selection = ReviewSelection::resolve(
3178 Some(Agent::Codex),
3179 Some("gpt-5.5".to_owned()),
3180 Some(ReviewerHarness::Codex),
3181 Some("gpt-5.5".to_owned()),
3182 None,
3183 false, &config,
3185 )
3186 .unwrap();
3187
3188 assert!(selection.allow_same_model);
3189 assert!(ReviewPlan::build(selection.request_for("review".to_owned())).is_ok());
3191 }
3192
3193 #[test]
3194 fn full_git_sha_accepts_sha1_and_sha256_lengths() {
3195 assert!(is_full_git_sha(&"a".repeat(40)));
3196 assert!(is_full_git_sha(&"b".repeat(64)));
3197 assert!(!is_full_git_sha(&"c".repeat(39)));
3198 assert!(!is_full_git_sha(&"g".repeat(40)));
3199 }
3200
3201 #[test]
3202 fn resolve_arbiter_uses_pair_when_cli_absent() {
3203 let config = crate::config::TruthMirrorConfig::default();
3204 let arbiter =
3205 ReviewSelection::resolve_arbiter(Agent::Codex, None, None, None, &config).unwrap();
3206
3207 assert_eq!(arbiter.arbiter_harness, ReviewerHarness::Pi);
3208 assert_eq!(arbiter.arbiter_effort, Effort::Xhigh);
3209 }
3210
3211 #[test]
3212 fn first_pass_prompt_is_adversarial_and_injects_context() {
3213 let prompt = super::first_pass_prompt(
3214 &claim(),
3215 "THE_DIFF_BODY",
3216 "INVIOLABLE CONSTRAINTS: never fake tests",
3217 );
3218
3219 assert!(prompt.contains("PROVE THIS CLAIM FALSE"));
3220 assert!(prompt.contains("default to REJECT"));
3221 assert!(prompt.contains("INVIOLABLE CONSTRAINTS: never fake tests"));
3222 assert!(prompt.contains("THE_DIFF_BODY"));
3223 assert!(prompt.contains("GREP THE CLASS, NOT THE INSTANCE"));
3225 assert!(prompt.contains("\"severity\""));
3226 assert!(prompt.contains("\"recommendation\""));
3227 assert!(prompt.contains("\"memory_skill\""));
3228 assert!(prompt.contains("\"anti_pattern_skill\""));
3229 }
3230
3231 #[test]
3232 fn strict_second_pass_is_a_completeness_critic() {
3233 let job = review_job(true);
3234 let first_output = pass_json();
3235 let prompt = super::strict_second_pass_prompt(&job, &first_output);
3236
3237 assert!(prompt.contains("COMPLETENESS CRITIC"));
3238 assert!(prompt.contains("generalize"));
3239 assert!(prompt.contains("GREP THE CLASS, NOT THE INSTANCE"));
3241 }
3242
3243 #[test]
3244 fn prompt_omits_context_block_when_empty() {
3245 let prompt = super::first_pass_prompt(&claim(), "d", "");
3246 assert!(!prompt.contains("INVIOLABLE CONSTRAINTS"));
3248 assert!(prompt.contains("PROVE THIS CLAIM FALSE"));
3249 }
3250
3251 fn sample_petition(attempts_so_far: u32) -> super::PetitionContext {
3252 super::PetitionContext {
3253 original_sha: "abc123".to_owned(),
3254 fix_sha: "def456".to_owned(),
3255 original_claim: "CLAIM: original | verified: cargo test | evidence: tests:cargo-test"
3256 .to_owned(),
3257 original_summary: "Original rejection summary.".to_owned(),
3258 original_findings: vec!["evidence too thin".to_owned()],
3259 original_structured_findings: vec![StructuredFinding {
3260 severity: FindingSeverity::High,
3261 title: "thin evidence".to_owned(),
3262 body: "Evidence pointer does not prove the claim.".to_owned(),
3263 file: "src/lib.rs".to_owned(),
3264 line_start: 1,
3265 line_end: 2,
3266 confidence: 90,
3267 recommendation: "Add executable evidence.".to_owned(),
3268 }],
3269 original_reviewer_model: "claude-opus-4-1".to_owned(),
3270 attempts_so_far,
3271 }
3272 }
3273
3274 #[test]
3275 fn petition_prompt_includes_original_findings_fix_sha_and_attempts() {
3276 let prompt = super::petition_prompt(&sample_petition(1), &claim(), "DIFF BODY", "");
3277
3278 assert!(prompt.contains("PETITION"));
3279 assert!(prompt.contains("original rejection SHA: abc123"));
3280 assert!(prompt.contains("fix commit SHA: def456"));
3281 assert!(prompt.contains("petition attempts so far: 1"));
3282 assert!(prompt.contains("Original rejection summary."));
3283 assert!(prompt.contains("thin evidence")); assert!(prompt.contains("DIFF BODY"));
3285 assert!(prompt.contains("\"verdict\": \"PASS\" | \"REJECT\" | \"FLAG\""));
3286 }
3287
3288 #[test]
3289 fn materialize_petition_prompt_rewrites_request_prompt() {
3290 let mut job = review_job(false);
3291 job.petition = Some(sample_petition(2));
3292 let original_prompt = job.request.prompt.clone();
3293 let updated = super::materialize_petition_prompt(job);
3294
3295 assert_ne!(updated.request.prompt, original_prompt);
3296 assert!(updated.request.prompt.contains("PETITION"));
3297 assert!(updated.request.prompt.contains("fix commit SHA: def456"));
3298 assert!(
3299 updated
3300 .request
3301 .prompt
3302 .contains("petition attempts so far: 2")
3303 );
3304 }
3305
3306 #[test]
3307 fn materialize_petition_prompt_passthrough_when_no_petition() {
3308 let job = review_job(false);
3309 let original_prompt = job.request.prompt.clone();
3310 let updated = super::materialize_petition_prompt(job);
3311 assert_eq!(updated.request.prompt, original_prompt);
3312 }
3313
3314 #[test]
3315 fn subprocess_runner_is_mockable() {
3316 struct MockRunner;
3317
3318 impl ProcessRunner for MockRunner {
3319 fn run(
3320 &self,
3321 invocation: &InvocationPlan,
3322 prompt: &str,
3323 ) -> Result<ProcessOutput, ReviewerError> {
3324 assert_eq!(invocation.program, "codex");
3325 assert_eq!(
3326 invocation.args_for_prompt(prompt).last().unwrap(),
3327 "review this"
3328 );
3329 Ok(ProcessOutput {
3330 status_code: Some(0),
3331 stdout: pass_json(),
3332 stderr: String::new(),
3333 })
3334 }
3335 }
3336
3337 let request = ReviewRequest::new(
3338 Agent::Codex,
3339 "gpt-5.4",
3340 ReviewerHarness::Codex,
3341 "gpt-5.5",
3342 false,
3343 "review this",
3344 );
3345 let plan = ReviewPlan::build(request).unwrap();
3346 let output = plan.run_with("review this", &MockRunner).unwrap();
3347
3348 assert!(output.stdout.contains("PASS"));
3349 }
3350
3351 #[test]
3352 fn verdict_parser_extracts_rejection_findings() {
3353 let verdict = ParsedVerdict::parse(&reject_json("missing proof")).unwrap();
3354
3355 assert_eq!(verdict.verdict, Verdict::Reject);
3356 assert_eq!(verdict.structured_findings[0].title, "missing proof");
3357 assert_eq!(verdict.structured_findings[0].confidence, 95);
3358 assert!(verdict.findings[0].contains("missing proof"));
3359 assert_eq!(
3360 verdict.memory_skill_classification.learning_source,
3361 "missing proof"
3362 );
3363 }
3364
3365 fn flag_json(title: &str) -> String {
3366 serde_json::json!({
3367 "verdict": "FLAG",
3368 "summary": "Claim substantiated; flagging thin evidence.",
3369 "findings": [{
3370 "severity": "medium",
3371 "title": title,
3372 "body": "Evidence pointer is thin but the claim holds.",
3373 "file": "src/lib.rs",
3374 "line_start": 1,
3375 "line_end": 1,
3376 "confidence": 60,
3377 "recommendation": "Tighten evidence."
3378 }],
3379 "next_steps": ["Add stronger evidence."],
3380 "memory_skill": {
3381 "kind": "none",
3382 "learning_source": "",
3383 "reasoning": "No reusable procedural memory to propose."
3384 }
3385 })
3386 .to_string()
3387 }
3388
3389 #[test]
3390 fn verdict_parser_extracts_flag_findings() {
3391 let verdict = ParsedVerdict::parse(&flag_json("thin evidence")).unwrap();
3392
3393 assert_eq!(verdict.verdict, Verdict::Flag);
3394 assert_eq!(verdict.structured_findings[0].title, "thin evidence");
3395 assert_eq!(verdict.structured_findings[0].confidence, 60);
3396 assert!(verdict.findings[0].contains("thin evidence"));
3397 }
3398
3399 #[test]
3400 fn verdict_parser_rejects_flag_with_no_findings() {
3401 let output = serde_json::json!({
3402 "verdict": "FLAG",
3403 "summary": "Claim substantiated but flagging.",
3404 "findings": [],
3405 "next_steps": [],
3406 "memory_skill": {
3407 "kind": "none",
3408 "learning_source": "",
3409 "reasoning": "no reusable memory"
3410 }
3411 });
3412
3413 let error = ParsedVerdict::parse(&output.to_string()).unwrap_err();
3414
3415 assert!(matches!(error, ReviewerError::VerdictSchema { .. }));
3416 }
3417
3418 #[test]
3419 fn verdict_parser_accepts_lowercase_flag_via_schema() {
3420 let output = serde_json::json!({
3425 "verdict": "flag",
3426 "summary": "Claim substantiated but flagging.",
3427 "findings": [{
3428 "severity": "low",
3429 "title": "x",
3430 "body": "y",
3431 "file": "src/lib.rs",
3432 "line_start": 1,
3433 "line_end": 1,
3434 "confidence": 50,
3435 "recommendation": "z"
3436 }],
3437 "next_steps": [],
3438 "memory_skill": {
3439 "kind": "none",
3440 "learning_source": "",
3441 "reasoning": "none"
3442 }
3443 });
3444
3445 let error = ParsedVerdict::parse(&output.to_string()).unwrap_err();
3446 assert!(matches!(error, ReviewerError::VerdictJson { .. }));
3447 }
3448
3449 #[test]
3450 fn verdict_parser_rejects_missing_memory_skill_classification() {
3451 let output = serde_json::json!({
3452 "verdict": "PASS",
3453 "summary": "The claim is substantiated.",
3454 "findings": [],
3455 "next_steps": []
3456 });
3457
3458 let error = ParsedVerdict::parse(&output.to_string()).unwrap_err();
3459
3460 assert!(matches!(error, ReviewerError::VerdictJson { .. }));
3461 }
3462
3463 #[test]
3464 fn verdict_parser_rejects_incompatible_memory_skill_classification() {
3465 let mut output: serde_json::Value = serde_json::from_str(&pass_json()).unwrap();
3466 output["memory_skill"]["kind"] = serde_json::json!("remediation_skill");
3467
3468 let error = ParsedVerdict::parse(&output.to_string()).unwrap_err();
3469
3470 assert!(matches!(error, ReviewerError::VerdictSchema { .. }));
3471 }
3472
3473 #[test]
3474 fn verdict_parser_accepts_normalized_float_confidence() {
3475 let mut output: serde_json::Value =
3476 serde_json::from_str(&reject_json("missing proof")).unwrap();
3477 output["findings"][0]["confidence"] = serde_json::json!(0.95);
3478
3479 let verdict = ParsedVerdict::parse(&output.to_string()).unwrap();
3480
3481 assert_eq!(verdict.structured_findings[0].confidence, 95);
3482 }
3483
3484 #[test]
3485 fn verdict_parser_rejects_legacy_line_protocol() {
3486 let error =
3487 ParsedVerdict::parse("VERDICT: REJECT\nFINDINGS:\n- missing proof\n").unwrap_err();
3488
3489 assert!(matches!(error, ReviewerError::VerdictJson { .. }));
3490 }
3491
3492 #[test]
3493 fn large_diff_materialization_falls_back_to_file_summary() {
3494 let files = "a.rs\nb.rs\nc.rs\n";
3495 let materialized = super::materialize_diff("branch:main", "tiny diff", files);
3496
3497 assert!(materialized.contains("too large to inline safely"));
3498 assert!(materialized.contains("actual_files=3"));
3499 assert!(materialized.contains("a.rs\nb.rs\nc.rs"));
3500 assert!(materialized.contains("inspect the repository directly"));
3501 }
3502
3503 #[test]
3504 fn review_queue_schedules_commits_without_running_models() {
3505 let temp = tempfile::tempdir().unwrap();
3506 let queue = ReviewQueue::new(temp.path());
3507
3508 queue.enqueue("abc123").unwrap();
3509
3510 let pending = queue.pending().unwrap();
3511 assert_eq!(pending.len(), 1);
3512 assert_eq!(pending[0].commit_sha, "abc123");
3513 assert!(!pending[0].run_id.is_empty());
3514
3515 let run = ReviewRunStore::new(temp.path())
3516 .read(&pending[0].run_id)
3517 .unwrap();
3518 assert_eq!(run.commit_sha, "abc123");
3519 assert_eq!(run.status, ReviewRunStatus::Queued);
3520 assert_eq!(run.entire_checkpoint, None);
3521 }
3522
3523 #[test]
3524 fn review_queue_summary_counts_pending_and_oldest() {
3525 let temp = tempfile::tempdir().unwrap();
3526 let queue = ReviewQueue::new(temp.path());
3527 std::fs::write(
3528 queue.path(),
3529 "{\"commit_sha\":\"abc123\",\"enqueued_at_unix\":30}\n{\"commit_sha\":\"def456\",\"enqueued_at_unix\":10}\n",
3530 )
3531 .unwrap();
3532
3533 let summary = queue.summary().unwrap();
3534
3535 assert_eq!(summary.pending_count, 2);
3536 assert_eq!(summary.oldest_enqueued_at_unix, Some(10));
3537 assert_eq!(summary.oldest_age_secs_at(42), Some(32));
3538 }
3539
3540 #[test]
3541 fn review_run_serializes_optional_entire_checkpoint() {
3542 let run = ReviewRun::queued_with_provenance(
3543 "run-id",
3544 "abcdef123456",
3545 "commit",
3546 Some(crate::provenance::EntireCheckpointRef {
3547 ref_name: "entire/session-abcdef".to_owned(),
3548 object_sha: "123456".to_owned(),
3549 }),
3550 );
3551
3552 let json = serde_json::to_string(&run).unwrap();
3553
3554 assert!(json.contains("entire_checkpoint"));
3555 assert!(json.contains("entire/session-abcdef"));
3556 }
3557
3558 #[test]
3559 fn review_run_omits_absent_optional_entire_checkpoint() {
3560 let run = ReviewRun::queued("run-id", "abcdef123456", "commit");
3561
3562 let json = serde_json::to_string(&run).unwrap();
3563
3564 assert!(!json.contains("entire_checkpoint"));
3565 }
3566
3567 #[test]
3568 fn review_run_status_counts_read_only_status_fields() {
3569 let temp = tempfile::tempdir().unwrap();
3570 let store = ReviewRunStore::new(temp.path());
3571 let queued = store.create_queued("abc123", "commit").unwrap();
3572 let mut failed = ReviewRun::queued("failed-run", "def456", "commit");
3573 failed.mark_failed("boom");
3574 store.write(&failed).unwrap();
3575 fs::write(store.path("malformed-run"), "{not-json\n").unwrap();
3576
3577 let counts = store.status_counts().unwrap();
3578
3579 assert_eq!(counts.queued, 1);
3580 assert_eq!(counts.failed, 1);
3581 assert_eq!(counts.running, 0);
3582 assert_eq!(counts.skipped_records, 1);
3583 assert_eq!(queued.status, ReviewRunStatus::Queued);
3584 }
3585
3586 #[test]
3587 fn review_cancel_marks_queued_run_and_removes_queue_item() {
3588 let temp = tempfile::tempdir().unwrap();
3589 let queue = ReviewQueue::new(temp.path());
3590 let queued = queue.enqueue("abc123").unwrap();
3591
3592 run_review_run_command(
3593 crate::cli::ReviewCommand::Cancel {
3594 run_id: queued.run_id.clone(),
3595 force: false,
3596 },
3597 temp.path(),
3598 )
3599 .unwrap();
3600
3601 assert!(queue.pending().unwrap().is_empty());
3602 let run = ReviewRunStore::new(temp.path())
3603 .read(&queued.run_id)
3604 .unwrap();
3605 assert_eq!(run.status, ReviewRunStatus::Cancelled);
3606 }
3607
3608 fn reaped_pid() -> u32 {
3610 let mut child = Command::new("true").spawn().expect("spawn `true`");
3611 let pid = child.id();
3612 child.wait().expect("reap `true`");
3613 pid
3614 }
3615
3616 fn write_running_run(store: &ReviewRunStore, worker_pid: Option<u32>) -> ReviewRun {
3619 let mut run = store.create_queued("abc123", "commit").unwrap();
3620 run.status = ReviewRunStatus::Running;
3621 run.phase = "reviewing".to_owned();
3622 run.worker_pid = worker_pid;
3623 store.write(&run).unwrap();
3624 run
3625 }
3626
3627 #[test]
3628 fn pid_liveness_probe_tracks_real_processes() {
3629 assert!(pid_is_alive(std::process::id()));
3630 assert!(!pid_is_alive(reaped_pid()));
3631 }
3632
3633 #[test]
3634 fn reconcile_liveness_only_reaps_dead_running_runs() {
3635 let mut queued = ReviewRun::queued("id", "abc123", "commit");
3636 assert!(!queued.reconcile_liveness(|_| false));
3638 assert_eq!(queued.status, ReviewRunStatus::Queued);
3639
3640 queued.mark_running("reviewing");
3641 assert!(!queued.reconcile_liveness(|_| true));
3643 assert_eq!(queued.status, ReviewRunStatus::Running);
3644 assert!(queued.reconcile_liveness(|_| false));
3646 assert_eq!(queued.status, ReviewRunStatus::Failed);
3647 assert!(queued.error.as_deref().unwrap().contains("stale run"));
3648 assert!(queued.worker_pid.is_none());
3649
3650 let mut legacy = ReviewRun::queued("id2", "def456", "commit");
3652 legacy.status = ReviewRunStatus::Running;
3653 legacy.worker_pid = None;
3654 assert!(!legacy.reconcile_liveness(|_| false));
3655 assert_eq!(legacy.status, ReviewRunStatus::Running);
3656 }
3657
3658 #[test]
3659 fn review_status_reaps_running_run_with_dead_worker_and_persists() {
3660 let temp = tempfile::tempdir().unwrap();
3661 let store = ReviewRunStore::new(temp.path());
3662 let run = write_running_run(&store, Some(reaped_pid()));
3663
3664 let reconciled = store.read_reconciled(&run.id).unwrap();
3665 assert_eq!(reconciled.status, ReviewRunStatus::Failed);
3666 assert!(reconciled.error.as_deref().unwrap().contains("stale run"));
3667
3668 assert_eq!(store.read(&run.id).unwrap().status, ReviewRunStatus::Failed);
3670 let listed = store.list_reconciled().unwrap();
3672 assert_eq!(listed.len(), 1);
3673 assert_eq!(listed[0].status, ReviewRunStatus::Failed);
3674 }
3675
3676 #[test]
3677 fn review_status_leaves_running_run_with_live_worker() {
3678 let temp = tempfile::tempdir().unwrap();
3679 let store = ReviewRunStore::new(temp.path());
3680 let run = write_running_run(&store, Some(std::process::id()));
3681
3682 let reconciled = store.read_reconciled(&run.id).unwrap();
3683 assert_eq!(reconciled.status, ReviewRunStatus::Running);
3684 }
3685
3686 #[test]
3687 fn cancel_reaps_running_run_with_dead_worker_without_force() {
3688 let temp = tempfile::tempdir().unwrap();
3689 let store = ReviewRunStore::new(temp.path());
3690 let run = write_running_run(&store, Some(reaped_pid()));
3691
3692 let cancelled = store.cancel(&run.id, false).unwrap();
3693 assert_eq!(cancelled.status, ReviewRunStatus::Failed);
3694 assert!(cancelled.error.as_deref().unwrap().contains("stale run"));
3695 }
3696
3697 #[test]
3698 fn cancel_refuses_live_running_run_without_force() {
3699 let temp = tempfile::tempdir().unwrap();
3700 let store = ReviewRunStore::new(temp.path());
3701 let run = write_running_run(&store, Some(std::process::id()));
3702
3703 let error = store.cancel(&run.id, false).unwrap_err();
3704 assert!(matches!(error, ReviewerError::ReviewRunStillAlive { .. }));
3705 assert_eq!(
3707 store.read(&run.id).unwrap().status,
3708 ReviewRunStatus::Running
3709 );
3710 }
3711
3712 #[test]
3713 fn cancel_force_kills_live_worker_and_cancels() {
3714 let temp = tempfile::tempdir().unwrap();
3715 let store = ReviewRunStore::new(temp.path());
3716 let mut child = Command::new("sleep")
3717 .arg("30")
3718 .spawn()
3719 .expect("spawn sleep");
3720 let pid = child.id();
3721 let run = write_running_run(&store, Some(pid));
3722
3723 let cancelled = store.cancel(&run.id, true).unwrap();
3724 assert_eq!(cancelled.status, ReviewRunStatus::Cancelled);
3725
3726 let _ = child.wait();
3728 assert!(!pid_is_alive(pid));
3729 }
3730
3731 #[test]
3732 fn cancel_legacy_running_run_requires_force_then_reaps() {
3733 let temp = tempfile::tempdir().unwrap();
3734 let store = ReviewRunStore::new(temp.path());
3735 let run = write_running_run(&store, None);
3736
3737 let error = store.cancel(&run.id, false).unwrap_err();
3738 assert!(matches!(
3739 error,
3740 ReviewerError::ReviewRunLivenessUnknown { .. }
3741 ));
3742
3743 let reaped = store.cancel(&run.id, true).unwrap();
3744 assert_eq!(reaped.status, ReviewRunStatus::Failed);
3745 }
3746
3747 #[test]
3748 fn cancel_refuses_already_terminal_run() {
3749 let temp = tempfile::tempdir().unwrap();
3750 let store = ReviewRunStore::new(temp.path());
3751 let run = store.create_queued("abc123", "commit").unwrap();
3752 store.mark_completed(&run.id, 0).unwrap();
3753
3754 let error = store.cancel(&run.id, true).unwrap_err();
3755 assert!(matches!(
3756 error,
3757 ReviewerError::CannotCancelReview {
3758 status: ReviewRunStatus::Completed,
3759 ..
3760 }
3761 ));
3762 }
3763
3764 #[test]
3765 fn execute_review_records_reject_verdict() {
3766 let temp = tempfile::tempdir().unwrap();
3767 let store = LedgerStore::new(temp.path());
3768 let job = review_job(false);
3769 let runner = SequenceRunner::new([reject_json("unsupported")]);
3770
3771 let execution = execute_review_job(job, &runner, &store).unwrap();
3772
3773 assert_eq!(execution.entries.len(), 1);
3774 assert_eq!(execution.entries[0].verdict, Verdict::Reject);
3775 assert_eq!(
3776 execution.entries[0].structured_findings[0].title,
3777 "unsupported"
3778 );
3779 assert!(
3780 execution.entries[0]
3781 .raw_reviewer_output
3782 .contains("\"REJECT\"")
3783 );
3784 assert_eq!(store.unresolved_rejections().unwrap().len(), 1);
3785 }
3786
3787 #[test]
3788 fn strict_two_pass_records_both_clean_passes() {
3789 let temp = tempfile::tempdir().unwrap();
3790 let store = LedgerStore::new(temp.path());
3791 let job = review_job(true);
3792 let runner = SequenceRunner::new([pass_json(), pass_json()]);
3793
3794 let execution = execute_review_job(job, &runner, &store).unwrap();
3795
3796 assert_eq!(execution.entries.len(), 2);
3797 assert_eq!(store.read_history().unwrap().len(), 2);
3798 assert_eq!(execution.entries[0].reviewer.model, "gpt-5.5");
3799 assert_eq!(execution.entries[1].reviewer.model, "claude-opus-4-8");
3800 }
3801
3802 #[test]
3803 fn strict_arbiter_model_must_be_third_model() {
3804 let temp = tempfile::tempdir().unwrap();
3805 let store = LedgerStore::new(temp.path());
3806 let mut job = review_job(true);
3807 job.strict.as_mut().unwrap().arbiter_model = "gpt-5.5".to_owned();
3808 let runner = SequenceRunner::new([pass_json()]);
3809
3810 let error = execute_review_job(job, &runner, &store).unwrap_err();
3811
3812 assert!(matches!(
3813 error,
3814 ReviewerError::StrictArbiterModelNotDistinct
3815 ));
3816 }
3817
3818 #[test]
3819 fn strict_goal_policy_stops_at_configured_lie_or_fuckup_count() {
3820 let policy = StrictGoalPolicy {
3821 stop_after_lies: 2,
3822 stop_after_fuckups: 3,
3823 };
3824
3825 assert_eq!(
3826 policy.decide(StrictGoalCounters {
3827 lies_exposed: 1,
3828 fuckups_registered: 2
3829 }),
3830 StrictGoalDecision::Continue
3831 );
3832 assert_eq!(
3833 policy.decide(StrictGoalCounters {
3834 lies_exposed: 2,
3835 fuckups_registered: 0
3836 }),
3837 StrictGoalDecision::Stop {
3838 reason: StrictGoalStopReason::LiesExposed
3839 }
3840 );
3841 assert_eq!(
3842 policy.decide(StrictGoalCounters {
3843 lies_exposed: 0,
3844 fuckups_registered: 3
3845 }),
3846 StrictGoalDecision::Stop {
3847 reason: StrictGoalStopReason::FuckupsRegistered
3848 }
3849 );
3850 }
3851
3852 #[test]
3853 fn drain_once_reviews_each_commit_once_and_clears_queue() {
3854 let temp = tempfile::tempdir().unwrap();
3855 let store = LedgerStore::new(temp.path());
3856 let queue = ReviewQueue::new(temp.path());
3857 queue.enqueue("abc123").unwrap();
3858 queue.enqueue("abc123").unwrap(); queue.enqueue("def456").unwrap();
3860
3861 let loader = StaticLoader::new();
3862 let runner = SequenceRunner::new([reject_json("unsupported"), pass_json()]);
3863 let selection = selection();
3864
3865 let report = drain_once(
3866 &queue,
3867 &loader,
3868 &selection,
3869 "",
3870 &runner,
3871 &store,
3872 &TruthMirrorConfig::default(),
3873 )
3874 .unwrap();
3875
3876 assert_eq!(report.reviewed, ["abc123", "def456"]);
3877 assert_eq!(report.ledger_entries, 2);
3878 assert!(queue.pending().unwrap().is_empty());
3879 assert_eq!(store.read_history().unwrap().len(), 2);
3880 assert_eq!(store.unresolved_rejections().unwrap().len(), 1);
3881
3882 let runs = ReviewRunStore::new(temp.path()).list().unwrap();
3883 assert_eq!(runs.len(), 3);
3884 assert_eq!(
3885 runs.iter()
3886 .filter(|run| run.status == ReviewRunStatus::Completed)
3887 .count(),
3888 2
3889 );
3890 assert_eq!(
3891 runs.iter()
3892 .filter(|run| run.status == ReviewRunStatus::Cancelled)
3893 .count(),
3894 1
3895 );
3896 }
3897
3898 #[test]
3899 fn drain_once_skips_a_poisoned_claim_and_reviews_surrounding_items() {
3900 let temp = tempfile::tempdir().unwrap();
3901 let store = LedgerStore::new(temp.path());
3902 let queue = ReviewQueue::new(temp.path());
3903 queue.enqueue("good-one").unwrap();
3904 queue.enqueue("poisoned").unwrap();
3905 queue.enqueue("good-two").unwrap();
3906
3907 let loader = PoisonedLoader;
3908 let runner = ConstRunner::new(pass_json());
3909 let report = drain_once(
3910 &queue,
3911 &loader,
3912 &selection(),
3913 "",
3914 &runner,
3915 &store,
3916 &TruthMirrorConfig::default(),
3917 )
3918 .unwrap();
3919
3920 assert_eq!(report.reviewed, ["good-one", "good-two"]);
3921 assert_eq!(report.failed, ["poisoned"]);
3922 assert!(queue.pending().unwrap().is_empty());
3923 assert_eq!(store.read_history().unwrap().len(), 2);
3924 let poisoned_run = ReviewRunStore::new(temp.path())
3925 .list()
3926 .unwrap()
3927 .into_iter()
3928 .find(|run| run.commit_sha == "poisoned")
3929 .unwrap();
3930 assert_eq!(poisoned_run.status, ReviewRunStatus::Failed);
3931 assert!(
3932 poisoned_run
3933 .error
3934 .as_deref()
3935 .is_some_and(|error| error.contains("missing evidence pointer"))
3936 );
3937 }
3938
3939 #[test]
3940 fn drain_once_completes_when_memory_skill_extraction_fails() {
3941 let temp = tempfile::tempdir().unwrap();
3942 let store = LedgerStore::new(temp.path());
3943 let queue = ReviewQueue::new(temp.path());
3944 let sha = "a".repeat(40);
3945 queue.enqueue(&sha).unwrap();
3946 let loader = StaticLoader::new();
3947 let runner = ConstRunner::new(pass_json());
3948 let mut config = TruthMirrorConfig::default();
3949 config.skills.enabled = true;
3950 config.memory_skill.enabled = true;
3951 config.memory_skill.signals.min_occurrences = 1;
3952 config.memory_skill.scan.blocked_patterns = vec!["Capture a verified procedure".to_owned()];
3953
3954 let report =
3955 drain_once(&queue, &loader, &selection(), "", &runner, &store, &config).unwrap();
3956
3957 assert_eq!(report.reviewed, [sha]);
3958 assert_eq!(report.ledger_entries, 1);
3959 assert!(queue.pending().unwrap().is_empty());
3960 assert_eq!(store.read_history().unwrap().len(), 1);
3961 let candidate_dir =
3962 crate::memory_skill::MemorySkillStore::new(temp.path(), &config.memory_skill)
3963 .unwrap()
3964 .candidate_dir()
3965 .to_path_buf();
3966 assert!(!candidate_dir.exists());
3967 }
3968
3969 #[test]
3970 fn drain_once_is_a_noop_on_empty_queue() {
3971 let temp = tempfile::tempdir().unwrap();
3972 let store = LedgerStore::new(temp.path());
3973 let queue = ReviewQueue::new(temp.path());
3974 let loader = StaticLoader::new();
3975 let runner = ConstRunner::new(pass_json());
3976
3977 let report = drain_once(
3978 &queue,
3979 &loader,
3980 &selection(),
3981 "",
3982 &runner,
3983 &store,
3984 &TruthMirrorConfig::default(),
3985 )
3986 .unwrap();
3987
3988 assert!(report.reviewed.is_empty());
3989 assert_eq!(report.ledger_entries, 0);
3990 assert_eq!(store.read_history().unwrap().len(), 0);
3991 }
3992
3993 #[test]
3994 fn until_empty_keeps_watching_while_queue_has_items() {
3995 assert_eq!(
3997 until_empty_decision(None, 60),
3998 UntilEmptyDecision::KeepWatching
3999 );
4000 }
4001
4002 #[test]
4003 fn until_empty_keeps_watching_inside_grace_window() {
4004 assert_eq!(
4006 until_empty_decision(Some(0), 60),
4007 UntilEmptyDecision::KeepWatching
4008 );
4009 assert_eq!(
4010 until_empty_decision(Some(59), 60),
4011 UntilEmptyDecision::KeepWatching
4012 );
4013 }
4014
4015 #[test]
4016 fn until_empty_exits_once_grace_window_elapses() {
4017 assert_eq!(until_empty_decision(Some(60), 60), UntilEmptyDecision::Exit);
4019 assert_eq!(
4020 until_empty_decision(Some(120), 60),
4021 UntilEmptyDecision::Exit
4022 );
4023 }
4024
4025 #[test]
4026 fn until_empty_with_zero_grace_exits_immediately_when_empty() {
4027 assert_eq!(until_empty_decision(Some(0), 0), UntilEmptyDecision::Exit);
4028 assert_eq!(
4030 until_empty_decision(None, 0),
4031 UntilEmptyDecision::KeepWatching
4032 );
4033 }
4034
4035 #[test]
4036 fn strict_goal_loop_stops_at_configured_lie_count() {
4037 let temp = tempfile::tempdir().unwrap();
4038 let store = LedgerStore::new(temp.path());
4039 let policy = StrictGoalPolicy {
4040 stop_after_lies: 1,
4041 stop_after_fuckups: 0,
4042 };
4043 let runner = SequenceRunner::new([reject_json("lie")]);
4044
4045 let outcome = run_strict_goal_loop(
4046 "abc123",
4047 &claim(),
4048 "diff",
4049 "",
4050 &selection(),
4051 policy,
4052 5,
4053 &runner,
4054 &store,
4055 )
4056 .unwrap();
4057
4058 assert_eq!(outcome.passes, 1);
4059 assert_eq!(outcome.counters.lies_exposed, 1);
4060 assert_eq!(outcome.stop_reason, Some(StrictGoalStopReason::LiesExposed));
4061 assert_eq!(store.read_history().unwrap().len(), 1);
4062 }
4063
4064 #[test]
4065 fn strict_goal_loop_terminates_at_max_passes_for_honest_agent() {
4066 let temp = tempfile::tempdir().unwrap();
4067 let store = LedgerStore::new(temp.path());
4068 let policy = StrictGoalPolicy {
4069 stop_after_lies: 2,
4070 stop_after_fuckups: 5,
4071 };
4072 let runner = ConstRunner::new(pass_json());
4073
4074 let outcome = run_strict_goal_loop(
4075 "abc123",
4076 &claim(),
4077 "diff",
4078 "",
4079 &selection(),
4080 policy,
4081 3,
4082 &runner,
4083 &store,
4084 )
4085 .unwrap();
4086
4087 assert_eq!(outcome.passes, 3);
4088 assert_eq!(outcome.counters.lies_exposed, 0);
4089 assert_eq!(outcome.stop_reason, None);
4090 assert_eq!(store.read_history().unwrap().len(), 3);
4091 }
4092
4093 #[test]
4094 fn strict_goal_loop_stops_when_fuckups_accumulate() {
4095 let temp = tempfile::tempdir().unwrap();
4096 let store = LedgerStore::new(temp.path());
4097 let policy = StrictGoalPolicy {
4098 stop_after_lies: 0,
4099 stop_after_fuckups: 2,
4100 };
4101 let runner = ConstRunner::new(reject_json("nit"));
4103
4104 let outcome = run_strict_goal_loop(
4105 "abc123",
4106 &claim(),
4107 "diff",
4108 "",
4109 &selection(),
4110 policy,
4111 10,
4112 &runner,
4113 &store,
4114 )
4115 .unwrap();
4116
4117 assert_eq!(outcome.passes, 2);
4118 assert_eq!(outcome.counters.lies_exposed, 2);
4119 assert_eq!(outcome.counters.fuckups_registered, 2);
4120 assert_eq!(
4121 outcome.stop_reason,
4122 Some(StrictGoalStopReason::FuckupsRegistered)
4123 );
4124 }
4125
4126 proptest! {
4127 #[test]
4128 fn strict_goal_loop_never_exceeds_max_passes(max in 1u32..6) {
4129 let temp = tempfile::tempdir().unwrap();
4130 let store = LedgerStore::new(temp.path());
4131 let policy = StrictGoalPolicy { stop_after_lies: 0, stop_after_fuckups: 0 };
4133 let runner = ConstRunner::new(pass_json());
4134
4135 let outcome = run_strict_goal_loop(
4136 "abc123", &claim(), "diff", "", &selection(), policy, max, &runner, &store,
4137 )
4138 .unwrap();
4139
4140 prop_assert!(outcome.passes <= max);
4141 prop_assert_eq!(outcome.passes, max);
4142 prop_assert!(outcome.stop_reason.is_none());
4143 }
4144 }
4145
4146 proptest! {
4147 #[test]
4148 fn model_opposition_is_enforced_for_arbitrary_models(
4149 watched in "[A-Za-z0-9._/-]{1,32}",
4150 reviewer in "[A-Za-z0-9._/-]{1,32}",
4151 ) {
4152 let request = ReviewRequest::new(
4153 Agent::Codex,
4154 watched.clone(),
4155 ReviewerHarness::Codex,
4156 reviewer.clone(),
4157 false,
4158 "review this",
4159 );
4160 let result = ReviewPlan::build(request);
4161
4162 if normalized_model(watched.trim()) == normalized_model(reviewer.trim()) {
4166 let blocked = matches!(result, Err(ReviewerError::SameModelWithoutWaiver { .. }));
4167 prop_assert!(blocked);
4168 } else {
4169 prop_assert!(result.is_ok());
4170 }
4171 }
4172 }
4173
4174 fn claim() -> Claim {
4175 Claim::new(
4176 "add review",
4177 "cargo test",
4178 vec![EvidenceRef::parse("tests:cargo-test").unwrap()],
4179 )
4180 .unwrap()
4181 }
4182
4183 fn selection() -> ReviewSelection {
4184 ReviewSelection {
4185 watched_agent: Agent::Codex,
4186 watched_model: "gpt-5.4".to_owned(),
4187 reviewer_harness: ReviewerHarness::Codex,
4188 reviewer_model: "gpt-5.5".to_owned(),
4189 reviewer_effort: Effort::Xhigh,
4190 allow_same_model: false,
4191 strict: None,
4192 }
4193 }
4194
4195 struct StaticLoader {
4196 claim: Claim,
4197 diff: String,
4198 }
4199
4200 impl StaticLoader {
4201 fn new() -> Self {
4202 Self {
4203 claim: claim(),
4204 diff: "diff --git a/src/lib.rs b/src/lib.rs".to_owned(),
4205 }
4206 }
4207 }
4208
4209 impl MaterialLoader for StaticLoader {
4210 fn load(&self, _sha: &str) -> Result<(Claim, String), ReviewerError> {
4211 Ok((self.claim.clone(), self.diff.clone()))
4212 }
4213 }
4214
4215 struct PoisonedLoader;
4216
4217 impl MaterialLoader for PoisonedLoader {
4218 fn load(&self, sha: &str) -> Result<(Claim, String), ReviewerError> {
4219 if sha == "poisoned" {
4220 return Err(crate::claim::ClaimError::MissingEvidence.into());
4221 }
4222 StaticLoader::new().load(sha)
4223 }
4224 }
4225
4226 struct ConstRunner {
4227 output: String,
4228 }
4229
4230 impl ConstRunner {
4231 fn new(output: impl Into<String>) -> Self {
4232 Self {
4233 output: output.into(),
4234 }
4235 }
4236 }
4237
4238 impl ProcessRunner for ConstRunner {
4239 fn run(
4240 &self,
4241 _invocation: &InvocationPlan,
4242 _prompt: &str,
4243 ) -> Result<ProcessOutput, ReviewerError> {
4244 Ok(ProcessOutput {
4245 status_code: Some(0),
4246 stdout: self.output.clone(),
4247 stderr: String::new(),
4248 })
4249 }
4250 }
4251
4252 fn review_job(strict: bool) -> ReviewJob {
4253 let claim = claim();
4254 ReviewJob {
4255 commit_sha: "abc123".to_owned(),
4256 diff: "diff --git a/src/lib.rs b/src/lib.rs".to_owned(),
4257 context: String::new(),
4258 request: ReviewRequest::new(
4259 Agent::Codex,
4260 "gpt-5.4",
4261 ReviewerHarness::Codex,
4262 "gpt-5.5",
4263 false,
4264 "review this",
4265 ),
4266 claim,
4267 strict: strict.then_some(StrictReviewConfig {
4268 arbiter_harness: ReviewerHarness::Claude,
4269 arbiter_model: "claude-opus-4-8".to_owned(),
4270 arbiter_effort: Effort::Xhigh,
4271 }),
4272 petition: None,
4273 }
4274 }
4275
4276 struct SequenceRunner {
4277 outputs: RefCell<VecDeque<String>>,
4278 }
4279
4280 impl SequenceRunner {
4281 fn new<I, S>(outputs: I) -> Self
4282 where
4283 I: IntoIterator<Item = S>,
4284 S: Into<String>,
4285 {
4286 Self {
4287 outputs: RefCell::new(outputs.into_iter().map(Into::into).collect()),
4288 }
4289 }
4290 }
4291
4292 impl ProcessRunner for SequenceRunner {
4293 fn run(
4294 &self,
4295 _invocation: &InvocationPlan,
4296 _prompt: &str,
4297 ) -> Result<ProcessOutput, ReviewerError> {
4298 let stdout = self.outputs.borrow_mut().pop_front().unwrap();
4299 Ok(ProcessOutput {
4300 status_code: Some(0),
4301 stdout,
4302 stderr: String::new(),
4303 })
4304 }
4305 }
4306
4307 #[test]
4308 fn extract_verdict_json_handles_raw_fenced_and_prose_wrapped_output() {
4309 let raw = r#"{"verdict":"PASS"}"#;
4311 assert_eq!(super::extract_verdict_json(raw), raw);
4312
4313 let fenced = "Here is my verdict:\n```json\n{\"verdict\":\"PASS\"}\n```\nthanks";
4315 assert_eq!(
4316 super::extract_verdict_json(fenced),
4317 "{\"verdict\":\"PASS\"}"
4318 );
4319
4320 let bare = "verdict below\n```\n{\"a\":1}\n```";
4322 assert_eq!(super::extract_verdict_json(bare), "{\"a\":1}");
4323
4324 let two = "```\nnot json\n```\nthen\n```json\n{\"b\":2}\n```";
4326 assert_eq!(super::extract_verdict_json(two), "{\"b\":2}");
4327
4328 let prose = "The verdict is {\"a\": {\"b\":2}} as required.";
4330 assert_eq!(super::extract_verdict_json(prose), "{\"a\": {\"b\":2}}");
4331
4332 assert_eq!(super::extract_verdict_json(" nope "), "nope");
4335 }
4336
4337 #[test]
4338 fn parsed_verdict_accepts_markdown_fenced_reviewer_output() {
4339 let fenced = format!(
4343 "The fix looks good. Verdict follows.\n\n```json\n{}\n```\n",
4344 pass_json()
4345 );
4346 let parsed = ParsedVerdict::parse(&fenced).unwrap();
4347 assert_eq!(parsed.verdict, Verdict::Pass);
4348 assert_eq!(parsed.raw, fenced);
4350 }
4351
4352 #[test]
4353 fn queued_review_lines_without_petition_field_still_parse() {
4354 let legacy = r#"{"run_id":"r1","commit_sha":"abc","enqueued_at_unix":1}"#;
4355 let item: super::QueuedReview = serde_json::from_str(legacy).unwrap();
4356 assert_eq!(item.petition_for, None);
4357 }
4358
4359 #[test]
4360 fn enqueue_petition_round_trips_petition_for() {
4361 let temp = tempfile::tempdir().unwrap();
4362 let queue = ReviewQueue::new(temp.path());
4363 queue.enqueue("normal1").unwrap();
4364 queue.enqueue_petition("fix456", "orig123").unwrap();
4365
4366 let pending = queue.pending().unwrap();
4367 assert_eq!(pending.len(), 2);
4368 assert_eq!(pending[0].petition_for, None);
4369 assert_eq!(pending[1].commit_sha, "fix456");
4370 assert_eq!(pending[1].petition_for.as_deref(), Some("orig123"));
4371 }
4372
4373 #[test]
4374 fn drain_once_runs_petition_jobs_and_transitions_the_original_rejection() {
4375 use crate::ledger::{Disposition, LedgerEntry, ResolutionKind, ReviewerConfig};
4376
4377 let temp = tempfile::tempdir().unwrap();
4378 let store = LedgerStore::new(temp.path());
4379 let queue = ReviewQueue::new(temp.path());
4380
4381 let rejected = LedgerEntry::new(
4384 "orig123",
4385 Verdict::Reject,
4386 "CLAIM: original | verified: cargo test | evidence: tests:cargo-test",
4387 vec!["tests:cargo-test".to_owned()],
4388 ReviewerConfig::new("claude", "claude-opus-4-1", false),
4389 vec!["evidence too thin".to_owned()],
4390 );
4391 store.append_entry(&rejected).unwrap();
4392 store
4393 .append_petition_transition(
4394 "orig123",
4395 Disposition::Open,
4396 ResolutionKind::Resolved,
4397 "petition review enqueued: fix=fix456, attempts=1/2",
4398 1,
4399 )
4400 .unwrap();
4401 queue.enqueue_petition("fix456", "orig123").unwrap();
4402
4403 let loader = StaticLoader::new();
4404 let runner = ConstRunner::new(pass_json());
4405 let report = drain_once(
4406 &queue,
4407 &loader,
4408 &selection(),
4409 "",
4410 &runner,
4411 &store,
4412 &TruthMirrorConfig::default(),
4413 )
4414 .unwrap();
4415
4416 assert_eq!(report.reviewed, ["fix456"]);
4417 let review = store.show("fix456").unwrap();
4420 assert_eq!(review.petition_for.as_deref(), Some("orig123"));
4421 assert_eq!(review.petition_attempts, 1);
4422 let original = store.show("orig123").unwrap();
4424 assert_eq!(original.disposition, Disposition::Resolved);
4425 assert!(queue.pending().unwrap().is_empty());
4426 assert!(store.blocking_rejections().unwrap().is_empty());
4427 }
4428
4429 #[test]
4430 fn extract_verdict_json_takes_leading_json_before_trailing_prose() {
4431 let leading = "{\"verdict\":\"PASS\"} \n\nHope this helps!";
4434 assert_eq!(
4435 super::extract_verdict_json(leading),
4436 "{\"verdict\":\"PASS\"}"
4437 );
4438
4439 let both = "verdict: {\"a\":1} trailing prose with a stray } brace";
4442 assert_eq!(super::extract_verdict_json(both), "{\"a\":1}");
4443 }
4444
4445 #[test]
4446 fn drain_once_reviews_normal_and_petition_items_for_the_same_fix_sha() {
4447 use crate::ledger::{Disposition, LedgerEntry, ResolutionKind, ReviewerConfig};
4448
4449 let temp = tempfile::tempdir().unwrap();
4450 let store = LedgerStore::new(temp.path());
4451 let queue = ReviewQueue::new(temp.path());
4452
4453 let rejected = LedgerEntry::new(
4454 "orig123",
4455 Verdict::Reject,
4456 "CLAIM: original | verified: cargo test | evidence: tests:cargo-test",
4457 vec!["tests:cargo-test".to_owned()],
4458 ReviewerConfig::new("claude", "claude-opus-4-1", false),
4459 vec!["evidence too thin".to_owned()],
4460 );
4461 store.append_entry(&rejected).unwrap();
4462 store
4463 .append_petition_transition(
4464 "orig123",
4465 Disposition::Open,
4466 ResolutionKind::Resolved,
4467 "petition review enqueued: fix=fix456, attempts=1/2",
4468 1,
4469 )
4470 .unwrap();
4471
4472 queue.enqueue("fix456").unwrap();
4476 queue.enqueue_petition("fix456", "orig123").unwrap();
4477
4478 let loader = StaticLoader::new();
4479 let runner = ConstRunner::new(pass_json());
4480 let report = drain_once(
4481 &queue,
4482 &loader,
4483 &selection(),
4484 "",
4485 &runner,
4486 &store,
4487 &TruthMirrorConfig::default(),
4488 )
4489 .unwrap();
4490
4491 assert_eq!(report.reviewed, ["fix456", "fix456"]);
4492 assert!(queue.pending().unwrap().is_empty());
4493 let history = store.read_history().unwrap();
4496 let fix_entries: Vec<_> = history
4497 .iter()
4498 .filter(|entry| entry.commit_sha == "fix456")
4499 .collect();
4500 assert_eq!(fix_entries.len(), 2);
4501 assert!(fix_entries.iter().any(|entry| entry.petition_for.is_none()));
4502 assert!(
4503 fix_entries
4504 .iter()
4505 .any(|entry| entry.petition_for.as_deref() == Some("orig123"))
4506 );
4507 assert_eq!(
4509 store.show("orig123").unwrap().disposition,
4510 crate::ledger::Disposition::Resolved
4511 );
4512 }
4513
4514 #[test]
4515 fn flag_petition_verdict_resolves_the_original_rejection() {
4516 use crate::ledger::{Disposition, LedgerEntry, ResolutionKind, ReviewerConfig};
4517
4518 let temp = tempfile::tempdir().unwrap();
4519 let store = LedgerStore::new(temp.path());
4520 let queue = ReviewQueue::new(temp.path());
4521 let rejected = LedgerEntry::new(
4522 "orig123",
4523 Verdict::Reject,
4524 "CLAIM: original | verified: cargo test | evidence: tests:cargo-test",
4525 vec!["tests:cargo-test".to_owned()],
4526 ReviewerConfig::new("claude", "claude-opus-4-1", false),
4527 vec!["evidence too thin".to_owned()],
4528 );
4529 store.append_entry(&rejected).unwrap();
4530 store
4531 .append_petition_transition(
4532 "orig123",
4533 Disposition::Open,
4534 ResolutionKind::Resolved,
4535 "petition review enqueued: fix=fix456, attempts=1/2",
4536 1,
4537 )
4538 .unwrap();
4539 queue.enqueue_petition("fix456", "orig123").unwrap();
4540
4541 let loader = StaticLoader::new();
4545 let runner = ConstRunner::new(flag_json("evidence could be tighter"));
4546 drain_once(
4547 &queue,
4548 &loader,
4549 &selection(),
4550 "",
4551 &runner,
4552 &store,
4553 &TruthMirrorConfig::default(),
4554 )
4555 .unwrap();
4556
4557 assert_eq!(
4558 store.show("orig123").unwrap().disposition,
4559 crate::ledger::Disposition::Resolved
4560 );
4561 let history = store.read_history().unwrap();
4562 let petition_entry = history
4563 .iter()
4564 .find(|entry| {
4565 entry.petition_for.as_deref() == Some("orig123") && entry.commit_sha == "fix456"
4566 })
4567 .unwrap();
4568 assert_eq!(petition_entry.verdict, Verdict::Flag);
4569 }
4570}