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