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