Skip to main content

orch/engine/
review.rs

1//! PR review pipeline — review agent orchestration.
2//!
3//! This module handles running the review agent on completed tasks, parsing its
4//! decision, and posting automated review comments.
5//!
6//! Related modules:
7//! - [`super::review_poll`] — polling open PRs for human review feedback
8//! - [`super::auto_merge`] — merging approved PRs and handling change requests
9
10/// Maximum consecutive review agent failures before the task is blocked
11/// for human intervention. Exported so `tick` and `sync` use the same threshold.
12pub(crate) const MAX_REVIEW_AGENT_FAILURES: u64 = 3;
13
14/// Maximum consecutive PR-creation failures before blocking the task.
15const MAX_PR_CREATE_FAILURES: u64 = 3;
16
17/// Map an [`AgentError`] to the structured outcome string stored in `task_runs.outcome`.
18///
19/// Mirrors the logic in `runner::classify_run_outcome` so review runs carry the
20/// same outcome fidelity as agent runs.
21fn 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        // Consult credit-exhaustion detection first — generic rate-limit patterns
26        // (e.g. "usage limit") also match billing-cycle exhaustion messages, so
27        // reviews on a billing-cycle-exhausted model must not be stored as a
28        // plain "rate_limit"/"auth_error" outcome (issue #3529).
29        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
42/// Format an [`AgentError`] as the compact string persisted to `task_runs.error`
43/// and surfaced via [`ReviewDecision::Failed`].
44///
45/// Mirrors the per-variant formatting in `runner::fallback::handle_error` so review
46/// runs never leak raw provider payloads (e.g. truncated `api_retry` NDJSON
47/// fragments) into the audit trail. Without this, `err.to_string()` would expand
48/// `AgentError::RateLimit { message }` via its `Display` impl and persist hundreds
49/// of bytes of raw JSON into `task_runs.error` (issue #3149, regression of #2881).
50fn 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
64/// Parse a PR number from a GitHub PR URL.
65///
66/// Validates that the URL matches the expected GitHub PR URL format
67/// (`https://github.com/<owner>/<repo>/pull/<number>`) before extracting the
68/// number. Returns `Err` with a descriptive message for any URL that doesn't
69/// conform to this structure, including URLs with wrong domains, missing
70/// `/pull/` segments, or non-numeric trailing components.
71///
72/// The extracted segment is stripped of any query string (`?`) or hash
73/// fragment (`#`) before parsing so that URLs like
74/// `https://github.com/owner/repo/pull/42?tab=files` are handled correctly.
75pub(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/// Review agent decision result.
140#[derive(Debug, Clone)]
141pub(crate) enum ReviewDecision {
142    /// Review approved, PR can be merged.
143    Approve {
144        issues: Vec<crate::engine::runner::response::ReviewIssue>,
145    },
146    /// Changes requested, PR needs fixes.
147    RequestChanges {
148        notes: String,
149        issues: Vec<crate::engine::runner::response::ReviewIssue>,
150    },
151    /// Review agent failed or crashed (reason stored for logging).
152    Failed(String),
153    /// Unrecoverable failure — task should be blocked for human intervention.
154    Blocked(String),
155    /// No PR exists — nothing to review, task marked done directly.
156    Skipped,
157    /// No PR and no commits — task re-routed for retry (no_code_reroutes counter must not be reset).
158    Rerouted,
159}
160
161/// Outcome of [`ensure_pr_exists`]: either a ready PR number or an early return decision.
162enum EnsurePrResult {
163    /// PR exists (found in store or via API, or just created). Proceed with review.
164    Ready(u64),
165    /// No PR and status already updated — return this decision to the caller.
166    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    /// PR head SHA when the review started, the commit the decision applies to
182    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
214/// KV key holding the commit an approving review was already written for
215fn approved_sha_key(repo: &str, task_id: &str, pr_number: u64) -> String {
216    format!("review_approved_sha:{repo}#{task_id}#{pr_number}")
217}
218
219/// Read `HEAD` of a worktree. `None` when the worktree is gone or not a repository
220const REVIEW_TIMEOUT_DEFAULT_SECS: u64 = 900;
221const REVIEW_TIMEOUT_MIN_SECS: u64 = 300;
222
223// A review reads a diff and runs the tests, so it gets its own limit, shorter than a task's
224fn review_timeout_secs(configured: Option<u64>) -> u64 {
225    configured
226        .unwrap_or(REVIEW_TIMEOUT_DEFAULT_SECS)
227        .max(REVIEW_TIMEOUT_MIN_SECS)
228}
229
230async 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/// Record that the commit just pushed to the PR carries an approving review, so a later
246/// NeedsReview event on the same commit skips the review agent
247async fn record_approved_sha(
248    store: &Arc<TaskStore>,
249    repo: &str,
250    task_id: &str,
251    pr_number: u64,
252    dir: &std::path::Path,
253) {
254    let Some(sha) = worktree_head_sha(dir).await else {
255        return;
256    };
257    if let Err(e) = store
258        .kv_set(&approved_sha_key(repo, task_id, pr_number), &sha)
259        .await
260    {
261        tracing::warn!(task_id, err = %e, "failed to record approved commit sha");
262    }
263}
264
265/// Drop a recorded approval so the next NeedsReview event runs a real review
266async fn clear_approved_sha(store: &Arc<TaskStore>, repo: &str, task_id: &str, pr_number: u64) {
267    if let Err(e) = store
268        .kv_delete(&approved_sha_key(repo, task_id, pr_number))
269        .await
270    {
271        tracing::warn!(task_id, err = %e, "failed to clear approved commit sha");
272    }
273}
274
275/// Records the earlier approval as this review round's decision. Without it the stale
276/// in_review check sees a round with no decision and resets the task every minute
277async fn record_carried_approval(
278    store: &Arc<TaskStore>,
279    repo: &str,
280    task_id: &str,
281    pr_number: u64,
282) {
283    store_log_activity(
284        &Some(Arc::clone(store)),
285        repo,
286        task_id,
287        "review_decision",
288        None,
289        None,
290        None::<&str>,
291        None::<&str>,
292        Some(&serde_json::json!({
293            "decision": "approve",
294            "pr_number": pr_number,
295            "reason": "head_already_approved",
296        })),
297    )
298    .await;
299}
300
301/**
302 * True when the PR's current head is the commit a previous review approved.
303 *
304 * Compared against the live PR head, not the worktree: a force-push from another clone
305 * moves the head while the worktree stays on the approved commit, and skipping the
306 * review there would merge code nobody reviewed. Any lookup failure returns false, so
307 * the fallback is to review again (#3783)
308 **/
309async fn pr_head_already_approved(
310    store: &Arc<TaskStore>,
311    repo: &str,
312    task_id: &str,
313    pr_number: u64,
314) -> bool {
315    let approved = match store
316        .kv_get(&approved_sha_key(repo, task_id, pr_number))
317        .await
318    {
319        Ok(sha) => sha,
320        Err(e) => {
321            tracing::warn!(task_id, err = %e, "failed to read approved commit sha");
322            return false;
323        }
324    };
325    if approved.is_none() {
326        return false;
327    }
328    let gh = match crate::github::http::GhHttp::new() {
329        Ok(gh) => gh,
330        Err(e) => {
331            tracing::warn!(task_id, err = %e, "cannot check PR head against recorded approval");
332            return false;
333        }
334    };
335    let pr_head = match gh.get_pr(repo, pr_number).await {
336        Ok(pr) => Some(pr.head.sha),
337        Err(e) => {
338            tracing::warn!(task_id, pr_number, err = %e, "failed to read PR head sha");
339            None
340        }
341    };
342    approval_covers_head(approved.as_deref(), pr_head.as_deref())
343}
344
345/// Whether a recorded approval still applies to the PR's current head commit
346fn approval_covers_head(approved: Option<&str>, pr_head: Option<&str>) -> bool {
347    match (approved, pr_head) {
348        (Some(approved), Some(head)) => !approved.is_empty() && approved == head,
349        _ => false,
350    }
351}
352
353fn build_review_disallowed_tools(worktree_path: &std::path::Path) -> Vec<String> {
354    let mut tools = vec![
355        "Bash(rm *)".to_string(),
356        "Bash(rm -*)".to_string(),
357        "Bash(git push*)".to_string(),
358        // Pushing and opening the PR are orch's steps, never the agent's (#3783)
359        "Bash(gh pr create*)".to_string(),
360    ];
361    let orch_yml = worktree_path.join(".orch.yml");
362    let orch_yml_str = orch_yml.to_string_lossy();
363    tools.extend([
364        format!("Write({orch_yml_str})"),
365        format!("Edit({orch_yml_str})"),
366    ]);
367    if let Ok(orch_home) = crate::home::orch_home() {
368        for path in [
369            orch_home.join("config.yml"),
370            orch_home.join("config.example.yml"),
371        ] {
372            let path_str = path.to_string_lossy();
373            tools.extend([
374                format!("Read({path_str})"),
375                format!("Write({path_str})"),
376                format!("Edit({path_str})"),
377            ]);
378        }
379    }
380    tools
381}
382
383async fn select_review_agent(
384    task_id: &str,
385    task_agent: &str,
386    store_id: Option<i64>,
387    router: &Arc<RwLock<Router>>,
388    store: &Arc<TaskStore>,
389) -> anyhow::Result<(String, Option<String>, bool)> {
390    let mut exclude_set: HashSet<String> = HashSet::new();
391    if !task_agent.is_empty() {
392        exclude_set.insert(task_agent.to_string());
393    }
394    if let Some(store_id) = store_id {
395        if let Ok(failed) = store.previous_review_agents(store_id).await {
396            for agent in failed {
397                exclude_set.insert(agent);
398            }
399        }
400    }
401
402    let mut r = router.write().await;
403    let mut tried_agents: HashSet<String> = HashSet::new();
404    let mut chosen_agent: Option<String> = None;
405    let mut chosen_model: Option<String> = None;
406    let available_count = r.available_agents.len().max(1);
407
408    loop {
409        let tmp_exclude_refs: Vec<&str> = exclude_set.iter().map(|s| s.as_str()).collect();
410        let agent = match r.next_round_robin_agent(&tmp_exclude_refs, "review") {
411            Some(a) => a,
412            None => break,
413        };
414
415        if tried_agents.contains(&agent) {
416            break;
417        }
418        tried_agents.insert(agent.clone());
419
420        let model = r.config.model_for_complexity(&agent, "review", task_id);
421        if model.is_some() {
422            chosen_agent = Some(agent);
423            chosen_model = model;
424            break;
425        }
426
427        exclude_set.insert(agent);
428        if exclude_set.len() >= available_count {
429            break;
430        }
431    }
432
433    if let Some(agent) = chosen_agent {
434        let same = !task_agent.is_empty() && agent == task_agent;
435        Ok((agent, chosen_model, same))
436    } else {
437        let final_exclude_refs: Vec<&str> = exclude_set.iter().map(|s| s.as_str()).collect();
438        let fallback_agent = r
439            .next_round_robin_agent(&final_exclude_refs, "review")
440            .unwrap_or_else(|| "claude".to_string());
441        let fallback_model = r
442            .config
443            .model_for_complexity(&fallback_agent, "review", task_id);
444        if fallback_model.is_none() {
445            anyhow::bail!(
446                "no review agent with a configured review model is available for task {}",
447                task_id
448            );
449        }
450        let same = !task_agent.is_empty() && fallback_agent == task_agent;
451        Ok((fallback_agent, fallback_model, same))
452    }
453}
454
455#[allow(clippy::too_many_arguments)]
456async fn build_review_context(
457    task: &ExternalTask,
458    repo: &str,
459    router: &Arc<RwLock<Router>>,
460    task_manager: &Arc<TaskManager>,
461    store: &Arc<TaskStore>,
462) -> anyhow::Result<ReviewPhase<ReviewContext>> {
463    let stored_task = store
464        .get_by_external_id(repo, &task.id.0)
465        .await
466        .with_context(|| format!("store lookup failed for task {}", task.id.0))?;
467
468    let store_id: Option<i64> = stored_task.as_ref().map(|t| t.id);
469
470    // A commit that already has an approval must not be reviewed again. Checked before
471    // the review_invocations increment so a stuck merge cannot exhaust the review quota
472    if let Some(pr_number) = stored_task.as_ref().and_then(|t| t.pr_number) {
473        let pr_number = pr_number as u64;
474        if pr_head_already_approved(store, repo, &task.id.0, pr_number).await {
475            tracing::info!(
476                task_id = task.id.0,
477                pr_number,
478                "PR head already approved — skipping review, merge retry continues"
479            );
480            record_carried_approval(store, repo, &task.id.0, pr_number).await;
481            return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Approve {
482                issues: Vec::new(),
483            }));
484        }
485    }
486
487    let had_prev_push_failure = stored_task
488        .as_ref()
489        .is_some_and(|t| t.last_error.contains("push failed"));
490    let review_attempt: u32 = match store_increment(
491        &Some(Arc::clone(store)),
492        repo,
493        &task.id.0,
494        "review_invocations",
495    )
496    .await
497    {
498        Ok(v) => v as u32,
499        Err(e) => {
500            tracing::warn!(task_id = task.id.0, err = %e, "failed to increment review_invocations — using fallback attempt=1");
501            1
502        }
503    };
504
505    let no_code_review = stored_task.as_ref().is_some_and(|t| {
506        t.worktree.trim().is_empty() && t.branch.trim().is_empty() && t.pr_number.is_none()
507    });
508    if no_code_review {
509        if crate::engine::tasks::is_internal_id(&task.id.0) {
510            // Internal tasks legitimately reach needs_review with nothing to review.
511            tracing::info!(
512                task_id = task.id.0,
513                "no worktree/branch/pr for internal needs_review task — skipping review and marking done"
514            );
515            if let Err(e) = task_manager
516                .update_task_status(&task.id, Status::Done)
517                .await
518            {
519                tracing::error!(
520                    task_id = task.id.0,
521                    err = %e,
522                    "update_task_status(Done) failed for no-code review skip"
523                );
524            }
525            return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Skipped));
526        }
527
528        // External task with no code produced: re-route instead of marking done.
529        // Shares the no_code_reroutes circuit breaker with the no-PR/no-commits path below.
530        let max_reroutes: u32 = config::get("workflow.max_reroute_attempts")
531            .ok()
532            .and_then(|s| s.parse().ok())
533            .or_else(|| {
534                config::get("workflow.max_attempts")
535                    .ok()
536                    .and_then(|s| s.parse().ok())
537            })
538            .unwrap_or(3);
539
540        let reroutes = match store_increment(
541            &Some(Arc::clone(store)),
542            repo,
543            &task.id.0,
544            "no_code_reroutes",
545        )
546        .await
547        {
548            Ok(v) => v,
549            Err(e) => {
550                tracing::warn!(task_id = task.id.0, err = %e, "failed to increment no_code_reroutes — skipping reroute/block decision this tick");
551                return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Failed(format!(
552                    "transient store error: {e}"
553                ))));
554            }
555        };
556
557        if reroutes as u32 >= max_reroutes {
558            tracing::error!(
559                task_id = task.id.0,
560                reroutes,
561                max_reroutes,
562                "reached max reroute attempts for no-worktree/branch/pr result — blocking for human review"
563            );
564            let msg = format!(
565                "no worktree/branch/pr after {}/{} reroute attempts",
566                reroutes, max_reroutes
567            );
568            let fields = [
569                (
570                    "block_reason",
571                    serde_json::json!(format!(
572                        "max reroute attempts ({}) reached — no worktree/branch/pr produced",
573                        max_reroutes
574                    )),
575                ),
576                ("last_error", serde_json::json!(msg)),
577                ("agent", serde_json::json!(null)),
578                ("model", serde_json::json!(null)),
579            ];
580            if let Err(e) = task_manager
581                .update_task_status_and_result(&task.id, Status::Blocked, &fields)
582                .await
583            {
584                tracing::error!(task_id = task.id.0, err = %e, "update_task_status_and_result(Blocked) failed — skipping block to avoid silent auto-unblock loop");
585            }
586            return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Blocked(msg)));
587        }
588
589        tracing::warn!(
590            task_id = task.id.0,
591            reroutes,
592            max_reroutes,
593            "no worktree/branch/pr for external needs_review task — re-routing to a different agent"
594        );
595        if let Err(e) = store_set_result(
596            &Some(Arc::clone(store)),
597            repo,
598            &task.id.0,
599            &[
600                ("agent", serde_json::json!(null)),
601                ("model", serde_json::json!(null)),
602                (
603                    "last_error",
604                    serde_json::json!("no worktree/branch/pr produced"),
605                ),
606            ],
607        )
608        .await
609        {
610            tracing::error!(task_id = task.id.0, err = %e, "failed to clear agent/model for reroute — skipping status transition");
611        } else if let Err(e) = task_manager.update_task_status(&task.id, Status::New).await {
612            tracing::error!(task_id = task.id.0, err = %e, "update_task_status(New) failed — task may be stuck in InReview");
613        }
614        return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Rerouted));
615    }
616
617    let mut worktree_path = match stored_task.as_ref().map(|t| t.worktree.as_str()) {
618        Some(w) if !w.is_empty() => std::path::PathBuf::from(w),
619        _ => {
620            tracing::warn!(task_id = task.id.0, "no worktree found for review");
621            return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Failed(
622                "no worktree found".to_string(),
623            )));
624        }
625    };
626
627    let mut branch_name = match stored_task.as_ref().map(|t| t.branch.as_str()) {
628        Some(b) if !b.is_empty() => b.to_string(),
629        _ => {
630            tracing::warn!(task_id = task.id.0, "no branch found for review");
631            return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Failed(
632                "no branch found".to_string(),
633            )));
634        }
635    };
636
637    let mut missing_worktree_recovered = false;
638    if !worktree_path.exists() {
639        match recover_missing_review_worktree(task, repo, store).await {
640            Ok(recovered) => {
641                branch_name = recovered.branch;
642                worktree_path = recovered.work_dir;
643                missing_worktree_recovered = true;
644            }
645            Err(e) => {
646                let reason = format!("missing review worktree recovery failed: {e}");
647                tracing::error!(task_id = task.id.0, error = %reason, "cannot recover review worktree");
648                store_set(
649                    &Some(Arc::clone(store)),
650                    repo,
651                    &task.id.0,
652                    &[("last_error", serde_json::json!(reason.clone()))],
653                )
654                .await;
655                return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Blocked(reason)));
656            }
657        }
658    }
659
660    if missing_worktree_recovered {
661        store_set(
662            &Some(Arc::clone(store)),
663            repo,
664            &task.id.0,
665            &[("last_error", serde_json::json!(""))],
666        )
667        .await;
668    }
669
670    let agent_summary = stored_task
671        .as_ref()
672        .map(|t| t.summary.clone())
673        .unwrap_or_default();
674    let stored_pr_number = stored_task
675        .as_ref()
676        .and_then(|t| t.pr_number)
677        .map(|n| n as u64)
678        .filter(|&n| n > 0);
679
680    let pr_number = match ensure_pr_exists(
681        task,
682        &branch_name,
683        &worktree_path,
684        repo,
685        &agent_summary,
686        store,
687        task_manager,
688        stored_pr_number,
689    )
690    .await?
691    {
692        EnsurePrResult::Ready(n) => n,
693        EnsurePrResult::EarlyReturn(decision) => return Ok(ReviewPhase::EarlyReturn(decision)),
694    };
695
696    // Run `git fetch` with a timeout so a hung network call cannot stall the
697    // review pipeline indefinitely. Treat a timeout or non-zero exit as a
698    // non-fatal warning (stale remote refs are acceptable for the review).
699    {
700        let fetch_timeout = std::time::Duration::from_secs(60);
701        let fetch_future = async {
702            let mut cmd = Command::new("git");
703            cmd.args(["fetch", "origin", "--prune"])
704                .current_dir(&worktree_path);
705            // Ensure the child is killed if the timeout elapses
706            cmd.kill_on_drop(true);
707            cmd.output_with_context().await
708        };
709
710        match tokio::time::timeout(fetch_timeout, fetch_future).await {
711            Ok(Ok(o)) if o.status.success() => {
712                // ok
713            }
714            Ok(Ok(o)) => {
715                tracing::warn!(
716                    task_id = task.id.0,
717                    exit_code = ?o.status.code(),
718                    stdout = %String::from_utf8_lossy(&o.stdout),
719                    stderr = %String::from_utf8_lossy(&o.stderr),
720                    "review: git fetch failed (non-zero exit) — diff/log may use stale remote refs"
721                );
722            }
723            Ok(Err(e)) => {
724                tracing::warn!(
725                    task_id = task.id.0,
726                    error = %e,
727                    "review: git fetch failed — diff/log may use stale remote refs"
728                );
729            }
730            Err(_) => {
731                tracing::warn!(
732                    task_id = task.id.0,
733                    "review: git fetch timed out after 60s — diff/log may use stale remote refs"
734                );
735            }
736        }
737    }
738
739    let default_branch = runner::worktree::detect_default_branch(&worktree_path).await;
740    let git_diff = runner::context::build_git_diff(&worktree_path, &default_branch).await;
741    let git_log = runner::context::build_git_log(&worktree_path, &default_branch).await;
742    // An unknown project falls back to the global config, never to the worktree's .orch.yml,
743    // which the task agent may have written
744    let project_dir = crate::config::get_projects_with_paths()
745        .unwrap_or_default()
746        .into_iter()
747        .find(|(candidate, _)| candidate == repo)
748        .map(|(_, dir)| dir);
749    let sandboxed = match &project_dir {
750        Some(dir) => runner::sandbox::enabled(dir),
751        None => runner::sandbox::enabled_globally(),
752    };
753    let main_project_dir = project_dir.unwrap_or_else(|| worktree_path.clone());
754    let review_prompt = runner::agent::build_review_prompt(
755        task,
756        &agent_summary,
757        &git_diff,
758        &git_log,
759        &default_branch,
760        pr_number,
761        runner::agent::Shipcheck::detect(&worktree_path)
762            .await
763            .for_prompt(sandboxed),
764    );
765
766    let task_agent = stored_task
767        .as_ref()
768        .and_then(|t| t.agent.clone())
769        .unwrap_or_default();
770    let (review_agent, review_model, same_agent_fallback) =
771        select_review_agent(&task.id.0, &task_agent, store_id, router, store).await?;
772
773    anyhow::ensure!(
774        review_model.is_some(),
775        "review model missing for task {} after agent selection — refusing to dispatch",
776        task.id.0
777    );
778
779    if same_agent_fallback {
780        tracing::warn!(
781            task_id = task.id.0,
782            agent = %review_agent,
783            "no other agent available for review — falling back to the agent that wrote the code"
784        );
785        store_log_activity(
786            &Some(Arc::clone(store)),
787            repo,
788            &task.id.0,
789            "review_agent_fallback",
790            None,
791            None,
792            Some(&review_agent),
793            review_model.as_deref(),
794            Some(&serde_json::json!({
795                "reason": "all available agents excluded (author + previous reviewers)",
796            })),
797        )
798        .await;
799    }
800
801    tracing::info!(
802        task_id = task.id.0,
803        agent = %review_agent,
804        model = ?review_model,
805        "spawning review agent"
806    );
807
808    let review_task_id = format!("{}-review", task.id.0);
809    let review_attempt_dir =
810        crate::home::task_attempt_dir_async(repo, &review_task_id, review_attempt).await?;
811    let output_file = review_attempt_dir.join("output.json");
812
813    let git_name =
814        crate::config::get("git.name").unwrap_or_else(|_| format!("{review_agent}[bot]"));
815    let git_email = crate::config::get("git.email")
816        .unwrap_or_else(|_| format!("{review_agent}[bot]@users.noreply.github.com"));
817    let review_timeout_secs = review_timeout_seconds();
818
819    let invocation = runner::agent::AgentInvocation {
820        agent: review_agent.clone(),
821        model: review_model.clone(),
822        work_dir: worktree_path.clone(),
823        system_prompt: runner::agent::review_system_prompt(),
824        agent_message: review_prompt,
825        task_id: review_task_id.clone(),
826        disallowed_tools: build_review_disallowed_tools(&worktree_path),
827        git_author_name: git_name,
828        git_author_email: git_email,
829        output_file: output_file.clone(),
830        timeout_seconds: review_timeout_secs,
831        repo: repo.to_string(),
832        attempt: review_attempt,
833        sandbox: sandboxed,
834        main_project_dir,
835        branch: branch_name.clone(),
836    };
837
838    let head_sha = match GhHttp::new() {
839        Ok(gh) => gh.get_pr(repo, pr_number).await.ok().map(|pr| pr.head.sha),
840        Err(_) => None,
841    };
842
843    Ok(ReviewPhase::Ready(ReviewContext {
844        store_id,
845        had_prev_push_failure,
846        review_attempt,
847        worktree_path,
848        branch_name,
849        pr_number,
850        head_sha,
851        default_branch,
852        review_agent,
853        review_model,
854        review_task_id,
855        review_attempt_dir,
856        output_file,
857        invocation,
858        same_agent_fallback,
859    }))
860}
861
862#[allow(clippy::too_many_arguments)]
863async fn complete_review_run(
864    store: &Arc<TaskStore>,
865    run_id: Option<i64>,
866    exit_code: Option<i32>,
867    stdout: &str,
868    stderr: &str,
869    parsed: &str,
870    outcome: &str,
871    error: &str,
872    tokens: RunTokenUsage,
873) {
874    if let Some(run_id) = run_id {
875        if let Err(e) = store
876            .complete_run(&CompleteRun {
877                run_id,
878                exit_code,
879                stdout,
880                stderr,
881                parsed,
882                outcome,
883                error,
884                tokens,
885            })
886            .await
887        {
888            tracing::warn!(
889                run_id,
890                error = %e,
891                "failed to record review run completion in audit trail"
892            );
893        }
894    }
895}
896
897async fn invoke_review_agent(
898    task: &ExternalTask,
899    tmux: &Arc<TmuxManager>,
900    ctx: &ReviewContext,
901    repo: &str,
902    store: &Arc<TaskStore>,
903) -> ReviewPhase<ReviewRun> {
904    let run_id = if let Some(sid) = ctx.store_id {
905        match store
906            .start_run(&StartRun {
907                task_id: sid,
908                attempt: ctx.review_attempt as i32,
909                run_type: "review",
910                agent: &ctx.review_agent,
911                model: ctx.review_model.as_deref().unwrap_or(""),
912                command: "",
913                prompt: "",
914            })
915            .await
916        {
917            Ok(run_id) => Some(run_id),
918            Err(e) => {
919                tracing::warn!(
920                    task_id = task.id.0,
921                    error = %e,
922                    "failed to record review run start in audit trail"
923                );
924                None
925            }
926        }
927    } else {
928        None
929    };
930
931    let session = match runner::agent::spawn_in_tmux(tmux, &ctx.invocation).await {
932        Ok(session) => session,
933        Err(e) => {
934            tracing::error!(task_id = task.id.0, error = %e, "failed to spawn review agent");
935            complete_review_run(
936                store,
937                run_id,
938                None,
939                "",
940                &e.to_string(),
941                "",
942                "failed",
943                &format!("spawn failed: {e}"),
944                RunTokenUsage::default(),
945            )
946            .await;
947            return ReviewPhase::EarlyReturn(ReviewDecision::Failed(format!("spawn failed: {e}")));
948        }
949    };
950
951    let start_comment = review_started_comment(&ctx.review_agent, ctx.review_model_str());
952    match GhHttp::new() {
953        Ok(gh) => {
954            let app = review_app(repo).await;
955            post_review_started(app.as_deref(), &gh, repo, ctx.head_sha.as_deref()).await;
956            if let Err(e) = gh
957                .add_comment(repo, &ctx.pr_number.to_string(), &start_comment)
958                .await
959            {
960                tracing::debug!(
961                    task_id = task.id.0,
962                    pr_number = ctx.pr_number,
963                    error = %e,
964                    "failed to post review-started comment"
965                );
966            }
967        }
968        Err(e) => {
969            tracing::debug!(
970                task_id = task.id.0,
971                pr_number = ctx.pr_number,
972                error = %e,
973                "failed to create GitHub client for review-started comment"
974            );
975        }
976    }
977
978    let poll_interval = std::time::Duration::from_secs(5);
979    let timeout_duration = std::time::Duration::from_secs(ctx.invocation.timeout_seconds);
980    let wait_result = tokio::time::timeout(
981        timeout_duration,
982        tmux.wait_for_completion(&session, poll_interval),
983    )
984    .await;
985
986    match wait_result {
987        Ok(Ok(_)) => {
988            tracing::info!(task_id = task.id.0, "review agent completed");
989            let _ = tmux.kill_session(&session).await;
990            ReviewPhase::Ready(ReviewRun { run_id })
991        }
992        Ok(Err(e)) => {
993            tracing::warn!(task_id = task.id.0, error = %e, "review agent process exited with error");
994            let _ = tmux.kill_session(&session).await;
995
996            // Before recording failure, check if this is a "successful exit with non-zero
997            // code" agent like kimi/minimax. Read the output file and check for a valid
998            // result with is_error=false — if found, treat as success and proceed to
999            // parse_review_output where the real result will be extracted.
1000            let exit_code: i32 = tokio::task::spawn_blocking({
1001                let review_attempt_dir = ctx.review_attempt_dir.clone();
1002                move || {
1003                    std::fs::read_to_string(review_attempt_dir.join("exit.txt"))
1004                        .ok()
1005                        .and_then(|s| s.trim().parse().ok())
1006                        .unwrap_or(-1)
1007                }
1008            })
1009            .await
1010            .unwrap_or(-1);
1011
1012            if exit_code != 0 {
1013                // Try reading output via read_output_file which checks both the primary path
1014                // and all prior attempt dirs as a fallback. This handles agents like kimi/minimax
1015                // that exit non-zero even on success, as well as cases where the current attempt's
1016                // output.json was not written but a prior attempt's output contains a valid result.
1017                let raw_output =
1018                    runner::response::read_output_file(&ctx.review_task_id, &ctx.output_file, repo)
1019                        .await;
1020                let agent_result =
1021                    runner::agents::find_agent_result(&ctx.review_agent, &raw_output);
1022                // Treat as success if we found a valid result without is_error set
1023                if agent_result.as_ref().map(|r| !r.is_error).unwrap_or(false) {
1024                    tracing::info!(
1025                        task_id = task.id.0,
1026                        agent = %ctx.review_agent,
1027                        exit_code,
1028                        output_found = !raw_output.is_empty(),
1029                        "review agent succeeded despite non-zero exit — proceeding to parse output"
1030                    );
1031                    return ReviewPhase::Ready(ReviewRun { run_id });
1032                }
1033            }
1034
1035            tracing::error!(task_id = task.id.0, error = %e, "review agent failed");
1036            complete_review_run(
1037                store,
1038                run_id,
1039                Some(exit_code),
1040                "",
1041                &e.to_string(),
1042                "",
1043                "failed",
1044                &format!("agent error: {e}"),
1045                RunTokenUsage::default(),
1046            )
1047            .await;
1048            ReviewPhase::EarlyReturn(ReviewDecision::Failed(format!("agent error: {e}")))
1049        }
1050        Err(_) => {
1051            tracing::error!(task_id = task.id.0, "review agent timed out");
1052            let _ = tmux.kill_session(&session).await;
1053            // A slow review says nothing about the model, so no cooldown: the task goes back
1054            // to needs_review and the next review picks an agent as usual
1055            complete_review_run(
1056                store,
1057                run_id,
1058                Some(-1),
1059                "",
1060                "timeout",
1061                "",
1062                "timeout",
1063                "timeout",
1064                RunTokenUsage::default(),
1065            )
1066            .await;
1067            ReviewPhase::EarlyReturn(ReviewDecision::Failed("timeout".to_string()))
1068        }
1069    }
1070}
1071
1072async fn record_review_agent_failure(
1073    task_id: &str,
1074    review_agent: &str,
1075    review_model: Option<&str>,
1076    err: &runner::agents::AgentError,
1077) {
1078    match err {
1079        runner::agents::AgentError::RateLimit { message }
1080        | runner::agents::AgentError::Auth { message } => {
1081            tracing::warn!(
1082                task_id,
1083                agent = %review_agent,
1084                "review agent hit error — adding to cooldown"
1085            );
1086            if let Some(reason) = crate::engine::cooldown::detect_credit_exhaustion(message) {
1087                if reason == crate::engine::cooldown::CreditExhaustionReason::BillingCycleExhausted
1088                {
1089                    if let Some(model) = review_model {
1090                        // Quota is model-scoped: don't penalize the whole agent.
1091                        crate::engine::cooldown::record_persistent_model_failure(
1092                            review_agent,
1093                            model,
1094                        )
1095                        .await;
1096                    } else {
1097                        crate::engine::cooldown::record_credit_exhaustion(review_agent, reason)
1098                            .await;
1099                    }
1100                } else {
1101                    crate::engine::cooldown::record_credit_exhaustion(review_agent, reason).await;
1102                }
1103            } else {
1104                runner::response::record_agent_failure_with_message(review_agent, &err.to_string())
1105                    .await;
1106            }
1107        }
1108        // Killed or truncated review run, not a model fault
1109        runner::agents::AgentError::Interrupted { .. } => {}
1110        runner::agents::AgentError::InvalidResponse { .. } => {
1111            // No usable review output, a short backoff since the model itself still works
1112            if let Some(model) = review_model {
1113                crate::engine::cooldown::record_transient_model_failure(review_agent, model).await;
1114            }
1115        }
1116        runner::agents::AgentError::ModelUnavailable { .. } => {
1117            // Model not available (provider error, model not found, etc.)
1118            // Apply persistent model cooldown (4h base → 7d max) per settled
1119            // architecture (AGENTS.md:325-328). The agent itself is not
1120            // penalized — its other models still route normally.
1121            if let Some(model) = review_model {
1122                crate::engine::cooldown::record_persistent_model_failure(review_agent, model).await;
1123            }
1124        }
1125        _ => {
1126            // Generic failure (AgentFailed, NetworkError, etc.)
1127            // Apply standard model backoff (5min→4h max) to prevent immediate re-selection.
1128            if let Some(model) = review_model {
1129                crate::engine::cooldown::record_model_failure(review_agent, model).await;
1130            }
1131        }
1132    }
1133}
1134
1135fn review_decision_from_response(
1136    review_response: runner::response::ReviewResponse,
1137) -> ReviewDecision {
1138    match review_response.decision.as_str() {
1139        "approve" => approve_from_evidence(review_response),
1140        "request_changes" => ReviewDecision::RequestChanges {
1141            notes: review_response.notes,
1142            issues: review_response.issues,
1143        },
1144        _ => ReviewDecision::Failed(format!("unknown decision: {}", review_response.decision)),
1145    }
1146}
1147
1148/// An approve that reports failing tests or error-severity issues is treated as request_changes
1149fn approve_from_evidence(review_response: runner::response::ReviewResponse) -> ReviewDecision {
1150    let tests_failed = review_response
1151        .test_results
1152        .as_deref()
1153        .is_some_and(|s| s.trim().eq_ignore_ascii_case("fail"));
1154    let has_error = review_response
1155        .issues
1156        .iter()
1157        .any(|issue| issue.severity.trim().eq_ignore_ascii_case("error"));
1158    if tests_failed || has_error {
1159        let notes = if tests_failed {
1160            notes_for_failed_tests(review_response.notes)
1161        } else {
1162            review_response.notes
1163        };
1164        ReviewDecision::RequestChanges {
1165            notes,
1166            issues: review_response.issues,
1167        }
1168    } else {
1169        ReviewDecision::Approve {
1170            issues: review_response.issues,
1171        }
1172    }
1173}
1174
1175fn notes_for_failed_tests(notes: String) -> String {
1176    const MARK: &str = "Review reported test_results: fail";
1177    if notes.trim().is_empty() {
1178        MARK.to_string()
1179    } else if notes.contains("test_results: fail") {
1180        notes
1181    } else {
1182        format!("{notes}\n\n{MARK}")
1183    }
1184}
1185
1186fn review_decision_name(decision: &ReviewDecision) -> &'static str {
1187    match decision {
1188        ReviewDecision::Approve { .. } => "approve",
1189        ReviewDecision::RequestChanges { .. } => "request_changes",
1190        ReviewDecision::Failed(_) => "failed",
1191        ReviewDecision::Blocked(_) => "blocked",
1192        ReviewDecision::Skipped => "skipped",
1193        ReviewDecision::Rerouted => "rerouted",
1194    }
1195}
1196
1197async fn parse_review_output(
1198    task: &ExternalTask,
1199    repo: &str,
1200    ctx: &ReviewContext,
1201    run: ReviewRun,
1202    store: &Arc<TaskStore>,
1203) -> ReviewPhase<ParsedReview> {
1204    let (file_exists, file_size, exit_code, stderr) = {
1205        let output_file_clone = ctx.output_file.clone();
1206        let review_attempt_dir_clone = ctx.review_attempt_dir.clone();
1207        match tokio::task::spawn_blocking(move || {
1208            let file_exists = output_file_clone.exists();
1209            let file_size = std::fs::metadata(&output_file_clone)
1210                .map(|m| m.len())
1211                .unwrap_or(0);
1212            let exit_code = std::fs::read_to_string(review_attempt_dir_clone.join("exit.txt"))
1213                .ok()
1214                .and_then(|s| s.trim().parse::<i32>().ok())
1215                .unwrap_or(-1);
1216            let stderr = std::fs::read_to_string(review_attempt_dir_clone.join("stderr.txt"))
1217                .unwrap_or_default();
1218            (file_exists, file_size, exit_code, stderr)
1219        })
1220        .await
1221        {
1222            Ok(tuple) => tuple,
1223            Err(e) => {
1224                tracing::error!(
1225                    task_id = task.id.0,
1226                    error = %e,
1227                    "spawn_blocking panicked reading review output metadata"
1228                );
1229                (false, 0, -1, format!("spawn_blocking failed: {e}"))
1230            }
1231        }
1232    };
1233
1234    tracing::info!(
1235        task_id = task.id.0,
1236        path = %ctx.output_file.display(),
1237        file_exists,
1238        file_size,
1239        "reading review agent output"
1240    );
1241
1242    let raw_output =
1243        runner::response::read_output_file(&ctx.review_task_id, &ctx.output_file, repo).await;
1244    let raw_output = runner::agents::claude::strip_tty_control_lines(&raw_output);
1245    let has_result_envelope =
1246        runner::agents::claude::find_ndjson_result_line(&raw_output).is_some();
1247    let agent_result = runner::agents::find_agent_result(&ctx.review_agent, &raw_output);
1248    let token_usage = RunTokenUsage {
1249        input_tokens: agent_result
1250            .as_ref()
1251            .and_then(|r| r.input_tokens)
1252            .unwrap_or(0),
1253        output_tokens: agent_result
1254            .as_ref()
1255            .and_then(|r| r.output_tokens)
1256            .unwrap_or(0),
1257        total_cost_usd: agent_result
1258            .as_ref()
1259            .and_then(|r| r.cost_usd)
1260            .unwrap_or(0.0),
1261        duration_secs: 0.0,
1262    };
1263
1264    let agent_result_is_error = agent_result.as_ref().map(|r| r.is_error).unwrap_or(false);
1265    let ndjson_completed_no_error = raw_output.contains("\"terminal_reason\":\"completed\"")
1266        && !raw_output.contains("\"is_error\":true");
1267    // Do not short-circuit completed NDJSON sessions before rescue parsing.
1268    let is_hard_failure =
1269        !ndjson_completed_no_error && (raw_output.is_empty() || agent_result_is_error);
1270
1271    if is_hard_failure {
1272        tracing::warn!(
1273            task_id = task.id.0,
1274            exit_code,
1275            raw_output_len = raw_output.len(),
1276            stderr_len = stderr.len(),
1277            "review agent: entering error path"
1278        );
1279        let agent_runner = runner::agents::get_runner(&ctx.review_agent);
1280        let err = agent_runner.classify_error(exit_code, &raw_output, &stderr);
1281        record_review_agent_failure(
1282            &task.id.0,
1283            &ctx.review_agent,
1284            ctx.review_model.as_deref(),
1285            &err,
1286        )
1287        .await;
1288        tracing::error!(task_id = task.id.0, error = %err, "review agent failed");
1289        let stored_error = format_review_error(&ctx.review_agent, &err);
1290        complete_review_run(
1291            store,
1292            run.run_id,
1293            Some(exit_code),
1294            &raw_output,
1295            &stderr,
1296            "",
1297            outcome_for_agent_error(&err),
1298            &stored_error,
1299            token_usage,
1300        )
1301        .await;
1302        return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1303    }
1304
1305    let text_for_review = agent_result
1306        .as_ref()
1307        .map(|r| r.result_text.clone())
1308        .filter(|text| !text.is_empty())
1309        .unwrap_or_else(|| {
1310            tracing::debug!(
1311                task_id = task.id.0,
1312                agent = %ctx.review_agent,
1313                "review agent: per-agent extractor returned no text, using raw output"
1314            );
1315            raw_output.clone()
1316        });
1317
1318    let review_response = match runner::response::parse_review_response(&text_for_review) {
1319        Ok(response) => response,
1320        Err(e) => {
1321            if let Some(response) = runner::response::infer_review_response(&text_for_review) {
1322                tracing::warn!(
1323                    task_id = task.id.0,
1324                    agent = %ctx.review_agent,
1325                    "review response parsed via keyword fallback"
1326                );
1327                response
1328            } else {
1329                // Guard: if the raw NDJSON signals a completed session
1330                // (terminal_reason:completed without is_error:true), do NOT run
1331                // generic pattern-based rate-limit detection over it. Token
1332                // counts in the telemetry blob can contain bare "429" values
1333                // that would otherwise be misclassified as HTTP 429 errors.
1334                let ndjson_completed = ndjson_completed_no_error;
1335
1336                // Missing type:result (kimi/minimax) or terminal_reason:completed: recover
1337                // from assistant text. Never MiniMax parse_response, which synthesizes status=done
1338                if ndjson_completed || !has_result_envelope {
1339                    let assistant_text = runner::agents::collect_assistant_messages_text(
1340                        &ctx.review_agent,
1341                        &raw_output,
1342                    );
1343                    if !assistant_text.is_empty() {
1344                        if let Ok(response) =
1345                            runner::response::parse_review_response(&assistant_text)
1346                        {
1347                            tracing::debug!(
1348                                task_id = task.id.0,
1349                                agent = %ctx.review_agent,
1350                                "found review response JSON in assistant message (terminal_reason:completed)"
1351                            );
1352                            let review_notes_for_comment = response.notes.clone();
1353                            let decision = review_decision_from_response(response);
1354                            return ReviewPhase::Ready(ParsedReview {
1355                                run_id: run.run_id,
1356                                exit_code,
1357                                stderr,
1358                                raw_output,
1359                                text_for_review: assistant_text,
1360                                token_usage,
1361                                review_notes_for_comment,
1362                                decision,
1363                            });
1364                        }
1365                        if let Some(response) =
1366                            runner::response::infer_review_response(&assistant_text)
1367                        {
1368                            tracing::warn!(
1369                                task_id = task.id.0,
1370                                agent = %ctx.review_agent,
1371                                "inferred review response from assistant message (terminal_reason:completed)"
1372                            );
1373                            let review_notes_for_comment = response.notes.clone();
1374                            let decision = review_decision_from_response(response);
1375                            return ReviewPhase::Ready(ParsedReview {
1376                                run_id: run.run_id,
1377                                exit_code,
1378                                stderr,
1379                                raw_output,
1380                                text_for_review: assistant_text,
1381                                token_usage,
1382                                review_notes_for_comment,
1383                                decision,
1384                            });
1385                        }
1386                    }
1387                }
1388
1389                // Scan only bounded head/tail windows, not the full review text.
1390                // The full transcript may contain agent work product (e.g. a
1391                // diff or markdown report quoting an `outcome`/`rate_limit`
1392                // table cell) that incidentally matches these patterns; real
1393                // CLI errors appear at the start or end of the output. See
1394                // `classify_from_text` in runner::agents for the same rationale.
1395                let scan_head = runner::agents::patterns::safe_head(
1396                    &text_for_review,
1397                    runner::agents::patterns::RATE_LIMIT_SCAN_HEAD_BYTES,
1398                );
1399                let scan_tail = runner::agents::patterns::safe_tail(
1400                    &text_for_review,
1401                    runner::agents::patterns::RATE_LIMIT_SCAN_TAIL_BYTES,
1402                );
1403
1404                if let Some(err) = (!ndjson_completed)
1405                    .then(|| {
1406                        runner::agents::patterns::detect_rate_limit(scan_head)
1407                            .or_else(|| runner::agents::patterns::detect_rate_limit(scan_tail))
1408                    })
1409                    .flatten()
1410                {
1411                    let runner::agents::AgentError::RateLimit { message } = &err else {
1412                        unreachable!("detect_rate_limit only returns AgentError::RateLimit")
1413                    };
1414                    tracing::warn!(
1415                        task_id = task.id.0,
1416                        agent = %ctx.review_agent,
1417                        "review agent hit rate limit — adding to cooldown"
1418                    );
1419                    // Route through record_review_agent_failure/outcome_for_agent_error
1420                    // (not the generic record_agent_failure_with_message + hardcoded
1421                    // "rate_limit" string) so billing-cycle exhaustion messages caught by
1422                    // this inline text scan get the same model-scoped persistent cooldown
1423                    // and outcome classification as the AgentError-based failure path
1424                    // above (issue #3551, gap left by #3530).
1425                    let summary = runner::fallback::summarize_rate_limit_error(message);
1426                    let stored_error = format!("{} rate limit: {summary}", ctx.review_agent);
1427                    record_review_agent_failure(
1428                        &task.id.0,
1429                        &ctx.review_agent,
1430                        ctx.review_model.as_deref(),
1431                        &err,
1432                    )
1433                    .await;
1434                    complete_review_run(
1435                        store,
1436                        run.run_id,
1437                        Some(exit_code),
1438                        &raw_output,
1439                        &stderr,
1440                        &text_for_review,
1441                        outcome_for_agent_error(&err),
1442                        &stored_error,
1443                        token_usage,
1444                    )
1445                    .await;
1446                    return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1447                }
1448
1449                if let Some(err) = runner::agents::patterns::detect_auth_error(scan_tail) {
1450                    let runner::agents::AgentError::Auth { message } = &err else {
1451                        unreachable!("detect_auth_error only returns AgentError::Auth")
1452                    };
1453                    tracing::warn!(
1454                        task_id = task.id.0,
1455                        agent = %ctx.review_agent,
1456                        "review agent hit auth error — adding to cooldown"
1457                    );
1458                    let stored_error = format!("auth error: {message}");
1459                    record_review_agent_failure(
1460                        &task.id.0,
1461                        &ctx.review_agent,
1462                        ctx.review_model.as_deref(),
1463                        &err,
1464                    )
1465                    .await;
1466                    complete_review_run(
1467                        store,
1468                        run.run_id,
1469                        Some(exit_code),
1470                        &raw_output,
1471                        &stderr,
1472                        &text_for_review,
1473                        outcome_for_agent_error(&err),
1474                        &stored_error,
1475                        token_usage,
1476                    )
1477                    .await;
1478                    return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1479                }
1480
1481                // Cut off at the output token limit (opencode step_finish reason=length)
1482                if agent_result
1483                    .as_ref()
1484                    .map(|r| r.truncated_by_length)
1485                    .unwrap_or(false)
1486                {
1487                    tracing::warn!(
1488                        task_id = task.id.0,
1489                        agent = %ctx.review_agent,
1490                        model = ?ctx.review_model,
1491                        "review agent output truncated: step_finish reason=length \
1492                         (token budget exceeded before completion)"
1493                    );
1494                    if let Some(model) = ctx.review_model.as_deref() {
1495                        crate::engine::cooldown::record_transient_model_failure(
1496                            &ctx.review_agent,
1497                            model,
1498                        )
1499                        .await;
1500                    }
1501                    let stored_error = "truncated: review output was cut off before completion \
1502                        (output/reasoning token budget exceeded)"
1503                        .to_string();
1504                    complete_review_run(
1505                        store,
1506                        run.run_id,
1507                        Some(exit_code),
1508                        &raw_output,
1509                        &stderr,
1510                        &text_for_review,
1511                        "truncated",
1512                        &stored_error,
1513                        token_usage,
1514                    )
1515                    .await;
1516                    return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1517                }
1518
1519                // No envelope and last content is thinking/tool_use: retry without
1520                // the format-failure cooldown (issue #3734)
1521                if runner::agents::claude::review_session_exited_mid_thought(&raw_output) {
1522                    tracing::warn!(
1523                        task_id = task.id.0,
1524                        agent = %ctx.review_agent,
1525                        model = ?ctx.review_model,
1526                        "review session exited mid-thought with no result envelope \
1527                         — storing as incomplete and retrying without persistent cooldown"
1528                    );
1529                    let stored_error =
1530                        "incomplete: review session exited before producing a decision".to_string();
1531                    complete_review_run(
1532                        store,
1533                        run.run_id,
1534                        Some(exit_code),
1535                        &raw_output,
1536                        &stderr,
1537                        &text_for_review,
1538                        "incomplete",
1539                        &stored_error,
1540                        token_usage,
1541                    )
1542                    .await;
1543                    return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1544                }
1545
1546                tracing::error!(
1547                    task_id = task.id.0,
1548                    error = %e,
1549                    agent = %ctx.review_agent,
1550                    output = %text_for_review.chars().take(300).collect::<String>(),
1551                    raw_output = %raw_output.chars().take(1000).collect::<String>(),
1552                    "failed to parse review response"
1553                );
1554                if let Some(model) = ctx.review_model.as_deref() {
1555                    crate::engine::cooldown::record_transient_model_failure(
1556                        &ctx.review_agent,
1557                        model,
1558                    )
1559                    .await;
1560                }
1561                complete_review_run(
1562                    store,
1563                    run.run_id,
1564                    Some(exit_code),
1565                    &raw_output,
1566                    &stderr,
1567                    &text_for_review,
1568                    "parse_error",
1569                    &format!("parse error: {e}"),
1570                    token_usage,
1571                )
1572                .await;
1573                return ReviewPhase::EarlyReturn(ReviewDecision::Failed(format!(
1574                    "parse error: {e}"
1575                )));
1576            }
1577        }
1578    };
1579
1580    let review_notes_for_comment = review_response.notes.clone();
1581    let decision = review_decision_from_response(review_response);
1582
1583    tracing::info!(
1584        task_id = task.id.0,
1585        pr_number = ctx.pr_number,
1586        decision = ?decision,
1587        "review agent decision received"
1588    );
1589    store_log_activity(
1590        &Some(Arc::clone(store)),
1591        repo,
1592        &task.id.0,
1593        "review_decision",
1594        None,
1595        None,
1596        Some(&ctx.review_agent),
1597        ctx.review_model.as_deref(),
1598        Some(&serde_json::json!({
1599            "decision": review_decision_name(&decision),
1600            "pr_number": ctx.pr_number,
1601        })),
1602    )
1603    .await;
1604
1605    ReviewPhase::Ready(ParsedReview {
1606        run_id: run.run_id,
1607        exit_code,
1608        stderr,
1609        raw_output,
1610        text_for_review,
1611        token_usage,
1612        review_notes_for_comment,
1613        decision,
1614    })
1615}
1616
1617async fn finalize_review_run(
1618    task: &ExternalTask,
1619    ctx: &ReviewContext,
1620    parsed: &ParsedReview,
1621    store: &Arc<TaskStore>,
1622) {
1623    let outcome = match &parsed.decision {
1624        ReviewDecision::Approve { .. }
1625        | ReviewDecision::RequestChanges { .. }
1626        | ReviewDecision::Skipped
1627        | ReviewDecision::Rerouted => "success",
1628        ReviewDecision::Failed(_) | ReviewDecision::Blocked(_) => "failed",
1629    };
1630    let error = match &parsed.decision {
1631        ReviewDecision::Failed(error) | ReviewDecision::Blocked(error) => error.as_str(),
1632        _ => "",
1633    };
1634
1635    if outcome == "success" {
1636        // Mirror the runner's success path so review-only models don't ratchet
1637        // into multi-day cooldowns from isolated failures never offset by resets.
1638        crate::engine::cooldown::record_agent_success(&ctx.review_agent, ctx.review_model_str())
1639            .await;
1640    }
1641
1642    complete_review_run(
1643        store,
1644        parsed.run_id,
1645        Some(parsed.exit_code),
1646        &parsed.raw_output,
1647        &parsed.stderr,
1648        &parsed.text_for_review,
1649        outcome,
1650        error,
1651        parsed.token_usage,
1652    )
1653    .await;
1654
1655    if let Some(store_id) = ctx.store_id {
1656        if parsed.token_usage.input_tokens > 0 || parsed.token_usage.output_tokens > 0 {
1657            let model = if ctx.review_model_str().is_empty() {
1658                "unknown"
1659            } else {
1660                ctx.review_model_str()
1661            };
1662            if let Err(e) = store
1663                .store_tokens(
1664                    store_id,
1665                    parsed.token_usage.input_tokens,
1666                    parsed.token_usage.output_tokens,
1667                    model,
1668                )
1669                .await
1670            {
1671                tracing::warn!(
1672                    task_id = task.id.0,
1673                    ?e,
1674                    "failed to store review agent token usage"
1675                );
1676            }
1677        }
1678    }
1679}
1680
1681async fn restore_review_config_if_needed(
1682    task_id: &str,
1683    worktree_path: &std::path::Path,
1684    default_branch: &str,
1685) {
1686    let _ = Command::new("git")
1687        .args(["checkout", "HEAD", "--", ".orch.yml"])
1688        .current_dir(worktree_path)
1689        .status()
1690        .await;
1691
1692    let merge_base = Command::new("git")
1693        .args(["merge-base", "HEAD", default_branch])
1694        .current_dir(worktree_path)
1695        .output()
1696        .await
1697        .ok()
1698        .filter(|output| output.status.success())
1699        .and_then(|output| String::from_utf8(output.stdout).ok())
1700        .map(|s| s.trim().to_string())
1701        .unwrap_or_default();
1702
1703    if merge_base.is_empty() {
1704        return;
1705    }
1706
1707    let orch_yml_changed = Command::new("git")
1708        .args([
1709            "diff",
1710            "--name-only",
1711            &merge_base,
1712            "HEAD",
1713            "--",
1714            ".orch.yml",
1715        ])
1716        .current_dir(worktree_path)
1717        .output()
1718        .await
1719        .ok()
1720        .filter(|output| output.status.success())
1721        .map(|output| !output.stdout.is_empty())
1722        .unwrap_or(false);
1723
1724    if !orch_yml_changed {
1725        return;
1726    }
1727
1728    tracing::warn!(
1729        task_id,
1730        "review agent modified .orch.yml (forbidden); restoring to merge-base version"
1731    );
1732    let restore_ok = Command::new("git")
1733        .args(["checkout", &merge_base, "--", ".orch.yml"])
1734        .current_dir(worktree_path)
1735        .status()
1736        .await
1737        .map(|status| status.success())
1738        .unwrap_or(false);
1739
1740    if !restore_ok {
1741        return;
1742    }
1743
1744    let staged = Command::new("git")
1745        .args(["diff", "--cached", "--name-only", "--", ".orch.yml"])
1746        .current_dir(worktree_path)
1747        .output()
1748        .await
1749        .ok()
1750        .filter(|output| output.status.success())
1751        .map(|output| !output.stdout.is_empty())
1752        .unwrap_or(false);
1753
1754    if staged {
1755        let _ = Command::new("git")
1756            .args([
1757                "-c",
1758                "commit.gpgsign=false",
1759                "commit",
1760                "-m",
1761                "revert: restore .orch.yml (review agent must not modify project config)",
1762            ])
1763            .current_dir(worktree_path)
1764            .status()
1765            .await;
1766    }
1767}
1768
1769fn is_missing_path_error_message(err: &str) -> bool {
1770    err.to_ascii_lowercase()
1771        .contains("no such file or directory")
1772}
1773
1774async fn push_review_branch(
1775    task: &ExternalTask,
1776    repo: &str,
1777    ctx: &ReviewContext,
1778    store: &Arc<TaskStore>,
1779) -> Result<(), String> {
1780    let mut worktree_path = ctx.worktree_path.clone();
1781    let mut branch_name = ctx.branch_name.clone();
1782    let mut retried_missing_worktree = false;
1783
1784    let push_result = loop {
1785        match runner::git_ops::push_branch(&worktree_path, &branch_name, &ctx.default_branch).await
1786        {
1787            Ok(ok) => break Ok(ok),
1788            Err(e)
1789                if !retried_missing_worktree
1790                    && !worktree_path.exists()
1791                    && is_missing_path_error_message(&e.to_string()) =>
1792            {
1793                retried_missing_worktree = true;
1794                tracing::warn!(
1795                    task_id = task.id.0,
1796                    worktree = %worktree_path.display(),
1797                    branch = %branch_name,
1798                    "review push failed because worktree disappeared; attempting one-time recovery"
1799                );
1800                match recover_missing_review_worktree(task, repo, store).await {
1801                    Ok(recovered) => {
1802                        worktree_path = recovered.work_dir;
1803                        branch_name = recovered.branch;
1804                        continue;
1805                    }
1806                    Err(recover_err) => {
1807                        break Err(anyhow::anyhow!(
1808                            "missing review worktree recovery failed after push error: {recover_err}"
1809                        ));
1810                    }
1811                }
1812            }
1813            Err(e) => break Err(e),
1814        }
1815    };
1816
1817    match push_result {
1818        Ok(_) => {
1819            store_log_activity(
1820                &Some(Arc::clone(store)),
1821                repo,
1822                &task.id.0,
1823                "push",
1824                None,
1825                None,
1826                Some(&ctx.review_agent),
1827                ctx.review_model.as_deref(),
1828                Some(&serde_json::json!({
1829                    "status": "ok",
1830                    "branch": branch_name.clone(),
1831                    "default_branch": ctx.default_branch.clone(),
1832                    "phase": "review",
1833                })),
1834            )
1835            .await;
1836            if ctx.had_prev_push_failure {
1837                if let Err(e) = store_set_result(
1838                    &Some(Arc::clone(store)),
1839                    repo,
1840                    &task.id.0,
1841                    &[
1842                        ("last_error", serde_json::json!("")),
1843                        ("push_failures", serde_json::json!(0)),
1844                    ],
1845                )
1846                .await
1847                {
1848                    tracing::warn!(task_id = task.id.0, err = %e, "failed to reset push_failures — counter may drift");
1849                }
1850            }
1851            Ok(())
1852        }
1853        Err(e) => {
1854            store_log_activity(
1855                &Some(Arc::clone(store)),
1856                repo,
1857                &task.id.0,
1858                "push",
1859                None,
1860                None,
1861                Some(&ctx.review_agent),
1862                ctx.review_model.as_deref(),
1863                Some(&serde_json::json!({
1864                    "status": "error",
1865                    "branch": branch_name.clone(),
1866                    "default_branch": ctx.default_branch.clone(),
1867                    "phase": "review",
1868                    "error": e.to_string(),
1869                })),
1870            )
1871            .await;
1872            tracing::error!(
1873                task_id = task.id.0,
1874                branch = %ctx.branch_name,
1875                error = %e,
1876                "review push failed"
1877            );
1878            store_set(
1879                &Some(Arc::clone(store)),
1880                repo,
1881                &task.id.0,
1882                &[("last_error", serde_json::json!(format!("push failed: {e}")))],
1883            )
1884            .await;
1885            Err(e.to_string())
1886        }
1887    }
1888}
1889
1890fn append_review_issues(
1891    body: &mut String,
1892    issues: &[crate::engine::runner::response::ReviewIssue],
1893) {
1894    if issues.is_empty() {
1895        return;
1896    }
1897    body.push_str("\n**Issues Found:**\n");
1898    for issue in issues {
1899        body.push_str(&format!(
1900            "- `{}` line {}: {} [{}]\n",
1901            issue.file,
1902            issue
1903                .line
1904                .map(|line| line.to_string())
1905                .unwrap_or_else(|| "?".to_string()),
1906            issue.description,
1907            issue.severity
1908        ));
1909    }
1910}
1911
1912fn build_pr_review_comment(decision: &ReviewDecision, review_notes_for_comment: &str) -> String {
1913    match decision {
1914        ReviewDecision::Approve { issues } => {
1915            let mut body = format!(
1916                "## Automated Review \u{2014} Approve\n\n{}",
1917                review_notes_for_comment
1918            );
1919            if !issues.is_empty() && !body.ends_with('\n') {
1920                body.push('\n');
1921            }
1922            append_review_issues(&mut body, issues);
1923            body
1924        }
1925        ReviewDecision::RequestChanges { notes, issues } => {
1926            let mut body = format!(
1927                "## Automated Review \u{2014} Changes Requested\n\n{}\n",
1928                notes
1929            );
1930            append_review_issues(&mut body, issues);
1931            body
1932        }
1933        _ => String::new(),
1934    }
1935}
1936
1937const REVIEW_STATUS_CONTEXT: &str = "orch/review";
1938
1939/// Commit status state and description for a review decision. Other decisions post nothing.
1940fn review_status(decision: &ReviewDecision) -> Option<(&'static str, &'static str)> {
1941    match decision {
1942        ReviewDecision::Approve { .. } => Some(("success", "Approved by automated review")),
1943        ReviewDecision::RequestChanges { .. } => {
1944            Some(("failure", "Automated review requested changes"))
1945        }
1946        _ => None,
1947    }
1948}
1949
1950/// Post `orch/review` on the SHA the review started from. A new push moves the PR head, so an
1951/// old decision never covers a new commit.
1952async fn post_review_status(
1953    gh: &GhHttp,
1954    repo: &str,
1955    sha: Option<&str>,
1956    state: &str,
1957    description: &str,
1958) {
1959    let Some(sha) = sha else {
1960        return;
1961    };
1962    if let Err(e) = gh
1963        .post_commit_status(repo, sha, state, REVIEW_STATUS_CONTEXT, description)
1964        .await
1965    {
1966        tracing::warn!(sha, error = %e, "failed to post orch/review commit status");
1967    }
1968}
1969
1970async fn post_review_comment(
1971    task: &ExternalTask,
1972    repo: &str,
1973    ctx: &ReviewContext,
1974    decision: &ReviewDecision,
1975    review_notes_for_comment: &str,
1976) -> anyhow::Result<()> {
1977    let gh = match GhHttp::new() {
1978        Ok(gh) => gh,
1979        Err(e) => {
1980            tracing::warn!(
1981                task_id = task.id.0,
1982                error = %e,
1983                "failed to create GH client for review comment"
1984            );
1985            return Ok(());
1986        }
1987    };
1988    let mut pr_comment = build_pr_review_comment(decision, review_notes_for_comment);
1989    if ctx.same_agent_fallback && !pr_comment.is_empty() {
1990        pr_comment.push_str(
1991            "\n> Note: no other agent was available, so this PR was reviewed by the same agent that wrote the code.\n",
1992        );
1993    }
1994
1995    // The agent may have rebased or committed a fix, pushed just before this, so the head read
1996    // before the review is stale and an App review on it would be refused or ignored
1997    let head_sha = worktree_head_sha(&ctx.worktree_path)
1998        .await
1999        .or_else(|| ctx.head_sha.clone());
2000
2001    let app = review_app(repo).await;
2002    post_review_decision(
2003        app.as_deref(),
2004        &gh,
2005        repo,
2006        head_sha.as_deref(),
2007        decision,
2008        review_notes_for_comment,
2009    )
2010    .await;
2011
2012    if !pr_comment.is_empty() {
2013        let footer =
2014            attribution_footer("Reviewed", &ctx.review_agent, Some(ctx.review_model_str()));
2015        let pr_comment_with_footer = format!("{}{}", pr_comment, footer);
2016        if let Err(e) = publish_review(
2017            app.as_deref(),
2018            &gh,
2019            repo,
2020            ctx.pr_number,
2021            head_sha.as_deref(),
2022            decision,
2023            &pr_comment_with_footer,
2024        )
2025        .await
2026        {
2027            tracing::warn!(
2028                task_id = task.id.0,
2029                pr_number = ctx.pr_number,
2030                error = %e,
2031                "failed to post automated review comment on PR"
2032            );
2033        }
2034    }
2035
2036    Ok(())
2037}
2038
2039/// The App to review `repo` with, `None` in comment mode or where the App is not installed
2040async fn review_app(repo: &str) -> Option<Arc<crate::github::app::AppAuth>> {
2041    let mode = crate::review_mode::for_repo(repo);
2042    review_app_for(mode, crate::github::app::shared(), repo).await
2043}
2044
2045async fn review_app_for(
2046    mode: crate::review_mode::ReviewMode,
2047    app: Option<Arc<crate::github::app::AppAuth>>,
2048    repo: &str,
2049) -> Option<Arc<crate::github::app::AppAuth>> {
2050    if mode != crate::review_mode::ReviewMode::App {
2051        return None;
2052    }
2053
2054    let app = app?;
2055    match app.token_for_repo(repo).await {
2056        Ok(Some(_)) => Some(app),
2057        Ok(None) => {
2058            tracing::info!(
2059                repo,
2060                "review.mode is app but the App is not installed here, reviewing with a comment"
2061            );
2062            None
2063        }
2064        Err(e) => {
2065            tracing::warn!(repo, error = %e, "App installation lookup failed, reviewing with a comment");
2066            None
2067        }
2068    }
2069}
2070
2071/// `orch/review` as an App check run in app mode, as a commit status in comment mode
2072async fn post_review_started(
2073    app: Option<&crate::github::app::AppAuth>,
2074    gh: &GhHttp,
2075    repo: &str,
2076    sha: Option<&str>,
2077) {
2078    use crate::github::app::CheckRunState;
2079    match (app, sha) {
2080        (Some(app), Some(sha)) => {
2081            if let Err(e) = app
2082                .post_check_run(repo, sha, CheckRunState::InProgress)
2083                .await
2084            {
2085                tracing::warn!(sha, error = %e, "failed to post the orch/review check run");
2086            }
2087        }
2088        _ => post_review_status(gh, repo, sha, "pending", "Automated review running").await,
2089    }
2090}
2091
2092async fn post_review_decision(
2093    app: Option<&crate::github::app::AppAuth>,
2094    gh: &GhHttp,
2095    repo: &str,
2096    sha: Option<&str>,
2097    decision: &ReviewDecision,
2098    notes: &str,
2099) {
2100    use crate::github::app::CheckRunState;
2101    let Some((state, description)) = review_status(decision) else {
2102        return;
2103    };
2104
2105    match (app, sha) {
2106        (Some(app), Some(sha)) => {
2107            let approved = state == "success";
2108            let completed = CheckRunState::Completed {
2109                approved,
2110                summary: if notes.is_empty() { description } else { notes },
2111            };
2112            if let Err(e) = app.post_check_run(repo, sha, completed).await {
2113                tracing::warn!(sha, error = %e, "failed to complete the orch/review check run");
2114            }
2115        }
2116        _ => post_review_status(gh, repo, sha, state, description).await,
2117    }
2118}
2119
2120/// Submit the decision as an App review on the sha the agent read, with the issues on diff lines
2121/// as inline comments, then post the comment the gate falls back to. Without an App only the
2122/// comment goes out
2123async fn publish_review(
2124    app: Option<&crate::github::app::AppAuth>,
2125    gh: &GhHttp,
2126    repo: &str,
2127    pr_number: u64,
2128    head_sha: Option<&str>,
2129    decision: &ReviewDecision,
2130    comment: &str,
2131) -> anyhow::Result<()> {
2132    use crate::github::app::ReviewEvent;
2133    let event = match decision {
2134        ReviewDecision::Approve { .. } => Some(ReviewEvent::Approve),
2135        ReviewDecision::RequestChanges { .. } => Some(ReviewEvent::RequestChanges),
2136        _ => None,
2137    };
2138    if let (Some(app), Some(sha), Some(event)) = (app, head_sha, event) {
2139        let inline = inline_comments(app, repo, pr_number, decision).await;
2140        if let Err(e) = app
2141            .submit_review(repo, pr_number, event, comment, sha, &inline)
2142            .await
2143        {
2144            tracing::warn!(pr_number, sha, error = %e, "failed to submit App review, the comment stays the decision");
2145        }
2146    }
2147    gh.add_comment(repo, &pr_number.to_string(), comment).await
2148}
2149
2150/// Issues on a changed line become inline comments, the rest stay in the review body, which
2151/// already lists every issue. GitHub refuses a review whose comment points outside the diff
2152async fn inline_comments(
2153    app: &crate::github::app::AppAuth,
2154    repo: &str,
2155    pr_number: u64,
2156    decision: &ReviewDecision,
2157) -> Vec<crate::github::app::InlineComment> {
2158    let ReviewDecision::RequestChanges { issues, .. } = decision else {
2159        return Vec::new();
2160    };
2161
2162    if issues.is_empty() {
2163        return Vec::new();
2164    }
2165
2166    match app.pull_request_hunks(repo, pr_number).await {
2167        Ok(hunks) => crate::github::app::split_inline(issues, &hunks).0,
2168        Err(e) => {
2169            tracing::warn!(pr_number, error = %e, "could not read the PR diff, issues stay in the review body");
2170            Vec::new()
2171        }
2172    }
2173}
2174
2175#[allow(clippy::too_many_arguments)]
2176async fn apply_review_decision(
2177    task: &ExternalTask,
2178    backend: &Arc<dyn ExternalBackend>,
2179    repo: &str,
2180    task_manager: &Arc<TaskManager>,
2181    store: &Arc<TaskStore>,
2182    ctx: &ReviewContext,
2183    parsed: &ParsedReview,
2184    router_config: &crate::engine::router::RouterConfig,
2185) -> anyhow::Result<ReviewDecision> {
2186    restore_review_config_if_needed(&task.id.0, &ctx.worktree_path, &ctx.default_branch).await;
2187
2188    if let Err(e) = push_review_branch(task, repo, ctx, store).await {
2189        return Ok(ReviewDecision::Failed(format!("push failed: {e}")));
2190    }
2191
2192    post_review_comment(
2193        task,
2194        repo,
2195        ctx,
2196        &parsed.decision,
2197        &parsed.review_notes_for_comment,
2198    )
2199    .await?;
2200
2201    match &parsed.decision {
2202        ReviewDecision::Approve { .. } => {
2203            record_approved_sha(store, repo, &task.id.0, ctx.pr_number, &ctx.worktree_path).await;
2204            // Always attempt auto-merge on approval. Tasks are only marked
2205            // Done when the PR is actually merged (detected by review_poll).
2206            // Never mark Done on approval alone — that closes the issue
2207            // before the code lands.
2208            if let Err(e) = auto_merge_pr(
2209                task,
2210                &ctx.branch_name,
2211                backend,
2212                repo,
2213                &ctx.review_agent,
2214                ctx.review_model_str(),
2215                task_manager,
2216                store,
2217            )
2218            .await
2219            {
2220                let error_msg = e.to_string();
2221                if error_msg.contains("not yet computed") {
2222                    tracing::warn!(
2223                        task_id = task.id.0,
2224                        pr_number = ctx.pr_number,
2225                        branch = %ctx.branch_name,
2226                        error = %e,
2227                        "auto-merge deferred — PR mergeability not yet computed"
2228                    );
2229                } else {
2230                    tracing::error!(
2231                        task_id = task.id.0,
2232                        pr_number = ctx.pr_number,
2233                        branch = %ctx.branch_name,
2234                        error = %e,
2235                        "auto-merge failed"
2236                    );
2237                }
2238                return Ok(ReviewDecision::Failed(format!("merge failed: {e}")));
2239            }
2240            Ok(parsed.decision.clone())
2241        }
2242        ReviewDecision::RequestChanges { notes, issues } => {
2243            clear_approved_sha(store, repo, &task.id.0, ctx.pr_number).await;
2244            handle_review_changes(
2245                task,
2246                notes,
2247                issues,
2248                backend,
2249                repo,
2250                ctx.pr_number,
2251                &ctx.review_agent,
2252                ctx.review_model_str(),
2253                task_manager,
2254                store,
2255                router_config,
2256            )
2257            .await?;
2258            Ok(parsed.decision.clone())
2259        }
2260        _ => Ok(parsed.decision.clone()),
2261    }
2262}
2263
2264async fn recover_missing_review_worktree(
2265    task: &ExternalTask,
2266    repo: &str,
2267    store: &Arc<TaskStore>,
2268) -> anyhow::Result<worktree::WorktreeSetup> {
2269    let project_dir = crate::config::get_projects_with_paths()?
2270        .into_iter()
2271        .find(|(candidate_repo, _)| candidate_repo == repo)
2272        .map(|(_, project_dir)| project_dir)
2273        .ok_or_else(|| anyhow::anyhow!("no project directory configured for repo {repo}"))?;
2274
2275    tracing::warn!(
2276        task_id = task.id.0,
2277        repo,
2278        project_dir = %project_dir.display(),
2279        "review worktree missing, recreating via normal setup"
2280    );
2281
2282    let store_ref = Some(Arc::clone(store));
2283    let wt = worktree::setup_worktree(&task.id.0, &task.title, &project_dir, &store_ref, repo)
2284        .await
2285        .context("recreating missing review worktree")?;
2286
2287    if !wt.work_dir.exists() {
2288        anyhow::bail!(
2289            "recreated review worktree at {} for task {} is still missing",
2290            wt.work_dir.display(),
2291            task.id.0
2292        );
2293    }
2294
2295    Ok(wt)
2296}
2297
2298/// Count commits on the worktree branch that are ahead of `origin/<default_branch>`.
2299///
2300/// Returns `Err` when the git command cannot determine the count (e.g., missing
2301/// remote ref, non-zero exit, unparseable output). Callers must NOT treat this
2302/// as zero — a failure means the ancestry relationship is unknown.
2303async fn count_ahead_commits(
2304    worktree_path: &std::path::Path,
2305    default_branch: &str,
2306) -> Result<u64, String> {
2307    let worktree_str = worktree_path.to_str().ok_or_else(|| {
2308        format!(
2309            "worktree path contains non-UTF-8 characters: {:?}",
2310            worktree_path
2311        )
2312    })?;
2313
2314    let output = tokio::process::Command::new("git")
2315        .args([
2316            "-C",
2317            worktree_str,
2318            "rev-list",
2319            "--count",
2320            &format!("origin/{default_branch}..HEAD"),
2321        ])
2322        .output()
2323        .await
2324        .map_err(|e| format!("git rev-list command failed to start: {e}"))?;
2325
2326    if !output.status.success() {
2327        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
2328        return Err(format!(
2329            "git rev-list exited with {:?}: {}",
2330            output.status.code(),
2331            if stderr.is_empty() {
2332                String::from_utf8_lossy(&output.stdout).trim().to_string()
2333            } else {
2334                stderr
2335            }
2336        ));
2337    }
2338
2339    String::from_utf8_lossy(&output.stdout)
2340        .trim()
2341        .parse::<u64>()
2342        .map_err(|e| format!("could not parse git rev-list output as u64: {e}"))
2343}
2344
2345/// Ensure an open PR exists for the task branch before running the review agent.
2346///
2347/// Checks the store first (avoids GitHub list-API cache race), then the API.
2348/// If no PR exists but the branch has commits, attempts to create one and
2349/// continues with the same review pass.
2350/// All error and no-op paths update task status and return [`EnsurePrResult::EarlyReturn`].
2351#[allow(clippy::too_many_arguments)]
2352async fn ensure_pr_exists(
2353    task: &ExternalTask,
2354    branch_name: &str,
2355    worktree_path: &std::path::Path,
2356    repo: &str,
2357    agent_summary: &str,
2358    store: &Arc<TaskStore>,
2359    task_manager: &Arc<TaskManager>,
2360    stored_pr_number: Option<u64>,
2361) -> anyhow::Result<EnsurePrResult> {
2362    let gh_check = GhHttp::new()?;
2363
2364    if let Some(n) = stored_pr_number {
2365        tracing::info!(
2366            task_id = task.id.0,
2367            pr_number = n,
2368            branch = %branch_name,
2369            "open PR found in store, proceeding with review"
2370        );
2371        return Ok(EnsurePrResult::Ready(n));
2372    }
2373
2374    match gh_check.get_pr_number(repo, branch_name).await {
2375        Ok(Some(n)) => {
2376            tracing::info!(
2377                task_id = task.id.0,
2378                pr_number = n,
2379                branch = %branch_name,
2380                "open PR found, proceeding with review"
2381            );
2382            Ok(EnsurePrResult::Ready(n))
2383        }
2384        Ok(None) => {
2385            // No open PR — check if branch has commits ahead of default branch.
2386            let default_branch = worktree::detect_default_branch(worktree_path).await;
2387            let has_commits = match count_ahead_commits(worktree_path, &default_branch).await {
2388                Ok(count) => count > 0,
2389                Err(e) => {
2390                    tracing::error!(
2391                        task_id = task.id.0,
2392                        branch = %branch_name,
2393                        default_branch = %default_branch,
2394                        err = %e,
2395                        "could not determine if branch has commits ahead of default"
2396                    );
2397                    return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2398                        format!("git ancestry check failed: {e}"),
2399                    )));
2400                }
2401            };
2402
2403            if has_commits {
2404                // Branch has unpushed or un-PR'd work — try to create a PR
2405                tracing::warn!(
2406                    task_id = task.id.0,
2407                    branch = %branch_name,
2408                    "no open PR but branch has commits — attempting to create PR"
2409                );
2410                let worktree_str = worktree_path.to_str().ok_or_else(|| {
2411                    anyhow::anyhow!(
2412                        "worktree path contains non-UTF-8 characters: {:?}",
2413                        worktree_path
2414                    )
2415                })?;
2416                // Push first in case agent forgot (use token auth like git_ops::push_branch)
2417                let auth_env = runner::git_ops::build_git_auth_env().await;
2418
2419                // Run a credentialed push and log any errors so failures are
2420                // visible in logs instead of being silently discarded.
2421                // Wrap with a timeout to prevent indefinitely blocking a Tokio worker on a
2422                // hung network connection (corporate proxy, partial GitHub outage, TCP hang).
2423                let push_timeout = std::time::Duration::from_secs(120);
2424                match tokio::time::timeout(
2425                    push_timeout,
2426                    tokio::process::Command::new("git")
2427                        .args(["-C", worktree_str, "push", "-u", "origin", branch_name])
2428                        .envs(auth_env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
2429                        .output(),
2430                )
2431                .await
2432                {
2433                    Err(_elapsed) => {
2434                        tracing::warn!(
2435                            task_id = task.id.0,
2436                            branch = %branch_name,
2437                            timeout_secs = push_timeout.as_secs(),
2438                            "review gate fallback push timed out"
2439                        );
2440                    }
2441                    Ok(Ok(o)) => {
2442                        if !o.status.success() {
2443                            tracing::warn!(
2444                                task_id = task.id.0,
2445                                branch = %branch_name,
2446                                exit_code = ?o.status.code(),
2447                                stdout = %String::from_utf8_lossy(&o.stdout),
2448                                stderr = %String::from_utf8_lossy(&o.stderr),
2449                                "review gate fallback push failed"
2450                            );
2451                        } else {
2452                            tracing::info!(task_id = task.id.0, branch = %branch_name, "review gate fallback push succeeded");
2453                        }
2454                    }
2455                    Ok(Err(e)) => {
2456                        tracing::error!(
2457                            task_id = task.id.0,
2458                            branch = %branch_name,
2459                            error = %e,
2460                            "review gate fallback push command failed to run"
2461                        );
2462                    }
2463                }
2464
2465                let task_ref = runner::git_ops::format_task_ref(&task.id.0);
2466                let pr_body = format!(
2467                    "Resolves {task_ref}\n\nAuto-created by orch review gate (agent forgot to open PR)"
2468                );
2469                let gh = GhHttp::new()?;
2470                match gh
2471                    .create_pr(repo, &task.title, &pr_body, branch_name, &default_branch)
2472                    .await
2473                {
2474                    Ok(url) => match parse_pr_number_from_url(&url) {
2475                        Ok(pr_num) => {
2476                            store_set_result(
2477                                &Some(Arc::clone(store)),
2478                                repo,
2479                                &task.id.0,
2480                                &[("pr_number", serde_json::json!(pr_num as i64))],
2481                            )
2482                            .await?;
2483                            tracing::info!(
2484                                task_id = task.id.0,
2485                                branch = %branch_name,
2486                                pr_url = %url,
2487                                "created missing PR via GhHttp — continuing review"
2488                            );
2489                            Ok(EnsurePrResult::Ready(pr_num))
2490                        }
2491                        Err(e) => {
2492                            tracing::warn!(
2493                                task_id = task.id.0,
2494                                branch = %branch_name,
2495                                pr_url = %url,
2496                                error = %e,
2497                                "created missing PR via GhHttp, but failed to parse PR number"
2498                            );
2499                            Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2500                                format!("created missing PR, but could not parse PR number: {e}"),
2501                            )))
2502                        }
2503                    },
2504                    Err(e) => {
2505                        let e_str = format!("{e}");
2506                        if e_str.contains("already exists") {
2507                            // GitHub list-API cache may be stale immediately after a 422.
2508                            // Use two attempts with exponential backoff (2s → 4s) to avoid
2509                            // racing into a duplicate `gh pr create` call.
2510                            let backoff_delays = [2u64, 4u64];
2511                            let mut found_pr: Option<u64> = None;
2512                            for delay_secs in backoff_delays {
2513                                tokio::time::sleep(tokio::time::Duration::from_secs(delay_secs))
2514                                    .await;
2515                                if let Ok(Some(n)) = gh_check.get_pr_number(repo, branch_name).await
2516                                {
2517                                    found_pr = Some(n);
2518                                    break;
2519                                }
2520                            }
2521                            if let Some(n) = found_pr {
2522                                tracing::info!(
2523                                    task_id = task.id.0,
2524                                    pr_number = n,
2525                                    branch = %branch_name,
2526                                    "found existing PR after create_pr 422 — retrying review"
2527                                );
2528                                store_set_result(
2529                                    &Some(Arc::clone(store)),
2530                                    repo,
2531                                    &task.id.0,
2532                                    &[("pr_number", serde_json::json!(n as i64))],
2533                                )
2534                                .await?;
2535                                return Ok(EnsurePrResult::Ready(n));
2536                            }
2537                        }
2538                        tracing::warn!(
2539                            task_id = task.id.0,
2540                            branch = %branch_name,
2541                            error = %e,
2542                            "create_pr failed via GhHttp, falling back to CLI"
2543                        );
2544                        // Fall back to CLI. Wrap the `gh pr create` call in a timeout so
2545                        // a hung CLI/network does not block the review pipeline.
2546                        let pr_timeout = std::time::Duration::from_secs(120);
2547                        let mut gh_cmd = tokio::process::Command::new("gh");
2548                        gh_cmd.args([
2549                            "pr",
2550                            "create",
2551                            "--repo",
2552                            repo,
2553                            "--head",
2554                            branch_name,
2555                            "--title",
2556                            &task.title,
2557                            "--body",
2558                            &pr_body,
2559                        ]);
2560                        gh_cmd.current_dir(worktree_path);
2561                        gh_cmd.kill_on_drop(true);
2562
2563                        let pr_result = tokio::time::timeout(pr_timeout, gh_cmd.output()).await;
2564                        match pr_result {
2565                            Ok(Ok(o)) if o.status.success() => {
2566                                let stdout = String::from_utf8_lossy(&o.stdout);
2567                                match parse_pr_number_from_url(stdout.trim()) {
2568                                    Ok(pr_num) => {
2569                                        store_set_result(
2570                                            &Some(Arc::clone(store)),
2571                                            repo,
2572                                            &task.id.0,
2573                                            &[("pr_number", serde_json::json!(pr_num as i64))],
2574                                        )
2575                                        .await?;
2576                                        tracing::info!(
2577                                            task_id = task.id.0,
2578                                            branch = %branch_name,
2579                                            "created missing PR via CLI — continuing review"
2580                                        );
2581                                        Ok(EnsurePrResult::Ready(pr_num))
2582                                    }
2583                                    Err(e) => {
2584                                        tracing::warn!(
2585                                            task_id = task.id.0,
2586                                            branch = %branch_name,
2587                                            stdout = %stdout,
2588                                            error = %e,
2589                                            "created missing PR via CLI, but failed to parse PR number"
2590                                        );
2591                                        Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2592                                            format!("created missing PR via CLI, but could not parse PR number: {e}")
2593                                        )))
2594                                    }
2595                                }
2596                            }
2597                            Ok(Ok(o)) => {
2598                                let stderr = String::from_utf8_lossy(&o.stderr);
2599                                if stderr.contains("already exists") {
2600                                    if let Some(pr_url) =
2601                                        stderr.lines().find(|l| l.trim().starts_with("https://"))
2602                                    {
2603                                        match parse_pr_number_from_url(pr_url.trim()) {
2604                                            Ok(pr_num) => {
2605                                                if let Err(e) = store_set_result(
2606                                                    &Some(Arc::clone(store)),
2607                                                    repo,
2608                                                    &task.id.0,
2609                                                    &[(
2610                                                        "pr_number",
2611                                                        serde_json::json!(pr_num as i64),
2612                                                    )],
2613                                                )
2614                                                .await
2615                                                {
2616                                                    tracing::warn!(
2617                                                        task_id = task.id.0,
2618                                                        pr_num,
2619                                                        err = %e,
2620                                                        "failed to persist pr_number from already-exists recovery — downstream may retry PR creation"
2621                                                    );
2622                                                }
2623                                                tracing::info!(
2624                                                    task_id = task.id.0,
2625                                                    branch = %branch_name,
2626                                                    pr_url = %pr_url,
2627                                                    "PR already exists (from CLI stderr) — retrying review"
2628                                                );
2629                                                return Ok(EnsurePrResult::Ready(pr_num));
2630                                            }
2631                                            Err(e) => {
2632                                                tracing::warn!(
2633                                                    task_id = task.id.0,
2634                                                    branch = %branch_name,
2635                                                    pr_url = %pr_url,
2636                                                    error = %e,
2637                                                    "found existing PR URL in CLI stderr, but failed to parse PR number"
2638                                                );
2639                                                // Continue to the error handling below
2640                                            }
2641                                        }
2642                                    }
2643                                }
2644                                tracing::error!(
2645                                    task_id = task.id.0,
2646                                    branch = %branch_name,
2647                                    stderr = %stderr,
2648                                    "failed to create missing PR — work may be stuck"
2649                                );
2650
2651                                // If this was a transient GitHub 5xx or transport error,
2652                                // do NOT increment the persistent pr_create_failures counter
2653                                // (which would eventually block the task). Let the
2654                                // engine retry later. Detect via git_ops::is_transient_github_api_error
2655                                // so generic agent/model transport errors (e.g. broken pipe)
2656                                // are NOT mis-classified as GitHub API transients.
2657                                if crate::engine::runner::git_ops::is_transient_github_api_error(
2658                                    &stderr,
2659                                ) {
2660                                    tracing::warn!(
2661                                        task_id = task.id.0,
2662                                        branch = %branch_name,
2663                                        "transient GitHub error creating PR; will retry later without incrementing persistent failure counter"
2664                                    );
2665                                    return Ok(EnsurePrResult::EarlyReturn(
2666                                        ReviewDecision::Failed(format!(
2667                                            "transient github error creating PR: {stderr}"
2668                                        )),
2669                                    ));
2670                                }
2671
2672                                match store_increment(
2673                                    &Some(Arc::clone(store)),
2674                                    repo,
2675                                    &task.id.0,
2676                                    "pr_create_failures",
2677                                )
2678                                .await
2679                                {
2680                                    Ok(failures) if failures >= MAX_PR_CREATE_FAILURES => {
2681                                        return Ok(EnsurePrResult::EarlyReturn(
2682                                            ReviewDecision::Blocked(format!(
2683                                                "no PR, create failed {failures} times: {stderr}"
2684                                            )),
2685                                        ));
2686                                    }
2687                                    Ok(_) => {}
2688                                    Err(e) => {
2689                                        tracing::warn!(task_id = task.id.0, err = %e, "failed to increment pr_create_failures — skipping blocking decision this tick");
2690                                    }
2691                                }
2692                                Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2693                                    format!("no PR, create failed: {stderr}"),
2694                                )))
2695                            }
2696                            Ok(Err(e)) => {
2697                                tracing::error!(
2698                                    task_id = task.id.0,
2699                                    error = %e,
2700                                    "failed to run gh pr create"
2701                                );
2702                                let e_str = format!("{e}");
2703                                if crate::engine::runner::git_ops::is_transient_github_api_error(
2704                                    &e_str,
2705                                ) {
2706                                    tracing::warn!(
2707                                        task_id = task.id.0,
2708                                        branch = %branch_name,
2709                                        "transient GitHub error from gh CLI fallback; will retry later without incrementing persistent failure counter"
2710                                    );
2711                                    return Ok(EnsurePrResult::EarlyReturn(
2712                                        ReviewDecision::Failed(format!("transient gh error: {e}")),
2713                                    ));
2714                                }
2715
2716                                match store_increment(
2717                                    &Some(Arc::clone(store)),
2718                                    repo,
2719                                    &task.id.0,
2720                                    "pr_create_failures",
2721                                )
2722                                .await
2723                                {
2724                                    Ok(failures) if failures >= MAX_PR_CREATE_FAILURES => {
2725                                        return Ok(EnsurePrResult::EarlyReturn(
2726                                            ReviewDecision::Blocked(format!(
2727                                                "no PR, gh error {failures} times: {e}"
2728                                            )),
2729                                        ));
2730                                    }
2731                                    Ok(_) => {}
2732                                    Err(e) => {
2733                                        tracing::warn!(task_id = task.id.0, err = %e, "failed to increment pr_create_failures — skipping blocking decision this tick");
2734                                    }
2735                                }
2736                                Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2737                                    format!("no PR, gh error: {e}"),
2738                                )))
2739                            }
2740                            Err(_elapsed) => {
2741                                // Timeout from tokio::time::timeout
2742                                tracing::error!(
2743                                    task_id = task.id.0,
2744                                    timeout_secs = pr_timeout.as_secs(),
2745                                    "gh pr create CLI fallback timed out"
2746                                );
2747                                let e_str = format!(
2748                                    "gh pr create timed out after {}s",
2749                                    pr_timeout.as_secs()
2750                                );
2751                                if crate::engine::runner::git_ops::is_transient_github_api_error(
2752                                    &e_str,
2753                                ) {
2754                                    tracing::warn!(
2755                                        task_id = task.id.0,
2756                                        branch = %branch_name,
2757                                        "transient GitHub timeout from gh CLI fallback; will retry later without incrementing persistent failure counter"
2758                                    );
2759                                    return Ok(EnsurePrResult::EarlyReturn(
2760                                        ReviewDecision::Failed(format!(
2761                                            "transient gh error: {e_str}"
2762                                        )),
2763                                    ));
2764                                }
2765
2766                                match store_increment(
2767                                    &Some(Arc::clone(store)),
2768                                    repo,
2769                                    &task.id.0,
2770                                    "pr_create_failures",
2771                                )
2772                                .await
2773                                {
2774                                    Ok(failures) if failures >= MAX_PR_CREATE_FAILURES => {
2775                                        return Ok(EnsurePrResult::EarlyReturn(
2776                                            ReviewDecision::Blocked(format!(
2777                                                "no PR, gh timeout {failures} times: {e_str}"
2778                                            )),
2779                                        ));
2780                                    }
2781                                    Ok(_) => {}
2782                                    Err(e) => {
2783                                        tracing::warn!(task_id = task.id.0, err = %e, "failed to increment pr_create_failures — skipping blocking decision this tick");
2784                                    }
2785                                }
2786                                Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2787                                    format!("no PR, gh timeout: {e_str}"),
2788                                )))
2789                            }
2790                        }
2791                    }
2792                }
2793            } else {
2794                // No PR and no commits — agent either failed or completed a read-only task.
2795                let merged = match gh_check.is_pr_merged(repo, branch_name).await {
2796                    Ok(v) => v,
2797                    Err(e) => {
2798                        tracing::warn!(task_id = task.id.0, branch = %branch_name, err = %e, "merge check failed, skipping task this tick");
2799                        return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2800                            format!("merge check failed: {e}"),
2801                        )));
2802                    }
2803                };
2804                if merged {
2805                    tracing::info!(
2806                        task_id = task.id.0,
2807                        branch = %branch_name,
2808                        "PR already merged, marking done"
2809                    );
2810                    if let Err(e) = task_manager
2811                        .update_task_status(&task.id, Status::Done)
2812                        .await
2813                    {
2814                        tracing::error!(task_id = task.id.0, err = %e, "update_task_status(Done) failed — task may be stuck in InReview");
2815                    }
2816                    return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped));
2817                }
2818
2819                let last_error = opt_store_get_task(&Some(Arc::clone(store)), repo, &task.id.0)
2820                    .await
2821                    .map(|t| t.last_error)
2822                    .unwrap_or_default();
2823                let reason = if !agent_summary.is_empty() {
2824                    agent_summary.to_string()
2825                } else {
2826                    last_error.clone()
2827                };
2828
2829                if last_error.contains("exceeded max attempts") {
2830                    tracing::warn!(
2831                        task_id = task.id.0,
2832                        branch = %branch_name,
2833                        "no PR and no commits after max attempts — marking blocked to stop loop"
2834                    );
2835                    // Write block_reason atomically with the status transition to prevent
2836                    // auto_unblock_blocked_tasks (block_reason.is_none() gate) from
2837                    // re-dispatching this task.
2838                    let fields = [
2839                        (
2840                            "block_reason",
2841                            serde_json::json!("max attempts exceeded — no PR or commits produced"),
2842                        ),
2843                        ("last_error", serde_json::json!(last_error)),
2844                    ];
2845                    if let Err(e) = task_manager
2846                        .update_task_status_and_result(&task.id, Status::Blocked, &fields)
2847                        .await
2848                    {
2849                        tracing::error!(task_id = task.id.0, err = %e, "update_task_status_and_result(Blocked) failed — skipping block to avoid silent auto-unblock loop");
2850                        return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2851                            format!("failed to write block_reason: {e}"),
2852                        )));
2853                    }
2854                    return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped));
2855                }
2856
2857                if last_error.contains("422") && last_error.contains("head") {
2858                    tracing::info!(
2859                        task_id = task.id.0,
2860                        branch = %branch_name,
2861                        "no PR — 422/head-invalid means work already merged, marking done"
2862                    );
2863                    if let Err(e) = task_manager
2864                        .update_task_status(&task.id, Status::Done)
2865                        .await
2866                    {
2867                        tracing::error!(task_id = task.id.0, err = %e, "update_task_status(Done) failed");
2868                    }
2869                    return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped));
2870                }
2871
2872                if last_error.contains("no fallback agents")
2873                    || last_error.contains("all agents exhausted")
2874                {
2875                    tracing::warn!(
2876                        task_id = task.id.0,
2877                        branch = %branch_name,
2878                        last_error = %last_error,
2879                        "no PR and no commits after failover exhaustion — marking blocked to stop loop"
2880                    );
2881                    // Write block_reason atomically with the status transition to prevent
2882                    // auto_unblock_blocked_tasks (block_reason.is_none() gate) from
2883                    // re-dispatching this task.
2884                    let fields = [
2885                        (
2886                            "block_reason",
2887                            serde_json::json!("all agents exhausted — no PR or commits produced"),
2888                        ),
2889                        ("last_error", serde_json::json!(last_error)),
2890                    ];
2891                    if let Err(e) = task_manager
2892                        .update_task_status_and_result(&task.id, Status::Blocked, &fields)
2893                        .await
2894                    {
2895                        tracing::error!(task_id = task.id.0, err = %e, "update_task_status_and_result(Blocked) failed — skipping block to avoid silent auto-unblock loop");
2896                        return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2897                            format!("failed to write block_reason: {e}"),
2898                        )));
2899                    }
2900                    return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped));
2901                }
2902
2903                // No PR and no commits — agent either failed or completed a read-only task.
2904                // Use a dedicated circuit-breaker counter persisted in the store so
2905                // repeated reroutes across separate runs are counted. This prevents
2906                // internal tasks from looping new→in_progress→needs_review→new indefinitely.
2907                // Prefer a dedicated config key `workflow.max_reroute_attempts` (fallback to
2908                // `workflow.max_attempts` for backwards compatibility).
2909                let max_reroutes: u32 = config::get("workflow.max_reroute_attempts")
2910                    .ok()
2911                    .and_then(|s| s.parse().ok())
2912                    .or_else(|| {
2913                        config::get("workflow.max_attempts")
2914                            .ok()
2915                            .and_then(|s| s.parse().ok())
2916                    })
2917                    .unwrap_or(3);
2918
2919                tracing::warn!(
2920                    task_id = task.id.0,
2921                    branch = %branch_name,
2922                    reason = %reason,
2923                    "no PR and no commits — re-routing for retry"
2924                );
2925
2926                // If the last_error indicates a transient GitHub 5xx/transport error,
2927                // do NOT increment the persistent reroute counter which would
2928                // eventually block the task. Instead, retry later.
2929                if should_skip_no_code_reroute_increment(&last_error) {
2930                    tracing::warn!(
2931                        task_id = task.id.0,
2932                        last_error = %last_error,
2933                        "transient GitHub error recorded; skipping persistent no_pr_reroutes increment"
2934                    );
2935                    return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2936                        format!("transient github error: {last_error}"),
2937                    )));
2938                }
2939
2940                // Atomically increment the persistent reroute counter and decide.
2941                let reroutes = match store_increment(
2942                    &Some(Arc::clone(store)),
2943                    repo,
2944                    &task.id.0,
2945                    "no_code_reroutes",
2946                )
2947                .await
2948                {
2949                    Ok(v) => v,
2950                    Err(e) => {
2951                        tracing::warn!(task_id = task.id.0, err = %e, "failed to increment no_code_reroutes — skipping reroute/block decision this tick");
2952                        // Skip action this tick
2953                        return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2954                            format!("transient store error: {e}"),
2955                        )));
2956                    }
2957                };
2958
2959                if reroutes as u32 >= max_reroutes {
2960                    tracing::error!(
2961                        task_id = task.id.0,
2962                        reroutes,
2963                        max_reroutes,
2964                        "reached max reroute attempts for no-pr-result — blocking for human review"
2965                    );
2966                    let msg = format!(
2967                        "no PR or code changes after {}/{} reroute attempts",
2968                        reroutes, max_reroutes
2969                    );
2970                    // Write block_reason atomically with the status transition to prevent
2971                    // auto_unblock_blocked_tasks (block_reason.is_none() gate) from
2972                    // re-dispatching this task.
2973                    let fields = [
2974                        (
2975                            "block_reason",
2976                            serde_json::json!(format!(
2977                                "max reroute attempts ({}) reached — no PR or code changes produced",
2978                                max_reroutes
2979                            )),
2980                        ),
2981                        ("last_error", serde_json::json!(msg)),
2982                        ("agent", serde_json::json!(null)),
2983                        ("model", serde_json::json!(null)),
2984                    ];
2985                    if let Err(e) = task_manager
2986                        .update_task_status_and_result(&task.id, Status::Blocked, &fields)
2987                        .await
2988                    {
2989                        tracing::error!(task_id = task.id.0, err = %e, "update_task_status_and_result(Blocked) failed — skipping block to avoid silent auto-unblock loop");
2990                    }
2991                    Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Blocked(msg)))
2992                } else if task.id.0.starts_with("internal:") {
2993                    // Internal tasks with no PR or code changes are read-only/analysis
2994                    // tasks (e.g. morning briefing, evening retrospective) that
2995                    // legitimately produce no git-visible changes. Mark them done
2996                    // instead of re-routing, which would create a loop: needs_review
2997                    // → review agent → no PR/no commits → reroute to New → agent
2998                    // runs → needs_review → ...
2999                    tracing::info!(
3000                        task_id = task.id.0,
3001                        "no PR and no commits for internal task — marking done"
3002                    );
3003                    if let Err(e) = task_manager
3004                        .update_task_status(&task.id, Status::Done)
3005                        .await
3006                    {
3007                        tracing::error!(task_id = task.id.0, err = %e, "update_task_status(Done) failed — task may be stuck in InReview");
3008                    }
3009                    Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped))
3010                } else {
3011                    // Clear agent/model so router picks a different one and note the
3012                    // fact that this attempt produced no PR or code changes.
3013                    // Use store_set_result so we only transition status when the
3014                    // store write succeeds — partial failure would orphan the task.
3015                    if let Err(e) = store_set_result(
3016                        &Some(Arc::clone(store)),
3017                        repo,
3018                        &task.id.0,
3019                        &[
3020                            ("agent", serde_json::json!(null)),
3021                            ("model", serde_json::json!(null)),
3022                            (
3023                                "last_error",
3024                                serde_json::json!("no PR or code changes produced"),
3025                            ),
3026                        ],
3027                    )
3028                    .await
3029                    {
3030                        tracing::error!(task_id = task.id.0, err = %e, "failed to clear agent/model for reroute — skipping status transition");
3031                        // Don't update status — task stays in InReview and will be retried next tick
3032                    } else if let Err(e) =
3033                        task_manager.update_task_status(&task.id, Status::New).await
3034                    {
3035                        tracing::error!(task_id = task.id.0, err = %e, "update_task_status(New) failed — task may be stuck in InReview");
3036                    }
3037                    Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Rerouted))
3038                }
3039            }
3040        }
3041        Err(e) => {
3042            tracing::warn!(
3043                task_id = task.id.0,
3044                branch = %branch_name,
3045                error = %e,
3046                "failed to check PR status"
3047            );
3048            Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
3049                format!("PR check failed: {e}"),
3050            )))
3051        }
3052    }
3053}
3054
3055/// The review agent's own time limit, as configured.
3056pub(crate) fn review_timeout_seconds() -> u64 {
3057    review_timeout_secs(
3058        crate::config::get("workflow.review_timeout_seconds")
3059            .ok()
3060            .and_then(|s| s.parse().ok()),
3061    )
3062}
3063
3064/// Seconds a finished review needs to get its result from the agent's output file into the engine.
3065/// The agent ends its tmux session first, so the no-session recovery sees a dead session while the
3066/// result is still in flight
3067pub(crate) const REVIEW_RESULT_DELIVERY_SECS: u64 = 120;
3068
3069/// Smallest no-session recovery limit that cannot cut a review short. Below the review agent's own
3070/// timeout plus delivery time, recovery resets a task whose finished review is still arriving and
3071/// the result is dropped as stale
3072pub(crate) fn no_session_review_floor(configured: u64, review_timeout: u64) -> u64 {
3073    configured.max(review_timeout + REVIEW_RESULT_DELIVERY_SECS)
3074}
3075
3076/// Run the review agent on a completed task and handle the outcome.
3077///
3078/// Called after a task completes with status:done and a PR is created.
3079/// The review agent checks the changes and either approves (triggers auto-merge)
3080/// or requests changes (re-dispatches the original agent).
3081pub(crate) async fn review_and_merge(
3082    task: &ExternalTask,
3083    backend: &Arc<dyn ExternalBackend>,
3084    tmux: &Arc<TmuxManager>,
3085    repo: &str,
3086    router: &Arc<RwLock<Router>>,
3087    task_manager: &Arc<TaskManager>,
3088    store: &Arc<TaskStore>,
3089) -> anyhow::Result<ReviewDecision> {
3090    let ctx = match build_review_context(task, repo, router, task_manager, store).await? {
3091        ReviewPhase::Ready(ctx) => ctx,
3092        ReviewPhase::EarlyReturn(decision) => return Ok(decision),
3093    };
3094
3095    let run = match invoke_review_agent(task, tmux, &ctx, repo, store).await {
3096        ReviewPhase::Ready(run) => run,
3097        ReviewPhase::EarlyReturn(decision) => return Ok(decision),
3098    };
3099
3100    let parsed = match parse_review_output(task, repo, &ctx, run, store).await {
3101        ReviewPhase::Ready(parsed) => parsed,
3102        ReviewPhase::EarlyReturn(decision) => return Ok(decision),
3103    };
3104
3105    finalize_review_run(task, &ctx, &parsed, store).await;
3106
3107    let router_config = router.read().await.config.clone();
3108    apply_review_decision(
3109        task,
3110        backend,
3111        repo,
3112        task_manager,
3113        store,
3114        &ctx,
3115        &parsed,
3116        &router_config,
3117    )
3118    .await
3119}
3120
3121#[cfg(test)]
3122mod tests {
3123    use super::*;
3124
3125    #[test]
3126    fn a_review_gets_15_minutes_unless_configured_and_never_under_5() {
3127        assert_eq!(review_timeout_secs(None), 900);
3128        assert_eq!(review_timeout_secs(Some(600)), 600);
3129        assert_eq!(review_timeout_secs(Some(60)), 300);
3130        assert_eq!(review_timeout_secs(Some(3600)), 3600);
3131    }
3132
3133    use crate::backends::ExternalTask;
3134    use crate::engine::router::RouterConfig;
3135    use crate::github::types::{GitHubReview, GitHubReviewComment, GitHubUser, PullRequestReview};
3136    use crate::store::{TaskStatus, TaskStore};
3137    use tempfile::TempDir;
3138
3139    // ── outcome_for_agent_error ─────────────────────────────────────────────
3140
3141    // ── no_session_review_floor ─────────────────────────────────────────────
3142
3143    #[test]
3144    fn no_session_review_floor_raises_a_limit_under_the_review_timeout() {
3145        assert_eq!(
3146            no_session_review_floor(600, 900),
3147            900 + REVIEW_RESULT_DELIVERY_SECS
3148        );
3149    }
3150
3151    #[test]
3152    fn no_session_review_floor_keeps_a_limit_over_the_review_timeout() {
3153        assert_eq!(no_session_review_floor(1800, 900), 1800);
3154    }
3155
3156    #[test]
3157    fn no_session_review_floor_clears_the_review_timeout_by_the_delivery_margin() {
3158        for (configured, review_timeout) in [(0, 0), (1800, 1800), (120, 7200)] {
3159            let floored = no_session_review_floor(configured, review_timeout);
3160            assert!(
3161                floored >= review_timeout + REVIEW_RESULT_DELIVERY_SECS,
3162                "{floored} must leave a finished review time to deliver its result"
3163            );
3164        }
3165    }
3166
3167    #[test]
3168    fn outcome_for_agent_error_kimi_billing_cycle_is_not_rate_limit() {
3169        // Regression test for issue #3529: a review run hitting Kimi's billing-cycle
3170        // exhaustion (task 156734 in the bug report) must not be stored as a plain
3171        // "rate_limit" outcome.
3172        let err = runner::agents::AgentError::RateLimit {
3173            message: "Failed to authenticate. API Error: 403 You've reached your usage limit \
3174                      for this billing cycle. Your quota will be refreshed soon."
3175                .to_string(),
3176        };
3177        assert_eq!(outcome_for_agent_error(&err), "billing_cycle_exhausted");
3178    }
3179
3180    #[test]
3181    fn outcome_for_agent_error_generic_rate_limit_unaffected() {
3182        let err = runner::agents::AgentError::RateLimit {
3183            message: "429 Too Many Requests".to_string(),
3184        };
3185        assert_eq!(outcome_for_agent_error(&err), "rate_limit");
3186    }
3187
3188    #[test]
3189    fn outcome_for_agent_error_generic_auth_unaffected() {
3190        let err = runner::agents::AgentError::Auth {
3191            message: "401 Unauthorized".to_string(),
3192        };
3193        assert_eq!(outcome_for_agent_error(&err), "auth_error");
3194    }
3195
3196    // ── parse_pr_number_from_url ────────────────────────────────────────────
3197
3198    #[test]
3199    fn parse_pr_number_standard_url() {
3200        assert_eq!(
3201            parse_pr_number_from_url("https://github.com/owner/repo/pull/42"),
3202            Ok(42)
3203        );
3204    }
3205
3206    #[test]
3207    fn parse_pr_number_trailing_slash() {
3208        assert_eq!(
3209            parse_pr_number_from_url("https://github.com/owner/repo/pull/42/"),
3210            Ok(42)
3211        );
3212    }
3213
3214    #[test]
3215    fn parse_pr_number_with_query_string() {
3216        assert_eq!(
3217            parse_pr_number_from_url("https://github.com/owner/repo/pull/42?tab=files"),
3218            Ok(42)
3219        );
3220    }
3221
3222    #[test]
3223    fn parse_pr_number_with_hash_fragment() {
3224        assert_eq!(
3225            parse_pr_number_from_url("https://github.com/owner/repo/pull/42#issuecomment-123"),
3226            Ok(42)
3227        );
3228    }
3229
3230    #[test]
3231    fn parse_pr_number_with_query_and_hash() {
3232        assert_eq!(
3233            parse_pr_number_from_url("https://github.com/owner/repo/pull/42?quick_pull=1#top"),
3234            Ok(42)
3235        );
3236    }
3237
3238    #[test]
3239    fn parse_pr_number_with_leading_whitespace() {
3240        assert_eq!(
3241            parse_pr_number_from_url("  https://github.com/owner/repo/pull/42\n"),
3242            Ok(42)
3243        );
3244    }
3245
3246    #[test]
3247    fn parse_pr_number_wrong_domain() {
3248        let url = "https://gitlab.com/owner/repo/merge_requests/42";
3249        let result = parse_pr_number_from_url(url);
3250        assert!(result.is_err());
3251        assert!(result
3252            .unwrap_err()
3253            .contains("does not start with 'https://github.com/'"));
3254    }
3255
3256    #[test]
3257    fn parse_pr_number_missing_pull_segment() {
3258        // Issue URL — not a PR URL
3259        let url = "https://github.com/owner/repo/issues/42";
3260        let result = parse_pr_number_from_url(url);
3261        assert!(result.is_err());
3262        assert!(result.unwrap_err().contains("missing '/pull/' segment"));
3263    }
3264
3265    #[test]
3266    fn parse_pr_number_zero_is_rejected() {
3267        let url = "https://github.com/owner/repo/pull/0";
3268        let result = parse_pr_number_from_url(url);
3269        assert!(result.is_err());
3270        assert!(result.unwrap_err().contains("cannot be zero"));
3271    }
3272
3273    #[test]
3274    fn parse_pr_number_non_numeric_segment() {
3275        let url = "https://github.com/owner/repo/pull/abc";
3276        let result = parse_pr_number_from_url(url);
3277        assert!(result.is_err());
3278        assert!(result.unwrap_err().contains("not a valid positive integer"));
3279    }
3280
3281    #[test]
3282    fn parse_pr_number_empty_string() {
3283        let result = parse_pr_number_from_url("");
3284        assert!(result.is_err());
3285        assert!(result.unwrap_err().contains("empty"));
3286    }
3287
3288    #[test]
3289    fn parse_pr_number_http_not_https() {
3290        // HTTP URLs are not accepted — only HTTPS
3291        let url = "http://github.com/owner/repo/pull/42";
3292        let result = parse_pr_number_from_url(url);
3293        assert!(result.is_err());
3294        assert!(result
3295            .unwrap_err()
3296            .contains("does not start with 'https://github.com/'"));
3297    }
3298
3299    // ── format_review_error ─────────────────────────────────────────────────
3300    //
3301    // Regression coverage for issue #3149: review runs were persisting raw
3302    // `api_retry` NDJSON fragments into `task_runs.error` because the review
3303    // pipeline formatted rate-limit errors with `err.to_string()` instead of
3304    // routing the raw provider message through `summarize_rate_limit_error`.
3305
3306    #[test]
3307    fn format_review_error_summarises_rate_limit_fragment_with_api_retry() {
3308        // Real-world fragment captured from a minimax review run (issue #3149).
3309        // The provider message contains a complete api_retry JSON line so the
3310        // summariser should produce a clean `status=… after … attempts` summary.
3311        let raw_fragment = "{\"attempt\":6,\"max_retries\":10,\"retry_delay_ms\":17266.789,\"error_status\":429,\"error\":\"rate_limit\",\"session_id\":\"5c7148ee\"}\n\
3312                            {\"type\":\"system\",\"subtype\":\"api_retry\",\"attempt\":7,\"max_retries\":10,\"retry_delay_ms\":35162.66,\"error_status\":429,\"error\":\"rate_limit\",\"session_id\":\"5c7148ee\"}";
3313        let err = runner::agents::AgentError::RateLimit {
3314            message: raw_fragment.to_string(),
3315        };
3316
3317        let stored = format_review_error("minimax", &err);
3318
3319        // Must not contain raw JSON braces or the api_retry marker — these are
3320        // exactly the leaks that filled `task_runs.error` with noisy payloads.
3321        assert!(
3322            !stored.contains("{\"type\""),
3323            "stored error must not contain raw NDJSON envelope, got: {stored}"
3324        );
3325        assert!(
3326            !stored.contains("subtype"),
3327            "stored error must not contain raw api_retry fragment, got: {stored}"
3328        );
3329        assert!(
3330            !stored.contains("session_id"),
3331            "stored error must not contain session_id from provider payload, got: {stored}"
3332        );
3333        assert_eq!(
3334            stored,
3335            "minimax rate limit: status=429 after 7 attempts (last delay 35s)"
3336        );
3337    }
3338
3339    #[test]
3340    fn format_review_error_summarises_truncated_fragment_without_subtype_marker() {
3341        // Mid-object fragment that begins after the `subtype` key was cut off — this
3342        // is the shape that landed in `task_runs.error` for rows 13634/13542/9821
3343        // before the fix. The summariser's fallback path should emit a compact
3344        // hint rather than echoing the raw payload.
3345        let raw_fragment = ",\"attempt\":5,\"max_retries\":10,\"retry_delay_ms\":9034.81,\"error_status\":429,\"error\":\"rate_limit\",\"session_id\":\"992e17d3\"";
3346        let err = runner::agents::AgentError::RateLimit {
3347            message: raw_fragment.to_string(),
3348        };
3349
3350        let stored = format_review_error("glm", &err);
3351
3352        assert!(stored.starts_with("glm rate limit:"));
3353        assert!(
3354            !stored.contains("session_id"),
3355            "stored error must not leak session_id from truncated fragment, got: {stored}"
3356        );
3357        // The fragment first-line truncation path is bounded — verify the stored
3358        // string stays well under the 512-char cap suggested by the bug report.
3359        assert!(
3360            stored.len() <= 200,
3361            "stored error should be compact (<=200 chars), got {} chars: {stored}",
3362            stored.len()
3363        );
3364    }
3365
3366    #[test]
3367    fn format_review_error_preserves_non_rate_limit_variants() {
3368        let err = runner::agents::AgentError::Auth {
3369            message: "invalid bearer token".to_string(),
3370        };
3371        assert_eq!(
3372            format_review_error("kimi", &err),
3373            "kimi auth error: invalid bearer token"
3374        );
3375
3376        let err = runner::agents::AgentError::Timeout {
3377            elapsed: std::time::Duration::from_secs(1801),
3378        };
3379        assert_eq!(
3380            format_review_error("kimi", &err),
3381            "kimi timed out after 1801s"
3382        );
3383    }
3384
3385    fn git(dir: &std::path::Path, args: &[&str]) {
3386        let mut cmd = std::process::Command::new("git");
3387        cmd.current_dir(dir);
3388        // Global commit.gpgsign calls the operator's signer (1Password)
3389        if args.first() == Some(&"commit") {
3390            cmd.args(["-c", "commit.gpgsign=false"]);
3391        }
3392        let status = cmd.args(args).status().unwrap();
3393        assert!(status.success(), "git {:?} failed", args);
3394    }
3395
3396    #[test]
3397    fn test_pull_request_review_requests_changes() {
3398        let review = PullRequestReview {
3399            review: GitHubReview {
3400                id: 1,
3401                user: GitHubUser {
3402                    login: "reviewer".to_string(),
3403                },
3404                body: Some("Please fix".to_string()),
3405                state: "CHANGES_REQUESTED".to_string(),
3406                html_url: None,
3407                submitted_at: "2024-01-01T00:00:00Z".to_string(),
3408                commit_id: None,
3409            },
3410            comments: vec![],
3411        };
3412        assert!(review.requests_changes());
3413    }
3414
3415    #[test]
3416    fn test_pull_request_review_does_not_request_changes() {
3417        let review = PullRequestReview {
3418            review: GitHubReview {
3419                id: 1,
3420                user: GitHubUser {
3421                    login: "reviewer".to_string(),
3422                },
3423                body: Some("LGTM".to_string()),
3424                state: "APPROVED".to_string(),
3425                html_url: None,
3426                submitted_at: "2024-01-01T00:00:00Z".to_string(),
3427                commit_id: None,
3428            },
3429            comments: vec![],
3430        };
3431        assert!(!review.requests_changes());
3432    }
3433
3434    #[test]
3435    fn test_pull_request_review_actionable_comments_filters_empty_and_replies() {
3436        let review = PullRequestReview {
3437            review: GitHubReview {
3438                id: 1,
3439                user: GitHubUser {
3440                    login: "reviewer".to_string(),
3441                },
3442                body: None,
3443                state: "CHANGES_REQUESTED".to_string(),
3444                html_url: None,
3445                submitted_at: "2024-01-01T00:00:00Z".to_string(),
3446                commit_id: None,
3447            },
3448            comments: vec![
3449                GitHubReviewComment {
3450                    id: 1,
3451                    user: GitHubUser {
3452                        login: "reviewer".to_string(),
3453                    },
3454                    body: "Fix this issue".to_string(),
3455                    path: "src/main.rs".to_string(),
3456                    line: Some(10),
3457                    original_line: Some(10),
3458                    commit_id: "abc".to_string(),
3459                    original_commit_id: "abc".to_string(),
3460                    html_url: "url".to_string(),
3461                    created_at: "2024-01-01T00:00:00Z".to_string(),
3462                    updated_at: "2024-01-01T00:00:00Z".to_string(),
3463                    in_reply_to_id: None,
3464                    diff_hunk: Some(
3465                        "@@ -8,5 +8,5 @@ fn main() {\n-    let x = 1;\n+    let x = 2;".to_string(),
3466                    ),
3467                },
3468                GitHubReviewComment {
3469                    id: 2,
3470                    user: GitHubUser {
3471                        login: "reviewer".to_string(),
3472                    },
3473                    body: "".to_string(),
3474                    path: "src/lib.rs".to_string(),
3475                    line: Some(20),
3476                    original_line: Some(20),
3477                    commit_id: "abc".to_string(),
3478                    original_commit_id: "abc".to_string(),
3479                    html_url: "url".to_string(),
3480                    created_at: "2024-01-01T00:00:00Z".to_string(),
3481                    updated_at: "2024-01-01T00:00:00Z".to_string(),
3482                    in_reply_to_id: None,
3483                    diff_hunk: None,
3484                },
3485                GitHubReviewComment {
3486                    id: 3,
3487                    user: GitHubUser {
3488                        login: "reviewer".to_string(),
3489                    },
3490                    body: "Reply to this".to_string(),
3491                    path: "src/lib.rs".to_string(),
3492                    line: Some(30),
3493                    original_line: Some(30),
3494                    commit_id: "abc".to_string(),
3495                    original_commit_id: "abc".to_string(),
3496                    html_url: "url".to_string(),
3497                    created_at: "2024-01-01T00:00:00Z".to_string(),
3498                    updated_at: "2024-01-01T00:00:00Z".to_string(),
3499                    in_reply_to_id: Some(1),
3500                    diff_hunk: None,
3501                },
3502            ],
3503        };
3504        let actionable = review.actionable_comments();
3505        assert_eq!(actionable.len(), 1);
3506        assert_eq!(actionable[0].id, 1);
3507        assert_eq!(actionable[0].body, "Fix this issue");
3508        assert_eq!(actionable[0].path, "src/main.rs");
3509        assert_eq!(
3510            actionable[0].diff_hunk.as_ref().unwrap(),
3511            "@@ -8,5 +8,5 @@ fn main() {\n-    let x = 1;\n+    let x = 2;"
3512        );
3513    }
3514
3515    #[test]
3516    fn test_router_config_model_for_complexity_returns_none_without_config() {
3517        // With no model_map configured, model_for_complexity must return None rather than
3518        // silently using a hardcoded model that may not exist for the given agent.
3519        let cfg = RouterConfig::default();
3520        assert!(cfg.model_for_complexity("claude", "simple", "").is_none());
3521        assert!(cfg.model_for_complexity("opencode", "review", "").is_none());
3522        assert!(cfg
3523            .model_for_complexity("unknown_agent_xyz", "simple", "")
3524            .is_none());
3525    }
3526
3527    #[test]
3528    fn review_status_maps_decisions() {
3529        assert_eq!(
3530            review_status(&ReviewDecision::Approve { issues: vec![] })
3531                .unwrap()
3532                .0,
3533            "success"
3534        );
3535        let changes = ReviewDecision::RequestChanges {
3536            notes: String::new(),
3537            issues: vec![],
3538        };
3539        assert_eq!(review_status(&changes).unwrap().0, "failure");
3540        assert!(review_status(&ReviewDecision::Failed("x".into())).is_none());
3541    }
3542
3543    fn sample_review_issue(severity: &str) -> runner::response::ReviewIssue {
3544        runner::response::ReviewIssue {
3545            file: "src/engine/tick.rs".into(),
3546            line: Some(142),
3547            severity: severity.into(),
3548            description: "index out of bounds on the last element".into(),
3549        }
3550    }
3551
3552    fn sample_review_response(
3553        decision: &str,
3554        test_results: Option<&str>,
3555        issues: Vec<runner::response::ReviewIssue>,
3556    ) -> runner::response::ReviewResponse {
3557        runner::response::ReviewResponse {
3558            decision: decision.into(),
3559            notes: "looks fine".into(),
3560            test_results: test_results.map(str::to_string),
3561            issues,
3562        }
3563    }
3564
3565    #[test]
3566    fn approve_with_failing_tests_becomes_request_changes() {
3567        let decision =
3568            review_decision_from_response(sample_review_response("approve", Some("fail"), vec![]));
3569        match decision {
3570            ReviewDecision::RequestChanges { notes, issues } => {
3571                assert!(
3572                    notes.contains("test_results: fail"),
3573                    "notes must name the failing test run, got {notes:?}"
3574                );
3575                assert!(issues.is_empty());
3576            }
3577            other => panic!("expected RequestChanges, got {other:?}"),
3578        }
3579    }
3580
3581    #[test]
3582    fn approve_with_error_issue_becomes_request_changes() {
3583        let issue = sample_review_issue("error");
3584        let decision = review_decision_from_response(sample_review_response(
3585            "approve",
3586            Some("pass"),
3587            vec![issue.clone()],
3588        ));
3589        match decision {
3590            ReviewDecision::RequestChanges { issues, .. } => {
3591                assert_eq!(issues, vec![issue]);
3592            }
3593            other => panic!("expected RequestChanges, got {other:?}"),
3594        }
3595    }
3596
3597    #[test]
3598    fn approve_with_warning_issue_stays_approve_and_comment_lists_it() {
3599        let issue = sample_review_issue("warning");
3600        let decision = review_decision_from_response(sample_review_response(
3601            "approve",
3602            Some("pass"),
3603            vec![issue.clone()],
3604        ));
3605        match &decision {
3606            ReviewDecision::Approve { issues } => {
3607                assert_eq!(issues, &vec![issue.clone()]);
3608            }
3609            other => panic!("expected Approve, got {other:?}"),
3610        }
3611        let comment = build_pr_review_comment(&decision, "looks fine");
3612        assert!(
3613            comment.contains("Approve"),
3614            "approve header missing from {comment}"
3615        );
3616        assert!(
3617            comment.contains(&issue.description),
3618            "warning issue missing from {comment}"
3619        );
3620        assert!(
3621            comment.contains("warning"),
3622            "warning severity missing from {comment}"
3623        );
3624    }
3625
3626    #[test]
3627    fn review_started_comment_includes_agent_and_model() {
3628        let expected_footer = attribution_footer("Review started", "kimi", Some("opus"));
3629        let expected = format!("🔍 Automated review started{}", expected_footer);
3630        assert_eq!(review_started_comment("kimi", "opus"), expected);
3631    }
3632
3633    // ── build_review_context: no worktree/branch/pr (issue #3623) ───────────
3634
3635    fn no_code_review_task(id: &str) -> ExternalTask {
3636        ExternalTask {
3637            id: crate::backends::ExternalId(id.to_string()),
3638            title: "opencode discovery timeout".to_string(),
3639            body: "".to_string(),
3640            state: "open".to_string(),
3641            labels: vec![],
3642            author: "tester".to_string(),
3643            created_at: "2026-01-01T00:00:00Z".to_string(),
3644            updated_at: "2026-01-01T00:00:00Z".to_string(),
3645            url: "https://example.com/issues/1".to_string(),
3646        }
3647    }
3648
3649    /// An approval only carries over while the PR head is still the commit that was
3650    /// approved. A force-push from another clone moves the head, and reviewing again is
3651    /// the safe answer in every other case (#3783)
3652    #[tokio::test]
3653    async fn carried_approval_counts_as_this_rounds_decision() {
3654        let store = Arc::new(TaskStore::open_memory().await.unwrap());
3655        let id = store
3656            .create_internal("owner/repo", "Approved head", "", "test", "carried", None)
3657            .await
3658            .unwrap();
3659        let task_id = format!("internal:{id}");
3660        store
3661            .update_status(id, crate::store::TaskStatus::InReview)
3662            .await
3663            .unwrap();
3664        assert_eq!(
3665            store.in_review_entry(id).await.unwrap().map(|e| e.1),
3666            Some(false)
3667        );
3668
3669        record_carried_approval(&store, "owner/repo", &task_id, 7).await;
3670
3671        assert_eq!(
3672            store.in_review_entry(id).await.unwrap().map(|e| e.1),
3673            Some(true),
3674            "the stale in_review check must see this round as decided"
3675        );
3676    }
3677
3678    #[test]
3679    fn approval_covers_head_only_for_the_exact_commit() {
3680        assert!(approval_covers_head(Some("abc123"), Some("abc123")));
3681        assert!(
3682            !approval_covers_head(Some("abc123"), Some("def456")),
3683            "a moved PR head must be reviewed"
3684        );
3685        assert!(
3686            !approval_covers_head(None, Some("abc123")),
3687            "no recorded approval means review"
3688        );
3689        assert!(
3690            !approval_covers_head(Some("abc123"), None),
3691            "an unreadable PR head must not skip the review"
3692        );
3693        assert!(!approval_covers_head(Some(""), Some("")));
3694    }
3695
3696    /**
3697     * The approval is recorded against the commit pushed to the PR and is dropped when a
3698     * review asks for changes, so the next NeedsReview event reviews the new work (#3783)
3699     **/
3700    #[tokio::test]
3701    async fn approved_sha_round_trips_through_the_kv_store() {
3702        let temp = TempDir::new().unwrap();
3703        let wt = temp.path();
3704        git(wt, &["init", "-b", "main"]);
3705        git(wt, &["config", "user.name", "Test"]);
3706        git(wt, &["config", "user.email", "test@test.com"]);
3707        std::fs::write(wt.join("fix.txt"), "the fix").unwrap();
3708        git(wt, &["add", "."]);
3709        git(wt, &["commit", "-m", "apply the fix"]);
3710
3711        let store = Arc::new(TaskStore::open_memory().await.unwrap());
3712        let repo = "owner/repo";
3713        let task_id = "3783";
3714
3715        record_approved_sha(&store, repo, task_id, 77, wt).await;
3716        let recorded = store
3717            .kv_get(&approved_sha_key(repo, task_id, 77))
3718            .await
3719            .unwrap()
3720            .expect("approval must be recorded");
3721        assert_eq!(
3722            recorded,
3723            worktree_head_sha(wt).await.unwrap(),
3724            "the recorded sha must be the commit that was pushed to the PR"
3725        );
3726        assert!(
3727            store
3728                .kv_get(&approved_sha_key(repo, task_id, 78))
3729                .await
3730                .unwrap()
3731                .is_none(),
3732            "an approval must not leak to another PR"
3733        );
3734
3735        clear_approved_sha(&store, repo, task_id, 77).await;
3736        assert!(
3737            store
3738                .kv_get(&approved_sha_key(repo, task_id, 77))
3739                .await
3740                .unwrap()
3741                .is_none(),
3742            "requesting changes must drop the approval"
3743        );
3744    }
3745
3746    #[tokio::test]
3747    async fn build_review_context_reroutes_external_task_with_no_code() {
3748        let store = Arc::new(TaskStore::open_memory().await.unwrap());
3749        let repo = "owner/repo";
3750        let ext = no_code_review_task("3617");
3751        store.ensure_external_task(repo, &ext).await.unwrap();
3752
3753        let backend = Arc::new(crate::backends::test_helpers::NoopBackend);
3754        let task_manager = Arc::new(TaskManager::with_store(
3755            backend,
3756            Arc::clone(&store),
3757            repo.to_string(),
3758        ));
3759        let router = Arc::new(RwLock::new(Router::new(RouterConfig::default())));
3760
3761        let phase = build_review_context(&ext, repo, &router, &task_manager, &store)
3762            .await
3763            .unwrap();
3764
3765        assert!(
3766            matches!(phase, ReviewPhase::EarlyReturn(ReviewDecision::Rerouted)),
3767            "external task with no worktree/branch/pr must reroute, not skip-to-done"
3768        );
3769
3770        let stored = store
3771            .get_by_external_id(repo, &ext.id.0)
3772            .await
3773            .unwrap()
3774            .expect("task must still exist");
3775        assert_eq!(stored.status, TaskStatus::New);
3776        assert!(stored.agent.is_none());
3777        assert!(stored.model.is_none());
3778    }
3779
3780    #[tokio::test]
3781    async fn build_review_context_blocks_external_task_after_max_reroutes() {
3782        // Isolate from the real ~/.orch/config.yml (which may set a different
3783        // workflow.max_attempts) so the default of 3 reroutes is deterministic.
3784        let temp_home = TempDir::new().unwrap();
3785        let orch_dir = temp_home.path().join(".orch");
3786        std::fs::create_dir_all(&orch_dir).unwrap();
3787        let old_orch_home = std::env::var_os("ORCH_HOME");
3788        std::env::set_var("ORCH_HOME", &orch_dir);
3789
3790        let store = Arc::new(TaskStore::open_memory().await.unwrap());
3791        let repo = "owner/repo";
3792        let ext = no_code_review_task("3618");
3793        store.ensure_external_task(repo, &ext).await.unwrap();
3794
3795        let backend = Arc::new(crate::backends::test_helpers::NoopBackend);
3796        let task_manager = Arc::new(TaskManager::with_store(
3797            backend,
3798            Arc::clone(&store),
3799            repo.to_string(),
3800        ));
3801        let router = Arc::new(RwLock::new(Router::new(RouterConfig::default())));
3802
3803        // Default max_reroute_attempts is 3 — exhaust it.
3804        for _ in 0..3 {
3805            let phase = build_review_context(&ext, repo, &router, &task_manager, &store)
3806                .await
3807                .unwrap();
3808            if let ReviewPhase::EarlyReturn(ReviewDecision::Blocked(_)) = phase {
3809                break;
3810            }
3811        }
3812
3813        let stored = store
3814            .get_by_external_id(repo, &ext.id.0)
3815            .await
3816            .unwrap()
3817            .expect("task must still exist");
3818        assert_eq!(stored.status, TaskStatus::Blocked);
3819
3820        if let Some(old) = old_orch_home {
3821            std::env::set_var("ORCH_HOME", old);
3822        } else {
3823            std::env::remove_var("ORCH_HOME");
3824        }
3825    }
3826
3827    #[tokio::test]
3828    async fn build_review_context_marks_internal_task_done_with_no_code() {
3829        let store = Arc::new(TaskStore::open_memory().await.unwrap());
3830        let repo = "owner/repo";
3831        let internal_id = store
3832            .create_internal(repo, "Daily retrospective", "", "job", "job-1", None)
3833            .await
3834            .unwrap();
3835        let ext_id = format!("internal:{internal_id}");
3836        let ext = no_code_review_task(&ext_id);
3837
3838        let backend = Arc::new(crate::backends::test_helpers::NoopBackend);
3839        let task_manager = Arc::new(TaskManager::with_store(
3840            backend,
3841            Arc::clone(&store),
3842            repo.to_string(),
3843        ));
3844        let router = Arc::new(RwLock::new(Router::new(RouterConfig::default())));
3845
3846        let phase = build_review_context(&ext, repo, &router, &task_manager, &store)
3847            .await
3848            .unwrap();
3849
3850        assert!(matches!(
3851            phase,
3852            ReviewPhase::EarlyReturn(ReviewDecision::Skipped)
3853        ));
3854        let stored = store.get(internal_id).await.unwrap();
3855        assert_eq!(stored.status, TaskStatus::Done);
3856    }
3857
3858    #[test]
3859    fn no_code_reroute_skip_check_rejects_agent_stream_disconnects() {
3860        let last_error =
3861            "codex failed: Reconnecting... (stream disconnected before completion: Broken pipe)";
3862        assert!(!should_skip_no_code_reroute_increment(last_error));
3863    }
3864
3865    // ── review fallback rate-limit/auth scan is bounded, not full-text ──────
3866    //
3867    // Regression test for a review transcript whose unparseable fallback text
3868    // is a long report/diff quoting a status table (`outcome` column values
3869    // like `rate_limit`, `failed`, `timeout`). Scanning the full text falsely
3870    // classified the review run as rate-limited even though the review agent
3871    // never actually hit a rate limit.
3872
3873    #[test]
3874    fn review_fallback_scan_ignores_rate_limit_word_buried_in_long_report() {
3875        let padding = "x".repeat(10_000);
3876        let text_for_review = format!(
3877            "{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}"
3878        );
3879
3880        let scan_head = runner::agents::patterns::safe_head(
3881            &text_for_review,
3882            runner::agents::patterns::RATE_LIMIT_SCAN_HEAD_BYTES,
3883        );
3884        let scan_tail = runner::agents::patterns::safe_tail(
3885            &text_for_review,
3886            runner::agents::patterns::RATE_LIMIT_SCAN_TAIL_BYTES,
3887        );
3888
3889        assert!(runner::agents::patterns::detect_rate_limit(scan_head).is_none());
3890        assert!(runner::agents::patterns::detect_rate_limit(scan_tail).is_none());
3891    }
3892
3893    #[test]
3894    fn review_fallback_scan_still_detects_real_rate_limit_at_tail() {
3895        let padding = "x".repeat(10_000);
3896        let text_for_review =
3897            format!("{padding}\nError: rate limit exceeded, please try again later");
3898
3899        let scan_tail = runner::agents::patterns::safe_tail(
3900            &text_for_review,
3901            runner::agents::patterns::RATE_LIMIT_SCAN_TAIL_BYTES,
3902        );
3903
3904        assert!(runner::agents::patterns::detect_rate_limit(scan_tail).is_some());
3905    }
3906
3907    #[tokio::test]
3908    async fn recover_missing_review_worktree_recreates_worktree() {
3909        let temp_home = TempDir::new().unwrap();
3910        let orch_dir = temp_home.path().join(".orch");
3911        std::fs::create_dir_all(&orch_dir).unwrap();
3912        let old_orch_home = std::env::var_os("ORCH_HOME");
3913        std::env::set_var("ORCH_HOME", &orch_dir);
3914
3915        let project_dir = temp_home.path().join("project");
3916        std::fs::create_dir_all(&project_dir).unwrap();
3917        git(&project_dir, &["init", "-b", "main"]);
3918        git(&project_dir, &["config", "user.name", "Test User"]);
3919        git(&project_dir, &["config", "user.email", "test@example.com"]);
3920        std::fs::write(project_dir.join("README.md"), "hello").unwrap();
3921        git(&project_dir, &["add", "README.md"]);
3922        git(&project_dir, &["commit", "-m", "init"]);
3923
3924        std::fs::write(
3925            orch_dir.join("config.yml"),
3926            format!("projects:\n  - {}\n", project_dir.display()),
3927        )
3928        .unwrap();
3929        std::fs::write(project_dir.join(".orch.yml"), "gh:\n  repo: owner/repo\n").unwrap();
3930
3931        let store = Arc::new(TaskStore::open_memory().await.unwrap());
3932        let repo = "owner/repo";
3933        store
3934            .create_internal(repo, "Fix review recovery", "", "review", "review-1", None)
3935            .await
3936            .unwrap();
3937        let ext = ExternalTask {
3938            id: crate::backends::ExternalId("1".to_string()),
3939            title: "Fix review recovery".to_string(),
3940            body: "".to_string(),
3941            state: "open".to_string(),
3942            labels: vec![],
3943            author: "tester".to_string(),
3944            created_at: "2026-01-01T00:00:00Z".to_string(),
3945            updated_at: "2026-01-01T00:00:00Z".to_string(),
3946            url: "https://example.com/issues/1".to_string(),
3947        };
3948        store.ensure_external_task(repo, &ext).await.unwrap();
3949
3950        let recovered = recover_missing_review_worktree(&ext, repo, &store)
3951            .await
3952            .unwrap();
3953
3954        assert!(recovered.work_dir.exists());
3955        assert!(recovered.branch.starts_with("gh-issue-1-"));
3956
3957        if let Some(old) = old_orch_home {
3958            std::env::set_var("ORCH_HOME", old);
3959        } else {
3960            std::env::remove_var("ORCH_HOME");
3961        }
3962    }
3963
3964    // ── count_ahead_commits ─────────────────────────────────────────────────
3965
3966    #[tokio::test]
3967    async fn count_ahead_commits_returns_zero_when_no_ahead_commits() {
3968        let temp = TempDir::new().unwrap();
3969        let dir = temp.path();
3970
3971        // Create a bare repo to act as the 'origin' remote
3972        let bare = temp.path().join("bare.git");
3973        std::fs::create_dir_all(&bare).unwrap();
3974        std::process::Command::new("git")
3975            .args(["init", "--bare"])
3976            .current_dir(&bare)
3977            .output()
3978            .unwrap();
3979
3980        git(dir, &["init", "-b", "main"]);
3981        git(dir, &["config", "user.name", "Test"]);
3982        git(dir, &["config", "user.email", "test@test.com"]);
3983        std::fs::write(dir.join("f"), "a").unwrap();
3984        git(dir, &["add", "f"]);
3985        git(dir, &["commit", "-m", "init"]);
3986        std::process::Command::new("git")
3987            .args(["remote", "add", "origin", bare.to_str().unwrap()])
3988            .current_dir(dir)
3989            .output()
3990            .unwrap();
3991        std::process::Command::new("git")
3992            .args(["push", "origin", "main"])
3993            .current_dir(dir)
3994            .output()
3995            .unwrap();
3996
3997        // origin/main now exists and HEAD is at the same commit — zero ahead
3998        let result = count_ahead_commits(dir, "main").await;
3999        assert!(
4000            result.is_ok(),
4001            "expected Ok when origin/main exists, got {result:?}"
4002        );
4003        assert_eq!(
4004            result.unwrap(),
4005            0,
4006            "expected 0 commits ahead when branch matches origin/main"
4007        );
4008    }
4009
4010    #[tokio::test]
4011    async fn count_ahead_commits_returns_error_when_remote_ref_missing() {
4012        let temp = TempDir::new().unwrap();
4013        let dir = temp.path();
4014        git(dir, &["init", "-b", "main"]);
4015        git(dir, &["config", "user.name", "Test"]);
4016        git(dir, &["config", "user.email", "test@test.com"]);
4017        std::fs::write(dir.join("f"), "a").unwrap();
4018        git(dir, &["add", "f"]);
4019        git(dir, &["commit", "-m", "init"]);
4020        // No remote 'origin' configured — rev-list will fail
4021        let result = count_ahead_commits(dir, "main").await;
4022        assert!(
4023            result.is_err(),
4024            "expected Err when origin/main does not exist, got {result:?}"
4025        );
4026        let err = result.unwrap_err();
4027        assert!(
4028            err.contains("git rev-list exited"),
4029            "error should mention exit failure, got: {err}"
4030        );
4031    }
4032
4033    #[tokio::test]
4034    async fn count_ahead_commits_returns_error_when_git_cannot_start() {
4035        // Use a path that is not a git repo — git will fail with non-zero exit
4036        let temp = TempDir::new().unwrap();
4037        let dir = temp.path();
4038        // Not a git repo at all
4039        let result = count_ahead_commits(dir, "main").await;
4040        assert!(
4041            result.is_err(),
4042            "expected Err when directory is not a git repo, got {result:?}"
4043        );
4044    }
4045
4046    // ── review cooldown escalation (issue #3314) ────────────────────────────
4047
4048    /// Review `InvalidResponse` applies the short review output backoff: 5 minutes first
4049    #[serial_test::serial(cooldown_state)]
4050    #[tokio::test]
4051    async fn review_invalid_response_applies_short_model_cooldown() {
4052        crate::engine::cooldown::reset_global_state().await;
4053        let agent = "opencode-3314-review-invalid";
4054        let model = "opencode/north-mini-code-free";
4055        let key = format!("{agent}:{model}");
4056
4057        assert!(
4058            !crate::engine::cooldown::is_model_in_cooldown(agent, model),
4059            "model should not be in cooldown before record_review_agent_failure"
4060        );
4061
4062        let err = runner::agents::AgentError::InvalidResponse {
4063            raw: "empty review output".to_string(),
4064        };
4065        record_review_agent_failure("test-3314-a", agent, Some(model), &err).await;
4066
4067        let now = chrono::Utc::now().timestamp();
4068        let until = crate::engine::cooldown::cooldown_until(&key)
4069            .expect("review InvalidResponse should set model cooldown");
4070        let remaining = until.saturating_sub(now);
4071        let base = crate::engine::cooldown::TRANSIENT_MODEL_BACKOFF_BASE_SECS;
4072        assert!(
4073            (base - 30..=base).contains(&remaining),
4074            "first unusable review output must cool the model for 5 minutes, got {remaining}s"
4075        );
4076    }
4077
4078    /// Repeated unusable review output escalates to 15 minutes and stays there
4079    #[serial_test::serial(cooldown_state)]
4080    #[tokio::test]
4081    async fn review_output_cooldown_caps_at_fifteen_minutes() {
4082        crate::engine::cooldown::reset_global_state().await;
4083        let store = Arc::new(TaskStore::open_memory().await.unwrap());
4084        crate::engine::cooldown::init_cooldown_store(store).await;
4085        let agent = "opencode-review-output-cap";
4086        let model = "opencode/north-mini-code-free";
4087        let key = format!("{agent}:{model}");
4088        let base = crate::engine::cooldown::TRANSIENT_MODEL_BACKOFF_BASE_SECS;
4089        let max = crate::engine::cooldown::TRANSIENT_MODEL_BACKOFF_MAX_SECS;
4090
4091        for (i, expected) in [base, max, max].into_iter().enumerate() {
4092            let err = runner::agents::AgentError::InvalidResponse {
4093                raw: format!("failed to parse review response {i}"),
4094            };
4095            record_review_agent_failure("test-review-output-cap", agent, Some(model), &err).await;
4096            let now = chrono::Utc::now().timestamp();
4097            let remaining = crate::engine::cooldown::cooldown_until(&key)
4098                .expect("unusable review output should set model cooldown")
4099                .saturating_sub(now);
4100            assert!(
4101                (expected - 30..=expected).contains(&remaining),
4102                "failure {} should cool the model for {expected}s, got {remaining}s",
4103                i + 1
4104            );
4105        }
4106    }
4107
4108    /// Regression test for issue #3551: a billing-cycle-exhaustion message caught by
4109    /// the review module's *inline text scan* (`detect_rate_limit` on raw review
4110    /// output, used when the review process exits without a clean `AgentError`) must
4111    /// get the same treatment as the `AgentError`-based failure path fixed by #3530 —
4112    /// not a hardcoded `"rate_limit"` outcome plus a generic short agent cooldown.
4113    #[serial_test::serial(cooldown_state)]
4114    #[tokio::test]
4115    async fn review_inline_scan_billing_cycle_message_gets_model_scoped_cooldown() {
4116        crate::engine::cooldown::reset_global_state().await;
4117        let agent = "kimi-3551-review-inline";
4118        let model = "opus";
4119        let key = format!("{agent}:{model}");
4120
4121        // Verbatim text from the observed kimi failure (issue #3551).
4122        let text_for_review = "kimi rate limit: Failed to authenticate. API Error: 403 \
4123            You've reached your usage limit for this billing cycle. Your quota will be \
4124            refreshed next cycle.";
4125
4126        let scan_tail = runner::agents::patterns::safe_tail(
4127            text_for_review,
4128            runner::agents::patterns::RATE_LIMIT_SCAN_TAIL_BYTES,
4129        );
4130        let err = runner::agents::patterns::detect_rate_limit(scan_tail)
4131            .expect("billing-cycle message must still be caught by the rate-limit scan");
4132
4133        assert_eq!(
4134            outcome_for_agent_error(&err),
4135            "billing_cycle_exhausted",
4136            "inline-scanned billing-cycle message must not be stored as plain rate_limit"
4137        );
4138
4139        record_review_agent_failure("test-3551", agent, Some(model), &err).await;
4140
4141        let now = chrono::Utc::now().timestamp();
4142        let until = crate::engine::cooldown::cooldown_until(&key)
4143            .expect("billing-cycle exhaustion must set a model-scoped cooldown");
4144        let remaining = until.saturating_sub(now);
4145        // Known model + BillingCycleExhausted routes to record_persistent_model_failure
4146        // (~4h base, 7d cap), not the agent-wide record_credit_exhaustion (~24h) path —
4147        // the quota is scoped to this model, so unrelated models on the same agent must
4148        // stay routable.
4149        assert!(
4150            remaining >= crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
4151            "billing-cycle exhaustion for a known model must apply the ~4h persistent \
4152             model cooldown, got {remaining}s — may have used the generic short agent \
4153             cooldown instead"
4154        );
4155    }
4156
4157    // ── review success resets failure_count (issue #3478) ──────────────────
4158
4159    fn test_review_task(id: &str) -> ExternalTask {
4160        ExternalTask {
4161            id: crate::backends::ExternalId(id.to_string()),
4162            title: "Test review task".to_string(),
4163            body: "".to_string(),
4164            state: "open".to_string(),
4165            labels: vec![],
4166            author: "tester".to_string(),
4167            created_at: "2026-01-01T00:00:00Z".to_string(),
4168            updated_at: "2026-01-01T00:00:00Z".to_string(),
4169            url: "https://example.com/issues/1".to_string(),
4170        }
4171    }
4172
4173    fn test_review_context(agent: &str, model: &str, work_dir: &std::path::Path) -> ReviewContext {
4174        ReviewContext {
4175            store_id: None,
4176            had_prev_push_failure: false,
4177            review_attempt: 1,
4178            worktree_path: work_dir.to_path_buf(),
4179            branch_name: "gh-issue-1-test".to_string(),
4180            pr_number: 1,
4181            head_sha: None,
4182            default_branch: "main".to_string(),
4183            review_agent: agent.to_string(),
4184            review_model: Some(model.to_string()),
4185            review_task_id: "review-1".to_string(),
4186            review_attempt_dir: work_dir.to_path_buf(),
4187            output_file: work_dir.join("output.txt"),
4188            invocation: runner::agent::AgentInvocation {
4189                agent: agent.to_string(),
4190                model: Some(model.to_string()),
4191                work_dir: work_dir.to_path_buf(),
4192                system_prompt: String::new(),
4193                agent_message: String::new(),
4194                task_id: "review-1".to_string(),
4195                disallowed_tools: vec![],
4196                git_author_name: "Test".to_string(),
4197                git_author_email: "test@example.com".to_string(),
4198                output_file: work_dir.join("output.txt"),
4199                timeout_seconds: 0,
4200                repo: "owner/repo".to_string(),
4201                attempt: 1,
4202                sandbox: false,
4203                main_project_dir: work_dir.to_path_buf(),
4204                branch: String::new(),
4205            },
4206            same_agent_fallback: false,
4207        }
4208    }
4209
4210    fn test_parsed_review(decision: ReviewDecision) -> ParsedReview {
4211        ParsedReview {
4212            run_id: None,
4213            exit_code: 0,
4214            stderr: String::new(),
4215            raw_output: String::new(),
4216            text_for_review: String::new(),
4217            token_usage: RunTokenUsage::default(),
4218            review_notes_for_comment: String::new(),
4219            decision,
4220        }
4221    }
4222
4223    fn write_review_output(dir: &std::path::Path, stdout: &str, exit_code: i32) {
4224        std::fs::write(dir.join("output.txt"), stdout).unwrap();
4225        std::fs::write(dir.join("exit.txt"), exit_code.to_string()).unwrap();
4226        std::fs::write(dir.join("stderr.txt"), "").unwrap();
4227    }
4228
4229    /// A kimi review that exits mid-thought (no result envelope, last content is a
4230    /// thinking block) must be stored as incomplete and must not apply the 4h
4231    /// persistent model cooldown (issue #3734).
4232    #[serial_test::serial(cooldown_state)]
4233    #[tokio::test]
4234    async fn parse_review_output_mid_thought_is_incomplete_without_persistent_cooldown() {
4235        crate::engine::cooldown::reset_global_state().await;
4236        let store = Arc::new(TaskStore::open_memory().await.unwrap());
4237        let work_dir = TempDir::new().unwrap();
4238        let agent = "kimi";
4239        let model = "opus-3734-mid-thought";
4240        let ctx = test_review_context(agent, model, work_dir.path());
4241        let redraw = "^D\u{0008}".repeat(20);
4242        let ndjson = format!(
4243            "{}\n{}\n{}\n{}\n{}",
4244            r#"{"type":"system","subtype":"thinking_tokens","tokens":1945}"#,
4245            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}}}"#,
4246            r#"{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"checking lease edge cases"}],"stop_reason":null,"usage":{"input_tokens":10,"output_tokens":0}}}"#,
4247            "\x1b[?25h",
4248            redraw,
4249        );
4250        write_review_output(work_dir.path(), &ndjson, 0);
4251
4252        let task = test_review_task("3734");
4253        let phase = parse_review_output(
4254            &task,
4255            "owner/repo",
4256            &ctx,
4257            ReviewRun { run_id: None },
4258            &store,
4259        )
4260        .await;
4261
4262        match phase {
4263            ReviewPhase::EarlyReturn(ReviewDecision::Failed(reason)) => {
4264                assert!(
4265                    reason.contains("incomplete:"),
4266                    "mid-thought review must be stored as incomplete, got {reason}"
4267                );
4268            }
4269            ReviewPhase::EarlyReturn(_) => panic!("expected Failed(incomplete)"),
4270            ReviewPhase::Ready(_) => panic!("expected Failed(incomplete), got Ready"),
4271        }
4272        assert!(
4273            !crate::engine::cooldown::is_model_in_cooldown(agent, model),
4274            "mid-thought incomplete must not apply persistent model cooldown"
4275        );
4276    }
4277
4278    /// A tool_use as the last assistant content with no result envelope is the
4279    /// same incomplete shape as a thinking block (issue #3734).
4280    #[serial_test::serial(cooldown_state)]
4281    #[tokio::test]
4282    async fn parse_review_output_tool_use_without_envelope_is_incomplete() {
4283        crate::engine::cooldown::reset_global_state().await;
4284        let store = Arc::new(TaskStore::open_memory().await.unwrap());
4285        let work_dir = TempDir::new().unwrap();
4286        let agent = "kimi";
4287        let model = "opus-3734-tool-use";
4288        let ctx = test_review_context(agent, model, work_dir.path());
4289        let ndjson = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Read","input":{"file_path":"src/lib.rs"}}]}}"#;
4290        write_review_output(work_dir.path(), ndjson, 0);
4291
4292        let task = test_review_task("3734-tool");
4293        let phase = parse_review_output(
4294            &task,
4295            "owner/repo",
4296            &ctx,
4297            ReviewRun { run_id: None },
4298            &store,
4299        )
4300        .await;
4301
4302        match phase {
4303            ReviewPhase::EarlyReturn(ReviewDecision::Failed(reason)) => {
4304                assert!(
4305                    reason.contains("incomplete:"),
4306                    "tool_use without envelope must be incomplete, got {reason}"
4307                );
4308            }
4309            ReviewPhase::EarlyReturn(_) => panic!("expected Failed(incomplete)"),
4310            ReviewPhase::Ready(_) => panic!("expected Failed(incomplete), got Ready"),
4311        }
4312        assert!(!crate::engine::cooldown::is_model_in_cooldown(agent, model));
4313    }
4314
4315    /// A finished result envelope whose text is not a review decision still
4316    /// gets the short format-failure cooldown.
4317    #[serial_test::serial(cooldown_state)]
4318    #[tokio::test]
4319    async fn parse_review_output_unparseable_envelope_applies_short_cooldown() {
4320        crate::engine::cooldown::reset_global_state().await;
4321        let store = Arc::new(TaskStore::open_memory().await.unwrap());
4322        let work_dir = TempDir::new().unwrap();
4323        let agent = "kimi";
4324        let model = "opus-3734-format";
4325        let ctx = test_review_context(agent, model, work_dir.path());
4326        let ndjson = concat!(
4327            r#"{"type":"assistant","message":{"content":[{"type":"text","text":"I have finished looking at the code."}]}}"#,
4328            "\n",
4329            r#"{"type":"result","subtype":"success","is_error":false,"result":"I have finished looking at the code."}"#,
4330        );
4331        write_review_output(work_dir.path(), ndjson, 0);
4332
4333        let task = test_review_task("3734-format");
4334        let phase = parse_review_output(
4335            &task,
4336            "owner/repo",
4337            &ctx,
4338            ReviewRun { run_id: None },
4339            &store,
4340        )
4341        .await;
4342
4343        match phase {
4344            ReviewPhase::EarlyReturn(ReviewDecision::Failed(reason)) => {
4345                assert!(
4346                    reason.contains("parse error"),
4347                    "finished unparseable envelope must stay a parse error, got {reason}"
4348                );
4349            }
4350            ReviewPhase::EarlyReturn(_) => panic!("expected Failed(parse error)"),
4351            ReviewPhase::Ready(_) => panic!("expected Failed(parse error), got Ready"),
4352        }
4353        let now = chrono::Utc::now().timestamp();
4354        let remaining = crate::engine::cooldown::cooldown_until(&format!("{agent}:{model}"))
4355            .expect("finished format failure must apply a model cooldown")
4356            .saturating_sub(now);
4357        assert!(
4358            remaining <= crate::engine::cooldown::TRANSIENT_MODEL_BACKOFF_BASE_SECS,
4359            "finished format failure must apply the 5 minute cooldown, got {remaining}s"
4360        );
4361    }
4362
4363    /// Missing result envelope with a review decision in assistant text uses
4364    /// the assistant-text extractor, not MiniMax status=done synthesis.
4365    #[serial_test::serial(cooldown_state)]
4366    #[tokio::test]
4367    async fn parse_review_output_missing_envelope_uses_assistant_decision() {
4368        crate::engine::cooldown::reset_global_state().await;
4369        let store = Arc::new(TaskStore::open_memory().await.unwrap());
4370        let work_dir = TempDir::new().unwrap();
4371        let agent = "kimi";
4372        let model = "opus-3734-assistant";
4373        let ctx = test_review_context(agent, model, work_dir.path());
4374        let ndjson = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"issues\":[]}"}]}}"#;
4375        write_review_output(work_dir.path(), ndjson, 0);
4376
4377        let task = test_review_task("3734-asst");
4378        let phase = parse_review_output(
4379            &task,
4380            "owner/repo",
4381            &ctx,
4382            ReviewRun { run_id: None },
4383            &store,
4384        )
4385        .await;
4386
4387        match phase {
4388            ReviewPhase::Ready(parsed) => {
4389                assert!(matches!(parsed.decision, ReviewDecision::Approve { .. }));
4390            }
4391            ReviewPhase::EarlyReturn(_) => {
4392                panic!("expected Ready(Approve) from assistant text, got EarlyReturn")
4393            }
4394        }
4395        assert!(!crate::engine::cooldown::is_model_in_cooldown(agent, model));
4396    }
4397
4398    /// MiniMax/Kimi parse_response synthesizes status=done from any assistant
4399    /// text. That task-path fallback must not be treated as a review approval.
4400    #[serial_test::serial(cooldown_state)]
4401    #[tokio::test]
4402    async fn parse_review_output_does_not_treat_task_done_synthesis_as_approval() {
4403        crate::engine::cooldown::reset_global_state().await;
4404        let store = Arc::new(TaskStore::open_memory().await.unwrap());
4405        let work_dir = TempDir::new().unwrap();
4406        let agent = "kimi";
4407        let model = "opus-3734-done";
4408        let ctx = test_review_context(agent, model, work_dir.path());
4409        let ndjson = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"analyzing the diff"}]}}"#;
4410        write_review_output(work_dir.path(), ndjson, 0);
4411
4412        let task = test_review_task("3734-done");
4413        let phase = parse_review_output(
4414            &task,
4415            "owner/repo",
4416            &ctx,
4417            ReviewRun { run_id: None },
4418            &store,
4419        )
4420        .await;
4421
4422        match phase {
4423            ReviewPhase::EarlyReturn(ReviewDecision::Failed(reason)) => {
4424                assert!(
4425                    reason.contains("parse error"),
4426                    "unfinished prose without a decision must not approve, got {reason}"
4427                );
4428            }
4429            ReviewPhase::Ready(_) => {
4430                panic!("task-path status=done synthesis must not become a review approval")
4431            }
4432            ReviewPhase::EarlyReturn(_) => panic!("expected Failed(parse error)"),
4433        }
4434    }
4435
4436    /// Regression test for issue #3478: a successful review run must reset
4437    /// `failure_count:{agent}:{model}`, mirroring the runner's task-agent
4438    /// success path, for generic/transient failures (not the persistent-model
4439    /// path — see issue #3571, which deliberately gives persistent failures
4440    /// their own counter that a success does NOT reset).
4441    #[serial_test::serial(cooldown_state)]
4442    #[tokio::test]
4443    async fn finalize_review_run_success_resets_generic_model_failure_count() {
4444        crate::engine::cooldown::reset_global_state().await;
4445        let store = Arc::new(TaskStore::open_memory().await.unwrap());
4446        crate::engine::cooldown::init_cooldown_store(store.clone()).await;
4447
4448        let agent = "test-3478-agent";
4449        let model = "test-3478-model";
4450        let failure_key = format!("failure_count:{agent}:{model}");
4451
4452        // AgentFailed is a generic/transient error, routed to record_model_failure
4453        // (not record_persistent_model_failure), so it should still be reset by
4454        // an intervening success.
4455        let err = runner::agents::AgentError::AgentFailed {
4456            message: "agent reported a generic failure".to_string(),
4457        };
4458        record_review_agent_failure("task-3478-a", agent, Some(model), &err).await;
4459        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4460        assert_eq!(
4461            store.kv_get(&failure_key).await.unwrap().as_deref(),
4462            Some("1"),
4463            "sanity check: failure should have incremented the persisted count"
4464        );
4465
4466        let work_dir = TempDir::new().unwrap();
4467        let ctx = test_review_context(agent, model, work_dir.path());
4468        let parsed = test_parsed_review(ReviewDecision::Approve { issues: vec![] });
4469        let task = test_review_task("1");
4470
4471        finalize_review_run(&task, &ctx, &parsed, &store).await;
4472        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4473
4474        assert_eq!(
4475            store.kv_get(&failure_key).await.unwrap().as_deref(),
4476            Some("0"),
4477            "successful review must reset failure_count:{{agent}}:{{model}} (issue #3478)"
4478        );
4479    }
4480
4481    /// Regression test for issue #3478: successful reviews interleaved between
4482    /// isolated generic failures must keep each failure's backoff at the base
4483    /// duration instead of escalating, since the previous success reset the
4484    /// counter. Uses AgentFailed (generic/transient), not ModelUnavailable
4485    /// (persistent-model path, see issue #3571).
4486    #[serial_test::serial(cooldown_state)]
4487    #[tokio::test]
4488    async fn finalize_review_run_interleaved_success_keeps_generic_cooldown_at_base() {
4489        crate::engine::cooldown::reset_global_state().await;
4490        let store = Arc::new(TaskStore::open_memory().await.unwrap());
4491        crate::engine::cooldown::init_cooldown_store(store.clone()).await;
4492
4493        let agent = "test-3478-interleave-agent";
4494        let model = "test-3478-interleave-model";
4495        let cooldown_key = format!("{agent}:{model}");
4496
4497        let work_dir = TempDir::new().unwrap();
4498        let ctx = test_review_context(agent, model, work_dir.path());
4499        let task = test_review_task("2");
4500
4501        for i in 0..4 {
4502            let err = runner::agents::AgentError::AgentFailed {
4503                message: format!("failure {i}"),
4504            };
4505            record_review_agent_failure("task-3478-b", agent, Some(model), &err).await;
4506
4507            let now = chrono::Utc::now().timestamp();
4508            let until = crate::engine::cooldown::cooldown_until(&cooldown_key)
4509                .expect("failure should set cooldown");
4510            let remaining = until.saturating_sub(now);
4511
4512            assert!(
4513                remaining <= crate::engine::cooldown::BACKOFF_BASE_SECS + 60,
4514                "failure #{} must stay at base backoff since the prior success reset the \
4515                 counter (issue #3478); got {remaining}s, base is {}s — an escalating value \
4516                 here means review successes are not resetting failure_count",
4517                i + 1,
4518                crate::engine::cooldown::BACKOFF_BASE_SECS
4519            );
4520
4521            // A successful review interleaves between each failure, mirroring the
4522            // real-world pattern from issue #3478 (reviews for other tasks succeed
4523            // in between isolated failures for this model).
4524            let parsed = test_parsed_review(ReviewDecision::Approve { issues: vec![] });
4525            finalize_review_run(&task, &ctx, &parsed, &store).await;
4526            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
4527        }
4528    }
4529
4530    /// Regression test for issue #3571: unlike generic failures, persistent
4531    /// model failures (e.g. ModelUnavailable) must NOT be
4532    /// reset by an intervening successful review. Otherwise a model that fails
4533    /// structurally every so often — but succeeds most of the time — never
4534    /// escalates past the 4h base cooldown and stays in rotation indefinitely.
4535    #[serial_test::serial(cooldown_state)]
4536    #[tokio::test]
4537    async fn finalize_review_run_success_does_not_reset_persistent_model_failure_count() {
4538        crate::engine::cooldown::reset_global_state().await;
4539        let store = Arc::new(TaskStore::open_memory().await.unwrap());
4540        crate::engine::cooldown::init_cooldown_store(store.clone()).await;
4541
4542        let agent = "test-3571-agent";
4543        let model = "test-3571-model";
4544        let cooldown_key = format!("{agent}:{model}");
4545
4546        let work_dir = TempDir::new().unwrap();
4547        let ctx = test_review_context(agent, model, work_dir.path());
4548        let task = test_review_task("3");
4549
4550        // First persistent model failure -> base 4h cooldown.
4551        let err = runner::agents::AgentError::ModelUnavailable {
4552            message: "model not found".to_string(),
4553            model: model.to_string(),
4554        };
4555        record_review_agent_failure("task-3571-a", agent, Some(model), &err).await;
4556        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
4557
4558        // A successful review for the same model interleaves, exactly like the
4559        // real-world pattern from issue #3571.
4560        let parsed = test_parsed_review(ReviewDecision::Approve { issues: vec![] });
4561        finalize_review_run(&task, &ctx, &parsed, &store).await;
4562        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
4563
4564        // Second persistent failure must escalate from count 2 (~12h), not
4565        // reset back to the 4h base, despite the intervening success.
4566        let err2 = runner::agents::AgentError::ModelUnavailable {
4567            message: "model not found again".to_string(),
4568            model: model.to_string(),
4569        };
4570        record_review_agent_failure("task-3571-b", agent, Some(model), &err2).await;
4571        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
4572
4573        let now = chrono::Utc::now().timestamp();
4574        let until = crate::engine::cooldown::cooldown_until(&cooldown_key)
4575            .expect("second failure should set cooldown");
4576        let remaining = until.saturating_sub(now);
4577
4578        assert!(
4579            remaining > crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS,
4580            "second persistent model failure should escalate past the 4h base despite an \
4581             intervening success (issue #3571); got {remaining}s, base is {}s",
4582            crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS
4583        );
4584    }
4585
4586    fn app_for_test(server: &wiremock::MockServer) -> Arc<crate::github::app::AppAuth> {
4587        Arc::new(crate::github::app::AppAuth::new(
4588            "12345".to_string(),
4589            crate::github::app::test_pem().to_string(),
4590            server.uri(),
4591        ))
4592    }
4593
4594    async fn mount_installed(server: &wiremock::MockServer, repo: &str) {
4595        use wiremock::matchers::{method, path};
4596        use wiremock::{Mock, ResponseTemplate};
4597        Mock::given(method("GET"))
4598            .and(path(format!("/repos/{repo}/installation")))
4599            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 42})))
4600            .mount(server)
4601            .await;
4602        Mock::given(method("POST"))
4603            .and(path("/app/installations/42/access_tokens"))
4604            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
4605                "token": "inst-1",
4606                "expires_at": "2099-01-01T00:00:00Z",
4607            })))
4608            .mount(server)
4609            .await;
4610    }
4611
4612    #[tokio::test]
4613    async fn app_mode_falls_back_to_comments_where_the_app_is_not_installed() {
4614        use crate::review_mode::ReviewMode;
4615        use wiremock::matchers::{method, path};
4616        use wiremock::{Mock, MockServer, ResponseTemplate};
4617        let server = MockServer::start().await;
4618        Mock::given(method("GET"))
4619            .and(path("/repos/org/b/installation"))
4620            .respond_with(ResponseTemplate::new(404))
4621            .expect(1)
4622            .mount(&server)
4623            .await;
4624        let app = app_for_test(&server);
4625
4626        assert!(
4627            review_app_for(ReviewMode::App, Some(Arc::clone(&app)), "org/b")
4628                .await
4629                .is_none()
4630        );
4631        // Comment mode never asks GitHub, the expect(1) above counts the only lookup
4632        assert!(
4633            review_app_for(ReviewMode::Comment, Some(Arc::clone(&app)), "org/b")
4634                .await
4635                .is_none()
4636        );
4637        assert!(review_app_for(ReviewMode::App, None, "org/b")
4638            .await
4639            .is_none());
4640    }
4641
4642    #[tokio::test]
4643    async fn the_check_run_starts_and_completes_on_the_reviewed_sha() {
4644        use wiremock::matchers::{body_partial_json, method, path};
4645        use wiremock::{Mock, MockServer, ResponseTemplate};
4646        let server = MockServer::start().await;
4647        mount_installed(&server, "o/r").await;
4648        Mock::given(method("POST"))
4649            .and(path("/repos/o/r/check-runs"))
4650            .and(body_partial_json(serde_json::json!({
4651                "name": "orch/review", "head_sha": "abc123", "status": "in_progress",
4652            })))
4653            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 1})))
4654            .expect(1)
4655            .mount(&server)
4656            .await;
4657        Mock::given(method("POST"))
4658            .and(path("/repos/o/r/check-runs"))
4659            .and(body_partial_json(serde_json::json!({
4660                "name": "orch/review", "head_sha": "abc123", "status": "completed",
4661                "conclusion": "failure", "output": {"summary": "fix it"},
4662            })))
4663            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 2})))
4664            .expect(1)
4665            .mount(&server)
4666            .await;
4667        let app = app_for_test(&server);
4668        let gh = GhHttp::for_test(server.uri(), Some(Arc::clone(&app)));
4669        let decision = ReviewDecision::RequestChanges {
4670            notes: "fix it".to_string(),
4671            issues: vec![],
4672        };
4673
4674        post_review_started(Some(&app), &gh, "o/r", Some("abc123")).await;
4675        post_review_decision(Some(&app), &gh, "o/r", Some("abc123"), &decision, "fix it").await;
4676        let requests = server.received_requests().await.expect("requests");
4677        assert!(requests
4678            .iter()
4679            .all(|req| !req.url.path().contains("/statuses/")));
4680    }
4681
4682    #[tokio::test]
4683    async fn issues_on_diff_lines_go_inline_and_the_rest_stay_in_the_body() {
4684        use crate::engine::runner::response::ReviewIssue;
4685        use wiremock::matchers::{method, path, query_param};
4686        use wiremock::{Mock, MockServer, ResponseTemplate};
4687        let server = MockServer::start().await;
4688        mount_installed(&server, "o/r").await;
4689        Mock::given(method("GET"))
4690            .and(path("/repos/o/r/pulls/9/files"))
4691            .and(query_param("page", "1"))
4692            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
4693                {"filename": "src/a.rs", "patch": "@@ -1,2 +10,3 @@\n ctx\n+new\n+new"},
4694            ])))
4695            .expect(1)
4696            .mount(&server)
4697            .await;
4698        Mock::given(method("POST"))
4699            .and(path("/repos/o/r/pulls/9/reviews"))
4700            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 1})))
4701            .expect(1)
4702            .mount(&server)
4703            .await;
4704        Mock::given(method("POST"))
4705            .and(path("/repos/o/r/issues/9/comments"))
4706            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 2})))
4707            .mount(&server)
4708            .await;
4709        let issue = |file: &str, line: Option<u32>| ReviewIssue {
4710            file: file.to_string(),
4711            line,
4712            severity: "error".to_string(),
4713            description: "wrong".to_string(),
4714        };
4715        let decision = ReviewDecision::RequestChanges {
4716            notes: "fix".to_string(),
4717            issues: vec![
4718                issue("src/a.rs", Some(11)),
4719                issue("src/a.rs", Some(50)),
4720                issue("src/b.rs", Some(3)),
4721                issue("src/a.rs", None),
4722            ],
4723        };
4724        let app = app_for_test(&server);
4725        let gh = GhHttp::for_test(server.uri(), Some(Arc::clone(&app)));
4726
4727        publish_review(Some(&app), &gh, "o/r", 9, Some("abc123"), &decision, "body")
4728            .await
4729            .expect("comment posted");
4730        let requests = server.received_requests().await.expect("requests");
4731        let review = requests
4732            .iter()
4733            .find(|req| req.url.path().ends_with("/reviews"))
4734            .expect("review submitted");
4735        let body: serde_json::Value = serde_json::from_slice(&review.body).unwrap();
4736        assert_eq!(
4737            body["comments"],
4738            serde_json::json!([{"path": "src/a.rs", "line": 11, "side": "RIGHT", "body": "**error:** wrong"}])
4739        );
4740    }
4741
4742    #[allow(clippy::await_holding_lock)]
4743    #[tokio::test]
4744    async fn without_an_app_the_review_path_posts_only_the_comment() {
4745        use wiremock::matchers::{method, path};
4746        use wiremock::{Mock, MockServer, ResponseTemplate};
4747        let _env = crate::github::token::ENV_LOCK.lock().unwrap();
4748        let prev = std::env::var_os("GH_TOKEN");
4749        std::env::set_var("GH_TOKEN", "owner-token");
4750        let server = MockServer::start().await;
4751        Mock::given(method("POST"))
4752            .and(path("/repos/o/r/issues/9/comments"))
4753            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 1})))
4754            .expect(1)
4755            .mount(&server)
4756            .await;
4757        let gh = GhHttp::for_test(server.uri(), None);
4758        let result = publish_review(
4759            None,
4760            &gh,
4761            "o/r",
4762            9,
4763            Some("abc123"),
4764            &ReviewDecision::Approve { issues: vec![] },
4765            "## Automated Review \u{2014} Approve\n\nok",
4766        )
4767        .await;
4768        match prev {
4769            Some(value) => std::env::set_var("GH_TOKEN", value),
4770            None => std::env::remove_var("GH_TOKEN"),
4771        }
4772        result.expect("comment posted");
4773        let requests = server.received_requests().await.expect("requests");
4774        assert!(requests
4775            .iter()
4776            .all(|req| !req.url.path().contains("/reviews")));
4777    }
4778
4779    #[tokio::test]
4780    async fn with_an_app_the_review_is_submitted_on_the_reviewed_sha() {
4781        use wiremock::matchers::{body_partial_json, method, path};
4782        use wiremock::{Mock, MockServer, ResponseTemplate};
4783        let server = MockServer::start().await;
4784        Mock::given(method("POST"))
4785            .and(path("/app/installations/42/access_tokens"))
4786            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
4787                "token": "inst-1",
4788                "expires_at": "2099-01-01T00:00:00Z",
4789            })))
4790            .mount(&server)
4791            .await;
4792        Mock::given(method("POST"))
4793            .and(path("/repos/o/r/pulls/9/reviews"))
4794            .and(body_partial_json(serde_json::json!({
4795                "event": "REQUEST_CHANGES",
4796                "commit_id": "abc123",
4797            })))
4798            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 1})))
4799            .expect(1)
4800            .mount(&server)
4801            .await;
4802        Mock::given(method("POST"))
4803            .and(path("/repos/o/r/issues/9/comments"))
4804            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 2})))
4805            .expect(1)
4806            .mount(&server)
4807            .await;
4808        Mock::given(method("GET"))
4809            .and(path("/repos/o/r/installation"))
4810            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 42})))
4811            .mount(&server)
4812            .await;
4813        let app = Arc::new(crate::github::app::AppAuth::new(
4814            "12345".to_string(),
4815            crate::github::app::test_pem().to_string(),
4816            server.uri(),
4817        ));
4818        let gh = GhHttp::for_test(server.uri(), Some(Arc::clone(&app)));
4819        let decision = ReviewDecision::RequestChanges {
4820            notes: "fix it".to_string(),
4821            issues: vec![],
4822        };
4823        publish_review(
4824            Some(&app),
4825            &gh,
4826            "o/r",
4827            9,
4828            Some("abc123"),
4829            &decision,
4830            "## Automated Review \u{2014} Changes Requested\n\nfix it",
4831        )
4832        .await
4833        .expect("comment posted");
4834    }
4835}