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 required_checks_state(&required_contexts, &check_runs, &statuses)
1028 };
1029
1030 tracing::info!(
1031 task_id = task_id,
1032 pr_number,
1033 state = %state,
1034 total,
1035 passing,
1036 failing,
1037 pending,
1038 "CI recovery status check"
1039 );
1040
1041 match state.as_str() {
1042 "success" => {
1043 tracing::info!(
1044 task_id = task_id,
1045 pr_number,
1046 "CI green after recovery — attempting direct merge"
1047 );
1048 match gh.merge_pr(repo, pr_number, true, &head_sha).await {
1049 Ok(()) => {
1050 tracing::info!(
1051 task_id = task_id,
1052 pr_number,
1053 "direct merge succeeded after recovery"
1054 );
1055 return Ok(true);
1056 }
1057 Err(e) => {
1058 let err = e.to_string().to_ascii_lowercase();
1059 tracing::warn!(
1060 task_id = task_id,
1061 pr_number,
1062 err = %e,
1063 "direct merge failed after recovery"
1064 );
1065 if err.contains("already merged")
1068 || err.contains("no merge commit")
1069 || err.contains("pull request already merged")
1070 {
1071 return Ok(true);
1072 }
1073 if err.contains("head branch was modified")
1075 || err.contains("sha does not match")
1076 {
1077 tracing::info!(
1078 task_id = task_id,
1079 pr_number,
1080 outcome = "poll_exited_without_merge",
1081 reason = "head_moved",
1082 "merge rejected because the head sha changed"
1083 );
1084 return Ok(false);
1085 }
1086 if (err.contains("head branch is out of date")
1090 || err.contains("behind")
1091 || err.contains("409"))
1092 && behind_retries < MAX_BEHIND_RETRIES
1093 {
1094 behind_retries += 1;
1095 match gh.update_pr_branch(repo, pr_number).await {
1096 Ok(()) => {
1097 tracing::info!(
1098 task_id = task_id,
1099 pr_number,
1100 behind_retries,
1101 "merge rejected due to out-of-date branch — updated and continuing recovery poll"
1102 );
1103 continue;
1104 }
1105 Err(update_err) => {
1106 tracing::warn!(
1107 task_id = task_id,
1108 pr_number,
1109 behind_retries,
1110 err = %update_err,
1111 outcome = "branch_update_failed",
1112 "failed to update out-of-date PR branch during recovery poll"
1113 );
1114 }
1115 }
1116 }
1117 tracing::info!(
1118 task_id = task_id,
1119 pr_number,
1120 outcome = "poll_exited_without_merge",
1121 reason = "direct_merge_failed",
1122 "direct merge did not succeed — leaving for next recovery cycle"
1123 );
1124 return Ok(false);
1125 }
1126 }
1127 }
1128 "failure" => {
1129 tracing::info!(
1130 task_id = task_id,
1131 pr_number,
1132 outcome = "poll_exited_without_merge",
1133 reason = "ci_failure",
1134 "CI still failing after recovery — will retry later"
1135 );
1136 return Ok(false);
1137 }
1138 _ => {}
1139 }
1140
1141 if start.elapsed() >= max_wait {
1142 tracing::info!(
1143 task_id = task_id,
1144 pr_number,
1145 outcome = "poll_exited_without_merge",
1146 reason = "poll_timeout",
1147 "CI still pending after recovery poll timeout — will retry later"
1148 );
1149 return Ok(false);
1150 }
1151
1152 poll_count += 1;
1153 let multiplier = (2_u64).pow(poll_count.saturating_sub(1)).min(4);
1154 let sleep_secs = base_interval_secs.saturating_mul(multiplier);
1155 tokio::time::sleep(std::time::Duration::from_secs(sleep_secs)).await;
1156 }
1157}
1158
1159const REBROADCAST_BLOCK_REASON: &str = "review agent rebroadcast escalated after repeated retries";
1166
1167async fn auto_recover_rebroadcast_blocked_tasks(
1188 repo: &str,
1189 router: &Arc<RwLock<Router>>,
1190 task_manager: &Arc<TaskManager>,
1191 store: &Arc<TaskStore>,
1192) -> anyhow::Result<()> {
1193 const MIN_BLOCK_AGE_MINUTES: i64 = 5;
1197
1198 let (any_routable, available_agent_count, healthy_review_agents) = {
1201 let r = router.read().await;
1202 let healthy_review_agents = r.healthy_agent_count("review");
1203 (
1204 !r.available_agents.is_empty() && healthy_review_agents > 0,
1205 r.available_agents.len(),
1206 healthy_review_agents,
1207 )
1208 };
1209 if !any_routable {
1210 tracing::debug!(
1211 repo,
1212 available_agent_count,
1213 healthy_review_agents,
1214 "rebroadcast-recovery: skipping this tick — no routable review agents"
1215 );
1216 return Ok(());
1217 }
1218
1219 let blocked = match store.list_by_status(repo, TaskStatus::Blocked).await {
1220 Ok(tasks) => tasks,
1221 Err(e) => {
1222 tracing::warn!(err = %e, "failed to list blocked tasks — skipping rebroadcast recovery this tick");
1223 return Ok(());
1224 }
1225 };
1226
1227 let now = chrono::Utc::now();
1228 let rebroadcast_blocked_count = blocked
1229 .iter()
1230 .filter(|t| t.block_reason.as_deref() == Some(REBROADCAST_BLOCK_REASON))
1231 .count();
1232 let candidates: Vec<_> = blocked
1233 .into_iter()
1234 .filter(|t| {
1235 if t.block_reason.as_deref() != Some(REBROADCAST_BLOCK_REASON) {
1236 return false;
1237 }
1238 if let Ok(updated_at) = chrono::DateTime::parse_from_rfc3339(&t.updated_at) {
1241 let age = now - updated_at.with_timezone(&chrono::Utc);
1242 age.num_minutes() >= MIN_BLOCK_AGE_MINUTES
1243 } else {
1244 true
1246 }
1247 })
1248 .collect();
1249
1250 if candidates.is_empty() {
1251 if rebroadcast_blocked_count > 0 {
1252 tracing::debug!(
1253 repo,
1254 rebroadcast_blocked_count,
1255 min_block_age_minutes = MIN_BLOCK_AGE_MINUTES,
1256 "rebroadcast-recovery: skipping this tick — matching blocked tasks found but none old enough yet"
1257 );
1258 }
1259 return Ok(());
1260 }
1261
1262 tracing::info!(
1263 count = candidates.len(),
1264 "sync: auto-recovering rebroadcast-blocked tasks — review agents now available"
1265 );
1266
1267 for task in candidates {
1268 let ext_id = task
1269 .external_id
1270 .clone()
1271 .unwrap_or_else(|| format!("internal:{}", task.id));
1272
1273 if let Err(e) = task_manager
1274 .update_task_status(
1275 &crate::backends::ExternalId(ext_id.clone()),
1276 Status::NeedsReview,
1277 )
1278 .await
1279 {
1280 tracing::warn!(
1281 task_id = task.id,
1282 err = %e,
1283 "rebroadcast-recovery: failed to reset task to needs_review — skipping"
1284 );
1285 continue;
1286 }
1287
1288 let fields: &[(&str, serde_json::Value)] = &[
1289 ("block_reason", serde_json::Value::Null),
1290 ("needs_review_refires", serde_json::json!(0)),
1291 ];
1292 if let Err(e) = store.set_fields(task.id, fields).await {
1293 tracing::warn!(
1294 task_id = task.id,
1295 err = %e,
1296 "rebroadcast-recovery: failed to clear block_reason/needs_review_refires after status reset"
1297 );
1298 }
1299
1300 tracing::info!(
1301 task_id = task.id,
1302 ext_id = %ext_id,
1303 "auto-recovering rebroadcast-blocked task — review agents now available"
1304 );
1305 }
1306
1307 Ok(())
1308}
1309
1310pub(crate) async fn auto_recover_rebroadcast_blocked_tasks_global(
1333 router: &Router,
1334 task_manager: &Arc<TaskManager>,
1335 store: &Arc<TaskStore>,
1336) -> anyhow::Result<()> {
1337 const MIN_BLOCK_AGE_MINUTES: i64 = 5;
1338
1339 let all_blocked = match store.list_all_by_status_global(TaskStatus::Blocked).await {
1340 Ok(tasks) => tasks,
1341 Err(e) => {
1342 tracing::warn!(err = %e, "global rebroadcast-blocked sweep: failed to list blocked tasks");
1343 return Ok(());
1344 }
1345 };
1346
1347 let now = chrono::Utc::now();
1348 let candidates: Vec<_> = all_blocked
1349 .into_iter()
1350 .filter(|t| {
1351 if t.block_reason.as_deref() != Some(REBROADCAST_BLOCK_REASON) {
1352 return false;
1353 }
1354 match chrono::DateTime::parse_from_rfc3339(&t.updated_at) {
1356 Ok(updated_at) => {
1357 let age = now - updated_at.with_timezone(&chrono::Utc);
1358 age.num_minutes() >= MIN_BLOCK_AGE_MINUTES
1359 }
1360 Err(_) => true,
1361 }
1362 })
1363 .collect();
1364
1365 if candidates.is_empty() {
1366 return Ok(());
1367 }
1368
1369 let gh = match crate::github::http::GhHttp::new() {
1370 Ok(g) => Some(g),
1371 Err(e) => {
1372 tracing::warn!(err = %e, "global rebroadcast-blocked sweep: failed to create GhHttp — skipping PR-state checks this tick");
1373 None
1374 }
1375 };
1376
1377 let any_routable =
1381 !router.available_agents.is_empty() && router.healthy_agent_count("review") > 0;
1382
1383 for task in candidates {
1384 let ext_id = task
1385 .external_id
1386 .clone()
1387 .unwrap_or_else(|| format!("internal:{}", task.id));
1388
1389 if let (Some(gh), Some(pr_number)) = (&gh, task.pr_number) {
1390 match gh.get_pr(&task.repo, pr_number as u64).await {
1391 Ok(pr) => {
1392 if pr.merged.unwrap_or(false) {
1393 tracing::info!(
1394 task_id = task.id,
1395 repo = %task.repo,
1396 pr_number,
1397 "global rebroadcast-blocked sweep: PR already merged — marking task done"
1398 );
1399 if let Err(e) = task_manager
1400 .mark_task_done_by_store_id(
1401 task.id,
1402 &task.repo,
1403 &crate::backends::ExternalId(ext_id.clone()),
1404 )
1405 .await
1406 {
1407 tracing::warn!(task_id = task.id, repo = %task.repo, err = %e, "global rebroadcast-blocked sweep: failed to mark merged task done");
1408 continue;
1409 }
1410 let fields: &[(&str, serde_json::Value)] = &[
1411 ("block_reason", serde_json::Value::Null),
1412 ("needs_review_refires", serde_json::json!(0)),
1413 ];
1414 if let Err(e) = store.set_fields(task.id, fields).await {
1415 tracing::warn!(task_id = task.id, err = %e, "global rebroadcast-blocked sweep: failed to clear fields after marking done");
1416 }
1417 continue;
1418 }
1419 if pr.state.eq_ignore_ascii_case("closed") {
1420 tracing::info!(
1421 task_id = task.id,
1422 repo = %task.repo,
1423 pr_number,
1424 "global rebroadcast-blocked sweep: PR closed unmerged — leaving task blocked, not re-dispatching"
1425 );
1426 let new_reason = format!(
1427 "{REBROADCAST_BLOCK_REASON} (PR #{pr_number} closed unmerged — no further auto-recovery)"
1428 );
1429 let fields: &[(&str, serde_json::Value)] =
1430 &[("block_reason", serde_json::json!(new_reason))];
1431 if let Err(e) = store.set_fields_silent(task.id, fields).await {
1435 tracing::warn!(task_id = task.id, err = %e, "global rebroadcast-blocked sweep: failed to annotate closed-unmerged block reason");
1436 }
1437 continue;
1438 }
1439 }
1441 Err(e) => {
1442 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");
1443 continue;
1444 }
1445 }
1446 }
1447
1448 if !any_routable {
1449 continue;
1450 }
1451
1452 if let Err(e) = task_manager
1453 .update_task_status_by_store_id(
1454 task.id,
1455 &task.repo,
1456 &crate::backends::ExternalId(ext_id.clone()),
1457 Status::NeedsReview,
1458 )
1459 .await
1460 {
1461 tracing::warn!(task_id = task.id, repo = %task.repo, err = %e, "global rebroadcast-blocked sweep: failed to reset task to needs_review — skipping");
1462 continue;
1463 }
1464
1465 let fields: &[(&str, serde_json::Value)] = &[
1466 ("block_reason", serde_json::Value::Null),
1467 ("needs_review_refires", serde_json::json!(0)),
1468 ];
1469 if let Err(e) = store.set_fields(task.id, fields).await {
1470 tracing::warn!(task_id = task.id, err = %e, "global rebroadcast-blocked sweep: failed to clear block_reason/needs_review_refires after status reset");
1471 }
1472
1473 tracing::info!(
1474 task_id = task.id,
1475 repo = %task.repo,
1476 ext_id = %ext_id,
1477 "global rebroadcast-blocked sweep: auto-recovered rebroadcast-blocked task — review agents now available"
1478 );
1479 }
1480
1481 Ok(())
1482}
1483
1484async fn auto_unblock_blocked_tasks(
1485 repo: &str,
1486 task_manager: &Arc<TaskManager>,
1487 store: &Arc<TaskStore>,
1488 dispatching: &Arc<DashMap<String, String>>,
1489 auto_merge_in_flight: &Arc<DashSet<String>>,
1490) -> anyhow::Result<()> {
1491 let blocked = match store.list_by_status(repo, TaskStatus::Blocked).await {
1492 Ok(tasks) => tasks,
1493 Err(e) => {
1494 tracing::warn!(err = %e, "failed to list blocked tasks — skipping auto-unblock this tick");
1495 return Ok(());
1496 }
1497 };
1498
1499 if blocked.is_empty() {
1500 return Ok(());
1501 }
1502
1503 let task_ids: Vec<i64> = blocked.iter().map(|t| t.id).collect();
1505 let mut runs_by_task = match store.get_runs_batch(&task_ids).await {
1506 Ok(map) => map,
1507 Err(e) => {
1508 tracing::warn!(err = %e, "get_runs_batch failed — skipping auto-unblock this tick");
1509 return Ok(());
1510 }
1511 };
1512
1513 let (ci_failure_tasks, other_blocked): (Vec<_>, Vec<_>) =
1515 blocked.into_iter().partition(is_ci_failure_block);
1516
1517 if !ci_failure_tasks.is_empty() {
1519 let gh = match crate::github::http::GhHttp::new() {
1520 Ok(g) => Some(g),
1521 Err(e) => {
1522 tracing::warn!(err = %e, "failed to create GhHttp — skipping CI failure unblock this tick");
1523 None
1524 }
1525 };
1526 for task in ci_failure_tasks {
1527 if ci_failure_unblock_cooldown_elapsed(&task) {
1528 if let Some(ref gh) = gh {
1529 if let Err(e) = try_unblock_ci_failure_task(
1530 gh,
1531 repo,
1532 task_manager,
1533 store,
1534 &task,
1535 auto_merge_in_flight,
1536 )
1537 .await
1538 {
1539 tracing::warn!(task_id = task.id, err = %e, "CI failure unblock check failed");
1540 }
1541 }
1542 }
1543 }
1544 }
1545
1546 for task in other_blocked {
1548 if task.block_reason.is_some() {
1549 continue;
1550 }
1551
1552 let task_id_str = task
1553 .external_id
1554 .clone()
1555 .unwrap_or_else(|| format!("internal:{}", task.id));
1556 let dispatch_key = format!("{}/{}", repo, task_id_str);
1557 if dispatching.contains_key(&dispatch_key) {
1558 continue;
1559 }
1560
1561 let runs = runs_by_task.remove(&task.id).unwrap_or_default();
1562 let relevant_runs: Vec<_> = runs
1563 .into_iter()
1564 .filter(|r| r.run_type == "agent" || r.run_type == "review")
1565 .collect();
1566 let mut failures = Vec::new();
1567 let mut has_review_failure = false;
1568 for run in relevant_runs.iter().rev() {
1569 if let Some(category) = classify_failure_from_run(run) {
1570 if run.run_type == "review" {
1571 has_review_failure = true;
1572 }
1573 failures.push(category);
1574 }
1575 if failures.len() >= 3 {
1576 break;
1577 }
1578 }
1579
1580 if failures.is_empty() || !failures.iter().all(|f| f.is_recoverable()) {
1581 continue;
1582 }
1583
1584 let reason_key = failures.first().map(|f| f.as_str()).unwrap_or("");
1586
1587 let current_reason = task.auto_unblock_last_reason.clone();
1590 let reason_changed = reason_key != current_reason;
1591
1592 let cooldown_count = if reason_changed {
1595 0
1596 } else {
1597 task.auto_unblock_count
1598 };
1599
1600 if cooldown_count >= 3 {
1604 continue;
1605 }
1606
1607 if !auto_unblock_cooldown_elapsed(cooldown_count, &task.auto_unblock_last_at) {
1608 continue;
1609 }
1610
1611 let new_count = cooldown_count + 1;
1615 let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
1616
1617 let ext_id = task
1618 .external_id
1619 .clone()
1620 .unwrap_or_else(|| format!("internal:{}", task.id));
1621 let new_status = if has_review_failure {
1622 Status::NeedsReview
1623 } else {
1624 Status::New
1625 };
1626 let new_status_str = if has_review_failure {
1627 "needs_review"
1628 } else {
1629 "new"
1630 };
1631
1632 if let Err(e) = task_manager
1635 .update_task_status(&crate::backends::ExternalId(ext_id), new_status)
1636 .await
1637 {
1638 tracing::warn!(
1639 task_id = task.id,
1640 err = %e,
1641 "auto-unblock failed to update status — counter not incremented"
1642 );
1643 continue;
1644 }
1645
1646 let mut fields: Vec<(&str, serde_json::Value)> = vec![
1648 ("auto_unblock_count", serde_json::json!(new_count)),
1649 ("auto_unblock_last_at", serde_json::json!(now)),
1650 ("auto_unblock_last_reason", serde_json::json!(reason_key)),
1651 ("agent", serde_json::Value::Null),
1652 ("model", serde_json::Value::Null),
1653 ];
1654 fields.push(("no_code_reroutes", serde_json::json!(0)));
1655 fields.push(("no_code_last_agent", serde_json::json!("")));
1656 if has_review_failure {
1657 fields.push(("review_agent_failures", serde_json::json!(0)));
1658 fields.push(("review_invocations", serde_json::json!(0)));
1659 }
1660 if let Err(e) = store.set_fields(task.id, &fields).await {
1661 tracing::warn!(task_id = task.id, err = %e, "failed to set auto_unblock fields after successful status update");
1662 }
1665
1666 if let Err(e) = store
1667 .append_activity(
1668 task.id,
1669 "auto_unblock",
1670 Some("blocked"),
1671 Some(new_status_str),
1672 None,
1673 None,
1674 Some(&serde_json::json!({
1675 "reason": reason_key,
1676 "count": if reason_changed { 1 } else { task.auto_unblock_count + 1 },
1677 "failures": failures.iter().map(|f| format!("{f:?}")).collect::<Vec<_>>(),
1678 })),
1679 )
1680 .await
1681 {
1682 tracing::warn!(task_id = task.id, err = %e, "failed to log auto_unblock activity");
1683 }
1684
1685 tracing::info!(
1686 task_id = task.id,
1687 failures = ?failures,
1688 review_failure = has_review_failure,
1689 "auto-unblocked task with recoverable failures"
1690 );
1691 }
1692
1693 Ok(())
1694}
1695
1696pub(crate) async fn auto_unblock_ci_failure_blocked_tasks_global(
1707 task_manager: &Arc<crate::engine::tasks::TaskManager>,
1708 store: &Arc<TaskStore>,
1709 auto_merge_in_flight: &Arc<DashSet<String>>,
1710) -> anyhow::Result<()> {
1711 let all_blocked = match store.list_all_by_status_global(TaskStatus::Blocked).await {
1712 Ok(tasks) => tasks,
1713 Err(e) => {
1714 tracing::warn!(err = %e, "global CI-failure blocked sweep: failed to list blocked tasks");
1715 return Ok(());
1716 }
1717 };
1718
1719 let ci_failure_tasks: Vec<_> = all_blocked
1720 .into_iter()
1721 .filter(is_ci_failure_block)
1722 .collect();
1723 if ci_failure_tasks.is_empty() {
1724 return Ok(());
1725 }
1726
1727 let gh = match crate::github::http::GhHttp::new() {
1728 Ok(g) => g,
1729 Err(e) => {
1730 tracing::warn!(err = %e, "global CI-failure blocked sweep: failed to create GhHttp");
1731 return Ok(());
1732 }
1733 };
1734
1735 for task in ci_failure_tasks {
1736 if !ci_failure_unblock_cooldown_elapsed(&task) {
1737 continue;
1738 }
1739 if let Err(e) = try_unblock_ci_failure_task(
1740 &gh,
1741 &task.repo,
1742 task_manager,
1743 store,
1744 &task,
1745 auto_merge_in_flight,
1746 )
1747 .await
1748 {
1749 tracing::warn!(
1750 task_id = task.id,
1751 repo = %task.repo,
1752 err = %e,
1753 "global CI-failure blocked sweep: unblock check failed"
1754 );
1755 }
1756 }
1757
1758 Ok(())
1759}
1760
1761pub(crate) async fn refire_and_escalate_stale_needs_review_global(
1774 task_manager: &Arc<TaskManager>,
1775 store: &Arc<TaskStore>,
1776 router: &Router,
1777 dispatching: &Arc<DashMap<String, String>>,
1778) -> anyhow::Result<()> {
1779 const MIN_STALE_NEEDS_REVIEW_MINUTES: i64 = 1;
1780 const MAX_NEEDS_REVIEW_REFIRE_ATTEMPTS: u64 = 5;
1781
1782 let needs_review_tasks = match store
1783 .list_all_by_status_global(TaskStatus::NeedsReview)
1784 .await
1785 {
1786 Ok(tasks) => tasks,
1787 Err(e) => {
1788 tracing::warn!(err = %e, "global NeedsReview refire sweep: failed to list needs_review tasks");
1789 return Ok(());
1790 }
1791 };
1792
1793 if needs_review_tasks.is_empty() {
1794 return Ok(());
1795 }
1796
1797 let all_review_agents_cooled = router.healthy_agent_count("review") == 0;
1801 if all_review_agents_cooled {
1802 tracing::info!(
1803 count = needs_review_tasks.len(),
1804 "global NeedsReview refire sweep: no review agent available — skipping this pass"
1805 );
1806 return Ok(());
1807 }
1808
1809 for stored in needs_review_tasks {
1810 let external = crate::engine::tasks::store_task_to_external(&stored);
1811 let ext_id = external.id.clone();
1812 let repo = stored.repo.clone();
1813
1814 let age_minutes = match chrono::DateTime::parse_from_rfc3339(&external.updated_at) {
1815 Ok(updated_at) => {
1816 let age = chrono::Utc::now() - updated_at.with_timezone(&chrono::Utc);
1817 if age.num_minutes() < MIN_STALE_NEEDS_REVIEW_MINUTES {
1818 continue;
1819 }
1820 Some(age.num_minutes())
1821 }
1822 Err(_) => None,
1823 };
1824
1825 let dispatch_key = format!("{}/{}", repo, ext_id.0);
1827 if dispatching.contains_key(&dispatch_key) {
1828 continue;
1829 }
1830
1831 let current_refires = stored.needs_review_refires.max(0) as u64;
1832 let new_refires = current_refires + 1;
1833
1834 if new_refires > MAX_NEEDS_REVIEW_REFIRE_ATTEMPTS {
1835 tracing::warn!(
1836 task_id = stored.id,
1837 repo = %repo,
1838 new_refires,
1839 "global NeedsReview refire sweep: escalating to Blocked after repeated refires"
1840 );
1841 let fields = [
1842 ("block_reason", serde_json::json!(REBROADCAST_BLOCK_REASON)),
1843 (
1844 "last_error",
1845 serde_json::json!(format!("escalated after {} retries", new_refires)),
1846 ),
1847 ];
1848 if let Err(e) = task_manager
1849 .update_task_status_and_result_by_store_id(
1850 stored.id,
1851 &repo,
1852 &ext_id,
1853 Status::Blocked,
1854 &fields,
1855 )
1856 .await
1857 {
1858 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");
1859 continue;
1860 }
1861 if let Err(e) = crate::store::store_increment_by_id(
1862 &Some(Arc::clone(store)),
1863 stored.id,
1864 "needs_review_refires",
1865 )
1866 .await
1867 {
1868 tracing::warn!(
1869 task_id = stored.id,
1870 repo = %repo,
1871 err = %e,
1872 "global NeedsReview refire sweep: failed to increment needs_review_refires after escalation"
1873 );
1874 }
1875 continue;
1876 }
1877
1878 let required_minutes = MIN_STALE_NEEDS_REVIEW_MINUTES * (1i64 << (current_refires as u32));
1881
1882 let should_fire = match age_minutes {
1883 Some(age) => age >= required_minutes,
1884 None => true,
1885 };
1886
1887 if !should_fire {
1888 continue;
1889 }
1890
1891 if let Err(e) = task_manager
1892 .update_task_status_by_store_id(stored.id, &repo, &ext_id, Status::NeedsReview)
1893 .await
1894 {
1895 tracing::warn!(
1896 task_id = stored.id,
1897 repo = %repo,
1898 err = %e,
1899 "global NeedsReview refire sweep: failed to re-fire NeedsReview event — not incrementing counter"
1900 );
1901 continue;
1902 }
1903
1904 let fired_refires = match crate::store::store_increment_by_id(
1905 &Some(Arc::clone(store)),
1906 stored.id,
1907 "needs_review_refires",
1908 )
1909 .await
1910 {
1911 Ok(v) => v,
1912 Err(e) => {
1913 tracing::warn!(
1914 task_id = stored.id,
1915 repo = %repo,
1916 err = %e,
1917 "global NeedsReview refire sweep: failed to increment needs_review_refires after re-fire"
1918 );
1919 current_refires + 1
1920 }
1921 };
1922
1923 tracing::info!(
1924 task_id = stored.id,
1925 repo = %repo,
1926 age_minutes,
1927 refires = fired_refires,
1928 required_minutes,
1929 "global NeedsReview refire sweep: re-firing NeedsReview event for stale task"
1930 );
1931 }
1932
1933 Ok(())
1934}
1935
1936#[allow(clippy::too_many_arguments)]
1943pub(crate) async fn sync_tick(
1944 backend: &Arc<dyn ExternalBackend>,
1945 tmux: &Arc<TmuxManager>,
1946 repo: &str,
1947 config: &EngineConfig,
1948 router: &Arc<RwLock<Router>>,
1949 task_manager: &Arc<TaskManager>,
1950 store: &Arc<TaskStore>,
1951 dispatching: &Arc<DashMap<String, String>>,
1952 auto_merge_in_flight: &Arc<DashSet<String>>,
1953) -> anyhow::Result<()> {
1954 if !store.is_leader() {
1956 return Ok(());
1957 }
1958
1959 tracing::debug!("sync tick");
1960
1961 let gh_circuit_open = is_github_circuit_open();
1964
1965 if gh_circuit_open {
1966 let remaining = github_circuit_remaining_secs();
1967 tracing::debug!(
1968 remaining_secs = remaining,
1969 "GitHub 5xx circuit breaker open — skipping non-critical sync work"
1970 );
1971 if let Err(e) =
1974 try_kv_set_prefer_store(&Some(store), "metrics:orch.github_5xx_circuit.open", "1").await
1975 {
1976 tracing::error!(err = %e, "failed to persist circuit breaker state — may cause thundering herd after restart");
1977 }
1978 } else if let Err(e) =
1979 try_kv_set_prefer_store(&Some(store), "metrics:orch.github_5xx_circuit.open", "0").await
1980 {
1981 tracing::error!(err = %e, "failed to clear circuit breaker state");
1982 }
1983
1984 if !gh_circuit_open {
1987 if let Err(e) = ingest_external_tasks(backend, repo, store).await {
1988 tracing::debug!(err = %e, "external task ingest failed");
1989 }
1990 if let Err(e) = backend.reconcile_project_statuses().await {
1994 tracing::warn!(err = %e, "project status reconciliation failed");
1995 }
1996 }
1997
1998 if !gh_circuit_open {
2000 let backend = Arc::clone(backend);
2001 let repo = repo.to_string();
2002 let task_manager = Arc::clone(task_manager);
2003 let store = Arc::clone(store);
2004 tokio::spawn(async move {
2005 if let Err(e) = cleanup_done_worktrees(&backend, &repo, &task_manager, &store).await {
2006 tracing::warn!(err = %e, "worktree cleanup failed");
2007 }
2008 });
2009 }
2010
2011 let (mut in_review_tasks, mut needs_review_tasks, blocked_review_candidates) =
2012 match prefetch_review_tasks(store, repo).await {
2013 Ok(tasks) => tasks,
2014 Err(e) => {
2015 tracing::warn!(err = %e, "failed to prefetch review tasks");
2016 (vec![], vec![], vec![])
2017 }
2018 };
2019
2020 if !gh_circuit_open {
2021 let mention_fallback = chrono::Utc::now() - chrono::Duration::hours(24);
2022 let mention_since = kv_get_prefer_store(&Some(store), "mentions_last_checked")
2023 .await
2024 .unwrap_or_else(|| mention_fallback.format("%Y-%m-%dT%H:%M:%SZ").to_string());
2025 let shared_since = mention_since.clone();
2026
2027 let cached_router_config = router.read().await.config.clone();
2028 let mut combined_needs = needs_review_tasks.clone();
2030 combined_needs.extend(blocked_review_candidates.clone());
2031
2032 let (merge_result, comments_result, review_result) = tokio::join!(
2033 check_merged_prs(
2034 backend,
2035 repo,
2036 store,
2037 task_manager,
2038 &in_review_tasks,
2039 &combined_needs,
2040 ),
2041 fetch_comments_since(backend, &shared_since),
2042 async {
2043 let gh = match crate::github::http::GhHttp::new() {
2044 Ok(g) => g,
2045 Err(e) => {
2046 tracing::warn!(err = %e, "failed to create GitHub HTTP client for review poll");
2047 return Ok(());
2048 }
2049 };
2050 review_open_prs(
2051 backend,
2052 repo,
2053 config,
2054 task_manager,
2055 store,
2056 dispatching,
2057 auto_merge_in_flight,
2058 &in_review_tasks,
2059 &gh,
2060 &cached_router_config,
2061 )
2062 .await
2063 }
2064 );
2065
2066 if let Err(e) = merge_result {
2067 tracing::warn!(err = %e, "PR merge check failed");
2068 }
2069
2070 match comments_result {
2071 Ok(comments) => {
2072 let filtered = filter_mentions_by_since(&comments, &mention_since);
2073
2074 if let Err(e) =
2075 scan_comments(backend, Some(store), repo, task_manager, Some(&filtered)).await
2076 {
2077 tracing::warn!(err = %e, "comment scan failed");
2078 }
2079 }
2080 Err(e) => {
2081 tracing::warn!(err = %e, "comment fetch failed");
2082 }
2083 }
2084
2085 if let Err(e) = review_result {
2086 tracing::warn!(err = %e, "PR review failed");
2087 }
2088
2089 match prefetch_review_tasks(store, repo).await {
2090 Ok((latest_in_review, latest_needs_review, _latest_blocked_candidates)) => {
2091 in_review_tasks = latest_in_review;
2092 needs_review_tasks = latest_needs_review;
2093 }
2094 Err(e) => {
2095 tracing::warn!(err = %e, "failed to refresh review task cache after sync work");
2096 }
2097 }
2098 }
2099
2100 let enable_review = config::get("workflow.enable_review_agent")
2108 .map(|v| v != "false")
2109 .unwrap_or(true);
2110 if enable_review {
2111 let live_sessions: std::collections::HashSet<String> = tmux
2115 .list_sessions()
2116 .await
2117 .unwrap_or_default()
2118 .into_iter()
2119 .map(|s| s.name)
2120 .collect();
2121 for task in &in_review_tasks {
2122 let dispatch_key = format!("{}/{}", repo, task.task_id());
2124 {
2125 if dispatching.contains_key(&dispatch_key) {
2126 tracing::debug!(
2127 task_id = task.task_id(),
2128 "task locked by dispatch flow, skipping stale check"
2129 );
2130 continue;
2131 }
2132 }
2133 const MIN_STALE_MINUTES: i64 = 1;
2137 let entered_at = match store.in_review_entry(task.stored.id).await {
2140 Ok(Some((_, true))) => {
2141 tracing::debug!(
2142 task_id = task.task_id(),
2143 "InReview task already has a review decision, waiting on merge"
2144 );
2145 continue;
2146 }
2147 Ok(Some((entered_at, false))) => entered_at,
2148 Ok(None) | Err(_) => task.external.updated_at.clone(),
2149 };
2150 match chrono::DateTime::parse_from_rfc3339(&entered_at) {
2151 Ok(updated_at) => {
2152 let age = chrono::Utc::now() - updated_at.with_timezone(&chrono::Utc);
2153 if age.num_minutes() < MIN_STALE_MINUTES {
2154 tracing::debug!(
2155 task_id = task.task_id(),
2156 age_seconds = age.num_seconds(),
2157 "InReview task is too young to be considered stale, skipping"
2158 );
2159 continue;
2160 }
2161 }
2162 Err(e) => {
2163 tracing::warn!(
2164 task_id = task.task_id(),
2165 ts = %entered_at,
2166 err = %e,
2167 "invalid in_review timestamp — treating task as potentially stale"
2168 );
2169 }
2170 }
2171
2172 let review_task_id = format!("{}-review", task.task_id());
2173 let review_session = tmux.session_name(repo, &review_task_id);
2174 if live_sessions.contains(&review_session) {
2175 continue;
2177 }
2178
2179 {
2183 tracing::warn!(
2184 task_id = task.task_id(),
2185 session = %review_session,
2186 "InReview task has no active review session — resetting to NeedsReview"
2187 );
2188 if let Err(e) = store::store_set_result(
2192 &Some(Arc::clone(store)),
2193 repo,
2194 task.task_id(),
2195 &[("review_agent_failures", serde_json::json!(0))],
2196 )
2197 .await
2198 {
2199 tracing::error!(task_id = task.task_id(), err = %e, "failed to write review_agent_failures to store");
2200 }
2201 match task_manager
2202 .update_task_status_if(&task.external.id, Status::NeedsReview, Status::InReview)
2203 .await
2204 {
2205 Err(e) => {
2206 tracing::error!(task_id = task.task_id(), err = %e, "failed to reset stale InReview task — task may be stuck in InReview indefinitely");
2207 }
2208 Ok(false) => {
2209 tracing::debug!(
2210 task_id = task.task_id(),
2211 "stale InReview reset skipped — task already transitioned (concurrent Done/Blocked/InProgress)"
2212 );
2213 }
2214 Ok(true) => {
2215 store::set_review_session_expected(store, repo, task.task_id(), false)
2216 .await;
2217 }
2218 }
2219 }
2220 }
2221 }
2222
2223 let in_progress_tasks: Vec<_> = match store.list_by_status(repo, TaskStatus::InProgress).await {
2229 Ok(tasks) => tasks
2230 .into_iter()
2231 .map(|stored| {
2232 let external = crate::engine::tasks::store_task_to_external(&stored);
2233 (stored, external)
2234 })
2235 .collect(),
2236 Err(e) => {
2237 tracing::warn!(err = %e, "failed to list in_progress tasks — skipping stale InProgress check");
2238 vec![]
2239 }
2240 };
2241
2242 if !in_progress_tasks.is_empty() {
2243 let live_sessions: std::collections::HashSet<String> = tmux
2245 .list_sessions()
2246 .await
2247 .unwrap_or_default()
2248 .into_iter()
2249 .map(|s| s.name)
2250 .collect();
2251
2252 for (_stored, external) in &in_progress_tasks {
2253 let task_id = &external.id.0;
2254
2255 let dispatch_key = format!("{}/{}", repo, task_id);
2257 {
2258 if dispatching.contains_key(&dispatch_key) {
2259 tracing::debug!(
2260 task_id = %task_id,
2261 "task locked by dispatch flow, skipping stale check"
2262 );
2263 continue;
2264 }
2265 }
2266
2267 const MIN_STALE_MINUTES: i64 = 5;
2271 match chrono::DateTime::parse_from_rfc3339(&external.updated_at) {
2272 Ok(updated_at) => {
2273 let age = chrono::Utc::now() - updated_at.with_timezone(&chrono::Utc);
2274 if age.num_minutes() < MIN_STALE_MINUTES {
2275 tracing::debug!(
2276 task_id = %task_id,
2277 age_seconds = age.num_seconds(),
2278 "InProgress task is too young to be considered stale, skipping"
2279 );
2280 continue;
2281 }
2282 }
2283 Err(e) => {
2284 tracing::warn!(
2285 task_id = %task_id,
2286 ts = %external.updated_at,
2287 err = %e,
2288 "invalid updated_at timestamp — treating task as potentially stale"
2289 );
2290 }
2291 }
2292
2293 let agent_session = tmux.session_name(repo, task_id);
2294 if live_sessions.contains(&agent_session) {
2295 continue;
2297 }
2298
2299 {
2303 tracing::warn!(
2304 task_id = %task_id,
2305 session = %agent_session,
2306 "InProgress task has no active agent session — resetting to Routed"
2307 );
2308 match task_manager
2309 .update_task_status_if(&external.id, Status::Routed, Status::InProgress)
2310 .await
2311 {
2312 Err(e) => {
2313 tracing::error!(task_id = %task_id, err = %e, "failed to reset stale InProgress task — task may be stuck in InProgress indefinitely");
2314 }
2315 Ok(false) => {
2316 tracing::debug!(
2317 task_id = %task_id,
2318 "stale InProgress reset skipped — task already transitioned (concurrent Done/Blocked/NeedsReview)"
2319 );
2320 }
2321 Ok(true) => {
2322 }
2324 }
2325 }
2326 }
2327 }
2328
2329 if enable_review {
2343 const MIN_STALE_NEEDS_REVIEW_MINUTES: i64 = 1;
2344 const MAX_NEEDS_REVIEW_REFIRE_ATTEMPTS: u64 = 5; let needs_review_count = needs_review_tasks.len();
2346 if needs_review_count > 0 {
2347 tracing::info!(
2348 count = needs_review_count,
2349 "sync catch-up: checking stale NeedsReview tasks"
2350 );
2351 } else {
2352 tracing::debug!(
2353 count = needs_review_count,
2354 "sync catch-up: checking stale NeedsReview tasks"
2355 );
2356 }
2357 let all_review_agents_cooled = {
2363 let r = router.read().await;
2364 r.healthy_agent_count("review") == 0
2365 };
2366 let skip_refires = all_review_agents_cooled && needs_review_count > 0;
2367 if skip_refires {
2368 tracing::info!(
2369 count = needs_review_count,
2370 "sync catch-up: all review agents currently cooled — skipping re-fire this tick (counter not incremented)"
2371 );
2372 }
2373 for task in &needs_review_tasks {
2374 if skip_refires {
2375 break;
2376 }
2377 let age_minutes = if let Ok(updated_at) =
2382 chrono::DateTime::parse_from_rfc3339(&task.external.updated_at)
2383 {
2384 let age = chrono::Utc::now() - updated_at.with_timezone(&chrono::Utc);
2385 if age.num_minutes() < MIN_STALE_NEEDS_REVIEW_MINUTES {
2386 continue;
2387 }
2388 Some(age.num_minutes())
2389 } else {
2390 None
2391 };
2392
2393 let dispatch_key = format!("{}/{}", repo, task.task_id());
2395 if dispatching.contains_key(&dispatch_key) {
2396 continue;
2397 }
2398 let current_refires = task.stored.needs_review_refires.max(0) as u64;
2400 let new_refires = current_refires + 1;
2401
2402 if new_refires > MAX_NEEDS_REVIEW_REFIRE_ATTEMPTS {
2407 tracing::warn!(
2408 task_id = task.task_id(),
2409 new_refires,
2410 "escalating NeedsReview task to Blocked after repeated refires"
2411 );
2412 let fields = [
2413 ("block_reason", serde_json::json!(REBROADCAST_BLOCK_REASON)),
2414 (
2415 "last_error",
2416 serde_json::json!(format!("escalated after {} retries", new_refires)),
2417 ),
2418 ];
2419 if let Err(e) = task_manager
2420 .update_task_status_and_result(&task.external.id, Status::Blocked, &fields)
2421 .await
2422 {
2423 tracing::error!(task_id = task.task_id(), err = %e, "update_task_status_and_result(Blocked) failed — skipping block to avoid silent auto-unblock loop");
2424 continue;
2425 }
2426 if let Err(e) = crate::store::store_increment(
2428 &Some(Arc::clone(store)),
2429 repo,
2430 task.task_id(),
2431 "needs_review_refires",
2432 )
2433 .await
2434 {
2435 tracing::warn!(
2436 task_id = task.task_id(),
2437 err = %e,
2438 "failed to increment needs_review_refires after escalation"
2439 );
2440 }
2441 continue;
2442 }
2443
2444 let required_minutes =
2449 MIN_STALE_NEEDS_REVIEW_MINUTES * (1i64 << (current_refires as u32));
2450
2451 let should_fire = match age_minutes {
2452 Some(age) => age >= required_minutes,
2453 None => true,
2454 };
2455
2456 if !should_fire {
2457 tracing::debug!(
2463 task_id = task.task_id(),
2464 age_minutes,
2465 current_refires,
2466 required_minutes,
2467 "sync catch-up: delaying NeedsReview re-fire due to backoff (no counter increment)"
2468 );
2469 continue;
2470 }
2471
2472 if let Err(e) = task_manager
2476 .update_task_status(&task.external.id, Status::NeedsReview)
2477 .await
2478 {
2479 tracing::warn!(
2480 task_id = task.task_id(),
2481 err = %e,
2482 "sync catch-up: failed to re-fire NeedsReview event — not incrementing counter"
2483 );
2484 continue;
2485 }
2486
2487 let fired_refires = match crate::store::store_increment(
2489 &Some(Arc::clone(store)),
2490 repo,
2491 task.task_id(),
2492 "needs_review_refires",
2493 )
2494 .await
2495 {
2496 Ok(v) => v,
2497 Err(e) => {
2498 tracing::warn!(
2499 task_id = task.task_id(),
2500 err = %e,
2501 "failed to increment needs_review_refires after re-fire"
2502 );
2503 current_refires + 1
2504 }
2505 };
2506
2507 tracing::info!(
2508 task_id = task.task_id(),
2509 age_minutes,
2510 refires = fired_refires,
2511 required_minutes,
2512 "sync catch-up: re-firing NeedsReview event for stale task"
2513 );
2514 }
2515 }
2516
2517 if let Err(e) =
2519 auto_unblock_blocked_tasks(repo, task_manager, store, dispatching, auto_merge_in_flight)
2520 .await
2521 {
2522 tracing::warn!(err = %e, "auto-unblock failed");
2523 }
2524
2525 if enable_review {
2527 if let Err(e) =
2528 auto_recover_rebroadcast_blocked_tasks(repo, router, task_manager, store).await
2529 {
2530 tracing::warn!(err = %e, "rebroadcast-recovery failed");
2531 }
2532 }
2533
2534 match skills_sync().await {
2536 Ok(()) => {
2537 let llm = router.read().await.llm_router_handle();
2542 llm.invalidate_skills_catalog().await;
2543 }
2544 Err(e) => {
2545 tracing::warn!(err = %e, "skills sync failed");
2546 }
2547 }
2548
2549 {
2554 let (available_agents, config) = {
2555 let r = router.read().await;
2556 (r.available_agents.clone(), r.config.clone())
2557 };
2558 let model_checker = |agent: &str| -> bool {
2559 for comp in &["simple", "medium", "complex", "review"] {
2560 if config.has_available_model_for_complexity(agent, comp) {
2561 return true;
2562 }
2563 }
2564 false
2565 };
2566 refresh_degraded_agents(
2567 store,
2568 &available_agents,
2569 &model_checker,
2570 crate::engine::router::config::health_check_window_hours(),
2571 crate::engine::router::config::degraded_rate_limit_threshold(),
2572 )
2573 .await;
2574 }
2575
2576 Ok(())
2577}
2578
2579async fn scan_comments(
2589 backend: &Arc<dyn ExternalBackend>,
2590 store: Option<&Arc<TaskStore>>,
2591 repo: &str,
2592 task_manager: &Arc<TaskManager>,
2593 prefetched_comments: Option<&[Mention]>,
2594) -> anyhow::Result<()> {
2595 let current_user = match backend.get_authenticated_user().await {
2596 Ok(Some(u)) => format!("@{}", u),
2597 Ok(None) => {
2598 tracing::debug!("backend does not support user identity, skipping comment scan");
2599 return Ok(());
2600 }
2601 Err(e) => {
2602 tracing::warn!(err = %e, "failed to get current user, skipping comment scan");
2603 return Ok(());
2604 }
2605 };
2606
2607 let comments = if let Some(c) = prefetched_comments {
2608 c.to_vec()
2609 } else {
2610 let fallback = chrono::Utc::now() - chrono::Duration::hours(24);
2611 let since_str = match kv_get_prefer_store(&store, "mentions_last_checked").await {
2612 Some(ts) => ts,
2613 None => fallback.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
2614 };
2615 match backend.get_mentions(&since_str).await {
2616 Ok(m) => m,
2617 Err(e) => {
2618 tracing::warn!(err = %e, "failed to get comments");
2619 return Ok(());
2620 }
2621 }
2622 };
2623
2624 let existing_mentions: std::collections::HashSet<String> = if let Some(s) = store {
2626 match s.list_source_ids_by_source(repo, "mention", 30).await {
2627 Ok(ids) => ids.into_iter().collect(),
2628 Err(e) => {
2629 tracing::warn!(err = %e, "failed to load mention source IDs — skipping to prevent duplicates");
2630 return Ok(());
2631 }
2632 }
2633 } else {
2634 std::collections::HashSet::new()
2635 };
2636
2637 let gh_cell: tokio::sync::OnceCell<crate::github::http::GhHttp> = tokio::sync::OnceCell::new();
2641 let get_gh = || async {
2642 gh_cell
2643 .get_or_try_init(|| async { crate::github::http::GhHttp::new() })
2644 .await
2645 };
2646
2647 let actions: Vec<CommentAction> = comments
2650 .iter()
2651 .map(|c| {
2652 classify_comment(
2653 &c.body,
2654 c.issue_url.as_deref(),
2655 ¤t_user,
2656 existing_mentions.contains(&c.id),
2657 )
2658 })
2659 .collect();
2660
2661 let pr_check_inputs: Vec<(usize, String)> = actions
2665 .iter()
2666 .enumerate()
2667 .filter_map(|(i, action)| {
2668 if let CommentAction::CreateMentionTask {
2669 issue_num: Some(ref num),
2670 } = action
2671 {
2672 Some((i, num.clone()))
2673 } else {
2674 None
2675 }
2676 })
2677 .collect();
2678
2679 let is_pr_values: Vec<bool> = if pr_check_inputs.is_empty() {
2681 Vec::new()
2682 } else {
2683 let gh = get_gh().await?;
2684 futures::future::join_all(
2685 pr_check_inputs
2686 .iter()
2687 .map(|(_, num)| async move { gh.is_pull_request(repo, num).await }),
2688 )
2689 .await
2690 };
2691
2692 let is_pr_map: std::collections::HashMap<usize, bool> = pr_check_inputs
2694 .iter()
2695 .map(|(i, _)| *i)
2696 .zip(is_pr_values)
2697 .collect();
2698
2699 let mut cursor = MentionCursor::default();
2700
2701 for (comment_idx, (comment, action)) in comments.iter().zip(actions).enumerate() {
2702 match action {
2703 CommentAction::Skip => {
2704 cursor.advance(&comment.created_at);
2705 }
2706
2707 CommentAction::ExecuteCommand { command, issue_num } => {
2708 let store_opt: Option<Arc<dyn CommandStoreOps>> =
2709 store.map(|s| Arc::clone(s) as Arc<dyn CommandStoreOps>);
2710 let gh = get_gh().await?;
2711 let _outcome = validate_and_run_command(
2712 backend,
2713 gh,
2714 repo,
2715 &issue_num,
2716 &command,
2717 &comment.author,
2718 &store_opt,
2719 task_manager,
2720 )
2721 .await;
2722 cursor.advance(&comment.created_at);
2725 }
2726
2727 CommentAction::ExecuteCommandForMention { command, .. } => {
2728 if let Err(e) = backend.acknowledge_mention(&comment.id).await {
2729 tracing::debug!(err = %e, mention_id = %comment.id, "failed to acknowledge mention");
2730 } else {
2734 let gh = get_gh().await?;
2737 let advanced_safely = handle_slash_command(
2738 backend,
2739 store,
2740 repo,
2741 task_manager,
2742 gh,
2743 comment,
2744 &command,
2745 &mut cursor,
2746 )
2747 .await?;
2748 if !advanced_safely {
2749 cursor.block_on_gap();
2750 break;
2751 }
2752 }
2753 }
2754
2755 CommentAction::CreateMentionTask { issue_num } => {
2756 if let Err(e) = backend.acknowledge_mention(&comment.id).await {
2757 tracing::debug!(err = %e, mention_id = %comment.id, "failed to acknowledge mention");
2758 } else {
2762 let is_pr = is_pr_map.get(&comment_idx).copied().unwrap_or(false);
2767
2768 let (title, task_body) = match (&issue_num, is_pr) {
2769 (Some(num), false) => (
2770 format!("Respond to mention by @{} on task #{num}", comment.author),
2771 format!(
2772 "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.",
2773 comment.author, comment.body
2774 ),
2775 ),
2776 (Some(num), true) => (
2777 format!("Respond to mention by @{} on PR #{num}", comment.author),
2778 format!(
2779 "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.",
2780 comment.author, comment.body
2781 ),
2782 ),
2783 (None, _) => (
2784 format!("Respond to mention by @{}", comment.author),
2785 format!("Mention by @{}:\n\n{}", comment.author, comment.body),
2786 ),
2787 };
2788
2789 if let Some(s) = store {
2790 let parent_id = match issue_num {
2793 Some(ref num) => {
2794 match resolve_mention_parent_id(s, repo, num, is_pr).await {
2795 Ok(id) => id,
2796 Err(e) => {
2797 tracing::warn!(
2798 mention_id = %comment.id,
2799 err = %e,
2800 "deferring mention task due to parent lookup failure"
2801 );
2802 return Err(anyhow::anyhow!(
2803 "deferring mention {} due to parent lookup failure",
2804 comment.id
2805 ));
2806 }
2807 }
2808 }
2809 None => None,
2810 };
2811 if !record_mention_task(
2812 s,
2813 repo,
2814 comment,
2815 &title,
2816 &task_body,
2817 parent_id,
2818 &mut cursor,
2819 )
2820 .await
2821 {
2822 cursor.block_on_gap();
2823 break;
2824 }
2825 }
2826 }
2827 }
2828 }
2829 }
2830
2831 if let Some(ts) = cursor.into_last_safe_ts() {
2832 kv_set_prefer_store(&store, "mentions_last_checked", &ts).await;
2833 }
2834
2835 Ok(())
2836}
2837
2838async fn skills_sync() -> anyhow::Result<()> {
2844 let skills = match config::get_skills() {
2845 Ok(s) => s,
2846 Err(e) => {
2847 tracing::debug!(err = %e, "no skills configured");
2848 return Ok(());
2849 }
2850 };
2851
2852 if skills.is_empty() {
2853 tracing::debug!("no skills configured, skipping sync");
2854 return Ok(());
2855 }
2856
2857 let skills_base = crate::home::skills_dir()?;
2858 let git_timeout = std::time::Duration::from_secs(60);
2859
2860 let futs: Vec<_> = skills
2863 .into_iter()
2864 .map(|skill| sync_one_skill(skill, skills_base.clone(), git_timeout))
2865 .collect();
2866 futures::future::join_all(futs).await;
2867
2868 Ok(())
2869}
2870
2871async fn sync_one_skill(
2876 skill: config::SkillConfig,
2877 skills_base: std::path::PathBuf,
2878 git_timeout: std::time::Duration,
2879) {
2880 if skill.repo.contains("..") || skill.repo.matches('/').count() != 1 {
2882 tracing::warn!(repo = %skill.repo, "invalid skill repo format, expected 'owner/repo'");
2883 return;
2884 }
2885
2886 let repo_dir = skills_base.join(&skill.repo);
2887 let repo_url = format!("https://github.com/{}.git", skill.repo);
2888
2889 let repo_exists = tokio::fs::metadata(&repo_dir)
2892 .await
2893 .map(|m| m.is_dir())
2894 .unwrap_or(false);
2895
2896 if repo_exists {
2897 tracing::debug!(repo = %skill.repo, "pulling skill repo");
2899 let pull_result = tokio::time::timeout(
2900 git_timeout,
2901 Command::new("git")
2902 .args(["pull", "--ff-only", "--prune"])
2903 .current_dir(&repo_dir)
2904 .output_with_context(),
2905 )
2906 .await;
2907
2908 match pull_result {
2909 Ok(Ok(output)) if !output.status.success() => {
2910 let stderr = String::from_utf8_lossy(&output.stderr);
2911 tracing::warn!(repo = %skill.repo, err = %stderr, "git pull failed");
2912 }
2913 Ok(Ok(_)) => {
2914 tracing::debug!(repo = %skill.repo, "skill repo updated");
2915 }
2916 Ok(Err(e)) => {
2917 tracing::warn!(repo = %skill.repo, err = %e, "git pull error");
2918 }
2919 Err(_) => {
2920 tracing::warn!(repo = %skill.repo, "git pull timed out after 60s");
2921 }
2922 }
2923 } else {
2924 tracing::debug!(repo = %skill.repo, "cloning skill repo");
2926 let Some(parent) = repo_dir.parent() else {
2927 tracing::warn!(repo = %skill.repo, "skill repo path has no parent directory");
2928 return;
2929 };
2930 if let Err(e) = tokio::fs::create_dir_all(parent).await {
2931 tracing::warn!(repo = %skill.repo, err = %e, "failed to create parent dir for skill");
2932 return;
2933 }
2934 let Some(repo_dir_str) = repo_dir.to_str() else {
2935 tracing::warn!(repo = %skill.repo, "skill repo path is not valid UTF-8");
2936 return;
2937 };
2938
2939 let clone_result = tokio::time::timeout(
2940 git_timeout,
2941 Command::new("git")
2942 .args(["clone", "--depth", "1", &repo_url, repo_dir_str])
2943 .output_with_context(),
2944 )
2945 .await;
2946
2947 match clone_result {
2948 Ok(Ok(output)) if !output.status.success() => {
2949 let stderr = String::from_utf8_lossy(&output.stderr);
2950 tracing::warn!(repo = %skill.repo, err = %stderr, "git clone failed");
2951 let _ = tokio::fs::remove_dir_all(&repo_dir).await;
2953 }
2954 Ok(Ok(_)) => {
2955 tracing::info!(repo = %skill.repo, "skill repo cloned");
2956 }
2957 Ok(Err(e)) => {
2958 tracing::warn!(repo = %skill.repo, err = %e, "git clone error");
2959 let _ = tokio::fs::remove_dir_all(&repo_dir).await;
2960 }
2961 Err(_) => {
2962 tracing::warn!(repo = %skill.repo, "git clone timed out after 60s");
2963 let _ = tokio::fs::remove_dir_all(&repo_dir).await;
2964 }
2965 }
2966 }
2967}
2968
2969struct DegradedAgentDetail {
2971 agent: String,
2973 reason: String,
2977 cooled_models: Vec<String>,
2980}
2981
2982struct AgentPoolHealth {
2984 agent: String,
2985 cooled_models: Vec<String>,
2986 persistent_failed_models: Vec<String>,
2987 total_configured_models: usize,
2988}
2989
2990pub(crate) async fn emit_degraded_agents_if_needed(
2999 available_agents: &[String],
3000 config: &crate::engine::router::RouterConfig,
3001 store: Option<&Arc<TaskStore>>,
3002) {
3003 const COMPLEXITIES: &[&str] = &["simple", "medium", "complex", "review"];
3005 const HEAVILY_COOLED_RATIO_THRESHOLD: f64 = 0.5;
3006
3007 let mut details: Vec<DegradedAgentDetail> = Vec::new();
3008 let mut pool_health: Vec<AgentPoolHealth> = Vec::new();
3009
3010 for agent in available_agents {
3011 let agent_in_cd = is_agent_in_cooldown(agent);
3012
3013 let mut cooled_models: Vec<String> = Vec::new();
3015 let mut persistent_failed_models: Vec<String> = Vec::new();
3016 let mut seen_models = std::collections::HashSet::new();
3017 for comp in COMPLEXITIES {
3018 if let Some(pool) = config.model_pool_for_complexity(agent, comp) {
3019 for model in pool {
3020 if seen_models.insert(model.clone()) && is_model_in_cooldown(agent, &model) {
3021 let key = format!("{agent}:{model}");
3022 if matches!(
3023 cooldown_reason(&key).as_deref(),
3024 Some("persistent_model_error") | Some("model_not_found")
3025 ) {
3026 persistent_failed_models.push(model.clone());
3027 }
3028 cooled_models.push(model);
3029 }
3030 }
3031 }
3032 }
3033 let total_configured_models = seen_models.len();
3034 pool_health.push(AgentPoolHealth {
3035 agent: agent.clone(),
3036 cooled_models: cooled_models.clone(),
3037 persistent_failed_models: persistent_failed_models.clone(),
3038 total_configured_models,
3039 });
3040
3041 let has_model = COMPLEXITIES
3044 .iter()
3045 .any(|comp| config.has_available_model_for_complexity(agent, comp));
3046
3047 if agent_in_cd || !has_model {
3048 let reason = if agent_in_cd {
3049 cooldown_reason(agent).unwrap_or_else(|| "agent_cooldown".to_string())
3050 } else {
3051 "no_available_model".to_string()
3052 };
3053 details.push(DegradedAgentDetail {
3054 agent: agent.clone(),
3055 reason,
3056 cooled_models,
3057 });
3058 }
3059 }
3060
3061 let count = details.len();
3062
3063 if let Err(e) = try_kv_set_prefer_store(
3066 &store,
3067 "metrics:orch.agents_degraded.count",
3068 &count.to_string(),
3069 )
3070 .await
3071 {
3072 tracing::warn!(err = %e, "failed to persist degraded agents count metric");
3073 }
3074
3075 if count >= 3 {
3076 let degraded_agents: Vec<&str> = details.iter().map(|d| d.agent.as_str()).collect();
3078 let cooled_models_dim: Vec<String> = details
3079 .iter()
3080 .filter(|d| !d.cooled_models.is_empty())
3081 .map(|d| format!("{}:[{}]", d.agent, d.cooled_models.join(",")))
3082 .collect();
3083 let reasons_dim: Vec<String> = details
3084 .iter()
3085 .map(|d| format!("{}={}", d.agent, d.reason))
3086 .collect();
3087
3088 tracing::warn!(
3089 degraded_count = count,
3090 degraded_agents = ?degraded_agents,
3091 cooled_models = %cooled_models_dim.join("; "),
3092 cooldown_reasons = %reasons_dim.join("; "),
3093 "multi-agent degradation detected"
3094 );
3095
3096 if let Err(e) =
3098 try_kv_set_prefer_store(&store, "metrics:orch.agents_degraded.alert", "1").await
3099 {
3100 tracing::warn!(err = %e, "failed to persist degraded agents alert metric");
3101 }
3102 } else if let Err(e) =
3103 try_kv_set_prefer_store(&store, "metrics:orch.agents_degraded.alert", "0").await
3104 {
3105 tracing::warn!(err = %e, "failed to clear degraded agents alert metric");
3107 }
3108
3109 let heavily_cooled_agents: Vec<String> = pool_health
3113 .iter()
3114 .filter_map(|d| {
3115 if d.total_configured_models == 0 || d.persistent_failed_models.is_empty() {
3116 return None;
3117 }
3118 let ratio = d.cooled_models.len() as f64 / d.total_configured_models as f64;
3119 (ratio >= HEAVILY_COOLED_RATIO_THRESHOLD).then(|| {
3120 format!(
3121 "{}:{}/{}:{}",
3122 d.agent,
3123 d.cooled_models.len(),
3124 d.total_configured_models,
3125 d.persistent_failed_models.join(",")
3126 )
3127 })
3128 })
3129 .collect();
3130
3131 const STALE_AGENTS_KV: &str = "metrics:orch.model_pool_stale_persistent.agents";
3134 let current_dim = heavily_cooled_agents.join(",");
3135 let prev_dim = kv_get_prefer_store(&store, STALE_AGENTS_KV)
3136 .await
3137 .unwrap_or_default();
3138
3139 if !heavily_cooled_agents.is_empty() {
3140 if current_dim != prev_dim {
3141 tracing::warn!(
3142 affected_agents = ?heavily_cooled_agents,
3143 "agent model pool appears stale: persistent model failures in heavily cooled pool"
3144 );
3145 }
3146 if let Err(e) = try_kv_set_prefer_store(&store, STALE_AGENTS_KV, ¤t_dim).await {
3147 tracing::warn!(err = %e, "failed to persist stale model pool agents state");
3148 }
3149 if let Err(e) = try_kv_set_prefer_store(
3150 &store,
3151 "metrics:orch.model_pool_stale_persistent.alert",
3152 "1",
3153 )
3154 .await
3155 {
3156 tracing::warn!(err = %e, "failed to persist stale model pool alert metric");
3157 }
3158 } else {
3159 if !prev_dim.is_empty() {
3160 tracing::info!("agent model pool stale condition cleared");
3161 }
3162 if let Err(e) = try_kv_set_prefer_store(&store, STALE_AGENTS_KV, "").await {
3163 tracing::warn!(err = %e, "failed to clear stale model pool agents state");
3164 }
3165 if let Err(e) = try_kv_set_prefer_store(
3166 &store,
3167 "metrics:orch.model_pool_stale_persistent.alert",
3168 "0",
3169 )
3170 .await
3171 {
3172 tracing::warn!(err = %e, "failed to clear stale model pool alert metric");
3173 }
3174 }
3175}
3176
3177pub(crate) async fn ingest_external_tasks(
3185 backend: &Arc<dyn ExternalBackend>,
3186 repo: &str,
3187 store: &Arc<TaskStore>,
3188) -> anyhow::Result<()> {
3189 #[derive(Clone)]
3190 struct DuplicateTarget {
3191 id: String,
3192 status_label: String,
3193 }
3194
3195 let mut existing_by_title: std::collections::HashMap<String, DuplicateTarget> =
3196 std::collections::HashMap::new();
3197 let mut accepted_by_title: std::collections::HashMap<String, DuplicateTarget> =
3198 std::collections::HashMap::new();
3199
3200 let cutoff = (chrono::Utc::now() - chrono::Duration::hours(24))
3201 .format("%Y-%m-%dT%H:%M:%SZ")
3202 .to_string();
3203 match store.list_for_doctor(repo, &cutoff).await {
3204 Ok(recent) => {
3205 let mut insert_target = |task: &crate::store::Task| {
3206 if task.origin == "internal" {
3207 return;
3208 }
3209 let Some(ext_id) = task.external_id.as_ref() else {
3210 return;
3211 };
3212 let status_label = task
3213 .labels
3214 .iter()
3215 .find(|l| l.starts_with("status:"))
3216 .cloned()
3217 .unwrap_or_default();
3218 existing_by_title
3219 .entry(task.title.clone())
3220 .or_insert(DuplicateTarget {
3221 id: ext_id.clone(),
3222 status_label,
3223 });
3224 };
3225
3226 for task in recent.iter().filter(|t| t.status != TaskStatus::Done) {
3228 insert_target(task);
3229 }
3230 for task in recent.iter().filter(|t| t.status == TaskStatus::Done) {
3231 insert_target(task);
3232 }
3233 }
3234 Err(e) => {
3235 tracing::warn!(repo, err = %e, "ingest: failed to load recent tasks for dedup");
3236 }
3237 }
3238
3239 let all_tasks: Vec<(crate::backends::ExternalTask, Option<Status>)> =
3249 match backend.list_active_open_issues().await {
3250 Ok(tasks) => tasks,
3251 Err(e) => {
3252 tracing::warn!(
3253 repo,
3254 err = %e,
3255 "ingest: failed to list open issues — no new issue is ingested this cycle"
3256 );
3257 return Ok(());
3258 }
3259 };
3260
3261 let mut id_status_pairs: Vec<(i64, crate::backends::Status)> = Vec::new();
3264 let mut new_task_node_ids: Vec<(i64, String)> = Vec::new();
3267 let existing_ext_ids: std::collections::HashSet<String> = match store
3272 .existing_external_ids(repo)
3273 .await
3274 {
3275 Ok(s) => s,
3276 Err(e) => {
3277 tracing::warn!(repo = repo, err = %e, "ingest: failed to load existing external ids — proceeding");
3278 std::collections::HashSet::new()
3279 }
3280 };
3281 for (task, status) in &all_tasks {
3282 let is_new = !existing_ext_ids.contains(&task.id.0);
3285
3286 if is_new {
3287 let duplicate_target = existing_by_title
3288 .get(&task.title)
3289 .or_else(|| accepted_by_title.get(&task.title));
3290 if let Some(target) = duplicate_target {
3291 if target.id != task.id.0 {
3292 let label = target.status_label.as_str();
3293 let should_dedupe =
3294 match backend.has_open_issue_with_title(&task.title, label).await {
3295 Ok(true) => true,
3296 Ok(false) => false,
3297 Err(e) => {
3298 tracing::warn!(
3299 task_id = task.id.0,
3300 err = %e,
3301 "ingest: dedup check failed — defaulting to keep issue"
3302 );
3303 false
3304 }
3305 };
3306
3307 if should_dedupe {
3308 let comment = format!(
3309 "Duplicate of #{id} — closing to avoid duplicate work.{footer}",
3310 id = target.id,
3311 footer = crate::engine::orch_footer()
3312 );
3313 if let Err(e) = backend.post_comment(&task.id, &comment).await {
3314 tracing::warn!(
3315 task_id = task.id.0,
3316 err = %e,
3317 "ingest: failed to comment on duplicate issue"
3318 );
3319 }
3320 match backend.update_status(&task.id, Status::Done).await {
3321 Ok(()) => {
3322 tracing::info!(
3323 task_id = task.id.0,
3324 duplicate_of = target.id,
3325 "ingest: closed duplicate issue"
3326 );
3327 continue;
3328 }
3329 Err(e) => {
3330 tracing::warn!(
3331 task_id = task.id.0,
3332 err = %e,
3333 "ingest: failed to close duplicate issue — falling through to ingest normally"
3334 );
3335 }
3336 }
3337 }
3338 }
3339 }
3340 }
3341
3342 match store.ensure_external_task(repo, task).await {
3343 Ok(store_id) => {
3344 if let Ok(number) = task.id.0.parse::<i64>() {
3345 if let Err(err) = crate::engine::dependencies::sync_issue_dependencies(
3346 backend.as_ref(),
3347 store,
3348 store_id,
3349 repo,
3350 Some(number),
3351 &task.body,
3352 )
3353 .await
3354 {
3355 tracing::warn!(
3356 task_id = task.id.0,
3357 err = %err,
3358 "ingest: dependency sync failed"
3359 );
3360 }
3361 }
3362 if let Some(s) = status {
3363 id_status_pairs.push((store_id, *s));
3364 }
3365 if is_new {
3367 let status_label = task
3368 .labels
3369 .iter()
3370 .find(|l| l.starts_with("status:"))
3371 .cloned()
3372 .unwrap_or_default();
3373 accepted_by_title
3374 .entry(task.title.clone())
3375 .or_insert(DuplicateTarget {
3376 id: task.id.0.clone(),
3377 status_label,
3378 });
3379 if let Err(e) = backend.acknowledge_issue(&task.id).await {
3380 tracing::debug!(
3381 task_id = task.id.0,
3382 err = %e,
3383 "ingest: acknowledgment failed"
3384 );
3385 }
3386 let task_status = status.unwrap_or(Status::New);
3389 match backend.sync_to_project(&task.id, task_status).await {
3390 Ok(Some(node_id)) => {
3391 new_task_node_ids.push((store_id, node_id));
3392 }
3393 Ok(None) => {
3394 }
3396 Err(e) => {
3397 tracing::debug!(
3398 task_id = task.id.0,
3399 err = %e,
3400 "ingest: project board sync failed"
3401 );
3402 }
3403 }
3404 }
3405 }
3406 Err(e) => {
3407 tracing::debug!(task_id = task.id.0, ?e, "ingest: upsert failed");
3408 }
3409 }
3410 }
3411
3412 if !id_status_pairs.is_empty() {
3418 let all_ids: Vec<i64> = id_status_pairs.iter().map(|(id, _)| *id).collect();
3419 let existing_map = match store.get_batch(&all_ids).await {
3420 Ok(map) => map,
3421 Err(e) => {
3422 tracing::warn!(err = %e, task_count = all_ids.len(), "ingest: get_batch failed — skipping initial status sync for newly ingested tasks this tick");
3423 return Ok(());
3424 }
3425 };
3426 for (store_id, status) in id_status_pairs {
3427 if let Some(existing) = existing_map.get(&store_id) {
3428 if existing.status == TaskStatus::New {
3429 let db_status = status_to_task_status(status);
3430 if db_status != TaskStatus::New {
3431 if let Err(e) = store.update_status(store_id, db_status).await {
3432 tracing::debug!(?e, "ingest: status sync failed");
3433 }
3434 }
3435 }
3436 }
3437 }
3438 }
3439
3440 if !new_task_node_ids.is_empty() {
3443 let node_ids: Vec<String> = new_task_node_ids.iter().map(|(_, n)| n.clone()).collect();
3444 match backend.get_project_item_estimates(&node_ids).await {
3445 Ok(estimates) => {
3446 for (store_id, node_id) in new_task_node_ids {
3447 if let Some(&estimate) = estimates.get(&node_id) {
3448 if estimate > 0 {
3449 if let Err(e) = store.set_estimate_if_zero(store_id, estimate).await {
3450 tracing::debug!(
3451 store_id,
3452 estimate,
3453 err = %e,
3454 "ingest: failed to set estimate from project"
3455 );
3456 }
3457 }
3458 }
3459 }
3460 }
3461 Err(e) => {
3462 tracing::debug!(
3463 err = %e,
3464 "ingest: project estimate fetch failed — skipping estimate sync this tick"
3465 );
3466 }
3467 }
3468 }
3469
3470 Ok(())
3471}
3472
3473#[cfg(test)]
3474mod tests {
3475 use super::*;
3476 use crate::backends::{ExternalId, ExternalTask, Mention, Status};
3477 use crate::engine::cooldown::set_agent_cooldown;
3478 use crate::store::{RunTokenUsage, TaskStore};
3479 use async_trait::async_trait;
3480 use serial_test::serial;
3481 use std::sync::Arc;
3482
3483 struct IngestMockBackend {
3487 tasks: Vec<(String, Vec<ExternalTask>)>,
3489 dedup_result: bool,
3490 comments: tokio::sync::Mutex<Vec<(String, String)>>,
3491 status_updates: tokio::sync::Mutex<Vec<(String, Status)>>,
3492 project_syncs: tokio::sync::Mutex<Vec<(String, Status)>>,
3493 project_reconciliations: std::sync::atomic::AtomicUsize,
3494 list_calls: std::sync::atomic::AtomicUsize,
3496 }
3497
3498 impl IngestMockBackend {
3499 fn with_tasks(tasks: Vec<(Status, ExternalTask)>) -> Arc<Self> {
3500 Self::with_tasks_and_dedup(tasks, false)
3501 }
3502
3503 fn with_tasks_and_dedup(
3504 tasks: Vec<(Status, ExternalTask)>,
3505 dedup_result: bool,
3506 ) -> Arc<Self> {
3507 let mut grouped: Vec<(String, Vec<ExternalTask>)> = Vec::new();
3508 for (status, task) in tasks {
3509 let label = status.as_label().to_string();
3510 if let Some(entry) = grouped.iter_mut().find(|(l, _)| l == &label) {
3511 entry.1.push(task);
3512 } else {
3513 grouped.push((label, vec![task]));
3514 }
3515 }
3516 Arc::new(Self {
3517 tasks: grouped,
3518 dedup_result,
3519 comments: tokio::sync::Mutex::new(Vec::new()),
3520 status_updates: tokio::sync::Mutex::new(Vec::new()),
3521 project_syncs: tokio::sync::Mutex::new(Vec::new()),
3522 project_reconciliations: std::sync::atomic::AtomicUsize::new(0),
3523 list_calls: std::sync::atomic::AtomicUsize::new(0),
3524 })
3525 }
3526 }
3527
3528 #[async_trait]
3529 impl ExternalBackend for IngestMockBackend {
3530 fn name(&self) -> &str {
3531 "ingest-mock"
3532 }
3533 async fn create_task(&self, _: &str, _: &str, _: &[String]) -> anyhow::Result<ExternalId> {
3534 Ok(ExternalId("new".into()))
3535 }
3536 async fn get_task(&self, _: &ExternalId) -> anyhow::Result<ExternalTask> {
3537 anyhow::bail!("not implemented")
3538 }
3539 async fn list_by_status(&self, status: Status) -> anyhow::Result<Vec<ExternalTask>> {
3540 let label = status.as_label().to_string();
3541 Ok(self
3542 .tasks
3543 .iter()
3544 .find(|(l, _)| l == &label)
3545 .map(|(_, t)| t.clone())
3546 .unwrap_or_default())
3547 }
3548 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
3549 Ok(vec![])
3550 }
3551 async fn list_active_open_issues(
3552 &self,
3553 ) -> anyhow::Result<Vec<(ExternalTask, Option<Status>)>> {
3554 self.list_calls
3555 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3556 let mut result = Vec::new();
3557 for (label, tasks) in &self.tasks {
3558 let status = match label.as_str() {
3559 "status:new" => None,
3560 "status:routed" => Some(Status::Routed),
3561 "status:in_progress" => Some(Status::InProgress),
3562 "status:needs_review" => Some(Status::NeedsReview),
3563 "status:in_review" => Some(Status::InReview),
3564 "status:blocked" => Some(Status::Blocked),
3565 _ => None,
3566 };
3567 for task in tasks {
3568 result.push((task.clone(), status));
3569 }
3570 }
3571 Ok(result)
3572 }
3573 async fn post_comment(&self, id: &ExternalId, body: &str) -> anyhow::Result<()> {
3574 self.comments
3575 .lock()
3576 .await
3577 .push((id.0.clone(), body.to_string()));
3578 Ok(())
3579 }
3580 async fn set_labels(&self, _: &ExternalId, _: &[String]) -> anyhow::Result<()> {
3581 Ok(())
3582 }
3583 async fn remove_label(&self, _: &ExternalId, _: &str) -> anyhow::Result<()> {
3584 Ok(())
3585 }
3586 async fn get_sub_issues(&self, _: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
3587 Ok(vec![])
3588 }
3589 async fn health_check(&self) -> anyhow::Result<()> {
3590 Ok(())
3591 }
3592 async fn get_mentions(&self, _: &str) -> anyhow::Result<Vec<Mention>> {
3593 Ok(vec![])
3594 }
3595 async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
3596 self.status_updates
3597 .lock()
3598 .await
3599 .push((id.0.clone(), status));
3600 Ok(())
3601 }
3602
3603 async fn has_open_issue_with_title(&self, _: &str, _: &str) -> anyhow::Result<bool> {
3604 Ok(self.dedup_result)
3605 }
3606
3607 async fn sync_to_project(
3608 &self,
3609 id: &ExternalId,
3610 status: Status,
3611 ) -> anyhow::Result<Option<String>> {
3612 self.project_syncs.lock().await.push((id.0.clone(), status));
3613 Ok(None) }
3615
3616 async fn reconcile_project_statuses(&self) -> anyhow::Result<()> {
3617 self.project_reconciliations
3618 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3619 Ok(())
3620 }
3621 }
3622
3623 fn make_ext_task(id: &str, title: &str) -> ExternalTask {
3624 ExternalTask {
3625 id: ExternalId(id.to_string()),
3626 title: title.to_string(),
3627 body: "".to_string(),
3628 state: "open".to_string(),
3629 labels: vec![],
3630 author: "user".to_string(),
3631 created_at: "2026-01-01T00:00:00Z".to_string(),
3632 updated_at: "2026-01-01T00:00:00Z".to_string(),
3633 url: format!("https://github.com/owner/repo/issues/{id}"),
3634 }
3635 }
3636
3637 #[serial(cooldown_state)]
3638 #[tokio::test]
3639 async fn ingest_upserts_tasks_into_store() {
3640 crate::engine::cooldown::reset_global_state().await;
3641 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![
3642 (Status::New, make_ext_task("1", "First issue")),
3643 (Status::InProgress, make_ext_task("2", "Second issue")),
3644 ]);
3645 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3646
3647 ingest_external_tasks(&backend, "owner/repo", &store)
3648 .await
3649 .unwrap();
3650
3651 let all = store.list_all("owner/repo").await.unwrap();
3653 assert_eq!(all.len(), 2);
3654
3655 let task1 = store
3656 .get_by_external_id("owner/repo", "1")
3657 .await
3658 .unwrap()
3659 .unwrap();
3660 assert_eq!(task1.title, "First issue");
3661 assert_eq!(task1.status, TaskStatus::New);
3662
3663 let task2 = store
3664 .get_by_external_id("owner/repo", "2")
3665 .await
3666 .unwrap()
3667 .unwrap();
3668 assert_eq!(task2.title, "Second issue");
3669 assert_eq!(task2.status, crate::store::TaskStatus::InProgress);
3670 }
3671
3672 #[serial(cooldown_state)]
3673 #[tokio::test]
3674 async fn ingest_takes_every_open_issue_whatever_its_age_or_the_last_ingest() {
3675 crate::engine::cooldown::reset_global_state().await;
3679 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3680 store
3681 .kv_set("issues_last_ingested:owner/repo", "2026-10-26T00:00:00Z")
3682 .await
3683 .unwrap();
3684
3685 let mut old = make_ext_task("73", "Filed weeks ago, never ingested");
3686 old.updated_at = "2026-01-01T00:00:00Z".to_string();
3687 let backend = IngestMockBackend::with_tasks(vec![(Status::New, old)]);
3688 let backend_dyn: Arc<dyn ExternalBackend> = backend.clone();
3689 ingest_external_tasks(&backend_dyn, "owner/repo", &store)
3690 .await
3691 .unwrap();
3692 assert!(store
3693 .get_by_external_id("owner/repo", "73")
3694 .await
3695 .unwrap()
3696 .is_some());
3697
3698 let backend = IngestMockBackend::with_tasks(vec![(
3700 Status::New,
3701 make_ext_task("78", "Opened after the restart"),
3702 )]);
3703 let backend_dyn: Arc<dyn ExternalBackend> = backend.clone();
3704 ingest_external_tasks(&backend_dyn, "owner/repo", &store)
3705 .await
3706 .unwrap();
3707 assert_eq!(
3708 backend
3709 .list_calls
3710 .load(std::sync::atomic::Ordering::Relaxed),
3711 1,
3712 "one sync is one listing"
3713 );
3714 assert!(store
3715 .get_by_external_id("owner/repo", "78")
3716 .await
3717 .unwrap()
3718 .is_some());
3719 assert_eq!(
3720 store
3721 .kv_get("issues_last_ingested:owner/repo")
3722 .await
3723 .unwrap()
3724 .as_deref(),
3725 Some("2026-10-26T00:00:00Z"),
3726 "the ingest must keep no cursor, so a stale one is never read or advanced"
3727 );
3728 }
3729
3730 #[serial(cooldown_state)]
3731 #[tokio::test]
3732 async fn ingest_updates_existing_tasks() {
3733 crate::engine::cooldown::reset_global_state().await;
3734 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![(
3735 Status::Routed,
3736 make_ext_task("42", "Updated title"),
3737 )]);
3738 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3739
3740 store
3742 .create(&crate::store::NewTask {
3743 external_id: Some("42".to_string()),
3744 repo: "owner/repo".to_string(),
3745 origin: "github".to_string(),
3746 title: "Original title".to_string(),
3747 ..Default::default()
3748 })
3749 .await
3750 .unwrap();
3751
3752 ingest_external_tasks(&backend, "owner/repo", &store)
3753 .await
3754 .unwrap();
3755
3756 let task = store
3758 .get_by_external_id("owner/repo", "42")
3759 .await
3760 .unwrap()
3761 .unwrap();
3762 assert_eq!(task.title, "Updated title");
3763 assert_eq!(task.status, crate::store::TaskStatus::Routed);
3764 }
3765
3766 #[serial(cooldown_state)]
3767 #[tokio::test]
3768 async fn ingest_closes_duplicate_titles() {
3769 crate::engine::cooldown::reset_global_state().await;
3770 use crate::store::{NewTask, TaskStore};
3771
3772 let backend = IngestMockBackend::with_tasks_and_dedup(
3773 vec![(Status::New, make_ext_task("2", "Duplicate issue"))],
3774 true,
3775 );
3776 let backend_dyn: Arc<dyn ExternalBackend> = backend.clone();
3777 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3778
3779 store
3780 .create(&NewTask {
3781 external_id: Some("1".to_string()),
3782 repo: "owner/repo".to_string(),
3783 origin: "github".to_string(),
3784 title: "Duplicate issue".to_string(),
3785 labels: vec!["status:new".to_string()],
3786 ..Default::default()
3787 })
3788 .await
3789 .unwrap();
3790
3791 ingest_external_tasks(&backend_dyn, "owner/repo", &store)
3792 .await
3793 .unwrap();
3794
3795 let all = store.list_all("owner/repo").await.unwrap();
3796 assert_eq!(all.len(), 1);
3797
3798 let comments = backend.comments.lock().await;
3799 assert_eq!(comments.len(), 1);
3800 assert!(comments[0].1.contains("Duplicate of #1"));
3801
3802 let status_updates = backend.status_updates.lock().await;
3803 assert_eq!(status_updates.len(), 1);
3804 assert_eq!(status_updates[0].0, "2");
3805 assert_eq!(status_updates[0].1, Status::Done);
3806 }
3807
3808 #[serial(cooldown_state)]
3809 #[tokio::test]
3810 async fn ingest_handles_empty_backend() {
3811 crate::engine::cooldown::reset_global_state().await;
3812 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
3813 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3814
3815 let result = ingest_external_tasks(&backend, "owner/repo", &store).await;
3817 assert!(result.is_ok());
3818
3819 let all = store.list_all("owner/repo").await.unwrap();
3820 assert!(all.is_empty());
3821 }
3822
3823 #[serial(cooldown_state)]
3824 #[tokio::test]
3825 async fn ingest_syncs_status_correctly_across_statuses() {
3826 crate::engine::cooldown::reset_global_state().await;
3827 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![
3828 (Status::New, make_ext_task("1", "New")),
3829 (Status::Routed, make_ext_task("2", "Routed")),
3830 (Status::InProgress, make_ext_task("3", "InProgress")),
3831 (Status::NeedsReview, make_ext_task("4", "NeedsReview")),
3832 (Status::InReview, make_ext_task("5", "InReview")),
3833 (Status::Blocked, make_ext_task("6", "Blocked")),
3834 ]);
3835 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3836
3837 ingest_external_tasks(&backend, "owner/repo", &store)
3838 .await
3839 .unwrap();
3840
3841 let counts = store.status_counts("owner/repo").await.unwrap();
3842 assert_eq!(counts.get("new"), Some(&1));
3843 assert_eq!(counts.get("routed"), Some(&1));
3844 assert_eq!(counts.get("in_progress"), Some(&1));
3845 assert_eq!(counts.get("needs_review"), Some(&1));
3846 assert_eq!(counts.get("in_review"), Some(&1));
3847 assert_eq!(counts.get("blocked"), Some(&1));
3848 }
3849
3850 #[serial(cooldown_state)]
3851 #[tokio::test]
3852 async fn ingest_does_not_overwrite_store_authoritative_status() {
3853 crate::engine::cooldown::reset_global_state().await;
3854 let backend: Arc<dyn ExternalBackend> =
3856 IngestMockBackend::with_tasks(vec![(Status::New, make_ext_task("99", "My task"))]);
3857 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3858
3859 let id = store
3861 .create(&crate::store::NewTask {
3862 external_id: Some("99".to_string()),
3863 repo: "owner/repo".to_string(),
3864 origin: "github".to_string(),
3865 title: "My task".to_string(),
3866 ..Default::default()
3867 })
3868 .await
3869 .unwrap();
3870 store
3871 .update_status(id, crate::store::TaskStatus::Routed)
3872 .await
3873 .unwrap();
3874
3875 ingest_external_tasks(&backend, "owner/repo", &store)
3877 .await
3878 .unwrap();
3879
3880 let task = store.get(id).await.unwrap();
3881 assert_eq!(
3882 task.status,
3883 crate::store::TaskStatus::Routed,
3884 "ingest must not overwrite store-authoritative status"
3885 );
3886 }
3887
3888 #[serial(cooldown_state)]
3889 #[tokio::test]
3890 async fn ingest_sync_to_project_called_only_for_new_tasks() {
3891 crate::engine::cooldown::reset_global_state().await;
3892 use crate::store::{NewTask, TaskStore};
3893
3894 let backend = IngestMockBackend::with_tasks(vec![
3896 (Status::New, make_ext_task("10", "Existing task")),
3897 (Status::New, make_ext_task("11", "New task")),
3898 ]);
3899 let backend_dyn: Arc<dyn ExternalBackend> = backend.clone();
3900 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3901
3902 store
3904 .create(&NewTask {
3905 external_id: Some("10".to_string()),
3906 repo: "owner/repo".to_string(),
3907 origin: "github".to_string(),
3908 title: "Existing task".to_string(),
3909 ..Default::default()
3910 })
3911 .await
3912 .unwrap();
3913
3914 ingest_external_tasks(&backend_dyn, "owner/repo", &store)
3915 .await
3916 .unwrap();
3917
3918 let syncs = backend.project_syncs.lock().await;
3919 assert_eq!(
3921 syncs.len(),
3922 1,
3923 "sync_to_project must be called exactly once (for new task only), got: {syncs:?}"
3924 );
3925 assert_eq!(syncs[0].0, "11", "sync_to_project must target the new task");
3926 }
3927
3928 #[serial(cooldown_state)]
3929 #[tokio::test]
3930 async fn ingest_handles_mixed_labeled_and_unlabeled_open_issues() {
3931 crate::engine::cooldown::reset_global_state().await;
3932 struct MixedBackend;
3936
3937 #[async_trait]
3938 impl ExternalBackend for MixedBackend {
3939 fn name(&self) -> &str {
3940 "mixed"
3941 }
3942 async fn create_task(
3943 &self,
3944 _: &str,
3945 _: &str,
3946 _: &[String],
3947 ) -> anyhow::Result<ExternalId> {
3948 Ok(ExternalId("x".into()))
3949 }
3950 async fn get_task(&self, _: &ExternalId) -> anyhow::Result<ExternalTask> {
3951 anyhow::bail!("not implemented")
3952 }
3953 async fn list_by_status(&self, _: Status) -> anyhow::Result<Vec<ExternalTask>> {
3954 Ok(vec![])
3955 }
3956 async fn list_active_open_issues(
3958 &self,
3959 ) -> anyhow::Result<Vec<(ExternalTask, Option<Status>)>> {
3960 Ok(vec![
3961 (make_ext_task("100", "Unlabeled routable"), None),
3962 (
3963 make_ext_task("101", "In progress issue"),
3964 Some(Status::InProgress),
3965 ),
3966 ])
3967 }
3968 async fn post_comment(&self, _: &ExternalId, _: &str) -> anyhow::Result<()> {
3969 Ok(())
3970 }
3971 async fn set_labels(&self, _: &ExternalId, _: &[String]) -> anyhow::Result<()> {
3972 Ok(())
3973 }
3974 async fn remove_label(&self, _: &ExternalId, _: &str) -> anyhow::Result<()> {
3975 Ok(())
3976 }
3977 async fn get_sub_issues(&self, _: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
3978 Ok(vec![])
3979 }
3980 async fn health_check(&self) -> anyhow::Result<()> {
3981 Ok(())
3982 }
3983 async fn get_mentions(&self, _: &str) -> anyhow::Result<Vec<Mention>> {
3984 Ok(vec![])
3985 }
3986 async fn update_status(&self, _: &ExternalId, _: Status) -> anyhow::Result<()> {
3987 Ok(())
3988 }
3989 async fn has_open_issue_with_title(&self, _: &str, _: &str) -> anyhow::Result<bool> {
3990 Ok(false)
3991 }
3992 async fn sync_to_project(
3993 &self,
3994 _: &ExternalId,
3995 _: Status,
3996 ) -> anyhow::Result<Option<String>> {
3997 Ok(None)
3998 }
3999 }
4000
4001 let backend: Arc<dyn ExternalBackend> = Arc::new(MixedBackend);
4002 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4003
4004 ingest_external_tasks(&backend, "owner/repo", &store)
4005 .await
4006 .unwrap();
4007
4008 let all = store.list_all("owner/repo").await.unwrap();
4009 assert_eq!(all.len(), 2, "both issues must be ingested");
4010
4011 let unlabeled = store
4012 .get_by_external_id("owner/repo", "100")
4013 .await
4014 .unwrap()
4015 .unwrap();
4016 assert_eq!(
4017 unlabeled.status,
4018 crate::store::TaskStatus::New,
4019 "unlabeled issue should start as New"
4020 );
4021
4022 let in_progress = store
4023 .get_by_external_id("owner/repo", "101")
4024 .await
4025 .unwrap()
4026 .unwrap();
4027 assert_eq!(
4028 in_progress.status,
4029 crate::store::TaskStatus::InProgress,
4030 "labeled in_progress issue must be synced to InProgress"
4031 );
4032 }
4033
4034 #[serial(cooldown_state)]
4037 #[tokio::test]
4038 async fn kv_get_prefer_store_reads_from_store() {
4039 crate::engine::cooldown::reset_global_state().await;
4040 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4041
4042 store.kv_set("k1", "store_val").await.unwrap();
4043
4044 let opt = Some(&store);
4045 let val = kv_get_prefer_store(&opt, "k1").await;
4046 assert_eq!(val.as_deref(), Some("store_val"));
4047 }
4048
4049 #[serial(cooldown_state)]
4050 #[tokio::test]
4051 async fn kv_get_prefer_store_returns_none_without_store() {
4052 crate::engine::cooldown::reset_global_state().await;
4053 let opt: Option<&Arc<TaskStore>> = None;
4054 let val = kv_get_prefer_store(&opt, "k2").await;
4055 assert_eq!(val, None);
4056 }
4057
4058 #[serial(cooldown_state)]
4059 #[tokio::test]
4060 async fn kv_set_prefer_store_writes_to_store() {
4061 crate::engine::cooldown::reset_global_state().await;
4062 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4063
4064 let opt = Some(&store);
4065 kv_set_prefer_store(&opt, "k3", "val3").await;
4066
4067 assert_eq!(store.kv_get("k3").await.unwrap().as_deref(), Some("val3"));
4068 }
4069
4070 #[serial(cooldown_state)]
4071 #[tokio::test]
4072 async fn kv_set_prefer_store_noop_without_store() {
4073 crate::engine::cooldown::reset_global_state().await;
4074 let opt: Option<&Arc<TaskStore>> = None;
4075 kv_set_prefer_store(&opt, "k4", "val4").await;
4077 }
4078
4079 #[serial(cooldown_state)]
4082 #[tokio::test]
4083 async fn try_kv_get_prefer_store_returns_value_from_store() {
4084 crate::engine::cooldown::reset_global_state().await;
4085 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4086 store.kv_set("try_k1", "try_val1").await.unwrap();
4087
4088 let opt = Some(&store);
4089 let result = try_kv_get_prefer_store(&opt, "try_k1").await;
4090 assert_eq!(result.unwrap().as_deref(), Some("try_val1"));
4091 }
4092
4093 #[serial(cooldown_state)]
4094 #[tokio::test]
4095 async fn try_kv_get_prefer_store_returns_none_for_missing_key() {
4096 crate::engine::cooldown::reset_global_state().await;
4097 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4098 let opt = Some(&store);
4099
4100 let result = try_kv_get_prefer_store(&opt, "nonexistent_key").await;
4101 assert_eq!(result.unwrap(), None); }
4103
4104 #[serial(cooldown_state)]
4105 #[tokio::test]
4106 async fn try_kv_get_prefer_store_returns_none_without_store() {
4107 crate::engine::cooldown::reset_global_state().await;
4108 let opt: Option<&Arc<TaskStore>> = None;
4109 let result = try_kv_get_prefer_store(&opt, "try_k2").await;
4110 assert_eq!(result.unwrap(), None);
4111 }
4112
4113 #[serial(cooldown_state)]
4114 #[tokio::test]
4115 async fn try_kv_set_prefer_store_writes_to_store() {
4116 crate::engine::cooldown::reset_global_state().await;
4117 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4118 let opt = Some(&store);
4119
4120 let result = try_kv_set_prefer_store(&opt, "try_k3", "try_val3").await;
4121 assert!(result.is_ok());
4122 assert_eq!(
4123 store.kv_get("try_k3").await.unwrap().as_deref(),
4124 Some("try_val3")
4125 );
4126 }
4127
4128 #[serial(cooldown_state)]
4129 #[tokio::test]
4130 async fn try_kv_set_prefer_store_ok_without_store() {
4131 crate::engine::cooldown::reset_global_state().await;
4132 let opt: Option<&Arc<TaskStore>> = None;
4133 let result = try_kv_set_prefer_store(&opt, "try_k4", "try_val4").await;
4135 assert!(result.is_ok());
4136 }
4137
4138 #[serial(cooldown_state)]
4141 #[test]
4142 fn dispatching_set_blocks_duplicate_processing() {
4143 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4144
4145 let repo = "owner/repo";
4146 let task_id = "42";
4147 let dispatch_key = format!("{}/{}", repo, task_id);
4148
4149 assert!(!dispatching.contains_key(&dispatch_key));
4151
4152 dispatching.insert(dispatch_key.clone(), task_id.to_string());
4154
4155 assert!(
4157 dispatching.contains_key(&dispatch_key),
4158 "task should be locked while dispatching"
4159 );
4160
4161 dispatching.remove(&dispatch_key);
4163
4164 assert!(
4166 !dispatching.contains_key(&dispatch_key),
4167 "task should be unlocked after review completes"
4168 );
4169 }
4170
4171 #[serial(cooldown_state)]
4172 #[test]
4173 fn dispatching_set_does_not_block_other_tasks() {
4174 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4175
4176 let repo = "owner/repo";
4177
4178 let key_42 = format!("{}/42", repo);
4180 dispatching.insert(key_42.clone(), "42".to_string());
4181
4182 let key_43 = format!("{}/43", repo);
4184 assert!(
4185 dispatching.contains_key(&key_42),
4186 "task 42 should be locked"
4187 );
4188 assert!(
4189 !dispatching.contains_key(&key_43),
4190 "task 43 should not be locked"
4191 );
4192 }
4193
4194 #[serial(cooldown_state)]
4207 #[tokio::test]
4208 async fn dispatch_key_held_during_review_agent_execution() {
4209 crate::engine::cooldown::reset_global_state().await;
4210 use tokio::sync::oneshot;
4211
4212 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4213
4214 let dispatch_key = "owner/repo/42".to_string();
4215
4216 dispatching.insert(dispatch_key.clone(), "42".to_string());
4218
4219 let (agent_started_tx, agent_started_rx) = oneshot::channel::<()>();
4220 let (check_done_tx, check_done_rx) = oneshot::channel::<()>();
4221
4222 let dispatching_agent = Arc::clone(&dispatching);
4223 let key_agent = dispatch_key.clone();
4224
4225 let review_task = tokio::spawn(async move {
4227 agent_started_tx.send(()).unwrap();
4228 check_done_rx.await.unwrap();
4230 dispatching_agent.remove(&key_agent);
4232 });
4233
4234 agent_started_rx.await.unwrap();
4237 assert!(
4238 dispatching.contains_key(&dispatch_key),
4239 "dispatch_key must be visible in the dispatching set while the review \
4240 agent is running — without the a6d8b9a fix, review_open_prs would see \
4241 the key absent and re-dispatch the task, silently dropping \
4242 CHANGES_REQUESTED feedback"
4243 );
4244
4245 check_done_tx.send(()).unwrap();
4247 review_task.await.unwrap();
4248
4249 assert!(
4251 !dispatching.contains_key(&dispatch_key),
4252 "dispatch_key must be removed from the dispatching set after the \
4253 review agent completes so subsequent sync ticks can process the task"
4254 );
4255 }
4256
4257 #[serial(cooldown_state)]
4270 #[tokio::test]
4271 async fn dispatch_guard_releases_key_on_panic() {
4272 crate::engine::cooldown::reset_global_state().await;
4273 use crate::engine::dispatch_guard::DispatchGuard;
4274
4275 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4276 let key = "owner/repo/42".to_string();
4277
4278 dispatching.insert(key.clone(), "42".to_string());
4282
4283 let guard = DispatchGuard::new(Arc::clone(&dispatching), key.clone());
4285
4286 let handle = tokio::spawn(async move {
4288 let _guard = guard; panic!("simulated panic inside review_and_merge");
4290 });
4291
4292 let _ = handle.await;
4294
4295 assert!(
4297 !dispatching.contains_key(&key),
4298 "dispatch key must be removed from the dispatching set even when the \
4299 spawned review task panics — without DispatchGuard the key leaks and \
4300 the task gets stuck in a permanent review loop"
4301 );
4302 }
4303
4304 #[serial(cooldown_state)]
4305 #[tokio::test]
4306 async fn sync_tick_reconciles_board_without_ingesting_or_transitioning_tasks() {
4307 crate::engine::cooldown::reset_global_state().await;
4308 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4309 let mock = IngestMockBackend::with_tasks(vec![]);
4310 let backend: Arc<dyn ExternalBackend> = mock.clone();
4311 let task_manager = Arc::new(TaskManager::with_store(
4312 backend.clone(),
4313 store.clone(),
4314 "owner/repo".to_string(),
4315 ));
4316 let dispatching = Arc::new(DashMap::new());
4317 sync_tick(
4318 &backend,
4319 &Arc::new(TmuxManager::new()),
4320 "owner/repo",
4321 &EngineConfig::default(),
4322 &Arc::new(RwLock::new(crate::engine::router::Router::from_config())),
4323 &task_manager,
4324 &store,
4325 &dispatching,
4326 &Arc::new(DashSet::new()),
4327 )
4328 .await
4329 .unwrap();
4330 assert_eq!(
4331 mock.project_reconciliations
4332 .load(std::sync::atomic::Ordering::Relaxed),
4333 1
4334 );
4335 assert!(mock.project_syncs.lock().await.is_empty());
4336 assert!(mock.status_updates.lock().await.is_empty());
4337 assert!(dispatching.is_empty());
4338 assert!(store
4339 .existing_external_ids("owner/repo")
4340 .await
4341 .unwrap()
4342 .is_empty());
4343 }
4344
4345 async fn stale_in_review_status_after_sync(with_decision: bool) -> crate::store::TaskStatus {
4348 crate::engine::cooldown::reset_global_state().await;
4349 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4350 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
4351 let task_manager = Arc::new(TaskManager::with_store(
4352 backend.clone(),
4353 store.clone(),
4354 "owner/repo".to_string(),
4355 ));
4356 let tmux = Arc::new(TmuxManager::new());
4357 let router = Arc::new(RwLock::new(crate::engine::router::Router::from_config()));
4358 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4359 let auto_merge_in_flight: Arc<DashSet<String>> = Arc::new(DashSet::new());
4360
4361 let id = store
4362 .upsert_external(&crate::store::UpsertExternal {
4363 repo: "owner/repo",
4364 ext_id: "55",
4365 title: "Orphaned review",
4366 body: "",
4367 author: "",
4368 url: "",
4369 labels: &[],
4370 origin: "github",
4371 })
4372 .await
4373 .unwrap();
4374 store
4375 .update_status(id, crate::store::TaskStatus::InReview)
4376 .await
4377 .unwrap();
4378 store
4379 .set_fields(id, &[("branch", serde_json::json!("feature/55"))])
4380 .await
4381 .unwrap();
4382 sqlx::query(
4384 "UPDATE task_activity SET timestamp = '2020-01-01T00:00:00Z'
4385 WHERE task_id = ? AND to_status = 'in_review'",
4386 )
4387 .bind(id)
4388 .execute(store.pool())
4389 .await
4390 .unwrap();
4391 if with_decision {
4392 crate::store::store_log_activity(
4393 &Some(store.clone()),
4394 "owner/repo",
4395 "55",
4396 "review_decision",
4397 None,
4398 None,
4399 None::<&str>,
4400 None::<&str>,
4401 Some(&serde_json::json!({"decision": "approve"})),
4402 )
4403 .await;
4404 }
4405
4406 sync_tick(
4407 &backend,
4408 &tmux,
4409 "owner/repo",
4410 &EngineConfig::default(),
4411 &router,
4412 &task_manager,
4413 &store,
4414 &dispatching,
4415 &auto_merge_in_flight,
4416 )
4417 .await
4418 .unwrap();
4419
4420 store.get(id).await.unwrap().status
4421 }
4422
4423 #[serial(cooldown_state)]
4424 #[tokio::test]
4425 async fn stale_in_review_recovery_resets_orphaned_task_without_session() {
4426 assert_eq!(
4428 stale_in_review_status_after_sync(false).await,
4429 crate::store::TaskStatus::NeedsReview
4430 );
4431 }
4432
4433 #[serial(cooldown_state)]
4434 #[tokio::test]
4435 async fn stale_in_review_recovery_leaves_decided_task_waiting_on_merge() {
4436 assert_eq!(
4437 stale_in_review_status_after_sync(true).await,
4438 crate::store::TaskStatus::InReview
4439 );
4440 }
4441
4442 #[serial(cooldown_state)]
4443 #[tokio::test]
4444 async fn needs_review_refire_increments_and_fires_when_old_enough() {
4445 crate::engine::cooldown::reset_global_state().await;
4446 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4447 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
4448 let task_manager = Arc::new(TaskManager::with_store(
4449 backend.clone(),
4450 store.clone(),
4451 "owner/repo".to_string(),
4452 ));
4453 let tmux = Arc::new(TmuxManager::new());
4454 let router = Arc::new(RwLock::new(reviewer_router()));
4455 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4456 let auto_merge_in_flight: Arc<DashSet<String>> = Arc::new(DashSet::new());
4457
4458 let id = store
4460 .upsert_external(&crate::store::UpsertExternal {
4461 repo: "owner/repo",
4462 ext_id: "201",
4463 title: "Needs review old",
4464 body: "",
4465 author: "",
4466 url: "",
4467 labels: &[],
4468 origin: "github",
4469 })
4470 .await
4471 .unwrap();
4472 store
4473 .update_status(id, crate::store::TaskStatus::NeedsReview)
4474 .await
4475 .unwrap();
4476 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
4478 .bind(id)
4479 .execute(store.pool())
4480 .await
4481 .unwrap();
4482
4483 let t = store.get(id).await.unwrap();
4485 assert_eq!(t.needs_review_refires, 0);
4486
4487 sync_tick(
4489 &backend,
4490 &tmux,
4491 "owner/repo",
4492 &EngineConfig::default(),
4493 &router,
4494 &task_manager,
4495 &store,
4496 &dispatching,
4497 &auto_merge_in_flight,
4498 )
4499 .await
4500 .unwrap();
4501
4502 let t2 = store.get(id).await.unwrap();
4503 assert_eq!(t2.needs_review_refires, 1);
4504 assert_eq!(t2.status, crate::store::TaskStatus::NeedsReview);
4505 }
4506
4507 #[serial(cooldown_state)]
4508 #[tokio::test]
4509 async fn needs_review_backoff_delays_fire_but_increments_counter() {
4510 crate::engine::cooldown::reset_global_state().await;
4511 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4512 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
4513 let task_manager = Arc::new(TaskManager::with_store(
4514 backend.clone(),
4515 store.clone(),
4516 "owner/repo".to_string(),
4517 ));
4518 let tmux = Arc::new(TmuxManager::new());
4519 let router = Arc::new(RwLock::new(crate::engine::router::Router::from_config()));
4520 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4521 let auto_merge_in_flight: Arc<DashSet<String>> = Arc::new(DashSet::new());
4522
4523 let id = store
4524 .upsert_external(&crate::store::UpsertExternal {
4525 repo: "owner/repo",
4526 ext_id: "202",
4527 title: "Needs review backoff",
4528 body: "",
4529 author: "",
4530 url: "",
4531 labels: &[],
4532 origin: "github",
4533 })
4534 .await
4535 .unwrap();
4536 store
4537 .update_status(id, crate::store::TaskStatus::NeedsReview)
4538 .await
4539 .unwrap();
4540
4541 store
4543 .set_fields(id, &[("needs_review_refires", serde_json::json!(1))])
4544 .await
4545 .unwrap();
4546
4547 let now_minus_1 = (chrono::Utc::now() - chrono::Duration::minutes(1))
4549 .format("%Y-%m-%dT%H:%M:%SZ")
4550 .to_string();
4551 sqlx::query("UPDATE tasks SET updated_at = ? WHERE id = ?")
4552 .bind(now_minus_1.clone())
4553 .bind(id)
4554 .execute(store.pool())
4555 .await
4556 .unwrap();
4557
4558 let before = store.get(id).await.unwrap();
4559 assert_eq!(before.needs_review_refires, 1);
4560
4561 sync_tick(
4562 &backend,
4563 &tmux,
4564 "owner/repo",
4565 &EngineConfig::default(),
4566 &router,
4567 &task_manager,
4568 &store,
4569 &dispatching,
4570 &auto_merge_in_flight,
4571 )
4572 .await
4573 .unwrap();
4574
4575 let after = store.get(id).await.unwrap();
4576 assert_eq!(after.needs_review_refires, 1);
4579 assert_eq!(after.updated_at, now_minus_1);
4580 assert_eq!(after.status, crate::store::TaskStatus::NeedsReview);
4581 }
4582
4583 fn reviewer_router() -> crate::engine::router::Router {
4585 let mut config = crate::engine::router::RouterConfig::default();
4586 config.model_map.insert(
4587 "review".to_string(),
4588 std::collections::HashMap::from([("claude".to_string(), vec!["sonnet".to_string()])]),
4589 );
4590 crate::engine::router::Router::new_for_test(config, vec!["claude".to_string()])
4591 }
4592
4593 #[serial(cooldown_state)]
4594 #[tokio::test]
4595 async fn needs_review_escalates_to_blocked_after_max_refires() {
4596 crate::engine::cooldown::reset_global_state().await;
4597 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4598 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
4599 let task_manager = Arc::new(TaskManager::with_store(
4600 backend.clone(),
4601 store.clone(),
4602 "owner/repo".to_string(),
4603 ));
4604 let tmux = Arc::new(TmuxManager::new());
4605 let router = Arc::new(RwLock::new(reviewer_router()));
4606 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4607 let auto_merge_in_flight: Arc<DashSet<String>> = Arc::new(DashSet::new());
4608
4609 let id = store
4610 .upsert_external(&crate::store::UpsertExternal {
4611 repo: "owner/repo",
4612 ext_id: "203",
4613 title: "Needs review escalate",
4614 body: "",
4615 author: "",
4616 url: "",
4617 labels: &[],
4618 origin: "github",
4619 })
4620 .await
4621 .unwrap();
4622 store
4623 .update_status(id, crate::store::TaskStatus::NeedsReview)
4624 .await
4625 .unwrap();
4626
4627 store
4631 .set_fields(id, &[("needs_review_refires", serde_json::json!(5))])
4632 .await
4633 .unwrap();
4634 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
4636 .bind(id)
4637 .execute(store.pool())
4638 .await
4639 .unwrap();
4640
4641 sync_tick(
4642 &backend,
4643 &tmux,
4644 "owner/repo",
4645 &EngineConfig::default(),
4646 &router,
4647 &task_manager,
4648 &store,
4649 &dispatching,
4650 &auto_merge_in_flight,
4651 )
4652 .await
4653 .unwrap();
4654
4655 let after = store.get(id).await.unwrap();
4656 assert_eq!(after.status, crate::store::TaskStatus::Blocked);
4657 assert!(after.block_reason.is_some());
4658 }
4659
4660 #[serial(cooldown_state)]
4664 #[tokio::test]
4665 async fn needs_review_allows_max_refires_before_escalation() {
4666 crate::engine::cooldown::reset_global_state().await;
4667 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4668 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
4669 let task_manager = Arc::new(TaskManager::with_store(
4670 backend.clone(),
4671 store.clone(),
4672 "owner/repo".to_string(),
4673 ));
4674 let tmux = Arc::new(TmuxManager::new());
4675 let router = Arc::new(RwLock::new(reviewer_router()));
4676 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4677 let auto_merge_in_flight: Arc<DashSet<String>> = Arc::new(DashSet::new());
4678
4679 let id = store
4680 .upsert_external(&crate::store::UpsertExternal {
4681 repo: "owner/repo",
4682 ext_id: "204",
4683 title: "Max refires test",
4684 body: "",
4685 author: "",
4686 url: "",
4687 labels: &[],
4688 origin: "github",
4689 })
4690 .await
4691 .unwrap();
4692 store
4693 .update_status(id, crate::store::TaskStatus::NeedsReview)
4694 .await
4695 .unwrap();
4696
4697 store
4700 .set_fields(id, &[("needs_review_refires", serde_json::json!(4))])
4701 .await
4702 .unwrap();
4703 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
4705 .bind(id)
4706 .execute(store.pool())
4707 .await
4708 .unwrap();
4709
4710 sync_tick(
4711 &backend,
4712 &tmux,
4713 "owner/repo",
4714 &EngineConfig::default(),
4715 &router,
4716 &task_manager,
4717 &store,
4718 &dispatching,
4719 &auto_merge_in_flight,
4720 )
4721 .await
4722 .unwrap();
4723
4724 let after = store.get(id).await.unwrap();
4725 assert_eq!(after.status, crate::store::TaskStatus::NeedsReview);
4727 assert_eq!(after.needs_review_refires, 5);
4728 assert!(after.block_reason.is_none());
4729 }
4730
4731 fn router_with_healthy_review_agent() -> crate::engine::router::Router {
4734 use crate::engine::router::{Router, RouterConfig};
4735 let mut config = RouterConfig::default();
4736 config
4737 .model_map
4738 .entry("review".to_string())
4739 .or_default()
4740 .insert("claude".to_string(), vec!["sonnet".to_string()]);
4741 let mut router = Router::new(config);
4742 router.available_agents = vec!["claude".to_string()];
4743 router
4744 }
4745
4746 #[serial(cooldown_state)]
4753 #[tokio::test]
4754 async fn global_refire_sweep_refires_stale_task_from_inactive_repo() {
4755 crate::engine::cooldown::reset_global_state().await;
4756 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4757 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
4758 let task_manager = Arc::new(TaskManager::with_store(
4760 backend,
4761 store.clone(),
4762 "active/repo".to_string(),
4763 ));
4764 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4765 let router = router_with_healthy_review_agent();
4766
4767 let id = store
4768 .upsert_external(&crate::store::UpsertExternal {
4769 repo: "inactive/repo",
4770 ext_id: "490",
4771 title: "Stale needs_review from inactive repo",
4772 body: "",
4773 author: "",
4774 url: "",
4775 labels: &[],
4776 origin: "github",
4777 })
4778 .await
4779 .unwrap();
4780 store
4781 .update_status(id, crate::store::TaskStatus::NeedsReview)
4782 .await
4783 .unwrap();
4784 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
4785 .bind(id)
4786 .execute(store.pool())
4787 .await
4788 .unwrap();
4789
4790 refire_and_escalate_stale_needs_review_global(&task_manager, &store, &router, &dispatching)
4791 .await
4792 .unwrap();
4793
4794 let task = store.get(id).await.unwrap();
4795 assert_eq!(
4796 task.needs_review_refires, 1,
4797 "cross-repo task must be refired"
4798 );
4799 assert_eq!(task.status, crate::store::TaskStatus::NeedsReview);
4800 }
4801
4802 #[serial(cooldown_state)]
4805 #[tokio::test]
4806 async fn global_refire_sweep_escalates_cross_repo_task_to_blocked() {
4807 crate::engine::cooldown::reset_global_state().await;
4808 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4809 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
4810 let task_manager = Arc::new(TaskManager::with_store(
4811 backend,
4812 store.clone(),
4813 "active/repo".to_string(),
4814 ));
4815 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4816 let router = router_with_healthy_review_agent();
4817
4818 let id = store
4819 .upsert_external(&crate::store::UpsertExternal {
4820 repo: "inactive/repo",
4821 ext_id: "493",
4822 title: "Exhausted refires from inactive repo",
4823 body: "",
4824 author: "",
4825 url: "",
4826 labels: &[],
4827 origin: "github",
4828 })
4829 .await
4830 .unwrap();
4831 store
4832 .update_status(id, crate::store::TaskStatus::NeedsReview)
4833 .await
4834 .unwrap();
4835 store
4836 .set_fields(id, &[("needs_review_refires", serde_json::json!(5))])
4837 .await
4838 .unwrap();
4839 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
4840 .bind(id)
4841 .execute(store.pool())
4842 .await
4843 .unwrap();
4844
4845 refire_and_escalate_stale_needs_review_global(&task_manager, &store, &router, &dispatching)
4846 .await
4847 .unwrap();
4848
4849 let task = store.get(id).await.unwrap();
4850 assert_eq!(task.status, crate::store::TaskStatus::Blocked);
4851 assert_eq!(
4852 task.block_reason.as_deref(),
4853 Some("review agent rebroadcast escalated after repeated retries")
4854 );
4855 }
4856
4857 #[serial(cooldown_state)]
4862 #[tokio::test]
4863 async fn global_refire_sweep_skips_when_all_review_agents_cooled() {
4864 crate::engine::cooldown::reset_global_state().await;
4865 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4866 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
4867 let task_manager = Arc::new(TaskManager::with_store(
4868 backend,
4869 store.clone(),
4870 "active/repo".to_string(),
4871 ));
4872 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4873 let router = crate::engine::router::Router::new_for_test(
4876 crate::engine::router::RouterConfig::default(),
4877 vec!["claude".to_string()],
4878 );
4879
4880 let id = store
4881 .upsert_external(&crate::store::UpsertExternal {
4882 repo: "inactive/repo",
4883 ext_id: "494",
4884 title: "Stale needs_review while agents cooled",
4885 body: "",
4886 author: "",
4887 url: "",
4888 labels: &[],
4889 origin: "github",
4890 })
4891 .await
4892 .unwrap();
4893 store
4894 .update_status(id, crate::store::TaskStatus::NeedsReview)
4895 .await
4896 .unwrap();
4897 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
4898 .bind(id)
4899 .execute(store.pool())
4900 .await
4901 .unwrap();
4902
4903 refire_and_escalate_stale_needs_review_global(&task_manager, &store, &router, &dispatching)
4904 .await
4905 .unwrap();
4906
4907 let task = store.get(id).await.unwrap();
4908 assert_eq!(
4909 task.needs_review_refires, 0,
4910 "counter must not be touched while cooled"
4911 );
4912 assert_eq!(task.status, crate::store::TaskStatus::NeedsReview);
4913 }
4914
4915 #[serial(cooldown_state)]
4918 #[tokio::test]
4919 async fn global_refire_sweep_skips_when_no_reviewer_available() {
4920 crate::engine::cooldown::reset_global_state().await;
4921 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4922 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
4923 let task_manager = Arc::new(TaskManager::with_store(
4924 backend,
4925 store.clone(),
4926 "active/repo".to_string(),
4927 ));
4928 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4929 let router = crate::engine::router::Router::new_for_test(
4930 crate::engine::router::RouterConfig::default(),
4931 vec![],
4932 );
4933
4934 let id = store
4935 .upsert_external(&crate::store::UpsertExternal {
4936 repo: "some/repo",
4937 ext_id: "495",
4938 title: "needs_review with no reviewer",
4939 body: "",
4940 author: "",
4941 url: "",
4942 labels: &[],
4943 origin: "github",
4944 })
4945 .await
4946 .unwrap();
4947 store
4948 .update_status(id, crate::store::TaskStatus::NeedsReview)
4949 .await
4950 .unwrap();
4951 store
4952 .set_fields(id, &[("needs_review_refires", serde_json::json!(5))])
4953 .await
4954 .unwrap();
4955 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
4956 .bind(id)
4957 .execute(store.pool())
4958 .await
4959 .unwrap();
4960
4961 refire_and_escalate_stale_needs_review_global(&task_manager, &store, &router, &dispatching)
4962 .await
4963 .unwrap();
4964
4965 let task = store.get(id).await.unwrap();
4966 assert_eq!(task.status, crate::store::TaskStatus::NeedsReview);
4967 assert_eq!(task.needs_review_refires, 5);
4968 }
4969
4970 #[serial(cooldown_state)]
4976 #[tokio::test]
4977 async fn auto_recover_rebroadcast_blocked_task_after_review_cycle() {
4978 crate::engine::cooldown::reset_global_state().await;
4979 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4980 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
4981 let task_manager = Arc::new(TaskManager::with_store(
4982 backend,
4983 store.clone(),
4984 "owner/repo".to_string(),
4985 ));
4986 let router = Arc::new(RwLock::new(router_with_healthy_review_agent()));
4987
4988 let id = store
4989 .upsert_external(&crate::store::UpsertExternal {
4990 repo: "owner/repo",
4991 ext_id: "511",
4992 title: "Rebroadcast blocked after review",
4993 body: "",
4994 author: "",
4995 url: "",
4996 labels: &[],
4997 origin: "github",
4998 })
4999 .await
5000 .unwrap();
5001 store
5002 .update_status(id, crate::store::TaskStatus::Blocked)
5003 .await
5004 .unwrap();
5005 store
5007 .set_block_reason(
5008 id,
5009 Some("review agent rebroadcast escalated after repeated retries"),
5010 )
5011 .await
5012 .unwrap();
5013 store
5014 .set_fields(id, &[("review_cycles", serde_json::json!(1))])
5015 .await
5016 .unwrap();
5017 store
5018 .set_fields(id, &[("needs_review_refires", serde_json::json!(6))])
5019 .await
5020 .unwrap();
5021 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
5023 .bind(id)
5024 .execute(store.pool())
5025 .await
5026 .unwrap();
5027
5028 auto_recover_rebroadcast_blocked_tasks("owner/repo", &router, &task_manager, &store)
5029 .await
5030 .unwrap();
5031
5032 let task = store.get(id).await.unwrap();
5033 assert_eq!(
5034 task.status,
5035 crate::store::TaskStatus::NeedsReview,
5036 "blocked rebroadcast task must be recovered to needs_review"
5037 );
5038 assert!(
5039 task.block_reason.is_none(),
5040 "block_reason must be cleared on recovery"
5041 );
5042 assert_eq!(
5043 task.needs_review_refires, 0,
5044 "needs_review_refires must be reset to 0 on recovery"
5045 );
5046 }
5047
5048 #[serial(cooldown_state)]
5051 #[tokio::test]
5052 async fn stale_in_review_recovery_skips_fresh_tasks() {
5053 crate::engine::cooldown::reset_global_state().await;
5054 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5055 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
5056 let task_manager = Arc::new(TaskManager::with_store(
5057 backend.clone(),
5058 store.clone(),
5059 "owner/repo".to_string(),
5060 ));
5061 let tmux = Arc::new(TmuxManager::new());
5062 let router = Arc::new(RwLock::new(crate::engine::router::Router::from_config()));
5063 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
5064 let auto_merge_in_flight: Arc<DashSet<String>> = Arc::new(DashSet::new());
5065
5066 let id = store
5067 .upsert_external(&crate::store::UpsertExternal {
5068 repo: "owner/repo",
5069 ext_id: "66",
5070 title: "Fresh review",
5071 body: "",
5072 author: "",
5073 url: "",
5074 labels: &[],
5075 origin: "github",
5076 })
5077 .await
5078 .unwrap();
5079 store
5080 .update_status(id, crate::store::TaskStatus::InReview)
5081 .await
5082 .unwrap();
5083 store
5084 .set_fields(id, &[("branch", serde_json::json!("feature/66"))])
5085 .await
5086 .unwrap();
5087 sync_tick(
5090 &backend,
5091 &tmux,
5092 "owner/repo",
5093 &EngineConfig::default(),
5094 &router,
5095 &task_manager,
5096 &store,
5097 &dispatching,
5098 &auto_merge_in_flight,
5099 )
5100 .await
5101 .unwrap();
5102
5103 let task = store.get(id).await.unwrap();
5105 assert_eq!(task.status, crate::store::TaskStatus::InReview);
5106 }
5107
5108 #[serial(cooldown_state)]
5109 #[tokio::test]
5110 async fn auto_unblock_routes_recoverable_failure() {
5111 crate::engine::cooldown::reset_global_state().await;
5112 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5113 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
5114 let task_manager = Arc::new(TaskManager::with_store(
5115 backend,
5116 store.clone(),
5117 "owner/repo".to_string(),
5118 ));
5119 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
5120
5121 let id = store
5122 .upsert_external(&crate::store::UpsertExternal {
5123 repo: "owner/repo",
5124 ext_id: "77",
5125 title: "Blocked task",
5126 body: "",
5127 author: "",
5128 url: "",
5129 labels: &[],
5130 origin: "github",
5131 })
5132 .await
5133 .unwrap();
5134 store
5135 .update_status(id, crate::store::TaskStatus::Blocked)
5136 .await
5137 .unwrap();
5138 store
5139 .set_fields(
5140 id,
5141 &[
5142 ("agent", serde_json::json!("codex")),
5143 ("model", serde_json::json!("gpt-5")),
5144 ],
5145 )
5146 .await
5147 .unwrap();
5148
5149 let run_id = store
5150 .start_run(&crate::store::StartRun {
5151 task_id: id,
5152 attempt: 1,
5153 run_type: "agent",
5154 agent: "codex",
5155 model: "gpt-5",
5156 command: "codex --model gpt-5",
5157 prompt: "do thing",
5158 })
5159 .await
5160 .unwrap();
5161 store
5162 .complete_run(&crate::store::CompleteRun {
5163 run_id,
5164 exit_code: Some(1),
5165 stdout: "",
5166 stderr: "",
5167 parsed: "",
5168 outcome: "rate_limit",
5169 error: "rate limit exceeded",
5170 tokens: RunTokenUsage::default(),
5171 })
5172 .await
5173 .unwrap();
5174
5175 auto_unblock_blocked_tasks(
5176 "owner/repo",
5177 &task_manager,
5178 &store,
5179 &dispatching,
5180 &Arc::new(DashSet::new()),
5181 )
5182 .await
5183 .unwrap();
5184
5185 let task = store.get(id).await.unwrap();
5186 assert_eq!(task.status, TaskStatus::New);
5187 assert!(task.agent.is_none());
5188 assert!(task.model.is_none());
5189 assert_eq!(task.auto_unblock_count, 1);
5190 assert!(!task.auto_unblock_last_at.is_empty());
5191 assert_eq!(task.auto_unblock_last_reason, "RateLimit");
5192 }
5193
5194 #[serial(cooldown_state)]
5195 #[tokio::test]
5196 async fn auto_unblock_skips_manual_block() {
5197 crate::engine::cooldown::reset_global_state().await;
5198 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5199 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
5200 let task_manager = Arc::new(TaskManager::with_store(
5201 backend,
5202 store.clone(),
5203 "owner/repo".to_string(),
5204 ));
5205 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
5206
5207 let id = store
5208 .upsert_external(&crate::store::UpsertExternal {
5209 repo: "owner/repo",
5210 ext_id: "88",
5211 title: "Manually blocked",
5212 body: "",
5213 author: "",
5214 url: "",
5215 labels: &[],
5216 origin: "github",
5217 })
5218 .await
5219 .unwrap();
5220 store
5221 .update_status(id, crate::store::TaskStatus::Blocked)
5222 .await
5223 .unwrap();
5224 store
5225 .set_block_reason(id, Some("waiting on input"))
5226 .await
5227 .unwrap();
5228
5229 let run_id = store
5230 .start_run(&crate::store::StartRun {
5231 task_id: id,
5232 attempt: 1,
5233 run_type: "agent",
5234 agent: "codex",
5235 model: "gpt-5",
5236 command: "codex --model gpt-5",
5237 prompt: "do thing",
5238 })
5239 .await
5240 .unwrap();
5241 store
5242 .complete_run(&crate::store::CompleteRun {
5243 run_id,
5244 exit_code: Some(1),
5245 stdout: "",
5246 stderr: "",
5247 parsed: "",
5248 outcome: "rate_limit",
5249 error: "rate limit exceeded",
5250 tokens: RunTokenUsage::default(),
5251 })
5252 .await
5253 .unwrap();
5254
5255 auto_unblock_blocked_tasks(
5256 "owner/repo",
5257 &task_manager,
5258 &store,
5259 &dispatching,
5260 &Arc::new(DashSet::new()),
5261 )
5262 .await
5263 .unwrap();
5264
5265 let task = store.get(id).await.unwrap();
5266 assert_eq!(task.status, crate::store::TaskStatus::Blocked);
5267 assert_eq!(task.auto_unblock_count, 0);
5268 }
5269
5270 #[serial(cooldown_state)]
5273 #[test]
5274 fn failure_category_as_str() {
5275 assert_eq!(FailureCategory::RateLimit.as_str(), "RateLimit");
5278 assert_eq!(FailureCategory::Timeout.as_str(), "Timeout");
5279 assert_eq!(
5280 FailureCategory::ModelUnavailable.as_str(),
5281 "ModelUnavailable"
5282 );
5283 assert_eq!(FailureCategory::MaxAttempts.as_str(), "MaxAttempts");
5284 assert_eq!(FailureCategory::Unknown.as_str(), "Unknown");
5285 assert_eq!(FailureCategory::SilentExit0.as_str(), "SilentExit0");
5286 assert_eq!(FailureCategory::ConnectionError.as_str(), "ConnectionError");
5287 assert_eq!(FailureCategory::CliFlagError.as_str(), "CliFlagError");
5288 assert_eq!(
5289 FailureCategory::AllAgentsExhausted.as_str(),
5290 "AllAgentsExhausted"
5291 );
5292 assert_eq!(FailureCategory::ParseError.as_str(), "ParseError");
5293 assert_eq!(FailureCategory::FalseFailure.as_str(), "FalseFailure");
5294 assert_eq!(FailureCategory::PushFailed.as_str(), "PushFailed");
5295 assert_eq!(FailureCategory::PrCreateFailed.as_str(), "PrCreateFailed");
5296 }
5297
5298 #[serial(cooldown_state)]
5301 #[test]
5302 fn classify_failure_model_unavailable_phrase_is_recoverable() {
5303 let category = classify_failure("model unavailable (anthropic/claude-sonnet-4-6): Model not found: anthropic/claude-sonnet-4-6", "error");
5304 assert_eq!(
5305 category,
5306 FailureCategory::ModelUnavailable,
5307 "\"model unavailable\" error must classify as ModelUnavailable"
5308 );
5309 assert!(
5310 category.is_recoverable(),
5311 "ModelUnavailable must be recoverable so auto-unblock can re-route"
5312 );
5313 }
5314
5315 #[serial(cooldown_state)]
5317 #[test]
5318 fn classify_failure_kimi_billing_cycle_is_credit_exhausted_not_rate_limit() {
5319 let error = "kimi rate limit: Failed to authenticate. API Error: 403 You've reached \
5326 your usage limit for this billing cycle. Your quota will be refreshed soon.";
5327 let category = classify_failure(error, "rate_limit");
5328 assert_eq!(
5329 category,
5330 FailureCategory::CreditExhausted,
5331 "billing-cycle exhaustion must classify as CreditExhausted, not RateLimit"
5332 );
5333 assert!(category.is_recoverable());
5334 }
5335
5336 #[serial(cooldown_state)]
5337 #[test]
5338 fn classify_failure_billing_cycle_exhausted_outcome_is_credit_exhausted() {
5339 let category = classify_failure("some billing message", "billing_cycle_exhausted");
5342 assert_eq!(category, FailureCategory::CreditExhausted);
5343 }
5344
5345 #[serial(cooldown_state)]
5346 #[test]
5347 fn classify_failure_generic_rate_limit_is_still_rate_limit() {
5348 let category = classify_failure("429 Too Many Requests", "rate_limit");
5351 assert_eq!(category, FailureCategory::RateLimit);
5352 }
5353
5354 #[serial(cooldown_state)]
5356 #[test]
5357 fn classify_failure_pr_create_failed_specific_patterns() {
5358 assert_eq!(
5360 classify_failure("create pr failed", ""),
5361 FailureCategory::PrCreateFailed
5362 );
5363 assert_eq!(
5364 classify_failure("failed to create pull request", ""),
5365 FailureCategory::PrCreateFailed
5366 );
5367 assert_eq!(
5368 classify_failure("pull request creation failed", ""),
5369 FailureCategory::PrCreateFailed
5370 );
5371
5372 assert_ne!(
5374 classify_failure("network failed while fetching pull request status", ""),
5375 FailureCategory::PrCreateFailed
5376 );
5377 assert_ne!(
5378 classify_failure("connection failed, retrying pull request check", ""),
5379 FailureCategory::PrCreateFailed
5380 );
5381 assert_ne!(
5382 classify_failure("pull request created but failed to merge", ""),
5383 FailureCategory::PrCreateFailed
5384 );
5385 }
5386
5387 #[serial(cooldown_state)]
5388 #[test]
5389 fn classify_failure_model_not_found_is_recoverable() {
5390 let category = classify_failure("Model not found: gpt-5-ultra", "error");
5391 assert_eq!(
5392 category,
5393 FailureCategory::ModelUnavailable,
5394 "\"model not found\" error must classify as ModelUnavailable"
5395 );
5396 assert!(category.is_recoverable());
5397 }
5398
5399 #[serial(cooldown_state)]
5400 #[test]
5401 fn classify_failure_model_does_not_exist_is_recoverable() {
5402 let category = classify_failure("The model `claude-opus-99` does not exist", "error");
5403 assert_eq!(category, FailureCategory::ModelUnavailable);
5404 assert!(category.is_recoverable());
5405 }
5406
5407 #[serial(cooldown_state)]
5408 #[tokio::test]
5409 async fn auto_unblock_routes_task_blocked_by_model_unavailable() {
5410 crate::engine::cooldown::reset_global_state().await;
5411 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5412 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
5413 let task_manager = Arc::new(TaskManager::with_store(
5414 backend,
5415 store.clone(),
5416 "owner/repo".to_string(),
5417 ));
5418 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
5419
5420 let id = store
5421 .upsert_external(&crate::store::UpsertExternal {
5422 repo: "owner/repo",
5423 ext_id: "1202",
5424 title: "Task blocked by model unavailable",
5425 body: "",
5426 author: "",
5427 url: "",
5428 labels: &[],
5429 origin: "github",
5430 })
5431 .await
5432 .unwrap();
5433 store
5434 .update_status(id, crate::store::TaskStatus::Blocked)
5435 .await
5436 .unwrap();
5437
5438 let run_id = store
5439 .start_run(&crate::store::StartRun {
5440 task_id: id,
5441 attempt: 1,
5442 run_type: "agent",
5443 agent: "opencode",
5444 model: "anthropic/claude-sonnet-4-6",
5445 command: "opencode",
5446 prompt: "fix the bug",
5447 })
5448 .await
5449 .unwrap();
5450 store
5451 .complete_run(&crate::store::CompleteRun {
5452 run_id,
5453 exit_code: Some(1),
5454 stdout: "",
5455 stderr: "",
5456 parsed: "",
5457 outcome: "error",
5458 error: "model unavailable (anthropic/claude-sonnet-4-6): Model not found: anthropic/claude-sonnet-4-6",
5459 tokens: RunTokenUsage::default(),
5460 })
5461 .await
5462 .unwrap();
5463
5464 auto_unblock_blocked_tasks(
5465 "owner/repo",
5466 &task_manager,
5467 &store,
5468 &dispatching,
5469 &Arc::new(DashSet::new()),
5470 )
5471 .await
5472 .unwrap();
5473
5474 let task = store.get(id).await.unwrap();
5475 assert_eq!(
5476 task.status,
5477 TaskStatus::New,
5478 "task blocked by model unavailable must be auto-unblocked and re-routed"
5479 );
5480 assert_eq!(task.auto_unblock_count, 1);
5481 assert_eq!(task.auto_unblock_last_reason, "ModelUnavailable");
5482 }
5483
5484 #[serial(cooldown_state)]
5487 #[tokio::test]
5488 async fn auto_unblock_resets_count_when_reason_changes() {
5489 crate::engine::cooldown::reset_global_state().await;
5490 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5494 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
5495 let task_manager = Arc::new(TaskManager::with_store(
5496 backend,
5497 store.clone(),
5498 "owner/repo".to_string(),
5499 ));
5500 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
5501
5502 let id = store
5503 .upsert_external(&crate::store::UpsertExternal {
5504 repo: "owner/repo",
5505 ext_id: "1227",
5506 title: "Task with different failure reason",
5507 body: "",
5508 author: "",
5509 url: "",
5510 labels: &[],
5511 origin: "github",
5512 })
5513 .await
5514 .unwrap();
5515 store
5516 .update_status(id, crate::store::TaskStatus::Blocked)
5517 .await
5518 .unwrap();
5519 store
5521 .set_fields(
5522 id,
5523 &[
5524 ("auto_unblock_count", serde_json::json!(1)),
5525 (
5526 "auto_unblock_last_reason",
5527 serde_json::json!("ModelUnavailable"),
5528 ),
5529 ],
5530 )
5531 .await
5532 .unwrap();
5533
5534 let run_id = store
5536 .start_run(&crate::store::StartRun {
5537 task_id: id,
5538 attempt: 2,
5539 run_type: "agent",
5540 agent: "claude",
5541 model: "opus",
5542 command: "claude",
5543 prompt: "fix the bug",
5544 })
5545 .await
5546 .unwrap();
5547 store
5548 .complete_run(&crate::store::CompleteRun {
5549 run_id,
5550 exit_code: Some(1),
5551 stdout: "",
5552 stderr: "",
5553 parsed: "",
5554 outcome: "error",
5555 error: "rate limit reached for model opus",
5556 tokens: RunTokenUsage::default(),
5557 })
5558 .await
5559 .unwrap();
5560
5561 auto_unblock_blocked_tasks(
5562 "owner/repo",
5563 &task_manager,
5564 &store,
5565 &dispatching,
5566 &Arc::new(DashSet::new()),
5567 )
5568 .await
5569 .unwrap();
5570
5571 let task = store.get(id).await.unwrap();
5572 assert_eq!(
5573 task.status,
5574 TaskStatus::New,
5575 "task must be auto-unblocked even with count=1 from a different reason"
5576 );
5577 assert_eq!(task.auto_unblock_count, 1);
5579 assert_eq!(
5580 task.auto_unblock_last_reason, "RateLimit",
5581 "reason must be updated to the new failure category"
5582 );
5583 }
5584
5585 #[serial(cooldown_state)]
5591 #[tokio::test]
5592 async fn auto_unblock_leaves_max_attempts_blocked_task_alone() {
5593 crate::engine::cooldown::reset_global_state().await;
5594 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5595 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
5596 let task_manager = Arc::new(TaskManager::with_store(
5597 backend,
5598 store.clone(),
5599 "owner/repo".to_string(),
5600 ));
5601 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
5602
5603 let id = store
5604 .upsert_external(&crate::store::UpsertExternal {
5605 repo: "owner/repo",
5606 ext_id: "3783",
5607 title: "Approved work sent back to the agent",
5608 body: "",
5609 author: "",
5610 url: "",
5611 labels: &[],
5612 origin: "github",
5613 })
5614 .await
5615 .unwrap();
5616 store
5617 .update_status(id, crate::store::TaskStatus::Blocked)
5618 .await
5619 .unwrap();
5620
5621 let run_id = store
5622 .start_run(&crate::store::StartRun {
5623 task_id: id,
5624 attempt: 11,
5625 run_type: "agent",
5626 agent: "claude",
5627 model: "opus",
5628 command: "claude",
5629 prompt: "fix the bug",
5630 })
5631 .await
5632 .unwrap();
5633 store
5634 .complete_run(&crate::store::CompleteRun {
5635 run_id,
5636 exit_code: Some(1),
5637 stdout: "",
5638 stderr: "",
5639 parsed: "",
5640 outcome: "error",
5641 error: "exceeded max attempts (11/10). Use `/retry` to reset.",
5642 tokens: RunTokenUsage::default(),
5643 })
5644 .await
5645 .unwrap();
5646
5647 auto_unblock_blocked_tasks(
5648 "owner/repo",
5649 &task_manager,
5650 &store,
5651 &dispatching,
5652 &Arc::new(DashSet::new()),
5653 )
5654 .await
5655 .unwrap();
5656
5657 let task = store.get(id).await.unwrap();
5658 assert_eq!(
5659 task.status,
5660 TaskStatus::Blocked,
5661 "max-attempts block must survive the next tick until a person acts"
5662 );
5663 assert_eq!(
5664 task.auto_unblock_count, 0,
5665 "no auto-unblock attempt should be recorded"
5666 );
5667 }
5668
5669 #[serial(cooldown_state)]
5670 #[tokio::test]
5671 async fn auto_unblock_stores_reason_on_first_unblock() {
5672 crate::engine::cooldown::reset_global_state().await;
5673 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5676 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
5677 let task_manager = Arc::new(TaskManager::with_store(
5678 backend,
5679 store.clone(),
5680 "owner/repo".to_string(),
5681 ));
5682 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
5683
5684 let id = store
5685 .upsert_external(&crate::store::UpsertExternal {
5686 repo: "owner/repo",
5687 ext_id: "1227b",
5688 title: "Task for first-time reason storage",
5689 body: "",
5690 author: "",
5691 url: "",
5692 labels: &[],
5693 origin: "github",
5694 })
5695 .await
5696 .unwrap();
5697 store
5698 .update_status(id, crate::store::TaskStatus::Blocked)
5699 .await
5700 .unwrap();
5701
5702 let run_id = store
5703 .start_run(&crate::store::StartRun {
5704 task_id: id,
5705 attempt: 1,
5706 run_type: "agent",
5707 agent: "claude",
5708 model: "opus",
5709 command: "claude",
5710 prompt: "fix the bug",
5711 })
5712 .await
5713 .unwrap();
5714 store
5715 .complete_run(&crate::store::CompleteRun {
5716 run_id,
5717 exit_code: Some(1),
5718 stdout: "",
5719 stderr: "",
5720 parsed: "",
5721 outcome: "timeout",
5722 error: "task timed out",
5723 tokens: RunTokenUsage::default(),
5724 })
5725 .await
5726 .unwrap();
5727
5728 auto_unblock_blocked_tasks(
5729 "owner/repo",
5730 &task_manager,
5731 &store,
5732 &dispatching,
5733 &Arc::new(DashSet::new()),
5734 )
5735 .await
5736 .unwrap();
5737
5738 let task = store.get(id).await.unwrap();
5739 assert_eq!(task.status, TaskStatus::New);
5740 assert_eq!(task.auto_unblock_count, 1);
5741 assert_eq!(
5742 task.auto_unblock_last_reason, "Timeout",
5743 "reason must be stored on first auto-unblock"
5744 );
5745 }
5746
5747 #[serial(cooldown_state)]
5750 #[test]
5751 fn classify_failure_sparse_checkout_not_parse_error() {
5752 let category = classify_failure("error: cannot reset paths in a sparse checkout", "error");
5755 assert_ne!(
5756 category,
5757 FailureCategory::ParseError,
5758 "sparse checkout errors must NOT be classified as ParseError"
5759 );
5760 assert_eq!(
5761 category,
5762 FailureCategory::Unknown,
5763 "sparse checkout errors should be Unknown (not recoverable auto-unblock)"
5764 );
5765 }
5766
5767 #[serial(cooldown_state)]
5768 #[test]
5769 fn classify_failure_sparse_index_not_parse_error() {
5770 let category = classify_failure("failed due to sparse index configuration", "error");
5772 assert_ne!(category, FailureCategory::ParseError);
5773 assert_eq!(category, FailureCategory::Unknown);
5774 }
5775
5776 #[serial(cooldown_state)]
5783 #[test]
5784 fn classify_failure_truncated_is_recoverable() {
5785 let category = classify_failure(
5786 "truncated: review output was cut off before completion \
5787 (output/reasoning token budget exceeded)",
5788 "truncated",
5789 );
5790 assert_eq!(
5791 category,
5792 FailureCategory::ParseError,
5793 "truncated outcome must classify as a recoverable parse-style failure"
5794 );
5795 assert!(
5796 category.is_recoverable(),
5797 "truncated failures must be recoverable so auto-unblock can retry"
5798 );
5799 }
5800
5801 #[serial(cooldown_state)]
5802 #[test]
5803 fn dispatching_key_includes_repo_for_cross_project_isolation() {
5804 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
5805
5806 let key_a = "owner/repo-a/42".to_string();
5808 dispatching.insert(key_a.clone(), "42".to_string());
5809
5810 let key_b = "owner/repo-b/42".to_string();
5812 assert!(dispatching.contains_key(&key_a));
5813 assert!(
5814 !dispatching.contains_key(&key_b),
5815 "same task ID in different repo should not be blocked"
5816 );
5817 }
5818
5819 #[serial(cooldown_state)]
5820 #[tokio::test]
5821 async fn emit_degraded_agents_writes_metric_when_three_or_more_agents_degraded() {
5822 crate::engine::cooldown::reset_global_state().await;
5823 use crate::engine::router::{Router, RouterConfig};
5824
5825 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5826
5827 let mut config = RouterConfig::default();
5829 config
5830 .model_map
5831 .entry("medium".to_string())
5832 .or_default()
5833 .insert(
5834 "test-agent-3a".to_string(),
5835 vec!["test-model-a".to_string()],
5836 );
5837 config
5838 .model_map
5839 .entry("medium".to_string())
5840 .or_default()
5841 .insert(
5842 "test-agent-3b".to_string(),
5843 vec!["test-model-b".to_string()],
5844 );
5845 config
5846 .model_map
5847 .entry("medium".to_string())
5848 .or_default()
5849 .insert(
5850 "test-agent-3c".to_string(),
5851 vec!["test-model-c".to_string()],
5852 );
5853 config
5854 .model_map
5855 .entry("medium".to_string())
5856 .or_default()
5857 .insert(
5858 "test-agent-3d".to_string(),
5859 vec!["test-model-d".to_string()],
5860 );
5861 let mut router = Router::new(config);
5862 router.available_agents = vec![
5863 "test-agent-3a".to_string(),
5864 "test-agent-3b".to_string(),
5865 "test-agent-3c".to_string(),
5866 "test-agent-3d".to_string(),
5867 ];
5868
5869 set_agent_cooldown("test-agent-3a", 3600).await;
5871 set_agent_cooldown("test-agent-3b", 3600).await;
5872 set_agent_cooldown("test-agent-3c", 3600).await;
5873
5874 emit_degraded_agents_if_needed(&router.available_agents, &router.config, Some(&store))
5876 .await;
5877
5878 let count_val = store
5880 .kv_get("metrics:orch.agents_degraded.count")
5881 .await
5882 .unwrap();
5883 assert_eq!(count_val.as_deref(), Some("3"));
5884
5885 let alert_val = store
5887 .kv_get("metrics:orch.agents_degraded.alert")
5888 .await
5889 .unwrap();
5890 assert_eq!(alert_val.as_deref(), Some("1"));
5891 }
5892
5893 #[serial(cooldown_state)]
5894 #[tokio::test]
5895 async fn emit_degraded_agents_clears_alert_when_below_threshold() {
5896 crate::engine::cooldown::reset_global_state().await;
5897 use crate::engine::router::{Router, RouterConfig};
5898
5899 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5900
5901 let mut config = RouterConfig::default();
5902 config
5903 .model_map
5904 .entry("medium".to_string())
5905 .or_default()
5906 .insert(
5907 "test-agent-2a".to_string(),
5908 vec!["test-model-a".to_string()],
5909 );
5910 config
5911 .model_map
5912 .entry("medium".to_string())
5913 .or_default()
5914 .insert(
5915 "test-agent-2b".to_string(),
5916 vec!["test-model-b".to_string()],
5917 );
5918 let mut router = Router::new(config);
5919 router.available_agents = vec!["test-agent-2a".to_string(), "test-agent-2b".to_string()];
5920
5921 set_agent_cooldown("test-agent-2a", 3600).await;
5923 set_agent_cooldown("test-agent-2b", 3600).await;
5924
5925 emit_degraded_agents_if_needed(&router.available_agents, &router.config, Some(&store))
5926 .await;
5927
5928 let count_val = store
5930 .kv_get("metrics:orch.agents_degraded.count")
5931 .await
5932 .unwrap();
5933 assert_eq!(count_val.as_deref(), Some("2"));
5934
5935 let alert_val = store
5937 .kv_get("metrics:orch.agents_degraded.alert")
5938 .await
5939 .unwrap();
5940 assert_eq!(alert_val.as_deref(), Some("0"));
5941
5942 let stale_pool_alert = store
5943 .kv_get("metrics:orch.model_pool_stale_persistent.alert")
5944 .await
5945 .unwrap();
5946 assert_eq!(stale_pool_alert.as_deref(), Some("0"));
5947 }
5948
5949 #[serial(cooldown_state)]
5950 #[tokio::test]
5951 async fn emit_degraded_agents_sets_stale_pool_alert_for_persistent_model_failures() {
5952 crate::engine::cooldown::reset_global_state().await;
5953 use crate::engine::cooldown::record_persistent_model_failure;
5954 use crate::engine::router::{Router, RouterConfig};
5955
5956 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5957
5958 let mut config = RouterConfig::default();
5959 config
5960 .model_map
5961 .entry("medium".to_string())
5962 .or_default()
5963 .insert(
5964 "test-agent-stale".to_string(),
5965 vec!["model-a".to_string(), "model-b".to_string()],
5966 );
5967 let mut router = Router::new(config);
5968 router.available_agents = vec!["test-agent-stale".to_string()];
5969
5970 record_persistent_model_failure("test-agent-stale", "model-a").await;
5972
5973 emit_degraded_agents_if_needed(&router.available_agents, &router.config, Some(&store))
5974 .await;
5975
5976 let stale_pool_alert = store
5977 .kv_get("metrics:orch.model_pool_stale_persistent.alert")
5978 .await
5979 .unwrap();
5980 assert_eq!(stale_pool_alert.as_deref(), Some("1"));
5981 }
5982
5983 use super::{classify_comment, CommentAction, MentionCursor};
5986 use crate::engine::commands::OwnerCommand;
5987
5988 #[serial(cooldown_state)]
5989 #[test]
5990 fn classify_skip_already_processed() {
5991 let action = classify_comment(
5992 "@orch /retry",
5993 Some("https://api.github.com/repos/o/r/issues/1"),
5994 "@bot",
5995 true,
5996 );
5997 assert_eq!(action, CommentAction::Skip);
5998 }
5999
6000 #[serial(cooldown_state)]
6001 #[test]
6002 fn classify_skip_irrelevant_comment() {
6003 let action = classify_comment("just a regular comment", None, "@bot", false);
6004 assert_eq!(action, CommentAction::Skip);
6005 }
6006
6007 #[serial(cooldown_state)]
6008 #[test]
6009 fn classify_command_without_mention() {
6010 let action = classify_comment(
6011 "/retry",
6012 Some("https://api.github.com/repos/o/r/issues/42"),
6013 "@bot",
6014 false,
6015 );
6016 assert_eq!(
6017 action,
6018 CommentAction::ExecuteCommand {
6019 command: OwnerCommand::Retry,
6020 issue_num: "42".to_string(),
6021 }
6022 );
6023 }
6024
6025 #[serial(cooldown_state)]
6026 #[test]
6027 fn classify_command_with_mention() {
6028 let action = classify_comment(
6029 "@orch /close",
6030 Some("https://api.github.com/repos/o/r/issues/7"),
6031 "@bot",
6032 false,
6033 );
6034 assert_eq!(
6035 action,
6036 CommentAction::ExecuteCommandForMention {
6037 command: OwnerCommand::Close,
6038 issue_num: "7".to_string(),
6039 }
6040 );
6041 }
6042
6043 #[serial(cooldown_state)]
6044 #[test]
6045 fn classify_mention_without_command() {
6046 let action = classify_comment(
6047 "@orch can you look at this?",
6048 Some("https://api.github.com/repos/o/r/issues/99"),
6049 "@bot",
6050 false,
6051 );
6052 assert_eq!(
6053 action,
6054 CommentAction::CreateMentionTask {
6055 issue_num: Some("99".to_string()),
6056 }
6057 );
6058 }
6059
6060 #[serial(cooldown_state)]
6061 #[test]
6062 fn classify_mention_without_issue_url() {
6063 let action = classify_comment("@orch help me", None, "@bot", false);
6064 assert_eq!(action, CommentAction::CreateMentionTask { issue_num: None });
6065 }
6066
6067 #[serial(cooldown_state)]
6068 #[test]
6069 fn classify_command_no_mention_no_url_skips() {
6070 let action = classify_comment("/retry", None, "@bot", false);
6071 assert_eq!(action, CommentAction::Skip);
6072 }
6073
6074 #[serial(cooldown_state)]
6075 #[test]
6076 fn classify_mention_via_current_user() {
6077 let action = classify_comment(
6078 "@bot please help",
6079 Some("https://api.github.com/repos/o/r/issues/5"),
6080 "@bot",
6081 false,
6082 );
6083 assert_eq!(
6084 action,
6085 CommentAction::CreateMentionTask {
6086 issue_num: Some("5".to_string()),
6087 }
6088 );
6089 }
6090
6091 #[serial(cooldown_state)]
6092 #[test]
6093 fn classify_command_with_mention_no_url_creates_task() {
6094 let action = classify_comment("@orch /retry", None, "@bot", false);
6096 assert_eq!(action, CommentAction::CreateMentionTask { issue_num: None });
6097 }
6098
6099 #[serial(cooldown_state)]
6100 #[test]
6101 fn mention_cursor_stops_advancing_after_gap() {
6102 let mut cursor = MentionCursor::default();
6103 cursor.advance("2026-01-01T00:00:00Z");
6104 cursor.block_on_gap();
6105 cursor.advance("2026-01-02T00:00:00Z");
6106
6107 assert_eq!(
6108 cursor.into_last_safe_ts().as_deref(),
6109 Some("2026-01-01T00:00:00Z")
6110 );
6111 }
6112
6113 #[serial(cooldown_state)]
6114 #[test]
6115 fn mention_cursor_tracks_max_timestamp_until_gap() {
6116 let mut cursor = MentionCursor::default();
6117 cursor.advance("2026-01-01T00:00:00Z");
6118 cursor.advance("2026-01-01T00:05:00Z");
6119 cursor.block_on_gap();
6120 cursor.advance("2026-01-01T00:10:00Z");
6121
6122 assert_eq!(
6123 cursor.into_last_safe_ts().as_deref(),
6124 Some("2026-01-01T00:05:00Z")
6125 );
6126 }
6127
6128 struct AckTrackingBackend {
6132 ack_calls: tokio::sync::Mutex<Vec<String>>,
6133 ack_failure: bool,
6134 inner: crate::backends::test_helpers::NoopBackend,
6135 }
6136
6137 impl AckTrackingBackend {
6138 fn new(ack_failure: bool) -> Arc<Self> {
6139 Arc::new(Self {
6140 ack_calls: tokio::sync::Mutex::new(Vec::new()),
6141 ack_failure,
6142 inner: crate::backends::test_helpers::NoopBackend,
6143 })
6144 }
6145
6146 fn fail_on_ack(&self) -> bool {
6147 self.ack_failure
6148 }
6149 }
6150
6151 #[async_trait]
6152 impl ExternalBackend for AckTrackingBackend {
6153 fn name(&self) -> &str {
6154 "ack-tracking"
6155 }
6156
6157 async fn acknowledge_mention(&self, comment_id: &str) -> anyhow::Result<()> {
6158 self.ack_calls.lock().await.push(comment_id.to_string());
6159 if self.fail_on_ack() {
6160 anyhow::bail!("network error: temporary failure posting reaction");
6162 }
6163 Ok(())
6164 }
6165
6166 async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
6168 Ok(Some("bot".into()))
6169 }
6170
6171 async fn get_mentions(&self, _since: &str) -> anyhow::Result<Vec<Mention>> {
6173 Ok(vec![])
6174 }
6175
6176 async fn create_task(&self, t: &str, b: &str, l: &[String]) -> anyhow::Result<ExternalId> {
6178 self.inner.create_task(t, b, l).await
6179 }
6180 async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask> {
6181 self.inner.get_task(id).await
6182 }
6183 async fn list_by_status(&self, s: Status) -> anyhow::Result<Vec<ExternalTask>> {
6184 self.inner.list_by_status(s).await
6185 }
6186 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
6187 self.inner.list_routable().await
6188 }
6189 async fn post_comment(&self, id: &ExternalId, b: &str) -> anyhow::Result<()> {
6190 self.inner.post_comment(id, b).await
6191 }
6192 async fn set_labels(&self, id: &ExternalId, l: &[String]) -> anyhow::Result<()> {
6193 self.inner.set_labels(id, l).await
6194 }
6195 async fn remove_label(&self, id: &ExternalId, l: &str) -> anyhow::Result<()> {
6196 self.inner.remove_label(id, l).await
6197 }
6198 async fn get_sub_issues(&self, id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
6199 self.inner.get_sub_issues(id).await
6200 }
6201 async fn create_sub_task(
6202 &self,
6203 p: &ExternalId,
6204 t: &str,
6205 b: &str,
6206 l: &[String],
6207 ) -> anyhow::Result<ExternalId> {
6208 self.inner.create_sub_task(p, t, b, l).await
6209 }
6210 async fn ensure_status_label(&self, l: &str) -> anyhow::Result<()> {
6211 self.inner.ensure_status_label(l).await
6212 }
6213 async fn has_open_issue_with_title(&self, t: &str, l: &str) -> anyhow::Result<bool> {
6214 self.inner.has_open_issue_with_title(t, l).await
6215 }
6216 async fn health_check(&self) -> anyhow::Result<()> {
6217 self.inner.health_check().await
6218 }
6219 async fn is_pr_merged(&self, b: &str) -> anyhow::Result<bool> {
6220 self.inner.is_pr_merged(b).await
6221 }
6222 async fn update_status(&self, id: &ExternalId, s: Status) -> anyhow::Result<()> {
6223 self.inner.update_status(id, s).await
6224 }
6225 }
6226
6227 #[serial(cooldown_state)]
6230 #[tokio::test]
6231 async fn create_mention_task_skips_on_ack_failure() {
6232 crate::engine::cooldown::reset_global_state().await;
6233 let backend: Arc<AckTrackingBackend> = AckTrackingBackend::new(true);
6234 let store: Arc<TaskStore> = Arc::new(TaskStore::open_memory().await.unwrap());
6235
6236 let initial_ts = "2026-01-01T00:00:00Z";
6238 store
6239 .kv_set("mentions_last_checked", initial_ts)
6240 .await
6241 .unwrap();
6242
6243 let mention = Mention {
6244 id: "mention-abc".into(),
6245 body: "@bot can you help?".into(),
6246 author: "alice".into(),
6247 created_at: "2026-01-02T00:00:00Z".into(),
6248 issue_url: Some("https://api.github.com/repos/owner/repo/issues/42".into()),
6249 };
6250
6251 let backend_trait: Arc<dyn ExternalBackend> = backend.clone();
6252 let task_manager = Arc::new(TaskManager::new(backend_trait.clone()));
6253
6254 scan_comments(
6255 &backend_trait,
6256 Some(&store),
6257 "owner/repo",
6258 &task_manager,
6259 Some(&[mention]),
6260 )
6261 .await
6262 .unwrap();
6263
6264 let calls = backend.ack_calls.lock().await;
6266 assert_eq!(
6267 &**calls,
6268 &["mention-abc"],
6269 "acknowledge_mention must be called"
6270 );
6271
6272 let all = store.list_all("owner/repo").await.unwrap();
6274 assert!(
6275 all.is_empty(),
6276 "no task should be created when acknowledge_mention fails"
6277 );
6278
6279 let cursor = store.kv_get("mentions_last_checked").await.unwrap();
6281 assert_eq!(
6282 cursor.as_deref(),
6283 Some(initial_ts),
6284 "cursor must not advance when acknowledge_mention fails"
6285 );
6286 }
6287
6288 #[serial(cooldown_state)]
6291 #[tokio::test]
6292 async fn execute_command_for_mention_skips_on_ack_failure() {
6293 crate::engine::cooldown::reset_global_state().await;
6294 let backend: Arc<AckTrackingBackend> = AckTrackingBackend::new(true);
6295 let store: Arc<TaskStore> = Arc::new(TaskStore::open_memory().await.unwrap());
6296
6297 let initial_ts = "2026-01-01T00:00:00Z";
6298 store
6299 .kv_set("mentions_last_checked", initial_ts)
6300 .await
6301 .unwrap();
6302
6303 let mention = Mention {
6305 id: "mention-def".into(),
6306 body: "@bot /close".into(),
6307 author: "alice".into(),
6308 created_at: "2026-01-02T00:00:00Z".into(),
6309 issue_url: Some("https://api.github.com/repos/owner/repo/issues/42".into()),
6310 };
6311
6312 let backend_trait: Arc<dyn ExternalBackend> = backend.clone();
6313 let task_manager = Arc::new(TaskManager::new(backend_trait.clone()));
6314
6315 scan_comments(
6316 &backend_trait,
6317 Some(&store),
6318 "owner/repo",
6319 &task_manager,
6320 Some(&[mention]),
6321 )
6322 .await
6323 .unwrap();
6324
6325 let calls = backend.ack_calls.lock().await;
6327 assert_eq!(&**calls, &["mention-def"]);
6328
6329 let all = store.list_all("owner/repo").await.unwrap();
6331 assert!(
6332 all.is_empty(),
6333 "no task should be created when acknowledge_mention fails for ExecuteCommandForMention"
6334 );
6335
6336 let cursor = store.kv_get("mentions_last_checked").await.unwrap();
6338 assert_eq!(
6339 cursor.as_deref(),
6340 Some(initial_ts),
6341 "cursor must not advance when acknowledge_mention fails for ExecuteCommandForMention"
6342 );
6343 }
6344
6345 fn make_task(block_reason: Option<String>) -> crate::store::Task {
6348 crate::store::Task {
6349 id: 1,
6350 external_id: None,
6351 repo: "owner/repo".to_string(),
6352 origin: String::new(),
6353 title: String::new(),
6354 body: String::new(),
6355 status: crate::store::TaskStatus::Blocked,
6356 source: String::new(),
6357 source_id: String::new(),
6358 author: String::new(),
6359 url: String::new(),
6360 labels: vec![],
6361 agent: None,
6362 model: None,
6363 complexity: String::new(),
6364 estimate: 0,
6365 route_reason: String::new(),
6366 agent_profile: String::new(),
6367 selected_skills: String::new(),
6368 route_attempts: 0,
6369 attempts: 0,
6370 branch: String::new(),
6371 worktree: String::new(),
6372 worktree_cleaned: false,
6373 owner_node: None,
6374 summary: String::new(),
6375 last_error: String::new(),
6376 parent_id: None,
6377 block_reason,
6378 pr_number: Some(42),
6379 pr_review_context: String::new(),
6380 last_review_ts: String::new(),
6381 review_ts_map: String::new(),
6382 last_comment_review_ts: String::new(),
6383 merge_conflict_retries: 0,
6384 ci_merge_failures: 0,
6385 pr_create_failures: 0,
6386 push_failures: 0,
6387 network_retries: 0,
6388 review_agent_failures: 0,
6389 review_cycles: 0,
6390 review_invocations: 0,
6391 review_session_expected: false,
6392 needs_review_refires: 0,
6393 input_tokens: 0,
6394 output_tokens: 0,
6395 input_cost_usd: 0.0,
6396 output_cost_usd: 0.0,
6397 total_cost_usd: 0.0,
6398 model_reroute_chain: String::new(),
6399 limit_reroute_chain: String::new(),
6400 memory: vec![],
6401 delegations: vec![],
6402 auto_unblock_count: 0,
6403 auto_unblock_last_at: String::new(),
6404 auto_unblock_last_reason: String::new(),
6405 ci_recovery_count: 0,
6406 no_code_reroutes: 0,
6407 no_code_last_agent: String::new(),
6408 created_at: String::new(),
6409 updated_at: String::new(),
6410 }
6411 }
6412
6413 #[serial(cooldown_state)]
6414 #[test]
6415 fn is_ci_failure_block_matches_ci_failure_limit() {
6416 let task = make_task(Some(
6417 "CI failure limit (3) reached during auto-merge".to_string(),
6418 ));
6419 assert!(is_ci_failure_block(&task));
6420 }
6421
6422 #[serial(cooldown_state)]
6423 #[test]
6424 fn is_ci_failure_block_matches_ci_checks_timeout() {
6425 let task = make_task(Some(
6426 "CI checks timed out after 3 auto-merge failures".to_string(),
6427 ));
6428 assert!(is_ci_failure_block(&task));
6429 }
6430
6431 #[serial(cooldown_state)]
6432 #[test]
6433 fn is_ci_failure_block_false_for_regular_block() {
6434 let task = make_task(Some("waiting on input".to_string()));
6435 assert!(!is_ci_failure_block(&task));
6436 }
6437
6438 #[serial(cooldown_state)]
6439 #[test]
6440 fn is_ci_failure_block_false_for_none_reason() {
6441 let task = make_task(None);
6442 assert!(!is_ci_failure_block(&task));
6443 }
6444
6445 #[serial(cooldown_state)]
6448 #[test]
6449 fn ci_failure_cooldown_zero_count_immediate() {
6450 let task = make_task(None);
6452 assert!(ci_failure_unblock_cooldown_elapsed(&task));
6453 }
6454
6455 #[serial(cooldown_state)]
6456 #[test]
6457 fn ci_failure_cooldown_empty_last_at_immediate() {
6458 let mut task = make_task(None);
6460 task.auto_unblock_count = 1;
6461 assert!(ci_failure_unblock_cooldown_elapsed(&task));
6462 }
6463
6464 #[serial(cooldown_state)]
6465 #[test]
6466 fn ci_failure_cooldown_invalid_timestamp_immediate() {
6467 let mut task = make_task(None);
6469 task.auto_unblock_count = 1;
6470 task.auto_unblock_last_at = "not-a-timestamp".to_string();
6471 assert!(ci_failure_unblock_cooldown_elapsed(&task));
6472 }
6473
6474 #[serial(cooldown_state)]
6475 #[test]
6476 fn ci_failure_cooldown_high_count_still_eventually_elapses() {
6477 let long_ago = (chrono::Utc::now() - chrono::Duration::days(30)).to_rfc3339();
6480 for count in [3i32, 5, 99] {
6481 let mut task = make_task(None);
6482 task.auto_unblock_count = count;
6483 task.auto_unblock_last_at = long_ago.clone();
6484 assert!(
6485 ci_failure_unblock_cooldown_elapsed(&task),
6486 "count={count} should eventually elapse"
6487 );
6488 }
6489 }
6490
6491 #[serial(cooldown_state)]
6492 #[test]
6493 fn ci_failure_cooldown_recent_timestamp_still_blocks() {
6494 let just_now = chrono::Utc::now().to_rfc3339();
6496 for count in [1i32, 2, 3, 99] {
6497 let mut task = make_task(None);
6498 task.auto_unblock_count = count;
6499 task.auto_unblock_last_at = just_now.clone();
6500 assert!(
6501 !ci_failure_unblock_cooldown_elapsed(&task),
6502 "count={count} should still be cooling down"
6503 );
6504 }
6505 }
6506
6507 #[serial(cooldown_state)]
6510 #[tokio::test]
6511 async fn global_ci_failure_sweep_returns_ok_with_no_blocked_tasks() {
6512 crate::engine::cooldown::reset_global_state().await;
6513 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6514 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
6515 let task_manager = Arc::new(TaskManager::with_store(
6516 backend,
6517 store.clone(),
6518 "owner/repo".to_string(),
6519 ));
6520 auto_unblock_ci_failure_blocked_tasks_global(
6521 &task_manager,
6522 &store,
6523 &Arc::new(DashSet::new()),
6524 )
6525 .await
6526 .unwrap();
6527 }
6528
6529 #[serial(cooldown_state)]
6530 #[tokio::test]
6531 async fn global_ci_failure_sweep_skips_tasks_in_cooldown() {
6532 crate::engine::cooldown::reset_global_state().await;
6535 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6536 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
6537 let task_manager = Arc::new(TaskManager::with_store(
6538 backend,
6539 store.clone(),
6540 "active/repo".to_string(),
6541 ));
6542
6543 let id = store
6545 .upsert_external(&crate::store::UpsertExternal {
6546 repo: "inactive/repo",
6547 ext_id: "10",
6548 title: "Old blocked task",
6549 body: "",
6550 author: "",
6551 url: "",
6552 labels: &[],
6553 origin: "github",
6554 })
6555 .await
6556 .unwrap();
6557 store
6558 .update_status(id, crate::store::TaskStatus::Blocked)
6559 .await
6560 .unwrap();
6561 let just_now = chrono::Utc::now().to_rfc3339();
6562 store
6563 .set_fields(
6564 id,
6565 &[
6566 (
6567 "block_reason",
6568 serde_json::json!("CI failure limit reached during auto-merge"),
6569 ),
6570 ("pr_number", serde_json::json!(99)),
6571 ("auto_unblock_count", serde_json::json!(1)),
6572 ("auto_unblock_last_at", serde_json::json!(just_now)),
6573 ],
6574 )
6575 .await
6576 .unwrap();
6577
6578 auto_unblock_ci_failure_blocked_tasks_global(
6579 &task_manager,
6580 &store,
6581 &Arc::new(DashSet::new()),
6582 )
6583 .await
6584 .unwrap();
6585
6586 let task = store.get(id).await.unwrap();
6588 assert_eq!(
6589 task.auto_unblock_count, 1,
6590 "task in cooldown must not be processed"
6591 );
6592 }
6593
6594 #[serial(cooldown_state)]
6595 #[tokio::test]
6596 async fn global_ci_failure_sweep_ignores_non_ci_failure_blocks() {
6597 crate::engine::cooldown::reset_global_state().await;
6598 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6599 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
6600 let task_manager = Arc::new(TaskManager::with_store(
6601 backend,
6602 store.clone(),
6603 "active/repo".to_string(),
6604 ));
6605
6606 let id = store
6608 .upsert_external(&crate::store::UpsertExternal {
6609 repo: "inactive/repo",
6610 ext_id: "20",
6611 title: "Manual block",
6612 body: "",
6613 author: "",
6614 url: "",
6615 labels: &[],
6616 origin: "github",
6617 })
6618 .await
6619 .unwrap();
6620 store
6621 .update_status(id, crate::store::TaskStatus::Blocked)
6622 .await
6623 .unwrap();
6624 store
6625 .set_fields(
6626 id,
6627 &[("block_reason", serde_json::json!("waiting for human input"))],
6628 )
6629 .await
6630 .unwrap();
6631
6632 auto_unblock_ci_failure_blocked_tasks_global(
6633 &task_manager,
6634 &store,
6635 &Arc::new(DashSet::new()),
6636 )
6637 .await
6638 .unwrap();
6639
6640 let task = store.get(id).await.unwrap();
6642 assert_eq!(task.status, crate::store::TaskStatus::Blocked);
6643 }
6644
6645 #[serial(cooldown_state)]
6648 #[tokio::test]
6649 async fn global_rebroadcast_sweep_returns_ok_with_no_blocked_tasks() {
6650 crate::engine::cooldown::reset_global_state().await;
6651 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6652 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
6653 let task_manager = Arc::new(TaskManager::with_store(
6654 backend,
6655 store.clone(),
6656 "owner/repo".to_string(),
6657 ));
6658 let router = router_with_healthy_review_agent();
6659 auto_recover_rebroadcast_blocked_tasks_global(&router, &task_manager, &store)
6660 .await
6661 .unwrap();
6662 }
6663
6664 #[serial(cooldown_state)]
6665 #[tokio::test]
6666 async fn global_rebroadcast_sweep_ignores_non_rebroadcast_blocks() {
6667 crate::engine::cooldown::reset_global_state().await;
6668 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6669 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
6670 let task_manager = Arc::new(TaskManager::with_store(
6671 backend,
6672 store.clone(),
6673 "active/repo".to_string(),
6674 ));
6675 let router = router_with_healthy_review_agent();
6676
6677 let id = store
6678 .upsert_external(&crate::store::UpsertExternal {
6679 repo: "inactive/repo",
6680 ext_id: "30",
6681 title: "Manual block",
6682 body: "",
6683 author: "",
6684 url: "",
6685 labels: &[],
6686 origin: "github",
6687 })
6688 .await
6689 .unwrap();
6690 store
6691 .update_status(id, crate::store::TaskStatus::Blocked)
6692 .await
6693 .unwrap();
6694 store
6695 .set_fields(
6696 id,
6697 &[("block_reason", serde_json::json!("waiting for human input"))],
6698 )
6699 .await
6700 .unwrap();
6701 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
6702 .bind(id)
6703 .execute(store.pool())
6704 .await
6705 .unwrap();
6706
6707 auto_recover_rebroadcast_blocked_tasks_global(&router, &task_manager, &store)
6708 .await
6709 .unwrap();
6710
6711 let task = store.get(id).await.unwrap();
6712 assert_eq!(task.status, crate::store::TaskStatus::Blocked);
6713 assert_eq!(
6714 task.block_reason.as_deref(),
6715 Some("waiting for human input")
6716 );
6717 }
6718
6719 #[serial(cooldown_state)]
6720 #[tokio::test]
6721 async fn global_rebroadcast_sweep_skips_freshly_blocked_tasks() {
6722 crate::engine::cooldown::reset_global_state().await;
6723 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6724 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
6725 let task_manager = Arc::new(TaskManager::with_store(
6726 backend,
6727 store.clone(),
6728 "active/repo".to_string(),
6729 ));
6730 let router = router_with_healthy_review_agent();
6731
6732 let id = store
6733 .upsert_external(&crate::store::UpsertExternal {
6734 repo: "inactive/repo",
6735 ext_id: "40",
6736 title: "Just escalated",
6737 body: "",
6738 author: "",
6739 url: "",
6740 labels: &[],
6741 origin: "github",
6742 })
6743 .await
6744 .unwrap();
6745 store
6746 .update_status(id, crate::store::TaskStatus::Blocked)
6747 .await
6748 .unwrap();
6749 store
6750 .set_fields(
6751 id,
6752 &[("block_reason", serde_json::json!(REBROADCAST_BLOCK_REASON))],
6753 )
6754 .await
6755 .unwrap();
6756 auto_recover_rebroadcast_blocked_tasks_global(&router, &task_manager, &store)
6760 .await
6761 .unwrap();
6762
6763 let task = store.get(id).await.unwrap();
6764 assert_eq!(task.status, crate::store::TaskStatus::Blocked);
6765 }
6766
6767 #[serial(cooldown_state)]
6774 #[tokio::test]
6775 async fn global_rebroadcast_sweep_recovers_task_from_inactive_repo() {
6776 crate::engine::cooldown::reset_global_state().await;
6777 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6778 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
6779 let task_manager = Arc::new(TaskManager::with_store(
6780 backend,
6781 store.clone(),
6782 "active/repo".to_string(),
6783 ));
6784 let router = router_with_healthy_review_agent();
6785
6786 let id = store
6787 .upsert_external(&crate::store::UpsertExternal {
6788 repo: "inactive/repo",
6789 ext_id: "458",
6790 title: "Rebroadcast blocked from inactive repo",
6791 body: "",
6792 author: "",
6793 url: "",
6794 labels: &[],
6795 origin: "github",
6796 })
6797 .await
6798 .unwrap();
6799 store
6800 .update_status(id, crate::store::TaskStatus::Blocked)
6801 .await
6802 .unwrap();
6803 store
6804 .set_fields(
6805 id,
6806 &[
6807 ("block_reason", serde_json::json!(REBROADCAST_BLOCK_REASON)),
6808 ("needs_review_refires", serde_json::json!(6)),
6809 ],
6810 )
6811 .await
6812 .unwrap();
6813 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
6814 .bind(id)
6815 .execute(store.pool())
6816 .await
6817 .unwrap();
6818
6819 auto_recover_rebroadcast_blocked_tasks_global(&router, &task_manager, &store)
6820 .await
6821 .unwrap();
6822
6823 let task = store.get(id).await.unwrap();
6824 assert_eq!(
6825 task.status,
6826 crate::store::TaskStatus::NeedsReview,
6827 "cross-repo rebroadcast-blocked task must be recovered to needs_review"
6828 );
6829 assert!(
6830 task.block_reason.is_none(),
6831 "block_reason must be cleared on recovery"
6832 );
6833 assert_eq!(
6834 task.needs_review_refires, 0,
6835 "needs_review_refires must be reset to 0 on recovery"
6836 );
6837 }
6838
6839 #[serial(cooldown_state)]
6843 #[tokio::test]
6844 async fn global_rebroadcast_sweep_skips_reset_without_routable_review_agent() {
6845 crate::engine::cooldown::reset_global_state().await;
6846 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6847 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
6848 let task_manager = Arc::new(TaskManager::with_store(
6849 backend,
6850 store.clone(),
6851 "active/repo".to_string(),
6852 ));
6853 let router = crate::engine::router::Router::new_for_test(
6856 crate::engine::router::RouterConfig::default(),
6857 vec!["claude".to_string()],
6858 );
6859
6860 let id = store
6861 .upsert_external(&crate::store::UpsertExternal {
6862 repo: "inactive/repo",
6863 ext_id: "459",
6864 title: "Rebroadcast blocked, no routable agents",
6865 body: "",
6866 author: "",
6867 url: "",
6868 labels: &[],
6869 origin: "github",
6870 })
6871 .await
6872 .unwrap();
6873 store
6874 .update_status(id, crate::store::TaskStatus::Blocked)
6875 .await
6876 .unwrap();
6877 store
6878 .set_fields(
6879 id,
6880 &[("block_reason", serde_json::json!(REBROADCAST_BLOCK_REASON))],
6881 )
6882 .await
6883 .unwrap();
6884 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
6885 .bind(id)
6886 .execute(store.pool())
6887 .await
6888 .unwrap();
6889
6890 auto_recover_rebroadcast_blocked_tasks_global(&router, &task_manager, &store)
6891 .await
6892 .unwrap();
6893
6894 let task = store.get(id).await.unwrap();
6895 assert_eq!(task.status, crate::store::TaskStatus::Blocked);
6896 assert_eq!(task.block_reason.as_deref(), Some(REBROADCAST_BLOCK_REASON));
6897 }
6898}