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