Skip to main content

orch/engine/
auto_merge.rs

1//! PR auto-merge and review-changes handling.
2//!
3//! Extracted from `engine/review.rs`. Handles:
4//! - [`auto_merge_pr`]: merge an approved PR after CI passes
5//! - [`handle_merge_conflict`]: rebase worktree on merge conflict (prevents infinite needs_review loop)
6//! - [`handle_review_changes`]: re-dispatch agent to address review feedback
7//! - [`dedup_reviews`]: deduplicate GitHub reviews by reviewer (keep latest)
8//! - [`any_changes_requested_in_reviews`]: check if any reviewer's latest review blocks merge
9//! - [`crate::engine::attribution_footer`]: standard "Commented/Reviewed by bot" footer for GitHub comments
10
11use crate::backends::{ExternalBackend, ExternalTask, Status};
12use crate::config;
13use crate::engine::cleanup::cleanup_task_worktree;
14use crate::engine::runner::{git_ops, worktree};
15use crate::engine::tasks::TaskManager;
16use crate::github::http::GhHttp;
17use crate::github::types::{GitHubCheckRun, GitHubPullRequest, GitHubReview};
18use crate::store::TaskStore;
19use crate::store::{
20    opt_store_get_task, store_increment, store_reset_failure_counters, store_set_result,
21};
22use std::collections::HashMap;
23use std::sync::{Arc, OnceLock};
24use tokio::sync::Semaphore;
25
26/// Minimum interval between CI status checks for the same task.
27///
28/// Without this cooldown, the engine polls CI for every in_review task on every
29/// tick (~10s). With 20+ tasks in CI failure loops, this creates 120+ requests/min,
30/// quickly exhausting GitHub's 5000-point/hour GraphQL rate limit. At 60s minimum
31/// polling interval, 20 tasks generate at most 20 requests/min — a 6x reduction.
32///
33/// Configurable via workflow.ci_check_cooldown_secs.
34const DEFAULT_CI_CHECK_COOLDOWN_SECS: u64 = 60;
35
36/// Maximum number of times we will attempt to rebase and retry a conflicting PR
37/// before giving up and blocking the task for human intervention.
38/// Both `review_open_prs` and `auto_merge_pr` use this constant so the limit is
39/// always consistent regardless of which code path increments the counter first.
40pub(crate) const MAX_MERGE_CONFLICT_RETRIES: u64 = 3;
41
42/// Maximum number of CI failure or CI timeout events in `auto_merge_pr` before
43/// the task is blocked for human intervention instead of re-entering NeedsReview.
44const MAX_CI_MERGE_FAILURES: u64 = 3;
45
46// Global semaphore limiting concurrent CI polling loops across all tasks.
47static CI_POLL_SEMAPHORE: OnceLock<Arc<Semaphore>> = OnceLock::new();
48
49fn ci_poll_semaphore() -> &'static Arc<Semaphore> {
50    CI_POLL_SEMAPHORE.get_or_init(|| Arc::new(Semaphore::new(3)))
51}
52
53/// Check if we should skip CI status checking for this task due to per-task cooldown.
54///
55/// Uses the KV store to track the last time CI was checked for each task.
56/// Returns `true` if the cooldown has not elapsed yet.
57async fn is_ci_check_in_cooldown(store: &Arc<TaskStore>, task_id: &str) -> anyhow::Result<bool> {
58    let cooldown_secs: u64 = config::get("workflow.ci_check_cooldown_secs")
59        .ok()
60        .and_then(|s| s.parse().ok())
61        .unwrap_or(DEFAULT_CI_CHECK_COOLDOWN_SECS);
62
63    let key = format!("ci_check_ts:{}", task_id);
64    let last_check = store.kv_get(&key).await?;
65
66    if let Some(last_ts) = last_check {
67        if let Ok(last_time) = chrono::DateTime::parse_from_rfc3339(&last_ts) {
68            let elapsed =
69                chrono::Utc::now().signed_duration_since(last_time.with_timezone(&chrono::Utc));
70            if elapsed.num_seconds() < cooldown_secs as i64 {
71                return Ok(true);
72            }
73        }
74    }
75
76    Ok(false)
77}
78
79/// Check if CI checks should be skipped for this task.
80///
81/// Returns true if the task is already blocked due to a billing failure or other
82/// unrecoverable CI failure. In such cases, there's no point polling CI status.
83fn should_skip_ci_check(stored: &crate::store::Task) -> bool {
84    let block_reason = stored.block_reason.as_deref().unwrap_or("");
85    block_reason.contains("billing")
86        || block_reason.contains("payment")
87        || block_reason.contains("spending limit")
88        || block_reason.contains("CI failure limit")
89}
90
91fn is_ssh_agent_fetch_failure(stderr: &str) -> bool {
92    let lower = stderr.to_ascii_lowercase();
93    lower.contains("sign_and_send_pubkey")
94        || lower.contains("agent refused operation")
95        || lower.contains("communication with agent failed")
96}
97
98#[derive(Debug)]
99enum WorktreeRebaseOutcome {
100    RebasedAndPushed,
101    RebaseConflict(String),
102    PushFailed(String),
103    SshFetchFailed(String),
104    Unavailable(String),
105}
106
107pub(crate) fn is_pr_behind(pr: &GitHubPullRequest) -> bool {
108    pr.mergeable_state
109        .as_deref()
110        .is_some_and(|state| state.eq_ignore_ascii_case("behind"))
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114enum MergeFailureClass {
115    RecoverableConflict,
116    Transient,
117    Blocking,
118}
119
120fn classify_merge_failure(error: &str) -> MergeFailureClass {
121    let err_msg = error.to_ascii_lowercase();
122    let is_conflict = err_msg.contains("405")
123        || err_msg.contains("not mergeable")
124        || err_msg.contains("merge conflict")
125        || (err_msg.contains("409") && err_msg.contains("head branch is out of date"))
126        || err_msg.contains("review and try the merge again");
127    let is_transient = err_msg.contains("502")
128        || err_msg.contains("503")
129        || err_msg.contains("504")
130        || err_msg.contains("server error")
131        || err_msg.contains("bad gateway")
132        || err_msg.contains("service unavailable");
133
134    if is_transient {
135        MergeFailureClass::Transient
136    } else if is_conflict {
137        MergeFailureClass::RecoverableConflict
138    } else {
139        MergeFailureClass::Blocking
140    }
141}
142
143async fn attempt_worktree_rebase_and_force_push(
144    task_id: &str,
145    worktree_path: Option<String>,
146) -> WorktreeRebaseOutcome {
147    let Some(wt) = worktree_path else {
148        return WorktreeRebaseOutcome::Unavailable("no worktree available".to_string());
149    };
150
151    let wt_path = std::path::PathBuf::from(&wt);
152    if !wt_path.exists() {
153        return WorktreeRebaseOutcome::Unavailable(format!("worktree missing: {wt}"));
154    }
155
156    tracing::info!(task_id, worktree = %wt, "attempting automatic rebase recovery");
157
158    let default_branch = worktree::detect_default_branch(&wt_path).await;
159    let git_timeout = std::time::Duration::from_secs(120);
160    let fetch_result = tokio::time::timeout(
161        git_timeout,
162        tokio::process::Command::new("git")
163            .args(["fetch", "origin"])
164            .current_dir(&wt_path)
165            .kill_on_drop(true)
166            .output(),
167    )
168    .await
169    .unwrap_or_else(|_| {
170        tracing::warn!(
171            task_id,
172            "git fetch timed out in rebase recovery after {}s",
173            git_timeout.as_secs()
174        );
175        Err(std::io::Error::other("git fetch timed out"))
176    });
177
178    let rebase_result = match fetch_result {
179        Ok(out) if out.status.success() => {
180            git_ops::stash_rebase_restore(
181                &wt_path,
182                &format!("origin/{default_branch}"),
183                git_ops::StashRebaseOpts {
184                    disable_gpg_signing: true,
185                    abort_on_failure: false,
186                },
187            )
188            .await
189        }
190        Ok(out) => {
191            let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
192            if is_ssh_agent_fetch_failure(&stderr) {
193                return WorktreeRebaseOutcome::SshFetchFailed(stderr);
194            }
195
196            tracing::warn!(task_id, stderr = %stderr, "git fetch failed before rebase");
197            return WorktreeRebaseOutcome::Unavailable(format!("git fetch failed: {stderr}"));
198        }
199        Err(err) => {
200            let err = err.to_string();
201            tracing::warn!(task_id, error = %err, "git fetch errored before rebase");
202            return WorktreeRebaseOutcome::Unavailable(format!("git fetch failed: {err}"));
203        }
204    };
205
206    match rebase_result {
207        Ok(git_ops::RebaseOutcome::Succeeded) => {
208            let push_result = tokio::time::timeout(
209                git_timeout,
210                tokio::process::Command::new("git")
211                    // lfs.locksverify=false skips the auxiliary LFS locks/verify
212                    // roundtrip; a transient timeout there must not fail the push
213                    .args(["-c", "lfs.locksverify=false", "push", "--force-with-lease"])
214                    .current_dir(&wt_path)
215                    .kill_on_drop(true)
216                    .output(),
217            )
218            .await
219            .unwrap_or_else(|_| {
220                tracing::warn!(
221                    task_id,
222                    "git push timed out in rebase recovery after {}s",
223                    git_timeout.as_secs()
224                );
225                Err(std::io::Error::other("git push timed out"))
226            });
227
228            match push_result {
229                Ok(push_out) if push_out.status.success() => {
230                    WorktreeRebaseOutcome::RebasedAndPushed
231                }
232                Ok(push_out) => WorktreeRebaseOutcome::PushFailed(
233                    String::from_utf8_lossy(&push_out.stderr).trim().to_string(),
234                ),
235                Err(push_err) => WorktreeRebaseOutcome::PushFailed(push_err.to_string()),
236            }
237        }
238        Ok(git_ops::RebaseOutcome::Failed(stderr)) => WorktreeRebaseOutcome::RebaseConflict(stderr),
239        Ok(git_ops::RebaseOutcome::Skipped(reason)) => {
240            WorktreeRebaseOutcome::Unavailable(format!("rebase skipped: {reason}"))
241        }
242        Err(err) => WorktreeRebaseOutcome::Unavailable(format!("rebase command failed: {err}")),
243    }
244}
245
246/// Record that CI was checked for this task, updating the timestamp in KV store.
247async fn record_ci_check(store: &Arc<TaskStore>, task_id: &str) -> anyhow::Result<()> {
248    let key = format!("ci_check_ts:{}", task_id);
249    let now = chrono::Utc::now().to_rfc3339();
250    store.kv_set(&key, &now).await
251}
252
253fn ci_pending_since_key(task_id: &str) -> String {
254    format!("ci_pending_since:{}", task_id)
255}
256
257/// Return how long CI has been continuously observed as `pending` for this
258/// task, persisting the first-seen timestamp in the KV store.
259///
260/// `auto_merge_pr` runs as many short-lived invocations rather than one long
261/// blocking call — `is_ci_check_in_cooldown` returns early well before
262/// `max_wait` elapses, and the next sync tick spawns a fresh invocation with
263/// its own local `Instant`. A local `start.elapsed()` therefore never
264/// accumulates past a few seconds, so the pending-timeout escalation can
265/// never fire. Persisting "first seen pending" here lets elapsed time
266/// accumulate across those invocations the same way `ci_check_ts` already
267/// does for the check cooldown.
268async fn ci_pending_elapsed(
269    store: &Arc<TaskStore>,
270    task_id: &str,
271) -> anyhow::Result<chrono::Duration> {
272    let key = ci_pending_since_key(task_id);
273    let now = chrono::Utc::now();
274    // `auto_merge_pr` can run as two overlapping invocations for the same task
275    // (the review-agent's direct approval call in review.rs races the periodic
276    // review_open_prs sweep, which is not guarded against that call site). A
277    // plain "read, then write-if-absent" has a TOCTOU window where both
278    // invocations observe an absent key and each writes its own "now",
279    // silently discarding whichever wrote first and resetting the elapsed
280    // clock. `kv_insert_if_absent` performs the check-and-set in one SQL
281    // statement so only the first writer's timestamp survives.
282    let stored = store.kv_insert_if_absent(&key, &now.to_rfc3339()).await?;
283    match chrono::DateTime::parse_from_rfc3339(&stored) {
284        Ok(dt) => Ok(now.signed_duration_since(dt.with_timezone(&chrono::Utc))),
285        Err(_) => {
286            tracing::warn!(
287                task_id,
288                stored,
289                "ci_pending_since value unparseable — resetting"
290            );
291            store.kv_set(&key, &now.to_rfc3339()).await?;
292            Ok(chrono::Duration::zero())
293        }
294    }
295}
296
297/// Clear the persisted pending-since timestamp once CI leaves the pending
298/// state (success, failure, or after a timeout escalation fires) so the next
299/// pending streak starts fresh.
300///
301/// `reason` identifies which branch triggered the clear (e.g. "ci_success",
302/// "ci_failure", "pending_timeout_escalation") and is logged alongside the
303/// value being cleared, so a future occurrence of the elapsed time silently
304/// resetting (see #3609) is diagnosable from logs alone instead of requiring
305/// another multi-day live reproduction.
306async fn clear_ci_pending_since(
307    store: &Arc<TaskStore>,
308    task_id: &str,
309    reason: &str,
310) -> anyhow::Result<()> {
311    let key = ci_pending_since_key(task_id);
312    if let Ok(Some(prior)) = store.kv_get(&key).await {
313        tracing::debug!(task_id, reason, prior_since = %prior, "clearing ci_pending_since");
314    }
315    store.kv_delete(&key).await
316}
317
318#[allow(clippy::too_many_arguments)]
319/// Handle a detected merge conflict by attempting a worktree rebase.
320///
321/// Both the `poll_mergeable_until` conflict path and the `gh.merge_pr()` failure
322/// path enter here. The `merge_error` param carries the original error string for
323/// PR-facing comments (optional — the poll path has no specific error).
324///
325/// Returns `Ok(())` in all cases; callers should NOT propagate this as an error
326/// since the retry/block decision is made internally. Propagating would cause
327/// `classify_review_failure` to return `ReviewOutcome::Reset`, re-spawning the
328/// review agent in an infinite loop.
329async fn handle_pr_rebase_recovery(
330    task: &ExternalTask,
331    repo: &str,
332    pr_number: u64,
333    gh: &GhHttp,
334    task_manager: &Arc<TaskManager>,
335    store: &Arc<TaskStore>,
336    review_agent: &str,
337    review_model: &str,
338    merge_error: Option<&str>,
339) -> anyhow::Result<()> {
340    let retries = opt_store_get_task(&Some(Arc::clone(store)), repo, &task.id.0)
341        .await
342        .map(|t| t.merge_conflict_retries.max(0) as u64)
343        .unwrap_or(0);
344    if retries >= MAX_MERGE_CONFLICT_RETRIES {
345        tracing::error!(
346            task_id = task.id.0,
347            retries,
348            "merge conflict retry limit reached"
349        );
350        // Persist block_reason BEFORE transitioning to Blocked to avoid
351        // a race where auto_unblock sees a blocked task without a reason
352        // and immediately unblocks it.
353        let fields = [(
354            "block_reason",
355            serde_json::json!(format!(
356                "merge conflict retry limit ({}) reached",
357                MAX_MERGE_CONFLICT_RETRIES
358            )),
359        )];
360        if let Err(e) = task_manager
361            .update_task_status_and_result(&task.id, Status::Blocked, &fields)
362            .await
363        {
364            tracing::error!(task_id = task.id.0, err = %e, "failed to write block_reason and set Blocked");
365            return Ok(());
366        }
367        let comment = format!(
368            "Auto-merge failed after {} rebase attempts: {}",
369            retries,
370            merge_error.unwrap_or("merge conflict")
371        );
372        let footer =
373            crate::engine::attribution_footer("Commented", review_agent, Some(review_model));
374        if let Err(e) = gh
375            .add_comment(
376                repo,
377                &pr_number.to_string(),
378                &format!("{}{}", comment, footer),
379            )
380            .await
381        {
382            tracing::warn!(
383                task_id = task.id.0,
384                pr_number,
385                err = %e,
386                "handle_merge_conflict: failed to post retry limit comment on PR"
387            );
388        }
389        return Ok(());
390    }
391
392    let worktree_path = opt_store_get_task(&Some(Arc::clone(store)), repo, &task.id.0)
393        .await
394        .map(|t| t.worktree)
395        .filter(|wt| !wt.is_empty());
396    match attempt_worktree_rebase_and_force_push(&task.id.0, worktree_path).await {
397        WorktreeRebaseOutcome::RebasedAndPushed => {
398            tracing::info!(
399                task_id = task.id.0,
400                "rebase succeeded — task stays in InReview for CI + auto-merge"
401            );
402            if let Err(e) = store_increment(
403                &Some(Arc::clone(store)),
404                repo,
405                &task.id.0,
406                "merge_conflict_retries",
407            )
408            .await
409            {
410                tracing::warn!(task_id = task.id.0, err = %e, "failed to increment merge_conflict_retries — skipping dispatch to avoid bypassing retry limit");
411                return Ok(());
412            }
413            match gh.enable_auto_merge(repo, pr_number).await {
414                Ok(_) => {
415                    tracing::info!(
416                        task_id = task.id.0,
417                        "auto-merge enabled — task stays in InReview until GitHub merges"
418                    );
419                }
420                Err(e) => {
421                    tracing::warn!(
422                        task_id = task.id.0,
423                        error = %e,
424                        "auto-merge unavailable — task stays in InReview for sync retry"
425                    );
426                }
427            }
428            return Ok(());
429        }
430        WorktreeRebaseOutcome::RebaseConflict(stderr) => {
431            tracing::warn!(
432                task_id = task.id.0,
433                stderr = %stderr,
434                "rebase failed with content conflict — re-routing to agent"
435            );
436            // Increment counter so retry limit fires if agent cannot resolve.
437            // This matches the CI failure pattern: increment before re-routing.
438            if let Err(e) = store_increment(
439                &Some(Arc::clone(store)),
440                repo,
441                &task.id.0,
442                "merge_conflict_retries",
443            )
444            .await
445            {
446                tracing::warn!(task_id = task.id.0, err = %e, "failed to increment merge_conflict_retries — skipping dispatch to avoid bypassing retry limit");
447                return Ok(());
448            }
449            if let Err(e) = store_set_result(
450                &Some(Arc::clone(store)),
451                repo,
452                &task.id.0,
453                &[
454                    (
455                        "last_error",
456                        serde_json::json!(
457                            "auto-merge rebase hit a content conflict; agent must resolve the in-progress rebase in the worktree"
458                        ),
459                    ),
460                    (
461                        "route_reason",
462                        serde_json::json!("re-dispatch after auto-merge rebase conflict"),
463                    ),
464                    (
465                        "pr_review_context",
466                        serde_json::json!(
467                            "Merging your PR failed: rebasing onto the default branch hit a content conflict. The worktree has the rebase in progress (see `git status`). Resolve the conflict markers keeping your changes, `git add` the files and run `git rebase --continue`. Do not redo the task from scratch."
468                        ),
469                    ),
470                ],
471            )
472            .await
473            {
474                tracing::warn!(task_id = task.id.0, err = %e, "store write failed");
475            }
476            task_manager
477                .update_task_status(&task.id, Status::Routed)
478                .await?;
479            return Ok(());
480        }
481        WorktreeRebaseOutcome::SshFetchFailed(stderr) => {
482            let block_reason = format!(
483                "auto-merge git fetch failed due to SSH agent error: {}",
484                stderr.trim()
485            );
486            tracing::error!(
487                task_id = task.id.0,
488                stderr = %stderr,
489                "git fetch failed before rebase due to SSH agent error — blocking for human review"
490            );
491            let fields = [("block_reason", serde_json::json!(block_reason.clone()))];
492            if let Err(e) = task_manager
493                .update_task_status_and_result(&task.id, Status::Blocked, &fields)
494                .await
495            {
496                tracing::error!(task_id = task.id.0, err = %e, "failed to write SSH-fetch block_reason and set Blocked");
497                return Ok(());
498            }
499            let footer =
500                crate::engine::attribution_footer("Commented", review_agent, Some(review_model));
501            let comment = format!(
502                "Auto-merge could not rebase because `git fetch origin` failed with an SSH agent error: {}",
503                stderr.trim()
504            );
505            if let Err(e) = gh
506                .add_comment(
507                    repo,
508                    &pr_number.to_string(),
509                    &format!("{}{}", comment, footer),
510                )
511                .await
512            {
513                tracing::warn!(
514                    task_id = task.id.0,
515                    pr_number,
516                    err = %e,
517                    "handle_pr_rebase_recovery: failed to post SSH-fetch failure comment on PR"
518                );
519            }
520            return Ok(());
521        }
522        WorktreeRebaseOutcome::PushFailed(push_err_msg) => {
523            let comment = format!(
524                "Auto-merge failed (rebase succeeded but force-push failed): {}",
525                push_err_msg
526            );
527            let block_reason = format!(
528                "auto-merge force-push failed after rebase: {}",
529                push_err_msg
530            );
531            let fields = [("block_reason", serde_json::json!(block_reason))];
532            if let Err(e) = task_manager
533                .update_task_status_and_result(&task.id, Status::Blocked, &fields)
534                .await
535            {
536                tracing::error!(task_id = task.id.0, err = %e, "failed to write block_reason and set Blocked");
537                return Ok(());
538            }
539            let footer =
540                crate::engine::attribution_footer("Commented", review_agent, Some(review_model));
541            if let Err(e) = gh
542                .add_comment(
543                    repo,
544                    &pr_number.to_string(),
545                    &format!("{}{}", comment, footer),
546                )
547                .await
548            {
549                tracing::warn!(
550                    task_id = task.id.0,
551                    pr_number,
552                    err = %e,
553                    "handle_pr_rebase_recovery: failed to post push failure comment on PR"
554                );
555            }
556            return Ok(());
557        }
558        WorktreeRebaseOutcome::Unavailable(reason) => {
559            tracing::warn!(
560                task_id = task.id.0,
561                reason = %reason,
562                "merge conflict could not be auto-rebased — re-routing to task agent"
563            );
564        }
565    }
566
567    // No worktree, worktree missing, or fetch/IO error — re-route to the task
568    // agent so it can resolve the conflict in a fresh worktree.
569    tracing::info!(
570        task_id = task.id.0,
571        pr_number,
572        "merge conflict could not be auto-rebased — re-routing to task agent"
573    );
574    if let Err(e) = store_increment(
575        &Some(Arc::clone(store)),
576        repo,
577        &task.id.0,
578        "merge_conflict_retries",
579    )
580    .await
581    {
582        tracing::warn!(task_id = task.id.0, err = %e, "failed to increment merge_conflict_retries — skipping dispatch to avoid bypassing retry limit");
583        return Ok(());
584    }
585    if let Err(e) = store_set_result(
586        &Some(Arc::clone(store)),
587        repo,
588        &task.id.0,
589        &[
590            (
591                "last_error",
592                serde_json::json!(
593                    "merge conflict detected but auto-rebase was not possible (no worktree or IO error); agent must rebase and resolve conflicts"
594                ),
595            ),
596            (
597                "route_reason",
598                serde_json::json!("re-dispatch after merge conflict — auto-rebase unavailable"),
599            ),
600        ],
601    )
602    .await
603    {
604        tracing::warn!(task_id = task.id.0, err = %e, "store write failed");
605    }
606    task_manager
607        .update_task_status(&task.id, Status::Routed)
608        .await?;
609
610    Ok(())
611}
612
613/// Poll GitHub for PR mergeability until it is computed or `max_wait` elapses.
614///
615/// GitHub returns `None` for `mergeable` when it hasn't computed the value yet —
616/// common when the review agent approves seconds after PR creation. Polling for
617/// a brief window avoids an unnecessary deferral to the next sync tick (~10-45s).
618///
619/// Returns `(pr, has_conflict)` where `has_conflict` is `true` when the PR has
620/// merge conflicts. Callers should handle conflicts via `handle_merge_conflict`
621/// instead of propagating an error — this prevents the infinite needs_review loop
622/// that occurred when `classify_review_failure` returned `ReviewOutcome::Reset`
623/// for merge-conflict errors, re-spawning the review agent on every attempt.
624async fn poll_mergeable_until(
625    gh: &GhHttp,
626    repo: &str,
627    pr_number: u64,
628    max_wait: std::time::Duration,
629) -> anyhow::Result<(GitHubPullRequest, bool)> {
630    let interval = std::time::Duration::from_secs(2);
631    let deadline = std::time::Instant::now() + max_wait;
632    loop {
633        let pr = gh.get_pr(repo, pr_number).await?;
634        match pr.mergeable {
635            Some(false) => return Ok((pr, true)),
636            Some(true) => return Ok((pr, false)),
637            None => {
638                if std::time::Instant::now() >= deadline {
639                    anyhow::bail!("PR mergeability not yet computed — retry");
640                }
641                let remaining = deadline - std::time::Instant::now();
642                let sleep_for = std::cmp::min(remaining, interval);
643                tokio::time::sleep(sleep_for).await;
644            }
645        }
646    }
647}
648
649pub(crate) fn required_checks_state(
650    required_contexts: &[String],
651    check_runs: &[(String, String, Option<String>)],
652    statuses: &[(String, String)],
653) -> (String, u64, u64, u64, u64) {
654    if required_contexts.is_empty() {
655        return ("success".to_string(), 0, 0, 0, 0);
656    }
657
658    let mut passing = 0u64;
659    let mut failing = 0u64;
660    let mut pending = 0u64;
661
662    for context in required_contexts {
663        let mut matched = false;
664
665        let mut check_run_match = None;
666        for (name, status, conclusion) in check_runs {
667            if name == context {
668                check_run_match = Some((status.as_str(), conclusion.as_deref()));
669                break;
670            }
671        }
672
673        if let Some((status, conclusion)) = check_run_match {
674            matched = true;
675            if status != "completed" || conclusion.is_none() {
676                pending += 1;
677            } else if matches!(conclusion, Some("success" | "neutral" | "skipped")) {
678                passing += 1;
679            } else {
680                failing += 1;
681            }
682        } else {
683            for (name, state) in statuses {
684                if name != context {
685                    continue;
686                }
687                matched = true;
688                match state.as_str() {
689                    "success" | "neutral" | "skipped" => passing += 1,
690                    "failure" | "error" => failing += 1,
691                    _ => pending += 1,
692                }
693                break;
694            }
695        }
696
697        if !matched {
698            pending += 1;
699        }
700    }
701
702    let total = required_contexts.len() as u64;
703    let state = if failing > 0 {
704        "failure".to_string()
705    } else if pending > 0 {
706        "pending".to_string()
707    } else {
708        "success".to_string()
709    };
710
711    (state, total, passing, failing, pending)
712}
713
714/// Deduplicate GitHub reviews by reviewer, keeping only the latest per reviewer.
715///
716/// GitHub returns reviews in chronological order. COMMENTED and DISMISSED reviews
717/// are ignored — they carry no approval/rejection signal.
718pub(crate) fn dedup_reviews<'a>(reviews: &'a [GitHubReview]) -> HashMap<String, &'a GitHubReview> {
719    let mut by_reviewer: HashMap<String, &'a GitHubReview> = HashMap::new();
720    for review in reviews {
721        if review.state != "APPROVED" && review.state != "CHANGES_REQUESTED" {
722            continue;
723        }
724        let is_newer_or_first = by_reviewer
725            .get(&review.user.login)
726            .is_none_or(|prev| review.submitted_at > prev.submitted_at);
727        if is_newer_or_first {
728            by_reviewer.insert(review.user.login.clone(), review);
729        }
730    }
731    by_reviewer
732}
733
734/// Returns `true` if any reviewer's **latest** review is `CHANGES_REQUESTED`.
735pub(crate) fn any_changes_requested_in_reviews(reviews: &[GitHubReview]) -> bool {
736    dedup_reviews(reviews)
737        .values()
738        .any(|r| r.state == "CHANGES_REQUESTED")
739}
740
741/// Counts failed check runs for `sha` that carry a GitHub Actions billing
742/// failure annotation.
743///
744/// When an account has unpaid invoices or has hit a spending limit, GitHub
745/// refuses to start jobs and records the reason ("The job was not started
746/// because recent account payments have failed …") as a check-run annotation.
747/// The CI state still comes back as `"failure"`, which would normally trigger
748/// the agent retry loop — but no code change can fix a billing problem.
749/// This function lets the caller short-circuit that loop and block the task
750/// immediately with a human-readable billing message instead.
751async fn billing_failed_runs(
752    gh: &GhHttp,
753    repo: &str,
754    sha: &str,
755    prefetched: Option<Vec<GitHubCheckRun>>,
756) -> u64 {
757    let check_runs = match prefetched {
758        Some(runs) => runs,
759        None => match gh.get_check_runs(repo, sha).await {
760            Ok(runs) => runs,
761            Err(e) => {
762                tracing::debug!(err = %e, "failed to fetch check runs for billing failure check");
763                return 0;
764            }
765        },
766    };
767
768    let mut count = 0;
769    for run in check_runs
770        .iter()
771        .filter(|r| r.conclusion.as_deref() == Some("failure"))
772    {
773        match gh.get_check_run_annotations(repo, run.id).await {
774            Ok(annotations) => {
775                for annotation in &annotations {
776                    let msg = annotation.message.as_deref().unwrap_or("");
777                    let title = annotation.title.as_deref().unwrap_or("");
778                    if msg.contains("account payments have failed")
779                        || msg.contains("spending limit")
780                        || title.contains("account payments have failed")
781                        || title.contains("spending limit")
782                    {
783                        count += 1;
784                        break;
785                    }
786                }
787            }
788            Err(e) => {
789                tracing::debug!(
790                    check_run_id = run.id,
791                    err = %e,
792                    "failed to fetch check run annotations"
793                );
794            }
795        }
796    }
797
798    count
799}
800
801/// Whether a PR head may merge, given how many commits it is behind its base.
802///
803/// `None` means the comparison could not be read. Only a head that contains the
804/// base tip has run its checks against the code the merge would produce, so an
805/// unreadable comparison holds the merge too (#3780)
806fn may_merge_onto_base(behind_by: Option<u64>) -> bool {
807    behind_by == Some(0)
808}
809
810/// Auto-merge a PR after review approval.
811///
812/// Checks that the automated review comment says "approve" and that CI checks
813/// are green before merging. If CI fails, sets task back to routed so the
814/// agent is re-dispatched to fix the issues.
815#[allow(clippy::too_many_arguments)]
816pub(crate) async fn auto_merge_pr(
817    task: &ExternalTask,
818    branch: &str,
819    _backend: &Arc<dyn ExternalBackend>,
820    repo: &str,
821    review_agent: &str,
822    review_model: &str,
823    task_manager: &Arc<TaskManager>,
824    store: &Arc<TaskStore>,
825) -> anyhow::Result<()> {
826    // Early exit: skip CI checks if the task is already blocked due to
827    // unrecoverable failures (billing, CI failure limit, etc.). No point
828    // polling CI for a task that will never be unblocked.
829    if let Some(stored_task) = opt_store_get_task(&Some(Arc::clone(store)), repo, &task.id.0).await
830    {
831        if stored_task.status == crate::store::TaskStatus::Blocked
832            && should_skip_ci_check(&stored_task)
833        {
834            tracing::debug!(
835                task_id = task.id.0,
836                "skipping auto-merge CI checks — task is blocked with unrecoverable failure"
837            );
838            return Ok(());
839        }
840    }
841
842    // 1. Get PR number from branch
843    let gh = GhHttp::new()?;
844    let pr_number = match gh.get_pr_number(repo, branch).await? {
845        Some(n) => n,
846        None => {
847            // No open PR — check if the branch was already merged/closed.
848            // Treat a closed/merged PR as idempotent success rather than a
849            // retryable failure so the review-agent failure counter is not
850            // incremented when the work is already done.
851            match gh.get_closed_pr_state(repo, branch).await {
852                Ok(Some((closed_pr_number, true, _))) => {
853                    // Validate the PR's head ref matches our branch before treating as merged
854                    match gh.get_pr(repo, closed_pr_number).await {
855                        Ok(pr) if pr.head.ref_ == branch => {
856                            tracing::info!(
857                                task_id = task.id.0,
858                                branch,
859                                pr_number = closed_pr_number,
860                                "PR already merged — marking task done (idempotent)"
861                            );
862                            task_manager
863                                .update_task_status(&task.id, Status::Done)
864                                .await?;
865                            if let Err(e) = cleanup_task_worktree(&task.id.0, repo, store).await {
866                                tracing::warn!(
867                                    task_id = task.id.0,
868                                    err = %e,
869                                    "post-merge cleanup failed"
870                                );
871                            }
872                            return Ok(());
873                        }
874                        Ok(_) => {
875                            tracing::warn!(
876                                task_id = task.id.0,
877                                branch,
878                                pr_number = closed_pr_number,
879                                "closed PR head ref does not match branch — may be wrong PR"
880                            );
881                            anyhow::bail!("no matching merged PR found for branch {}", branch);
882                        }
883                        Err(e) => {
884                            tracing::warn!(
885                                task_id = task.id.0,
886                                branch,
887                                pr_number = closed_pr_number,
888                                err = %e,
889                                "failed to verify PR details, assuming merged"
890                            );
891                            // Fall through to marking done since get_closed_pr_state said it was merged
892                            task_manager
893                                .update_task_status(&task.id, Status::Done)
894                                .await?;
895                            if let Err(e) = cleanup_task_worktree(&task.id.0, repo, store).await {
896                                tracing::warn!(
897                                    task_id = task.id.0,
898                                    err = %e,
899                                    "post-merge cleanup failed"
900                                );
901                            }
902                            return Ok(());
903                        }
904                    }
905                }
906                Ok(Some((closed_pr_number, false, state))) => {
907                    // Validate the PR's head ref matches our branch before treating as closed
908                    match gh.get_pr(repo, closed_pr_number).await {
909                        Ok(pr) if pr.head.ref_ == branch => {
910                            tracing::info!(
911                                task_id = task.id.0,
912                                branch,
913                                pr_number = closed_pr_number,
914                                pr_state = %state,
915                                "PR was closed without merge — marking task done"
916                            );
917                            task_manager
918                                .update_task_status(&task.id, Status::Done)
919                                .await?;
920                            if let Err(e) = cleanup_task_worktree(&task.id.0, repo, store).await {
921                                tracing::warn!(
922                                    task_id = task.id.0,
923                                    err = %e,
924                                    "post-close cleanup failed"
925                                );
926                            }
927                            return Ok(());
928                        }
929                        Ok(_) => {
930                            tracing::warn!(
931                                task_id = task.id.0,
932                                branch,
933                                pr_number = closed_pr_number,
934                                "closed PR head ref does not match branch — may be wrong PR"
935                            );
936                            anyhow::bail!("no matching closed PR found for branch {}", branch);
937                        }
938                        Err(e) => {
939                            tracing::warn!(
940                                task_id = task.id.0,
941                                branch,
942                                pr_number = closed_pr_number,
943                                err = %e,
944                                "failed to verify PR details, assuming closed"
945                            );
946                            // Fall through to marking done since get_closed_pr_state said it was closed
947                            task_manager
948                                .update_task_status(&task.id, Status::Done)
949                                .await?;
950                            if let Err(e) = cleanup_task_worktree(&task.id.0, repo, store).await {
951                                tracing::warn!(
952                                    task_id = task.id.0,
953                                    err = %e,
954                                    "post-close cleanup failed"
955                                );
956                            }
957                            return Ok(());
958                        }
959                    }
960                }
961                Ok(None) => {
962                    // No closed PR found either — this is a genuine error
963                    anyhow::bail!("no open PR found for branch {}", branch);
964                }
965                Err(e) => {
966                    // Error checking closed PR state — fail gracefully
967                    tracing::warn!(
968                        task_id = task.id.0,
969                        branch,
970                        err = %e,
971                        "failed to check closed PR state"
972                    );
973                    anyhow::bail!("no open PR found for branch {}", branch);
974                }
975            }
976        }
977    };
978
979    // 2. Verify the automated review comment says "approve"
980    let review_status = gh.get_automated_review_status(repo, pr_number).await?;
981    match review_status.as_deref() {
982        Some("approve") => {
983            tracing::info!(task_id = task.id.0, pr_number, "automated review approved");
984        }
985        Some("changes_requested") => {
986            tracing::warn!(
987                task_id = task.id.0,
988                pr_number,
989                "automated review says changes_requested — approve comment was not posted, returning error"
990            );
991            anyhow::bail!(
992                "approve comment was not posted (latest review is changes_requested); task should be re-queued"
993            );
994        }
995        _ => {
996            tracing::info!(
997                task_id = task.id.0,
998                pr_number,
999                "no automated review comment found — proceeding with merge"
1000            );
1001        }
1002    }
1003
1004    // 3. Rerun the failed push-triggered review gate check so the commit status flips green
1005    if let Err(e) = gh
1006        .rerun_failed_workflow(repo, "Orch Review Gate", branch)
1007        .await
1008    {
1009        tracing::debug!(
1010            task_id = task.id.0,
1011            error = %e,
1012            "failed to rerun orch-review workflow (may not have a failed run)"
1013        );
1014    }
1015
1016    // 4. Wait for CI checks to pass (poll with exponential backoff, up to max_wait)
1017    // Configurable via workflow.ci_poll_max_wait_secs and workflow.ci_poll_interval_secs
1018    let max_wait_secs: u64 = config::get("workflow.ci_poll_max_wait_secs")
1019        .ok()
1020        .and_then(|s| s.parse().ok())
1021        .unwrap_or(600);
1022    let base_interval_secs: u64 = config::get("workflow.ci_poll_interval_secs")
1023        .ok()
1024        .and_then(|s| s.parse().ok())
1025        .unwrap_or(15);
1026    let max_wait = std::time::Duration::from_secs(max_wait_secs);
1027    let mut poll_count: u32 = 0;
1028
1029    // Poll for mergeability to avoid deferring to the next sync tick (~10-45s)
1030    // when GitHub returns None because it hasn't computed mergeability yet.
1031    // This is common when the review agent approves quickly after PR creation.
1032    let mergeability_poll_secs: u64 = config::get("workflow.mergeability_poll_secs")
1033        .ok()
1034        .and_then(|s| s.parse().ok())
1035        .unwrap_or(10);
1036    // Poll for mergeability. When conflicts are detected, handle them via the
1037    // rebase path instead of propagating an error — this avoids the infinite
1038    // needs_review loop caused by classify_review_failure returning Reset.
1039    let (pr, has_conflict) = poll_mergeable_until(
1040        &gh,
1041        repo,
1042        pr_number,
1043        std::time::Duration::from_secs(mergeability_poll_secs),
1044    )
1045    .await?;
1046    if has_conflict {
1047        handle_pr_rebase_recovery(
1048            task,
1049            repo,
1050            pr_number,
1051            &gh,
1052            task_manager,
1053            store,
1054            review_agent,
1055            review_model,
1056            None,
1057        )
1058        .await?;
1059        return Ok(());
1060    }
1061    let head_sha = pr.head.sha.clone();
1062    let base_branch = pr.base.ref_.clone();
1063    let required_contexts = gh
1064        .get_required_status_check_contexts(repo, &base_branch)
1065        .await?;
1066    // Determine whether the repository has any GitHub Actions workflows so that
1067    // `get_combined_status` can distinguish "no CI configured" (legitimately
1068    // empty check-run list → success) from "CI not started yet" (empty list
1069    // because workflows exist but haven't queued yet → pending).
1070    //
1071    // IMPORTANT: transient lookup failures (rate-limit, 5xx, token scope) must
1072    // NOT silently collapse to `false`.  If we assumed `false` on an error,
1073    // `combined_status_state` would see `total == 0 && has_workflows == false`
1074    // and return "success", letting the PR merge without any CI verification.
1075    // Instead we propagate the error so the caller retries on the next sync tick.
1076    let repo_has_workflows = if required_contexts.is_empty() {
1077        gh.has_workflows(repo).await?
1078    } else {
1079        true
1080    };
1081
1082    loop {
1083        // Check per-task CI check cooldown before making GitHub API calls.
1084        // This prevents rate limit exhaustion when many tasks are in CI loops.
1085        if is_ci_check_in_cooldown(store, &task.id.0).await? {
1086            tracing::debug!(
1087                task_id = task.id.0,
1088                pr_number,
1089                "CI check skipped due to per-task cooldown"
1090            );
1091            return Ok(());
1092        }
1093
1094        // Acquire a global permit only for the HTTP polling batch.
1095        // The inner block ensures the permit drops before the match/sleep.
1096        // When required_contexts is non-empty we also cache the raw check runs so
1097        // that is_billing_failure can reuse them without a redundant API call.
1098        let mut prefetched_check_runs: Option<Vec<GitHubCheckRun>> = None;
1099        let (state, total, passing, failing, pending) = {
1100            let _permit = ci_poll_semaphore().clone().acquire_owned().await;
1101            if required_contexts.is_empty() {
1102                gh.get_combined_status(
1103                    repo,
1104                    &head_sha,
1105                    repo_has_workflows,
1106                    pr.mergeable_state.as_deref(),
1107                )
1108                .await?
1109            } else {
1110                let check_runs = gh.get_check_runs(repo, &head_sha).await?;
1111                let statuses = gh.get_commit_status_contexts(repo, &head_sha).await?;
1112                let check_run_tuples = check_runs
1113                    .iter()
1114                    .map(|run| (run.name.clone(), run.status.clone(), run.conclusion.clone()))
1115                    .collect::<Vec<_>>();
1116                let result =
1117                    required_checks_state(&required_contexts, &check_run_tuples, &statuses);
1118                prefetched_check_runs = Some(check_runs);
1119                result
1120            }
1121        }; // _permit dropped here — released before logging, match, and sleep.
1122
1123        tracing::info!(
1124            task_id = task.id.0,
1125            pr_number,
1126            state = %state,
1127            total,
1128            passing,
1129            failing,
1130            pending,
1131            "CI status check"
1132        );
1133
1134        // Record that we checked CI for this task (before breaking on success or
1135        // returning on failure/pending timeout). This enables per-task cooldown.
1136        if let Err(e) = record_ci_check(store, &task.id.0).await {
1137            tracing::warn!(task_id = task.id.0, err = %e, "failed to record CI check timestamp");
1138        }
1139
1140        // A check that never started because of an Actions billing or spending limit
1141        // is "not run", not a failure. If every failed check is of that kind and the
1142        // other checks passed, the PR is green. Only without required contexts, where
1143        // `failing` counts exactly the failed check runs.
1144        let mut state = state;
1145        if state == "failure" && passing > 0 && required_contexts.is_empty() {
1146            let unstarted =
1147                billing_failed_runs(&gh, repo, &head_sha, prefetched_check_runs.clone()).await;
1148            if unstarted > 0 && unstarted == failing {
1149                tracing::warn!(
1150                    task_id = task.id.0,
1151                    pr_number,
1152                    unstarted,
1153                    passing,
1154                    "checks did not start because of a GitHub Actions billing limit — treating as not run"
1155                );
1156                state = "success".to_string();
1157            }
1158        }
1159
1160        match state.as_str() {
1161            "success" => {
1162                if let Err(e) = clear_ci_pending_since(store, &task.id.0, "ci_success").await {
1163                    tracing::warn!(task_id = task.id.0, err = %e, "failed to clear ci_pending_since after CI success");
1164                }
1165                break;
1166            }
1167            "failure" => {
1168                if let Err(e) = clear_ci_pending_since(store, &task.id.0, "ci_failure").await {
1169                    tracing::warn!(task_id = task.id.0, err = %e, "failed to clear ci_pending_since after CI failure");
1170                }
1171                match gh.get_pr(repo, pr_number).await {
1172                    Ok(current_pr) if is_pr_behind(&current_pr) => {
1173                        tracing::info!(
1174                            task_id = task.id.0,
1175                            pr_number,
1176                            mergeable_state = current_pr.mergeable_state.as_deref().unwrap_or(""),
1177                            "CI failed while PR is behind base branch — attempting automatic rebase recovery"
1178                        );
1179
1180                        let worktree_path =
1181                            opt_store_get_task(&Some(Arc::clone(store)), repo, &task.id.0)
1182                                .await
1183                                .map(|t| t.worktree)
1184                                .filter(|wt| !wt.is_empty());
1185
1186                        match attempt_worktree_rebase_and_force_push(&task.id.0, worktree_path)
1187                            .await
1188                        {
1189                            WorktreeRebaseOutcome::RebasedAndPushed => {
1190                                if let Err(e) = store_set_result(
1191                                    &Some(Arc::clone(store)),
1192                                    repo,
1193                                    &task.id.0,
1194                                    &[("ci_merge_failures", serde_json::json!(0))],
1195                                )
1196                                .await
1197                                {
1198                                    tracing::warn!(task_id = task.id.0, err = %e, "failed to reset ci_merge_failures after behind-branch rebase");
1199                                }
1200
1201                                match gh.enable_auto_merge(repo, pr_number).await {
1202                                    Ok(_) => {
1203                                        tracing::info!(
1204                                            task_id = task.id.0,
1205                                            "behind-branch rebase succeeded — auto-merge re-enabled"
1206                                        );
1207                                    }
1208                                    Err(e) => {
1209                                        tracing::warn!(
1210                                            task_id = task.id.0,
1211                                            error = %e,
1212                                            "behind-branch rebase succeeded but auto-merge could not be re-enabled"
1213                                        );
1214                                    }
1215                                }
1216
1217                                return Ok(());
1218                            }
1219                            WorktreeRebaseOutcome::RebaseConflict(stderr) => {
1220                                tracing::warn!(
1221                                    task_id = task.id.0,
1222                                    stderr = %stderr,
1223                                    "behind-branch rebase hit a content conflict — re-routing to agent"
1224                                );
1225                                if let Err(e) = store_increment(
1226                                    &Some(Arc::clone(store)),
1227                                    repo,
1228                                    &task.id.0,
1229                                    "merge_conflict_retries",
1230                                )
1231                                .await
1232                                {
1233                                    tracing::warn!(task_id = task.id.0, err = %e, "failed to increment merge_conflict_retries after behind-branch rebase conflict");
1234                                    return Ok(());
1235                                }
1236                                if let Err(e) = store_set_result(
1237                                    &Some(Arc::clone(store)),
1238                                    repo,
1239                                    &task.id.0,
1240                                    &[
1241                                        (
1242                                            "ci_merge_failures",
1243                                            serde_json::json!(0),
1244                                        ),
1245                                        (
1246                                            "last_error",
1247                                            serde_json::json!(
1248                                                "PR fell behind the base branch and auto-rebase hit a content conflict; agent must resolve the in-progress rebase in the worktree"
1249                                            ),
1250                                        ),
1251                                        (
1252                                            "route_reason",
1253                                            serde_json::json!("re-dispatch after behind-branch auto-rebase conflict"),
1254                                        ),
1255                                    ],
1256                                )
1257                                .await
1258                                {
1259                                    tracing::warn!(task_id = task.id.0, err = %e, "store write failed after behind-branch rebase conflict");
1260                                }
1261                                task_manager
1262                                    .update_task_status(&task.id, Status::Routed)
1263                                    .await?;
1264                                return Ok(());
1265                            }
1266                            WorktreeRebaseOutcome::SshFetchFailed(stderr) => {
1267                                let block_reason = format!(
1268                                    "auto-merge git fetch failed due to SSH agent error: {}",
1269                                    stderr.trim()
1270                                );
1271                                let fields =
1272                                    [("block_reason", serde_json::json!(block_reason.clone()))];
1273                                if let Err(e) = task_manager
1274                                    .update_task_status_and_result(
1275                                        &task.id,
1276                                        Status::Blocked,
1277                                        &fields,
1278                                    )
1279                                    .await
1280                                {
1281                                    tracing::error!(task_id = task.id.0, err = %e, "failed to write SSH-fetch block_reason and set Blocked after behind-branch CI failure");
1282                                    return Ok(());
1283                                }
1284                                let footer = crate::engine::attribution_footer(
1285                                    "Commented",
1286                                    review_agent,
1287                                    Some(review_model),
1288                                );
1289                                let comment = format!(
1290                                    "Auto-merge could not rebase the behind branch because `git fetch origin` failed with an SSH agent error: {}",
1291                                    stderr.trim()
1292                                );
1293                                if let Err(e) = gh
1294                                    .add_comment(
1295                                        repo,
1296                                        &pr_number.to_string(),
1297                                        &format!("{}{}", comment, footer),
1298                                    )
1299                                    .await
1300                                {
1301                                    tracing::warn!(
1302                                        task_id = task.id.0,
1303                                        pr_number,
1304                                        err = %e,
1305                                        "auto_merge: failed to post behind-branch SSH-fetch failure comment on PR"
1306                                    );
1307                                }
1308                                return Ok(());
1309                            }
1310                            WorktreeRebaseOutcome::PushFailed(push_err_msg) => {
1311                                let comment = format!(
1312                                    "Auto-merge failed (behind-branch rebase succeeded but force-push failed): {}",
1313                                    push_err_msg
1314                                );
1315                                let block_reason = format!(
1316                                    "auto-merge force-push failed after behind-branch rebase: {}",
1317                                    push_err_msg
1318                                );
1319                                let fields = [("block_reason", serde_json::json!(block_reason))];
1320                                if let Err(e) = task_manager
1321                                    .update_task_status_and_result(
1322                                        &task.id,
1323                                        Status::Blocked,
1324                                        &fields,
1325                                    )
1326                                    .await
1327                                {
1328                                    tracing::error!(task_id = task.id.0, err = %e, "failed to write block_reason and set Blocked after behind-branch rebase push failure");
1329                                    return Ok(());
1330                                }
1331                                let footer = crate::engine::attribution_footer(
1332                                    "Commented",
1333                                    review_agent,
1334                                    Some(review_model),
1335                                );
1336                                if let Err(e) = gh
1337                                    .add_comment(
1338                                        repo,
1339                                        &pr_number.to_string(),
1340                                        &format!("{}{}", comment, footer),
1341                                    )
1342                                    .await
1343                                {
1344                                    tracing::warn!(
1345                                        task_id = task.id.0,
1346                                        pr_number,
1347                                        err = %e,
1348                                        "auto_merge: failed to post behind-branch push failure comment on PR"
1349                                    );
1350                                }
1351                                return Ok(());
1352                            }
1353                            WorktreeRebaseOutcome::Unavailable(reason) => {
1354                                tracing::warn!(
1355                                    task_id = task.id.0,
1356                                    reason = %reason,
1357                                    "behind-branch auto-rebase unavailable — re-routing to agent instead of counting a CI failure"
1358                                );
1359                                if let Err(e) = store_set_result(
1360                                    &Some(Arc::clone(store)),
1361                                    repo,
1362                                    &task.id.0,
1363                                    &[
1364                                        (
1365                                            "ci_merge_failures",
1366                                            serde_json::json!(0),
1367                                        ),
1368                                        (
1369                                            "last_error",
1370                                            serde_json::json!(format!(
1371                                                "PR fell behind the base branch and auto-rebase was unavailable: {reason}"
1372                                            )),
1373                                        ),
1374                                        (
1375                                            "route_reason",
1376                                            serde_json::json!("re-dispatch after behind-branch CI failure — auto-rebase unavailable"),
1377                                        ),
1378                                    ],
1379                                )
1380                                .await
1381                                {
1382                                    tracing::warn!(task_id = task.id.0, err = %e, "store write failed after unavailable behind-branch recovery");
1383                                }
1384                                task_manager
1385                                    .update_task_status(&task.id, Status::Routed)
1386                                    .await?;
1387                                return Ok(());
1388                            }
1389                        }
1390                    }
1391                    Ok(_) => {}
1392                    Err(e) => {
1393                        tracing::warn!(
1394                            task_id = task.id.0,
1395                            pr_number,
1396                            err = %e,
1397                            "failed to refresh PR state before CI-failure handling"
1398                        );
1399                    }
1400                }
1401
1402                // Check for billing failures before incrementing the code-quality
1403                // failure counter.  Billing failures are infrastructure problems —
1404                // no agent can fix them — so we block the task immediately and skip
1405                // the re-route loop entirely.
1406                if billing_failed_runs(&gh, repo, &head_sha, prefetched_check_runs.take()).await > 0
1407                {
1408                    tracing::error!(
1409                        task_id = task.id.0,
1410                        pr_number,
1411                        "GitHub Actions billing failure detected — blocking for human intervention"
1412                    );
1413                    let fields = [(
1414                        "block_reason",
1415                        serde_json::json!(
1416                            "GitHub Actions billing failure — check Billing & plans settings \
1417                             (jobs are not starting due to payment failure or spending limit)"
1418                        ),
1419                    )];
1420                    if let Err(e) = task_manager
1421                        .update_task_status_and_result(&task.id, Status::Blocked, &fields)
1422                        .await
1423                    {
1424                        tracing::error!(task_id = task.id.0, err = %e, "failed to write billing block_reason and set Blocked");
1425                    }
1426                    return Ok(());
1427                }
1428
1429                let ci_failures = match store_increment(
1430                    &Some(Arc::clone(store)),
1431                    repo,
1432                    &task.id.0,
1433                    "ci_merge_failures",
1434                )
1435                .await
1436                {
1437                    Ok(v) => v,
1438                    Err(e) => {
1439                        tracing::warn!(task_id = task.id.0, err = %e, "failed to increment ci_merge_failures — skipping CI-failure based block this tick");
1440                        // Skip escalation this tick and let the sync retry later
1441                        return Ok(());
1442                    }
1443                };
1444                if ci_failures >= MAX_CI_MERGE_FAILURES {
1445                    tracing::error!(
1446                        task_id = task.id.0,
1447                        pr_number,
1448                        ci_failures,
1449                        "CI failure limit reached — blocking for human intervention"
1450                    );
1451                    // Persist block_reason BEFORE transitioning to Blocked to avoid
1452                    // a race where auto_unblock sees a blocked task without a reason
1453                    // and immediately unblocks it.
1454                    let fields = [(
1455                        "block_reason",
1456                        serde_json::json!(format!(
1457                            "CI failure limit ({}) reached during auto-merge",
1458                            MAX_CI_MERGE_FAILURES
1459                        )),
1460                    )];
1461                    if let Err(e) = task_manager
1462                        .update_task_status_and_result(&task.id, Status::Blocked, &fields)
1463                        .await
1464                    {
1465                        tracing::error!(task_id = task.id.0, err = %e, "failed to write block_reason and set Blocked");
1466                        return Ok(());
1467                    }
1468                    // Immediately attempt to clean up the task's worktree when blocking
1469                    // due to CI failures to avoid orphaned worktrees that will never be
1470                    // re-used until human intervention.
1471                    if let Err(e) = cleanup_task_worktree(&task.id.0, repo, store).await {
1472                        tracing::warn!(task_id = task.id.0, err = %e, "cleanup after CI-failure block failed");
1473                    }
1474                } else {
1475                    tracing::warn!(
1476                        task_id = task.id.0,
1477                        pr_number,
1478                        failing,
1479                        ci_failures,
1480                        "CI failed — re-routing to agent to fix"
1481                    );
1482                    task_manager
1483                        .update_task_status(&task.id, Status::Routed)
1484                        .await?;
1485                    store_reset_failure_counters(&Some(Arc::clone(store)), repo, &task.id.0).await;
1486                }
1487                return Ok(());
1488            }
1489            _ => {
1490                // pending — wait up to max_wait, tracked via a persisted
1491                // "first seen pending" timestamp rather than the local
1492                // `start` Instant (see `ci_pending_elapsed` doc comment).
1493                let pending_elapsed = match ci_pending_elapsed(store, &task.id.0).await {
1494                    Ok(d) => d,
1495                    Err(e) => {
1496                        tracing::warn!(task_id = task.id.0, err = %e, "failed to read/persist ci_pending_since — skipping timeout check this tick");
1497                        chrono::Duration::zero()
1498                    }
1499                };
1500                if pending_elapsed
1501                    >= chrono::Duration::from_std(max_wait).unwrap_or(chrono::Duration::MAX)
1502                {
1503                    if let Err(e) =
1504                        clear_ci_pending_since(store, &task.id.0, "pending_timeout_escalation")
1505                            .await
1506                    {
1507                        tracing::warn!(task_id = task.id.0, err = %e, "failed to clear ci_pending_since before timeout escalation");
1508                    }
1509                    let ci_failures = match store_increment(
1510                        &Some(Arc::clone(store)),
1511                        repo,
1512                        &task.id.0,
1513                        "ci_merge_failures",
1514                    )
1515                    .await
1516                    {
1517                        Ok(v) => v,
1518                        Err(e) => {
1519                            tracing::warn!(task_id = task.id.0, err = %e, "failed to increment ci_merge_failures — skipping CI-timeout based block this tick");
1520                            return Ok(());
1521                        }
1522                    };
1523                    if ci_failures >= MAX_CI_MERGE_FAILURES {
1524                        tracing::error!(
1525                            task_id = task.id.0,
1526                            ci_failures,
1527                            "CI timeout limit reached — blocking for human intervention"
1528                        );
1529                        // Persist block_reason BEFORE transitioning to Blocked to avoid
1530                        // a race where auto_unblock sees a blocked task without a reason
1531                        // and immediately unblocks it.
1532                        let fields = [(
1533                            "block_reason",
1534                            serde_json::json!(format!(
1535                                "CI checks timed out after {} auto-merge failures",
1536                                MAX_CI_MERGE_FAILURES
1537                            )),
1538                        )];
1539                        if let Err(e) = task_manager
1540                            .update_task_status_and_result(&task.id, Status::Blocked, &fields)
1541                            .await
1542                        {
1543                            tracing::error!(task_id = task.id.0, err = %e, "failed to write block_reason and set Blocked");
1544                            return Ok(());
1545                        }
1546                        // Attempt cleanup for CI-timeout based blocks as well.
1547                        if let Err(e) = cleanup_task_worktree(&task.id.0, repo, store).await {
1548                            tracing::warn!(task_id = task.id.0, err = %e, "cleanup after CI-timeout block failed");
1549                        }
1550                    } else {
1551                        tracing::warn!(
1552                            task_id = task.id.0,
1553                            ci_failures,
1554                            "CI checks still pending after timeout — re-routing to agent"
1555                        );
1556                        task_manager
1557                            .update_task_status(&task.id, Status::Routed)
1558                            .await?;
1559                        store_reset_failure_counters(&Some(Arc::clone(store)), repo, &task.id.0)
1560                            .await;
1561                    }
1562                    return Ok(());
1563                }
1564            }
1565        }
1566
1567        // Exponential backoff: double the interval each iteration, capped at 4x base
1568        poll_count += 1;
1569        let multiplier = (2_u64).pow(poll_count.saturating_sub(1)).min(4);
1570        let sleep_secs = base_interval_secs.saturating_mul(multiplier);
1571        tokio::time::sleep(std::time::Duration::from_secs(sleep_secs)).await;
1572    }
1573
1574    // 5. Re-verify no reviewer has requested changes since the CI wait started.
1575    let post_ci_reviews = gh.get_pr_reviews(repo, pr_number).await?;
1576    if any_changes_requested_in_reviews(&post_ci_reviews) {
1577        tracing::warn!(
1578            task_id = task.id.0,
1579            pr_number,
1580            "a reviewer requested changes during CI wait — aborting merge"
1581        );
1582        anyhow::bail!("a reviewer requested changes during CI wait — aborting merge");
1583    }
1584
1585    // 5b. Refuse to merge a head that does not contain the current base tip: its
1586    // checks ran against older code, so two PRs that each pass can still break
1587    // the base together (#3780). Merge the base in and re-check next tick.
1588    let behind_by = gh
1589        .compare_behind_by(repo, &base_branch, &head_sha)
1590        .await
1591        .ok();
1592    if !may_merge_onto_base(behind_by) {
1593        tracing::warn!(
1594            task_id = task.id.0,
1595            pr_number,
1596            ?behind_by,
1597            base_branch = %base_branch,
1598            "PR head does not contain the base tip — updating branch and re-running checks before merge"
1599        );
1600        if behind_by.is_some() {
1601            if let Err(e) = gh.update_pr_branch(repo, pr_number).await {
1602                tracing::warn!(task_id = task.id.0, pr_number, err = %e, "update-branch failed; retrying next tick");
1603            }
1604        }
1605        return Ok(());
1606    }
1607
1608    tracing::info!(
1609        task_id = task.id.0,
1610        pr_number,
1611        branch = %branch,
1612        "merging PR"
1613    );
1614
1615    // 6. Merge via gh CLI
1616    if let Err(e) = gh.merge_pr(repo, pr_number, true).await {
1617        let merge_error = e.to_string();
1618        match classify_merge_failure(&merge_error) {
1619            MergeFailureClass::Transient => {
1620                tracing::warn!(
1621                    task_id = task.id.0,
1622                    pr_number,
1623                    error = %e,
1624                    "transient GitHub error on merge — will retry next sync"
1625                );
1626                // Don't change status — task stays in InReview, sync tick retries
1627                return Ok(());
1628            }
1629            MergeFailureClass::RecoverableConflict => {
1630                // Delegate to the shared helper — same logic as the poll_mergeable_until path.
1631                handle_pr_rebase_recovery(
1632                    task,
1633                    repo,
1634                    pr_number,
1635                    &gh,
1636                    task_manager,
1637                    store,
1638                    review_agent,
1639                    review_model,
1640                    Some(&merge_error),
1641                )
1642                .await?;
1643                return Ok(());
1644            }
1645            MergeFailureClass::Blocking => {}
1646        }
1647
1648        // Non-conflict merge failure (permissions, branch protection, etc.)
1649        tracing::error!(task_id = task.id.0, error = %e, "merge failed — blocking for human review");
1650        // Persist block_reason BEFORE transitioning to Blocked to avoid
1651        // a race where auto_unblock sees a blocked task without a reason
1652        // and immediately unblocks it.
1653        let fields = [(
1654            "block_reason",
1655            serde_json::json!(format!("auto-merge failed: {}", e)),
1656        )];
1657        if let Err(e) = task_manager
1658            .update_task_status_and_result(&task.id, Status::Blocked, &fields)
1659            .await
1660        {
1661            tracing::error!(task_id = task.id.0, err = %e, "failed to write block_reason and set Blocked");
1662            return Ok(());
1663        }
1664        let comment = format!("Auto-merge failed: {}", e);
1665        let footer =
1666            crate::engine::attribution_footer("Commented", review_agent, Some(review_model));
1667        if let Err(e) = gh
1668            .add_comment(
1669                repo,
1670                &pr_number.to_string(),
1671                &format!("{}{}", comment, footer),
1672            )
1673            .await
1674        {
1675            tracing::warn!(
1676                task_id = task.id.0,
1677                pr_number,
1678                err = %e,
1679                "auto-merge: failed to post merge failure comment on PR"
1680            );
1681        }
1682        return Ok(());
1683    }
1684
1685    // 6. Update status to done
1686    if let Err(e) = store_set_result(
1687        &Some(Arc::clone(store)),
1688        repo,
1689        &task.id.0,
1690        &[("ci_merge_failures", serde_json::json!(0))],
1691    )
1692    .await
1693    {
1694        tracing::warn!(task_id = task.id.0, err = %e, "store write failed");
1695    }
1696    task_manager
1697        .update_task_status(&task.id, Status::Done)
1698        .await?;
1699
1700    // 7. Cleanup worktree + branches
1701    if let Err(e) = cleanup_task_worktree(&task.id.0, repo, store).await {
1702        tracing::warn!(task_id = task.id.0, err = %e, "post-merge cleanup failed");
1703    }
1704
1705    // Kill any orphaned review session that might be racing us
1706    let tmux = crate::tmux::TmuxManager::new();
1707    let stale_session = tmux.session_name(repo, &format!("{}-review", task.id.0));
1708    if let Err(e) = tmux.kill_session(&stale_session).await {
1709        tracing::debug!(task_id = task.id.0, error = %e, "failed to kill stale review session after auto-merge");
1710    }
1711
1712    // 8. Post final comment on the PR
1713    let comment = "✅ PR reviewed, approved, and merged.";
1714    let footer = crate::engine::attribution_footer("Reviewed", review_agent, Some(review_model));
1715    if let Err(e) = gh
1716        .add_comment(
1717            repo,
1718            &pr_number.to_string(),
1719            &format!("{}{}", comment, footer),
1720        )
1721        .await
1722    {
1723        tracing::warn!(
1724            task_id = task.id.0,
1725            pr_number,
1726            err = %e,
1727            "auto-merge: failed to post success comment on PR"
1728        );
1729    }
1730
1731    tracing::info!(task_id = task.id.0, "auto-merge completed");
1732
1733    Ok(())
1734}
1735
1736/// Returns `true` when the branch's latest commit is newer than the most
1737/// recent automated review comment on the PR.
1738///
1739/// This is used to detect reviews that ran against stale code: if the agent
1740/// pushed a fix *after* the review comment was posted, the review's feedback
1741/// is no longer valid and we should not escalate based on it.
1742///
1743/// Returns `false` on any API error so that we always fall back to normal
1744/// escalation behaviour rather than getting stuck in a retry loop.
1745async fn branch_newer_than_last_review(
1746    gh: &GhHttp,
1747    repo: &str,
1748    pr_number: u64,
1749    pr_num_str: &str,
1750) -> bool {
1751    // 1. Get the current branch HEAD SHA.
1752    let head_sha = match gh.get_pr(repo, pr_number).await {
1753        Ok(pr) => pr.head.sha,
1754        Err(e) => {
1755            tracing::debug!(pr_number, err = %e, "stale-review check: failed to get PR");
1756            return false;
1757        }
1758    };
1759
1760    // 2. Get the committer date of the HEAD commit.
1761    let commit_date = match gh.get_commit_timestamp(repo, &head_sha).await {
1762        Ok(d) => d,
1763        Err(e) => {
1764            tracing::debug!(pr_number, sha = %head_sha, err = %e, "stale-review check: failed to get commit date");
1765            return false;
1766        }
1767    };
1768
1769    // 3. Find the latest automated review comment on the PR.
1770    let comments = match gh.list_comments(repo, pr_num_str).await {
1771        Ok(c) => c,
1772        Err(e) => {
1773            tracing::debug!(pr_number, err = %e, "stale-review check: failed to list comments");
1774            return false;
1775        }
1776    };
1777
1778    // Defensive: find the latest automated review comment by created_at rather
1779    // than relying on the iteration order returned by the API.
1780    let mut latest: Option<&crate::github::types::GitHubComment> = None;
1781    for c in &comments {
1782        if !c.body.starts_with("## Automated Review") {
1783            continue;
1784        }
1785        match latest {
1786            None => latest = Some(c),
1787            Some(prev) => {
1788                if c.created_at > prev.created_at {
1789                    latest = Some(c);
1790                }
1791            }
1792        }
1793    }
1794    let last_review_date = latest.map(|c| c.created_at.as_str());
1795
1796    match last_review_date {
1797        Some(review_date) => {
1798            let newer = commit_date.as_str() > review_date;
1799            if newer {
1800                tracing::info!(
1801                    pr_number,
1802                    commit_date = %commit_date,
1803                    review_date = %review_date,
1804                    "stale-review check: branch has commits newer than last review"
1805                );
1806            }
1807            newer
1808        }
1809        // No automated review comment found — cannot determine staleness, don't skip escalation.
1810        None => false,
1811    }
1812}
1813
1814/// Check whether all *required* CI checks pass for the given PR.
1815///
1816/// Returns `Some(true)` when all required contexts report success, `Some(false)` when
1817/// at least one required check is failing or pending, and `None` on any API error (so
1818/// the caller can fall through to the default escalation path).
1819async fn required_ci_checks_pass(gh: &GhHttp, repo: &str, pr_number: u64) -> Option<bool> {
1820    let pr = match gh.get_pr(repo, pr_number).await {
1821        Ok(pr) => pr,
1822        Err(e) => {
1823            tracing::warn!(pr_number, err = %e, "required_ci_checks_pass: failed to fetch PR");
1824            return None;
1825        }
1826    };
1827
1828    let head_sha = pr.head.sha.clone();
1829    let base_branch = pr.base.ref_.clone();
1830
1831    let required_contexts = match gh
1832        .get_required_status_check_contexts(repo, &base_branch)
1833        .await
1834    {
1835        Ok(ctx) => ctx,
1836        Err(e) => {
1837            tracing::warn!(
1838                pr_number,
1839                err = %e,
1840                "required_ci_checks_pass: failed to fetch required contexts"
1841            );
1842            return None;
1843        }
1844    };
1845
1846    // No required contexts configured — treat as passing.
1847    if required_contexts.is_empty() {
1848        return Some(true);
1849    }
1850
1851    let check_runs = match gh.get_check_runs(repo, &head_sha).await {
1852        Ok(runs) => runs
1853            .into_iter()
1854            .map(|r| (r.name, r.status, r.conclusion))
1855            .collect::<Vec<_>>(),
1856        Err(e) => {
1857            tracing::warn!(
1858                pr_number,
1859                err = %e,
1860                "required_ci_checks_pass: failed to fetch check runs"
1861            );
1862            return None;
1863        }
1864    };
1865
1866    let statuses = match gh.get_commit_status_contexts(repo, &head_sha).await {
1867        Ok(s) => s,
1868        Err(e) => {
1869            tracing::warn!(
1870                pr_number,
1871                err = %e,
1872                "required_ci_checks_pass: failed to fetch commit statuses"
1873            );
1874            return None;
1875        }
1876    };
1877
1878    let (state, ..) = required_checks_state(&required_contexts, &check_runs, &statuses);
1879    Some(state == "success")
1880}
1881
1882/// Handle review changes request — re-dispatch the original agent.
1883#[allow(clippy::too_many_arguments)]
1884pub(crate) async fn handle_review_changes(
1885    task: &ExternalTask,
1886    notes: &str,
1887    issues: &[crate::engine::runner::response::ReviewIssue],
1888    _backend: &Arc<dyn ExternalBackend>,
1889    repo: &str,
1890    pr_number: u64,
1891    review_agent: &str,
1892    review_model: &str,
1893    task_manager: &Arc<TaskManager>,
1894    store: &Arc<TaskStore>,
1895    router_config: &crate::engine::router::config::RouterConfig,
1896) -> anyhow::Result<()> {
1897    // 1. Check review cycle count (max 2 review rounds)
1898    let task_store_record = opt_store_get_task(&Some(Arc::clone(store)), repo, &task.id.0).await;
1899    let review_cycles: u32 = task_store_record
1900        .as_ref()
1901        .map(|t| t.review_cycles.max(0) as u32)
1902        .unwrap_or(0);
1903
1904    let max_cycles: u32 = config::get("workflow.max_review_cycles")
1905        .ok()
1906        .and_then(|v| v.parse().ok())
1907        .unwrap_or(2);
1908
1909    let gh = GhHttp::new()?;
1910    let pr_num_str = pr_number.to_string();
1911
1912    if review_cycles >= max_cycles {
1913        // Before escalating, check whether the branch has been updated after the
1914        // last automated review comment was posted.  If it has, the review that
1915        // triggered this escalation ran against stale code — the agent already
1916        // pushed a fix.  In that case, trigger one fresh review instead of
1917        // blocking the PR with outdated feedback.
1918        if branch_newer_than_last_review(&gh, repo, pr_number, &pr_num_str).await {
1919            tracing::info!(
1920                task_id = task.id.0,
1921                review_cycles,
1922                "branch updated since last review — triggering fresh review instead of escalating"
1923            );
1924            if let Err(e) = task_manager
1925                .update_task_status(&task.id, Status::NeedsReview)
1926                .await
1927            {
1928                tracing::warn!(
1929                    task_id = task.id.0,
1930                    err = %e,
1931                    "failed to set NeedsReview for fresh review — will escalate instead"
1932                );
1933                // Fall through to escalation below.
1934            } else {
1935                // Reset review_cycles so the fresh review against new code starts
1936                // from a clean count. Without this reset, the next review would
1937                // immediately see review_cycles >= max_cycles and escalate again.
1938                if let Err(e) = store_set_result(
1939                    &Some(Arc::clone(store)),
1940                    repo,
1941                    &task.id.0,
1942                    &[("review_cycles", serde_json::json!(0))],
1943                )
1944                .await
1945                {
1946                    tracing::warn!(task_id = task.id.0, err = %e, "store write failed");
1947                }
1948                return Ok(());
1949            }
1950        }
1951
1952        // Before blocking, check whether all *required* CI checks pass.
1953        // If they do, the review agent may have been triggered by a non-required
1954        // check failure (e.g. `review-gate`).  Allow one auto-recovery to reset
1955        // the review cycle counter and re-trigger the review agent.
1956        let ci_recovery_count: i32 = task_store_record
1957            .as_ref()
1958            .map(|t| t.ci_recovery_count)
1959            .unwrap_or(0);
1960
1961        if ci_recovery_count < 1 {
1962            match required_ci_checks_pass(&gh, repo, pr_number).await {
1963                Some(true) => {
1964                    tracing::info!(
1965                        task_id = task.id.0,
1966                        review_cycles,
1967                        "required CI checks pass — auto-recovering from non-required check failure"
1968                    );
1969                    if let Err(e) = store_set_result(
1970                        &Some(Arc::clone(store)),
1971                        repo,
1972                        &task.id.0,
1973                        &[("review_cycles", serde_json::json!(0))],
1974                    )
1975                    .await
1976                    {
1977                        tracing::warn!(task_id = task.id.0, err = %e, "store write failed");
1978                    }
1979                    if let Err(e) = store_increment(
1980                        &Some(Arc::clone(store)),
1981                        repo,
1982                        &task.id.0,
1983                        "ci_recovery_count",
1984                    )
1985                    .await
1986                    {
1987                        tracing::warn!(task_id = task.id.0, err = %e, "failed to increment ci_recovery_count — recovery count may be inaccurate");
1988                    }
1989                    if let Err(e) = task_manager
1990                        .update_task_status(&task.id, Status::NeedsReview)
1991                        .await
1992                    {
1993                        tracing::warn!(
1994                            task_id = task.id.0,
1995                            err = %e,
1996                            "auto-recovery: failed to set NeedsReview — will escalate instead"
1997                        );
1998                        // Fall through to escalation below.
1999                    } else {
2000                        let recovery_comment = format!(
2001                            "🔄 Auto-recovery: required CI checks pass. \
2002                            The review agent may have been blocked by a non-required check failure. \
2003                            Resetting review cycles and re-triggering review.{}",
2004                            crate::engine::attribution_footer("Commented", review_agent, Some(review_model))
2005                        );
2006                        if let Err(e) = gh.add_comment(repo, &pr_num_str, &recovery_comment).await {
2007                            tracing::warn!(
2008                                task_id = task.id.0,
2009                                pr_number,
2010                                err = %e,
2011                                "auto-recovery: failed to post recovery comment on PR"
2012                            );
2013                        }
2014                        return Ok(());
2015                    }
2016                }
2017                Some(false) => {
2018                    tracing::info!(
2019                        task_id = task.id.0,
2020                        review_cycles,
2021                        "required CI checks failing — escalating to Blocked"
2022                    );
2023                }
2024                None => {
2025                    tracing::warn!(
2026                        task_id = task.id.0,
2027                        review_cycles,
2028                        "could not determine required CI check state — escalating to Blocked"
2029                    );
2030                }
2031            }
2032        }
2033
2034        tracing::warn!(
2035            task_id = task.id.0,
2036            review_cycles,
2037            max_cycles,
2038            "max review cycles exceeded, blocking for human review"
2039        );
2040        // Persist block_reason BEFORE transitioning to Blocked to avoid
2041        // a race where auto_unblock sees a blocked task without a reason
2042        // and immediately unblocks it.
2043        let fields = [
2044            (
2045                "block_reason",
2046                serde_json::json!(format!("max review cycles ({}) exceeded", max_cycles)),
2047            ),
2048            (
2049                "last_error",
2050                serde_json::json!(format!(
2051                    "review agent requested changes {} times — escalated to human review",
2052                    review_cycles
2053                )),
2054            ),
2055        ];
2056        // A transient store failure here must not be counted as a review-agent
2057        // crash. Log and return Ok — the next tick will re-check the task and
2058        // can retry the status transition.
2059        if let Err(e) = task_manager
2060            .update_task_status_and_result(&task.id, Status::Blocked, &fields)
2061            .await
2062        {
2063            tracing::warn!(
2064                task_id = task.id.0,
2065                err = %e,
2066                "failed to set Blocked after max review cycles — will retry on next tick"
2067            );
2068            return Ok(());
2069        }
2070        let escalation = format!(
2071            "🔍 Review agent requested changes after {} cycles. Escalating to human.\n\n**Review Notes:**\n{}",
2072            review_cycles, notes
2073        );
2074        let footer =
2075            crate::engine::attribution_footer("Commented", review_agent, Some(review_model));
2076        if let Err(e) = gh
2077            .add_comment(repo, &pr_num_str, &format!("{}{}", escalation, footer))
2078            .await
2079        {
2080            tracing::warn!(task_id = task.id.0, pr_number, err = %e, "failed to post escalation comment on PR");
2081        }
2082        return Ok(());
2083    }
2084
2085    // 2. Build review context for the re-dispatched agent's prompt.
2086    let mut comment = format!(
2087        "🔍 Review agent requested changes (cycle {} of {}):\n\n{}",
2088        review_cycles + 1,
2089        max_cycles,
2090        notes
2091    );
2092
2093    if !issues.is_empty() {
2094        comment.push_str("\n\n**Issues Found:**\n");
2095        for issue in issues {
2096            comment.push_str(&format!(
2097                "- `{}` line {}: {} [{}]\n",
2098                issue.file,
2099                issue
2100                    .line
2101                    .map(|l| l.to_string())
2102                    .unwrap_or_else(|| "?".to_string()),
2103                issue.description,
2104                issue.severity
2105            ));
2106        }
2107    }
2108
2109    // 3. Store review context (but NOT review_cycles — increment only after
2110    // successful status transition to avoid premature escalation on failure).
2111    if let Err(e) = store_set_result(
2112        &Some(Arc::clone(store)),
2113        repo,
2114        &task.id.0,
2115        &[("pr_review_context", serde_json::json!(comment.clone()))],
2116    )
2117    .await
2118    {
2119        tracing::warn!(task_id = task.id.0, err = %e, "store write failed");
2120    }
2121
2122    // 3b. Re-assign a valid model for the task's agent using
2123    // model_for_complexity().  The previous approach of reusing the stored
2124    // model directly produced invalid combos after failover (e.g. opencode:opus
2125    // when "opus" was the review agent's model, not a valid opencode model).
2126    //
2127    // We read the task's existing agent and complexity from the store record
2128    // fetched at line 1087, then resolve a fresh model from the agent's
2129    // configured pool.  If all models for that agent are cooled, we fall back
2130    // to Status::New for a full re-route.
2131    let previous_agent = task_store_record
2132        .as_ref()
2133        .and_then(|t| t.agent.as_ref())
2134        .map(|a| a.as_str())
2135        .unwrap_or("");
2136    let complexity = task_store_record
2137        .as_ref()
2138        .map(|t| {
2139            if t.complexity.is_empty() {
2140                "medium".to_string()
2141            } else {
2142                t.complexity.clone()
2143            }
2144        })
2145        .unwrap_or_else(|| "medium".to_string());
2146
2147    let new_model = router_config.model_for_complexity(previous_agent, &complexity, &task.id.0);
2148
2149    match new_model {
2150        Some(model) => {
2151            // Got a valid model for this agent — update the store and set Routed.
2152            if let Err(e) = store_set_result(
2153                &Some(Arc::clone(store)),
2154                repo,
2155                &task.id.0,
2156                &[
2157                    ("model", serde_json::json!(model)),
2158                    (
2159                        "route_reason",
2160                        serde_json::json!("re-dispatch after review changes"),
2161                    ),
2162                ],
2163            )
2164            .await
2165            {
2166                tracing::warn!(task_id = task.id.0, err = %e, "model update failed — will retry on next tick");
2167                return Ok(());
2168            }
2169
2170            let fields = [
2171                (
2172                    "review_cycles",
2173                    serde_json::json!((review_cycles + 1) as i64),
2174                ),
2175                (
2176                    "review_agent_failures",
2177                    serde_json::json!(0), // reset failures for the new review round
2178                ),
2179            ];
2180            if let Err(e) = task_manager
2181                .update_task_status_and_result(&task.id, Status::Routed, &fields)
2182                .await
2183            {
2184                tracing::warn!(
2185                    task_id = task.id.0,
2186                    err = %e,
2187                    "Routed transition failed — will retry on next tick"
2188                );
2189                return Ok(());
2190            }
2191
2192            tracing::info!(
2193                task_id = task.id.0,
2194                review_cycles = review_cycles + 1,
2195                pr_number,
2196                agent = previous_agent,
2197                model = %model,
2198                complexity = %complexity,
2199                "re-dispatching task (new model via model_for_complexity) to address review feedback on same PR"
2200            );
2201        }
2202        None => {
2203            // All models for this agent are cooled — fall back to full re-route.
2204            // Clear agent/model/route_reason so Phase 3a picks the best available combo.
2205            if let Err(e) = store_set_result(
2206                &Some(Arc::clone(store)),
2207                repo,
2208                &task.id.0,
2209                &[
2210                    ("agent", serde_json::json!(null)),
2211                    ("model", serde_json::json!(null)),
2212                    ("route_reason", serde_json::json!("")),
2213                ],
2214            )
2215            .await
2216            {
2217                tracing::warn!(task_id = task.id.0, err = %e, "store write failed");
2218            }
2219
2220            let fields = [
2221                (
2222                    "review_cycles",
2223                    serde_json::json!((review_cycles + 1) as i64),
2224                ),
2225                (
2226                    "review_agent_failures",
2227                    serde_json::json!(0), // reset failures for the new review round
2228                ),
2229            ];
2230            if let Err(e) = task_manager
2231                .update_task_status_and_result(&task.id, Status::New, &fields)
2232                .await
2233            {
2234                tracing::warn!(
2235                    task_id = task.id.0,
2236                    err = %e,
2237                    "all models cooled for agent {previous_agent} — New transition failed, will retry on next tick"
2238                );
2239                return Ok(());
2240            }
2241
2242            tracing::info!(
2243                task_id = task.id.0,
2244                review_cycles = review_cycles + 1,
2245                pr_number,
2246                agent = previous_agent,
2247                "all models cooled for agent — falling back to full re-route (Status::New)"
2248            );
2249        }
2250    }
2251
2252    Ok(())
2253}
2254
2255/// ISO-8601 timestamp comparison sanity check used by `branch_newer_than_last_review`.
2256///
2257/// GitHub timestamps are RFC 3339 / ISO-8601 strings (e.g. `"2024-01-15T12:30:00Z"`).
2258/// Lexicographic string ordering is equivalent to chronological ordering for this
2259/// format, which is what we rely on in `branch_newer_than_last_review`.
2260#[cfg(test)]
2261fn iso8601_is_newer(newer: &str, older: &str) -> bool {
2262    newer > older
2263}
2264
2265#[cfg(test)]
2266mod tests {
2267    use super::*;
2268    use crate::backends::test_helpers::NoopBackend;
2269    use crate::backends::{ExternalId, ExternalTask};
2270    use crate::github::types::{GitHubReview, GitHubUser};
2271
2272    /// Auto-merge refuses a PR whose head does not contain the current base
2273    /// commit: its green checks ran against older code (#3780).
2274    #[test]
2275    fn may_merge_onto_base_refuses_a_head_behind_its_base() {
2276        assert!(
2277            may_merge_onto_base(Some(0)),
2278            "a head that contains the base tip may merge"
2279        );
2280        assert!(
2281            !may_merge_onto_base(Some(1)),
2282            "a head one commit behind the base must not merge"
2283        );
2284        assert!(
2285            !may_merge_onto_base(Some(12)),
2286            "a head well behind the base must not merge"
2287        );
2288        assert!(
2289            !may_merge_onto_base(None),
2290            "an unreadable comparison must hold the merge, not permit it"
2291        );
2292    }
2293
2294    fn make_review(login: &str, state: &str, submitted_at: &str) -> GitHubReview {
2295        GitHubReview {
2296            id: 1,
2297            user: GitHubUser {
2298                login: login.to_string(),
2299            },
2300            body: None,
2301            state: state.to_string(),
2302            html_url: None,
2303            submitted_at: submitted_at.to_string(),
2304            commit_id: None,
2305        }
2306    }
2307
2308    #[test]
2309    fn required_checks_state_uses_required_contexts() {
2310        let required = vec!["ci".to_string(), "lint".to_string()];
2311        let check_runs = vec![
2312            (
2313                "ci".to_string(),
2314                "completed".to_string(),
2315                Some("success".to_string()),
2316            ),
2317            (
2318                "lint".to_string(),
2319                "completed".to_string(),
2320                Some("failure".to_string()),
2321            ),
2322        ];
2323        let statuses = vec![("unused".to_string(), "success".to_string())];
2324
2325        let (state, total, passing, failing, pending) =
2326            required_checks_state(&required, &check_runs, &statuses);
2327
2328        assert_eq!(state, "failure");
2329        assert_eq!(total, 2);
2330        assert_eq!(passing, 1);
2331        assert_eq!(failing, 1);
2332        assert_eq!(pending, 0);
2333    }
2334
2335    #[test]
2336    fn test_any_changes_requested_returns_true_when_latest_is_changes_requested() {
2337        let reviews = vec![
2338            make_review("alice", "APPROVED", "2026-01-01T10:00:00Z"),
2339            make_review("alice", "CHANGES_REQUESTED", "2026-01-01T11:00:00Z"),
2340        ];
2341        assert!(
2342            any_changes_requested_in_reviews(&reviews),
2343            "should return true when reviewer's latest review is CHANGES_REQUESTED"
2344        );
2345    }
2346
2347    #[test]
2348    fn test_any_changes_requested_returns_false_when_latest_is_approved() {
2349        let reviews = vec![
2350            make_review("alice", "CHANGES_REQUESTED", "2026-01-01T10:00:00Z"),
2351            make_review("alice", "APPROVED", "2026-01-01T11:00:00Z"),
2352        ];
2353        assert!(
2354            !any_changes_requested_in_reviews(&reviews),
2355            "should return false when reviewer's latest review is APPROVED"
2356        );
2357    }
2358
2359    #[test]
2360    fn test_any_changes_requested_returns_false_for_empty_reviews() {
2361        assert!(
2362            !any_changes_requested_in_reviews(&[]),
2363            "should return false for empty review list"
2364        );
2365    }
2366
2367    #[test]
2368    fn test_any_changes_requested_ignores_commented_state() {
2369        let reviews = vec![make_review("alice", "COMMENTED", "2026-01-01T10:00:00Z")];
2370        assert!(
2371            !any_changes_requested_in_reviews(&reviews),
2372            "COMMENTED state should be ignored"
2373        );
2374    }
2375
2376    #[test]
2377    fn test_any_changes_requested_one_reviewer_blocks_even_if_other_approved() {
2378        let reviews = vec![
2379            make_review("alice", "APPROVED", "2026-01-01T10:00:00Z"),
2380            make_review("bob", "CHANGES_REQUESTED", "2026-01-01T10:00:00Z"),
2381        ];
2382        assert!(
2383            any_changes_requested_in_reviews(&reviews),
2384            "should return true if any reviewer has outstanding CHANGES_REQUESTED"
2385        );
2386    }
2387
2388    /// Regression test: handle_review_changes must set status to Routed (not New)
2389    /// when a valid model is available for the task's agent via model_for_complexity().
2390    /// This preserves the documented review-cycle architecture where re-dispatch
2391    /// reuses existing routing context instead of forcing full LLM re-routing.
2392    #[tokio::test]
2393    async fn handle_review_changes_sets_routed_with_valid_model() {
2394        use crate::engine::tasks::TaskManager;
2395        use crate::store::{TaskStatus, TaskStore};
2396
2397        let store = Arc::new(TaskStore::open_memory().await.unwrap());
2398        let repo = "owner/repo";
2399
2400        let task_id_num = store
2401            .create(&crate::store::NewTask {
2402                external_id: None,
2403                repo: repo.to_string(),
2404                origin: "internal".to_string(),
2405                title: "Implement feature X".to_string(),
2406                body: "body".to_string(),
2407                source: "cron".to_string(),
2408                source_id: "daily".to_string(),
2409                author: "".to_string(),
2410                url: "".to_string(),
2411                labels: vec![],
2412                parent_id: None,
2413            })
2414            .await
2415            .unwrap();
2416        store
2417            .update_status(task_id_num, crate::store::TaskStatus::InReview)
2418            .await
2419            .unwrap();
2420
2421        let task_id_str = format!("internal:{task_id_num}");
2422        let backend: Arc<dyn crate::backends::ExternalBackend> = Arc::new(NoopBackend);
2423        let task_manager = Arc::new(TaskManager::with_store(
2424            Arc::clone(&backend),
2425            Arc::clone(&store),
2426            repo.to_string(),
2427        ));
2428
2429        let task = ExternalTask {
2430            id: ExternalId(task_id_str.clone()),
2431            title: "Implement feature X".to_string(),
2432            body: "body".to_string(),
2433            state: "open".to_string(),
2434            labels: vec![],
2435            author: "".to_string(),
2436            created_at: "2026-01-01T00:00:00Z".to_string(),
2437            updated_at: "2026-01-01T00:00:00Z".to_string(),
2438            url: "".to_string(),
2439        };
2440
2441        let result = handle_review_changes(
2442            &task,
2443            "Please fix the type error on line 42",
2444            &[],
2445            &backend,
2446            repo,
2447            101,
2448            "claude",
2449            "sonnet",
2450            &task_manager,
2451            &store,
2452            &crate::engine::router::RouterConfig::default(),
2453        )
2454        .await;
2455
2456        assert!(
2457            result.is_ok(),
2458            "handle_review_changes returned Err: {result:?}"
2459        );
2460
2461        let updated = store.get(task_id_num).await.unwrap();
2462        // When the agent has no model pools configured (default test config),
2463        // model_for_complexity returns None → falls back to Status::New.
2464        assert_eq!(
2465            updated.status,
2466            TaskStatus::New,
2467            "handle_review_changes must fall back to New when model_for_complexity returns None"
2468        );
2469
2470        assert!(
2471            !updated.pr_review_context.is_empty(),
2472            "pr_review_context must be stored so the re-dispatched agent sees the feedback"
2473        );
2474        assert!(
2475            !updated
2476                .pr_review_context
2477                .starts_with("A reviewer has requested changes"),
2478            "pr_review_context must NOT start with the template header"
2479        );
2480        assert!(
2481            updated
2482                .pr_review_context
2483                .contains("Please fix the type error on line 42"),
2484            "pr_review_context must contain the review notes passed to handle_review_changes"
2485        );
2486
2487        assert_eq!(
2488            updated.review_cycles, 1,
2489            "review_cycles must be incremented on each review change request"
2490        );
2491
2492        assert_eq!(
2493            updated.review_agent_failures, 0,
2494            "review_agent_failures must be reset to 0 when re-routing after review changes"
2495        );
2496    }
2497
2498    /// Regression: handle_review_changes must resolve a valid model via
2499    /// model_for_complexity() instead of reusing the review agent's model.
2500    /// When the task's agent has a valid model available, it sets Routed.
2501    /// When all models are cooled, it falls back to New for full re-route (#1723).
2502    #[tokio::test]
2503    async fn handle_review_changes_resolves_valid_model_or_falls_back() {
2504        use crate::engine::tasks::TaskManager;
2505        use crate::store::{TaskStatus, TaskStore};
2506
2507        let store = Arc::new(TaskStore::open_memory().await.unwrap());
2508        let repo = "owner/repo";
2509
2510        let task_id_num = store
2511            .create(&crate::store::NewTask {
2512                external_id: None,
2513                repo: repo.to_string(),
2514                origin: "internal".to_string(),
2515                title: "Test".to_string(),
2516                body: "body".to_string(),
2517                source: "cron".to_string(),
2518                source_id: "daily".to_string(),
2519                author: "".to_string(),
2520                url: "".to_string(),
2521                labels: vec![],
2522                parent_id: None,
2523            })
2524            .await
2525            .unwrap();
2526        store
2527            .update_status(task_id_num, crate::store::TaskStatus::InReview)
2528            .await
2529            .unwrap();
2530
2531        // Pre-set agent/model/complexity to simulate a stored routing.
2532        // The model "opus" may not be valid for this agent's pool, but
2533        // model_for_complexity will resolve a valid one.
2534        store
2535            .set_fields(
2536                task_id_num,
2537                &[
2538                    ("agent", serde_json::json!("claude")),
2539                    ("model", serde_json::json!("opus")),
2540                    ("complexity", serde_json::json!("medium")),
2541                    (
2542                        "route_reason",
2543                        serde_json::json!("llm classified as medium"),
2544                    ),
2545                ],
2546            )
2547            .await
2548            .unwrap();
2549
2550        let task_id_str = format!("internal:{task_id_num}");
2551        let backend: Arc<dyn crate::backends::ExternalBackend> = Arc::new(NoopBackend);
2552        let task_manager = Arc::new(TaskManager::with_store(
2553            Arc::clone(&backend),
2554            Arc::clone(&store),
2555            repo.to_string(),
2556        ));
2557
2558        let task = ExternalTask {
2559            id: ExternalId(task_id_str.clone()),
2560            title: "Test".to_string(),
2561            body: "body".to_string(),
2562            state: "open".to_string(),
2563            labels: vec![],
2564            author: "".to_string(),
2565            created_at: "2026-01-01T00:00:00Z".to_string(),
2566            updated_at: "2026-01-01T00:00:00Z".to_string(),
2567            url: "".to_string(),
2568        };
2569
2570        let result = handle_review_changes(
2571            &task,
2572            "Fix the bug",
2573            &[],
2574            &backend,
2575            repo,
2576            42,
2577            "minimax",
2578            "opus",
2579            &task_manager,
2580            &store,
2581            &crate::engine::router::RouterConfig::default(),
2582        )
2583        .await;
2584
2585        assert!(
2586            result.is_ok(),
2587            "handle_review_changes returned Err: {result:?}"
2588        );
2589
2590        let updated = store.get(task_id_num).await.unwrap();
2591
2592        // When model_for_complexity returns a valid model → Routed, agent preserved.
2593        // When all models are cooled → falls back to New, agent cleared for full re-route.
2594        // In test env with no model_map configured, model_for_complexity returns None → New.
2595        // This is the expected fallback behavior.
2596        let model_was_resolved = updated
2597            .model
2598            .as_deref()
2599            .map(|m| !m.is_empty())
2600            .unwrap_or(false);
2601        if model_was_resolved {
2602            // Got a valid model → should be Routed, agent preserved
2603            assert_eq!(
2604                updated.agent.as_deref(),
2605                Some("claude"),
2606                "agent must be preserved when model_for_complexity returns a valid model"
2607            );
2608            assert_eq!(
2609                updated.status,
2610                TaskStatus::Routed,
2611                "status must be Routed when model_for_complexity returns a valid model"
2612            );
2613        } else {
2614            // All models cooled or no pools configured → falls back to New, agent cleared
2615            assert_eq!(
2616                updated.agent.as_deref(),
2617                None,
2618                "agent must be cleared when falling back to full re-route"
2619            );
2620            assert_eq!(
2621                updated.status,
2622                TaskStatus::New,
2623                "status must be New when model_for_complexity returns None (all cooled)"
2624            );
2625        }
2626
2627        assert_eq!(
2628            updated.review_agent_failures, 0,
2629            "review_agent_failures must be reset to 0 when re-routing after review changes"
2630        );
2631    }
2632
2633    /// Regression: a transient `update_task_status` failure must NOT
2634    /// cause `handle_review_changes` to return `Err`.  Returning `Err` would
2635    /// propagate to the review subscriber which increments
2636    /// `review_agent_failures` — incorrectly consuming the retry quota even
2637    /// though the review decision itself was already persisted.
2638    ///
2639    /// We simulate the failure by giving the `TaskManager` a different repo
2640    /// than the one the task was created in, so `resolve_task_id` returns
2641    /// `None` and `update_task_status` returns `Err` for that internal task.
2642    #[tokio::test]
2643    async fn handle_review_changes_transition_failure_returns_ok() {
2644        use crate::engine::tasks::TaskManager;
2645        use crate::store::{TaskStatus, TaskStore};
2646
2647        let store = Arc::new(TaskStore::open_memory().await.unwrap());
2648        let task_repo = "owner/repo";
2649
2650        let task_id_num = store
2651            .create(&crate::store::NewTask {
2652                external_id: None,
2653                repo: task_repo.to_string(),
2654                origin: "internal".to_string(),
2655                title: "Fix bug".to_string(),
2656                body: "body".to_string(),
2657                source: "cron".to_string(),
2658                source_id: "daily".to_string(),
2659                author: "".to_string(),
2660                url: "".to_string(),
2661                labels: vec![],
2662                parent_id: None,
2663            })
2664            .await
2665            .unwrap();
2666        store
2667            .update_status(task_id_num, crate::store::TaskStatus::InReview)
2668            .await
2669            .unwrap();
2670
2671        let task_id_str = format!("internal:{task_id_num}");
2672        let backend: Arc<dyn crate::backends::ExternalBackend> = Arc::new(NoopBackend);
2673
2674        // Intentionally wrong repo — causes resolve_task_id to return None,
2675        // which makes update_task_status return Err for the internal task.
2676        let wrong_repo = "other/repo";
2677        let task_manager = Arc::new(TaskManager::with_store(
2678            Arc::clone(&backend),
2679            Arc::clone(&store),
2680            wrong_repo.to_string(),
2681        ));
2682
2683        let task = ExternalTask {
2684            id: ExternalId(task_id_str.clone()),
2685            title: "Fix bug".to_string(),
2686            body: "body".to_string(),
2687            state: "open".to_string(),
2688            labels: vec![],
2689            author: "".to_string(),
2690            created_at: "2026-01-01T00:00:00Z".to_string(),
2691            updated_at: "2026-01-01T00:00:00Z".to_string(),
2692            url: "".to_string(),
2693        };
2694
2695        // The status transition will fail (wrong repo), but handle_review_changes
2696        // must still return Ok — review context was persisted before the failure.
2697        let result = handle_review_changes(
2698            &task,
2699            "Fix the null pointer on line 10",
2700            &[],
2701            &backend,
2702            task_repo,
2703            202,
2704            "claude",
2705            "sonnet",
2706            &task_manager,
2707            &store,
2708            &crate::engine::router::RouterConfig::default(),
2709        )
2710        .await;
2711
2712        assert!(
2713            result.is_ok(),
2714            "handle_review_changes must return Ok even when status transition fails: {result:?}"
2715        );
2716
2717        // Review context must have been persisted despite the status failure.
2718        let stored = store.get(task_id_num).await.unwrap();
2719        assert!(
2720            stored
2721                .pr_review_context
2722                .contains("Fix the null pointer on line 10"),
2723            "pr_review_context must be persisted even when status transition fails"
2724        );
2725        // Review_cycles should NOT be incremented if the status transition fails.
2726        // This prevents premature escalation when the Routed transition fails.
2727        // The increment happens AFTER successful status transition in the fixed code.
2728        assert_eq!(
2729            stored.review_cycles, 0,
2730            "review_cycles must NOT be incremented when status transition fails"
2731        );
2732        // Status stays InReview (not Routed/New) because the transition failed.
2733        assert_eq!(
2734            stored.status,
2735            TaskStatus::InReview,
2736            "status should remain InReview when status transition failed"
2737        );
2738    }
2739
2740    /// ISO-8601 timestamps returned by GitHub compare lexicographically in
2741    /// chronological order.  `branch_newer_than_last_review` relies on this.
2742    #[test]
2743    fn iso8601_timestamps_compare_correctly() {
2744        // newer commit (pushed after review) should be greater
2745        assert!(iso8601_is_newer(
2746            "2024-01-15T13:00:00Z",
2747            "2024-01-15T12:30:00Z"
2748        ));
2749        // same timestamp is not "newer"
2750        assert!(!iso8601_is_newer(
2751            "2024-01-15T12:30:00Z",
2752            "2024-01-15T12:30:00Z"
2753        ));
2754        // older commit is not newer
2755        assert!(!iso8601_is_newer(
2756            "2024-01-14T23:59:59Z",
2757            "2024-01-15T00:00:01Z"
2758        ));
2759        // cross-day boundary
2760        assert!(iso8601_is_newer(
2761            "2024-01-16T00:00:01Z",
2762            "2024-01-15T23:59:59Z"
2763        ));
2764    }
2765
2766    #[test]
2767    fn is_pr_behind_matches_mergeable_state() {
2768        let mut pr = GitHubPullRequest {
2769            number: 1,
2770            title: "Test PR".to_string(),
2771            body: None,
2772            state: "open".to_string(),
2773            head: crate::github::types::GitHubBranchRef {
2774                ref_: "feature/test".to_string(),
2775                sha: "abc123".to_string(),
2776            },
2777            base: crate::github::types::GitHubBranchRef {
2778                ref_: "main".to_string(),
2779                sha: "def456".to_string(),
2780            },
2781            mergeable: Some(true),
2782            mergeable_state: Some("behind".to_string()),
2783            merged: Some(false),
2784            html_url: "https://github.com/owner/repo/pull/1".to_string(),
2785        };
2786
2787        assert!(is_pr_behind(&pr));
2788
2789        pr.mergeable_state = Some("clean".to_string());
2790        assert!(!is_pr_behind(&pr));
2791
2792        pr.mergeable_state = None;
2793        assert!(!is_pr_behind(&pr));
2794    }
2795
2796    #[test]
2797    fn classify_merge_failure_treats_out_of_date_head_as_recoverable() {
2798        let error = r#"GitHub PR merge failed (409 Conflict): {"message":"Head branch is out of date. Review and try the merge again."}"#;
2799        assert_eq!(
2800            classify_merge_failure(error),
2801            MergeFailureClass::RecoverableConflict
2802        );
2803    }
2804
2805    #[test]
2806    fn classify_merge_failure_treats_gateway_errors_as_transient() {
2807        assert_eq!(
2808            classify_merge_failure("GitHub PR merge failed (502 Bad Gateway)"),
2809            MergeFailureClass::Transient
2810        );
2811    }
2812
2813    #[test]
2814    fn classify_merge_failure_keeps_branch_protection_errors_blocking() {
2815        let error = "GitHub PR merge failed (403 Forbidden): branch protection prevented merge";
2816        assert_eq!(classify_merge_failure(error), MergeFailureClass::Blocking);
2817    }
2818
2819    /// When the GitHub API is unavailable (no real token in unit tests),
2820    /// `handle_review_changes` must still escalate correctly rather than
2821    /// getting stuck.  The stale-review check fails gracefully and we fall
2822    /// through to the normal Blocked path.
2823    #[tokio::test]
2824    async fn handle_review_changes_escalates_when_stale_check_fails() {
2825        use crate::engine::tasks::TaskManager;
2826        use crate::store::{TaskStatus, TaskStore};
2827
2828        let store = Arc::new(TaskStore::open_memory().await.unwrap());
2829        let repo = "owner/repo";
2830
2831        let task_id_num = store
2832            .create(&crate::store::NewTask {
2833                external_id: None,
2834                repo: repo.to_string(),
2835                origin: "internal".to_string(),
2836                title: "Fix defaults".to_string(),
2837                body: "body".to_string(),
2838                source: "cron".to_string(),
2839                source_id: "daily".to_string(),
2840                author: "".to_string(),
2841                url: "".to_string(),
2842                labels: vec![],
2843                parent_id: None,
2844            })
2845            .await
2846            .unwrap();
2847
2848        // Set review_cycles to max so we enter the escalation path.
2849        store
2850            .set_fields(task_id_num, &[("review_cycles", serde_json::json!(2i64))])
2851            .await
2852            .unwrap();
2853        store
2854            .update_status(task_id_num, TaskStatus::InReview)
2855            .await
2856            .unwrap();
2857
2858        let task_id_str = format!("internal:{task_id_num}");
2859        let backend: Arc<dyn crate::backends::ExternalBackend> = Arc::new(NoopBackend);
2860        let task_manager = Arc::new(TaskManager::with_store(
2861            Arc::clone(&backend),
2862            Arc::clone(&store),
2863            repo.to_string(),
2864        ));
2865
2866        let task = ExternalTask {
2867            id: ExternalId(task_id_str.clone()),
2868            title: "Fix defaults".to_string(),
2869            body: "body".to_string(),
2870            state: "open".to_string(),
2871            labels: vec![],
2872            author: "".to_string(),
2873            created_at: "2026-01-01T00:00:00Z".to_string(),
2874            updated_at: "2026-01-01T00:00:00Z".to_string(),
2875            url: "".to_string(),
2876        };
2877
2878        // With no real GitHub token the stale check will fail gracefully and
2879        // we fall through to normal escalation (Blocked status).
2880        let result = handle_review_changes(
2881            &task,
2882            "sync_interval defaults are wrong",
2883            &[],
2884            &backend,
2885            repo,
2886            303,
2887            "minimax",
2888            "minimax-m2",
2889            &task_manager,
2890            &store,
2891            &crate::engine::router::RouterConfig::default(),
2892        )
2893        .await;
2894
2895        assert!(
2896            result.is_ok(),
2897            "handle_review_changes must return Ok on escalation path: {result:?}"
2898        );
2899
2900        let stored = store.get(task_id_num).await.unwrap();
2901        assert_eq!(
2902            stored.status,
2903            TaskStatus::Blocked,
2904            "task must be Blocked when max review cycles exceeded and stale check is unavailable"
2905        );
2906    }
2907
2908    /// Regression test for the bug where `auto_merge_pr`'s pending-CI timeout
2909    /// never fired: elapsed pending time must accumulate in the KV store
2910    /// across separate calls (simulating separate short-lived invocations),
2911    /// not reset to zero each time like a function-local `Instant` would.
2912    #[tokio::test]
2913    async fn ci_pending_elapsed_persists_across_calls() {
2914        use crate::store::TaskStore;
2915
2916        let store = Arc::new(TaskStore::open_memory().await.unwrap());
2917        let task_id = "internal:1";
2918
2919        // First observation: no prior timestamp, so elapsed starts at zero
2920        // and the "first seen pending" timestamp is persisted.
2921        let first = ci_pending_elapsed(&store, task_id).await.unwrap();
2922        assert!(
2923            first.num_seconds() < 1,
2924            "first pending observation should report ~zero elapsed, got {first:?}"
2925        );
2926
2927        // Simulate time passing between two short-lived invocations by
2928        // backdating the persisted timestamp directly, the way it would look
2929        // if `is_ci_check_in_cooldown` had short-circuited several polls ago.
2930        let backdated = chrono::Utc::now() - chrono::Duration::seconds(700);
2931        store
2932            .kv_set(&ci_pending_since_key(task_id), &backdated.to_rfc3339())
2933            .await
2934            .unwrap();
2935
2936        let second = ci_pending_elapsed(&store, task_id).await.unwrap();
2937        assert!(
2938            second.num_seconds() >= 600,
2939            "elapsed pending time must accumulate across calls instead of resetting, got {second:?}"
2940        );
2941
2942        // Clearing removes the persisted timestamp so the next observation
2943        // starts a fresh streak at ~zero elapsed.
2944        clear_ci_pending_since(&store, task_id, "test")
2945            .await
2946            .unwrap();
2947        let after_clear = ci_pending_elapsed(&store, task_id).await.unwrap();
2948        assert!(
2949            after_clear.num_seconds() < 1,
2950            "elapsed must reset to ~zero after clear_ci_pending_since, got {after_clear:?}"
2951        );
2952    }
2953
2954    /// Regression test for #3609: `auto_merge_pr` can run as two overlapping
2955    /// invocations for the same task (the review-agent's direct approval call
2956    /// in review.rs is not guarded by the same `auto_merge_in_flight` set as
2957    /// the periodic review_open_prs sweep). A racing call that observes CI as
2958    /// newly pending must not overwrite an already-persisted "since" — doing
2959    /// so silently resets the elapsed clock and the 600s timeout escalation
2960    /// never fires, even though a human-visible log line shows CI pending
2961    /// continuously well past `max_wait`.
2962    #[tokio::test]
2963    async fn ci_pending_elapsed_atomic_insert_survives_concurrent_racers() {
2964        use crate::store::TaskStore;
2965
2966        let store = Arc::new(TaskStore::open_memory().await.unwrap());
2967        let task_id = "internal:2";
2968
2969        let backdated = chrono::Utc::now() - chrono::Duration::seconds(700);
2970        store
2971            .kv_set(&ci_pending_since_key(task_id), &backdated.to_rfc3339())
2972            .await
2973            .unwrap();
2974
2975        // A second, racing invocation observing the same pending state must
2976        // read back the already-persisted timestamp instead of clobbering it
2977        // with its own "now".
2978        let racer = ci_pending_elapsed(&store, task_id).await.unwrap();
2979        assert!(
2980            racer.num_seconds() >= 600,
2981            "a racing call must not reset an already-persisted ci_pending_since, got {racer:?}"
2982        );
2983    }
2984}