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