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