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