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 or set reviewer.model in config to \
239 enforce per-model opposition.",
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 with_patterns(evidence_patterns: Vec<String>) -> Self {
1448 Self { evidence_patterns }
1449 }
1450}
1451
1452impl MaterialLoader for GitMaterialLoader {
1453 fn load(&self, sha: &str) -> Result<(Claim, String), ReviewerError> {
1454 let message = git_output(["show", "--format=%B", "--no-patch", sha])?;
1455 let diff = git_output(["show", "--format=", "--patch", sha])?;
1456 let claim = if self.evidence_patterns.is_empty() {
1457 Claim::parse(&message)?
1458 } else {
1459 Claim::parse_with(&message, &self.evidence_patterns)?
1460 };
1461 Ok((claim, diff))
1462 }
1463}
1464
1465#[derive(Clone, Debug, Default, Eq, PartialEq)]
1466pub struct DrainReport {
1467 pub reviewed: Vec<String>,
1468 pub ledger_entries: usize,
1469}
1470
1471pub fn drain_once<R: ProcessRunner, L: MaterialLoader>(
1475 queue: &ReviewQueue,
1476 loader: &L,
1477 selection: &ReviewSelection,
1478 context: &str,
1479 runner: &R,
1480 store: &LedgerStore,
1481 config: &config::TruthMirrorConfig,
1482) -> Result<DrainReport, ReviewerError> {
1483 let pending = queue.pending()?;
1484 let run_store = ReviewRunStore::new(&queue.root);
1485 let mut seen = std::collections::BTreeSet::new();
1490 let mut order = Vec::new();
1491 for item in &pending {
1492 let identity = (item.commit_sha.clone(), item.petition_for.clone());
1493 if seen.insert(identity) {
1494 order.push(item.clone());
1495 } else if !item.run_id.trim().is_empty() {
1496 match run_store.read(&item.run_id) {
1497 Ok(run) if run.status == ReviewRunStatus::Queued => {
1498 run_store.cancel_queued(&item.run_id)?;
1499 }
1500 _ => {}
1501 }
1502 }
1503 }
1504
1505 let mut report = DrainReport::default();
1506 for item in order {
1507 let sha = item.commit_sha;
1508 let run_id = if item.run_id.trim().is_empty() {
1509 generate_run_id(&sha)
1510 } else {
1511 item.run_id
1512 };
1513 let target = if item.petition_for.is_some() {
1514 "petition"
1515 } else {
1516 "commit"
1517 };
1518 let run = run_store.ensure_queued(&run_id, &sha, target)?;
1519 if run.status == ReviewRunStatus::Cancelled {
1520 queue.remove_item(&sha, item.petition_for.as_deref())?;
1521 continue;
1522 }
1523 run_store.mark_running(&run_id, "reviewing")?;
1524 let (claim, diff) = loader.load(&sha)?;
1525 let petition = match &item.petition_for {
1529 Some(original_sha) => Some(petition_context_from_ledger(original_sha, &sha, store)?),
1530 None => None,
1531 };
1532 let prompt = first_pass_prompt(&claim, &diff, context);
1533 let job = ReviewJob {
1534 commit_sha: sha.clone(),
1535 claim,
1536 diff,
1537 context: context.to_owned(),
1538 request: selection.request_for(prompt),
1539 strict: selection.strict.clone(),
1540 petition,
1541 };
1542 let execution = match execute_review_job(job, runner, store) {
1543 Ok(execution) => execution,
1544 Err(error) => {
1545 let _ = run_store.mark_failed(&run_id, error.to_string());
1546 return Err(error);
1547 }
1548 };
1549 record_memory_skill_outcome(
1550 &queue.root,
1551 config,
1552 &run_id,
1553 &sha,
1554 "watch-drain",
1555 &execution.entries,
1556 );
1557 report.ledger_entries += execution.entries.len();
1558 run_store.mark_completed(&run_id, execution.entries.len())?;
1559 queue.remove_item(&sha, item.petition_for.as_deref())?;
1562 report.reviewed.push(sha);
1563 }
1564
1565 Ok(report)
1566}
1567
1568fn record_memory_skill_outcome(
1569 state_dir: &Path,
1570 config: &config::TruthMirrorConfig,
1571 run_id: &str,
1572 commit_sha: &str,
1573 phase: &str,
1574 entries: &[LedgerEntry],
1575) {
1576 if !is_full_git_sha(commit_sha) {
1577 tracing::debug!(
1578 run_id = %run_id,
1579 commit_sha = %commit_sha,
1580 phase = %phase,
1581 "memory-skill extraction skipped for non-commit review target"
1582 );
1583 return;
1584 }
1585 match crate::memory_skill::evaluate_review_completion(state_dir, config, run_id, entries) {
1586 Ok(_) => {}
1587 Err(crate::memory_skill::MemorySkillError::ScanRejected { reason }) => {
1588 tracing::info!(
1589 run_id = %run_id,
1590 commit_sha = %commit_sha,
1591 phase = %phase,
1592 memory_skill_outcome = "scan_rejected",
1593 reason = %reason,
1594 "memory-skill extraction skipped by scan gate"
1595 );
1596 }
1597 Err(error) => {
1598 let error_kind = memory_skill_error_kind(&error);
1599 tracing::warn!(
1600 run_id = %run_id,
1601 commit_sha = %commit_sha,
1602 phase = %phase,
1603 memory_skill_outcome = "failed",
1604 memory_skill_error_kind = error_kind,
1605 error = %error,
1606 "memory-skill extraction failed after review completion"
1607 );
1608 }
1609 }
1610}
1611
1612fn memory_skill_error_kind(error: &crate::memory_skill::MemorySkillError) -> &'static str {
1613 match error {
1614 crate::memory_skill::MemorySkillError::Io(_) => "io",
1615 crate::memory_skill::MemorySkillError::Json(_) => "json",
1616 crate::memory_skill::MemorySkillError::Ledger(_) => "ledger",
1617 crate::memory_skill::MemorySkillError::CandidateNotFound { .. } => "candidate_not_found",
1618 crate::memory_skill::MemorySkillError::AdvisoryNotFound { .. } => "advisory_not_found",
1619 crate::memory_skill::MemorySkillError::EmptyRejectReason => "empty_reject_reason",
1620 crate::memory_skill::MemorySkillError::EmptySupersedeReason => "empty_supersede_reason",
1621 crate::memory_skill::MemorySkillError::SelfSupersede { .. } => "self_supersede",
1622 crate::memory_skill::MemorySkillError::InvalidSupersedeReplacement { .. } => {
1623 "invalid_supersede_replacement"
1624 }
1625 crate::memory_skill::MemorySkillError::InvalidTransition { .. } => "invalid_transition",
1626 crate::memory_skill::MemorySkillError::TransitionLocked { .. } => "transition_locked",
1627 crate::memory_skill::MemorySkillError::UnsafeGlobalWrite { .. } => "unsafe_global_write",
1628 crate::memory_skill::MemorySkillError::UnsafeApprovedPath { .. } => "unsafe_approved_path",
1629 crate::memory_skill::MemorySkillError::UnsafeStatePath { .. } => "unsafe_state_path",
1630 crate::memory_skill::MemorySkillError::ScanRejected { .. } => "scan_rejected",
1631 crate::memory_skill::MemorySkillError::RenderedSkillTooLarge { .. } => {
1632 "rendered_skill_too_large"
1633 }
1634 }
1635}
1636
1637fn review_context(config: &config::TruthMirrorConfig) -> String {
1640 let repo_root = match git_output(["rev-parse", "--show-toplevel"]) {
1641 Ok(root) => PathBuf::from(root.trim()),
1642 Err(_) => return String::new(),
1643 };
1644 let provider = crate::context::trajectory_provider(&repo_root, &config.history);
1645 crate::context::build_review_context(
1646 &repo_root,
1647 &config.ground_truth,
1648 &config.history,
1649 Some(provider.as_ref()),
1650 )
1651 .unwrap_or_default()
1652}
1653
1654pub fn run_watch_command(
1655 args: cli::WatchArgs,
1656 state_dir: &Path,
1657 config: &config::TruthMirrorConfig,
1658) -> Result<ExitCode> {
1659 let selection = ReviewSelection::resolve(
1660 args.watched_agent,
1661 args.watched_model,
1662 args.reviewer_harness,
1663 args.reviewer_model,
1664 args.reviewer_effort,
1665 args.allow_same_model,
1666 config,
1667 )?;
1668 let queue = ReviewQueue::new(state_dir);
1669 let store = LedgerStore::new(state_dir);
1670 let loader = GitMaterialLoader::with_patterns(config.gates.to_policy().evidence_patterns);
1671 let runner = StdProcessRunner;
1672
1673 if args.once {
1674 let context = review_context(config);
1675 let report = drain_once(
1676 &queue, &loader, &selection, &context, &runner, &store, config,
1677 )?;
1678 println!(
1679 "truth-mirror watch: reviewed {} commit(s), wrote {} ledger entrie(s)",
1680 report.reviewed.len(),
1681 report.ledger_entries
1682 );
1683 return Ok(ExitCode::SUCCESS);
1684 }
1685
1686 let interval = std::time::Duration::from_secs(args.poll_secs.max(1));
1687
1688 if args.until_empty {
1689 return run_until_empty(
1690 &queue, &loader, &selection, &runner, &store, config, state_dir, args.grace, interval,
1691 );
1692 }
1693
1694 loop {
1695 let context = review_context(config);
1697 let report = drain_once(
1698 &queue, &loader, &selection, &context, &runner, &store, config,
1699 )?;
1700 if !report.reviewed.is_empty() {
1701 println!(
1702 "truth-mirror watch: reviewed {} commit(s)",
1703 report.reviewed.len()
1704 );
1705 }
1706 std::thread::sleep(interval);
1707 }
1708}
1709
1710#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1718pub enum UntilEmptyDecision {
1719 KeepWatching,
1722 Exit,
1724}
1725
1726pub fn until_empty_decision(empty_for_secs: Option<u64>, grace_secs: u64) -> UntilEmptyDecision {
1727 match empty_for_secs {
1728 Some(elapsed) if elapsed >= grace_secs => UntilEmptyDecision::Exit,
1729 _ => UntilEmptyDecision::KeepWatching,
1730 }
1731}
1732
1733#[derive(Clone, Debug, Eq, PartialEq)]
1734enum QueueFingerprint {
1735 Missing,
1736 Present {
1737 len: u64,
1738 modified: Option<SystemTime>,
1739 },
1740}
1741
1742#[derive(Clone, Debug, Default)]
1743struct QueueEmptyCache {
1744 fingerprint: Option<QueueFingerprint>,
1745 pending_count: Option<usize>,
1746}
1747
1748fn queue_fingerprint(queue: &ReviewQueue) -> Result<QueueFingerprint, ReviewerError> {
1749 match fs::metadata(queue.path()) {
1750 Ok(metadata) => Ok(QueueFingerprint::Present {
1751 len: metadata.len(),
1752 modified: metadata.modified().ok(),
1753 }),
1754 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(QueueFingerprint::Missing),
1755 Err(error) => Err(ReviewerError::QueueIo(error)),
1756 }
1757}
1758
1759fn queue_has_pending_cached(
1760 queue: &ReviewQueue,
1761 cache: &mut QueueEmptyCache,
1762) -> Result<bool, ReviewerError> {
1763 let fingerprint = queue_fingerprint(queue)?;
1764 if matches!(
1765 fingerprint,
1766 QueueFingerprint::Missing | QueueFingerprint::Present { len: 0, .. }
1767 ) {
1768 cache.fingerprint = Some(fingerprint);
1769 cache.pending_count = Some(0);
1770 return Ok(false);
1771 }
1772
1773 if let (true, Some(pending_count)) = (
1774 cache.fingerprint.as_ref() == Some(&fingerprint),
1775 cache.pending_count,
1776 ) {
1777 return Ok(pending_count > 0);
1778 }
1779
1780 let pending_count = queue.summary()?.pending_count;
1781 cache.fingerprint = Some(fingerprint);
1782 cache.pending_count = Some(pending_count);
1783 Ok(pending_count > 0)
1784}
1785
1786#[allow(clippy::too_many_arguments)]
1793fn run_until_empty<R: ProcessRunner, L: MaterialLoader>(
1794 queue: &ReviewQueue,
1795 loader: &L,
1796 selection: &ReviewSelection,
1797 runner: &R,
1798 store: &LedgerStore,
1799 config: &config::TruthMirrorConfig,
1800 state_dir: &Path,
1801 grace_secs: u64,
1802 interval: std::time::Duration,
1803) -> Result<ExitCode> {
1804 match crate::watcher::try_acquire_lock(state_dir) {
1808 Ok(crate::watcher::LockClaim::Acquired) => {}
1809 Ok(crate::watcher::LockClaim::HeldByLiveWatcher) => {
1810 eprintln!(
1811 "truth-mirror watch: watcher lock is already held; continuing without lock ownership"
1812 );
1813 }
1814 Err(error) => {
1815 eprintln!(
1816 "truth-mirror watch: failed to acquire watcher lock ({error}); continuing without lock ownership"
1817 );
1818 }
1819 }
1820
1821 let outcome = (|| -> Result<()> {
1822 let mut empty_since: Option<u64> = None;
1823 let mut queue_empty_cache = QueueEmptyCache::default();
1824 loop {
1825 let context = review_context(config);
1826 let report = drain_once(queue, loader, selection, &context, runner, store, config)?;
1827 if !report.reviewed.is_empty() {
1828 println!(
1829 "truth-mirror watch: reviewed {} commit(s)",
1830 report.reviewed.len()
1831 );
1832 }
1833
1834 let now = unix_now();
1835 if !queue_has_pending_cached(queue, &mut queue_empty_cache)? {
1836 let started = *empty_since.get_or_insert(now);
1837 let empty_for = now.saturating_sub(started);
1838 if until_empty_decision(Some(empty_for), grace_secs) == UntilEmptyDecision::Exit {
1839 return Ok(());
1840 }
1841 } else {
1842 empty_since = None;
1844 }
1845
1846 std::thread::sleep(interval);
1847 }
1848 })();
1849
1850 let _ = crate::watcher::release_lock_if_owned(state_dir);
1853 outcome?;
1854 Ok(ExitCode::SUCCESS)
1855}
1856
1857pub fn run_ensure_watcher_command(
1863 _args: cli::EnsureWatcherArgs,
1864 state_dir: &Path,
1865) -> Result<ExitCode> {
1866 match crate::watcher::ensure_watcher(state_dir)? {
1867 crate::watcher::LockClaim::Acquired => {
1868 println!("truth-mirror: watcher started");
1869 }
1870 crate::watcher::LockClaim::HeldByLiveWatcher => {
1871 tracing::debug!("ensure-watcher: live watcher already owns the state dir");
1872 }
1873 }
1874 Ok(ExitCode::SUCCESS)
1875}
1876
1877#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1878pub struct StrictGoalPolicy {
1879 pub stop_after_lies: u32,
1880 pub stop_after_fuckups: u32,
1881}
1882
1883#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1884pub struct StrictGoalCounters {
1885 pub lies_exposed: u32,
1886 pub fuckups_registered: u32,
1887}
1888
1889#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1890pub enum StrictGoalDecision {
1891 Continue,
1892 Stop { reason: StrictGoalStopReason },
1893}
1894
1895#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1896pub enum StrictGoalStopReason {
1897 LiesExposed,
1898 FuckupsRegistered,
1899}
1900
1901impl StrictGoalPolicy {
1902 pub fn decide(&self, counters: StrictGoalCounters) -> StrictGoalDecision {
1903 if self.stop_after_lies > 0 && counters.lies_exposed >= self.stop_after_lies {
1904 return StrictGoalDecision::Stop {
1905 reason: StrictGoalStopReason::LiesExposed,
1906 };
1907 }
1908
1909 if self.stop_after_fuckups > 0 && counters.fuckups_registered >= self.stop_after_fuckups {
1910 return StrictGoalDecision::Stop {
1911 reason: StrictGoalStopReason::FuckupsRegistered,
1912 };
1913 }
1914
1915 StrictGoalDecision::Continue
1916 }
1917}
1918
1919#[derive(Clone, Debug, Eq, PartialEq)]
1920pub struct StrictGoalOutcome {
1921 pub passes: u32,
1922 pub counters: StrictGoalCounters,
1923 pub stop_reason: Option<StrictGoalStopReason>,
1926 pub entries: Vec<LedgerEntry>,
1927}
1928
1929impl StrictGoalOutcome {
1930 pub fn stop_reason_suffix(&self) -> &'static str {
1931 match self.stop_reason {
1932 Some(StrictGoalStopReason::LiesExposed) => " (stopped: lies exposed)",
1933 Some(StrictGoalStopReason::FuckupsRegistered) => " (stopped: fuckups registered)",
1934 None => " (stopped: max passes)",
1935 }
1936 }
1937}
1938
1939#[allow(clippy::too_many_arguments)]
1944pub fn run_strict_goal_loop<R: ProcessRunner>(
1945 commit_sha: &str,
1946 claim: &Claim,
1947 diff: &str,
1948 context: &str,
1949 selection: &ReviewSelection,
1950 policy: StrictGoalPolicy,
1951 max_passes: u32,
1952 runner: &R,
1953 store: &LedgerStore,
1954) -> Result<StrictGoalOutcome, ReviewerError> {
1955 let ceiling = max_passes.max(1);
1956 let mut outcome = StrictGoalOutcome {
1957 passes: 0,
1958 counters: StrictGoalCounters {
1959 lies_exposed: 0,
1960 fuckups_registered: 0,
1961 },
1962 stop_reason: None,
1963 entries: Vec::new(),
1964 };
1965
1966 while outcome.passes < ceiling {
1967 let prompt = strict_goal_prompt(claim, diff, context, outcome.passes + 1, &outcome.entries);
1968 let request = selection.request_for(prompt);
1969 let plan = ReviewPlan::build(request.clone())?;
1970 let output = plan.run_with(&request.prompt, runner)?;
1971 ensure_process_success(&output)?;
1972 let verdict = ParsedVerdict::parse(&output.stdout)?;
1973
1974 let job = ReviewJob {
1975 commit_sha: commit_sha.to_owned(),
1976 claim: claim.clone(),
1977 diff: diff.to_owned(),
1978 context: context.to_owned(),
1979 request,
1980 strict: None,
1981 petition: None,
1982 };
1983 let entry = entry_from_verdict(&job, &plan, &verdict);
1984 store.append_entry(&entry)?;
1985 outcome.entries.push(entry);
1986
1987 outcome.passes += 1;
1988 if verdict.verdict == Verdict::Reject {
1989 outcome.counters.lies_exposed += 1;
1990 }
1991 outcome.counters.fuckups_registered = outcome
1992 .counters
1993 .fuckups_registered
1994 .saturating_add(u32::try_from(verdict.findings.len()).unwrap_or(u32::MAX));
1995
1996 if let StrictGoalDecision::Stop { reason } = policy.decide(outcome.counters) {
1997 outcome.stop_reason = Some(reason);
1998 break;
1999 }
2000 }
2001
2002 Ok(outcome)
2003}
2004
2005fn strict_goal_prompt(
2006 claim: &Claim,
2007 diff: &str,
2008 context: &str,
2009 pass: u32,
2010 prior: &[LedgerEntry],
2011) -> String {
2012 let prior_findings: Vec<String> = prior
2013 .iter()
2014 .flat_map(|entry| entry.findings.clone())
2015 .collect();
2016 let prior_block = if prior_findings.is_empty() {
2017 "(none)".to_owned()
2018 } else {
2019 prior_findings.join("\n")
2020 };
2021 format!(
2022 "{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{}",
2023 context_block(context),
2024 claim.to_line(),
2025 diff
2026 )
2027}
2028
2029pub fn run_review_command(
2030 args: cli::ReviewArgs,
2031 state_dir: &Path,
2032 config: &config::TruthMirrorConfig,
2033) -> Result<ExitCode> {
2034 if let Some(command) = args.command {
2035 return run_review_run_command(command, state_dir);
2036 }
2037
2038 let material = ReviewMaterial::load(
2039 &args,
2040 state_dir,
2041 &config.gates.to_policy().evidence_patterns,
2042 )?;
2043
2044 let mut selection = ReviewSelection::resolve(
2045 args.watched_agent,
2046 args.watched_model,
2047 args.reviewer_harness,
2048 args.reviewer_model,
2049 args.reviewer_effort,
2050 args.allow_same_model,
2051 config,
2052 )?;
2053
2054 if args.strict_two_pass {
2055 selection.strict = Some(ReviewSelection::resolve_arbiter(
2056 selection.watched_agent,
2057 args.arbiter_harness,
2058 args.arbiter_model,
2059 args.arbiter_effort,
2060 config,
2061 )?);
2062 }
2063 let store = LedgerStore::new(state_dir);
2064 let run_store = ReviewRunStore::new(state_dir);
2065 let context = review_context(config);
2066 let run = run_store.create_queued(&material.commit_sha, material.target_label.clone())?;
2067 run_store.mark_running(&run.id, "reviewing")?;
2068
2069 if args.strict_goal {
2070 let policy = config
2071 .strict
2072 .goal_policy(args.stop_after_lies, args.stop_after_fuckups);
2073 let max_passes = args.max_passes.unwrap_or(config.strict.max_passes);
2074 let outcome = match run_strict_goal_loop(
2075 &material.commit_sha,
2076 &material.claim,
2077 &material.diff,
2078 &context,
2079 &selection,
2080 policy,
2081 max_passes,
2082 &StdProcessRunner,
2083 &store,
2084 ) {
2085 Ok(outcome) => outcome,
2086 Err(error) => {
2087 let _ = run_store.mark_failed(&run.id, error.to_string());
2088 return Err(error.into());
2089 }
2090 };
2091 record_memory_skill_outcome(
2092 state_dir,
2093 config,
2094 &run.id,
2095 &material.commit_sha,
2096 "strict-goal",
2097 &outcome.entries,
2098 );
2099 run_store.mark_completed(&run.id, outcome.entries.len())?;
2100 println!(
2101 "truth-mirror strict-goal: run {}, {} pass(es), {} lie(s), {} fuckup(s){}",
2102 run.id,
2103 outcome.passes,
2104 outcome.counters.lies_exposed,
2105 outcome.counters.fuckups_registered,
2106 outcome.stop_reason_suffix(),
2107 );
2108 return Ok(ExitCode::SUCCESS);
2109 }
2110
2111 let prompt = first_pass_prompt(&material.claim, &material.diff, &context);
2112 let commit_sha = material.commit_sha.clone();
2113 let job = ReviewJob {
2114 commit_sha: material.commit_sha,
2115 claim: material.claim,
2116 diff: material.diff,
2117 context,
2118 request: selection.request_for(prompt),
2119 strict: selection.strict.clone(),
2120 petition: None,
2121 };
2122
2123 let execution = match execute_review_job(job, &StdProcessRunner, &store) {
2124 Ok(execution) => execution,
2125 Err(error) => {
2126 let _ = run_store.mark_failed(&run.id, error.to_string());
2127 return Err(error.into());
2128 }
2129 };
2130 record_memory_skill_outcome(
2131 state_dir,
2132 config,
2133 &run.id,
2134 &commit_sha,
2135 "manual-review",
2136 &execution.entries,
2137 );
2138 run_store.mark_completed(&run.id, execution.entries.len())?;
2139 println!(
2140 "truth-mirror review: run {}, wrote {} ledger entrie(s)",
2141 run.id,
2142 execution.entries.len()
2143 );
2144 Ok(ExitCode::SUCCESS)
2145}
2146
2147fn run_review_run_command(command: cli::ReviewCommand, state_dir: &Path) -> Result<ExitCode> {
2148 let runs = ReviewRunStore::new(state_dir);
2149 match command {
2150 cli::ReviewCommand::Status { run_id } => {
2151 if let Some(run_id) = run_id {
2152 print_run(&runs.read_reconciled(&run_id)?);
2153 } else {
2154 let all = runs.list_reconciled()?;
2155 if all.is_empty() {
2156 println!("No review runs.");
2157 } else {
2158 for run in all {
2159 print_run_summary(&run);
2160 }
2161 }
2162 }
2163 }
2164 cli::ReviewCommand::Result { run_id } => {
2165 let run = match run_id {
2166 Some(run_id) => runs.read(&run_id)?,
2167 None => runs.latest_result()?,
2168 };
2169 print_run(&run);
2170 print_run_ledger_entries(state_dir, &run)?;
2171 }
2172 cli::ReviewCommand::Cancel { run_id, force } => {
2173 let run = runs.cancel(&run_id, force)?;
2174 ReviewQueue::new(state_dir).remove_run_id(&run_id)?;
2175 match run.status {
2176 ReviewRunStatus::Failed => println!(
2177 "reaped stale review run {} ({}): {}",
2178 run.id,
2179 run.commit_sha,
2180 run.error.as_deref().unwrap_or("worker was not alive"),
2181 ),
2182 _ => println!("cancelled review run {} ({})", run.id, run.commit_sha),
2183 }
2184 }
2185 }
2186 Ok(ExitCode::SUCCESS)
2187}
2188
2189fn print_run_summary(run: &ReviewRun) {
2190 println!(
2191 "{} {} {} {} entries={} updated={}",
2192 run.id, run.status, run.commit_sha, run.phase, run.ledger_entries, run.updated_at_unix
2193 );
2194}
2195
2196fn print_run(run: &ReviewRun) {
2197 println!("run: {}", run.id);
2198 println!("status: {}", run.status);
2199 println!("commit: {}", run.commit_sha);
2200 println!("target: {}", run.target);
2201 println!("phase: {}", run.phase);
2202 println!("ledger_entries: {}", run.ledger_entries);
2203 if let Some(pid) = run.worker_pid {
2204 println!("worker_pid: {pid}");
2205 }
2206 println!("created_at_unix: {}", run.created_at_unix);
2207 println!("updated_at_unix: {}", run.updated_at_unix);
2208 if let Some(started) = run.started_at_unix {
2209 println!("started_at_unix: {started}");
2210 }
2211 if let Some(completed) = run.completed_at_unix {
2212 println!("completed_at_unix: {completed}");
2213 }
2214 if let Some(error) = &run.error {
2215 println!("error: {error}");
2216 }
2217 if let Some(checkpoint) = &run.entire_checkpoint {
2218 println!("entire_ref: {}", checkpoint.ref_name);
2219 println!("entire_sha: {}", checkpoint.object_sha);
2220 }
2221}
2222
2223fn print_run_ledger_entries(state_dir: &Path, run: &ReviewRun) -> Result<(), ReviewerError> {
2224 let store = LedgerStore::new(state_dir);
2225 let entries: Vec<LedgerEntry> = store
2226 .read_history()?
2227 .into_iter()
2228 .filter(|entry| entry.commit_sha == run.commit_sha)
2229 .collect();
2230 if entries.is_empty() {
2231 println!("ledger_entries: none");
2232 return Ok(());
2233 }
2234 println!("ledger_entries:");
2235 for entry in entries {
2236 println!(
2237 "- {} {} {} findings={}",
2238 entry.commit_sha,
2239 entry.verdict,
2240 entry.disposition,
2241 entry.findings.len()
2242 );
2243 }
2244 Ok(())
2245}
2246
2247#[derive(Clone, Debug, Eq, PartialEq)]
2248struct ReviewMaterial {
2249 commit_sha: String,
2250 target_label: String,
2251 claim: Claim,
2252 diff: String,
2253}
2254
2255impl ReviewMaterial {
2256 fn load(
2257 args: &cli::ReviewArgs,
2258 state_dir: &Path,
2259 evidence_patterns: &[String],
2260 ) -> Result<Self, ReviewerError> {
2261 let parse = |text: &str| {
2262 if evidence_patterns.is_empty() {
2263 Claim::parse(text)
2264 } else {
2265 Claim::parse_with(text, evidence_patterns)
2266 }
2267 };
2268
2269 let scope = if args.staged {
2270 ReviewScope::Staged
2271 } else {
2272 args.scope
2273 };
2274
2275 match scope {
2276 ReviewScope::Commit => {
2277 let target = args
2278 .target
2279 .clone()
2280 .ok_or(ReviewerError::MissingReviewTarget)?;
2281 let sha = resolve_commit_target(&target)?;
2282 let message = git_output(["show", "--format=%B", "--no-patch", sha.as_str()])?;
2283 let diff = git_output(["show", "--format=", "--patch", sha.as_str()])?;
2284 let claim = parse(&message)?;
2285 Ok(Self {
2286 commit_sha: sha.clone(),
2287 target_label: format!("commit:{target}"),
2288 claim,
2289 diff,
2290 })
2291 }
2292 ReviewScope::Staged => Self::load_staged(state_dir, &parse),
2293 ReviewScope::Auto => {
2294 reject_target_with_scope(args)?;
2295 if working_tree_dirty()? {
2296 Self::load_working_tree(state_dir, &parse)
2297 } else {
2298 Self::load_branch(args.base.as_deref(), &parse)
2299 }
2300 }
2301 ReviewScope::WorkingTree => {
2302 reject_target_with_scope(args)?;
2303 Self::load_working_tree(state_dir, &parse)
2304 }
2305 ReviewScope::Branch => {
2306 reject_target_with_scope(args)?;
2307 Self::load_branch(args.base.as_deref(), &parse)
2308 }
2309 }
2310 }
2311
2312 fn load_staged<F>(state_dir: &Path, parse: &F) -> Result<Self, ReviewerError>
2313 where
2314 F: Fn(&str) -> Result<Claim, crate::claim::ClaimError>,
2315 {
2316 let raw = git_output(["diff", "--cached"])?;
2317 let files = git_output(["diff", "--cached", "--name-only"])?;
2318 let diff = materialize_diff("staged", &raw, &files);
2319 let claim = parse(&read_claim_file(state_dir)?)?;
2320 Ok(Self {
2321 commit_sha: "STAGED".to_owned(),
2322 target_label: "staged".to_owned(),
2323 claim,
2324 diff,
2325 })
2326 }
2327
2328 fn load_working_tree<F>(state_dir: &Path, parse: &F) -> Result<Self, ReviewerError>
2329 where
2330 F: Fn(&str) -> Result<Claim, crate::claim::ClaimError>,
2331 {
2332 let status = git_output(["status", "--porcelain"])?;
2333 let tracked = git_output(["diff", "HEAD", "--patch"])?;
2334 let files = git_output(["diff", "HEAD", "--name-only"])?;
2335 let untracked = untracked_file_context()?;
2336 let raw = format!(
2337 "WORKING TREE STATUS:\n{status}\n\nTRACKED DIFF AGAINST HEAD:\n{tracked}\n\nUNTRACKED FILES:\n{untracked}"
2338 );
2339 let diff = materialize_diff("working-tree", &raw, &files);
2340 let claim = parse(&read_claim_file(state_dir)?)?;
2341 Ok(Self {
2342 commit_sha: "WORKING_TREE".to_owned(),
2343 target_label: "working-tree".to_owned(),
2344 claim,
2345 diff,
2346 })
2347 }
2348
2349 fn load_branch<F>(base: Option<&str>, parse: &F) -> Result<Self, ReviewerError>
2350 where
2351 F: Fn(&str) -> Result<Claim, crate::claim::ClaimError>,
2352 {
2353 let base = match base {
2354 Some(base) => base.to_owned(),
2355 None => default_branch_ref()?,
2356 };
2357 let merge_base = git_output_slice(&["merge-base", "HEAD", &base])?;
2358 let merge_base = merge_base.trim().to_owned();
2359 let range = format!("{merge_base}..HEAD");
2360 let message = git_output(["show", "--format=%B", "--no-patch", "HEAD"])?;
2361 let log = git_output_slice(&["log", "--oneline", &range])?;
2362 let stat = git_output_slice(&["diff", "--stat", &range])?;
2363 let raw_patch = git_output_slice(&["diff", "--patch", &range])?;
2364 let files = git_output_slice(&["diff", "--name-only", &range])?;
2365 let raw = format!(
2366 "BRANCH BASE: {base}\nMERGE BASE: {merge_base}\nCOMMITS:\n{log}\n\nDIFF STAT:\n{stat}\n\nDIFF:\n{raw_patch}"
2367 );
2368 let diff = materialize_diff(&format!("branch:{base}"), &raw, &files);
2369 let claim = parse(&message)?;
2370 Ok(Self {
2371 commit_sha: "HEAD".to_owned(),
2372 target_label: format!("branch:{base}"),
2373 claim,
2374 diff,
2375 })
2376 }
2377}
2378
2379fn resolve_commit_target(target: &str) -> Result<String, ReviewerError> {
2380 let rev = format!("{target}^{{commit}}");
2381 Ok(
2382 git_output_slice(&["rev-parse", "--verify", "--quiet", "--end-of-options", &rev])?
2383 .trim()
2384 .to_owned(),
2385 )
2386}
2387
2388fn reject_target_with_scope(args: &cli::ReviewArgs) -> Result<(), ReviewerError> {
2389 if let Some(target) = &args.target {
2390 return Err(ReviewerError::UnexpectedReviewTarget {
2391 scope: args.scope,
2392 target: target.clone(),
2393 });
2394 }
2395 Ok(())
2396}
2397
2398fn read_claim_file(state_dir: &Path) -> Result<String, ReviewerError> {
2399 let claim_path = state_dir.join("claim.txt");
2400 fs::read_to_string(&claim_path).map_err(|source| ReviewerError::ClaimFileRead {
2401 path: claim_path,
2402 source,
2403 })
2404}
2405
2406fn working_tree_dirty() -> Result<bool, ReviewerError> {
2407 Ok(!git_output(["status", "--porcelain"])?.trim().is_empty())
2408}
2409
2410fn default_branch_ref() -> Result<String, ReviewerError> {
2411 if let Ok(symbolic) = git_output([
2412 "symbolic-ref",
2413 "--quiet",
2414 "--short",
2415 "refs/remotes/origin/HEAD",
2416 ]) {
2417 let trimmed = symbolic.trim();
2418 if !trimmed.is_empty() {
2419 return Ok(trimmed.to_owned());
2420 }
2421 }
2422
2423 for candidate in [
2424 "origin/main",
2425 "origin/master",
2426 "origin/trunk",
2427 "main",
2428 "master",
2429 "trunk",
2430 ] {
2431 if git_output_slice(&["rev-parse", "--verify", "--quiet", candidate]).is_ok() {
2432 return Ok(candidate.to_owned());
2433 }
2434 }
2435
2436 Err(ReviewerError::DefaultBranchNotFound)
2437}
2438
2439fn materialize_diff(label: &str, raw: &str, files: &str) -> String {
2440 let file_list: Vec<&str> = files
2441 .lines()
2442 .filter(|line| !line.trim().is_empty())
2443 .collect();
2444 let bytes = raw.len();
2445 if bytes <= MAX_INLINE_DIFF_BYTES && file_list.len() <= MAX_INLINE_DIFF_FILES {
2446 return raw.to_owned();
2447 }
2448
2449 format!(
2450 "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.",
2451 file_list.len(),
2452 if file_list.is_empty() {
2453 "(none)".to_owned()
2454 } else {
2455 file_list.join("\n")
2456 }
2457 )
2458}
2459
2460fn is_full_git_sha(value: &str) -> bool {
2461 matches!(value.len(), 40 | 64) && value.bytes().all(|byte| byte.is_ascii_hexdigit())
2462}
2463
2464fn untracked_file_context() -> Result<String, ReviewerError> {
2465 let files = git_output(["ls-files", "--others", "--exclude-standard"])?;
2466 let mut output = String::new();
2467 for file in files.lines().filter(|line| !line.trim().is_empty()) {
2468 let path = Path::new(file);
2469 let metadata = match fs::metadata(path) {
2470 Ok(metadata) => metadata,
2471 Err(_) => continue,
2472 };
2473 if !metadata.is_file() {
2474 continue;
2475 }
2476 if metadata.len() > MAX_UNTRACKED_FILE_BYTES {
2477 output.push_str(&format!(
2478 "\n--- {file} omitted: {} bytes exceeds {MAX_UNTRACKED_FILE_BYTES} byte inline limit ---\n",
2479 metadata.len()
2480 ));
2481 continue;
2482 }
2483 let bytes = match fs::read(path) {
2484 Ok(bytes) => bytes,
2485 Err(_) => continue,
2486 };
2487 if bytes.contains(&0) {
2488 output.push_str(&format!("\n--- {file} omitted: binary file ---\n"));
2489 continue;
2490 }
2491 output.push_str(&format!(
2492 "\n--- {file} ---\n{}",
2493 String::from_utf8_lossy(&bytes)
2494 ));
2495 }
2496
2497 if output.is_empty() {
2498 Ok("(none)".to_owned())
2499 } else {
2500 Ok(output)
2501 }
2502}
2503
2504#[derive(Debug, Error)]
2505pub enum ReviewerError {
2506 #[error("missing {role} model")]
2507 MissingModel { role: String },
2508 #[error(
2509 "same reviewer model is disallowed without --allow-same-model: watched={watched_model}, reviewer={reviewer_model}"
2510 )]
2511 SameModelWithoutWaiver {
2512 watched_model: String,
2513 reviewer_model: String,
2514 },
2515 #[error("strict arbiter model must differ from watched and first reviewer models")]
2516 StrictArbiterModelNotDistinct,
2517 #[error("no adversarial pair configured for writer harness {writer:?}")]
2518 NoPairForWriter { writer: String },
2519 #[error(
2520 "strict review requires an arbiter (pair.arbiter or --arbiter-harness/--arbiter-model)"
2521 )]
2522 MissingArbiter,
2523 #[error(
2524 "--{role}-harness={harness:?} was overridden without a matching --{role}-model; the pair's model is for a different harness"
2525 )]
2526 OverrideNeedsModel { role: String, harness: String },
2527 #[error("custom reviewer harness requires explicit command configuration")]
2528 UnsupportedCustomHarness,
2529 #[error("unknown watched agent {value:?}")]
2530 UnknownAgent { value: String },
2531 #[error("unknown reviewer harness {value:?}")]
2532 UnknownHarness { value: String },
2533 #[error("missing review target")]
2534 MissingReviewTarget,
2535 #[error("--scope={scope:?} does not accept positional target {target:?}")]
2536 UnexpectedReviewTarget { scope: ReviewScope, target: String },
2537 #[error("could not determine default branch; pass --base explicitly")]
2538 DefaultBranchNotFound,
2539 #[error("failed to read staged claim file {path}: {source}")]
2540 ClaimFileRead {
2541 path: PathBuf,
2542 #[source]
2543 source: io::Error,
2544 },
2545 #[error("reviewer output was not valid structured JSON verdict: {source}: {output:?}")]
2546 VerdictJson {
2547 source: serde_json::Error,
2548 output: String,
2549 },
2550 #[error("reviewer structured verdict violated schema: {message}")]
2551 VerdictSchema { message: String },
2552 #[error("reviewer process exited with status {status:?}: {stderr}")]
2553 ReviewerProcessFailed { status: Option<i32>, stderr: String },
2554 #[error("git command failed: git {args:?}: {stderr}")]
2555 GitFailed { args: Vec<String>, stderr: String },
2556 #[error("failed to spawn git command: {0}")]
2557 GitSpawn(io::Error),
2558 #[error("failed to spawn reviewer process: {0}")]
2559 Spawn(io::Error),
2560 #[error("failed to open reviewer stdin pipe")]
2561 MissingStdinPipe,
2562 #[error("failed to write reviewer prompt: {0}")]
2563 WritePrompt(io::Error),
2564 #[error("failed to wait for reviewer process: {0}")]
2565 Wait(io::Error),
2566 #[error("review queue IO failed: {0}")]
2567 QueueIo(io::Error),
2568 #[error("review queue JSON failed: {0}")]
2569 QueueJson(serde_json::Error),
2570 #[error("review run IO failed: {0}")]
2571 RunIo(io::Error),
2572 #[error("review run JSON failed: {0}")]
2573 RunJson(serde_json::Error),
2574 #[error("review run not found: {id}")]
2575 ReviewRunNotFound { id: String },
2576 #[error("no review runs found")]
2577 NoReviewRuns,
2578 #[error("cannot cancel review run {id} with status {status}; it has already finished")]
2579 CannotCancelReview { id: String, status: ReviewRunStatus },
2580 #[error(
2581 "review run {id} is still running (worker pid {pid} is alive); pass --force to kill it"
2582 )]
2583 ReviewRunStillAlive { id: String, pid: u32 },
2584 #[error(
2585 "review run {id} is running but records no worker pid; pass --force to reap it if it is stuck"
2586 )]
2587 ReviewRunLivenessUnknown { id: String },
2588 #[error("failed to spawn kill for stale worker: {0}")]
2589 KillWorker(io::Error),
2590 #[error("failed to kill worker process {pid}")]
2591 KillWorkerFailed { pid: u32 },
2592 #[error(transparent)]
2593 Claim(#[from] crate::claim::ClaimError),
2594 #[error(transparent)]
2595 Ledger(#[from] crate::ledger::LedgerError),
2596}
2597
2598const 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.
2599
2600Attack 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.
2601
2602GREP 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.
2603
2604Return valid JSON only. Do not wrap it in Markdown. The schema is:
2605{
2606 "verdict": "PASS" | "REJECT" | "FLAG",
2607 "summary": "one concise sentence explaining why the claim passes or fails",
2608 "findings": [
2609 {
2610 "severity": "critical" | "high" | "medium" | "low",
2611 "title": "short defect title",
2612 "body": "what can go wrong, why this code is vulnerable, and what evidence proves it",
2613 "file": "repo-relative file path",
2614 "line_start": 1,
2615 "line_end": 1,
2616 "confidence": 0,
2617 "recommendation": "concrete change required"
2618 }
2619 ],
2620 "next_steps": ["short concrete follow-up commands or edits"],
2621 "memory_skill": {
2622 "kind": "none" | "how_to_skill" | "anti_pattern_skill" | "remediation_skill",
2623 "learning_source": "short reusable procedure or failure class; empty only when kind is none",
2624 "reasoning": "why this memory-skill classification applies or why no candidate should be proposed"
2625 }
2626}
2627
2628For "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.
2629
2630Verdict semantics:
2631- "PASS": the claim is substantiated AND there are no findings. Never pair PASS with findings.
2632- "REJECT": at least one finding is materially false / unverified / unaddressed. Must include at least one finding.
2633- "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."#;
2634
2635fn context_block(context: &str) -> String {
2636 if context.trim().is_empty() {
2637 String::new()
2638 } else {
2639 format!("\n\n{context}")
2640 }
2641}
2642
2643fn first_pass_prompt(claim: &Claim, diff: &str, context: &str) -> String {
2644 format!(
2645 "{ADVERSARIAL_PREAMBLE}{}\n\nCLAIM:\n{}\n\nDIFF:\n{}",
2646 context_block(context),
2647 claim.to_line(),
2648 diff
2649 )
2650}
2651
2652const 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.
2653
2654Return valid JSON only. Do not wrap it in Markdown. The schema is:
2655{
2656 "verdict": "PASS" | "REJECT" | "FLAG",
2657 "summary": "one concise sentence explaining whether the fix materially addresses each finding",
2658 "findings": [
2659 {
2660 "severity": "critical" | "high" | "medium" | "low",
2661 "title": "short defect title",
2662 "body": "what still fails after the fix, why this code is still vulnerable, and what evidence proves it",
2663 "file": "repo-relative file path",
2664 "line_start": 1,
2665 "line_end": 1,
2666 "confidence": 0,
2667 "recommendation": "concrete change required"
2668 }
2669 ],
2670 "next_steps": ["short concrete follow-up commands or edits"],
2671 "memory_skill": {
2672 "kind": "none" | "how_to_skill" | "anti_pattern_skill" | "remediation_skill",
2673 "learning_source": "short reusable procedure or failure class; empty only when kind is none",
2674 "reasoning": "why this memory-skill classification applies or why no candidate should be proposed"
2675 }
2676}
2677
2678Verdict semantics:
2679- "PASS" means the fix materially addresses every original finding. Return PASS only when findings is empty.
2680- "REJECT" means at least one finding is still materially unaddressed by the fix.
2681- "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.
2682
2683For "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."#;
2684
2685fn petition_prompt(petition: &PetitionContext, claim: &Claim, diff: &str, context: &str) -> String {
2686 let findings_block = if petition.original_structured_findings.is_empty() {
2687 petition.original_findings.join("\n")
2688 } else {
2689 petition
2690 .original_structured_findings
2691 .iter()
2692 .map(StructuredFinding::display_line)
2693 .collect::<Vec<_>>()
2694 .join("\n")
2695 };
2696 format!(
2697 "{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{}",
2698 context_block(context),
2699 petition.original_sha,
2700 petition.fix_sha,
2701 petition.attempts_so_far,
2702 petition.original_reviewer_model,
2703 petition.original_claim,
2704 petition.original_summary,
2705 if findings_block.trim().is_empty() {
2706 "(none recorded)".to_owned()
2707 } else {
2708 findings_block
2709 },
2710 diff,
2711 claim.to_line(),
2712 )
2713}
2714
2715fn strict_second_pass_prompt(job: &ReviewJob, first_output: &str) -> String {
2716 if let Some(petition) = &job.petition {
2721 let petition_question = petition_prompt(petition, &job.claim, &job.diff, &job.context);
2722 return format!(
2723 "{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}"
2724 );
2725 }
2726 format!(
2727 "{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{}",
2728 context_block(&job.context),
2729 job.claim.to_line(),
2730 first_output,
2731 job.diff
2732 )
2733}
2734
2735fn entry_from_verdict(job: &ReviewJob, plan: &ReviewPlan, verdict: &ParsedVerdict) -> LedgerEntry {
2736 let mut entry = LedgerEntry::new(
2737 job.commit_sha.clone(),
2738 verdict.verdict,
2739 job.claim.to_line(),
2740 job.claim
2741 .evidence
2742 .iter()
2743 .map(EvidenceRef::as_str)
2744 .map(str::to_owned)
2745 .collect(),
2746 plan.reviewer_config(),
2747 verdict.findings.clone(),
2748 )
2749 .with_structured_review(
2750 verdict.summary.clone(),
2751 verdict.structured_findings.clone(),
2752 verdict.next_steps.clone(),
2753 verdict.raw.clone(),
2754 )
2755 .with_memory_skill_classification(verdict.memory_skill_classification.clone());
2756 if let Some(petition) = &job.petition {
2757 entry.petition_for = Some(petition.original_sha.clone());
2758 entry.petition_attempts = petition.attempts_so_far;
2762 }
2763 entry
2764}
2765
2766fn ensure_process_success(output: &ProcessOutput) -> Result<(), ReviewerError> {
2767 if output.status_code == Some(0) {
2768 return Ok(());
2769 }
2770
2771 Err(ReviewerError::ReviewerProcessFailed {
2772 status: output.status_code,
2773 stderr: output.stderr.clone(),
2774 })
2775}
2776
2777fn validate_strict_arbiter(
2778 request: &ReviewRequest,
2779 strict: &StrictReviewConfig,
2780) -> Result<(), ReviewerError> {
2781 let arbiter = normalized_model(&strict.arbiter_model);
2782 if arbiter == normalized_model(&request.watched_model)
2783 || arbiter == normalized_model(&request.reviewer_model)
2784 {
2785 return Err(ReviewerError::StrictArbiterModelNotDistinct);
2786 }
2787 Ok(())
2788}
2789
2790fn validate_model_present(role: &str, model: &str) -> Result<(), ReviewerError> {
2791 if model.trim().is_empty() {
2792 return Err(ReviewerError::MissingModel {
2793 role: role.to_owned(),
2794 });
2795 }
2796 Ok(())
2797}
2798
2799fn git_output<const N: usize>(args: [&str; N]) -> Result<String, ReviewerError> {
2800 git_output_slice(&args)
2801}
2802
2803fn git_output_slice(args: &[&str]) -> Result<String, ReviewerError> {
2804 let output = Command::new("git")
2805 .args(args)
2806 .output()
2807 .map_err(ReviewerError::GitSpawn)?;
2808 if !output.status.success() {
2809 return Err(ReviewerError::GitFailed {
2810 args: args.iter().map(|arg| (*arg).to_owned()).collect(),
2811 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
2812 });
2813 }
2814
2815 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
2816}
2817
2818fn agent_from_slug(value: &str) -> Result<Agent, ReviewerError> {
2819 match value.trim().to_ascii_lowercase().as_str() {
2820 "claude" => Ok(Agent::Claude),
2821 "codex" => Ok(Agent::Codex),
2822 "pi" => Ok(Agent::Pi),
2823 "grok" => Ok(Agent::Grok),
2824 _ => Err(ReviewerError::UnknownAgent {
2825 value: value.to_owned(),
2826 }),
2827 }
2828}
2829
2830fn harness_from_slug(value: &str) -> Result<ReviewerHarness, ReviewerError> {
2831 match value.trim().to_ascii_lowercase().as_str() {
2832 "claude" => Ok(ReviewerHarness::Claude),
2833 "codex" => Ok(ReviewerHarness::Codex),
2834 "pi" => Ok(ReviewerHarness::Pi),
2835 "gemini" => Ok(ReviewerHarness::Gemini),
2836 "opencode" => Ok(ReviewerHarness::Opencode),
2837 "custom" => Ok(ReviewerHarness::Custom),
2838 _ => Err(ReviewerError::UnknownHarness {
2839 value: value.to_owned(),
2840 }),
2841 }
2842}
2843
2844fn harness_slug(harness: ReviewerHarness) -> &'static str {
2845 match harness {
2846 ReviewerHarness::Claude => "claude",
2847 ReviewerHarness::Codex => "codex",
2848 ReviewerHarness::Pi => "pi",
2849 ReviewerHarness::Gemini => "gemini",
2850 ReviewerHarness::Opencode => "opencode",
2851 ReviewerHarness::Custom => "custom",
2852 }
2853}
2854
2855fn normalized_model(model: &str) -> String {
2861 config::normalized_model(model)
2862}
2863
2864fn generate_run_id(commit_sha: &str) -> String {
2865 let nanos = SystemTime::now()
2866 .duration_since(UNIX_EPOCH)
2867 .map_or(0, |duration| duration.as_nanos());
2868 let short_sha: String = commit_sha
2869 .chars()
2870 .filter(|character| character.is_ascii_alphanumeric())
2871 .take(12)
2872 .collect();
2873 if short_sha.is_empty() {
2874 format!("{nanos}-{}", std::process::id())
2875 } else {
2876 format!("{nanos}-{}-{short_sha}", std::process::id())
2877 }
2878}
2879
2880#[cfg(test)]
2881mod tests {
2882 use std::{cell::RefCell, collections::VecDeque, fs, process::Command};
2883
2884 use super::normalized_model;
2885
2886 use proptest::prelude::*;
2887
2888 use super::{
2889 InvocationPlan, MaterialLoader, ParsedVerdict, ProcessOutput, ProcessRunner,
2890 PromptDelivery, ReviewJob, ReviewPlan, ReviewQueue, ReviewRequest, ReviewRun,
2891 ReviewRunStatus, ReviewRunStore, ReviewSelection, ReviewerError, StrictGoalCounters,
2892 StrictGoalDecision, StrictGoalPolicy, StrictGoalStopReason, StrictReviewConfig,
2893 UntilEmptyDecision, drain_once, execute_review_job, is_full_git_sha,
2894 run_review_run_command, run_strict_goal_loop, until_empty_decision,
2895 };
2896 use crate::{
2897 claim::{Claim, EvidenceRef},
2898 cli::{Agent, ReviewerHarness},
2899 config::{Effort, TruthMirrorConfig},
2900 ledger::{FindingSeverity, LedgerStore, StructuredFinding, Verdict},
2901 watcher::pid_is_alive,
2902 };
2903
2904 fn pass_json() -> String {
2905 serde_json::json!({
2906 "verdict": "PASS",
2907 "summary": "The claim is substantiated by the diff and evidence.",
2908 "findings": [],
2909 "next_steps": [],
2910 "memory_skill": {
2911 "kind": "how_to_skill",
2912 "learning_source": "run reusable review workflow",
2913 "reasoning": "The passing claim describes a reusable verified procedure."
2914 }
2915 })
2916 .to_string()
2917 }
2918
2919 fn reject_json(title: &str) -> String {
2920 serde_json::json!({
2921 "verdict": "REJECT",
2922 "summary": "The claim is not substantiated.",
2923 "findings": [{
2924 "severity": "high",
2925 "title": title,
2926 "body": "The cited evidence does not prove the claimed behavior.",
2927 "file": "src/lib.rs",
2928 "line_start": 1,
2929 "line_end": 1,
2930 "confidence": 95,
2931 "recommendation": "Provide executable evidence that proves the claim."
2932 }],
2933 "next_steps": ["Run the relevant verification command."],
2934 "memory_skill": {
2935 "kind": "anti_pattern_skill",
2936 "learning_source": title,
2937 "reasoning": "The rejection identifies a reusable false-claim failure class."
2938 }
2939 })
2940 .to_string()
2941 }
2942
2943 #[test]
2944 fn same_harness_different_model_is_valid() {
2945 let request = ReviewRequest::new(
2946 Agent::Codex,
2947 "gpt-5.4",
2948 ReviewerHarness::Codex,
2949 "gpt-5.5",
2950 false,
2951 "review this",
2952 );
2953
2954 let plan = ReviewPlan::build(request).unwrap();
2955
2956 assert_eq!(plan.watched_agent, Agent::Codex);
2957 assert_eq!(plan.reviewer_harness, ReviewerHarness::Codex);
2958 assert_eq!(plan.invocation.program, "codex");
2959 }
2960
2961 #[test]
2962 fn same_model_is_blocked_by_default() {
2963 let request = ReviewRequest::new(
2964 Agent::Codex,
2965 " GPT-5.5 ",
2966 ReviewerHarness::Claude,
2967 "gpt-5.5",
2968 false,
2969 "review this",
2970 );
2971
2972 let error = ReviewPlan::build(request).unwrap_err();
2973
2974 assert!(matches!(
2975 error,
2976 ReviewerError::SameModelWithoutWaiver { .. }
2977 ));
2978 }
2979
2980 #[test]
2981 fn allow_same_model_override_is_deliberate() {
2982 let request = ReviewRequest::new(
2983 Agent::Codex,
2984 "gpt-5.5",
2985 ReviewerHarness::Codex,
2986 "gpt-5.5",
2987 true,
2988 "review this",
2989 );
2990
2991 let plan = ReviewPlan::build(request).unwrap();
2992
2993 assert!(plan.allow_same_model);
2994 assert_eq!(plan.reviewer_model, "gpt-5.5");
2995 }
2996
2997 #[test]
2998 fn provider_mapping_uses_verified_prompt_shapes_and_effort() {
2999 let codex =
3000 InvocationPlan::for_harness(ReviewerHarness::Codex, "gpt-5.5", Effort::Xhigh).unwrap();
3001 assert_eq!(codex.program, "codex");
3002 assert_eq!(
3003 codex.args_for_prompt("prompt"),
3004 [
3005 "exec",
3006 "-m",
3007 "gpt-5.5",
3008 "-c",
3009 "model_reasoning_effort=xhigh",
3010 "prompt"
3011 ]
3012 );
3013
3014 let claude =
3015 InvocationPlan::for_harness(ReviewerHarness::Claude, "opus", Effort::High).unwrap();
3016 assert_eq!(claude.program, "claude");
3017 assert_eq!(claude.prompt_delivery, PromptDelivery::Stdin);
3018 assert_eq!(
3019 claude.args_for_prompt("prompt"),
3020 ["--print", "--model", "opus", "--effort", "high"]
3021 );
3022
3023 let gemini =
3024 InvocationPlan::for_harness(ReviewerHarness::Gemini, "gemini-pro", Effort::Xhigh)
3025 .unwrap();
3026 assert_eq!(
3027 gemini.args_for_prompt("prompt"),
3028 ["-m", "gemini-pro", "-p", "prompt"]
3029 );
3030
3031 let pi = InvocationPlan::for_harness(ReviewerHarness::Pi, "openai/gpt-5.5", Effort::Xhigh)
3032 .unwrap();
3033 assert_eq!(pi.prompt_delivery, PromptDelivery::Stdin);
3034 assert_eq!(
3035 pi.args_for_prompt("prompt"),
3036 [
3037 "--model",
3038 "openai/gpt-5.5",
3039 "--thinking",
3040 "xhigh",
3041 "--tools",
3042 "read,grep,find,ls",
3043 "-p"
3044 ]
3045 );
3046 }
3047
3048 #[test]
3049 fn custom_harness_requires_explicit_configuration() {
3050 let error = InvocationPlan::for_harness(ReviewerHarness::Custom, "model", Effort::Xhigh)
3051 .unwrap_err();
3052
3053 assert!(matches!(error, ReviewerError::UnsupportedCustomHarness));
3054 }
3055
3056 #[test]
3057 fn effort_maps_to_each_harness_flag() {
3058 for effort in [
3059 Effort::Minimal,
3060 Effort::Low,
3061 Effort::Medium,
3062 Effort::High,
3063 Effort::Xhigh,
3064 ] {
3065 let e = effort.as_str();
3066
3067 let codex = InvocationPlan::for_harness(ReviewerHarness::Codex, "m", effort).unwrap();
3068 assert!(codex.args.contains(&format!("model_reasoning_effort={e}")));
3069
3070 let claude = InvocationPlan::for_harness(ReviewerHarness::Claude, "m", effort).unwrap();
3071 let claude_idx = claude.args.iter().position(|a| a == "--effort").unwrap();
3072 assert_eq!(claude.args[claude_idx + 1], effort.claude_value());
3074 assert_ne!(claude.args[claude_idx + 1], "minimal");
3075
3076 let pi = InvocationPlan::for_harness(ReviewerHarness::Pi, "m", effort).unwrap();
3077 let pi_idx = pi.args.iter().position(|a| a == "--thinking").unwrap();
3078 assert_eq!(pi.args[pi_idx + 1], e);
3079 }
3080 }
3081
3082 #[test]
3083 fn resolve_picks_configured_reviewer_for_every_writer() {
3084 let config = crate::config::TruthMirrorConfig::default();
3085
3086 let cases = [
3087 (Agent::Codex, ReviewerHarness::Claude, "claude-opus-4-8"),
3088 (Agent::Claude, ReviewerHarness::Codex, "gpt-5.5"),
3089 (Agent::Pi, ReviewerHarness::Codex, "gpt-5.5"),
3090 (Agent::Grok, ReviewerHarness::Claude, "claude-opus-4-8"),
3091 ];
3092
3093 for (writer, reviewer_harness, reviewer_model) in cases {
3094 let selection =
3095 ReviewSelection::resolve(Some(writer), None, None, None, None, false, &config)
3096 .unwrap();
3097
3098 assert_eq!(selection.reviewer_harness, reviewer_harness);
3099 assert_eq!(selection.reviewer_model, reviewer_model);
3100 assert_eq!(selection.reviewer_effort, Effort::Xhigh);
3101 }
3102 }
3103
3104 #[test]
3105 fn overriding_reviewer_harness_without_model_is_rejected() {
3106 let config = crate::config::TruthMirrorConfig::default();
3109 let error = ReviewSelection::resolve(
3110 Some(Agent::Codex),
3111 None,
3112 Some(ReviewerHarness::Pi),
3113 None,
3114 None,
3115 false,
3116 &config,
3117 )
3118 .unwrap_err();
3119
3120 assert!(matches!(error, ReviewerError::OverrideNeedsModel { .. }));
3121 }
3122
3123 #[test]
3124 fn overriding_reviewer_harness_matching_pair_is_ok() {
3125 let config = crate::config::TruthMirrorConfig::default();
3126 let selection = ReviewSelection::resolve(
3127 Some(Agent::Codex),
3128 None,
3129 Some(ReviewerHarness::Claude),
3130 None,
3131 None,
3132 false,
3133 &config,
3134 )
3135 .unwrap();
3136
3137 assert_eq!(selection.reviewer_harness, ReviewerHarness::Claude);
3138 assert_eq!(selection.reviewer_model, "claude-opus-4-8");
3139 }
3140
3141 #[test]
3142 fn config_allow_same_model_waives_opposition() {
3143 let config = crate::config::TruthMirrorConfig {
3144 allow_same_model: true,
3145 ..crate::config::TruthMirrorConfig::default()
3146 };
3147
3148 let selection = ReviewSelection::resolve(
3149 Some(Agent::Codex),
3150 Some("gpt-5.5".to_owned()),
3151 Some(ReviewerHarness::Codex),
3152 Some("gpt-5.5".to_owned()),
3153 None,
3154 false, &config,
3156 )
3157 .unwrap();
3158
3159 assert!(selection.allow_same_model);
3160 assert!(ReviewPlan::build(selection.request_for("review".to_owned())).is_ok());
3162 }
3163
3164 #[test]
3165 fn full_git_sha_accepts_sha1_and_sha256_lengths() {
3166 assert!(is_full_git_sha(&"a".repeat(40)));
3167 assert!(is_full_git_sha(&"b".repeat(64)));
3168 assert!(!is_full_git_sha(&"c".repeat(39)));
3169 assert!(!is_full_git_sha(&"g".repeat(40)));
3170 }
3171
3172 #[test]
3173 fn resolve_arbiter_uses_pair_when_cli_absent() {
3174 let config = crate::config::TruthMirrorConfig::default();
3175 let arbiter =
3176 ReviewSelection::resolve_arbiter(Agent::Codex, None, None, None, &config).unwrap();
3177
3178 assert_eq!(arbiter.arbiter_harness, ReviewerHarness::Pi);
3179 assert_eq!(arbiter.arbiter_effort, Effort::Xhigh);
3180 }
3181
3182 #[test]
3183 fn first_pass_prompt_is_adversarial_and_injects_context() {
3184 let prompt = super::first_pass_prompt(
3185 &claim(),
3186 "THE_DIFF_BODY",
3187 "INVIOLABLE CONSTRAINTS: never fake tests",
3188 );
3189
3190 assert!(prompt.contains("PROVE THIS CLAIM FALSE"));
3191 assert!(prompt.contains("default to REJECT"));
3192 assert!(prompt.contains("INVIOLABLE CONSTRAINTS: never fake tests"));
3193 assert!(prompt.contains("THE_DIFF_BODY"));
3194 assert!(prompt.contains("GREP THE CLASS, NOT THE INSTANCE"));
3196 assert!(prompt.contains("\"severity\""));
3197 assert!(prompt.contains("\"recommendation\""));
3198 assert!(prompt.contains("\"memory_skill\""));
3199 assert!(prompt.contains("\"anti_pattern_skill\""));
3200 }
3201
3202 #[test]
3203 fn strict_second_pass_is_a_completeness_critic() {
3204 let job = review_job(true);
3205 let first_output = pass_json();
3206 let prompt = super::strict_second_pass_prompt(&job, &first_output);
3207
3208 assert!(prompt.contains("COMPLETENESS CRITIC"));
3209 assert!(prompt.contains("generalize"));
3210 assert!(prompt.contains("GREP THE CLASS, NOT THE INSTANCE"));
3212 }
3213
3214 #[test]
3215 fn prompt_omits_context_block_when_empty() {
3216 let prompt = super::first_pass_prompt(&claim(), "d", "");
3217 assert!(!prompt.contains("INVIOLABLE CONSTRAINTS"));
3219 assert!(prompt.contains("PROVE THIS CLAIM FALSE"));
3220 }
3221
3222 fn sample_petition(attempts_so_far: u32) -> super::PetitionContext {
3223 super::PetitionContext {
3224 original_sha: "abc123".to_owned(),
3225 fix_sha: "def456".to_owned(),
3226 original_claim: "CLAIM: original | verified: cargo test | evidence: tests:cargo-test"
3227 .to_owned(),
3228 original_summary: "Original rejection summary.".to_owned(),
3229 original_findings: vec!["evidence too thin".to_owned()],
3230 original_structured_findings: vec![StructuredFinding {
3231 severity: FindingSeverity::High,
3232 title: "thin evidence".to_owned(),
3233 body: "Evidence pointer does not prove the claim.".to_owned(),
3234 file: "src/lib.rs".to_owned(),
3235 line_start: 1,
3236 line_end: 2,
3237 confidence: 90,
3238 recommendation: "Add executable evidence.".to_owned(),
3239 }],
3240 original_reviewer_model: "claude-opus-4-1".to_owned(),
3241 attempts_so_far,
3242 }
3243 }
3244
3245 #[test]
3246 fn petition_prompt_includes_original_findings_fix_sha_and_attempts() {
3247 let prompt = super::petition_prompt(&sample_petition(1), &claim(), "DIFF BODY", "");
3248
3249 assert!(prompt.contains("PETITION"));
3250 assert!(prompt.contains("original rejection SHA: abc123"));
3251 assert!(prompt.contains("fix commit SHA: def456"));
3252 assert!(prompt.contains("petition attempts so far: 1"));
3253 assert!(prompt.contains("Original rejection summary."));
3254 assert!(prompt.contains("thin evidence")); assert!(prompt.contains("DIFF BODY"));
3256 assert!(prompt.contains("\"verdict\": \"PASS\" | \"REJECT\" | \"FLAG\""));
3257 }
3258
3259 #[test]
3260 fn materialize_petition_prompt_rewrites_request_prompt() {
3261 let mut job = review_job(false);
3262 job.petition = Some(sample_petition(2));
3263 let original_prompt = job.request.prompt.clone();
3264 let updated = super::materialize_petition_prompt(job);
3265
3266 assert_ne!(updated.request.prompt, original_prompt);
3267 assert!(updated.request.prompt.contains("PETITION"));
3268 assert!(updated.request.prompt.contains("fix commit SHA: def456"));
3269 assert!(
3270 updated
3271 .request
3272 .prompt
3273 .contains("petition attempts so far: 2")
3274 );
3275 }
3276
3277 #[test]
3278 fn materialize_petition_prompt_passthrough_when_no_petition() {
3279 let job = review_job(false);
3280 let original_prompt = job.request.prompt.clone();
3281 let updated = super::materialize_petition_prompt(job);
3282 assert_eq!(updated.request.prompt, original_prompt);
3283 }
3284
3285 #[test]
3286 fn subprocess_runner_is_mockable() {
3287 struct MockRunner;
3288
3289 impl ProcessRunner for MockRunner {
3290 fn run(
3291 &self,
3292 invocation: &InvocationPlan,
3293 prompt: &str,
3294 ) -> Result<ProcessOutput, ReviewerError> {
3295 assert_eq!(invocation.program, "codex");
3296 assert_eq!(
3297 invocation.args_for_prompt(prompt).last().unwrap(),
3298 "review this"
3299 );
3300 Ok(ProcessOutput {
3301 status_code: Some(0),
3302 stdout: pass_json(),
3303 stderr: String::new(),
3304 })
3305 }
3306 }
3307
3308 let request = ReviewRequest::new(
3309 Agent::Codex,
3310 "gpt-5.4",
3311 ReviewerHarness::Codex,
3312 "gpt-5.5",
3313 false,
3314 "review this",
3315 );
3316 let plan = ReviewPlan::build(request).unwrap();
3317 let output = plan.run_with("review this", &MockRunner).unwrap();
3318
3319 assert!(output.stdout.contains("PASS"));
3320 }
3321
3322 #[test]
3323 fn verdict_parser_extracts_rejection_findings() {
3324 let verdict = ParsedVerdict::parse(&reject_json("missing proof")).unwrap();
3325
3326 assert_eq!(verdict.verdict, Verdict::Reject);
3327 assert_eq!(verdict.structured_findings[0].title, "missing proof");
3328 assert_eq!(verdict.structured_findings[0].confidence, 95);
3329 assert!(verdict.findings[0].contains("missing proof"));
3330 assert_eq!(
3331 verdict.memory_skill_classification.learning_source,
3332 "missing proof"
3333 );
3334 }
3335
3336 fn flag_json(title: &str) -> String {
3337 serde_json::json!({
3338 "verdict": "FLAG",
3339 "summary": "Claim substantiated; flagging thin evidence.",
3340 "findings": [{
3341 "severity": "medium",
3342 "title": title,
3343 "body": "Evidence pointer is thin but the claim holds.",
3344 "file": "src/lib.rs",
3345 "line_start": 1,
3346 "line_end": 1,
3347 "confidence": 60,
3348 "recommendation": "Tighten evidence."
3349 }],
3350 "next_steps": ["Add stronger evidence."],
3351 "memory_skill": {
3352 "kind": "none",
3353 "learning_source": "",
3354 "reasoning": "No reusable procedural memory to propose."
3355 }
3356 })
3357 .to_string()
3358 }
3359
3360 #[test]
3361 fn verdict_parser_extracts_flag_findings() {
3362 let verdict = ParsedVerdict::parse(&flag_json("thin evidence")).unwrap();
3363
3364 assert_eq!(verdict.verdict, Verdict::Flag);
3365 assert_eq!(verdict.structured_findings[0].title, "thin evidence");
3366 assert_eq!(verdict.structured_findings[0].confidence, 60);
3367 assert!(verdict.findings[0].contains("thin evidence"));
3368 }
3369
3370 #[test]
3371 fn verdict_parser_rejects_flag_with_no_findings() {
3372 let output = serde_json::json!({
3373 "verdict": "FLAG",
3374 "summary": "Claim substantiated but flagging.",
3375 "findings": [],
3376 "next_steps": [],
3377 "memory_skill": {
3378 "kind": "none",
3379 "learning_source": "",
3380 "reasoning": "no reusable memory"
3381 }
3382 });
3383
3384 let error = ParsedVerdict::parse(&output.to_string()).unwrap_err();
3385
3386 assert!(matches!(error, ReviewerError::VerdictSchema { .. }));
3387 }
3388
3389 #[test]
3390 fn verdict_parser_accepts_lowercase_flag_via_schema() {
3391 let output = serde_json::json!({
3396 "verdict": "flag",
3397 "summary": "Claim substantiated but flagging.",
3398 "findings": [{
3399 "severity": "low",
3400 "title": "x",
3401 "body": "y",
3402 "file": "src/lib.rs",
3403 "line_start": 1,
3404 "line_end": 1,
3405 "confidence": 50,
3406 "recommendation": "z"
3407 }],
3408 "next_steps": [],
3409 "memory_skill": {
3410 "kind": "none",
3411 "learning_source": "",
3412 "reasoning": "none"
3413 }
3414 });
3415
3416 let error = ParsedVerdict::parse(&output.to_string()).unwrap_err();
3417 assert!(matches!(error, ReviewerError::VerdictJson { .. }));
3418 }
3419
3420 #[test]
3421 fn verdict_parser_rejects_missing_memory_skill_classification() {
3422 let output = serde_json::json!({
3423 "verdict": "PASS",
3424 "summary": "The claim is substantiated.",
3425 "findings": [],
3426 "next_steps": []
3427 });
3428
3429 let error = ParsedVerdict::parse(&output.to_string()).unwrap_err();
3430
3431 assert!(matches!(error, ReviewerError::VerdictJson { .. }));
3432 }
3433
3434 #[test]
3435 fn verdict_parser_rejects_incompatible_memory_skill_classification() {
3436 let mut output: serde_json::Value = serde_json::from_str(&pass_json()).unwrap();
3437 output["memory_skill"]["kind"] = serde_json::json!("remediation_skill");
3438
3439 let error = ParsedVerdict::parse(&output.to_string()).unwrap_err();
3440
3441 assert!(matches!(error, ReviewerError::VerdictSchema { .. }));
3442 }
3443
3444 #[test]
3445 fn verdict_parser_accepts_normalized_float_confidence() {
3446 let mut output: serde_json::Value =
3447 serde_json::from_str(&reject_json("missing proof")).unwrap();
3448 output["findings"][0]["confidence"] = serde_json::json!(0.95);
3449
3450 let verdict = ParsedVerdict::parse(&output.to_string()).unwrap();
3451
3452 assert_eq!(verdict.structured_findings[0].confidence, 95);
3453 }
3454
3455 #[test]
3456 fn verdict_parser_rejects_legacy_line_protocol() {
3457 let error =
3458 ParsedVerdict::parse("VERDICT: REJECT\nFINDINGS:\n- missing proof\n").unwrap_err();
3459
3460 assert!(matches!(error, ReviewerError::VerdictJson { .. }));
3461 }
3462
3463 #[test]
3464 fn large_diff_materialization_falls_back_to_file_summary() {
3465 let files = "a.rs\nb.rs\nc.rs\n";
3466 let materialized = super::materialize_diff("branch:main", "tiny diff", files);
3467
3468 assert!(materialized.contains("too large to inline safely"));
3469 assert!(materialized.contains("actual_files=3"));
3470 assert!(materialized.contains("a.rs\nb.rs\nc.rs"));
3471 assert!(materialized.contains("inspect the repository directly"));
3472 }
3473
3474 #[test]
3475 fn review_queue_schedules_commits_without_running_models() {
3476 let temp = tempfile::tempdir().unwrap();
3477 let queue = ReviewQueue::new(temp.path());
3478
3479 queue.enqueue("abc123").unwrap();
3480
3481 let pending = queue.pending().unwrap();
3482 assert_eq!(pending.len(), 1);
3483 assert_eq!(pending[0].commit_sha, "abc123");
3484 assert!(!pending[0].run_id.is_empty());
3485
3486 let run = ReviewRunStore::new(temp.path())
3487 .read(&pending[0].run_id)
3488 .unwrap();
3489 assert_eq!(run.commit_sha, "abc123");
3490 assert_eq!(run.status, ReviewRunStatus::Queued);
3491 assert_eq!(run.entire_checkpoint, None);
3492 }
3493
3494 #[test]
3495 fn review_queue_summary_counts_pending_and_oldest() {
3496 let temp = tempfile::tempdir().unwrap();
3497 let queue = ReviewQueue::new(temp.path());
3498 std::fs::write(
3499 queue.path(),
3500 "{\"commit_sha\":\"abc123\",\"enqueued_at_unix\":30}\n{\"commit_sha\":\"def456\",\"enqueued_at_unix\":10}\n",
3501 )
3502 .unwrap();
3503
3504 let summary = queue.summary().unwrap();
3505
3506 assert_eq!(summary.pending_count, 2);
3507 assert_eq!(summary.oldest_enqueued_at_unix, Some(10));
3508 assert_eq!(summary.oldest_age_secs_at(42), Some(32));
3509 }
3510
3511 #[test]
3512 fn review_run_serializes_optional_entire_checkpoint() {
3513 let run = ReviewRun::queued_with_provenance(
3514 "run-id",
3515 "abcdef123456",
3516 "commit",
3517 Some(crate::provenance::EntireCheckpointRef {
3518 ref_name: "entire/session-abcdef".to_owned(),
3519 object_sha: "123456".to_owned(),
3520 }),
3521 );
3522
3523 let json = serde_json::to_string(&run).unwrap();
3524
3525 assert!(json.contains("entire_checkpoint"));
3526 assert!(json.contains("entire/session-abcdef"));
3527 }
3528
3529 #[test]
3530 fn review_run_omits_absent_optional_entire_checkpoint() {
3531 let run = ReviewRun::queued("run-id", "abcdef123456", "commit");
3532
3533 let json = serde_json::to_string(&run).unwrap();
3534
3535 assert!(!json.contains("entire_checkpoint"));
3536 }
3537
3538 #[test]
3539 fn review_run_status_counts_read_only_status_fields() {
3540 let temp = tempfile::tempdir().unwrap();
3541 let store = ReviewRunStore::new(temp.path());
3542 let queued = store.create_queued("abc123", "commit").unwrap();
3543 let mut failed = ReviewRun::queued("failed-run", "def456", "commit");
3544 failed.mark_failed("boom");
3545 store.write(&failed).unwrap();
3546 fs::write(store.path("malformed-run"), "{not-json\n").unwrap();
3547
3548 let counts = store.status_counts().unwrap();
3549
3550 assert_eq!(counts.queued, 1);
3551 assert_eq!(counts.failed, 1);
3552 assert_eq!(counts.running, 0);
3553 assert_eq!(counts.skipped_records, 1);
3554 assert_eq!(queued.status, ReviewRunStatus::Queued);
3555 }
3556
3557 #[test]
3558 fn review_cancel_marks_queued_run_and_removes_queue_item() {
3559 let temp = tempfile::tempdir().unwrap();
3560 let queue = ReviewQueue::new(temp.path());
3561 let queued = queue.enqueue("abc123").unwrap();
3562
3563 run_review_run_command(
3564 crate::cli::ReviewCommand::Cancel {
3565 run_id: queued.run_id.clone(),
3566 force: false,
3567 },
3568 temp.path(),
3569 )
3570 .unwrap();
3571
3572 assert!(queue.pending().unwrap().is_empty());
3573 let run = ReviewRunStore::new(temp.path())
3574 .read(&queued.run_id)
3575 .unwrap();
3576 assert_eq!(run.status, ReviewRunStatus::Cancelled);
3577 }
3578
3579 fn reaped_pid() -> u32 {
3581 let mut child = Command::new("true").spawn().expect("spawn `true`");
3582 let pid = child.id();
3583 child.wait().expect("reap `true`");
3584 pid
3585 }
3586
3587 fn write_running_run(store: &ReviewRunStore, worker_pid: Option<u32>) -> ReviewRun {
3590 let mut run = store.create_queued("abc123", "commit").unwrap();
3591 run.status = ReviewRunStatus::Running;
3592 run.phase = "reviewing".to_owned();
3593 run.worker_pid = worker_pid;
3594 store.write(&run).unwrap();
3595 run
3596 }
3597
3598 #[test]
3599 fn pid_liveness_probe_tracks_real_processes() {
3600 assert!(pid_is_alive(std::process::id()));
3601 assert!(!pid_is_alive(reaped_pid()));
3602 }
3603
3604 #[test]
3605 fn reconcile_liveness_only_reaps_dead_running_runs() {
3606 let mut queued = ReviewRun::queued("id", "abc123", "commit");
3607 assert!(!queued.reconcile_liveness(|_| false));
3609 assert_eq!(queued.status, ReviewRunStatus::Queued);
3610
3611 queued.mark_running("reviewing");
3612 assert!(!queued.reconcile_liveness(|_| true));
3614 assert_eq!(queued.status, ReviewRunStatus::Running);
3615 assert!(queued.reconcile_liveness(|_| false));
3617 assert_eq!(queued.status, ReviewRunStatus::Failed);
3618 assert!(queued.error.as_deref().unwrap().contains("stale run"));
3619 assert!(queued.worker_pid.is_none());
3620
3621 let mut legacy = ReviewRun::queued("id2", "def456", "commit");
3623 legacy.status = ReviewRunStatus::Running;
3624 legacy.worker_pid = None;
3625 assert!(!legacy.reconcile_liveness(|_| false));
3626 assert_eq!(legacy.status, ReviewRunStatus::Running);
3627 }
3628
3629 #[test]
3630 fn review_status_reaps_running_run_with_dead_worker_and_persists() {
3631 let temp = tempfile::tempdir().unwrap();
3632 let store = ReviewRunStore::new(temp.path());
3633 let run = write_running_run(&store, Some(reaped_pid()));
3634
3635 let reconciled = store.read_reconciled(&run.id).unwrap();
3636 assert_eq!(reconciled.status, ReviewRunStatus::Failed);
3637 assert!(reconciled.error.as_deref().unwrap().contains("stale run"));
3638
3639 assert_eq!(store.read(&run.id).unwrap().status, ReviewRunStatus::Failed);
3641 let listed = store.list_reconciled().unwrap();
3643 assert_eq!(listed.len(), 1);
3644 assert_eq!(listed[0].status, ReviewRunStatus::Failed);
3645 }
3646
3647 #[test]
3648 fn review_status_leaves_running_run_with_live_worker() {
3649 let temp = tempfile::tempdir().unwrap();
3650 let store = ReviewRunStore::new(temp.path());
3651 let run = write_running_run(&store, Some(std::process::id()));
3652
3653 let reconciled = store.read_reconciled(&run.id).unwrap();
3654 assert_eq!(reconciled.status, ReviewRunStatus::Running);
3655 }
3656
3657 #[test]
3658 fn cancel_reaps_running_run_with_dead_worker_without_force() {
3659 let temp = tempfile::tempdir().unwrap();
3660 let store = ReviewRunStore::new(temp.path());
3661 let run = write_running_run(&store, Some(reaped_pid()));
3662
3663 let cancelled = store.cancel(&run.id, false).unwrap();
3664 assert_eq!(cancelled.status, ReviewRunStatus::Failed);
3665 assert!(cancelled.error.as_deref().unwrap().contains("stale run"));
3666 }
3667
3668 #[test]
3669 fn cancel_refuses_live_running_run_without_force() {
3670 let temp = tempfile::tempdir().unwrap();
3671 let store = ReviewRunStore::new(temp.path());
3672 let run = write_running_run(&store, Some(std::process::id()));
3673
3674 let error = store.cancel(&run.id, false).unwrap_err();
3675 assert!(matches!(error, ReviewerError::ReviewRunStillAlive { .. }));
3676 assert_eq!(
3678 store.read(&run.id).unwrap().status,
3679 ReviewRunStatus::Running
3680 );
3681 }
3682
3683 #[test]
3684 fn cancel_force_kills_live_worker_and_cancels() {
3685 let temp = tempfile::tempdir().unwrap();
3686 let store = ReviewRunStore::new(temp.path());
3687 let mut child = Command::new("sleep")
3688 .arg("30")
3689 .spawn()
3690 .expect("spawn sleep");
3691 let pid = child.id();
3692 let run = write_running_run(&store, Some(pid));
3693
3694 let cancelled = store.cancel(&run.id, true).unwrap();
3695 assert_eq!(cancelled.status, ReviewRunStatus::Cancelled);
3696
3697 let _ = child.wait();
3699 assert!(!pid_is_alive(pid));
3700 }
3701
3702 #[test]
3703 fn cancel_legacy_running_run_requires_force_then_reaps() {
3704 let temp = tempfile::tempdir().unwrap();
3705 let store = ReviewRunStore::new(temp.path());
3706 let run = write_running_run(&store, None);
3707
3708 let error = store.cancel(&run.id, false).unwrap_err();
3709 assert!(matches!(
3710 error,
3711 ReviewerError::ReviewRunLivenessUnknown { .. }
3712 ));
3713
3714 let reaped = store.cancel(&run.id, true).unwrap();
3715 assert_eq!(reaped.status, ReviewRunStatus::Failed);
3716 }
3717
3718 #[test]
3719 fn cancel_refuses_already_terminal_run() {
3720 let temp = tempfile::tempdir().unwrap();
3721 let store = ReviewRunStore::new(temp.path());
3722 let run = store.create_queued("abc123", "commit").unwrap();
3723 store.mark_completed(&run.id, 0).unwrap();
3724
3725 let error = store.cancel(&run.id, true).unwrap_err();
3726 assert!(matches!(
3727 error,
3728 ReviewerError::CannotCancelReview {
3729 status: ReviewRunStatus::Completed,
3730 ..
3731 }
3732 ));
3733 }
3734
3735 #[test]
3736 fn execute_review_records_reject_verdict() {
3737 let temp = tempfile::tempdir().unwrap();
3738 let store = LedgerStore::new(temp.path());
3739 let job = review_job(false);
3740 let runner = SequenceRunner::new([reject_json("unsupported")]);
3741
3742 let execution = execute_review_job(job, &runner, &store).unwrap();
3743
3744 assert_eq!(execution.entries.len(), 1);
3745 assert_eq!(execution.entries[0].verdict, Verdict::Reject);
3746 assert_eq!(
3747 execution.entries[0].structured_findings[0].title,
3748 "unsupported"
3749 );
3750 assert!(
3751 execution.entries[0]
3752 .raw_reviewer_output
3753 .contains("\"REJECT\"")
3754 );
3755 assert_eq!(store.unresolved_rejections().unwrap().len(), 1);
3756 }
3757
3758 #[test]
3759 fn strict_two_pass_records_both_clean_passes() {
3760 let temp = tempfile::tempdir().unwrap();
3761 let store = LedgerStore::new(temp.path());
3762 let job = review_job(true);
3763 let runner = SequenceRunner::new([pass_json(), pass_json()]);
3764
3765 let execution = execute_review_job(job, &runner, &store).unwrap();
3766
3767 assert_eq!(execution.entries.len(), 2);
3768 assert_eq!(store.read_history().unwrap().len(), 2);
3769 assert_eq!(execution.entries[0].reviewer.model, "gpt-5.5");
3770 assert_eq!(execution.entries[1].reviewer.model, "claude-opus-4-8");
3771 }
3772
3773 #[test]
3774 fn strict_arbiter_model_must_be_third_model() {
3775 let temp = tempfile::tempdir().unwrap();
3776 let store = LedgerStore::new(temp.path());
3777 let mut job = review_job(true);
3778 job.strict.as_mut().unwrap().arbiter_model = "gpt-5.5".to_owned();
3779 let runner = SequenceRunner::new([pass_json()]);
3780
3781 let error = execute_review_job(job, &runner, &store).unwrap_err();
3782
3783 assert!(matches!(
3784 error,
3785 ReviewerError::StrictArbiterModelNotDistinct
3786 ));
3787 }
3788
3789 #[test]
3790 fn strict_goal_policy_stops_at_configured_lie_or_fuckup_count() {
3791 let policy = StrictGoalPolicy {
3792 stop_after_lies: 2,
3793 stop_after_fuckups: 3,
3794 };
3795
3796 assert_eq!(
3797 policy.decide(StrictGoalCounters {
3798 lies_exposed: 1,
3799 fuckups_registered: 2
3800 }),
3801 StrictGoalDecision::Continue
3802 );
3803 assert_eq!(
3804 policy.decide(StrictGoalCounters {
3805 lies_exposed: 2,
3806 fuckups_registered: 0
3807 }),
3808 StrictGoalDecision::Stop {
3809 reason: StrictGoalStopReason::LiesExposed
3810 }
3811 );
3812 assert_eq!(
3813 policy.decide(StrictGoalCounters {
3814 lies_exposed: 0,
3815 fuckups_registered: 3
3816 }),
3817 StrictGoalDecision::Stop {
3818 reason: StrictGoalStopReason::FuckupsRegistered
3819 }
3820 );
3821 }
3822
3823 #[test]
3824 fn drain_once_reviews_each_commit_once_and_clears_queue() {
3825 let temp = tempfile::tempdir().unwrap();
3826 let store = LedgerStore::new(temp.path());
3827 let queue = ReviewQueue::new(temp.path());
3828 queue.enqueue("abc123").unwrap();
3829 queue.enqueue("abc123").unwrap(); queue.enqueue("def456").unwrap();
3831
3832 let loader = StaticLoader::new();
3833 let runner = SequenceRunner::new([reject_json("unsupported"), pass_json()]);
3834 let selection = selection();
3835
3836 let report = drain_once(
3837 &queue,
3838 &loader,
3839 &selection,
3840 "",
3841 &runner,
3842 &store,
3843 &TruthMirrorConfig::default(),
3844 )
3845 .unwrap();
3846
3847 assert_eq!(report.reviewed, ["abc123", "def456"]);
3848 assert_eq!(report.ledger_entries, 2);
3849 assert!(queue.pending().unwrap().is_empty());
3850 assert_eq!(store.read_history().unwrap().len(), 2);
3851 assert_eq!(store.unresolved_rejections().unwrap().len(), 1);
3852
3853 let runs = ReviewRunStore::new(temp.path()).list().unwrap();
3854 assert_eq!(runs.len(), 3);
3855 assert_eq!(
3856 runs.iter()
3857 .filter(|run| run.status == ReviewRunStatus::Completed)
3858 .count(),
3859 2
3860 );
3861 assert_eq!(
3862 runs.iter()
3863 .filter(|run| run.status == ReviewRunStatus::Cancelled)
3864 .count(),
3865 1
3866 );
3867 }
3868
3869 #[test]
3870 fn drain_once_completes_when_memory_skill_extraction_fails() {
3871 let temp = tempfile::tempdir().unwrap();
3872 let store = LedgerStore::new(temp.path());
3873 let queue = ReviewQueue::new(temp.path());
3874 let sha = "a".repeat(40);
3875 queue.enqueue(&sha).unwrap();
3876 let loader = StaticLoader::new();
3877 let runner = ConstRunner::new(pass_json());
3878 let mut config = TruthMirrorConfig::default();
3879 config.skills.enabled = true;
3880 config.memory_skill.enabled = true;
3881 config.memory_skill.signals.min_occurrences = 1;
3882 config.memory_skill.scan.blocked_patterns = vec!["Capture a verified procedure".to_owned()];
3883
3884 let report =
3885 drain_once(&queue, &loader, &selection(), "", &runner, &store, &config).unwrap();
3886
3887 assert_eq!(report.reviewed, [sha]);
3888 assert_eq!(report.ledger_entries, 1);
3889 assert!(queue.pending().unwrap().is_empty());
3890 assert_eq!(store.read_history().unwrap().len(), 1);
3891 let candidate_dir =
3892 crate::memory_skill::MemorySkillStore::new(temp.path(), &config.memory_skill)
3893 .unwrap()
3894 .candidate_dir()
3895 .to_path_buf();
3896 assert!(!candidate_dir.exists());
3897 }
3898
3899 #[test]
3900 fn drain_once_is_a_noop_on_empty_queue() {
3901 let temp = tempfile::tempdir().unwrap();
3902 let store = LedgerStore::new(temp.path());
3903 let queue = ReviewQueue::new(temp.path());
3904 let loader = StaticLoader::new();
3905 let runner = ConstRunner::new(pass_json());
3906
3907 let report = drain_once(
3908 &queue,
3909 &loader,
3910 &selection(),
3911 "",
3912 &runner,
3913 &store,
3914 &TruthMirrorConfig::default(),
3915 )
3916 .unwrap();
3917
3918 assert!(report.reviewed.is_empty());
3919 assert_eq!(report.ledger_entries, 0);
3920 assert_eq!(store.read_history().unwrap().len(), 0);
3921 }
3922
3923 #[test]
3924 fn until_empty_keeps_watching_while_queue_has_items() {
3925 assert_eq!(
3927 until_empty_decision(None, 60),
3928 UntilEmptyDecision::KeepWatching
3929 );
3930 }
3931
3932 #[test]
3933 fn until_empty_keeps_watching_inside_grace_window() {
3934 assert_eq!(
3936 until_empty_decision(Some(0), 60),
3937 UntilEmptyDecision::KeepWatching
3938 );
3939 assert_eq!(
3940 until_empty_decision(Some(59), 60),
3941 UntilEmptyDecision::KeepWatching
3942 );
3943 }
3944
3945 #[test]
3946 fn until_empty_exits_once_grace_window_elapses() {
3947 assert_eq!(until_empty_decision(Some(60), 60), UntilEmptyDecision::Exit);
3949 assert_eq!(
3950 until_empty_decision(Some(120), 60),
3951 UntilEmptyDecision::Exit
3952 );
3953 }
3954
3955 #[test]
3956 fn until_empty_with_zero_grace_exits_immediately_when_empty() {
3957 assert_eq!(until_empty_decision(Some(0), 0), UntilEmptyDecision::Exit);
3958 assert_eq!(
3960 until_empty_decision(None, 0),
3961 UntilEmptyDecision::KeepWatching
3962 );
3963 }
3964
3965 #[test]
3966 fn strict_goal_loop_stops_at_configured_lie_count() {
3967 let temp = tempfile::tempdir().unwrap();
3968 let store = LedgerStore::new(temp.path());
3969 let policy = StrictGoalPolicy {
3970 stop_after_lies: 1,
3971 stop_after_fuckups: 0,
3972 };
3973 let runner = SequenceRunner::new([reject_json("lie")]);
3974
3975 let outcome = run_strict_goal_loop(
3976 "abc123",
3977 &claim(),
3978 "diff",
3979 "",
3980 &selection(),
3981 policy,
3982 5,
3983 &runner,
3984 &store,
3985 )
3986 .unwrap();
3987
3988 assert_eq!(outcome.passes, 1);
3989 assert_eq!(outcome.counters.lies_exposed, 1);
3990 assert_eq!(outcome.stop_reason, Some(StrictGoalStopReason::LiesExposed));
3991 assert_eq!(store.read_history().unwrap().len(), 1);
3992 }
3993
3994 #[test]
3995 fn strict_goal_loop_terminates_at_max_passes_for_honest_agent() {
3996 let temp = tempfile::tempdir().unwrap();
3997 let store = LedgerStore::new(temp.path());
3998 let policy = StrictGoalPolicy {
3999 stop_after_lies: 2,
4000 stop_after_fuckups: 5,
4001 };
4002 let runner = ConstRunner::new(pass_json());
4003
4004 let outcome = run_strict_goal_loop(
4005 "abc123",
4006 &claim(),
4007 "diff",
4008 "",
4009 &selection(),
4010 policy,
4011 3,
4012 &runner,
4013 &store,
4014 )
4015 .unwrap();
4016
4017 assert_eq!(outcome.passes, 3);
4018 assert_eq!(outcome.counters.lies_exposed, 0);
4019 assert_eq!(outcome.stop_reason, None);
4020 assert_eq!(store.read_history().unwrap().len(), 3);
4021 }
4022
4023 #[test]
4024 fn strict_goal_loop_stops_when_fuckups_accumulate() {
4025 let temp = tempfile::tempdir().unwrap();
4026 let store = LedgerStore::new(temp.path());
4027 let policy = StrictGoalPolicy {
4028 stop_after_lies: 0,
4029 stop_after_fuckups: 2,
4030 };
4031 let runner = ConstRunner::new(reject_json("nit"));
4033
4034 let outcome = run_strict_goal_loop(
4035 "abc123",
4036 &claim(),
4037 "diff",
4038 "",
4039 &selection(),
4040 policy,
4041 10,
4042 &runner,
4043 &store,
4044 )
4045 .unwrap();
4046
4047 assert_eq!(outcome.passes, 2);
4048 assert_eq!(outcome.counters.lies_exposed, 2);
4049 assert_eq!(outcome.counters.fuckups_registered, 2);
4050 assert_eq!(
4051 outcome.stop_reason,
4052 Some(StrictGoalStopReason::FuckupsRegistered)
4053 );
4054 }
4055
4056 proptest! {
4057 #[test]
4058 fn strict_goal_loop_never_exceeds_max_passes(max in 1u32..6) {
4059 let temp = tempfile::tempdir().unwrap();
4060 let store = LedgerStore::new(temp.path());
4061 let policy = StrictGoalPolicy { stop_after_lies: 0, stop_after_fuckups: 0 };
4063 let runner = ConstRunner::new(pass_json());
4064
4065 let outcome = run_strict_goal_loop(
4066 "abc123", &claim(), "diff", "", &selection(), policy, max, &runner, &store,
4067 )
4068 .unwrap();
4069
4070 prop_assert!(outcome.passes <= max);
4071 prop_assert_eq!(outcome.passes, max);
4072 prop_assert!(outcome.stop_reason.is_none());
4073 }
4074 }
4075
4076 proptest! {
4077 #[test]
4078 fn model_opposition_is_enforced_for_arbitrary_models(
4079 watched in "[A-Za-z0-9._/-]{1,32}",
4080 reviewer in "[A-Za-z0-9._/-]{1,32}",
4081 ) {
4082 let request = ReviewRequest::new(
4083 Agent::Codex,
4084 watched.clone(),
4085 ReviewerHarness::Codex,
4086 reviewer.clone(),
4087 false,
4088 "review this",
4089 );
4090 let result = ReviewPlan::build(request);
4091
4092 if normalized_model(watched.trim()) == normalized_model(reviewer.trim()) {
4096 let blocked = matches!(result, Err(ReviewerError::SameModelWithoutWaiver { .. }));
4097 prop_assert!(blocked);
4098 } else {
4099 prop_assert!(result.is_ok());
4100 }
4101 }
4102 }
4103
4104 fn claim() -> Claim {
4105 Claim::new(
4106 "add review",
4107 "cargo test",
4108 vec![EvidenceRef::parse("tests:cargo-test").unwrap()],
4109 )
4110 .unwrap()
4111 }
4112
4113 fn selection() -> ReviewSelection {
4114 ReviewSelection {
4115 watched_agent: Agent::Codex,
4116 watched_model: "gpt-5.4".to_owned(),
4117 reviewer_harness: ReviewerHarness::Codex,
4118 reviewer_model: "gpt-5.5".to_owned(),
4119 reviewer_effort: Effort::Xhigh,
4120 allow_same_model: false,
4121 strict: None,
4122 }
4123 }
4124
4125 struct StaticLoader {
4126 claim: Claim,
4127 diff: String,
4128 }
4129
4130 impl StaticLoader {
4131 fn new() -> Self {
4132 Self {
4133 claim: claim(),
4134 diff: "diff --git a/src/lib.rs b/src/lib.rs".to_owned(),
4135 }
4136 }
4137 }
4138
4139 impl MaterialLoader for StaticLoader {
4140 fn load(&self, _sha: &str) -> Result<(Claim, String), ReviewerError> {
4141 Ok((self.claim.clone(), self.diff.clone()))
4142 }
4143 }
4144
4145 struct ConstRunner {
4146 output: String,
4147 }
4148
4149 impl ConstRunner {
4150 fn new(output: impl Into<String>) -> Self {
4151 Self {
4152 output: output.into(),
4153 }
4154 }
4155 }
4156
4157 impl ProcessRunner for ConstRunner {
4158 fn run(
4159 &self,
4160 _invocation: &InvocationPlan,
4161 _prompt: &str,
4162 ) -> Result<ProcessOutput, ReviewerError> {
4163 Ok(ProcessOutput {
4164 status_code: Some(0),
4165 stdout: self.output.clone(),
4166 stderr: String::new(),
4167 })
4168 }
4169 }
4170
4171 fn review_job(strict: bool) -> ReviewJob {
4172 let claim = claim();
4173 ReviewJob {
4174 commit_sha: "abc123".to_owned(),
4175 diff: "diff --git a/src/lib.rs b/src/lib.rs".to_owned(),
4176 context: String::new(),
4177 request: ReviewRequest::new(
4178 Agent::Codex,
4179 "gpt-5.4",
4180 ReviewerHarness::Codex,
4181 "gpt-5.5",
4182 false,
4183 "review this",
4184 ),
4185 claim,
4186 strict: strict.then_some(StrictReviewConfig {
4187 arbiter_harness: ReviewerHarness::Claude,
4188 arbiter_model: "claude-opus-4-8".to_owned(),
4189 arbiter_effort: Effort::Xhigh,
4190 }),
4191 petition: None,
4192 }
4193 }
4194
4195 struct SequenceRunner {
4196 outputs: RefCell<VecDeque<String>>,
4197 }
4198
4199 impl SequenceRunner {
4200 fn new<I, S>(outputs: I) -> Self
4201 where
4202 I: IntoIterator<Item = S>,
4203 S: Into<String>,
4204 {
4205 Self {
4206 outputs: RefCell::new(outputs.into_iter().map(Into::into).collect()),
4207 }
4208 }
4209 }
4210
4211 impl ProcessRunner for SequenceRunner {
4212 fn run(
4213 &self,
4214 _invocation: &InvocationPlan,
4215 _prompt: &str,
4216 ) -> Result<ProcessOutput, ReviewerError> {
4217 let stdout = self.outputs.borrow_mut().pop_front().unwrap();
4218 Ok(ProcessOutput {
4219 status_code: Some(0),
4220 stdout,
4221 stderr: String::new(),
4222 })
4223 }
4224 }
4225
4226 #[test]
4227 fn extract_verdict_json_handles_raw_fenced_and_prose_wrapped_output() {
4228 let raw = r#"{"verdict":"PASS"}"#;
4230 assert_eq!(super::extract_verdict_json(raw), raw);
4231
4232 let fenced = "Here is my verdict:\n```json\n{\"verdict\":\"PASS\"}\n```\nthanks";
4234 assert_eq!(
4235 super::extract_verdict_json(fenced),
4236 "{\"verdict\":\"PASS\"}"
4237 );
4238
4239 let bare = "verdict below\n```\n{\"a\":1}\n```";
4241 assert_eq!(super::extract_verdict_json(bare), "{\"a\":1}");
4242
4243 let two = "```\nnot json\n```\nthen\n```json\n{\"b\":2}\n```";
4245 assert_eq!(super::extract_verdict_json(two), "{\"b\":2}");
4246
4247 let prose = "The verdict is {\"a\": {\"b\":2}} as required.";
4249 assert_eq!(super::extract_verdict_json(prose), "{\"a\": {\"b\":2}}");
4250
4251 assert_eq!(super::extract_verdict_json(" nope "), "nope");
4254 }
4255
4256 #[test]
4257 fn parsed_verdict_accepts_markdown_fenced_reviewer_output() {
4258 let fenced = format!(
4262 "The fix looks good. Verdict follows.\n\n```json\n{}\n```\n",
4263 pass_json()
4264 );
4265 let parsed = ParsedVerdict::parse(&fenced).unwrap();
4266 assert_eq!(parsed.verdict, Verdict::Pass);
4267 assert_eq!(parsed.raw, fenced);
4269 }
4270
4271 #[test]
4272 fn queued_review_lines_without_petition_field_still_parse() {
4273 let legacy = r#"{"run_id":"r1","commit_sha":"abc","enqueued_at_unix":1}"#;
4274 let item: super::QueuedReview = serde_json::from_str(legacy).unwrap();
4275 assert_eq!(item.petition_for, None);
4276 }
4277
4278 #[test]
4279 fn enqueue_petition_round_trips_petition_for() {
4280 let temp = tempfile::tempdir().unwrap();
4281 let queue = ReviewQueue::new(temp.path());
4282 queue.enqueue("normal1").unwrap();
4283 queue.enqueue_petition("fix456", "orig123").unwrap();
4284
4285 let pending = queue.pending().unwrap();
4286 assert_eq!(pending.len(), 2);
4287 assert_eq!(pending[0].petition_for, None);
4288 assert_eq!(pending[1].commit_sha, "fix456");
4289 assert_eq!(pending[1].petition_for.as_deref(), Some("orig123"));
4290 }
4291
4292 #[test]
4293 fn drain_once_runs_petition_jobs_and_transitions_the_original_rejection() {
4294 use crate::ledger::{Disposition, LedgerEntry, ResolutionKind, ReviewerConfig};
4295
4296 let temp = tempfile::tempdir().unwrap();
4297 let store = LedgerStore::new(temp.path());
4298 let queue = ReviewQueue::new(temp.path());
4299
4300 let rejected = LedgerEntry::new(
4303 "orig123",
4304 Verdict::Reject,
4305 "CLAIM: original | verified: cargo test | evidence: tests:cargo-test",
4306 vec!["tests:cargo-test".to_owned()],
4307 ReviewerConfig::new("claude", "claude-opus-4-1", false),
4308 vec!["evidence too thin".to_owned()],
4309 );
4310 store.append_entry(&rejected).unwrap();
4311 store
4312 .append_petition_transition(
4313 "orig123",
4314 Disposition::Open,
4315 ResolutionKind::Resolved,
4316 "petition review enqueued: fix=fix456, attempts=1/2",
4317 1,
4318 )
4319 .unwrap();
4320 queue.enqueue_petition("fix456", "orig123").unwrap();
4321
4322 let loader = StaticLoader::new();
4323 let runner = ConstRunner::new(pass_json());
4324 let report = drain_once(
4325 &queue,
4326 &loader,
4327 &selection(),
4328 "",
4329 &runner,
4330 &store,
4331 &TruthMirrorConfig::default(),
4332 )
4333 .unwrap();
4334
4335 assert_eq!(report.reviewed, ["fix456"]);
4336 let review = store.show("fix456").unwrap();
4339 assert_eq!(review.petition_for.as_deref(), Some("orig123"));
4340 assert_eq!(review.petition_attempts, 1);
4341 let original = store.show("orig123").unwrap();
4343 assert_eq!(original.disposition, Disposition::Resolved);
4344 assert!(queue.pending().unwrap().is_empty());
4345 assert!(store.blocking_rejections().unwrap().is_empty());
4346 }
4347
4348 #[test]
4349 fn extract_verdict_json_takes_leading_json_before_trailing_prose() {
4350 let leading = "{\"verdict\":\"PASS\"} \n\nHope this helps!";
4353 assert_eq!(
4354 super::extract_verdict_json(leading),
4355 "{\"verdict\":\"PASS\"}"
4356 );
4357
4358 let both = "verdict: {\"a\":1} trailing prose with a stray } brace";
4361 assert_eq!(super::extract_verdict_json(both), "{\"a\":1}");
4362 }
4363
4364 #[test]
4365 fn drain_once_reviews_normal_and_petition_items_for_the_same_fix_sha() {
4366 use crate::ledger::{Disposition, LedgerEntry, ResolutionKind, ReviewerConfig};
4367
4368 let temp = tempfile::tempdir().unwrap();
4369 let store = LedgerStore::new(temp.path());
4370 let queue = ReviewQueue::new(temp.path());
4371
4372 let rejected = LedgerEntry::new(
4373 "orig123",
4374 Verdict::Reject,
4375 "CLAIM: original | verified: cargo test | evidence: tests:cargo-test",
4376 vec!["tests:cargo-test".to_owned()],
4377 ReviewerConfig::new("claude", "claude-opus-4-1", false),
4378 vec!["evidence too thin".to_owned()],
4379 );
4380 store.append_entry(&rejected).unwrap();
4381 store
4382 .append_petition_transition(
4383 "orig123",
4384 Disposition::Open,
4385 ResolutionKind::Resolved,
4386 "petition review enqueued: fix=fix456, attempts=1/2",
4387 1,
4388 )
4389 .unwrap();
4390
4391 queue.enqueue("fix456").unwrap();
4395 queue.enqueue_petition("fix456", "orig123").unwrap();
4396
4397 let loader = StaticLoader::new();
4398 let runner = ConstRunner::new(pass_json());
4399 let report = drain_once(
4400 &queue,
4401 &loader,
4402 &selection(),
4403 "",
4404 &runner,
4405 &store,
4406 &TruthMirrorConfig::default(),
4407 )
4408 .unwrap();
4409
4410 assert_eq!(report.reviewed, ["fix456", "fix456"]);
4411 assert!(queue.pending().unwrap().is_empty());
4412 let history = store.read_history().unwrap();
4415 let fix_entries: Vec<_> = history
4416 .iter()
4417 .filter(|entry| entry.commit_sha == "fix456")
4418 .collect();
4419 assert_eq!(fix_entries.len(), 2);
4420 assert!(fix_entries.iter().any(|entry| entry.petition_for.is_none()));
4421 assert!(
4422 fix_entries
4423 .iter()
4424 .any(|entry| entry.petition_for.as_deref() == Some("orig123"))
4425 );
4426 assert_eq!(
4428 store.show("orig123").unwrap().disposition,
4429 crate::ledger::Disposition::Resolved
4430 );
4431 }
4432
4433 #[test]
4434 fn flag_petition_verdict_resolves_the_original_rejection() {
4435 use crate::ledger::{Disposition, LedgerEntry, ResolutionKind, ReviewerConfig};
4436
4437 let temp = tempfile::tempdir().unwrap();
4438 let store = LedgerStore::new(temp.path());
4439 let queue = ReviewQueue::new(temp.path());
4440 let rejected = LedgerEntry::new(
4441 "orig123",
4442 Verdict::Reject,
4443 "CLAIM: original | verified: cargo test | evidence: tests:cargo-test",
4444 vec!["tests:cargo-test".to_owned()],
4445 ReviewerConfig::new("claude", "claude-opus-4-1", false),
4446 vec!["evidence too thin".to_owned()],
4447 );
4448 store.append_entry(&rejected).unwrap();
4449 store
4450 .append_petition_transition(
4451 "orig123",
4452 Disposition::Open,
4453 ResolutionKind::Resolved,
4454 "petition review enqueued: fix=fix456, attempts=1/2",
4455 1,
4456 )
4457 .unwrap();
4458 queue.enqueue_petition("fix456", "orig123").unwrap();
4459
4460 let loader = StaticLoader::new();
4464 let runner = ConstRunner::new(flag_json("evidence could be tighter"));
4465 drain_once(
4466 &queue,
4467 &loader,
4468 &selection(),
4469 "",
4470 &runner,
4471 &store,
4472 &TruthMirrorConfig::default(),
4473 )
4474 .unwrap();
4475
4476 assert_eq!(
4477 store.show("orig123").unwrap().disposition,
4478 crate::ledger::Disposition::Resolved
4479 );
4480 let history = store.read_history().unwrap();
4481 let petition_entry = history
4482 .iter()
4483 .find(|entry| {
4484 entry.petition_for.as_deref() == Some("orig123") && entry.commit_sha == "fix456"
4485 })
4486 .unwrap();
4487 assert_eq!(petition_entry.verdict, Verdict::Flag);
4488 }
4489}