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