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            post_review_status(
949                &gh,
950                repo,
951                ctx.head_sha.as_deref(),
952                "pending",
953                "Automated review running",
954            )
955            .await;
956            if let Err(e) = gh
957                .add_comment(repo, &ctx.pr_number.to_string(), &start_comment)
958                .await
959            {
960                tracing::debug!(
961                    task_id = task.id.0,
962                    pr_number = ctx.pr_number,
963                    error = %e,
964                    "failed to post review-started comment"
965                );
966            }
967        }
968        Err(e) => {
969            tracing::debug!(
970                task_id = task.id.0,
971                pr_number = ctx.pr_number,
972                error = %e,
973                "failed to create GitHub client for review-started comment"
974            );
975        }
976    }
977
978    let poll_interval = std::time::Duration::from_secs(5);
979    let timeout_duration = std::time::Duration::from_secs(ctx.invocation.timeout_seconds);
980    let wait_result = tokio::time::timeout(
981        timeout_duration,
982        tmux.wait_for_completion(&session, poll_interval),
983    )
984    .await;
985
986    match wait_result {
987        Ok(Ok(_)) => {
988            tracing::info!(task_id = task.id.0, "review agent completed");
989            let _ = tmux.kill_session(&session).await;
990            ReviewPhase::Ready(ReviewRun { run_id })
991        }
992        Ok(Err(e)) => {
993            tracing::warn!(task_id = task.id.0, error = %e, "review agent process exited with error");
994            let _ = tmux.kill_session(&session).await;
995
996            // Before recording failure, check if this is a "successful exit with non-zero
997            // code" agent like kimi/minimax. Read the output file and check for a valid
998            // result with is_error=false — if found, treat as success and proceed to
999            // parse_review_output where the real result will be extracted.
1000            let exit_code: i32 = tokio::task::spawn_blocking({
1001                let review_attempt_dir = ctx.review_attempt_dir.clone();
1002                move || {
1003                    std::fs::read_to_string(review_attempt_dir.join("exit.txt"))
1004                        .ok()
1005                        .and_then(|s| s.trim().parse().ok())
1006                        .unwrap_or(-1)
1007                }
1008            })
1009            .await
1010            .unwrap_or(-1);
1011
1012            if exit_code != 0 {
1013                // Try reading output via read_output_file which checks both the primary path
1014                // and all prior attempt dirs as a fallback. This handles agents like kimi/minimax
1015                // that exit non-zero even on success, as well as cases where the current attempt's
1016                // output.json was not written but a prior attempt's output contains a valid result.
1017                let raw_output =
1018                    runner::response::read_output_file(&ctx.review_task_id, &ctx.output_file, repo)
1019                        .await;
1020                let agent_result =
1021                    runner::agents::find_agent_result(&ctx.review_agent, &raw_output);
1022                // Treat as success if we found a valid result without is_error set
1023                if agent_result.as_ref().map(|r| !r.is_error).unwrap_or(false) {
1024                    tracing::info!(
1025                        task_id = task.id.0,
1026                        agent = %ctx.review_agent,
1027                        exit_code,
1028                        output_found = !raw_output.is_empty(),
1029                        "review agent succeeded despite non-zero exit — proceeding to parse output"
1030                    );
1031                    return ReviewPhase::Ready(ReviewRun { run_id });
1032                }
1033            }
1034
1035            tracing::error!(task_id = task.id.0, error = %e, "review agent failed");
1036            complete_review_run(
1037                store,
1038                run_id,
1039                Some(exit_code),
1040                "",
1041                &e.to_string(),
1042                "",
1043                "failed",
1044                &format!("agent error: {e}"),
1045                RunTokenUsage::default(),
1046            )
1047            .await;
1048            ReviewPhase::EarlyReturn(ReviewDecision::Failed(format!("agent error: {e}")))
1049        }
1050        Err(_) => {
1051            tracing::error!(task_id = task.id.0, "review agent timed out");
1052            let _ = tmux.kill_session(&session).await;
1053            if let Some(model) = ctx.review_model.as_deref() {
1054                // Structural review failure: the model cannot finish review inside
1055                // the allowed time. Use persistent backoff (4h base → 7d max) so
1056                // a successful agent run on the same model does not reset the
1057                // failure counter and let this model be re-selected for review.
1058                crate::engine::cooldown::record_persistent_model_failure(&ctx.review_agent, model)
1059                    .await;
1060            }
1061            complete_review_run(
1062                store,
1063                run_id,
1064                Some(-1),
1065                "",
1066                "timeout",
1067                "",
1068                "timeout",
1069                "timeout",
1070                RunTokenUsage::default(),
1071            )
1072            .await;
1073            ReviewPhase::EarlyReturn(ReviewDecision::Failed("timeout".to_string()))
1074        }
1075    }
1076}
1077
1078async fn record_review_agent_failure(
1079    task_id: &str,
1080    review_agent: &str,
1081    review_model: Option<&str>,
1082    err: &runner::agents::AgentError,
1083) {
1084    match err {
1085        runner::agents::AgentError::RateLimit { message }
1086        | runner::agents::AgentError::Auth { message } => {
1087            tracing::warn!(
1088                task_id,
1089                agent = %review_agent,
1090                "review agent hit error — adding to cooldown"
1091            );
1092            if let Some(reason) = crate::engine::cooldown::detect_credit_exhaustion(message) {
1093                if reason == crate::engine::cooldown::CreditExhaustionReason::BillingCycleExhausted
1094                {
1095                    if let Some(model) = review_model {
1096                        // Quota is model-scoped: don't penalize the whole agent.
1097                        crate::engine::cooldown::record_persistent_model_failure(
1098                            review_agent,
1099                            model,
1100                        )
1101                        .await;
1102                    } else {
1103                        crate::engine::cooldown::record_credit_exhaustion(review_agent, reason)
1104                            .await;
1105                    }
1106                } else {
1107                    crate::engine::cooldown::record_credit_exhaustion(review_agent, reason).await;
1108                }
1109            } else {
1110                runner::response::record_agent_failure_with_message(review_agent, &err.to_string())
1111                    .await;
1112            }
1113        }
1114        runner::agents::AgentError::InvalidResponse { .. } => {
1115            // Structural failure: model produced no parseable output at all.
1116            // Apply persistent backoff (4h base → 7d max) — same rationale as
1117            // silent exit-0 in the agent runner. A model that cannot produce
1118            // ANY output for a review is unlikely to self-heal on the next try.
1119            if let Some(model) = review_model {
1120                crate::engine::cooldown::record_persistent_model_failure(review_agent, model).await;
1121            }
1122        }
1123        runner::agents::AgentError::ModelUnavailable { .. } => {
1124            // Model not available (provider error, model not found, etc.)
1125            // Apply persistent model cooldown (4h base → 7d max) per settled
1126            // architecture (AGENTS.md:325-328). The agent itself is not
1127            // penalized — its other models still route normally.
1128            if let Some(model) = review_model {
1129                crate::engine::cooldown::record_persistent_model_failure(review_agent, model).await;
1130            }
1131        }
1132        _ => {
1133            // Generic failure (AgentFailed, NetworkError, etc.)
1134            // Apply standard model backoff (5min→4h max) to prevent immediate re-selection.
1135            if let Some(model) = review_model {
1136                crate::engine::cooldown::record_model_failure(review_agent, model).await;
1137            }
1138        }
1139    }
1140}
1141
1142fn review_decision_from_response(
1143    review_response: runner::response::ReviewResponse,
1144) -> ReviewDecision {
1145    match review_response.decision.as_str() {
1146        "approve" => ReviewDecision::Approve,
1147        "request_changes" => ReviewDecision::RequestChanges {
1148            notes: review_response.notes,
1149            issues: review_response.issues,
1150        },
1151        _ => ReviewDecision::Failed(format!("unknown decision: {}", review_response.decision)),
1152    }
1153}
1154
1155fn review_decision_name(decision: &ReviewDecision) -> &'static str {
1156    match decision {
1157        ReviewDecision::Approve => "approve",
1158        ReviewDecision::RequestChanges { .. } => "request_changes",
1159        ReviewDecision::Failed(_) => "failed",
1160        ReviewDecision::Blocked(_) => "blocked",
1161        ReviewDecision::Skipped => "skipped",
1162        ReviewDecision::Rerouted => "rerouted",
1163    }
1164}
1165
1166async fn parse_review_output(
1167    task: &ExternalTask,
1168    repo: &str,
1169    ctx: &ReviewContext,
1170    run: ReviewRun,
1171    store: &Arc<TaskStore>,
1172) -> ReviewPhase<ParsedReview> {
1173    let (file_exists, file_size, exit_code, stderr) = {
1174        let output_file_clone = ctx.output_file.clone();
1175        let review_attempt_dir_clone = ctx.review_attempt_dir.clone();
1176        match tokio::task::spawn_blocking(move || {
1177            let file_exists = output_file_clone.exists();
1178            let file_size = std::fs::metadata(&output_file_clone)
1179                .map(|m| m.len())
1180                .unwrap_or(0);
1181            let exit_code = std::fs::read_to_string(review_attempt_dir_clone.join("exit.txt"))
1182                .ok()
1183                .and_then(|s| s.trim().parse::<i32>().ok())
1184                .unwrap_or(-1);
1185            let stderr = std::fs::read_to_string(review_attempt_dir_clone.join("stderr.txt"))
1186                .unwrap_or_default();
1187            (file_exists, file_size, exit_code, stderr)
1188        })
1189        .await
1190        {
1191            Ok(tuple) => tuple,
1192            Err(e) => {
1193                tracing::error!(
1194                    task_id = task.id.0,
1195                    error = %e,
1196                    "spawn_blocking panicked reading review output metadata"
1197                );
1198                (false, 0, -1, format!("spawn_blocking failed: {e}"))
1199            }
1200        }
1201    };
1202
1203    tracing::info!(
1204        task_id = task.id.0,
1205        path = %ctx.output_file.display(),
1206        file_exists,
1207        file_size,
1208        "reading review agent output"
1209    );
1210
1211    let raw_output =
1212        runner::response::read_output_file(&ctx.review_task_id, &ctx.output_file, repo).await;
1213    let raw_output = runner::agents::claude::strip_tty_control_lines(&raw_output);
1214    let has_result_envelope =
1215        runner::agents::claude::find_ndjson_result_line(&raw_output).is_some();
1216    let agent_result = runner::agents::find_agent_result(&ctx.review_agent, &raw_output);
1217    let token_usage = RunTokenUsage {
1218        input_tokens: agent_result
1219            .as_ref()
1220            .and_then(|r| r.input_tokens)
1221            .unwrap_or(0),
1222        output_tokens: agent_result
1223            .as_ref()
1224            .and_then(|r| r.output_tokens)
1225            .unwrap_or(0),
1226        total_cost_usd: agent_result
1227            .as_ref()
1228            .and_then(|r| r.cost_usd)
1229            .unwrap_or(0.0),
1230        duration_secs: 0.0,
1231    };
1232
1233    let agent_result_is_error = agent_result.as_ref().map(|r| r.is_error).unwrap_or(false);
1234    let ndjson_completed_no_error = raw_output.contains("\"terminal_reason\":\"completed\"")
1235        && !raw_output.contains("\"is_error\":true");
1236    // Do not short-circuit completed NDJSON sessions before rescue parsing.
1237    let is_hard_failure =
1238        !ndjson_completed_no_error && (raw_output.is_empty() || agent_result_is_error);
1239
1240    if is_hard_failure {
1241        tracing::warn!(
1242            task_id = task.id.0,
1243            exit_code,
1244            raw_output_len = raw_output.len(),
1245            stderr_len = stderr.len(),
1246            "review agent: entering error path"
1247        );
1248        let agent_runner = runner::agents::get_runner(&ctx.review_agent);
1249        let err = agent_runner.classify_error(exit_code, &raw_output, &stderr);
1250        record_review_agent_failure(
1251            &task.id.0,
1252            &ctx.review_agent,
1253            ctx.review_model.as_deref(),
1254            &err,
1255        )
1256        .await;
1257        tracing::error!(task_id = task.id.0, error = %err, "review agent failed");
1258        let stored_error = format_review_error(&ctx.review_agent, &err);
1259        complete_review_run(
1260            store,
1261            run.run_id,
1262            Some(exit_code),
1263            &raw_output,
1264            &stderr,
1265            "",
1266            outcome_for_agent_error(&err),
1267            &stored_error,
1268            token_usage,
1269        )
1270        .await;
1271        return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1272    }
1273
1274    let text_for_review = agent_result
1275        .as_ref()
1276        .map(|r| r.result_text.clone())
1277        .filter(|text| !text.is_empty())
1278        .unwrap_or_else(|| {
1279            tracing::debug!(
1280                task_id = task.id.0,
1281                agent = %ctx.review_agent,
1282                "review agent: per-agent extractor returned no text, using raw output"
1283            );
1284            raw_output.clone()
1285        });
1286
1287    let review_response = match runner::response::parse_review_response(&text_for_review) {
1288        Ok(response) => response,
1289        Err(e) => {
1290            if let Some(response) = runner::response::infer_review_response(&text_for_review) {
1291                tracing::warn!(
1292                    task_id = task.id.0,
1293                    agent = %ctx.review_agent,
1294                    "review response parsed via keyword fallback"
1295                );
1296                response
1297            } else {
1298                // Guard: if the raw NDJSON signals a completed session
1299                // (terminal_reason:completed without is_error:true), do NOT run
1300                // generic pattern-based rate-limit detection over it. Token
1301                // counts in the telemetry blob can contain bare "429" values
1302                // that would otherwise be misclassified as HTTP 429 errors.
1303                let ndjson_completed = ndjson_completed_no_error;
1304
1305                // Missing type:result (kimi/minimax) or terminal_reason:completed: recover
1306                // from assistant text. Never MiniMax parse_response, which synthesizes status=done
1307                if ndjson_completed || !has_result_envelope {
1308                    let assistant_text = runner::agents::collect_assistant_messages_text(
1309                        &ctx.review_agent,
1310                        &raw_output,
1311                    );
1312                    if !assistant_text.is_empty() {
1313                        if let Ok(response) =
1314                            runner::response::parse_review_response(&assistant_text)
1315                        {
1316                            tracing::debug!(
1317                                task_id = task.id.0,
1318                                agent = %ctx.review_agent,
1319                                "found review response JSON in assistant message (terminal_reason:completed)"
1320                            );
1321                            let review_notes_for_comment = response.notes.clone();
1322                            let decision = review_decision_from_response(response);
1323                            return ReviewPhase::Ready(ParsedReview {
1324                                run_id: run.run_id,
1325                                exit_code,
1326                                stderr,
1327                                raw_output,
1328                                text_for_review: assistant_text,
1329                                token_usage,
1330                                review_notes_for_comment,
1331                                decision,
1332                            });
1333                        }
1334                        if let Some(response) =
1335                            runner::response::infer_review_response(&assistant_text)
1336                        {
1337                            tracing::warn!(
1338                                task_id = task.id.0,
1339                                agent = %ctx.review_agent,
1340                                "inferred review response from assistant message (terminal_reason:completed)"
1341                            );
1342                            let review_notes_for_comment = response.notes.clone();
1343                            let decision = review_decision_from_response(response);
1344                            return ReviewPhase::Ready(ParsedReview {
1345                                run_id: run.run_id,
1346                                exit_code,
1347                                stderr,
1348                                raw_output,
1349                                text_for_review: assistant_text,
1350                                token_usage,
1351                                review_notes_for_comment,
1352                                decision,
1353                            });
1354                        }
1355                    }
1356                }
1357
1358                // Scan only bounded head/tail windows, not the full review text.
1359                // The full transcript may contain agent work product (e.g. a
1360                // diff or markdown report quoting an `outcome`/`rate_limit`
1361                // table cell) that incidentally matches these patterns; real
1362                // CLI errors appear at the start or end of the output. See
1363                // `classify_from_text` in runner::agents for the same rationale.
1364                let scan_head = runner::agents::patterns::safe_head(
1365                    &text_for_review,
1366                    runner::agents::patterns::RATE_LIMIT_SCAN_HEAD_BYTES,
1367                );
1368                let scan_tail = runner::agents::patterns::safe_tail(
1369                    &text_for_review,
1370                    runner::agents::patterns::RATE_LIMIT_SCAN_TAIL_BYTES,
1371                );
1372
1373                if let Some(err) = (!ndjson_completed)
1374                    .then(|| {
1375                        runner::agents::patterns::detect_rate_limit(scan_head)
1376                            .or_else(|| runner::agents::patterns::detect_rate_limit(scan_tail))
1377                    })
1378                    .flatten()
1379                {
1380                    let runner::agents::AgentError::RateLimit { message } = &err else {
1381                        unreachable!("detect_rate_limit only returns AgentError::RateLimit")
1382                    };
1383                    tracing::warn!(
1384                        task_id = task.id.0,
1385                        agent = %ctx.review_agent,
1386                        "review agent hit rate limit — adding to cooldown"
1387                    );
1388                    // Route through record_review_agent_failure/outcome_for_agent_error
1389                    // (not the generic record_agent_failure_with_message + hardcoded
1390                    // "rate_limit" string) so billing-cycle exhaustion messages caught by
1391                    // this inline text scan get the same model-scoped persistent cooldown
1392                    // and outcome classification as the AgentError-based failure path
1393                    // above (issue #3551, gap left by #3530).
1394                    let summary = runner::fallback::summarize_rate_limit_error(message);
1395                    let stored_error = format!("{} rate limit: {summary}", ctx.review_agent);
1396                    record_review_agent_failure(
1397                        &task.id.0,
1398                        &ctx.review_agent,
1399                        ctx.review_model.as_deref(),
1400                        &err,
1401                    )
1402                    .await;
1403                    complete_review_run(
1404                        store,
1405                        run.run_id,
1406                        Some(exit_code),
1407                        &raw_output,
1408                        &stderr,
1409                        &text_for_review,
1410                        outcome_for_agent_error(&err),
1411                        &stored_error,
1412                        token_usage,
1413                    )
1414                    .await;
1415                    return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1416                }
1417
1418                if let Some(err) = runner::agents::patterns::detect_auth_error(scan_tail) {
1419                    let runner::agents::AgentError::Auth { message } = &err else {
1420                        unreachable!("detect_auth_error only returns AgentError::Auth")
1421                    };
1422                    tracing::warn!(
1423                        task_id = task.id.0,
1424                        agent = %ctx.review_agent,
1425                        "review agent hit auth error — adding to cooldown"
1426                    );
1427                    let stored_error = format!("auth error: {message}");
1428                    record_review_agent_failure(
1429                        &task.id.0,
1430                        &ctx.review_agent,
1431                        ctx.review_model.as_deref(),
1432                        &err,
1433                    )
1434                    .await;
1435                    complete_review_run(
1436                        store,
1437                        run.run_id,
1438                        Some(exit_code),
1439                        &raw_output,
1440                        &stderr,
1441                        &text_for_review,
1442                        outcome_for_agent_error(&err),
1443                        &stored_error,
1444                        token_usage,
1445                    )
1446                    .await;
1447                    return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1448                }
1449
1450                // The model was cut off for exceeding its output/reasoning
1451                // token budget (opencode step_finish reason=length), not
1452                // because it produced malformed prose. Like a timeout, this is
1453                // a structural inability to finish review under the current
1454                // budget, so use the persistent 4h-7d cooldown rather than the
1455                // generic success-resettable model failure counter.
1456                if agent_result
1457                    .as_ref()
1458                    .map(|r| r.truncated_by_length)
1459                    .unwrap_or(false)
1460                {
1461                    tracing::warn!(
1462                        task_id = task.id.0,
1463                        agent = %ctx.review_agent,
1464                        model = ?ctx.review_model,
1465                        "review agent output truncated: step_finish reason=length \
1466                         (token budget exceeded before completion)"
1467                    );
1468                    if let Some(model) = ctx.review_model.as_deref() {
1469                        crate::engine::cooldown::record_persistent_model_failure(
1470                            &ctx.review_agent,
1471                            model,
1472                        )
1473                        .await;
1474                    }
1475                    let stored_error = "truncated: review output was cut off before completion \
1476                        (output/reasoning token budget exceeded)"
1477                        .to_string();
1478                    complete_review_run(
1479                        store,
1480                        run.run_id,
1481                        Some(exit_code),
1482                        &raw_output,
1483                        &stderr,
1484                        &text_for_review,
1485                        "truncated",
1486                        &stored_error,
1487                        token_usage,
1488                    )
1489                    .await;
1490                    return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1491                }
1492
1493                // No envelope and last content is thinking/tool_use: retry without
1494                // the persistent format-failure cooldown (issue #3734)
1495                if runner::agents::claude::review_session_exited_mid_thought(&raw_output) {
1496                    tracing::warn!(
1497                        task_id = task.id.0,
1498                        agent = %ctx.review_agent,
1499                        model = ?ctx.review_model,
1500                        "review session exited mid-thought with no result envelope \
1501                         — storing as incomplete and retrying without persistent cooldown"
1502                    );
1503                    let stored_error =
1504                        "incomplete: review session exited before producing a decision".to_string();
1505                    complete_review_run(
1506                        store,
1507                        run.run_id,
1508                        Some(exit_code),
1509                        &raw_output,
1510                        &stderr,
1511                        &text_for_review,
1512                        "incomplete",
1513                        &stored_error,
1514                        token_usage,
1515                    )
1516                    .await;
1517                    return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1518                }
1519
1520                tracing::error!(
1521                    task_id = task.id.0,
1522                    error = %e,
1523                    agent = %ctx.review_agent,
1524                    output = %text_for_review.chars().take(300).collect::<String>(),
1525                    raw_output = %raw_output.chars().take(1000).collect::<String>(),
1526                    "failed to parse review response"
1527                );
1528                if let Some(model) = ctx.review_model.as_deref() {
1529                    // Finished output that is not a review decision: persistent backoff
1530                    // (4h base → 7d max) so a later success cannot immediately re-select it
1531                    crate::engine::cooldown::record_persistent_model_failure(
1532                        &ctx.review_agent,
1533                        model,
1534                    )
1535                    .await;
1536                }
1537                complete_review_run(
1538                    store,
1539                    run.run_id,
1540                    Some(exit_code),
1541                    &raw_output,
1542                    &stderr,
1543                    &text_for_review,
1544                    "parse_error",
1545                    &format!("parse error: {e}"),
1546                    token_usage,
1547                )
1548                .await;
1549                return ReviewPhase::EarlyReturn(ReviewDecision::Failed(format!(
1550                    "parse error: {e}"
1551                )));
1552            }
1553        }
1554    };
1555
1556    let review_notes_for_comment = review_response.notes.clone();
1557    let decision = review_decision_from_response(review_response);
1558
1559    tracing::info!(
1560        task_id = task.id.0,
1561        pr_number = ctx.pr_number,
1562        decision = ?decision,
1563        "review agent decision received"
1564    );
1565    store_log_activity(
1566        &Some(Arc::clone(store)),
1567        repo,
1568        &task.id.0,
1569        "review_decision",
1570        None,
1571        None,
1572        Some(&ctx.review_agent),
1573        ctx.review_model.as_deref(),
1574        Some(&serde_json::json!({
1575            "decision": review_decision_name(&decision),
1576            "pr_number": ctx.pr_number,
1577        })),
1578    )
1579    .await;
1580
1581    ReviewPhase::Ready(ParsedReview {
1582        run_id: run.run_id,
1583        exit_code,
1584        stderr,
1585        raw_output,
1586        text_for_review,
1587        token_usage,
1588        review_notes_for_comment,
1589        decision,
1590    })
1591}
1592
1593async fn finalize_review_run(
1594    task: &ExternalTask,
1595    ctx: &ReviewContext,
1596    parsed: &ParsedReview,
1597    store: &Arc<TaskStore>,
1598) {
1599    let outcome = match &parsed.decision {
1600        ReviewDecision::Approve
1601        | ReviewDecision::RequestChanges { .. }
1602        | ReviewDecision::Skipped
1603        | ReviewDecision::Rerouted => "success",
1604        ReviewDecision::Failed(_) | ReviewDecision::Blocked(_) => "failed",
1605    };
1606    let error = match &parsed.decision {
1607        ReviewDecision::Failed(error) | ReviewDecision::Blocked(error) => error.as_str(),
1608        _ => "",
1609    };
1610
1611    if outcome == "success" {
1612        // Mirror the runner's success path so review-only models don't ratchet
1613        // into multi-day cooldowns from isolated failures never offset by resets.
1614        crate::engine::cooldown::record_agent_success(&ctx.review_agent, ctx.review_model_str())
1615            .await;
1616    }
1617
1618    complete_review_run(
1619        store,
1620        parsed.run_id,
1621        Some(parsed.exit_code),
1622        &parsed.raw_output,
1623        &parsed.stderr,
1624        &parsed.text_for_review,
1625        outcome,
1626        error,
1627        parsed.token_usage,
1628    )
1629    .await;
1630
1631    if let Some(store_id) = ctx.store_id {
1632        if parsed.token_usage.input_tokens > 0 || parsed.token_usage.output_tokens > 0 {
1633            let model = if ctx.review_model_str().is_empty() {
1634                "unknown"
1635            } else {
1636                ctx.review_model_str()
1637            };
1638            if let Err(e) = store
1639                .store_tokens(
1640                    store_id,
1641                    parsed.token_usage.input_tokens,
1642                    parsed.token_usage.output_tokens,
1643                    model,
1644                )
1645                .await
1646            {
1647                tracing::warn!(
1648                    task_id = task.id.0,
1649                    ?e,
1650                    "failed to store review agent token usage"
1651                );
1652            }
1653        }
1654    }
1655}
1656
1657async fn restore_review_config_if_needed(
1658    task_id: &str,
1659    worktree_path: &std::path::Path,
1660    default_branch: &str,
1661) {
1662    let _ = Command::new("git")
1663        .args(["checkout", "HEAD", "--", ".orch.yml"])
1664        .current_dir(worktree_path)
1665        .status()
1666        .await;
1667
1668    let merge_base = Command::new("git")
1669        .args(["merge-base", "HEAD", default_branch])
1670        .current_dir(worktree_path)
1671        .output()
1672        .await
1673        .ok()
1674        .filter(|output| output.status.success())
1675        .and_then(|output| String::from_utf8(output.stdout).ok())
1676        .map(|s| s.trim().to_string())
1677        .unwrap_or_default();
1678
1679    if merge_base.is_empty() {
1680        return;
1681    }
1682
1683    let orch_yml_changed = Command::new("git")
1684        .args([
1685            "diff",
1686            "--name-only",
1687            &merge_base,
1688            "HEAD",
1689            "--",
1690            ".orch.yml",
1691        ])
1692        .current_dir(worktree_path)
1693        .output()
1694        .await
1695        .ok()
1696        .filter(|output| output.status.success())
1697        .map(|output| !output.stdout.is_empty())
1698        .unwrap_or(false);
1699
1700    if !orch_yml_changed {
1701        return;
1702    }
1703
1704    tracing::warn!(
1705        task_id,
1706        "review agent modified .orch.yml (forbidden); restoring to merge-base version"
1707    );
1708    let restore_ok = Command::new("git")
1709        .args(["checkout", &merge_base, "--", ".orch.yml"])
1710        .current_dir(worktree_path)
1711        .status()
1712        .await
1713        .map(|status| status.success())
1714        .unwrap_or(false);
1715
1716    if !restore_ok {
1717        return;
1718    }
1719
1720    let staged = Command::new("git")
1721        .args(["diff", "--cached", "--name-only", "--", ".orch.yml"])
1722        .current_dir(worktree_path)
1723        .output()
1724        .await
1725        .ok()
1726        .filter(|output| output.status.success())
1727        .map(|output| !output.stdout.is_empty())
1728        .unwrap_or(false);
1729
1730    if staged {
1731        let _ = Command::new("git")
1732            .args([
1733                "-c",
1734                "commit.gpgsign=false",
1735                "commit",
1736                "-m",
1737                "revert: restore .orch.yml (review agent must not modify project config)",
1738            ])
1739            .current_dir(worktree_path)
1740            .status()
1741            .await;
1742    }
1743}
1744
1745fn is_missing_path_error_message(err: &str) -> bool {
1746    err.to_ascii_lowercase()
1747        .contains("no such file or directory")
1748}
1749
1750async fn push_review_branch(
1751    task: &ExternalTask,
1752    repo: &str,
1753    ctx: &ReviewContext,
1754    store: &Arc<TaskStore>,
1755) -> Result<(), String> {
1756    let mut worktree_path = ctx.worktree_path.clone();
1757    let mut branch_name = ctx.branch_name.clone();
1758    let mut retried_missing_worktree = false;
1759
1760    let push_result = loop {
1761        match runner::git_ops::push_branch(&worktree_path, &branch_name, &ctx.default_branch).await
1762        {
1763            Ok(ok) => break Ok(ok),
1764            Err(e)
1765                if !retried_missing_worktree
1766                    && !worktree_path.exists()
1767                    && is_missing_path_error_message(&e.to_string()) =>
1768            {
1769                retried_missing_worktree = true;
1770                tracing::warn!(
1771                    task_id = task.id.0,
1772                    worktree = %worktree_path.display(),
1773                    branch = %branch_name,
1774                    "review push failed because worktree disappeared; attempting one-time recovery"
1775                );
1776                match recover_missing_review_worktree(task, repo, store).await {
1777                    Ok(recovered) => {
1778                        worktree_path = recovered.work_dir;
1779                        branch_name = recovered.branch;
1780                        continue;
1781                    }
1782                    Err(recover_err) => {
1783                        break Err(anyhow::anyhow!(
1784                            "missing review worktree recovery failed after push error: {recover_err}"
1785                        ));
1786                    }
1787                }
1788            }
1789            Err(e) => break Err(e),
1790        }
1791    };
1792
1793    match push_result {
1794        Ok(_) => {
1795            store_log_activity(
1796                &Some(Arc::clone(store)),
1797                repo,
1798                &task.id.0,
1799                "push",
1800                None,
1801                None,
1802                Some(&ctx.review_agent),
1803                ctx.review_model.as_deref(),
1804                Some(&serde_json::json!({
1805                    "status": "ok",
1806                    "branch": branch_name.clone(),
1807                    "default_branch": ctx.default_branch.clone(),
1808                    "phase": "review",
1809                })),
1810            )
1811            .await;
1812            if ctx.had_prev_push_failure {
1813                if let Err(e) = store_set_result(
1814                    &Some(Arc::clone(store)),
1815                    repo,
1816                    &task.id.0,
1817                    &[
1818                        ("last_error", serde_json::json!("")),
1819                        ("push_failures", serde_json::json!(0)),
1820                    ],
1821                )
1822                .await
1823                {
1824                    tracing::warn!(task_id = task.id.0, err = %e, "failed to reset push_failures — counter may drift");
1825                }
1826            }
1827            Ok(())
1828        }
1829        Err(e) => {
1830            store_log_activity(
1831                &Some(Arc::clone(store)),
1832                repo,
1833                &task.id.0,
1834                "push",
1835                None,
1836                None,
1837                Some(&ctx.review_agent),
1838                ctx.review_model.as_deref(),
1839                Some(&serde_json::json!({
1840                    "status": "error",
1841                    "branch": branch_name.clone(),
1842                    "default_branch": ctx.default_branch.clone(),
1843                    "phase": "review",
1844                    "error": e.to_string(),
1845                })),
1846            )
1847            .await;
1848            tracing::error!(
1849                task_id = task.id.0,
1850                branch = %ctx.branch_name,
1851                error = %e,
1852                "review push failed"
1853            );
1854            store_set(
1855                &Some(Arc::clone(store)),
1856                repo,
1857                &task.id.0,
1858                &[("last_error", serde_json::json!(format!("push failed: {e}")))],
1859            )
1860            .await;
1861            Err(e.to_string())
1862        }
1863    }
1864}
1865
1866fn build_pr_review_comment(decision: &ReviewDecision, review_notes_for_comment: &str) -> String {
1867    match decision {
1868        ReviewDecision::Approve => {
1869            format!(
1870                "## Automated Review \u{2014} Approve\n\n{}",
1871                review_notes_for_comment
1872            )
1873        }
1874        ReviewDecision::RequestChanges { notes, issues } => {
1875            let mut body = format!(
1876                "## Automated Review \u{2014} Changes Requested\n\n{}\n",
1877                notes
1878            );
1879            if !issues.is_empty() {
1880                body.push_str("\n**Issues Found:**\n");
1881                for issue in issues {
1882                    body.push_str(&format!(
1883                        "- `{}` line {}: {} [{}]\n",
1884                        issue.file,
1885                        issue
1886                            .line
1887                            .map(|line| line.to_string())
1888                            .unwrap_or_else(|| "?".to_string()),
1889                        issue.description,
1890                        issue.severity
1891                    ));
1892                }
1893            }
1894            body
1895        }
1896        _ => String::new(),
1897    }
1898}
1899
1900const REVIEW_STATUS_CONTEXT: &str = "orch/review";
1901
1902/// Commit status state and description for a review decision. Other decisions post nothing.
1903fn review_status(decision: &ReviewDecision) -> Option<(&'static str, &'static str)> {
1904    match decision {
1905        ReviewDecision::Approve => Some(("success", "Approved by automated review")),
1906        ReviewDecision::RequestChanges { .. } => {
1907            Some(("failure", "Automated review requested changes"))
1908        }
1909        _ => None,
1910    }
1911}
1912
1913/// Post `orch/review` on the SHA the review started from. A new push moves the PR head, so an
1914/// old decision never covers a new commit.
1915async fn post_review_status(
1916    gh: &GhHttp,
1917    repo: &str,
1918    sha: Option<&str>,
1919    state: &str,
1920    description: &str,
1921) {
1922    let Some(sha) = sha else {
1923        return;
1924    };
1925    if let Err(e) = gh
1926        .post_commit_status(repo, sha, state, REVIEW_STATUS_CONTEXT, description)
1927        .await
1928    {
1929        tracing::warn!(sha, error = %e, "failed to post orch/review commit status");
1930    }
1931}
1932
1933async fn post_review_comment(
1934    task: &ExternalTask,
1935    repo: &str,
1936    ctx: &ReviewContext,
1937    decision: &ReviewDecision,
1938    review_notes_for_comment: &str,
1939) -> anyhow::Result<()> {
1940    let gh = match GhHttp::new() {
1941        Ok(gh) => gh,
1942        Err(e) => {
1943            tracing::warn!(
1944                task_id = task.id.0,
1945                error = %e,
1946                "failed to create GH client for review comment"
1947            );
1948            return Ok(());
1949        }
1950    };
1951    let mut pr_comment = build_pr_review_comment(decision, review_notes_for_comment);
1952    if ctx.same_agent_fallback && !pr_comment.is_empty() {
1953        pr_comment.push_str(
1954            "\n> Note: no other agent was available, so this PR was reviewed by the same agent that wrote the code.\n",
1955        );
1956    }
1957
1958    // The agent may have rebased or committed a fix, pushed just before this, so the head read
1959    // before the review is stale and an App review on it would be refused or ignored
1960    let head_sha = worktree_head_sha(&ctx.worktree_path)
1961        .await
1962        .or_else(|| ctx.head_sha.clone());
1963
1964    if let Some((state, description)) = review_status(decision) {
1965        post_review_status(&gh, repo, head_sha.as_deref(), state, description).await;
1966    }
1967
1968    if !pr_comment.is_empty() {
1969        let footer =
1970            attribution_footer("Reviewed", &ctx.review_agent, Some(ctx.review_model_str()));
1971        let pr_comment_with_footer = format!("{}{}", pr_comment, footer);
1972        if let Err(e) = publish_review(
1973            crate::github::app::shared().as_deref(),
1974            &gh,
1975            repo,
1976            ctx.pr_number,
1977            head_sha.as_deref(),
1978            decision,
1979            &pr_comment_with_footer,
1980        )
1981        .await
1982        {
1983            tracing::warn!(
1984                task_id = task.id.0,
1985                pr_number = ctx.pr_number,
1986                error = %e,
1987                "failed to post automated review comment on PR"
1988            );
1989        }
1990    }
1991
1992    Ok(())
1993}
1994
1995/// Submit the decision as an App review on the sha the agent read, then post the comment the
1996/// gate falls back to. Without an App only the comment goes out
1997async fn publish_review(
1998    app: Option<&crate::github::app::AppAuth>,
1999    gh: &GhHttp,
2000    repo: &str,
2001    pr_number: u64,
2002    head_sha: Option<&str>,
2003    decision: &ReviewDecision,
2004    comment: &str,
2005) -> anyhow::Result<()> {
2006    use crate::github::app::ReviewEvent;
2007    let event = match decision {
2008        ReviewDecision::Approve => Some(ReviewEvent::Approve),
2009        ReviewDecision::RequestChanges { .. } => Some(ReviewEvent::RequestChanges),
2010        _ => None,
2011    };
2012    if let (Some(app), Some(sha), Some(event)) = (app, head_sha, event) {
2013        if let Err(e) = app
2014            .submit_review(repo, pr_number, event, comment, sha)
2015            .await
2016        {
2017            tracing::warn!(pr_number, sha, error = %e, "failed to submit App review, the comment stays the decision");
2018        }
2019    }
2020    gh.add_comment(repo, &pr_number.to_string(), comment).await
2021}
2022
2023#[allow(clippy::too_many_arguments)]
2024async fn apply_review_decision(
2025    task: &ExternalTask,
2026    backend: &Arc<dyn ExternalBackend>,
2027    repo: &str,
2028    task_manager: &Arc<TaskManager>,
2029    store: &Arc<TaskStore>,
2030    ctx: &ReviewContext,
2031    parsed: &ParsedReview,
2032    router_config: &crate::engine::router::RouterConfig,
2033) -> anyhow::Result<ReviewDecision> {
2034    restore_review_config_if_needed(&task.id.0, &ctx.worktree_path, &ctx.default_branch).await;
2035
2036    if let Err(e) = push_review_branch(task, repo, ctx, store).await {
2037        return Ok(ReviewDecision::Failed(format!("push failed: {e}")));
2038    }
2039
2040    post_review_comment(
2041        task,
2042        repo,
2043        ctx,
2044        &parsed.decision,
2045        &parsed.review_notes_for_comment,
2046    )
2047    .await?;
2048
2049    match &parsed.decision {
2050        ReviewDecision::Approve => {
2051            record_approved_sha(store, repo, &task.id.0, ctx.pr_number, &ctx.worktree_path).await;
2052            // Always attempt auto-merge on approval. Tasks are only marked
2053            // Done when the PR is actually merged (detected by review_poll).
2054            // Never mark Done on approval alone — that closes the issue
2055            // before the code lands.
2056            if let Err(e) = auto_merge_pr(
2057                task,
2058                &ctx.branch_name,
2059                backend,
2060                repo,
2061                &ctx.review_agent,
2062                ctx.review_model_str(),
2063                task_manager,
2064                store,
2065            )
2066            .await
2067            {
2068                let error_msg = e.to_string();
2069                if error_msg.contains("not yet computed") {
2070                    tracing::warn!(
2071                        task_id = task.id.0,
2072                        pr_number = ctx.pr_number,
2073                        branch = %ctx.branch_name,
2074                        error = %e,
2075                        "auto-merge deferred — PR mergeability not yet computed"
2076                    );
2077                } else {
2078                    tracing::error!(
2079                        task_id = task.id.0,
2080                        pr_number = ctx.pr_number,
2081                        branch = %ctx.branch_name,
2082                        error = %e,
2083                        "auto-merge failed"
2084                    );
2085                }
2086                return Ok(ReviewDecision::Failed(format!("merge failed: {e}")));
2087            }
2088            Ok(ReviewDecision::Approve)
2089        }
2090        ReviewDecision::RequestChanges { notes, issues } => {
2091            clear_approved_sha(store, repo, &task.id.0, ctx.pr_number).await;
2092            handle_review_changes(
2093                task,
2094                notes,
2095                issues,
2096                backend,
2097                repo,
2098                ctx.pr_number,
2099                &ctx.review_agent,
2100                ctx.review_model_str(),
2101                task_manager,
2102                store,
2103                router_config,
2104            )
2105            .await?;
2106            Ok(parsed.decision.clone())
2107        }
2108        _ => Ok(parsed.decision.clone()),
2109    }
2110}
2111
2112async fn recover_missing_review_worktree(
2113    task: &ExternalTask,
2114    repo: &str,
2115    store: &Arc<TaskStore>,
2116) -> anyhow::Result<worktree::WorktreeSetup> {
2117    let project_dir = crate::config::get_projects_with_paths()?
2118        .into_iter()
2119        .find(|(candidate_repo, _)| candidate_repo == repo)
2120        .map(|(_, project_dir)| project_dir)
2121        .ok_or_else(|| anyhow::anyhow!("no project directory configured for repo {repo}"))?;
2122
2123    tracing::warn!(
2124        task_id = task.id.0,
2125        repo,
2126        project_dir = %project_dir.display(),
2127        "review worktree missing, recreating via normal setup"
2128    );
2129
2130    let store_ref = Some(Arc::clone(store));
2131    let wt = worktree::setup_worktree(&task.id.0, &task.title, &project_dir, &store_ref, repo)
2132        .await
2133        .context("recreating missing review worktree")?;
2134
2135    if !wt.work_dir.exists() {
2136        anyhow::bail!(
2137            "recreated review worktree at {} for task {} is still missing",
2138            wt.work_dir.display(),
2139            task.id.0
2140        );
2141    }
2142
2143    Ok(wt)
2144}
2145
2146/// Count commits on the worktree branch that are ahead of `origin/<default_branch>`.
2147///
2148/// Returns `Err` when the git command cannot determine the count (e.g., missing
2149/// remote ref, non-zero exit, unparseable output). Callers must NOT treat this
2150/// as zero — a failure means the ancestry relationship is unknown.
2151async fn count_ahead_commits(
2152    worktree_path: &std::path::Path,
2153    default_branch: &str,
2154) -> Result<u64, String> {
2155    let worktree_str = worktree_path.to_str().ok_or_else(|| {
2156        format!(
2157            "worktree path contains non-UTF-8 characters: {:?}",
2158            worktree_path
2159        )
2160    })?;
2161
2162    let output = tokio::process::Command::new("git")
2163        .args([
2164            "-C",
2165            worktree_str,
2166            "rev-list",
2167            "--count",
2168            &format!("origin/{default_branch}..HEAD"),
2169        ])
2170        .output()
2171        .await
2172        .map_err(|e| format!("git rev-list command failed to start: {e}"))?;
2173
2174    if !output.status.success() {
2175        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
2176        return Err(format!(
2177            "git rev-list exited with {:?}: {}",
2178            output.status.code(),
2179            if stderr.is_empty() {
2180                String::from_utf8_lossy(&output.stdout).trim().to_string()
2181            } else {
2182                stderr
2183            }
2184        ));
2185    }
2186
2187    String::from_utf8_lossy(&output.stdout)
2188        .trim()
2189        .parse::<u64>()
2190        .map_err(|e| format!("could not parse git rev-list output as u64: {e}"))
2191}
2192
2193/// Ensure an open PR exists for the task branch before running the review agent.
2194///
2195/// Checks the store first (avoids GitHub list-API cache race), then the API.
2196/// If no PR exists but the branch has commits, attempts to create one and
2197/// continues with the same review pass.
2198/// All error and no-op paths update task status and return [`EnsurePrResult::EarlyReturn`].
2199#[allow(clippy::too_many_arguments)]
2200async fn ensure_pr_exists(
2201    task: &ExternalTask,
2202    branch_name: &str,
2203    worktree_path: &std::path::Path,
2204    repo: &str,
2205    agent_summary: &str,
2206    store: &Arc<TaskStore>,
2207    task_manager: &Arc<TaskManager>,
2208    stored_pr_number: Option<u64>,
2209) -> anyhow::Result<EnsurePrResult> {
2210    let gh_check = GhHttp::new()?;
2211
2212    if let Some(n) = stored_pr_number {
2213        tracing::info!(
2214            task_id = task.id.0,
2215            pr_number = n,
2216            branch = %branch_name,
2217            "open PR found in store, proceeding with review"
2218        );
2219        return Ok(EnsurePrResult::Ready(n));
2220    }
2221
2222    match gh_check.get_pr_number(repo, branch_name).await {
2223        Ok(Some(n)) => {
2224            tracing::info!(
2225                task_id = task.id.0,
2226                pr_number = n,
2227                branch = %branch_name,
2228                "open PR found, proceeding with review"
2229            );
2230            Ok(EnsurePrResult::Ready(n))
2231        }
2232        Ok(None) => {
2233            // No open PR — check if branch has commits ahead of default branch.
2234            let default_branch = worktree::detect_default_branch(worktree_path).await;
2235            let has_commits = match count_ahead_commits(worktree_path, &default_branch).await {
2236                Ok(count) => count > 0,
2237                Err(e) => {
2238                    tracing::error!(
2239                        task_id = task.id.0,
2240                        branch = %branch_name,
2241                        default_branch = %default_branch,
2242                        err = %e,
2243                        "could not determine if branch has commits ahead of default"
2244                    );
2245                    return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2246                        format!("git ancestry check failed: {e}"),
2247                    )));
2248                }
2249            };
2250
2251            if has_commits {
2252                // Branch has unpushed or un-PR'd work — try to create a PR
2253                tracing::warn!(
2254                    task_id = task.id.0,
2255                    branch = %branch_name,
2256                    "no open PR but branch has commits — attempting to create PR"
2257                );
2258                let worktree_str = worktree_path.to_str().ok_or_else(|| {
2259                    anyhow::anyhow!(
2260                        "worktree path contains non-UTF-8 characters: {:?}",
2261                        worktree_path
2262                    )
2263                })?;
2264                // Push first in case agent forgot (use token auth like git_ops::push_branch)
2265                let auth_env = runner::git_ops::build_git_auth_env().await;
2266
2267                // Run a credentialed push and log any errors so failures are
2268                // visible in logs instead of being silently discarded.
2269                // Wrap with a timeout to prevent indefinitely blocking a Tokio worker on a
2270                // hung network connection (corporate proxy, partial GitHub outage, TCP hang).
2271                let push_timeout = std::time::Duration::from_secs(120);
2272                match tokio::time::timeout(
2273                    push_timeout,
2274                    tokio::process::Command::new("git")
2275                        .args(["-C", worktree_str, "push", "-u", "origin", branch_name])
2276                        .envs(auth_env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
2277                        .output(),
2278                )
2279                .await
2280                {
2281                    Err(_elapsed) => {
2282                        tracing::warn!(
2283                            task_id = task.id.0,
2284                            branch = %branch_name,
2285                            timeout_secs = push_timeout.as_secs(),
2286                            "review gate fallback push timed out"
2287                        );
2288                    }
2289                    Ok(Ok(o)) => {
2290                        if !o.status.success() {
2291                            tracing::warn!(
2292                                task_id = task.id.0,
2293                                branch = %branch_name,
2294                                exit_code = ?o.status.code(),
2295                                stdout = %String::from_utf8_lossy(&o.stdout),
2296                                stderr = %String::from_utf8_lossy(&o.stderr),
2297                                "review gate fallback push failed"
2298                            );
2299                        } else {
2300                            tracing::info!(task_id = task.id.0, branch = %branch_name, "review gate fallback push succeeded");
2301                        }
2302                    }
2303                    Ok(Err(e)) => {
2304                        tracing::error!(
2305                            task_id = task.id.0,
2306                            branch = %branch_name,
2307                            error = %e,
2308                            "review gate fallback push command failed to run"
2309                        );
2310                    }
2311                }
2312
2313                let task_ref = runner::git_ops::format_task_ref(&task.id.0);
2314                let pr_body = format!(
2315                    "Resolves {task_ref}\n\nAuto-created by orch review gate (agent forgot to open PR)"
2316                );
2317                let gh = GhHttp::new()?;
2318                match gh
2319                    .create_pr(repo, &task.title, &pr_body, branch_name, &default_branch)
2320                    .await
2321                {
2322                    Ok(url) => match parse_pr_number_from_url(&url) {
2323                        Ok(pr_num) => {
2324                            store_set_result(
2325                                &Some(Arc::clone(store)),
2326                                repo,
2327                                &task.id.0,
2328                                &[("pr_number", serde_json::json!(pr_num as i64))],
2329                            )
2330                            .await?;
2331                            tracing::info!(
2332                                task_id = task.id.0,
2333                                branch = %branch_name,
2334                                pr_url = %url,
2335                                "created missing PR via GhHttp — continuing review"
2336                            );
2337                            Ok(EnsurePrResult::Ready(pr_num))
2338                        }
2339                        Err(e) => {
2340                            tracing::warn!(
2341                                task_id = task.id.0,
2342                                branch = %branch_name,
2343                                pr_url = %url,
2344                                error = %e,
2345                                "created missing PR via GhHttp, but failed to parse PR number"
2346                            );
2347                            Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2348                                format!("created missing PR, but could not parse PR number: {e}"),
2349                            )))
2350                        }
2351                    },
2352                    Err(e) => {
2353                        let e_str = format!("{e}");
2354                        if e_str.contains("already exists") {
2355                            // GitHub list-API cache may be stale immediately after a 422.
2356                            // Use two attempts with exponential backoff (2s → 4s) to avoid
2357                            // racing into a duplicate `gh pr create` call.
2358                            let backoff_delays = [2u64, 4u64];
2359                            let mut found_pr: Option<u64> = None;
2360                            for delay_secs in backoff_delays {
2361                                tokio::time::sleep(tokio::time::Duration::from_secs(delay_secs))
2362                                    .await;
2363                                if let Ok(Some(n)) = gh_check.get_pr_number(repo, branch_name).await
2364                                {
2365                                    found_pr = Some(n);
2366                                    break;
2367                                }
2368                            }
2369                            if let Some(n) = found_pr {
2370                                tracing::info!(
2371                                    task_id = task.id.0,
2372                                    pr_number = n,
2373                                    branch = %branch_name,
2374                                    "found existing PR after create_pr 422 — retrying review"
2375                                );
2376                                store_set_result(
2377                                    &Some(Arc::clone(store)),
2378                                    repo,
2379                                    &task.id.0,
2380                                    &[("pr_number", serde_json::json!(n as i64))],
2381                                )
2382                                .await?;
2383                                return Ok(EnsurePrResult::Ready(n));
2384                            }
2385                        }
2386                        tracing::warn!(
2387                            task_id = task.id.0,
2388                            branch = %branch_name,
2389                            error = %e,
2390                            "create_pr failed via GhHttp, falling back to CLI"
2391                        );
2392                        // Fall back to CLI. Wrap the `gh pr create` call in a timeout so
2393                        // a hung CLI/network does not block the review pipeline.
2394                        let pr_timeout = std::time::Duration::from_secs(120);
2395                        let mut gh_cmd = tokio::process::Command::new("gh");
2396                        gh_cmd.args([
2397                            "pr",
2398                            "create",
2399                            "--repo",
2400                            repo,
2401                            "--head",
2402                            branch_name,
2403                            "--title",
2404                            &task.title,
2405                            "--body",
2406                            &pr_body,
2407                        ]);
2408                        gh_cmd.current_dir(worktree_path);
2409                        gh_cmd.kill_on_drop(true);
2410
2411                        let pr_result = tokio::time::timeout(pr_timeout, gh_cmd.output()).await;
2412                        match pr_result {
2413                            Ok(Ok(o)) if o.status.success() => {
2414                                let stdout = String::from_utf8_lossy(&o.stdout);
2415                                match parse_pr_number_from_url(stdout.trim()) {
2416                                    Ok(pr_num) => {
2417                                        store_set_result(
2418                                            &Some(Arc::clone(store)),
2419                                            repo,
2420                                            &task.id.0,
2421                                            &[("pr_number", serde_json::json!(pr_num as i64))],
2422                                        )
2423                                        .await?;
2424                                        tracing::info!(
2425                                            task_id = task.id.0,
2426                                            branch = %branch_name,
2427                                            "created missing PR via CLI — continuing review"
2428                                        );
2429                                        Ok(EnsurePrResult::Ready(pr_num))
2430                                    }
2431                                    Err(e) => {
2432                                        tracing::warn!(
2433                                            task_id = task.id.0,
2434                                            branch = %branch_name,
2435                                            stdout = %stdout,
2436                                            error = %e,
2437                                            "created missing PR via CLI, but failed to parse PR number"
2438                                        );
2439                                        Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2440                                            format!("created missing PR via CLI, but could not parse PR number: {e}")
2441                                        )))
2442                                    }
2443                                }
2444                            }
2445                            Ok(Ok(o)) => {
2446                                let stderr = String::from_utf8_lossy(&o.stderr);
2447                                if stderr.contains("already exists") {
2448                                    if let Some(pr_url) =
2449                                        stderr.lines().find(|l| l.trim().starts_with("https://"))
2450                                    {
2451                                        match parse_pr_number_from_url(pr_url.trim()) {
2452                                            Ok(pr_num) => {
2453                                                if let Err(e) = store_set_result(
2454                                                    &Some(Arc::clone(store)),
2455                                                    repo,
2456                                                    &task.id.0,
2457                                                    &[(
2458                                                        "pr_number",
2459                                                        serde_json::json!(pr_num as i64),
2460                                                    )],
2461                                                )
2462                                                .await
2463                                                {
2464                                                    tracing::warn!(
2465                                                        task_id = task.id.0,
2466                                                        pr_num,
2467                                                        err = %e,
2468                                                        "failed to persist pr_number from already-exists recovery — downstream may retry PR creation"
2469                                                    );
2470                                                }
2471                                                tracing::info!(
2472                                                    task_id = task.id.0,
2473                                                    branch = %branch_name,
2474                                                    pr_url = %pr_url,
2475                                                    "PR already exists (from CLI stderr) — retrying review"
2476                                                );
2477                                                return Ok(EnsurePrResult::Ready(pr_num));
2478                                            }
2479                                            Err(e) => {
2480                                                tracing::warn!(
2481                                                    task_id = task.id.0,
2482                                                    branch = %branch_name,
2483                                                    pr_url = %pr_url,
2484                                                    error = %e,
2485                                                    "found existing PR URL in CLI stderr, but failed to parse PR number"
2486                                                );
2487                                                // Continue to the error handling below
2488                                            }
2489                                        }
2490                                    }
2491                                }
2492                                tracing::error!(
2493                                    task_id = task.id.0,
2494                                    branch = %branch_name,
2495                                    stderr = %stderr,
2496                                    "failed to create missing PR — work may be stuck"
2497                                );
2498
2499                                // If this was a transient GitHub 5xx or transport error,
2500                                // do NOT increment the persistent pr_create_failures counter
2501                                // (which would eventually block the task). Let the
2502                                // engine retry later. Detect via git_ops::is_transient_github_api_error
2503                                // so generic agent/model transport errors (e.g. broken pipe)
2504                                // are NOT mis-classified as GitHub API transients.
2505                                if crate::engine::runner::git_ops::is_transient_github_api_error(
2506                                    &stderr,
2507                                ) {
2508                                    tracing::warn!(
2509                                        task_id = task.id.0,
2510                                        branch = %branch_name,
2511                                        "transient GitHub error creating PR; will retry later without incrementing persistent failure counter"
2512                                    );
2513                                    return Ok(EnsurePrResult::EarlyReturn(
2514                                        ReviewDecision::Failed(format!(
2515                                            "transient github error creating PR: {stderr}"
2516                                        )),
2517                                    ));
2518                                }
2519
2520                                match store_increment(
2521                                    &Some(Arc::clone(store)),
2522                                    repo,
2523                                    &task.id.0,
2524                                    "pr_create_failures",
2525                                )
2526                                .await
2527                                {
2528                                    Ok(failures) if failures >= MAX_PR_CREATE_FAILURES => {
2529                                        return Ok(EnsurePrResult::EarlyReturn(
2530                                            ReviewDecision::Blocked(format!(
2531                                                "no PR, create failed {failures} times: {stderr}"
2532                                            )),
2533                                        ));
2534                                    }
2535                                    Ok(_) => {}
2536                                    Err(e) => {
2537                                        tracing::warn!(task_id = task.id.0, err = %e, "failed to increment pr_create_failures — skipping blocking decision this tick");
2538                                    }
2539                                }
2540                                Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2541                                    format!("no PR, create failed: {stderr}"),
2542                                )))
2543                            }
2544                            Ok(Err(e)) => {
2545                                tracing::error!(
2546                                    task_id = task.id.0,
2547                                    error = %e,
2548                                    "failed to run gh pr create"
2549                                );
2550                                let e_str = format!("{e}");
2551                                if crate::engine::runner::git_ops::is_transient_github_api_error(
2552                                    &e_str,
2553                                ) {
2554                                    tracing::warn!(
2555                                        task_id = task.id.0,
2556                                        branch = %branch_name,
2557                                        "transient GitHub error from gh CLI fallback; will retry later without incrementing persistent failure counter"
2558                                    );
2559                                    return Ok(EnsurePrResult::EarlyReturn(
2560                                        ReviewDecision::Failed(format!("transient gh error: {e}")),
2561                                    ));
2562                                }
2563
2564                                match store_increment(
2565                                    &Some(Arc::clone(store)),
2566                                    repo,
2567                                    &task.id.0,
2568                                    "pr_create_failures",
2569                                )
2570                                .await
2571                                {
2572                                    Ok(failures) if failures >= MAX_PR_CREATE_FAILURES => {
2573                                        return Ok(EnsurePrResult::EarlyReturn(
2574                                            ReviewDecision::Blocked(format!(
2575                                                "no PR, gh error {failures} times: {e}"
2576                                            )),
2577                                        ));
2578                                    }
2579                                    Ok(_) => {}
2580                                    Err(e) => {
2581                                        tracing::warn!(task_id = task.id.0, err = %e, "failed to increment pr_create_failures — skipping blocking decision this tick");
2582                                    }
2583                                }
2584                                Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2585                                    format!("no PR, gh error: {e}"),
2586                                )))
2587                            }
2588                            Err(_elapsed) => {
2589                                // Timeout from tokio::time::timeout
2590                                tracing::error!(
2591                                    task_id = task.id.0,
2592                                    timeout_secs = pr_timeout.as_secs(),
2593                                    "gh pr create CLI fallback timed out"
2594                                );
2595                                let e_str = format!(
2596                                    "gh pr create timed out after {}s",
2597                                    pr_timeout.as_secs()
2598                                );
2599                                if crate::engine::runner::git_ops::is_transient_github_api_error(
2600                                    &e_str,
2601                                ) {
2602                                    tracing::warn!(
2603                                        task_id = task.id.0,
2604                                        branch = %branch_name,
2605                                        "transient GitHub timeout from gh CLI fallback; will retry later without incrementing persistent failure counter"
2606                                    );
2607                                    return Ok(EnsurePrResult::EarlyReturn(
2608                                        ReviewDecision::Failed(format!(
2609                                            "transient gh error: {e_str}"
2610                                        )),
2611                                    ));
2612                                }
2613
2614                                match store_increment(
2615                                    &Some(Arc::clone(store)),
2616                                    repo,
2617                                    &task.id.0,
2618                                    "pr_create_failures",
2619                                )
2620                                .await
2621                                {
2622                                    Ok(failures) if failures >= MAX_PR_CREATE_FAILURES => {
2623                                        return Ok(EnsurePrResult::EarlyReturn(
2624                                            ReviewDecision::Blocked(format!(
2625                                                "no PR, gh timeout {failures} times: {e_str}"
2626                                            )),
2627                                        ));
2628                                    }
2629                                    Ok(_) => {}
2630                                    Err(e) => {
2631                                        tracing::warn!(task_id = task.id.0, err = %e, "failed to increment pr_create_failures — skipping blocking decision this tick");
2632                                    }
2633                                }
2634                                Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2635                                    format!("no PR, gh timeout: {e_str}"),
2636                                )))
2637                            }
2638                        }
2639                    }
2640                }
2641            } else {
2642                // No PR and no commits — agent either failed or completed a read-only task.
2643                let merged = match gh_check.is_pr_merged(repo, branch_name).await {
2644                    Ok(v) => v,
2645                    Err(e) => {
2646                        tracing::warn!(task_id = task.id.0, branch = %branch_name, err = %e, "merge check failed, skipping task this tick");
2647                        return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2648                            format!("merge check failed: {e}"),
2649                        )));
2650                    }
2651                };
2652                if merged {
2653                    tracing::info!(
2654                        task_id = task.id.0,
2655                        branch = %branch_name,
2656                        "PR already merged, marking done"
2657                    );
2658                    if let Err(e) = task_manager
2659                        .update_task_status(&task.id, Status::Done)
2660                        .await
2661                    {
2662                        tracing::error!(task_id = task.id.0, err = %e, "update_task_status(Done) failed — task may be stuck in InReview");
2663                    }
2664                    return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped));
2665                }
2666
2667                let last_error = opt_store_get_task(&Some(Arc::clone(store)), repo, &task.id.0)
2668                    .await
2669                    .map(|t| t.last_error)
2670                    .unwrap_or_default();
2671                let reason = if !agent_summary.is_empty() {
2672                    agent_summary.to_string()
2673                } else {
2674                    last_error.clone()
2675                };
2676
2677                if last_error.contains("exceeded max attempts") {
2678                    tracing::warn!(
2679                        task_id = task.id.0,
2680                        branch = %branch_name,
2681                        "no PR and no commits after max attempts — marking blocked to stop loop"
2682                    );
2683                    // Write block_reason atomically with the status transition to prevent
2684                    // auto_unblock_blocked_tasks (block_reason.is_none() gate) from
2685                    // re-dispatching this task.
2686                    let fields = [
2687                        (
2688                            "block_reason",
2689                            serde_json::json!("max attempts exceeded — no PR or commits produced"),
2690                        ),
2691                        ("last_error", serde_json::json!(last_error)),
2692                    ];
2693                    if let Err(e) = task_manager
2694                        .update_task_status_and_result(&task.id, Status::Blocked, &fields)
2695                        .await
2696                    {
2697                        tracing::error!(task_id = task.id.0, err = %e, "update_task_status_and_result(Blocked) failed — skipping block to avoid silent auto-unblock loop");
2698                        return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2699                            format!("failed to write block_reason: {e}"),
2700                        )));
2701                    }
2702                    return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped));
2703                }
2704
2705                if last_error.contains("422") && last_error.contains("head") {
2706                    tracing::info!(
2707                        task_id = task.id.0,
2708                        branch = %branch_name,
2709                        "no PR — 422/head-invalid means work already merged, marking done"
2710                    );
2711                    if let Err(e) = task_manager
2712                        .update_task_status(&task.id, Status::Done)
2713                        .await
2714                    {
2715                        tracing::error!(task_id = task.id.0, err = %e, "update_task_status(Done) failed");
2716                    }
2717                    return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped));
2718                }
2719
2720                if last_error.contains("no fallback agents")
2721                    || last_error.contains("all agents exhausted")
2722                {
2723                    tracing::warn!(
2724                        task_id = task.id.0,
2725                        branch = %branch_name,
2726                        last_error = %last_error,
2727                        "no PR and no commits after failover exhaustion — marking blocked to stop loop"
2728                    );
2729                    // Write block_reason atomically with the status transition to prevent
2730                    // auto_unblock_blocked_tasks (block_reason.is_none() gate) from
2731                    // re-dispatching this task.
2732                    let fields = [
2733                        (
2734                            "block_reason",
2735                            serde_json::json!("all agents exhausted — no PR or commits produced"),
2736                        ),
2737                        ("last_error", serde_json::json!(last_error)),
2738                    ];
2739                    if let Err(e) = task_manager
2740                        .update_task_status_and_result(&task.id, Status::Blocked, &fields)
2741                        .await
2742                    {
2743                        tracing::error!(task_id = task.id.0, err = %e, "update_task_status_and_result(Blocked) failed — skipping block to avoid silent auto-unblock loop");
2744                        return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2745                            format!("failed to write block_reason: {e}"),
2746                        )));
2747                    }
2748                    return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped));
2749                }
2750
2751                // No PR and no commits — agent either failed or completed a read-only task.
2752                // Use a dedicated circuit-breaker counter persisted in the store so
2753                // repeated reroutes across separate runs are counted. This prevents
2754                // internal tasks from looping new→in_progress→needs_review→new indefinitely.
2755                // Prefer a dedicated config key `workflow.max_reroute_attempts` (fallback to
2756                // `workflow.max_attempts` for backwards compatibility).
2757                let max_reroutes: u32 = config::get("workflow.max_reroute_attempts")
2758                    .ok()
2759                    .and_then(|s| s.parse().ok())
2760                    .or_else(|| {
2761                        config::get("workflow.max_attempts")
2762                            .ok()
2763                            .and_then(|s| s.parse().ok())
2764                    })
2765                    .unwrap_or(3);
2766
2767                tracing::warn!(
2768                    task_id = task.id.0,
2769                    branch = %branch_name,
2770                    reason = %reason,
2771                    "no PR and no commits — re-routing for retry"
2772                );
2773
2774                // If the last_error indicates a transient GitHub 5xx/transport error,
2775                // do NOT increment the persistent reroute counter which would
2776                // eventually block the task. Instead, retry later.
2777                if should_skip_no_code_reroute_increment(&last_error) {
2778                    tracing::warn!(
2779                        task_id = task.id.0,
2780                        last_error = %last_error,
2781                        "transient GitHub error recorded; skipping persistent no_pr_reroutes increment"
2782                    );
2783                    return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2784                        format!("transient github error: {last_error}"),
2785                    )));
2786                }
2787
2788                // Atomically increment the persistent reroute counter and decide.
2789                let reroutes = match store_increment(
2790                    &Some(Arc::clone(store)),
2791                    repo,
2792                    &task.id.0,
2793                    "no_code_reroutes",
2794                )
2795                .await
2796                {
2797                    Ok(v) => v,
2798                    Err(e) => {
2799                        tracing::warn!(task_id = task.id.0, err = %e, "failed to increment no_code_reroutes — skipping reroute/block decision this tick");
2800                        // Skip action this tick
2801                        return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2802                            format!("transient store error: {e}"),
2803                        )));
2804                    }
2805                };
2806
2807                if reroutes as u32 >= max_reroutes {
2808                    tracing::error!(
2809                        task_id = task.id.0,
2810                        reroutes,
2811                        max_reroutes,
2812                        "reached max reroute attempts for no-pr-result — blocking for human review"
2813                    );
2814                    let msg = format!(
2815                        "no PR or code changes after {}/{} reroute attempts",
2816                        reroutes, max_reroutes
2817                    );
2818                    // Write block_reason atomically with the status transition to prevent
2819                    // auto_unblock_blocked_tasks (block_reason.is_none() gate) from
2820                    // re-dispatching this task.
2821                    let fields = [
2822                        (
2823                            "block_reason",
2824                            serde_json::json!(format!(
2825                                "max reroute attempts ({}) reached — no PR or code changes produced",
2826                                max_reroutes
2827                            )),
2828                        ),
2829                        ("last_error", serde_json::json!(msg)),
2830                        ("agent", serde_json::json!(null)),
2831                        ("model", serde_json::json!(null)),
2832                    ];
2833                    if let Err(e) = task_manager
2834                        .update_task_status_and_result(&task.id, Status::Blocked, &fields)
2835                        .await
2836                    {
2837                        tracing::error!(task_id = task.id.0, err = %e, "update_task_status_and_result(Blocked) failed — skipping block to avoid silent auto-unblock loop");
2838                    }
2839                    Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Blocked(msg)))
2840                } else if task.id.0.starts_with("internal:") {
2841                    // Internal tasks with no PR or code changes are read-only/analysis
2842                    // tasks (e.g. morning briefing, evening retrospective) that
2843                    // legitimately produce no git-visible changes. Mark them done
2844                    // instead of re-routing, which would create a loop: needs_review
2845                    // → review agent → no PR/no commits → reroute to New → agent
2846                    // runs → needs_review → ...
2847                    tracing::info!(
2848                        task_id = task.id.0,
2849                        "no PR and no commits for internal task — marking done"
2850                    );
2851                    if let Err(e) = task_manager
2852                        .update_task_status(&task.id, Status::Done)
2853                        .await
2854                    {
2855                        tracing::error!(task_id = task.id.0, err = %e, "update_task_status(Done) failed — task may be stuck in InReview");
2856                    }
2857                    Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped))
2858                } else {
2859                    // Clear agent/model so router picks a different one and note the
2860                    // fact that this attempt produced no PR or code changes.
2861                    // Use store_set_result so we only transition status when the
2862                    // store write succeeds — partial failure would orphan the task.
2863                    if let Err(e) = store_set_result(
2864                        &Some(Arc::clone(store)),
2865                        repo,
2866                        &task.id.0,
2867                        &[
2868                            ("agent", serde_json::json!(null)),
2869                            ("model", serde_json::json!(null)),
2870                            (
2871                                "last_error",
2872                                serde_json::json!("no PR or code changes produced"),
2873                            ),
2874                        ],
2875                    )
2876                    .await
2877                    {
2878                        tracing::error!(task_id = task.id.0, err = %e, "failed to clear agent/model for reroute — skipping status transition");
2879                        // Don't update status — task stays in InReview and will be retried next tick
2880                    } else if let Err(e) =
2881                        task_manager.update_task_status(&task.id, Status::New).await
2882                    {
2883                        tracing::error!(task_id = task.id.0, err = %e, "update_task_status(New) failed — task may be stuck in InReview");
2884                    }
2885                    Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Rerouted))
2886                }
2887            }
2888        }
2889        Err(e) => {
2890            tracing::warn!(
2891                task_id = task.id.0,
2892                branch = %branch_name,
2893                error = %e,
2894                "failed to check PR status"
2895            );
2896            Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2897                format!("PR check failed: {e}"),
2898            )))
2899        }
2900    }
2901}
2902
2903/// Run the review agent on a completed task and handle the outcome.
2904///
2905/// Called after a task completes with status:done and a PR is created.
2906/// The review agent checks the changes and either approves (triggers auto-merge)
2907/// or requests changes (re-dispatches the original agent).
2908pub(crate) async fn review_and_merge(
2909    task: &ExternalTask,
2910    backend: &Arc<dyn ExternalBackend>,
2911    tmux: &Arc<TmuxManager>,
2912    repo: &str,
2913    router: &Arc<RwLock<Router>>,
2914    task_manager: &Arc<TaskManager>,
2915    store: &Arc<TaskStore>,
2916) -> anyhow::Result<ReviewDecision> {
2917    set_review_session_expected(store, repo, &task.id.0, true).await;
2918
2919    let ctx = match build_review_context(task, repo, router, task_manager, store).await? {
2920        ReviewPhase::Ready(ctx) => ctx,
2921        ReviewPhase::EarlyReturn(decision) => return Ok(decision),
2922    };
2923
2924    let run = match invoke_review_agent(task, tmux, &ctx, repo, store).await {
2925        ReviewPhase::Ready(run) => run,
2926        ReviewPhase::EarlyReturn(decision) => return Ok(decision),
2927    };
2928
2929    let parsed = match parse_review_output(task, repo, &ctx, run, store).await {
2930        ReviewPhase::Ready(parsed) => parsed,
2931        ReviewPhase::EarlyReturn(decision) => return Ok(decision),
2932    };
2933
2934    finalize_review_run(task, &ctx, &parsed, store).await;
2935
2936    let router_config = router.read().await.config.clone();
2937    apply_review_decision(
2938        task,
2939        backend,
2940        repo,
2941        task_manager,
2942        store,
2943        &ctx,
2944        &parsed,
2945        &router_config,
2946    )
2947    .await
2948}
2949
2950#[cfg(test)]
2951mod tests {
2952    use super::*;
2953
2954    use crate::backends::ExternalTask;
2955    use crate::engine::router::RouterConfig;
2956    use crate::github::types::{GitHubReview, GitHubReviewComment, GitHubUser, PullRequestReview};
2957    use crate::store::{TaskStatus, TaskStore};
2958    use tempfile::TempDir;
2959
2960    // ── outcome_for_agent_error ─────────────────────────────────────────────
2961
2962    #[test]
2963    fn outcome_for_agent_error_kimi_billing_cycle_is_not_rate_limit() {
2964        // Regression test for issue #3529: a review run hitting Kimi's billing-cycle
2965        // exhaustion (task 156734 in the bug report) must not be stored as a plain
2966        // "rate_limit" outcome.
2967        let err = runner::agents::AgentError::RateLimit {
2968            message: "Failed to authenticate. API Error: 403 You've reached your usage limit \
2969                      for this billing cycle. Your quota will be refreshed soon."
2970                .to_string(),
2971        };
2972        assert_eq!(outcome_for_agent_error(&err), "billing_cycle_exhausted");
2973    }
2974
2975    #[test]
2976    fn outcome_for_agent_error_generic_rate_limit_unaffected() {
2977        let err = runner::agents::AgentError::RateLimit {
2978            message: "429 Too Many Requests".to_string(),
2979        };
2980        assert_eq!(outcome_for_agent_error(&err), "rate_limit");
2981    }
2982
2983    #[test]
2984    fn outcome_for_agent_error_generic_auth_unaffected() {
2985        let err = runner::agents::AgentError::Auth {
2986            message: "401 Unauthorized".to_string(),
2987        };
2988        assert_eq!(outcome_for_agent_error(&err), "auth_error");
2989    }
2990
2991    // ── parse_pr_number_from_url ────────────────────────────────────────────
2992
2993    #[test]
2994    fn parse_pr_number_standard_url() {
2995        assert_eq!(
2996            parse_pr_number_from_url("https://github.com/owner/repo/pull/42"),
2997            Ok(42)
2998        );
2999    }
3000
3001    #[test]
3002    fn parse_pr_number_trailing_slash() {
3003        assert_eq!(
3004            parse_pr_number_from_url("https://github.com/owner/repo/pull/42/"),
3005            Ok(42)
3006        );
3007    }
3008
3009    #[test]
3010    fn parse_pr_number_with_query_string() {
3011        assert_eq!(
3012            parse_pr_number_from_url("https://github.com/owner/repo/pull/42?tab=files"),
3013            Ok(42)
3014        );
3015    }
3016
3017    #[test]
3018    fn parse_pr_number_with_hash_fragment() {
3019        assert_eq!(
3020            parse_pr_number_from_url("https://github.com/owner/repo/pull/42#issuecomment-123"),
3021            Ok(42)
3022        );
3023    }
3024
3025    #[test]
3026    fn parse_pr_number_with_query_and_hash() {
3027        assert_eq!(
3028            parse_pr_number_from_url("https://github.com/owner/repo/pull/42?quick_pull=1#top"),
3029            Ok(42)
3030        );
3031    }
3032
3033    #[test]
3034    fn parse_pr_number_with_leading_whitespace() {
3035        assert_eq!(
3036            parse_pr_number_from_url("  https://github.com/owner/repo/pull/42\n"),
3037            Ok(42)
3038        );
3039    }
3040
3041    #[test]
3042    fn parse_pr_number_wrong_domain() {
3043        let url = "https://gitlab.com/owner/repo/merge_requests/42";
3044        let result = parse_pr_number_from_url(url);
3045        assert!(result.is_err());
3046        assert!(result
3047            .unwrap_err()
3048            .contains("does not start with 'https://github.com/'"));
3049    }
3050
3051    #[test]
3052    fn parse_pr_number_missing_pull_segment() {
3053        // Issue URL — not a PR URL
3054        let url = "https://github.com/owner/repo/issues/42";
3055        let result = parse_pr_number_from_url(url);
3056        assert!(result.is_err());
3057        assert!(result.unwrap_err().contains("missing '/pull/' segment"));
3058    }
3059
3060    #[test]
3061    fn parse_pr_number_zero_is_rejected() {
3062        let url = "https://github.com/owner/repo/pull/0";
3063        let result = parse_pr_number_from_url(url);
3064        assert!(result.is_err());
3065        assert!(result.unwrap_err().contains("cannot be zero"));
3066    }
3067
3068    #[test]
3069    fn parse_pr_number_non_numeric_segment() {
3070        let url = "https://github.com/owner/repo/pull/abc";
3071        let result = parse_pr_number_from_url(url);
3072        assert!(result.is_err());
3073        assert!(result.unwrap_err().contains("not a valid positive integer"));
3074    }
3075
3076    #[test]
3077    fn parse_pr_number_empty_string() {
3078        let result = parse_pr_number_from_url("");
3079        assert!(result.is_err());
3080        assert!(result.unwrap_err().contains("empty"));
3081    }
3082
3083    #[test]
3084    fn parse_pr_number_http_not_https() {
3085        // HTTP URLs are not accepted — only HTTPS
3086        let url = "http://github.com/owner/repo/pull/42";
3087        let result = parse_pr_number_from_url(url);
3088        assert!(result.is_err());
3089        assert!(result
3090            .unwrap_err()
3091            .contains("does not start with 'https://github.com/'"));
3092    }
3093
3094    // ── format_review_error ─────────────────────────────────────────────────
3095    //
3096    // Regression coverage for issue #3149: review runs were persisting raw
3097    // `api_retry` NDJSON fragments into `task_runs.error` because the review
3098    // pipeline formatted rate-limit errors with `err.to_string()` instead of
3099    // routing the raw provider message through `summarize_rate_limit_error`.
3100
3101    #[test]
3102    fn format_review_error_summarises_rate_limit_fragment_with_api_retry() {
3103        // Real-world fragment captured from a minimax review run (issue #3149).
3104        // The provider message contains a complete api_retry JSON line so the
3105        // summariser should produce a clean `status=… after … attempts` summary.
3106        let raw_fragment = "{\"attempt\":6,\"max_retries\":10,\"retry_delay_ms\":17266.789,\"error_status\":429,\"error\":\"rate_limit\",\"session_id\":\"5c7148ee\"}\n\
3107                            {\"type\":\"system\",\"subtype\":\"api_retry\",\"attempt\":7,\"max_retries\":10,\"retry_delay_ms\":35162.66,\"error_status\":429,\"error\":\"rate_limit\",\"session_id\":\"5c7148ee\"}";
3108        let err = runner::agents::AgentError::RateLimit {
3109            message: raw_fragment.to_string(),
3110        };
3111
3112        let stored = format_review_error("minimax", &err);
3113
3114        // Must not contain raw JSON braces or the api_retry marker — these are
3115        // exactly the leaks that filled `task_runs.error` with noisy payloads.
3116        assert!(
3117            !stored.contains("{\"type\""),
3118            "stored error must not contain raw NDJSON envelope, got: {stored}"
3119        );
3120        assert!(
3121            !stored.contains("subtype"),
3122            "stored error must not contain raw api_retry fragment, got: {stored}"
3123        );
3124        assert!(
3125            !stored.contains("session_id"),
3126            "stored error must not contain session_id from provider payload, got: {stored}"
3127        );
3128        assert_eq!(
3129            stored,
3130            "minimax rate limit: status=429 after 7 attempts (last delay 35s)"
3131        );
3132    }
3133
3134    #[test]
3135    fn format_review_error_summarises_truncated_fragment_without_subtype_marker() {
3136        // Mid-object fragment that begins after the `subtype` key was cut off — this
3137        // is the shape that landed in `task_runs.error` for rows 13634/13542/9821
3138        // before the fix. The summariser's fallback path should emit a compact
3139        // hint rather than echoing the raw payload.
3140        let raw_fragment = ",\"attempt\":5,\"max_retries\":10,\"retry_delay_ms\":9034.81,\"error_status\":429,\"error\":\"rate_limit\",\"session_id\":\"992e17d3\"";
3141        let err = runner::agents::AgentError::RateLimit {
3142            message: raw_fragment.to_string(),
3143        };
3144
3145        let stored = format_review_error("glm", &err);
3146
3147        assert!(stored.starts_with("glm rate limit:"));
3148        assert!(
3149            !stored.contains("session_id"),
3150            "stored error must not leak session_id from truncated fragment, got: {stored}"
3151        );
3152        // The fragment first-line truncation path is bounded — verify the stored
3153        // string stays well under the 512-char cap suggested by the bug report.
3154        assert!(
3155            stored.len() <= 200,
3156            "stored error should be compact (<=200 chars), got {} chars: {stored}",
3157            stored.len()
3158        );
3159    }
3160
3161    #[test]
3162    fn format_review_error_preserves_non_rate_limit_variants() {
3163        let err = runner::agents::AgentError::Auth {
3164            message: "invalid bearer token".to_string(),
3165        };
3166        assert_eq!(
3167            format_review_error("kimi", &err),
3168            "kimi auth error: invalid bearer token"
3169        );
3170
3171        let err = runner::agents::AgentError::Timeout {
3172            elapsed: std::time::Duration::from_secs(1801),
3173        };
3174        assert_eq!(
3175            format_review_error("kimi", &err),
3176            "kimi timed out after 1801s"
3177        );
3178    }
3179
3180    fn git(dir: &std::path::Path, args: &[&str]) {
3181        let mut cmd = std::process::Command::new("git");
3182        cmd.current_dir(dir);
3183        // Global commit.gpgsign calls the operator's signer (1Password)
3184        if args.first() == Some(&"commit") {
3185            cmd.args(["-c", "commit.gpgsign=false"]);
3186        }
3187        let status = cmd.args(args).status().unwrap();
3188        assert!(status.success(), "git {:?} failed", args);
3189    }
3190
3191    #[test]
3192    fn test_pull_request_review_requests_changes() {
3193        let review = PullRequestReview {
3194            review: GitHubReview {
3195                id: 1,
3196                user: GitHubUser {
3197                    login: "reviewer".to_string(),
3198                },
3199                body: Some("Please fix".to_string()),
3200                state: "CHANGES_REQUESTED".to_string(),
3201                html_url: None,
3202                submitted_at: "2024-01-01T00:00:00Z".to_string(),
3203                commit_id: None,
3204            },
3205            comments: vec![],
3206        };
3207        assert!(review.requests_changes());
3208    }
3209
3210    #[test]
3211    fn test_pull_request_review_does_not_request_changes() {
3212        let review = PullRequestReview {
3213            review: GitHubReview {
3214                id: 1,
3215                user: GitHubUser {
3216                    login: "reviewer".to_string(),
3217                },
3218                body: Some("LGTM".to_string()),
3219                state: "APPROVED".to_string(),
3220                html_url: None,
3221                submitted_at: "2024-01-01T00:00:00Z".to_string(),
3222                commit_id: None,
3223            },
3224            comments: vec![],
3225        };
3226        assert!(!review.requests_changes());
3227    }
3228
3229    #[test]
3230    fn test_pull_request_review_actionable_comments_filters_empty_and_replies() {
3231        let review = PullRequestReview {
3232            review: GitHubReview {
3233                id: 1,
3234                user: GitHubUser {
3235                    login: "reviewer".to_string(),
3236                },
3237                body: None,
3238                state: "CHANGES_REQUESTED".to_string(),
3239                html_url: None,
3240                submitted_at: "2024-01-01T00:00:00Z".to_string(),
3241                commit_id: None,
3242            },
3243            comments: vec![
3244                GitHubReviewComment {
3245                    id: 1,
3246                    user: GitHubUser {
3247                        login: "reviewer".to_string(),
3248                    },
3249                    body: "Fix this issue".to_string(),
3250                    path: "src/main.rs".to_string(),
3251                    line: Some(10),
3252                    original_line: Some(10),
3253                    commit_id: "abc".to_string(),
3254                    original_commit_id: "abc".to_string(),
3255                    html_url: "url".to_string(),
3256                    created_at: "2024-01-01T00:00:00Z".to_string(),
3257                    updated_at: "2024-01-01T00:00:00Z".to_string(),
3258                    in_reply_to_id: None,
3259                    diff_hunk: Some(
3260                        "@@ -8,5 +8,5 @@ fn main() {\n-    let x = 1;\n+    let x = 2;".to_string(),
3261                    ),
3262                },
3263                GitHubReviewComment {
3264                    id: 2,
3265                    user: GitHubUser {
3266                        login: "reviewer".to_string(),
3267                    },
3268                    body: "".to_string(),
3269                    path: "src/lib.rs".to_string(),
3270                    line: Some(20),
3271                    original_line: Some(20),
3272                    commit_id: "abc".to_string(),
3273                    original_commit_id: "abc".to_string(),
3274                    html_url: "url".to_string(),
3275                    created_at: "2024-01-01T00:00:00Z".to_string(),
3276                    updated_at: "2024-01-01T00:00:00Z".to_string(),
3277                    in_reply_to_id: None,
3278                    diff_hunk: None,
3279                },
3280                GitHubReviewComment {
3281                    id: 3,
3282                    user: GitHubUser {
3283                        login: "reviewer".to_string(),
3284                    },
3285                    body: "Reply to this".to_string(),
3286                    path: "src/lib.rs".to_string(),
3287                    line: Some(30),
3288                    original_line: Some(30),
3289                    commit_id: "abc".to_string(),
3290                    original_commit_id: "abc".to_string(),
3291                    html_url: "url".to_string(),
3292                    created_at: "2024-01-01T00:00:00Z".to_string(),
3293                    updated_at: "2024-01-01T00:00:00Z".to_string(),
3294                    in_reply_to_id: Some(1),
3295                    diff_hunk: None,
3296                },
3297            ],
3298        };
3299        let actionable = review.actionable_comments();
3300        assert_eq!(actionable.len(), 1);
3301        assert_eq!(actionable[0].id, 1);
3302        assert_eq!(actionable[0].body, "Fix this issue");
3303        assert_eq!(actionable[0].path, "src/main.rs");
3304        assert_eq!(
3305            actionable[0].diff_hunk.as_ref().unwrap(),
3306            "@@ -8,5 +8,5 @@ fn main() {\n-    let x = 1;\n+    let x = 2;"
3307        );
3308    }
3309
3310    #[test]
3311    fn test_router_config_model_for_complexity_returns_none_without_config() {
3312        // With no model_map configured, model_for_complexity must return None rather than
3313        // silently using a hardcoded model that may not exist for the given agent.
3314        let cfg = RouterConfig::default();
3315        assert!(cfg.model_for_complexity("claude", "simple", "").is_none());
3316        assert!(cfg.model_for_complexity("opencode", "review", "").is_none());
3317        assert!(cfg
3318            .model_for_complexity("unknown_agent_xyz", "simple", "")
3319            .is_none());
3320    }
3321
3322    #[test]
3323    fn review_status_maps_decisions() {
3324        assert_eq!(
3325            review_status(&ReviewDecision::Approve).unwrap().0,
3326            "success"
3327        );
3328        let changes = ReviewDecision::RequestChanges {
3329            notes: String::new(),
3330            issues: vec![],
3331        };
3332        assert_eq!(review_status(&changes).unwrap().0, "failure");
3333        assert!(review_status(&ReviewDecision::Failed("x".into())).is_none());
3334    }
3335
3336    #[test]
3337    fn review_started_comment_includes_agent_and_model() {
3338        let expected_footer = attribution_footer("Review started", "kimi", Some("opus"));
3339        let expected = format!("🔍 Automated review started{}", expected_footer);
3340        assert_eq!(review_started_comment("kimi", "opus"), expected);
3341    }
3342
3343    // ── build_review_context: no worktree/branch/pr (issue #3623) ───────────
3344
3345    fn no_code_review_task(id: &str) -> ExternalTask {
3346        ExternalTask {
3347            id: crate::backends::ExternalId(id.to_string()),
3348            title: "opencode discovery timeout".to_string(),
3349            body: "".to_string(),
3350            state: "open".to_string(),
3351            labels: vec![],
3352            author: "tester".to_string(),
3353            created_at: "2026-01-01T00:00:00Z".to_string(),
3354            updated_at: "2026-01-01T00:00:00Z".to_string(),
3355            url: "https://example.com/issues/1".to_string(),
3356        }
3357    }
3358
3359    /// An approval only carries over while the PR head is still the commit that was
3360    /// approved. A force-push from another clone moves the head, and reviewing again is
3361    /// the safe answer in every other case (#3783)
3362    #[tokio::test]
3363    async fn carried_approval_counts_as_this_rounds_decision() {
3364        let store = Arc::new(TaskStore::open_memory().await.unwrap());
3365        let id = store
3366            .create_internal("owner/repo", "Approved head", "", "test", "carried", None)
3367            .await
3368            .unwrap();
3369        let task_id = format!("internal:{id}");
3370        store
3371            .update_status(id, crate::store::TaskStatus::InReview)
3372            .await
3373            .unwrap();
3374        assert_eq!(
3375            store.in_review_entry(id).await.unwrap().map(|e| e.1),
3376            Some(false)
3377        );
3378
3379        record_carried_approval(&store, "owner/repo", &task_id, 7).await;
3380
3381        assert_eq!(
3382            store.in_review_entry(id).await.unwrap().map(|e| e.1),
3383            Some(true),
3384            "the stale in_review check must see this round as decided"
3385        );
3386    }
3387
3388    #[test]
3389    fn approval_covers_head_only_for_the_exact_commit() {
3390        assert!(approval_covers_head(Some("abc123"), Some("abc123")));
3391        assert!(
3392            !approval_covers_head(Some("abc123"), Some("def456")),
3393            "a moved PR head must be reviewed"
3394        );
3395        assert!(
3396            !approval_covers_head(None, Some("abc123")),
3397            "no recorded approval means review"
3398        );
3399        assert!(
3400            !approval_covers_head(Some("abc123"), None),
3401            "an unreadable PR head must not skip the review"
3402        );
3403        assert!(!approval_covers_head(Some(""), Some("")));
3404    }
3405
3406    /**
3407     * The approval is recorded against the commit pushed to the PR and is dropped when a
3408     * review asks for changes, so the next NeedsReview event reviews the new work (#3783)
3409     **/
3410    #[tokio::test]
3411    async fn approved_sha_round_trips_through_the_kv_store() {
3412        let temp = TempDir::new().unwrap();
3413        let wt = temp.path();
3414        git(wt, &["init", "-b", "main"]);
3415        git(wt, &["config", "user.name", "Test"]);
3416        git(wt, &["config", "user.email", "test@test.com"]);
3417        std::fs::write(wt.join("fix.txt"), "the fix").unwrap();
3418        git(wt, &["add", "."]);
3419        git(wt, &["commit", "-m", "apply the fix"]);
3420
3421        let store = Arc::new(TaskStore::open_memory().await.unwrap());
3422        let repo = "owner/repo";
3423        let task_id = "3783";
3424
3425        record_approved_sha(&store, repo, task_id, 77, wt).await;
3426        let recorded = store
3427            .kv_get(&approved_sha_key(repo, task_id, 77))
3428            .await
3429            .unwrap()
3430            .expect("approval must be recorded");
3431        assert_eq!(
3432            recorded,
3433            worktree_head_sha(wt).await.unwrap(),
3434            "the recorded sha must be the commit that was pushed to the PR"
3435        );
3436        assert!(
3437            store
3438                .kv_get(&approved_sha_key(repo, task_id, 78))
3439                .await
3440                .unwrap()
3441                .is_none(),
3442            "an approval must not leak to another PR"
3443        );
3444
3445        clear_approved_sha(&store, repo, task_id, 77).await;
3446        assert!(
3447            store
3448                .kv_get(&approved_sha_key(repo, task_id, 77))
3449                .await
3450                .unwrap()
3451                .is_none(),
3452            "requesting changes must drop the approval"
3453        );
3454    }
3455
3456    #[tokio::test]
3457    async fn build_review_context_reroutes_external_task_with_no_code() {
3458        let store = Arc::new(TaskStore::open_memory().await.unwrap());
3459        let repo = "owner/repo";
3460        let ext = no_code_review_task("3617");
3461        store.ensure_external_task(repo, &ext).await.unwrap();
3462
3463        let backend = Arc::new(crate::backends::test_helpers::NoopBackend);
3464        let task_manager = Arc::new(TaskManager::with_store(
3465            backend,
3466            Arc::clone(&store),
3467            repo.to_string(),
3468        ));
3469        let router = Arc::new(RwLock::new(Router::new(RouterConfig::default())));
3470
3471        let phase = build_review_context(&ext, repo, &router, &task_manager, &store)
3472            .await
3473            .unwrap();
3474
3475        assert!(
3476            matches!(phase, ReviewPhase::EarlyReturn(ReviewDecision::Rerouted)),
3477            "external task with no worktree/branch/pr must reroute, not skip-to-done"
3478        );
3479
3480        let stored = store
3481            .get_by_external_id(repo, &ext.id.0)
3482            .await
3483            .unwrap()
3484            .expect("task must still exist");
3485        assert_eq!(stored.status, TaskStatus::New);
3486        assert!(stored.agent.is_none());
3487        assert!(stored.model.is_none());
3488    }
3489
3490    #[tokio::test]
3491    async fn build_review_context_blocks_external_task_after_max_reroutes() {
3492        // Isolate from the real ~/.orch/config.yml (which may set a different
3493        // workflow.max_attempts) so the default of 3 reroutes is deterministic.
3494        let temp_home = TempDir::new().unwrap();
3495        let orch_dir = temp_home.path().join(".orch");
3496        std::fs::create_dir_all(&orch_dir).unwrap();
3497        let old_orch_home = std::env::var_os("ORCH_HOME");
3498        std::env::set_var("ORCH_HOME", &orch_dir);
3499
3500        let store = Arc::new(TaskStore::open_memory().await.unwrap());
3501        let repo = "owner/repo";
3502        let ext = no_code_review_task("3618");
3503        store.ensure_external_task(repo, &ext).await.unwrap();
3504
3505        let backend = Arc::new(crate::backends::test_helpers::NoopBackend);
3506        let task_manager = Arc::new(TaskManager::with_store(
3507            backend,
3508            Arc::clone(&store),
3509            repo.to_string(),
3510        ));
3511        let router = Arc::new(RwLock::new(Router::new(RouterConfig::default())));
3512
3513        // Default max_reroute_attempts is 3 — exhaust it.
3514        for _ in 0..3 {
3515            let phase = build_review_context(&ext, repo, &router, &task_manager, &store)
3516                .await
3517                .unwrap();
3518            if let ReviewPhase::EarlyReturn(ReviewDecision::Blocked(_)) = phase {
3519                break;
3520            }
3521        }
3522
3523        let stored = store
3524            .get_by_external_id(repo, &ext.id.0)
3525            .await
3526            .unwrap()
3527            .expect("task must still exist");
3528        assert_eq!(stored.status, TaskStatus::Blocked);
3529
3530        if let Some(old) = old_orch_home {
3531            std::env::set_var("ORCH_HOME", old);
3532        } else {
3533            std::env::remove_var("ORCH_HOME");
3534        }
3535    }
3536
3537    #[tokio::test]
3538    async fn build_review_context_marks_internal_task_done_with_no_code() {
3539        let store = Arc::new(TaskStore::open_memory().await.unwrap());
3540        let repo = "owner/repo";
3541        let internal_id = store
3542            .create_internal(repo, "Daily retrospective", "", "job", "job-1", None)
3543            .await
3544            .unwrap();
3545        let ext_id = format!("internal:{internal_id}");
3546        let ext = no_code_review_task(&ext_id);
3547
3548        let backend = Arc::new(crate::backends::test_helpers::NoopBackend);
3549        let task_manager = Arc::new(TaskManager::with_store(
3550            backend,
3551            Arc::clone(&store),
3552            repo.to_string(),
3553        ));
3554        let router = Arc::new(RwLock::new(Router::new(RouterConfig::default())));
3555
3556        let phase = build_review_context(&ext, repo, &router, &task_manager, &store)
3557            .await
3558            .unwrap();
3559
3560        assert!(matches!(
3561            phase,
3562            ReviewPhase::EarlyReturn(ReviewDecision::Skipped)
3563        ));
3564        let stored = store.get(internal_id).await.unwrap();
3565        assert_eq!(stored.status, TaskStatus::Done);
3566    }
3567
3568    #[test]
3569    fn no_code_reroute_skip_check_rejects_agent_stream_disconnects() {
3570        let last_error =
3571            "codex failed: Reconnecting... (stream disconnected before completion: Broken pipe)";
3572        assert!(!should_skip_no_code_reroute_increment(last_error));
3573    }
3574
3575    // ── review fallback rate-limit/auth scan is bounded, not full-text ──────
3576    //
3577    // Regression test for a review transcript whose unparseable fallback text
3578    // is a long report/diff quoting a status table (`outcome` column values
3579    // like `rate_limit`, `failed`, `timeout`). Scanning the full text falsely
3580    // classified the review run as rate-limited even though the review agent
3581    // never actually hit a rate limit.
3582
3583    #[test]
3584    fn review_fallback_scan_ignores_rate_limit_word_buried_in_long_report() {
3585        let padding = "x".repeat(10_000);
3586        let text_for_review = format!(
3587            "{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}"
3588        );
3589
3590        let scan_head = runner::agents::patterns::safe_head(
3591            &text_for_review,
3592            runner::agents::patterns::RATE_LIMIT_SCAN_HEAD_BYTES,
3593        );
3594        let scan_tail = runner::agents::patterns::safe_tail(
3595            &text_for_review,
3596            runner::agents::patterns::RATE_LIMIT_SCAN_TAIL_BYTES,
3597        );
3598
3599        assert!(runner::agents::patterns::detect_rate_limit(scan_head).is_none());
3600        assert!(runner::agents::patterns::detect_rate_limit(scan_tail).is_none());
3601    }
3602
3603    #[test]
3604    fn review_fallback_scan_still_detects_real_rate_limit_at_tail() {
3605        let padding = "x".repeat(10_000);
3606        let text_for_review =
3607            format!("{padding}\nError: rate limit exceeded, please try again later");
3608
3609        let scan_tail = runner::agents::patterns::safe_tail(
3610            &text_for_review,
3611            runner::agents::patterns::RATE_LIMIT_SCAN_TAIL_BYTES,
3612        );
3613
3614        assert!(runner::agents::patterns::detect_rate_limit(scan_tail).is_some());
3615    }
3616
3617    #[tokio::test]
3618    async fn recover_missing_review_worktree_recreates_worktree() {
3619        let temp_home = TempDir::new().unwrap();
3620        let orch_dir = temp_home.path().join(".orch");
3621        std::fs::create_dir_all(&orch_dir).unwrap();
3622        let old_orch_home = std::env::var_os("ORCH_HOME");
3623        std::env::set_var("ORCH_HOME", &orch_dir);
3624
3625        let project_dir = temp_home.path().join("project");
3626        std::fs::create_dir_all(&project_dir).unwrap();
3627        git(&project_dir, &["init", "-b", "main"]);
3628        git(&project_dir, &["config", "user.name", "Test User"]);
3629        git(&project_dir, &["config", "user.email", "test@example.com"]);
3630        std::fs::write(project_dir.join("README.md"), "hello").unwrap();
3631        git(&project_dir, &["add", "README.md"]);
3632        git(&project_dir, &["commit", "-m", "init"]);
3633
3634        std::fs::write(
3635            orch_dir.join("config.yml"),
3636            format!("projects:\n  - {}\n", project_dir.display()),
3637        )
3638        .unwrap();
3639        std::fs::write(project_dir.join(".orch.yml"), "gh:\n  repo: owner/repo\n").unwrap();
3640
3641        let store = Arc::new(TaskStore::open_memory().await.unwrap());
3642        let repo = "owner/repo";
3643        store
3644            .create_internal(repo, "Fix review recovery", "", "review", "review-1", None)
3645            .await
3646            .unwrap();
3647        let ext = ExternalTask {
3648            id: crate::backends::ExternalId("1".to_string()),
3649            title: "Fix review recovery".to_string(),
3650            body: "".to_string(),
3651            state: "open".to_string(),
3652            labels: vec![],
3653            author: "tester".to_string(),
3654            created_at: "2026-01-01T00:00:00Z".to_string(),
3655            updated_at: "2026-01-01T00:00:00Z".to_string(),
3656            url: "https://example.com/issues/1".to_string(),
3657        };
3658        store.ensure_external_task(repo, &ext).await.unwrap();
3659
3660        let recovered = recover_missing_review_worktree(&ext, repo, &store)
3661            .await
3662            .unwrap();
3663
3664        assert!(recovered.work_dir.exists());
3665        assert!(recovered.branch.starts_with("gh-issue-1-"));
3666
3667        if let Some(old) = old_orch_home {
3668            std::env::set_var("ORCH_HOME", old);
3669        } else {
3670            std::env::remove_var("ORCH_HOME");
3671        }
3672    }
3673
3674    // ── count_ahead_commits ─────────────────────────────────────────────────
3675
3676    #[tokio::test]
3677    async fn count_ahead_commits_returns_zero_when_no_ahead_commits() {
3678        let temp = TempDir::new().unwrap();
3679        let dir = temp.path();
3680
3681        // Create a bare repo to act as the 'origin' remote
3682        let bare = temp.path().join("bare.git");
3683        std::fs::create_dir_all(&bare).unwrap();
3684        std::process::Command::new("git")
3685            .args(["init", "--bare"])
3686            .current_dir(&bare)
3687            .output()
3688            .unwrap();
3689
3690        git(dir, &["init", "-b", "main"]);
3691        git(dir, &["config", "user.name", "Test"]);
3692        git(dir, &["config", "user.email", "test@test.com"]);
3693        std::fs::write(dir.join("f"), "a").unwrap();
3694        git(dir, &["add", "f"]);
3695        git(dir, &["commit", "-m", "init"]);
3696        std::process::Command::new("git")
3697            .args(["remote", "add", "origin", bare.to_str().unwrap()])
3698            .current_dir(dir)
3699            .output()
3700            .unwrap();
3701        std::process::Command::new("git")
3702            .args(["push", "origin", "main"])
3703            .current_dir(dir)
3704            .output()
3705            .unwrap();
3706
3707        // origin/main now exists and HEAD is at the same commit — zero ahead
3708        let result = count_ahead_commits(dir, "main").await;
3709        assert!(
3710            result.is_ok(),
3711            "expected Ok when origin/main exists, got {result:?}"
3712        );
3713        assert_eq!(
3714            result.unwrap(),
3715            0,
3716            "expected 0 commits ahead when branch matches origin/main"
3717        );
3718    }
3719
3720    #[tokio::test]
3721    async fn count_ahead_commits_returns_error_when_remote_ref_missing() {
3722        let temp = TempDir::new().unwrap();
3723        let dir = temp.path();
3724        git(dir, &["init", "-b", "main"]);
3725        git(dir, &["config", "user.name", "Test"]);
3726        git(dir, &["config", "user.email", "test@test.com"]);
3727        std::fs::write(dir.join("f"), "a").unwrap();
3728        git(dir, &["add", "f"]);
3729        git(dir, &["commit", "-m", "init"]);
3730        // No remote 'origin' configured — rev-list will fail
3731        let result = count_ahead_commits(dir, "main").await;
3732        assert!(
3733            result.is_err(),
3734            "expected Err when origin/main does not exist, got {result:?}"
3735        );
3736        let err = result.unwrap_err();
3737        assert!(
3738            err.contains("git rev-list exited"),
3739            "error should mention exit failure, got: {err}"
3740        );
3741    }
3742
3743    #[tokio::test]
3744    async fn count_ahead_commits_returns_error_when_git_cannot_start() {
3745        // Use a path that is not a git repo — git will fail with non-zero exit
3746        let temp = TempDir::new().unwrap();
3747        let dir = temp.path();
3748        // Not a git repo at all
3749        let result = count_ahead_commits(dir, "main").await;
3750        assert!(
3751            result.is_err(),
3752            "expected Err when directory is not a git repo, got {result:?}"
3753        );
3754    }
3755
3756    // ── review cooldown escalation (issue #3314) ────────────────────────────
3757
3758    /// Review `InvalidResponse` (hard failure: empty/error output) must apply the
3759    /// persistent model backoff (~4h base) so the model isn't re-selected on the
3760    /// next tick after a brief standard cooldown expires.
3761    ///
3762    /// Regression test for issue #3314: `record_model_failure` (5-min base) was used
3763    /// instead of `record_persistent_model_failure` (4h base), allowing repeated
3764    /// re-selection of a structurally broken review model.
3765    #[serial_test::serial(cooldown_state)]
3766    #[tokio::test]
3767    async fn review_invalid_response_applies_persistent_model_cooldown() {
3768        crate::engine::cooldown::reset_global_state().await;
3769        let agent = "opencode-3314-review-invalid";
3770        let model = "opencode/north-mini-code-free";
3771        let key = format!("{agent}:{model}");
3772
3773        assert!(
3774            !crate::engine::cooldown::is_model_in_cooldown(agent, model),
3775            "model should not be in cooldown before record_review_agent_failure"
3776        );
3777
3778        let err = runner::agents::AgentError::InvalidResponse {
3779            raw: "empty review output".to_string(),
3780        };
3781        record_review_agent_failure("test-3314-a", agent, Some(model), &err).await;
3782
3783        let now = chrono::Utc::now().timestamp();
3784        let until = crate::engine::cooldown::cooldown_until(&key)
3785            .expect("review InvalidResponse should set model cooldown");
3786        let remaining = until.saturating_sub(now);
3787
3788        // Persistent backoff base is ~4h (14400s); standard model backoff base is 5min (300s).
3789        // Verify the persistent (4h) path was taken, not the short one.
3790        assert!(
3791            remaining >= crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
3792            "review InvalidResponse must apply ~4h persistent cooldown (issue #3314), \
3793             got {remaining}s — may have used 5-min standard backoff instead"
3794        );
3795    }
3796
3797    /// Review `parse_error` (got output but couldn't parse it as a review response) must
3798    /// also apply the persistent model backoff so the model is cooled long enough to
3799    /// prevent re-selection after a brief cooldown or a success-reset from another task.
3800    ///
3801    /// Verifies the `record_review_agent_failure` path for `InvalidResponse`
3802    /// (which covers both the hard-failure and soft parse-error paths that were
3803    /// changed in the fix for issue #3314).
3804    #[serial_test::serial(cooldown_state)]
3805    #[tokio::test]
3806    async fn review_persistent_cooldown_survives_after_first_failure() {
3807        crate::engine::cooldown::reset_global_state().await;
3808        let agent = "opencode-3314-review-parse";
3809        let model = "opencode/north-mini-code-free";
3810        let key = format!("{agent}:{model}");
3811
3812        // First failure: persistent backoff should apply immediately
3813        let err = runner::agents::AgentError::InvalidResponse {
3814            raw: "failed to parse review response".to_string(),
3815        };
3816        record_review_agent_failure("test-3314-b", agent, Some(model), &err).await;
3817
3818        let now = chrono::Utc::now().timestamp();
3819        let until1 = crate::engine::cooldown::cooldown_until(&key)
3820            .expect("first failure should set cooldown");
3821        let remaining1 = until1.saturating_sub(now);
3822
3823        // Simulate a success for the same agent+model (from a non-review task).
3824        // This resets failure_count:{agent}:{model} to 0.
3825        crate::engine::cooldown::record_agent_success(agent, model).await;
3826
3827        // After success reset, cooldown timestamp is still set — the success only
3828        // resets the failure count, NOT the existing cooldown timer.
3829        // The next review failure will restart from count=1 (base backoff).
3830        // The key invariant: persistent base (4h) >> standard base (5min) so even
3831        // after a reset, the first-failure cooldown still lasts ~4h.
3832        assert!(
3833            remaining1 >= crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
3834            "after success reset, first-failure cooldown should still be ~4h, got {remaining1}s"
3835        );
3836    }
3837
3838    /// Regression test for issue #3551: a billing-cycle-exhaustion message caught by
3839    /// the review module's *inline text scan* (`detect_rate_limit` on raw review
3840    /// output, used when the review process exits without a clean `AgentError`) must
3841    /// get the same treatment as the `AgentError`-based failure path fixed by #3530 —
3842    /// not a hardcoded `"rate_limit"` outcome plus a generic short agent cooldown.
3843    #[serial_test::serial(cooldown_state)]
3844    #[tokio::test]
3845    async fn review_inline_scan_billing_cycle_message_gets_model_scoped_cooldown() {
3846        crate::engine::cooldown::reset_global_state().await;
3847        let agent = "kimi-3551-review-inline";
3848        let model = "opus";
3849        let key = format!("{agent}:{model}");
3850
3851        // Verbatim text from the observed kimi failure (issue #3551).
3852        let text_for_review = "kimi rate limit: Failed to authenticate. API Error: 403 \
3853            You've reached your usage limit for this billing cycle. Your quota will be \
3854            refreshed next cycle.";
3855
3856        let scan_tail = runner::agents::patterns::safe_tail(
3857            text_for_review,
3858            runner::agents::patterns::RATE_LIMIT_SCAN_TAIL_BYTES,
3859        );
3860        let err = runner::agents::patterns::detect_rate_limit(scan_tail)
3861            .expect("billing-cycle message must still be caught by the rate-limit scan");
3862
3863        assert_eq!(
3864            outcome_for_agent_error(&err),
3865            "billing_cycle_exhausted",
3866            "inline-scanned billing-cycle message must not be stored as plain rate_limit"
3867        );
3868
3869        record_review_agent_failure("test-3551", agent, Some(model), &err).await;
3870
3871        let now = chrono::Utc::now().timestamp();
3872        let until = crate::engine::cooldown::cooldown_until(&key)
3873            .expect("billing-cycle exhaustion must set a model-scoped cooldown");
3874        let remaining = until.saturating_sub(now);
3875        // Known model + BillingCycleExhausted routes to record_persistent_model_failure
3876        // (~4h base, 7d cap), not the agent-wide record_credit_exhaustion (~24h) path —
3877        // the quota is scoped to this model, so unrelated models on the same agent must
3878        // stay routable.
3879        assert!(
3880            remaining >= crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
3881            "billing-cycle exhaustion for a known model must apply the ~4h persistent \
3882             model cooldown, got {remaining}s — may have used the generic short agent \
3883             cooldown instead"
3884        );
3885    }
3886
3887    // ── review success resets failure_count (issue #3478) ──────────────────
3888
3889    fn test_review_task(id: &str) -> ExternalTask {
3890        ExternalTask {
3891            id: crate::backends::ExternalId(id.to_string()),
3892            title: "Test review task".to_string(),
3893            body: "".to_string(),
3894            state: "open".to_string(),
3895            labels: vec![],
3896            author: "tester".to_string(),
3897            created_at: "2026-01-01T00:00:00Z".to_string(),
3898            updated_at: "2026-01-01T00:00:00Z".to_string(),
3899            url: "https://example.com/issues/1".to_string(),
3900        }
3901    }
3902
3903    fn test_review_context(agent: &str, model: &str, work_dir: &std::path::Path) -> ReviewContext {
3904        ReviewContext {
3905            store_id: None,
3906            had_prev_push_failure: false,
3907            review_attempt: 1,
3908            worktree_path: work_dir.to_path_buf(),
3909            branch_name: "gh-issue-1-test".to_string(),
3910            pr_number: 1,
3911            head_sha: None,
3912            default_branch: "main".to_string(),
3913            review_agent: agent.to_string(),
3914            review_model: Some(model.to_string()),
3915            review_task_id: "review-1".to_string(),
3916            review_attempt_dir: work_dir.to_path_buf(),
3917            output_file: work_dir.join("output.txt"),
3918            invocation: runner::agent::AgentInvocation {
3919                agent: agent.to_string(),
3920                model: Some(model.to_string()),
3921                work_dir: work_dir.to_path_buf(),
3922                system_prompt: String::new(),
3923                agent_message: String::new(),
3924                task_id: "review-1".to_string(),
3925                disallowed_tools: vec![],
3926                git_author_name: "Test".to_string(),
3927                git_author_email: "test@example.com".to_string(),
3928                output_file: work_dir.join("output.txt"),
3929                timeout_seconds: 0,
3930                repo: "owner/repo".to_string(),
3931                attempt: 1,
3932                sandbox: false,
3933                main_project_dir: work_dir.to_path_buf(),
3934                branch: String::new(),
3935            },
3936            same_agent_fallback: false,
3937        }
3938    }
3939
3940    fn test_parsed_review(decision: ReviewDecision) -> ParsedReview {
3941        ParsedReview {
3942            run_id: None,
3943            exit_code: 0,
3944            stderr: String::new(),
3945            raw_output: String::new(),
3946            text_for_review: String::new(),
3947            token_usage: RunTokenUsage::default(),
3948            review_notes_for_comment: String::new(),
3949            decision,
3950        }
3951    }
3952
3953    fn write_review_output(dir: &std::path::Path, stdout: &str, exit_code: i32) {
3954        std::fs::write(dir.join("output.txt"), stdout).unwrap();
3955        std::fs::write(dir.join("exit.txt"), exit_code.to_string()).unwrap();
3956        std::fs::write(dir.join("stderr.txt"), "").unwrap();
3957    }
3958
3959    /// A kimi review that exits mid-thought (no result envelope, last content is a
3960    /// thinking block) must be stored as incomplete and must not apply the 4h
3961    /// persistent model cooldown (issue #3734).
3962    #[serial_test::serial(cooldown_state)]
3963    #[tokio::test]
3964    async fn parse_review_output_mid_thought_is_incomplete_without_persistent_cooldown() {
3965        crate::engine::cooldown::reset_global_state().await;
3966        let store = Arc::new(TaskStore::open_memory().await.unwrap());
3967        let work_dir = TempDir::new().unwrap();
3968        let agent = "kimi";
3969        let model = "opus-3734-mid-thought";
3970        let ctx = test_review_context(agent, model, work_dir.path());
3971        let redraw = "^D\u{0008}".repeat(20);
3972        let ndjson = format!(
3973            "{}\n{}\n{}\n{}\n{}",
3974            r#"{"type":"system","subtype":"thinking_tokens","tokens":1945}"#,
3975            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}}}"#,
3976            r#"{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"checking lease edge cases"}],"stop_reason":null,"usage":{"input_tokens":10,"output_tokens":0}}}"#,
3977            "\x1b[?25h",
3978            redraw,
3979        );
3980        write_review_output(work_dir.path(), &ndjson, 0);
3981
3982        let task = test_review_task("3734");
3983        let phase = parse_review_output(
3984            &task,
3985            "owner/repo",
3986            &ctx,
3987            ReviewRun { run_id: None },
3988            &store,
3989        )
3990        .await;
3991
3992        match phase {
3993            ReviewPhase::EarlyReturn(ReviewDecision::Failed(reason)) => {
3994                assert!(
3995                    reason.contains("incomplete:"),
3996                    "mid-thought review must be stored as incomplete, got {reason}"
3997                );
3998            }
3999            ReviewPhase::EarlyReturn(_) => panic!("expected Failed(incomplete)"),
4000            ReviewPhase::Ready(_) => panic!("expected Failed(incomplete), got Ready"),
4001        }
4002        assert!(
4003            !crate::engine::cooldown::is_model_in_cooldown(agent, model),
4004            "mid-thought incomplete must not apply persistent model cooldown"
4005        );
4006    }
4007
4008    /// A tool_use as the last assistant content with no result envelope is the
4009    /// same incomplete shape as a thinking block (issue #3734).
4010    #[serial_test::serial(cooldown_state)]
4011    #[tokio::test]
4012    async fn parse_review_output_tool_use_without_envelope_is_incomplete() {
4013        crate::engine::cooldown::reset_global_state().await;
4014        let store = Arc::new(TaskStore::open_memory().await.unwrap());
4015        let work_dir = TempDir::new().unwrap();
4016        let agent = "kimi";
4017        let model = "opus-3734-tool-use";
4018        let ctx = test_review_context(agent, model, work_dir.path());
4019        let ndjson = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Read","input":{"file_path":"src/lib.rs"}}]}}"#;
4020        write_review_output(work_dir.path(), ndjson, 0);
4021
4022        let task = test_review_task("3734-tool");
4023        let phase = parse_review_output(
4024            &task,
4025            "owner/repo",
4026            &ctx,
4027            ReviewRun { run_id: None },
4028            &store,
4029        )
4030        .await;
4031
4032        match phase {
4033            ReviewPhase::EarlyReturn(ReviewDecision::Failed(reason)) => {
4034                assert!(
4035                    reason.contains("incomplete:"),
4036                    "tool_use without envelope must be incomplete, got {reason}"
4037                );
4038            }
4039            ReviewPhase::EarlyReturn(_) => panic!("expected Failed(incomplete)"),
4040            ReviewPhase::Ready(_) => panic!("expected Failed(incomplete), got Ready"),
4041        }
4042        assert!(!crate::engine::cooldown::is_model_in_cooldown(agent, model));
4043    }
4044
4045    /// A finished result envelope whose text is not a review decision still
4046    /// gets the persistent format-failure cooldown.
4047    #[serial_test::serial(cooldown_state)]
4048    #[tokio::test]
4049    async fn parse_review_output_unparseable_envelope_applies_persistent_cooldown() {
4050        crate::engine::cooldown::reset_global_state().await;
4051        let store = Arc::new(TaskStore::open_memory().await.unwrap());
4052        let work_dir = TempDir::new().unwrap();
4053        let agent = "kimi";
4054        let model = "opus-3734-format";
4055        let ctx = test_review_context(agent, model, work_dir.path());
4056        let ndjson = concat!(
4057            r#"{"type":"assistant","message":{"content":[{"type":"text","text":"I have finished looking at the code."}]}}"#,
4058            "\n",
4059            r#"{"type":"result","subtype":"success","is_error":false,"result":"I have finished looking at the code."}"#,
4060        );
4061        write_review_output(work_dir.path(), ndjson, 0);
4062
4063        let task = test_review_task("3734-format");
4064        let phase = parse_review_output(
4065            &task,
4066            "owner/repo",
4067            &ctx,
4068            ReviewRun { run_id: None },
4069            &store,
4070        )
4071        .await;
4072
4073        match phase {
4074            ReviewPhase::EarlyReturn(ReviewDecision::Failed(reason)) => {
4075                assert!(
4076                    reason.contains("parse error"),
4077                    "finished unparseable envelope must stay a parse error, got {reason}"
4078                );
4079            }
4080            ReviewPhase::EarlyReturn(_) => panic!("expected Failed(parse error)"),
4081            ReviewPhase::Ready(_) => panic!("expected Failed(parse error), got Ready"),
4082        }
4083        assert!(
4084            crate::engine::cooldown::is_model_in_cooldown(agent, model),
4085            "finished format failure must apply persistent model cooldown"
4086        );
4087    }
4088
4089    /// Missing result envelope with a review decision in assistant text uses
4090    /// the assistant-text extractor, not MiniMax status=done synthesis.
4091    #[serial_test::serial(cooldown_state)]
4092    #[tokio::test]
4093    async fn parse_review_output_missing_envelope_uses_assistant_decision() {
4094        crate::engine::cooldown::reset_global_state().await;
4095        let store = Arc::new(TaskStore::open_memory().await.unwrap());
4096        let work_dir = TempDir::new().unwrap();
4097        let agent = "kimi";
4098        let model = "opus-3734-assistant";
4099        let ctx = test_review_context(agent, model, work_dir.path());
4100        let ndjson = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"issues\":[]}"}]}}"#;
4101        write_review_output(work_dir.path(), ndjson, 0);
4102
4103        let task = test_review_task("3734-asst");
4104        let phase = parse_review_output(
4105            &task,
4106            "owner/repo",
4107            &ctx,
4108            ReviewRun { run_id: None },
4109            &store,
4110        )
4111        .await;
4112
4113        match phase {
4114            ReviewPhase::Ready(parsed) => {
4115                assert!(matches!(parsed.decision, ReviewDecision::Approve));
4116            }
4117            ReviewPhase::EarlyReturn(_) => {
4118                panic!("expected Ready(Approve) from assistant text, got EarlyReturn")
4119            }
4120        }
4121        assert!(!crate::engine::cooldown::is_model_in_cooldown(agent, model));
4122    }
4123
4124    /// MiniMax/Kimi parse_response synthesizes status=done from any assistant
4125    /// text. That task-path fallback must not be treated as a review approval.
4126    #[serial_test::serial(cooldown_state)]
4127    #[tokio::test]
4128    async fn parse_review_output_does_not_treat_task_done_synthesis_as_approval() {
4129        crate::engine::cooldown::reset_global_state().await;
4130        let store = Arc::new(TaskStore::open_memory().await.unwrap());
4131        let work_dir = TempDir::new().unwrap();
4132        let agent = "kimi";
4133        let model = "opus-3734-done";
4134        let ctx = test_review_context(agent, model, work_dir.path());
4135        let ndjson = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"analyzing the diff"}]}}"#;
4136        write_review_output(work_dir.path(), ndjson, 0);
4137
4138        let task = test_review_task("3734-done");
4139        let phase = parse_review_output(
4140            &task,
4141            "owner/repo",
4142            &ctx,
4143            ReviewRun { run_id: None },
4144            &store,
4145        )
4146        .await;
4147
4148        match phase {
4149            ReviewPhase::EarlyReturn(ReviewDecision::Failed(reason)) => {
4150                assert!(
4151                    reason.contains("parse error"),
4152                    "unfinished prose without a decision must not approve, got {reason}"
4153                );
4154            }
4155            ReviewPhase::Ready(_) => {
4156                panic!("task-path status=done synthesis must not become a review approval")
4157            }
4158            ReviewPhase::EarlyReturn(_) => panic!("expected Failed(parse error)"),
4159        }
4160    }
4161
4162    /// Regression test for issue #3478: a successful review run must reset
4163    /// `failure_count:{agent}:{model}`, mirroring the runner's task-agent
4164    /// success path, for generic/transient failures (not the persistent-model
4165    /// path — see issue #3571, which deliberately gives persistent failures
4166    /// their own counter that a success does NOT reset).
4167    #[serial_test::serial(cooldown_state)]
4168    #[tokio::test]
4169    async fn finalize_review_run_success_resets_generic_model_failure_count() {
4170        crate::engine::cooldown::reset_global_state().await;
4171        let store = Arc::new(TaskStore::open_memory().await.unwrap());
4172        crate::engine::cooldown::init_cooldown_store(store.clone()).await;
4173
4174        let agent = "test-3478-agent";
4175        let model = "test-3478-model";
4176        let failure_key = format!("failure_count:{agent}:{model}");
4177
4178        // AgentFailed is a generic/transient error, routed to record_model_failure
4179        // (not record_persistent_model_failure), so it should still be reset by
4180        // an intervening success.
4181        let err = runner::agents::AgentError::AgentFailed {
4182            message: "agent reported a generic failure".to_string(),
4183        };
4184        record_review_agent_failure("task-3478-a", agent, Some(model), &err).await;
4185        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4186        assert_eq!(
4187            store.kv_get(&failure_key).await.unwrap().as_deref(),
4188            Some("1"),
4189            "sanity check: failure should have incremented the persisted count"
4190        );
4191
4192        let work_dir = TempDir::new().unwrap();
4193        let ctx = test_review_context(agent, model, work_dir.path());
4194        let parsed = test_parsed_review(ReviewDecision::Approve);
4195        let task = test_review_task("1");
4196
4197        finalize_review_run(&task, &ctx, &parsed, &store).await;
4198        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4199
4200        assert_eq!(
4201            store.kv_get(&failure_key).await.unwrap().as_deref(),
4202            Some("0"),
4203            "successful review must reset failure_count:{{agent}}:{{model}} (issue #3478)"
4204        );
4205    }
4206
4207    /// Regression test for issue #3478: successful reviews interleaved between
4208    /// isolated generic failures must keep each failure's backoff at the base
4209    /// duration instead of escalating, since the previous success reset the
4210    /// counter. Uses AgentFailed (generic/transient), not InvalidResponse
4211    /// (persistent-model path — see issue #3571).
4212    #[serial_test::serial(cooldown_state)]
4213    #[tokio::test]
4214    async fn finalize_review_run_interleaved_success_keeps_generic_cooldown_at_base() {
4215        crate::engine::cooldown::reset_global_state().await;
4216        let store = Arc::new(TaskStore::open_memory().await.unwrap());
4217        crate::engine::cooldown::init_cooldown_store(store.clone()).await;
4218
4219        let agent = "test-3478-interleave-agent";
4220        let model = "test-3478-interleave-model";
4221        let cooldown_key = format!("{agent}:{model}");
4222
4223        let work_dir = TempDir::new().unwrap();
4224        let ctx = test_review_context(agent, model, work_dir.path());
4225        let task = test_review_task("2");
4226
4227        for i in 0..4 {
4228            let err = runner::agents::AgentError::AgentFailed {
4229                message: format!("failure {i}"),
4230            };
4231            record_review_agent_failure("task-3478-b", agent, Some(model), &err).await;
4232
4233            let now = chrono::Utc::now().timestamp();
4234            let until = crate::engine::cooldown::cooldown_until(&cooldown_key)
4235                .expect("failure should set cooldown");
4236            let remaining = until.saturating_sub(now);
4237
4238            assert!(
4239                remaining <= crate::engine::cooldown::BACKOFF_BASE_SECS + 60,
4240                "failure #{} must stay at base backoff since the prior success reset the \
4241                 counter (issue #3478); got {remaining}s, base is {}s — an escalating value \
4242                 here means review successes are not resetting failure_count",
4243                i + 1,
4244                crate::engine::cooldown::BACKOFF_BASE_SECS
4245            );
4246
4247            // A successful review interleaves between each failure, mirroring the
4248            // real-world pattern from issue #3478 (reviews for other tasks succeed
4249            // in between isolated failures for this model).
4250            let parsed = test_parsed_review(ReviewDecision::Approve);
4251            finalize_review_run(&task, &ctx, &parsed, &store).await;
4252            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
4253        }
4254    }
4255
4256    /// Regression test for issue #3571: unlike generic failures, persistent
4257    /// model failures (e.g. review parse errors, InvalidResponse) must NOT be
4258    /// reset by an intervening successful review. Otherwise a model that fails
4259    /// structurally every so often — but succeeds most of the time — never
4260    /// escalates past the 4h base cooldown and stays in rotation indefinitely.
4261    #[serial_test::serial(cooldown_state)]
4262    #[tokio::test]
4263    async fn finalize_review_run_success_does_not_reset_persistent_model_failure_count() {
4264        crate::engine::cooldown::reset_global_state().await;
4265        let store = Arc::new(TaskStore::open_memory().await.unwrap());
4266        crate::engine::cooldown::init_cooldown_store(store.clone()).await;
4267
4268        let agent = "test-3571-agent";
4269        let model = "test-3571-model";
4270        let cooldown_key = format!("{agent}:{model}");
4271
4272        let work_dir = TempDir::new().unwrap();
4273        let ctx = test_review_context(agent, model, work_dir.path());
4274        let task = test_review_task("3");
4275
4276        // First structural (parse-error-equivalent) failure -> base 4h cooldown.
4277        let err = runner::agents::AgentError::InvalidResponse {
4278            raw: "empty review output".to_string(),
4279        };
4280        record_review_agent_failure("task-3571-a", agent, Some(model), &err).await;
4281        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
4282
4283        // A successful review for the same model interleaves, exactly like the
4284        // real-world pattern from issue #3571.
4285        let parsed = test_parsed_review(ReviewDecision::Approve);
4286        finalize_review_run(&task, &ctx, &parsed, &store).await;
4287        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
4288
4289        // Second structural failure must escalate from count 2 (~12h), not
4290        // reset back to the 4h base, despite the intervening success.
4291        let err2 = runner::agents::AgentError::InvalidResponse {
4292            raw: "empty review output again".to_string(),
4293        };
4294        record_review_agent_failure("task-3571-b", agent, Some(model), &err2).await;
4295        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
4296
4297        let now = chrono::Utc::now().timestamp();
4298        let until = crate::engine::cooldown::cooldown_until(&cooldown_key)
4299            .expect("second failure should set cooldown");
4300        let remaining = until.saturating_sub(now);
4301
4302        assert!(
4303            remaining > crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS,
4304            "second persistent model failure should escalate past the 4h base despite an \
4305             intervening success (issue #3571); got {remaining}s, base is {}s",
4306            crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS
4307        );
4308    }
4309
4310    #[allow(clippy::await_holding_lock)]
4311    #[tokio::test]
4312    async fn without_an_app_the_review_path_posts_only_the_comment() {
4313        use wiremock::matchers::{method, path};
4314        use wiremock::{Mock, MockServer, ResponseTemplate};
4315        let _env = crate::github::token::ENV_LOCK.lock().unwrap();
4316        let prev = std::env::var_os("GH_TOKEN");
4317        std::env::set_var("GH_TOKEN", "owner-token");
4318        let server = MockServer::start().await;
4319        Mock::given(method("POST"))
4320            .and(path("/repos/o/r/issues/9/comments"))
4321            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 1})))
4322            .expect(1)
4323            .mount(&server)
4324            .await;
4325        let gh = GhHttp::for_test(server.uri(), None);
4326        let result = publish_review(
4327            None,
4328            &gh,
4329            "o/r",
4330            9,
4331            Some("abc123"),
4332            &ReviewDecision::Approve,
4333            "## Automated Review \u{2014} Approve\n\nok",
4334        )
4335        .await;
4336        match prev {
4337            Some(value) => std::env::set_var("GH_TOKEN", value),
4338            None => std::env::remove_var("GH_TOKEN"),
4339        }
4340        result.expect("comment posted");
4341        let requests = server.received_requests().await.expect("requests");
4342        assert!(requests
4343            .iter()
4344            .all(|req| !req.url.path().contains("/reviews")));
4345    }
4346
4347    #[tokio::test]
4348    async fn with_an_app_the_review_is_submitted_on_the_reviewed_sha() {
4349        use wiremock::matchers::{body_partial_json, method, path};
4350        use wiremock::{Mock, MockServer, ResponseTemplate};
4351        let server = MockServer::start().await;
4352        Mock::given(method("POST"))
4353            .and(path("/app/installations/42/access_tokens"))
4354            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
4355                "token": "inst-1",
4356                "expires_at": "2099-01-01T00:00:00Z",
4357            })))
4358            .mount(&server)
4359            .await;
4360        Mock::given(method("POST"))
4361            .and(path("/repos/o/r/pulls/9/reviews"))
4362            .and(body_partial_json(serde_json::json!({
4363                "event": "REQUEST_CHANGES",
4364                "commit_id": "abc123",
4365            })))
4366            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 1})))
4367            .expect(1)
4368            .mount(&server)
4369            .await;
4370        Mock::given(method("POST"))
4371            .and(path("/repos/o/r/issues/9/comments"))
4372            .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 2})))
4373            .expect(1)
4374            .mount(&server)
4375            .await;
4376        Mock::given(method("GET"))
4377            .and(path("/repos/o/r/installation"))
4378            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 42})))
4379            .mount(&server)
4380            .await;
4381        let app = Arc::new(crate::github::app::AppAuth::new(
4382            "12345".to_string(),
4383            crate::github::app::test_pem().to_string(),
4384            server.uri(),
4385        ));
4386        let gh = GhHttp::for_test(server.uri(), Some(Arc::clone(&app)));
4387        let decision = ReviewDecision::RequestChanges {
4388            notes: "fix it".to_string(),
4389            issues: vec![],
4390        };
4391        publish_review(
4392            Some(&app),
4393            &gh,
4394            "o/r",
4395            9,
4396            Some("abc123"),
4397            &decision,
4398            "## Automated Review \u{2014} Changes Requested\n\nfix it",
4399        )
4400        .await
4401        .expect("comment posted");
4402    }
4403}