Skip to main content

orch/engine/
review.rs

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