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