Skip to main content

orch/engine/
review.rs

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