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