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