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