1use super::router::Router;
11use crate::backends::{ExternalBackend, ExternalTask, Status};
14use crate::cmd::CommandErrorContext;
15use crate::config;
16use crate::engine::attribution_footer;
17use crate::engine::auto_merge::{auto_merge_pr, handle_review_changes};
18use crate::engine::cooldown;
19use crate::engine::cooldown::CreditExhaustionReason;
20use crate::engine::router::RouterConfig;
21use crate::engine::runner;
22use crate::engine::runner::git_ops;
23use crate::engine::runner::response::ReviewIssue;
24use crate::engine::runner::worktree;
25use crate::engine::tasks;
26use crate::engine::tasks::TaskManager;
27use crate::github::app;
28use crate::github::app::AppAuth;
29use crate::github::app::CheckRunState;
30use crate::github::app::InlineComment;
31use crate::github::app::ReviewEvent;
32use crate::github::http::GhHttp;
33use crate::github::types::GitHubPullRequest;
34use crate::home;
35use crate::review_mode;
36use crate::review_mode::ReviewMode;
37use crate::store;
38use crate::store::TaskStore;
39use crate::store::store_log_activity;
40use crate::store::{CompleteRun, RunTokenUsage, StartRun};
41use crate::store::{opt_store_get_task, store_increment};
42use crate::store::{store_set, store_set_result};
43use crate::tmux::TmuxManager;
44use anyhow::Context;
45use std::collections::HashSet;
46use std::fmt::Write as _;
47use std::fs;
48use std::path::Path;
49use std::path::PathBuf;
50use std::process::Stdio;
51use std::sync::Arc;
52use std::time::Duration;
53use tokio::fs as tokio_fs;
54use tokio::io::AsyncWriteExt;
55use tokio::process::Command;
56use tokio::sync::RwLock;
57use tokio::task;
58use tokio::time;
59
60pub(crate) const MAX_REVIEW_AGENT_FAILURES: u64 = 3;
61
62const MAX_PR_CREATE_FAILURES: u64 = 3;
64
65fn outcome_for_agent_error(err: &runner::agents::AgentError) -> &'static str {
70 match err {
71 runner::agents::AgentError::Timeout { .. } => "timeout",
72 runner::agents::AgentError::Interrupted { .. } => "interrupted",
73 runner::agents::AgentError::RateLimit { message } => {
78 cooldown::detect_credit_exhaustion_outcome(message).unwrap_or("rate_limit")
79 }
80 runner::agents::AgentError::Auth { message } => {
81 cooldown::detect_credit_exhaustion_outcome(message).unwrap_or("auth_error")
82 }
83 runner::agents::AgentError::InvalidResponse { .. } => "parse_error",
84 _ => "failed",
85 }
86}
87
88fn format_review_error(agent: &str, err: &runner::agents::AgentError) -> String {
97 match err {
98 runner::agents::AgentError::RateLimit { message } => format!(
99 "{agent} rate limit: {}",
100 runner::fallback::summarize_rate_limit_error(message)
101 ),
102 runner::agents::AgentError::Auth { message } => format!("{agent} auth error: {message}"),
103 runner::agents::AgentError::Timeout { elapsed } => {
104 format!("{agent} timed out after {}s", elapsed.as_secs())
105 }
106 other => format!("{agent} agent error: {other}"),
107 }
108}
109
110pub(crate) fn parse_pr_number_from_url(url: &str) -> Result<u64, String> {
122 let url = url.trim();
123 if url.is_empty() {
124 return Err("URL is empty".to_string());
125 }
126 if !url.starts_with("https://github.com/") {
127 return Err(format!(
128 "URL does not start with 'https://github.com/': {url}"
129 ));
130 }
131 if !url.contains("/pull/") {
132 return Err(format!("URL missing '/pull/' segment: {url}"));
133 }
134 let last_segment = url
135 .trim_end_matches('/')
136 .rsplit('/')
137 .next()
138 .ok_or_else(|| format!("No segment after '/pull/' in URL: {url}"))?;
139 let number_part = last_segment
140 .split(['?', '#'])
141 .next()
142 .filter(|s| !s.is_empty())
143 .ok_or_else(|| format!("PR number segment is empty in URL: {url}"))?;
144 let pr_num: u64 = number_part.parse().map_err(|_| {
145 format!("PR number is not a valid positive integer: '{number_part}' in URL: {url}")
146 })?;
147 if pr_num == 0 {
148 return Err(format!("PR number cannot be zero in URL: {url}"));
149 }
150 Ok(pr_num)
151}
152
153fn review_started_comment(review_agent: &str, review_model: &str) -> String {
154 let footer = attribution_footer("Review started", review_agent, Some(review_model));
155 format!("🔍 Automated review started{footer}")
156}
157
158fn should_skip_no_code_reroute_increment(last_error: &str) -> bool {
159 git_ops::is_transient_github_api_error(last_error)
160}
161
162#[derive(Debug, Clone)]
164pub(crate) enum ReviewDecision {
165 Approve { issues: Vec<ReviewIssue> },
167 RequestChanges {
169 notes: String,
170 issues: Vec<ReviewIssue>,
171 },
172 Failed(String),
174 Blocked(String),
176 Skipped,
178 Rerouted,
180}
181
182enum EnsurePrResult {
184 Ready(u64),
186 EarlyReturn(ReviewDecision),
188}
189
190enum ReviewPhase<T> {
191 Ready(T),
192 EarlyReturn(ReviewDecision),
193}
194
195struct ReviewContext {
196 store_id: Option<i64>,
197 had_prev_push_failure: bool,
198 review_attempt: u32,
199 worktree_path: PathBuf,
200 branch_name: String,
201 pr_number: u64,
202 head_sha: Option<String>,
204 default_branch: String,
205 review_agent: String,
206 review_model: Option<String>,
207 review_task_id: String,
208 review_attempt_dir: PathBuf,
209 output_file: PathBuf,
210 invocation: runner::agent::AgentInvocation,
211 same_agent_fallback: bool,
212}
213
214impl ReviewContext {
215 fn review_model_str(&self) -> &str {
216 self.review_model.as_deref().unwrap_or("")
217 }
218}
219
220struct ReviewRun {
221 run_id: Option<i64>,
222}
223
224struct ParsedReview {
225 run_id: Option<i64>,
226 exit_code: i32,
227 stderr: String,
228 raw_output: String,
229 text_for_review: String,
230 token_usage: RunTokenUsage,
231 review_notes_for_comment: String,
232 decision: ReviewDecision,
233}
234
235fn approved_sha_key(repo: &str, task_id: &str, pr_number: u64) -> String {
237 format!("review_approved_sha:{repo}#{task_id}#{pr_number}")
238}
239
240const REVIEW_TIMEOUT_DEFAULT_SECS: u64 = 900;
241const REVIEW_TIMEOUT_MIN_SECS: u64 = 300;
242
243fn review_timeout_secs(configured: Option<u64>) -> u64 {
245 configured
246 .unwrap_or(REVIEW_TIMEOUT_DEFAULT_SECS)
247 .max(REVIEW_TIMEOUT_MIN_SECS)
248}
249
250pub(crate) async fn worktree_head_sha(dir: &Path) -> Option<String> {
252 let out = Command::new("git")
253 .args(["rev-parse", "HEAD"])
254 .current_dir(dir)
255 .kill_on_drop(true)
256 .output()
257 .await
258 .ok()?;
259 if !out.status.success() {
260 return None;
261 }
262 let sha = String::from_utf8_lossy(&out.stdout).trim().to_string();
263 (!sha.is_empty()).then_some(sha)
264}
265
266#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
269pub(crate) struct ApprovalEvent {
270 sha: String,
271 repo: String,
272 branch: String,
273 base: String,
274}
275
276impl ApprovalEvent {
277 fn parse(raw: &str) -> Option<Self> {
279 let raw = raw.trim();
280 if raw.is_empty() {
281 return None;
282 }
283 serde_json::from_str(raw).ok().or_else(|| {
284 Some(Self {
285 sha: raw.to_string(),
286 repo: String::new(),
287 branch: String::new(),
288 base: String::new(),
289 })
290 })
291 }
292}
293
294async fn read_approval(
295 store: &Arc<TaskStore>,
296 repo: &str,
297 task_id: &str,
298 pr_number: u64,
299) -> anyhow::Result<Option<ApprovalEvent>> {
300 Ok(store
301 .kv_get(&approved_sha_key(repo, task_id, pr_number))
302 .await?
303 .as_deref()
304 .and_then(ApprovalEvent::parse))
305}
306
307async fn write_approval(
308 store: &Arc<TaskStore>,
309 task_id: &str,
310 pr_number: u64,
311 event: &ApprovalEvent,
312) {
313 let key = approved_sha_key(&event.repo, task_id, pr_number);
314 let value = serde_json::to_string(event).unwrap_or_default();
315 if let Err(e) = store.kv_set(&key, &value).await {
316 tracing::warn!(task_id, err = %e, "failed to record the approval");
317 }
318}
319
320async fn record_approved_sha(
323 store: &Arc<TaskStore>,
324 repo: &str,
325 task_id: &str,
326 pr_number: u64,
327 dir: &Path,
328 branch: &str,
329 base: &str,
330) {
331 let Some(sha) = worktree_head_sha(dir).await else {
332 return;
333 };
334 let event = ApprovalEvent {
335 sha,
336 repo: repo.to_string(),
337 branch: branch.to_string(),
338 base: base.to_string(),
339 };
340 write_approval(store, task_id, pr_number, &event).await;
341}
342
343async fn clear_approved_sha(store: &Arc<TaskStore>, repo: &str, task_id: &str, pr_number: u64) {
345 if let Err(e) = store
346 .kv_delete(&approved_sha_key(repo, task_id, pr_number))
347 .await
348 {
349 tracing::warn!(task_id, err = %e, "failed to clear approved commit sha");
350 }
351}
352
353async fn record_carried_approval(
356 store: &Arc<TaskStore>,
357 repo: &str,
358 task_id: &str,
359 pr_number: u64,
360) {
361 store_log_activity(
362 &Some(Arc::clone(store)),
363 repo,
364 task_id,
365 "review_decision",
366 None,
367 None,
368 None::<&str>,
369 None::<&str>,
370 Some(&serde_json::json!({
371 "decision": "approve",
372 "pr_number": pr_number,
373 "reason": "head_already_approved",
374 })),
375 )
376 .await;
377}
378
379async fn pr_head_already_approved(
388 store: &Arc<TaskStore>,
389 repo: &str,
390 task_id: &str,
391 pr_number: u64,
392) -> bool {
393 let approved = match read_approval(store, repo, task_id, pr_number).await {
394 Ok(event) => event.map(|e| e.sha),
395 Err(e) => {
396 tracing::warn!(task_id, err = %e, "failed to read approved commit sha");
397 return false;
398 }
399 };
400 if approved.is_none() {
401 return false;
402 }
403 let gh = match GhHttp::new() {
404 Ok(gh) => gh,
405 Err(e) => {
406 tracing::warn!(task_id, err = %e, "cannot check PR head against recorded approval");
407 return false;
408 }
409 };
410 let pr_head = match gh.get_pr(repo, pr_number).await {
411 Ok(pr) => Some(pr.head.sha),
412 Err(e) => {
413 tracing::warn!(task_id, pr_number, err = %e, "failed to read PR head sha");
414 None
415 }
416 };
417 approval_covers_head(approved.as_deref(), pr_head.as_deref())
418}
419
420struct CarryContext {
422 approval: ApprovalEvent,
423 worktree: PathBuf,
424}
425
426async fn carry_context(
433 store: &Arc<TaskStore>,
434 repo: &str,
435 task_id: &str,
436 pr: &GitHubPullRequest,
437 pr_number: u64,
438) -> Option<CarryContext> {
439 let approval = read_approval(store, repo, task_id, pr_number)
440 .await
441 .ok()
442 .flatten()?;
443 let task = opt_store_get_task(&Some(Arc::clone(store)), repo, task_id).await?;
444 if !pr_matches_approval(pr, &approval, &task.branch) || task.worktree.is_empty() {
445 tracing::warn!(
446 task_id,
447 pr_number,
448 "the PR does not match the approved repository, branch and base, approval not carried"
449 );
450 return None;
451 }
452 let worktree = PathBuf::from(&task.worktree);
453 if runner::git_ops::get_current_branch(&worktree).await != approval.branch
454 || worktree::detect_default_branch(&worktree).await != approval.base
455 {
456 tracing::warn!(
457 task_id,
458 pr_number,
459 "the task worktree does not track the approved branch and base, approval not carried"
460 );
461 return None;
462 }
463 Some(CarryContext { approval, worktree })
464}
465
466fn pr_matches_approval(
469 pr: &GitHubPullRequest,
470 approval: &ApprovalEvent,
471 task_branch: &str,
472) -> bool {
473 let same_repo = !approval.repo.is_empty()
474 && pr
475 .head
476 .repo
477 .as_ref()
478 .is_some_and(|r| r.full_name.eq_ignore_ascii_case(&approval.repo));
479 same_repo
480 && !approval.branch.is_empty()
481 && pr.head.ref_ == approval.branch
482 && approval.branch == task_branch
483 && !approval.base.is_empty()
484 && pr.base.ref_ == approval.base
485}
486
487pub(crate) async fn carry_approval_across_base_update(
495 store: &Arc<TaskStore>,
496 gh: &GhHttp,
497 repo: &str,
498 task_id: &str,
499 pr: &GitHubPullRequest,
500 pr_number: u64,
501) {
502 let head_sha = pr.head.sha.as_str();
503 let Ok(Some(recorded)) = read_approval(store, repo, task_id, pr_number).await else {
504 return;
505 };
506 if recorded.sha == head_sha {
507 return;
508 }
509 let Some(ctx) = carry_context(store, repo, task_id, pr, pr_number).await else {
510 return;
511 };
512 let meta = match gh.commit_meta(repo, head_sha).await {
513 Ok(meta) => meta,
514 Err(e) => {
515 tracing::warn!(task_id, err = %e, "cannot read the PR head commit to carry the approval");
516 return;
517 }
518 };
519 let Some(base_parent) = base_merge_parent(&ctx.approval.sha, &meta.parents) else {
520 return;
521 };
522 if meta.committer_login.as_deref() != Some("web-flow") {
523 tracing::warn!(
524 task_id,
525 pr_number,
526 head_sha,
527 "the base merge was not made by GitHub, approval not carried"
528 );
529 return;
530 }
531 let Some(author) = meta.author_login.as_deref() else {
532 return;
533 };
534 if !matches!(gh.is_collaborator(repo, author).await, Ok(true)) {
535 tracing::warn!(
536 task_id,
537 pr_number,
538 author,
539 "the base merge author is not a collaborator, approval not carried"
540 );
541 return;
542 }
543 if !matches!(
544 gh.branch_contains(repo, &pr.base.ref_, base_parent).await,
545 Ok(true)
546 ) {
547 return;
548 }
549 let _ = runner::git_ops::git()
550 .await
551 .args(["fetch", "origin"])
552 .current_dir(&ctx.worktree)
553 .output()
554 .await;
555 if !is_clean_merge(&ctx.worktree, &ctx.approval.sha, base_parent, head_sha).await {
556 tracing::warn!(
557 task_id,
558 pr_number,
559 head_sha,
560 "the PR head is not a clean merge of the base, approval not carried"
561 );
562 return;
563 }
564
565 publish_carried_approval(store, gh, repo, task_id, pr_number, &ctx.approval, head_sha).await;
566}
567
568pub(crate) async fn carry_approval_after_rebase(
575 store: &Arc<TaskStore>,
576 gh: &GhHttp,
577 repo: &str,
578 task_id: &str,
579 pr: &GitHubPullRequest,
580 pr_number: u64,
581) {
582 let Some(ctx) = carry_context(store, repo, task_id, pr, pr_number).await else {
583 return;
584 };
585 let rebased_from = pr.head.sha.as_str();
586 if ctx.approval.sha != rebased_from {
587 return;
588 }
589 let Some(new_head) = worktree_head_sha(&ctx.worktree).await else {
590 return;
591 };
592 let base = format!("origin/{}", pr.base.ref_);
593 if !same_commits_rebased(&ctx.worktree, rebased_from, &base, &new_head).await {
594 tracing::warn!(
595 task_id,
596 pr_number,
597 "the rebased commits differ from the approved ones, approval not carried"
598 );
599 return;
600 }
601 let mut pushed = false;
602 for _ in 0..3 {
603 if gh
604 .get_pr(repo, pr_number)
605 .await
606 .is_ok_and(|p| p.head.sha == new_head)
607 {
608 pushed = true;
609 break;
610 }
611 time::sleep(Duration::from_secs(2)).await;
612 }
613 if !pushed {
614 tracing::warn!(
615 task_id,
616 pr_number,
617 "GitHub does not show the rebased head yet, approval not carried"
618 );
619 return;
620 }
621
622 publish_carried_approval(
623 store,
624 gh,
625 repo,
626 task_id,
627 pr_number,
628 &ctx.approval,
629 &new_head,
630 )
631 .await;
632}
633
634async fn same_commits_rebased(dir: &Path, old_head: &str, base: &str, new_head: &str) -> bool {
637 let Some(fork_point) = git_output(dir, &["merge-base", old_head, base]).await else {
638 return false;
639 };
640 let on_base = git_output(dir, &["merge-base", "--is-ancestor", base, new_head])
641 .await
642 .is_some();
643 let old = commit_fingerprints(dir, &format!("{fork_point}..{old_head}")).await;
644 let new = commit_fingerprints(dir, &format!("{base}..{new_head}")).await;
645 on_base && old.is_some() && !old.as_ref().is_some_and(Vec::is_empty) && old == new
646}
647
648async fn commit_fingerprints(dir: &Path, range: &str) -> Option<Vec<(String, String)>> {
651 let log = git_output(
652 dir,
653 &["log", "--reverse", "--format=%H %P%x00%an <%ae>", range],
654 )
655 .await?;
656 let mut prints = Vec::new();
657 for line in log.lines().filter(|l| !l.is_empty()) {
658 let (shas, author) = line.split_once('\0')?;
659 let mut shas = shas.split(' ');
660 let sha = shas.next()?;
661 if shas.count() != 1 {
662 return None;
663 }
664 prints.push((patch_id(dir, sha).await?, author.to_string()));
665 }
666 Some(prints)
667}
668
669async fn patch_id(dir: &Path, sha: &str) -> Option<String> {
671 let diff = git_output(dir, &["show", "--format=", sha]).await?;
672 let mut child = Command::new("git")
673 .args(["patch-id", "--stable"])
674 .current_dir(dir)
675 .stdin(Stdio::piped())
676 .stdout(Stdio::piped())
677 .kill_on_drop(true)
678 .spawn()
679 .ok()?;
680 let mut stdin = child.stdin.take()?;
681 stdin.write_all(diff.as_bytes()).await.ok()?;
682 stdin.write_all(b"\n").await.ok()?;
683 drop(stdin);
684 let out = child.wait_with_output().await.ok()?;
685 String::from_utf8_lossy(&out.stdout)
686 .split_whitespace()
687 .next()
688 .map(String::from)
689}
690
691async fn publish_carried_approval(
694 store: &Arc<TaskStore>,
695 gh: &GhHttp,
696 repo: &str,
697 task_id: &str,
698 pr_number: u64,
699 approval: &ApprovalEvent,
700 head_sha: &str,
701) {
702 let carried = ApprovalEvent {
703 sha: head_sha.to_string(),
704 ..approval.clone()
705 };
706 write_approval(store, task_id, pr_number, &carried).await;
707 let note = "Approval carried across a base branch update";
708 let app = review_app(repo).await;
709 post_review_decision(
710 app.as_deref(),
711 gh,
712 repo,
713 Some(head_sha),
714 &ReviewDecision::Approve { issues: vec![] },
715 note,
716 )
717 .await;
718 tracing::info!(
719 task_id,
720 pr_number,
721 head_sha,
722 "approval carried across a base branch update"
723 );
724}
725
726async fn git_output(dir: &Path, args: &[&str]) -> Option<String> {
728 let out = Command::new("git")
729 .args(args)
730 .current_dir(dir)
731 .kill_on_drop(true)
732 .output()
733 .await
734 .ok()?;
735 out.status
736 .success()
737 .then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
738}
739
740async fn is_clean_merge(dir: &Path, approved: &str, base_parent: &str, head: &str) -> bool {
742 let merged = git_output(dir, &["merge-tree", "--write-tree", approved, base_parent]).await;
743 let head_tree = git_output(dir, &["rev-parse", &format!("{head}^{{tree}}")]).await;
744 match (merged, head_tree) {
745 (Some(merged), Some(head_tree)) => merged.lines().next() == Some(head_tree.as_str()),
746 _ => false,
747 }
748}
749
750fn base_merge_parent<'a>(approved: &str, parents: &'a [String]) -> Option<&'a str> {
752 match parents {
753 [first, second] if first == approved => Some(second.as_str()),
754 _ => None,
755 }
756}
757
758fn approval_covers_head(approved: Option<&str>, pr_head: Option<&str>) -> bool {
760 match (approved, pr_head) {
761 (Some(approved), Some(head)) => !approved.is_empty() && approved == head,
762 _ => false,
763 }
764}
765
766fn build_review_disallowed_tools(worktree_path: &Path) -> Vec<String> {
767 let mut tools = vec![
768 "Bash(rm *)".to_string(),
769 "Bash(rm -*)".to_string(),
770 "Bash(git push*)".to_string(),
771 "Bash(gh pr create*)".to_string(),
773 ];
774 tools.extend(runner::git_ops::remote_change_tools());
775 let orch_yml = worktree_path.join(".orch.yml");
776 let orch_yml_str = orch_yml.to_string_lossy();
777 tools.extend([
778 format!("Write({orch_yml_str})"),
779 format!("Edit({orch_yml_str})"),
780 ]);
781 if let Ok(orch_home) = home::orch_home() {
782 for path in [
783 orch_home.join("config.yml"),
784 orch_home.join("config.example.yml"),
785 ] {
786 let path_str = path.to_string_lossy();
787 tools.extend([
788 format!("Read({path_str})"),
789 format!("Write({path_str})"),
790 format!("Edit({path_str})"),
791 ]);
792 }
793 }
794 tools
795}
796
797async fn select_review_agent(
798 task_id: &str,
799 task_agent: &str,
800 store_id: Option<i64>,
801 router: &Arc<RwLock<Router>>,
802 store: &Arc<TaskStore>,
803) -> anyhow::Result<(String, Option<String>, bool)> {
804 let mut exclude_set: HashSet<String> = HashSet::new();
805 if !task_agent.is_empty() {
806 exclude_set.insert(task_agent.to_string());
807 }
808 if let Some(store_id) = store_id
809 && let Ok(failed) = store.previous_review_agents(store_id).await
810 {
811 for agent in failed {
812 exclude_set.insert(agent);
813 }
814 }
815
816 let mut r = router.write().await;
817 let mut tried_agents: HashSet<String> = HashSet::new();
818 let mut chosen_agent: Option<String> = None;
819 let mut chosen_model: Option<String> = None;
820 let available_count = r.available_agents.len().max(1);
821
822 loop {
823 let tmp_exclude_refs: Vec<&str> = exclude_set.iter().map(String::as_str).collect();
824 let Some(agent) = r.next_round_robin_agent(&tmp_exclude_refs, "review") else {
825 break;
826 };
827
828 if tried_agents.contains(&agent) {
829 break;
830 }
831 tried_agents.insert(agent.clone());
832
833 let model = r.config.model_for_complexity(&agent, "review", task_id);
834 if model.is_some() {
835 chosen_agent = Some(agent);
836 chosen_model = model;
837 break;
838 }
839
840 exclude_set.insert(agent);
841 if exclude_set.len() >= available_count {
842 break;
843 }
844 }
845
846 if let Some(agent) = chosen_agent {
847 let same = !task_agent.is_empty() && agent == task_agent;
848 Ok((agent, chosen_model, same))
849 } else {
850 let final_exclude_refs: Vec<&str> = exclude_set.iter().map(String::as_str).collect();
851 let fallback_agent = r
852 .next_round_robin_agent(&final_exclude_refs, "review")
853 .unwrap_or_else(|| "claude".to_string());
854 let fallback_model = r
855 .config
856 .model_for_complexity(&fallback_agent, "review", task_id);
857 if fallback_model.is_none() {
858 anyhow::bail!(
859 "no review agent with a configured review model is available for task {task_id}"
860 );
861 }
862 let same = !task_agent.is_empty() && fallback_agent == task_agent;
863 Ok((fallback_agent, fallback_model, same))
864 }
865}
866
867async fn build_review_context(
868 task: &ExternalTask,
869 repo: &str,
870 router: &Arc<RwLock<Router>>,
871 task_manager: &Arc<TaskManager>,
872 store: &Arc<TaskStore>,
873) -> anyhow::Result<ReviewPhase<ReviewContext>> {
874 let stored_task = store
875 .get_by_external_id(repo, &task.id.0)
876 .await
877 .with_context(|| format!("store lookup failed for task {}", task.id.0))?;
878
879 let store_id: Option<i64> = stored_task.as_ref().map(|t| t.id);
880
881 if let Some(pr_number) = stored_task
884 .as_ref()
885 .and_then(|t| t.pr_number)
886 .and_then(|n| u64::try_from(n).ok())
887 && pr_head_already_approved(store, repo, &task.id.0, pr_number).await
888 {
889 tracing::info!(
890 task_id = task.id.0,
891 pr_number,
892 "PR head already approved — skipping review, merge retry continues"
893 );
894 record_carried_approval(store, repo, &task.id.0, pr_number).await;
895 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Approve {
896 issues: Vec::new(),
897 }));
898 }
899
900 let had_prev_push_failure = stored_task
901 .as_ref()
902 .is_some_and(|t| t.last_error.contains("push failed"));
903 let review_attempt: u32 = match store_increment(
904 &Some(Arc::clone(store)),
905 repo,
906 &task.id.0,
907 "review_invocations",
908 )
909 .await
910 {
911 Ok(v) => u32::try_from(v).unwrap_or(u32::MAX),
912 Err(e) => {
913 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment review_invocations — using fallback attempt=1");
914 1
915 }
916 };
917
918 let no_code_review = stored_task.as_ref().is_some_and(|t| {
919 t.worktree.trim().is_empty() && t.branch.trim().is_empty() && t.pr_number.is_none()
920 });
921 if no_code_review {
922 if tasks::is_internal_id(&task.id.0) {
923 tracing::info!(
925 task_id = task.id.0,
926 "no worktree/branch/pr for internal needs_review task — skipping review and marking done"
927 );
928 if let Err(e) = task_manager
929 .update_task_status(&task.id, Status::Done)
930 .await
931 {
932 tracing::error!(
933 task_id = task.id.0,
934 err = %e,
935 "update_task_status(Done) failed for no-code review skip"
936 );
937 }
938 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Skipped));
939 }
940
941 let max_reroutes: u32 = config::get("workflow.max_reroute_attempts")
944 .ok()
945 .and_then(|s| s.parse().ok())
946 .or_else(|| {
947 config::get("workflow.max_attempts")
948 .ok()
949 .and_then(|s| s.parse().ok())
950 })
951 .unwrap_or(3);
952
953 let reroutes = match store_increment(
954 &Some(Arc::clone(store)),
955 repo,
956 &task.id.0,
957 "no_code_reroutes",
958 )
959 .await
960 {
961 Ok(v) => v,
962 Err(e) => {
963 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment no_code_reroutes — skipping reroute/block decision this tick");
964 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Failed(format!(
965 "transient store error: {e}"
966 ))));
967 }
968 };
969
970 if reroutes >= u64::from(max_reroutes) {
971 tracing::error!(
972 task_id = task.id.0,
973 reroutes,
974 max_reroutes,
975 "reached max reroute attempts for no-worktree/branch/pr result — blocking for human review"
976 );
977 let msg =
978 format!("no worktree/branch/pr after {reroutes}/{max_reroutes} reroute attempts");
979 let fields = [
980 (
981 "block_reason",
982 serde_json::json!(format!(
983 "max reroute attempts ({}) reached — no worktree/branch/pr produced",
984 max_reroutes
985 )),
986 ),
987 ("last_error", serde_json::json!(msg)),
988 ("agent", serde_json::json!(null)),
989 ("model", serde_json::json!(null)),
990 ];
991 if let Err(e) = task_manager
992 .update_task_status_and_result(&task.id, Status::Blocked, &fields)
993 .await
994 {
995 tracing::error!(task_id = task.id.0, err = %e, "update_task_status_and_result(Blocked) failed — skipping block to avoid silent auto-unblock loop");
996 }
997 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Blocked(msg)));
998 }
999
1000 tracing::warn!(
1001 task_id = task.id.0,
1002 reroutes,
1003 max_reroutes,
1004 "no worktree/branch/pr for external needs_review task — re-routing to a different agent"
1005 );
1006 match store_set_result(
1007 &Some(Arc::clone(store)),
1008 repo,
1009 &task.id.0,
1010 &[
1011 ("agent", serde_json::json!(null)),
1012 ("model", serde_json::json!(null)),
1013 (
1014 "last_error",
1015 serde_json::json!("no worktree/branch/pr produced"),
1016 ),
1017 ],
1018 )
1019 .await
1020 {
1021 Err(e) => {
1022 tracing::error!(task_id = task.id.0, err = %e, "failed to clear agent/model for reroute — skipping status transition");
1023 }
1024 _ => {
1025 if let Err(e) = task_manager.update_task_status(&task.id, Status::New).await {
1026 tracing::error!(task_id = task.id.0, err = %e, "update_task_status(New) failed — task may be stuck in InReview");
1027 }
1028 }
1029 }
1030 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Rerouted));
1031 }
1032
1033 let mut worktree_path = match stored_task.as_ref().map(|t| t.worktree.as_str()) {
1034 Some(w) if !w.is_empty() => PathBuf::from(w),
1035 _ => {
1036 tracing::warn!(task_id = task.id.0, "no worktree found for review");
1037 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Failed(
1038 "no worktree found".to_string(),
1039 )));
1040 }
1041 };
1042
1043 let mut branch_name = match stored_task.as_ref().map(|t| t.branch.as_str()) {
1044 Some(b) if !b.is_empty() => b.to_string(),
1045 _ => {
1046 tracing::warn!(task_id = task.id.0, "no branch found for review");
1047 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Failed(
1048 "no branch found".to_string(),
1049 )));
1050 }
1051 };
1052
1053 let mut missing_worktree_recovered = false;
1054 if !worktree_path.exists() {
1055 match recover_missing_review_worktree(task, repo, store).await {
1056 Ok(recovered) => {
1057 branch_name = recovered.branch;
1058 worktree_path = recovered.work_dir;
1059 missing_worktree_recovered = true;
1060 }
1061 Err(e) => {
1062 let reason = format!("missing review worktree recovery failed: {e}");
1063 tracing::error!(task_id = task.id.0, error = %reason, "cannot recover review worktree");
1064 store_set(
1065 &Some(Arc::clone(store)),
1066 repo,
1067 &task.id.0,
1068 &[("last_error", serde_json::json!(reason.clone()))],
1069 )
1070 .await;
1071 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Blocked(reason)));
1072 }
1073 }
1074 }
1075
1076 if missing_worktree_recovered {
1077 store_set(
1078 &Some(Arc::clone(store)),
1079 repo,
1080 &task.id.0,
1081 &[("last_error", serde_json::json!(""))],
1082 )
1083 .await;
1084 }
1085
1086 let agent_summary = stored_task
1087 .as_ref()
1088 .map(|t| t.summary.clone())
1089 .unwrap_or_default();
1090 let stored_pr_number = stored_task
1091 .as_ref()
1092 .and_then(|t| t.pr_number)
1093 .and_then(|n| u64::try_from(n).ok())
1094 .filter(|&n| n > 0);
1095
1096 let pr_number = match ensure_pr_exists(
1097 task,
1098 &branch_name,
1099 &worktree_path,
1100 repo,
1101 &agent_summary,
1102 store,
1103 task_manager,
1104 stored_pr_number,
1105 )
1106 .await?
1107 {
1108 EnsurePrResult::Ready(n) => n,
1109 EnsurePrResult::EarlyReturn(decision) => return Ok(ReviewPhase::EarlyReturn(decision)),
1110 };
1111
1112 {
1116 let fetch_timeout = Duration::from_secs(60);
1117 let fetch_future = async {
1118 let mut cmd = runner::git_ops::git().await;
1119 cmd.args(["fetch", "origin", "--prune"])
1120 .current_dir(&worktree_path);
1121 cmd.kill_on_drop(true);
1123 cmd.output_with_context().await
1124 };
1125
1126 match time::timeout(fetch_timeout, fetch_future).await {
1127 Ok(Ok(o)) if o.status.success() => {
1128 }
1130 Ok(Ok(o)) => {
1131 tracing::warn!(
1132 task_id = task.id.0,
1133 exit_code = ?o.status.code(),
1134 stdout = %String::from_utf8_lossy(&o.stdout),
1135 stderr = %String::from_utf8_lossy(&o.stderr),
1136 "review: git fetch failed (non-zero exit) — diff/log may use stale remote refs"
1137 );
1138 }
1139 Ok(Err(e)) => {
1140 tracing::warn!(
1141 task_id = task.id.0,
1142 error = %e,
1143 "review: git fetch failed — diff/log may use stale remote refs"
1144 );
1145 }
1146 Err(_) => {
1147 tracing::warn!(
1148 task_id = task.id.0,
1149 "review: git fetch timed out after 60s — diff/log may use stale remote refs"
1150 );
1151 }
1152 }
1153 }
1154
1155 let default_branch = runner::worktree::detect_default_branch(&worktree_path).await;
1156 let git_diff = runner::context::build_git_diff(&worktree_path, &default_branch).await;
1157 let git_log = runner::context::build_git_log(&worktree_path, &default_branch).await;
1158 let project_dir = review_project_dir(repo);
1161 let sandboxed = sandboxed_in(project_dir.as_deref());
1162 let main_project_dir = project_dir.unwrap_or_else(|| worktree_path.clone());
1163 let review_prompt = runner::agent::build_review_prompt(
1164 task,
1165 &agent_summary,
1166 &git_diff,
1167 &git_log,
1168 &default_branch,
1169 pr_number,
1170 runner::agent::Shipcheck::detect(&worktree_path)
1171 .await
1172 .for_prompt(sandboxed),
1173 );
1174
1175 let task_agent = stored_task
1176 .as_ref()
1177 .and_then(|t| t.agent.clone())
1178 .unwrap_or_default();
1179 let (review_agent, review_model, same_agent_fallback) =
1180 select_review_agent(&task.id.0, &task_agent, store_id, router, store).await?;
1181
1182 anyhow::ensure!(
1183 review_model.is_some(),
1184 "review model missing for task {} after agent selection — refusing to dispatch",
1185 task.id.0
1186 );
1187
1188 if same_agent_fallback {
1189 tracing::warn!(
1190 task_id = task.id.0,
1191 agent = %review_agent,
1192 "no other agent available for review — falling back to the agent that wrote the code"
1193 );
1194 store_log_activity(
1195 &Some(Arc::clone(store)),
1196 repo,
1197 &task.id.0,
1198 "review_agent_fallback",
1199 None,
1200 None,
1201 Some(&review_agent),
1202 review_model.as_deref(),
1203 Some(&serde_json::json!({
1204 "reason": "all available agents excluded (author + previous reviewers)",
1205 })),
1206 )
1207 .await;
1208 }
1209
1210 tracing::info!(
1211 task_id = task.id.0,
1212 agent = %review_agent,
1213 model = ?review_model,
1214 "spawning review agent"
1215 );
1216
1217 let review_task_id = format!("{}-review", task.id.0);
1218 let review_attempt_dir =
1219 home::task_attempt_dir_async(repo, &review_task_id, review_attempt).await?;
1220 let output_file = review_attempt_dir.join("output.json");
1221
1222 let git_name = config::get("git.name").unwrap_or_else(|_| format!("{review_agent}[bot]"));
1223 let git_email = config::get("git.email")
1224 .unwrap_or_else(|_| format!("{review_agent}[bot]@users.noreply.github.com"));
1225 let review_timeout_secs = review_timeout_seconds();
1226
1227 let invocation = runner::agent::AgentInvocation {
1228 agent: review_agent.clone(),
1229 model: review_model.clone(),
1230 work_dir: worktree_path.clone(),
1231 system_prompt: runner::agent::review_system_prompt(),
1232 agent_message: review_prompt,
1233 task_id: review_task_id.clone(),
1234 disallowed_tools: build_review_disallowed_tools(&worktree_path),
1235 git_author_name: git_name,
1236 git_author_email: git_email,
1237 output_file: output_file.clone(),
1238 timeout_seconds: review_timeout_secs,
1239 repo: repo.to_string(),
1240 attempt: review_attempt,
1241 sandbox: sandboxed,
1242 main_project_dir,
1243 branch: branch_name.clone(),
1244 };
1245
1246 let head_sha = match GhHttp::new() {
1247 Ok(gh) => gh.get_pr(repo, pr_number).await.ok().map(|pr| pr.head.sha),
1248 Err(_) => None,
1249 };
1250
1251 Ok(ReviewPhase::Ready(ReviewContext {
1252 store_id,
1253 had_prev_push_failure,
1254 review_attempt,
1255 worktree_path,
1256 branch_name,
1257 pr_number,
1258 head_sha,
1259 default_branch,
1260 review_agent,
1261 review_model,
1262 review_task_id,
1263 review_attempt_dir,
1264 output_file,
1265 invocation,
1266 same_agent_fallback,
1267 }))
1268}
1269
1270#[expect(
1271 clippy::too_many_arguments,
1272 reason = "the run record needs the outcome, the agent and the timing together"
1273)]
1274async fn complete_review_run(
1275 store: &Arc<TaskStore>,
1276 run_id: Option<i64>,
1277 exit_code: Option<i32>,
1278 stdout: &str,
1279 stderr: &str,
1280 parsed: &str,
1281 outcome: &str,
1282 error: &str,
1283 tokens: RunTokenUsage,
1284) {
1285 if let Some(run_id) = run_id
1286 && let Err(e) = store
1287 .complete_run(&CompleteRun {
1288 run_id,
1289 exit_code,
1290 stdout,
1291 stderr,
1292 parsed,
1293 outcome,
1294 error,
1295 tokens,
1296 })
1297 .await
1298 {
1299 tracing::warn!(
1300 run_id,
1301 error = %e,
1302 "failed to record review run completion in audit trail"
1303 );
1304 }
1305}
1306
1307async fn invoke_review_agent(
1308 task: &ExternalTask,
1309 tmux: &Arc<TmuxManager>,
1310 ctx: &ReviewContext,
1311 repo: &str,
1312 store: &Arc<TaskStore>,
1313) -> ReviewPhase<ReviewRun> {
1314 let run_id = if let Some(sid) = ctx.store_id {
1315 match store
1316 .start_run(&StartRun {
1317 task_id: sid,
1318 attempt: i32::try_from(ctx.review_attempt).unwrap_or(i32::MAX),
1319 run_type: "review",
1320 agent: &ctx.review_agent,
1321 model: ctx.review_model.as_deref().unwrap_or(""),
1322 command: "",
1323 prompt: "",
1324 })
1325 .await
1326 {
1327 Ok(run_id) => Some(run_id),
1328 Err(e) => {
1329 tracing::warn!(
1330 task_id = task.id.0,
1331 error = %e,
1332 "failed to record review run start in audit trail"
1333 );
1334 None
1335 }
1336 }
1337 } else {
1338 None
1339 };
1340
1341 let session = match runner::agent::spawn_in_tmux(tmux, &ctx.invocation).await {
1342 Ok(session) => session,
1343 Err(e) => {
1344 tracing::error!(task_id = task.id.0, error = %e, "failed to spawn review agent");
1345 complete_review_run(
1346 store,
1347 run_id,
1348 None,
1349 "",
1350 &e.to_string(),
1351 "",
1352 "failed",
1353 &format!("spawn failed: {e}"),
1354 RunTokenUsage::default(),
1355 )
1356 .await;
1357 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(format!("spawn failed: {e}")));
1358 }
1359 };
1360
1361 let start_comment = review_started_comment(&ctx.review_agent, ctx.review_model_str());
1362 match GhHttp::new() {
1363 Ok(gh) => {
1364 let app = review_app(repo).await;
1365 post_review_started(app.as_deref(), &gh, repo, ctx.head_sha.as_deref()).await;
1366 if app.is_none()
1368 && let Err(e) = gh
1369 .add_comment(repo, &ctx.pr_number.to_string(), &start_comment)
1370 .await
1371 {
1372 tracing::debug!(
1373 task_id = task.id.0,
1374 pr_number = ctx.pr_number,
1375 error = %e,
1376 "failed to post review-started comment"
1377 );
1378 }
1379 }
1380 Err(e) => {
1381 tracing::debug!(
1382 task_id = task.id.0,
1383 pr_number = ctx.pr_number,
1384 error = %e,
1385 "failed to create GitHub client for review-started comment"
1386 );
1387 }
1388 }
1389
1390 let poll_interval = Duration::from_secs(5);
1391 let timeout_duration = Duration::from_secs(ctx.invocation.timeout_seconds);
1392 let wait_result = time::timeout(timeout_duration, async {
1393 tokio::select! {
1394 result = tmux.wait_for_completion(&session, poll_interval) => result,
1395 () = wait_for_review_decision(ctx, poll_interval) => {
1397 tracing::info!(task_id = task.id.0, "review decision is in while the session is still open, ending it");
1398 Ok(String::new())
1399 }
1400 }
1401 })
1402 .await;
1403
1404 match wait_result {
1405 Ok(Ok(_)) => {
1406 tracing::info!(task_id = task.id.0, "review agent completed");
1407 let _ = tmux.kill_session(&session).await;
1408 ReviewPhase::Ready(ReviewRun { run_id })
1409 }
1410 Ok(Err(e)) => {
1411 tracing::warn!(task_id = task.id.0, error = %e, "review agent process exited with error");
1412 let _ = tmux.kill_session(&session).await;
1413
1414 let exit_code: i32 = task::spawn_blocking({
1419 let review_attempt_dir = ctx.review_attempt_dir.clone();
1420 move || {
1421 fs::read_to_string(review_attempt_dir.join("exit.txt"))
1422 .ok()
1423 .and_then(|s| s.trim().parse().ok())
1424 .unwrap_or(-1)
1425 }
1426 })
1427 .await
1428 .unwrap_or(-1);
1429
1430 if exit_code != 0 {
1431 let raw_output =
1436 runner::response::read_output_file(&ctx.review_task_id, &ctx.output_file, repo)
1437 .await;
1438 let agent_result =
1439 runner::agents::find_agent_result(&ctx.review_agent, &raw_output);
1440 if agent_result.as_ref().is_some_and(|r| !r.is_error) {
1442 tracing::info!(
1443 task_id = task.id.0,
1444 agent = %ctx.review_agent,
1445 exit_code,
1446 output_found = !raw_output.is_empty(),
1447 "review agent succeeded despite non-zero exit — proceeding to parse output"
1448 );
1449 return ReviewPhase::Ready(ReviewRun { run_id });
1450 }
1451 }
1452
1453 tracing::error!(task_id = task.id.0, error = %e, "review agent failed");
1454 complete_review_run(
1455 store,
1456 run_id,
1457 Some(exit_code),
1458 "",
1459 &e.to_string(),
1460 "",
1461 "failed",
1462 &format!("agent error: {e}"),
1463 RunTokenUsage::default(),
1464 )
1465 .await;
1466 ReviewPhase::EarlyReturn(ReviewDecision::Failed(format!("agent error: {e}")))
1467 }
1468 Err(_) => {
1469 let _ = tmux.kill_session(&session).await;
1470 if review_output_has_decision(ctx).await {
1471 tracing::warn!(
1472 task_id = task.id.0,
1473 "review agent timed out after writing its decision, using it"
1474 );
1475 return ReviewPhase::Ready(ReviewRun { run_id });
1476 }
1477
1478 tracing::error!(task_id = task.id.0, "review agent timed out");
1479 store_review_timeout(store, run_id, -1, "", "timeout").await
1480 }
1481 }
1482}
1483
1484const REVIEW_TIMEOUT_EXIT_CODE: i32 = 124;
1486
1487async fn store_review_timeout<T>(
1490 store: &Arc<TaskStore>,
1491 run_id: Option<i64>,
1492 exit_code: i32,
1493 raw_output: &str,
1494 stderr: &str,
1495) -> ReviewPhase<T> {
1496 complete_review_run(
1497 store,
1498 run_id,
1499 Some(exit_code),
1500 raw_output,
1501 stderr,
1502 "",
1503 "timeout",
1504 "timeout",
1505 RunTokenUsage::default(),
1506 )
1507 .await;
1508 ReviewPhase::EarlyReturn(ReviewDecision::Failed("timeout".to_string()))
1509}
1510
1511async fn review_output_has_decision(ctx: &ReviewContext) -> bool {
1514 let raw = tokio_fs::read_to_string(&ctx.output_file)
1515 .await
1516 .unwrap_or_default();
1517 output_has_review_decision(&ctx.review_agent, &raw)
1518}
1519
1520fn output_has_review_decision(agent: &str, raw: &str) -> bool {
1521 let raw = runner::agents::claude::strip_tty_control_lines(raw);
1522 runner::agents::find_agent_result(agent, &raw)
1523 .filter(|result| !result.is_error)
1524 .is_some_and(|result| runner::response::parse_review_response(&result.result_text).is_ok())
1525}
1526
1527async fn wait_for_review_decision(ctx: &ReviewContext, poll_interval: Duration) {
1528 loop {
1529 time::sleep(poll_interval).await;
1530 if review_output_has_decision(ctx).await {
1531 return;
1532 }
1533 }
1534}
1535
1536async fn record_review_agent_failure(
1537 task_id: &str,
1538 review_agent: &str,
1539 review_model: Option<&str>,
1540 err: &runner::agents::AgentError,
1541) {
1542 match err {
1543 runner::agents::AgentError::RateLimit { message }
1544 | runner::agents::AgentError::Auth { message } => {
1545 tracing::warn!(
1546 task_id,
1547 agent = %review_agent,
1548 "review agent hit error — adding to cooldown"
1549 );
1550 if let Some(reason) = cooldown::detect_credit_exhaustion(message) {
1551 if reason == CreditExhaustionReason::BillingCycleExhausted {
1552 if let Some(model) = review_model {
1553 cooldown::record_persistent_model_failure(review_agent, model).await;
1555 } else {
1556 cooldown::record_credit_exhaustion(review_agent, reason).await;
1557 }
1558 } else {
1559 cooldown::record_credit_exhaustion(review_agent, reason).await;
1560 }
1561 } else {
1562 runner::response::record_agent_failure_with_message(review_agent, &err.to_string())
1563 .await;
1564 }
1565 }
1566 runner::agents::AgentError::Interrupted { .. } => {}
1568 runner::agents::AgentError::InvalidResponse { .. } => {
1569 if let Some(model) = review_model {
1571 cooldown::record_transient_model_failure(review_agent, model).await;
1572 }
1573 }
1574 runner::agents::AgentError::ModelUnavailable { message, .. } => {
1575 if let Some(model) = review_model {
1577 cooldown::record_model_unavailable(review_agent, model, message).await;
1578 }
1579 }
1580 _ => {
1581 if let Some(model) = review_model {
1584 cooldown::record_model_failure(review_agent, model).await;
1585 }
1586 }
1587 }
1588}
1589
1590fn review_decision_from_response(
1591 review_response: runner::response::ReviewResponse,
1592) -> ReviewDecision {
1593 match review_response.decision.as_str() {
1594 "approve" => approve_from_evidence(review_response),
1595 "request_changes" => ReviewDecision::RequestChanges {
1596 notes: review_response.notes,
1597 issues: review_response.issues,
1598 },
1599 _ => ReviewDecision::Failed(format!("unknown decision: {}", review_response.decision)),
1600 }
1601}
1602
1603fn approve_from_evidence(review_response: runner::response::ReviewResponse) -> ReviewDecision {
1605 let tests_failed = review_response
1606 .test_results
1607 .as_deref()
1608 .is_some_and(|s| s.trim().eq_ignore_ascii_case("fail"));
1609 let has_error = review_response
1610 .issues
1611 .iter()
1612 .any(|issue| issue.severity.trim().eq_ignore_ascii_case("error"));
1613 if tests_failed || has_error {
1614 let notes = if tests_failed {
1615 notes_for_failed_tests(review_response.notes)
1616 } else {
1617 review_response.notes
1618 };
1619 ReviewDecision::RequestChanges {
1620 notes,
1621 issues: review_response.issues,
1622 }
1623 } else {
1624 ReviewDecision::Approve {
1625 issues: review_response.issues,
1626 }
1627 }
1628}
1629
1630fn notes_for_failed_tests(notes: String) -> String {
1631 const MARK: &str = "Review reported test_results: fail";
1632 if notes.trim().is_empty() {
1633 MARK.to_string()
1634 } else if notes.contains("test_results: fail") {
1635 notes
1636 } else {
1637 format!("{notes}\n\n{MARK}")
1638 }
1639}
1640
1641fn review_decision_name(decision: &ReviewDecision) -> &'static str {
1642 match decision {
1643 ReviewDecision::Approve { .. } => "approve",
1644 ReviewDecision::RequestChanges { .. } => "request_changes",
1645 ReviewDecision::Failed(_) => "failed",
1646 ReviewDecision::Blocked(_) => "blocked",
1647 ReviewDecision::Skipped => "skipped",
1648 ReviewDecision::Rerouted => "rerouted",
1649 }
1650}
1651
1652async fn parse_review_output(
1653 task: &ExternalTask,
1654 repo: &str,
1655 ctx: &ReviewContext,
1656 run: ReviewRun,
1657 store: &Arc<TaskStore>,
1658) -> ReviewPhase<ParsedReview> {
1659 let (file_exists, file_size, exit_code, stderr) = {
1660 let output_file_clone = ctx.output_file.clone();
1661 let review_attempt_dir_clone = ctx.review_attempt_dir.clone();
1662 match task::spawn_blocking(move || {
1663 let file_exists = output_file_clone.exists();
1664 let file_size = fs::metadata(&output_file_clone).map_or(0, |m| m.len());
1665 let exit_code = fs::read_to_string(review_attempt_dir_clone.join("exit.txt"))
1666 .ok()
1667 .and_then(|s| s.trim().parse::<i32>().ok())
1668 .unwrap_or(-1);
1669 let stderr =
1670 fs::read_to_string(review_attempt_dir_clone.join("stderr.txt")).unwrap_or_default();
1671 (file_exists, file_size, exit_code, stderr)
1672 })
1673 .await
1674 {
1675 Ok(tuple) => tuple,
1676 Err(e) => {
1677 tracing::error!(
1678 task_id = task.id.0,
1679 error = %e,
1680 "spawn_blocking panicked reading review output metadata"
1681 );
1682 (false, 0, -1, format!("spawn_blocking failed: {e}"))
1683 }
1684 }
1685 };
1686
1687 tracing::info!(
1688 task_id = task.id.0,
1689 path = %ctx.output_file.display(),
1690 file_exists,
1691 file_size,
1692 "reading review agent output"
1693 );
1694
1695 let raw_output =
1696 runner::response::read_output_file(&ctx.review_task_id, &ctx.output_file, repo).await;
1697 let raw_output = runner::agents::claude::strip_tty_control_lines(&raw_output);
1698 let has_result_envelope =
1699 runner::agents::claude::find_ndjson_result_line(&raw_output).is_some();
1700 let agent_result = runner::agents::find_agent_result(&ctx.review_agent, &raw_output);
1701 let token_usage = RunTokenUsage {
1702 input_tokens: agent_result
1703 .as_ref()
1704 .and_then(|r| r.input_tokens)
1705 .unwrap_or(0),
1706 output_tokens: agent_result
1707 .as_ref()
1708 .and_then(|r| r.output_tokens)
1709 .unwrap_or(0),
1710 total_cost_usd: agent_result
1711 .as_ref()
1712 .and_then(|r| r.cost_usd)
1713 .unwrap_or(0.0),
1714 duration_secs: 0.0,
1715 };
1716
1717 let agent_result_is_error = agent_result.as_ref().is_some_and(|r| r.is_error);
1718 let ndjson_completed_no_error = raw_output.contains("\"terminal_reason\":\"completed\"")
1719 && !raw_output.contains("\"is_error\":true");
1720 let is_hard_failure =
1722 !ndjson_completed_no_error && (raw_output.is_empty() || agent_result_is_error);
1723
1724 if exit_code == REVIEW_TIMEOUT_EXIT_CODE
1725 && !output_has_review_decision(&ctx.review_agent, &raw_output)
1726 {
1727 tracing::error!(
1728 task_id = task.id.0,
1729 "review agent killed by its own timeout without a decision"
1730 );
1731 return store_review_timeout(store, run.run_id, exit_code, &raw_output, &stderr).await;
1732 }
1733
1734 if is_hard_failure {
1735 tracing::warn!(
1736 task_id = task.id.0,
1737 exit_code,
1738 raw_output_len = raw_output.len(),
1739 stderr_len = stderr.len(),
1740 "review agent: entering error path"
1741 );
1742 let agent_runner = runner::agents::get_runner(&ctx.review_agent);
1743 let err = agent_runner.classify_error(exit_code, &raw_output, &stderr);
1744 record_review_agent_failure(
1745 &task.id.0,
1746 &ctx.review_agent,
1747 ctx.review_model.as_deref(),
1748 &err,
1749 )
1750 .await;
1751 tracing::error!(task_id = task.id.0, error = %err, "review agent failed");
1752 let stored_error = format_review_error(&ctx.review_agent, &err);
1753 complete_review_run(
1754 store,
1755 run.run_id,
1756 Some(exit_code),
1757 &raw_output,
1758 &stderr,
1759 "",
1760 outcome_for_agent_error(&err),
1761 &stored_error,
1762 token_usage,
1763 )
1764 .await;
1765 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1766 }
1767
1768 let text_for_review = agent_result
1769 .as_ref()
1770 .map(|r| r.result_text.clone())
1771 .filter(|text| !text.is_empty())
1772 .unwrap_or_else(|| {
1773 tracing::debug!(
1774 task_id = task.id.0,
1775 agent = %ctx.review_agent,
1776 "review agent: per-agent extractor returned no text, using raw output"
1777 );
1778 raw_output.clone()
1779 });
1780
1781 let review_response = match runner::response::parse_review_response(&text_for_review) {
1782 Ok(response) => response,
1783 Err(e) => {
1784 if let Some(response) = runner::response::infer_review_response(&text_for_review) {
1785 tracing::warn!(
1786 task_id = task.id.0,
1787 agent = %ctx.review_agent,
1788 "review response parsed via keyword fallback"
1789 );
1790 response
1791 } else {
1792 let ndjson_completed = ndjson_completed_no_error;
1798
1799 if ndjson_completed || !has_result_envelope {
1802 let assistant_text = runner::agents::collect_assistant_messages_text(
1803 &ctx.review_agent,
1804 &raw_output,
1805 );
1806 if !assistant_text.is_empty() {
1807 if let Ok(response) =
1808 runner::response::parse_review_response(&assistant_text)
1809 {
1810 tracing::debug!(
1811 task_id = task.id.0,
1812 agent = %ctx.review_agent,
1813 "found review response JSON in assistant message (terminal_reason:completed)"
1814 );
1815 let review_notes_for_comment = response.notes.clone();
1816 let decision = review_decision_from_response(response);
1817 return ReviewPhase::Ready(ParsedReview {
1818 run_id: run.run_id,
1819 exit_code,
1820 stderr,
1821 raw_output,
1822 text_for_review: assistant_text,
1823 token_usage,
1824 review_notes_for_comment,
1825 decision,
1826 });
1827 }
1828 if let Some(response) =
1829 runner::response::infer_review_response(&assistant_text)
1830 {
1831 tracing::warn!(
1832 task_id = task.id.0,
1833 agent = %ctx.review_agent,
1834 "inferred review response from assistant message (terminal_reason:completed)"
1835 );
1836 let review_notes_for_comment = response.notes.clone();
1837 let decision = review_decision_from_response(response);
1838 return ReviewPhase::Ready(ParsedReview {
1839 run_id: run.run_id,
1840 exit_code,
1841 stderr,
1842 raw_output,
1843 text_for_review: assistant_text,
1844 token_usage,
1845 review_notes_for_comment,
1846 decision,
1847 });
1848 }
1849 }
1850 }
1851
1852 let scan_head = runner::agents::patterns::safe_head(
1859 &text_for_review,
1860 runner::agents::patterns::RATE_LIMIT_SCAN_HEAD_BYTES,
1861 );
1862 let scan_tail = runner::agents::patterns::safe_tail(
1863 &text_for_review,
1864 runner::agents::patterns::RATE_LIMIT_SCAN_TAIL_BYTES,
1865 );
1866
1867 if let Some(err) = (!ndjson_completed)
1868 .then(|| {
1869 runner::agents::patterns::detect_rate_limit(scan_head)
1870 .or_else(|| runner::agents::patterns::detect_rate_limit(scan_tail))
1871 })
1872 .flatten()
1873 {
1874 let runner::agents::AgentError::RateLimit { message } = &err else {
1875 unreachable!("detect_rate_limit only returns AgentError::RateLimit")
1876 };
1877 tracing::warn!(
1878 task_id = task.id.0,
1879 agent = %ctx.review_agent,
1880 "review agent hit rate limit — adding to cooldown"
1881 );
1882 let summary = runner::fallback::summarize_rate_limit_error(message);
1889 let stored_error = format!("{} rate limit: {summary}", ctx.review_agent);
1890 record_review_agent_failure(
1891 &task.id.0,
1892 &ctx.review_agent,
1893 ctx.review_model.as_deref(),
1894 &err,
1895 )
1896 .await;
1897 complete_review_run(
1898 store,
1899 run.run_id,
1900 Some(exit_code),
1901 &raw_output,
1902 &stderr,
1903 &text_for_review,
1904 outcome_for_agent_error(&err),
1905 &stored_error,
1906 token_usage,
1907 )
1908 .await;
1909 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1910 }
1911
1912 if let Some(err) = runner::agents::patterns::detect_auth_error(scan_tail) {
1913 let runner::agents::AgentError::Auth { message } = &err else {
1914 unreachable!("detect_auth_error only returns AgentError::Auth")
1915 };
1916 tracing::warn!(
1917 task_id = task.id.0,
1918 agent = %ctx.review_agent,
1919 "review agent hit auth error — adding to cooldown"
1920 );
1921 let stored_error = format!("auth error: {message}");
1922 record_review_agent_failure(
1923 &task.id.0,
1924 &ctx.review_agent,
1925 ctx.review_model.as_deref(),
1926 &err,
1927 )
1928 .await;
1929 complete_review_run(
1930 store,
1931 run.run_id,
1932 Some(exit_code),
1933 &raw_output,
1934 &stderr,
1935 &text_for_review,
1936 outcome_for_agent_error(&err),
1937 &stored_error,
1938 token_usage,
1939 )
1940 .await;
1941 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1942 }
1943
1944 if agent_result.as_ref().is_some_and(|r| r.truncated_by_length) {
1946 tracing::warn!(
1947 task_id = task.id.0,
1948 agent = %ctx.review_agent,
1949 model = ?ctx.review_model,
1950 "review agent output truncated: step_finish reason=length \
1951 (token budget exceeded before completion)"
1952 );
1953 if let Some(model) = ctx.review_model.as_deref() {
1954 cooldown::record_transient_model_failure(&ctx.review_agent, model).await;
1955 }
1956 let stored_error = "truncated: review output was cut off before completion \
1957 (output/reasoning token budget exceeded)"
1958 .to_string();
1959 complete_review_run(
1960 store,
1961 run.run_id,
1962 Some(exit_code),
1963 &raw_output,
1964 &stderr,
1965 &text_for_review,
1966 "truncated",
1967 &stored_error,
1968 token_usage,
1969 )
1970 .await;
1971 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1972 }
1973
1974 if runner::agents::claude::review_session_exited_mid_thought(&raw_output) {
1977 tracing::warn!(
1978 task_id = task.id.0,
1979 agent = %ctx.review_agent,
1980 model = ?ctx.review_model,
1981 "review session exited mid-thought with no result envelope \
1982 — storing as incomplete and retrying without persistent cooldown"
1983 );
1984 let stored_error =
1985 "incomplete: review session exited before producing a decision".to_string();
1986 complete_review_run(
1987 store,
1988 run.run_id,
1989 Some(exit_code),
1990 &raw_output,
1991 &stderr,
1992 &text_for_review,
1993 "incomplete",
1994 &stored_error,
1995 token_usage,
1996 )
1997 .await;
1998 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1999 }
2000
2001 tracing::error!(
2002 task_id = task.id.0,
2003 error = %e,
2004 agent = %ctx.review_agent,
2005 output = %text_for_review.chars().take(300).collect::<String>(),
2006 raw_output = %raw_output.chars().take(1000).collect::<String>(),
2007 "failed to parse review response"
2008 );
2009 if let Some(model) = ctx.review_model.as_deref() {
2010 cooldown::record_transient_model_failure(&ctx.review_agent, model).await;
2011 }
2012 complete_review_run(
2013 store,
2014 run.run_id,
2015 Some(exit_code),
2016 &raw_output,
2017 &stderr,
2018 &text_for_review,
2019 "parse_error",
2020 &format!("parse error: {e}"),
2021 token_usage,
2022 )
2023 .await;
2024 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(format!(
2025 "parse error: {e}"
2026 )));
2027 }
2028 }
2029 };
2030
2031 let review_notes_for_comment = review_response.notes.clone();
2032 let decision = review_decision_from_response(review_response);
2033
2034 tracing::info!(
2035 task_id = task.id.0,
2036 pr_number = ctx.pr_number,
2037 decision = ?decision,
2038 "review agent decision received"
2039 );
2040 store_log_activity(
2041 &Some(Arc::clone(store)),
2042 repo,
2043 &task.id.0,
2044 "review_decision",
2045 None,
2046 None,
2047 Some(&ctx.review_agent),
2048 ctx.review_model.as_deref(),
2049 Some(&serde_json::json!({
2050 "decision": review_decision_name(&decision),
2051 "pr_number": ctx.pr_number,
2052 })),
2053 )
2054 .await;
2055 if let Err(e) = store::store_set_result(
2056 &Some(Arc::clone(store)),
2057 repo,
2058 &task.id.0,
2059 &[("review_resets", serde_json::json!(0))],
2060 )
2061 .await
2062 {
2063 tracing::warn!(task_id = task.id.0, err = %e, "failed to clear review_resets after a decision");
2064 }
2065
2066 ReviewPhase::Ready(ParsedReview {
2067 run_id: run.run_id,
2068 exit_code,
2069 stderr,
2070 raw_output,
2071 text_for_review,
2072 token_usage,
2073 review_notes_for_comment,
2074 decision,
2075 })
2076}
2077
2078async fn finalize_review_run(
2079 task: &ExternalTask,
2080 ctx: &ReviewContext,
2081 parsed: &ParsedReview,
2082 store: &Arc<TaskStore>,
2083) {
2084 let outcome = match &parsed.decision {
2085 ReviewDecision::Approve { .. }
2086 | ReviewDecision::RequestChanges { .. }
2087 | ReviewDecision::Skipped
2088 | ReviewDecision::Rerouted => "success",
2089 ReviewDecision::Failed(_) | ReviewDecision::Blocked(_) => "failed",
2090 };
2091 let error = match &parsed.decision {
2092 ReviewDecision::Failed(error) | ReviewDecision::Blocked(error) => error.as_str(),
2093 _ => "",
2094 };
2095
2096 if outcome == "success" {
2097 cooldown::record_agent_success(&ctx.review_agent, ctx.review_model_str()).await;
2100 }
2101
2102 complete_review_run(
2103 store,
2104 parsed.run_id,
2105 Some(parsed.exit_code),
2106 &parsed.raw_output,
2107 &parsed.stderr,
2108 &parsed.text_for_review,
2109 outcome,
2110 error,
2111 parsed.token_usage,
2112 )
2113 .await;
2114
2115 if let Some(store_id) = ctx.store_id
2116 && (parsed.token_usage.input_tokens > 0 || parsed.token_usage.output_tokens > 0)
2117 {
2118 let model = if ctx.review_model_str().is_empty() {
2119 "unknown"
2120 } else {
2121 ctx.review_model_str()
2122 };
2123 if let Err(e) = store
2124 .store_tokens(
2125 store_id,
2126 parsed.token_usage.input_tokens,
2127 parsed.token_usage.output_tokens,
2128 model,
2129 )
2130 .await
2131 {
2132 tracing::warn!(
2133 task_id = task.id.0,
2134 ?e,
2135 "failed to store review agent token usage"
2136 );
2137 }
2138 }
2139}
2140
2141async fn restore_review_config_if_needed(
2142 task_id: &str,
2143 worktree_path: &Path,
2144 default_branch: &str,
2145) {
2146 let _ = Command::new("git")
2147 .args(["checkout", "HEAD", "--", ".orch.yml"])
2148 .current_dir(worktree_path)
2149 .status()
2150 .await;
2151
2152 let merge_base = Command::new("git")
2153 .args(["merge-base", "HEAD", default_branch])
2154 .current_dir(worktree_path)
2155 .output()
2156 .await
2157 .ok()
2158 .filter(|output| output.status.success())
2159 .and_then(|output| String::from_utf8(output.stdout).ok())
2160 .map(|s| s.trim().to_string())
2161 .unwrap_or_default();
2162
2163 if merge_base.is_empty() {
2164 return;
2165 }
2166
2167 let orch_yml_changed = Command::new("git")
2168 .args([
2169 "diff",
2170 "--name-only",
2171 &merge_base,
2172 "HEAD",
2173 "--",
2174 ".orch.yml",
2175 ])
2176 .current_dir(worktree_path)
2177 .output()
2178 .await
2179 .ok()
2180 .filter(|output| output.status.success())
2181 .is_some_and(|output| !output.stdout.is_empty());
2182
2183 if !orch_yml_changed {
2184 return;
2185 }
2186
2187 tracing::warn!(
2188 task_id,
2189 "review agent modified .orch.yml (forbidden); restoring to merge-base version"
2190 );
2191 let restore_ok = Command::new("git")
2192 .args(["checkout", &merge_base, "--", ".orch.yml"])
2193 .current_dir(worktree_path)
2194 .status()
2195 .await
2196 .is_ok_and(|status| status.success());
2197
2198 if !restore_ok {
2199 return;
2200 }
2201
2202 let staged = Command::new("git")
2203 .args(["diff", "--cached", "--name-only", "--", ".orch.yml"])
2204 .current_dir(worktree_path)
2205 .output()
2206 .await
2207 .ok()
2208 .filter(|output| output.status.success())
2209 .is_some_and(|output| !output.stdout.is_empty());
2210
2211 if staged {
2212 let _ = Command::new("git")
2213 .args([
2214 "-c",
2215 "commit.gpgsign=false",
2216 "commit",
2217 "-m",
2218 "revert: restore .orch.yml (review agent must not modify project config)",
2219 ])
2220 .current_dir(worktree_path)
2221 .status()
2222 .await;
2223 }
2224}
2225
2226fn is_missing_path_error_message(err: &str) -> bool {
2227 err.to_ascii_lowercase()
2228 .contains("no such file or directory")
2229}
2230
2231async fn push_review_branch(
2232 task: &ExternalTask,
2233 repo: &str,
2234 ctx: &ReviewContext,
2235 store: &Arc<TaskStore>,
2236) -> Result<(), String> {
2237 let mut worktree_path = ctx.worktree_path.clone();
2238 let mut branch_name = ctx.branch_name.clone();
2239 let mut retried_missing_worktree = false;
2240
2241 let push_result = loop {
2242 match runner::git_ops::push_branch(&worktree_path, &branch_name, &ctx.default_branch).await
2243 {
2244 Ok(ok) => break Ok(ok),
2245 Err(e)
2246 if !retried_missing_worktree
2247 && !worktree_path.exists()
2248 && is_missing_path_error_message(&e.to_string()) =>
2249 {
2250 retried_missing_worktree = true;
2251 tracing::warn!(
2252 task_id = task.id.0,
2253 worktree = %worktree_path.display(),
2254 branch = %branch_name,
2255 "review push failed because worktree disappeared; attempting one-time recovery"
2256 );
2257 match recover_missing_review_worktree(task, repo, store).await {
2258 Ok(recovered) => {
2259 worktree_path = recovered.work_dir;
2260 branch_name = recovered.branch;
2261 }
2262 Err(recover_err) => {
2263 break Err(anyhow::anyhow!(
2264 "missing review worktree recovery failed after push error: {recover_err}"
2265 ));
2266 }
2267 }
2268 }
2269 Err(e) => break Err(e),
2270 }
2271 };
2272
2273 match push_result {
2274 Ok(_) => {
2275 store_log_activity(
2276 &Some(Arc::clone(store)),
2277 repo,
2278 &task.id.0,
2279 "push",
2280 None,
2281 None,
2282 Some(&ctx.review_agent),
2283 ctx.review_model.as_deref(),
2284 Some(&serde_json::json!({
2285 "status": "ok",
2286 "branch": branch_name.clone(),
2287 "default_branch": ctx.default_branch.clone(),
2288 "phase": "review",
2289 })),
2290 )
2291 .await;
2292 if ctx.had_prev_push_failure
2293 && let Err(e) = store_set_result(
2294 &Some(Arc::clone(store)),
2295 repo,
2296 &task.id.0,
2297 &[
2298 ("last_error", serde_json::json!("")),
2299 ("push_failures", serde_json::json!(0)),
2300 ],
2301 )
2302 .await
2303 {
2304 tracing::warn!(task_id = task.id.0, err = %e, "failed to reset push_failures — counter may drift");
2305 }
2306 Ok(())
2307 }
2308 Err(e) => {
2309 store_log_activity(
2310 &Some(Arc::clone(store)),
2311 repo,
2312 &task.id.0,
2313 "push",
2314 None,
2315 None,
2316 Some(&ctx.review_agent),
2317 ctx.review_model.as_deref(),
2318 Some(&serde_json::json!({
2319 "status": "error",
2320 "branch": branch_name.clone(),
2321 "default_branch": ctx.default_branch.clone(),
2322 "phase": "review",
2323 "error": e.to_string(),
2324 })),
2325 )
2326 .await;
2327 tracing::error!(
2328 task_id = task.id.0,
2329 branch = %ctx.branch_name,
2330 error = %e,
2331 "review push failed"
2332 );
2333 store_set(
2334 &Some(Arc::clone(store)),
2335 repo,
2336 &task.id.0,
2337 &[("last_error", serde_json::json!(format!("push failed: {e}")))],
2338 )
2339 .await;
2340 Err(e.to_string())
2341 }
2342 }
2343}
2344
2345fn append_review_issues(body: &mut String, issues: &[ReviewIssue]) {
2346 if issues.is_empty() {
2347 return;
2348 }
2349 body.push_str("\n**Issues Found:**\n");
2350 for issue in issues {
2351 let _ = writeln!(
2352 body,
2353 "- `{}` line {}: {} [{}]",
2354 issue.file,
2355 issue
2356 .line
2357 .map_or_else(|| "?".to_string(), |line| line.to_string()),
2358 issue.description,
2359 issue.severity
2360 );
2361 }
2362}
2363
2364fn build_pr_review_comment(decision: &ReviewDecision, review_notes_for_comment: &str) -> String {
2365 match decision {
2366 ReviewDecision::Approve { issues } => {
2367 let mut body =
2368 format!("## Automated Review \u{2014} Approve\n\n{review_notes_for_comment}");
2369 if !issues.is_empty() && !body.ends_with('\n') {
2370 body.push('\n');
2371 }
2372 append_review_issues(&mut body, issues);
2373 body
2374 }
2375 ReviewDecision::RequestChanges { notes, issues } => {
2376 let mut body = format!("## Automated Review \u{2014} Changes Requested\n\n{notes}\n");
2377 append_review_issues(&mut body, issues);
2378 body
2379 }
2380 _ => String::new(),
2381 }
2382}
2383
2384const REVIEW_STATUS_CONTEXT: &str = "orch/review";
2385
2386fn review_status(decision: &ReviewDecision) -> Option<(&'static str, &'static str)> {
2388 match decision {
2389 ReviewDecision::Approve { .. } => Some(("success", "Approved by automated review")),
2390 ReviewDecision::RequestChanges { .. } => {
2391 Some(("failure", "Automated review requested changes"))
2392 }
2393 _ => None,
2394 }
2395}
2396
2397async fn post_review_status(
2400 gh: &GhHttp,
2401 repo: &str,
2402 sha: Option<&str>,
2403 state: &str,
2404 description: &str,
2405) {
2406 let Some(sha) = sha else {
2407 return;
2408 };
2409 if let Err(e) = gh
2410 .post_commit_status(repo, sha, state, REVIEW_STATUS_CONTEXT, description)
2411 .await
2412 {
2413 tracing::warn!(sha, error = %e, "failed to post orch/review commit status");
2414 }
2415}
2416
2417async fn post_review_comment(
2418 task: &ExternalTask,
2419 repo: &str,
2420 ctx: &ReviewContext,
2421 decision: &ReviewDecision,
2422 review_notes_for_comment: &str,
2423) -> anyhow::Result<()> {
2424 let gh = match GhHttp::new() {
2425 Ok(gh) => gh,
2426 Err(e) => {
2427 tracing::warn!(
2428 task_id = task.id.0,
2429 error = %e,
2430 "failed to create GH client for review comment"
2431 );
2432 return Ok(());
2433 }
2434 };
2435 let mut pr_comment = build_pr_review_comment(decision, review_notes_for_comment);
2436 if ctx.same_agent_fallback && !pr_comment.is_empty() {
2437 pr_comment.push_str(
2438 "\n> Note: no other agent was available, so this PR was reviewed by the same agent in a new session.\n",
2439 );
2440 }
2441
2442 let head_sha = worktree_head_sha(&ctx.worktree_path)
2445 .await
2446 .or_else(|| ctx.head_sha.clone());
2447
2448 let app = review_app(repo).await;
2449 post_review_decision(
2450 app.as_deref(),
2451 &gh,
2452 repo,
2453 head_sha.as_deref(),
2454 decision,
2455 review_notes_for_comment,
2456 )
2457 .await;
2458
2459 if !pr_comment.is_empty() {
2460 let footer =
2461 attribution_footer("Reviewed", &ctx.review_agent, Some(ctx.review_model_str()));
2462 let pr_comment_with_footer = format!("{pr_comment}{footer}");
2463 if let Err(e) = publish_review(
2464 app.as_deref(),
2465 &gh,
2466 repo,
2467 ctx.pr_number,
2468 head_sha.as_deref(),
2469 decision,
2470 &pr_comment_with_footer,
2471 )
2472 .await
2473 {
2474 tracing::warn!(
2475 task_id = task.id.0,
2476 pr_number = ctx.pr_number,
2477 error = %e,
2478 "failed to post automated review comment on PR"
2479 );
2480 }
2481 }
2482
2483 Ok(())
2484}
2485
2486async fn review_app(repo: &str) -> Option<Arc<AppAuth>> {
2488 let mode = review_mode::for_repo(repo);
2489 review_app_for(mode, app::shared(), repo).await
2490}
2491
2492async fn review_app_for(
2493 mode: ReviewMode,
2494 app: Option<Arc<AppAuth>>,
2495 repo: &str,
2496) -> Option<Arc<AppAuth>> {
2497 if mode != ReviewMode::App {
2498 return None;
2499 }
2500
2501 let app = app?;
2502 match app.token_for_repo(repo).await {
2503 Ok(Some(_)) => Some(app),
2504 Ok(None) => {
2505 tracing::info!(
2506 repo,
2507 "review.mode is app but the App is not installed here, reviewing with a comment"
2508 );
2509 None
2510 }
2511 Err(e) => {
2512 tracing::warn!(repo, error = %e, "App installation lookup failed, reviewing with a comment");
2513 None
2514 }
2515 }
2516}
2517
2518async fn post_review_started(app: Option<&AppAuth>, gh: &GhHttp, repo: &str, sha: Option<&str>) {
2520 match (app, sha) {
2521 (Some(app), Some(sha)) => {
2522 if let Err(e) = app
2523 .post_check_run(repo, sha, CheckRunState::InProgress)
2524 .await
2525 {
2526 tracing::warn!(sha, error = %e, "failed to post the orch/review check run");
2527 }
2528 }
2529 _ => post_review_status(gh, repo, sha, "pending", "Automated review running").await,
2530 }
2531}
2532
2533async fn post_review_decision(
2534 app: Option<&AppAuth>,
2535 gh: &GhHttp,
2536 repo: &str,
2537 sha: Option<&str>,
2538 decision: &ReviewDecision,
2539 notes: &str,
2540) {
2541 let Some((state, description)) = review_status(decision) else {
2542 return;
2543 };
2544
2545 match (app, sha) {
2546 (Some(app), Some(sha)) => {
2547 let approved = state == "success";
2548 let completed = CheckRunState::Completed {
2549 approved,
2550 summary: if notes.is_empty() { description } else { notes },
2551 };
2552 if let Err(e) = app.post_check_run(repo, sha, completed).await {
2553 tracing::warn!(sha, error = %e, "failed to complete the orch/review check run");
2554 }
2555 }
2556 _ => post_review_status(gh, repo, sha, state, description).await,
2557 }
2558}
2559
2560async fn publish_review(
2564 app: Option<&AppAuth>,
2565 gh: &GhHttp,
2566 repo: &str,
2567 pr_number: u64,
2568 head_sha: Option<&str>,
2569 decision: &ReviewDecision,
2570 comment: &str,
2571) -> anyhow::Result<()> {
2572 let event = match decision {
2573 ReviewDecision::Approve { .. } => Some(ReviewEvent::Approve),
2574 ReviewDecision::RequestChanges { .. } => Some(ReviewEvent::RequestChanges),
2575 _ => None,
2576 };
2577 if let (Some(app), Some(sha), Some(event)) = (app, head_sha, event) {
2578 let inline = inline_comments(app, repo, pr_number, decision).await;
2579 match app
2580 .submit_review(repo, pr_number, event, comment, sha, &inline)
2581 .await
2582 {
2583 Ok(()) => return Ok(()),
2584 Err(e) => {
2585 tracing::warn!(pr_number, sha, error = %e, "failed to submit App review, posting the decision as a comment");
2586 }
2587 }
2588 }
2589 gh.add_comment(repo, &pr_number.to_string(), comment).await
2590}
2591
2592async fn inline_comments(
2595 app: &AppAuth,
2596 repo: &str,
2597 pr_number: u64,
2598 decision: &ReviewDecision,
2599) -> Vec<InlineComment> {
2600 let ReviewDecision::RequestChanges { issues, .. } = decision else {
2601 return Vec::new();
2602 };
2603
2604 if issues.is_empty() {
2605 return Vec::new();
2606 }
2607
2608 match app.pull_request_hunks(repo, pr_number).await {
2609 Ok(hunks) => app::split_inline(issues, &hunks).0,
2610 Err(e) => {
2611 tracing::warn!(pr_number, error = %e, "could not read the PR diff, issues stay in the review body");
2612 Vec::new()
2613 }
2614 }
2615}
2616
2617#[expect(
2618 clippy::too_many_arguments,
2619 reason = "applying a decision touches the backend, the store, tmux and the router"
2620)]
2621async fn apply_review_decision(
2622 task: &ExternalTask,
2623 backend: &Arc<dyn ExternalBackend>,
2624 repo: &str,
2625 task_manager: &Arc<TaskManager>,
2626 store: &Arc<TaskStore>,
2627 ctx: &ReviewContext,
2628 parsed: &ParsedReview,
2629 router_config: &RouterConfig,
2630) -> anyhow::Result<ReviewDecision> {
2631 restore_review_config_if_needed(&task.id.0, &ctx.worktree_path, &ctx.default_branch).await;
2632
2633 if let Err(e) = push_review_branch(task, repo, ctx, store).await {
2634 return Ok(ReviewDecision::Failed(format!("push failed: {e}")));
2635 }
2636
2637 post_review_comment(
2638 task,
2639 repo,
2640 ctx,
2641 &parsed.decision,
2642 &parsed.review_notes_for_comment,
2643 )
2644 .await?;
2645
2646 match &parsed.decision {
2647 ReviewDecision::Approve { .. } => {
2648 record_approved_sha(
2649 store,
2650 repo,
2651 &task.id.0,
2652 ctx.pr_number,
2653 &ctx.worktree_path,
2654 &ctx.branch_name,
2655 &ctx.default_branch,
2656 )
2657 .await;
2658 if let Err(e) = auto_merge_pr(
2663 task,
2664 &ctx.branch_name,
2665 backend,
2666 repo,
2667 &ctx.review_agent,
2668 ctx.review_model_str(),
2669 task_manager,
2670 store,
2671 )
2672 .await
2673 {
2674 let error_msg = e.to_string();
2675 if error_msg.contains("not yet computed") {
2676 tracing::warn!(
2677 task_id = task.id.0,
2678 pr_number = ctx.pr_number,
2679 branch = %ctx.branch_name,
2680 error = %e,
2681 "auto-merge deferred — PR mergeability not yet computed"
2682 );
2683 } else {
2684 tracing::error!(
2685 task_id = task.id.0,
2686 pr_number = ctx.pr_number,
2687 branch = %ctx.branch_name,
2688 error = %e,
2689 "auto-merge failed"
2690 );
2691 }
2692 return Ok(ReviewDecision::Failed(format!("merge failed: {e}")));
2693 }
2694 Ok(parsed.decision.clone())
2695 }
2696 ReviewDecision::RequestChanges { notes, issues } => {
2697 clear_approved_sha(store, repo, &task.id.0, ctx.pr_number).await;
2698 handle_review_changes(
2699 task,
2700 notes,
2701 issues,
2702 backend,
2703 repo,
2704 ctx.pr_number,
2705 &ctx.review_agent,
2706 ctx.review_model_str(),
2707 task_manager,
2708 store,
2709 router_config,
2710 )
2711 .await?;
2712 Ok(parsed.decision.clone())
2713 }
2714 _ => Ok(parsed.decision.clone()),
2715 }
2716}
2717
2718async fn recover_missing_review_worktree(
2719 task: &ExternalTask,
2720 repo: &str,
2721 store: &Arc<TaskStore>,
2722) -> anyhow::Result<worktree::WorktreeSetup> {
2723 let project_dir = config::get_projects_with_paths()?
2724 .into_iter()
2725 .find(|(candidate_repo, _)| candidate_repo == repo)
2726 .map(|(_, project_dir)| project_dir)
2727 .ok_or_else(|| anyhow::anyhow!("no project directory configured for repo {repo}"))?;
2728
2729 tracing::warn!(
2730 task_id = task.id.0,
2731 repo,
2732 project_dir = %project_dir.display(),
2733 "review worktree missing, recreating via normal setup"
2734 );
2735
2736 let store_ref = Some(Arc::clone(store));
2737 let wt = worktree::setup_worktree(&task.id.0, &task.title, &project_dir, &store_ref, repo)
2738 .await
2739 .context("recreating missing review worktree")?;
2740
2741 if !wt.work_dir.exists() {
2742 anyhow::bail!(
2743 "recreated review worktree at {} for task {} is still missing",
2744 wt.work_dir.display(),
2745 task.id.0
2746 );
2747 }
2748
2749 Ok(wt)
2750}
2751
2752async fn count_ahead_commits(worktree_path: &Path, default_branch: &str) -> Result<u64, String> {
2758 let worktree_str = worktree_path.to_str().ok_or_else(|| {
2759 format!(
2760 "worktree path contains non-UTF-8 characters: {}",
2761 worktree_path.display()
2762 )
2763 })?;
2764
2765 let output = Command::new("git")
2766 .args([
2767 "-C",
2768 worktree_str,
2769 "rev-list",
2770 "--count",
2771 &format!("origin/{default_branch}..HEAD"),
2772 ])
2773 .output()
2774 .await
2775 .map_err(|e| format!("git rev-list command failed to start: {e}"))?;
2776
2777 if !output.status.success() {
2778 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
2779 return Err(format!(
2780 "git rev-list exited with {:?}: {}",
2781 output.status.code(),
2782 if stderr.is_empty() {
2783 String::from_utf8_lossy(&output.stdout).trim().to_string()
2784 } else {
2785 stderr
2786 }
2787 ));
2788 }
2789
2790 String::from_utf8_lossy(&output.stdout)
2791 .trim()
2792 .parse::<u64>()
2793 .map_err(|e| format!("could not parse git rev-list output as u64: {e}"))
2794}
2795
2796#[expect(
2803 clippy::too_many_arguments,
2804 reason = "every early return path needs its own piece of review state"
2805)]
2806async fn ensure_pr_exists(
2807 task: &ExternalTask,
2808 branch_name: &str,
2809 worktree_path: &Path,
2810 repo: &str,
2811 agent_summary: &str,
2812 store: &Arc<TaskStore>,
2813 task_manager: &Arc<TaskManager>,
2814 stored_pr_number: Option<u64>,
2815) -> anyhow::Result<EnsurePrResult> {
2816 let gh_check = GhHttp::new()?;
2817
2818 if let Some(n) = stored_pr_number {
2819 tracing::info!(
2820 task_id = task.id.0,
2821 pr_number = n,
2822 branch = %branch_name,
2823 "open PR found in store, proceeding with review"
2824 );
2825 return Ok(EnsurePrResult::Ready(n));
2826 }
2827
2828 match gh_check.get_pr_number(repo, branch_name).await {
2829 Ok(Some(n)) => {
2830 tracing::info!(
2831 task_id = task.id.0,
2832 pr_number = n,
2833 branch = %branch_name,
2834 "open PR found, proceeding with review"
2835 );
2836 Ok(EnsurePrResult::Ready(n))
2837 }
2838 Ok(None) => {
2839 let default_branch = worktree::detect_default_branch(worktree_path).await;
2841 let has_commits = match count_ahead_commits(worktree_path, &default_branch).await {
2842 Ok(count) => count > 0,
2843 Err(e) => {
2844 tracing::error!(
2845 task_id = task.id.0,
2846 branch = %branch_name,
2847 default_branch = %default_branch,
2848 err = %e,
2849 "could not determine if branch has commits ahead of default"
2850 );
2851 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2852 format!("git ancestry check failed: {e}"),
2853 )));
2854 }
2855 };
2856
2857 if has_commits {
2858 tracing::warn!(
2860 task_id = task.id.0,
2861 branch = %branch_name,
2862 "no open PR but branch has commits — attempting to create PR"
2863 );
2864 let worktree_str = worktree_path.to_str().ok_or_else(|| {
2865 anyhow::anyhow!(
2866 "worktree path contains non-UTF-8 characters: {}",
2867 worktree_path.display()
2868 )
2869 })?;
2870 let push_timeout = Duration::from_secs(120);
2877 match time::timeout(
2878 push_timeout,
2879 git_ops::git()
2880 .await
2881 .args(["-C", worktree_str, "push", "-u", "origin", branch_name])
2882 .output(),
2883 )
2884 .await
2885 {
2886 Err(_elapsed) => {
2887 tracing::warn!(
2888 task_id = task.id.0,
2889 branch = %branch_name,
2890 timeout_secs = push_timeout.as_secs(),
2891 "review gate fallback push timed out"
2892 );
2893 }
2894 Ok(Ok(o)) => {
2895 if o.status.success() {
2896 tracing::info!(task_id = task.id.0, branch = %branch_name, "review gate fallback push succeeded");
2897 } else {
2898 tracing::warn!(
2899 task_id = task.id.0,
2900 branch = %branch_name,
2901 exit_code = ?o.status.code(),
2902 stdout = %String::from_utf8_lossy(&o.stdout),
2903 stderr = %String::from_utf8_lossy(&o.stderr),
2904 "review gate fallback push failed"
2905 );
2906 }
2907 }
2908 Ok(Err(e)) => {
2909 tracing::error!(
2910 task_id = task.id.0,
2911 branch = %branch_name,
2912 error = %e,
2913 "review gate fallback push command failed to run"
2914 );
2915 }
2916 }
2917
2918 let task_ref = runner::git_ops::format_task_ref(&task.id.0);
2919 let pr_body = format!(
2920 "Resolves {task_ref}\n\nAuto-created by orch review gate (agent forgot to open PR)"
2921 );
2922 let gh = GhHttp::new()?;
2923 match gh
2924 .create_pr(repo, &task.title, &pr_body, branch_name, &default_branch)
2925 .await
2926 {
2927 Ok(url) => match parse_pr_number_from_url(&url) {
2928 Ok(pr_num) => {
2929 store_set_result(
2930 &Some(Arc::clone(store)),
2931 repo,
2932 &task.id.0,
2933 &[("pr_number", serde_json::json!(pr_num))],
2934 )
2935 .await?;
2936 tracing::info!(
2937 task_id = task.id.0,
2938 branch = %branch_name,
2939 pr_url = %url,
2940 "created missing PR via GhHttp — continuing review"
2941 );
2942 Ok(EnsurePrResult::Ready(pr_num))
2943 }
2944 Err(e) => {
2945 tracing::warn!(
2946 task_id = task.id.0,
2947 branch = %branch_name,
2948 pr_url = %url,
2949 error = %e,
2950 "created missing PR via GhHttp, but failed to parse PR number"
2951 );
2952 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2953 format!("created missing PR, but could not parse PR number: {e}"),
2954 )))
2955 }
2956 },
2957 Err(e) => {
2958 let e_str = format!("{e}");
2959 if e_str.contains("already exists") {
2960 let backoff_delays = [2u64, 4u64];
2964 let mut found_pr: Option<u64> = None;
2965 for delay_secs in backoff_delays {
2966 time::sleep(time::Duration::from_secs(delay_secs)).await;
2967 if let Ok(Some(n)) = gh_check.get_pr_number(repo, branch_name).await
2968 {
2969 found_pr = Some(n);
2970 break;
2971 }
2972 }
2973 if let Some(n) = found_pr {
2974 tracing::info!(
2975 task_id = task.id.0,
2976 pr_number = n,
2977 branch = %branch_name,
2978 "found existing PR after create_pr 422 — retrying review"
2979 );
2980 store_set_result(
2981 &Some(Arc::clone(store)),
2982 repo,
2983 &task.id.0,
2984 &[("pr_number", serde_json::json!(n))],
2985 )
2986 .await?;
2987 return Ok(EnsurePrResult::Ready(n));
2988 }
2989 }
2990 tracing::warn!(
2991 task_id = task.id.0,
2992 branch = %branch_name,
2993 error = %e,
2994 "create_pr failed via GhHttp, falling back to CLI"
2995 );
2996 let pr_timeout = Duration::from_secs(120);
2999 let mut gh_cmd = Command::new("gh");
3000 gh_cmd.args([
3001 "pr",
3002 "create",
3003 "--repo",
3004 repo,
3005 "--head",
3006 branch_name,
3007 "--title",
3008 &task.title,
3009 "--body",
3010 &pr_body,
3011 ]);
3012 gh_cmd.current_dir(worktree_path);
3013 gh_cmd.kill_on_drop(true);
3014
3015 let pr_result = time::timeout(pr_timeout, gh_cmd.output()).await;
3016 match pr_result {
3017 Ok(Ok(o)) if o.status.success() => {
3018 let stdout = String::from_utf8_lossy(&o.stdout);
3019 match parse_pr_number_from_url(stdout.trim()) {
3020 Ok(pr_num) => {
3021 store_set_result(
3022 &Some(Arc::clone(store)),
3023 repo,
3024 &task.id.0,
3025 &[("pr_number", serde_json::json!(pr_num))],
3026 )
3027 .await?;
3028 tracing::info!(
3029 task_id = task.id.0,
3030 branch = %branch_name,
3031 "created missing PR via CLI — continuing review"
3032 );
3033 Ok(EnsurePrResult::Ready(pr_num))
3034 }
3035 Err(e) => {
3036 tracing::warn!(
3037 task_id = task.id.0,
3038 branch = %branch_name,
3039 stdout = %stdout,
3040 error = %e,
3041 "created missing PR via CLI, but failed to parse PR number"
3042 );
3043 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
3044 format!(
3045 "created missing PR via CLI, but could not parse PR number: {e}"
3046 ),
3047 )))
3048 }
3049 }
3050 }
3051 Ok(Ok(o)) => {
3052 let stderr = String::from_utf8_lossy(&o.stderr);
3053 if stderr.contains("already exists")
3054 && let Some(pr_url) =
3055 stderr.lines().find(|l| l.trim().starts_with("https://"))
3056 {
3057 match parse_pr_number_from_url(pr_url.trim()) {
3058 Ok(pr_num) => {
3059 if let Err(e) = store_set_result(
3060 &Some(Arc::clone(store)),
3061 repo,
3062 &task.id.0,
3063 &[("pr_number", serde_json::json!(pr_num))],
3064 )
3065 .await
3066 {
3067 tracing::warn!(
3068 task_id = task.id.0,
3069 pr_num,
3070 err = %e,
3071 "failed to persist pr_number from already-exists recovery — downstream may retry PR creation"
3072 );
3073 }
3074 tracing::info!(
3075 task_id = task.id.0,
3076 branch = %branch_name,
3077 pr_url = %pr_url,
3078 "PR already exists (from CLI stderr) — retrying review"
3079 );
3080 return Ok(EnsurePrResult::Ready(pr_num));
3081 }
3082 Err(e) => {
3083 tracing::warn!(
3084 task_id = task.id.0,
3085 branch = %branch_name,
3086 pr_url = %pr_url,
3087 error = %e,
3088 "found existing PR URL in CLI stderr, but failed to parse PR number"
3089 );
3090 }
3092 }
3093 }
3094 tracing::error!(
3095 task_id = task.id.0,
3096 branch = %branch_name,
3097 stderr = %stderr,
3098 "failed to create missing PR — work may be stuck"
3099 );
3100
3101 if git_ops::is_transient_github_api_error(&stderr) {
3108 tracing::warn!(
3109 task_id = task.id.0,
3110 branch = %branch_name,
3111 "transient GitHub error creating PR; will retry later without incrementing persistent failure counter"
3112 );
3113 return Ok(EnsurePrResult::EarlyReturn(
3114 ReviewDecision::Failed(format!(
3115 "transient github error creating PR: {stderr}"
3116 )),
3117 ));
3118 }
3119
3120 match store_increment(
3121 &Some(Arc::clone(store)),
3122 repo,
3123 &task.id.0,
3124 "pr_create_failures",
3125 )
3126 .await
3127 {
3128 Ok(failures) if failures >= MAX_PR_CREATE_FAILURES => {
3129 return Ok(EnsurePrResult::EarlyReturn(
3130 ReviewDecision::Blocked(format!(
3131 "no PR, create failed {failures} times: {stderr}"
3132 )),
3133 ));
3134 }
3135 Ok(_) => {}
3136 Err(e) => {
3137 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment pr_create_failures — skipping blocking decision this tick");
3138 }
3139 }
3140 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
3141 format!("no PR, create failed: {stderr}"),
3142 )))
3143 }
3144 Ok(Err(e)) => {
3145 tracing::error!(
3146 task_id = task.id.0,
3147 error = %e,
3148 "failed to run gh pr create"
3149 );
3150 let e_str = format!("{e}");
3151 if git_ops::is_transient_github_api_error(&e_str) {
3152 tracing::warn!(
3153 task_id = task.id.0,
3154 branch = %branch_name,
3155 "transient GitHub error from gh CLI fallback; will retry later without incrementing persistent failure counter"
3156 );
3157 return Ok(EnsurePrResult::EarlyReturn(
3158 ReviewDecision::Failed(format!("transient gh error: {e}")),
3159 ));
3160 }
3161
3162 match store_increment(
3163 &Some(Arc::clone(store)),
3164 repo,
3165 &task.id.0,
3166 "pr_create_failures",
3167 )
3168 .await
3169 {
3170 Ok(failures) if failures >= MAX_PR_CREATE_FAILURES => {
3171 return Ok(EnsurePrResult::EarlyReturn(
3172 ReviewDecision::Blocked(format!(
3173 "no PR, gh error {failures} times: {e}"
3174 )),
3175 ));
3176 }
3177 Ok(_) => {}
3178 Err(e) => {
3179 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment pr_create_failures — skipping blocking decision this tick");
3180 }
3181 }
3182 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
3183 format!("no PR, gh error: {e}"),
3184 )))
3185 }
3186 Err(_elapsed) => {
3187 tracing::error!(
3189 task_id = task.id.0,
3190 timeout_secs = pr_timeout.as_secs(),
3191 "gh pr create CLI fallback timed out"
3192 );
3193 let e_str = format!(
3194 "gh pr create timed out after {}s",
3195 pr_timeout.as_secs()
3196 );
3197 if git_ops::is_transient_github_api_error(&e_str) {
3198 tracing::warn!(
3199 task_id = task.id.0,
3200 branch = %branch_name,
3201 "transient GitHub timeout from gh CLI fallback; will retry later without incrementing persistent failure counter"
3202 );
3203 return Ok(EnsurePrResult::EarlyReturn(
3204 ReviewDecision::Failed(format!(
3205 "transient gh error: {e_str}"
3206 )),
3207 ));
3208 }
3209
3210 match store_increment(
3211 &Some(Arc::clone(store)),
3212 repo,
3213 &task.id.0,
3214 "pr_create_failures",
3215 )
3216 .await
3217 {
3218 Ok(failures) if failures >= MAX_PR_CREATE_FAILURES => {
3219 return Ok(EnsurePrResult::EarlyReturn(
3220 ReviewDecision::Blocked(format!(
3221 "no PR, gh timeout {failures} times: {e_str}"
3222 )),
3223 ));
3224 }
3225 Ok(_) => {}
3226 Err(e) => {
3227 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment pr_create_failures — skipping blocking decision this tick");
3228 }
3229 }
3230 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
3231 format!("no PR, gh timeout: {e_str}"),
3232 )))
3233 }
3234 }
3235 }
3236 }
3237 } else {
3238 let merged = match gh_check.is_pr_merged(repo, branch_name).await {
3240 Ok(v) => v,
3241 Err(e) => {
3242 tracing::warn!(task_id = task.id.0, branch = %branch_name, err = %e, "merge check failed, skipping task this tick");
3243 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
3244 format!("merge check failed: {e}"),
3245 )));
3246 }
3247 };
3248 if merged {
3249 tracing::info!(
3250 task_id = task.id.0,
3251 branch = %branch_name,
3252 "PR already merged, marking done"
3253 );
3254 if let Err(e) = task_manager
3255 .update_task_status(&task.id, Status::Done)
3256 .await
3257 {
3258 tracing::error!(task_id = task.id.0, err = %e, "update_task_status(Done) failed — task may be stuck in InReview");
3259 }
3260 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped));
3261 }
3262
3263 let last_error = opt_store_get_task(&Some(Arc::clone(store)), repo, &task.id.0)
3264 .await
3265 .map(|t| t.last_error)
3266 .unwrap_or_default();
3267 let reason = if agent_summary.is_empty() {
3268 last_error.clone()
3269 } else {
3270 agent_summary.to_string()
3271 };
3272
3273 if last_error.contains("exceeded max attempts") {
3274 tracing::warn!(
3275 task_id = task.id.0,
3276 branch = %branch_name,
3277 "no PR and no commits after max attempts — marking blocked to stop loop"
3278 );
3279 let fields = [
3283 (
3284 "block_reason",
3285 serde_json::json!("max attempts exceeded — no PR or commits produced"),
3286 ),
3287 ("last_error", serde_json::json!(last_error)),
3288 ];
3289 if let Err(e) = task_manager
3290 .update_task_status_and_result(&task.id, Status::Blocked, &fields)
3291 .await
3292 {
3293 tracing::error!(task_id = task.id.0, err = %e, "update_task_status_and_result(Blocked) failed — skipping block to avoid silent auto-unblock loop");
3294 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
3295 format!("failed to write block_reason: {e}"),
3296 )));
3297 }
3298 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped));
3299 }
3300
3301 if last_error.contains("422") && last_error.contains("head") {
3302 tracing::info!(
3303 task_id = task.id.0,
3304 branch = %branch_name,
3305 "no PR — 422/head-invalid means work already merged, marking done"
3306 );
3307 if let Err(e) = task_manager
3308 .update_task_status(&task.id, Status::Done)
3309 .await
3310 {
3311 tracing::error!(task_id = task.id.0, err = %e, "update_task_status(Done) failed");
3312 }
3313 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped));
3314 }
3315
3316 if last_error.contains("no fallback agents")
3317 || last_error.contains("all agents exhausted")
3318 {
3319 tracing::warn!(
3320 task_id = task.id.0,
3321 branch = %branch_name,
3322 last_error = %last_error,
3323 "no PR and no commits after failover exhaustion — marking blocked to stop loop"
3324 );
3325 let fields = [
3329 (
3330 "block_reason",
3331 serde_json::json!("all agents exhausted — no PR or commits produced"),
3332 ),
3333 ("last_error", serde_json::json!(last_error)),
3334 ];
3335 if let Err(e) = task_manager
3336 .update_task_status_and_result(&task.id, Status::Blocked, &fields)
3337 .await
3338 {
3339 tracing::error!(task_id = task.id.0, err = %e, "update_task_status_and_result(Blocked) failed — skipping block to avoid silent auto-unblock loop");
3340 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
3341 format!("failed to write block_reason: {e}"),
3342 )));
3343 }
3344 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped));
3345 }
3346
3347 let max_reroutes: u32 = config::get("workflow.max_reroute_attempts")
3354 .ok()
3355 .and_then(|s| s.parse().ok())
3356 .or_else(|| {
3357 config::get("workflow.max_attempts")
3358 .ok()
3359 .and_then(|s| s.parse().ok())
3360 })
3361 .unwrap_or(3);
3362
3363 tracing::warn!(
3364 task_id = task.id.0,
3365 branch = %branch_name,
3366 reason = %reason,
3367 "no PR and no commits — re-routing for retry"
3368 );
3369
3370 if should_skip_no_code_reroute_increment(&last_error) {
3374 tracing::warn!(
3375 task_id = task.id.0,
3376 last_error = %last_error,
3377 "transient GitHub error recorded; skipping persistent no_pr_reroutes increment"
3378 );
3379 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
3380 format!("transient github error: {last_error}"),
3381 )));
3382 }
3383
3384 let reroutes = match store_increment(
3386 &Some(Arc::clone(store)),
3387 repo,
3388 &task.id.0,
3389 "no_code_reroutes",
3390 )
3391 .await
3392 {
3393 Ok(v) => v,
3394 Err(e) => {
3395 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment no_code_reroutes — skipping reroute/block decision this tick");
3396 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
3398 format!("transient store error: {e}"),
3399 )));
3400 }
3401 };
3402
3403 if reroutes >= u64::from(max_reroutes) {
3404 tracing::error!(
3405 task_id = task.id.0,
3406 reroutes,
3407 max_reroutes,
3408 "reached max reroute attempts for no-pr-result — blocking for human review"
3409 );
3410 let msg = format!(
3411 "no PR or code changes after {reroutes}/{max_reroutes} reroute attempts"
3412 );
3413 let fields = [
3417 (
3418 "block_reason",
3419 serde_json::json!(format!(
3420 "max reroute attempts ({}) reached — no PR or code changes produced",
3421 max_reroutes
3422 )),
3423 ),
3424 ("last_error", serde_json::json!(msg)),
3425 ("agent", serde_json::json!(null)),
3426 ("model", serde_json::json!(null)),
3427 ];
3428 if let Err(e) = task_manager
3429 .update_task_status_and_result(&task.id, Status::Blocked, &fields)
3430 .await
3431 {
3432 tracing::error!(task_id = task.id.0, err = %e, "update_task_status_and_result(Blocked) failed — skipping block to avoid silent auto-unblock loop");
3433 }
3434 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Blocked(msg)))
3435 } else if task.id.0.starts_with("internal:") {
3436 tracing::info!(
3443 task_id = task.id.0,
3444 "no PR and no commits for internal task — marking done"
3445 );
3446 if let Err(e) = task_manager
3447 .update_task_status(&task.id, Status::Done)
3448 .await
3449 {
3450 tracing::error!(task_id = task.id.0, err = %e, "update_task_status(Done) failed — task may be stuck in InReview");
3451 }
3452 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped))
3453 } else {
3454 match store_set_result(
3459 &Some(Arc::clone(store)),
3460 repo,
3461 &task.id.0,
3462 &[
3463 ("agent", serde_json::json!(null)),
3464 ("model", serde_json::json!(null)),
3465 (
3466 "last_error",
3467 serde_json::json!("no PR or code changes produced"),
3468 ),
3469 ],
3470 )
3471 .await
3472 {
3473 Err(e) => {
3474 tracing::error!(task_id = task.id.0, err = %e, "failed to clear agent/model for reroute — skipping status transition");
3475 }
3477 _ => {
3478 if let Err(e) =
3479 task_manager.update_task_status(&task.id, Status::New).await
3480 {
3481 tracing::error!(task_id = task.id.0, err = %e, "update_task_status(New) failed — task may be stuck in InReview");
3482 }
3483 }
3484 }
3485 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Rerouted))
3486 }
3487 }
3488 }
3489 Err(e) => {
3490 tracing::warn!(
3491 task_id = task.id.0,
3492 branch = %branch_name,
3493 error = %e,
3494 "failed to check PR status"
3495 );
3496 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
3497 format!("PR check failed: {e}"),
3498 )))
3499 }
3500 }
3501}
3502
3503fn review_project_dir(repo: &str) -> Option<PathBuf> {
3504 config::get_projects_with_paths()
3505 .unwrap_or_default()
3506 .into_iter()
3507 .find(|(candidate, _)| candidate == repo)
3508 .map(|(_, dir)| dir)
3509}
3510
3511fn sandboxed_in(project_dir: Option<&Path>) -> bool {
3512 match project_dir {
3513 Some(dir) => runner::sandbox::enabled(dir),
3514 None => runner::sandbox::enabled_globally(),
3515 }
3516}
3517
3518pub(crate) fn review_sandboxed(repo: &str) -> bool {
3520 sandboxed_in(review_project_dir(repo).as_deref())
3521}
3522
3523pub(crate) fn review_timeout_seconds() -> u64 {
3525 review_timeout_secs(
3526 config::get("workflow.review_timeout_seconds")
3527 .ok()
3528 .and_then(|s| s.parse().ok()),
3529 )
3530}
3531
3532pub(crate) const REVIEW_RESULT_DELIVERY_SECS: u64 = 120;
3536
3537pub(crate) fn no_session_review_floor(configured: u64, review_timeout: u64) -> u64 {
3541 configured.max(review_timeout + REVIEW_RESULT_DELIVERY_SECS)
3542}
3543
3544pub(crate) async fn review_and_merge(
3550 task: &ExternalTask,
3551 backend: &Arc<dyn ExternalBackend>,
3552 tmux: &Arc<TmuxManager>,
3553 repo: &str,
3554 router: &Arc<RwLock<Router>>,
3555 task_manager: &Arc<TaskManager>,
3556 store: &Arc<TaskStore>,
3557) -> anyhow::Result<ReviewDecision> {
3558 let ctx = match build_review_context(task, repo, router, task_manager, store).await? {
3559 ReviewPhase::Ready(ctx) => ctx,
3560 ReviewPhase::EarlyReturn(decision) => return Ok(decision),
3561 };
3562
3563 let run = match invoke_review_agent(task, tmux, &ctx, repo, store).await {
3564 ReviewPhase::Ready(run) => run,
3565 ReviewPhase::EarlyReturn(decision) => return Ok(decision),
3566 };
3567
3568 let parsed = match parse_review_output(task, repo, &ctx, run, store).await {
3569 ReviewPhase::Ready(parsed) => parsed,
3570 ReviewPhase::EarlyReturn(decision) => return Ok(decision),
3571 };
3572
3573 finalize_review_run(task, &ctx, &parsed, store).await;
3574
3575 let router_config = router.read().await.config.clone();
3576 apply_review_decision(
3577 task,
3578 backend,
3579 repo,
3580 task_manager,
3581 store,
3582 &ctx,
3583 &parsed,
3584 &router_config,
3585 )
3586 .await
3587}
3588
3589#[cfg(test)]
3590mod tests {
3591 use std::env;
3592 use std::process;
3593
3594 use super::*;
3595 use crate::backends::ExternalId;
3596 use crate::backends::ExternalTask;
3597 use crate::backends::test_helpers::NoopBackend;
3598 use crate::engine::router::RouterConfig;
3599 use crate::engine::runner::response::ReviewIssue;
3600 use crate::github::token;
3601 use crate::github::types::{GitHubReview, GitHubReviewComment, GitHubUser, PullRequestReview};
3602 use crate::review_mode::ReviewMode;
3603 use crate::store::{TaskStatus, TaskStore};
3604 use tempfile::TempDir;
3605 use wiremock::Mock;
3606 use wiremock::MockServer;
3607 use wiremock::ResponseTemplate;
3608 use wiremock::matchers::body_partial_json;
3609 use wiremock::matchers::method;
3610 use wiremock::matchers::path;
3611 use wiremock::matchers::query_param;
3612
3613 #[test]
3614 fn a_review_decision_counts_only_from_a_successful_result() {
3615 let approve = r#"{"type":"result","subtype":"success","is_error":false,"result":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"issues\":[]}"}"#;
3616 let prose = r#"{"type":"result","subtype":"success","is_error":false,"result":"I have finished looking at the code."}"#;
3617 let failed = r#"{"type":"result","subtype":"error","is_error":true,"result":"{\"decision\":\"approve\",\"notes\":\"\",\"issues\":[]}"}"#;
3618 let thinking = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"Reading the diff"}]}}"#;
3619
3620 assert!(output_has_review_decision("claude", approve));
3621 assert!(!output_has_review_decision("claude", prose));
3622 assert!(!output_has_review_decision("claude", failed));
3623 assert!(!output_has_review_decision("claude", thinking));
3624 assert!(!output_has_review_decision("claude", ""));
3625 }
3626
3627 #[test]
3628 fn a_review_gets_15_minutes_unless_configured_and_never_under_5() {
3629 assert_eq!(review_timeout_secs(None), 900);
3630 assert_eq!(review_timeout_secs(Some(600)), 600);
3631 assert_eq!(review_timeout_secs(Some(60)), 300);
3632 assert_eq!(review_timeout_secs(Some(3600)), 3600);
3633 }
3634
3635 #[test]
3640 fn no_session_review_floor_raises_a_limit_under_the_review_timeout() {
3641 assert_eq!(
3642 no_session_review_floor(600, 900),
3643 900 + REVIEW_RESULT_DELIVERY_SECS
3644 );
3645 }
3646
3647 #[test]
3648 fn no_session_review_floor_keeps_a_limit_over_the_review_timeout() {
3649 assert_eq!(no_session_review_floor(1800, 900), 1800);
3650 }
3651
3652 #[test]
3653 fn no_session_review_floor_clears_the_review_timeout_by_the_delivery_margin() {
3654 for (configured, review_timeout) in [(0, 0), (1800, 1800), (120, 7200)] {
3655 let floored = no_session_review_floor(configured, review_timeout);
3656 assert!(
3657 floored >= review_timeout + REVIEW_RESULT_DELIVERY_SECS,
3658 "{floored} must leave a finished review time to deliver its result"
3659 );
3660 }
3661 }
3662
3663 #[test]
3664 fn outcome_for_agent_error_kimi_billing_cycle_is_not_rate_limit() {
3665 let err = runner::agents::AgentError::RateLimit {
3669 message: "Failed to authenticate. API Error: 403 You've reached your usage limit \
3670 for this billing cycle. Your quota will be refreshed soon."
3671 .to_string(),
3672 };
3673 assert_eq!(outcome_for_agent_error(&err), "billing_cycle_exhausted");
3674 }
3675
3676 #[test]
3677 fn outcome_for_agent_error_generic_rate_limit_unaffected() {
3678 let err = runner::agents::AgentError::RateLimit {
3679 message: "429 Too Many Requests".to_string(),
3680 };
3681 assert_eq!(outcome_for_agent_error(&err), "rate_limit");
3682 }
3683
3684 #[test]
3685 fn outcome_for_agent_error_generic_auth_unaffected() {
3686 let err = runner::agents::AgentError::Auth {
3687 message: "401 Unauthorized".to_string(),
3688 };
3689 assert_eq!(outcome_for_agent_error(&err), "auth_error");
3690 }
3691
3692 #[test]
3695 fn parse_pr_number_standard_url() {
3696 assert_eq!(
3697 parse_pr_number_from_url("https://github.com/owner/repo/pull/42"),
3698 Ok(42)
3699 );
3700 }
3701
3702 #[test]
3703 fn parse_pr_number_trailing_slash() {
3704 assert_eq!(
3705 parse_pr_number_from_url("https://github.com/owner/repo/pull/42/"),
3706 Ok(42)
3707 );
3708 }
3709
3710 #[test]
3711 fn parse_pr_number_with_query_string() {
3712 assert_eq!(
3713 parse_pr_number_from_url("https://github.com/owner/repo/pull/42?tab=files"),
3714 Ok(42)
3715 );
3716 }
3717
3718 #[test]
3719 fn parse_pr_number_with_hash_fragment() {
3720 assert_eq!(
3721 parse_pr_number_from_url("https://github.com/owner/repo/pull/42#issuecomment-123"),
3722 Ok(42)
3723 );
3724 }
3725
3726 #[test]
3727 fn parse_pr_number_with_query_and_hash() {
3728 assert_eq!(
3729 parse_pr_number_from_url("https://github.com/owner/repo/pull/42?quick_pull=1#top"),
3730 Ok(42)
3731 );
3732 }
3733
3734 #[test]
3735 fn parse_pr_number_with_leading_whitespace() {
3736 assert_eq!(
3737 parse_pr_number_from_url(" https://github.com/owner/repo/pull/42\n"),
3738 Ok(42)
3739 );
3740 }
3741
3742 #[test]
3743 fn parse_pr_number_wrong_domain() {
3744 let url = "https://gitlab.com/owner/repo/merge_requests/42";
3745 let result = parse_pr_number_from_url(url);
3746 assert!(result.is_err());
3747 assert!(
3748 result
3749 .unwrap_err()
3750 .contains("does not start with 'https://github.com/'")
3751 );
3752 }
3753
3754 #[test]
3755 fn parse_pr_number_missing_pull_segment() {
3756 let url = "https://github.com/owner/repo/issues/42";
3758 let result = parse_pr_number_from_url(url);
3759 assert!(result.is_err());
3760 assert!(result.unwrap_err().contains("missing '/pull/' segment"));
3761 }
3762
3763 #[test]
3764 fn parse_pr_number_zero_is_rejected() {
3765 let url = "https://github.com/owner/repo/pull/0";
3766 let result = parse_pr_number_from_url(url);
3767 assert!(result.is_err());
3768 assert!(result.unwrap_err().contains("cannot be zero"));
3769 }
3770
3771 #[test]
3772 fn parse_pr_number_non_numeric_segment() {
3773 let url = "https://github.com/owner/repo/pull/abc";
3774 let result = parse_pr_number_from_url(url);
3775 assert!(result.is_err());
3776 assert!(result.unwrap_err().contains("not a valid positive integer"));
3777 }
3778
3779 #[test]
3780 fn parse_pr_number_empty_string() {
3781 let result = parse_pr_number_from_url("");
3782 assert!(result.is_err());
3783 assert!(result.unwrap_err().contains("empty"));
3784 }
3785
3786 #[test]
3787 fn parse_pr_number_http_not_https() {
3788 let url = "http://github.com/owner/repo/pull/42";
3790 let result = parse_pr_number_from_url(url);
3791 assert!(result.is_err());
3792 assert!(
3793 result
3794 .unwrap_err()
3795 .contains("does not start with 'https://github.com/'")
3796 );
3797 }
3798
3799 #[test]
3807 fn format_review_error_summarises_rate_limit_fragment_with_api_retry() {
3808 let raw_fragment = "{\"attempt\":6,\"max_retries\":10,\"retry_delay_ms\":17266.789,\"error_status\":429,\"error\":\"rate_limit\",\"session_id\":\"5c7148ee\"}\n\
3812 {\"type\":\"system\",\"subtype\":\"api_retry\",\"attempt\":7,\"max_retries\":10,\"retry_delay_ms\":35162.66,\"error_status\":429,\"error\":\"rate_limit\",\"session_id\":\"5c7148ee\"}";
3813 let err = runner::agents::AgentError::RateLimit {
3814 message: raw_fragment.to_string(),
3815 };
3816
3817 let stored = format_review_error("minimax", &err);
3818
3819 assert!(
3822 !stored.contains("{\"type\""),
3823 "stored error must not contain raw NDJSON envelope, got: {stored}"
3824 );
3825 assert!(
3826 !stored.contains("subtype"),
3827 "stored error must not contain raw api_retry fragment, got: {stored}"
3828 );
3829 assert!(
3830 !stored.contains("session_id"),
3831 "stored error must not contain session_id from provider payload, got: {stored}"
3832 );
3833 assert_eq!(
3834 stored,
3835 "minimax rate limit: status=429 after 7 attempts (last delay 35s)"
3836 );
3837 }
3838
3839 #[test]
3840 fn format_review_error_summarises_truncated_fragment_without_subtype_marker() {
3841 let raw_fragment = ",\"attempt\":5,\"max_retries\":10,\"retry_delay_ms\":9034.81,\"error_status\":429,\"error\":\"rate_limit\",\"session_id\":\"992e17d3\"";
3846 let err = runner::agents::AgentError::RateLimit {
3847 message: raw_fragment.to_string(),
3848 };
3849
3850 let stored = format_review_error("glm", &err);
3851
3852 assert!(stored.starts_with("glm rate limit:"));
3853 assert!(
3854 !stored.contains("session_id"),
3855 "stored error must not leak session_id from truncated fragment, got: {stored}"
3856 );
3857 assert!(
3860 stored.len() <= 200,
3861 "stored error should be compact (<=200 chars), got {} chars: {stored}",
3862 stored.len()
3863 );
3864 }
3865
3866 #[test]
3867 fn format_review_error_preserves_non_rate_limit_variants() {
3868 let err = runner::agents::AgentError::Auth {
3869 message: "invalid bearer token".to_string(),
3870 };
3871 assert_eq!(
3872 format_review_error("kimi", &err),
3873 "kimi auth error: invalid bearer token"
3874 );
3875
3876 let err = runner::agents::AgentError::Timeout {
3877 elapsed: Duration::from_secs(1801),
3878 };
3879 assert_eq!(
3880 format_review_error("kimi", &err),
3881 "kimi timed out after 1801s"
3882 );
3883 }
3884
3885 fn git(dir: &Path, args: &[&str]) {
3886 let mut cmd = process::Command::new("git");
3887 cmd.current_dir(dir);
3888 if args.first() == Some(&"commit") {
3890 cmd.args(["-c", "commit.gpgsign=false"]);
3891 }
3892 let status = cmd.args(args).status().unwrap();
3893 assert!(status.success(), "git {args:?} failed");
3894 }
3895
3896 #[test]
3897 fn test_pull_request_review_requests_changes() {
3898 let review = PullRequestReview {
3899 review: GitHubReview {
3900 id: 1,
3901 user: GitHubUser {
3902 login: "reviewer".to_string(),
3903 },
3904 body: Some("Please fix".to_string()),
3905 state: "CHANGES_REQUESTED".to_string(),
3906 html_url: None,
3907 submitted_at: "2024-01-01T00:00:00Z".to_string(),
3908 commit_id: None,
3909 },
3910 comments: vec![],
3911 };
3912 assert!(review.requests_changes());
3913 }
3914
3915 #[test]
3916 fn test_pull_request_review_does_not_request_changes() {
3917 let review = PullRequestReview {
3918 review: GitHubReview {
3919 id: 1,
3920 user: GitHubUser {
3921 login: "reviewer".to_string(),
3922 },
3923 body: Some("LGTM".to_string()),
3924 state: "APPROVED".to_string(),
3925 html_url: None,
3926 submitted_at: "2024-01-01T00:00:00Z".to_string(),
3927 commit_id: None,
3928 },
3929 comments: vec![],
3930 };
3931 assert!(!review.requests_changes());
3932 }
3933
3934 #[test]
3935 fn test_pull_request_review_actionable_comments_filters_empty_and_replies() {
3936 let review = PullRequestReview {
3937 review: GitHubReview {
3938 id: 1,
3939 user: GitHubUser {
3940 login: "reviewer".to_string(),
3941 },
3942 body: None,
3943 state: "CHANGES_REQUESTED".to_string(),
3944 html_url: None,
3945 submitted_at: "2024-01-01T00:00:00Z".to_string(),
3946 commit_id: None,
3947 },
3948 comments: vec![
3949 GitHubReviewComment {
3950 id: 1,
3951 user: GitHubUser {
3952 login: "reviewer".to_string(),
3953 },
3954 body: "Fix this issue".to_string(),
3955 path: "src/main.rs".to_string(),
3956 line: Some(10),
3957 original_line: Some(10),
3958 commit_id: "abc".to_string(),
3959 original_commit_id: "abc".to_string(),
3960 html_url: "url".to_string(),
3961 created_at: "2024-01-01T00:00:00Z".to_string(),
3962 updated_at: "2024-01-01T00:00:00Z".to_string(),
3963 in_reply_to_id: None,
3964 diff_hunk: Some(
3965 "@@ -8,5 +8,5 @@ fn main() {\n- let x = 1;\n+ let x = 2;".to_string(),
3966 ),
3967 },
3968 GitHubReviewComment {
3969 id: 2,
3970 user: GitHubUser {
3971 login: "reviewer".to_string(),
3972 },
3973 body: String::new(),
3974 path: "src/lib.rs".to_string(),
3975 line: Some(20),
3976 original_line: Some(20),
3977 commit_id: "abc".to_string(),
3978 original_commit_id: "abc".to_string(),
3979 html_url: "url".to_string(),
3980 created_at: "2024-01-01T00:00:00Z".to_string(),
3981 updated_at: "2024-01-01T00:00:00Z".to_string(),
3982 in_reply_to_id: None,
3983 diff_hunk: None,
3984 },
3985 GitHubReviewComment {
3986 id: 3,
3987 user: GitHubUser {
3988 login: "reviewer".to_string(),
3989 },
3990 body: "Reply to this".to_string(),
3991 path: "src/lib.rs".to_string(),
3992 line: Some(30),
3993 original_line: Some(30),
3994 commit_id: "abc".to_string(),
3995 original_commit_id: "abc".to_string(),
3996 html_url: "url".to_string(),
3997 created_at: "2024-01-01T00:00:00Z".to_string(),
3998 updated_at: "2024-01-01T00:00:00Z".to_string(),
3999 in_reply_to_id: Some(1),
4000 diff_hunk: None,
4001 },
4002 ],
4003 };
4004 let actionable = review.actionable_comments();
4005 assert_eq!(actionable.len(), 1);
4006 assert_eq!(actionable[0].id, 1);
4007 assert_eq!(actionable[0].body, "Fix this issue");
4008 assert_eq!(actionable[0].path, "src/main.rs");
4009 assert_eq!(
4010 actionable[0].diff_hunk.as_ref().unwrap(),
4011 "@@ -8,5 +8,5 @@ fn main() {\n- let x = 1;\n+ let x = 2;"
4012 );
4013 }
4014
4015 #[test]
4016 fn test_router_config_model_for_complexity_returns_none_without_config() {
4017 let cfg = RouterConfig::default();
4020 assert!(cfg.model_for_complexity("claude", "simple", "").is_none());
4021 assert!(cfg.model_for_complexity("opencode", "review", "").is_none());
4022 assert!(
4023 cfg.model_for_complexity("unknown_agent_xyz", "simple", "")
4024 .is_none()
4025 );
4026 }
4027
4028 #[test]
4029 fn review_status_maps_decisions() {
4030 assert_eq!(
4031 review_status(&ReviewDecision::Approve { issues: vec![] })
4032 .unwrap()
4033 .0,
4034 "success"
4035 );
4036 let changes = ReviewDecision::RequestChanges {
4037 notes: String::new(),
4038 issues: vec![],
4039 };
4040 assert_eq!(review_status(&changes).unwrap().0, "failure");
4041 assert!(review_status(&ReviewDecision::Failed("x".into())).is_none());
4042 }
4043
4044 fn sample_review_issue(severity: &str) -> runner::response::ReviewIssue {
4045 runner::response::ReviewIssue {
4046 file: "src/engine/tick.rs".into(),
4047 line: Some(142),
4048 severity: severity.into(),
4049 description: "index out of bounds on the last element".into(),
4050 }
4051 }
4052
4053 fn sample_review_response(
4054 decision: &str,
4055 test_results: Option<&str>,
4056 issues: Vec<runner::response::ReviewIssue>,
4057 ) -> runner::response::ReviewResponse {
4058 runner::response::ReviewResponse {
4059 decision: decision.into(),
4060 notes: "looks fine".into(),
4061 test_results: test_results.map(str::to_string),
4062 issues,
4063 }
4064 }
4065
4066 #[test]
4067 fn approve_with_failing_tests_becomes_request_changes() {
4068 let decision =
4069 review_decision_from_response(sample_review_response("approve", Some("fail"), vec![]));
4070 match decision {
4071 ReviewDecision::RequestChanges { notes, issues } => {
4072 assert!(
4073 notes.contains("test_results: fail"),
4074 "notes must name the failing test run, got {notes:?}"
4075 );
4076 assert!(issues.is_empty(), "{issues:?}");
4077 }
4078 other => panic!("expected RequestChanges, got {other:?}"),
4079 }
4080 }
4081
4082 #[test]
4083 fn approve_with_error_issue_becomes_request_changes() {
4084 let issue = sample_review_issue("error");
4085 let decision = review_decision_from_response(sample_review_response(
4086 "approve",
4087 Some("pass"),
4088 vec![issue.clone()],
4089 ));
4090 match decision {
4091 ReviewDecision::RequestChanges { issues, .. } => {
4092 assert_eq!(issues, vec![issue]);
4093 }
4094 other => panic!("expected RequestChanges, got {other:?}"),
4095 }
4096 }
4097
4098 #[test]
4099 fn approve_with_warning_issue_stays_approve_and_comment_lists_it() {
4100 let issue = sample_review_issue("warning");
4101 let decision = review_decision_from_response(sample_review_response(
4102 "approve",
4103 Some("pass"),
4104 vec![issue.clone()],
4105 ));
4106 match &decision {
4107 ReviewDecision::Approve { issues } => {
4108 assert_eq!(issues, &vec![issue.clone()]);
4109 }
4110 other => panic!("expected Approve, got {other:?}"),
4111 }
4112 let comment = build_pr_review_comment(&decision, "looks fine");
4113 assert!(
4114 comment.contains("Approve"),
4115 "approve header missing from {comment}"
4116 );
4117 assert!(
4118 comment.contains(&issue.description),
4119 "warning issue missing from {comment}"
4120 );
4121 assert!(
4122 comment.contains("warning"),
4123 "warning severity missing from {comment}"
4124 );
4125 }
4126
4127 #[test]
4128 fn review_started_comment_includes_agent_and_model() {
4129 let expected_footer = attribution_footer("Review started", "kimi", Some("opus"));
4130 let expected = format!("🔍 Automated review started{expected_footer}");
4131 assert_eq!(review_started_comment("kimi", "opus"), expected);
4132 }
4133
4134 fn no_code_review_task(id: &str) -> ExternalTask {
4137 ExternalTask {
4138 id: ExternalId(id.to_string()),
4139 title: "opencode discovery timeout".to_string(),
4140 body: String::new(),
4141 state: "open".to_string(),
4142 labels: vec![],
4143 author: "tester".to_string(),
4144 created_at: "2026-01-01T00:00:00Z".to_string(),
4145 updated_at: "2026-01-01T00:00:00Z".to_string(),
4146 url: "https://example.com/issues/1".to_string(),
4147 }
4148 }
4149
4150 #[tokio::test]
4154 async fn carried_approval_counts_as_this_rounds_decision() {
4155 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4156 let id = store
4157 .create_internal("owner/repo", "Approved head", "", "test", "carried", None)
4158 .await
4159 .unwrap();
4160 let task_id = format!("internal:{id}");
4161 store.update_status(id, TaskStatus::InReview).await.unwrap();
4162 assert_eq!(
4163 store.in_review_entry(id).await.unwrap().map(|e| e.1),
4164 Some(false)
4165 );
4166
4167 record_carried_approval(&store, "owner/repo", &task_id, 7).await;
4168
4169 assert_eq!(
4170 store.in_review_entry(id).await.unwrap().map(|e| e.1),
4171 Some(true),
4172 "the stale in_review check must see this round as decided"
4173 );
4174 }
4175
4176 #[test]
4177 fn approval_covers_head_only_for_the_exact_commit() {
4178 assert!(approval_covers_head(Some("abc123"), Some("abc123")));
4179 assert!(
4180 !approval_covers_head(Some("abc123"), Some("def456")),
4181 "a moved PR head must be reviewed"
4182 );
4183 assert!(
4184 !approval_covers_head(None, Some("abc123")),
4185 "no recorded approval means review"
4186 );
4187 assert!(
4188 !approval_covers_head(Some("abc123"), None),
4189 "an unreadable PR head must not skip the review"
4190 );
4191 assert!(!approval_covers_head(Some(""), Some("")));
4192 }
4193
4194 fn pr_for(head_repo: Option<&str>, head: &str, base: &str) -> GitHubPullRequest {
4195 serde_json::from_value(serde_json::json!({
4196 "number": 7,
4197 "title": "t",
4198 "state": "open",
4199 "head": {"ref": head, "sha": "h", "repo": head_repo.map(|r| serde_json::json!({"full_name": r}))},
4200 "base": {"ref": base, "sha": "b"},
4201 "html_url": "https://github.com/owner/repo/pull/7",
4202 }))
4203 .unwrap()
4204 }
4205
4206 #[test]
4207 fn a_carried_approval_must_match_the_approved_repo_branch_and_base() {
4208 let approval = ApprovalEvent {
4209 sha: "a".into(),
4210 repo: "owner/repo".into(),
4211 branch: "gh-issue-1".into(),
4212 base: "main".into(),
4213 };
4214 let ok = pr_for(Some("owner/repo"), "gh-issue-1", "main");
4215 assert!(pr_matches_approval(&ok, &approval, "gh-issue-1"));
4216 assert!(!pr_matches_approval(
4217 &pr_for(Some("fork/repo"), "gh-issue-1", "main"),
4218 &approval,
4219 "gh-issue-1"
4220 ));
4221 assert!(!pr_matches_approval(
4222 &pr_for(None, "gh-issue-1", "main"),
4223 &approval,
4224 "gh-issue-1"
4225 ));
4226 assert!(!pr_matches_approval(
4227 &pr_for(Some("owner/repo"), "other", "main"),
4228 &approval,
4229 "gh-issue-1"
4230 ));
4231 assert!(!pr_matches_approval(
4232 &pr_for(Some("owner/repo"), "gh-issue-1", "release"),
4233 &approval,
4234 "gh-issue-1"
4235 ));
4236 assert!(!pr_matches_approval(&ok, &approval, "another-task-branch"));
4237
4238 let legacy = ApprovalEvent::parse("abc123").unwrap();
4239 assert_eq!(legacy.sha, "abc123");
4240 assert!(!pr_matches_approval(&ok, &legacy, "gh-issue-1"));
4241 let json = serde_json::to_string(&approval).unwrap();
4242 assert_eq!(ApprovalEvent::parse(&json), Some(approval));
4243 assert_eq!(ApprovalEvent::parse(" "), None);
4244 }
4245
4246 #[tokio::test]
4247 async fn only_the_same_commits_rebased_onto_the_base_carry_the_approval() {
4248 let dir = tempfile::tempdir().unwrap();
4249 let path = dir.path();
4250 let git = |args: &[&str]| {
4251 let out = process::Command::new("git")
4252 .args([
4253 "-c",
4254 "user.name=agent",
4255 "-c",
4256 "user.email=agent@t",
4257 "-c",
4258 "commit.gpgsign=false",
4259 ])
4260 .args(args)
4261 .env_remove("GIT_AUTHOR_NAME")
4262 .env_remove("GIT_AUTHOR_EMAIL")
4263 .env_remove("GIT_COMMITTER_NAME")
4264 .env_remove("GIT_COMMITTER_EMAIL")
4265 .current_dir(path)
4266 .output()
4267 .unwrap();
4268 assert!(
4269 out.status.success(),
4270 "git {args:?}: {}",
4271 String::from_utf8_lossy(&out.stderr)
4272 );
4273 String::from_utf8_lossy(&out.stdout).trim().to_string()
4274 };
4275 let write_commit = |file: &str, body: &str| {
4276 fs::write(path.join(file), body).unwrap();
4277 git(&["add", file]);
4278 git(&["commit", "-q", "-m", body]);
4279 };
4280 git(&["init", "-q", "-b", "main"]);
4281 write_commit("a", "base");
4282 git(&["checkout", "-q", "-b", "pr"]);
4283 write_commit("b", "first");
4284 write_commit("c", "second");
4285 let approved = git(&["rev-parse", "HEAD"]);
4286 git(&["checkout", "-q", "main"]);
4287 write_commit("d", "base moved");
4288 git(&["checkout", "-q", "-b", "rebased", &approved]);
4289 git(&["rebase", "-q", "main"]);
4290 let rebased = git(&["rev-parse", "HEAD"]);
4291 assert!(same_commits_rebased(path, &approved, "main", &rebased).await);
4292
4293 write_commit("c", "second, changed");
4295 let changed = git(&["rev-parse", "HEAD"]);
4296 assert!(!same_commits_rebased(path, &approved, "main", &changed).await);
4297
4298 git(&["checkout", "-q", "-b", "other-author", &rebased]);
4300 git(&[
4301 "-c",
4302 "user.name=someone",
4303 "-c",
4304 "user.email=someone@t",
4305 "commit",
4306 "-q",
4307 "--amend",
4308 "--reset-author",
4309 "--no-edit",
4310 ]);
4311 let reauthored = git(&["rev-parse", "HEAD"]);
4312 assert!(!same_commits_rebased(path, &approved, "main", &reauthored).await);
4313 }
4314
4315 #[tokio::test]
4316 async fn only_a_clean_merge_of_the_base_counts_as_base_only() {
4317 let dir = tempfile::tempdir().unwrap();
4318 let path = dir.path();
4319 let git = |args: &[&str]| {
4320 let out = process::Command::new("git")
4321 .args([
4322 "-c",
4323 "user.name=t",
4324 "-c",
4325 "user.email=t@t",
4326 "-c",
4327 "commit.gpgsign=false",
4328 ])
4329 .args(args)
4330 .env_remove("GIT_AUTHOR_NAME")
4331 .env_remove("GIT_AUTHOR_EMAIL")
4332 .env_remove("GIT_COMMITTER_NAME")
4333 .env_remove("GIT_COMMITTER_EMAIL")
4334 .current_dir(path)
4335 .output()
4336 .unwrap();
4337 assert!(
4338 out.status.success(),
4339 "git {args:?}: {}",
4340 String::from_utf8_lossy(&out.stderr)
4341 );
4342 String::from_utf8_lossy(&out.stdout).trim().to_string()
4343 };
4344 let commit = |file: &str, msg: &str| {
4345 fs::write(path.join(file), msg).unwrap();
4346 git(&["add", file]);
4347 git(&["commit", "-q", "-m", msg]);
4348 git(&["rev-parse", "HEAD"])
4349 };
4350 git(&["init", "-q", "-b", "main"]);
4351 commit("a", "base");
4352 git(&["checkout", "-q", "-b", "pr"]);
4353 let approved = commit("b", "approved change");
4354 git(&["checkout", "-q", "main"]);
4355 let base_tip = commit("c", "base moved");
4356 git(&["checkout", "-q", "pr"]);
4357 git(&["merge", "-q", "--no-edit", "main"]);
4358 let clean = git(&["rev-parse", "HEAD"]);
4359 assert!(is_clean_merge(path, &approved, &base_tip, &clean).await);
4360
4361 fs::write(path.join("d"), "unreviewed").unwrap();
4362 git(&["add", "d"]);
4363 git(&["commit", "-q", "--amend", "--no-edit"]);
4364 let forged = git(&["rev-parse", "HEAD"]);
4365 assert!(!is_clean_merge(path, &approved, &base_tip, &forged).await);
4366 }
4367
4368 #[test]
4369 fn a_review_agent_cannot_change_the_repository_remotes() {
4370 let tools = build_review_disallowed_tools(Path::new("/tmp/wt"));
4371 for guard in runner::git_ops::remote_change_tools() {
4372 assert!(tools.contains(&guard), "missing {guard}");
4373 }
4374 }
4375
4376 #[test]
4377 fn only_a_merge_of_the_approved_commit_carries_the_approval() {
4378 let parents = |p: &[&str]| p.iter().map(ToString::to_string).collect::<Vec<_>>();
4379 assert_eq!(
4380 base_merge_parent("approved", &parents(&["approved", "base"])),
4381 Some("base")
4382 );
4383 assert_eq!(base_merge_parent("approved", &parents(&["approved"])), None);
4385 assert_eq!(
4386 base_merge_parent("approved", &parents(&["other", "base"])),
4387 None
4388 );
4389 assert_eq!(
4390 base_merge_parent("approved", &parents(&["approved", "a", "b"])),
4391 None
4392 );
4393 }
4394
4395 #[tokio::test]
4400 async fn approved_sha_round_trips_through_the_kv_store() {
4401 let temp = TempDir::new().unwrap();
4402 let wt = temp.path();
4403 git(wt, &["init", "-b", "main"]);
4404 git(wt, &["config", "user.name", "Test"]);
4405 git(wt, &["config", "user.email", "test@test.com"]);
4406 fs::write(wt.join("fix.txt"), "the fix").unwrap();
4407 git(wt, &["add", "."]);
4408 git(wt, &["commit", "-m", "apply the fix"]);
4409
4410 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4411 let repo = "owner/repo";
4412 let task_id = "3783";
4413
4414 record_approved_sha(&store, repo, task_id, 77, wt, "gh-issue-3783", "main").await;
4415 let recorded = read_approval(&store, repo, task_id, 77)
4416 .await
4417 .unwrap()
4418 .expect("approval must be recorded");
4419 assert_eq!(recorded.repo, repo);
4420 assert_eq!(recorded.branch, "gh-issue-3783");
4421 assert_eq!(recorded.base, "main");
4422 assert_eq!(
4423 recorded.sha,
4424 worktree_head_sha(wt).await.unwrap(),
4425 "the recorded sha must be the commit that was pushed to the PR"
4426 );
4427 assert!(
4428 store
4429 .kv_get(&approved_sha_key(repo, task_id, 78))
4430 .await
4431 .unwrap()
4432 .is_none(),
4433 "an approval must not leak to another PR"
4434 );
4435
4436 clear_approved_sha(&store, repo, task_id, 77).await;
4437 assert!(
4438 store
4439 .kv_get(&approved_sha_key(repo, task_id, 77))
4440 .await
4441 .unwrap()
4442 .is_none(),
4443 "requesting changes must drop the approval"
4444 );
4445 }
4446
4447 #[tokio::test]
4448 async fn build_review_context_reroutes_external_task_with_no_code() {
4449 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4450 let repo = "owner/repo";
4451 let ext = no_code_review_task("3617");
4452 store.ensure_external_task(repo, &ext).await.unwrap();
4453
4454 let backend = Arc::new(NoopBackend);
4455 let task_manager = Arc::new(TaskManager::with_store(
4456 backend,
4457 Arc::clone(&store),
4458 repo.to_string(),
4459 ));
4460 let router = Arc::new(RwLock::new(Router::new(RouterConfig::default())));
4461
4462 let phase = build_review_context(&ext, repo, &router, &task_manager, &store)
4463 .await
4464 .unwrap();
4465
4466 assert!(
4467 matches!(phase, ReviewPhase::EarlyReturn(ReviewDecision::Rerouted)),
4468 "external task with no worktree/branch/pr must reroute, not skip-to-done"
4469 );
4470
4471 let stored = store
4472 .get_by_external_id(repo, &ext.id.0)
4473 .await
4474 .unwrap()
4475 .expect("task must still exist");
4476 assert_eq!(stored.status, TaskStatus::New);
4477 assert!(stored.agent.is_none());
4478 assert!(stored.model.is_none());
4479 }
4480
4481 #[tokio::test]
4482 async fn build_review_context_blocks_external_task_after_max_reroutes() {
4483 let temp_home = TempDir::new().unwrap();
4486 let orch_dir = temp_home.path().join(".orch");
4487 fs::create_dir_all(&orch_dir).unwrap();
4488 let old_orch_home = env::var_os("ORCH_HOME");
4489 unsafe { env::set_var("ORCH_HOME", &orch_dir) };
4491
4492 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4493 let repo = "owner/repo";
4494 let ext = no_code_review_task("3618");
4495 store.ensure_external_task(repo, &ext).await.unwrap();
4496
4497 let backend = Arc::new(NoopBackend);
4498 let task_manager = Arc::new(TaskManager::with_store(
4499 backend,
4500 Arc::clone(&store),
4501 repo.to_string(),
4502 ));
4503 let router = Arc::new(RwLock::new(Router::new(RouterConfig::default())));
4504
4505 for _ in 0..3 {
4507 let phase = build_review_context(&ext, repo, &router, &task_manager, &store)
4508 .await
4509 .unwrap();
4510 if let ReviewPhase::EarlyReturn(ReviewDecision::Blocked(_)) = phase {
4511 break;
4512 }
4513 }
4514
4515 let stored = store
4516 .get_by_external_id(repo, &ext.id.0)
4517 .await
4518 .unwrap()
4519 .expect("task must still exist");
4520 assert_eq!(stored.status, TaskStatus::Blocked);
4521
4522 if let Some(old) = old_orch_home {
4523 unsafe { env::set_var("ORCH_HOME", old) };
4525 } else {
4526 unsafe { env::remove_var("ORCH_HOME") };
4528 }
4529 }
4530
4531 #[tokio::test]
4532 async fn build_review_context_marks_internal_task_done_with_no_code() {
4533 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4534 let repo = "owner/repo";
4535 let internal_id = store
4536 .create_internal(repo, "Daily retrospective", "", "job", "job-1", None)
4537 .await
4538 .unwrap();
4539 let ext_id = format!("internal:{internal_id}");
4540 let ext = no_code_review_task(&ext_id);
4541
4542 let backend = Arc::new(NoopBackend);
4543 let task_manager = Arc::new(TaskManager::with_store(
4544 backend,
4545 Arc::clone(&store),
4546 repo.to_string(),
4547 ));
4548 let router = Arc::new(RwLock::new(Router::new(RouterConfig::default())));
4549
4550 let phase = build_review_context(&ext, repo, &router, &task_manager, &store)
4551 .await
4552 .unwrap();
4553
4554 assert!(matches!(
4555 phase,
4556 ReviewPhase::EarlyReturn(ReviewDecision::Skipped)
4557 ));
4558 let stored = store.get(internal_id).await.unwrap();
4559 assert_eq!(stored.status, TaskStatus::Done);
4560 }
4561
4562 #[test]
4563 fn no_code_reroute_skip_check_rejects_agent_stream_disconnects() {
4564 let last_error =
4565 "codex failed: Reconnecting... (stream disconnected before completion: Broken pipe)";
4566 assert!(!should_skip_no_code_reroute_increment(last_error));
4567 }
4568
4569 #[test]
4578 fn review_fallback_scan_ignores_rate_limit_word_buried_in_long_report() {
4579 let padding = "x".repeat(10_000);
4580 let text_for_review = format!(
4581 "{padding}\n+| codex | gpt-5.4 | failed | 1 |\n+| codex | gpt-5.5 | failed / aborted / (empty) | 1 each |\n+| kimi | opus | rate_limit | 1 |\n{padding}"
4582 );
4583
4584 let scan_head = runner::agents::patterns::safe_head(
4585 &text_for_review,
4586 runner::agents::patterns::RATE_LIMIT_SCAN_HEAD_BYTES,
4587 );
4588 let scan_tail = runner::agents::patterns::safe_tail(
4589 &text_for_review,
4590 runner::agents::patterns::RATE_LIMIT_SCAN_TAIL_BYTES,
4591 );
4592
4593 assert!(runner::agents::patterns::detect_rate_limit(scan_head).is_none());
4594 assert!(runner::agents::patterns::detect_rate_limit(scan_tail).is_none());
4595 }
4596
4597 #[test]
4598 fn review_fallback_scan_still_detects_real_rate_limit_at_tail() {
4599 let padding = "x".repeat(10_000);
4600 let text_for_review =
4601 format!("{padding}\nError: rate limit exceeded, please try again later");
4602
4603 let scan_tail = runner::agents::patterns::safe_tail(
4604 &text_for_review,
4605 runner::agents::patterns::RATE_LIMIT_SCAN_TAIL_BYTES,
4606 );
4607
4608 assert!(runner::agents::patterns::detect_rate_limit(scan_tail).is_some());
4609 }
4610
4611 #[tokio::test]
4612 async fn recover_missing_review_worktree_recreates_worktree() {
4613 let temp_home = TempDir::new().unwrap();
4614 let orch_dir = temp_home.path().join(".orch");
4615 fs::create_dir_all(&orch_dir).unwrap();
4616 let old_orch_home = env::var_os("ORCH_HOME");
4617 unsafe { env::set_var("ORCH_HOME", &orch_dir) };
4619
4620 let project_dir = temp_home.path().join("project");
4621 fs::create_dir_all(&project_dir).unwrap();
4622 git(&project_dir, &["init", "-b", "main"]);
4623 git(&project_dir, &["config", "user.name", "Test User"]);
4624 git(&project_dir, &["config", "user.email", "test@example.com"]);
4625 fs::write(project_dir.join("README.md"), "hello").unwrap();
4626 git(&project_dir, &["add", "README.md"]);
4627 git(&project_dir, &["commit", "-m", "init"]);
4628
4629 fs::write(
4630 orch_dir.join("config.yml"),
4631 format!("projects:\n - {}\n", project_dir.display()),
4632 )
4633 .unwrap();
4634 fs::write(project_dir.join(".orch.yml"), "gh:\n repo: owner/repo\n").unwrap();
4635
4636 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4637 let repo = "owner/repo";
4638 store
4639 .create_internal(repo, "Fix review recovery", "", "review", "review-1", None)
4640 .await
4641 .unwrap();
4642 let ext = ExternalTask {
4643 id: ExternalId("1".to_string()),
4644 title: "Fix review recovery".to_string(),
4645 body: String::new(),
4646 state: "open".to_string(),
4647 labels: vec![],
4648 author: "tester".to_string(),
4649 created_at: "2026-01-01T00:00:00Z".to_string(),
4650 updated_at: "2026-01-01T00:00:00Z".to_string(),
4651 url: "https://example.com/issues/1".to_string(),
4652 };
4653 store.ensure_external_task(repo, &ext).await.unwrap();
4654
4655 let recovered = recover_missing_review_worktree(&ext, repo, &store)
4656 .await
4657 .unwrap();
4658
4659 assert!(recovered.work_dir.exists());
4660 assert!(recovered.branch.starts_with("gh-issue-1-"));
4661
4662 if let Some(old) = old_orch_home {
4663 unsafe { env::set_var("ORCH_HOME", old) };
4665 } else {
4666 unsafe { env::remove_var("ORCH_HOME") };
4668 }
4669 }
4670
4671 #[tokio::test]
4674 async fn count_ahead_commits_returns_zero_when_no_ahead_commits() {
4675 let temp = TempDir::new().unwrap();
4676 let dir = temp.path();
4677
4678 let bare = temp.path().join("bare.git");
4680 fs::create_dir_all(&bare).unwrap();
4681 process::Command::new("git")
4682 .args(["init", "--bare"])
4683 .current_dir(&bare)
4684 .output()
4685 .unwrap();
4686
4687 git(dir, &["init", "-b", "main"]);
4688 git(dir, &["config", "user.name", "Test"]);
4689 git(dir, &["config", "user.email", "test@test.com"]);
4690 fs::write(dir.join("f"), "a").unwrap();
4691 git(dir, &["add", "f"]);
4692 git(dir, &["commit", "-m", "init"]);
4693 process::Command::new("git")
4694 .args(["remote", "add", "origin", bare.to_str().unwrap()])
4695 .current_dir(dir)
4696 .output()
4697 .unwrap();
4698 process::Command::new("git")
4699 .args(["push", "origin", "main"])
4700 .current_dir(dir)
4701 .output()
4702 .unwrap();
4703
4704 let result = count_ahead_commits(dir, "main").await;
4706 assert!(
4707 result.is_ok(),
4708 "expected Ok when origin/main exists, got {result:?}"
4709 );
4710 assert_eq!(
4711 result.unwrap(),
4712 0,
4713 "expected 0 commits ahead when branch matches origin/main"
4714 );
4715 }
4716
4717 #[tokio::test]
4718 async fn count_ahead_commits_returns_error_when_remote_ref_missing() {
4719 let temp = TempDir::new().unwrap();
4720 let dir = temp.path();
4721 git(dir, &["init", "-b", "main"]);
4722 git(dir, &["config", "user.name", "Test"]);
4723 git(dir, &["config", "user.email", "test@test.com"]);
4724 fs::write(dir.join("f"), "a").unwrap();
4725 git(dir, &["add", "f"]);
4726 git(dir, &["commit", "-m", "init"]);
4727 let result = count_ahead_commits(dir, "main").await;
4729 assert!(
4730 result.is_err(),
4731 "expected Err when origin/main does not exist, got {result:?}"
4732 );
4733 let err = result.unwrap_err();
4734 assert!(
4735 err.contains("git rev-list exited"),
4736 "error should mention exit failure, got: {err}"
4737 );
4738 }
4739
4740 #[tokio::test]
4741 async fn count_ahead_commits_returns_error_when_git_cannot_start() {
4742 let temp = TempDir::new().unwrap();
4744 let dir = temp.path();
4745 let result = count_ahead_commits(dir, "main").await;
4747 assert!(
4748 result.is_err(),
4749 "expected Err when directory is not a git repo, got {result:?}"
4750 );
4751 }
4752
4753 #[serial_test::serial(cooldown_state)]
4757 #[tokio::test]
4758 async fn review_invalid_response_applies_short_model_cooldown() {
4759 cooldown::reset_global_state().await;
4760 let agent = "opencode-3314-review-invalid";
4761 let model = "opencode/north-mini-code-free";
4762 let key = format!("{agent}:{model}");
4763
4764 assert!(
4765 !cooldown::is_model_in_cooldown(agent, model),
4766 "model should not be in cooldown before record_review_agent_failure"
4767 );
4768
4769 let err = runner::agents::AgentError::InvalidResponse {
4770 raw: "empty review output".to_string(),
4771 };
4772 record_review_agent_failure("test-3314-a", agent, Some(model), &err).await;
4773
4774 let now = chrono::Utc::now().timestamp();
4775 let until = cooldown::cooldown_until(&key)
4776 .expect("review InvalidResponse should set model cooldown");
4777 let remaining = until.saturating_sub(now);
4778 let base = cooldown::TRANSIENT_MODEL_BACKOFF_BASE_SECS;
4779 assert!(
4780 (base - 30..=base).contains(&remaining),
4781 "first unusable review output must cool the model for 5 minutes, got {remaining}s"
4782 );
4783 }
4784
4785 #[serial_test::serial(cooldown_state)]
4787 #[tokio::test]
4788 async fn review_model_unavailable_transient_applies_short_cooldown() {
4789 cooldown::reset_global_state().await;
4790 let agent = "test-review-unavailable-transient";
4791 let model = "test-model";
4792 let key = format!("{agent}:{model}");
4793
4794 let err = runner::agents::AgentError::ModelUnavailable {
4795 message: "model is temporarily overloaded".to_string(),
4796 model: model.to_string(),
4797 };
4798 record_review_agent_failure("test-unavailable-a", agent, Some(model), &err).await;
4799
4800 let now = chrono::Utc::now().timestamp();
4801 let until = cooldown::cooldown_until(&key)
4802 .expect("review ModelUnavailable should set model cooldown");
4803 let remaining = until.saturating_sub(now);
4804 let base = cooldown::BACKOFF_BASE_SECS;
4805 assert!(
4806 (base - 30..=base).contains(&remaining),
4807 "a non-permanent ModelUnavailable must get the 5 minute base, got {remaining}s"
4808 );
4809 }
4810
4811 #[serial_test::serial(cooldown_state)]
4813 #[tokio::test]
4814 async fn review_model_not_found_applies_persistent_cooldown() {
4815 cooldown::reset_global_state().await;
4816 let agent = "test-review-unavailable-gone";
4817 let model = "test-model";
4818 let key = format!("{agent}:{model}");
4819
4820 let err = runner::agents::AgentError::ModelUnavailable {
4821 message: "Model not found: test-model".to_string(),
4822 model: model.to_string(),
4823 };
4824 record_review_agent_failure("test-unavailable-b", agent, Some(model), &err).await;
4825
4826 let now = chrono::Utc::now().timestamp();
4827 let until = cooldown::cooldown_until(&key)
4828 .expect("review model not found should set model cooldown");
4829 let remaining = until.saturating_sub(now);
4830 let base = cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS;
4831 assert!(
4832 (base - 30..=base).contains(&remaining),
4833 "model not found must get the 4 hour persistent base, got {remaining}s"
4834 );
4835 }
4836
4837 #[serial_test::serial(cooldown_state)]
4839 #[tokio::test]
4840 async fn review_output_cooldown_caps_at_fifteen_minutes() {
4841 cooldown::reset_global_state().await;
4842 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4843 cooldown::init_cooldown_store(store).await;
4844 let agent = "opencode-review-output-cap";
4845 let model = "opencode/north-mini-code-free";
4846 let key = format!("{agent}:{model}");
4847 let base = cooldown::TRANSIENT_MODEL_BACKOFF_BASE_SECS;
4848 let max = cooldown::TRANSIENT_MODEL_BACKOFF_MAX_SECS;
4849
4850 for (i, expected) in [base, max, max].into_iter().enumerate() {
4851 let err = runner::agents::AgentError::InvalidResponse {
4852 raw: format!("failed to parse review response {i}"),
4853 };
4854 record_review_agent_failure("test-review-output-cap", agent, Some(model), &err).await;
4855 let now = chrono::Utc::now().timestamp();
4856 let remaining = cooldown::cooldown_until(&key)
4857 .expect("unusable review output should set model cooldown")
4858 .saturating_sub(now);
4859 assert!(
4860 (expected - 30..=expected).contains(&remaining),
4861 "failure {} should cool the model for {expected}s, got {remaining}s",
4862 i + 1
4863 );
4864 }
4865 }
4866
4867 #[serial_test::serial(cooldown_state)]
4873 #[tokio::test]
4874 async fn review_inline_scan_billing_cycle_message_gets_model_scoped_cooldown() {
4875 cooldown::reset_global_state().await;
4876 let agent = "kimi-3551-review-inline";
4877 let model = "opus";
4878 let key = format!("{agent}:{model}");
4879
4880 let text_for_review = "kimi rate limit: Failed to authenticate. API Error: 403 \
4882 You've reached your usage limit for this billing cycle. Your quota will be \
4883 refreshed next cycle.";
4884
4885 let scan_tail = runner::agents::patterns::safe_tail(
4886 text_for_review,
4887 runner::agents::patterns::RATE_LIMIT_SCAN_TAIL_BYTES,
4888 );
4889 let err = runner::agents::patterns::detect_rate_limit(scan_tail)
4890 .expect("billing-cycle message must still be caught by the rate-limit scan");
4891
4892 assert_eq!(
4893 outcome_for_agent_error(&err),
4894 "billing_cycle_exhausted",
4895 "inline-scanned billing-cycle message must not be stored as plain rate_limit"
4896 );
4897
4898 record_review_agent_failure("test-3551", agent, Some(model), &err).await;
4899
4900 let now = chrono::Utc::now().timestamp();
4901 let until = cooldown::cooldown_until(&key)
4902 .expect("billing-cycle exhaustion must set a model-scoped cooldown");
4903 let remaining = until.saturating_sub(now);
4904 assert!(
4909 remaining >= cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
4910 "billing-cycle exhaustion for a known model must apply the ~4h persistent \
4911 model cooldown, got {remaining}s — may have used the generic short agent \
4912 cooldown instead"
4913 );
4914 }
4915
4916 fn test_review_task(id: &str) -> ExternalTask {
4919 ExternalTask {
4920 id: ExternalId(id.to_string()),
4921 title: "Test review task".to_string(),
4922 body: String::new(),
4923 state: "open".to_string(),
4924 labels: vec![],
4925 author: "tester".to_string(),
4926 created_at: "2026-01-01T00:00:00Z".to_string(),
4927 updated_at: "2026-01-01T00:00:00Z".to_string(),
4928 url: "https://example.com/issues/1".to_string(),
4929 }
4930 }
4931
4932 fn test_review_context(agent: &str, model: &str, work_dir: &Path) -> ReviewContext {
4933 ReviewContext {
4934 store_id: None,
4935 had_prev_push_failure: false,
4936 review_attempt: 1,
4937 worktree_path: work_dir.to_path_buf(),
4938 branch_name: "gh-issue-1-test".to_string(),
4939 pr_number: 1,
4940 head_sha: None,
4941 default_branch: "main".to_string(),
4942 review_agent: agent.to_string(),
4943 review_model: Some(model.to_string()),
4944 review_task_id: "review-1".to_string(),
4945 review_attempt_dir: work_dir.to_path_buf(),
4946 output_file: work_dir.join("output.txt"),
4947 invocation: runner::agent::AgentInvocation {
4948 agent: agent.to_string(),
4949 model: Some(model.to_string()),
4950 work_dir: work_dir.to_path_buf(),
4951 system_prompt: String::new(),
4952 agent_message: String::new(),
4953 task_id: "review-1".to_string(),
4954 disallowed_tools: vec![],
4955 git_author_name: "Test".to_string(),
4956 git_author_email: "test@example.com".to_string(),
4957 output_file: work_dir.join("output.txt"),
4958 timeout_seconds: 0,
4959 repo: "owner/repo".to_string(),
4960 attempt: 1,
4961 sandbox: false,
4962 main_project_dir: work_dir.to_path_buf(),
4963 branch: String::new(),
4964 },
4965 same_agent_fallback: false,
4966 }
4967 }
4968
4969 fn test_parsed_review(decision: ReviewDecision) -> ParsedReview {
4970 ParsedReview {
4971 run_id: None,
4972 exit_code: 0,
4973 stderr: String::new(),
4974 raw_output: String::new(),
4975 text_for_review: String::new(),
4976 token_usage: RunTokenUsage::default(),
4977 review_notes_for_comment: String::new(),
4978 decision,
4979 }
4980 }
4981
4982 fn write_review_output(dir: &Path, stdout: &str, exit_code: i32) {
4983 fs::write(dir.join("output.txt"), stdout).unwrap();
4984 fs::write(dir.join("exit.txt"), exit_code.to_string()).unwrap();
4985 fs::write(dir.join("stderr.txt"), "").unwrap();
4986 }
4987
4988 #[serial_test::serial(cooldown_state)]
4992 #[tokio::test]
4993 async fn parse_review_output_mid_thought_is_incomplete_without_persistent_cooldown() {
4994 cooldown::reset_global_state().await;
4995 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4996 let work_dir = TempDir::new().unwrap();
4997 let agent = "kimi";
4998 let model = "opus-3734-mid-thought";
4999 let ctx = test_review_context(agent, model, work_dir.path());
5000 let redraw = "^D\u{0008}".repeat(20);
5001 let ndjson = format!(
5002 "{}\n{}\n{}\n{}\n{}",
5003 r#"{"type":"system","subtype":"thinking_tokens","tokens":1945}"#,
5004 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"CI passes. Now verifying callers and the lease logic edge cases:"}],"stop_reason":null,"usage":{"input_tokens":10,"output_tokens":0}}}"#,
5005 r#"{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"checking lease edge cases"}],"stop_reason":null,"usage":{"input_tokens":10,"output_tokens":0}}}"#,
5006 "\x1b[?25h",
5007 redraw,
5008 );
5009 write_review_output(work_dir.path(), &ndjson, 0);
5010
5011 let task = test_review_task("3734");
5012 let phase = parse_review_output(
5013 &task,
5014 "owner/repo",
5015 &ctx,
5016 ReviewRun { run_id: None },
5017 &store,
5018 )
5019 .await;
5020
5021 match phase {
5022 ReviewPhase::EarlyReturn(ReviewDecision::Failed(reason)) => {
5023 assert!(
5024 reason.contains("incomplete:"),
5025 "mid-thought review must be stored as incomplete, got {reason}"
5026 );
5027 }
5028 ReviewPhase::EarlyReturn(_) => panic!("expected Failed(incomplete)"),
5029 ReviewPhase::Ready(_) => panic!("expected Failed(incomplete), got Ready"),
5030 }
5031 assert!(
5032 !cooldown::is_model_in_cooldown(agent, model),
5033 "mid-thought incomplete must not apply persistent model cooldown"
5034 );
5035 }
5036
5037 #[serial_test::serial(cooldown_state)]
5040 #[tokio::test]
5041 async fn parse_review_output_exit_124_is_timeout_without_cooldown() {
5042 cooldown::reset_global_state().await;
5043 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5044 let work_dir = TempDir::new().unwrap();
5045 let agent = "claude";
5046 let model = "opus-3942-timeout";
5047 let ctx = test_review_context(agent, model, work_dir.path());
5048 let ndjson = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"`shipcheck run` is still going. I am waiting on that local CI run before I decide."}]}}"#;
5049 write_review_output(work_dir.path(), ndjson, 124);
5050
5051 let task = test_review_task("3942");
5052 let phase = parse_review_output(
5053 &task,
5054 "owner/repo",
5055 &ctx,
5056 ReviewRun { run_id: None },
5057 &store,
5058 )
5059 .await;
5060
5061 match phase {
5062 ReviewPhase::EarlyReturn(ReviewDecision::Failed(reason)) => {
5063 assert_eq!(reason, "timeout");
5064 }
5065 ReviewPhase::EarlyReturn(_) => panic!("expected Failed(timeout)"),
5066 ReviewPhase::Ready(_) => panic!("expected Failed(timeout), got Ready"),
5067 }
5068 assert!(!cooldown::is_model_in_cooldown(agent, model));
5069 }
5070
5071 #[serial_test::serial(cooldown_state)]
5074 #[tokio::test]
5075 async fn parse_review_output_tool_use_without_envelope_is_incomplete() {
5076 cooldown::reset_global_state().await;
5077 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5078 let work_dir = TempDir::new().unwrap();
5079 let agent = "kimi";
5080 let model = "opus-3734-tool-use";
5081 let ctx = test_review_context(agent, model, work_dir.path());
5082 let ndjson = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Read","input":{"file_path":"src/lib.rs"}}]}}"#;
5083 write_review_output(work_dir.path(), ndjson, 0);
5084
5085 let task = test_review_task("3734-tool");
5086 let phase = parse_review_output(
5087 &task,
5088 "owner/repo",
5089 &ctx,
5090 ReviewRun { run_id: None },
5091 &store,
5092 )
5093 .await;
5094
5095 match phase {
5096 ReviewPhase::EarlyReturn(ReviewDecision::Failed(reason)) => {
5097 assert!(
5098 reason.contains("incomplete:"),
5099 "tool_use without envelope must be incomplete, got {reason}"
5100 );
5101 }
5102 ReviewPhase::EarlyReturn(_) => panic!("expected Failed(incomplete)"),
5103 ReviewPhase::Ready(_) => panic!("expected Failed(incomplete), got Ready"),
5104 }
5105 assert!(!cooldown::is_model_in_cooldown(agent, model));
5106 }
5107
5108 #[serial_test::serial(cooldown_state)]
5111 #[tokio::test]
5112 async fn parse_review_output_unparseable_envelope_applies_short_cooldown() {
5113 cooldown::reset_global_state().await;
5114 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5115 let work_dir = TempDir::new().unwrap();
5116 let agent = "kimi";
5117 let model = "opus-3734-format";
5118 let ctx = test_review_context(agent, model, work_dir.path());
5119 let ndjson = concat!(
5120 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"I have finished looking at the code."}]}}"#,
5121 "\n",
5122 r#"{"type":"result","subtype":"success","is_error":false,"result":"I have finished looking at the code."}"#,
5123 );
5124 write_review_output(work_dir.path(), ndjson, 0);
5125
5126 let task = test_review_task("3734-format");
5127 let phase = parse_review_output(
5128 &task,
5129 "owner/repo",
5130 &ctx,
5131 ReviewRun { run_id: None },
5132 &store,
5133 )
5134 .await;
5135
5136 match phase {
5137 ReviewPhase::EarlyReturn(ReviewDecision::Failed(reason)) => {
5138 assert!(
5139 reason.contains("parse error"),
5140 "finished unparseable envelope must stay a parse error, got {reason}"
5141 );
5142 }
5143 ReviewPhase::EarlyReturn(_) => panic!("expected Failed(parse error)"),
5144 ReviewPhase::Ready(_) => panic!("expected Failed(parse error), got Ready"),
5145 }
5146 let now = chrono::Utc::now().timestamp();
5147 let remaining = cooldown::cooldown_until(&format!("{agent}:{model}"))
5148 .expect("finished format failure must apply a model cooldown")
5149 .saturating_sub(now);
5150 assert!(
5151 remaining <= cooldown::TRANSIENT_MODEL_BACKOFF_BASE_SECS,
5152 "finished format failure must apply the 5 minute cooldown, got {remaining}s"
5153 );
5154 }
5155
5156 #[serial_test::serial(cooldown_state)]
5159 #[tokio::test]
5160 async fn parse_review_output_missing_envelope_uses_assistant_decision() {
5161 cooldown::reset_global_state().await;
5162 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5163 let work_dir = TempDir::new().unwrap();
5164 let agent = "kimi";
5165 let model = "opus-3734-assistant";
5166 let ctx = test_review_context(agent, model, work_dir.path());
5167 let ndjson = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"issues\":[]}"}]}}"#;
5168 write_review_output(work_dir.path(), ndjson, 0);
5169
5170 let task = test_review_task("3734-asst");
5171 let phase = parse_review_output(
5172 &task,
5173 "owner/repo",
5174 &ctx,
5175 ReviewRun { run_id: None },
5176 &store,
5177 )
5178 .await;
5179
5180 match phase {
5181 ReviewPhase::Ready(parsed) => {
5182 assert!(matches!(parsed.decision, ReviewDecision::Approve { .. }));
5183 }
5184 ReviewPhase::EarlyReturn(_) => {
5185 panic!("expected Ready(Approve) from assistant text, got EarlyReturn")
5186 }
5187 }
5188 assert!(!cooldown::is_model_in_cooldown(agent, model));
5189 }
5190
5191 #[serial_test::serial(cooldown_state)]
5194 #[tokio::test]
5195 async fn parse_review_output_does_not_treat_task_done_synthesis_as_approval() {
5196 cooldown::reset_global_state().await;
5197 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5198 let work_dir = TempDir::new().unwrap();
5199 let agent = "kimi";
5200 let model = "opus-3734-done";
5201 let ctx = test_review_context(agent, model, work_dir.path());
5202 let ndjson = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"analyzing the diff"}]}}"#;
5203 write_review_output(work_dir.path(), ndjson, 0);
5204
5205 let task = test_review_task("3734-done");
5206 let phase = parse_review_output(
5207 &task,
5208 "owner/repo",
5209 &ctx,
5210 ReviewRun { run_id: None },
5211 &store,
5212 )
5213 .await;
5214
5215 match phase {
5216 ReviewPhase::EarlyReturn(ReviewDecision::Failed(reason)) => {
5217 assert!(
5218 reason.contains("parse error"),
5219 "unfinished prose without a decision must not approve, got {reason}"
5220 );
5221 }
5222 ReviewPhase::Ready(_) => {
5223 panic!("task-path status=done synthesis must not become a review approval")
5224 }
5225 ReviewPhase::EarlyReturn(_) => panic!("expected Failed(parse error)"),
5226 }
5227 }
5228
5229 #[serial_test::serial(cooldown_state)]
5235 #[tokio::test]
5236 async fn finalize_review_run_success_resets_generic_model_failure_count() {
5237 cooldown::reset_global_state().await;
5238 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5239 cooldown::init_cooldown_store(Arc::clone(&store)).await;
5240
5241 let agent = "test-3478-agent";
5242 let model = "test-3478-model";
5243 let failure_key = format!("failure_count:{agent}:{model}");
5244
5245 let err = runner::agents::AgentError::AgentFailed {
5249 message: "agent reported a generic failure".to_string(),
5250 };
5251 record_review_agent_failure("task-3478-a", agent, Some(model), &err).await;
5252 time::sleep(Duration::from_millis(50)).await;
5253 assert_eq!(
5254 store.kv_get(&failure_key).await.unwrap().as_deref(),
5255 Some("1"),
5256 "sanity check: failure should have incremented the persisted count"
5257 );
5258
5259 let work_dir = TempDir::new().unwrap();
5260 let ctx = test_review_context(agent, model, work_dir.path());
5261 let parsed = test_parsed_review(ReviewDecision::Approve { issues: vec![] });
5262 let task = test_review_task("1");
5263
5264 finalize_review_run(&task, &ctx, &parsed, &store).await;
5265 time::sleep(Duration::from_millis(50)).await;
5266
5267 assert_eq!(
5268 store.kv_get(&failure_key).await.unwrap().as_deref(),
5269 Some("0"),
5270 "successful review must reset failure_count:{{agent}}:{{model}} (issue #3478)"
5271 );
5272 }
5273
5274 #[serial_test::serial(cooldown_state)]
5280 #[tokio::test]
5281 async fn finalize_review_run_interleaved_success_keeps_generic_cooldown_at_base() {
5282 cooldown::reset_global_state().await;
5283 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5284 cooldown::init_cooldown_store(Arc::clone(&store)).await;
5285
5286 let agent = "test-3478-interleave-agent";
5287 let model = "test-3478-interleave-model";
5288 let cooldown_key = format!("{agent}:{model}");
5289
5290 let work_dir = TempDir::new().unwrap();
5291 let ctx = test_review_context(agent, model, work_dir.path());
5292 let task = test_review_task("2");
5293
5294 for i in 0..4 {
5295 let err = runner::agents::AgentError::AgentFailed {
5296 message: format!("failure {i}"),
5297 };
5298 record_review_agent_failure("task-3478-b", agent, Some(model), &err).await;
5299
5300 let now = chrono::Utc::now().timestamp();
5301 let until =
5302 cooldown::cooldown_until(&cooldown_key).expect("failure should set cooldown");
5303 let remaining = until.saturating_sub(now);
5304
5305 assert!(
5306 remaining <= cooldown::BACKOFF_BASE_SECS + 60,
5307 "failure #{} must stay at base backoff since the prior success reset the \
5308 counter (issue #3478); got {remaining}s, base is {}s — an escalating value \
5309 here means review successes are not resetting failure_count",
5310 i + 1,
5311 cooldown::BACKOFF_BASE_SECS
5312 );
5313
5314 let parsed = test_parsed_review(ReviewDecision::Approve { issues: vec![] });
5318 finalize_review_run(&task, &ctx, &parsed, &store).await;
5319 time::sleep(Duration::from_millis(20)).await;
5320 }
5321 }
5322
5323 #[serial_test::serial(cooldown_state)]
5329 #[tokio::test]
5330 async fn finalize_review_run_success_does_not_reset_persistent_model_failure_count() {
5331 cooldown::reset_global_state().await;
5332 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5333 cooldown::init_cooldown_store(Arc::clone(&store)).await;
5334
5335 let agent = "test-3571-agent";
5336 let model = "test-3571-model";
5337 let cooldown_key = format!("{agent}:{model}");
5338
5339 let work_dir = TempDir::new().unwrap();
5340 let ctx = test_review_context(agent, model, work_dir.path());
5341 let task = test_review_task("3");
5342
5343 let err = runner::agents::AgentError::ModelUnavailable {
5345 message: "model not found".to_string(),
5346 model: model.to_string(),
5347 };
5348 record_review_agent_failure("task-3571-a", agent, Some(model), &err).await;
5349 time::sleep(Duration::from_millis(20)).await;
5350
5351 let parsed = test_parsed_review(ReviewDecision::Approve { issues: vec![] });
5354 finalize_review_run(&task, &ctx, &parsed, &store).await;
5355 time::sleep(Duration::from_millis(20)).await;
5356
5357 let err2 = runner::agents::AgentError::ModelUnavailable {
5360 message: "model not found again".to_string(),
5361 model: model.to_string(),
5362 };
5363 record_review_agent_failure("task-3571-b", agent, Some(model), &err2).await;
5364 time::sleep(Duration::from_millis(20)).await;
5365
5366 let now = chrono::Utc::now().timestamp();
5367 let until =
5368 cooldown::cooldown_until(&cooldown_key).expect("second failure should set cooldown");
5369 let remaining = until.saturating_sub(now);
5370
5371 assert!(
5372 remaining > cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS,
5373 "second persistent model failure should escalate past the 4h base despite an \
5374 intervening success (issue #3571); got {remaining}s, base is {}s",
5375 cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS
5376 );
5377 }
5378
5379 fn app_for_test(server: &wiremock::MockServer) -> Arc<AppAuth> {
5380 Arc::new(AppAuth::new(
5381 "12345".to_string(),
5382 app::test_pem().to_string(),
5383 server.uri(),
5384 ))
5385 }
5386
5387 async fn mount_installed(server: &wiremock::MockServer, repo: &str) {
5388 Mock::given(method("GET"))
5389 .and(path(format!("/repos/{repo}/installation")))
5390 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 42})))
5391 .mount(server)
5392 .await;
5393 Mock::given(method("POST"))
5394 .and(path("/app/installations/42/access_tokens"))
5395 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
5396 "token": "inst-1",
5397 "expires_at": "2099-01-01T00:00:00Z",
5398 })))
5399 .mount(server)
5400 .await;
5401 }
5402
5403 #[tokio::test]
5404 async fn app_mode_falls_back_to_comments_where_the_app_is_not_installed() {
5405 let server = MockServer::start().await;
5406 Mock::given(method("GET"))
5407 .and(path("/repos/org/b/installation"))
5408 .respond_with(ResponseTemplate::new(404))
5409 .expect(1)
5410 .mount(&server)
5411 .await;
5412 let app = app_for_test(&server);
5413
5414 assert!(
5415 review_app_for(ReviewMode::App, Some(Arc::clone(&app)), "org/b")
5416 .await
5417 .is_none()
5418 );
5419 assert!(
5421 review_app_for(ReviewMode::Comment, Some(Arc::clone(&app)), "org/b")
5422 .await
5423 .is_none()
5424 );
5425 assert!(
5426 review_app_for(ReviewMode::App, None, "org/b")
5427 .await
5428 .is_none()
5429 );
5430 }
5431
5432 #[tokio::test]
5433 async fn the_check_run_starts_and_completes_on_the_reviewed_sha() {
5434 let server = MockServer::start().await;
5435 mount_installed(&server, "o/r").await;
5436 Mock::given(method("POST"))
5437 .and(path("/repos/o/r/check-runs"))
5438 .and(body_partial_json(serde_json::json!({
5439 "name": "orch/review", "head_sha": "abc123", "status": "in_progress",
5440 })))
5441 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 1})))
5442 .expect(1)
5443 .mount(&server)
5444 .await;
5445 Mock::given(method("POST"))
5446 .and(path("/repos/o/r/check-runs"))
5447 .and(body_partial_json(serde_json::json!({
5448 "name": "orch/review", "head_sha": "abc123", "status": "completed",
5449 "conclusion": "failure", "output": {"summary": "fix it"},
5450 })))
5451 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 2})))
5452 .expect(1)
5453 .mount(&server)
5454 .await;
5455 let app = app_for_test(&server);
5456 let gh = GhHttp::for_test(server.uri(), Some(Arc::clone(&app)));
5457 let decision = ReviewDecision::RequestChanges {
5458 notes: "fix it".to_string(),
5459 issues: vec![],
5460 };
5461
5462 post_review_started(Some(&app), &gh, "o/r", Some("abc123")).await;
5463 post_review_decision(Some(&app), &gh, "o/r", Some("abc123"), &decision, "fix it").await;
5464 let requests = server.received_requests().await.expect("requests");
5465 assert!(
5466 requests
5467 .iter()
5468 .all(|req| !req.url.path().contains("/statuses/"))
5469 );
5470 }
5471
5472 #[tokio::test]
5473 async fn issues_on_diff_lines_go_inline_and_the_rest_stay_in_the_body() {
5474 let server = MockServer::start().await;
5475 mount_installed(&server, "o/r").await;
5476 Mock::given(method("GET"))
5477 .and(path("/repos/o/r/pulls/9/files"))
5478 .and(query_param("page", "1"))
5479 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
5480 {"filename": "src/a.rs", "patch": "@@ -1,2 +10,3 @@\n ctx\n+new\n+new"},
5481 ])))
5482 .expect(1)
5483 .mount(&server)
5484 .await;
5485 Mock::given(method("POST"))
5486 .and(path("/repos/o/r/pulls/9/reviews"))
5487 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 1})))
5488 .expect(1)
5489 .mount(&server)
5490 .await;
5491 Mock::given(method("POST"))
5492 .and(path("/repos/o/r/issues/9/comments"))
5493 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 2})))
5494 .mount(&server)
5495 .await;
5496 let issue = |file: &str, line: Option<u32>| ReviewIssue {
5497 file: file.to_string(),
5498 line,
5499 severity: "error".to_string(),
5500 description: "wrong".to_string(),
5501 };
5502 let decision = ReviewDecision::RequestChanges {
5503 notes: "fix".to_string(),
5504 issues: vec![
5505 issue("src/a.rs", Some(11)),
5506 issue("src/a.rs", Some(50)),
5507 issue("src/b.rs", Some(3)),
5508 issue("src/a.rs", None),
5509 ],
5510 };
5511 let app = app_for_test(&server);
5512 let gh = GhHttp::for_test(server.uri(), Some(Arc::clone(&app)));
5513
5514 publish_review(Some(&app), &gh, "o/r", 9, Some("abc123"), &decision, "body")
5515 .await
5516 .expect("comment posted");
5517 let requests = server.received_requests().await.expect("requests");
5518 let review = requests
5519 .iter()
5520 .find(|req| req.url.path().ends_with("/reviews"))
5521 .expect("review submitted");
5522 let body: serde_json::Value = serde_json::from_slice(&review.body).unwrap();
5523 assert_eq!(
5524 body["comments"],
5525 serde_json::json!([{"path": "src/a.rs", "line": 11, "side": "RIGHT", "body": "**error:** wrong"}])
5526 );
5527 }
5528
5529 #[expect(
5530 clippy::await_holding_lock,
5531 reason = "the env lock must stay held across the awaited review call"
5532 )]
5533 #[tokio::test]
5534 async fn without_an_app_the_review_path_posts_only_the_comment() {
5535 let _env = token::ENV_LOCK.lock().unwrap();
5536 let prev = env::var_os("GH_TOKEN");
5537 unsafe { env::set_var("GH_TOKEN", "owner-token") };
5539 let server = MockServer::start().await;
5540 Mock::given(method("POST"))
5541 .and(path("/repos/o/r/issues/9/comments"))
5542 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 1})))
5543 .expect(1)
5544 .mount(&server)
5545 .await;
5546 let gh = GhHttp::for_test(server.uri(), None);
5547 let result = publish_review(
5548 None,
5549 &gh,
5550 "o/r",
5551 9,
5552 Some("abc123"),
5553 &ReviewDecision::Approve { issues: vec![] },
5554 "## Automated Review \u{2014} Approve\n\nok",
5555 )
5556 .await;
5557 match prev {
5558 Some(value) => unsafe { env::set_var("GH_TOKEN", value) },
5560 None => unsafe { env::remove_var("GH_TOKEN") },
5562 }
5563 result.expect("comment posted");
5564 let requests = server.received_requests().await.expect("requests");
5565 assert!(
5566 requests
5567 .iter()
5568 .all(|req| !req.url.path().contains("/reviews"))
5569 );
5570 }
5571
5572 #[tokio::test]
5573 async fn with_an_app_the_review_is_submitted_on_the_reviewed_sha() {
5574 let server = MockServer::start().await;
5575 Mock::given(method("POST"))
5576 .and(path("/app/installations/42/access_tokens"))
5577 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
5578 "token": "inst-1",
5579 "expires_at": "2099-01-01T00:00:00Z",
5580 })))
5581 .mount(&server)
5582 .await;
5583 Mock::given(method("POST"))
5584 .and(path("/repos/o/r/pulls/9/reviews"))
5585 .and(body_partial_json(serde_json::json!({
5586 "event": "REQUEST_CHANGES",
5587 "commit_id": "abc123",
5588 })))
5589 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 1})))
5590 .expect(1)
5591 .mount(&server)
5592 .await;
5593 Mock::given(method("POST"))
5594 .and(path("/repos/o/r/issues/9/comments"))
5595 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 2})))
5596 .expect(0)
5597 .mount(&server)
5598 .await;
5599 Mock::given(method("GET"))
5600 .and(path("/repos/o/r/installation"))
5601 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 42})))
5602 .mount(&server)
5603 .await;
5604 let app = Arc::new(AppAuth::new(
5605 "12345".to_string(),
5606 app::test_pem().to_string(),
5607 server.uri(),
5608 ));
5609 let gh = GhHttp::for_test(server.uri(), Some(Arc::clone(&app)));
5610 let decision = ReviewDecision::RequestChanges {
5611 notes: "fix it".to_string(),
5612 issues: vec![],
5613 };
5614 publish_review(
5615 Some(&app),
5616 &gh,
5617 "o/r",
5618 9,
5619 Some("abc123"),
5620 &decision,
5621 "## Automated Review \u{2014} Changes Requested\n\nfix it",
5622 )
5623 .await
5624 .expect("comment posted");
5625 }
5626}