1use crate::backends::{ExternalBackend, Mention, Status};
13use crate::cmd::CommandErrorContext;
14use crate::config;
15use crate::engine::auto_merge::{is_pr_behind, required_checks_state};
16use crate::engine::cleanup::cleanup_task_worktree;
17use crate::engine::cooldown::{
18 cooldown_reason, github_circuit_remaining_secs, is_agent_in_cooldown, is_github_circuit_open,
19 is_model_in_cooldown, refresh_degraded_agents,
20};
21use crate::engine::router::Router;
22use crate::engine::runner::agents::patterns;
23use crate::engine::tasks::{status_to_task_status, TaskManager};
24use crate::store::TaskStatus;
25use crate::store::TaskStore;
26use crate::tmux::TmuxManager;
27use dashmap::{DashMap, DashSet};
28use std::sync::Arc;
29use tokio::process::Command;
30use tokio::sync::RwLock;
31
32async fn kv_get_prefer_store(store: &Option<&Arc<TaskStore>>, key: &str) -> Option<String> {
38 match try_kv_get_prefer_store(store, key).await {
39 Ok(opt) => opt,
40 Err(e) => {
41 tracing::error!(key, err = %e, "kv_get failed");
42 None
43 }
44 }
45}
46
47async fn try_kv_get_prefer_store(
53 store: &Option<&Arc<TaskStore>>,
54 key: &str,
55) -> anyhow::Result<Option<String>> {
56 if let Some(s) = store {
57 Ok(s.kv_get(key).await?)
58 } else {
59 Ok(None)
60 }
61}
62
63async fn kv_set_prefer_store(store: &Option<&Arc<TaskStore>>, key: &str, value: &str) {
69 if let Err(e) = try_kv_set_prefer_store(store, key, value).await {
70 tracing::error!(key, err = %e, "kv_set failed");
71 }
72}
73
74async fn try_kv_set_prefer_store(
80 store: &Option<&Arc<TaskStore>>,
81 key: &str,
82 value: &str,
83) -> anyhow::Result<()> {
84 if let Some(s) = store {
85 s.kv_set(key, value).await?;
86 }
87 Ok(())
88}
89
90use super::cleanup::{check_merged_prs, cleanup_done_worktrees};
91use super::commands::{parse_command, validate_and_run_command, CommandOutcome, CommandStoreOps};
92use super::review_poll::review_open_prs;
93use super::EngineConfig;
94use crate::github::types::extract_issue_number_from_url;
95
96#[derive(Debug, Clone)]
97pub(crate) struct ReviewTaskSnapshot {
98 pub external: crate::backends::ExternalTask,
99 pub stored: crate::store::Task,
100}
101
102async fn prefetch_review_tasks(
103 store: &Arc<TaskStore>,
104 repo: &str,
105) -> anyhow::Result<(
106 Vec<ReviewTaskSnapshot>,
107 Vec<ReviewTaskSnapshot>,
108 Vec<ReviewTaskSnapshot>,
109)> {
110 let mut in_review = store.list_by_status(repo, TaskStatus::InReview).await?;
112 in_review.extend(store.list_by_status(repo, TaskStatus::Approved).await?);
113 let in_review = in_review
114 .into_iter()
115 .map(|stored| ReviewTaskSnapshot {
116 external: crate::engine::tasks::store_task_to_external(&stored),
117 stored,
118 })
119 .collect();
120 let needs_review = store
121 .list_by_status(repo, TaskStatus::NeedsReview)
122 .await?
123 .into_iter()
124 .map(|stored| ReviewTaskSnapshot {
125 external: crate::engine::tasks::store_task_to_external(&stored),
126 stored,
127 })
128 .collect();
129
130 let blocked_candidates = store
134 .list_by_status(repo, TaskStatus::Blocked)
135 .await?
136 .into_iter()
137 .filter(|stored| stored.pr_number.is_some() || !stored.branch.is_empty())
138 .map(|stored| ReviewTaskSnapshot {
139 external: crate::engine::tasks::store_task_to_external(&stored),
140 stored,
141 })
142 .collect();
143
144 Ok((in_review, needs_review, blocked_candidates))
145}
146
147async fn fetch_comments_since(
148 backend: &Arc<dyn ExternalBackend>,
149 since: &str,
150) -> anyhow::Result<Vec<Mention>> {
151 backend.get_mentions(since).await
152}
153
154fn filter_mentions_by_since(comments: &[Mention], since: &str) -> Vec<Mention> {
155 comments
156 .iter()
157 .filter(|comment| comment.created_at.as_str() > since)
158 .cloned()
159 .collect()
160}
161
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163enum FailureCategory {
164 FalseFailure,
165 RateLimit,
166 CreditExhausted,
167 SilentExit0,
168 Timeout,
169 ConnectionError,
170 CliFlagError,
171 AllAgentsExhausted,
172 ParseError,
173 MaxAttempts,
174 PushFailed,
175 PrCreateFailed,
176 ModelUnavailable,
177 Unknown,
178}
179
180impl FailureCategory {
181 fn is_recoverable(self) -> bool {
188 matches!(
189 self,
190 FailureCategory::FalseFailure
191 | FailureCategory::RateLimit
192 | FailureCategory::CreditExhausted
193 | FailureCategory::SilentExit0
194 | FailureCategory::Timeout
195 | FailureCategory::ConnectionError
196 | FailureCategory::CliFlagError
197 | FailureCategory::AllAgentsExhausted
198 | FailureCategory::ParseError
199 | FailureCategory::ModelUnavailable
200 )
201 }
202
203 fn as_str(self) -> &'static str {
204 match self {
205 FailureCategory::FalseFailure => "FalseFailure",
206 FailureCategory::RateLimit => "RateLimit",
207 FailureCategory::CreditExhausted => "CreditExhausted",
208 FailureCategory::SilentExit0 => "SilentExit0",
209 FailureCategory::Timeout => "Timeout",
210 FailureCategory::ConnectionError => "ConnectionError",
211 FailureCategory::CliFlagError => "CliFlagError",
212 FailureCategory::AllAgentsExhausted => "AllAgentsExhausted",
213 FailureCategory::ParseError => "ParseError",
214 FailureCategory::MaxAttempts => "MaxAttempts",
215 FailureCategory::PushFailed => "PushFailed",
216 FailureCategory::PrCreateFailed => "PrCreateFailed",
217 FailureCategory::ModelUnavailable => "ModelUnavailable",
218 FailureCategory::Unknown => "Unknown",
219 }
220 }
221}
222
223fn classify_failure(error: &str, outcome: &str) -> FailureCategory {
224 let lower = error.to_lowercase();
225
226 if outcome == "timeout" || lower.contains("timeout") {
227 return FailureCategory::Timeout;
228 }
229
230 if outcome == "billing_cycle_exhausted"
241 || outcome == "credit_exhausted"
242 || outcome == "org_level_disabled"
243 || crate::engine::cooldown::detect_credit_exhaustion(&lower).is_some()
244 {
245 return FailureCategory::CreditExhausted;
246 }
247
248 if outcome == "rate_limit" || lower.contains("rate limit") || lower.contains("usage limit") {
249 return FailureCategory::RateLimit;
250 }
251
252 if error.trim().is_empty() {
253 return FailureCategory::FalseFailure;
254 }
255
256 if lower.contains("all agents exhausted") {
257 return FailureCategory::AllAgentsExhausted;
258 }
259
260 if lower.contains("max attempts") || lower.contains("exceeded max attempts") {
261 return FailureCategory::MaxAttempts;
262 }
263
264 if lower.contains("output-format=stream-json") && lower.contains("requires --verbose") {
265 return FailureCategory::CliFlagError;
266 }
267
268 if lower.contains("model unavailable")
269 || lower.contains("model not found")
270 || (lower.contains("does not exist") && lower.contains("model"))
271 {
272 return FailureCategory::ModelUnavailable;
273 }
274
275 if lower.contains("push failed") {
276 return FailureCategory::PushFailed;
277 }
278
279 if lower.contains("create pr failed")
280 || lower.contains("failed to create pull request")
281 || lower.contains("pull request creation failed")
282 {
283 return FailureCategory::PrCreateFailed;
284 }
285
286 if lower.contains("exit 0") {
287 return FailureCategory::SilentExit0;
288 }
289
290 if outcome == "parse_error"
291 || outcome == "truncated"
292 || lower.contains("invalid response")
293 || lower.contains("parse error")
294 || lower.contains("truncated")
295 {
296 return FailureCategory::ParseError;
297 }
298
299 if patterns::detect_network_error(&lower).is_some() {
300 return FailureCategory::ConnectionError;
301 }
302
303 FailureCategory::Unknown
304}
305
306fn classify_failure_from_run(run: &crate::store::TaskRun) -> Option<FailureCategory> {
307 if run.outcome == "success" && run.error.trim().is_empty() {
309 return None;
310 }
311
312 if run.outcome.trim().is_empty()
319 && run.error.trim().is_empty()
320 && run.exit_code.is_none()
321 && run.stdout.trim().is_empty()
322 && run.stderr.trim().is_empty()
323 {
324 return None;
325 }
326
327 let has_output = !run.stdout.trim().is_empty() || !run.stderr.trim().is_empty();
328 if run.exit_code == Some(0) && !has_output && run.outcome != "success" {
329 return Some(FailureCategory::SilentExit0);
330 }
331
332 let mut category = classify_failure(&run.error, &run.outcome);
333
334 if category == FailureCategory::FalseFailure {
343 let has_nonzero_exit = run.exit_code.is_some() && run.exit_code != Some(0);
344 let has_output = !run.stdout.trim().is_empty() || !run.stderr.trim().is_empty();
345 if has_nonzero_exit || has_output {
346 category = FailureCategory::Unknown;
347 }
348 }
349
350 Some(category)
351}
352
353async fn resolve_mention_parent_id(
363 store: &crate::store::TaskStore,
364 repo: &str,
365 issue_num: &str,
366 is_pr: bool,
367) -> anyhow::Result<Option<i64>> {
368 if is_pr {
369 if let Ok(pr_num) = issue_num.parse::<i32>() {
370 store.resolve_id_by_pr_number(repo, pr_num).await
371 } else {
372 Ok(None)
373 }
374 } else {
375 store.resolve_id_by_external(repo, issue_num).await
376 }
377}
378
379async fn record_mention_task(
381 store: &TaskStore,
382 repo: &str,
383 mention: &Mention,
384 title: &str,
385 body: &str,
386 parent_id: Option<i64>,
387 cursor: &mut MentionCursor,
388) -> bool {
389 match store
390 .create_internal(repo, title, body, "mention", &mention.id, parent_id)
391 .await
392 {
393 Ok(task_id) => {
394 tracing::info!(task_id, mention_id = %mention.id, "created mention task");
395 cursor.advance(&mention.created_at);
396 true
397 }
398 Err(e) => {
399 tracing::warn!(mention_id = %mention.id, err = %e, "failed to create mention task");
400 false
401 }
402 }
403}
404
405fn advance_cursor(last_safe_ts: &mut Option<String>, ts: &str) {
407 if last_safe_ts.as_deref() < Some(ts) {
408 *last_safe_ts = Some(ts.to_string());
409 }
410}
411
412#[derive(Debug, Default)]
413struct MentionCursor {
414 last_safe_ts: Option<String>,
415 blocked_by_gap: bool,
416}
417
418impl MentionCursor {
419 fn advance(&mut self, ts: &str) {
420 if self.blocked_by_gap {
421 return;
422 }
423 advance_cursor(&mut self.last_safe_ts, ts);
424 }
425
426 fn block_on_gap(&mut self) {
427 self.blocked_by_gap = true;
428 }
429
430 fn into_last_safe_ts(self) -> Option<String> {
431 self.last_safe_ts
432 }
433}
434
435#[derive(Debug, PartialEq)]
437pub(crate) enum CommentAction {
438 Skip,
440 ExecuteCommand {
442 command: crate::engine::commands::OwnerCommand,
443 issue_num: String,
444 },
445 ExecuteCommandForMention {
447 command: crate::engine::commands::OwnerCommand,
448 issue_num: String,
449 },
450 CreateMentionTask { issue_num: Option<String> },
452}
453
454pub(crate) fn classify_comment(
459 body: &str,
460 issue_url: Option<&str>,
461 current_user: &str,
462 already_processed: bool,
463) -> CommentAction {
464 if already_processed {
465 return CommentAction::Skip;
466 }
467
468 let is_mention =
469 body.contains(current_user) || body.contains("@orch") || body.contains("@orchestrator");
470 let command = parse_command(body);
471 let issue_num = issue_url.and_then(extract_issue_number_from_url);
472
473 match (command, is_mention, issue_num) {
474 (Some(cmd), true, Some(num)) => CommentAction::ExecuteCommandForMention {
475 command: cmd,
476 issue_num: num,
477 },
478 (Some(cmd), false, Some(num)) => CommentAction::ExecuteCommand {
479 command: cmd,
480 issue_num: num,
481 },
482 (Some(_), true, None) => CommentAction::CreateMentionTask { issue_num: None },
484 (Some(_), false, None) => CommentAction::Skip,
485 (None, true, issue_num) => CommentAction::CreateMentionTask { issue_num },
487 (None, false, _) => CommentAction::Skip,
489 }
490}
491
492#[allow(clippy::too_many_arguments)]
496async fn handle_slash_command(
505 backend: &Arc<dyn ExternalBackend>,
506 store: Option<&Arc<TaskStore>>,
507 repo: &str,
508 task_manager: &Arc<TaskManager>,
509 gh: &dyn crate::engine::commands::CommandGh,
510 mention: &Mention,
511 command: &crate::engine::commands::OwnerCommand,
512 cursor: &mut MentionCursor,
513) -> anyhow::Result<bool> {
514 let issue_num = match mention
515 .issue_url
516 .as_deref()
517 .and_then(extract_issue_number_from_url)
518 {
519 Some(num) => num,
520 None => {
521 tracing::warn!(comment_id = %mention.id, "slash command without valid issue number");
522 return Ok(true);
523 }
524 };
525
526 let store_opt: Option<Arc<dyn CommandStoreOps>> =
527 store.map(|s| Arc::clone(s) as Arc<dyn CommandStoreOps>);
528 let outcome = validate_and_run_command(
529 backend,
530 gh,
531 repo,
532 &issue_num,
533 command,
534 &mention.author,
535 &store_opt,
536 task_manager,
537 )
538 .await;
539
540 let is_pr = match &outcome {
542 CommandOutcome::Executed { is_pr }
543 | CommandOutcome::NotOpen { is_pr }
544 | CommandOutcome::NotCollaborator { is_pr } => *is_pr,
545 CommandOutcome::FetchFailed | CommandOutcome::CollaboratorCheckFailed => {
546 tracing::warn!(
549 comment_id = %mention.id,
550 command = %command,
551 "deferring @orch slash command after a transient GitHub error"
552 );
553 return Ok(false);
554 }
555 };
556
557 if let Some(s) = store {
560 let parent_id = match resolve_mention_parent_id(s, repo, &issue_num, is_pr).await {
561 Ok(parent_id) => parent_id,
562 Err(e) => {
563 tracing::warn!(
564 comment_id = %mention.id,
565 err = %e,
566 "deferring mention task due to parent lookup failure"
567 );
568 return Err(e);
569 }
570 };
571
572 let (title, body) = match &outcome {
573 CommandOutcome::NotOpen { .. } => (
574 format!(
575 "Skipped @orch command on closed #{issue_num} from @{}",
576 mention.author
577 ),
578 format!(
579 "Mention by @{}:\n\n{}\n\n**Skipped:** target is closed",
580 mention.author, mention.body
581 ),
582 ),
583 CommandOutcome::NotCollaborator { .. } => (
584 format!(
585 "Skipped @orch command on #{issue_num} from non-collaborator @{}",
586 mention.author
587 ),
588 format!(
589 "Mention by @{}:\n\n{}\n\n**Skipped:** author is not a collaborator",
590 mention.author, mention.body
591 ),
592 ),
593 CommandOutcome::Executed { .. } => (
594 format!(
595 "Respond to mention by @{} on task #{issue_num} — command executed",
596 mention.author
597 ),
598 format!(
599 "Mention by @{} on #{issue_num}:\n\n{}\n\n**Status:** Command executed",
600 mention.author, mention.body
601 ),
602 ),
603 CommandOutcome::FetchFailed | CommandOutcome::CollaboratorCheckFailed => {
604 tracing::debug!("skipping mention task record due to earlier command failure");
605 return Ok(true);
606 }
607 };
608
609 let created = record_mention_task(s, repo, mention, &title, &body, parent_id, cursor).await;
610 if !created {
611 return Ok(false);
612 }
613 }
614 Ok(true)
615}
616
617fn auto_unblock_cooldown_elapsed(count: i32, last_at: &str) -> bool {
618 if count == 0 {
619 return true;
620 }
621
622 let required = match count {
623 1 => chrono::Duration::minutes(30),
624 2 => chrono::Duration::hours(2),
625 _ => return false,
626 };
627
628 if last_at.trim().is_empty() {
629 return true;
630 }
631
632 match chrono::DateTime::parse_from_rfc3339(last_at) {
633 Ok(ts) => chrono::Utc::now() - ts.with_timezone(&chrono::Utc) >= required,
634 Err(_) => true,
635 }
636}
637
638fn is_ci_failure_block(task: &crate::store::Task) -> bool {
639 let reason = task.block_reason.as_deref().unwrap_or("");
640 reason.contains("CI failure limit") || reason.contains("CI checks timed out")
641}
642
643fn ci_failure_unblock_cooldown_elapsed(task: &crate::store::Task) -> bool {
644 if task.auto_unblock_count == 0 {
645 return true;
646 }
647
648 let required = match task.auto_unblock_count {
652 1 => chrono::Duration::minutes(10),
653 2 => chrono::Duration::hours(1),
654 3 => chrono::Duration::hours(6),
655 _ => chrono::Duration::hours(24),
656 };
657
658 let last_at = &task.auto_unblock_last_at;
659 if last_at.trim().is_empty() {
660 return true;
661 }
662
663 match chrono::DateTime::parse_from_rfc3339(last_at) {
664 Ok(ts) => chrono::Utc::now() - ts.with_timezone(&chrono::Utc) >= required,
665 Err(_) => true,
666 }
667}
668
669async fn try_unblock_ci_failure_task(
670 gh: &crate::github::http::GhHttp,
671 repo: &str,
672 task_manager: &Arc<TaskManager>,
673 store: &Arc<TaskStore>,
674 task: &crate::store::Task,
675 auto_merge_in_flight: &Arc<DashSet<String>>,
676) -> anyhow::Result<bool> {
677 let pr_number = match task.pr_number {
678 Some(n) => n,
679 None => {
680 tracing::debug!(
681 task_id = task.id,
682 "CI failure blocked task has no PR number"
683 );
684 return Ok(false);
685 }
686 };
687
688 let pr = match gh.get_pr(repo, pr_number as u64).await {
689 Ok(p) => p,
690 Err(e) => {
691 tracing::warn!(task_id = task.id, pr_number, err = %e, "failed to get PR status for CI failure block");
692 return Ok(false);
693 }
694 };
695
696 tracing::debug!(
697 task_id = task.id,
698 pr_number,
699 merged = pr.merged,
700 state = ?pr.state,
701 "verifying PR state for CI failure blocked task"
702 );
703
704 if pr.merged.unwrap_or(false) || pr.state.eq_ignore_ascii_case("closed") {
705 tracing::info!(
706 task_id = task.id,
707 pr_number,
708 merged = pr.merged,
709 state = %pr.state,
710 "PR merged/closed — unblocking CI failure blocked task"
711 );
712 let ext_id = task
713 .external_id
714 .clone()
715 .unwrap_or_else(|| format!("internal:{}", task.id));
716 task_manager
720 .mark_task_done_by_store_id(task.id, &task.repo, &crate::backends::ExternalId(ext_id))
721 .await?;
722 let fields = vec![
723 ("block_reason", serde_json::Value::Null),
724 ("auto_unblock_count", serde_json::json!(0)),
725 ("auto_unblock_last_at", serde_json::json!("")),
726 ("auto_unblock_last_reason", serde_json::json!("")),
727 ];
728 store.set_fields(task.id, &fields).await?;
729 return Ok(true);
730 }
731
732 let task_id_str = task
736 .external_id
737 .clone()
738 .unwrap_or_else(|| format!("internal:{}", task.id));
739 if auto_merge_in_flight.contains(&task_id_str) {
740 tracing::debug!(
741 task_id = task.id,
742 pr_number,
743 "CI-failure recovery merge already in flight — skipping"
744 );
745 return Ok(false);
746 }
747
748 let branch_update_result = gh.update_pr_branch(repo, pr_number as u64).await;
754 match branch_update_result {
755 Ok(()) => {
756 tracing::info!(
757 task_id = task.id,
758 pr_number,
759 "updated CI-failure-blocked PR branch against base to retrigger CI"
760 );
761 }
762 Err(ref e) => {
763 tracing::warn!(
765 task_id = task.id,
766 pr_number,
767 err = %e,
768 outcome = "branch_update_failed",
769 "failed to update CI-failure-blocked PR branch (may already be up to date)"
770 );
771 }
772 }
773
774 if let Err(e) = gh.enable_auto_merge(repo, pr_number as u64).await {
775 tracing::warn!(task_id = task.id, pr_number, err = %e, "failed to re-enable auto-merge after branch update");
776 }
777
778 let pr_state = pr.state.as_str();
781 let new_reason = if let Err(ref e) = branch_update_result {
782 format!(
783 "CI failure limit reached during auto-merge (PR #{} still open, state: {}, branch update failed: {})",
784 pr_number, pr_state, e
785 )
786 } else {
787 format!(
788 "CI failure limit reached during auto-merge (PR #{} still open, state: {})",
789 pr_number, pr_state
790 )
791 };
792 let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
793 let new_count = task.auto_unblock_count.saturating_add(1);
796 let fields = vec![
797 ("block_reason", serde_json::json!(&new_reason)),
798 ("auto_unblock_count", serde_json::json!(new_count)),
799 ("auto_unblock_last_at", serde_json::json!(&now)),
800 ];
801 store.set_fields_silent(task.id, &fields).await?;
805
806 auto_merge_in_flight.insert(task_id_str.clone());
810 let gh_clone = gh.clone();
811 let repo_string = repo.to_string();
812 let task_manager_clone = Arc::clone(task_manager);
813 let store_clone = Arc::clone(store);
814 let task_clone = task.clone();
815 let in_flight_clone = Arc::clone(auto_merge_in_flight);
816
817 tokio::spawn(async move {
818 let _guard = scopeguard::guard((), |_| {
819 in_flight_clone.remove(&task_id_str);
820 });
821
822 match poll_and_merge_recovered_pr(&gh_clone, &repo_string, pr_number as u64, task_clone.id)
823 .await
824 {
825 Ok(true) => {
826 tracing::info!(
827 task_id = task_clone.id,
828 pr_number,
829 "CI-failure-blocked PR merged during recovery sweep"
830 );
831 let ext_id = task_clone
832 .external_id
833 .clone()
834 .unwrap_or_else(|| format!("internal:{}", task_clone.id));
835 let fields = [
836 ("block_reason", serde_json::Value::Null),
837 ("auto_unblock_count", serde_json::json!(0)),
838 ("auto_unblock_last_at", serde_json::json!("")),
839 ("auto_unblock_last_reason", serde_json::json!("")),
840 ];
841 if let Err(e) = task_manager_clone
842 .update_task_status_and_result_by_store_id(
843 task_clone.id,
844 &task_clone.repo,
845 &crate::backends::ExternalId(ext_id),
846 Status::Done,
847 &fields,
848 )
849 .await
850 {
851 tracing::warn!(
852 task_id = task_clone.id,
853 pr_number,
854 err = %e,
855 "failed to mark CI-failure-blocked task done after recovery merge"
856 );
857 }
858 if let Err(e) =
859 cleanup_task_worktree(&format!("{}", task_clone.id), &repo_string, &store_clone)
860 .await
861 {
862 tracing::warn!(
863 task_id = task_clone.id,
864 pr_number,
865 err = %e,
866 "cleanup after CI-failure recovery merge failed"
867 );
868 }
869 }
870 Ok(false) => {
871 tracing::debug!(
872 task_id = task_clone.id,
873 pr_number,
874 "CI-failure-blocked PR not merged this recovery tick — poll completed without merge"
875 );
876 }
877 Err(e) => {
878 tracing::warn!(
879 task_id = task_clone.id,
880 pr_number,
881 err = %e,
882 "error while polling CI-failure-blocked PR for recovery"
883 );
884 }
885 }
886 });
887
888 Ok(false)
889}
890
891async fn poll_and_merge_recovered_pr(
902 gh: &crate::github::http::GhHttp,
903 repo: &str,
904 pr_number: u64,
905 task_id: i64,
906) -> anyhow::Result<bool> {
907 let max_wait_secs: u64 = config::get("workflow.ci_poll_max_wait_secs")
908 .ok()
909 .and_then(|s| s.parse().ok())
910 .unwrap_or(600);
911 let base_interval_secs: u64 = config::get("workflow.ci_poll_interval_secs")
912 .ok()
913 .and_then(|s| s.parse().ok())
914 .unwrap_or(15);
915 let max_wait = std::time::Duration::from_secs(max_wait_secs);
916 let start = std::time::Instant::now();
917 let mut poll_count: u32 = 0;
918 const MAX_BEHIND_RETRIES: u32 = 3;
922 let mut behind_retries: u32 = 0;
923
924 let initial_pr = gh.get_pr(repo, pr_number).await?;
925 let base_branch = initial_pr.base.ref_.clone();
926 let required_contexts = gh
927 .get_required_status_check_contexts(repo, &base_branch)
928 .await?;
929 let repo_has_workflows = if required_contexts.is_empty() {
930 gh.has_workflows(repo).await?
931 } else {
932 true
933 };
934
935 loop {
936 let pr = gh.get_pr(repo, pr_number).await?;
937 if pr.merged.unwrap_or(false) || pr.state.eq_ignore_ascii_case("closed") {
938 tracing::info!(
939 task_id = task_id,
940 pr_number,
941 "PR merged/closed during recovery poll"
942 );
943 return Ok(true);
944 }
945
946 if pr.mergeable == Some(false) {
947 tracing::info!(
948 task_id = task_id,
949 pr_number,
950 outcome = "poll_exited_without_merge",
951 reason = "merge_conflicts",
952 "PR has merge conflicts after recovery update — leaving for next recovery cycle"
953 );
954 return Ok(false);
955 }
956
957 if is_pr_behind(&pr) {
958 if behind_retries >= MAX_BEHIND_RETRIES {
959 tracing::info!(
960 task_id = task_id,
961 pr_number,
962 behind_retries,
963 max_behind_retries = MAX_BEHIND_RETRIES,
964 outcome = "poll_exited_without_merge",
965 reason = "behind_retry_exhausted",
966 "PR still behind base after recovery retries — leaving for next recovery cycle"
967 );
968 return Ok(false);
969 }
970 behind_retries += 1;
971 match gh.update_pr_branch(repo, pr_number).await {
972 Ok(()) => {
973 tracing::info!(
974 task_id = task_id,
975 pr_number,
976 behind_retries,
977 "PR was behind base — updated branch and continuing recovery poll"
978 );
979 continue;
980 }
981 Err(e) => {
982 tracing::warn!(
983 task_id = task_id,
984 pr_number,
985 behind_retries,
986 err = %e,
987 outcome = "branch_update_failed",
988 "failed to update behind PR branch during recovery poll"
989 );
990 return Ok(false);
991 }
992 }
993 }
994
995 let head_sha = pr.head.sha.clone();
996 let (state, total, passing, failing, pending) = if required_contexts.is_empty() {
997 gh.get_combined_status(
998 repo,
999 &head_sha,
1000 repo_has_workflows,
1001 pr.mergeable_state.as_deref(),
1002 )
1003 .await?
1004 } else {
1005 let check_runs = gh.get_check_runs(repo, &head_sha).await?;
1006 let statuses = gh.get_commit_status_contexts(repo, &head_sha).await?;
1007 required_checks_state(&required_contexts, &check_runs, &statuses)
1008 };
1009
1010 tracing::info!(
1011 task_id = task_id,
1012 pr_number,
1013 state = %state,
1014 total,
1015 passing,
1016 failing,
1017 pending,
1018 "CI recovery status check"
1019 );
1020
1021 match state.as_str() {
1022 "success" => {
1023 tracing::info!(
1024 task_id = task_id,
1025 pr_number,
1026 "CI green after recovery — attempting direct merge"
1027 );
1028 match gh.merge_pr(repo, pr_number, true, &head_sha).await {
1029 Ok(()) => {
1030 tracing::info!(
1031 task_id = task_id,
1032 pr_number,
1033 "direct merge succeeded after recovery"
1034 );
1035 return Ok(true);
1036 }
1037 Err(e) => {
1038 let err = e.to_string().to_ascii_lowercase();
1039 tracing::warn!(
1040 task_id = task_id,
1041 pr_number,
1042 err = %e,
1043 "direct merge failed after recovery"
1044 );
1045 if err.contains("already merged")
1048 || err.contains("no merge commit")
1049 || err.contains("pull request already merged")
1050 {
1051 return Ok(true);
1052 }
1053 if err.contains("head branch was modified")
1055 || err.contains("sha does not match")
1056 {
1057 tracing::info!(
1058 task_id = task_id,
1059 pr_number,
1060 outcome = "poll_exited_without_merge",
1061 reason = "head_moved",
1062 "merge rejected because the head sha changed"
1063 );
1064 return Ok(false);
1065 }
1066 if (err.contains("head branch is out of date")
1070 || err.contains("behind")
1071 || err.contains("409"))
1072 && behind_retries < MAX_BEHIND_RETRIES
1073 {
1074 behind_retries += 1;
1075 match gh.update_pr_branch(repo, pr_number).await {
1076 Ok(()) => {
1077 tracing::info!(
1078 task_id = task_id,
1079 pr_number,
1080 behind_retries,
1081 "merge rejected due to out-of-date branch — updated and continuing recovery poll"
1082 );
1083 continue;
1084 }
1085 Err(update_err) => {
1086 tracing::warn!(
1087 task_id = task_id,
1088 pr_number,
1089 behind_retries,
1090 err = %update_err,
1091 outcome = "branch_update_failed",
1092 "failed to update out-of-date PR branch during recovery poll"
1093 );
1094 }
1095 }
1096 }
1097 tracing::info!(
1098 task_id = task_id,
1099 pr_number,
1100 outcome = "poll_exited_without_merge",
1101 reason = "direct_merge_failed",
1102 "direct merge did not succeed — leaving for next recovery cycle"
1103 );
1104 return Ok(false);
1105 }
1106 }
1107 }
1108 "failure" => {
1109 tracing::info!(
1110 task_id = task_id,
1111 pr_number,
1112 outcome = "poll_exited_without_merge",
1113 reason = "ci_failure",
1114 "CI still failing after recovery — will retry later"
1115 );
1116 return Ok(false);
1117 }
1118 _ => {}
1119 }
1120
1121 if start.elapsed() >= max_wait {
1122 tracing::info!(
1123 task_id = task_id,
1124 pr_number,
1125 outcome = "poll_exited_without_merge",
1126 reason = "poll_timeout",
1127 "CI still pending after recovery poll timeout — will retry later"
1128 );
1129 return Ok(false);
1130 }
1131
1132 poll_count += 1;
1133 let multiplier = (2_u64).pow(poll_count.saturating_sub(1)).min(4);
1134 let sleep_secs = base_interval_secs.saturating_mul(multiplier);
1135 tokio::time::sleep(std::time::Duration::from_secs(sleep_secs)).await;
1136 }
1137}
1138
1139const REBROADCAST_BLOCK_REASON: &str = "review agent rebroadcast escalated after repeated retries";
1144
1145pub(crate) async fn auto_recover_rebroadcast_blocked_tasks_global(
1160 router: &Router,
1161 task_manager: &Arc<TaskManager>,
1162 store: &Arc<TaskStore>,
1163) -> anyhow::Result<()> {
1164 const MIN_BLOCK_AGE_MINUTES: i64 = 5;
1165
1166 let all_blocked = match store.list_all_by_status_global(TaskStatus::Blocked).await {
1167 Ok(tasks) => tasks,
1168 Err(e) => {
1169 tracing::warn!(err = %e, "global rebroadcast-blocked sweep: failed to list blocked tasks");
1170 return Ok(());
1171 }
1172 };
1173
1174 let now = chrono::Utc::now();
1175 let candidates: Vec<_> = all_blocked
1176 .into_iter()
1177 .filter(|t| {
1178 if t.block_reason.as_deref() != Some(REBROADCAST_BLOCK_REASON) {
1179 return false;
1180 }
1181 match chrono::DateTime::parse_from_rfc3339(&t.updated_at) {
1183 Ok(updated_at) => {
1184 let age = now - updated_at.with_timezone(&chrono::Utc);
1185 age.num_minutes() >= MIN_BLOCK_AGE_MINUTES
1186 }
1187 Err(_) => true,
1188 }
1189 })
1190 .collect();
1191
1192 if candidates.is_empty() {
1193 return Ok(());
1194 }
1195
1196 let gh = match crate::github::http::GhHttp::new() {
1197 Ok(g) => Some(g),
1198 Err(e) => {
1199 tracing::warn!(err = %e, "global rebroadcast-blocked sweep: failed to create GhHttp — skipping PR-state checks this tick");
1200 None
1201 }
1202 };
1203
1204 let any_routable =
1207 !router.available_agents.is_empty() && router.healthy_agent_count("review") > 0;
1208
1209 for task in candidates {
1210 let ext_id = task
1211 .external_id
1212 .clone()
1213 .unwrap_or_else(|| format!("internal:{}", task.id));
1214
1215 if let (Some(gh), Some(pr_number)) = (&gh, task.pr_number) {
1216 match gh.get_pr(&task.repo, pr_number as u64).await {
1217 Ok(pr) => {
1218 if pr.merged.unwrap_or(false) {
1219 tracing::info!(
1220 task_id = task.id,
1221 repo = %task.repo,
1222 pr_number,
1223 "global rebroadcast-blocked sweep: PR already merged — marking task done"
1224 );
1225 if let Err(e) = task_manager
1226 .mark_task_done_by_store_id(
1227 task.id,
1228 &task.repo,
1229 &crate::backends::ExternalId(ext_id.clone()),
1230 )
1231 .await
1232 {
1233 tracing::warn!(task_id = task.id, repo = %task.repo, err = %e, "global rebroadcast-blocked sweep: failed to mark merged task done");
1234 continue;
1235 }
1236 let fields: &[(&str, serde_json::Value)] = &[
1237 ("block_reason", serde_json::Value::Null),
1238 ("needs_review_refires", serde_json::json!(0)),
1239 ];
1240 if let Err(e) = store.set_fields(task.id, fields).await {
1241 tracing::warn!(task_id = task.id, err = %e, "global rebroadcast-blocked sweep: failed to clear fields after marking done");
1242 }
1243 continue;
1244 }
1245 if pr.state.eq_ignore_ascii_case("closed") {
1246 tracing::info!(
1247 task_id = task.id,
1248 repo = %task.repo,
1249 pr_number,
1250 "global rebroadcast-blocked sweep: PR closed unmerged — leaving task blocked, not re-dispatching"
1251 );
1252 let new_reason = format!(
1253 "{REBROADCAST_BLOCK_REASON} (PR #{pr_number} closed unmerged — no further auto-recovery)"
1254 );
1255 let fields: &[(&str, serde_json::Value)] =
1256 &[("block_reason", serde_json::json!(new_reason))];
1257 if let Err(e) = store.set_fields_silent(task.id, fields).await {
1261 tracing::warn!(task_id = task.id, err = %e, "global rebroadcast-blocked sweep: failed to annotate closed-unmerged block reason");
1262 }
1263 continue;
1264 }
1265 }
1267 Err(e) => {
1268 tracing::warn!(task_id = task.id, repo = %task.repo, pr_number, err = %e, "global rebroadcast-blocked sweep: failed to get PR status — skipping this tick");
1269 continue;
1270 }
1271 }
1272 }
1273
1274 if !any_routable {
1275 continue;
1276 }
1277
1278 if let Err(e) = task_manager
1279 .update_task_status_by_store_id(
1280 task.id,
1281 &task.repo,
1282 &crate::backends::ExternalId(ext_id.clone()),
1283 Status::NeedsReview,
1284 )
1285 .await
1286 {
1287 tracing::warn!(task_id = task.id, repo = %task.repo, err = %e, "global rebroadcast-blocked sweep: failed to reset task to needs_review — skipping");
1288 continue;
1289 }
1290
1291 let fields: &[(&str, serde_json::Value)] = &[
1292 ("block_reason", serde_json::Value::Null),
1293 ("needs_review_refires", serde_json::json!(0)),
1294 ];
1295 if let Err(e) = store.set_fields(task.id, fields).await {
1296 tracing::warn!(task_id = task.id, err = %e, "global rebroadcast-blocked sweep: failed to clear block_reason/needs_review_refires after status reset");
1297 }
1298
1299 tracing::info!(
1300 task_id = task.id,
1301 repo = %task.repo,
1302 ext_id = %ext_id,
1303 "global rebroadcast-blocked sweep: auto-recovered rebroadcast-blocked task — review agents now available"
1304 );
1305 }
1306
1307 Ok(())
1308}
1309
1310async fn auto_unblock_blocked_tasks(
1311 repo: &str,
1312 task_manager: &Arc<TaskManager>,
1313 store: &Arc<TaskStore>,
1314 dispatching: &Arc<DashMap<String, String>>,
1315 auto_merge_in_flight: &Arc<DashSet<String>>,
1316) -> anyhow::Result<()> {
1317 let blocked = match store.list_by_status(repo, TaskStatus::Blocked).await {
1318 Ok(tasks) => tasks,
1319 Err(e) => {
1320 tracing::warn!(err = %e, "failed to list blocked tasks — skipping auto-unblock this tick");
1321 return Ok(());
1322 }
1323 };
1324
1325 if blocked.is_empty() {
1326 return Ok(());
1327 }
1328
1329 let task_ids: Vec<i64> = blocked.iter().map(|t| t.id).collect();
1331 let mut runs_by_task = match store.get_runs_batch(&task_ids).await {
1332 Ok(map) => map,
1333 Err(e) => {
1334 tracing::warn!(err = %e, "get_runs_batch failed — skipping auto-unblock this tick");
1335 return Ok(());
1336 }
1337 };
1338
1339 let (ci_failure_tasks, other_blocked): (Vec<_>, Vec<_>) =
1341 blocked.into_iter().partition(is_ci_failure_block);
1342
1343 if !ci_failure_tasks.is_empty() {
1345 let gh = match crate::github::http::GhHttp::new() {
1346 Ok(g) => Some(g),
1347 Err(e) => {
1348 tracing::warn!(err = %e, "failed to create GhHttp — skipping CI failure unblock this tick");
1349 None
1350 }
1351 };
1352 for task in ci_failure_tasks {
1353 if ci_failure_unblock_cooldown_elapsed(&task) {
1354 if let Some(ref gh) = gh {
1355 if let Err(e) = try_unblock_ci_failure_task(
1356 gh,
1357 repo,
1358 task_manager,
1359 store,
1360 &task,
1361 auto_merge_in_flight,
1362 )
1363 .await
1364 {
1365 tracing::warn!(task_id = task.id, err = %e, "CI failure unblock check failed");
1366 }
1367 }
1368 }
1369 }
1370 }
1371
1372 for task in other_blocked {
1374 if task.block_reason.is_some() {
1375 continue;
1376 }
1377
1378 let task_id_str = task
1379 .external_id
1380 .clone()
1381 .unwrap_or_else(|| format!("internal:{}", task.id));
1382 let dispatch_key = format!("{}/{}", repo, task_id_str);
1383 if dispatching.contains_key(&dispatch_key) {
1384 continue;
1385 }
1386
1387 let runs = runs_by_task.remove(&task.id).unwrap_or_default();
1388 let relevant_runs: Vec<_> = runs
1389 .into_iter()
1390 .filter(|r| r.run_type == "agent" || r.run_type == "review")
1391 .collect();
1392 let mut failures = Vec::new();
1393 let mut has_review_failure = false;
1394 for run in relevant_runs.iter().rev() {
1395 if let Some(category) = classify_failure_from_run(run) {
1396 if run.run_type == "review" {
1397 has_review_failure = true;
1398 }
1399 failures.push(category);
1400 }
1401 if failures.len() >= 3 {
1402 break;
1403 }
1404 }
1405
1406 if failures.is_empty() || !failures.iter().all(|f| f.is_recoverable()) {
1407 continue;
1408 }
1409
1410 let reason_key = failures.first().map(|f| f.as_str()).unwrap_or("");
1412
1413 let current_reason = task.auto_unblock_last_reason.clone();
1416 let reason_changed = reason_key != current_reason;
1417
1418 let cooldown_count = if reason_changed {
1421 0
1422 } else {
1423 task.auto_unblock_count
1424 };
1425
1426 if cooldown_count >= 3 {
1430 continue;
1431 }
1432
1433 if !auto_unblock_cooldown_elapsed(cooldown_count, &task.auto_unblock_last_at) {
1434 continue;
1435 }
1436
1437 let new_count = cooldown_count + 1;
1441 let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
1442
1443 let ext_id = task
1444 .external_id
1445 .clone()
1446 .unwrap_or_else(|| format!("internal:{}", task.id));
1447 let new_status = if has_review_failure {
1448 Status::NeedsReview
1449 } else {
1450 Status::New
1451 };
1452 let new_status_str = if has_review_failure {
1453 "needs_review"
1454 } else {
1455 "new"
1456 };
1457
1458 if let Err(e) = task_manager
1461 .update_task_status(&crate::backends::ExternalId(ext_id), new_status)
1462 .await
1463 {
1464 tracing::warn!(
1465 task_id = task.id,
1466 err = %e,
1467 "auto-unblock failed to update status — counter not incremented"
1468 );
1469 continue;
1470 }
1471
1472 let mut fields: Vec<(&str, serde_json::Value)> = vec![
1474 ("auto_unblock_count", serde_json::json!(new_count)),
1475 ("auto_unblock_last_at", serde_json::json!(now)),
1476 ("auto_unblock_last_reason", serde_json::json!(reason_key)),
1477 ("agent", serde_json::Value::Null),
1478 ("model", serde_json::Value::Null),
1479 ];
1480 fields.push(("no_code_reroutes", serde_json::json!(0)));
1481 fields.push(("no_code_last_agent", serde_json::json!("")));
1482 if has_review_failure {
1483 fields.push(("review_agent_failures", serde_json::json!(0)));
1484 fields.push(("review_invocations", serde_json::json!(0)));
1485 fields.push(("review_resets", serde_json::json!(0)));
1486 }
1487 if let Err(e) = store.set_fields(task.id, &fields).await {
1488 tracing::warn!(task_id = task.id, err = %e, "failed to set auto_unblock fields after successful status update");
1489 }
1492
1493 if let Err(e) = store
1494 .append_activity(
1495 task.id,
1496 "auto_unblock",
1497 Some("blocked"),
1498 Some(new_status_str),
1499 None,
1500 None,
1501 Some(&serde_json::json!({
1502 "reason": reason_key,
1503 "count": if reason_changed { 1 } else { task.auto_unblock_count + 1 },
1504 "failures": failures.iter().map(|f| format!("{f:?}")).collect::<Vec<_>>(),
1505 })),
1506 )
1507 .await
1508 {
1509 tracing::warn!(task_id = task.id, err = %e, "failed to log auto_unblock activity");
1510 }
1511
1512 tracing::info!(
1513 task_id = task.id,
1514 failures = ?failures,
1515 review_failure = has_review_failure,
1516 "auto-unblocked task with recoverable failures"
1517 );
1518 }
1519
1520 Ok(())
1521}
1522
1523pub(crate) async fn auto_unblock_ci_failure_blocked_tasks_global(
1534 task_manager: &Arc<crate::engine::tasks::TaskManager>,
1535 store: &Arc<TaskStore>,
1536 auto_merge_in_flight: &Arc<DashSet<String>>,
1537) -> anyhow::Result<()> {
1538 let all_blocked = match store.list_all_by_status_global(TaskStatus::Blocked).await {
1539 Ok(tasks) => tasks,
1540 Err(e) => {
1541 tracing::warn!(err = %e, "global CI-failure blocked sweep: failed to list blocked tasks");
1542 return Ok(());
1543 }
1544 };
1545
1546 let ci_failure_tasks: Vec<_> = all_blocked
1547 .into_iter()
1548 .filter(is_ci_failure_block)
1549 .collect();
1550 if ci_failure_tasks.is_empty() {
1551 return Ok(());
1552 }
1553
1554 let gh = match crate::github::http::GhHttp::new() {
1555 Ok(g) => g,
1556 Err(e) => {
1557 tracing::warn!(err = %e, "global CI-failure blocked sweep: failed to create GhHttp");
1558 return Ok(());
1559 }
1560 };
1561
1562 for task in ci_failure_tasks {
1563 if !ci_failure_unblock_cooldown_elapsed(&task) {
1564 continue;
1565 }
1566 if let Err(e) = try_unblock_ci_failure_task(
1567 &gh,
1568 &task.repo,
1569 task_manager,
1570 store,
1571 &task,
1572 auto_merge_in_flight,
1573 )
1574 .await
1575 {
1576 tracing::warn!(
1577 task_id = task.id,
1578 repo = %task.repo,
1579 err = %e,
1580 "global CI-failure blocked sweep: unblock check failed"
1581 );
1582 }
1583 }
1584
1585 Ok(())
1586}
1587
1588pub(crate) async fn refire_and_escalate_stale_needs_review_global(
1602 task_manager: &Arc<TaskManager>,
1603 store: &Arc<TaskStore>,
1604 router: &Router,
1605 dispatching: &Arc<DashMap<String, String>>,
1606) -> anyhow::Result<()> {
1607 const MIN_STALE_NEEDS_REVIEW_MINUTES: i64 = 1;
1608 const MAX_NEEDS_REVIEW_REFIRE_ATTEMPTS: u64 = 5;
1609
1610 let needs_review_tasks = match store
1611 .list_all_by_status_global(TaskStatus::NeedsReview)
1612 .await
1613 {
1614 Ok(tasks) => tasks,
1615 Err(e) => {
1616 tracing::warn!(err = %e, "global NeedsReview refire sweep: failed to list needs_review tasks");
1617 return Ok(());
1618 }
1619 };
1620
1621 if needs_review_tasks.is_empty() {
1622 return Ok(());
1623 }
1624
1625 let all_review_agents_cooled = router.healthy_agent_count("review") == 0;
1628 if all_review_agents_cooled {
1629 tracing::info!(
1630 count = needs_review_tasks.len(),
1631 "global NeedsReview refire sweep: no review agent available — skipping this pass"
1632 );
1633 return Ok(());
1634 }
1635
1636 for stored in needs_review_tasks {
1637 let external = crate::engine::tasks::store_task_to_external(&stored);
1638 let ext_id = external.id.clone();
1639 let repo = stored.repo.clone();
1640
1641 let age_minutes = match chrono::DateTime::parse_from_rfc3339(&external.updated_at) {
1642 Ok(updated_at) => {
1643 let age = chrono::Utc::now() - updated_at.with_timezone(&chrono::Utc);
1644 if age.num_minutes() < MIN_STALE_NEEDS_REVIEW_MINUTES {
1645 continue;
1646 }
1647 Some(age.num_minutes())
1648 }
1649 Err(_) => None,
1650 };
1651
1652 let dispatch_key = format!("{}/{}", repo, ext_id.0);
1654 if dispatching.contains_key(&dispatch_key) {
1655 continue;
1656 }
1657
1658 let current_refires = stored.needs_review_refires.max(0) as u64;
1659 let new_refires = current_refires + 1;
1660
1661 if new_refires > MAX_NEEDS_REVIEW_REFIRE_ATTEMPTS {
1662 tracing::warn!(
1663 task_id = stored.id,
1664 repo = %repo,
1665 new_refires,
1666 "global NeedsReview refire sweep: escalating to Blocked after repeated refires"
1667 );
1668 let fields = [
1669 ("block_reason", serde_json::json!(REBROADCAST_BLOCK_REASON)),
1670 (
1671 "last_error",
1672 serde_json::json!(format!("escalated after {} retries", new_refires)),
1673 ),
1674 ];
1675 if let Err(e) = task_manager
1676 .update_task_status_and_result_by_store_id(
1677 stored.id,
1678 &repo,
1679 &ext_id,
1680 Status::Blocked,
1681 &fields,
1682 )
1683 .await
1684 {
1685 tracing::error!(task_id = stored.id, repo = %repo, err = %e, "global NeedsReview refire sweep: escalation to Blocked failed — skipping to avoid silent auto-unblock loop");
1686 continue;
1687 }
1688 if let Err(e) = crate::store::store_increment_by_id(
1689 &Some(Arc::clone(store)),
1690 stored.id,
1691 "needs_review_refires",
1692 )
1693 .await
1694 {
1695 tracing::warn!(
1696 task_id = stored.id,
1697 repo = %repo,
1698 err = %e,
1699 "global NeedsReview refire sweep: failed to increment needs_review_refires after escalation"
1700 );
1701 }
1702 continue;
1703 }
1704
1705 let required_minutes = MIN_STALE_NEEDS_REVIEW_MINUTES * (1i64 << (current_refires as u32));
1707
1708 let should_fire = match age_minutes {
1709 Some(age) => age >= required_minutes,
1710 None => true,
1711 };
1712
1713 if !should_fire {
1714 continue;
1715 }
1716
1717 if let Err(e) = task_manager
1718 .update_task_status_by_store_id(stored.id, &repo, &ext_id, Status::NeedsReview)
1719 .await
1720 {
1721 tracing::warn!(
1722 task_id = stored.id,
1723 repo = %repo,
1724 err = %e,
1725 "global NeedsReview refire sweep: failed to re-fire NeedsReview event — not incrementing counter"
1726 );
1727 continue;
1728 }
1729
1730 let fired_refires = match crate::store::store_increment_by_id(
1731 &Some(Arc::clone(store)),
1732 stored.id,
1733 "needs_review_refires",
1734 )
1735 .await
1736 {
1737 Ok(v) => v,
1738 Err(e) => {
1739 tracing::warn!(
1740 task_id = stored.id,
1741 repo = %repo,
1742 err = %e,
1743 "global NeedsReview refire sweep: failed to increment needs_review_refires after re-fire"
1744 );
1745 current_refires + 1
1746 }
1747 };
1748
1749 tracing::info!(
1750 task_id = stored.id,
1751 repo = %repo,
1752 age_minutes,
1753 refires = fired_refires,
1754 required_minutes,
1755 "global NeedsReview refire sweep: re-firing NeedsReview event for stale task"
1756 );
1757 }
1758
1759 Ok(())
1760}
1761
1762#[allow(clippy::too_many_arguments)]
1769pub(crate) async fn sync_tick(
1770 backend: &Arc<dyn ExternalBackend>,
1771 tmux: &Arc<TmuxManager>,
1772 repo: &str,
1773 config: &EngineConfig,
1774 router: &Arc<RwLock<Router>>,
1775 task_manager: &Arc<TaskManager>,
1776 store: &Arc<TaskStore>,
1777 dispatching: &Arc<DashMap<String, String>>,
1778 auto_merge_in_flight: &Arc<DashSet<String>>,
1779) -> anyhow::Result<()> {
1780 if !store.is_leader() {
1782 return Ok(());
1783 }
1784
1785 tracing::debug!("sync tick");
1786
1787 let mut phases: Vec<(&str, u128)> = Vec::new();
1788 let mut at = std::time::Instant::now();
1789
1790 let gh_circuit_open = is_github_circuit_open();
1793
1794 if gh_circuit_open {
1795 let remaining = github_circuit_remaining_secs();
1796 tracing::debug!(
1797 remaining_secs = remaining,
1798 "GitHub 5xx circuit breaker open — skipping non-critical sync work"
1799 );
1800 if let Err(e) =
1803 try_kv_set_prefer_store(&Some(store), "metrics:orch.github_5xx_circuit.open", "1").await
1804 {
1805 tracing::error!(err = %e, "failed to persist circuit breaker state — may cause thundering herd after restart");
1806 }
1807 } else if let Err(e) =
1808 try_kv_set_prefer_store(&Some(store), "metrics:orch.github_5xx_circuit.open", "0").await
1809 {
1810 tracing::error!(err = %e, "failed to clear circuit breaker state");
1811 }
1812
1813 if !gh_circuit_open {
1816 if let Err(e) = ingest_external_tasks(backend, repo, store).await {
1817 tracing::debug!(err = %e, "external task ingest failed");
1818 }
1819 phases.push(("ingest", at.elapsed().as_millis()));
1820 at = std::time::Instant::now();
1821
1822 if let Err(e) = backend.reconcile_project_statuses().await {
1826 tracing::warn!(err = %e, "project status reconciliation failed");
1827 }
1828 phases.push(("board_reconcile", at.elapsed().as_millis()));
1829 }
1830 at = std::time::Instant::now();
1831
1832 if !gh_circuit_open {
1834 let backend = Arc::clone(backend);
1835 let repo = repo.to_string();
1836 let task_manager = Arc::clone(task_manager);
1837 let store = Arc::clone(store);
1838 tokio::spawn(async move {
1839 if let Err(e) = cleanup_done_worktrees(&backend, &repo, &task_manager, &store).await {
1840 tracing::warn!(err = %e, "worktree cleanup failed");
1841 }
1842 });
1843 }
1844
1845 let (in_review_tasks, needs_review_tasks, blocked_review_candidates) =
1846 match prefetch_review_tasks(store, repo).await {
1847 Ok(tasks) => tasks,
1848 Err(e) => {
1849 tracing::warn!(err = %e, "failed to prefetch review tasks");
1850 (vec![], vec![], vec![])
1851 }
1852 };
1853 phases.push(("prefetch_review", at.elapsed().as_millis()));
1854 at = std::time::Instant::now();
1855
1856 if !gh_circuit_open {
1857 let mention_fallback = chrono::Utc::now() - chrono::Duration::hours(24);
1858 let mention_since = kv_get_prefer_store(&Some(store), "mentions_last_checked")
1859 .await
1860 .unwrap_or_else(|| mention_fallback.format("%Y-%m-%dT%H:%M:%SZ").to_string());
1861 let shared_since = mention_since.clone();
1862
1863 let cached_router_config = router.read().await.config.clone();
1864 let mut combined_needs = needs_review_tasks.clone();
1866 combined_needs.extend(blocked_review_candidates.clone());
1867
1868 let (merge_result, comments_result, review_result) = tokio::join!(
1869 check_merged_prs(
1870 backend,
1871 repo,
1872 store,
1873 task_manager,
1874 &in_review_tasks,
1875 &combined_needs,
1876 ),
1877 fetch_comments_since(backend, &shared_since),
1878 async {
1879 let gh = match crate::github::http::GhHttp::new() {
1880 Ok(g) => g,
1881 Err(e) => {
1882 tracing::warn!(err = %e, "failed to create GitHub HTTP client for review poll");
1883 return Ok(());
1884 }
1885 };
1886 review_open_prs(
1887 backend,
1888 repo,
1889 config,
1890 task_manager,
1891 store,
1892 dispatching,
1893 auto_merge_in_flight,
1894 &in_review_tasks,
1895 &gh,
1896 &cached_router_config,
1897 )
1898 .await
1899 }
1900 );
1901
1902 if let Err(e) = merge_result {
1903 tracing::warn!(err = %e, "PR merge check failed");
1904 }
1905
1906 match comments_result {
1907 Ok(comments) => {
1908 let filtered = filter_mentions_by_since(&comments, &mention_since);
1909
1910 if let Err(e) =
1911 scan_comments(backend, Some(store), repo, task_manager, Some(&filtered)).await
1912 {
1913 tracing::warn!(err = %e, "comment scan failed");
1914 }
1915 }
1916 Err(e) => {
1917 tracing::warn!(err = %e, "comment fetch failed");
1918 }
1919 }
1920
1921 if let Err(e) = review_result {
1922 tracing::warn!(err = %e, "PR review failed");
1923 }
1924
1925 phases.push(("merge_comments_review", at.elapsed().as_millis()));
1926 }
1927 at = std::time::Instant::now();
1928
1929 let in_progress_tasks: Vec<_> = match store.list_by_status(repo, TaskStatus::InProgress).await {
1939 Ok(tasks) => tasks
1940 .into_iter()
1941 .map(|stored| {
1942 let external = crate::engine::tasks::store_task_to_external(&stored);
1943 (stored, external)
1944 })
1945 .collect(),
1946 Err(e) => {
1947 tracing::warn!(err = %e, "failed to list in_progress tasks — skipping stale InProgress check");
1948 vec![]
1949 }
1950 };
1951
1952 if !in_progress_tasks.is_empty() {
1953 let live_sessions: std::collections::HashSet<String> = tmux
1955 .list_sessions()
1956 .await
1957 .unwrap_or_default()
1958 .into_iter()
1959 .map(|s| s.name)
1960 .collect();
1961
1962 for (_stored, external) in &in_progress_tasks {
1963 let task_id = &external.id.0;
1964
1965 let dispatch_key = format!("{}/{}", repo, task_id);
1967 {
1968 if dispatching.contains_key(&dispatch_key) {
1969 tracing::debug!(
1970 task_id = %task_id,
1971 "task locked by dispatch flow, skipping stale check"
1972 );
1973 continue;
1974 }
1975 }
1976
1977 const MIN_STALE_MINUTES: i64 = 5;
1981 match chrono::DateTime::parse_from_rfc3339(&external.updated_at) {
1982 Ok(updated_at) => {
1983 let age = chrono::Utc::now() - updated_at.with_timezone(&chrono::Utc);
1984 if age.num_minutes() < MIN_STALE_MINUTES {
1985 tracing::debug!(
1986 task_id = %task_id,
1987 age_seconds = age.num_seconds(),
1988 "InProgress task is too young to be considered stale, skipping"
1989 );
1990 continue;
1991 }
1992 }
1993 Err(e) => {
1994 tracing::warn!(
1995 task_id = %task_id,
1996 ts = %external.updated_at,
1997 err = %e,
1998 "invalid updated_at timestamp — treating task as potentially stale"
1999 );
2000 }
2001 }
2002
2003 let agent_session = tmux.session_name(repo, task_id);
2004 if live_sessions.contains(&agent_session) {
2005 continue;
2007 }
2008
2009 {
2013 tracing::warn!(
2014 task_id = %task_id,
2015 session = %agent_session,
2016 "InProgress task has no active agent session — resetting to Routed"
2017 );
2018 match task_manager
2019 .update_task_status_if(&external.id, Status::Routed, Status::InProgress)
2020 .await
2021 {
2022 Err(e) => {
2023 tracing::error!(task_id = %task_id, err = %e, "failed to reset stale InProgress task — task may be stuck in InProgress indefinitely");
2024 }
2025 Ok(false) => {
2026 tracing::debug!(
2027 task_id = %task_id,
2028 "stale InProgress reset skipped — task already transitioned (concurrent Done/Blocked/NeedsReview)"
2029 );
2030 }
2031 Ok(true) => {
2032 }
2034 }
2035 }
2036 }
2037 }
2038
2039 phases.push(("stale_in_progress", at.elapsed().as_millis()));
2040 at = std::time::Instant::now();
2041 if let Err(e) =
2043 auto_unblock_blocked_tasks(repo, task_manager, store, dispatching, auto_merge_in_flight)
2044 .await
2045 {
2046 tracing::warn!(err = %e, "auto-unblock failed");
2047 }
2048
2049 phases.push(("auto_unblock", at.elapsed().as_millis()));
2050
2051 crate::engine::log_phase_timings("sync", repo, &phases);
2052 Ok(())
2053}
2054
2055pub(crate) async fn sync_shared_tail(router: &Arc<RwLock<Router>>, store: Option<&Arc<TaskStore>>) {
2060 let Some(store) = store else {
2061 return;
2062 };
2063 if !store.is_leader() {
2064 return;
2065 }
2066 match skills_sync().await {
2067 Ok(()) => {
2068 let llm = router.read().await.llm_router_handle();
2071 llm.invalidate_skills_catalog().await;
2072 }
2073 Err(e) => {
2074 tracing::warn!(err = %e, "skills sync failed");
2075 }
2076 }
2077
2078 let (available_agents, config) = {
2082 let r = router.read().await;
2083 (r.available_agents.clone(), r.config.clone())
2084 };
2085 let model_checker = |agent: &str| -> bool {
2086 for comp in &["simple", "medium", "complex", "review"] {
2087 if config.has_available_model_for_complexity(agent, comp) {
2088 return true;
2089 }
2090 }
2091 false
2092 };
2093 refresh_degraded_agents(
2094 store,
2095 &available_agents,
2096 &model_checker,
2097 crate::engine::router::config::health_check_window_hours(),
2098 crate::engine::router::config::degraded_rate_limit_threshold(),
2099 )
2100 .await;
2101
2102 emit_degraded_agents_if_needed(&available_agents, &config, Some(store)).await;
2103}
2104
2105async fn scan_comments(
2115 backend: &Arc<dyn ExternalBackend>,
2116 store: Option<&Arc<TaskStore>>,
2117 repo: &str,
2118 task_manager: &Arc<TaskManager>,
2119 prefetched_comments: Option<&[Mention]>,
2120) -> anyhow::Result<()> {
2121 let current_user = match backend.get_authenticated_user().await {
2122 Ok(Some(u)) => format!("@{}", u),
2123 Ok(None) => {
2124 tracing::debug!("backend does not support user identity, skipping comment scan");
2125 return Ok(());
2126 }
2127 Err(e) => {
2128 tracing::warn!(err = %e, "failed to get current user, skipping comment scan");
2129 return Ok(());
2130 }
2131 };
2132
2133 let comments = if let Some(c) = prefetched_comments {
2134 c.to_vec()
2135 } else {
2136 let fallback = chrono::Utc::now() - chrono::Duration::hours(24);
2137 let since_str = match kv_get_prefer_store(&store, "mentions_last_checked").await {
2138 Some(ts) => ts,
2139 None => fallback.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
2140 };
2141 match backend.get_mentions(&since_str).await {
2142 Ok(m) => m,
2143 Err(e) => {
2144 tracing::warn!(err = %e, "failed to get comments");
2145 return Ok(());
2146 }
2147 }
2148 };
2149
2150 let existing_mentions: std::collections::HashSet<String> = if let Some(s) = store {
2152 match s.list_source_ids_by_source(repo, "mention", 30).await {
2153 Ok(ids) => ids.into_iter().collect(),
2154 Err(e) => {
2155 tracing::warn!(err = %e, "failed to load mention source IDs — skipping to prevent duplicates");
2156 return Ok(());
2157 }
2158 }
2159 } else {
2160 std::collections::HashSet::new()
2161 };
2162
2163 let gh_cell: tokio::sync::OnceCell<crate::github::http::GhHttp> = tokio::sync::OnceCell::new();
2167 let get_gh = || async {
2168 gh_cell
2169 .get_or_try_init(|| async { crate::github::http::GhHttp::new() })
2170 .await
2171 };
2172
2173 let actions: Vec<CommentAction> = comments
2176 .iter()
2177 .map(|c| {
2178 classify_comment(
2179 &c.body,
2180 c.issue_url.as_deref(),
2181 ¤t_user,
2182 existing_mentions.contains(&c.id),
2183 )
2184 })
2185 .collect();
2186
2187 let pr_check_inputs: Vec<(usize, String)> = actions
2191 .iter()
2192 .enumerate()
2193 .filter_map(|(i, action)| {
2194 if let CommentAction::CreateMentionTask {
2195 issue_num: Some(ref num),
2196 } = action
2197 {
2198 Some((i, num.clone()))
2199 } else {
2200 None
2201 }
2202 })
2203 .collect();
2204
2205 let is_pr_values: Vec<bool> = if pr_check_inputs.is_empty() {
2207 Vec::new()
2208 } else {
2209 let gh = get_gh().await?;
2210 futures::future::join_all(
2211 pr_check_inputs
2212 .iter()
2213 .map(|(_, num)| async move { gh.is_pull_request(repo, num).await }),
2214 )
2215 .await
2216 };
2217
2218 let is_pr_map: std::collections::HashMap<usize, bool> = pr_check_inputs
2220 .iter()
2221 .map(|(i, _)| *i)
2222 .zip(is_pr_values)
2223 .collect();
2224
2225 let mut cursor = MentionCursor::default();
2226
2227 for (comment_idx, (comment, action)) in comments.iter().zip(actions).enumerate() {
2228 match action {
2229 CommentAction::Skip => {
2230 cursor.advance(&comment.created_at);
2231 }
2232
2233 CommentAction::ExecuteCommand { command, issue_num } => {
2234 let store_opt: Option<Arc<dyn CommandStoreOps>> =
2235 store.map(|s| Arc::clone(s) as Arc<dyn CommandStoreOps>);
2236 let gh = get_gh().await?;
2237 let outcome = validate_and_run_command(
2238 backend,
2239 gh,
2240 repo,
2241 &issue_num,
2242 &command,
2243 &comment.author,
2244 &store_opt,
2245 task_manager,
2246 )
2247 .await;
2248 match outcome {
2249 CommandOutcome::FetchFailed | CommandOutcome::CollaboratorCheckFailed => {
2252 tracing::warn!(
2253 comment_id = %comment.id,
2254 command = %command,
2255 "deferring slash command after a transient GitHub error"
2256 );
2257 cursor.block_on_gap();
2258 break;
2259 }
2260 CommandOutcome::Executed { .. }
2263 | CommandOutcome::NotOpen { .. }
2264 | CommandOutcome::NotCollaborator { .. } => cursor.advance(&comment.created_at),
2265 }
2266 }
2267
2268 CommentAction::ExecuteCommandForMention { command, .. } => {
2269 if let Err(e) = backend.acknowledge_mention(&comment.id).await {
2270 tracing::debug!(err = %e, mention_id = %comment.id, "failed to acknowledge mention");
2271 } else {
2275 let gh = get_gh().await?;
2278 let advanced_safely = handle_slash_command(
2279 backend,
2280 store,
2281 repo,
2282 task_manager,
2283 gh,
2284 comment,
2285 &command,
2286 &mut cursor,
2287 )
2288 .await?;
2289 if !advanced_safely {
2290 cursor.block_on_gap();
2291 break;
2292 }
2293 }
2294 }
2295
2296 CommentAction::CreateMentionTask { issue_num } => {
2297 if let Err(e) = backend.acknowledge_mention(&comment.id).await {
2298 tracing::debug!(err = %e, mention_id = %comment.id, "failed to acknowledge mention");
2299 } else {
2303 let is_pr = is_pr_map.get(&comment_idx).copied().unwrap_or(false);
2308
2309 let (title, task_body) = match (&issue_num, is_pr) {
2310 (Some(num), false) => (
2311 format!("Respond to mention by @{} on task #{num}", comment.author),
2312 format!(
2313 "Mention by @{} on GitHub issue #{num}:\n\n{}\n\n---\nThis mention was posted on issue #{num}. Review the full issue to understand the context before responding.",
2314 comment.author, comment.body
2315 ),
2316 ),
2317 (Some(num), true) => (
2318 format!("Respond to mention by @{} on PR #{num}", comment.author),
2319 format!(
2320 "Mention by @{} on GitHub PR #{num}:\n\n{}\n\n---\nThis mention was posted on PR #{num}. Review the full PR (diff, description, and comments) to understand the context before responding.",
2321 comment.author, comment.body
2322 ),
2323 ),
2324 (None, _) => (
2325 format!("Respond to mention by @{}", comment.author),
2326 format!("Mention by @{}:\n\n{}", comment.author, comment.body),
2327 ),
2328 };
2329
2330 if let Some(s) = store {
2331 let parent_id = match issue_num {
2334 Some(ref num) => {
2335 match resolve_mention_parent_id(s, repo, num, is_pr).await {
2336 Ok(id) => id,
2337 Err(e) => {
2338 tracing::warn!(
2339 mention_id = %comment.id,
2340 err = %e,
2341 "deferring mention task due to parent lookup failure"
2342 );
2343 return Err(anyhow::anyhow!(
2344 "deferring mention {} due to parent lookup failure",
2345 comment.id
2346 ));
2347 }
2348 }
2349 }
2350 None => None,
2351 };
2352 if !record_mention_task(
2353 s,
2354 repo,
2355 comment,
2356 &title,
2357 &task_body,
2358 parent_id,
2359 &mut cursor,
2360 )
2361 .await
2362 {
2363 cursor.block_on_gap();
2364 break;
2365 }
2366 }
2367 }
2368 }
2369 }
2370 }
2371
2372 if let Some(ts) = cursor.into_last_safe_ts() {
2373 kv_set_prefer_store(&store, "mentions_last_checked", &ts).await;
2374 }
2375
2376 Ok(())
2377}
2378
2379async fn skills_sync() -> anyhow::Result<()> {
2385 let skills = match config::get_skills() {
2386 Ok(s) => s,
2387 Err(e) => {
2388 tracing::debug!(err = %e, "no skills configured");
2389 return Ok(());
2390 }
2391 };
2392
2393 if skills.is_empty() {
2394 tracing::debug!("no skills configured, skipping sync");
2395 return Ok(());
2396 }
2397
2398 let skills_base = crate::home::skills_dir()?;
2399 let git_timeout = std::time::Duration::from_secs(60);
2400
2401 let futs: Vec<_> = skills
2404 .into_iter()
2405 .map(|skill| sync_one_skill(skill, skills_base.clone(), git_timeout))
2406 .collect();
2407 futures::future::join_all(futs).await;
2408
2409 Ok(())
2410}
2411
2412async fn sync_one_skill(
2417 skill: config::SkillConfig,
2418 skills_base: std::path::PathBuf,
2419 git_timeout: std::time::Duration,
2420) {
2421 if skill.repo.contains("..") || skill.repo.matches('/').count() != 1 {
2423 tracing::warn!(repo = %skill.repo, "invalid skill repo format, expected 'owner/repo'");
2424 return;
2425 }
2426
2427 let repo_dir = skills_base.join(&skill.repo);
2428 let repo_url = format!("https://github.com/{}.git", skill.repo);
2429
2430 let repo_exists = tokio::fs::metadata(&repo_dir)
2433 .await
2434 .map(|m| m.is_dir())
2435 .unwrap_or(false);
2436
2437 if repo_exists {
2438 tracing::debug!(repo = %skill.repo, "pulling skill repo");
2440 let pull_result = tokio::time::timeout(
2441 git_timeout,
2442 Command::new("git")
2443 .args(["pull", "--ff-only", "--prune"])
2444 .current_dir(&repo_dir)
2445 .output_with_context(),
2446 )
2447 .await;
2448
2449 match pull_result {
2450 Ok(Ok(output)) if !output.status.success() => {
2451 let stderr = String::from_utf8_lossy(&output.stderr);
2452 tracing::warn!(repo = %skill.repo, err = %stderr, "git pull failed");
2453 }
2454 Ok(Ok(_)) => {
2455 tracing::debug!(repo = %skill.repo, "skill repo updated");
2456 }
2457 Ok(Err(e)) => {
2458 tracing::warn!(repo = %skill.repo, err = %e, "git pull error");
2459 }
2460 Err(_) => {
2461 tracing::warn!(repo = %skill.repo, "git pull timed out after 60s");
2462 }
2463 }
2464 } else {
2465 tracing::debug!(repo = %skill.repo, "cloning skill repo");
2467 let Some(parent) = repo_dir.parent() else {
2468 tracing::warn!(repo = %skill.repo, "skill repo path has no parent directory");
2469 return;
2470 };
2471 if let Err(e) = tokio::fs::create_dir_all(parent).await {
2472 tracing::warn!(repo = %skill.repo, err = %e, "failed to create parent dir for skill");
2473 return;
2474 }
2475 let Some(repo_dir_str) = repo_dir.to_str() else {
2476 tracing::warn!(repo = %skill.repo, "skill repo path is not valid UTF-8");
2477 return;
2478 };
2479
2480 let clone_result = tokio::time::timeout(
2481 git_timeout,
2482 Command::new("git")
2483 .args(["clone", "--depth", "1", &repo_url, repo_dir_str])
2484 .output_with_context(),
2485 )
2486 .await;
2487
2488 match clone_result {
2489 Ok(Ok(output)) if !output.status.success() => {
2490 let stderr = String::from_utf8_lossy(&output.stderr);
2491 tracing::warn!(repo = %skill.repo, err = %stderr, "git clone failed");
2492 let _ = tokio::fs::remove_dir_all(&repo_dir).await;
2494 }
2495 Ok(Ok(_)) => {
2496 tracing::info!(repo = %skill.repo, "skill repo cloned");
2497 }
2498 Ok(Err(e)) => {
2499 tracing::warn!(repo = %skill.repo, err = %e, "git clone error");
2500 let _ = tokio::fs::remove_dir_all(&repo_dir).await;
2501 }
2502 Err(_) => {
2503 tracing::warn!(repo = %skill.repo, "git clone timed out after 60s");
2504 let _ = tokio::fs::remove_dir_all(&repo_dir).await;
2505 }
2506 }
2507 }
2508}
2509
2510struct DegradedAgentDetail {
2512 agent: String,
2514 reason: String,
2518 cooled_models: Vec<String>,
2521}
2522
2523struct AgentPoolHealth {
2525 agent: String,
2526 cooled_models: Vec<String>,
2527 persistent_failed_models: Vec<String>,
2528 total_configured_models: usize,
2529}
2530
2531pub(crate) async fn emit_degraded_agents_if_needed(
2540 available_agents: &[String],
2541 config: &crate::engine::router::RouterConfig,
2542 store: Option<&Arc<TaskStore>>,
2543) {
2544 const COMPLEXITIES: &[&str] = &["simple", "medium", "complex", "review"];
2546 const HEAVILY_COOLED_RATIO_THRESHOLD: f64 = 0.5;
2547
2548 let mut details: Vec<DegradedAgentDetail> = Vec::new();
2549 let mut pool_health: Vec<AgentPoolHealth> = Vec::new();
2550
2551 for agent in available_agents {
2552 let agent_in_cd = is_agent_in_cooldown(agent);
2553
2554 let mut cooled_models: Vec<String> = Vec::new();
2556 let mut persistent_failed_models: Vec<String> = Vec::new();
2557 let mut seen_models = std::collections::HashSet::new();
2558 for comp in COMPLEXITIES {
2559 if let Some(pool) = config.model_pool_for_complexity(agent, comp) {
2560 for model in pool {
2561 if seen_models.insert(model.clone()) && is_model_in_cooldown(agent, &model) {
2562 let key = format!("{agent}:{model}");
2563 if matches!(
2564 cooldown_reason(&key).as_deref(),
2565 Some("persistent_model_error") | Some("model_not_found")
2566 ) {
2567 persistent_failed_models.push(model.clone());
2568 }
2569 cooled_models.push(model);
2570 }
2571 }
2572 }
2573 }
2574 let total_configured_models = seen_models.len();
2575 pool_health.push(AgentPoolHealth {
2576 agent: agent.clone(),
2577 cooled_models: cooled_models.clone(),
2578 persistent_failed_models: persistent_failed_models.clone(),
2579 total_configured_models,
2580 });
2581
2582 let has_model = COMPLEXITIES
2585 .iter()
2586 .any(|comp| config.has_available_model_for_complexity(agent, comp));
2587
2588 if agent_in_cd || !has_model {
2589 let reason = if agent_in_cd {
2590 cooldown_reason(agent).unwrap_or_else(|| "agent_cooldown".to_string())
2591 } else {
2592 "no_available_model".to_string()
2593 };
2594 details.push(DegradedAgentDetail {
2595 agent: agent.clone(),
2596 reason,
2597 cooled_models,
2598 });
2599 }
2600 }
2601
2602 let count = details.len();
2603
2604 if let Err(e) = try_kv_set_prefer_store(
2607 &store,
2608 "metrics:orch.agents_degraded.count",
2609 &count.to_string(),
2610 )
2611 .await
2612 {
2613 tracing::warn!(err = %e, "failed to persist degraded agents count metric");
2614 }
2615
2616 if count >= 3 {
2617 let degraded_agents: Vec<&str> = details.iter().map(|d| d.agent.as_str()).collect();
2619 let cooled_models_dim: Vec<String> = details
2620 .iter()
2621 .filter(|d| !d.cooled_models.is_empty())
2622 .map(|d| format!("{}:[{}]", d.agent, d.cooled_models.join(",")))
2623 .collect();
2624 let reasons_dim: Vec<String> = details
2625 .iter()
2626 .map(|d| format!("{}={}", d.agent, d.reason))
2627 .collect();
2628
2629 tracing::warn!(
2630 degraded_count = count,
2631 degraded_agents = ?degraded_agents,
2632 cooled_models = %cooled_models_dim.join("; "),
2633 cooldown_reasons = %reasons_dim.join("; "),
2634 "multi-agent degradation detected"
2635 );
2636
2637 if let Err(e) =
2639 try_kv_set_prefer_store(&store, "metrics:orch.agents_degraded.alert", "1").await
2640 {
2641 tracing::warn!(err = %e, "failed to persist degraded agents alert metric");
2642 }
2643 } else if let Err(e) =
2644 try_kv_set_prefer_store(&store, "metrics:orch.agents_degraded.alert", "0").await
2645 {
2646 tracing::warn!(err = %e, "failed to clear degraded agents alert metric");
2648 }
2649
2650 let heavily_cooled_agents: Vec<String> = pool_health
2654 .iter()
2655 .filter_map(|d| {
2656 if d.total_configured_models == 0 || d.persistent_failed_models.is_empty() {
2657 return None;
2658 }
2659 let ratio = d.cooled_models.len() as f64 / d.total_configured_models as f64;
2660 (ratio >= HEAVILY_COOLED_RATIO_THRESHOLD).then(|| {
2661 format!(
2662 "{}:{}/{}:{}",
2663 d.agent,
2664 d.cooled_models.len(),
2665 d.total_configured_models,
2666 d.persistent_failed_models.join(",")
2667 )
2668 })
2669 })
2670 .collect();
2671
2672 const STALE_AGENTS_KV: &str = "metrics:orch.model_pool_stale_persistent.agents";
2675 let current_dim = heavily_cooled_agents.join(",");
2676 let prev_dim = kv_get_prefer_store(&store, STALE_AGENTS_KV)
2677 .await
2678 .unwrap_or_default();
2679
2680 if !heavily_cooled_agents.is_empty() {
2681 if current_dim != prev_dim {
2682 tracing::warn!(
2683 affected_agents = ?heavily_cooled_agents,
2684 "agent model pool appears stale: persistent model failures in heavily cooled pool"
2685 );
2686 }
2687 if let Err(e) = try_kv_set_prefer_store(&store, STALE_AGENTS_KV, ¤t_dim).await {
2688 tracing::warn!(err = %e, "failed to persist stale model pool agents state");
2689 }
2690 if let Err(e) = try_kv_set_prefer_store(
2691 &store,
2692 "metrics:orch.model_pool_stale_persistent.alert",
2693 "1",
2694 )
2695 .await
2696 {
2697 tracing::warn!(err = %e, "failed to persist stale model pool alert metric");
2698 }
2699 } else {
2700 if !prev_dim.is_empty() {
2701 tracing::info!("agent model pool stale condition cleared");
2702 }
2703 if let Err(e) = try_kv_set_prefer_store(&store, STALE_AGENTS_KV, "").await {
2704 tracing::warn!(err = %e, "failed to clear stale model pool agents state");
2705 }
2706 if let Err(e) = try_kv_set_prefer_store(
2707 &store,
2708 "metrics:orch.model_pool_stale_persistent.alert",
2709 "0",
2710 )
2711 .await
2712 {
2713 tracing::warn!(err = %e, "failed to clear stale model pool alert metric");
2714 }
2715 }
2716}
2717
2718pub(crate) async fn ingest_external_tasks(
2726 backend: &Arc<dyn ExternalBackend>,
2727 repo: &str,
2728 store: &Arc<TaskStore>,
2729) -> anyhow::Result<()> {
2730 #[derive(Clone)]
2731 struct DuplicateTarget {
2732 id: String,
2733 status_label: String,
2734 }
2735
2736 let mut existing_by_title: std::collections::HashMap<String, DuplicateTarget> =
2737 std::collections::HashMap::new();
2738 let mut accepted_by_title: std::collections::HashMap<String, DuplicateTarget> =
2739 std::collections::HashMap::new();
2740
2741 let cutoff = (chrono::Utc::now() - chrono::Duration::hours(24))
2742 .format("%Y-%m-%dT%H:%M:%SZ")
2743 .to_string();
2744 match store.list_for_doctor(repo, &cutoff).await {
2745 Ok(recent) => {
2746 let mut insert_target = |task: &crate::store::Task| {
2747 if task.origin == "internal" {
2748 return;
2749 }
2750 let Some(ext_id) = task.external_id.as_ref() else {
2751 return;
2752 };
2753 let status_label = task
2754 .labels
2755 .iter()
2756 .find(|l| l.starts_with("status:"))
2757 .cloned()
2758 .unwrap_or_default();
2759 existing_by_title
2760 .entry(task.title.clone())
2761 .or_insert(DuplicateTarget {
2762 id: ext_id.clone(),
2763 status_label,
2764 });
2765 };
2766
2767 for task in recent.iter().filter(|t| t.status != TaskStatus::Done) {
2769 insert_target(task);
2770 }
2771 for task in recent.iter().filter(|t| t.status == TaskStatus::Done) {
2772 insert_target(task);
2773 }
2774 }
2775 Err(e) => {
2776 tracing::warn!(repo, err = %e, "ingest: failed to load recent tasks for dedup");
2777 }
2778 }
2779
2780 let all_tasks: Vec<(crate::backends::ExternalTask, Option<Status>)> =
2790 match backend.list_active_open_issues().await {
2791 Ok(tasks) => tasks,
2792 Err(e) => {
2793 tracing::warn!(
2794 repo,
2795 err = %e,
2796 "ingest: failed to list open issues — no new issue is ingested this cycle"
2797 );
2798 return Ok(());
2799 }
2800 };
2801
2802 let mut id_status_pairs: Vec<(i64, crate::backends::Status)> = Vec::new();
2805 let mut new_task_node_ids: Vec<(i64, String)> = Vec::new();
2808 let existing_ext_ids: std::collections::HashSet<String> = match store
2813 .existing_external_ids(repo)
2814 .await
2815 {
2816 Ok(s) => s,
2817 Err(e) => {
2818 tracing::warn!(repo = repo, err = %e, "ingest: failed to load existing external ids — proceeding");
2819 std::collections::HashSet::new()
2820 }
2821 };
2822 for (task, status) in &all_tasks {
2823 let is_new = !existing_ext_ids.contains(&task.id.0);
2826
2827 if is_new {
2828 let duplicate_target = existing_by_title
2829 .get(&task.title)
2830 .or_else(|| accepted_by_title.get(&task.title));
2831 if let Some(target) = duplicate_target {
2832 if target.id != task.id.0 {
2833 let label = target.status_label.as_str();
2834 let should_dedupe =
2835 match backend.has_open_issue_with_title(&task.title, label).await {
2836 Ok(true) => true,
2837 Ok(false) => false,
2838 Err(e) => {
2839 tracing::warn!(
2840 task_id = task.id.0,
2841 err = %e,
2842 "ingest: dedup check failed — defaulting to keep issue"
2843 );
2844 false
2845 }
2846 };
2847
2848 if should_dedupe {
2849 let comment = format!(
2850 "Duplicate of #{id} — closing to avoid duplicate work.{footer}",
2851 id = target.id,
2852 footer = crate::engine::orch_footer()
2853 );
2854 if let Err(e) = backend.post_comment(&task.id, &comment).await {
2855 tracing::warn!(
2856 task_id = task.id.0,
2857 err = %e,
2858 "ingest: failed to comment on duplicate issue"
2859 );
2860 }
2861 match backend.update_status(&task.id, Status::Done).await {
2862 Ok(()) => {
2863 tracing::info!(
2864 task_id = task.id.0,
2865 duplicate_of = target.id,
2866 "ingest: closed duplicate issue"
2867 );
2868 continue;
2869 }
2870 Err(e) => {
2871 tracing::warn!(
2872 task_id = task.id.0,
2873 err = %e,
2874 "ingest: failed to close duplicate issue — falling through to ingest normally"
2875 );
2876 }
2877 }
2878 }
2879 }
2880 }
2881 }
2882
2883 match store.ensure_external_task(repo, task).await {
2884 Ok(store_id) => {
2885 if let Ok(number) = task.id.0.parse::<i64>() {
2886 if let Err(err) = crate::engine::dependencies::sync_issue_dependencies(
2887 backend.as_ref(),
2888 store,
2889 store_id,
2890 repo,
2891 Some(number),
2892 &task.body,
2893 )
2894 .await
2895 {
2896 tracing::warn!(
2897 task_id = task.id.0,
2898 err = %err,
2899 "ingest: dependency sync failed"
2900 );
2901 }
2902 }
2903 if let Some(s) = status {
2904 id_status_pairs.push((store_id, *s));
2905 }
2906 if is_new {
2908 let status_label = task
2909 .labels
2910 .iter()
2911 .find(|l| l.starts_with("status:"))
2912 .cloned()
2913 .unwrap_or_default();
2914 accepted_by_title
2915 .entry(task.title.clone())
2916 .or_insert(DuplicateTarget {
2917 id: task.id.0.clone(),
2918 status_label,
2919 });
2920 if let Err(e) = backend.acknowledge_issue(&task.id).await {
2921 tracing::debug!(
2922 task_id = task.id.0,
2923 err = %e,
2924 "ingest: acknowledgment failed"
2925 );
2926 }
2927 let task_status = status.unwrap_or(Status::New);
2930 match backend.sync_to_project(&task.id, task_status).await {
2931 Ok(Some(node_id)) => {
2932 new_task_node_ids.push((store_id, node_id));
2933 }
2934 Ok(None) => {
2935 }
2937 Err(e) => {
2938 tracing::debug!(
2939 task_id = task.id.0,
2940 err = %e,
2941 "ingest: project board sync failed"
2942 );
2943 }
2944 }
2945 }
2946 }
2947 Err(e) => {
2948 tracing::debug!(task_id = task.id.0, ?e, "ingest: upsert failed");
2949 }
2950 }
2951 }
2952
2953 if !id_status_pairs.is_empty() {
2959 let all_ids: Vec<i64> = id_status_pairs.iter().map(|(id, _)| *id).collect();
2960 let existing_map = match store.get_batch(&all_ids).await {
2961 Ok(map) => map,
2962 Err(e) => {
2963 tracing::warn!(err = %e, task_count = all_ids.len(), "ingest: get_batch failed — skipping initial status sync for newly ingested tasks this tick");
2964 return Ok(());
2965 }
2966 };
2967 for (store_id, status) in id_status_pairs {
2968 if let Some(existing) = existing_map.get(&store_id) {
2969 if existing.status == TaskStatus::New {
2970 let db_status = status_to_task_status(status);
2971 if db_status != TaskStatus::New {
2972 if let Err(e) = store.update_status(store_id, db_status).await {
2973 tracing::debug!(?e, "ingest: status sync failed");
2974 }
2975 }
2976 }
2977 }
2978 }
2979 }
2980
2981 if !new_task_node_ids.is_empty() {
2984 let node_ids: Vec<String> = new_task_node_ids.iter().map(|(_, n)| n.clone()).collect();
2985 match backend.get_project_item_estimates(&node_ids).await {
2986 Ok(estimates) => {
2987 for (store_id, node_id) in new_task_node_ids {
2988 if let Some(&estimate) = estimates.get(&node_id) {
2989 if estimate > 0 {
2990 if let Err(e) = store.set_estimate_if_zero(store_id, estimate).await {
2991 tracing::debug!(
2992 store_id,
2993 estimate,
2994 err = %e,
2995 "ingest: failed to set estimate from project"
2996 );
2997 }
2998 }
2999 }
3000 }
3001 }
3002 Err(e) => {
3003 tracing::debug!(
3004 err = %e,
3005 "ingest: project estimate fetch failed — skipping estimate sync this tick"
3006 );
3007 }
3008 }
3009 }
3010
3011 Ok(())
3012}
3013
3014#[cfg(test)]
3015mod tests {
3016 use super::*;
3017 use crate::backends::{ExternalId, ExternalTask, Mention, Status};
3018 use crate::engine::cooldown::set_agent_cooldown;
3019 use crate::store::{RunTokenUsage, TaskStore};
3020 use async_trait::async_trait;
3021 use serial_test::serial;
3022 use std::sync::Arc;
3023
3024 struct IngestMockBackend {
3028 tasks: Vec<(String, Vec<ExternalTask>)>,
3030 dedup_result: bool,
3031 comments: tokio::sync::Mutex<Vec<(String, String)>>,
3032 status_updates: tokio::sync::Mutex<Vec<(String, Status)>>,
3033 project_syncs: tokio::sync::Mutex<Vec<(String, Status)>>,
3034 project_reconciliations: std::sync::atomic::AtomicUsize,
3035 list_calls: std::sync::atomic::AtomicUsize,
3037 }
3038
3039 impl IngestMockBackend {
3040 fn with_tasks(tasks: Vec<(Status, ExternalTask)>) -> Arc<Self> {
3041 Self::with_tasks_and_dedup(tasks, false)
3042 }
3043
3044 fn with_tasks_and_dedup(
3045 tasks: Vec<(Status, ExternalTask)>,
3046 dedup_result: bool,
3047 ) -> Arc<Self> {
3048 let mut grouped: Vec<(String, Vec<ExternalTask>)> = Vec::new();
3049 for (status, task) in tasks {
3050 let label = status.as_label().to_string();
3051 if let Some(entry) = grouped.iter_mut().find(|(l, _)| l == &label) {
3052 entry.1.push(task);
3053 } else {
3054 grouped.push((label, vec![task]));
3055 }
3056 }
3057 Arc::new(Self {
3058 tasks: grouped,
3059 dedup_result,
3060 comments: tokio::sync::Mutex::new(Vec::new()),
3061 status_updates: tokio::sync::Mutex::new(Vec::new()),
3062 project_syncs: tokio::sync::Mutex::new(Vec::new()),
3063 project_reconciliations: std::sync::atomic::AtomicUsize::new(0),
3064 list_calls: std::sync::atomic::AtomicUsize::new(0),
3065 })
3066 }
3067 }
3068
3069 #[async_trait]
3070 impl ExternalBackend for IngestMockBackend {
3071 fn name(&self) -> &str {
3072 "ingest-mock"
3073 }
3074 async fn create_task(&self, _: &str, _: &str, _: &[String]) -> anyhow::Result<ExternalId> {
3075 Ok(ExternalId("new".into()))
3076 }
3077 async fn get_task(&self, _: &ExternalId) -> anyhow::Result<ExternalTask> {
3078 anyhow::bail!("not implemented")
3079 }
3080 async fn list_by_status(&self, status: Status) -> anyhow::Result<Vec<ExternalTask>> {
3081 let label = status.as_label().to_string();
3082 Ok(self
3083 .tasks
3084 .iter()
3085 .find(|(l, _)| l == &label)
3086 .map(|(_, t)| t.clone())
3087 .unwrap_or_default())
3088 }
3089 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
3090 Ok(vec![])
3091 }
3092 async fn list_active_open_issues(
3093 &self,
3094 ) -> anyhow::Result<Vec<(ExternalTask, Option<Status>)>> {
3095 self.list_calls
3096 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3097 let mut result = Vec::new();
3098 for (label, tasks) in &self.tasks {
3099 let status = match label.as_str() {
3100 "status:new" => None,
3101 "status:routed" => Some(Status::Routed),
3102 "status:in_progress" => Some(Status::InProgress),
3103 "status:needs_review" => Some(Status::NeedsReview),
3104 "status:in_review" => Some(Status::InReview),
3105 "status:approved" => Some(Status::Approved),
3106 "status:blocked" => Some(Status::Blocked),
3107 _ => None,
3108 };
3109 for task in tasks {
3110 result.push((task.clone(), status));
3111 }
3112 }
3113 Ok(result)
3114 }
3115 async fn post_comment(&self, id: &ExternalId, body: &str) -> anyhow::Result<()> {
3116 self.comments
3117 .lock()
3118 .await
3119 .push((id.0.clone(), body.to_string()));
3120 Ok(())
3121 }
3122 async fn set_labels(&self, _: &ExternalId, _: &[String]) -> anyhow::Result<()> {
3123 Ok(())
3124 }
3125 async fn remove_label(&self, _: &ExternalId, _: &str) -> anyhow::Result<()> {
3126 Ok(())
3127 }
3128 async fn get_sub_issues(&self, _: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
3129 Ok(vec![])
3130 }
3131 async fn health_check(&self) -> anyhow::Result<()> {
3132 Ok(())
3133 }
3134 async fn get_mentions(&self, _: &str) -> anyhow::Result<Vec<Mention>> {
3135 Ok(vec![])
3136 }
3137 async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
3138 self.status_updates
3139 .lock()
3140 .await
3141 .push((id.0.clone(), status));
3142 Ok(())
3143 }
3144
3145 async fn has_open_issue_with_title(&self, _: &str, _: &str) -> anyhow::Result<bool> {
3146 Ok(self.dedup_result)
3147 }
3148
3149 async fn sync_to_project(
3150 &self,
3151 id: &ExternalId,
3152 status: Status,
3153 ) -> anyhow::Result<Option<String>> {
3154 self.project_syncs.lock().await.push((id.0.clone(), status));
3155 Ok(None) }
3157
3158 async fn reconcile_project_statuses(&self) -> anyhow::Result<()> {
3159 self.project_reconciliations
3160 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3161 Ok(())
3162 }
3163 }
3164
3165 fn make_ext_task(id: &str, title: &str) -> ExternalTask {
3166 ExternalTask {
3167 id: ExternalId(id.to_string()),
3168 title: title.to_string(),
3169 body: "".to_string(),
3170 state: "open".to_string(),
3171 labels: vec![],
3172 author: "user".to_string(),
3173 created_at: "2026-01-01T00:00:00Z".to_string(),
3174 updated_at: "2026-01-01T00:00:00Z".to_string(),
3175 url: format!("https://github.com/owner/repo/issues/{id}"),
3176 }
3177 }
3178
3179 #[serial(cooldown_state)]
3180 #[tokio::test]
3181 async fn ingest_upserts_tasks_into_store() {
3182 crate::engine::cooldown::reset_global_state().await;
3183 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![
3184 (Status::New, make_ext_task("1", "First issue")),
3185 (Status::InProgress, make_ext_task("2", "Second issue")),
3186 ]);
3187 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3188
3189 ingest_external_tasks(&backend, "owner/repo", &store)
3190 .await
3191 .unwrap();
3192
3193 let all = store.list_all("owner/repo").await.unwrap();
3195 assert_eq!(all.len(), 2);
3196
3197 let task1 = store
3198 .get_by_external_id("owner/repo", "1")
3199 .await
3200 .unwrap()
3201 .unwrap();
3202 assert_eq!(task1.title, "First issue");
3203 assert_eq!(task1.status, TaskStatus::New);
3204
3205 let task2 = store
3206 .get_by_external_id("owner/repo", "2")
3207 .await
3208 .unwrap()
3209 .unwrap();
3210 assert_eq!(task2.title, "Second issue");
3211 assert_eq!(task2.status, crate::store::TaskStatus::InProgress);
3212 }
3213
3214 #[serial(cooldown_state)]
3215 #[tokio::test]
3216 async fn ingest_takes_every_open_issue_whatever_its_age_or_the_last_ingest() {
3217 crate::engine::cooldown::reset_global_state().await;
3221 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3222 store
3223 .kv_set("issues_last_ingested:owner/repo", "2026-10-26T00:00:00Z")
3224 .await
3225 .unwrap();
3226
3227 let mut old = make_ext_task("73", "Filed weeks ago, never ingested");
3228 old.updated_at = "2026-01-01T00:00:00Z".to_string();
3229 let backend = IngestMockBackend::with_tasks(vec![(Status::New, old)]);
3230 let backend_dyn: Arc<dyn ExternalBackend> = backend.clone();
3231 ingest_external_tasks(&backend_dyn, "owner/repo", &store)
3232 .await
3233 .unwrap();
3234 assert!(store
3235 .get_by_external_id("owner/repo", "73")
3236 .await
3237 .unwrap()
3238 .is_some());
3239
3240 let backend = IngestMockBackend::with_tasks(vec![(
3242 Status::New,
3243 make_ext_task("78", "Opened after the restart"),
3244 )]);
3245 let backend_dyn: Arc<dyn ExternalBackend> = backend.clone();
3246 ingest_external_tasks(&backend_dyn, "owner/repo", &store)
3247 .await
3248 .unwrap();
3249 assert_eq!(
3250 backend
3251 .list_calls
3252 .load(std::sync::atomic::Ordering::Relaxed),
3253 1,
3254 "one sync is one listing"
3255 );
3256 assert!(store
3257 .get_by_external_id("owner/repo", "78")
3258 .await
3259 .unwrap()
3260 .is_some());
3261 assert_eq!(
3262 store
3263 .kv_get("issues_last_ingested:owner/repo")
3264 .await
3265 .unwrap()
3266 .as_deref(),
3267 Some("2026-10-26T00:00:00Z"),
3268 "the ingest must keep no cursor, so a stale one is never read or advanced"
3269 );
3270 }
3271
3272 #[serial(cooldown_state)]
3273 #[tokio::test]
3274 async fn ingest_updates_existing_tasks() {
3275 crate::engine::cooldown::reset_global_state().await;
3276 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![(
3277 Status::Routed,
3278 make_ext_task("42", "Updated title"),
3279 )]);
3280 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3281
3282 store
3284 .create(&crate::store::NewTask {
3285 external_id: Some("42".to_string()),
3286 repo: "owner/repo".to_string(),
3287 origin: "github".to_string(),
3288 title: "Original title".to_string(),
3289 ..Default::default()
3290 })
3291 .await
3292 .unwrap();
3293
3294 ingest_external_tasks(&backend, "owner/repo", &store)
3295 .await
3296 .unwrap();
3297
3298 let task = store
3300 .get_by_external_id("owner/repo", "42")
3301 .await
3302 .unwrap()
3303 .unwrap();
3304 assert_eq!(task.title, "Updated title");
3305 assert_eq!(task.status, crate::store::TaskStatus::Routed);
3306 }
3307
3308 #[serial(cooldown_state)]
3309 #[tokio::test]
3310 async fn ingest_closes_duplicate_titles() {
3311 crate::engine::cooldown::reset_global_state().await;
3312 use crate::store::{NewTask, TaskStore};
3313
3314 let backend = IngestMockBackend::with_tasks_and_dedup(
3315 vec![(Status::New, make_ext_task("2", "Duplicate issue"))],
3316 true,
3317 );
3318 let backend_dyn: Arc<dyn ExternalBackend> = backend.clone();
3319 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3320
3321 store
3322 .create(&NewTask {
3323 external_id: Some("1".to_string()),
3324 repo: "owner/repo".to_string(),
3325 origin: "github".to_string(),
3326 title: "Duplicate issue".to_string(),
3327 labels: vec!["status:new".to_string()],
3328 ..Default::default()
3329 })
3330 .await
3331 .unwrap();
3332
3333 ingest_external_tasks(&backend_dyn, "owner/repo", &store)
3334 .await
3335 .unwrap();
3336
3337 let all = store.list_all("owner/repo").await.unwrap();
3338 assert_eq!(all.len(), 1);
3339
3340 let comments = backend.comments.lock().await;
3341 assert_eq!(comments.len(), 1);
3342 assert!(comments[0].1.contains("Duplicate of #1"));
3343
3344 let status_updates = backend.status_updates.lock().await;
3345 assert_eq!(status_updates.len(), 1);
3346 assert_eq!(status_updates[0].0, "2");
3347 assert_eq!(status_updates[0].1, Status::Done);
3348 }
3349
3350 #[serial(cooldown_state)]
3351 #[tokio::test]
3352 async fn ingest_handles_empty_backend() {
3353 crate::engine::cooldown::reset_global_state().await;
3354 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
3355 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3356
3357 let result = ingest_external_tasks(&backend, "owner/repo", &store).await;
3359 assert!(result.is_ok());
3360
3361 let all = store.list_all("owner/repo").await.unwrap();
3362 assert!(all.is_empty());
3363 }
3364
3365 #[serial(cooldown_state)]
3366 #[tokio::test]
3367 async fn ingest_syncs_status_correctly_across_statuses() {
3368 crate::engine::cooldown::reset_global_state().await;
3369 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![
3370 (Status::New, make_ext_task("1", "New")),
3371 (Status::Routed, make_ext_task("2", "Routed")),
3372 (Status::InProgress, make_ext_task("3", "InProgress")),
3373 (Status::NeedsReview, make_ext_task("4", "NeedsReview")),
3374 (Status::InReview, make_ext_task("5", "InReview")),
3375 (Status::Blocked, make_ext_task("6", "Blocked")),
3376 ]);
3377 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3378
3379 ingest_external_tasks(&backend, "owner/repo", &store)
3380 .await
3381 .unwrap();
3382
3383 let counts = store.status_counts("owner/repo").await.unwrap();
3384 assert_eq!(counts.get("new"), Some(&1));
3385 assert_eq!(counts.get("routed"), Some(&1));
3386 assert_eq!(counts.get("in_progress"), Some(&1));
3387 assert_eq!(counts.get("needs_review"), Some(&1));
3388 assert_eq!(counts.get("in_review"), Some(&1));
3389 assert_eq!(counts.get("blocked"), Some(&1));
3390 }
3391
3392 #[serial(cooldown_state)]
3393 #[tokio::test]
3394 async fn ingest_does_not_overwrite_store_authoritative_status() {
3395 crate::engine::cooldown::reset_global_state().await;
3396 let backend: Arc<dyn ExternalBackend> =
3398 IngestMockBackend::with_tasks(vec![(Status::New, make_ext_task("99", "My task"))]);
3399 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3400
3401 let id = store
3403 .create(&crate::store::NewTask {
3404 external_id: Some("99".to_string()),
3405 repo: "owner/repo".to_string(),
3406 origin: "github".to_string(),
3407 title: "My task".to_string(),
3408 ..Default::default()
3409 })
3410 .await
3411 .unwrap();
3412 store
3413 .update_status(id, crate::store::TaskStatus::Routed)
3414 .await
3415 .unwrap();
3416
3417 ingest_external_tasks(&backend, "owner/repo", &store)
3419 .await
3420 .unwrap();
3421
3422 let task = store.get(id).await.unwrap();
3423 assert_eq!(
3424 task.status,
3425 crate::store::TaskStatus::Routed,
3426 "ingest must not overwrite store-authoritative status"
3427 );
3428 }
3429
3430 #[serial(cooldown_state)]
3431 #[tokio::test]
3432 async fn ingest_sync_to_project_called_only_for_new_tasks() {
3433 crate::engine::cooldown::reset_global_state().await;
3434 use crate::store::{NewTask, TaskStore};
3435
3436 let backend = IngestMockBackend::with_tasks(vec![
3438 (Status::New, make_ext_task("10", "Existing task")),
3439 (Status::New, make_ext_task("11", "New task")),
3440 ]);
3441 let backend_dyn: Arc<dyn ExternalBackend> = backend.clone();
3442 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3443
3444 store
3446 .create(&NewTask {
3447 external_id: Some("10".to_string()),
3448 repo: "owner/repo".to_string(),
3449 origin: "github".to_string(),
3450 title: "Existing task".to_string(),
3451 ..Default::default()
3452 })
3453 .await
3454 .unwrap();
3455
3456 ingest_external_tasks(&backend_dyn, "owner/repo", &store)
3457 .await
3458 .unwrap();
3459
3460 let syncs = backend.project_syncs.lock().await;
3461 assert_eq!(
3463 syncs.len(),
3464 1,
3465 "sync_to_project must be called exactly once (for new task only), got: {syncs:?}"
3466 );
3467 assert_eq!(syncs[0].0, "11", "sync_to_project must target the new task");
3468 }
3469
3470 #[serial(cooldown_state)]
3471 #[tokio::test]
3472 async fn ingest_handles_mixed_labeled_and_unlabeled_open_issues() {
3473 crate::engine::cooldown::reset_global_state().await;
3474 struct MixedBackend;
3478
3479 #[async_trait]
3480 impl ExternalBackend for MixedBackend {
3481 fn name(&self) -> &str {
3482 "mixed"
3483 }
3484 async fn create_task(
3485 &self,
3486 _: &str,
3487 _: &str,
3488 _: &[String],
3489 ) -> anyhow::Result<ExternalId> {
3490 Ok(ExternalId("x".into()))
3491 }
3492 async fn get_task(&self, _: &ExternalId) -> anyhow::Result<ExternalTask> {
3493 anyhow::bail!("not implemented")
3494 }
3495 async fn list_by_status(&self, _: Status) -> anyhow::Result<Vec<ExternalTask>> {
3496 Ok(vec![])
3497 }
3498 async fn list_active_open_issues(
3500 &self,
3501 ) -> anyhow::Result<Vec<(ExternalTask, Option<Status>)>> {
3502 Ok(vec![
3503 (make_ext_task("100", "Unlabeled routable"), None),
3504 (
3505 make_ext_task("101", "In progress issue"),
3506 Some(Status::InProgress),
3507 ),
3508 ])
3509 }
3510 async fn post_comment(&self, _: &ExternalId, _: &str) -> anyhow::Result<()> {
3511 Ok(())
3512 }
3513 async fn set_labels(&self, _: &ExternalId, _: &[String]) -> anyhow::Result<()> {
3514 Ok(())
3515 }
3516 async fn remove_label(&self, _: &ExternalId, _: &str) -> anyhow::Result<()> {
3517 Ok(())
3518 }
3519 async fn get_sub_issues(&self, _: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
3520 Ok(vec![])
3521 }
3522 async fn health_check(&self) -> anyhow::Result<()> {
3523 Ok(())
3524 }
3525 async fn get_mentions(&self, _: &str) -> anyhow::Result<Vec<Mention>> {
3526 Ok(vec![])
3527 }
3528 async fn update_status(&self, _: &ExternalId, _: Status) -> anyhow::Result<()> {
3529 Ok(())
3530 }
3531 async fn has_open_issue_with_title(&self, _: &str, _: &str) -> anyhow::Result<bool> {
3532 Ok(false)
3533 }
3534 async fn sync_to_project(
3535 &self,
3536 _: &ExternalId,
3537 _: Status,
3538 ) -> anyhow::Result<Option<String>> {
3539 Ok(None)
3540 }
3541 }
3542
3543 let backend: Arc<dyn ExternalBackend> = Arc::new(MixedBackend);
3544 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3545
3546 ingest_external_tasks(&backend, "owner/repo", &store)
3547 .await
3548 .unwrap();
3549
3550 let all = store.list_all("owner/repo").await.unwrap();
3551 assert_eq!(all.len(), 2, "both issues must be ingested");
3552
3553 let unlabeled = store
3554 .get_by_external_id("owner/repo", "100")
3555 .await
3556 .unwrap()
3557 .unwrap();
3558 assert_eq!(
3559 unlabeled.status,
3560 crate::store::TaskStatus::New,
3561 "unlabeled issue should start as New"
3562 );
3563
3564 let in_progress = store
3565 .get_by_external_id("owner/repo", "101")
3566 .await
3567 .unwrap()
3568 .unwrap();
3569 assert_eq!(
3570 in_progress.status,
3571 crate::store::TaskStatus::InProgress,
3572 "labeled in_progress issue must be synced to InProgress"
3573 );
3574 }
3575
3576 #[serial(cooldown_state)]
3579 #[tokio::test]
3580 async fn kv_get_prefer_store_reads_from_store() {
3581 crate::engine::cooldown::reset_global_state().await;
3582 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3583
3584 store.kv_set("k1", "store_val").await.unwrap();
3585
3586 let opt = Some(&store);
3587 let val = kv_get_prefer_store(&opt, "k1").await;
3588 assert_eq!(val.as_deref(), Some("store_val"));
3589 }
3590
3591 #[serial(cooldown_state)]
3592 #[tokio::test]
3593 async fn kv_get_prefer_store_returns_none_without_store() {
3594 crate::engine::cooldown::reset_global_state().await;
3595 let opt: Option<&Arc<TaskStore>> = None;
3596 let val = kv_get_prefer_store(&opt, "k2").await;
3597 assert_eq!(val, None);
3598 }
3599
3600 #[serial(cooldown_state)]
3601 #[tokio::test]
3602 async fn kv_set_prefer_store_writes_to_store() {
3603 crate::engine::cooldown::reset_global_state().await;
3604 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3605
3606 let opt = Some(&store);
3607 kv_set_prefer_store(&opt, "k3", "val3").await;
3608
3609 assert_eq!(store.kv_get("k3").await.unwrap().as_deref(), Some("val3"));
3610 }
3611
3612 #[serial(cooldown_state)]
3613 #[tokio::test]
3614 async fn kv_set_prefer_store_noop_without_store() {
3615 crate::engine::cooldown::reset_global_state().await;
3616 let opt: Option<&Arc<TaskStore>> = None;
3617 kv_set_prefer_store(&opt, "k4", "val4").await;
3619 }
3620
3621 #[serial(cooldown_state)]
3624 #[tokio::test]
3625 async fn try_kv_get_prefer_store_returns_value_from_store() {
3626 crate::engine::cooldown::reset_global_state().await;
3627 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3628 store.kv_set("try_k1", "try_val1").await.unwrap();
3629
3630 let opt = Some(&store);
3631 let result = try_kv_get_prefer_store(&opt, "try_k1").await;
3632 assert_eq!(result.unwrap().as_deref(), Some("try_val1"));
3633 }
3634
3635 #[serial(cooldown_state)]
3636 #[tokio::test]
3637 async fn try_kv_get_prefer_store_returns_none_for_missing_key() {
3638 crate::engine::cooldown::reset_global_state().await;
3639 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3640 let opt = Some(&store);
3641
3642 let result = try_kv_get_prefer_store(&opt, "nonexistent_key").await;
3643 assert_eq!(result.unwrap(), None); }
3645
3646 #[serial(cooldown_state)]
3647 #[tokio::test]
3648 async fn try_kv_get_prefer_store_returns_none_without_store() {
3649 crate::engine::cooldown::reset_global_state().await;
3650 let opt: Option<&Arc<TaskStore>> = None;
3651 let result = try_kv_get_prefer_store(&opt, "try_k2").await;
3652 assert_eq!(result.unwrap(), None);
3653 }
3654
3655 #[serial(cooldown_state)]
3656 #[tokio::test]
3657 async fn try_kv_set_prefer_store_writes_to_store() {
3658 crate::engine::cooldown::reset_global_state().await;
3659 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3660 let opt = Some(&store);
3661
3662 let result = try_kv_set_prefer_store(&opt, "try_k3", "try_val3").await;
3663 assert!(result.is_ok());
3664 assert_eq!(
3665 store.kv_get("try_k3").await.unwrap().as_deref(),
3666 Some("try_val3")
3667 );
3668 }
3669
3670 #[serial(cooldown_state)]
3671 #[tokio::test]
3672 async fn try_kv_set_prefer_store_ok_without_store() {
3673 crate::engine::cooldown::reset_global_state().await;
3674 let opt: Option<&Arc<TaskStore>> = None;
3675 let result = try_kv_set_prefer_store(&opt, "try_k4", "try_val4").await;
3677 assert!(result.is_ok());
3678 }
3679
3680 #[serial(cooldown_state)]
3683 #[test]
3684 fn dispatching_set_blocks_duplicate_processing() {
3685 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
3686
3687 let repo = "owner/repo";
3688 let task_id = "42";
3689 let dispatch_key = format!("{}/{}", repo, task_id);
3690
3691 assert!(!dispatching.contains_key(&dispatch_key));
3693
3694 dispatching.insert(dispatch_key.clone(), task_id.to_string());
3696
3697 assert!(
3699 dispatching.contains_key(&dispatch_key),
3700 "task should be locked while dispatching"
3701 );
3702
3703 dispatching.remove(&dispatch_key);
3705
3706 assert!(
3708 !dispatching.contains_key(&dispatch_key),
3709 "task should be unlocked after review completes"
3710 );
3711 }
3712
3713 #[serial(cooldown_state)]
3714 #[test]
3715 fn dispatching_set_does_not_block_other_tasks() {
3716 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
3717
3718 let repo = "owner/repo";
3719
3720 let key_42 = format!("{}/42", repo);
3722 dispatching.insert(key_42.clone(), "42".to_string());
3723
3724 let key_43 = format!("{}/43", repo);
3726 assert!(
3727 dispatching.contains_key(&key_42),
3728 "task 42 should be locked"
3729 );
3730 assert!(
3731 !dispatching.contains_key(&key_43),
3732 "task 43 should not be locked"
3733 );
3734 }
3735
3736 #[serial(cooldown_state)]
3749 #[tokio::test]
3750 async fn dispatch_key_held_during_review_agent_execution() {
3751 crate::engine::cooldown::reset_global_state().await;
3752 use tokio::sync::oneshot;
3753
3754 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
3755
3756 let dispatch_key = "owner/repo/42".to_string();
3757
3758 dispatching.insert(dispatch_key.clone(), "42".to_string());
3760
3761 let (agent_started_tx, agent_started_rx) = oneshot::channel::<()>();
3762 let (check_done_tx, check_done_rx) = oneshot::channel::<()>();
3763
3764 let dispatching_agent = Arc::clone(&dispatching);
3765 let key_agent = dispatch_key.clone();
3766
3767 let review_task = tokio::spawn(async move {
3769 agent_started_tx.send(()).unwrap();
3770 check_done_rx.await.unwrap();
3772 dispatching_agent.remove(&key_agent);
3774 });
3775
3776 agent_started_rx.await.unwrap();
3779 assert!(
3780 dispatching.contains_key(&dispatch_key),
3781 "dispatch_key must be visible in the dispatching set while the review \
3782 agent is running — without the a6d8b9a fix, review_open_prs would see \
3783 the key absent and re-dispatch the task, silently dropping \
3784 CHANGES_REQUESTED feedback"
3785 );
3786
3787 check_done_tx.send(()).unwrap();
3789 review_task.await.unwrap();
3790
3791 assert!(
3793 !dispatching.contains_key(&dispatch_key),
3794 "dispatch_key must be removed from the dispatching set after the \
3795 review agent completes so subsequent sync ticks can process the task"
3796 );
3797 }
3798
3799 #[serial(cooldown_state)]
3812 #[tokio::test]
3813 async fn dispatch_guard_releases_key_on_panic() {
3814 crate::engine::cooldown::reset_global_state().await;
3815 use crate::engine::dispatch_guard::DispatchGuard;
3816
3817 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
3818 let key = "owner/repo/42".to_string();
3819
3820 dispatching.insert(key.clone(), "42".to_string());
3824
3825 let guard = DispatchGuard::new(Arc::clone(&dispatching), key.clone());
3827
3828 let handle = tokio::spawn(async move {
3830 let _guard = guard; panic!("simulated panic inside review_and_merge");
3832 });
3833
3834 let _ = handle.await;
3836
3837 assert!(
3839 !dispatching.contains_key(&key),
3840 "dispatch key must be removed from the dispatching set even when the \
3841 spawned review task panics — without DispatchGuard the key leaks and \
3842 the task gets stuck in a permanent review loop"
3843 );
3844 }
3845
3846 #[serial(cooldown_state)]
3847 #[tokio::test]
3848 async fn sync_tick_reconciles_board_without_ingesting_or_transitioning_tasks() {
3849 crate::engine::cooldown::reset_global_state().await;
3850 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3851 let mock = IngestMockBackend::with_tasks(vec![]);
3852 let backend: Arc<dyn ExternalBackend> = mock.clone();
3853 let task_manager = Arc::new(TaskManager::with_store(
3854 backend.clone(),
3855 store.clone(),
3856 "owner/repo".to_string(),
3857 ));
3858 let dispatching = Arc::new(DashMap::new());
3859 sync_tick(
3860 &backend,
3861 &Arc::new(TmuxManager::new()),
3862 "owner/repo",
3863 &EngineConfig::default(),
3864 &Arc::new(RwLock::new(crate::engine::router::Router::new_for_test(
3865 crate::engine::router::RouterConfig::default(),
3866 vec!["claude".to_string()],
3867 ))),
3868 &task_manager,
3869 &store,
3870 &dispatching,
3871 &Arc::new(DashSet::new()),
3872 )
3873 .await
3874 .unwrap();
3875 assert_eq!(
3876 mock.project_reconciliations
3877 .load(std::sync::atomic::Ordering::Relaxed),
3878 1
3879 );
3880 assert!(mock.project_syncs.lock().await.is_empty());
3881 assert!(mock.status_updates.lock().await.is_empty());
3882 assert!(dispatching.is_empty());
3883 assert!(store
3884 .existing_external_ids("owner/repo")
3885 .await
3886 .unwrap()
3887 .is_empty());
3888 }
3889
3890 #[serial(cooldown_state)]
3891 #[tokio::test]
3892 async fn needs_review_refire_increments_and_fires_when_old_enough() {
3893 crate::engine::cooldown::reset_global_state().await;
3894 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3895 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
3896 let task_manager = Arc::new(TaskManager::with_store(
3897 backend,
3898 store.clone(),
3899 "owner/repo".to_string(),
3900 ));
3901 let router = reviewer_router();
3902 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
3903
3904 let id = store
3905 .upsert_external(&crate::store::UpsertExternal {
3906 repo: "owner/repo",
3907 ext_id: "201",
3908 title: "Needs review old",
3909 body: "",
3910 author: "",
3911 url: "",
3912 labels: &[],
3913 origin: "github",
3914 })
3915 .await
3916 .unwrap();
3917 store
3918 .update_status(id, crate::store::TaskStatus::NeedsReview)
3919 .await
3920 .unwrap();
3921 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
3922 .bind(id)
3923 .execute(store.pool())
3924 .await
3925 .unwrap();
3926
3927 let t = store.get(id).await.unwrap();
3928 assert_eq!(t.needs_review_refires, 0);
3929
3930 refire_and_escalate_stale_needs_review_global(&task_manager, &store, &router, &dispatching)
3931 .await
3932 .unwrap();
3933
3934 let t2 = store.get(id).await.unwrap();
3935 assert_eq!(t2.needs_review_refires, 1);
3936 assert_eq!(t2.status, crate::store::TaskStatus::NeedsReview);
3937 }
3938
3939 #[serial(cooldown_state)]
3940 #[tokio::test]
3941 async fn needs_review_backoff_delays_fire_but_increments_counter() {
3942 crate::engine::cooldown::reset_global_state().await;
3943 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3944 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
3945 let task_manager = Arc::new(TaskManager::with_store(
3946 backend,
3947 store.clone(),
3948 "owner/repo".to_string(),
3949 ));
3950 let router = reviewer_router();
3951 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
3952
3953 let id = store
3954 .upsert_external(&crate::store::UpsertExternal {
3955 repo: "owner/repo",
3956 ext_id: "202",
3957 title: "Needs review backoff",
3958 body: "",
3959 author: "",
3960 url: "",
3961 labels: &[],
3962 origin: "github",
3963 })
3964 .await
3965 .unwrap();
3966 store
3967 .update_status(id, crate::store::TaskStatus::NeedsReview)
3968 .await
3969 .unwrap();
3970
3971 store
3972 .set_fields(id, &[("needs_review_refires", serde_json::json!(1))])
3973 .await
3974 .unwrap();
3975
3976 let now_minus_1 = (chrono::Utc::now() - chrono::Duration::minutes(1))
3978 .format("%Y-%m-%dT%H:%M:%SZ")
3979 .to_string();
3980 sqlx::query("UPDATE tasks SET updated_at = ? WHERE id = ?")
3981 .bind(now_minus_1.clone())
3982 .bind(id)
3983 .execute(store.pool())
3984 .await
3985 .unwrap();
3986
3987 let before = store.get(id).await.unwrap();
3988 assert_eq!(before.needs_review_refires, 1);
3989
3990 refire_and_escalate_stale_needs_review_global(&task_manager, &store, &router, &dispatching)
3991 .await
3992 .unwrap();
3993
3994 let after = store.get(id).await.unwrap();
3995 assert_eq!(after.needs_review_refires, 1);
3997 assert_eq!(after.updated_at, now_minus_1);
3998 assert_eq!(after.status, crate::store::TaskStatus::NeedsReview);
3999 }
4000
4001 fn reviewer_router() -> crate::engine::router::Router {
4003 let mut config = crate::engine::router::RouterConfig::default();
4004 config.model_map.insert(
4005 "review".to_string(),
4006 std::collections::HashMap::from([("claude".to_string(), vec!["sonnet".to_string()])]),
4007 );
4008 crate::engine::router::Router::new_for_test(config, vec!["claude".to_string()])
4009 }
4010
4011 #[serial(cooldown_state)]
4012 #[tokio::test]
4013 async fn needs_review_escalates_to_blocked_after_max_refires() {
4014 crate::engine::cooldown::reset_global_state().await;
4015 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4016 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
4017 let task_manager = Arc::new(TaskManager::with_store(
4018 backend,
4019 store.clone(),
4020 "owner/repo".to_string(),
4021 ));
4022 let router = reviewer_router();
4023 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4024
4025 let id = store
4026 .upsert_external(&crate::store::UpsertExternal {
4027 repo: "owner/repo",
4028 ext_id: "203",
4029 title: "Needs review escalate",
4030 body: "",
4031 author: "",
4032 url: "",
4033 labels: &[],
4034 origin: "github",
4035 })
4036 .await
4037 .unwrap();
4038 store
4039 .update_status(id, crate::store::TaskStatus::NeedsReview)
4040 .await
4041 .unwrap();
4042
4043 store
4045 .set_fields(id, &[("needs_review_refires", serde_json::json!(5))])
4046 .await
4047 .unwrap();
4048 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
4049 .bind(id)
4050 .execute(store.pool())
4051 .await
4052 .unwrap();
4053
4054 refire_and_escalate_stale_needs_review_global(&task_manager, &store, &router, &dispatching)
4055 .await
4056 .unwrap();
4057
4058 let after = store.get(id).await.unwrap();
4059 assert_eq!(after.status, crate::store::TaskStatus::Blocked);
4060 assert!(after.block_reason.is_some());
4061 }
4062
4063 #[serial(cooldown_state)]
4066 #[tokio::test]
4067 async fn needs_review_allows_max_refires_before_escalation() {
4068 crate::engine::cooldown::reset_global_state().await;
4069 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4070 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
4071 let task_manager = Arc::new(TaskManager::with_store(
4072 backend,
4073 store.clone(),
4074 "owner/repo".to_string(),
4075 ));
4076 let router = reviewer_router();
4077 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4078
4079 let id = store
4080 .upsert_external(&crate::store::UpsertExternal {
4081 repo: "owner/repo",
4082 ext_id: "204",
4083 title: "Max refires test",
4084 body: "",
4085 author: "",
4086 url: "",
4087 labels: &[],
4088 origin: "github",
4089 })
4090 .await
4091 .unwrap();
4092 store
4093 .update_status(id, crate::store::TaskStatus::NeedsReview)
4094 .await
4095 .unwrap();
4096
4097 store
4099 .set_fields(id, &[("needs_review_refires", serde_json::json!(4))])
4100 .await
4101 .unwrap();
4102 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
4103 .bind(id)
4104 .execute(store.pool())
4105 .await
4106 .unwrap();
4107
4108 refire_and_escalate_stale_needs_review_global(&task_manager, &store, &router, &dispatching)
4109 .await
4110 .unwrap();
4111
4112 let after = store.get(id).await.unwrap();
4113 assert_eq!(after.status, crate::store::TaskStatus::NeedsReview);
4114 assert_eq!(after.needs_review_refires, 5);
4115 assert!(after.block_reason.is_none());
4116 }
4117
4118 fn router_with_healthy_review_agent() -> crate::engine::router::Router {
4121 use crate::engine::router::{Router, RouterConfig};
4122 let mut config = RouterConfig::default();
4123 config
4124 .model_map
4125 .entry("review".to_string())
4126 .or_default()
4127 .insert("claude".to_string(), vec!["sonnet".to_string()]);
4128 let mut router = Router::new(config);
4129 router.available_agents = vec!["claude".to_string()];
4130 router
4131 }
4132
4133 #[serial(cooldown_state)]
4138 #[tokio::test]
4139 async fn global_refire_sweep_refires_stale_task_from_inactive_repo() {
4140 crate::engine::cooldown::reset_global_state().await;
4141 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4142 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
4143 let task_manager = Arc::new(TaskManager::with_store(
4145 backend,
4146 store.clone(),
4147 "active/repo".to_string(),
4148 ));
4149 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4150 let router = router_with_healthy_review_agent();
4151
4152 let id = store
4153 .upsert_external(&crate::store::UpsertExternal {
4154 repo: "inactive/repo",
4155 ext_id: "490",
4156 title: "Stale needs_review from inactive repo",
4157 body: "",
4158 author: "",
4159 url: "",
4160 labels: &[],
4161 origin: "github",
4162 })
4163 .await
4164 .unwrap();
4165 store
4166 .update_status(id, crate::store::TaskStatus::NeedsReview)
4167 .await
4168 .unwrap();
4169 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
4170 .bind(id)
4171 .execute(store.pool())
4172 .await
4173 .unwrap();
4174
4175 refire_and_escalate_stale_needs_review_global(&task_manager, &store, &router, &dispatching)
4176 .await
4177 .unwrap();
4178
4179 let task = store.get(id).await.unwrap();
4180 assert_eq!(
4181 task.needs_review_refires, 1,
4182 "cross-repo task must be refired"
4183 );
4184 assert_eq!(task.status, crate::store::TaskStatus::NeedsReview);
4185 }
4186
4187 #[serial(cooldown_state)]
4190 #[tokio::test]
4191 async fn global_refire_sweep_escalates_cross_repo_task_to_blocked() {
4192 crate::engine::cooldown::reset_global_state().await;
4193 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4194 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
4195 let task_manager = Arc::new(TaskManager::with_store(
4196 backend,
4197 store.clone(),
4198 "active/repo".to_string(),
4199 ));
4200 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4201 let router = router_with_healthy_review_agent();
4202
4203 let id = store
4204 .upsert_external(&crate::store::UpsertExternal {
4205 repo: "inactive/repo",
4206 ext_id: "493",
4207 title: "Exhausted refires from inactive repo",
4208 body: "",
4209 author: "",
4210 url: "",
4211 labels: &[],
4212 origin: "github",
4213 })
4214 .await
4215 .unwrap();
4216 store
4217 .update_status(id, crate::store::TaskStatus::NeedsReview)
4218 .await
4219 .unwrap();
4220 store
4221 .set_fields(id, &[("needs_review_refires", serde_json::json!(5))])
4222 .await
4223 .unwrap();
4224 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
4225 .bind(id)
4226 .execute(store.pool())
4227 .await
4228 .unwrap();
4229
4230 refire_and_escalate_stale_needs_review_global(&task_manager, &store, &router, &dispatching)
4231 .await
4232 .unwrap();
4233
4234 let task = store.get(id).await.unwrap();
4235 assert_eq!(task.status, crate::store::TaskStatus::Blocked);
4236 assert_eq!(
4237 task.block_reason.as_deref(),
4238 Some("review agent rebroadcast escalated after repeated retries")
4239 );
4240 }
4241
4242 #[serial(cooldown_state)]
4247 #[tokio::test]
4248 async fn global_refire_sweep_skips_when_all_review_agents_cooled() {
4249 crate::engine::cooldown::reset_global_state().await;
4250 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4251 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
4252 let task_manager = Arc::new(TaskManager::with_store(
4253 backend,
4254 store.clone(),
4255 "active/repo".to_string(),
4256 ));
4257 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4258 let router = crate::engine::router::Router::new_for_test(
4261 crate::engine::router::RouterConfig::default(),
4262 vec!["claude".to_string()],
4263 );
4264
4265 let id = store
4266 .upsert_external(&crate::store::UpsertExternal {
4267 repo: "inactive/repo",
4268 ext_id: "494",
4269 title: "Stale needs_review while agents cooled",
4270 body: "",
4271 author: "",
4272 url: "",
4273 labels: &[],
4274 origin: "github",
4275 })
4276 .await
4277 .unwrap();
4278 store
4279 .update_status(id, crate::store::TaskStatus::NeedsReview)
4280 .await
4281 .unwrap();
4282 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
4283 .bind(id)
4284 .execute(store.pool())
4285 .await
4286 .unwrap();
4287
4288 refire_and_escalate_stale_needs_review_global(&task_manager, &store, &router, &dispatching)
4289 .await
4290 .unwrap();
4291
4292 let task = store.get(id).await.unwrap();
4293 assert_eq!(
4294 task.needs_review_refires, 0,
4295 "counter must not be touched while cooled"
4296 );
4297 assert_eq!(task.status, crate::store::TaskStatus::NeedsReview);
4298 }
4299
4300 #[serial(cooldown_state)]
4303 #[tokio::test]
4304 async fn global_refire_sweep_skips_when_no_reviewer_available() {
4305 crate::engine::cooldown::reset_global_state().await;
4306 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4307 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
4308 let task_manager = Arc::new(TaskManager::with_store(
4309 backend,
4310 store.clone(),
4311 "active/repo".to_string(),
4312 ));
4313 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4314 let router = crate::engine::router::Router::new_for_test(
4315 crate::engine::router::RouterConfig::default(),
4316 vec![],
4317 );
4318
4319 let id = store
4320 .upsert_external(&crate::store::UpsertExternal {
4321 repo: "some/repo",
4322 ext_id: "495",
4323 title: "needs_review with no reviewer",
4324 body: "",
4325 author: "",
4326 url: "",
4327 labels: &[],
4328 origin: "github",
4329 })
4330 .await
4331 .unwrap();
4332 store
4333 .update_status(id, crate::store::TaskStatus::NeedsReview)
4334 .await
4335 .unwrap();
4336 store
4337 .set_fields(id, &[("needs_review_refires", serde_json::json!(5))])
4338 .await
4339 .unwrap();
4340 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
4341 .bind(id)
4342 .execute(store.pool())
4343 .await
4344 .unwrap();
4345
4346 refire_and_escalate_stale_needs_review_global(&task_manager, &store, &router, &dispatching)
4347 .await
4348 .unwrap();
4349
4350 let task = store.get(id).await.unwrap();
4351 assert_eq!(task.status, crate::store::TaskStatus::NeedsReview);
4352 assert_eq!(task.needs_review_refires, 5);
4353 }
4354
4355 #[serial(cooldown_state)]
4361 #[tokio::test]
4362 async fn auto_recover_rebroadcast_blocked_task_after_review_cycle() {
4363 crate::engine::cooldown::reset_global_state().await;
4364 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4365 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
4366 let task_manager = Arc::new(TaskManager::with_store(
4367 backend,
4368 store.clone(),
4369 "owner/repo".to_string(),
4370 ));
4371 let router = router_with_healthy_review_agent();
4372
4373 let id = store
4374 .upsert_external(&crate::store::UpsertExternal {
4375 repo: "owner/repo",
4376 ext_id: "511",
4377 title: "Rebroadcast blocked after review",
4378 body: "",
4379 author: "",
4380 url: "",
4381 labels: &[],
4382 origin: "github",
4383 })
4384 .await
4385 .unwrap();
4386 store
4387 .update_status(id, crate::store::TaskStatus::Blocked)
4388 .await
4389 .unwrap();
4390 store
4391 .set_block_reason(
4392 id,
4393 Some("review agent rebroadcast escalated after repeated retries"),
4394 )
4395 .await
4396 .unwrap();
4397 store
4398 .set_fields(id, &[("review_cycles", serde_json::json!(1))])
4399 .await
4400 .unwrap();
4401 store
4402 .set_fields(id, &[("needs_review_refires", serde_json::json!(6))])
4403 .await
4404 .unwrap();
4405 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
4406 .bind(id)
4407 .execute(store.pool())
4408 .await
4409 .unwrap();
4410
4411 auto_recover_rebroadcast_blocked_tasks_global(&router, &task_manager, &store)
4412 .await
4413 .unwrap();
4414
4415 let task = store.get(id).await.unwrap();
4416 assert_eq!(
4417 task.status,
4418 crate::store::TaskStatus::NeedsReview,
4419 "blocked rebroadcast task must be recovered to needs_review"
4420 );
4421 assert!(
4422 task.block_reason.is_none(),
4423 "block_reason must be cleared on recovery"
4424 );
4425 assert_eq!(
4426 task.needs_review_refires, 0,
4427 "needs_review_refires must be reset to 0 on recovery"
4428 );
4429 }
4430
4431 #[serial(cooldown_state)]
4434 #[tokio::test]
4435 async fn stale_in_review_recovery_skips_fresh_tasks() {
4436 crate::engine::cooldown::reset_global_state().await;
4437 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4438 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
4439 let task_manager = Arc::new(TaskManager::with_store(
4440 backend.clone(),
4441 store.clone(),
4442 "owner/repo".to_string(),
4443 ));
4444 let tmux = Arc::new(TmuxManager::new());
4445 let router = Arc::new(RwLock::new(crate::engine::router::Router::new_for_test(
4446 crate::engine::router::RouterConfig::default(),
4447 vec!["claude".to_string()],
4448 )));
4449 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4450 let auto_merge_in_flight: Arc<DashSet<String>> = Arc::new(DashSet::new());
4451
4452 let id = store
4453 .upsert_external(&crate::store::UpsertExternal {
4454 repo: "owner/repo",
4455 ext_id: "66",
4456 title: "Fresh review",
4457 body: "",
4458 author: "",
4459 url: "",
4460 labels: &[],
4461 origin: "github",
4462 })
4463 .await
4464 .unwrap();
4465 store
4466 .update_status(id, crate::store::TaskStatus::InReview)
4467 .await
4468 .unwrap();
4469 store
4470 .set_fields(id, &[("branch", serde_json::json!("feature/66"))])
4471 .await
4472 .unwrap();
4473 sync_tick(
4476 &backend,
4477 &tmux,
4478 "owner/repo",
4479 &EngineConfig::default(),
4480 &router,
4481 &task_manager,
4482 &store,
4483 &dispatching,
4484 &auto_merge_in_flight,
4485 )
4486 .await
4487 .unwrap();
4488
4489 let task = store.get(id).await.unwrap();
4491 assert_eq!(task.status, crate::store::TaskStatus::InReview);
4492 }
4493
4494 #[serial(cooldown_state)]
4495 #[tokio::test]
4496 async fn auto_unblock_routes_recoverable_failure() {
4497 crate::engine::cooldown::reset_global_state().await;
4498 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4499 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
4500 let task_manager = Arc::new(TaskManager::with_store(
4501 backend,
4502 store.clone(),
4503 "owner/repo".to_string(),
4504 ));
4505 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4506
4507 let id = store
4508 .upsert_external(&crate::store::UpsertExternal {
4509 repo: "owner/repo",
4510 ext_id: "77",
4511 title: "Blocked task",
4512 body: "",
4513 author: "",
4514 url: "",
4515 labels: &[],
4516 origin: "github",
4517 })
4518 .await
4519 .unwrap();
4520 store
4521 .update_status(id, crate::store::TaskStatus::Blocked)
4522 .await
4523 .unwrap();
4524 store
4525 .set_fields(
4526 id,
4527 &[
4528 ("agent", serde_json::json!("codex")),
4529 ("model", serde_json::json!("gpt-5")),
4530 ],
4531 )
4532 .await
4533 .unwrap();
4534
4535 let run_id = store
4536 .start_run(&crate::store::StartRun {
4537 task_id: id,
4538 attempt: 1,
4539 run_type: "agent",
4540 agent: "codex",
4541 model: "gpt-5",
4542 command: "codex --model gpt-5",
4543 prompt: "do thing",
4544 })
4545 .await
4546 .unwrap();
4547 store
4548 .complete_run(&crate::store::CompleteRun {
4549 run_id,
4550 exit_code: Some(1),
4551 stdout: "",
4552 stderr: "",
4553 parsed: "",
4554 outcome: "rate_limit",
4555 error: "rate limit exceeded",
4556 tokens: RunTokenUsage::default(),
4557 })
4558 .await
4559 .unwrap();
4560
4561 auto_unblock_blocked_tasks(
4562 "owner/repo",
4563 &task_manager,
4564 &store,
4565 &dispatching,
4566 &Arc::new(DashSet::new()),
4567 )
4568 .await
4569 .unwrap();
4570
4571 let task = store.get(id).await.unwrap();
4572 assert_eq!(task.status, TaskStatus::New);
4573 assert!(task.agent.is_none());
4574 assert!(task.model.is_none());
4575 assert_eq!(task.auto_unblock_count, 1);
4576 assert!(!task.auto_unblock_last_at.is_empty());
4577 assert_eq!(task.auto_unblock_last_reason, "RateLimit");
4578 }
4579
4580 #[serial(cooldown_state)]
4581 #[tokio::test]
4582 async fn auto_unblock_skips_manual_block() {
4583 crate::engine::cooldown::reset_global_state().await;
4584 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4585 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
4586 let task_manager = Arc::new(TaskManager::with_store(
4587 backend,
4588 store.clone(),
4589 "owner/repo".to_string(),
4590 ));
4591 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4592
4593 let id = store
4594 .upsert_external(&crate::store::UpsertExternal {
4595 repo: "owner/repo",
4596 ext_id: "88",
4597 title: "Manually blocked",
4598 body: "",
4599 author: "",
4600 url: "",
4601 labels: &[],
4602 origin: "github",
4603 })
4604 .await
4605 .unwrap();
4606 store
4607 .update_status(id, crate::store::TaskStatus::Blocked)
4608 .await
4609 .unwrap();
4610 store
4611 .set_block_reason(id, Some("waiting on input"))
4612 .await
4613 .unwrap();
4614
4615 let run_id = store
4616 .start_run(&crate::store::StartRun {
4617 task_id: id,
4618 attempt: 1,
4619 run_type: "agent",
4620 agent: "codex",
4621 model: "gpt-5",
4622 command: "codex --model gpt-5",
4623 prompt: "do thing",
4624 })
4625 .await
4626 .unwrap();
4627 store
4628 .complete_run(&crate::store::CompleteRun {
4629 run_id,
4630 exit_code: Some(1),
4631 stdout: "",
4632 stderr: "",
4633 parsed: "",
4634 outcome: "rate_limit",
4635 error: "rate limit exceeded",
4636 tokens: RunTokenUsage::default(),
4637 })
4638 .await
4639 .unwrap();
4640
4641 auto_unblock_blocked_tasks(
4642 "owner/repo",
4643 &task_manager,
4644 &store,
4645 &dispatching,
4646 &Arc::new(DashSet::new()),
4647 )
4648 .await
4649 .unwrap();
4650
4651 let task = store.get(id).await.unwrap();
4652 assert_eq!(task.status, crate::store::TaskStatus::Blocked);
4653 assert_eq!(task.auto_unblock_count, 0);
4654 }
4655
4656 #[serial(cooldown_state)]
4659 #[test]
4660 fn failure_category_as_str() {
4661 assert_eq!(FailureCategory::RateLimit.as_str(), "RateLimit");
4664 assert_eq!(FailureCategory::Timeout.as_str(), "Timeout");
4665 assert_eq!(
4666 FailureCategory::ModelUnavailable.as_str(),
4667 "ModelUnavailable"
4668 );
4669 assert_eq!(FailureCategory::MaxAttempts.as_str(), "MaxAttempts");
4670 assert_eq!(FailureCategory::Unknown.as_str(), "Unknown");
4671 assert_eq!(FailureCategory::SilentExit0.as_str(), "SilentExit0");
4672 assert_eq!(FailureCategory::ConnectionError.as_str(), "ConnectionError");
4673 assert_eq!(FailureCategory::CliFlagError.as_str(), "CliFlagError");
4674 assert_eq!(
4675 FailureCategory::AllAgentsExhausted.as_str(),
4676 "AllAgentsExhausted"
4677 );
4678 assert_eq!(FailureCategory::ParseError.as_str(), "ParseError");
4679 assert_eq!(FailureCategory::FalseFailure.as_str(), "FalseFailure");
4680 assert_eq!(FailureCategory::PushFailed.as_str(), "PushFailed");
4681 assert_eq!(FailureCategory::PrCreateFailed.as_str(), "PrCreateFailed");
4682 }
4683
4684 #[serial(cooldown_state)]
4687 #[test]
4688 fn classify_failure_model_unavailable_phrase_is_recoverable() {
4689 let category = classify_failure("model unavailable (anthropic/claude-sonnet-4-6): Model not found: anthropic/claude-sonnet-4-6", "error");
4690 assert_eq!(
4691 category,
4692 FailureCategory::ModelUnavailable,
4693 "\"model unavailable\" error must classify as ModelUnavailable"
4694 );
4695 assert!(
4696 category.is_recoverable(),
4697 "ModelUnavailable must be recoverable so auto-unblock can re-route"
4698 );
4699 }
4700
4701 #[serial(cooldown_state)]
4703 #[test]
4704 fn classify_failure_kimi_billing_cycle_is_credit_exhausted_not_rate_limit() {
4705 let error = "kimi rate limit: Failed to authenticate. API Error: 403 You've reached \
4712 your usage limit for this billing cycle. Your quota will be refreshed soon.";
4713 let category = classify_failure(error, "rate_limit");
4714 assert_eq!(
4715 category,
4716 FailureCategory::CreditExhausted,
4717 "billing-cycle exhaustion must classify as CreditExhausted, not RateLimit"
4718 );
4719 assert!(category.is_recoverable());
4720 }
4721
4722 #[serial(cooldown_state)]
4723 #[test]
4724 fn classify_failure_billing_cycle_exhausted_outcome_is_credit_exhausted() {
4725 let category = classify_failure("some billing message", "billing_cycle_exhausted");
4728 assert_eq!(category, FailureCategory::CreditExhausted);
4729 }
4730
4731 #[serial(cooldown_state)]
4732 #[test]
4733 fn classify_failure_generic_rate_limit_is_still_rate_limit() {
4734 let category = classify_failure("429 Too Many Requests", "rate_limit");
4737 assert_eq!(category, FailureCategory::RateLimit);
4738 }
4739
4740 #[serial(cooldown_state)]
4742 #[test]
4743 fn classify_failure_pr_create_failed_specific_patterns() {
4744 assert_eq!(
4746 classify_failure("create pr failed", ""),
4747 FailureCategory::PrCreateFailed
4748 );
4749 assert_eq!(
4750 classify_failure("failed to create pull request", ""),
4751 FailureCategory::PrCreateFailed
4752 );
4753 assert_eq!(
4754 classify_failure("pull request creation failed", ""),
4755 FailureCategory::PrCreateFailed
4756 );
4757
4758 assert_ne!(
4760 classify_failure("network failed while fetching pull request status", ""),
4761 FailureCategory::PrCreateFailed
4762 );
4763 assert_ne!(
4764 classify_failure("connection failed, retrying pull request check", ""),
4765 FailureCategory::PrCreateFailed
4766 );
4767 assert_ne!(
4768 classify_failure("pull request created but failed to merge", ""),
4769 FailureCategory::PrCreateFailed
4770 );
4771 }
4772
4773 #[serial(cooldown_state)]
4774 #[test]
4775 fn classify_failure_model_not_found_is_recoverable() {
4776 let category = classify_failure("Model not found: gpt-5-ultra", "error");
4777 assert_eq!(
4778 category,
4779 FailureCategory::ModelUnavailable,
4780 "\"model not found\" error must classify as ModelUnavailable"
4781 );
4782 assert!(category.is_recoverable());
4783 }
4784
4785 #[serial(cooldown_state)]
4786 #[test]
4787 fn classify_failure_model_does_not_exist_is_recoverable() {
4788 let category = classify_failure("The model `claude-opus-99` does not exist", "error");
4789 assert_eq!(category, FailureCategory::ModelUnavailable);
4790 assert!(category.is_recoverable());
4791 }
4792
4793 #[serial(cooldown_state)]
4794 #[tokio::test]
4795 async fn auto_unblock_routes_task_blocked_by_model_unavailable() {
4796 crate::engine::cooldown::reset_global_state().await;
4797 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4798 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
4799 let task_manager = Arc::new(TaskManager::with_store(
4800 backend,
4801 store.clone(),
4802 "owner/repo".to_string(),
4803 ));
4804 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4805
4806 let id = store
4807 .upsert_external(&crate::store::UpsertExternal {
4808 repo: "owner/repo",
4809 ext_id: "1202",
4810 title: "Task blocked by model unavailable",
4811 body: "",
4812 author: "",
4813 url: "",
4814 labels: &[],
4815 origin: "github",
4816 })
4817 .await
4818 .unwrap();
4819 store
4820 .update_status(id, crate::store::TaskStatus::Blocked)
4821 .await
4822 .unwrap();
4823
4824 let run_id = store
4825 .start_run(&crate::store::StartRun {
4826 task_id: id,
4827 attempt: 1,
4828 run_type: "agent",
4829 agent: "opencode",
4830 model: "anthropic/claude-sonnet-4-6",
4831 command: "opencode",
4832 prompt: "fix the bug",
4833 })
4834 .await
4835 .unwrap();
4836 store
4837 .complete_run(&crate::store::CompleteRun {
4838 run_id,
4839 exit_code: Some(1),
4840 stdout: "",
4841 stderr: "",
4842 parsed: "",
4843 outcome: "error",
4844 error: "model unavailable (anthropic/claude-sonnet-4-6): Model not found: anthropic/claude-sonnet-4-6",
4845 tokens: RunTokenUsage::default(),
4846 })
4847 .await
4848 .unwrap();
4849
4850 auto_unblock_blocked_tasks(
4851 "owner/repo",
4852 &task_manager,
4853 &store,
4854 &dispatching,
4855 &Arc::new(DashSet::new()),
4856 )
4857 .await
4858 .unwrap();
4859
4860 let task = store.get(id).await.unwrap();
4861 assert_eq!(
4862 task.status,
4863 TaskStatus::New,
4864 "task blocked by model unavailable must be auto-unblocked and re-routed"
4865 );
4866 assert_eq!(task.auto_unblock_count, 1);
4867 assert_eq!(task.auto_unblock_last_reason, "ModelUnavailable");
4868 }
4869
4870 #[serial(cooldown_state)]
4873 #[tokio::test]
4874 async fn auto_unblock_resets_count_when_reason_changes() {
4875 crate::engine::cooldown::reset_global_state().await;
4876 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4880 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
4881 let task_manager = Arc::new(TaskManager::with_store(
4882 backend,
4883 store.clone(),
4884 "owner/repo".to_string(),
4885 ));
4886 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4887
4888 let id = store
4889 .upsert_external(&crate::store::UpsertExternal {
4890 repo: "owner/repo",
4891 ext_id: "1227",
4892 title: "Task with different failure reason",
4893 body: "",
4894 author: "",
4895 url: "",
4896 labels: &[],
4897 origin: "github",
4898 })
4899 .await
4900 .unwrap();
4901 store
4902 .update_status(id, crate::store::TaskStatus::Blocked)
4903 .await
4904 .unwrap();
4905 store
4907 .set_fields(
4908 id,
4909 &[
4910 ("auto_unblock_count", serde_json::json!(1)),
4911 (
4912 "auto_unblock_last_reason",
4913 serde_json::json!("ModelUnavailable"),
4914 ),
4915 ],
4916 )
4917 .await
4918 .unwrap();
4919
4920 let run_id = store
4922 .start_run(&crate::store::StartRun {
4923 task_id: id,
4924 attempt: 2,
4925 run_type: "agent",
4926 agent: "claude",
4927 model: "opus",
4928 command: "claude",
4929 prompt: "fix the bug",
4930 })
4931 .await
4932 .unwrap();
4933 store
4934 .complete_run(&crate::store::CompleteRun {
4935 run_id,
4936 exit_code: Some(1),
4937 stdout: "",
4938 stderr: "",
4939 parsed: "",
4940 outcome: "error",
4941 error: "rate limit reached for model opus",
4942 tokens: RunTokenUsage::default(),
4943 })
4944 .await
4945 .unwrap();
4946
4947 auto_unblock_blocked_tasks(
4948 "owner/repo",
4949 &task_manager,
4950 &store,
4951 &dispatching,
4952 &Arc::new(DashSet::new()),
4953 )
4954 .await
4955 .unwrap();
4956
4957 let task = store.get(id).await.unwrap();
4958 assert_eq!(
4959 task.status,
4960 TaskStatus::New,
4961 "task must be auto-unblocked even with count=1 from a different reason"
4962 );
4963 assert_eq!(task.auto_unblock_count, 1);
4965 assert_eq!(
4966 task.auto_unblock_last_reason, "RateLimit",
4967 "reason must be updated to the new failure category"
4968 );
4969 }
4970
4971 #[serial(cooldown_state)]
4977 #[tokio::test]
4978 async fn auto_unblock_leaves_max_attempts_blocked_task_alone() {
4979 crate::engine::cooldown::reset_global_state().await;
4980 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4981 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
4982 let task_manager = Arc::new(TaskManager::with_store(
4983 backend,
4984 store.clone(),
4985 "owner/repo".to_string(),
4986 ));
4987 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4988
4989 let id = store
4990 .upsert_external(&crate::store::UpsertExternal {
4991 repo: "owner/repo",
4992 ext_id: "3783",
4993 title: "Approved work sent back to the agent",
4994 body: "",
4995 author: "",
4996 url: "",
4997 labels: &[],
4998 origin: "github",
4999 })
5000 .await
5001 .unwrap();
5002 store
5003 .update_status(id, crate::store::TaskStatus::Blocked)
5004 .await
5005 .unwrap();
5006
5007 let run_id = store
5008 .start_run(&crate::store::StartRun {
5009 task_id: id,
5010 attempt: 11,
5011 run_type: "agent",
5012 agent: "claude",
5013 model: "opus",
5014 command: "claude",
5015 prompt: "fix the bug",
5016 })
5017 .await
5018 .unwrap();
5019 store
5020 .complete_run(&crate::store::CompleteRun {
5021 run_id,
5022 exit_code: Some(1),
5023 stdout: "",
5024 stderr: "",
5025 parsed: "",
5026 outcome: "error",
5027 error: "exceeded max attempts (11/10). Use `/retry` to reset.",
5028 tokens: RunTokenUsage::default(),
5029 })
5030 .await
5031 .unwrap();
5032
5033 auto_unblock_blocked_tasks(
5034 "owner/repo",
5035 &task_manager,
5036 &store,
5037 &dispatching,
5038 &Arc::new(DashSet::new()),
5039 )
5040 .await
5041 .unwrap();
5042
5043 let task = store.get(id).await.unwrap();
5044 assert_eq!(
5045 task.status,
5046 TaskStatus::Blocked,
5047 "max-attempts block must survive the next tick until a person acts"
5048 );
5049 assert_eq!(
5050 task.auto_unblock_count, 0,
5051 "no auto-unblock attempt should be recorded"
5052 );
5053 }
5054
5055 #[serial(cooldown_state)]
5056 #[tokio::test]
5057 async fn auto_unblock_stores_reason_on_first_unblock() {
5058 crate::engine::cooldown::reset_global_state().await;
5059 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5062 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
5063 let task_manager = Arc::new(TaskManager::with_store(
5064 backend,
5065 store.clone(),
5066 "owner/repo".to_string(),
5067 ));
5068 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
5069
5070 let id = store
5071 .upsert_external(&crate::store::UpsertExternal {
5072 repo: "owner/repo",
5073 ext_id: "1227b",
5074 title: "Task for first-time reason storage",
5075 body: "",
5076 author: "",
5077 url: "",
5078 labels: &[],
5079 origin: "github",
5080 })
5081 .await
5082 .unwrap();
5083 store
5084 .update_status(id, crate::store::TaskStatus::Blocked)
5085 .await
5086 .unwrap();
5087
5088 let run_id = store
5089 .start_run(&crate::store::StartRun {
5090 task_id: id,
5091 attempt: 1,
5092 run_type: "agent",
5093 agent: "claude",
5094 model: "opus",
5095 command: "claude",
5096 prompt: "fix the bug",
5097 })
5098 .await
5099 .unwrap();
5100 store
5101 .complete_run(&crate::store::CompleteRun {
5102 run_id,
5103 exit_code: Some(1),
5104 stdout: "",
5105 stderr: "",
5106 parsed: "",
5107 outcome: "timeout",
5108 error: "task timed out",
5109 tokens: RunTokenUsage::default(),
5110 })
5111 .await
5112 .unwrap();
5113
5114 auto_unblock_blocked_tasks(
5115 "owner/repo",
5116 &task_manager,
5117 &store,
5118 &dispatching,
5119 &Arc::new(DashSet::new()),
5120 )
5121 .await
5122 .unwrap();
5123
5124 let task = store.get(id).await.unwrap();
5125 assert_eq!(task.status, TaskStatus::New);
5126 assert_eq!(task.auto_unblock_count, 1);
5127 assert_eq!(
5128 task.auto_unblock_last_reason, "Timeout",
5129 "reason must be stored on first auto-unblock"
5130 );
5131 }
5132
5133 #[serial(cooldown_state)]
5136 #[test]
5137 fn classify_failure_sparse_checkout_not_parse_error() {
5138 let category = classify_failure("error: cannot reset paths in a sparse checkout", "error");
5141 assert_ne!(
5142 category,
5143 FailureCategory::ParseError,
5144 "sparse checkout errors must NOT be classified as ParseError"
5145 );
5146 assert_eq!(
5147 category,
5148 FailureCategory::Unknown,
5149 "sparse checkout errors should be Unknown (not recoverable auto-unblock)"
5150 );
5151 }
5152
5153 #[serial(cooldown_state)]
5154 #[test]
5155 fn classify_failure_sparse_index_not_parse_error() {
5156 let category = classify_failure("failed due to sparse index configuration", "error");
5158 assert_ne!(category, FailureCategory::ParseError);
5159 assert_eq!(category, FailureCategory::Unknown);
5160 }
5161
5162 #[serial(cooldown_state)]
5169 #[test]
5170 fn classify_failure_truncated_is_recoverable() {
5171 let category = classify_failure(
5172 "truncated: review output was cut off before completion \
5173 (output/reasoning token budget exceeded)",
5174 "truncated",
5175 );
5176 assert_eq!(
5177 category,
5178 FailureCategory::ParseError,
5179 "truncated outcome must classify as a recoverable parse-style failure"
5180 );
5181 assert!(
5182 category.is_recoverable(),
5183 "truncated failures must be recoverable so auto-unblock can retry"
5184 );
5185 }
5186
5187 #[serial(cooldown_state)]
5188 #[test]
5189 fn dispatching_key_includes_repo_for_cross_project_isolation() {
5190 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
5191
5192 let key_a = "owner/repo-a/42".to_string();
5194 dispatching.insert(key_a.clone(), "42".to_string());
5195
5196 let key_b = "owner/repo-b/42".to_string();
5198 assert!(dispatching.contains_key(&key_a));
5199 assert!(
5200 !dispatching.contains_key(&key_b),
5201 "same task ID in different repo should not be blocked"
5202 );
5203 }
5204
5205 #[serial(cooldown_state)]
5206 #[tokio::test]
5207 async fn emit_degraded_agents_writes_metric_when_three_or_more_agents_degraded() {
5208 crate::engine::cooldown::reset_global_state().await;
5209 use crate::engine::router::{Router, RouterConfig};
5210
5211 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5212
5213 let mut config = RouterConfig::default();
5215 config
5216 .model_map
5217 .entry("medium".to_string())
5218 .or_default()
5219 .insert(
5220 "test-agent-3a".to_string(),
5221 vec!["test-model-a".to_string()],
5222 );
5223 config
5224 .model_map
5225 .entry("medium".to_string())
5226 .or_default()
5227 .insert(
5228 "test-agent-3b".to_string(),
5229 vec!["test-model-b".to_string()],
5230 );
5231 config
5232 .model_map
5233 .entry("medium".to_string())
5234 .or_default()
5235 .insert(
5236 "test-agent-3c".to_string(),
5237 vec!["test-model-c".to_string()],
5238 );
5239 config
5240 .model_map
5241 .entry("medium".to_string())
5242 .or_default()
5243 .insert(
5244 "test-agent-3d".to_string(),
5245 vec!["test-model-d".to_string()],
5246 );
5247 let mut router = Router::new(config);
5248 router.available_agents = vec![
5249 "test-agent-3a".to_string(),
5250 "test-agent-3b".to_string(),
5251 "test-agent-3c".to_string(),
5252 "test-agent-3d".to_string(),
5253 ];
5254
5255 set_agent_cooldown("test-agent-3a", 3600).await;
5257 set_agent_cooldown("test-agent-3b", 3600).await;
5258 set_agent_cooldown("test-agent-3c", 3600).await;
5259
5260 emit_degraded_agents_if_needed(&router.available_agents, &router.config, Some(&store))
5262 .await;
5263
5264 let count_val = store
5266 .kv_get("metrics:orch.agents_degraded.count")
5267 .await
5268 .unwrap();
5269 assert_eq!(count_val.as_deref(), Some("3"));
5270
5271 let alert_val = store
5273 .kv_get("metrics:orch.agents_degraded.alert")
5274 .await
5275 .unwrap();
5276 assert_eq!(alert_val.as_deref(), Some("1"));
5277 }
5278
5279 #[serial(cooldown_state)]
5280 #[tokio::test]
5281 async fn emit_degraded_agents_clears_alert_when_below_threshold() {
5282 crate::engine::cooldown::reset_global_state().await;
5283 use crate::engine::router::{Router, RouterConfig};
5284
5285 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5286
5287 let mut config = RouterConfig::default();
5288 config
5289 .model_map
5290 .entry("medium".to_string())
5291 .or_default()
5292 .insert(
5293 "test-agent-2a".to_string(),
5294 vec!["test-model-a".to_string()],
5295 );
5296 config
5297 .model_map
5298 .entry("medium".to_string())
5299 .or_default()
5300 .insert(
5301 "test-agent-2b".to_string(),
5302 vec!["test-model-b".to_string()],
5303 );
5304 let mut router = Router::new(config);
5305 router.available_agents = vec!["test-agent-2a".to_string(), "test-agent-2b".to_string()];
5306
5307 set_agent_cooldown("test-agent-2a", 3600).await;
5309 set_agent_cooldown("test-agent-2b", 3600).await;
5310
5311 emit_degraded_agents_if_needed(&router.available_agents, &router.config, Some(&store))
5312 .await;
5313
5314 let count_val = store
5316 .kv_get("metrics:orch.agents_degraded.count")
5317 .await
5318 .unwrap();
5319 assert_eq!(count_val.as_deref(), Some("2"));
5320
5321 let alert_val = store
5323 .kv_get("metrics:orch.agents_degraded.alert")
5324 .await
5325 .unwrap();
5326 assert_eq!(alert_val.as_deref(), Some("0"));
5327
5328 let stale_pool_alert = store
5329 .kv_get("metrics:orch.model_pool_stale_persistent.alert")
5330 .await
5331 .unwrap();
5332 assert_eq!(stale_pool_alert.as_deref(), Some("0"));
5333 }
5334
5335 #[serial(cooldown_state)]
5336 #[tokio::test]
5337 async fn emit_degraded_agents_sets_stale_pool_alert_for_persistent_model_failures() {
5338 crate::engine::cooldown::reset_global_state().await;
5339 use crate::engine::cooldown::record_persistent_model_failure;
5340 use crate::engine::router::{Router, RouterConfig};
5341
5342 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5343
5344 let mut config = RouterConfig::default();
5345 config
5346 .model_map
5347 .entry("medium".to_string())
5348 .or_default()
5349 .insert(
5350 "test-agent-stale".to_string(),
5351 vec!["model-a".to_string(), "model-b".to_string()],
5352 );
5353 let mut router = Router::new(config);
5354 router.available_agents = vec!["test-agent-stale".to_string()];
5355
5356 record_persistent_model_failure("test-agent-stale", "model-a").await;
5358
5359 emit_degraded_agents_if_needed(&router.available_agents, &router.config, Some(&store))
5360 .await;
5361
5362 let stale_pool_alert = store
5363 .kv_get("metrics:orch.model_pool_stale_persistent.alert")
5364 .await
5365 .unwrap();
5366 assert_eq!(stale_pool_alert.as_deref(), Some("1"));
5367 }
5368
5369 use super::{classify_comment, CommentAction, MentionCursor};
5372 use crate::engine::commands::OwnerCommand;
5373
5374 #[serial(cooldown_state)]
5375 #[test]
5376 fn classify_skip_already_processed() {
5377 let action = classify_comment(
5378 "@orch /retry",
5379 Some("https://api.github.com/repos/o/r/issues/1"),
5380 "@bot",
5381 true,
5382 );
5383 assert_eq!(action, CommentAction::Skip);
5384 }
5385
5386 #[serial(cooldown_state)]
5387 #[test]
5388 fn classify_skip_irrelevant_comment() {
5389 let action = classify_comment("just a regular comment", None, "@bot", false);
5390 assert_eq!(action, CommentAction::Skip);
5391 }
5392
5393 #[serial(cooldown_state)]
5394 #[test]
5395 fn classify_command_without_mention() {
5396 let action = classify_comment(
5397 "/retry",
5398 Some("https://api.github.com/repos/o/r/issues/42"),
5399 "@bot",
5400 false,
5401 );
5402 assert_eq!(
5403 action,
5404 CommentAction::ExecuteCommand {
5405 command: OwnerCommand::Retry,
5406 issue_num: "42".to_string(),
5407 }
5408 );
5409 }
5410
5411 #[serial(cooldown_state)]
5412 #[test]
5413 fn classify_command_with_mention() {
5414 let action = classify_comment(
5415 "@orch /close",
5416 Some("https://api.github.com/repos/o/r/issues/7"),
5417 "@bot",
5418 false,
5419 );
5420 assert_eq!(
5421 action,
5422 CommentAction::ExecuteCommandForMention {
5423 command: OwnerCommand::Close,
5424 issue_num: "7".to_string(),
5425 }
5426 );
5427 }
5428
5429 #[serial(cooldown_state)]
5430 #[test]
5431 fn classify_mention_without_command() {
5432 let action = classify_comment(
5433 "@orch can you look at this?",
5434 Some("https://api.github.com/repos/o/r/issues/99"),
5435 "@bot",
5436 false,
5437 );
5438 assert_eq!(
5439 action,
5440 CommentAction::CreateMentionTask {
5441 issue_num: Some("99".to_string()),
5442 }
5443 );
5444 }
5445
5446 #[serial(cooldown_state)]
5447 #[test]
5448 fn classify_mention_without_issue_url() {
5449 let action = classify_comment("@orch help me", None, "@bot", false);
5450 assert_eq!(action, CommentAction::CreateMentionTask { issue_num: None });
5451 }
5452
5453 #[serial(cooldown_state)]
5454 #[test]
5455 fn classify_command_no_mention_no_url_skips() {
5456 let action = classify_comment("/retry", None, "@bot", false);
5457 assert_eq!(action, CommentAction::Skip);
5458 }
5459
5460 #[serial(cooldown_state)]
5461 #[test]
5462 fn classify_mention_via_current_user() {
5463 let action = classify_comment(
5464 "@bot please help",
5465 Some("https://api.github.com/repos/o/r/issues/5"),
5466 "@bot",
5467 false,
5468 );
5469 assert_eq!(
5470 action,
5471 CommentAction::CreateMentionTask {
5472 issue_num: Some("5".to_string()),
5473 }
5474 );
5475 }
5476
5477 #[serial(cooldown_state)]
5478 #[test]
5479 fn classify_command_with_mention_no_url_creates_task() {
5480 let action = classify_comment("@orch /retry", None, "@bot", false);
5482 assert_eq!(action, CommentAction::CreateMentionTask { issue_num: None });
5483 }
5484
5485 #[serial(cooldown_state)]
5486 #[test]
5487 fn mention_cursor_stops_advancing_after_gap() {
5488 let mut cursor = MentionCursor::default();
5489 cursor.advance("2026-01-01T00:00:00Z");
5490 cursor.block_on_gap();
5491 cursor.advance("2026-01-02T00:00:00Z");
5492
5493 assert_eq!(
5494 cursor.into_last_safe_ts().as_deref(),
5495 Some("2026-01-01T00:00:00Z")
5496 );
5497 }
5498
5499 #[serial(cooldown_state)]
5500 #[test]
5501 fn mention_cursor_tracks_max_timestamp_until_gap() {
5502 let mut cursor = MentionCursor::default();
5503 cursor.advance("2026-01-01T00:00:00Z");
5504 cursor.advance("2026-01-01T00:05:00Z");
5505 cursor.block_on_gap();
5506 cursor.advance("2026-01-01T00:10:00Z");
5507
5508 assert_eq!(
5509 cursor.into_last_safe_ts().as_deref(),
5510 Some("2026-01-01T00:05:00Z")
5511 );
5512 }
5513
5514 struct AckTrackingBackend {
5518 ack_calls: tokio::sync::Mutex<Vec<String>>,
5519 posted: tokio::sync::Mutex<Vec<String>>,
5520 ack_failure: bool,
5521 inner: crate::backends::test_helpers::NoopBackend,
5522 }
5523
5524 impl AckTrackingBackend {
5525 fn new(ack_failure: bool) -> Arc<Self> {
5526 Arc::new(Self {
5527 ack_calls: tokio::sync::Mutex::new(Vec::new()),
5528 posted: tokio::sync::Mutex::new(Vec::new()),
5529 ack_failure,
5530 inner: crate::backends::test_helpers::NoopBackend,
5531 })
5532 }
5533
5534 fn fail_on_ack(&self) -> bool {
5535 self.ack_failure
5536 }
5537 }
5538
5539 #[async_trait]
5540 impl ExternalBackend for AckTrackingBackend {
5541 fn name(&self) -> &str {
5542 "ack-tracking"
5543 }
5544
5545 async fn acknowledge_mention(&self, comment_id: &str) -> anyhow::Result<()> {
5546 self.ack_calls.lock().await.push(comment_id.to_string());
5547 if self.fail_on_ack() {
5548 anyhow::bail!("network error: temporary failure posting reaction");
5550 }
5551 Ok(())
5552 }
5553
5554 async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
5556 Ok(Some("bot".into()))
5557 }
5558
5559 async fn get_mentions(&self, _since: &str) -> anyhow::Result<Vec<Mention>> {
5561 Ok(vec![])
5562 }
5563
5564 async fn create_task(&self, t: &str, b: &str, l: &[String]) -> anyhow::Result<ExternalId> {
5566 self.inner.create_task(t, b, l).await
5567 }
5568 async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask> {
5569 self.inner.get_task(id).await
5570 }
5571 async fn list_by_status(&self, s: Status) -> anyhow::Result<Vec<ExternalTask>> {
5572 self.inner.list_by_status(s).await
5573 }
5574 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
5575 self.inner.list_routable().await
5576 }
5577 async fn post_comment(&self, id: &ExternalId, b: &str) -> anyhow::Result<()> {
5578 self.posted.lock().await.push(b.to_string());
5579 self.inner.post_comment(id, b).await
5580 }
5581 async fn set_labels(&self, id: &ExternalId, l: &[String]) -> anyhow::Result<()> {
5582 self.inner.set_labels(id, l).await
5583 }
5584 async fn remove_label(&self, id: &ExternalId, l: &str) -> anyhow::Result<()> {
5585 self.inner.remove_label(id, l).await
5586 }
5587 async fn get_sub_issues(&self, id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
5588 self.inner.get_sub_issues(id).await
5589 }
5590 async fn create_sub_task(
5591 &self,
5592 p: &ExternalId,
5593 t: &str,
5594 b: &str,
5595 l: &[String],
5596 ) -> anyhow::Result<ExternalId> {
5597 self.inner.create_sub_task(p, t, b, l).await
5598 }
5599 async fn ensure_status_label(&self, l: &str) -> anyhow::Result<()> {
5600 self.inner.ensure_status_label(l).await
5601 }
5602 async fn has_open_issue_with_title(&self, t: &str, l: &str) -> anyhow::Result<bool> {
5603 self.inner.has_open_issue_with_title(t, l).await
5604 }
5605 async fn health_check(&self) -> anyhow::Result<()> {
5606 self.inner.health_check().await
5607 }
5608 async fn is_pr_merged(&self, b: &str) -> anyhow::Result<bool> {
5609 self.inner.is_pr_merged(b).await
5610 }
5611 async fn update_status(&self, id: &ExternalId, s: Status) -> anyhow::Result<()> {
5612 self.inner.update_status(id, s).await
5613 }
5614 }
5615
5616 #[serial(cooldown_state)]
5619 #[tokio::test]
5620 async fn create_mention_task_skips_on_ack_failure() {
5621 crate::engine::cooldown::reset_global_state().await;
5622 let backend: Arc<AckTrackingBackend> = AckTrackingBackend::new(true);
5623 let store: Arc<TaskStore> = Arc::new(TaskStore::open_memory().await.unwrap());
5624
5625 let initial_ts = "2026-01-01T00:00:00Z";
5627 store
5628 .kv_set("mentions_last_checked", initial_ts)
5629 .await
5630 .unwrap();
5631
5632 let mention = Mention {
5633 id: "mention-abc".into(),
5634 body: "@bot can you help?".into(),
5635 author: "alice".into(),
5636 created_at: "2026-01-02T00:00:00Z".into(),
5637 issue_url: Some("https://api.github.com/repos/owner/repo/issues/42".into()),
5638 };
5639
5640 let backend_trait: Arc<dyn ExternalBackend> = backend.clone();
5641 let task_manager = Arc::new(TaskManager::new(backend_trait.clone()));
5642
5643 scan_comments(
5644 &backend_trait,
5645 Some(&store),
5646 "owner/repo",
5647 &task_manager,
5648 Some(&[mention]),
5649 )
5650 .await
5651 .unwrap();
5652
5653 let calls = backend.ack_calls.lock().await;
5655 assert_eq!(
5656 &**calls,
5657 &["mention-abc"],
5658 "acknowledge_mention must be called"
5659 );
5660
5661 let all = store.list_all("owner/repo").await.unwrap();
5663 assert!(
5664 all.is_empty(),
5665 "no task should be created when acknowledge_mention fails"
5666 );
5667
5668 let cursor = store.kv_get("mentions_last_checked").await.unwrap();
5670 assert_eq!(
5671 cursor.as_deref(),
5672 Some(initial_ts),
5673 "cursor must not advance when acknowledge_mention fails"
5674 );
5675 }
5676
5677 #[serial(cooldown_state)]
5680 #[tokio::test]
5681 async fn execute_command_for_mention_skips_on_ack_failure() {
5682 crate::engine::cooldown::reset_global_state().await;
5683 let backend: Arc<AckTrackingBackend> = AckTrackingBackend::new(true);
5684 let store: Arc<TaskStore> = Arc::new(TaskStore::open_memory().await.unwrap());
5685
5686 let initial_ts = "2026-01-01T00:00:00Z";
5687 store
5688 .kv_set("mentions_last_checked", initial_ts)
5689 .await
5690 .unwrap();
5691
5692 let mention = Mention {
5694 id: "mention-def".into(),
5695 body: "@bot /close".into(),
5696 author: "alice".into(),
5697 created_at: "2026-01-02T00:00:00Z".into(),
5698 issue_url: Some("https://api.github.com/repos/owner/repo/issues/42".into()),
5699 };
5700
5701 let backend_trait: Arc<dyn ExternalBackend> = backend.clone();
5702 let task_manager = Arc::new(TaskManager::new(backend_trait.clone()));
5703
5704 scan_comments(
5705 &backend_trait,
5706 Some(&store),
5707 "owner/repo",
5708 &task_manager,
5709 Some(&[mention]),
5710 )
5711 .await
5712 .unwrap();
5713
5714 let calls = backend.ack_calls.lock().await;
5716 assert_eq!(&**calls, &["mention-def"]);
5717
5718 let all = store.list_all("owner/repo").await.unwrap();
5720 assert!(
5721 all.is_empty(),
5722 "no task should be created when acknowledge_mention fails for ExecuteCommandForMention"
5723 );
5724
5725 let cursor = store.kv_get("mentions_last_checked").await.unwrap();
5727 assert_eq!(
5728 cursor.as_deref(),
5729 Some(initial_ts),
5730 "cursor must not advance when acknowledge_mention fails for ExecuteCommandForMention"
5731 );
5732 }
5733
5734 #[derive(Default)]
5738 struct FlakyGh {
5739 get_issue_calls: std::sync::atomic::AtomicUsize,
5740 }
5741
5742 #[async_trait]
5743 impl crate::engine::commands::CommandGh for FlakyGh {
5744 async fn get_issue(
5745 &self,
5746 _repo: &str,
5747 number: &str,
5748 ) -> anyhow::Result<crate::github::types::GitHubIssue> {
5749 if self
5750 .get_issue_calls
5751 .fetch_add(1, std::sync::atomic::Ordering::SeqCst)
5752 == 0
5753 {
5754 anyhow::bail!("network error: temporary failure fetching issue");
5755 }
5756 Ok(crate::github::types::GitHubIssue {
5757 number: number.parse().unwrap_or(0),
5758 id: 1,
5759 title: "t".into(),
5760 body: None,
5761 state: "open".into(),
5762 labels: Vec::new(),
5763 user: crate::github::types::GitHubUser {
5764 login: "alice".into(),
5765 },
5766 created_at: String::new(),
5767 updated_at: String::new(),
5768 html_url: String::new(),
5769 node_id: None,
5770 pull_request: None,
5771 author_association: Some("OWNER".into()),
5772 state_reason: None,
5773 })
5774 }
5775
5776 async fn is_collaborator(&self, _repo: &str, _username: &str) -> anyhow::Result<bool> {
5777 Ok(true)
5778 }
5779
5780 async fn close_issue(&self, _repo: &str, _number: &str) -> anyhow::Result<()> {
5781 Ok(())
5782 }
5783 }
5784
5785 #[serial(cooldown_state)]
5788 #[tokio::test]
5789 async fn slash_command_retries_after_transient_fetch_failure() {
5790 crate::engine::cooldown::reset_global_state().await;
5791 let backend: Arc<AckTrackingBackend> = AckTrackingBackend::new(false);
5792 let store: Arc<TaskStore> = Arc::new(TaskStore::open_memory().await.unwrap());
5793 let initial_ts = "2026-01-01T00:00:00Z";
5794
5795 let mention = Mention {
5796 id: "mention-ghi".into(),
5797 body: "@bot /unblock".into(),
5798 author: "alice".into(),
5799 created_at: "2026-01-02T00:00:00Z".into(),
5800 issue_url: Some("https://api.github.com/repos/owner/repo/issues/42".into()),
5801 };
5802
5803 let backend_trait: Arc<dyn ExternalBackend> = backend.clone();
5804 let task_manager = Arc::new(TaskManager::new(backend_trait.clone()));
5805 let gh = FlakyGh::default();
5806 let mut cursor = MentionCursor::default();
5807
5808 let advanced_safely = handle_slash_command(
5810 &backend_trait,
5811 Some(&store),
5812 "owner/repo",
5813 &task_manager,
5814 &gh,
5815 &mention,
5816 &crate::engine::commands::OwnerCommand::Unblock,
5817 &mut cursor,
5818 )
5819 .await
5820 .unwrap();
5821
5822 assert!(
5823 !advanced_safely,
5824 "a transient fetch failure must stop cursor advancement"
5825 );
5826 assert!(
5827 cursor.last_safe_ts.is_none(),
5828 "cursor must stay before the comment so the next tick retries it"
5829 );
5830 assert!(
5831 store.list_all("owner/repo").await.unwrap().is_empty(),
5832 "no mention task on a transient failure"
5833 );
5834 assert!(
5835 backend.posted.lock().await.is_empty(),
5836 "nothing is posted to GitHub on a transient failure"
5837 );
5838 assert_eq!(
5839 filter_mentions_by_since(std::slice::from_ref(&mention), initial_ts).len(),
5840 1,
5841 "the comment is still in range for the next scan"
5842 );
5843
5844 let mut cursor = MentionCursor::default();
5846 let advanced_safely = handle_slash_command(
5847 &backend_trait,
5848 Some(&store),
5849 "owner/repo",
5850 &task_manager,
5851 &gh,
5852 &mention,
5853 &crate::engine::commands::OwnerCommand::Unblock,
5854 &mut cursor,
5855 )
5856 .await
5857 .unwrap();
5858
5859 assert!(advanced_safely);
5860 assert_eq!(
5861 cursor.into_last_safe_ts().as_deref(),
5862 Some("2026-01-02T00:00:00Z"),
5863 "cursor advances once the command actually runs"
5864 );
5865 assert_eq!(
5866 backend.posted.lock().await.len(),
5867 1,
5868 "the command runs once, posting a single confirmation"
5869 );
5870 assert_eq!(
5871 store.list_all("owner/repo").await.unwrap().len(),
5872 1,
5873 "one sentinel mention task for the executed command"
5874 );
5875 }
5876
5877 #[serial(cooldown_state)]
5880 #[tokio::test]
5881 async fn execute_command_keeps_cursor_on_fetch_failure() {
5882 crate::engine::cooldown::reset_global_state().await;
5883 let backend: Arc<AckTrackingBackend> = AckTrackingBackend::new(false);
5884 let store: Arc<TaskStore> = Arc::new(TaskStore::open_memory().await.unwrap());
5885
5886 let initial_ts = "2026-01-01T00:00:00Z";
5887 store
5888 .kv_set("mentions_last_checked", initial_ts)
5889 .await
5890 .unwrap();
5891
5892 let comment = Mention {
5894 id: "comment-jkl".into(),
5895 body: "/unblock".into(),
5896 author: "alice".into(),
5897 created_at: "2026-01-02T00:00:00Z".into(),
5898 issue_url: Some("https://api.github.com/repos/owner/repo/issues/42".into()),
5899 };
5900
5901 let backend_trait: Arc<dyn ExternalBackend> = backend.clone();
5902 let task_manager = Arc::new(TaskManager::new(backend_trait.clone()));
5903
5904 scan_comments(
5905 &backend_trait,
5906 Some(&store),
5907 "owner/repo",
5908 &task_manager,
5909 Some(&[comment]),
5910 )
5911 .await
5912 .unwrap();
5913
5914 assert!(
5915 backend.posted.lock().await.is_empty(),
5916 "nothing is posted when the issue fetch fails"
5917 );
5918 assert_eq!(
5919 store
5920 .kv_get("mentions_last_checked")
5921 .await
5922 .unwrap()
5923 .as_deref(),
5924 Some(initial_ts),
5925 "cursor must not advance past a command that never ran"
5926 );
5927 }
5928
5929 fn make_task(block_reason: Option<String>) -> crate::store::Task {
5932 crate::store::Task {
5933 id: 1,
5934 external_id: None,
5935 repo: "owner/repo".to_string(),
5936 origin: String::new(),
5937 title: String::new(),
5938 body: String::new(),
5939 status: crate::store::TaskStatus::Blocked,
5940 source: String::new(),
5941 source_id: String::new(),
5942 author: String::new(),
5943 url: String::new(),
5944 labels: vec![],
5945 agent: None,
5946 model: None,
5947 complexity: String::new(),
5948 estimate: 0,
5949 route_reason: String::new(),
5950 agent_profile: String::new(),
5951 selected_skills: String::new(),
5952 route_attempts: 0,
5953 attempts: 0,
5954 branch: String::new(),
5955 worktree: String::new(),
5956 worktree_cleaned: false,
5957 owner_node: None,
5958 summary: String::new(),
5959 last_error: String::new(),
5960 parent_id: None,
5961 block_reason,
5962 pr_number: Some(42),
5963 pr_review_context: String::new(),
5964 last_review_ts: String::new(),
5965 review_ts_map: String::new(),
5966 last_comment_review_ts: String::new(),
5967 merge_conflict_retries: 0,
5968 ci_merge_failures: 0,
5969 pr_create_failures: 0,
5970 push_failures: 0,
5971 network_retries: 0,
5972 review_agent_failures: 0,
5973 review_resets: 0,
5974 review_cycles: 0,
5975 review_invocations: 0,
5976 needs_review_refires: 0,
5977 input_tokens: 0,
5978 output_tokens: 0,
5979 input_cost_usd: 0.0,
5980 output_cost_usd: 0.0,
5981 total_cost_usd: 0.0,
5982 model_reroute_chain: String::new(),
5983 limit_reroute_chain: String::new(),
5984 memory: vec![],
5985 delegations: vec![],
5986 auto_unblock_count: 0,
5987 auto_unblock_last_at: String::new(),
5988 auto_unblock_last_reason: String::new(),
5989 ci_recovery_count: 0,
5990 no_code_reroutes: 0,
5991 no_code_last_agent: String::new(),
5992 created_at: String::new(),
5993 updated_at: String::new(),
5994 }
5995 }
5996
5997 #[serial(cooldown_state)]
5998 #[test]
5999 fn is_ci_failure_block_matches_ci_failure_limit() {
6000 let task = make_task(Some(
6001 "CI failure limit (3) reached during auto-merge".to_string(),
6002 ));
6003 assert!(is_ci_failure_block(&task));
6004 }
6005
6006 #[serial(cooldown_state)]
6007 #[test]
6008 fn is_ci_failure_block_matches_ci_checks_timeout() {
6009 let task = make_task(Some(
6010 "CI checks timed out after 3 auto-merge failures".to_string(),
6011 ));
6012 assert!(is_ci_failure_block(&task));
6013 }
6014
6015 #[serial(cooldown_state)]
6016 #[test]
6017 fn is_ci_failure_block_false_for_regular_block() {
6018 let task = make_task(Some("waiting on input".to_string()));
6019 assert!(!is_ci_failure_block(&task));
6020 }
6021
6022 #[serial(cooldown_state)]
6023 #[test]
6024 fn is_ci_failure_block_false_for_none_reason() {
6025 let task = make_task(None);
6026 assert!(!is_ci_failure_block(&task));
6027 }
6028
6029 #[serial(cooldown_state)]
6032 #[test]
6033 fn ci_failure_cooldown_zero_count_immediate() {
6034 let task = make_task(None);
6036 assert!(ci_failure_unblock_cooldown_elapsed(&task));
6037 }
6038
6039 #[serial(cooldown_state)]
6040 #[test]
6041 fn ci_failure_cooldown_empty_last_at_immediate() {
6042 let mut task = make_task(None);
6044 task.auto_unblock_count = 1;
6045 assert!(ci_failure_unblock_cooldown_elapsed(&task));
6046 }
6047
6048 #[serial(cooldown_state)]
6049 #[test]
6050 fn ci_failure_cooldown_invalid_timestamp_immediate() {
6051 let mut task = make_task(None);
6053 task.auto_unblock_count = 1;
6054 task.auto_unblock_last_at = "not-a-timestamp".to_string();
6055 assert!(ci_failure_unblock_cooldown_elapsed(&task));
6056 }
6057
6058 #[serial(cooldown_state)]
6059 #[test]
6060 fn ci_failure_cooldown_high_count_still_eventually_elapses() {
6061 let long_ago = (chrono::Utc::now() - chrono::Duration::days(30)).to_rfc3339();
6064 for count in [3i32, 5, 99] {
6065 let mut task = make_task(None);
6066 task.auto_unblock_count = count;
6067 task.auto_unblock_last_at = long_ago.clone();
6068 assert!(
6069 ci_failure_unblock_cooldown_elapsed(&task),
6070 "count={count} should eventually elapse"
6071 );
6072 }
6073 }
6074
6075 #[serial(cooldown_state)]
6076 #[test]
6077 fn ci_failure_cooldown_recent_timestamp_still_blocks() {
6078 let just_now = chrono::Utc::now().to_rfc3339();
6080 for count in [1i32, 2, 3, 99] {
6081 let mut task = make_task(None);
6082 task.auto_unblock_count = count;
6083 task.auto_unblock_last_at = just_now.clone();
6084 assert!(
6085 !ci_failure_unblock_cooldown_elapsed(&task),
6086 "count={count} should still be cooling down"
6087 );
6088 }
6089 }
6090
6091 #[serial(cooldown_state)]
6094 #[tokio::test]
6095 async fn global_ci_failure_sweep_returns_ok_with_no_blocked_tasks() {
6096 crate::engine::cooldown::reset_global_state().await;
6097 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6098 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
6099 let task_manager = Arc::new(TaskManager::with_store(
6100 backend,
6101 store.clone(),
6102 "owner/repo".to_string(),
6103 ));
6104 auto_unblock_ci_failure_blocked_tasks_global(
6105 &task_manager,
6106 &store,
6107 &Arc::new(DashSet::new()),
6108 )
6109 .await
6110 .unwrap();
6111 }
6112
6113 #[serial(cooldown_state)]
6114 #[tokio::test]
6115 async fn global_ci_failure_sweep_skips_tasks_in_cooldown() {
6116 crate::engine::cooldown::reset_global_state().await;
6119 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6120 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
6121 let task_manager = Arc::new(TaskManager::with_store(
6122 backend,
6123 store.clone(),
6124 "active/repo".to_string(),
6125 ));
6126
6127 let id = store
6129 .upsert_external(&crate::store::UpsertExternal {
6130 repo: "inactive/repo",
6131 ext_id: "10",
6132 title: "Old blocked task",
6133 body: "",
6134 author: "",
6135 url: "",
6136 labels: &[],
6137 origin: "github",
6138 })
6139 .await
6140 .unwrap();
6141 store
6142 .update_status(id, crate::store::TaskStatus::Blocked)
6143 .await
6144 .unwrap();
6145 let just_now = chrono::Utc::now().to_rfc3339();
6146 store
6147 .set_fields(
6148 id,
6149 &[
6150 (
6151 "block_reason",
6152 serde_json::json!("CI failure limit reached during auto-merge"),
6153 ),
6154 ("pr_number", serde_json::json!(99)),
6155 ("auto_unblock_count", serde_json::json!(1)),
6156 ("auto_unblock_last_at", serde_json::json!(just_now)),
6157 ],
6158 )
6159 .await
6160 .unwrap();
6161
6162 auto_unblock_ci_failure_blocked_tasks_global(
6163 &task_manager,
6164 &store,
6165 &Arc::new(DashSet::new()),
6166 )
6167 .await
6168 .unwrap();
6169
6170 let task = store.get(id).await.unwrap();
6172 assert_eq!(
6173 task.auto_unblock_count, 1,
6174 "task in cooldown must not be processed"
6175 );
6176 }
6177
6178 #[serial(cooldown_state)]
6179 #[tokio::test]
6180 async fn global_ci_failure_sweep_ignores_non_ci_failure_blocks() {
6181 crate::engine::cooldown::reset_global_state().await;
6182 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6183 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
6184 let task_manager = Arc::new(TaskManager::with_store(
6185 backend,
6186 store.clone(),
6187 "active/repo".to_string(),
6188 ));
6189
6190 let id = store
6192 .upsert_external(&crate::store::UpsertExternal {
6193 repo: "inactive/repo",
6194 ext_id: "20",
6195 title: "Manual block",
6196 body: "",
6197 author: "",
6198 url: "",
6199 labels: &[],
6200 origin: "github",
6201 })
6202 .await
6203 .unwrap();
6204 store
6205 .update_status(id, crate::store::TaskStatus::Blocked)
6206 .await
6207 .unwrap();
6208 store
6209 .set_fields(
6210 id,
6211 &[("block_reason", serde_json::json!("waiting for human input"))],
6212 )
6213 .await
6214 .unwrap();
6215
6216 auto_unblock_ci_failure_blocked_tasks_global(
6217 &task_manager,
6218 &store,
6219 &Arc::new(DashSet::new()),
6220 )
6221 .await
6222 .unwrap();
6223
6224 let task = store.get(id).await.unwrap();
6226 assert_eq!(task.status, crate::store::TaskStatus::Blocked);
6227 }
6228
6229 #[serial(cooldown_state)]
6232 #[tokio::test]
6233 async fn global_rebroadcast_sweep_returns_ok_with_no_blocked_tasks() {
6234 crate::engine::cooldown::reset_global_state().await;
6235 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6236 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
6237 let task_manager = Arc::new(TaskManager::with_store(
6238 backend,
6239 store.clone(),
6240 "owner/repo".to_string(),
6241 ));
6242 let router = router_with_healthy_review_agent();
6243 auto_recover_rebroadcast_blocked_tasks_global(&router, &task_manager, &store)
6244 .await
6245 .unwrap();
6246 }
6247
6248 #[serial(cooldown_state)]
6249 #[tokio::test]
6250 async fn global_rebroadcast_sweep_ignores_non_rebroadcast_blocks() {
6251 crate::engine::cooldown::reset_global_state().await;
6252 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6253 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
6254 let task_manager = Arc::new(TaskManager::with_store(
6255 backend,
6256 store.clone(),
6257 "active/repo".to_string(),
6258 ));
6259 let router = router_with_healthy_review_agent();
6260
6261 let id = store
6262 .upsert_external(&crate::store::UpsertExternal {
6263 repo: "inactive/repo",
6264 ext_id: "30",
6265 title: "Manual block",
6266 body: "",
6267 author: "",
6268 url: "",
6269 labels: &[],
6270 origin: "github",
6271 })
6272 .await
6273 .unwrap();
6274 store
6275 .update_status(id, crate::store::TaskStatus::Blocked)
6276 .await
6277 .unwrap();
6278 store
6279 .set_fields(
6280 id,
6281 &[("block_reason", serde_json::json!("waiting for human input"))],
6282 )
6283 .await
6284 .unwrap();
6285 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
6286 .bind(id)
6287 .execute(store.pool())
6288 .await
6289 .unwrap();
6290
6291 auto_recover_rebroadcast_blocked_tasks_global(&router, &task_manager, &store)
6292 .await
6293 .unwrap();
6294
6295 let task = store.get(id).await.unwrap();
6296 assert_eq!(task.status, crate::store::TaskStatus::Blocked);
6297 assert_eq!(
6298 task.block_reason.as_deref(),
6299 Some("waiting for human input")
6300 );
6301 }
6302
6303 #[serial(cooldown_state)]
6304 #[tokio::test]
6305 async fn global_rebroadcast_sweep_skips_freshly_blocked_tasks() {
6306 crate::engine::cooldown::reset_global_state().await;
6307 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6308 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
6309 let task_manager = Arc::new(TaskManager::with_store(
6310 backend,
6311 store.clone(),
6312 "active/repo".to_string(),
6313 ));
6314 let router = router_with_healthy_review_agent();
6315
6316 let id = store
6317 .upsert_external(&crate::store::UpsertExternal {
6318 repo: "inactive/repo",
6319 ext_id: "40",
6320 title: "Just escalated",
6321 body: "",
6322 author: "",
6323 url: "",
6324 labels: &[],
6325 origin: "github",
6326 })
6327 .await
6328 .unwrap();
6329 store
6330 .update_status(id, crate::store::TaskStatus::Blocked)
6331 .await
6332 .unwrap();
6333 store
6334 .set_fields(
6335 id,
6336 &[("block_reason", serde_json::json!(REBROADCAST_BLOCK_REASON))],
6337 )
6338 .await
6339 .unwrap();
6340 auto_recover_rebroadcast_blocked_tasks_global(&router, &task_manager, &store)
6344 .await
6345 .unwrap();
6346
6347 let task = store.get(id).await.unwrap();
6348 assert_eq!(task.status, crate::store::TaskStatus::Blocked);
6349 }
6350
6351 #[serial(cooldown_state)]
6356 #[tokio::test]
6357 async fn global_rebroadcast_sweep_recovers_task_from_inactive_repo() {
6358 crate::engine::cooldown::reset_global_state().await;
6359 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6360 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
6361 let task_manager = Arc::new(TaskManager::with_store(
6362 backend,
6363 store.clone(),
6364 "active/repo".to_string(),
6365 ));
6366 let router = router_with_healthy_review_agent();
6367
6368 let id = store
6369 .upsert_external(&crate::store::UpsertExternal {
6370 repo: "inactive/repo",
6371 ext_id: "458",
6372 title: "Rebroadcast blocked from inactive repo",
6373 body: "",
6374 author: "",
6375 url: "",
6376 labels: &[],
6377 origin: "github",
6378 })
6379 .await
6380 .unwrap();
6381 store
6382 .update_status(id, crate::store::TaskStatus::Blocked)
6383 .await
6384 .unwrap();
6385 store
6386 .set_fields(
6387 id,
6388 &[
6389 ("block_reason", serde_json::json!(REBROADCAST_BLOCK_REASON)),
6390 ("needs_review_refires", serde_json::json!(6)),
6391 ],
6392 )
6393 .await
6394 .unwrap();
6395 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
6396 .bind(id)
6397 .execute(store.pool())
6398 .await
6399 .unwrap();
6400
6401 auto_recover_rebroadcast_blocked_tasks_global(&router, &task_manager, &store)
6402 .await
6403 .unwrap();
6404
6405 let task = store.get(id).await.unwrap();
6406 assert_eq!(
6407 task.status,
6408 crate::store::TaskStatus::NeedsReview,
6409 "cross-repo rebroadcast-blocked task must be recovered to needs_review"
6410 );
6411 assert!(
6412 task.block_reason.is_none(),
6413 "block_reason must be cleared on recovery"
6414 );
6415 assert_eq!(
6416 task.needs_review_refires, 0,
6417 "needs_review_refires must be reset to 0 on recovery"
6418 );
6419 }
6420
6421 #[serial(cooldown_state)]
6424 #[tokio::test]
6425 async fn global_rebroadcast_sweep_skips_reset_without_routable_review_agent() {
6426 crate::engine::cooldown::reset_global_state().await;
6427 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6428 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
6429 let task_manager = Arc::new(TaskManager::with_store(
6430 backend,
6431 store.clone(),
6432 "active/repo".to_string(),
6433 ));
6434 let router = crate::engine::router::Router::new_for_test(
6437 crate::engine::router::RouterConfig::default(),
6438 vec!["claude".to_string()],
6439 );
6440
6441 let id = store
6442 .upsert_external(&crate::store::UpsertExternal {
6443 repo: "inactive/repo",
6444 ext_id: "459",
6445 title: "Rebroadcast blocked, no routable agents",
6446 body: "",
6447 author: "",
6448 url: "",
6449 labels: &[],
6450 origin: "github",
6451 })
6452 .await
6453 .unwrap();
6454 store
6455 .update_status(id, crate::store::TaskStatus::Blocked)
6456 .await
6457 .unwrap();
6458 store
6459 .set_fields(
6460 id,
6461 &[("block_reason", serde_json::json!(REBROADCAST_BLOCK_REASON))],
6462 )
6463 .await
6464 .unwrap();
6465 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
6466 .bind(id)
6467 .execute(store.pool())
6468 .await
6469 .unwrap();
6470
6471 auto_recover_rebroadcast_blocked_tasks_global(&router, &task_manager, &store)
6472 .await
6473 .unwrap();
6474
6475 let task = store.get(id).await.unwrap();
6476 assert_eq!(task.status, crate::store::TaskStatus::Blocked);
6477 assert_eq!(task.block_reason.as_deref(), Some(REBROADCAST_BLOCK_REASON));
6478 }
6479}