Skip to main content

orch/engine/
review.rs

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