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