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