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