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