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