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 if let Err(e) = app
2604 .submit_review(repo, pr_number, event, comment, sha, &inline)
2605 .await
2606 {
2607 tracing::warn!(pr_number, sha, error = %e, "failed to submit App review, the comment stays the decision");
2608 }
2609 }
2610 gh.add_comment(repo, &pr_number.to_string(), comment).await
2611}
2612
2613async fn inline_comments(
2616 app: &crate::github::app::AppAuth,
2617 repo: &str,
2618 pr_number: u64,
2619 decision: &ReviewDecision,
2620) -> Vec<crate::github::app::InlineComment> {
2621 let ReviewDecision::RequestChanges { issues, .. } = decision else {
2622 return Vec::new();
2623 };
2624
2625 if issues.is_empty() {
2626 return Vec::new();
2627 }
2628
2629 match app.pull_request_hunks(repo, pr_number).await {
2630 Ok(hunks) => crate::github::app::split_inline(issues, &hunks).0,
2631 Err(e) => {
2632 tracing::warn!(pr_number, error = %e, "could not read the PR diff, issues stay in the review body");
2633 Vec::new()
2634 }
2635 }
2636}
2637
2638#[allow(clippy::too_many_arguments)]
2639async fn apply_review_decision(
2640 task: &ExternalTask,
2641 backend: &Arc<dyn ExternalBackend>,
2642 repo: &str,
2643 task_manager: &Arc<TaskManager>,
2644 store: &Arc<TaskStore>,
2645 ctx: &ReviewContext,
2646 parsed: &ParsedReview,
2647 router_config: &crate::engine::router::RouterConfig,
2648) -> anyhow::Result<ReviewDecision> {
2649 restore_review_config_if_needed(&task.id.0, &ctx.worktree_path, &ctx.default_branch).await;
2650
2651 if let Err(e) = push_review_branch(task, repo, ctx, store).await {
2652 return Ok(ReviewDecision::Failed(format!("push failed: {e}")));
2653 }
2654
2655 post_review_comment(
2656 task,
2657 repo,
2658 ctx,
2659 &parsed.decision,
2660 &parsed.review_notes_for_comment,
2661 )
2662 .await?;
2663
2664 match &parsed.decision {
2665 ReviewDecision::Approve { .. } => {
2666 record_approved_sha(
2667 store,
2668 repo,
2669 &task.id.0,
2670 ctx.pr_number,
2671 &ctx.worktree_path,
2672 &ctx.branch_name,
2673 &ctx.default_branch,
2674 )
2675 .await;
2676 if let Err(e) = auto_merge_pr(
2681 task,
2682 &ctx.branch_name,
2683 backend,
2684 repo,
2685 &ctx.review_agent,
2686 ctx.review_model_str(),
2687 task_manager,
2688 store,
2689 )
2690 .await
2691 {
2692 let error_msg = e.to_string();
2693 if error_msg.contains("not yet computed") {
2694 tracing::warn!(
2695 task_id = task.id.0,
2696 pr_number = ctx.pr_number,
2697 branch = %ctx.branch_name,
2698 error = %e,
2699 "auto-merge deferred — PR mergeability not yet computed"
2700 );
2701 } else {
2702 tracing::error!(
2703 task_id = task.id.0,
2704 pr_number = ctx.pr_number,
2705 branch = %ctx.branch_name,
2706 error = %e,
2707 "auto-merge failed"
2708 );
2709 }
2710 return Ok(ReviewDecision::Failed(format!("merge failed: {e}")));
2711 }
2712 Ok(parsed.decision.clone())
2713 }
2714 ReviewDecision::RequestChanges { notes, issues } => {
2715 clear_approved_sha(store, repo, &task.id.0, ctx.pr_number).await;
2716 handle_review_changes(
2717 task,
2718 notes,
2719 issues,
2720 backend,
2721 repo,
2722 ctx.pr_number,
2723 &ctx.review_agent,
2724 ctx.review_model_str(),
2725 task_manager,
2726 store,
2727 router_config,
2728 )
2729 .await?;
2730 Ok(parsed.decision.clone())
2731 }
2732 _ => Ok(parsed.decision.clone()),
2733 }
2734}
2735
2736async fn recover_missing_review_worktree(
2737 task: &ExternalTask,
2738 repo: &str,
2739 store: &Arc<TaskStore>,
2740) -> anyhow::Result<worktree::WorktreeSetup> {
2741 let project_dir = crate::config::get_projects_with_paths()?
2742 .into_iter()
2743 .find(|(candidate_repo, _)| candidate_repo == repo)
2744 .map(|(_, project_dir)| project_dir)
2745 .ok_or_else(|| anyhow::anyhow!("no project directory configured for repo {repo}"))?;
2746
2747 tracing::warn!(
2748 task_id = task.id.0,
2749 repo,
2750 project_dir = %project_dir.display(),
2751 "review worktree missing, recreating via normal setup"
2752 );
2753
2754 let store_ref = Some(Arc::clone(store));
2755 let wt = worktree::setup_worktree(&task.id.0, &task.title, &project_dir, &store_ref, repo)
2756 .await
2757 .context("recreating missing review worktree")?;
2758
2759 if !wt.work_dir.exists() {
2760 anyhow::bail!(
2761 "recreated review worktree at {} for task {} is still missing",
2762 wt.work_dir.display(),
2763 task.id.0
2764 );
2765 }
2766
2767 Ok(wt)
2768}
2769
2770async fn count_ahead_commits(
2776 worktree_path: &std::path::Path,
2777 default_branch: &str,
2778) -> Result<u64, String> {
2779 let worktree_str = worktree_path.to_str().ok_or_else(|| {
2780 format!(
2781 "worktree path contains non-UTF-8 characters: {:?}",
2782 worktree_path
2783 )
2784 })?;
2785
2786 let output = tokio::process::Command::new("git")
2787 .args([
2788 "-C",
2789 worktree_str,
2790 "rev-list",
2791 "--count",
2792 &format!("origin/{default_branch}..HEAD"),
2793 ])
2794 .output()
2795 .await
2796 .map_err(|e| format!("git rev-list command failed to start: {e}"))?;
2797
2798 if !output.status.success() {
2799 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
2800 return Err(format!(
2801 "git rev-list exited with {:?}: {}",
2802 output.status.code(),
2803 if stderr.is_empty() {
2804 String::from_utf8_lossy(&output.stdout).trim().to_string()
2805 } else {
2806 stderr
2807 }
2808 ));
2809 }
2810
2811 String::from_utf8_lossy(&output.stdout)
2812 .trim()
2813 .parse::<u64>()
2814 .map_err(|e| format!("could not parse git rev-list output as u64: {e}"))
2815}
2816
2817#[allow(clippy::too_many_arguments)]
2824async fn ensure_pr_exists(
2825 task: &ExternalTask,
2826 branch_name: &str,
2827 worktree_path: &std::path::Path,
2828 repo: &str,
2829 agent_summary: &str,
2830 store: &Arc<TaskStore>,
2831 task_manager: &Arc<TaskManager>,
2832 stored_pr_number: Option<u64>,
2833) -> anyhow::Result<EnsurePrResult> {
2834 let gh_check = GhHttp::new()?;
2835
2836 if let Some(n) = stored_pr_number {
2837 tracing::info!(
2838 task_id = task.id.0,
2839 pr_number = n,
2840 branch = %branch_name,
2841 "open PR found in store, proceeding with review"
2842 );
2843 return Ok(EnsurePrResult::Ready(n));
2844 }
2845
2846 match gh_check.get_pr_number(repo, branch_name).await {
2847 Ok(Some(n)) => {
2848 tracing::info!(
2849 task_id = task.id.0,
2850 pr_number = n,
2851 branch = %branch_name,
2852 "open PR found, proceeding with review"
2853 );
2854 Ok(EnsurePrResult::Ready(n))
2855 }
2856 Ok(None) => {
2857 let default_branch = worktree::detect_default_branch(worktree_path).await;
2859 let has_commits = match count_ahead_commits(worktree_path, &default_branch).await {
2860 Ok(count) => count > 0,
2861 Err(e) => {
2862 tracing::error!(
2863 task_id = task.id.0,
2864 branch = %branch_name,
2865 default_branch = %default_branch,
2866 err = %e,
2867 "could not determine if branch has commits ahead of default"
2868 );
2869 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2870 format!("git ancestry check failed: {e}"),
2871 )));
2872 }
2873 };
2874
2875 if has_commits {
2876 tracing::warn!(
2878 task_id = task.id.0,
2879 branch = %branch_name,
2880 "no open PR but branch has commits — attempting to create PR"
2881 );
2882 let worktree_str = worktree_path.to_str().ok_or_else(|| {
2883 anyhow::anyhow!(
2884 "worktree path contains non-UTF-8 characters: {:?}",
2885 worktree_path
2886 )
2887 })?;
2888 let auth_env = runner::git_ops::build_git_auth_env().await;
2890
2891 let push_timeout = std::time::Duration::from_secs(120);
2896 match tokio::time::timeout(
2897 push_timeout,
2898 tokio::process::Command::new("git")
2899 .args(["-C", worktree_str, "push", "-u", "origin", branch_name])
2900 .envs(auth_env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
2901 .output(),
2902 )
2903 .await
2904 {
2905 Err(_elapsed) => {
2906 tracing::warn!(
2907 task_id = task.id.0,
2908 branch = %branch_name,
2909 timeout_secs = push_timeout.as_secs(),
2910 "review gate fallback push timed out"
2911 );
2912 }
2913 Ok(Ok(o)) => {
2914 if !o.status.success() {
2915 tracing::warn!(
2916 task_id = task.id.0,
2917 branch = %branch_name,
2918 exit_code = ?o.status.code(),
2919 stdout = %String::from_utf8_lossy(&o.stdout),
2920 stderr = %String::from_utf8_lossy(&o.stderr),
2921 "review gate fallback push failed"
2922 );
2923 } else {
2924 tracing::info!(task_id = task.id.0, branch = %branch_name, "review gate fallback push succeeded");
2925 }
2926 }
2927 Ok(Err(e)) => {
2928 tracing::error!(
2929 task_id = task.id.0,
2930 branch = %branch_name,
2931 error = %e,
2932 "review gate fallback push command failed to run"
2933 );
2934 }
2935 }
2936
2937 let task_ref = runner::git_ops::format_task_ref(&task.id.0);
2938 let pr_body = format!(
2939 "Resolves {task_ref}\n\nAuto-created by orch review gate (agent forgot to open PR)"
2940 );
2941 let gh = GhHttp::new()?;
2942 match gh
2943 .create_pr(repo, &task.title, &pr_body, branch_name, &default_branch)
2944 .await
2945 {
2946 Ok(url) => match parse_pr_number_from_url(&url) {
2947 Ok(pr_num) => {
2948 store_set_result(
2949 &Some(Arc::clone(store)),
2950 repo,
2951 &task.id.0,
2952 &[("pr_number", serde_json::json!(pr_num as i64))],
2953 )
2954 .await?;
2955 tracing::info!(
2956 task_id = task.id.0,
2957 branch = %branch_name,
2958 pr_url = %url,
2959 "created missing PR via GhHttp — continuing review"
2960 );
2961 Ok(EnsurePrResult::Ready(pr_num))
2962 }
2963 Err(e) => {
2964 tracing::warn!(
2965 task_id = task.id.0,
2966 branch = %branch_name,
2967 pr_url = %url,
2968 error = %e,
2969 "created missing PR via GhHttp, but failed to parse PR number"
2970 );
2971 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2972 format!("created missing PR, but could not parse PR number: {e}"),
2973 )))
2974 }
2975 },
2976 Err(e) => {
2977 let e_str = format!("{e}");
2978 if e_str.contains("already exists") {
2979 let backoff_delays = [2u64, 4u64];
2983 let mut found_pr: Option<u64> = None;
2984 for delay_secs in backoff_delays {
2985 tokio::time::sleep(tokio::time::Duration::from_secs(delay_secs))
2986 .await;
2987 if let Ok(Some(n)) = gh_check.get_pr_number(repo, branch_name).await
2988 {
2989 found_pr = Some(n);
2990 break;
2991 }
2992 }
2993 if let Some(n) = found_pr {
2994 tracing::info!(
2995 task_id = task.id.0,
2996 pr_number = n,
2997 branch = %branch_name,
2998 "found existing PR after create_pr 422 — retrying review"
2999 );
3000 store_set_result(
3001 &Some(Arc::clone(store)),
3002 repo,
3003 &task.id.0,
3004 &[("pr_number", serde_json::json!(n as i64))],
3005 )
3006 .await?;
3007 return Ok(EnsurePrResult::Ready(n));
3008 }
3009 }
3010 tracing::warn!(
3011 task_id = task.id.0,
3012 branch = %branch_name,
3013 error = %e,
3014 "create_pr failed via GhHttp, falling back to CLI"
3015 );
3016 let pr_timeout = std::time::Duration::from_secs(120);
3019 let mut gh_cmd = tokio::process::Command::new("gh");
3020 gh_cmd.args([
3021 "pr",
3022 "create",
3023 "--repo",
3024 repo,
3025 "--head",
3026 branch_name,
3027 "--title",
3028 &task.title,
3029 "--body",
3030 &pr_body,
3031 ]);
3032 gh_cmd.current_dir(worktree_path);
3033 gh_cmd.kill_on_drop(true);
3034
3035 let pr_result = tokio::time::timeout(pr_timeout, gh_cmd.output()).await;
3036 match pr_result {
3037 Ok(Ok(o)) if o.status.success() => {
3038 let stdout = String::from_utf8_lossy(&o.stdout);
3039 match parse_pr_number_from_url(stdout.trim()) {
3040 Ok(pr_num) => {
3041 store_set_result(
3042 &Some(Arc::clone(store)),
3043 repo,
3044 &task.id.0,
3045 &[("pr_number", serde_json::json!(pr_num as i64))],
3046 )
3047 .await?;
3048 tracing::info!(
3049 task_id = task.id.0,
3050 branch = %branch_name,
3051 "created missing PR via CLI — continuing review"
3052 );
3053 Ok(EnsurePrResult::Ready(pr_num))
3054 }
3055 Err(e) => {
3056 tracing::warn!(
3057 task_id = task.id.0,
3058 branch = %branch_name,
3059 stdout = %stdout,
3060 error = %e,
3061 "created missing PR via CLI, but failed to parse PR number"
3062 );
3063 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
3064 format!("created missing PR via CLI, but could not parse PR number: {e}")
3065 )))
3066 }
3067 }
3068 }
3069 Ok(Ok(o)) => {
3070 let stderr = String::from_utf8_lossy(&o.stderr);
3071 if stderr.contains("already exists") {
3072 if let Some(pr_url) =
3073 stderr.lines().find(|l| l.trim().starts_with("https://"))
3074 {
3075 match parse_pr_number_from_url(pr_url.trim()) {
3076 Ok(pr_num) => {
3077 if let Err(e) = store_set_result(
3078 &Some(Arc::clone(store)),
3079 repo,
3080 &task.id.0,
3081 &[(
3082 "pr_number",
3083 serde_json::json!(pr_num as i64),
3084 )],
3085 )
3086 .await
3087 {
3088 tracing::warn!(
3089 task_id = task.id.0,
3090 pr_num,
3091 err = %e,
3092 "failed to persist pr_number from already-exists recovery — downstream may retry PR creation"
3093 );
3094 }
3095 tracing::info!(
3096 task_id = task.id.0,
3097 branch = %branch_name,
3098 pr_url = %pr_url,
3099 "PR already exists (from CLI stderr) — retrying review"
3100 );
3101 return Ok(EnsurePrResult::Ready(pr_num));
3102 }
3103 Err(e) => {
3104 tracing::warn!(
3105 task_id = task.id.0,
3106 branch = %branch_name,
3107 pr_url = %pr_url,
3108 error = %e,
3109 "found existing PR URL in CLI stderr, but failed to parse PR number"
3110 );
3111 }
3113 }
3114 }
3115 }
3116 tracing::error!(
3117 task_id = task.id.0,
3118 branch = %branch_name,
3119 stderr = %stderr,
3120 "failed to create missing PR — work may be stuck"
3121 );
3122
3123 if crate::engine::runner::git_ops::is_transient_github_api_error(
3130 &stderr,
3131 ) {
3132 tracing::warn!(
3133 task_id = task.id.0,
3134 branch = %branch_name,
3135 "transient GitHub error creating PR; will retry later without incrementing persistent failure counter"
3136 );
3137 return Ok(EnsurePrResult::EarlyReturn(
3138 ReviewDecision::Failed(format!(
3139 "transient github error creating PR: {stderr}"
3140 )),
3141 ));
3142 }
3143
3144 match store_increment(
3145 &Some(Arc::clone(store)),
3146 repo,
3147 &task.id.0,
3148 "pr_create_failures",
3149 )
3150 .await
3151 {
3152 Ok(failures) if failures >= MAX_PR_CREATE_FAILURES => {
3153 return Ok(EnsurePrResult::EarlyReturn(
3154 ReviewDecision::Blocked(format!(
3155 "no PR, create failed {failures} times: {stderr}"
3156 )),
3157 ));
3158 }
3159 Ok(_) => {}
3160 Err(e) => {
3161 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment pr_create_failures — skipping blocking decision this tick");
3162 }
3163 }
3164 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
3165 format!("no PR, create failed: {stderr}"),
3166 )))
3167 }
3168 Ok(Err(e)) => {
3169 tracing::error!(
3170 task_id = task.id.0,
3171 error = %e,
3172 "failed to run gh pr create"
3173 );
3174 let e_str = format!("{e}");
3175 if crate::engine::runner::git_ops::is_transient_github_api_error(
3176 &e_str,
3177 ) {
3178 tracing::warn!(
3179 task_id = task.id.0,
3180 branch = %branch_name,
3181 "transient GitHub error from gh CLI fallback; will retry later without incrementing persistent failure counter"
3182 );
3183 return Ok(EnsurePrResult::EarlyReturn(
3184 ReviewDecision::Failed(format!("transient gh error: {e}")),
3185 ));
3186 }
3187
3188 match store_increment(
3189 &Some(Arc::clone(store)),
3190 repo,
3191 &task.id.0,
3192 "pr_create_failures",
3193 )
3194 .await
3195 {
3196 Ok(failures) if failures >= MAX_PR_CREATE_FAILURES => {
3197 return Ok(EnsurePrResult::EarlyReturn(
3198 ReviewDecision::Blocked(format!(
3199 "no PR, gh error {failures} times: {e}"
3200 )),
3201 ));
3202 }
3203 Ok(_) => {}
3204 Err(e) => {
3205 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment pr_create_failures — skipping blocking decision this tick");
3206 }
3207 }
3208 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
3209 format!("no PR, gh error: {e}"),
3210 )))
3211 }
3212 Err(_elapsed) => {
3213 tracing::error!(
3215 task_id = task.id.0,
3216 timeout_secs = pr_timeout.as_secs(),
3217 "gh pr create CLI fallback timed out"
3218 );
3219 let e_str = format!(
3220 "gh pr create timed out after {}s",
3221 pr_timeout.as_secs()
3222 );
3223 if crate::engine::runner::git_ops::is_transient_github_api_error(
3224 &e_str,
3225 ) {
3226 tracing::warn!(
3227 task_id = task.id.0,
3228 branch = %branch_name,
3229 "transient GitHub timeout from gh CLI fallback; will retry later without incrementing persistent failure counter"
3230 );
3231 return Ok(EnsurePrResult::EarlyReturn(
3232 ReviewDecision::Failed(format!(
3233 "transient gh error: {e_str}"
3234 )),
3235 ));
3236 }
3237
3238 match store_increment(
3239 &Some(Arc::clone(store)),
3240 repo,
3241 &task.id.0,
3242 "pr_create_failures",
3243 )
3244 .await
3245 {
3246 Ok(failures) if failures >= MAX_PR_CREATE_FAILURES => {
3247 return Ok(EnsurePrResult::EarlyReturn(
3248 ReviewDecision::Blocked(format!(
3249 "no PR, gh timeout {failures} times: {e_str}"
3250 )),
3251 ));
3252 }
3253 Ok(_) => {}
3254 Err(e) => {
3255 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment pr_create_failures — skipping blocking decision this tick");
3256 }
3257 }
3258 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
3259 format!("no PR, gh timeout: {e_str}"),
3260 )))
3261 }
3262 }
3263 }
3264 }
3265 } else {
3266 let merged = match gh_check.is_pr_merged(repo, branch_name).await {
3268 Ok(v) => v,
3269 Err(e) => {
3270 tracing::warn!(task_id = task.id.0, branch = %branch_name, err = %e, "merge check failed, skipping task this tick");
3271 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
3272 format!("merge check failed: {e}"),
3273 )));
3274 }
3275 };
3276 if merged {
3277 tracing::info!(
3278 task_id = task.id.0,
3279 branch = %branch_name,
3280 "PR already merged, marking done"
3281 );
3282 if let Err(e) = task_manager
3283 .update_task_status(&task.id, Status::Done)
3284 .await
3285 {
3286 tracing::error!(task_id = task.id.0, err = %e, "update_task_status(Done) failed — task may be stuck in InReview");
3287 }
3288 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped));
3289 }
3290
3291 let last_error = opt_store_get_task(&Some(Arc::clone(store)), repo, &task.id.0)
3292 .await
3293 .map(|t| t.last_error)
3294 .unwrap_or_default();
3295 let reason = if !agent_summary.is_empty() {
3296 agent_summary.to_string()
3297 } else {
3298 last_error.clone()
3299 };
3300
3301 if last_error.contains("exceeded max attempts") {
3302 tracing::warn!(
3303 task_id = task.id.0,
3304 branch = %branch_name,
3305 "no PR and no commits after max attempts — marking blocked to stop loop"
3306 );
3307 let fields = [
3311 (
3312 "block_reason",
3313 serde_json::json!("max attempts exceeded — no PR or commits produced"),
3314 ),
3315 ("last_error", serde_json::json!(last_error)),
3316 ];
3317 if let Err(e) = task_manager
3318 .update_task_status_and_result(&task.id, Status::Blocked, &fields)
3319 .await
3320 {
3321 tracing::error!(task_id = task.id.0, err = %e, "update_task_status_and_result(Blocked) failed — skipping block to avoid silent auto-unblock loop");
3322 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
3323 format!("failed to write block_reason: {e}"),
3324 )));
3325 }
3326 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped));
3327 }
3328
3329 if last_error.contains("422") && last_error.contains("head") {
3330 tracing::info!(
3331 task_id = task.id.0,
3332 branch = %branch_name,
3333 "no PR — 422/head-invalid means work already merged, marking done"
3334 );
3335 if let Err(e) = task_manager
3336 .update_task_status(&task.id, Status::Done)
3337 .await
3338 {
3339 tracing::error!(task_id = task.id.0, err = %e, "update_task_status(Done) failed");
3340 }
3341 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped));
3342 }
3343
3344 if last_error.contains("no fallback agents")
3345 || last_error.contains("all agents exhausted")
3346 {
3347 tracing::warn!(
3348 task_id = task.id.0,
3349 branch = %branch_name,
3350 last_error = %last_error,
3351 "no PR and no commits after failover exhaustion — marking blocked to stop loop"
3352 );
3353 let fields = [
3357 (
3358 "block_reason",
3359 serde_json::json!("all agents exhausted — no PR or commits produced"),
3360 ),
3361 ("last_error", serde_json::json!(last_error)),
3362 ];
3363 if let Err(e) = task_manager
3364 .update_task_status_and_result(&task.id, Status::Blocked, &fields)
3365 .await
3366 {
3367 tracing::error!(task_id = task.id.0, err = %e, "update_task_status_and_result(Blocked) failed — skipping block to avoid silent auto-unblock loop");
3368 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
3369 format!("failed to write block_reason: {e}"),
3370 )));
3371 }
3372 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped));
3373 }
3374
3375 let max_reroutes: u32 = config::get("workflow.max_reroute_attempts")
3382 .ok()
3383 .and_then(|s| s.parse().ok())
3384 .or_else(|| {
3385 config::get("workflow.max_attempts")
3386 .ok()
3387 .and_then(|s| s.parse().ok())
3388 })
3389 .unwrap_or(3);
3390
3391 tracing::warn!(
3392 task_id = task.id.0,
3393 branch = %branch_name,
3394 reason = %reason,
3395 "no PR and no commits — re-routing for retry"
3396 );
3397
3398 if should_skip_no_code_reroute_increment(&last_error) {
3402 tracing::warn!(
3403 task_id = task.id.0,
3404 last_error = %last_error,
3405 "transient GitHub error recorded; skipping persistent no_pr_reroutes increment"
3406 );
3407 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
3408 format!("transient github error: {last_error}"),
3409 )));
3410 }
3411
3412 let reroutes = match store_increment(
3414 &Some(Arc::clone(store)),
3415 repo,
3416 &task.id.0,
3417 "no_code_reroutes",
3418 )
3419 .await
3420 {
3421 Ok(v) => v,
3422 Err(e) => {
3423 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment no_code_reroutes — skipping reroute/block decision this tick");
3424 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
3426 format!("transient store error: {e}"),
3427 )));
3428 }
3429 };
3430
3431 if reroutes as u32 >= max_reroutes {
3432 tracing::error!(
3433 task_id = task.id.0,
3434 reroutes,
3435 max_reroutes,
3436 "reached max reroute attempts for no-pr-result — blocking for human review"
3437 );
3438 let msg = format!(
3439 "no PR or code changes after {}/{} reroute attempts",
3440 reroutes, max_reroutes
3441 );
3442 let fields = [
3446 (
3447 "block_reason",
3448 serde_json::json!(format!(
3449 "max reroute attempts ({}) reached — no PR or code changes produced",
3450 max_reroutes
3451 )),
3452 ),
3453 ("last_error", serde_json::json!(msg)),
3454 ("agent", serde_json::json!(null)),
3455 ("model", serde_json::json!(null)),
3456 ];
3457 if let Err(e) = task_manager
3458 .update_task_status_and_result(&task.id, Status::Blocked, &fields)
3459 .await
3460 {
3461 tracing::error!(task_id = task.id.0, err = %e, "update_task_status_and_result(Blocked) failed — skipping block to avoid silent auto-unblock loop");
3462 }
3463 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Blocked(msg)))
3464 } else if task.id.0.starts_with("internal:") {
3465 tracing::info!(
3472 task_id = task.id.0,
3473 "no PR and no commits for internal task — marking done"
3474 );
3475 if let Err(e) = task_manager
3476 .update_task_status(&task.id, Status::Done)
3477 .await
3478 {
3479 tracing::error!(task_id = task.id.0, err = %e, "update_task_status(Done) failed — task may be stuck in InReview");
3480 }
3481 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped))
3482 } else {
3483 if let Err(e) = store_set_result(
3488 &Some(Arc::clone(store)),
3489 repo,
3490 &task.id.0,
3491 &[
3492 ("agent", serde_json::json!(null)),
3493 ("model", serde_json::json!(null)),
3494 (
3495 "last_error",
3496 serde_json::json!("no PR or code changes produced"),
3497 ),
3498 ],
3499 )
3500 .await
3501 {
3502 tracing::error!(task_id = task.id.0, err = %e, "failed to clear agent/model for reroute — skipping status transition");
3503 } else if let Err(e) =
3505 task_manager.update_task_status(&task.id, Status::New).await
3506 {
3507 tracing::error!(task_id = task.id.0, err = %e, "update_task_status(New) failed — task may be stuck in InReview");
3508 }
3509 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Rerouted))
3510 }
3511 }
3512 }
3513 Err(e) => {
3514 tracing::warn!(
3515 task_id = task.id.0,
3516 branch = %branch_name,
3517 error = %e,
3518 "failed to check PR status"
3519 );
3520 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
3521 format!("PR check failed: {e}"),
3522 )))
3523 }
3524 }
3525}
3526
3527pub(crate) fn review_timeout_seconds() -> u64 {
3529 review_timeout_secs(
3530 crate::config::get("workflow.review_timeout_seconds")
3531 .ok()
3532 .and_then(|s| s.parse().ok()),
3533 )
3534}
3535
3536pub(crate) const REVIEW_RESULT_DELIVERY_SECS: u64 = 120;
3540
3541pub(crate) fn no_session_review_floor(configured: u64, review_timeout: u64) -> u64 {
3545 configured.max(review_timeout + REVIEW_RESULT_DELIVERY_SECS)
3546}
3547
3548pub(crate) async fn review_and_merge(
3554 task: &ExternalTask,
3555 backend: &Arc<dyn ExternalBackend>,
3556 tmux: &Arc<TmuxManager>,
3557 repo: &str,
3558 router: &Arc<RwLock<Router>>,
3559 task_manager: &Arc<TaskManager>,
3560 store: &Arc<TaskStore>,
3561) -> anyhow::Result<ReviewDecision> {
3562 let ctx = match build_review_context(task, repo, router, task_manager, store).await? {
3563 ReviewPhase::Ready(ctx) => ctx,
3564 ReviewPhase::EarlyReturn(decision) => return Ok(decision),
3565 };
3566
3567 let run = match invoke_review_agent(task, tmux, &ctx, repo, store).await {
3568 ReviewPhase::Ready(run) => run,
3569 ReviewPhase::EarlyReturn(decision) => return Ok(decision),
3570 };
3571
3572 let parsed = match parse_review_output(task, repo, &ctx, run, store).await {
3573 ReviewPhase::Ready(parsed) => parsed,
3574 ReviewPhase::EarlyReturn(decision) => return Ok(decision),
3575 };
3576
3577 finalize_review_run(task, &ctx, &parsed, store).await;
3578
3579 let router_config = router.read().await.config.clone();
3580 apply_review_decision(
3581 task,
3582 backend,
3583 repo,
3584 task_manager,
3585 store,
3586 &ctx,
3587 &parsed,
3588 &router_config,
3589 )
3590 .await
3591}
3592
3593#[cfg(test)]
3594mod tests {
3595 use super::*;
3596
3597 #[test]
3598 fn a_review_decision_counts_only_from_a_successful_result() {
3599 let approve = r#"{"type":"result","subtype":"success","is_error":false,"result":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"issues\":[]}"}"#;
3600 let prose = r#"{"type":"result","subtype":"success","is_error":false,"result":"I have finished looking at the code."}"#;
3601 let failed = r#"{"type":"result","subtype":"error","is_error":true,"result":"{\"decision\":\"approve\",\"notes\":\"\",\"issues\":[]}"}"#;
3602 let thinking = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"Reading the diff"}]}}"#;
3603
3604 assert!(output_has_review_decision("claude", approve));
3605 assert!(!output_has_review_decision("claude", prose));
3606 assert!(!output_has_review_decision("claude", failed));
3607 assert!(!output_has_review_decision("claude", thinking));
3608 assert!(!output_has_review_decision("claude", ""));
3609 }
3610
3611 #[test]
3612 fn a_review_gets_15_minutes_unless_configured_and_never_under_5() {
3613 assert_eq!(review_timeout_secs(None), 900);
3614 assert_eq!(review_timeout_secs(Some(600)), 600);
3615 assert_eq!(review_timeout_secs(Some(60)), 300);
3616 assert_eq!(review_timeout_secs(Some(3600)), 3600);
3617 }
3618
3619 use crate::backends::ExternalTask;
3620 use crate::engine::router::RouterConfig;
3621 use crate::github::types::{GitHubReview, GitHubReviewComment, GitHubUser, PullRequestReview};
3622 use crate::store::{TaskStatus, TaskStore};
3623 use tempfile::TempDir;
3624
3625 #[test]
3630 fn no_session_review_floor_raises_a_limit_under_the_review_timeout() {
3631 assert_eq!(
3632 no_session_review_floor(600, 900),
3633 900 + REVIEW_RESULT_DELIVERY_SECS
3634 );
3635 }
3636
3637 #[test]
3638 fn no_session_review_floor_keeps_a_limit_over_the_review_timeout() {
3639 assert_eq!(no_session_review_floor(1800, 900), 1800);
3640 }
3641
3642 #[test]
3643 fn no_session_review_floor_clears_the_review_timeout_by_the_delivery_margin() {
3644 for (configured, review_timeout) in [(0, 0), (1800, 1800), (120, 7200)] {
3645 let floored = no_session_review_floor(configured, review_timeout);
3646 assert!(
3647 floored >= review_timeout + REVIEW_RESULT_DELIVERY_SECS,
3648 "{floored} must leave a finished review time to deliver its result"
3649 );
3650 }
3651 }
3652
3653 #[test]
3654 fn outcome_for_agent_error_kimi_billing_cycle_is_not_rate_limit() {
3655 let err = runner::agents::AgentError::RateLimit {
3659 message: "Failed to authenticate. API Error: 403 You've reached your usage limit \
3660 for this billing cycle. Your quota will be refreshed soon."
3661 .to_string(),
3662 };
3663 assert_eq!(outcome_for_agent_error(&err), "billing_cycle_exhausted");
3664 }
3665
3666 #[test]
3667 fn outcome_for_agent_error_generic_rate_limit_unaffected() {
3668 let err = runner::agents::AgentError::RateLimit {
3669 message: "429 Too Many Requests".to_string(),
3670 };
3671 assert_eq!(outcome_for_agent_error(&err), "rate_limit");
3672 }
3673
3674 #[test]
3675 fn outcome_for_agent_error_generic_auth_unaffected() {
3676 let err = runner::agents::AgentError::Auth {
3677 message: "401 Unauthorized".to_string(),
3678 };
3679 assert_eq!(outcome_for_agent_error(&err), "auth_error");
3680 }
3681
3682 #[test]
3685 fn parse_pr_number_standard_url() {
3686 assert_eq!(
3687 parse_pr_number_from_url("https://github.com/owner/repo/pull/42"),
3688 Ok(42)
3689 );
3690 }
3691
3692 #[test]
3693 fn parse_pr_number_trailing_slash() {
3694 assert_eq!(
3695 parse_pr_number_from_url("https://github.com/owner/repo/pull/42/"),
3696 Ok(42)
3697 );
3698 }
3699
3700 #[test]
3701 fn parse_pr_number_with_query_string() {
3702 assert_eq!(
3703 parse_pr_number_from_url("https://github.com/owner/repo/pull/42?tab=files"),
3704 Ok(42)
3705 );
3706 }
3707
3708 #[test]
3709 fn parse_pr_number_with_hash_fragment() {
3710 assert_eq!(
3711 parse_pr_number_from_url("https://github.com/owner/repo/pull/42#issuecomment-123"),
3712 Ok(42)
3713 );
3714 }
3715
3716 #[test]
3717 fn parse_pr_number_with_query_and_hash() {
3718 assert_eq!(
3719 parse_pr_number_from_url("https://github.com/owner/repo/pull/42?quick_pull=1#top"),
3720 Ok(42)
3721 );
3722 }
3723
3724 #[test]
3725 fn parse_pr_number_with_leading_whitespace() {
3726 assert_eq!(
3727 parse_pr_number_from_url(" https://github.com/owner/repo/pull/42\n"),
3728 Ok(42)
3729 );
3730 }
3731
3732 #[test]
3733 fn parse_pr_number_wrong_domain() {
3734 let url = "https://gitlab.com/owner/repo/merge_requests/42";
3735 let result = parse_pr_number_from_url(url);
3736 assert!(result.is_err());
3737 assert!(result
3738 .unwrap_err()
3739 .contains("does not start with 'https://github.com/'"));
3740 }
3741
3742 #[test]
3743 fn parse_pr_number_missing_pull_segment() {
3744 let url = "https://github.com/owner/repo/issues/42";
3746 let result = parse_pr_number_from_url(url);
3747 assert!(result.is_err());
3748 assert!(result.unwrap_err().contains("missing '/pull/' segment"));
3749 }
3750
3751 #[test]
3752 fn parse_pr_number_zero_is_rejected() {
3753 let url = "https://github.com/owner/repo/pull/0";
3754 let result = parse_pr_number_from_url(url);
3755 assert!(result.is_err());
3756 assert!(result.unwrap_err().contains("cannot be zero"));
3757 }
3758
3759 #[test]
3760 fn parse_pr_number_non_numeric_segment() {
3761 let url = "https://github.com/owner/repo/pull/abc";
3762 let result = parse_pr_number_from_url(url);
3763 assert!(result.is_err());
3764 assert!(result.unwrap_err().contains("not a valid positive integer"));
3765 }
3766
3767 #[test]
3768 fn parse_pr_number_empty_string() {
3769 let result = parse_pr_number_from_url("");
3770 assert!(result.is_err());
3771 assert!(result.unwrap_err().contains("empty"));
3772 }
3773
3774 #[test]
3775 fn parse_pr_number_http_not_https() {
3776 let url = "http://github.com/owner/repo/pull/42";
3778 let result = parse_pr_number_from_url(url);
3779 assert!(result.is_err());
3780 assert!(result
3781 .unwrap_err()
3782 .contains("does not start with 'https://github.com/'"));
3783 }
3784
3785 #[test]
3793 fn format_review_error_summarises_rate_limit_fragment_with_api_retry() {
3794 let raw_fragment = "{\"attempt\":6,\"max_retries\":10,\"retry_delay_ms\":17266.789,\"error_status\":429,\"error\":\"rate_limit\",\"session_id\":\"5c7148ee\"}\n\
3798 {\"type\":\"system\",\"subtype\":\"api_retry\",\"attempt\":7,\"max_retries\":10,\"retry_delay_ms\":35162.66,\"error_status\":429,\"error\":\"rate_limit\",\"session_id\":\"5c7148ee\"}";
3799 let err = runner::agents::AgentError::RateLimit {
3800 message: raw_fragment.to_string(),
3801 };
3802
3803 let stored = format_review_error("minimax", &err);
3804
3805 assert!(
3808 !stored.contains("{\"type\""),
3809 "stored error must not contain raw NDJSON envelope, got: {stored}"
3810 );
3811 assert!(
3812 !stored.contains("subtype"),
3813 "stored error must not contain raw api_retry fragment, got: {stored}"
3814 );
3815 assert!(
3816 !stored.contains("session_id"),
3817 "stored error must not contain session_id from provider payload, got: {stored}"
3818 );
3819 assert_eq!(
3820 stored,
3821 "minimax rate limit: status=429 after 7 attempts (last delay 35s)"
3822 );
3823 }
3824
3825 #[test]
3826 fn format_review_error_summarises_truncated_fragment_without_subtype_marker() {
3827 let raw_fragment = ",\"attempt\":5,\"max_retries\":10,\"retry_delay_ms\":9034.81,\"error_status\":429,\"error\":\"rate_limit\",\"session_id\":\"992e17d3\"";
3832 let err = runner::agents::AgentError::RateLimit {
3833 message: raw_fragment.to_string(),
3834 };
3835
3836 let stored = format_review_error("glm", &err);
3837
3838 assert!(stored.starts_with("glm rate limit:"));
3839 assert!(
3840 !stored.contains("session_id"),
3841 "stored error must not leak session_id from truncated fragment, got: {stored}"
3842 );
3843 assert!(
3846 stored.len() <= 200,
3847 "stored error should be compact (<=200 chars), got {} chars: {stored}",
3848 stored.len()
3849 );
3850 }
3851
3852 #[test]
3853 fn format_review_error_preserves_non_rate_limit_variants() {
3854 let err = runner::agents::AgentError::Auth {
3855 message: "invalid bearer token".to_string(),
3856 };
3857 assert_eq!(
3858 format_review_error("kimi", &err),
3859 "kimi auth error: invalid bearer token"
3860 );
3861
3862 let err = runner::agents::AgentError::Timeout {
3863 elapsed: std::time::Duration::from_secs(1801),
3864 };
3865 assert_eq!(
3866 format_review_error("kimi", &err),
3867 "kimi timed out after 1801s"
3868 );
3869 }
3870
3871 fn git(dir: &std::path::Path, args: &[&str]) {
3872 let mut cmd = std::process::Command::new("git");
3873 cmd.current_dir(dir);
3874 if args.first() == Some(&"commit") {
3876 cmd.args(["-c", "commit.gpgsign=false"]);
3877 }
3878 let status = cmd.args(args).status().unwrap();
3879 assert!(status.success(), "git {:?} failed", args);
3880 }
3881
3882 #[test]
3883 fn test_pull_request_review_requests_changes() {
3884 let review = PullRequestReview {
3885 review: GitHubReview {
3886 id: 1,
3887 user: GitHubUser {
3888 login: "reviewer".to_string(),
3889 },
3890 body: Some("Please fix".to_string()),
3891 state: "CHANGES_REQUESTED".to_string(),
3892 html_url: None,
3893 submitted_at: "2024-01-01T00:00:00Z".to_string(),
3894 commit_id: None,
3895 },
3896 comments: vec![],
3897 };
3898 assert!(review.requests_changes());
3899 }
3900
3901 #[test]
3902 fn test_pull_request_review_does_not_request_changes() {
3903 let review = PullRequestReview {
3904 review: GitHubReview {
3905 id: 1,
3906 user: GitHubUser {
3907 login: "reviewer".to_string(),
3908 },
3909 body: Some("LGTM".to_string()),
3910 state: "APPROVED".to_string(),
3911 html_url: None,
3912 submitted_at: "2024-01-01T00:00:00Z".to_string(),
3913 commit_id: None,
3914 },
3915 comments: vec![],
3916 };
3917 assert!(!review.requests_changes());
3918 }
3919
3920 #[test]
3921 fn test_pull_request_review_actionable_comments_filters_empty_and_replies() {
3922 let review = PullRequestReview {
3923 review: GitHubReview {
3924 id: 1,
3925 user: GitHubUser {
3926 login: "reviewer".to_string(),
3927 },
3928 body: None,
3929 state: "CHANGES_REQUESTED".to_string(),
3930 html_url: None,
3931 submitted_at: "2024-01-01T00:00:00Z".to_string(),
3932 commit_id: None,
3933 },
3934 comments: vec![
3935 GitHubReviewComment {
3936 id: 1,
3937 user: GitHubUser {
3938 login: "reviewer".to_string(),
3939 },
3940 body: "Fix this issue".to_string(),
3941 path: "src/main.rs".to_string(),
3942 line: Some(10),
3943 original_line: Some(10),
3944 commit_id: "abc".to_string(),
3945 original_commit_id: "abc".to_string(),
3946 html_url: "url".to_string(),
3947 created_at: "2024-01-01T00:00:00Z".to_string(),
3948 updated_at: "2024-01-01T00:00:00Z".to_string(),
3949 in_reply_to_id: None,
3950 diff_hunk: Some(
3951 "@@ -8,5 +8,5 @@ fn main() {\n- let x = 1;\n+ let x = 2;".to_string(),
3952 ),
3953 },
3954 GitHubReviewComment {
3955 id: 2,
3956 user: GitHubUser {
3957 login: "reviewer".to_string(),
3958 },
3959 body: "".to_string(),
3960 path: "src/lib.rs".to_string(),
3961 line: Some(20),
3962 original_line: Some(20),
3963 commit_id: "abc".to_string(),
3964 original_commit_id: "abc".to_string(),
3965 html_url: "url".to_string(),
3966 created_at: "2024-01-01T00:00:00Z".to_string(),
3967 updated_at: "2024-01-01T00:00:00Z".to_string(),
3968 in_reply_to_id: None,
3969 diff_hunk: None,
3970 },
3971 GitHubReviewComment {
3972 id: 3,
3973 user: GitHubUser {
3974 login: "reviewer".to_string(),
3975 },
3976 body: "Reply to this".to_string(),
3977 path: "src/lib.rs".to_string(),
3978 line: Some(30),
3979 original_line: Some(30),
3980 commit_id: "abc".to_string(),
3981 original_commit_id: "abc".to_string(),
3982 html_url: "url".to_string(),
3983 created_at: "2024-01-01T00:00:00Z".to_string(),
3984 updated_at: "2024-01-01T00:00:00Z".to_string(),
3985 in_reply_to_id: Some(1),
3986 diff_hunk: None,
3987 },
3988 ],
3989 };
3990 let actionable = review.actionable_comments();
3991 assert_eq!(actionable.len(), 1);
3992 assert_eq!(actionable[0].id, 1);
3993 assert_eq!(actionable[0].body, "Fix this issue");
3994 assert_eq!(actionable[0].path, "src/main.rs");
3995 assert_eq!(
3996 actionable[0].diff_hunk.as_ref().unwrap(),
3997 "@@ -8,5 +8,5 @@ fn main() {\n- let x = 1;\n+ let x = 2;"
3998 );
3999 }
4000
4001 #[test]
4002 fn test_router_config_model_for_complexity_returns_none_without_config() {
4003 let cfg = RouterConfig::default();
4006 assert!(cfg.model_for_complexity("claude", "simple", "").is_none());
4007 assert!(cfg.model_for_complexity("opencode", "review", "").is_none());
4008 assert!(cfg
4009 .model_for_complexity("unknown_agent_xyz", "simple", "")
4010 .is_none());
4011 }
4012
4013 #[test]
4014 fn review_status_maps_decisions() {
4015 assert_eq!(
4016 review_status(&ReviewDecision::Approve { issues: vec![] })
4017 .unwrap()
4018 .0,
4019 "success"
4020 );
4021 let changes = ReviewDecision::RequestChanges {
4022 notes: String::new(),
4023 issues: vec![],
4024 };
4025 assert_eq!(review_status(&changes).unwrap().0, "failure");
4026 assert!(review_status(&ReviewDecision::Failed("x".into())).is_none());
4027 }
4028
4029 fn sample_review_issue(severity: &str) -> runner::response::ReviewIssue {
4030 runner::response::ReviewIssue {
4031 file: "src/engine/tick.rs".into(),
4032 line: Some(142),
4033 severity: severity.into(),
4034 description: "index out of bounds on the last element".into(),
4035 }
4036 }
4037
4038 fn sample_review_response(
4039 decision: &str,
4040 test_results: Option<&str>,
4041 issues: Vec<runner::response::ReviewIssue>,
4042 ) -> runner::response::ReviewResponse {
4043 runner::response::ReviewResponse {
4044 decision: decision.into(),
4045 notes: "looks fine".into(),
4046 test_results: test_results.map(str::to_string),
4047 issues,
4048 }
4049 }
4050
4051 #[test]
4052 fn approve_with_failing_tests_becomes_request_changes() {
4053 let decision =
4054 review_decision_from_response(sample_review_response("approve", Some("fail"), vec![]));
4055 match decision {
4056 ReviewDecision::RequestChanges { notes, issues } => {
4057 assert!(
4058 notes.contains("test_results: fail"),
4059 "notes must name the failing test run, got {notes:?}"
4060 );
4061 assert!(issues.is_empty());
4062 }
4063 other => panic!("expected RequestChanges, got {other:?}"),
4064 }
4065 }
4066
4067 #[test]
4068 fn approve_with_error_issue_becomes_request_changes() {
4069 let issue = sample_review_issue("error");
4070 let decision = review_decision_from_response(sample_review_response(
4071 "approve",
4072 Some("pass"),
4073 vec![issue.clone()],
4074 ));
4075 match decision {
4076 ReviewDecision::RequestChanges { issues, .. } => {
4077 assert_eq!(issues, vec![issue]);
4078 }
4079 other => panic!("expected RequestChanges, got {other:?}"),
4080 }
4081 }
4082
4083 #[test]
4084 fn approve_with_warning_issue_stays_approve_and_comment_lists_it() {
4085 let issue = sample_review_issue("warning");
4086 let decision = review_decision_from_response(sample_review_response(
4087 "approve",
4088 Some("pass"),
4089 vec![issue.clone()],
4090 ));
4091 match &decision {
4092 ReviewDecision::Approve { issues } => {
4093 assert_eq!(issues, &vec![issue.clone()]);
4094 }
4095 other => panic!("expected Approve, got {other:?}"),
4096 }
4097 let comment = build_pr_review_comment(&decision, "looks fine");
4098 assert!(
4099 comment.contains("Approve"),
4100 "approve header missing from {comment}"
4101 );
4102 assert!(
4103 comment.contains(&issue.description),
4104 "warning issue missing from {comment}"
4105 );
4106 assert!(
4107 comment.contains("warning"),
4108 "warning severity missing from {comment}"
4109 );
4110 }
4111
4112 #[test]
4113 fn review_started_comment_includes_agent_and_model() {
4114 let expected_footer = attribution_footer("Review started", "kimi", Some("opus"));
4115 let expected = format!("🔍 Automated review started{}", expected_footer);
4116 assert_eq!(review_started_comment("kimi", "opus"), expected);
4117 }
4118
4119 fn no_code_review_task(id: &str) -> ExternalTask {
4122 ExternalTask {
4123 id: crate::backends::ExternalId(id.to_string()),
4124 title: "opencode discovery timeout".to_string(),
4125 body: "".to_string(),
4126 state: "open".to_string(),
4127 labels: vec![],
4128 author: "tester".to_string(),
4129 created_at: "2026-01-01T00:00:00Z".to_string(),
4130 updated_at: "2026-01-01T00:00:00Z".to_string(),
4131 url: "https://example.com/issues/1".to_string(),
4132 }
4133 }
4134
4135 #[tokio::test]
4139 async fn carried_approval_counts_as_this_rounds_decision() {
4140 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4141 let id = store
4142 .create_internal("owner/repo", "Approved head", "", "test", "carried", None)
4143 .await
4144 .unwrap();
4145 let task_id = format!("internal:{id}");
4146 store
4147 .update_status(id, crate::store::TaskStatus::InReview)
4148 .await
4149 .unwrap();
4150 assert_eq!(
4151 store.in_review_entry(id).await.unwrap().map(|e| e.1),
4152 Some(false)
4153 );
4154
4155 record_carried_approval(&store, "owner/repo", &task_id, 7).await;
4156
4157 assert_eq!(
4158 store.in_review_entry(id).await.unwrap().map(|e| e.1),
4159 Some(true),
4160 "the stale in_review check must see this round as decided"
4161 );
4162 }
4163
4164 #[test]
4165 fn approval_covers_head_only_for_the_exact_commit() {
4166 assert!(approval_covers_head(Some("abc123"), Some("abc123")));
4167 assert!(
4168 !approval_covers_head(Some("abc123"), Some("def456")),
4169 "a moved PR head must be reviewed"
4170 );
4171 assert!(
4172 !approval_covers_head(None, Some("abc123")),
4173 "no recorded approval means review"
4174 );
4175 assert!(
4176 !approval_covers_head(Some("abc123"), None),
4177 "an unreadable PR head must not skip the review"
4178 );
4179 assert!(!approval_covers_head(Some(""), Some("")));
4180 }
4181
4182 fn pr_for(
4183 head_repo: Option<&str>,
4184 head: &str,
4185 base: &str,
4186 ) -> crate::github::types::GitHubPullRequest {
4187 serde_json::from_value(serde_json::json!({
4188 "number": 7,
4189 "title": "t",
4190 "state": "open",
4191 "head": {"ref": head, "sha": "h", "repo": head_repo.map(|r| serde_json::json!({"full_name": r}))},
4192 "base": {"ref": base, "sha": "b"},
4193 "html_url": "https://github.com/owner/repo/pull/7",
4194 }))
4195 .unwrap()
4196 }
4197
4198 #[test]
4199 fn a_carried_approval_must_match_the_approved_repo_branch_and_base() {
4200 let approval = ApprovalEvent {
4201 sha: "a".into(),
4202 repo: "owner/repo".into(),
4203 branch: "gh-issue-1".into(),
4204 base: "main".into(),
4205 };
4206 let ok = pr_for(Some("owner/repo"), "gh-issue-1", "main");
4207 assert!(pr_matches_approval(&ok, &approval, "gh-issue-1"));
4208 assert!(!pr_matches_approval(
4209 &pr_for(Some("fork/repo"), "gh-issue-1", "main"),
4210 &approval,
4211 "gh-issue-1"
4212 ));
4213 assert!(!pr_matches_approval(
4214 &pr_for(None, "gh-issue-1", "main"),
4215 &approval,
4216 "gh-issue-1"
4217 ));
4218 assert!(!pr_matches_approval(
4219 &pr_for(Some("owner/repo"), "other", "main"),
4220 &approval,
4221 "gh-issue-1"
4222 ));
4223 assert!(!pr_matches_approval(
4224 &pr_for(Some("owner/repo"), "gh-issue-1", "release"),
4225 &approval,
4226 "gh-issue-1"
4227 ));
4228 assert!(!pr_matches_approval(&ok, &approval, "another-task-branch"));
4229
4230 let legacy = ApprovalEvent::parse("abc123").unwrap();
4231 assert_eq!(legacy.sha, "abc123");
4232 assert!(!pr_matches_approval(&ok, &legacy, "gh-issue-1"));
4233 let json = serde_json::to_string(&approval).unwrap();
4234 assert_eq!(ApprovalEvent::parse(&json), Some(approval));
4235 assert_eq!(ApprovalEvent::parse(" "), None);
4236 }
4237
4238 #[tokio::test]
4239 async fn only_the_same_commits_rebased_onto_the_base_carry_the_approval() {
4240 let dir = tempfile::tempdir().unwrap();
4241 let path = dir.path();
4242 let git = |args: &[&str]| {
4243 let out = std::process::Command::new("git")
4244 .args([
4245 "-c",
4246 "user.name=agent",
4247 "-c",
4248 "user.email=agent@t",
4249 "-c",
4250 "commit.gpgsign=false",
4251 ])
4252 .args(args)
4253 .current_dir(path)
4254 .output()
4255 .unwrap();
4256 assert!(
4257 out.status.success(),
4258 "git {args:?}: {}",
4259 String::from_utf8_lossy(&out.stderr)
4260 );
4261 String::from_utf8_lossy(&out.stdout).trim().to_string()
4262 };
4263 let write_commit = |file: &str, body: &str| {
4264 std::fs::write(path.join(file), body).unwrap();
4265 git(&["add", file]);
4266 git(&["commit", "-q", "-m", body]);
4267 };
4268 git(&["init", "-q", "-b", "main"]);
4269 write_commit("a", "base");
4270 git(&["checkout", "-q", "-b", "pr"]);
4271 write_commit("b", "first");
4272 write_commit("c", "second");
4273 let approved = git(&["rev-parse", "HEAD"]);
4274 git(&["checkout", "-q", "main"]);
4275 write_commit("d", "base moved");
4276 git(&["checkout", "-q", "-b", "rebased", &approved]);
4277 git(&["rebase", "-q", "main"]);
4278 let rebased = git(&["rev-parse", "HEAD"]);
4279 assert!(same_commits_rebased(path, &approved, "main", &rebased).await);
4280
4281 write_commit("c", "second, changed");
4283 let changed = git(&["rev-parse", "HEAD"]);
4284 assert!(!same_commits_rebased(path, &approved, "main", &changed).await);
4285
4286 git(&["checkout", "-q", "-b", "other-author", &rebased]);
4288 git(&[
4289 "-c",
4290 "user.name=someone",
4291 "-c",
4292 "user.email=someone@t",
4293 "commit",
4294 "-q",
4295 "--amend",
4296 "--reset-author",
4297 "--no-edit",
4298 ]);
4299 let reauthored = git(&["rev-parse", "HEAD"]);
4300 assert!(!same_commits_rebased(path, &approved, "main", &reauthored).await);
4301 }
4302
4303 #[tokio::test]
4304 async fn only_a_clean_merge_of_the_base_counts_as_base_only() {
4305 let dir = tempfile::tempdir().unwrap();
4306 let path = dir.path();
4307 let git = |args: &[&str]| {
4308 let out = std::process::Command::new("git")
4309 .args([
4310 "-c",
4311 "user.name=t",
4312 "-c",
4313 "user.email=t@t",
4314 "-c",
4315 "commit.gpgsign=false",
4316 ])
4317 .args(args)
4318 .current_dir(path)
4319 .output()
4320 .unwrap();
4321 assert!(
4322 out.status.success(),
4323 "git {args:?}: {}",
4324 String::from_utf8_lossy(&out.stderr)
4325 );
4326 String::from_utf8_lossy(&out.stdout).trim().to_string()
4327 };
4328 let commit = |file: &str, msg: &str| {
4329 std::fs::write(path.join(file), msg).unwrap();
4330 git(&["add", file]);
4331 git(&["commit", "-q", "-m", msg]);
4332 git(&["rev-parse", "HEAD"])
4333 };
4334 git(&["init", "-q", "-b", "main"]);
4335 commit("a", "base");
4336 git(&["checkout", "-q", "-b", "pr"]);
4337 let approved = commit("b", "approved change");
4338 git(&["checkout", "-q", "main"]);
4339 let base_tip = commit("c", "base moved");
4340 git(&["checkout", "-q", "pr"]);
4341 git(&["merge", "-q", "--no-edit", "main"]);
4342 let clean = git(&["rev-parse", "HEAD"]);
4343 assert!(is_clean_merge(path, &approved, &base_tip, &clean).await);
4344
4345 std::fs::write(path.join("d"), "unreviewed").unwrap();
4346 git(&["add", "d"]);
4347 git(&["commit", "-q", "--amend", "--no-edit"]);
4348 let forged = git(&["rev-parse", "HEAD"]);
4349 assert!(!is_clean_merge(path, &approved, &base_tip, &forged).await);
4350 }
4351
4352 #[test]
4353 fn only_a_merge_of_the_approved_commit_carries_the_approval() {
4354 let parents = |p: &[&str]| p.iter().map(|s| s.to_string()).collect::<Vec<_>>();
4355 assert_eq!(
4356 base_merge_parent("approved", &parents(&["approved", "base"])),
4357 Some("base")
4358 );
4359 assert_eq!(base_merge_parent("approved", &parents(&["approved"])), None);
4361 assert_eq!(
4362 base_merge_parent("approved", &parents(&["other", "base"])),
4363 None
4364 );
4365 assert_eq!(
4366 base_merge_parent("approved", &parents(&["approved", "a", "b"])),
4367 None
4368 );
4369 }
4370
4371 #[tokio::test]
4376 async fn approved_sha_round_trips_through_the_kv_store() {
4377 let temp = TempDir::new().unwrap();
4378 let wt = temp.path();
4379 git(wt, &["init", "-b", "main"]);
4380 git(wt, &["config", "user.name", "Test"]);
4381 git(wt, &["config", "user.email", "test@test.com"]);
4382 std::fs::write(wt.join("fix.txt"), "the fix").unwrap();
4383 git(wt, &["add", "."]);
4384 git(wt, &["commit", "-m", "apply the fix"]);
4385
4386 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4387 let repo = "owner/repo";
4388 let task_id = "3783";
4389
4390 record_approved_sha(&store, repo, task_id, 77, wt, "gh-issue-3783", "main").await;
4391 let recorded = read_approval(&store, repo, task_id, 77)
4392 .await
4393 .unwrap()
4394 .expect("approval must be recorded");
4395 assert_eq!(recorded.repo, repo);
4396 assert_eq!(recorded.branch, "gh-issue-3783");
4397 assert_eq!(recorded.base, "main");
4398 assert_eq!(
4399 recorded.sha,
4400 worktree_head_sha(wt).await.unwrap(),
4401 "the recorded sha must be the commit that was pushed to the PR"
4402 );
4403 assert!(
4404 store
4405 .kv_get(&approved_sha_key(repo, task_id, 78))
4406 .await
4407 .unwrap()
4408 .is_none(),
4409 "an approval must not leak to another PR"
4410 );
4411
4412 clear_approved_sha(&store, repo, task_id, 77).await;
4413 assert!(
4414 store
4415 .kv_get(&approved_sha_key(repo, task_id, 77))
4416 .await
4417 .unwrap()
4418 .is_none(),
4419 "requesting changes must drop the approval"
4420 );
4421 }
4422
4423 #[tokio::test]
4424 async fn build_review_context_reroutes_external_task_with_no_code() {
4425 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4426 let repo = "owner/repo";
4427 let ext = no_code_review_task("3617");
4428 store.ensure_external_task(repo, &ext).await.unwrap();
4429
4430 let backend = Arc::new(crate::backends::test_helpers::NoopBackend);
4431 let task_manager = Arc::new(TaskManager::with_store(
4432 backend,
4433 Arc::clone(&store),
4434 repo.to_string(),
4435 ));
4436 let router = Arc::new(RwLock::new(Router::new(RouterConfig::default())));
4437
4438 let phase = build_review_context(&ext, repo, &router, &task_manager, &store)
4439 .await
4440 .unwrap();
4441
4442 assert!(
4443 matches!(phase, ReviewPhase::EarlyReturn(ReviewDecision::Rerouted)),
4444 "external task with no worktree/branch/pr must reroute, not skip-to-done"
4445 );
4446
4447 let stored = store
4448 .get_by_external_id(repo, &ext.id.0)
4449 .await
4450 .unwrap()
4451 .expect("task must still exist");
4452 assert_eq!(stored.status, TaskStatus::New);
4453 assert!(stored.agent.is_none());
4454 assert!(stored.model.is_none());
4455 }
4456
4457 #[tokio::test]
4458 async fn build_review_context_blocks_external_task_after_max_reroutes() {
4459 let temp_home = TempDir::new().unwrap();
4462 let orch_dir = temp_home.path().join(".orch");
4463 std::fs::create_dir_all(&orch_dir).unwrap();
4464 let old_orch_home = std::env::var_os("ORCH_HOME");
4465 std::env::set_var("ORCH_HOME", &orch_dir);
4466
4467 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4468 let repo = "owner/repo";
4469 let ext = no_code_review_task("3618");
4470 store.ensure_external_task(repo, &ext).await.unwrap();
4471
4472 let backend = Arc::new(crate::backends::test_helpers::NoopBackend);
4473 let task_manager = Arc::new(TaskManager::with_store(
4474 backend,
4475 Arc::clone(&store),
4476 repo.to_string(),
4477 ));
4478 let router = Arc::new(RwLock::new(Router::new(RouterConfig::default())));
4479
4480 for _ in 0..3 {
4482 let phase = build_review_context(&ext, repo, &router, &task_manager, &store)
4483 .await
4484 .unwrap();
4485 if let ReviewPhase::EarlyReturn(ReviewDecision::Blocked(_)) = phase {
4486 break;
4487 }
4488 }
4489
4490 let stored = store
4491 .get_by_external_id(repo, &ext.id.0)
4492 .await
4493 .unwrap()
4494 .expect("task must still exist");
4495 assert_eq!(stored.status, TaskStatus::Blocked);
4496
4497 if let Some(old) = old_orch_home {
4498 std::env::set_var("ORCH_HOME", old);
4499 } else {
4500 std::env::remove_var("ORCH_HOME");
4501 }
4502 }
4503
4504 #[tokio::test]
4505 async fn build_review_context_marks_internal_task_done_with_no_code() {
4506 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4507 let repo = "owner/repo";
4508 let internal_id = store
4509 .create_internal(repo, "Daily retrospective", "", "job", "job-1", None)
4510 .await
4511 .unwrap();
4512 let ext_id = format!("internal:{internal_id}");
4513 let ext = no_code_review_task(&ext_id);
4514
4515 let backend = Arc::new(crate::backends::test_helpers::NoopBackend);
4516 let task_manager = Arc::new(TaskManager::with_store(
4517 backend,
4518 Arc::clone(&store),
4519 repo.to_string(),
4520 ));
4521 let router = Arc::new(RwLock::new(Router::new(RouterConfig::default())));
4522
4523 let phase = build_review_context(&ext, repo, &router, &task_manager, &store)
4524 .await
4525 .unwrap();
4526
4527 assert!(matches!(
4528 phase,
4529 ReviewPhase::EarlyReturn(ReviewDecision::Skipped)
4530 ));
4531 let stored = store.get(internal_id).await.unwrap();
4532 assert_eq!(stored.status, TaskStatus::Done);
4533 }
4534
4535 #[test]
4536 fn no_code_reroute_skip_check_rejects_agent_stream_disconnects() {
4537 let last_error =
4538 "codex failed: Reconnecting... (stream disconnected before completion: Broken pipe)";
4539 assert!(!should_skip_no_code_reroute_increment(last_error));
4540 }
4541
4542 #[test]
4551 fn review_fallback_scan_ignores_rate_limit_word_buried_in_long_report() {
4552 let padding = "x".repeat(10_000);
4553 let text_for_review = format!(
4554 "{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}"
4555 );
4556
4557 let scan_head = runner::agents::patterns::safe_head(
4558 &text_for_review,
4559 runner::agents::patterns::RATE_LIMIT_SCAN_HEAD_BYTES,
4560 );
4561 let scan_tail = runner::agents::patterns::safe_tail(
4562 &text_for_review,
4563 runner::agents::patterns::RATE_LIMIT_SCAN_TAIL_BYTES,
4564 );
4565
4566 assert!(runner::agents::patterns::detect_rate_limit(scan_head).is_none());
4567 assert!(runner::agents::patterns::detect_rate_limit(scan_tail).is_none());
4568 }
4569
4570 #[test]
4571 fn review_fallback_scan_still_detects_real_rate_limit_at_tail() {
4572 let padding = "x".repeat(10_000);
4573 let text_for_review =
4574 format!("{padding}\nError: rate limit exceeded, please try again later");
4575
4576 let scan_tail = runner::agents::patterns::safe_tail(
4577 &text_for_review,
4578 runner::agents::patterns::RATE_LIMIT_SCAN_TAIL_BYTES,
4579 );
4580
4581 assert!(runner::agents::patterns::detect_rate_limit(scan_tail).is_some());
4582 }
4583
4584 #[tokio::test]
4585 async fn recover_missing_review_worktree_recreates_worktree() {
4586 let temp_home = TempDir::new().unwrap();
4587 let orch_dir = temp_home.path().join(".orch");
4588 std::fs::create_dir_all(&orch_dir).unwrap();
4589 let old_orch_home = std::env::var_os("ORCH_HOME");
4590 std::env::set_var("ORCH_HOME", &orch_dir);
4591
4592 let project_dir = temp_home.path().join("project");
4593 std::fs::create_dir_all(&project_dir).unwrap();
4594 git(&project_dir, &["init", "-b", "main"]);
4595 git(&project_dir, &["config", "user.name", "Test User"]);
4596 git(&project_dir, &["config", "user.email", "test@example.com"]);
4597 std::fs::write(project_dir.join("README.md"), "hello").unwrap();
4598 git(&project_dir, &["add", "README.md"]);
4599 git(&project_dir, &["commit", "-m", "init"]);
4600
4601 std::fs::write(
4602 orch_dir.join("config.yml"),
4603 format!("projects:\n - {}\n", project_dir.display()),
4604 )
4605 .unwrap();
4606 std::fs::write(project_dir.join(".orch.yml"), "gh:\n repo: owner/repo\n").unwrap();
4607
4608 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4609 let repo = "owner/repo";
4610 store
4611 .create_internal(repo, "Fix review recovery", "", "review", "review-1", None)
4612 .await
4613 .unwrap();
4614 let ext = ExternalTask {
4615 id: crate::backends::ExternalId("1".to_string()),
4616 title: "Fix review recovery".to_string(),
4617 body: "".to_string(),
4618 state: "open".to_string(),
4619 labels: vec![],
4620 author: "tester".to_string(),
4621 created_at: "2026-01-01T00:00:00Z".to_string(),
4622 updated_at: "2026-01-01T00:00:00Z".to_string(),
4623 url: "https://example.com/issues/1".to_string(),
4624 };
4625 store.ensure_external_task(repo, &ext).await.unwrap();
4626
4627 let recovered = recover_missing_review_worktree(&ext, repo, &store)
4628 .await
4629 .unwrap();
4630
4631 assert!(recovered.work_dir.exists());
4632 assert!(recovered.branch.starts_with("gh-issue-1-"));
4633
4634 if let Some(old) = old_orch_home {
4635 std::env::set_var("ORCH_HOME", old);
4636 } else {
4637 std::env::remove_var("ORCH_HOME");
4638 }
4639 }
4640
4641 #[tokio::test]
4644 async fn count_ahead_commits_returns_zero_when_no_ahead_commits() {
4645 let temp = TempDir::new().unwrap();
4646 let dir = temp.path();
4647
4648 let bare = temp.path().join("bare.git");
4650 std::fs::create_dir_all(&bare).unwrap();
4651 std::process::Command::new("git")
4652 .args(["init", "--bare"])
4653 .current_dir(&bare)
4654 .output()
4655 .unwrap();
4656
4657 git(dir, &["init", "-b", "main"]);
4658 git(dir, &["config", "user.name", "Test"]);
4659 git(dir, &["config", "user.email", "test@test.com"]);
4660 std::fs::write(dir.join("f"), "a").unwrap();
4661 git(dir, &["add", "f"]);
4662 git(dir, &["commit", "-m", "init"]);
4663 std::process::Command::new("git")
4664 .args(["remote", "add", "origin", bare.to_str().unwrap()])
4665 .current_dir(dir)
4666 .output()
4667 .unwrap();
4668 std::process::Command::new("git")
4669 .args(["push", "origin", "main"])
4670 .current_dir(dir)
4671 .output()
4672 .unwrap();
4673
4674 let result = count_ahead_commits(dir, "main").await;
4676 assert!(
4677 result.is_ok(),
4678 "expected Ok when origin/main exists, got {result:?}"
4679 );
4680 assert_eq!(
4681 result.unwrap(),
4682 0,
4683 "expected 0 commits ahead when branch matches origin/main"
4684 );
4685 }
4686
4687 #[tokio::test]
4688 async fn count_ahead_commits_returns_error_when_remote_ref_missing() {
4689 let temp = TempDir::new().unwrap();
4690 let dir = temp.path();
4691 git(dir, &["init", "-b", "main"]);
4692 git(dir, &["config", "user.name", "Test"]);
4693 git(dir, &["config", "user.email", "test@test.com"]);
4694 std::fs::write(dir.join("f"), "a").unwrap();
4695 git(dir, &["add", "f"]);
4696 git(dir, &["commit", "-m", "init"]);
4697 let result = count_ahead_commits(dir, "main").await;
4699 assert!(
4700 result.is_err(),
4701 "expected Err when origin/main does not exist, got {result:?}"
4702 );
4703 let err = result.unwrap_err();
4704 assert!(
4705 err.contains("git rev-list exited"),
4706 "error should mention exit failure, got: {err}"
4707 );
4708 }
4709
4710 #[tokio::test]
4711 async fn count_ahead_commits_returns_error_when_git_cannot_start() {
4712 let temp = TempDir::new().unwrap();
4714 let dir = temp.path();
4715 let result = count_ahead_commits(dir, "main").await;
4717 assert!(
4718 result.is_err(),
4719 "expected Err when directory is not a git repo, got {result:?}"
4720 );
4721 }
4722
4723 #[serial_test::serial(cooldown_state)]
4727 #[tokio::test]
4728 async fn review_invalid_response_applies_short_model_cooldown() {
4729 crate::engine::cooldown::reset_global_state().await;
4730 let agent = "opencode-3314-review-invalid";
4731 let model = "opencode/north-mini-code-free";
4732 let key = format!("{agent}:{model}");
4733
4734 assert!(
4735 !crate::engine::cooldown::is_model_in_cooldown(agent, model),
4736 "model should not be in cooldown before record_review_agent_failure"
4737 );
4738
4739 let err = runner::agents::AgentError::InvalidResponse {
4740 raw: "empty review output".to_string(),
4741 };
4742 record_review_agent_failure("test-3314-a", agent, Some(model), &err).await;
4743
4744 let now = chrono::Utc::now().timestamp();
4745 let until = crate::engine::cooldown::cooldown_until(&key)
4746 .expect("review InvalidResponse should set model cooldown");
4747 let remaining = until.saturating_sub(now);
4748 let base = crate::engine::cooldown::TRANSIENT_MODEL_BACKOFF_BASE_SECS;
4749 assert!(
4750 (base - 30..=base).contains(&remaining),
4751 "first unusable review output must cool the model for 5 minutes, got {remaining}s"
4752 );
4753 }
4754
4755 #[serial_test::serial(cooldown_state)]
4757 #[tokio::test]
4758 async fn review_output_cooldown_caps_at_fifteen_minutes() {
4759 crate::engine::cooldown::reset_global_state().await;
4760 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4761 crate::engine::cooldown::init_cooldown_store(store).await;
4762 let agent = "opencode-review-output-cap";
4763 let model = "opencode/north-mini-code-free";
4764 let key = format!("{agent}:{model}");
4765 let base = crate::engine::cooldown::TRANSIENT_MODEL_BACKOFF_BASE_SECS;
4766 let max = crate::engine::cooldown::TRANSIENT_MODEL_BACKOFF_MAX_SECS;
4767
4768 for (i, expected) in [base, max, max].into_iter().enumerate() {
4769 let err = runner::agents::AgentError::InvalidResponse {
4770 raw: format!("failed to parse review response {i}"),
4771 };
4772 record_review_agent_failure("test-review-output-cap", agent, Some(model), &err).await;
4773 let now = chrono::Utc::now().timestamp();
4774 let remaining = crate::engine::cooldown::cooldown_until(&key)
4775 .expect("unusable review output should set model cooldown")
4776 .saturating_sub(now);
4777 assert!(
4778 (expected - 30..=expected).contains(&remaining),
4779 "failure {} should cool the model for {expected}s, got {remaining}s",
4780 i + 1
4781 );
4782 }
4783 }
4784
4785 #[serial_test::serial(cooldown_state)]
4791 #[tokio::test]
4792 async fn review_inline_scan_billing_cycle_message_gets_model_scoped_cooldown() {
4793 crate::engine::cooldown::reset_global_state().await;
4794 let agent = "kimi-3551-review-inline";
4795 let model = "opus";
4796 let key = format!("{agent}:{model}");
4797
4798 let text_for_review = "kimi rate limit: Failed to authenticate. API Error: 403 \
4800 You've reached your usage limit for this billing cycle. Your quota will be \
4801 refreshed next cycle.";
4802
4803 let scan_tail = runner::agents::patterns::safe_tail(
4804 text_for_review,
4805 runner::agents::patterns::RATE_LIMIT_SCAN_TAIL_BYTES,
4806 );
4807 let err = runner::agents::patterns::detect_rate_limit(scan_tail)
4808 .expect("billing-cycle message must still be caught by the rate-limit scan");
4809
4810 assert_eq!(
4811 outcome_for_agent_error(&err),
4812 "billing_cycle_exhausted",
4813 "inline-scanned billing-cycle message must not be stored as plain rate_limit"
4814 );
4815
4816 record_review_agent_failure("test-3551", agent, Some(model), &err).await;
4817
4818 let now = chrono::Utc::now().timestamp();
4819 let until = crate::engine::cooldown::cooldown_until(&key)
4820 .expect("billing-cycle exhaustion must set a model-scoped cooldown");
4821 let remaining = until.saturating_sub(now);
4822 assert!(
4827 remaining >= crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
4828 "billing-cycle exhaustion for a known model must apply the ~4h persistent \
4829 model cooldown, got {remaining}s — may have used the generic short agent \
4830 cooldown instead"
4831 );
4832 }
4833
4834 fn test_review_task(id: &str) -> ExternalTask {
4837 ExternalTask {
4838 id: crate::backends::ExternalId(id.to_string()),
4839 title: "Test review task".to_string(),
4840 body: "".to_string(),
4841 state: "open".to_string(),
4842 labels: vec![],
4843 author: "tester".to_string(),
4844 created_at: "2026-01-01T00:00:00Z".to_string(),
4845 updated_at: "2026-01-01T00:00:00Z".to_string(),
4846 url: "https://example.com/issues/1".to_string(),
4847 }
4848 }
4849
4850 fn test_review_context(agent: &str, model: &str, work_dir: &std::path::Path) -> ReviewContext {
4851 ReviewContext {
4852 store_id: None,
4853 had_prev_push_failure: false,
4854 review_attempt: 1,
4855 worktree_path: work_dir.to_path_buf(),
4856 branch_name: "gh-issue-1-test".to_string(),
4857 pr_number: 1,
4858 head_sha: None,
4859 default_branch: "main".to_string(),
4860 review_agent: agent.to_string(),
4861 review_model: Some(model.to_string()),
4862 review_task_id: "review-1".to_string(),
4863 review_attempt_dir: work_dir.to_path_buf(),
4864 output_file: work_dir.join("output.txt"),
4865 invocation: runner::agent::AgentInvocation {
4866 agent: agent.to_string(),
4867 model: Some(model.to_string()),
4868 work_dir: work_dir.to_path_buf(),
4869 system_prompt: String::new(),
4870 agent_message: String::new(),
4871 task_id: "review-1".to_string(),
4872 disallowed_tools: vec![],
4873 git_author_name: "Test".to_string(),
4874 git_author_email: "test@example.com".to_string(),
4875 output_file: work_dir.join("output.txt"),
4876 timeout_seconds: 0,
4877 repo: "owner/repo".to_string(),
4878 attempt: 1,
4879 sandbox: false,
4880 main_project_dir: work_dir.to_path_buf(),
4881 branch: String::new(),
4882 },
4883 same_agent_fallback: false,
4884 }
4885 }
4886
4887 fn test_parsed_review(decision: ReviewDecision) -> ParsedReview {
4888 ParsedReview {
4889 run_id: None,
4890 exit_code: 0,
4891 stderr: String::new(),
4892 raw_output: String::new(),
4893 text_for_review: String::new(),
4894 token_usage: RunTokenUsage::default(),
4895 review_notes_for_comment: String::new(),
4896 decision,
4897 }
4898 }
4899
4900 fn write_review_output(dir: &std::path::Path, stdout: &str, exit_code: i32) {
4901 std::fs::write(dir.join("output.txt"), stdout).unwrap();
4902 std::fs::write(dir.join("exit.txt"), exit_code.to_string()).unwrap();
4903 std::fs::write(dir.join("stderr.txt"), "").unwrap();
4904 }
4905
4906 #[serial_test::serial(cooldown_state)]
4910 #[tokio::test]
4911 async fn parse_review_output_mid_thought_is_incomplete_without_persistent_cooldown() {
4912 crate::engine::cooldown::reset_global_state().await;
4913 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4914 let work_dir = TempDir::new().unwrap();
4915 let agent = "kimi";
4916 let model = "opus-3734-mid-thought";
4917 let ctx = test_review_context(agent, model, work_dir.path());
4918 let redraw = "^D\u{0008}".repeat(20);
4919 let ndjson = format!(
4920 "{}\n{}\n{}\n{}\n{}",
4921 r#"{"type":"system","subtype":"thinking_tokens","tokens":1945}"#,
4922 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}}}"#,
4923 r#"{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"checking lease edge cases"}],"stop_reason":null,"usage":{"input_tokens":10,"output_tokens":0}}}"#,
4924 "\x1b[?25h",
4925 redraw,
4926 );
4927 write_review_output(work_dir.path(), &ndjson, 0);
4928
4929 let task = test_review_task("3734");
4930 let phase = parse_review_output(
4931 &task,
4932 "owner/repo",
4933 &ctx,
4934 ReviewRun { run_id: None },
4935 &store,
4936 )
4937 .await;
4938
4939 match phase {
4940 ReviewPhase::EarlyReturn(ReviewDecision::Failed(reason)) => {
4941 assert!(
4942 reason.contains("incomplete:"),
4943 "mid-thought review must be stored as incomplete, got {reason}"
4944 );
4945 }
4946 ReviewPhase::EarlyReturn(_) => panic!("expected Failed(incomplete)"),
4947 ReviewPhase::Ready(_) => panic!("expected Failed(incomplete), got Ready"),
4948 }
4949 assert!(
4950 !crate::engine::cooldown::is_model_in_cooldown(agent, model),
4951 "mid-thought incomplete must not apply persistent model cooldown"
4952 );
4953 }
4954
4955 #[serial_test::serial(cooldown_state)]
4958 #[tokio::test]
4959 async fn parse_review_output_tool_use_without_envelope_is_incomplete() {
4960 crate::engine::cooldown::reset_global_state().await;
4961 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4962 let work_dir = TempDir::new().unwrap();
4963 let agent = "kimi";
4964 let model = "opus-3734-tool-use";
4965 let ctx = test_review_context(agent, model, work_dir.path());
4966 let ndjson = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Read","input":{"file_path":"src/lib.rs"}}]}}"#;
4967 write_review_output(work_dir.path(), ndjson, 0);
4968
4969 let task = test_review_task("3734-tool");
4970 let phase = parse_review_output(
4971 &task,
4972 "owner/repo",
4973 &ctx,
4974 ReviewRun { run_id: None },
4975 &store,
4976 )
4977 .await;
4978
4979 match phase {
4980 ReviewPhase::EarlyReturn(ReviewDecision::Failed(reason)) => {
4981 assert!(
4982 reason.contains("incomplete:"),
4983 "tool_use without envelope must be incomplete, got {reason}"
4984 );
4985 }
4986 ReviewPhase::EarlyReturn(_) => panic!("expected Failed(incomplete)"),
4987 ReviewPhase::Ready(_) => panic!("expected Failed(incomplete), got Ready"),
4988 }
4989 assert!(!crate::engine::cooldown::is_model_in_cooldown(agent, model));
4990 }
4991
4992 #[serial_test::serial(cooldown_state)]
4995 #[tokio::test]
4996 async fn parse_review_output_unparseable_envelope_applies_short_cooldown() {
4997 crate::engine::cooldown::reset_global_state().await;
4998 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4999 let work_dir = TempDir::new().unwrap();
5000 let agent = "kimi";
5001 let model = "opus-3734-format";
5002 let ctx = test_review_context(agent, model, work_dir.path());
5003 let ndjson = concat!(
5004 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"I have finished looking at the code."}]}}"#,
5005 "\n",
5006 r#"{"type":"result","subtype":"success","is_error":false,"result":"I have finished looking at the code."}"#,
5007 );
5008 write_review_output(work_dir.path(), ndjson, 0);
5009
5010 let task = test_review_task("3734-format");
5011 let phase = parse_review_output(
5012 &task,
5013 "owner/repo",
5014 &ctx,
5015 ReviewRun { run_id: None },
5016 &store,
5017 )
5018 .await;
5019
5020 match phase {
5021 ReviewPhase::EarlyReturn(ReviewDecision::Failed(reason)) => {
5022 assert!(
5023 reason.contains("parse error"),
5024 "finished unparseable envelope must stay a parse error, got {reason}"
5025 );
5026 }
5027 ReviewPhase::EarlyReturn(_) => panic!("expected Failed(parse error)"),
5028 ReviewPhase::Ready(_) => panic!("expected Failed(parse error), got Ready"),
5029 }
5030 let now = chrono::Utc::now().timestamp();
5031 let remaining = crate::engine::cooldown::cooldown_until(&format!("{agent}:{model}"))
5032 .expect("finished format failure must apply a model cooldown")
5033 .saturating_sub(now);
5034 assert!(
5035 remaining <= crate::engine::cooldown::TRANSIENT_MODEL_BACKOFF_BASE_SECS,
5036 "finished format failure must apply the 5 minute cooldown, got {remaining}s"
5037 );
5038 }
5039
5040 #[serial_test::serial(cooldown_state)]
5043 #[tokio::test]
5044 async fn parse_review_output_missing_envelope_uses_assistant_decision() {
5045 crate::engine::cooldown::reset_global_state().await;
5046 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5047 let work_dir = TempDir::new().unwrap();
5048 let agent = "kimi";
5049 let model = "opus-3734-assistant";
5050 let ctx = test_review_context(agent, model, work_dir.path());
5051 let ndjson = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"issues\":[]}"}]}}"#;
5052 write_review_output(work_dir.path(), ndjson, 0);
5053
5054 let task = test_review_task("3734-asst");
5055 let phase = parse_review_output(
5056 &task,
5057 "owner/repo",
5058 &ctx,
5059 ReviewRun { run_id: None },
5060 &store,
5061 )
5062 .await;
5063
5064 match phase {
5065 ReviewPhase::Ready(parsed) => {
5066 assert!(matches!(parsed.decision, ReviewDecision::Approve { .. }));
5067 }
5068 ReviewPhase::EarlyReturn(_) => {
5069 panic!("expected Ready(Approve) from assistant text, got EarlyReturn")
5070 }
5071 }
5072 assert!(!crate::engine::cooldown::is_model_in_cooldown(agent, model));
5073 }
5074
5075 #[serial_test::serial(cooldown_state)]
5078 #[tokio::test]
5079 async fn parse_review_output_does_not_treat_task_done_synthesis_as_approval() {
5080 crate::engine::cooldown::reset_global_state().await;
5081 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5082 let work_dir = TempDir::new().unwrap();
5083 let agent = "kimi";
5084 let model = "opus-3734-done";
5085 let ctx = test_review_context(agent, model, work_dir.path());
5086 let ndjson = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"analyzing the diff"}]}}"#;
5087 write_review_output(work_dir.path(), ndjson, 0);
5088
5089 let task = test_review_task("3734-done");
5090 let phase = parse_review_output(
5091 &task,
5092 "owner/repo",
5093 &ctx,
5094 ReviewRun { run_id: None },
5095 &store,
5096 )
5097 .await;
5098
5099 match phase {
5100 ReviewPhase::EarlyReturn(ReviewDecision::Failed(reason)) => {
5101 assert!(
5102 reason.contains("parse error"),
5103 "unfinished prose without a decision must not approve, got {reason}"
5104 );
5105 }
5106 ReviewPhase::Ready(_) => {
5107 panic!("task-path status=done synthesis must not become a review approval")
5108 }
5109 ReviewPhase::EarlyReturn(_) => panic!("expected Failed(parse error)"),
5110 }
5111 }
5112
5113 #[serial_test::serial(cooldown_state)]
5119 #[tokio::test]
5120 async fn finalize_review_run_success_resets_generic_model_failure_count() {
5121 crate::engine::cooldown::reset_global_state().await;
5122 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5123 crate::engine::cooldown::init_cooldown_store(store.clone()).await;
5124
5125 let agent = "test-3478-agent";
5126 let model = "test-3478-model";
5127 let failure_key = format!("failure_count:{agent}:{model}");
5128
5129 let err = runner::agents::AgentError::AgentFailed {
5133 message: "agent reported a generic failure".to_string(),
5134 };
5135 record_review_agent_failure("task-3478-a", agent, Some(model), &err).await;
5136 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5137 assert_eq!(
5138 store.kv_get(&failure_key).await.unwrap().as_deref(),
5139 Some("1"),
5140 "sanity check: failure should have incremented the persisted count"
5141 );
5142
5143 let work_dir = TempDir::new().unwrap();
5144 let ctx = test_review_context(agent, model, work_dir.path());
5145 let parsed = test_parsed_review(ReviewDecision::Approve { issues: vec![] });
5146 let task = test_review_task("1");
5147
5148 finalize_review_run(&task, &ctx, &parsed, &store).await;
5149 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
5150
5151 assert_eq!(
5152 store.kv_get(&failure_key).await.unwrap().as_deref(),
5153 Some("0"),
5154 "successful review must reset failure_count:{{agent}}:{{model}} (issue #3478)"
5155 );
5156 }
5157
5158 #[serial_test::serial(cooldown_state)]
5164 #[tokio::test]
5165 async fn finalize_review_run_interleaved_success_keeps_generic_cooldown_at_base() {
5166 crate::engine::cooldown::reset_global_state().await;
5167 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5168 crate::engine::cooldown::init_cooldown_store(store.clone()).await;
5169
5170 let agent = "test-3478-interleave-agent";
5171 let model = "test-3478-interleave-model";
5172 let cooldown_key = format!("{agent}:{model}");
5173
5174 let work_dir = TempDir::new().unwrap();
5175 let ctx = test_review_context(agent, model, work_dir.path());
5176 let task = test_review_task("2");
5177
5178 for i in 0..4 {
5179 let err = runner::agents::AgentError::AgentFailed {
5180 message: format!("failure {i}"),
5181 };
5182 record_review_agent_failure("task-3478-b", agent, Some(model), &err).await;
5183
5184 let now = chrono::Utc::now().timestamp();
5185 let until = crate::engine::cooldown::cooldown_until(&cooldown_key)
5186 .expect("failure should set cooldown");
5187 let remaining = until.saturating_sub(now);
5188
5189 assert!(
5190 remaining <= crate::engine::cooldown::BACKOFF_BASE_SECS + 60,
5191 "failure #{} must stay at base backoff since the prior success reset the \
5192 counter (issue #3478); got {remaining}s, base is {}s — an escalating value \
5193 here means review successes are not resetting failure_count",
5194 i + 1,
5195 crate::engine::cooldown::BACKOFF_BASE_SECS
5196 );
5197
5198 let parsed = test_parsed_review(ReviewDecision::Approve { issues: vec![] });
5202 finalize_review_run(&task, &ctx, &parsed, &store).await;
5203 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
5204 }
5205 }
5206
5207 #[serial_test::serial(cooldown_state)]
5213 #[tokio::test]
5214 async fn finalize_review_run_success_does_not_reset_persistent_model_failure_count() {
5215 crate::engine::cooldown::reset_global_state().await;
5216 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5217 crate::engine::cooldown::init_cooldown_store(store.clone()).await;
5218
5219 let agent = "test-3571-agent";
5220 let model = "test-3571-model";
5221 let cooldown_key = format!("{agent}:{model}");
5222
5223 let work_dir = TempDir::new().unwrap();
5224 let ctx = test_review_context(agent, model, work_dir.path());
5225 let task = test_review_task("3");
5226
5227 let err = runner::agents::AgentError::ModelUnavailable {
5229 message: "model not found".to_string(),
5230 model: model.to_string(),
5231 };
5232 record_review_agent_failure("task-3571-a", agent, Some(model), &err).await;
5233 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
5234
5235 let parsed = test_parsed_review(ReviewDecision::Approve { issues: vec![] });
5238 finalize_review_run(&task, &ctx, &parsed, &store).await;
5239 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
5240
5241 let err2 = runner::agents::AgentError::ModelUnavailable {
5244 message: "model not found again".to_string(),
5245 model: model.to_string(),
5246 };
5247 record_review_agent_failure("task-3571-b", agent, Some(model), &err2).await;
5248 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
5249
5250 let now = chrono::Utc::now().timestamp();
5251 let until = crate::engine::cooldown::cooldown_until(&cooldown_key)
5252 .expect("second failure should set cooldown");
5253 let remaining = until.saturating_sub(now);
5254
5255 assert!(
5256 remaining > crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS,
5257 "second persistent model failure should escalate past the 4h base despite an \
5258 intervening success (issue #3571); got {remaining}s, base is {}s",
5259 crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS
5260 );
5261 }
5262
5263 fn app_for_test(server: &wiremock::MockServer) -> Arc<crate::github::app::AppAuth> {
5264 Arc::new(crate::github::app::AppAuth::new(
5265 "12345".to_string(),
5266 crate::github::app::test_pem().to_string(),
5267 server.uri(),
5268 ))
5269 }
5270
5271 async fn mount_installed(server: &wiremock::MockServer, repo: &str) {
5272 use wiremock::matchers::{method, path};
5273 use wiremock::{Mock, ResponseTemplate};
5274 Mock::given(method("GET"))
5275 .and(path(format!("/repos/{repo}/installation")))
5276 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 42})))
5277 .mount(server)
5278 .await;
5279 Mock::given(method("POST"))
5280 .and(path("/app/installations/42/access_tokens"))
5281 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
5282 "token": "inst-1",
5283 "expires_at": "2099-01-01T00:00:00Z",
5284 })))
5285 .mount(server)
5286 .await;
5287 }
5288
5289 #[tokio::test]
5290 async fn app_mode_falls_back_to_comments_where_the_app_is_not_installed() {
5291 use crate::review_mode::ReviewMode;
5292 use wiremock::matchers::{method, path};
5293 use wiremock::{Mock, MockServer, ResponseTemplate};
5294 let server = MockServer::start().await;
5295 Mock::given(method("GET"))
5296 .and(path("/repos/org/b/installation"))
5297 .respond_with(ResponseTemplate::new(404))
5298 .expect(1)
5299 .mount(&server)
5300 .await;
5301 let app = app_for_test(&server);
5302
5303 assert!(
5304 review_app_for(ReviewMode::App, Some(Arc::clone(&app)), "org/b")
5305 .await
5306 .is_none()
5307 );
5308 assert!(
5310 review_app_for(ReviewMode::Comment, Some(Arc::clone(&app)), "org/b")
5311 .await
5312 .is_none()
5313 );
5314 assert!(review_app_for(ReviewMode::App, None, "org/b")
5315 .await
5316 .is_none());
5317 }
5318
5319 #[tokio::test]
5320 async fn the_check_run_starts_and_completes_on_the_reviewed_sha() {
5321 use wiremock::matchers::{body_partial_json, method, path};
5322 use wiremock::{Mock, MockServer, ResponseTemplate};
5323 let server = MockServer::start().await;
5324 mount_installed(&server, "o/r").await;
5325 Mock::given(method("POST"))
5326 .and(path("/repos/o/r/check-runs"))
5327 .and(body_partial_json(serde_json::json!({
5328 "name": "orch/review", "head_sha": "abc123", "status": "in_progress",
5329 })))
5330 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 1})))
5331 .expect(1)
5332 .mount(&server)
5333 .await;
5334 Mock::given(method("POST"))
5335 .and(path("/repos/o/r/check-runs"))
5336 .and(body_partial_json(serde_json::json!({
5337 "name": "orch/review", "head_sha": "abc123", "status": "completed",
5338 "conclusion": "failure", "output": {"summary": "fix it"},
5339 })))
5340 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 2})))
5341 .expect(1)
5342 .mount(&server)
5343 .await;
5344 let app = app_for_test(&server);
5345 let gh = GhHttp::for_test(server.uri(), Some(Arc::clone(&app)));
5346 let decision = ReviewDecision::RequestChanges {
5347 notes: "fix it".to_string(),
5348 issues: vec![],
5349 };
5350
5351 post_review_started(Some(&app), &gh, "o/r", Some("abc123")).await;
5352 post_review_decision(Some(&app), &gh, "o/r", Some("abc123"), &decision, "fix it").await;
5353 let requests = server.received_requests().await.expect("requests");
5354 assert!(requests
5355 .iter()
5356 .all(|req| !req.url.path().contains("/statuses/")));
5357 }
5358
5359 #[tokio::test]
5360 async fn issues_on_diff_lines_go_inline_and_the_rest_stay_in_the_body() {
5361 use crate::engine::runner::response::ReviewIssue;
5362 use wiremock::matchers::{method, path, query_param};
5363 use wiremock::{Mock, MockServer, ResponseTemplate};
5364 let server = MockServer::start().await;
5365 mount_installed(&server, "o/r").await;
5366 Mock::given(method("GET"))
5367 .and(path("/repos/o/r/pulls/9/files"))
5368 .and(query_param("page", "1"))
5369 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
5370 {"filename": "src/a.rs", "patch": "@@ -1,2 +10,3 @@\n ctx\n+new\n+new"},
5371 ])))
5372 .expect(1)
5373 .mount(&server)
5374 .await;
5375 Mock::given(method("POST"))
5376 .and(path("/repos/o/r/pulls/9/reviews"))
5377 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 1})))
5378 .expect(1)
5379 .mount(&server)
5380 .await;
5381 Mock::given(method("POST"))
5382 .and(path("/repos/o/r/issues/9/comments"))
5383 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 2})))
5384 .mount(&server)
5385 .await;
5386 let issue = |file: &str, line: Option<u32>| ReviewIssue {
5387 file: file.to_string(),
5388 line,
5389 severity: "error".to_string(),
5390 description: "wrong".to_string(),
5391 };
5392 let decision = ReviewDecision::RequestChanges {
5393 notes: "fix".to_string(),
5394 issues: vec![
5395 issue("src/a.rs", Some(11)),
5396 issue("src/a.rs", Some(50)),
5397 issue("src/b.rs", Some(3)),
5398 issue("src/a.rs", None),
5399 ],
5400 };
5401 let app = app_for_test(&server);
5402 let gh = GhHttp::for_test(server.uri(), Some(Arc::clone(&app)));
5403
5404 publish_review(Some(&app), &gh, "o/r", 9, Some("abc123"), &decision, "body")
5405 .await
5406 .expect("comment posted");
5407 let requests = server.received_requests().await.expect("requests");
5408 let review = requests
5409 .iter()
5410 .find(|req| req.url.path().ends_with("/reviews"))
5411 .expect("review submitted");
5412 let body: serde_json::Value = serde_json::from_slice(&review.body).unwrap();
5413 assert_eq!(
5414 body["comments"],
5415 serde_json::json!([{"path": "src/a.rs", "line": 11, "side": "RIGHT", "body": "**error:** wrong"}])
5416 );
5417 }
5418
5419 #[allow(clippy::await_holding_lock)]
5420 #[tokio::test]
5421 async fn without_an_app_the_review_path_posts_only_the_comment() {
5422 use wiremock::matchers::{method, path};
5423 use wiremock::{Mock, MockServer, ResponseTemplate};
5424 let _env = crate::github::token::ENV_LOCK.lock().unwrap();
5425 let prev = std::env::var_os("GH_TOKEN");
5426 std::env::set_var("GH_TOKEN", "owner-token");
5427 let server = MockServer::start().await;
5428 Mock::given(method("POST"))
5429 .and(path("/repos/o/r/issues/9/comments"))
5430 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 1})))
5431 .expect(1)
5432 .mount(&server)
5433 .await;
5434 let gh = GhHttp::for_test(server.uri(), None);
5435 let result = publish_review(
5436 None,
5437 &gh,
5438 "o/r",
5439 9,
5440 Some("abc123"),
5441 &ReviewDecision::Approve { issues: vec![] },
5442 "## Automated Review \u{2014} Approve\n\nok",
5443 )
5444 .await;
5445 match prev {
5446 Some(value) => std::env::set_var("GH_TOKEN", value),
5447 None => std::env::remove_var("GH_TOKEN"),
5448 }
5449 result.expect("comment posted");
5450 let requests = server.received_requests().await.expect("requests");
5451 assert!(requests
5452 .iter()
5453 .all(|req| !req.url.path().contains("/reviews")));
5454 }
5455
5456 #[tokio::test]
5457 async fn with_an_app_the_review_is_submitted_on_the_reviewed_sha() {
5458 use wiremock::matchers::{body_partial_json, method, path};
5459 use wiremock::{Mock, MockServer, ResponseTemplate};
5460 let server = MockServer::start().await;
5461 Mock::given(method("POST"))
5462 .and(path("/app/installations/42/access_tokens"))
5463 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
5464 "token": "inst-1",
5465 "expires_at": "2099-01-01T00:00:00Z",
5466 })))
5467 .mount(&server)
5468 .await;
5469 Mock::given(method("POST"))
5470 .and(path("/repos/o/r/pulls/9/reviews"))
5471 .and(body_partial_json(serde_json::json!({
5472 "event": "REQUEST_CHANGES",
5473 "commit_id": "abc123",
5474 })))
5475 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 1})))
5476 .expect(1)
5477 .mount(&server)
5478 .await;
5479 Mock::given(method("POST"))
5480 .and(path("/repos/o/r/issues/9/comments"))
5481 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 2})))
5482 .expect(1)
5483 .mount(&server)
5484 .await;
5485 Mock::given(method("GET"))
5486 .and(path("/repos/o/r/installation"))
5487 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 42})))
5488 .mount(&server)
5489 .await;
5490 let app = Arc::new(crate::github::app::AppAuth::new(
5491 "12345".to_string(),
5492 crate::github::app::test_pem().to_string(),
5493 server.uri(),
5494 ));
5495 let gh = GhHttp::for_test(server.uri(), Some(Arc::clone(&app)));
5496 let decision = ReviewDecision::RequestChanges {
5497 notes: "fix it".to_string(),
5498 issues: vec![],
5499 };
5500 publish_review(
5501 Some(&app),
5502 &gh,
5503 "o/r",
5504 9,
5505 Some("abc123"),
5506 &decision,
5507 "## Automated Review \u{2014} Changes Requested\n\nfix it",
5508 )
5509 .await
5510 .expect("comment posted");
5511 }
5512}