Skip to main content

orch/engine/subscribers/
review.rs

1//! Reacts to NeedsReview events — spawns review agent immediately.
2
3use crate::backends::{ExternalBackend, ExternalId, ExternalTask, Status};
4use crate::engine::cooldown::AGENT_COOLDOWN_SECS;
5use crate::engine::dispatch_guard::DispatchGuard;
6use crate::engine::events::TaskEvent;
7use crate::engine::review::{review_and_merge, ReviewDecision, MAX_REVIEW_AGENT_FAILURES};
8use crate::engine::review_gate::CiGate;
9use crate::engine::review_shipcheck::Prerun;
10use crate::engine::router::Router;
11use crate::engine::tasks::TaskManager;
12use crate::github::http::GhHttp;
13use crate::repo_context::REPO_CONTEXT;
14use crate::store::{opt_store_get_task, TaskStore};
15use crate::tmux::TmuxManager;
16use dashmap::DashMap;
17use std::sync::Arc;
18use tokio::sync::broadcast;
19use tokio::sync::{RwLock, Semaphore};
20
21enum ReviewOutcome {
22    Reset,
23    RateLimited,
24    /// The PR was approved but the merge did not land for a reason outside the
25    /// review (mergeability not computed yet, rate limit, transient GitHub error).
26    /// The task moves to Approved so `review_open_prs` retries the merge, instead of
27    /// resetting to NeedsReview (which spawns a redundant review agent)
28    RetryMerge,
29    /// The review approved the PR. The task moves to Approved, where orch waits for CI and
30    /// merges with no agent running
31    Approved,
32    /// Re-route to the task agent (e.g. merge conflicts that need the agent to fix).
33    Reroute,
34    Block(String),
35    Ok,
36}
37
38/// Spawn a task that listens for NeedsReview events and triggers the review agent.
39///
40/// This is the sole trigger for NeedsReview → InReview transitions. The sync tick
41/// no longer has a competing NeedsReview loop (removed in fix for issue #857 — both
42/// paths firing simultaneously caused double failure-counter increments).
43#[allow(clippy::too_many_arguments)]
44pub fn spawn(
45    mut rx: broadcast::Receiver<TaskEvent>,
46    backend: Arc<dyn ExternalBackend>,
47    tmux: Arc<TmuxManager>,
48    semaphore: Arc<Semaphore>,
49    task_manager: Arc<TaskManager>,
50    router: Arc<RwLock<Router>>,
51    dispatching: Arc<DashMap<String, String>>,
52    store: Arc<TaskStore>,
53    repo: String,
54) {
55    tokio::spawn(async move {
56        loop {
57            match rx.recv().await {
58                Ok(event) if event.new_status == "needs_review" && event.repo == repo => {
59                    // Check if review agent is enabled (read fresh each time).
60                    let enable_review = crate::config::get("workflow.enable_review_agent")
61                        .map(|v| v != "false")
62                        .unwrap_or(true);
63                    if !enable_review {
64                        continue;
65                    }
66
67                    let task_id = &event.task_id;
68                    let dispatch_key = format!("{}/{}", repo, task_id);
69
70                    // Atomically claim the task before any async work so concurrent
71                    // review events cannot double-spawn the same review flow.
72                    // Using DashMap lets us distinguish same-task duplicates (expected) from
73                    // key collisions involving a different task (should never happen).
74                    {
75                        use dashmap::mapref::entry::Entry;
76                        match dispatching.entry(dispatch_key.clone()) {
77                            Entry::Occupied(existing) => {
78                                let existing_id = existing.get().clone();
79                                drop(existing);
80                                if existing_id == *task_id {
81                                    tracing::debug!(
82                                        task_id,
83                                        "task locked by dispatch flow, skipping event-driven review"
84                                    );
85                                } else {
86                                    tracing::warn!(
87                                        task_id,
88                                        existing_task_id = existing_id,
89                                        dispatch_key,
90                                        "dispatch key collision: unexpected task already holds this key"
91                                    );
92                                }
93                                continue;
94                            }
95                            Entry::Vacant(slot) => {
96                                slot.insert(task_id.clone());
97                            }
98                        }
99                    }
100
101                    // Create guard IMMEDIATELY after inserting the key — owns removal for all subsequent exit paths
102                    let dispatch_guard =
103                        DispatchGuard::new(dispatching.clone(), dispatch_key.clone());
104
105                    tracing::info!(task_id, "event-driven review triggered");
106
107                    // Look up the task from the store to get an ExternalTask.
108                    let task = {
109                        if crate::engine::tasks::is_internal_id(task_id)
110                            || match store.has_external_tasks(&repo).await {
111                                Ok(has_external) => has_external,
112                                Err(e) => {
113                                    tracing::warn!(
114                                        repo = %repo,
115                                        task_id,
116                                        error = %e,
117                                        "failed to check external task sentinel; keeping store-first review lookup"
118                                    );
119                                    true
120                                }
121                            }
122                        {
123                            // O(1) indexed lookup — resolve task_id to its SQLite row id,
124                            // then fetch directly instead of scanning the whole needs_review set.
125                            let result = if let Some(numeric_id) =
126                                crate::engine::tasks::parse_internal_id(task_id)
127                            {
128                                store.get(numeric_id).await.map(Some)
129                            } else {
130                                store.get_by_external_id(&repo, task_id).await
131                            };
132                            match result {
133                                Ok(Some(t))
134                                    if t.status == crate::store::TaskStatus::NeedsReview =>
135                                {
136                                    Some(crate::engine::tasks::store_task_to_external(&t))
137                                }
138                                Ok(Some(t)) => {
139                                    tracing::debug!(
140                                        task_id,
141                                        status = t.status.as_str(),
142                                        "task found but not in needs_review status, skipping"
143                                    );
144                                    None
145                                }
146                                Ok(None) => None,
147                                Err(e) => {
148                                    tracing::warn!(task_id, err = %e, "store lookup failed for review");
149                                    None
150                                }
151                            }
152                        } else {
153                            // Fall back to backend
154                            match backend.list_by_status(Status::NeedsReview).await {
155                                Ok(tasks) => tasks.into_iter().find(|t| t.id.0 == *task_id),
156                                Err(e) => {
157                                    tracing::warn!(task_id, err = %e, "backend lookup failed for review");
158                                    None
159                                }
160                            }
161                        }
162                    };
163
164                    let Some(task) = task else {
165                        tracing::debug!(
166                            task_id,
167                            "task not found in needs_review list (may have already transitioned)"
168                        );
169                        continue;
170                    };
171
172                    if let Some(stored) =
173                        opt_store_get_task(&Some(store.clone()), &repo, &task.id.0).await
174                    {
175                        let no_code_review = stored.worktree.trim().is_empty()
176                            && stored.branch.trim().is_empty()
177                            && stored.pr_number.is_none();
178                        if no_code_review {
179                            tracing::info!(
180                                task_id,
181                                "no worktree/branch/pr for needs_review task — skipping review dispatch and marking done"
182                            );
183                            crate::store::store_reset_failure_counters(
184                                &Some(store.clone()),
185                                &repo,
186                                &task.id.0,
187                            )
188                            .await;
189                            if let Err(e) = crate::store::store_set_result(
190                                &Some(store.clone()),
191                                &repo,
192                                &task.id.0,
193                                &[("needs_review_refires", serde_json::json!(0))],
194                            )
195                            .await
196                            {
197                                tracing::warn!(
198                                    task_id,
199                                    err = %e,
200                                    "failed to reset needs_review_refires while skipping no-code review"
201                                );
202                            }
203                            if let Err(e) = task_manager
204                                .update_task_status(&task.id, Status::Done)
205                                .await
206                            {
207                                tracing::error!(
208                                    task_id,
209                                    err = %e,
210                                    "failed to mark no-code needs_review task as done"
211                                );
212                            }
213                            continue;
214                        }
215                    }
216
217                    // If no agents are currently routable (all cooled, degraded, or without
218                    // an available review model), skip this event and let sync re-fire when
219                    // an agent is available again. Sleeping inline here would block every
220                    // subsequent NeedsReview event for this repo for the duration of the
221                    // cooldown (potentially hours/days), causing sync's rebroadcast loop to
222                    // escalate piled-up tasks to Blocked.
223                    {
224                        let router_guard = router.read().await;
225                        let no_routable = !router_guard.available_agents.is_empty()
226                            && router_guard.healthy_agent_count("review") == 0;
227                        drop(router_guard);
228                        if no_routable {
229                            tracing::info!(
230                                task_id,
231                                "no routable review agents — leaving task in needs_review for sync to re-fire"
232                            );
233                            continue;
234                        }
235                    }
236
237                    if !ci_allows_review(&store, &task_manager, &repo, &task).await {
238                        continue;
239                    }
240
241                    // Try to acquire a semaphore permit.
242                    let permit = match semaphore.clone().try_acquire_owned() {
243                        Ok(p) => p,
244                        Err(_) => {
245                            tracing::debug!(task_id, "all parallel slots busy; sync catch-up will re-fire when a slot opens");
246                            continue;
247                        }
248                    };
249
250                    // Transition to InReview — this IS the atomic guard against duplicates.
251                    if let Err(e) = task_manager
252                        .update_task_status(&task.id, Status::InReview)
253                        .await
254                    {
255                        tracing::warn!(task_id, err = %e, "failed to transition to InReview");
256                        drop(permit);
257                        continue;
258                    }
259
260                    let backend_c = backend.clone();
261                    let task_manager_c = task_manager.clone();
262                    let tmux_c = tmux.clone();
263                    let router_c = router.clone();
264                    let store_c = store.clone();
265                    let repo_s = repo.clone();
266                    tokio::spawn(REPO_CONTEXT.scope(repo_s.clone(), async move {
267                        let _dispatch_guard = dispatch_guard; // released on drop (normal or panic)
268                        let tid = task.id.0.clone();
269
270                        // Re-check agent routability inside the spawned task. A concurrent
271                        // review that ran between the outer dispatch check and now may have
272                        // set a cooldown — bail early rather than burning a review run
273                        // against a rate-limited agent.
274                        let pre_check_all_cooled = {
275                            let r = router_c.read().await;
276                            !r.available_agents.is_empty() && r.healthy_agent_count("review") == 0
277                        };
278                        if pre_check_all_cooled {
279                            tracing::info!(
280                                task_id = tid,
281                                "all agents cooled at review spawn time — resetting to needs_review for sync to re-fire"
282                            );
283                            if let Err(e) = task_manager_c
284                                .update_task_status(
285                                    &ExternalId(tid.clone()),
286                                    Status::NeedsReview,
287                                )
288                                .await
289                            {
290                                tracing::error!(task_id = %tid, err = %e, "pre-check: update_task_status(NeedsReview) failed — task may be stuck in InReview");
291                            }
292                            drop(permit);
293                            return;
294                        }
295
296                        let outcome = match review_and_merge(
297                            &task,
298                            &backend_c,
299                            &tmux_c,
300                            &repo_s,
301                            &router_c,
302                            &task_manager_c,
303                            &store_c,
304                        )
305                        .await
306                        {
307                            Ok(ReviewDecision::Blocked(reason)) => {
308                                tracing::error!(
309                                    task_id = tid,
310                                    reason,
311                                    "review gate blocked after repeated failures — marking task blocked"
312                                );
313                                ReviewOutcome::Block(format!(
314                                    "review gate blocked after repeated failures: {reason}"
315                                ))
316                            }
317                            Ok(ReviewDecision::Failed(reason)) => {
318                                classify_review_failure(
319                                    &store_c, &repo_s, &tid, &reason, "review agent",
320                                )
321                                .await
322                            }
323                            Err(e) => {
324                                let reason = format!("{e:#}");
325                                classify_review_failure(
326                                    &store_c, &repo_s, &tid, &reason, "review_and_merge",
327                                )
328                                .await
329                            }
330                            Ok(ReviewDecision::Approve { .. }) => {
331                                // On approval we clear transient failure counters
332                                // but preserve the `review_cycles` counter which
333                                // tracks how many times the PR has requested
334                                // changes and is used as a circuit-breaker.
335                                crate::store::store_reset_failure_counters(
336                                    &Some(store_c.clone()),
337                                    &repo_s,
338                                    &tid,
339                                )
340                                .await;
341                                // Also reset needs_review_refires so the sync backoff doesn't persist across a successful review
342                                if let Err(e) = crate::store::store_set_result(
343                                    &Some(store_c.clone()),
344                                    &repo_s,
345                                    &tid,
346                                    &[("needs_review_refires", serde_json::json!(0))],
347                                )
348                                .await
349                                {
350                                    tracing::warn!(task_id = %tid, err = %e, "failed to write needs_review_refires to store");
351                                }
352
353                                // Approval only means the code looks good, the merge is the
354                                // completion signal. review_open_prs merges Approved tasks and
355                                // moves them to Done
356                                ReviewOutcome::Approved
357                            }
358                            Ok(ReviewDecision::Rerouted) => {
359                                // Task was re-routed because there was no PR and no commits.
360                                // Do NOT reset failure counters — no_code_reroutes must accumulate
361                                // so the max_reroute_attempts safety limit can be reached.
362                                ReviewOutcome::Ok
363                            }
364                            Ok(ReviewDecision::Skipped) => {
365                                // Skipped indicates there's nothing to review (no PR etc.).
366                                // Clear transient failure counters but preserve review_cycles.
367                                crate::store::store_reset_failure_counters(
368                                    &Some(store_c.clone()),
369                                    &repo_s,
370                                    &tid,
371                                )
372                                .await;
373                                if let Err(e) = crate::store::store_set_result(
374                                    &Some(store_c.clone()),
375                                    &repo_s,
376                                    &tid,
377                                    &[("needs_review_refires", serde_json::json!(0))],
378                                )
379                                .await
380                                {
381                                    tracing::warn!(task_id = %tid, err = %e, "failed to write needs_review_refires to store");
382                                }
383                                ReviewOutcome::Ok
384                            }
385                            Ok(ReviewDecision::RequestChanges { .. }) => {
386                                // handle_review_changes already incremented review_cycles —
387                                // only reset transient per-attempt counters.
388                                crate::store::store_reset_failure_counters(
389                                    &Some(store_c.clone()),
390                                    &repo_s,
391                                    &tid,
392                                )
393                                .await;
394                                if let Err(e) = crate::store::store_set_result(
395                                    &Some(store_c.clone()),
396                                    &repo_s,
397                                    &tid,
398                                    &[("needs_review_refires", serde_json::json!(0))],
399                                )
400                                .await
401                                {
402                                    tracing::warn!(task_id = %tid, err = %e, "failed to write needs_review_refires to store");
403                                }
404                                ReviewOutcome::Ok
405                            }
406                        };
407                        // Freshness guard: re-fetch the task's current status before applying
408                        // any outcome transitions. A concurrent path (e.g. PR merge, manual
409                        // close, or another review cycle) may have already moved the task out
410                        // of `in_review`. If so, discard the stale outcome rather than
411                        // overwriting the newer state.
412                        //
413                        // IMPORTANT: Distinguish between DB errors and legitimate status changes.
414                        // A DB lookup failure should NOT be treated as "task moved out of in_review"
415                        // — we should retain the review outcome rather than discarding it on
416                        // transient errors.
417                        let fetched: Result<Option<(crate::store::TaskStatus, Option<String>)>, anyhow::Error> = async {
418                            let id = match store_c.resolve_task_id(&repo_s, &tid).await {
419                                Ok(Some(id)) => id,
420                                Ok(None) => return Ok(None), // Task doesn't exist — treat as missing
421                                Err(e) => return Err(e),      // DB error — propagate, don't discard
422                            };
423                            let task = store_c.get(id).await?;
424                            Ok(Some((task.status, task.block_reason)))
425                        }
426                        .await;
427
428                        let (current_status, current_block_reason) = match fetched {
429                            Ok(Some((status, block_reason))) => (Some(status), block_reason),
430                            Ok(None) => {
431                                // Task doesn't exist in store — treat as not in_review
432                                (None, None)
433                            }
434                            Err(e) => {
435                                // DB error — log and keep the review outcome instead of discarding
436                                tracing::error!(
437                                    task_id = tid,
438                                    error = %e,
439                                    "review outcome retained — DB lookup failed, not treating as stale"
440                                );
441                                // Skip the stale outcome check and proceed with the outcome
442                                // by treating it as if the task is still InReview
443                                (Some(crate::store::TaskStatus::InReview), None)
444                            }
445                        };
446
447                        if !matches!(current_status, Some(crate::store::TaskStatus::InReview)) {
448                            let ci_blocked = matches!(
449                                current_status,
450                                Some(crate::store::TaskStatus::Blocked)
451                            ) && current_block_reason
452                                .as_deref()
453                                .is_some_and(|r| r.contains("CI failure limit"));
454                            let expected = ci_blocked
455                                || matches!(
456                                    current_status,
457                                    Some(crate::store::TaskStatus::Done)
458                                        | Some(crate::store::TaskStatus::Approved)
459                                        | Some(crate::store::TaskStatus::Routed)
460                                        | Some(crate::store::TaskStatus::New)
461                                );
462                            if expected {
463                                tracing::debug!(
464                                    task_id = tid,
465                                    current_status = current_status.as_ref().map(|s| s.as_str()).unwrap_or("unknown"),
466                                    "review outcome discarded — task transitioned away from in_review (expected)"
467                                );
468                            } else {
469                                tracing::warn!(
470                                    task_id = tid,
471                                    current_status = current_status.as_ref().map(|s| s.as_str()).unwrap_or("unknown"),
472                                    "review outcome discarded — task is no longer in_review (stale review agent result)"
473                                );
474                            }
475
476                            // Kill the orphaned review session
477                            let stale_session = tmux_c.session_name(&repo_s, &format!("{}-review", tid));
478                            if let Err(e) = tmux_c.kill_session(&stale_session).await {
479                                tracing::debug!(task_id = tid, error = %e, "failed to kill stale review session");
480                            }
481
482                            drop(permit);
483                            return;
484                        }
485
486                        match outcome {
487                            ReviewOutcome::Reset => {
488                                // Kill any stale tmux review session before resetting — the
489                                // session may still be alive if the agent hit a rate limit,
490                                // if two concurrent reviewers raced and the loser's spawn
491                                // failed with "duplicate session", or if review_and_merge
492                                // returned an error before reaching its own cleanup.
493                                // Killing a non-existent session is a harmless no-op.
494                                let stale_session =
495                                    tmux_c.session_name(&repo_s, &format!("{}-review", tid));
496                                tmux_c.kill_session(&stale_session).await.ok();
497                                // Backoff before re-queuing to prevent rapid spin
498                                // if the review agent keeps failing instantly.
499                                tokio::time::sleep(std::time::Duration::from_secs(10)).await;
500                                if let Err(e) = task_manager_c
501                                    .update_task_status(
502                                        &ExternalId(tid.clone()),
503                                        Status::NeedsReview,
504                                    )
505                                    .await
506                                {
507                                    tracing::error!(task_id = %tid, err = %e, "update_task_status(NeedsReview) failed — task may be stuck in InReview");
508                                }
509                            }
510                            ReviewOutcome::Reroute => {
511                                // Merge conflict or similar — route back to the
512                                // task agent so it can fix the issue.
513                                let stale_session =
514                                    tmux_c.session_name(&repo_s, &format!("{}-review", tid));
515                                tmux_c.kill_session(&stale_session).await.ok();
516                                tracing::info!(
517                                    task_id = tid,
518                                    "re-routing to task agent to resolve merge conflict"
519                                );
520                                if let Err(e) = task_manager_c
521                                    .update_task_status(
522                                        &ExternalId(tid.clone()),
523                                        Status::Routed,
524                                    )
525                                    .await
526                                {
527                                    tracing::error!(task_id = %tid, err = %e, "update_task_status(Routed) failed");
528                                }
529                            }
530                            ReviewOutcome::Approved | ReviewOutcome::RetryMerge => {
531                                // The review is over, so its session goes. The merge may have
532                                // landed already, or CI sent the task back, so only in_review moves
533                                let stale_session =
534                                    tmux_c.session_name(&repo_s, &format!("{}-review", tid));
535                                tmux_c.kill_session(&stale_session).await.ok();
536                                match task_manager_c
537                                    .update_task_status_if(
538                                        &ExternalId(tid.clone()),
539                                        Status::Approved,
540                                        Status::InReview,
541                                    )
542                                    .await
543                                {
544                                    Ok(true) => tracing::info!(
545                                        task_id = tid,
546                                        "approval received, task approved until the PR is merged"
547                                    ),
548                                    Ok(false) => tracing::debug!(
549                                        task_id = tid,
550                                        "approval received after the task left in_review"
551                                    ),
552                                    Err(e) => tracing::error!(
553                                        task_id = %tid,
554                                        err = %e,
555                                        "update_task_status_if(Approved) failed, task may be stuck in in_review"
556                                    ),
557                                }
558                            }
559                            ReviewOutcome::RateLimited => {
560                                // Kill stale tmux session — same as Reset.
561                                let stale_session =
562                                    tmux_c.session_name(&repo_s, &format!("{}-review", tid));
563                                tmux_c.kill_session(&stale_session).await.ok();
564
565                                // Compute wait duration: if any agent is available right now,
566                                // use a short backoff (30 s). If every agent is cooled, sleep
567                                // until the earliest cooldown expires so we don't spin.
568                                let wait_secs = {
569                                    let r = router_c.read().await;
570                                    if r.healthy_agent_count("review") > 0 {
571                                        30u64
572                                    } else {
573                                        let now = chrono::Utc::now().timestamp();
574                                        r.earliest_cooldown_until(Some("review"))
575                                            .map(|until| ((until - now).max(1)) as u64)
576                                            .unwrap_or(AGENT_COOLDOWN_SECS as u64)
577                                    }
578                                };
579                                tracing::info!(
580                                    task_id = tid,
581                                    wait_secs,
582                                    "deferring review retry due to agent rate limit cooldown"
583                                );
584                                tokio::time::sleep(std::time::Duration::from_secs(wait_secs)).await;
585                                if let Err(e) = task_manager_c
586                                    .update_task_status(
587                                        &ExternalId(tid.clone()),
588                                        Status::NeedsReview,
589                                    )
590                                    .await
591                                {
592                                    tracing::error!(task_id = %tid, err = %e, "update_task_status(NeedsReview) failed after rate limit backoff — task may be stuck in InReview");
593                                }
594                            }
595                             ReviewOutcome::Block(reason) => {
596                                 // Persist block_reason BEFORE transitioning to Blocked to avoid
597                                 // a race where auto_unblock sees a blocked task without a reason
598                                 // and immediately unblocks it.
599                                 let fields = [
600                                     (
601                                         "block_reason",
602                                         serde_json::json!("review agent blocked — exceeded failure threshold"),
603                                     ),
604                                     ("last_error", serde_json::json!(reason)),
605                                 ];
606                                 if let Err(e) = task_manager_c
607                                     .update_task_status_and_result(
608                                         &ExternalId(tid.clone()),
609                                         Status::Blocked,
610                                         &fields,
611                                     )
612                                     .await
613                                 {
614                                     tracing::error!(task_id = %tid, err = %e, "update_task_status_and_result(Blocked) failed — task may be stuck in InReview");
615                                 }
616                             }
617                            ReviewOutcome::Ok => {}
618                        }
619
620                        drop(permit);
621                        // _dispatch_guard dropped here — releases the per-task lock.
622                    }));
623
624                    // Small delay between dispatches to allow cooldowns from
625                    // early failures to propagate before subsequent reviews are
626                    // dispatched. This prevents batch dispatch of N reviews to
627                    // the same rate-limited agent when events arrive concurrently.
628                    tokio::time::sleep(std::time::Duration::from_secs(2)).await;
629                }
630                Ok(_) => {} // Not a needs_review event or different repo
631                Err(broadcast::error::RecvError::Lagged(n)) => {
632                    tracing::debug!(n, "review subscriber lagged; sync catch-up will re-fire missed NeedsReview tasks");
633                }
634                Err(_) => break, // Channel closed
635            }
636        }
637    });
638}
639
640/**
641 * Record a failed review attempt on the task's activity timeline and hand the outcome
642 * back to `classify_review_failure`, which returns it unchanged. Without this the only
643 * trace of a failed attempt was the final `last_error` written at escalation, so
644 * `orch task inspect` showed that a task gave up but never why each attempt failed.
645 *
646 * `external` marks a cause outside the review itself (agent rate limit, GitHub API
647 * limit, no review model). Those also clear `needs_review_refires`: the refire quota
648 * exists to catch a task the subscriber never picks up, and spending it on a cause the
649 * review agent cannot control escalated healthy tasks to Blocked during a rate-limit
650 * window. The retry itself is already handled by the `RateLimited` / `Reset` arms
651 **/
652/// The CI gate (#3904): true when the review may start. A task held for CI stays in needs_review
653/// and its refire count goes back to 0, so the sync sweep checks it again and never blocks it
654async fn ci_allows_review(
655    store: &Arc<TaskStore>,
656    task_manager: &Arc<TaskManager>,
657    repo: &str,
658    task: &ExternalTask,
659) -> bool {
660    let task_id = task.id.0.as_str();
661    let stored = opt_store_get_task(&Some(store.clone()), repo, task_id).await;
662    let Some(pr_number) = stored
663        .as_ref()
664        .and_then(|t| t.pr_number)
665        .and_then(|n| u64::try_from(n).ok())
666    else {
667        return true;
668    };
669    let worktree = stored
670        .map(|t| std::path::PathBuf::from(t.worktree))
671        .filter(|p| !p.as_os_str().is_empty() && p.exists());
672    let gate = match GhHttp::new() {
673        Ok(gh) => {
674            crate::engine::review_gate::review_ci_gate(&gh, store, repo, task_id, pr_number).await
675        }
676        Err(e) => Err(e),
677    };
678
679    let hold = match gate {
680        Ok(CiGate::Start) => return true,
681        // The merge still waits for green CI, so a lookup error does not hold the review
682        Err(e) => {
683            tracing::warn!(task_id, pr_number, err = %e, "CI gate lookup failed, starting the review");
684            return true;
685        }
686        Ok(CiGate::Red(failed)) => {
687            route_back_on_red_ci(store, task_manager, repo, task, pr_number, &failed).await;
688            return false;
689        }
690        Ok(CiGate::Wait) => "CI still running on the PR head, review waits",
691        // The reviewer runs shipcheck when no check ran, so orch runs it first, outside the review's limit
692        Ok(CiGate::NotStarted { waited_out }) => match local_ci_worktree(repo, worktree).await {
693            Some(wt) => match crate::engine::review_shipcheck::prerun(&wt).await {
694                Prerun::Ready => return true,
695                Prerun::Running => "shipcheck running on the PR head, review waits",
696            },
697            None if waited_out => {
698                tracing::warn!(
699                    task_id,
700                    pr_number,
701                    "no CI ran on the PR head, starting the review anyway"
702                );
703                return true;
704            }
705            None => "no CI started on the PR head yet, review waits",
706        },
707    };
708
709    tracing::info!(task_id, pr_number, "{hold}");
710    if let Err(e) = crate::store::store_set_result(
711        &Some(store.clone()),
712        repo,
713        task_id,
714        &[("needs_review_refires", serde_json::json!(0))],
715    )
716    .await
717    {
718        tracing::warn!(task_id, err = %e, "failed to reset needs_review_refires for a task held by the CI gate");
719    }
720
721    false
722}
723
724/// The task worktree, when the reviewer there would run `shipcheck run`
725async fn local_ci_worktree(
726    repo: &str,
727    worktree: Option<std::path::PathBuf>,
728) -> Option<std::path::PathBuf> {
729    let wt = worktree?;
730    let sandboxed = crate::engine::review::review_sandboxed(repo);
731    let shipcheck = crate::engine::runner::agent::Shipcheck::detect(&wt)
732        .await
733        .for_prompt(sandboxed);
734    (shipcheck == crate::engine::runner::agent::Shipcheck::Available).then_some(wt)
735}
736
737/// CI failed before the review, so the task agent fixes it without a review round
738async fn route_back_on_red_ci(
739    store: &Arc<TaskStore>,
740    task_manager: &Arc<TaskManager>,
741    repo: &str,
742    task: &ExternalTask,
743    pr_number: u64,
744    failed: &[String],
745) {
746    let task_id = task.id.0.as_str();
747    let failures = match crate::store::store_increment(
748        &Some(store.clone()),
749        repo,
750        task_id,
751        "ci_merge_failures",
752    )
753    .await
754    {
755        Ok(v) => v,
756        Err(e) => {
757            tracing::warn!(task_id, err = %e, "failed to increment ci_merge_failures, review waits");
758            return;
759        }
760    };
761    let checks = failed.join(", ");
762    let max = crate::engine::auto_merge::MAX_CI_MERGE_FAILURES;
763    let (status, fields) = if failures >= max {
764        tracing::error!(
765            task_id,
766            pr_number,
767            failures,
768            checks,
769            "CI failed before review, CI failure limit reached"
770        );
771        (
772            Status::Blocked,
773            vec![(
774                "block_reason",
775                serde_json::json!(format!("CI failure limit ({max}) reached before review")),
776            )],
777        )
778    } else {
779        tracing::warn!(
780            task_id,
781            pr_number,
782            failures,
783            checks,
784            "CI failed before review, routing back to the task agent"
785        );
786        (
787            Status::Routed,
788            vec![
789                (
790                    "last_error",
791                    serde_json::json!(format!(
792                        "CI failed on PR #{pr_number} before review: {checks}. Fix the failing checks"
793                    )),
794                ),
795                ("route_reason", serde_json::json!("re-dispatch after CI failed before review")),
796            ],
797        )
798    };
799    if let Err(e) = task_manager
800        .update_task_status_and_result(&task.id, status, &fields)
801        .await
802    {
803        tracing::error!(task_id, err = %e, "failed to move a task whose CI failed before review");
804    }
805}
806
807async fn recorded(
808    store: &Arc<TaskStore>,
809    repo: &str,
810    task_id: &str,
811    reason: &str,
812    cause: &str,
813    external: bool,
814    outcome: ReviewOutcome,
815) -> ReviewOutcome {
816    let store_id = match store.resolve_task_id(repo, task_id).await {
817        Ok(Some(id)) => id,
818        Ok(None) => {
819            tracing::warn!(
820                task_id,
821                "review attempt not recorded — task not found in store"
822            );
823            return outcome;
824        }
825        Err(e) => {
826            tracing::warn!(task_id, err = %e, "review attempt not recorded — task id lookup failed");
827            return outcome;
828        }
829    };
830
831    if let Err(e) = store
832        .append_activity(
833            store_id,
834            "review_attempt_failed",
835            None,
836            None,
837            None,
838            None,
839            Some(&serde_json::json!({
840                "cause": cause,
841                "external": external,
842                "reason": reason,
843            })),
844        )
845        .await
846    {
847        tracing::warn!(task_id, err = %e, "failed to record review attempt failure");
848    }
849
850    if external {
851        if let Err(e) = store
852            .set_fields(store_id, &[("needs_review_refires", serde_json::json!(0))])
853            .await
854        {
855            tracing::warn!(task_id, err = %e, "failed to clear needs_review_refires after external review failure");
856        }
857    }
858
859    outcome
860}
861
862/// Resets that end with no decision and do not count as a failure, before the task blocks
863pub(crate) const MAX_REVIEW_RESETS: u64 = 6;
864
865/// A reset that does not count toward `MAX_REVIEW_AGENT_FAILURES` still raises `review_resets`,
866/// so a review that never produces a decision stops bouncing between needs_review and in_review
867async fn reset_or_block(
868    store: &Arc<TaskStore>,
869    repo: &str,
870    task_id: &str,
871    reason: &str,
872    cause: &str,
873    external: bool,
874    context: &str,
875) -> ReviewOutcome {
876    let resets =
877        match crate::store::store_increment(&Some(store.clone()), repo, task_id, "review_resets")
878            .await
879        {
880            Ok(v) => v,
881            Err(e) => {
882                tracing::warn!(task_id, err = %e, "failed to increment review_resets");
883                0
884            }
885        };
886    let outcome = if resets >= MAX_REVIEW_RESETS {
887        tracing::error!(
888            task_id,
889            reason,
890            resets,
891            "{context} ended {resets} times without a decision, blocking task"
892        );
893        ReviewOutcome::Block(format!(
894            "{context} ended {resets} times without a decision: {reason}"
895        ))
896    } else {
897        ReviewOutcome::Reset
898    };
899    recorded(store, repo, task_id, reason, cause, external, outcome).await
900}
901
902/// Classify a review failure and return the appropriate outcome.
903///
904/// Both the `Ok(ReviewDecision::Failed)` and `Err(e)` arms share the same
905/// logic; callers convert the error to a string before calling this helper.
906/// `context` is used in log messages to distinguish the two call sites
907/// ("review agent" vs "review_and_merge").
908async fn classify_review_failure(
909    store: &Arc<TaskStore>,
910    repo: &str,
911    task_id: &str,
912    reason: &str,
913    context: &str,
914) -> ReviewOutcome {
915    // Rate-limit and auth failures are not real review failures — don't count them
916    // toward MAX_REVIEW_AGENT_FAILURES. The cooldown system steers the next
917    // attempt to an available agent.
918    let lower_reason = reason.to_lowercase();
919
920    // Parse errors are orch's fault, not review feedback. Re-route for retry
921    // without counting toward the failure threshold, so parse errors don't
922    // incorrectly block tasks that have no actual review feedback.
923    if lower_reason.contains("parse error") {
924        tracing::warn!(
925            task_id,
926            reason,
927            "{context} hit parse error — re-routing for retry without counting as failure"
928        );
929        return reset_or_block(store, repo, task_id, reason, "parse_error", false, context).await;
930    }
931
932    // Session exited mid-thought with no result envelope (issue #3734). Same
933    // treatment as a parse error: retry without counting toward the failure cap
934    if lower_reason.contains("incomplete:") {
935        tracing::warn!(
936            task_id,
937            reason,
938            "{context} session incomplete — re-routing for retry without counting as failure"
939        );
940        return reset_or_block(
941            store,
942            repo,
943            task_id,
944            reason,
945            "incomplete_session",
946            false,
947            context,
948        )
949        .await;
950    }
951
952    // The review output was cut off by the model's output/reasoning token
953    // budget, not a genuine parse/format failure. Same treatment as a parse
954    // error: re-route without counting toward MAX_REVIEW_AGENT_FAILURES.
955    if lower_reason.contains("truncated") {
956        tracing::warn!(
957            task_id,
958            reason,
959            "{context} output truncated by token budget — re-routing for retry without counting as failure"
960        );
961        return reset_or_block(
962            store,
963            repo,
964            task_id,
965            reason,
966            "truncated_output",
967            false,
968            context,
969        )
970        .await;
971    }
972
973    // Transient mergeability check: GitHub hasn't finished computing yet.
974    // This is a normal, expected transient state for freshly-pushed PRs.
975    // Return RetryMerge (not Reset) so the task stays in InReview — avoids
976    // spawning a redundant review agent that would just re-approve anyway.
977    if lower_reason.contains("not yet computed") {
978        tracing::warn!(
979            task_id,
980            reason,
981            "{context} PR mergeability check still pending — will retry merge without re-reviewing"
982        );
983        return recorded(
984            store,
985            repo,
986            task_id,
987            reason,
988            "mergeability_pending",
989            true,
990            ReviewOutcome::RetryMerge,
991        )
992        .await;
993    }
994
995    // Merge conflicts should be handled by handle_merge_conflict() in
996    // auto_merge.rs (which routes to the task agent). If a conflict error
997    // leaks here, route to the task agent instead of resetting to NeedsReview
998    // (which would re-spawn the review agent in a loop).
999    if lower_reason.contains("merge conflict") || lower_reason.contains("not mergeable") {
1000        tracing::warn!(
1001            task_id,
1002            reason,
1003            "{context} hit merge conflict — routing to task agent to resolve"
1004        );
1005        return recorded(
1006            store,
1007            repo,
1008            task_id,
1009            reason,
1010            "merge_conflict",
1011            false,
1012            ReviewOutcome::Reroute,
1013        )
1014        .await;
1015    }
1016
1017    // Any other merge failure on approved work (rate limit, 5xx, stale lease, a check
1018    // still pending) is orch's problem, not the agent's. Keep the task in InReview so
1019    // review_open_prs retries the merge once the cause clears, and never re-dispatch the
1020    // agent — that burned attempts until the task blocked on work that was already done
1021    if lower_reason.contains("merge failed") {
1022        tracing::warn!(
1023            task_id,
1024            reason,
1025            "{context} merge failed without a conflict — retrying the merge, not re-dispatching the agent"
1026        );
1027        return recorded(
1028            store,
1029            repo,
1030            task_id,
1031            reason,
1032            "merge_retry",
1033            true,
1034            ReviewOutcome::RetryMerge,
1035        )
1036        .await;
1037    }
1038
1039    // No configured review model for any available agent, or the selected agent
1040    // has no review model. This is a configuration/cooldown gap, not a review
1041    // failure. Defer without incrementing the failure counter so the task retries
1042    // once a review model is available.
1043    if lower_reason.contains("no review agent") || lower_reason.contains("review model missing") {
1044        tracing::warn!(
1045            task_id,
1046            reason,
1047            "{context}: no review model available — deferring retry until one is available"
1048        );
1049        return recorded(
1050            store,
1051            repo,
1052            task_id,
1053            reason,
1054            "no_review_model",
1055            true,
1056            ReviewOutcome::RateLimited,
1057        )
1058        .await;
1059    }
1060
1061    // GhHttp's own throttle says "rate-limited", and GitHub answers 429 or abuse detection
1062    // without the words "rate limit"
1063    if lower_reason.contains("rate limit")
1064        || lower_reason.contains("rate-limited")
1065        || lower_reason.contains("rate limited")
1066        || lower_reason.contains("too many requests")
1067        || lower_reason.contains("abuse detection")
1068        || lower_reason.contains("failed (429)")
1069        || lower_reason.contains("auth error")
1070    {
1071        tracing::warn!(
1072            task_id,
1073            reason,
1074            "{context} hit rate limit or auth error — deferring retry until cooldown expires"
1075        );
1076        return recorded(
1077            store,
1078            repo,
1079            task_id,
1080            reason,
1081            "rate_limit",
1082            true,
1083            ReviewOutcome::RateLimited,
1084        )
1085        .await;
1086    }
1087
1088    if crate::engine::runner::git_ops::is_transient_github_api_error(reason) {
1089        tracing::warn!(
1090            task_id,
1091            reason,
1092            "{context} hit transient GitHub error — deferring retry without counting as failure"
1093        );
1094        return reset_or_block(
1095            store,
1096            repo,
1097            task_id,
1098            reason,
1099            "github_api_error",
1100            true,
1101            context,
1102        )
1103        .await;
1104    }
1105
1106    // Provider-side network errors (ConnectionRefused, timeouts, etc.) are
1107    // transient infrastructure issues, not review feedback. Re-route without
1108    // counting toward MAX_REVIEW_AGENT_FAILURES.
1109    if crate::engine::runner::agents::patterns::detect_network_error(reason).is_some() {
1110        tracing::warn!(
1111            task_id,
1112            reason,
1113            "{context} hit transient provider network error — re-routing for retry without counting as failure"
1114        );
1115        return reset_or_block(
1116            store,
1117            repo,
1118            task_id,
1119            reason,
1120            "provider_network_error",
1121            true,
1122            context,
1123        )
1124        .await;
1125    }
1126
1127    let failures = match crate::store::store_increment(
1128        &Some(store.clone()),
1129        repo,
1130        task_id,
1131        "review_agent_failures",
1132    )
1133    .await
1134    {
1135        Ok(v) => v,
1136        Err(e) => {
1137            tracing::warn!(task_id, err = %e, "failed to increment review_agent_failures — skipping failure escalation this tick");
1138            // Treat as 0 for now (no escalation)
1139            0
1140        }
1141    };
1142    let blocking = failures >= MAX_REVIEW_AGENT_FAILURES;
1143    // Post failure comment to the PR so the history is visible.
1144    post_review_failure_comment(store, repo, task_id, reason, failures, blocking).await;
1145    if blocking {
1146        tracing::error!(
1147            task_id,
1148            reason,
1149            failures,
1150            "{context} failed too many times — blocking task"
1151        );
1152        recorded(
1153            store,
1154            repo,
1155            task_id,
1156            reason,
1157            "review_failure",
1158            false,
1159            ReviewOutcome::Block(format!("{context} failed {failures} times: {reason}")),
1160        )
1161        .await
1162    } else {
1163        tracing::error!(
1164            task_id,
1165            reason,
1166            failures,
1167            "{context} failed — resetting to NeedsReview for retry"
1168        );
1169        recorded(
1170            store,
1171            repo,
1172            task_id,
1173            reason,
1174            "review_failure",
1175            false,
1176            ReviewOutcome::Reset,
1177        )
1178        .await
1179    }
1180}
1181
1182/// Post a comment on the PR explaining why the review agent failed.
1183/// Best-effort — if the PR number is missing or the API call fails, we just log.
1184async fn post_review_failure_comment(
1185    store: &Arc<TaskStore>,
1186    repo: &str,
1187    task_id: &str,
1188    reason: &str,
1189    failures: u64,
1190    blocking: bool,
1191) {
1192    let pr_number = match opt_store_get_task(&Some(store.clone()), repo, task_id).await {
1193        Some(t) => match t.pr_number {
1194            Some(n) => n,
1195            None => return, // No PR to comment on
1196        },
1197        _ => return, // No PR to comment on
1198    };
1199
1200    let status = if blocking {
1201        "Task blocked for human review."
1202    } else {
1203        "Retrying with a different agent."
1204    };
1205
1206    let comment = format!(
1207        "⚠️ Review agent failed (attempt {}/{})\n\n**Reason:** {}\n\n{}{}",
1208        failures,
1209        MAX_REVIEW_AGENT_FAILURES,
1210        reason,
1211        status,
1212        crate::engine::orch_footer()
1213    );
1214
1215    let gh = match GhHttp::new() {
1216        Ok(gh) => gh,
1217        Err(_) => return,
1218    };
1219    if let Err(e) = gh.add_comment(repo, &pr_number.to_string(), &comment).await {
1220        tracing::debug!(
1221            task_id,
1222            pr_number,
1223            error = %e,
1224            "failed to post review failure comment"
1225        );
1226    }
1227}
1228
1229#[cfg(test)]
1230mod tests {
1231    use super::*;
1232
1233    /// An incomplete mid-thought review (no result envelope) must reset for
1234    /// retry without counting toward MAX_REVIEW_AGENT_FAILURES (issue #3734).
1235    #[tokio::test]
1236    async fn classify_review_failure_incomplete_resets_without_counting() {
1237        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1238        let repo = "owner/repo";
1239        let store_id = store
1240            .create_internal(
1241                repo,
1242                "Incomplete review test",
1243                "",
1244                "review",
1245                "review-3734",
1246                None,
1247            )
1248            .await
1249            .unwrap();
1250        let task_id = format!("internal:{store_id}");
1251
1252        for _ in 0..(MAX_REVIEW_AGENT_FAILURES + 1) {
1253            let outcome = classify_review_failure(
1254                &store,
1255                repo,
1256                &task_id,
1257                "incomplete: review session exited before producing a decision",
1258                "review agent",
1259            )
1260            .await;
1261            assert!(
1262                matches!(outcome, ReviewOutcome::Reset),
1263                "incomplete review failure must reset for retry, not block"
1264            );
1265        }
1266
1267        let task = opt_store_get_task(&Some(store.clone()), repo, &task_id)
1268            .await
1269            .expect("task should exist");
1270        assert_eq!(
1271            task.review_agent_failures, 0,
1272            "incomplete failures must not count toward review_agent_failures"
1273        );
1274    }
1275
1276    /// A truncated review output (opencode step_finish reason=length) must be
1277    /// treated like a parse error: reset for retry without counting toward
1278    /// MAX_REVIEW_AGENT_FAILURES, so a run of token-budget flukes never
1279    /// permanently blocks the task (issue #3473).
1280    #[tokio::test]
1281    async fn classify_review_failure_truncated_resets_without_counting() {
1282        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1283        let repo = "owner/repo";
1284        let store_id = store
1285            .create_internal(
1286                repo,
1287                "Truncated review test",
1288                "",
1289                "review",
1290                "review-1",
1291                None,
1292            )
1293            .await
1294            .unwrap();
1295        let task_id = format!("internal:{store_id}");
1296
1297        for _ in 0..(MAX_REVIEW_AGENT_FAILURES + 1) {
1298            let outcome = classify_review_failure(
1299                &store,
1300                repo,
1301                &task_id,
1302                "truncated: review output was cut off before completion \
1303                 (output/reasoning token budget exceeded)",
1304                "review agent",
1305            )
1306            .await;
1307            assert!(
1308                matches!(outcome, ReviewOutcome::Reset),
1309                "truncated review failure must reset for retry, not block"
1310            );
1311        }
1312
1313        let task = opt_store_get_task(&Some(store.clone()), repo, &task_id)
1314            .await
1315            .expect("task should exist");
1316        assert_eq!(
1317            task.review_agent_failures, 0,
1318            "truncated failures must not count toward review_agent_failures"
1319        );
1320    }
1321
1322    /**
1323     * After an approved review, a merge that fails for anything other than a content
1324     * conflict must retry the merge with the task still in InReview. Re-dispatching the
1325     * agent re-ran finished work until the task hit the attempt limit (#3783)
1326     **/
1327    #[tokio::test]
1328    async fn classify_review_failure_non_conflict_merge_failure_retries_merge() {
1329        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1330        let repo = "owner/repo";
1331        let store_id = store
1332            .create_internal(repo, "Merge retry", "", "review", "review-3783", None)
1333            .await
1334            .unwrap();
1335        let task_id = format!("internal:{store_id}");
1336
1337        let error_messages = [
1338            "merge failed: GitHub REST API rate-limited, skipping call for 187s",
1339            "merge failed: GitHub PR merge failed (502 Bad Gateway)",
1340            "merge failed: required status check still pending",
1341        ];
1342
1343        for _ in 0..(MAX_REVIEW_AGENT_FAILURES + 1) {
1344            for msg in &error_messages {
1345                let outcome =
1346                    classify_review_failure(&store, repo, &task_id, msg, "review agent").await;
1347                assert!(
1348                    matches!(outcome, ReviewOutcome::RetryMerge),
1349                    "non-conflict merge failure '{msg}' must retry the merge, not re-dispatch the agent"
1350                );
1351            }
1352        }
1353
1354        let task = opt_store_get_task(&Some(store.clone()), repo, &task_id)
1355            .await
1356            .expect("task should exist");
1357        assert_eq!(
1358            task.review_agent_failures, 0,
1359            "a stuck merge is not a review failure"
1360        );
1361    }
1362
1363    /// A genuine content conflict is the one merge failure the agent has to fix
1364    #[tokio::test]
1365    async fn classify_review_failure_merge_conflict_still_reroutes() {
1366        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1367        let repo = "owner/repo";
1368        let store_id = store
1369            .create_internal(repo, "Merge conflict", "", "review", "review-3783b", None)
1370            .await
1371            .unwrap();
1372        let task_id = format!("internal:{store_id}");
1373
1374        for msg in [
1375            "merge failed: PR is not mergeable",
1376            "merge conflict detected on PR #12",
1377        ] {
1378            let outcome =
1379                classify_review_failure(&store, repo, &task_id, msg, "review agent").await;
1380            assert!(
1381                matches!(outcome, ReviewOutcome::Reroute),
1382                "content conflict '{msg}' must go back to the task agent"
1383            );
1384        }
1385    }
1386
1387    /// Transient provider network errors (ConnectionRefused, timeouts) must be
1388    /// treated like parse errors: reset for retry without counting toward
1389    /// MAX_REVIEW_AGENT_FAILURES (issue #3482).
1390    #[tokio::test]
1391    async fn classify_review_failure_network_error_resets_without_counting() {
1392        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1393        let repo = "owner/repo";
1394        let store_id = store
1395            .create_internal(
1396                repo,
1397                "Network error review test",
1398                "",
1399                "review",
1400                "review-2",
1401                None,
1402            )
1403            .await
1404            .unwrap();
1405        let task_id = format!("internal:{store_id}");
1406
1407        let error_messages = [
1408            "API Error: Unable to connect to API (ConnectionRefused)",
1409            "connection refused",
1410            "ECONNREFUSED",
1411            "Upstream idle timeout exceeded",
1412            "connect ETIMEDOUT",
1413            "network request failed",
1414        ];
1415
1416        for msg in &error_messages {
1417            // Each message on its own, the reset cap has its own test
1418            store
1419                .set_fields(store_id, &[("review_resets", serde_json::json!(0))])
1420                .await
1421                .unwrap();
1422            let outcome =
1423                classify_review_failure(&store, repo, &task_id, msg, "review agent").await;
1424            assert!(
1425                matches!(outcome, ReviewOutcome::Reset),
1426                "network error '{msg}' must reset for retry, not block"
1427            );
1428        }
1429
1430        let task = opt_store_get_task(&Some(store.clone()), repo, &task_id)
1431            .await
1432            .expect("task should exist");
1433        assert_eq!(
1434            task.review_agent_failures, 0,
1435            "network errors must not count toward review_agent_failures"
1436        );
1437    }
1438
1439    /// When no agent has a configured review model (or the fallback agent has none),
1440    /// the review must be deferred, not counted as a review failure. Counting it
1441    /// would eventually block tasks for a configuration/cooldown gap (issue #3584).
1442    #[tokio::test]
1443    async fn classify_review_failure_missing_review_model_is_rate_limited() {
1444        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1445        let repo = "owner/repo";
1446        let store_id = store
1447            .create_internal(
1448                repo,
1449                "Missing review model test",
1450                "",
1451                "review",
1452                "review-3",
1453                None,
1454            )
1455            .await
1456            .unwrap();
1457        let task_id = format!("internal:{store_id}");
1458
1459        let error_messages = [
1460            "no review agent with a configured review model is available for task internal:123",
1461            "review model missing for task internal:123 after agent selection — refusing to dispatch",
1462        ];
1463
1464        for _ in 0..(MAX_REVIEW_AGENT_FAILURES + 1) {
1465            for msg in &error_messages {
1466                let outcome =
1467                    classify_review_failure(&store, repo, &task_id, msg, "review_and_merge").await;
1468                assert!(
1469                    matches!(outcome, ReviewOutcome::RateLimited),
1470                    "missing review model error '{msg}' must defer, not block"
1471                );
1472            }
1473        }
1474
1475        let task = opt_store_get_task(&Some(store.clone()), repo, &task_id)
1476            .await
1477            .expect("task should exist");
1478        assert_eq!(
1479            task.review_agent_failures, 0,
1480            "missing review model errors must not count toward review_agent_failures"
1481        );
1482    }
1483
1484    /// Resets that do not count as failures still stop at `MAX_REVIEW_RESETS`
1485    #[tokio::test]
1486    async fn classify_review_failure_blocks_after_max_resets() {
1487        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1488        let repo = "owner/repo";
1489        let store_id = store
1490            .create_internal(repo, "Looping review", "", "review", "review-resets", None)
1491            .await
1492            .unwrap();
1493        let task_id = format!("internal:{store_id}");
1494        let msg = "parse error: failed to parse review response";
1495
1496        for attempt in 1..MAX_REVIEW_RESETS {
1497            let outcome =
1498                classify_review_failure(&store, repo, &task_id, msg, "review agent").await;
1499            assert!(
1500                matches!(outcome, ReviewOutcome::Reset),
1501                "reset {attempt} must retry the review"
1502            );
1503        }
1504        let outcome = classify_review_failure(&store, repo, &task_id, msg, "review agent").await;
1505        assert!(
1506            matches!(outcome, ReviewOutcome::Block(ref r) if r.contains("without a decision")),
1507            "reset {MAX_REVIEW_RESETS} must block the task"
1508        );
1509
1510        let task = opt_store_get_task(&Some(store.clone()), repo, &task_id)
1511            .await
1512            .expect("task should exist");
1513        assert_eq!(task.review_resets as u64, MAX_REVIEW_RESETS);
1514        assert_eq!(
1515            task.review_agent_failures, 0,
1516            "a reset must not count as a review failure"
1517        );
1518    }
1519
1520    /**
1521     * A review that fails on a rate limit is retried once the cooldown clears, and must
1522     * never spend the rebroadcast quota: `needs_review_refires` is cleared so the sync
1523     * sweep cannot escalate the task to Blocked for a cause outside the review (#3767)
1524     **/
1525    #[tokio::test]
1526    async fn classify_review_failure_rate_limit_clears_refire_quota() {
1527        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1528        let repo = "owner/repo";
1529        let store_id = store
1530            .create_internal(
1531                repo,
1532                "Rate limited review",
1533                "",
1534                "review",
1535                "review-3767",
1536                None,
1537            )
1538            .await
1539            .unwrap();
1540        let task_id = format!("internal:{store_id}");
1541
1542        let error_messages = [
1543            "claude failed: rate limit reached for model sonnet",
1544            "GitHub API GET https://api.github.com/repos/owner/repo/pulls/65 failed (403): API rate limit exceeded",
1545            "GitHub core API rate-limited, skipping call for 1800s",
1546            "GitHub API GET https://api.github.com/repos/owner/repo/pulls/65 failed (429): Too Many Requests",
1547        ];
1548
1549        // The sweep has already burned most of the quota before the rate-limit window
1550        store
1551            .set_fields(store_id, &[("needs_review_refires", serde_json::json!(5))])
1552            .await
1553            .unwrap();
1554
1555        for _ in 0..(MAX_REVIEW_AGENT_FAILURES + 1) {
1556            for msg in &error_messages {
1557                let outcome =
1558                    classify_review_failure(&store, repo, &task_id, msg, "review agent").await;
1559                assert!(
1560                    matches!(outcome, ReviewOutcome::RateLimited | ReviewOutcome::Reset),
1561                    "rate limit '{msg}' must defer for retry, not block"
1562                );
1563            }
1564        }
1565
1566        let task = opt_store_get_task(&Some(store.clone()), repo, &task_id)
1567            .await
1568            .expect("task should exist");
1569        assert_eq!(
1570            task.needs_review_refires, 0,
1571            "a rate limit must not consume the rebroadcast quota"
1572        );
1573        assert_eq!(
1574            task.review_agent_failures, 0,
1575            "rate limits must not count toward review_agent_failures"
1576        );
1577        assert_ne!(
1578            task.status,
1579            crate::store::TaskStatus::Blocked,
1580            "a rate-limited review must leave the task retryable"
1581        );
1582
1583        let attempts = store
1584            .get_activity(store_id, None)
1585            .await
1586            .unwrap()
1587            .into_iter()
1588            .filter(|a| a.event_type == "review_attempt_failed")
1589            .collect::<Vec<_>>();
1590        assert_eq!(
1591            attempts.len(),
1592            (MAX_REVIEW_AGENT_FAILURES as usize + 1) * error_messages.len(),
1593            "every failed attempt must be recorded for `orch task inspect`"
1594        );
1595        assert!(
1596            attempts
1597                .iter()
1598                .all(|a| a.details.get("external").and_then(|v| v.as_bool()) == Some(true)),
1599            "rate limits must be recorded as an external cause"
1600        );
1601        assert!(
1602            attempts.iter().any(
1603                |a| a.details.get("reason").and_then(|v| v.as_str()) == Some(error_messages[0])
1604            ),
1605            "the recorded attempt must carry that attempt's error"
1606        );
1607    }
1608}