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::review_gate::CiGate;
9use crate::engine::review_shipcheck::Prerun;
10use crate::engine::router::Router;
11use crate::engine::tasks::TaskManager;
12use crate::github::http::GhHttp;
13use crate::repo_context::REPO_CONTEXT;
14use crate::store::{opt_store_get_task, TaskStore};
15use crate::tmux::TmuxManager;
16use dashmap::DashMap;
17use std::sync::Arc;
18use tokio::sync::broadcast;
19use tokio::sync::{RwLock, Semaphore};
20
21enum ReviewOutcome {
22 Reset,
23 RateLimited,
24 RetryMerge,
29 Approved,
32 Reroute,
34 Block(String),
35 Ok,
36}
37
38#[allow(clippy::too_many_arguments)]
44pub fn spawn(
45 mut rx: broadcast::Receiver<TaskEvent>,
46 backend: Arc<dyn ExternalBackend>,
47 tmux: Arc<TmuxManager>,
48 semaphore: Arc<Semaphore>,
49 task_manager: Arc<TaskManager>,
50 router: Arc<RwLock<Router>>,
51 dispatching: Arc<DashMap<String, String>>,
52 store: Arc<TaskStore>,
53 repo: String,
54) {
55 tokio::spawn(async move {
56 loop {
57 match rx.recv().await {
58 Ok(event) if event.new_status == "needs_review" && event.repo == repo => {
59 let enable_review = crate::config::get("workflow.enable_review_agent")
61 .map(|v| v != "false")
62 .unwrap_or(true);
63 if !enable_review {
64 continue;
65 }
66
67 let task_id = &event.task_id;
68 let dispatch_key = format!("{}/{}", repo, task_id);
69
70 {
75 use dashmap::mapref::entry::Entry;
76 match dispatching.entry(dispatch_key.clone()) {
77 Entry::Occupied(existing) => {
78 let existing_id = existing.get().clone();
79 drop(existing);
80 if existing_id == *task_id {
81 tracing::debug!(
82 task_id,
83 "task locked by dispatch flow, skipping event-driven review"
84 );
85 } else {
86 tracing::warn!(
87 task_id,
88 existing_task_id = existing_id,
89 dispatch_key,
90 "dispatch key collision: unexpected task already holds this key"
91 );
92 }
93 continue;
94 }
95 Entry::Vacant(slot) => {
96 slot.insert(task_id.clone());
97 }
98 }
99 }
100
101 let dispatch_guard =
103 DispatchGuard::new(dispatching.clone(), dispatch_key.clone());
104
105 tracing::info!(task_id, "event-driven review triggered");
106
107 let task = {
109 if crate::engine::tasks::is_internal_id(task_id)
110 || match store.has_external_tasks(&repo).await {
111 Ok(has_external) => has_external,
112 Err(e) => {
113 tracing::warn!(
114 repo = %repo,
115 task_id,
116 error = %e,
117 "failed to check external task sentinel; keeping store-first review lookup"
118 );
119 true
120 }
121 }
122 {
123 let result = if let Some(numeric_id) =
126 crate::engine::tasks::parse_internal_id(task_id)
127 {
128 store.get(numeric_id).await.map(Some)
129 } else {
130 store.get_by_external_id(&repo, task_id).await
131 };
132 match result {
133 Ok(Some(t))
134 if t.status == crate::store::TaskStatus::NeedsReview =>
135 {
136 Some(crate::engine::tasks::store_task_to_external(&t))
137 }
138 Ok(Some(t)) => {
139 tracing::debug!(
140 task_id,
141 status = t.status.as_str(),
142 "task found but not in needs_review status, skipping"
143 );
144 None
145 }
146 Ok(None) => None,
147 Err(e) => {
148 tracing::warn!(task_id, err = %e, "store lookup failed for review");
149 None
150 }
151 }
152 } else {
153 match backend.list_by_status(Status::NeedsReview).await {
155 Ok(tasks) => tasks.into_iter().find(|t| t.id.0 == *task_id),
156 Err(e) => {
157 tracing::warn!(task_id, err = %e, "backend lookup failed for review");
158 None
159 }
160 }
161 }
162 };
163
164 let Some(task) = task else {
165 tracing::debug!(
166 task_id,
167 "task not found in needs_review list (may have already transitioned)"
168 );
169 continue;
170 };
171
172 if let Some(stored) =
173 opt_store_get_task(&Some(store.clone()), &repo, &task.id.0).await
174 {
175 let no_code_review = stored.worktree.trim().is_empty()
176 && stored.branch.trim().is_empty()
177 && stored.pr_number.is_none();
178 if no_code_review {
179 tracing::info!(
180 task_id,
181 "no worktree/branch/pr for needs_review task — skipping review dispatch and marking done"
182 );
183 crate::store::store_reset_failure_counters(
184 &Some(store.clone()),
185 &repo,
186 &task.id.0,
187 )
188 .await;
189 if let Err(e) = crate::store::store_set_result(
190 &Some(store.clone()),
191 &repo,
192 &task.id.0,
193 &[("needs_review_refires", serde_json::json!(0))],
194 )
195 .await
196 {
197 tracing::warn!(
198 task_id,
199 err = %e,
200 "failed to reset needs_review_refires while skipping no-code review"
201 );
202 }
203 if let Err(e) = task_manager
204 .update_task_status(&task.id, Status::Done)
205 .await
206 {
207 tracing::error!(
208 task_id,
209 err = %e,
210 "failed to mark no-code needs_review task as done"
211 );
212 }
213 continue;
214 }
215 }
216
217 {
224 let router_guard = router.read().await;
225 let no_routable = !router_guard.available_agents.is_empty()
226 && router_guard.healthy_agent_count("review") == 0;
227 drop(router_guard);
228 if no_routable {
229 tracing::info!(
230 task_id,
231 "no routable review agents — leaving task in needs_review for sync to re-fire"
232 );
233 continue;
234 }
235 }
236
237 if !ci_allows_review(&store, &task_manager, &repo, &task).await {
238 continue;
239 }
240
241 let permit = match semaphore.clone().try_acquire_owned() {
243 Ok(p) => p,
244 Err(_) => {
245 tracing::debug!(task_id, "all parallel slots busy; sync catch-up will re-fire when a slot opens");
246 continue;
247 }
248 };
249
250 if let Err(e) = task_manager
252 .update_task_status(&task.id, Status::InReview)
253 .await
254 {
255 tracing::warn!(task_id, err = %e, "failed to transition to InReview");
256 drop(permit);
257 continue;
258 }
259
260 let backend_c = backend.clone();
261 let task_manager_c = task_manager.clone();
262 let tmux_c = tmux.clone();
263 let router_c = router.clone();
264 let store_c = store.clone();
265 let repo_s = repo.clone();
266 tokio::spawn(REPO_CONTEXT.scope(repo_s.clone(), async move {
267 let _dispatch_guard = dispatch_guard; let tid = task.id.0.clone();
269
270 let pre_check_all_cooled = {
275 let r = router_c.read().await;
276 !r.available_agents.is_empty() && r.healthy_agent_count("review") == 0
277 };
278 if pre_check_all_cooled {
279 tracing::info!(
280 task_id = tid,
281 "all agents cooled at review spawn time — resetting to needs_review for sync to re-fire"
282 );
283 if let Err(e) = task_manager_c
284 .update_task_status(
285 &ExternalId(tid.clone()),
286 Status::NeedsReview,
287 )
288 .await
289 {
290 tracing::error!(task_id = %tid, err = %e, "pre-check: update_task_status(NeedsReview) failed — task may be stuck in InReview");
291 }
292 drop(permit);
293 return;
294 }
295
296 let outcome = match review_and_merge(
297 &task,
298 &backend_c,
299 &tmux_c,
300 &repo_s,
301 &router_c,
302 &task_manager_c,
303 &store_c,
304 )
305 .await
306 {
307 Ok(ReviewDecision::Blocked(reason)) => {
308 tracing::error!(
309 task_id = tid,
310 reason,
311 "review gate blocked after repeated failures — marking task blocked"
312 );
313 ReviewOutcome::Block(format!(
314 "review gate blocked after repeated failures: {reason}"
315 ))
316 }
317 Ok(ReviewDecision::Failed(reason)) => {
318 classify_review_failure(
319 &store_c, &repo_s, &tid, &reason, "review agent",
320 )
321 .await
322 }
323 Err(e) => {
324 let reason = format!("{e:#}");
325 classify_review_failure(
326 &store_c, &repo_s, &tid, &reason, "review_and_merge",
327 )
328 .await
329 }
330 Ok(ReviewDecision::Approve { .. }) => {
331 crate::store::store_reset_failure_counters(
336 &Some(store_c.clone()),
337 &repo_s,
338 &tid,
339 )
340 .await;
341 if let Err(e) = crate::store::store_set_result(
343 &Some(store_c.clone()),
344 &repo_s,
345 &tid,
346 &[("needs_review_refires", serde_json::json!(0))],
347 )
348 .await
349 {
350 tracing::warn!(task_id = %tid, err = %e, "failed to write needs_review_refires to store");
351 }
352
353 ReviewOutcome::Approved
357 }
358 Ok(ReviewDecision::Rerouted) => {
359 ReviewOutcome::Ok
363 }
364 Ok(ReviewDecision::Skipped) => {
365 crate::store::store_reset_failure_counters(
368 &Some(store_c.clone()),
369 &repo_s,
370 &tid,
371 )
372 .await;
373 if let Err(e) = crate::store::store_set_result(
374 &Some(store_c.clone()),
375 &repo_s,
376 &tid,
377 &[("needs_review_refires", serde_json::json!(0))],
378 )
379 .await
380 {
381 tracing::warn!(task_id = %tid, err = %e, "failed to write needs_review_refires to store");
382 }
383 ReviewOutcome::Ok
384 }
385 Ok(ReviewDecision::RequestChanges { .. }) => {
386 crate::store::store_reset_failure_counters(
389 &Some(store_c.clone()),
390 &repo_s,
391 &tid,
392 )
393 .await;
394 if let Err(e) = crate::store::store_set_result(
395 &Some(store_c.clone()),
396 &repo_s,
397 &tid,
398 &[("needs_review_refires", serde_json::json!(0))],
399 )
400 .await
401 {
402 tracing::warn!(task_id = %tid, err = %e, "failed to write needs_review_refires to store");
403 }
404 ReviewOutcome::Ok
405 }
406 };
407 let fetched: Result<Option<(crate::store::TaskStatus, Option<String>)>, anyhow::Error> = async {
418 let id = match store_c.resolve_task_id(&repo_s, &tid).await {
419 Ok(Some(id)) => id,
420 Ok(None) => return Ok(None), Err(e) => return Err(e), };
423 let task = store_c.get(id).await?;
424 Ok(Some((task.status, task.block_reason)))
425 }
426 .await;
427
428 let (current_status, current_block_reason) = match fetched {
429 Ok(Some((status, block_reason))) => (Some(status), block_reason),
430 Ok(None) => {
431 (None, None)
433 }
434 Err(e) => {
435 tracing::error!(
437 task_id = tid,
438 error = %e,
439 "review outcome retained — DB lookup failed, not treating as stale"
440 );
441 (Some(crate::store::TaskStatus::InReview), None)
444 }
445 };
446
447 if !matches!(current_status, Some(crate::store::TaskStatus::InReview)) {
448 let ci_blocked = matches!(
449 current_status,
450 Some(crate::store::TaskStatus::Blocked)
451 ) && current_block_reason
452 .as_deref()
453 .is_some_and(|r| r.contains("CI failure limit"));
454 let expected = ci_blocked
455 || matches!(
456 current_status,
457 Some(crate::store::TaskStatus::Done)
458 | Some(crate::store::TaskStatus::Approved)
459 | Some(crate::store::TaskStatus::Routed)
460 | Some(crate::store::TaskStatus::New)
461 );
462 if expected {
463 tracing::debug!(
464 task_id = tid,
465 current_status = current_status.as_ref().map(|s| s.as_str()).unwrap_or("unknown"),
466 "review outcome discarded — task transitioned away from in_review (expected)"
467 );
468 } else {
469 tracing::warn!(
470 task_id = tid,
471 current_status = current_status.as_ref().map(|s| s.as_str()).unwrap_or("unknown"),
472 "review outcome discarded — task is no longer in_review (stale review agent result)"
473 );
474 }
475
476 let stale_session = tmux_c.session_name(&repo_s, &format!("{}-review", tid));
478 if let Err(e) = tmux_c.kill_session(&stale_session).await {
479 tracing::debug!(task_id = tid, error = %e, "failed to kill stale review session");
480 }
481
482 drop(permit);
483 return;
484 }
485
486 match outcome {
487 ReviewOutcome::Reset => {
488 let stale_session =
495 tmux_c.session_name(&repo_s, &format!("{}-review", tid));
496 tmux_c.kill_session(&stale_session).await.ok();
497 tokio::time::sleep(std::time::Duration::from_secs(10)).await;
500 if let Err(e) = task_manager_c
501 .update_task_status(
502 &ExternalId(tid.clone()),
503 Status::NeedsReview,
504 )
505 .await
506 {
507 tracing::error!(task_id = %tid, err = %e, "update_task_status(NeedsReview) failed — task may be stuck in InReview");
508 }
509 }
510 ReviewOutcome::Reroute => {
511 let stale_session =
514 tmux_c.session_name(&repo_s, &format!("{}-review", tid));
515 tmux_c.kill_session(&stale_session).await.ok();
516 tracing::info!(
517 task_id = tid,
518 "re-routing to task agent to resolve merge conflict"
519 );
520 if let Err(e) = task_manager_c
521 .update_task_status(
522 &ExternalId(tid.clone()),
523 Status::Routed,
524 )
525 .await
526 {
527 tracing::error!(task_id = %tid, err = %e, "update_task_status(Routed) failed");
528 }
529 }
530 ReviewOutcome::Approved | ReviewOutcome::RetryMerge => {
531 let stale_session =
534 tmux_c.session_name(&repo_s, &format!("{}-review", tid));
535 tmux_c.kill_session(&stale_session).await.ok();
536 match task_manager_c
537 .update_task_status_if(
538 &ExternalId(tid.clone()),
539 Status::Approved,
540 Status::InReview,
541 )
542 .await
543 {
544 Ok(true) => tracing::info!(
545 task_id = tid,
546 "approval received, task approved until the PR is merged"
547 ),
548 Ok(false) => tracing::debug!(
549 task_id = tid,
550 "approval received after the task left in_review"
551 ),
552 Err(e) => tracing::error!(
553 task_id = %tid,
554 err = %e,
555 "update_task_status_if(Approved) failed, task may be stuck in in_review"
556 ),
557 }
558 }
559 ReviewOutcome::RateLimited => {
560 let stale_session =
562 tmux_c.session_name(&repo_s, &format!("{}-review", tid));
563 tmux_c.kill_session(&stale_session).await.ok();
564
565 let wait_secs = {
569 let r = router_c.read().await;
570 if r.healthy_agent_count("review") > 0 {
571 30u64
572 } else {
573 let now = chrono::Utc::now().timestamp();
574 r.earliest_cooldown_until(Some("review"))
575 .map(|until| ((until - now).max(1)) as u64)
576 .unwrap_or(AGENT_COOLDOWN_SECS as u64)
577 }
578 };
579 tracing::info!(
580 task_id = tid,
581 wait_secs,
582 "deferring review retry due to agent rate limit cooldown"
583 );
584 tokio::time::sleep(std::time::Duration::from_secs(wait_secs)).await;
585 if let Err(e) = task_manager_c
586 .update_task_status(
587 &ExternalId(tid.clone()),
588 Status::NeedsReview,
589 )
590 .await
591 {
592 tracing::error!(task_id = %tid, err = %e, "update_task_status(NeedsReview) failed after rate limit backoff — task may be stuck in InReview");
593 }
594 }
595 ReviewOutcome::Block(reason) => {
596 let fields = [
600 (
601 "block_reason",
602 serde_json::json!("review agent blocked — exceeded failure threshold"),
603 ),
604 ("last_error", serde_json::json!(reason)),
605 ];
606 if let Err(e) = task_manager_c
607 .update_task_status_and_result(
608 &ExternalId(tid.clone()),
609 Status::Blocked,
610 &fields,
611 )
612 .await
613 {
614 tracing::error!(task_id = %tid, err = %e, "update_task_status_and_result(Blocked) failed — task may be stuck in InReview");
615 }
616 }
617 ReviewOutcome::Ok => {}
618 }
619
620 drop(permit);
621 }));
623
624 tokio::time::sleep(std::time::Duration::from_secs(2)).await;
629 }
630 Ok(_) => {} Err(broadcast::error::RecvError::Lagged(n)) => {
632 tracing::debug!(n, "review subscriber lagged; sync catch-up will re-fire missed NeedsReview tasks");
633 }
634 Err(_) => break, }
636 }
637 });
638}
639
640async fn ci_allows_review(
655 store: &Arc<TaskStore>,
656 task_manager: &Arc<TaskManager>,
657 repo: &str,
658 task: &ExternalTask,
659) -> bool {
660 let task_id = task.id.0.as_str();
661 let stored = opt_store_get_task(&Some(store.clone()), repo, task_id).await;
662 let Some(pr_number) = stored
663 .as_ref()
664 .and_then(|t| t.pr_number)
665 .and_then(|n| u64::try_from(n).ok())
666 else {
667 return true;
668 };
669 let worktree = stored
670 .map(|t| std::path::PathBuf::from(t.worktree))
671 .filter(|p| !p.as_os_str().is_empty() && p.exists());
672 let gate = match GhHttp::new() {
673 Ok(gh) => {
674 crate::engine::review_gate::review_ci_gate(&gh, store, repo, task_id, pr_number).await
675 }
676 Err(e) => Err(e),
677 };
678
679 let hold = match gate {
680 Ok(CiGate::Start) => return true,
681 Err(e) => {
683 tracing::warn!(task_id, pr_number, err = %e, "CI gate lookup failed, starting the review");
684 return true;
685 }
686 Ok(CiGate::Red(failed)) => {
687 route_back_on_red_ci(store, task_manager, repo, task, pr_number, &failed).await;
688 return false;
689 }
690 Ok(CiGate::Wait) => "CI still running on the PR head, review waits",
691 Ok(CiGate::NotStarted { waited_out }) => match local_ci_worktree(repo, worktree).await {
693 Some(wt) => match crate::engine::review_shipcheck::prerun(&wt).await {
694 Prerun::Ready => return true,
695 Prerun::Running => "shipcheck running on the PR head, review waits",
696 },
697 None if waited_out => {
698 tracing::warn!(
699 task_id,
700 pr_number,
701 "no CI ran on the PR head, starting the review anyway"
702 );
703 return true;
704 }
705 None => "no CI started on the PR head yet, review waits",
706 },
707 };
708
709 tracing::info!(task_id, pr_number, "{hold}");
710 if let Err(e) = crate::store::store_set_result(
711 &Some(store.clone()),
712 repo,
713 task_id,
714 &[("needs_review_refires", serde_json::json!(0))],
715 )
716 .await
717 {
718 tracing::warn!(task_id, err = %e, "failed to reset needs_review_refires for a task held by the CI gate");
719 }
720
721 false
722}
723
724async fn local_ci_worktree(
726 repo: &str,
727 worktree: Option<std::path::PathBuf>,
728) -> Option<std::path::PathBuf> {
729 let wt = worktree?;
730 let sandboxed = crate::engine::review::review_sandboxed(repo);
731 let shipcheck = crate::engine::runner::agent::Shipcheck::detect(&wt)
732 .await
733 .for_prompt(sandboxed);
734 (shipcheck == crate::engine::runner::agent::Shipcheck::Available).then_some(wt)
735}
736
737async fn route_back_on_red_ci(
739 store: &Arc<TaskStore>,
740 task_manager: &Arc<TaskManager>,
741 repo: &str,
742 task: &ExternalTask,
743 pr_number: u64,
744 failed: &[String],
745) {
746 let task_id = task.id.0.as_str();
747 let failures = match crate::store::store_increment(
748 &Some(store.clone()),
749 repo,
750 task_id,
751 "ci_merge_failures",
752 )
753 .await
754 {
755 Ok(v) => v,
756 Err(e) => {
757 tracing::warn!(task_id, err = %e, "failed to increment ci_merge_failures, review waits");
758 return;
759 }
760 };
761 let checks = failed.join(", ");
762 let max = crate::engine::auto_merge::MAX_CI_MERGE_FAILURES;
763 let (status, fields) = if failures >= max {
764 tracing::error!(
765 task_id,
766 pr_number,
767 failures,
768 checks,
769 "CI failed before review, CI failure limit reached"
770 );
771 (
772 Status::Blocked,
773 vec![(
774 "block_reason",
775 serde_json::json!(format!("CI failure limit ({max}) reached before review")),
776 )],
777 )
778 } else {
779 tracing::warn!(
780 task_id,
781 pr_number,
782 failures,
783 checks,
784 "CI failed before review, routing back to the task agent"
785 );
786 (
787 Status::Routed,
788 vec![
789 (
790 "last_error",
791 serde_json::json!(format!(
792 "CI failed on PR #{pr_number} before review: {checks}. Fix the failing checks"
793 )),
794 ),
795 ("route_reason", serde_json::json!("re-dispatch after CI failed before review")),
796 ],
797 )
798 };
799 if let Err(e) = task_manager
800 .update_task_status_and_result(&task.id, status, &fields)
801 .await
802 {
803 tracing::error!(task_id, err = %e, "failed to move a task whose CI failed before review");
804 }
805}
806
807async fn recorded(
808 store: &Arc<TaskStore>,
809 repo: &str,
810 task_id: &str,
811 reason: &str,
812 cause: &str,
813 external: bool,
814 outcome: ReviewOutcome,
815) -> ReviewOutcome {
816 let store_id = match store.resolve_task_id(repo, task_id).await {
817 Ok(Some(id)) => id,
818 Ok(None) => {
819 tracing::warn!(
820 task_id,
821 "review attempt not recorded — task not found in store"
822 );
823 return outcome;
824 }
825 Err(e) => {
826 tracing::warn!(task_id, err = %e, "review attempt not recorded — task id lookup failed");
827 return outcome;
828 }
829 };
830
831 if let Err(e) = store
832 .append_activity(
833 store_id,
834 "review_attempt_failed",
835 None,
836 None,
837 None,
838 None,
839 Some(&serde_json::json!({
840 "cause": cause,
841 "external": external,
842 "reason": reason,
843 })),
844 )
845 .await
846 {
847 tracing::warn!(task_id, err = %e, "failed to record review attempt failure");
848 }
849
850 if external {
851 if let Err(e) = store
852 .set_fields(store_id, &[("needs_review_refires", serde_json::json!(0))])
853 .await
854 {
855 tracing::warn!(task_id, err = %e, "failed to clear needs_review_refires after external review failure");
856 }
857 }
858
859 outcome
860}
861
862pub(crate) const MAX_REVIEW_RESETS: u64 = 6;
864
865async fn reset_or_block(
868 store: &Arc<TaskStore>,
869 repo: &str,
870 task_id: &str,
871 reason: &str,
872 cause: &str,
873 external: bool,
874 context: &str,
875) -> ReviewOutcome {
876 let resets =
877 match crate::store::store_increment(&Some(store.clone()), repo, task_id, "review_resets")
878 .await
879 {
880 Ok(v) => v,
881 Err(e) => {
882 tracing::warn!(task_id, err = %e, "failed to increment review_resets");
883 0
884 }
885 };
886 let outcome = if resets >= MAX_REVIEW_RESETS {
887 tracing::error!(
888 task_id,
889 reason,
890 resets,
891 "{context} ended {resets} times without a decision, blocking task"
892 );
893 ReviewOutcome::Block(format!(
894 "{context} ended {resets} times without a decision: {reason}"
895 ))
896 } else {
897 ReviewOutcome::Reset
898 };
899 recorded(store, repo, task_id, reason, cause, external, outcome).await
900}
901
902async fn classify_review_failure(
909 store: &Arc<TaskStore>,
910 repo: &str,
911 task_id: &str,
912 reason: &str,
913 context: &str,
914) -> ReviewOutcome {
915 let lower_reason = reason.to_lowercase();
919
920 if lower_reason.contains("parse error") {
924 tracing::warn!(
925 task_id,
926 reason,
927 "{context} hit parse error — re-routing for retry without counting as failure"
928 );
929 return reset_or_block(store, repo, task_id, reason, "parse_error", false, context).await;
930 }
931
932 if lower_reason.contains("incomplete:") {
935 tracing::warn!(
936 task_id,
937 reason,
938 "{context} session incomplete — re-routing for retry without counting as failure"
939 );
940 return reset_or_block(
941 store,
942 repo,
943 task_id,
944 reason,
945 "incomplete_session",
946 false,
947 context,
948 )
949 .await;
950 }
951
952 if lower_reason.contains("truncated") {
956 tracing::warn!(
957 task_id,
958 reason,
959 "{context} output truncated by token budget — re-routing for retry without counting as failure"
960 );
961 return reset_or_block(
962 store,
963 repo,
964 task_id,
965 reason,
966 "truncated_output",
967 false,
968 context,
969 )
970 .await;
971 }
972
973 if lower_reason.contains("not yet computed") {
978 tracing::warn!(
979 task_id,
980 reason,
981 "{context} PR mergeability check still pending — will retry merge without re-reviewing"
982 );
983 return recorded(
984 store,
985 repo,
986 task_id,
987 reason,
988 "mergeability_pending",
989 true,
990 ReviewOutcome::RetryMerge,
991 )
992 .await;
993 }
994
995 if lower_reason.contains("merge conflict") || lower_reason.contains("not mergeable") {
1000 tracing::warn!(
1001 task_id,
1002 reason,
1003 "{context} hit merge conflict — routing to task agent to resolve"
1004 );
1005 return recorded(
1006 store,
1007 repo,
1008 task_id,
1009 reason,
1010 "merge_conflict",
1011 false,
1012 ReviewOutcome::Reroute,
1013 )
1014 .await;
1015 }
1016
1017 if lower_reason.contains("merge failed") {
1022 tracing::warn!(
1023 task_id,
1024 reason,
1025 "{context} merge failed without a conflict — retrying the merge, not re-dispatching the agent"
1026 );
1027 return recorded(
1028 store,
1029 repo,
1030 task_id,
1031 reason,
1032 "merge_retry",
1033 true,
1034 ReviewOutcome::RetryMerge,
1035 )
1036 .await;
1037 }
1038
1039 if lower_reason.contains("no review agent") || lower_reason.contains("review model missing") {
1044 tracing::warn!(
1045 task_id,
1046 reason,
1047 "{context}: no review model available — deferring retry until one is available"
1048 );
1049 return recorded(
1050 store,
1051 repo,
1052 task_id,
1053 reason,
1054 "no_review_model",
1055 true,
1056 ReviewOutcome::RateLimited,
1057 )
1058 .await;
1059 }
1060
1061 if lower_reason.contains("rate limit")
1064 || lower_reason.contains("rate-limited")
1065 || lower_reason.contains("rate limited")
1066 || lower_reason.contains("too many requests")
1067 || lower_reason.contains("abuse detection")
1068 || lower_reason.contains("failed (429)")
1069 || lower_reason.contains("auth error")
1070 {
1071 tracing::warn!(
1072 task_id,
1073 reason,
1074 "{context} hit rate limit or auth error — deferring retry until cooldown expires"
1075 );
1076 return recorded(
1077 store,
1078 repo,
1079 task_id,
1080 reason,
1081 "rate_limit",
1082 true,
1083 ReviewOutcome::RateLimited,
1084 )
1085 .await;
1086 }
1087
1088 if crate::engine::runner::git_ops::is_transient_github_api_error(reason) {
1089 tracing::warn!(
1090 task_id,
1091 reason,
1092 "{context} hit transient GitHub error — deferring retry without counting as failure"
1093 );
1094 return reset_or_block(
1095 store,
1096 repo,
1097 task_id,
1098 reason,
1099 "github_api_error",
1100 true,
1101 context,
1102 )
1103 .await;
1104 }
1105
1106 if crate::engine::runner::agents::patterns::detect_network_error(reason).is_some() {
1110 tracing::warn!(
1111 task_id,
1112 reason,
1113 "{context} hit transient provider network error — re-routing for retry without counting as failure"
1114 );
1115 return reset_or_block(
1116 store,
1117 repo,
1118 task_id,
1119 reason,
1120 "provider_network_error",
1121 true,
1122 context,
1123 )
1124 .await;
1125 }
1126
1127 let failures = match crate::store::store_increment(
1128 &Some(store.clone()),
1129 repo,
1130 task_id,
1131 "review_agent_failures",
1132 )
1133 .await
1134 {
1135 Ok(v) => v,
1136 Err(e) => {
1137 tracing::warn!(task_id, err = %e, "failed to increment review_agent_failures — skipping failure escalation this tick");
1138 0
1140 }
1141 };
1142 let blocking = failures >= MAX_REVIEW_AGENT_FAILURES;
1143 post_review_failure_comment(store, repo, task_id, reason, failures, blocking).await;
1145 if blocking {
1146 tracing::error!(
1147 task_id,
1148 reason,
1149 failures,
1150 "{context} failed too many times — blocking task"
1151 );
1152 recorded(
1153 store,
1154 repo,
1155 task_id,
1156 reason,
1157 "review_failure",
1158 false,
1159 ReviewOutcome::Block(format!("{context} failed {failures} times: {reason}")),
1160 )
1161 .await
1162 } else {
1163 tracing::error!(
1164 task_id,
1165 reason,
1166 failures,
1167 "{context} failed — resetting to NeedsReview for retry"
1168 );
1169 recorded(
1170 store,
1171 repo,
1172 task_id,
1173 reason,
1174 "review_failure",
1175 false,
1176 ReviewOutcome::Reset,
1177 )
1178 .await
1179 }
1180}
1181
1182async fn post_review_failure_comment(
1185 store: &Arc<TaskStore>,
1186 repo: &str,
1187 task_id: &str,
1188 reason: &str,
1189 failures: u64,
1190 blocking: bool,
1191) {
1192 let pr_number = match opt_store_get_task(&Some(store.clone()), repo, task_id).await {
1193 Some(t) => match t.pr_number {
1194 Some(n) => n,
1195 None => return, },
1197 _ => return, };
1199
1200 let status = if blocking {
1201 "Task blocked for human review."
1202 } else {
1203 "Retrying with a different agent."
1204 };
1205
1206 let comment = format!(
1207 "⚠️ Review agent failed (attempt {}/{})\n\n**Reason:** {}\n\n{}{}",
1208 failures,
1209 MAX_REVIEW_AGENT_FAILURES,
1210 reason,
1211 status,
1212 crate::engine::orch_footer()
1213 );
1214
1215 let gh = match GhHttp::new() {
1216 Ok(gh) => gh,
1217 Err(_) => return,
1218 };
1219 if let Err(e) = gh.add_comment(repo, &pr_number.to_string(), &comment).await {
1220 tracing::debug!(
1221 task_id,
1222 pr_number,
1223 error = %e,
1224 "failed to post review failure comment"
1225 );
1226 }
1227}
1228
1229#[cfg(test)]
1230mod tests {
1231 use super::*;
1232
1233 #[tokio::test]
1236 async fn classify_review_failure_incomplete_resets_without_counting() {
1237 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1238 let repo = "owner/repo";
1239 let store_id = store
1240 .create_internal(
1241 repo,
1242 "Incomplete review test",
1243 "",
1244 "review",
1245 "review-3734",
1246 None,
1247 )
1248 .await
1249 .unwrap();
1250 let task_id = format!("internal:{store_id}");
1251
1252 for _ in 0..(MAX_REVIEW_AGENT_FAILURES + 1) {
1253 let outcome = classify_review_failure(
1254 &store,
1255 repo,
1256 &task_id,
1257 "incomplete: review session exited before producing a decision",
1258 "review agent",
1259 )
1260 .await;
1261 assert!(
1262 matches!(outcome, ReviewOutcome::Reset),
1263 "incomplete review failure must reset for retry, not block"
1264 );
1265 }
1266
1267 let task = opt_store_get_task(&Some(store.clone()), repo, &task_id)
1268 .await
1269 .expect("task should exist");
1270 assert_eq!(
1271 task.review_agent_failures, 0,
1272 "incomplete failures must not count toward review_agent_failures"
1273 );
1274 }
1275
1276 #[tokio::test]
1281 async fn classify_review_failure_truncated_resets_without_counting() {
1282 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1283 let repo = "owner/repo";
1284 let store_id = store
1285 .create_internal(
1286 repo,
1287 "Truncated review test",
1288 "",
1289 "review",
1290 "review-1",
1291 None,
1292 )
1293 .await
1294 .unwrap();
1295 let task_id = format!("internal:{store_id}");
1296
1297 for _ in 0..(MAX_REVIEW_AGENT_FAILURES + 1) {
1298 let outcome = classify_review_failure(
1299 &store,
1300 repo,
1301 &task_id,
1302 "truncated: review output was cut off before completion \
1303 (output/reasoning token budget exceeded)",
1304 "review agent",
1305 )
1306 .await;
1307 assert!(
1308 matches!(outcome, ReviewOutcome::Reset),
1309 "truncated review failure must reset for retry, not block"
1310 );
1311 }
1312
1313 let task = opt_store_get_task(&Some(store.clone()), repo, &task_id)
1314 .await
1315 .expect("task should exist");
1316 assert_eq!(
1317 task.review_agent_failures, 0,
1318 "truncated failures must not count toward review_agent_failures"
1319 );
1320 }
1321
1322 #[tokio::test]
1328 async fn classify_review_failure_non_conflict_merge_failure_retries_merge() {
1329 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1330 let repo = "owner/repo";
1331 let store_id = store
1332 .create_internal(repo, "Merge retry", "", "review", "review-3783", None)
1333 .await
1334 .unwrap();
1335 let task_id = format!("internal:{store_id}");
1336
1337 let error_messages = [
1338 "merge failed: GitHub REST API rate-limited, skipping call for 187s",
1339 "merge failed: GitHub PR merge failed (502 Bad Gateway)",
1340 "merge failed: required status check still pending",
1341 ];
1342
1343 for _ in 0..(MAX_REVIEW_AGENT_FAILURES + 1) {
1344 for msg in &error_messages {
1345 let outcome =
1346 classify_review_failure(&store, repo, &task_id, msg, "review agent").await;
1347 assert!(
1348 matches!(outcome, ReviewOutcome::RetryMerge),
1349 "non-conflict merge failure '{msg}' must retry the merge, not re-dispatch the agent"
1350 );
1351 }
1352 }
1353
1354 let task = opt_store_get_task(&Some(store.clone()), repo, &task_id)
1355 .await
1356 .expect("task should exist");
1357 assert_eq!(
1358 task.review_agent_failures, 0,
1359 "a stuck merge is not a review failure"
1360 );
1361 }
1362
1363 #[tokio::test]
1365 async fn classify_review_failure_merge_conflict_still_reroutes() {
1366 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1367 let repo = "owner/repo";
1368 let store_id = store
1369 .create_internal(repo, "Merge conflict", "", "review", "review-3783b", None)
1370 .await
1371 .unwrap();
1372 let task_id = format!("internal:{store_id}");
1373
1374 for msg in [
1375 "merge failed: PR is not mergeable",
1376 "merge conflict detected on PR #12",
1377 ] {
1378 let outcome =
1379 classify_review_failure(&store, repo, &task_id, msg, "review agent").await;
1380 assert!(
1381 matches!(outcome, ReviewOutcome::Reroute),
1382 "content conflict '{msg}' must go back to the task agent"
1383 );
1384 }
1385 }
1386
1387 #[tokio::test]
1391 async fn classify_review_failure_network_error_resets_without_counting() {
1392 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1393 let repo = "owner/repo";
1394 let store_id = store
1395 .create_internal(
1396 repo,
1397 "Network error review test",
1398 "",
1399 "review",
1400 "review-2",
1401 None,
1402 )
1403 .await
1404 .unwrap();
1405 let task_id = format!("internal:{store_id}");
1406
1407 let error_messages = [
1408 "API Error: Unable to connect to API (ConnectionRefused)",
1409 "connection refused",
1410 "ECONNREFUSED",
1411 "Upstream idle timeout exceeded",
1412 "connect ETIMEDOUT",
1413 "network request failed",
1414 ];
1415
1416 for msg in &error_messages {
1417 store
1419 .set_fields(store_id, &[("review_resets", serde_json::json!(0))])
1420 .await
1421 .unwrap();
1422 let outcome =
1423 classify_review_failure(&store, repo, &task_id, msg, "review agent").await;
1424 assert!(
1425 matches!(outcome, ReviewOutcome::Reset),
1426 "network error '{msg}' must reset for retry, not block"
1427 );
1428 }
1429
1430 let task = opt_store_get_task(&Some(store.clone()), repo, &task_id)
1431 .await
1432 .expect("task should exist");
1433 assert_eq!(
1434 task.review_agent_failures, 0,
1435 "network errors must not count toward review_agent_failures"
1436 );
1437 }
1438
1439 #[tokio::test]
1443 async fn classify_review_failure_missing_review_model_is_rate_limited() {
1444 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1445 let repo = "owner/repo";
1446 let store_id = store
1447 .create_internal(
1448 repo,
1449 "Missing review model test",
1450 "",
1451 "review",
1452 "review-3",
1453 None,
1454 )
1455 .await
1456 .unwrap();
1457 let task_id = format!("internal:{store_id}");
1458
1459 let error_messages = [
1460 "no review agent with a configured review model is available for task internal:123",
1461 "review model missing for task internal:123 after agent selection — refusing to dispatch",
1462 ];
1463
1464 for _ in 0..(MAX_REVIEW_AGENT_FAILURES + 1) {
1465 for msg in &error_messages {
1466 let outcome =
1467 classify_review_failure(&store, repo, &task_id, msg, "review_and_merge").await;
1468 assert!(
1469 matches!(outcome, ReviewOutcome::RateLimited),
1470 "missing review model error '{msg}' must defer, not block"
1471 );
1472 }
1473 }
1474
1475 let task = opt_store_get_task(&Some(store.clone()), repo, &task_id)
1476 .await
1477 .expect("task should exist");
1478 assert_eq!(
1479 task.review_agent_failures, 0,
1480 "missing review model errors must not count toward review_agent_failures"
1481 );
1482 }
1483
1484 #[tokio::test]
1486 async fn classify_review_failure_blocks_after_max_resets() {
1487 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1488 let repo = "owner/repo";
1489 let store_id = store
1490 .create_internal(repo, "Looping review", "", "review", "review-resets", None)
1491 .await
1492 .unwrap();
1493 let task_id = format!("internal:{store_id}");
1494 let msg = "parse error: failed to parse review response";
1495
1496 for attempt in 1..MAX_REVIEW_RESETS {
1497 let outcome =
1498 classify_review_failure(&store, repo, &task_id, msg, "review agent").await;
1499 assert!(
1500 matches!(outcome, ReviewOutcome::Reset),
1501 "reset {attempt} must retry the review"
1502 );
1503 }
1504 let outcome = classify_review_failure(&store, repo, &task_id, msg, "review agent").await;
1505 assert!(
1506 matches!(outcome, ReviewOutcome::Block(ref r) if r.contains("without a decision")),
1507 "reset {MAX_REVIEW_RESETS} must block the task"
1508 );
1509
1510 let task = opt_store_get_task(&Some(store.clone()), repo, &task_id)
1511 .await
1512 .expect("task should exist");
1513 assert_eq!(task.review_resets as u64, MAX_REVIEW_RESETS);
1514 assert_eq!(
1515 task.review_agent_failures, 0,
1516 "a reset must not count as a review failure"
1517 );
1518 }
1519
1520 #[tokio::test]
1526 async fn classify_review_failure_rate_limit_clears_refire_quota() {
1527 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1528 let repo = "owner/repo";
1529 let store_id = store
1530 .create_internal(
1531 repo,
1532 "Rate limited review",
1533 "",
1534 "review",
1535 "review-3767",
1536 None,
1537 )
1538 .await
1539 .unwrap();
1540 let task_id = format!("internal:{store_id}");
1541
1542 let error_messages = [
1543 "claude failed: rate limit reached for model sonnet",
1544 "GitHub API GET https://api.github.com/repos/owner/repo/pulls/65 failed (403): API rate limit exceeded",
1545 "GitHub core API rate-limited, skipping call for 1800s",
1546 "GitHub API GET https://api.github.com/repos/owner/repo/pulls/65 failed (429): Too Many Requests",
1547 ];
1548
1549 store
1551 .set_fields(store_id, &[("needs_review_refires", serde_json::json!(5))])
1552 .await
1553 .unwrap();
1554
1555 for _ in 0..(MAX_REVIEW_AGENT_FAILURES + 1) {
1556 for msg in &error_messages {
1557 let outcome =
1558 classify_review_failure(&store, repo, &task_id, msg, "review agent").await;
1559 assert!(
1560 matches!(outcome, ReviewOutcome::RateLimited | ReviewOutcome::Reset),
1561 "rate limit '{msg}' must defer for retry, not block"
1562 );
1563 }
1564 }
1565
1566 let task = opt_store_get_task(&Some(store.clone()), repo, &task_id)
1567 .await
1568 .expect("task should exist");
1569 assert_eq!(
1570 task.needs_review_refires, 0,
1571 "a rate limit must not consume the rebroadcast quota"
1572 );
1573 assert_eq!(
1574 task.review_agent_failures, 0,
1575 "rate limits must not count toward review_agent_failures"
1576 );
1577 assert_ne!(
1578 task.status,
1579 crate::store::TaskStatus::Blocked,
1580 "a rate-limited review must leave the task retryable"
1581 );
1582
1583 let attempts = store
1584 .get_activity(store_id, None)
1585 .await
1586 .unwrap()
1587 .into_iter()
1588 .filter(|a| a.event_type == "review_attempt_failed")
1589 .collect::<Vec<_>>();
1590 assert_eq!(
1591 attempts.len(),
1592 (MAX_REVIEW_AGENT_FAILURES as usize + 1) * error_messages.len(),
1593 "every failed attempt must be recorded for `orch task inspect`"
1594 );
1595 assert!(
1596 attempts
1597 .iter()
1598 .all(|a| a.details.get("external").and_then(|v| v.as_bool()) == Some(true)),
1599 "rate limits must be recorded as an external cause"
1600 );
1601 assert!(
1602 attempts.iter().any(
1603 |a| a.details.get("reason").and_then(|v| v.as_str()) == Some(error_messages[0])
1604 ),
1605 "the recorded attempt must carry that attempt's error"
1606 );
1607 }
1608}