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