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