1use 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 RetryMerge,
27 Approved,
30 Reroute,
32 Block(String),
33 Ok,
34}
35
36#[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 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 {
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 let dispatch_guard =
101 DispatchGuard::new(dispatching.clone(), dispatch_key.clone());
102
103 tracing::info!(task_id, "event-driven review triggered");
104
105 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 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 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 {
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 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 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; let tid = task.id.0.clone();
267
268 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 crate::store::store_reset_failure_counters(
334 &Some(store_c.clone()),
335 &repo_s,
336 &tid,
337 )
338 .await;
339 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 ReviewOutcome::Approved
355 }
356 Ok(ReviewDecision::Rerouted) => {
357 ReviewOutcome::Ok
361 }
362 Ok(ReviewDecision::Skipped) => {
363 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 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 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), Err(e) => return Err(e), };
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 (None, None)
431 }
432 Err(e) => {
433 tracing::error!(
435 task_id = tid,
436 error = %e,
437 "review outcome retained — DB lookup failed, not treating as stale"
438 );
439 (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 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 let stale_session =
493 tmux_c.session_name(&repo_s, &format!("{}-review", tid));
494 tmux_c.kill_session(&stale_session).await.ok();
495 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 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 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 let stale_session =
560 tmux_c.session_name(&repo_s, &format!("{}-review", tid));
561 tmux_c.kill_session(&stale_session).await.ok();
562
563 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 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 }));
621
622 tokio::time::sleep(std::time::Duration::from_secs(2)).await;
627 }
628 Ok(_) => {} 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, }
634 }
635 });
636}
637
638async 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 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
705async 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
830pub(crate) const MAX_REVIEW_RESETS: u64 = 6;
832
833async 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
870async fn classify_review_failure(
877 store: &Arc<TaskStore>,
878 repo: &str,
879 task_id: &str,
880 reason: &str,
881 context: &str,
882) -> ReviewOutcome {
883 let lower_reason = reason.to_lowercase();
887
888 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 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 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 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 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 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 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 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 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 0
1108 }
1109 };
1110 let blocking = failures >= MAX_REVIEW_AGENT_FAILURES;
1111 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
1150async 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, },
1165 _ => return, };
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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 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}