Skip to main content

orch/engine/
review.rs

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