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