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