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::store_log_activity;
126use crate::store::TaskStore;
127use crate::store::{opt_store_get_task, store_increment};
128use crate::store::{store_set, store_set_result};
129use crate::store::{CompleteRun, RunTokenUsage, StartRun};
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 if let Ok(failed) = store.previous_review_agents(store_id).await {
800 for agent in failed {
801 exclude_set.insert(agent);
802 }
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.iter().map(|s| s.as_str()).collect();
814 let agent = match r.next_round_robin_agent(&tmp_exclude_refs, "review") {
815 Some(a) => a,
816 None => break,
817 };
818
819 if tried_agents.contains(&agent) {
820 break;
821 }
822 tried_agents.insert(agent.clone());
823
824 let model = r.config.model_for_complexity(&agent, "review", task_id);
825 if model.is_some() {
826 chosen_agent = Some(agent);
827 chosen_model = model;
828 break;
829 }
830
831 exclude_set.insert(agent);
832 if exclude_set.len() >= available_count {
833 break;
834 }
835 }
836
837 if let Some(agent) = chosen_agent {
838 let same = !task_agent.is_empty() && agent == task_agent;
839 Ok((agent, chosen_model, same))
840 } else {
841 let final_exclude_refs: Vec<&str> = exclude_set.iter().map(|s| s.as_str()).collect();
842 let fallback_agent = r
843 .next_round_robin_agent(&final_exclude_refs, "review")
844 .unwrap_or_else(|| "claude".to_string());
845 let fallback_model = r
846 .config
847 .model_for_complexity(&fallback_agent, "review", task_id);
848 if fallback_model.is_none() {
849 anyhow::bail!(
850 "no review agent with a configured review model is available for task {}",
851 task_id
852 );
853 }
854 let same = !task_agent.is_empty() && fallback_agent == task_agent;
855 Ok((fallback_agent, fallback_model, same))
856 }
857}
858
859#[allow(clippy::too_many_arguments)]
860async fn build_review_context(
861 task: &ExternalTask,
862 repo: &str,
863 router: &Arc<RwLock<Router>>,
864 task_manager: &Arc<TaskManager>,
865 store: &Arc<TaskStore>,
866) -> anyhow::Result<ReviewPhase<ReviewContext>> {
867 let stored_task = store
868 .get_by_external_id(repo, &task.id.0)
869 .await
870 .with_context(|| format!("store lookup failed for task {}", task.id.0))?;
871
872 let store_id: Option<i64> = stored_task.as_ref().map(|t| t.id);
873
874 if let Some(pr_number) = stored_task.as_ref().and_then(|t| t.pr_number) {
877 let pr_number = pr_number as u64;
878 if pr_head_already_approved(store, repo, &task.id.0, pr_number).await {
879 tracing::info!(
880 task_id = task.id.0,
881 pr_number,
882 "PR head already approved — skipping review, merge retry continues"
883 );
884 record_carried_approval(store, repo, &task.id.0, pr_number).await;
885 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Approve {
886 issues: Vec::new(),
887 }));
888 }
889 }
890
891 let had_prev_push_failure = stored_task
892 .as_ref()
893 .is_some_and(|t| t.last_error.contains("push failed"));
894 let review_attempt: u32 = match store_increment(
895 &Some(Arc::clone(store)),
896 repo,
897 &task.id.0,
898 "review_invocations",
899 )
900 .await
901 {
902 Ok(v) => v as u32,
903 Err(e) => {
904 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment review_invocations — using fallback attempt=1");
905 1
906 }
907 };
908
909 let no_code_review = stored_task.as_ref().is_some_and(|t| {
910 t.worktree.trim().is_empty() && t.branch.trim().is_empty() && t.pr_number.is_none()
911 });
912 if no_code_review {
913 if crate::engine::tasks::is_internal_id(&task.id.0) {
914 tracing::info!(
916 task_id = task.id.0,
917 "no worktree/branch/pr for internal needs_review task — skipping review and marking done"
918 );
919 if let Err(e) = task_manager
920 .update_task_status(&task.id, Status::Done)
921 .await
922 {
923 tracing::error!(
924 task_id = task.id.0,
925 err = %e,
926 "update_task_status(Done) failed for no-code review skip"
927 );
928 }
929 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Skipped));
930 }
931
932 let max_reroutes: u32 = config::get("workflow.max_reroute_attempts")
935 .ok()
936 .and_then(|s| s.parse().ok())
937 .or_else(|| {
938 config::get("workflow.max_attempts")
939 .ok()
940 .and_then(|s| s.parse().ok())
941 })
942 .unwrap_or(3);
943
944 let reroutes = match store_increment(
945 &Some(Arc::clone(store)),
946 repo,
947 &task.id.0,
948 "no_code_reroutes",
949 )
950 .await
951 {
952 Ok(v) => v,
953 Err(e) => {
954 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment no_code_reroutes — skipping reroute/block decision this tick");
955 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Failed(format!(
956 "transient store error: {e}"
957 ))));
958 }
959 };
960
961 if reroutes as u32 >= max_reroutes {
962 tracing::error!(
963 task_id = task.id.0,
964 reroutes,
965 max_reroutes,
966 "reached max reroute attempts for no-worktree/branch/pr result — blocking for human review"
967 );
968 let msg = format!(
969 "no worktree/branch/pr after {}/{} reroute attempts",
970 reroutes, max_reroutes
971 );
972 let fields = [
973 (
974 "block_reason",
975 serde_json::json!(format!(
976 "max reroute attempts ({}) reached — no worktree/branch/pr produced",
977 max_reroutes
978 )),
979 ),
980 ("last_error", serde_json::json!(msg)),
981 ("agent", serde_json::json!(null)),
982 ("model", serde_json::json!(null)),
983 ];
984 if let Err(e) = task_manager
985 .update_task_status_and_result(&task.id, Status::Blocked, &fields)
986 .await
987 {
988 tracing::error!(task_id = task.id.0, err = %e, "update_task_status_and_result(Blocked) failed — skipping block to avoid silent auto-unblock loop");
989 }
990 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Blocked(msg)));
991 }
992
993 tracing::warn!(
994 task_id = task.id.0,
995 reroutes,
996 max_reroutes,
997 "no worktree/branch/pr for external needs_review task — re-routing to a different agent"
998 );
999 if let Err(e) = store_set_result(
1000 &Some(Arc::clone(store)),
1001 repo,
1002 &task.id.0,
1003 &[
1004 ("agent", serde_json::json!(null)),
1005 ("model", serde_json::json!(null)),
1006 (
1007 "last_error",
1008 serde_json::json!("no worktree/branch/pr produced"),
1009 ),
1010 ],
1011 )
1012 .await
1013 {
1014 tracing::error!(task_id = task.id.0, err = %e, "failed to clear agent/model for reroute — skipping status transition");
1015 } else if let Err(e) = task_manager.update_task_status(&task.id, Status::New).await {
1016 tracing::error!(task_id = task.id.0, err = %e, "update_task_status(New) failed — task may be stuck in InReview");
1017 }
1018 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Rerouted));
1019 }
1020
1021 let mut worktree_path = match stored_task.as_ref().map(|t| t.worktree.as_str()) {
1022 Some(w) if !w.is_empty() => std::path::PathBuf::from(w),
1023 _ => {
1024 tracing::warn!(task_id = task.id.0, "no worktree found for review");
1025 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Failed(
1026 "no worktree found".to_string(),
1027 )));
1028 }
1029 };
1030
1031 let mut branch_name = match stored_task.as_ref().map(|t| t.branch.as_str()) {
1032 Some(b) if !b.is_empty() => b.to_string(),
1033 _ => {
1034 tracing::warn!(task_id = task.id.0, "no branch found for review");
1035 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Failed(
1036 "no branch found".to_string(),
1037 )));
1038 }
1039 };
1040
1041 let mut missing_worktree_recovered = false;
1042 if !worktree_path.exists() {
1043 match recover_missing_review_worktree(task, repo, store).await {
1044 Ok(recovered) => {
1045 branch_name = recovered.branch;
1046 worktree_path = recovered.work_dir;
1047 missing_worktree_recovered = true;
1048 }
1049 Err(e) => {
1050 let reason = format!("missing review worktree recovery failed: {e}");
1051 tracing::error!(task_id = task.id.0, error = %reason, "cannot recover review worktree");
1052 store_set(
1053 &Some(Arc::clone(store)),
1054 repo,
1055 &task.id.0,
1056 &[("last_error", serde_json::json!(reason.clone()))],
1057 )
1058 .await;
1059 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Blocked(reason)));
1060 }
1061 }
1062 }
1063
1064 if missing_worktree_recovered {
1065 store_set(
1066 &Some(Arc::clone(store)),
1067 repo,
1068 &task.id.0,
1069 &[("last_error", serde_json::json!(""))],
1070 )
1071 .await;
1072 }
1073
1074 let agent_summary = stored_task
1075 .as_ref()
1076 .map(|t| t.summary.clone())
1077 .unwrap_or_default();
1078 let stored_pr_number = stored_task
1079 .as_ref()
1080 .and_then(|t| t.pr_number)
1081 .map(|n| n as u64)
1082 .filter(|&n| n > 0);
1083
1084 let pr_number = match ensure_pr_exists(
1085 task,
1086 &branch_name,
1087 &worktree_path,
1088 repo,
1089 &agent_summary,
1090 store,
1091 task_manager,
1092 stored_pr_number,
1093 )
1094 .await?
1095 {
1096 EnsurePrResult::Ready(n) => n,
1097 EnsurePrResult::EarlyReturn(decision) => return Ok(ReviewPhase::EarlyReturn(decision)),
1098 };
1099
1100 {
1104 let fetch_timeout = std::time::Duration::from_secs(60);
1105 let fetch_future = async {
1106 let mut cmd = runner::git_ops::git().await;
1107 cmd.args(["fetch", "origin", "--prune"])
1108 .current_dir(&worktree_path);
1109 cmd.kill_on_drop(true);
1111 cmd.output_with_context().await
1112 };
1113
1114 match tokio::time::timeout(fetch_timeout, fetch_future).await {
1115 Ok(Ok(o)) if o.status.success() => {
1116 }
1118 Ok(Ok(o)) => {
1119 tracing::warn!(
1120 task_id = task.id.0,
1121 exit_code = ?o.status.code(),
1122 stdout = %String::from_utf8_lossy(&o.stdout),
1123 stderr = %String::from_utf8_lossy(&o.stderr),
1124 "review: git fetch failed (non-zero exit) — diff/log may use stale remote refs"
1125 );
1126 }
1127 Ok(Err(e)) => {
1128 tracing::warn!(
1129 task_id = task.id.0,
1130 error = %e,
1131 "review: git fetch failed — diff/log may use stale remote refs"
1132 );
1133 }
1134 Err(_) => {
1135 tracing::warn!(
1136 task_id = task.id.0,
1137 "review: git fetch timed out after 60s — diff/log may use stale remote refs"
1138 );
1139 }
1140 }
1141 }
1142
1143 let default_branch = runner::worktree::detect_default_branch(&worktree_path).await;
1144 let git_diff = runner::context::build_git_diff(&worktree_path, &default_branch).await;
1145 let git_log = runner::context::build_git_log(&worktree_path, &default_branch).await;
1146 let project_dir = crate::config::get_projects_with_paths()
1149 .unwrap_or_default()
1150 .into_iter()
1151 .find(|(candidate, _)| candidate == repo)
1152 .map(|(_, dir)| dir);
1153 let sandboxed = match &project_dir {
1154 Some(dir) => runner::sandbox::enabled(dir),
1155 None => runner::sandbox::enabled_globally(),
1156 };
1157 let main_project_dir = project_dir.unwrap_or_else(|| worktree_path.clone());
1158 let review_prompt = runner::agent::build_review_prompt(
1159 task,
1160 &agent_summary,
1161 &git_diff,
1162 &git_log,
1163 &default_branch,
1164 pr_number,
1165 runner::agent::Shipcheck::detect(&worktree_path)
1166 .await
1167 .for_prompt(sandboxed),
1168 );
1169
1170 let task_agent = stored_task
1171 .as_ref()
1172 .and_then(|t| t.agent.clone())
1173 .unwrap_or_default();
1174 let (review_agent, review_model, same_agent_fallback) =
1175 select_review_agent(&task.id.0, &task_agent, store_id, router, store).await?;
1176
1177 anyhow::ensure!(
1178 review_model.is_some(),
1179 "review model missing for task {} after agent selection — refusing to dispatch",
1180 task.id.0
1181 );
1182
1183 if same_agent_fallback {
1184 tracing::warn!(
1185 task_id = task.id.0,
1186 agent = %review_agent,
1187 "no other agent available for review — falling back to the agent that wrote the code"
1188 );
1189 store_log_activity(
1190 &Some(Arc::clone(store)),
1191 repo,
1192 &task.id.0,
1193 "review_agent_fallback",
1194 None,
1195 None,
1196 Some(&review_agent),
1197 review_model.as_deref(),
1198 Some(&serde_json::json!({
1199 "reason": "all available agents excluded (author + previous reviewers)",
1200 })),
1201 )
1202 .await;
1203 }
1204
1205 tracing::info!(
1206 task_id = task.id.0,
1207 agent = %review_agent,
1208 model = ?review_model,
1209 "spawning review agent"
1210 );
1211
1212 let review_task_id = format!("{}-review", task.id.0);
1213 let review_attempt_dir =
1214 crate::home::task_attempt_dir_async(repo, &review_task_id, review_attempt).await?;
1215 let output_file = review_attempt_dir.join("output.json");
1216
1217 let git_name =
1218 crate::config::get("git.name").unwrap_or_else(|_| format!("{review_agent}[bot]"));
1219 let git_email = crate::config::get("git.email")
1220 .unwrap_or_else(|_| format!("{review_agent}[bot]@users.noreply.github.com"));
1221 let review_timeout_secs = review_timeout_seconds();
1222
1223 let invocation = runner::agent::AgentInvocation {
1224 agent: review_agent.clone(),
1225 model: review_model.clone(),
1226 work_dir: worktree_path.clone(),
1227 system_prompt: runner::agent::review_system_prompt(),
1228 agent_message: review_prompt,
1229 task_id: review_task_id.clone(),
1230 disallowed_tools: build_review_disallowed_tools(&worktree_path),
1231 git_author_name: git_name,
1232 git_author_email: git_email,
1233 output_file: output_file.clone(),
1234 timeout_seconds: review_timeout_secs,
1235 repo: repo.to_string(),
1236 attempt: review_attempt,
1237 sandbox: sandboxed,
1238 main_project_dir,
1239 branch: branch_name.clone(),
1240 };
1241
1242 let head_sha = match GhHttp::new() {
1243 Ok(gh) => gh.get_pr(repo, pr_number).await.ok().map(|pr| pr.head.sha),
1244 Err(_) => None,
1245 };
1246
1247 Ok(ReviewPhase::Ready(ReviewContext {
1248 store_id,
1249 had_prev_push_failure,
1250 review_attempt,
1251 worktree_path,
1252 branch_name,
1253 pr_number,
1254 head_sha,
1255 default_branch,
1256 review_agent,
1257 review_model,
1258 review_task_id,
1259 review_attempt_dir,
1260 output_file,
1261 invocation,
1262 same_agent_fallback,
1263 }))
1264}
1265
1266#[allow(clippy::too_many_arguments)]
1267async fn complete_review_run(
1268 store: &Arc<TaskStore>,
1269 run_id: Option<i64>,
1270 exit_code: Option<i32>,
1271 stdout: &str,
1272 stderr: &str,
1273 parsed: &str,
1274 outcome: &str,
1275 error: &str,
1276 tokens: RunTokenUsage,
1277) {
1278 if let Some(run_id) = run_id {
1279 if let Err(e) = store
1280 .complete_run(&CompleteRun {
1281 run_id,
1282 exit_code,
1283 stdout,
1284 stderr,
1285 parsed,
1286 outcome,
1287 error,
1288 tokens,
1289 })
1290 .await
1291 {
1292 tracing::warn!(
1293 run_id,
1294 error = %e,
1295 "failed to record review run completion in audit trail"
1296 );
1297 }
1298 }
1299}
1300
1301async fn invoke_review_agent(
1302 task: &ExternalTask,
1303 tmux: &Arc<TmuxManager>,
1304 ctx: &ReviewContext,
1305 repo: &str,
1306 store: &Arc<TaskStore>,
1307) -> ReviewPhase<ReviewRun> {
1308 let run_id = if let Some(sid) = ctx.store_id {
1309 match store
1310 .start_run(&StartRun {
1311 task_id: sid,
1312 attempt: ctx.review_attempt as i32,
1313 run_type: "review",
1314 agent: &ctx.review_agent,
1315 model: ctx.review_model.as_deref().unwrap_or(""),
1316 command: "",
1317 prompt: "",
1318 })
1319 .await
1320 {
1321 Ok(run_id) => Some(run_id),
1322 Err(e) => {
1323 tracing::warn!(
1324 task_id = task.id.0,
1325 error = %e,
1326 "failed to record review run start in audit trail"
1327 );
1328 None
1329 }
1330 }
1331 } else {
1332 None
1333 };
1334
1335 let session = match runner::agent::spawn_in_tmux(tmux, &ctx.invocation).await {
1336 Ok(session) => session,
1337 Err(e) => {
1338 tracing::error!(task_id = task.id.0, error = %e, "failed to spawn review agent");
1339 complete_review_run(
1340 store,
1341 run_id,
1342 None,
1343 "",
1344 &e.to_string(),
1345 "",
1346 "failed",
1347 &format!("spawn failed: {e}"),
1348 RunTokenUsage::default(),
1349 )
1350 .await;
1351 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(format!("spawn failed: {e}")));
1352 }
1353 };
1354
1355 let start_comment = review_started_comment(&ctx.review_agent, ctx.review_model_str());
1356 match GhHttp::new() {
1357 Ok(gh) => {
1358 let app = review_app(repo).await;
1359 post_review_started(app.as_deref(), &gh, repo, ctx.head_sha.as_deref()).await;
1360 if app.is_none() {
1362 if let Err(e) = gh
1363 .add_comment(repo, &ctx.pr_number.to_string(), &start_comment)
1364 .await
1365 {
1366 tracing::debug!(
1367 task_id = task.id.0,
1368 pr_number = ctx.pr_number,
1369 error = %e,
1370 "failed to post review-started comment"
1371 );
1372 }
1373 }
1374 }
1375 Err(e) => {
1376 tracing::debug!(
1377 task_id = task.id.0,
1378 pr_number = ctx.pr_number,
1379 error = %e,
1380 "failed to create GitHub client for review-started comment"
1381 );
1382 }
1383 }
1384
1385 let poll_interval = std::time::Duration::from_secs(5);
1386 let timeout_duration = std::time::Duration::from_secs(ctx.invocation.timeout_seconds);
1387 let wait_result = tokio::time::timeout(timeout_duration, async {
1388 tokio::select! {
1389 result = tmux.wait_for_completion(&session, poll_interval) => result,
1390 () = wait_for_review_decision(ctx, poll_interval) => {
1392 tracing::info!(task_id = task.id.0, "review decision is in while the session is still open, ending it");
1393 Ok(String::new())
1394 }
1395 }
1396 })
1397 .await;
1398
1399 match wait_result {
1400 Ok(Ok(_)) => {
1401 tracing::info!(task_id = task.id.0, "review agent completed");
1402 let _ = tmux.kill_session(&session).await;
1403 ReviewPhase::Ready(ReviewRun { run_id })
1404 }
1405 Ok(Err(e)) => {
1406 tracing::warn!(task_id = task.id.0, error = %e, "review agent process exited with error");
1407 let _ = tmux.kill_session(&session).await;
1408
1409 let exit_code: i32 = tokio::task::spawn_blocking({
1414 let review_attempt_dir = ctx.review_attempt_dir.clone();
1415 move || {
1416 std::fs::read_to_string(review_attempt_dir.join("exit.txt"))
1417 .ok()
1418 .and_then(|s| s.trim().parse().ok())
1419 .unwrap_or(-1)
1420 }
1421 })
1422 .await
1423 .unwrap_or(-1);
1424
1425 if exit_code != 0 {
1426 let raw_output =
1431 runner::response::read_output_file(&ctx.review_task_id, &ctx.output_file, repo)
1432 .await;
1433 let agent_result =
1434 runner::agents::find_agent_result(&ctx.review_agent, &raw_output);
1435 if agent_result.as_ref().map(|r| !r.is_error).unwrap_or(false) {
1437 tracing::info!(
1438 task_id = task.id.0,
1439 agent = %ctx.review_agent,
1440 exit_code,
1441 output_found = !raw_output.is_empty(),
1442 "review agent succeeded despite non-zero exit — proceeding to parse output"
1443 );
1444 return ReviewPhase::Ready(ReviewRun { run_id });
1445 }
1446 }
1447
1448 tracing::error!(task_id = task.id.0, error = %e, "review agent failed");
1449 complete_review_run(
1450 store,
1451 run_id,
1452 Some(exit_code),
1453 "",
1454 &e.to_string(),
1455 "",
1456 "failed",
1457 &format!("agent error: {e}"),
1458 RunTokenUsage::default(),
1459 )
1460 .await;
1461 ReviewPhase::EarlyReturn(ReviewDecision::Failed(format!("agent error: {e}")))
1462 }
1463 Err(_) => {
1464 let _ = tmux.kill_session(&session).await;
1465 if review_output_has_decision(ctx).await {
1466 tracing::warn!(
1467 task_id = task.id.0,
1468 "review agent timed out after writing its decision, using it"
1469 );
1470 return ReviewPhase::Ready(ReviewRun { run_id });
1471 }
1472
1473 tracing::error!(task_id = task.id.0, "review agent timed out");
1474 complete_review_run(
1477 store,
1478 run_id,
1479 Some(-1),
1480 "",
1481 "timeout",
1482 "",
1483 "timeout",
1484 "timeout",
1485 RunTokenUsage::default(),
1486 )
1487 .await;
1488 ReviewPhase::EarlyReturn(ReviewDecision::Failed("timeout".to_string()))
1489 }
1490 }
1491}
1492
1493async fn review_output_has_decision(ctx: &ReviewContext) -> bool {
1496 let raw = tokio::fs::read_to_string(&ctx.output_file)
1497 .await
1498 .unwrap_or_default();
1499 output_has_review_decision(&ctx.review_agent, &raw)
1500}
1501
1502fn output_has_review_decision(agent: &str, raw: &str) -> bool {
1503 let raw = runner::agents::claude::strip_tty_control_lines(raw);
1504 runner::agents::find_agent_result(agent, &raw)
1505 .filter(|result| !result.is_error)
1506 .is_some_and(|result| runner::response::parse_review_response(&result.result_text).is_ok())
1507}
1508
1509async fn wait_for_review_decision(ctx: &ReviewContext, poll_interval: std::time::Duration) {
1510 loop {
1511 tokio::time::sleep(poll_interval).await;
1512 if review_output_has_decision(ctx).await {
1513 return;
1514 }
1515 }
1516}
1517
1518async fn record_review_agent_failure(
1519 task_id: &str,
1520 review_agent: &str,
1521 review_model: Option<&str>,
1522 err: &runner::agents::AgentError,
1523) {
1524 match err {
1525 runner::agents::AgentError::RateLimit { message }
1526 | runner::agents::AgentError::Auth { message } => {
1527 tracing::warn!(
1528 task_id,
1529 agent = %review_agent,
1530 "review agent hit error — adding to cooldown"
1531 );
1532 if let Some(reason) = crate::engine::cooldown::detect_credit_exhaustion(message) {
1533 if reason == crate::engine::cooldown::CreditExhaustionReason::BillingCycleExhausted
1534 {
1535 if let Some(model) = review_model {
1536 crate::engine::cooldown::record_persistent_model_failure(
1538 review_agent,
1539 model,
1540 )
1541 .await;
1542 } else {
1543 crate::engine::cooldown::record_credit_exhaustion(review_agent, reason)
1544 .await;
1545 }
1546 } else {
1547 crate::engine::cooldown::record_credit_exhaustion(review_agent, reason).await;
1548 }
1549 } else {
1550 runner::response::record_agent_failure_with_message(review_agent, &err.to_string())
1551 .await;
1552 }
1553 }
1554 runner::agents::AgentError::Interrupted { .. } => {}
1556 runner::agents::AgentError::InvalidResponse { .. } => {
1557 if let Some(model) = review_model {
1559 crate::engine::cooldown::record_transient_model_failure(review_agent, model).await;
1560 }
1561 }
1562 runner::agents::AgentError::ModelUnavailable { message, .. } => {
1563 if let Some(model) = review_model {
1565 crate::engine::cooldown::record_model_unavailable(review_agent, model, message)
1566 .await;
1567 }
1568 }
1569 _ => {
1570 if let Some(model) = review_model {
1573 crate::engine::cooldown::record_model_failure(review_agent, model).await;
1574 }
1575 }
1576 }
1577}
1578
1579fn review_decision_from_response(
1580 review_response: runner::response::ReviewResponse,
1581) -> ReviewDecision {
1582 match review_response.decision.as_str() {
1583 "approve" => approve_from_evidence(review_response),
1584 "request_changes" => ReviewDecision::RequestChanges {
1585 notes: review_response.notes,
1586 issues: review_response.issues,
1587 },
1588 _ => ReviewDecision::Failed(format!("unknown decision: {}", review_response.decision)),
1589 }
1590}
1591
1592fn approve_from_evidence(review_response: runner::response::ReviewResponse) -> ReviewDecision {
1594 let tests_failed = review_response
1595 .test_results
1596 .as_deref()
1597 .is_some_and(|s| s.trim().eq_ignore_ascii_case("fail"));
1598 let has_error = review_response
1599 .issues
1600 .iter()
1601 .any(|issue| issue.severity.trim().eq_ignore_ascii_case("error"));
1602 if tests_failed || has_error {
1603 let notes = if tests_failed {
1604 notes_for_failed_tests(review_response.notes)
1605 } else {
1606 review_response.notes
1607 };
1608 ReviewDecision::RequestChanges {
1609 notes,
1610 issues: review_response.issues,
1611 }
1612 } else {
1613 ReviewDecision::Approve {
1614 issues: review_response.issues,
1615 }
1616 }
1617}
1618
1619fn notes_for_failed_tests(notes: String) -> String {
1620 const MARK: &str = "Review reported test_results: fail";
1621 if notes.trim().is_empty() {
1622 MARK.to_string()
1623 } else if notes.contains("test_results: fail") {
1624 notes
1625 } else {
1626 format!("{notes}\n\n{MARK}")
1627 }
1628}
1629
1630fn review_decision_name(decision: &ReviewDecision) -> &'static str {
1631 match decision {
1632 ReviewDecision::Approve { .. } => "approve",
1633 ReviewDecision::RequestChanges { .. } => "request_changes",
1634 ReviewDecision::Failed(_) => "failed",
1635 ReviewDecision::Blocked(_) => "blocked",
1636 ReviewDecision::Skipped => "skipped",
1637 ReviewDecision::Rerouted => "rerouted",
1638 }
1639}
1640
1641async fn parse_review_output(
1642 task: &ExternalTask,
1643 repo: &str,
1644 ctx: &ReviewContext,
1645 run: ReviewRun,
1646 store: &Arc<TaskStore>,
1647) -> ReviewPhase<ParsedReview> {
1648 let (file_exists, file_size, exit_code, stderr) = {
1649 let output_file_clone = ctx.output_file.clone();
1650 let review_attempt_dir_clone = ctx.review_attempt_dir.clone();
1651 match tokio::task::spawn_blocking(move || {
1652 let file_exists = output_file_clone.exists();
1653 let file_size = std::fs::metadata(&output_file_clone)
1654 .map(|m| m.len())
1655 .unwrap_or(0);
1656 let exit_code = std::fs::read_to_string(review_attempt_dir_clone.join("exit.txt"))
1657 .ok()
1658 .and_then(|s| s.trim().parse::<i32>().ok())
1659 .unwrap_or(-1);
1660 let stderr = std::fs::read_to_string(review_attempt_dir_clone.join("stderr.txt"))
1661 .unwrap_or_default();
1662 (file_exists, file_size, exit_code, stderr)
1663 })
1664 .await
1665 {
1666 Ok(tuple) => tuple,
1667 Err(e) => {
1668 tracing::error!(
1669 task_id = task.id.0,
1670 error = %e,
1671 "spawn_blocking panicked reading review output metadata"
1672 );
1673 (false, 0, -1, format!("spawn_blocking failed: {e}"))
1674 }
1675 }
1676 };
1677
1678 tracing::info!(
1679 task_id = task.id.0,
1680 path = %ctx.output_file.display(),
1681 file_exists,
1682 file_size,
1683 "reading review agent output"
1684 );
1685
1686 let raw_output =
1687 runner::response::read_output_file(&ctx.review_task_id, &ctx.output_file, repo).await;
1688 let raw_output = runner::agents::claude::strip_tty_control_lines(&raw_output);
1689 let has_result_envelope =
1690 runner::agents::claude::find_ndjson_result_line(&raw_output).is_some();
1691 let agent_result = runner::agents::find_agent_result(&ctx.review_agent, &raw_output);
1692 let token_usage = RunTokenUsage {
1693 input_tokens: agent_result
1694 .as_ref()
1695 .and_then(|r| r.input_tokens)
1696 .unwrap_or(0),
1697 output_tokens: agent_result
1698 .as_ref()
1699 .and_then(|r| r.output_tokens)
1700 .unwrap_or(0),
1701 total_cost_usd: agent_result
1702 .as_ref()
1703 .and_then(|r| r.cost_usd)
1704 .unwrap_or(0.0),
1705 duration_secs: 0.0,
1706 };
1707
1708 let agent_result_is_error = agent_result.as_ref().map(|r| r.is_error).unwrap_or(false);
1709 let ndjson_completed_no_error = raw_output.contains("\"terminal_reason\":\"completed\"")
1710 && !raw_output.contains("\"is_error\":true");
1711 let is_hard_failure =
1713 !ndjson_completed_no_error && (raw_output.is_empty() || agent_result_is_error);
1714
1715 if is_hard_failure {
1716 tracing::warn!(
1717 task_id = task.id.0,
1718 exit_code,
1719 raw_output_len = raw_output.len(),
1720 stderr_len = stderr.len(),
1721 "review agent: entering error path"
1722 );
1723 let agent_runner = runner::agents::get_runner(&ctx.review_agent);
1724 let err = agent_runner.classify_error(exit_code, &raw_output, &stderr);
1725 record_review_agent_failure(
1726 &task.id.0,
1727 &ctx.review_agent,
1728 ctx.review_model.as_deref(),
1729 &err,
1730 )
1731 .await;
1732 tracing::error!(task_id = task.id.0, error = %err, "review agent failed");
1733 let stored_error = format_review_error(&ctx.review_agent, &err);
1734 complete_review_run(
1735 store,
1736 run.run_id,
1737 Some(exit_code),
1738 &raw_output,
1739 &stderr,
1740 "",
1741 outcome_for_agent_error(&err),
1742 &stored_error,
1743 token_usage,
1744 )
1745 .await;
1746 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1747 }
1748
1749 let text_for_review = agent_result
1750 .as_ref()
1751 .map(|r| r.result_text.clone())
1752 .filter(|text| !text.is_empty())
1753 .unwrap_or_else(|| {
1754 tracing::debug!(
1755 task_id = task.id.0,
1756 agent = %ctx.review_agent,
1757 "review agent: per-agent extractor returned no text, using raw output"
1758 );
1759 raw_output.clone()
1760 });
1761
1762 let review_response = match runner::response::parse_review_response(&text_for_review) {
1763 Ok(response) => response,
1764 Err(e) => {
1765 if let Some(response) = runner::response::infer_review_response(&text_for_review) {
1766 tracing::warn!(
1767 task_id = task.id.0,
1768 agent = %ctx.review_agent,
1769 "review response parsed via keyword fallback"
1770 );
1771 response
1772 } else {
1773 let ndjson_completed = ndjson_completed_no_error;
1779
1780 if ndjson_completed || !has_result_envelope {
1783 let assistant_text = runner::agents::collect_assistant_messages_text(
1784 &ctx.review_agent,
1785 &raw_output,
1786 );
1787 if !assistant_text.is_empty() {
1788 if let Ok(response) =
1789 runner::response::parse_review_response(&assistant_text)
1790 {
1791 tracing::debug!(
1792 task_id = task.id.0,
1793 agent = %ctx.review_agent,
1794 "found review response JSON in assistant message (terminal_reason:completed)"
1795 );
1796 let review_notes_for_comment = response.notes.clone();
1797 let decision = review_decision_from_response(response);
1798 return ReviewPhase::Ready(ParsedReview {
1799 run_id: run.run_id,
1800 exit_code,
1801 stderr,
1802 raw_output,
1803 text_for_review: assistant_text,
1804 token_usage,
1805 review_notes_for_comment,
1806 decision,
1807 });
1808 }
1809 if let Some(response) =
1810 runner::response::infer_review_response(&assistant_text)
1811 {
1812 tracing::warn!(
1813 task_id = task.id.0,
1814 agent = %ctx.review_agent,
1815 "inferred review response from assistant message (terminal_reason:completed)"
1816 );
1817 let review_notes_for_comment = response.notes.clone();
1818 let decision = review_decision_from_response(response);
1819 return ReviewPhase::Ready(ParsedReview {
1820 run_id: run.run_id,
1821 exit_code,
1822 stderr,
1823 raw_output,
1824 text_for_review: assistant_text,
1825 token_usage,
1826 review_notes_for_comment,
1827 decision,
1828 });
1829 }
1830 }
1831 }
1832
1833 let scan_head = runner::agents::patterns::safe_head(
1840 &text_for_review,
1841 runner::agents::patterns::RATE_LIMIT_SCAN_HEAD_BYTES,
1842 );
1843 let scan_tail = runner::agents::patterns::safe_tail(
1844 &text_for_review,
1845 runner::agents::patterns::RATE_LIMIT_SCAN_TAIL_BYTES,
1846 );
1847
1848 if let Some(err) = (!ndjson_completed)
1849 .then(|| {
1850 runner::agents::patterns::detect_rate_limit(scan_head)
1851 .or_else(|| runner::agents::patterns::detect_rate_limit(scan_tail))
1852 })
1853 .flatten()
1854 {
1855 let runner::agents::AgentError::RateLimit { message } = &err else {
1856 unreachable!("detect_rate_limit only returns AgentError::RateLimit")
1857 };
1858 tracing::warn!(
1859 task_id = task.id.0,
1860 agent = %ctx.review_agent,
1861 "review agent hit rate limit — adding to cooldown"
1862 );
1863 let summary = runner::fallback::summarize_rate_limit_error(message);
1870 let stored_error = format!("{} rate limit: {summary}", ctx.review_agent);
1871 record_review_agent_failure(
1872 &task.id.0,
1873 &ctx.review_agent,
1874 ctx.review_model.as_deref(),
1875 &err,
1876 )
1877 .await;
1878 complete_review_run(
1879 store,
1880 run.run_id,
1881 Some(exit_code),
1882 &raw_output,
1883 &stderr,
1884 &text_for_review,
1885 outcome_for_agent_error(&err),
1886 &stored_error,
1887 token_usage,
1888 )
1889 .await;
1890 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1891 }
1892
1893 if let Some(err) = runner::agents::patterns::detect_auth_error(scan_tail) {
1894 let runner::agents::AgentError::Auth { message } = &err else {
1895 unreachable!("detect_auth_error only returns AgentError::Auth")
1896 };
1897 tracing::warn!(
1898 task_id = task.id.0,
1899 agent = %ctx.review_agent,
1900 "review agent hit auth error — adding to cooldown"
1901 );
1902 let stored_error = format!("auth error: {message}");
1903 record_review_agent_failure(
1904 &task.id.0,
1905 &ctx.review_agent,
1906 ctx.review_model.as_deref(),
1907 &err,
1908 )
1909 .await;
1910 complete_review_run(
1911 store,
1912 run.run_id,
1913 Some(exit_code),
1914 &raw_output,
1915 &stderr,
1916 &text_for_review,
1917 outcome_for_agent_error(&err),
1918 &stored_error,
1919 token_usage,
1920 )
1921 .await;
1922 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1923 }
1924
1925 if agent_result
1927 .as_ref()
1928 .map(|r| r.truncated_by_length)
1929 .unwrap_or(false)
1930 {
1931 tracing::warn!(
1932 task_id = task.id.0,
1933 agent = %ctx.review_agent,
1934 model = ?ctx.review_model,
1935 "review agent output truncated: step_finish reason=length \
1936 (token budget exceeded before completion)"
1937 );
1938 if let Some(model) = ctx.review_model.as_deref() {
1939 crate::engine::cooldown::record_transient_model_failure(
1940 &ctx.review_agent,
1941 model,
1942 )
1943 .await;
1944 }
1945 let stored_error = "truncated: review output was cut off before completion \
1946 (output/reasoning token budget exceeded)"
1947 .to_string();
1948 complete_review_run(
1949 store,
1950 run.run_id,
1951 Some(exit_code),
1952 &raw_output,
1953 &stderr,
1954 &text_for_review,
1955 "truncated",
1956 &stored_error,
1957 token_usage,
1958 )
1959 .await;
1960 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1961 }
1962
1963 if runner::agents::claude::review_session_exited_mid_thought(&raw_output) {
1966 tracing::warn!(
1967 task_id = task.id.0,
1968 agent = %ctx.review_agent,
1969 model = ?ctx.review_model,
1970 "review session exited mid-thought with no result envelope \
1971 — storing as incomplete and retrying without persistent cooldown"
1972 );
1973 let stored_error =
1974 "incomplete: review session exited before producing a decision".to_string();
1975 complete_review_run(
1976 store,
1977 run.run_id,
1978 Some(exit_code),
1979 &raw_output,
1980 &stderr,
1981 &text_for_review,
1982 "incomplete",
1983 &stored_error,
1984 token_usage,
1985 )
1986 .await;
1987 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1988 }
1989
1990 tracing::error!(
1991 task_id = task.id.0,
1992 error = %e,
1993 agent = %ctx.review_agent,
1994 output = %text_for_review.chars().take(300).collect::<String>(),
1995 raw_output = %raw_output.chars().take(1000).collect::<String>(),
1996 "failed to parse review response"
1997 );
1998 if let Some(model) = ctx.review_model.as_deref() {
1999 crate::engine::cooldown::record_transient_model_failure(
2000 &ctx.review_agent,
2001 model,
2002 )
2003 .await;
2004 }
2005 complete_review_run(
2006 store,
2007 run.run_id,
2008 Some(exit_code),
2009 &raw_output,
2010 &stderr,
2011 &text_for_review,
2012 "parse_error",
2013 &format!("parse error: {e}"),
2014 token_usage,
2015 )
2016 .await;
2017 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(format!(
2018 "parse error: {e}"
2019 )));
2020 }
2021 }
2022 };
2023
2024 let review_notes_for_comment = review_response.notes.clone();
2025 let decision = review_decision_from_response(review_response);
2026
2027 tracing::info!(
2028 task_id = task.id.0,
2029 pr_number = ctx.pr_number,
2030 decision = ?decision,
2031 "review agent decision received"
2032 );
2033 store_log_activity(
2034 &Some(Arc::clone(store)),
2035 repo,
2036 &task.id.0,
2037 "review_decision",
2038 None,
2039 None,
2040 Some(&ctx.review_agent),
2041 ctx.review_model.as_deref(),
2042 Some(&serde_json::json!({
2043 "decision": review_decision_name(&decision),
2044 "pr_number": ctx.pr_number,
2045 })),
2046 )
2047 .await;
2048 if let Err(e) = crate::store::store_set_result(
2049 &Some(Arc::clone(store)),
2050 repo,
2051 &task.id.0,
2052 &[("review_resets", serde_json::json!(0))],
2053 )
2054 .await
2055 {
2056 tracing::warn!(task_id = task.id.0, err = %e, "failed to clear review_resets after a decision");
2057 }
2058
2059 ReviewPhase::Ready(ParsedReview {
2060 run_id: run.run_id,
2061 exit_code,
2062 stderr,
2063 raw_output,
2064 text_for_review,
2065 token_usage,
2066 review_notes_for_comment,
2067 decision,
2068 })
2069}
2070
2071async fn finalize_review_run(
2072 task: &ExternalTask,
2073 ctx: &ReviewContext,
2074 parsed: &ParsedReview,
2075 store: &Arc<TaskStore>,
2076) {
2077 let outcome = match &parsed.decision {
2078 ReviewDecision::Approve { .. }
2079 | ReviewDecision::RequestChanges { .. }
2080 | ReviewDecision::Skipped
2081 | ReviewDecision::Rerouted => "success",
2082 ReviewDecision::Failed(_) | ReviewDecision::Blocked(_) => "failed",
2083 };
2084 let error = match &parsed.decision {
2085 ReviewDecision::Failed(error) | ReviewDecision::Blocked(error) => error.as_str(),
2086 _ => "",
2087 };
2088
2089 if outcome == "success" {
2090 crate::engine::cooldown::record_agent_success(&ctx.review_agent, ctx.review_model_str())
2093 .await;
2094 }
2095
2096 complete_review_run(
2097 store,
2098 parsed.run_id,
2099 Some(parsed.exit_code),
2100 &parsed.raw_output,
2101 &parsed.stderr,
2102 &parsed.text_for_review,
2103 outcome,
2104 error,
2105 parsed.token_usage,
2106 )
2107 .await;
2108
2109 if let Some(store_id) = ctx.store_id {
2110 if parsed.token_usage.input_tokens > 0 || parsed.token_usage.output_tokens > 0 {
2111 let model = if ctx.review_model_str().is_empty() {
2112 "unknown"
2113 } else {
2114 ctx.review_model_str()
2115 };
2116 if let Err(e) = store
2117 .store_tokens(
2118 store_id,
2119 parsed.token_usage.input_tokens,
2120 parsed.token_usage.output_tokens,
2121 model,
2122 )
2123 .await
2124 {
2125 tracing::warn!(
2126 task_id = task.id.0,
2127 ?e,
2128 "failed to store review agent token usage"
2129 );
2130 }
2131 }
2132 }
2133}
2134
2135async fn restore_review_config_if_needed(
2136 task_id: &str,
2137 worktree_path: &std::path::Path,
2138 default_branch: &str,
2139) {
2140 let _ = Command::new("git")
2141 .args(["checkout", "HEAD", "--", ".orch.yml"])
2142 .current_dir(worktree_path)
2143 .status()
2144 .await;
2145
2146 let merge_base = Command::new("git")
2147 .args(["merge-base", "HEAD", default_branch])
2148 .current_dir(worktree_path)
2149 .output()
2150 .await
2151 .ok()
2152 .filter(|output| output.status.success())
2153 .and_then(|output| String::from_utf8(output.stdout).ok())
2154 .map(|s| s.trim().to_string())
2155 .unwrap_or_default();
2156
2157 if merge_base.is_empty() {
2158 return;
2159 }
2160
2161 let orch_yml_changed = Command::new("git")
2162 .args([
2163 "diff",
2164 "--name-only",
2165 &merge_base,
2166 "HEAD",
2167 "--",
2168 ".orch.yml",
2169 ])
2170 .current_dir(worktree_path)
2171 .output()
2172 .await
2173 .ok()
2174 .filter(|output| output.status.success())
2175 .map(|output| !output.stdout.is_empty())
2176 .unwrap_or(false);
2177
2178 if !orch_yml_changed {
2179 return;
2180 }
2181
2182 tracing::warn!(
2183 task_id,
2184 "review agent modified .orch.yml (forbidden); restoring to merge-base version"
2185 );
2186 let restore_ok = Command::new("git")
2187 .args(["checkout", &merge_base, "--", ".orch.yml"])
2188 .current_dir(worktree_path)
2189 .status()
2190 .await
2191 .map(|status| status.success())
2192 .unwrap_or(false);
2193
2194 if !restore_ok {
2195 return;
2196 }
2197
2198 let staged = Command::new("git")
2199 .args(["diff", "--cached", "--name-only", "--", ".orch.yml"])
2200 .current_dir(worktree_path)
2201 .output()
2202 .await
2203 .ok()
2204 .filter(|output| output.status.success())
2205 .map(|output| !output.stdout.is_empty())
2206 .unwrap_or(false);
2207
2208 if staged {
2209 let _ = Command::new("git")
2210 .args([
2211 "-c",
2212 "commit.gpgsign=false",
2213 "commit",
2214 "-m",
2215 "revert: restore .orch.yml (review agent must not modify project config)",
2216 ])
2217 .current_dir(worktree_path)
2218 .status()
2219 .await;
2220 }
2221}
2222
2223fn is_missing_path_error_message(err: &str) -> bool {
2224 err.to_ascii_lowercase()
2225 .contains("no such file or directory")
2226}
2227
2228async fn push_review_branch(
2229 task: &ExternalTask,
2230 repo: &str,
2231 ctx: &ReviewContext,
2232 store: &Arc<TaskStore>,
2233) -> Result<(), String> {
2234 let mut worktree_path = ctx.worktree_path.clone();
2235 let mut branch_name = ctx.branch_name.clone();
2236 let mut retried_missing_worktree = false;
2237
2238 let push_result = loop {
2239 match runner::git_ops::push_branch(&worktree_path, &branch_name, &ctx.default_branch).await
2240 {
2241 Ok(ok) => break Ok(ok),
2242 Err(e)
2243 if !retried_missing_worktree
2244 && !worktree_path.exists()
2245 && is_missing_path_error_message(&e.to_string()) =>
2246 {
2247 retried_missing_worktree = true;
2248 tracing::warn!(
2249 task_id = task.id.0,
2250 worktree = %worktree_path.display(),
2251 branch = %branch_name,
2252 "review push failed because worktree disappeared; attempting one-time recovery"
2253 );
2254 match recover_missing_review_worktree(task, repo, store).await {
2255 Ok(recovered) => {
2256 worktree_path = recovered.work_dir;
2257 branch_name = recovered.branch;
2258 continue;
2259 }
2260 Err(recover_err) => {
2261 break Err(anyhow::anyhow!(
2262 "missing review worktree recovery failed after push error: {recover_err}"
2263 ));
2264 }
2265 }
2266 }
2267 Err(e) => break Err(e),
2268 }
2269 };
2270
2271 match push_result {
2272 Ok(_) => {
2273 store_log_activity(
2274 &Some(Arc::clone(store)),
2275 repo,
2276 &task.id.0,
2277 "push",
2278 None,
2279 None,
2280 Some(&ctx.review_agent),
2281 ctx.review_model.as_deref(),
2282 Some(&serde_json::json!({
2283 "status": "ok",
2284 "branch": branch_name.clone(),
2285 "default_branch": ctx.default_branch.clone(),
2286 "phase": "review",
2287 })),
2288 )
2289 .await;
2290 if ctx.had_prev_push_failure {
2291 if let Err(e) = store_set_result(
2292 &Some(Arc::clone(store)),
2293 repo,
2294 &task.id.0,
2295 &[
2296 ("last_error", serde_json::json!("")),
2297 ("push_failures", serde_json::json!(0)),
2298 ],
2299 )
2300 .await
2301 {
2302 tracing::warn!(task_id = task.id.0, err = %e, "failed to reset push_failures — counter may drift");
2303 }
2304 }
2305 Ok(())
2306 }
2307 Err(e) => {
2308 store_log_activity(
2309 &Some(Arc::clone(store)),
2310 repo,
2311 &task.id.0,
2312 "push",
2313 None,
2314 None,
2315 Some(&ctx.review_agent),
2316 ctx.review_model.as_deref(),
2317 Some(&serde_json::json!({
2318 "status": "error",
2319 "branch": branch_name.clone(),
2320 "default_branch": ctx.default_branch.clone(),
2321 "phase": "review",
2322 "error": e.to_string(),
2323 })),
2324 )
2325 .await;
2326 tracing::error!(
2327 task_id = task.id.0,
2328 branch = %ctx.branch_name,
2329 error = %e,
2330 "review push failed"
2331 );
2332 store_set(
2333 &Some(Arc::clone(store)),
2334 repo,
2335 &task.id.0,
2336 &[("last_error", serde_json::json!(format!("push failed: {e}")))],
2337 )
2338 .await;
2339 Err(e.to_string())
2340 }
2341 }
2342}
2343
2344fn append_review_issues(
2345 body: &mut String,
2346 issues: &[crate::engine::runner::response::ReviewIssue],
2347) {
2348 if issues.is_empty() {
2349 return;
2350 }
2351 body.push_str("\n**Issues Found:**\n");
2352 for issue in issues {
2353 body.push_str(&format!(
2354 "- `{}` line {}: {} [{}]\n",
2355 issue.file,
2356 issue
2357 .line
2358 .map(|line| line.to_string())
2359 .unwrap_or_else(|| "?".to_string()),
2360 issue.description,
2361 issue.severity
2362 ));
2363 }
2364}
2365
2366fn build_pr_review_comment(decision: &ReviewDecision, review_notes_for_comment: &str) -> String {
2367 match decision {
2368 ReviewDecision::Approve { issues } => {
2369 let mut body = format!(
2370 "## Automated Review \u{2014} Approve\n\n{}",
2371 review_notes_for_comment
2372 );
2373 if !issues.is_empty() && !body.ends_with('\n') {
2374 body.push('\n');
2375 }
2376 append_review_issues(&mut body, issues);
2377 body
2378 }
2379 ReviewDecision::RequestChanges { notes, issues } => {
2380 let mut body = format!(
2381 "## Automated Review \u{2014} Changes Requested\n\n{}\n",
2382 notes
2383 );
2384 append_review_issues(&mut body, issues);
2385 body
2386 }
2387 _ => String::new(),
2388 }
2389}
2390
2391const REVIEW_STATUS_CONTEXT: &str = "orch/review";
2392
2393fn review_status(decision: &ReviewDecision) -> Option<(&'static str, &'static str)> {
2395 match decision {
2396 ReviewDecision::Approve { .. } => Some(("success", "Approved by automated review")),
2397 ReviewDecision::RequestChanges { .. } => {
2398 Some(("failure", "Automated review requested changes"))
2399 }
2400 _ => None,
2401 }
2402}
2403
2404async fn post_review_status(
2407 gh: &GhHttp,
2408 repo: &str,
2409 sha: Option<&str>,
2410 state: &str,
2411 description: &str,
2412) {
2413 let Some(sha) = sha else {
2414 return;
2415 };
2416 if let Err(e) = gh
2417 .post_commit_status(repo, sha, state, REVIEW_STATUS_CONTEXT, description)
2418 .await
2419 {
2420 tracing::warn!(sha, error = %e, "failed to post orch/review commit status");
2421 }
2422}
2423
2424async fn post_review_comment(
2425 task: &ExternalTask,
2426 repo: &str,
2427 ctx: &ReviewContext,
2428 decision: &ReviewDecision,
2429 review_notes_for_comment: &str,
2430) -> anyhow::Result<()> {
2431 let gh = match GhHttp::new() {
2432 Ok(gh) => gh,
2433 Err(e) => {
2434 tracing::warn!(
2435 task_id = task.id.0,
2436 error = %e,
2437 "failed to create GH client for review comment"
2438 );
2439 return Ok(());
2440 }
2441 };
2442 let mut pr_comment = build_pr_review_comment(decision, review_notes_for_comment);
2443 if ctx.same_agent_fallback && !pr_comment.is_empty() {
2444 pr_comment.push_str(
2445 "\n> Note: no other agent was available, so this PR was reviewed by the same agent that wrote the code.\n",
2446 );
2447 }
2448
2449 let head_sha = worktree_head_sha(&ctx.worktree_path)
2452 .await
2453 .or_else(|| ctx.head_sha.clone());
2454
2455 let app = review_app(repo).await;
2456 post_review_decision(
2457 app.as_deref(),
2458 &gh,
2459 repo,
2460 head_sha.as_deref(),
2461 decision,
2462 review_notes_for_comment,
2463 )
2464 .await;
2465
2466 if !pr_comment.is_empty() {
2467 let footer =
2468 attribution_footer("Reviewed", &ctx.review_agent, Some(ctx.review_model_str()));
2469 let pr_comment_with_footer = format!("{}{}", pr_comment, footer);
2470 if let Err(e) = publish_review(
2471 app.as_deref(),
2472 &gh,
2473 repo,
2474 ctx.pr_number,
2475 head_sha.as_deref(),
2476 decision,
2477 &pr_comment_with_footer,
2478 )
2479 .await
2480 {
2481 tracing::warn!(
2482 task_id = task.id.0,
2483 pr_number = ctx.pr_number,
2484 error = %e,
2485 "failed to post automated review comment on PR"
2486 );
2487 }
2488 }
2489
2490 Ok(())
2491}
2492
2493async fn review_app(repo: &str) -> Option<Arc<crate::github::app::AppAuth>> {
2495 let mode = crate::review_mode::for_repo(repo);
2496 review_app_for(mode, crate::github::app::shared(), repo).await
2497}
2498
2499async fn review_app_for(
2500 mode: crate::review_mode::ReviewMode,
2501 app: Option<Arc<crate::github::app::AppAuth>>,
2502 repo: &str,
2503) -> Option<Arc<crate::github::app::AppAuth>> {
2504 if mode != crate::review_mode::ReviewMode::App {
2505 return None;
2506 }
2507
2508 let app = app?;
2509 match app.token_for_repo(repo).await {
2510 Ok(Some(_)) => Some(app),
2511 Ok(None) => {
2512 tracing::info!(
2513 repo,
2514 "review.mode is app but the App is not installed here, reviewing with a comment"
2515 );
2516 None
2517 }
2518 Err(e) => {
2519 tracing::warn!(repo, error = %e, "App installation lookup failed, reviewing with a comment");
2520 None
2521 }
2522 }
2523}
2524
2525async fn post_review_started(
2527 app: Option<&crate::github::app::AppAuth>,
2528 gh: &GhHttp,
2529 repo: &str,
2530 sha: Option<&str>,
2531) {
2532 use crate::github::app::CheckRunState;
2533 match (app, sha) {
2534 (Some(app), Some(sha)) => {
2535 if let Err(e) = app
2536 .post_check_run(repo, sha, CheckRunState::InProgress)
2537 .await
2538 {
2539 tracing::warn!(sha, error = %e, "failed to post the orch/review check run");
2540 }
2541 }
2542 _ => post_review_status(gh, repo, sha, "pending", "Automated review running").await,
2543 }
2544}
2545
2546async fn post_review_decision(
2547 app: Option<&crate::github::app::AppAuth>,
2548 gh: &GhHttp,
2549 repo: &str,
2550 sha: Option<&str>,
2551 decision: &ReviewDecision,
2552 notes: &str,
2553) {
2554 use crate::github::app::CheckRunState;
2555 let Some((state, description)) = review_status(decision) else {
2556 return;
2557 };
2558
2559 match (app, sha) {
2560 (Some(app), Some(sha)) => {
2561 let approved = state == "success";
2562 let completed = CheckRunState::Completed {
2563 approved,
2564 summary: if notes.is_empty() { description } else { notes },
2565 };
2566 if let Err(e) = app.post_check_run(repo, sha, completed).await {
2567 tracing::warn!(sha, error = %e, "failed to complete the orch/review check run");
2568 }
2569 }
2570 _ => post_review_status(gh, repo, sha, state, description).await,
2571 }
2572}
2573
2574async fn publish_review(
2578 app: Option<&crate::github::app::AppAuth>,
2579 gh: &GhHttp,
2580 repo: &str,
2581 pr_number: u64,
2582 head_sha: Option<&str>,
2583 decision: &ReviewDecision,
2584 comment: &str,
2585) -> anyhow::Result<()> {
2586 use crate::github::app::ReviewEvent;
2587 let event = match decision {
2588 ReviewDecision::Approve { .. } => Some(ReviewEvent::Approve),
2589 ReviewDecision::RequestChanges { .. } => Some(ReviewEvent::RequestChanges),
2590 _ => None,
2591 };
2592 if let (Some(app), Some(sha), Some(event)) = (app, head_sha, event) {
2593 let inline = inline_comments(app, repo, pr_number, decision).await;
2594 match app
2595 .submit_review(repo, pr_number, event, comment, sha, &inline)
2596 .await
2597 {
2598 Ok(()) => return Ok(()),
2599 Err(e) => {
2600 tracing::warn!(pr_number, sha, error = %e, "failed to submit App review, posting the decision as a comment");
2601 }
2602 }
2603 }
2604 gh.add_comment(repo, &pr_number.to_string(), comment).await
2605}
2606
2607async fn inline_comments(
2610 app: &crate::github::app::AppAuth,
2611 repo: &str,
2612 pr_number: u64,
2613 decision: &ReviewDecision,
2614) -> Vec<crate::github::app::InlineComment> {
2615 let ReviewDecision::RequestChanges { issues, .. } = decision else {
2616 return Vec::new();
2617 };
2618
2619 if issues.is_empty() {
2620 return Vec::new();
2621 }
2622
2623 match app.pull_request_hunks(repo, pr_number).await {
2624 Ok(hunks) => crate::github::app::split_inline(issues, &hunks).0,
2625 Err(e) => {
2626 tracing::warn!(pr_number, error = %e, "could not read the PR diff, issues stay in the review body");
2627 Vec::new()
2628 }
2629 }
2630}
2631
2632#[allow(clippy::too_many_arguments)]
2633async fn apply_review_decision(
2634 task: &ExternalTask,
2635 backend: &Arc<dyn ExternalBackend>,
2636 repo: &str,
2637 task_manager: &Arc<TaskManager>,
2638 store: &Arc<TaskStore>,
2639 ctx: &ReviewContext,
2640 parsed: &ParsedReview,
2641 router_config: &crate::engine::router::RouterConfig,
2642) -> anyhow::Result<ReviewDecision> {
2643 restore_review_config_if_needed(&task.id.0, &ctx.worktree_path, &ctx.default_branch).await;
2644
2645 if let Err(e) = push_review_branch(task, repo, ctx, store).await {
2646 return Ok(ReviewDecision::Failed(format!("push failed: {e}")));
2647 }
2648
2649 post_review_comment(
2650 task,
2651 repo,
2652 ctx,
2653 &parsed.decision,
2654 &parsed.review_notes_for_comment,
2655 )
2656 .await?;
2657
2658 match &parsed.decision {
2659 ReviewDecision::Approve { .. } => {
2660 record_approved_sha(
2661 store,
2662 repo,
2663 &task.id.0,
2664 ctx.pr_number,
2665 &ctx.worktree_path,
2666 &ctx.branch_name,
2667 &ctx.default_branch,
2668 )
2669 .await;
2670 if let Err(e) = auto_merge_pr(
2675 task,
2676 &ctx.branch_name,
2677 backend,
2678 repo,
2679 &ctx.review_agent,
2680 ctx.review_model_str(),
2681 task_manager,
2682 store,
2683 )
2684 .await
2685 {
2686 let error_msg = e.to_string();
2687 if error_msg.contains("not yet computed") {
2688 tracing::warn!(
2689 task_id = task.id.0,
2690 pr_number = ctx.pr_number,
2691 branch = %ctx.branch_name,
2692 error = %e,
2693 "auto-merge deferred — PR mergeability not yet computed"
2694 );
2695 } else {
2696 tracing::error!(
2697 task_id = task.id.0,
2698 pr_number = ctx.pr_number,
2699 branch = %ctx.branch_name,
2700 error = %e,
2701 "auto-merge failed"
2702 );
2703 }
2704 return Ok(ReviewDecision::Failed(format!("merge failed: {e}")));
2705 }
2706 Ok(parsed.decision.clone())
2707 }
2708 ReviewDecision::RequestChanges { notes, issues } => {
2709 clear_approved_sha(store, repo, &task.id.0, ctx.pr_number).await;
2710 handle_review_changes(
2711 task,
2712 notes,
2713 issues,
2714 backend,
2715 repo,
2716 ctx.pr_number,
2717 &ctx.review_agent,
2718 ctx.review_model_str(),
2719 task_manager,
2720 store,
2721 router_config,
2722 )
2723 .await?;
2724 Ok(parsed.decision.clone())
2725 }
2726 _ => Ok(parsed.decision.clone()),
2727 }
2728}
2729
2730async fn recover_missing_review_worktree(
2731 task: &ExternalTask,
2732 repo: &str,
2733 store: &Arc<TaskStore>,
2734) -> anyhow::Result<worktree::WorktreeSetup> {
2735 let project_dir = crate::config::get_projects_with_paths()?
2736 .into_iter()
2737 .find(|(candidate_repo, _)| candidate_repo == repo)
2738 .map(|(_, project_dir)| project_dir)
2739 .ok_or_else(|| anyhow::anyhow!("no project directory configured for repo {repo}"))?;
2740
2741 tracing::warn!(
2742 task_id = task.id.0,
2743 repo,
2744 project_dir = %project_dir.display(),
2745 "review worktree missing, recreating via normal setup"
2746 );
2747
2748 let store_ref = Some(Arc::clone(store));
2749 let wt = worktree::setup_worktree(&task.id.0, &task.title, &project_dir, &store_ref, repo)
2750 .await
2751 .context("recreating missing review worktree")?;
2752
2753 if !wt.work_dir.exists() {
2754 anyhow::bail!(
2755 "recreated review worktree at {} for task {} is still missing",
2756 wt.work_dir.display(),
2757 task.id.0
2758 );
2759 }
2760
2761 Ok(wt)
2762}
2763
2764async fn count_ahead_commits(
2770 worktree_path: &std::path::Path,
2771 default_branch: &str,
2772) -> Result<u64, String> {
2773 let worktree_str = worktree_path.to_str().ok_or_else(|| {
2774 format!(
2775 "worktree path contains non-UTF-8 characters: {:?}",
2776 worktree_path
2777 )
2778 })?;
2779
2780 let output = tokio::process::Command::new("git")
2781 .args([
2782 "-C",
2783 worktree_str,
2784 "rev-list",
2785 "--count",
2786 &format!("origin/{default_branch}..HEAD"),
2787 ])
2788 .output()
2789 .await
2790 .map_err(|e| format!("git rev-list command failed to start: {e}"))?;
2791
2792 if !output.status.success() {
2793 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
2794 return Err(format!(
2795 "git rev-list exited with {:?}: {}",
2796 output.status.code(),
2797 if stderr.is_empty() {
2798 String::from_utf8_lossy(&output.stdout).trim().to_string()
2799 } else {
2800 stderr
2801 }
2802 ));
2803 }
2804
2805 String::from_utf8_lossy(&output.stdout)
2806 .trim()
2807 .parse::<u64>()
2808 .map_err(|e| format!("could not parse git rev-list output as u64: {e}"))
2809}
2810
2811#[allow(clippy::too_many_arguments)]
2818async fn ensure_pr_exists(
2819 task: &ExternalTask,
2820 branch_name: &str,
2821 worktree_path: &std::path::Path,
2822 repo: &str,
2823 agent_summary: &str,
2824 store: &Arc<TaskStore>,
2825 task_manager: &Arc<TaskManager>,
2826 stored_pr_number: Option<u64>,
2827) -> anyhow::Result<EnsurePrResult> {
2828 let gh_check = GhHttp::new()?;
2829
2830 if let Some(n) = stored_pr_number {
2831 tracing::info!(
2832 task_id = task.id.0,
2833 pr_number = n,
2834 branch = %branch_name,
2835 "open PR found in store, proceeding with review"
2836 );
2837 return Ok(EnsurePrResult::Ready(n));
2838 }
2839
2840 match gh_check.get_pr_number(repo, branch_name).await {
2841 Ok(Some(n)) => {
2842 tracing::info!(
2843 task_id = task.id.0,
2844 pr_number = n,
2845 branch = %branch_name,
2846 "open PR found, proceeding with review"
2847 );
2848 Ok(EnsurePrResult::Ready(n))
2849 }
2850 Ok(None) => {
2851 let default_branch = worktree::detect_default_branch(worktree_path).await;
2853 let has_commits = match count_ahead_commits(worktree_path, &default_branch).await {
2854 Ok(count) => count > 0,
2855 Err(e) => {
2856 tracing::error!(
2857 task_id = task.id.0,
2858 branch = %branch_name,
2859 default_branch = %default_branch,
2860 err = %e,
2861 "could not determine if branch has commits ahead of default"
2862 );
2863 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2864 format!("git ancestry check failed: {e}"),
2865 )));
2866 }
2867 };
2868
2869 if has_commits {
2870 tracing::warn!(
2872 task_id = task.id.0,
2873 branch = %branch_name,
2874 "no open PR but branch has commits — attempting to create PR"
2875 );
2876 let worktree_str = worktree_path.to_str().ok_or_else(|| {
2877 anyhow::anyhow!(
2878 "worktree path contains non-UTF-8 characters: {:?}",
2879 worktree_path
2880 )
2881 })?;
2882 let push_timeout = std::time::Duration::from_secs(120);
2889 match tokio::time::timeout(
2890 push_timeout,
2891 crate::engine::runner::git_ops::git()
2892 .await
2893 .args(["-C", worktree_str, "push", "-u", "origin", branch_name])
2894 .output(),
2895 )
2896 .await
2897 {
2898 Err(_elapsed) => {
2899 tracing::warn!(
2900 task_id = task.id.0,
2901 branch = %branch_name,
2902 timeout_secs = push_timeout.as_secs(),
2903 "review gate fallback push timed out"
2904 );
2905 }
2906 Ok(Ok(o)) => {
2907 if !o.status.success() {
2908 tracing::warn!(
2909 task_id = task.id.0,
2910 branch = %branch_name,
2911 exit_code = ?o.status.code(),
2912 stdout = %String::from_utf8_lossy(&o.stdout),
2913 stderr = %String::from_utf8_lossy(&o.stderr),
2914 "review gate fallback push failed"
2915 );
2916 } else {
2917 tracing::info!(task_id = task.id.0, branch = %branch_name, "review gate fallback push succeeded");
2918 }
2919 }
2920 Ok(Err(e)) => {
2921 tracing::error!(
2922 task_id = task.id.0,
2923 branch = %branch_name,
2924 error = %e,
2925 "review gate fallback push command failed to run"
2926 );
2927 }
2928 }
2929
2930 let task_ref = runner::git_ops::format_task_ref(&task.id.0);
2931 let pr_body = format!(
2932 "Resolves {task_ref}\n\nAuto-created by orch review gate (agent forgot to open PR)"
2933 );
2934 let gh = GhHttp::new()?;
2935 match gh
2936 .create_pr(repo, &task.title, &pr_body, branch_name, &default_branch)
2937 .await
2938 {
2939 Ok(url) => match parse_pr_number_from_url(&url) {
2940 Ok(pr_num) => {
2941 store_set_result(
2942 &Some(Arc::clone(store)),
2943 repo,
2944 &task.id.0,
2945 &[("pr_number", serde_json::json!(pr_num as i64))],
2946 )
2947 .await?;
2948 tracing::info!(
2949 task_id = task.id.0,
2950 branch = %branch_name,
2951 pr_url = %url,
2952 "created missing PR via GhHttp — continuing review"
2953 );
2954 Ok(EnsurePrResult::Ready(pr_num))
2955 }
2956 Err(e) => {
2957 tracing::warn!(
2958 task_id = task.id.0,
2959 branch = %branch_name,
2960 pr_url = %url,
2961 error = %e,
2962 "created missing PR via GhHttp, but failed to parse PR number"
2963 );
2964 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2965 format!("created missing PR, but could not parse PR number: {e}"),
2966 )))
2967 }
2968 },
2969 Err(e) => {
2970 let e_str = format!("{e}");
2971 if e_str.contains("already exists") {
2972 let backoff_delays = [2u64, 4u64];
2976 let mut found_pr: Option<u64> = None;
2977 for delay_secs in backoff_delays {
2978 tokio::time::sleep(tokio::time::Duration::from_secs(delay_secs))
2979 .await;
2980 if let Ok(Some(n)) = gh_check.get_pr_number(repo, branch_name).await
2981 {
2982 found_pr = Some(n);
2983 break;
2984 }
2985 }
2986 if let Some(n) = found_pr {
2987 tracing::info!(
2988 task_id = task.id.0,
2989 pr_number = n,
2990 branch = %branch_name,
2991 "found existing PR after create_pr 422 — retrying review"
2992 );
2993 store_set_result(
2994 &Some(Arc::clone(store)),
2995 repo,
2996 &task.id.0,
2997 &[("pr_number", serde_json::json!(n as i64))],
2998 )
2999 .await?;
3000 return Ok(EnsurePrResult::Ready(n));
3001 }
3002 }
3003 tracing::warn!(
3004 task_id = task.id.0,
3005 branch = %branch_name,
3006 error = %e,
3007 "create_pr failed via GhHttp, falling back to CLI"
3008 );
3009 let pr_timeout = std::time::Duration::from_secs(120);
3012 let mut gh_cmd = tokio::process::Command::new("gh");
3013 gh_cmd.args([
3014 "pr",
3015 "create",
3016 "--repo",
3017 repo,
3018 "--head",
3019 branch_name,
3020 "--title",
3021 &task.title,
3022 "--body",
3023 &pr_body,
3024 ]);
3025 gh_cmd.current_dir(worktree_path);
3026 gh_cmd.kill_on_drop(true);
3027
3028 let pr_result = tokio::time::timeout(pr_timeout, gh_cmd.output()).await;
3029 match pr_result {
3030 Ok(Ok(o)) if o.status.success() => {
3031 let stdout = String::from_utf8_lossy(&o.stdout);
3032 match parse_pr_number_from_url(stdout.trim()) {
3033 Ok(pr_num) => {
3034 store_set_result(
3035 &Some(Arc::clone(store)),
3036 repo,
3037 &task.id.0,
3038 &[("pr_number", serde_json::json!(pr_num as i64))],
3039 )
3040 .await?;
3041 tracing::info!(
3042 task_id = task.id.0,
3043 branch = %branch_name,
3044 "created missing PR via CLI — continuing review"
3045 );
3046 Ok(EnsurePrResult::Ready(pr_num))
3047 }
3048 Err(e) => {
3049 tracing::warn!(
3050 task_id = task.id.0,
3051 branch = %branch_name,
3052 stdout = %stdout,
3053 error = %e,
3054 "created missing PR via CLI, but failed to parse PR number"
3055 );
3056 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
3057 format!("created missing PR via CLI, but could not parse PR number: {e}")
3058 )))
3059 }
3060 }
3061 }
3062 Ok(Ok(o)) => {
3063 let stderr = String::from_utf8_lossy(&o.stderr);
3064 if stderr.contains("already exists") {
3065 if let Some(pr_url) =
3066 stderr.lines().find(|l| l.trim().starts_with("https://"))
3067 {
3068 match parse_pr_number_from_url(pr_url.trim()) {
3069 Ok(pr_num) => {
3070 if let Err(e) = store_set_result(
3071 &Some(Arc::clone(store)),
3072 repo,
3073 &task.id.0,
3074 &[(
3075 "pr_number",
3076 serde_json::json!(pr_num as i64),
3077 )],
3078 )
3079 .await
3080 {
3081 tracing::warn!(
3082 task_id = task.id.0,
3083 pr_num,
3084 err = %e,
3085 "failed to persist pr_number from already-exists recovery — downstream may retry PR creation"
3086 );
3087 }
3088 tracing::info!(
3089 task_id = task.id.0,
3090 branch = %branch_name,
3091 pr_url = %pr_url,
3092 "PR already exists (from CLI stderr) — retrying review"
3093 );
3094 return Ok(EnsurePrResult::Ready(pr_num));
3095 }
3096 Err(e) => {
3097 tracing::warn!(
3098 task_id = task.id.0,
3099 branch = %branch_name,
3100 pr_url = %pr_url,
3101 error = %e,
3102 "found existing PR URL in CLI stderr, but failed to parse PR number"
3103 );
3104 }
3106 }
3107 }
3108 }
3109 tracing::error!(
3110 task_id = task.id.0,
3111 branch = %branch_name,
3112 stderr = %stderr,
3113 "failed to create missing PR — work may be stuck"
3114 );
3115
3116 if crate::engine::runner::git_ops::is_transient_github_api_error(
3123 &stderr,
3124 ) {
3125 tracing::warn!(
3126 task_id = task.id.0,
3127 branch = %branch_name,
3128 "transient GitHub error creating PR; will retry later without incrementing persistent failure counter"
3129 );
3130 return Ok(EnsurePrResult::EarlyReturn(
3131 ReviewDecision::Failed(format!(
3132 "transient github error creating PR: {stderr}"
3133 )),
3134 ));
3135 }
3136
3137 match store_increment(
3138 &Some(Arc::clone(store)),
3139 repo,
3140 &task.id.0,
3141 "pr_create_failures",
3142 )
3143 .await
3144 {
3145 Ok(failures) if failures >= MAX_PR_CREATE_FAILURES => {
3146 return Ok(EnsurePrResult::EarlyReturn(
3147 ReviewDecision::Blocked(format!(
3148 "no PR, create failed {failures} times: {stderr}"
3149 )),
3150 ));
3151 }
3152 Ok(_) => {}
3153 Err(e) => {
3154 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment pr_create_failures — skipping blocking decision this tick");
3155 }
3156 }
3157 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
3158 format!("no PR, create failed: {stderr}"),
3159 )))
3160 }
3161 Ok(Err(e)) => {
3162 tracing::error!(
3163 task_id = task.id.0,
3164 error = %e,
3165 "failed to run gh pr create"
3166 );
3167 let e_str = format!("{e}");
3168 if crate::engine::runner::git_ops::is_transient_github_api_error(
3169 &e_str,
3170 ) {
3171 tracing::warn!(
3172 task_id = task.id.0,
3173 branch = %branch_name,
3174 "transient GitHub error from gh CLI fallback; will retry later without incrementing persistent failure counter"
3175 );
3176 return Ok(EnsurePrResult::EarlyReturn(
3177 ReviewDecision::Failed(format!("transient gh error: {e}")),
3178 ));
3179 }
3180
3181 match store_increment(
3182 &Some(Arc::clone(store)),
3183 repo,
3184 &task.id.0,
3185 "pr_create_failures",
3186 )
3187 .await
3188 {
3189 Ok(failures) if failures >= MAX_PR_CREATE_FAILURES => {
3190 return Ok(EnsurePrResult::EarlyReturn(
3191 ReviewDecision::Blocked(format!(
3192 "no PR, gh error {failures} times: {e}"
3193 )),
3194 ));
3195 }
3196 Ok(_) => {}
3197 Err(e) => {
3198 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment pr_create_failures — skipping blocking decision this tick");
3199 }
3200 }
3201 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
3202 format!("no PR, gh error: {e}"),
3203 )))
3204 }
3205 Err(_elapsed) => {
3206 tracing::error!(
3208 task_id = task.id.0,
3209 timeout_secs = pr_timeout.as_secs(),
3210 "gh pr create CLI fallback timed out"
3211 );
3212 let e_str = format!(
3213 "gh pr create timed out after {}s",
3214 pr_timeout.as_secs()
3215 );
3216 if crate::engine::runner::git_ops::is_transient_github_api_error(
3217 &e_str,
3218 ) {
3219 tracing::warn!(
3220 task_id = task.id.0,
3221 branch = %branch_name,
3222 "transient GitHub timeout from gh CLI fallback; will retry later without incrementing persistent failure counter"
3223 );
3224 return Ok(EnsurePrResult::EarlyReturn(
3225 ReviewDecision::Failed(format!(
3226 "transient gh error: {e_str}"
3227 )),
3228 ));
3229 }
3230
3231 match store_increment(
3232 &Some(Arc::clone(store)),
3233 repo,
3234 &task.id.0,
3235 "pr_create_failures",
3236 )
3237 .await
3238 {
3239 Ok(failures) if failures >= MAX_PR_CREATE_FAILURES => {
3240 return Ok(EnsurePrResult::EarlyReturn(
3241 ReviewDecision::Blocked(format!(
3242 "no PR, gh timeout {failures} times: {e_str}"
3243 )),
3244 ));
3245 }
3246 Ok(_) => {}
3247 Err(e) => {
3248 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment pr_create_failures — skipping blocking decision this tick");
3249 }
3250 }
3251 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
3252 format!("no PR, gh timeout: {e_str}"),
3253 )))
3254 }
3255 }
3256 }
3257 }
3258 } else {
3259 let merged = match gh_check.is_pr_merged(repo, branch_name).await {
3261 Ok(v) => v,
3262 Err(e) => {
3263 tracing::warn!(task_id = task.id.0, branch = %branch_name, err = %e, "merge check failed, skipping task this tick");
3264 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
3265 format!("merge check failed: {e}"),
3266 )));
3267 }
3268 };
3269 if merged {
3270 tracing::info!(
3271 task_id = task.id.0,
3272 branch = %branch_name,
3273 "PR already merged, marking done"
3274 );
3275 if let Err(e) = task_manager
3276 .update_task_status(&task.id, Status::Done)
3277 .await
3278 {
3279 tracing::error!(task_id = task.id.0, err = %e, "update_task_status(Done) failed — task may be stuck in InReview");
3280 }
3281 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped));
3282 }
3283
3284 let last_error = opt_store_get_task(&Some(Arc::clone(store)), repo, &task.id.0)
3285 .await
3286 .map(|t| t.last_error)
3287 .unwrap_or_default();
3288 let reason = if !agent_summary.is_empty() {
3289 agent_summary.to_string()
3290 } else {
3291 last_error.clone()
3292 };
3293
3294 if last_error.contains("exceeded max attempts") {
3295 tracing::warn!(
3296 task_id = task.id.0,
3297 branch = %branch_name,
3298 "no PR and no commits after max attempts — marking blocked to stop loop"
3299 );
3300 let fields = [
3304 (
3305 "block_reason",
3306 serde_json::json!("max attempts exceeded — no PR or commits produced"),
3307 ),
3308 ("last_error", serde_json::json!(last_error)),
3309 ];
3310 if let Err(e) = task_manager
3311 .update_task_status_and_result(&task.id, Status::Blocked, &fields)
3312 .await
3313 {
3314 tracing::error!(task_id = task.id.0, err = %e, "update_task_status_and_result(Blocked) failed — skipping block to avoid silent auto-unblock loop");
3315 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
3316 format!("failed to write block_reason: {e}"),
3317 )));
3318 }
3319 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped));
3320 }
3321
3322 if last_error.contains("422") && last_error.contains("head") {
3323 tracing::info!(
3324 task_id = task.id.0,
3325 branch = %branch_name,
3326 "no PR — 422/head-invalid means work already merged, marking done"
3327 );
3328 if let Err(e) = task_manager
3329 .update_task_status(&task.id, Status::Done)
3330 .await
3331 {
3332 tracing::error!(task_id = task.id.0, err = %e, "update_task_status(Done) failed");
3333 }
3334 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped));
3335 }
3336
3337 if last_error.contains("no fallback agents")
3338 || last_error.contains("all agents exhausted")
3339 {
3340 tracing::warn!(
3341 task_id = task.id.0,
3342 branch = %branch_name,
3343 last_error = %last_error,
3344 "no PR and no commits after failover exhaustion — marking blocked to stop loop"
3345 );
3346 let fields = [
3350 (
3351 "block_reason",
3352 serde_json::json!("all agents exhausted — no PR or commits produced"),
3353 ),
3354 ("last_error", serde_json::json!(last_error)),
3355 ];
3356 if let Err(e) = task_manager
3357 .update_task_status_and_result(&task.id, Status::Blocked, &fields)
3358 .await
3359 {
3360 tracing::error!(task_id = task.id.0, err = %e, "update_task_status_and_result(Blocked) failed — skipping block to avoid silent auto-unblock loop");
3361 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
3362 format!("failed to write block_reason: {e}"),
3363 )));
3364 }
3365 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped));
3366 }
3367
3368 let max_reroutes: u32 = config::get("workflow.max_reroute_attempts")
3375 .ok()
3376 .and_then(|s| s.parse().ok())
3377 .or_else(|| {
3378 config::get("workflow.max_attempts")
3379 .ok()
3380 .and_then(|s| s.parse().ok())
3381 })
3382 .unwrap_or(3);
3383
3384 tracing::warn!(
3385 task_id = task.id.0,
3386 branch = %branch_name,
3387 reason = %reason,
3388 "no PR and no commits — re-routing for retry"
3389 );
3390
3391 if should_skip_no_code_reroute_increment(&last_error) {
3395 tracing::warn!(
3396 task_id = task.id.0,
3397 last_error = %last_error,
3398 "transient GitHub error recorded; skipping persistent no_pr_reroutes increment"
3399 );
3400 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
3401 format!("transient github error: {last_error}"),
3402 )));
3403 }
3404
3405 let reroutes = match store_increment(
3407 &Some(Arc::clone(store)),
3408 repo,
3409 &task.id.0,
3410 "no_code_reroutes",
3411 )
3412 .await
3413 {
3414 Ok(v) => v,
3415 Err(e) => {
3416 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment no_code_reroutes — skipping reroute/block decision this tick");
3417 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
3419 format!("transient store error: {e}"),
3420 )));
3421 }
3422 };
3423
3424 if reroutes as u32 >= max_reroutes {
3425 tracing::error!(
3426 task_id = task.id.0,
3427 reroutes,
3428 max_reroutes,
3429 "reached max reroute attempts for no-pr-result — blocking for human review"
3430 );
3431 let msg = format!(
3432 "no PR or code changes after {}/{} reroute attempts",
3433 reroutes, max_reroutes
3434 );
3435 let fields = [
3439 (
3440 "block_reason",
3441 serde_json::json!(format!(
3442 "max reroute attempts ({}) reached — no PR or code changes produced",
3443 max_reroutes
3444 )),
3445 ),
3446 ("last_error", serde_json::json!(msg)),
3447 ("agent", serde_json::json!(null)),
3448 ("model", serde_json::json!(null)),
3449 ];
3450 if let Err(e) = task_manager
3451 .update_task_status_and_result(&task.id, Status::Blocked, &fields)
3452 .await
3453 {
3454 tracing::error!(task_id = task.id.0, err = %e, "update_task_status_and_result(Blocked) failed — skipping block to avoid silent auto-unblock loop");
3455 }
3456 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Blocked(msg)))
3457 } else if task.id.0.starts_with("internal:") {
3458 tracing::info!(
3465 task_id = task.id.0,
3466 "no PR and no commits for internal task — marking done"
3467 );
3468 if let Err(e) = task_manager
3469 .update_task_status(&task.id, Status::Done)
3470 .await
3471 {
3472 tracing::error!(task_id = task.id.0, err = %e, "update_task_status(Done) failed — task may be stuck in InReview");
3473 }
3474 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped))
3475 } else {
3476 if let Err(e) = store_set_result(
3481 &Some(Arc::clone(store)),
3482 repo,
3483 &task.id.0,
3484 &[
3485 ("agent", serde_json::json!(null)),
3486 ("model", serde_json::json!(null)),
3487 (
3488 "last_error",
3489 serde_json::json!("no PR or code changes produced"),
3490 ),
3491 ],
3492 )
3493 .await
3494 {
3495 tracing::error!(task_id = task.id.0, err = %e, "failed to clear agent/model for reroute — skipping status transition");
3496 } else if let Err(e) =
3498 task_manager.update_task_status(&task.id, Status::New).await
3499 {
3500 tracing::error!(task_id = task.id.0, err = %e, "update_task_status(New) failed — task may be stuck in InReview");
3501 }
3502 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Rerouted))
3503 }
3504 }
3505 }
3506 Err(e) => {
3507 tracing::warn!(
3508 task_id = task.id.0,
3509 branch = %branch_name,
3510 error = %e,
3511 "failed to check PR status"
3512 );
3513 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
3514 format!("PR check failed: {e}"),
3515 )))
3516 }
3517 }
3518}
3519
3520pub(crate) fn review_timeout_seconds() -> u64 {
3522 review_timeout_secs(
3523 crate::config::get("workflow.review_timeout_seconds")
3524 .ok()
3525 .and_then(|s| s.parse().ok()),
3526 )
3527}
3528
3529pub(crate) const REVIEW_RESULT_DELIVERY_SECS: u64 = 120;
3533
3534pub(crate) fn no_session_review_floor(configured: u64, review_timeout: u64) -> u64 {
3538 configured.max(review_timeout + REVIEW_RESULT_DELIVERY_SECS)
3539}
3540
3541pub(crate) async fn review_and_merge(
3547 task: &ExternalTask,
3548 backend: &Arc<dyn ExternalBackend>,
3549 tmux: &Arc<TmuxManager>,
3550 repo: &str,
3551 router: &Arc<RwLock<Router>>,
3552 task_manager: &Arc<TaskManager>,
3553 store: &Arc<TaskStore>,
3554) -> anyhow::Result<ReviewDecision> {
3555 let ctx = match build_review_context(task, repo, router, task_manager, store).await? {
3556 ReviewPhase::Ready(ctx) => ctx,
3557 ReviewPhase::EarlyReturn(decision) => return Ok(decision),
3558 };
3559
3560 let run = match invoke_review_agent(task, tmux, &ctx, repo, store).await {
3561 ReviewPhase::Ready(run) => run,
3562 ReviewPhase::EarlyReturn(decision) => return Ok(decision),
3563 };
3564
3565 let parsed = match parse_review_output(task, repo, &ctx, run, store).await {
3566 ReviewPhase::Ready(parsed) => parsed,
3567 ReviewPhase::EarlyReturn(decision) => return Ok(decision),
3568 };
3569
3570 finalize_review_run(task, &ctx, &parsed, store).await;
3571
3572 let router_config = router.read().await.config.clone();
3573 apply_review_decision(
3574 task,
3575 backend,
3576 repo,
3577 task_manager,
3578 store,
3579 &ctx,
3580 &parsed,
3581 &router_config,
3582 )
3583 .await
3584}
3585
3586#[cfg(test)]
3587mod tests {
3588 use super::*;
3589
3590 #[test]
3591 fn a_review_decision_counts_only_from_a_successful_result() {
3592 let approve = r#"{"type":"result","subtype":"success","is_error":false,"result":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"issues\":[]}"}"#;
3593 let prose = r#"{"type":"result","subtype":"success","is_error":false,"result":"I have finished looking at the code."}"#;
3594 let failed = r#"{"type":"result","subtype":"error","is_error":true,"result":"{\"decision\":\"approve\",\"notes\":\"\",\"issues\":[]}"}"#;
3595 let thinking = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"Reading the diff"}]}}"#;
3596
3597 assert!(output_has_review_decision("claude", approve));
3598 assert!(!output_has_review_decision("claude", prose));
3599 assert!(!output_has_review_decision("claude", failed));
3600 assert!(!output_has_review_decision("claude", thinking));
3601 assert!(!output_has_review_decision("claude", ""));
3602 }
3603
3604 #[test]
3605 fn a_review_gets_15_minutes_unless_configured_and_never_under_5() {
3606 assert_eq!(review_timeout_secs(None), 900);
3607 assert_eq!(review_timeout_secs(Some(600)), 600);
3608 assert_eq!(review_timeout_secs(Some(60)), 300);
3609 assert_eq!(review_timeout_secs(Some(3600)), 3600);
3610 }
3611
3612 use crate::backends::ExternalTask;
3613 use crate::engine::router::RouterConfig;
3614 use crate::github::types::{GitHubReview, GitHubReviewComment, GitHubUser, PullRequestReview};
3615 use crate::store::{TaskStatus, TaskStore};
3616 use tempfile::TempDir;
3617
3618 #[test]
3623 fn no_session_review_floor_raises_a_limit_under_the_review_timeout() {
3624 assert_eq!(
3625 no_session_review_floor(600, 900),
3626 900 + REVIEW_RESULT_DELIVERY_SECS
3627 );
3628 }
3629
3630 #[test]
3631 fn no_session_review_floor_keeps_a_limit_over_the_review_timeout() {
3632 assert_eq!(no_session_review_floor(1800, 900), 1800);
3633 }
3634
3635 #[test]
3636 fn no_session_review_floor_clears_the_review_timeout_by_the_delivery_margin() {
3637 for (configured, review_timeout) in [(0, 0), (1800, 1800), (120, 7200)] {
3638 let floored = no_session_review_floor(configured, review_timeout);
3639 assert!(
3640 floored >= review_timeout + REVIEW_RESULT_DELIVERY_SECS,
3641 "{floored} must leave a finished review time to deliver its result"
3642 );
3643 }
3644 }
3645
3646 #[test]
3647 fn outcome_for_agent_error_kimi_billing_cycle_is_not_rate_limit() {
3648 let err = runner::agents::AgentError::RateLimit {
3652 message: "Failed to authenticate. API Error: 403 You've reached your usage limit \
3653 for this billing cycle. Your quota will be refreshed soon."
3654 .to_string(),
3655 };
3656 assert_eq!(outcome_for_agent_error(&err), "billing_cycle_exhausted");
3657 }
3658
3659 #[test]
3660 fn outcome_for_agent_error_generic_rate_limit_unaffected() {
3661 let err = runner::agents::AgentError::RateLimit {
3662 message: "429 Too Many Requests".to_string(),
3663 };
3664 assert_eq!(outcome_for_agent_error(&err), "rate_limit");
3665 }
3666
3667 #[test]
3668 fn outcome_for_agent_error_generic_auth_unaffected() {
3669 let err = runner::agents::AgentError::Auth {
3670 message: "401 Unauthorized".to_string(),
3671 };
3672 assert_eq!(outcome_for_agent_error(&err), "auth_error");
3673 }
3674
3675 #[test]
3678 fn parse_pr_number_standard_url() {
3679 assert_eq!(
3680 parse_pr_number_from_url("https://github.com/owner/repo/pull/42"),
3681 Ok(42)
3682 );
3683 }
3684
3685 #[test]
3686 fn parse_pr_number_trailing_slash() {
3687 assert_eq!(
3688 parse_pr_number_from_url("https://github.com/owner/repo/pull/42/"),
3689 Ok(42)
3690 );
3691 }
3692
3693 #[test]
3694 fn parse_pr_number_with_query_string() {
3695 assert_eq!(
3696 parse_pr_number_from_url("https://github.com/owner/repo/pull/42?tab=files"),
3697 Ok(42)
3698 );
3699 }
3700
3701 #[test]
3702 fn parse_pr_number_with_hash_fragment() {
3703 assert_eq!(
3704 parse_pr_number_from_url("https://github.com/owner/repo/pull/42#issuecomment-123"),
3705 Ok(42)
3706 );
3707 }
3708
3709 #[test]
3710 fn parse_pr_number_with_query_and_hash() {
3711 assert_eq!(
3712 parse_pr_number_from_url("https://github.com/owner/repo/pull/42?quick_pull=1#top"),
3713 Ok(42)
3714 );
3715 }
3716
3717 #[test]
3718 fn parse_pr_number_with_leading_whitespace() {
3719 assert_eq!(
3720 parse_pr_number_from_url(" https://github.com/owner/repo/pull/42\n"),
3721 Ok(42)
3722 );
3723 }
3724
3725 #[test]
3726 fn parse_pr_number_wrong_domain() {
3727 let url = "https://gitlab.com/owner/repo/merge_requests/42";
3728 let result = parse_pr_number_from_url(url);
3729 assert!(result.is_err());
3730 assert!(result
3731 .unwrap_err()
3732 .contains("does not start with 'https://github.com/'"));
3733 }
3734
3735 #[test]
3736 fn parse_pr_number_missing_pull_segment() {
3737 let url = "https://github.com/owner/repo/issues/42";
3739 let result = parse_pr_number_from_url(url);
3740 assert!(result.is_err());
3741 assert!(result.unwrap_err().contains("missing '/pull/' segment"));
3742 }
3743
3744 #[test]
3745 fn parse_pr_number_zero_is_rejected() {
3746 let url = "https://github.com/owner/repo/pull/0";
3747 let result = parse_pr_number_from_url(url);
3748 assert!(result.is_err());
3749 assert!(result.unwrap_err().contains("cannot be zero"));
3750 }
3751
3752 #[test]
3753 fn parse_pr_number_non_numeric_segment() {
3754 let url = "https://github.com/owner/repo/pull/abc";
3755 let result = parse_pr_number_from_url(url);
3756 assert!(result.is_err());
3757 assert!(result.unwrap_err().contains("not a valid positive integer"));
3758 }
3759
3760 #[test]
3761 fn parse_pr_number_empty_string() {
3762 let result = parse_pr_number_from_url("");
3763 assert!(result.is_err());
3764 assert!(result.unwrap_err().contains("empty"));
3765 }
3766
3767 #[test]
3768 fn parse_pr_number_http_not_https() {
3769 let url = "http://github.com/owner/repo/pull/42";
3771 let result = parse_pr_number_from_url(url);
3772 assert!(result.is_err());
3773 assert!(result
3774 .unwrap_err()
3775 .contains("does not start with 'https://github.com/'"));
3776 }
3777
3778 #[test]
3786 fn format_review_error_summarises_rate_limit_fragment_with_api_retry() {
3787 let raw_fragment = "{\"attempt\":6,\"max_retries\":10,\"retry_delay_ms\":17266.789,\"error_status\":429,\"error\":\"rate_limit\",\"session_id\":\"5c7148ee\"}\n\
3791 {\"type\":\"system\",\"subtype\":\"api_retry\",\"attempt\":7,\"max_retries\":10,\"retry_delay_ms\":35162.66,\"error_status\":429,\"error\":\"rate_limit\",\"session_id\":\"5c7148ee\"}";
3792 let err = runner::agents::AgentError::RateLimit {
3793 message: raw_fragment.to_string(),
3794 };
3795
3796 let stored = format_review_error("minimax", &err);
3797
3798 assert!(
3801 !stored.contains("{\"type\""),
3802 "stored error must not contain raw NDJSON envelope, got: {stored}"
3803 );
3804 assert!(
3805 !stored.contains("subtype"),
3806 "stored error must not contain raw api_retry fragment, got: {stored}"
3807 );
3808 assert!(
3809 !stored.contains("session_id"),
3810 "stored error must not contain session_id from provider payload, got: {stored}"
3811 );
3812 assert_eq!(
3813 stored,
3814 "minimax rate limit: status=429 after 7 attempts (last delay 35s)"
3815 );
3816 }
3817
3818 #[test]
3819 fn format_review_error_summarises_truncated_fragment_without_subtype_marker() {
3820 let raw_fragment = ",\"attempt\":5,\"max_retries\":10,\"retry_delay_ms\":9034.81,\"error_status\":429,\"error\":\"rate_limit\",\"session_id\":\"992e17d3\"";
3825 let err = runner::agents::AgentError::RateLimit {
3826 message: raw_fragment.to_string(),
3827 };
3828
3829 let stored = format_review_error("glm", &err);
3830
3831 assert!(stored.starts_with("glm rate limit:"));
3832 assert!(
3833 !stored.contains("session_id"),
3834 "stored error must not leak session_id from truncated fragment, got: {stored}"
3835 );
3836 assert!(
3839 stored.len() <= 200,
3840 "stored error should be compact (<=200 chars), got {} chars: {stored}",
3841 stored.len()
3842 );
3843 }
3844
3845 #[test]
3846 fn format_review_error_preserves_non_rate_limit_variants() {
3847 let err = runner::agents::AgentError::Auth {
3848 message: "invalid bearer token".to_string(),
3849 };
3850 assert_eq!(
3851 format_review_error("kimi", &err),
3852 "kimi auth error: invalid bearer token"
3853 );
3854
3855 let err = runner::agents::AgentError::Timeout {
3856 elapsed: std::time::Duration::from_secs(1801),
3857 };
3858 assert_eq!(
3859 format_review_error("kimi", &err),
3860 "kimi timed out after 1801s"
3861 );
3862 }
3863
3864 fn git(dir: &std::path::Path, args: &[&str]) {
3865 let mut cmd = std::process::Command::new("git");
3866 cmd.current_dir(dir);
3867 if args.first() == Some(&"commit") {
3869 cmd.args(["-c", "commit.gpgsign=false"]);
3870 }
3871 let status = cmd.args(args).status().unwrap();
3872 assert!(status.success(), "git {:?} failed", args);
3873 }
3874
3875 #[test]
3876 fn test_pull_request_review_requests_changes() {
3877 let review = PullRequestReview {
3878 review: GitHubReview {
3879 id: 1,
3880 user: GitHubUser {
3881 login: "reviewer".to_string(),
3882 },
3883 body: Some("Please fix".to_string()),
3884 state: "CHANGES_REQUESTED".to_string(),
3885 html_url: None,
3886 submitted_at: "2024-01-01T00:00:00Z".to_string(),
3887 commit_id: None,
3888 },
3889 comments: vec![],
3890 };
3891 assert!(review.requests_changes());
3892 }
3893
3894 #[test]
3895 fn test_pull_request_review_does_not_request_changes() {
3896 let review = PullRequestReview {
3897 review: GitHubReview {
3898 id: 1,
3899 user: GitHubUser {
3900 login: "reviewer".to_string(),
3901 },
3902 body: Some("LGTM".to_string()),
3903 state: "APPROVED".to_string(),
3904 html_url: None,
3905 submitted_at: "2024-01-01T00:00:00Z".to_string(),
3906 commit_id: None,
3907 },
3908 comments: vec![],
3909 };
3910 assert!(!review.requests_changes());
3911 }
3912
3913 #[test]
3914 fn test_pull_request_review_actionable_comments_filters_empty_and_replies() {
3915 let review = PullRequestReview {
3916 review: GitHubReview {
3917 id: 1,
3918 user: GitHubUser {
3919 login: "reviewer".to_string(),
3920 },
3921 body: None,
3922 state: "CHANGES_REQUESTED".to_string(),
3923 html_url: None,
3924 submitted_at: "2024-01-01T00:00:00Z".to_string(),
3925 commit_id: None,
3926 },
3927 comments: vec![
3928 GitHubReviewComment {
3929 id: 1,
3930 user: GitHubUser {
3931 login: "reviewer".to_string(),
3932 },
3933 body: "Fix this issue".to_string(),
3934 path: "src/main.rs".to_string(),
3935 line: Some(10),
3936 original_line: Some(10),
3937 commit_id: "abc".to_string(),
3938 original_commit_id: "abc".to_string(),
3939 html_url: "url".to_string(),
3940 created_at: "2024-01-01T00:00:00Z".to_string(),
3941 updated_at: "2024-01-01T00:00:00Z".to_string(),
3942 in_reply_to_id: None,
3943 diff_hunk: Some(
3944 "@@ -8,5 +8,5 @@ fn main() {\n- let x = 1;\n+ let x = 2;".to_string(),
3945 ),
3946 },
3947 GitHubReviewComment {
3948 id: 2,
3949 user: GitHubUser {
3950 login: "reviewer".to_string(),
3951 },
3952 body: "".to_string(),
3953 path: "src/lib.rs".to_string(),
3954 line: Some(20),
3955 original_line: Some(20),
3956 commit_id: "abc".to_string(),
3957 original_commit_id: "abc".to_string(),
3958 html_url: "url".to_string(),
3959 created_at: "2024-01-01T00:00:00Z".to_string(),
3960 updated_at: "2024-01-01T00:00:00Z".to_string(),
3961 in_reply_to_id: None,
3962 diff_hunk: None,
3963 },
3964 GitHubReviewComment {
3965 id: 3,
3966 user: GitHubUser {
3967 login: "reviewer".to_string(),
3968 },
3969 body: "Reply to this".to_string(),
3970 path: "src/lib.rs".to_string(),
3971 line: Some(30),
3972 original_line: Some(30),
3973 commit_id: "abc".to_string(),
3974 original_commit_id: "abc".to_string(),
3975 html_url: "url".to_string(),
3976 created_at: "2024-01-01T00:00:00Z".to_string(),
3977 updated_at: "2024-01-01T00:00:00Z".to_string(),
3978 in_reply_to_id: Some(1),
3979 diff_hunk: None,
3980 },
3981 ],
3982 };
3983 let actionable = review.actionable_comments();
3984 assert_eq!(actionable.len(), 1);
3985 assert_eq!(actionable[0].id, 1);
3986 assert_eq!(actionable[0].body, "Fix this issue");
3987 assert_eq!(actionable[0].path, "src/main.rs");
3988 assert_eq!(
3989 actionable[0].diff_hunk.as_ref().unwrap(),
3990 "@@ -8,5 +8,5 @@ fn main() {\n- let x = 1;\n+ let x = 2;"
3991 );
3992 }
3993
3994 #[test]
3995 fn test_router_config_model_for_complexity_returns_none_without_config() {
3996 let cfg = RouterConfig::default();
3999 assert!(cfg.model_for_complexity("claude", "simple", "").is_none());
4000 assert!(cfg.model_for_complexity("opencode", "review", "").is_none());
4001 assert!(cfg
4002 .model_for_complexity("unknown_agent_xyz", "simple", "")
4003 .is_none());
4004 }
4005
4006 #[test]
4007 fn review_status_maps_decisions() {
4008 assert_eq!(
4009 review_status(&ReviewDecision::Approve { issues: vec![] })
4010 .unwrap()
4011 .0,
4012 "success"
4013 );
4014 let changes = ReviewDecision::RequestChanges {
4015 notes: String::new(),
4016 issues: vec![],
4017 };
4018 assert_eq!(review_status(&changes).unwrap().0, "failure");
4019 assert!(review_status(&ReviewDecision::Failed("x".into())).is_none());
4020 }
4021
4022 fn sample_review_issue(severity: &str) -> runner::response::ReviewIssue {
4023 runner::response::ReviewIssue {
4024 file: "src/engine/tick.rs".into(),
4025 line: Some(142),
4026 severity: severity.into(),
4027 description: "index out of bounds on the last element".into(),
4028 }
4029 }
4030
4031 fn sample_review_response(
4032 decision: &str,
4033 test_results: Option<&str>,
4034 issues: Vec<runner::response::ReviewIssue>,
4035 ) -> runner::response::ReviewResponse {
4036 runner::response::ReviewResponse {
4037 decision: decision.into(),
4038 notes: "looks fine".into(),
4039 test_results: test_results.map(str::to_string),
4040 issues,
4041 }
4042 }
4043
4044 #[test]
4045 fn approve_with_failing_tests_becomes_request_changes() {
4046 let decision =
4047 review_decision_from_response(sample_review_response("approve", Some("fail"), vec![]));
4048 match decision {
4049 ReviewDecision::RequestChanges { notes, issues } => {
4050 assert!(
4051 notes.contains("test_results: fail"),
4052 "notes must name the failing test run, got {notes:?}"
4053 );
4054 assert!(issues.is_empty());
4055 }
4056 other => panic!("expected RequestChanges, got {other:?}"),
4057 }
4058 }
4059
4060 #[test]
4061 fn approve_with_error_issue_becomes_request_changes() {
4062 let issue = sample_review_issue("error");
4063 let decision = review_decision_from_response(sample_review_response(
4064 "approve",
4065 Some("pass"),
4066 vec![issue.clone()],
4067 ));
4068 match decision {
4069 ReviewDecision::RequestChanges { issues, .. } => {
4070 assert_eq!(issues, vec![issue]);
4071 }
4072 other => panic!("expected RequestChanges, got {other:?}"),
4073 }
4074 }
4075
4076 #[test]
4077 fn approve_with_warning_issue_stays_approve_and_comment_lists_it() {
4078 let issue = sample_review_issue("warning");
4079 let decision = review_decision_from_response(sample_review_response(
4080 "approve",
4081 Some("pass"),
4082 vec![issue.clone()],
4083 ));
4084 match &decision {
4085 ReviewDecision::Approve { issues } => {
4086 assert_eq!(issues, &vec![issue.clone()]);
4087 }
4088 other => panic!("expected Approve, got {other:?}"),
4089 }
4090 let comment = build_pr_review_comment(&decision, "looks fine");
4091 assert!(
4092 comment.contains("Approve"),
4093 "approve header missing from {comment}"
4094 );
4095 assert!(
4096 comment.contains(&issue.description),
4097 "warning issue missing from {comment}"
4098 );
4099 assert!(
4100 comment.contains("warning"),
4101 "warning severity missing from {comment}"
4102 );
4103 }
4104
4105 #[test]
4106 fn review_started_comment_includes_agent_and_model() {
4107 let expected_footer = attribution_footer("Review started", "kimi", Some("opus"));
4108 let expected = format!("🔍 Automated review started{}", expected_footer);
4109 assert_eq!(review_started_comment("kimi", "opus"), expected);
4110 }
4111
4112 fn no_code_review_task(id: &str) -> ExternalTask {
4115 ExternalTask {
4116 id: crate::backends::ExternalId(id.to_string()),
4117 title: "opencode discovery timeout".to_string(),
4118 body: "".to_string(),
4119 state: "open".to_string(),
4120 labels: vec![],
4121 author: "tester".to_string(),
4122 created_at: "2026-01-01T00:00:00Z".to_string(),
4123 updated_at: "2026-01-01T00:00:00Z".to_string(),
4124 url: "https://example.com/issues/1".to_string(),
4125 }
4126 }
4127
4128 #[tokio::test]
4132 async fn carried_approval_counts_as_this_rounds_decision() {
4133 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4134 let id = store
4135 .create_internal("owner/repo", "Approved head", "", "test", "carried", None)
4136 .await
4137 .unwrap();
4138 let task_id = format!("internal:{id}");
4139 store
4140 .update_status(id, crate::store::TaskStatus::InReview)
4141 .await
4142 .unwrap();
4143 assert_eq!(
4144 store.in_review_entry(id).await.unwrap().map(|e| e.1),
4145 Some(false)
4146 );
4147
4148 record_carried_approval(&store, "owner/repo", &task_id, 7).await;
4149
4150 assert_eq!(
4151 store.in_review_entry(id).await.unwrap().map(|e| e.1),
4152 Some(true),
4153 "the stale in_review check must see this round as decided"
4154 );
4155 }
4156
4157 #[test]
4158 fn approval_covers_head_only_for_the_exact_commit() {
4159 assert!(approval_covers_head(Some("abc123"), Some("abc123")));
4160 assert!(
4161 !approval_covers_head(Some("abc123"), Some("def456")),
4162 "a moved PR head must be reviewed"
4163 );
4164 assert!(
4165 !approval_covers_head(None, Some("abc123")),
4166 "no recorded approval means review"
4167 );
4168 assert!(
4169 !approval_covers_head(Some("abc123"), None),
4170 "an unreadable PR head must not skip the review"
4171 );
4172 assert!(!approval_covers_head(Some(""), Some("")));
4173 }
4174
4175 fn pr_for(
4176 head_repo: Option<&str>,
4177 head: &str,
4178 base: &str,
4179 ) -> crate::github::types::GitHubPullRequest {
4180 serde_json::from_value(serde_json::json!({
4181 "number": 7,
4182 "title": "t",
4183 "state": "open",
4184 "head": {"ref": head, "sha": "h", "repo": head_repo.map(|r| serde_json::json!({"full_name": r}))},
4185 "base": {"ref": base, "sha": "b"},
4186 "html_url": "https://github.com/owner/repo/pull/7",
4187 }))
4188 .unwrap()
4189 }
4190
4191 #[test]
4192 fn a_carried_approval_must_match_the_approved_repo_branch_and_base() {
4193 let approval = ApprovalEvent {
4194 sha: "a".into(),
4195 repo: "owner/repo".into(),
4196 branch: "gh-issue-1".into(),
4197 base: "main".into(),
4198 };
4199 let ok = pr_for(Some("owner/repo"), "gh-issue-1", "main");
4200 assert!(pr_matches_approval(&ok, &approval, "gh-issue-1"));
4201 assert!(!pr_matches_approval(
4202 &pr_for(Some("fork/repo"), "gh-issue-1", "main"),
4203 &approval,
4204 "gh-issue-1"
4205 ));
4206 assert!(!pr_matches_approval(
4207 &pr_for(None, "gh-issue-1", "main"),
4208 &approval,
4209 "gh-issue-1"
4210 ));
4211 assert!(!pr_matches_approval(
4212 &pr_for(Some("owner/repo"), "other", "main"),
4213 &approval,
4214 "gh-issue-1"
4215 ));
4216 assert!(!pr_matches_approval(
4217 &pr_for(Some("owner/repo"), "gh-issue-1", "release"),
4218 &approval,
4219 "gh-issue-1"
4220 ));
4221 assert!(!pr_matches_approval(&ok, &approval, "another-task-branch"));
4222
4223 let legacy = ApprovalEvent::parse("abc123").unwrap();
4224 assert_eq!(legacy.sha, "abc123");
4225 assert!(!pr_matches_approval(&ok, &legacy, "gh-issue-1"));
4226 let json = serde_json::to_string(&approval).unwrap();
4227 assert_eq!(ApprovalEvent::parse(&json), Some(approval));
4228 assert_eq!(ApprovalEvent::parse(" "), None);
4229 }
4230
4231 #[tokio::test]
4232 async fn only_the_same_commits_rebased_onto_the_base_carry_the_approval() {
4233 let dir = tempfile::tempdir().unwrap();
4234 let path = dir.path();
4235 let git = |args: &[&str]| {
4236 let out = std::process::Command::new("git")
4237 .args([
4238 "-c",
4239 "user.name=agent",
4240 "-c",
4241 "user.email=agent@t",
4242 "-c",
4243 "commit.gpgsign=false",
4244 ])
4245 .args(args)
4246 .env_remove("GIT_AUTHOR_NAME")
4247 .env_remove("GIT_AUTHOR_EMAIL")
4248 .env_remove("GIT_COMMITTER_NAME")
4249 .env_remove("GIT_COMMITTER_EMAIL")
4250 .current_dir(path)
4251 .output()
4252 .unwrap();
4253 assert!(
4254 out.status.success(),
4255 "git {args:?}: {}",
4256 String::from_utf8_lossy(&out.stderr)
4257 );
4258 String::from_utf8_lossy(&out.stdout).trim().to_string()
4259 };
4260 let write_commit = |file: &str, body: &str| {
4261 std::fs::write(path.join(file), body).unwrap();
4262 git(&["add", file]);
4263 git(&["commit", "-q", "-m", body]);
4264 };
4265 git(&["init", "-q", "-b", "main"]);
4266 write_commit("a", "base");
4267 git(&["checkout", "-q", "-b", "pr"]);
4268 write_commit("b", "first");
4269 write_commit("c", "second");
4270 let approved = git(&["rev-parse", "HEAD"]);
4271 git(&["checkout", "-q", "main"]);
4272 write_commit("d", "base moved");
4273 git(&["checkout", "-q", "-b", "rebased", &approved]);
4274 git(&["rebase", "-q", "main"]);
4275 let rebased = git(&["rev-parse", "HEAD"]);
4276 assert!(same_commits_rebased(path, &approved, "main", &rebased).await);
4277
4278 write_commit("c", "second, changed");
4280 let changed = git(&["rev-parse", "HEAD"]);
4281 assert!(!same_commits_rebased(path, &approved, "main", &changed).await);
4282
4283 git(&["checkout", "-q", "-b", "other-author", &rebased]);
4285 git(&[
4286 "-c",
4287 "user.name=someone",
4288 "-c",
4289 "user.email=someone@t",
4290 "commit",
4291 "-q",
4292 "--amend",
4293 "--reset-author",
4294 "--no-edit",
4295 ]);
4296 let reauthored = git(&["rev-parse", "HEAD"]);
4297 assert!(!same_commits_rebased(path, &approved, "main", &reauthored).await);
4298 }
4299
4300 #[tokio::test]
4301 async fn only_a_clean_merge_of_the_base_counts_as_base_only() {
4302 let dir = tempfile::tempdir().unwrap();
4303 let path = dir.path();
4304 let git = |args: &[&str]| {
4305 let out = std::process::Command::new("git")
4306 .args([
4307 "-c",
4308 "user.name=t",
4309 "-c",
4310 "user.email=t@t",
4311 "-c",
4312 "commit.gpgsign=false",
4313 ])
4314 .args(args)
4315 .env_remove("GIT_AUTHOR_NAME")
4316 .env_remove("GIT_AUTHOR_EMAIL")
4317 .env_remove("GIT_COMMITTER_NAME")
4318 .env_remove("GIT_COMMITTER_EMAIL")
4319 .current_dir(path)
4320 .output()
4321 .unwrap();
4322 assert!(
4323 out.status.success(),
4324 "git {args:?}: {}",
4325 String::from_utf8_lossy(&out.stderr)
4326 );
4327 String::from_utf8_lossy(&out.stdout).trim().to_string()
4328 };
4329 let commit = |file: &str, msg: &str| {
4330 std::fs::write(path.join(file), msg).unwrap();
4331 git(&["add", file]);
4332 git(&["commit", "-q", "-m", msg]);
4333 git(&["rev-parse", "HEAD"])
4334 };
4335 git(&["init", "-q", "-b", "main"]);
4336 commit("a", "base");
4337 git(&["checkout", "-q", "-b", "pr"]);
4338 let approved = commit("b", "approved change");
4339 git(&["checkout", "-q", "main"]);
4340 let base_tip = commit("c", "base moved");
4341 git(&["checkout", "-q", "pr"]);
4342 git(&["merge", "-q", "--no-edit", "main"]);
4343 let clean = git(&["rev-parse", "HEAD"]);
4344 assert!(is_clean_merge(path, &approved, &base_tip, &clean).await);
4345
4346 std::fs::write(path.join("d"), "unreviewed").unwrap();
4347 git(&["add", "d"]);
4348 git(&["commit", "-q", "--amend", "--no-edit"]);
4349 let forged = git(&["rev-parse", "HEAD"]);
4350 assert!(!is_clean_merge(path, &approved, &base_tip, &forged).await);
4351 }
4352
4353 #[test]
4354 fn a_review_agent_cannot_change_the_repository_remotes() {
4355 let tools = build_review_disallowed_tools(std::path::Path::new("/tmp/wt"));
4356 for guard in runner::git_ops::remote_change_tools() {
4357 assert!(tools.contains(&guard), "missing {guard}");
4358 }
4359 }
4360
4361 #[test]
4362 fn only_a_merge_of_the_approved_commit_carries_the_approval() {
4363 let parents = |p: &[&str]| p.iter().map(|s| s.to_string()).collect::<Vec<_>>();
4364 assert_eq!(
4365 base_merge_parent("approved", &parents(&["approved", "base"])),
4366 Some("base")
4367 );
4368 assert_eq!(base_merge_parent("approved", &parents(&["approved"])), None);
4370 assert_eq!(
4371 base_merge_parent("approved", &parents(&["other", "base"])),
4372 None
4373 );
4374 assert_eq!(
4375 base_merge_parent("approved", &parents(&["approved", "a", "b"])),
4376 None
4377 );
4378 }
4379
4380 #[tokio::test]
4385 async fn approved_sha_round_trips_through_the_kv_store() {
4386 let temp = TempDir::new().unwrap();
4387 let wt = temp.path();
4388 git(wt, &["init", "-b", "main"]);
4389 git(wt, &["config", "user.name", "Test"]);
4390 git(wt, &["config", "user.email", "test@test.com"]);
4391 std::fs::write(wt.join("fix.txt"), "the fix").unwrap();
4392 git(wt, &["add", "."]);
4393 git(wt, &["commit", "-m", "apply the fix"]);
4394
4395 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4396 let repo = "owner/repo";
4397 let task_id = "3783";
4398
4399 record_approved_sha(&store, repo, task_id, 77, wt, "gh-issue-3783", "main").await;
4400 let recorded = read_approval(&store, repo, task_id, 77)
4401 .await
4402 .unwrap()
4403 .expect("approval must be recorded");
4404 assert_eq!(recorded.repo, repo);
4405 assert_eq!(recorded.branch, "gh-issue-3783");
4406 assert_eq!(recorded.base, "main");
4407 assert_eq!(
4408 recorded.sha,
4409 worktree_head_sha(wt).await.unwrap(),
4410 "the recorded sha must be the commit that was pushed to the PR"
4411 );
4412 assert!(
4413 store
4414 .kv_get(&approved_sha_key(repo, task_id, 78))
4415 .await
4416 .unwrap()
4417 .is_none(),
4418 "an approval must not leak to another PR"
4419 );
4420
4421 clear_approved_sha(&store, repo, task_id, 77).await;
4422 assert!(
4423 store
4424 .kv_get(&approved_sha_key(repo, task_id, 77))
4425 .await
4426 .unwrap()
4427 .is_none(),
4428 "requesting changes must drop the approval"
4429 );
4430 }
4431
4432 #[tokio::test]
4433 async fn build_review_context_reroutes_external_task_with_no_code() {
4434 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4435 let repo = "owner/repo";
4436 let ext = no_code_review_task("3617");
4437 store.ensure_external_task(repo, &ext).await.unwrap();
4438
4439 let backend = Arc::new(crate::backends::test_helpers::NoopBackend);
4440 let task_manager = Arc::new(TaskManager::with_store(
4441 backend,
4442 Arc::clone(&store),
4443 repo.to_string(),
4444 ));
4445 let router = Arc::new(RwLock::new(Router::new(RouterConfig::default())));
4446
4447 let phase = build_review_context(&ext, repo, &router, &task_manager, &store)
4448 .await
4449 .unwrap();
4450
4451 assert!(
4452 matches!(phase, ReviewPhase::EarlyReturn(ReviewDecision::Rerouted)),
4453 "external task with no worktree/branch/pr must reroute, not skip-to-done"
4454 );
4455
4456 let stored = store
4457 .get_by_external_id(repo, &ext.id.0)
4458 .await
4459 .unwrap()
4460 .expect("task must still exist");
4461 assert_eq!(stored.status, TaskStatus::New);
4462 assert!(stored.agent.is_none());
4463 assert!(stored.model.is_none());
4464 }
4465
4466 #[tokio::test]
4467 async fn build_review_context_blocks_external_task_after_max_reroutes() {
4468 let temp_home = TempDir::new().unwrap();
4471 let orch_dir = temp_home.path().join(".orch");
4472 std::fs::create_dir_all(&orch_dir).unwrap();
4473 let old_orch_home = std::env::var_os("ORCH_HOME");
4474 std::env::set_var("ORCH_HOME", &orch_dir);
4475
4476 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4477 let repo = "owner/repo";
4478 let ext = no_code_review_task("3618");
4479 store.ensure_external_task(repo, &ext).await.unwrap();
4480
4481 let backend = Arc::new(crate::backends::test_helpers::NoopBackend);
4482 let task_manager = Arc::new(TaskManager::with_store(
4483 backend,
4484 Arc::clone(&store),
4485 repo.to_string(),
4486 ));
4487 let router = Arc::new(RwLock::new(Router::new(RouterConfig::default())));
4488
4489 for _ in 0..3 {
4491 let phase = build_review_context(&ext, repo, &router, &task_manager, &store)
4492 .await
4493 .unwrap();
4494 if let ReviewPhase::EarlyReturn(ReviewDecision::Blocked(_)) = phase {
4495 break;
4496 }
4497 }
4498
4499 let stored = store
4500 .get_by_external_id(repo, &ext.id.0)
4501 .await
4502 .unwrap()
4503 .expect("task must still exist");
4504 assert_eq!(stored.status, TaskStatus::Blocked);
4505
4506 if let Some(old) = old_orch_home {
4507 std::env::set_var("ORCH_HOME", old);
4508 } else {
4509 std::env::remove_var("ORCH_HOME");
4510 }
4511 }
4512
4513 #[tokio::test]
4514 async fn build_review_context_marks_internal_task_done_with_no_code() {
4515 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4516 let repo = "owner/repo";
4517 let internal_id = store
4518 .create_internal(repo, "Daily retrospective", "", "job", "job-1", None)
4519 .await
4520 .unwrap();
4521 let ext_id = format!("internal:{internal_id}");
4522 let ext = no_code_review_task(&ext_id);
4523
4524 let backend = Arc::new(crate::backends::test_helpers::NoopBackend);
4525 let task_manager = Arc::new(TaskManager::with_store(
4526 backend,
4527 Arc::clone(&store),
4528 repo.to_string(),
4529 ));
4530 let router = Arc::new(RwLock::new(Router::new(RouterConfig::default())));
4531
4532 let phase = build_review_context(&ext, repo, &router, &task_manager, &store)
4533 .await
4534 .unwrap();
4535
4536 assert!(matches!(
4537 phase,
4538 ReviewPhase::EarlyReturn(ReviewDecision::Skipped)
4539 ));
4540 let stored = store.get(internal_id).await.unwrap();
4541 assert_eq!(stored.status, TaskStatus::Done);
4542 }
4543
4544 #[test]
4545 fn no_code_reroute_skip_check_rejects_agent_stream_disconnects() {
4546 let last_error =
4547 "codex failed: Reconnecting... (stream disconnected before completion: Broken pipe)";
4548 assert!(!should_skip_no_code_reroute_increment(last_error));
4549 }
4550
4551 #[test]
4560 fn review_fallback_scan_ignores_rate_limit_word_buried_in_long_report() {
4561 let padding = "x".repeat(10_000);
4562 let text_for_review = format!(
4563 "{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}"
4564 );
4565
4566 let scan_head = runner::agents::patterns::safe_head(
4567 &text_for_review,
4568 runner::agents::patterns::RATE_LIMIT_SCAN_HEAD_BYTES,
4569 );
4570 let scan_tail = runner::agents::patterns::safe_tail(
4571 &text_for_review,
4572 runner::agents::patterns::RATE_LIMIT_SCAN_TAIL_BYTES,
4573 );
4574
4575 assert!(runner::agents::patterns::detect_rate_limit(scan_head).is_none());
4576 assert!(runner::agents::patterns::detect_rate_limit(scan_tail).is_none());
4577 }
4578
4579 #[test]
4580 fn review_fallback_scan_still_detects_real_rate_limit_at_tail() {
4581 let padding = "x".repeat(10_000);
4582 let text_for_review =
4583 format!("{padding}\nError: rate limit exceeded, please try again later");
4584
4585 let scan_tail = runner::agents::patterns::safe_tail(
4586 &text_for_review,
4587 runner::agents::patterns::RATE_LIMIT_SCAN_TAIL_BYTES,
4588 );
4589
4590 assert!(runner::agents::patterns::detect_rate_limit(scan_tail).is_some());
4591 }
4592
4593 #[tokio::test]
4594 async fn recover_missing_review_worktree_recreates_worktree() {
4595 let temp_home = TempDir::new().unwrap();
4596 let orch_dir = temp_home.path().join(".orch");
4597 std::fs::create_dir_all(&orch_dir).unwrap();
4598 let old_orch_home = std::env::var_os("ORCH_HOME");
4599 std::env::set_var("ORCH_HOME", &orch_dir);
4600
4601 let project_dir = temp_home.path().join("project");
4602 std::fs::create_dir_all(&project_dir).unwrap();
4603 git(&project_dir, &["init", "-b", "main"]);
4604 git(&project_dir, &["config", "user.name", "Test User"]);
4605 git(&project_dir, &["config", "user.email", "test@example.com"]);
4606 std::fs::write(project_dir.join("README.md"), "hello").unwrap();
4607 git(&project_dir, &["add", "README.md"]);
4608 git(&project_dir, &["commit", "-m", "init"]);
4609
4610 std::fs::write(
4611 orch_dir.join("config.yml"),
4612 format!("projects:\n - {}\n", project_dir.display()),
4613 )
4614 .unwrap();
4615 std::fs::write(project_dir.join(".orch.yml"), "gh:\n repo: owner/repo\n").unwrap();
4616
4617 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4618 let repo = "owner/repo";
4619 store
4620 .create_internal(repo, "Fix review recovery", "", "review", "review-1", None)
4621 .await
4622 .unwrap();
4623 let ext = ExternalTask {
4624 id: crate::backends::ExternalId("1".to_string()),
4625 title: "Fix review recovery".to_string(),
4626 body: "".to_string(),
4627 state: "open".to_string(),
4628 labels: vec![],
4629 author: "tester".to_string(),
4630 created_at: "2026-01-01T00:00:00Z".to_string(),
4631 updated_at: "2026-01-01T00:00:00Z".to_string(),
4632 url: "https://example.com/issues/1".to_string(),
4633 };
4634 store.ensure_external_task(repo, &ext).await.unwrap();
4635
4636 let recovered = recover_missing_review_worktree(&ext, repo, &store)
4637 .await
4638 .unwrap();
4639
4640 assert!(recovered.work_dir.exists());
4641 assert!(recovered.branch.starts_with("gh-issue-1-"));
4642
4643 if let Some(old) = old_orch_home {
4644 std::env::set_var("ORCH_HOME", old);
4645 } else {
4646 std::env::remove_var("ORCH_HOME");
4647 }
4648 }
4649
4650 #[tokio::test]
4653 async fn count_ahead_commits_returns_zero_when_no_ahead_commits() {
4654 let temp = TempDir::new().unwrap();
4655 let dir = temp.path();
4656
4657 let bare = temp.path().join("bare.git");
4659 std::fs::create_dir_all(&bare).unwrap();
4660 std::process::Command::new("git")
4661 .args(["init", "--bare"])
4662 .current_dir(&bare)
4663 .output()
4664 .unwrap();
4665
4666 git(dir, &["init", "-b", "main"]);
4667 git(dir, &["config", "user.name", "Test"]);
4668 git(dir, &["config", "user.email", "test@test.com"]);
4669 std::fs::write(dir.join("f"), "a").unwrap();
4670 git(dir, &["add", "f"]);
4671 git(dir, &["commit", "-m", "init"]);
4672 std::process::Command::new("git")
4673 .args(["remote", "add", "origin", bare.to_str().unwrap()])
4674 .current_dir(dir)
4675 .output()
4676 .unwrap();
4677 std::process::Command::new("git")
4678 .args(["push", "origin", "main"])
4679 .current_dir(dir)
4680 .output()
4681 .unwrap();
4682
4683 let result = count_ahead_commits(dir, "main").await;
4685 assert!(
4686 result.is_ok(),
4687 "expected Ok when origin/main exists, got {result:?}"
4688 );
4689 assert_eq!(
4690 result.unwrap(),
4691 0,
4692 "expected 0 commits ahead when branch matches origin/main"
4693 );
4694 }
4695
4696 #[tokio::test]
4697 async fn count_ahead_commits_returns_error_when_remote_ref_missing() {
4698 let temp = TempDir::new().unwrap();
4699 let dir = temp.path();
4700 git(dir, &["init", "-b", "main"]);
4701 git(dir, &["config", "user.name", "Test"]);
4702 git(dir, &["config", "user.email", "test@test.com"]);
4703 std::fs::write(dir.join("f"), "a").unwrap();
4704 git(dir, &["add", "f"]);
4705 git(dir, &["commit", "-m", "init"]);
4706 let result = count_ahead_commits(dir, "main").await;
4708 assert!(
4709 result.is_err(),
4710 "expected Err when origin/main does not exist, got {result:?}"
4711 );
4712 let err = result.unwrap_err();
4713 assert!(
4714 err.contains("git rev-list exited"),
4715 "error should mention exit failure, got: {err}"
4716 );
4717 }
4718
4719 #[tokio::test]
4720 async fn count_ahead_commits_returns_error_when_git_cannot_start() {
4721 let temp = TempDir::new().unwrap();
4723 let dir = temp.path();
4724 let result = count_ahead_commits(dir, "main").await;
4726 assert!(
4727 result.is_err(),
4728 "expected Err when directory is not a git repo, got {result:?}"
4729 );
4730 }
4731
4732 #[serial_test::serial(cooldown_state)]
4736 #[tokio::test]
4737 async fn review_invalid_response_applies_short_model_cooldown() {
4738 crate::engine::cooldown::reset_global_state().await;
4739 let agent = "opencode-3314-review-invalid";
4740 let model = "opencode/north-mini-code-free";
4741 let key = format!("{agent}:{model}");
4742
4743 assert!(
4744 !crate::engine::cooldown::is_model_in_cooldown(agent, model),
4745 "model should not be in cooldown before record_review_agent_failure"
4746 );
4747
4748 let err = runner::agents::AgentError::InvalidResponse {
4749 raw: "empty review output".to_string(),
4750 };
4751 record_review_agent_failure("test-3314-a", agent, Some(model), &err).await;
4752
4753 let now = chrono::Utc::now().timestamp();
4754 let until = crate::engine::cooldown::cooldown_until(&key)
4755 .expect("review InvalidResponse should set model cooldown");
4756 let remaining = until.saturating_sub(now);
4757 let base = crate::engine::cooldown::TRANSIENT_MODEL_BACKOFF_BASE_SECS;
4758 assert!(
4759 (base - 30..=base).contains(&remaining),
4760 "first unusable review output must cool the model for 5 minutes, got {remaining}s"
4761 );
4762 }
4763
4764 #[serial_test::serial(cooldown_state)]
4766 #[tokio::test]
4767 async fn review_model_unavailable_transient_applies_short_cooldown() {
4768 crate::engine::cooldown::reset_global_state().await;
4769 let agent = "test-review-unavailable-transient";
4770 let model = "test-model";
4771 let key = format!("{agent}:{model}");
4772
4773 let err = runner::agents::AgentError::ModelUnavailable {
4774 message: "model is temporarily overloaded".to_string(),
4775 model: model.to_string(),
4776 };
4777 record_review_agent_failure("test-unavailable-a", agent, Some(model), &err).await;
4778
4779 let now = chrono::Utc::now().timestamp();
4780 let until = crate::engine::cooldown::cooldown_until(&key)
4781 .expect("review ModelUnavailable should set model cooldown");
4782 let remaining = until.saturating_sub(now);
4783 let base = crate::engine::cooldown::BACKOFF_BASE_SECS;
4784 assert!(
4785 (base - 30..=base).contains(&remaining),
4786 "a non-permanent ModelUnavailable must get the 5 minute base, got {remaining}s"
4787 );
4788 }
4789
4790 #[serial_test::serial(cooldown_state)]
4792 #[tokio::test]
4793 async fn review_model_not_found_applies_persistent_cooldown() {
4794 crate::engine::cooldown::reset_global_state().await;
4795 let agent = "test-review-unavailable-gone";
4796 let model = "test-model";
4797 let key = format!("{agent}:{model}");
4798
4799 let err = runner::agents::AgentError::ModelUnavailable {
4800 message: "Model not found: test-model".to_string(),
4801 model: model.to_string(),
4802 };
4803 record_review_agent_failure("test-unavailable-b", agent, Some(model), &err).await;
4804
4805 let now = chrono::Utc::now().timestamp();
4806 let until = crate::engine::cooldown::cooldown_until(&key)
4807 .expect("review model not found should set model cooldown");
4808 let remaining = until.saturating_sub(now);
4809 let base = crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS;
4810 assert!(
4811 (base - 30..=base).contains(&remaining),
4812 "model not found must get the 4 hour persistent base, got {remaining}s"
4813 );
4814 }
4815
4816 #[serial_test::serial(cooldown_state)]
4818 #[tokio::test]
4819 async fn review_output_cooldown_caps_at_fifteen_minutes() {
4820 crate::engine::cooldown::reset_global_state().await;
4821 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4822 crate::engine::cooldown::init_cooldown_store(store).await;
4823 let agent = "opencode-review-output-cap";
4824 let model = "opencode/north-mini-code-free";
4825 let key = format!("{agent}:{model}");
4826 let base = crate::engine::cooldown::TRANSIENT_MODEL_BACKOFF_BASE_SECS;
4827 let max = crate::engine::cooldown::TRANSIENT_MODEL_BACKOFF_MAX_SECS;
4828
4829 for (i, expected) in [base, max, max].into_iter().enumerate() {
4830 let err = runner::agents::AgentError::InvalidResponse {
4831 raw: format!("failed to parse review response {i}"),
4832 };
4833 record_review_agent_failure("test-review-output-cap", agent, Some(model), &err).await;
4834 let now = chrono::Utc::now().timestamp();
4835 let remaining = crate::engine::cooldown::cooldown_until(&key)
4836 .expect("unusable review output should set model cooldown")
4837 .saturating_sub(now);
4838 assert!(
4839 (expected - 30..=expected).contains(&remaining),
4840 "failure {} should cool the model for {expected}s, got {remaining}s",
4841 i + 1
4842 );
4843 }
4844 }
4845
4846 #[serial_test::serial(cooldown_state)]
4852 #[tokio::test]
4853 async fn review_inline_scan_billing_cycle_message_gets_model_scoped_cooldown() {
4854 crate::engine::cooldown::reset_global_state().await;
4855 let agent = "kimi-3551-review-inline";
4856 let model = "opus";
4857 let key = format!("{agent}:{model}");
4858
4859 let text_for_review = "kimi rate limit: Failed to authenticate. API Error: 403 \
4861 You've reached your usage limit for this billing cycle. Your quota will be \
4862 refreshed next cycle.";
4863
4864 let scan_tail = runner::agents::patterns::safe_tail(
4865 text_for_review,
4866 runner::agents::patterns::RATE_LIMIT_SCAN_TAIL_BYTES,
4867 );
4868 let err = runner::agents::patterns::detect_rate_limit(scan_tail)
4869 .expect("billing-cycle message must still be caught by the rate-limit scan");
4870
4871 assert_eq!(
4872 outcome_for_agent_error(&err),
4873 "billing_cycle_exhausted",
4874 "inline-scanned billing-cycle message must not be stored as plain rate_limit"
4875 );
4876
4877 record_review_agent_failure("test-3551", agent, Some(model), &err).await;
4878
4879 let now = chrono::Utc::now().timestamp();
4880 let until = crate::engine::cooldown::cooldown_until(&key)
4881 .expect("billing-cycle exhaustion must set a model-scoped cooldown");
4882 let remaining = until.saturating_sub(now);
4883 assert!(
4888 remaining >= crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
4889 "billing-cycle exhaustion for a known model must apply the ~4h persistent \
4890 model cooldown, got {remaining}s — may have used the generic short agent \
4891 cooldown instead"
4892 );
4893 }
4894
4895 fn test_review_task(id: &str) -> ExternalTask {
4898 ExternalTask {
4899 id: crate::backends::ExternalId(id.to_string()),
4900 title: "Test review task".to_string(),
4901 body: "".to_string(),
4902 state: "open".to_string(),
4903 labels: vec![],
4904 author: "tester".to_string(),
4905 created_at: "2026-01-01T00:00:00Z".to_string(),
4906 updated_at: "2026-01-01T00:00:00Z".to_string(),
4907 url: "https://example.com/issues/1".to_string(),
4908 }
4909 }
4910
4911 fn test_review_context(agent: &str, model: &str, work_dir: &std::path::Path) -> ReviewContext {
4912 ReviewContext {
4913 store_id: None,
4914 had_prev_push_failure: false,
4915 review_attempt: 1,
4916 worktree_path: work_dir.to_path_buf(),
4917 branch_name: "gh-issue-1-test".to_string(),
4918 pr_number: 1,
4919 head_sha: None,
4920 default_branch: "main".to_string(),
4921 review_agent: agent.to_string(),
4922 review_model: Some(model.to_string()),
4923 review_task_id: "review-1".to_string(),
4924 review_attempt_dir: work_dir.to_path_buf(),
4925 output_file: work_dir.join("output.txt"),
4926 invocation: runner::agent::AgentInvocation {
4927 agent: agent.to_string(),
4928 model: Some(model.to_string()),
4929 work_dir: work_dir.to_path_buf(),
4930 system_prompt: String::new(),
4931 agent_message: String::new(),
4932 task_id: "review-1".to_string(),
4933 disallowed_tools: vec![],
4934 git_author_name: "Test".to_string(),
4935 git_author_email: "test@example.com".to_string(),
4936 output_file: work_dir.join("output.txt"),
4937 timeout_seconds: 0,
4938 repo: "owner/repo".to_string(),
4939 attempt: 1,
4940 sandbox: false,
4941 main_project_dir: work_dir.to_path_buf(),
4942 branch: String::new(),
4943 },
4944 same_agent_fallback: false,
4945 }
4946 }
4947
4948 fn test_parsed_review(decision: ReviewDecision) -> ParsedReview {
4949 ParsedReview {
4950 run_id: None,
4951 exit_code: 0,
4952 stderr: String::new(),
4953 raw_output: String::new(),
4954 text_for_review: String::new(),
4955 token_usage: RunTokenUsage::default(),
4956 review_notes_for_comment: String::new(),
4957 decision,
4958 }
4959 }
4960
4961 fn write_review_output(dir: &std::path::Path, stdout: &str, exit_code: i32) {
4962 std::fs::write(dir.join("output.txt"), stdout).unwrap();
4963 std::fs::write(dir.join("exit.txt"), exit_code.to_string()).unwrap();
4964 std::fs::write(dir.join("stderr.txt"), "").unwrap();
4965 }
4966
4967 #[serial_test::serial(cooldown_state)]
4971 #[tokio::test]
4972 async fn parse_review_output_mid_thought_is_incomplete_without_persistent_cooldown() {
4973 crate::engine::cooldown::reset_global_state().await;
4974 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4975 let work_dir = TempDir::new().unwrap();
4976 let agent = "kimi";
4977 let model = "opus-3734-mid-thought";
4978 let ctx = test_review_context(agent, model, work_dir.path());
4979 let redraw = "^D\u{0008}".repeat(20);
4980 let ndjson = format!(
4981 "{}\n{}\n{}\n{}\n{}",
4982 r#"{"type":"system","subtype":"thinking_tokens","tokens":1945}"#,
4983 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}}}"#,
4984 r#"{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"checking lease edge cases"}],"stop_reason":null,"usage":{"input_tokens":10,"output_tokens":0}}}"#,
4985 "\x1b[?25h",
4986 redraw,
4987 );
4988 write_review_output(work_dir.path(), &ndjson, 0);
4989
4990 let task = test_review_task("3734");
4991 let phase = parse_review_output(
4992 &task,
4993 "owner/repo",
4994 &ctx,
4995 ReviewRun { run_id: None },
4996 &store,
4997 )
4998 .await;
4999
5000 match phase {
5001 ReviewPhase::EarlyReturn(ReviewDecision::Failed(reason)) => {
5002 assert!(
5003 reason.contains("incomplete:"),
5004 "mid-thought review must be stored as incomplete, got {reason}"
5005 );
5006 }
5007 ReviewPhase::EarlyReturn(_) => panic!("expected Failed(incomplete)"),
5008 ReviewPhase::Ready(_) => panic!("expected Failed(incomplete), got Ready"),
5009 }
5010 assert!(
5011 !crate::engine::cooldown::is_model_in_cooldown(agent, model),
5012 "mid-thought incomplete must not apply persistent model cooldown"
5013 );
5014 }
5015
5016 #[serial_test::serial(cooldown_state)]
5019 #[tokio::test]
5020 async fn parse_review_output_tool_use_without_envelope_is_incomplete() {
5021 crate::engine::cooldown::reset_global_state().await;
5022 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5023 let work_dir = TempDir::new().unwrap();
5024 let agent = "kimi";
5025 let model = "opus-3734-tool-use";
5026 let ctx = test_review_context(agent, model, work_dir.path());
5027 let ndjson = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Read","input":{"file_path":"src/lib.rs"}}]}}"#;
5028 write_review_output(work_dir.path(), ndjson, 0);
5029
5030 let task = test_review_task("3734-tool");
5031 let phase = parse_review_output(
5032 &task,
5033 "owner/repo",
5034 &ctx,
5035 ReviewRun { run_id: None },
5036 &store,
5037 )
5038 .await;
5039
5040 match phase {
5041 ReviewPhase::EarlyReturn(ReviewDecision::Failed(reason)) => {
5042 assert!(
5043 reason.contains("incomplete:"),
5044 "tool_use without envelope must be incomplete, got {reason}"
5045 );
5046 }
5047 ReviewPhase::EarlyReturn(_) => panic!("expected Failed(incomplete)"),
5048 ReviewPhase::Ready(_) => panic!("expected Failed(incomplete), got Ready"),
5049 }
5050 assert!(!crate::engine::cooldown::is_model_in_cooldown(agent, model));
5051 }
5052
5053 #[serial_test::serial(cooldown_state)]
5056 #[tokio::test]
5057 async fn parse_review_output_unparseable_envelope_applies_short_cooldown() {
5058 crate::engine::cooldown::reset_global_state().await;
5059 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5060 let work_dir = TempDir::new().unwrap();
5061 let agent = "kimi";
5062 let model = "opus-3734-format";
5063 let ctx = test_review_context(agent, model, work_dir.path());
5064 let ndjson = concat!(
5065 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"I have finished looking at the code."}]}}"#,
5066 "\n",
5067 r#"{"type":"result","subtype":"success","is_error":false,"result":"I have finished looking at the code."}"#,
5068 );
5069 write_review_output(work_dir.path(), ndjson, 0);
5070
5071 let task = test_review_task("3734-format");
5072 let phase = parse_review_output(
5073 &task,
5074 "owner/repo",
5075 &ctx,
5076 ReviewRun { run_id: None },
5077 &store,
5078 )
5079 .await;
5080
5081 match phase {
5082 ReviewPhase::EarlyReturn(ReviewDecision::Failed(reason)) => {
5083 assert!(
5084 reason.contains("parse error"),
5085 "finished unparseable envelope must stay a parse error, got {reason}"
5086 );
5087 }
5088 ReviewPhase::EarlyReturn(_) => panic!("expected Failed(parse error)"),
5089 ReviewPhase::Ready(_) => panic!("expected Failed(parse error), got Ready"),
5090 }
5091 let now = chrono::Utc::now().timestamp();
5092 let remaining = crate::engine::cooldown::cooldown_until(&format!("{agent}:{model}"))
5093 .expect("finished format failure must apply a model cooldown")
5094 .saturating_sub(now);
5095 assert!(
5096 remaining <= crate::engine::cooldown::TRANSIENT_MODEL_BACKOFF_BASE_SECS,
5097 "finished format failure must apply the 5 minute cooldown, got {remaining}s"
5098 );
5099 }
5100
5101 #[serial_test::serial(cooldown_state)]
5104 #[tokio::test]
5105 async fn parse_review_output_missing_envelope_uses_assistant_decision() {
5106 crate::engine::cooldown::reset_global_state().await;
5107 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5108 let work_dir = TempDir::new().unwrap();
5109 let agent = "kimi";
5110 let model = "opus-3734-assistant";
5111 let ctx = test_review_context(agent, model, work_dir.path());
5112 let ndjson = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"issues\":[]}"}]}}"#;
5113 write_review_output(work_dir.path(), ndjson, 0);
5114
5115 let task = test_review_task("3734-asst");
5116 let phase = parse_review_output(
5117 &task,
5118 "owner/repo",
5119 &ctx,
5120 ReviewRun { run_id: None },
5121 &store,
5122 )
5123 .await;
5124
5125 match phase {
5126 ReviewPhase::Ready(parsed) => {
5127 assert!(matches!(parsed.decision, ReviewDecision::Approve { .. }));
5128 }
5129 ReviewPhase::EarlyReturn(_) => {
5130 panic!("expected Ready(Approve) from assistant text, got EarlyReturn")
5131 }
5132 }
5133 assert!(!crate::engine::cooldown::is_model_in_cooldown(agent, model));
5134 }
5135
5136 #[serial_test::serial(cooldown_state)]
5139 #[tokio::test]
5140 async fn parse_review_output_does_not_treat_task_done_synthesis_as_approval() {
5141 crate::engine::cooldown::reset_global_state().await;
5142 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5143 let work_dir = TempDir::new().unwrap();
5144 let agent = "kimi";
5145 let model = "opus-3734-done";
5146 let ctx = test_review_context(agent, model, work_dir.path());
5147 let ndjson = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"analyzing the diff"}]}}"#;
5148 write_review_output(work_dir.path(), ndjson, 0);
5149
5150 let task = test_review_task("3734-done");
5151 let phase = parse_review_output(
5152 &task,
5153 "owner/repo",
5154 &ctx,
5155 ReviewRun { run_id: None },
5156 &store,
5157 )
5158 .await;
5159
5160 match phase {
5161 ReviewPhase::EarlyReturn(ReviewDecision::Failed(reason)) => {
5162 assert!(
5163 reason.contains("parse error"),
5164 "unfinished prose without a decision must not approve, got {reason}"
5165 );
5166 }
5167 ReviewPhase::Ready(_) => {
5168 panic!("task-path status=done synthesis must not become a review approval")
5169 }
5170 ReviewPhase::EarlyReturn(_) => panic!("expected Failed(parse error)"),
5171 }
5172 }
5173
5174 #[serial_test::serial(cooldown_state)]
5180 #[tokio::test]
5181 async fn finalize_review_run_success_resets_generic_model_failure_count() {
5182 crate::engine::cooldown::reset_global_state().await;
5183 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5184 crate::engine::cooldown::init_cooldown_store(store.clone()).await;
5185
5186 let agent = "test-3478-agent";
5187 let model = "test-3478-model";
5188 let failure_key = format!("failure_count:{agent}:{model}");
5189
5190 let err = runner::agents::AgentError::AgentFailed {
5194 message: "agent reported a generic failure".to_string(),
5195 };
5196 record_review_agent_failure("task-3478-a", agent, Some(model), &err).await;
5197 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5198 assert_eq!(
5199 store.kv_get(&failure_key).await.unwrap().as_deref(),
5200 Some("1"),
5201 "sanity check: failure should have incremented the persisted count"
5202 );
5203
5204 let work_dir = TempDir::new().unwrap();
5205 let ctx = test_review_context(agent, model, work_dir.path());
5206 let parsed = test_parsed_review(ReviewDecision::Approve { issues: vec![] });
5207 let task = test_review_task("1");
5208
5209 finalize_review_run(&task, &ctx, &parsed, &store).await;
5210 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5211
5212 assert_eq!(
5213 store.kv_get(&failure_key).await.unwrap().as_deref(),
5214 Some("0"),
5215 "successful review must reset failure_count:{{agent}}:{{model}} (issue #3478)"
5216 );
5217 }
5218
5219 #[serial_test::serial(cooldown_state)]
5225 #[tokio::test]
5226 async fn finalize_review_run_interleaved_success_keeps_generic_cooldown_at_base() {
5227 crate::engine::cooldown::reset_global_state().await;
5228 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5229 crate::engine::cooldown::init_cooldown_store(store.clone()).await;
5230
5231 let agent = "test-3478-interleave-agent";
5232 let model = "test-3478-interleave-model";
5233 let cooldown_key = format!("{agent}:{model}");
5234
5235 let work_dir = TempDir::new().unwrap();
5236 let ctx = test_review_context(agent, model, work_dir.path());
5237 let task = test_review_task("2");
5238
5239 for i in 0..4 {
5240 let err = runner::agents::AgentError::AgentFailed {
5241 message: format!("failure {i}"),
5242 };
5243 record_review_agent_failure("task-3478-b", agent, Some(model), &err).await;
5244
5245 let now = chrono::Utc::now().timestamp();
5246 let until = crate::engine::cooldown::cooldown_until(&cooldown_key)
5247 .expect("failure should set cooldown");
5248 let remaining = until.saturating_sub(now);
5249
5250 assert!(
5251 remaining <= crate::engine::cooldown::BACKOFF_BASE_SECS + 60,
5252 "failure #{} must stay at base backoff since the prior success reset the \
5253 counter (issue #3478); got {remaining}s, base is {}s — an escalating value \
5254 here means review successes are not resetting failure_count",
5255 i + 1,
5256 crate::engine::cooldown::BACKOFF_BASE_SECS
5257 );
5258
5259 let parsed = test_parsed_review(ReviewDecision::Approve { issues: vec![] });
5263 finalize_review_run(&task, &ctx, &parsed, &store).await;
5264 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
5265 }
5266 }
5267
5268 #[serial_test::serial(cooldown_state)]
5274 #[tokio::test]
5275 async fn finalize_review_run_success_does_not_reset_persistent_model_failure_count() {
5276 crate::engine::cooldown::reset_global_state().await;
5277 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5278 crate::engine::cooldown::init_cooldown_store(store.clone()).await;
5279
5280 let agent = "test-3571-agent";
5281 let model = "test-3571-model";
5282 let cooldown_key = format!("{agent}:{model}");
5283
5284 let work_dir = TempDir::new().unwrap();
5285 let ctx = test_review_context(agent, model, work_dir.path());
5286 let task = test_review_task("3");
5287
5288 let err = runner::agents::AgentError::ModelUnavailable {
5290 message: "model not found".to_string(),
5291 model: model.to_string(),
5292 };
5293 record_review_agent_failure("task-3571-a", agent, Some(model), &err).await;
5294 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
5295
5296 let parsed = test_parsed_review(ReviewDecision::Approve { issues: vec![] });
5299 finalize_review_run(&task, &ctx, &parsed, &store).await;
5300 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
5301
5302 let err2 = runner::agents::AgentError::ModelUnavailable {
5305 message: "model not found again".to_string(),
5306 model: model.to_string(),
5307 };
5308 record_review_agent_failure("task-3571-b", agent, Some(model), &err2).await;
5309 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
5310
5311 let now = chrono::Utc::now().timestamp();
5312 let until = crate::engine::cooldown::cooldown_until(&cooldown_key)
5313 .expect("second failure should set cooldown");
5314 let remaining = until.saturating_sub(now);
5315
5316 assert!(
5317 remaining > crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS,
5318 "second persistent model failure should escalate past the 4h base despite an \
5319 intervening success (issue #3571); got {remaining}s, base is {}s",
5320 crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS
5321 );
5322 }
5323
5324 fn app_for_test(server: &wiremock::MockServer) -> Arc<crate::github::app::AppAuth> {
5325 Arc::new(crate::github::app::AppAuth::new(
5326 "12345".to_string(),
5327 crate::github::app::test_pem().to_string(),
5328 server.uri(),
5329 ))
5330 }
5331
5332 async fn mount_installed(server: &wiremock::MockServer, repo: &str) {
5333 use wiremock::matchers::{method, path};
5334 use wiremock::{Mock, ResponseTemplate};
5335 Mock::given(method("GET"))
5336 .and(path(format!("/repos/{repo}/installation")))
5337 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 42})))
5338 .mount(server)
5339 .await;
5340 Mock::given(method("POST"))
5341 .and(path("/app/installations/42/access_tokens"))
5342 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
5343 "token": "inst-1",
5344 "expires_at": "2099-01-01T00:00:00Z",
5345 })))
5346 .mount(server)
5347 .await;
5348 }
5349
5350 #[tokio::test]
5351 async fn app_mode_falls_back_to_comments_where_the_app_is_not_installed() {
5352 use crate::review_mode::ReviewMode;
5353 use wiremock::matchers::{method, path};
5354 use wiremock::{Mock, MockServer, ResponseTemplate};
5355 let server = MockServer::start().await;
5356 Mock::given(method("GET"))
5357 .and(path("/repos/org/b/installation"))
5358 .respond_with(ResponseTemplate::new(404))
5359 .expect(1)
5360 .mount(&server)
5361 .await;
5362 let app = app_for_test(&server);
5363
5364 assert!(
5365 review_app_for(ReviewMode::App, Some(Arc::clone(&app)), "org/b")
5366 .await
5367 .is_none()
5368 );
5369 assert!(
5371 review_app_for(ReviewMode::Comment, Some(Arc::clone(&app)), "org/b")
5372 .await
5373 .is_none()
5374 );
5375 assert!(review_app_for(ReviewMode::App, None, "org/b")
5376 .await
5377 .is_none());
5378 }
5379
5380 #[tokio::test]
5381 async fn the_check_run_starts_and_completes_on_the_reviewed_sha() {
5382 use wiremock::matchers::{body_partial_json, method, path};
5383 use wiremock::{Mock, MockServer, ResponseTemplate};
5384 let server = MockServer::start().await;
5385 mount_installed(&server, "o/r").await;
5386 Mock::given(method("POST"))
5387 .and(path("/repos/o/r/check-runs"))
5388 .and(body_partial_json(serde_json::json!({
5389 "name": "orch/review", "head_sha": "abc123", "status": "in_progress",
5390 })))
5391 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 1})))
5392 .expect(1)
5393 .mount(&server)
5394 .await;
5395 Mock::given(method("POST"))
5396 .and(path("/repos/o/r/check-runs"))
5397 .and(body_partial_json(serde_json::json!({
5398 "name": "orch/review", "head_sha": "abc123", "status": "completed",
5399 "conclusion": "failure", "output": {"summary": "fix it"},
5400 })))
5401 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 2})))
5402 .expect(1)
5403 .mount(&server)
5404 .await;
5405 let app = app_for_test(&server);
5406 let gh = GhHttp::for_test(server.uri(), Some(Arc::clone(&app)));
5407 let decision = ReviewDecision::RequestChanges {
5408 notes: "fix it".to_string(),
5409 issues: vec![],
5410 };
5411
5412 post_review_started(Some(&app), &gh, "o/r", Some("abc123")).await;
5413 post_review_decision(Some(&app), &gh, "o/r", Some("abc123"), &decision, "fix it").await;
5414 let requests = server.received_requests().await.expect("requests");
5415 assert!(requests
5416 .iter()
5417 .all(|req| !req.url.path().contains("/statuses/")));
5418 }
5419
5420 #[tokio::test]
5421 async fn issues_on_diff_lines_go_inline_and_the_rest_stay_in_the_body() {
5422 use crate::engine::runner::response::ReviewIssue;
5423 use wiremock::matchers::{method, path, query_param};
5424 use wiremock::{Mock, MockServer, ResponseTemplate};
5425 let server = MockServer::start().await;
5426 mount_installed(&server, "o/r").await;
5427 Mock::given(method("GET"))
5428 .and(path("/repos/o/r/pulls/9/files"))
5429 .and(query_param("page", "1"))
5430 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
5431 {"filename": "src/a.rs", "patch": "@@ -1,2 +10,3 @@\n ctx\n+new\n+new"},
5432 ])))
5433 .expect(1)
5434 .mount(&server)
5435 .await;
5436 Mock::given(method("POST"))
5437 .and(path("/repos/o/r/pulls/9/reviews"))
5438 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 1})))
5439 .expect(1)
5440 .mount(&server)
5441 .await;
5442 Mock::given(method("POST"))
5443 .and(path("/repos/o/r/issues/9/comments"))
5444 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 2})))
5445 .mount(&server)
5446 .await;
5447 let issue = |file: &str, line: Option<u32>| ReviewIssue {
5448 file: file.to_string(),
5449 line,
5450 severity: "error".to_string(),
5451 description: "wrong".to_string(),
5452 };
5453 let decision = ReviewDecision::RequestChanges {
5454 notes: "fix".to_string(),
5455 issues: vec![
5456 issue("src/a.rs", Some(11)),
5457 issue("src/a.rs", Some(50)),
5458 issue("src/b.rs", Some(3)),
5459 issue("src/a.rs", None),
5460 ],
5461 };
5462 let app = app_for_test(&server);
5463 let gh = GhHttp::for_test(server.uri(), Some(Arc::clone(&app)));
5464
5465 publish_review(Some(&app), &gh, "o/r", 9, Some("abc123"), &decision, "body")
5466 .await
5467 .expect("comment posted");
5468 let requests = server.received_requests().await.expect("requests");
5469 let review = requests
5470 .iter()
5471 .find(|req| req.url.path().ends_with("/reviews"))
5472 .expect("review submitted");
5473 let body: serde_json::Value = serde_json::from_slice(&review.body).unwrap();
5474 assert_eq!(
5475 body["comments"],
5476 serde_json::json!([{"path": "src/a.rs", "line": 11, "side": "RIGHT", "body": "**error:** wrong"}])
5477 );
5478 }
5479
5480 #[allow(clippy::await_holding_lock)]
5481 #[tokio::test]
5482 async fn without_an_app_the_review_path_posts_only_the_comment() {
5483 use wiremock::matchers::{method, path};
5484 use wiremock::{Mock, MockServer, ResponseTemplate};
5485 let _env = crate::github::token::ENV_LOCK.lock().unwrap();
5486 let prev = std::env::var_os("GH_TOKEN");
5487 std::env::set_var("GH_TOKEN", "owner-token");
5488 let server = MockServer::start().await;
5489 Mock::given(method("POST"))
5490 .and(path("/repos/o/r/issues/9/comments"))
5491 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 1})))
5492 .expect(1)
5493 .mount(&server)
5494 .await;
5495 let gh = GhHttp::for_test(server.uri(), None);
5496 let result = publish_review(
5497 None,
5498 &gh,
5499 "o/r",
5500 9,
5501 Some("abc123"),
5502 &ReviewDecision::Approve { issues: vec![] },
5503 "## Automated Review \u{2014} Approve\n\nok",
5504 )
5505 .await;
5506 match prev {
5507 Some(value) => std::env::set_var("GH_TOKEN", value),
5508 None => std::env::remove_var("GH_TOKEN"),
5509 }
5510 result.expect("comment posted");
5511 let requests = server.received_requests().await.expect("requests");
5512 assert!(requests
5513 .iter()
5514 .all(|req| !req.url.path().contains("/reviews")));
5515 }
5516
5517 #[tokio::test]
5518 async fn with_an_app_the_review_is_submitted_on_the_reviewed_sha() {
5519 use wiremock::matchers::{body_partial_json, method, path};
5520 use wiremock::{Mock, MockServer, ResponseTemplate};
5521 let server = MockServer::start().await;
5522 Mock::given(method("POST"))
5523 .and(path("/app/installations/42/access_tokens"))
5524 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
5525 "token": "inst-1",
5526 "expires_at": "2099-01-01T00:00:00Z",
5527 })))
5528 .mount(&server)
5529 .await;
5530 Mock::given(method("POST"))
5531 .and(path("/repos/o/r/pulls/9/reviews"))
5532 .and(body_partial_json(serde_json::json!({
5533 "event": "REQUEST_CHANGES",
5534 "commit_id": "abc123",
5535 })))
5536 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 1})))
5537 .expect(1)
5538 .mount(&server)
5539 .await;
5540 Mock::given(method("POST"))
5541 .and(path("/repos/o/r/issues/9/comments"))
5542 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 2})))
5543 .expect(0)
5544 .mount(&server)
5545 .await;
5546 Mock::given(method("GET"))
5547 .and(path("/repos/o/r/installation"))
5548 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 42})))
5549 .mount(&server)
5550 .await;
5551 let app = Arc::new(crate::github::app::AppAuth::new(
5552 "12345".to_string(),
5553 crate::github::app::test_pem().to_string(),
5554 server.uri(),
5555 ));
5556 let gh = GhHttp::for_test(server.uri(), Some(Arc::clone(&app)));
5557 let decision = ReviewDecision::RequestChanges {
5558 notes: "fix it".to_string(),
5559 issues: vec![],
5560 };
5561 publish_review(
5562 Some(&app),
5563 &gh,
5564 "o/r",
5565 9,
5566 Some("abc123"),
5567 &decision,
5568 "## Automated Review \u{2014} Changes Requested\n\nfix it",
5569 )
5570 .await
5571 .expect("comment posted");
5572 }
5573}