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