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 if let Err(e) = backend.reconcile_project_statuses().await {
1980 tracing::warn!(err = %e, "project status reconciliation failed");
1981 }
1982 }
1983
1984 if !gh_circuit_open {
1986 let backend = Arc::clone(backend);
1987 let repo = repo.to_string();
1988 let task_manager = Arc::clone(task_manager);
1989 let store = Arc::clone(store);
1990 tokio::spawn(async move {
1991 if let Err(e) = cleanup_done_worktrees(&backend, &repo, &task_manager, &store).await {
1992 tracing::warn!(err = %e, "worktree cleanup failed");
1993 }
1994 });
1995 }
1996
1997 let (mut in_review_tasks, mut needs_review_tasks, blocked_review_candidates) =
1998 match prefetch_review_tasks(store, repo).await {
1999 Ok(tasks) => tasks,
2000 Err(e) => {
2001 tracing::warn!(err = %e, "failed to prefetch review tasks");
2002 (vec![], vec![], vec![])
2003 }
2004 };
2005
2006 if !gh_circuit_open {
2007 let mention_fallback = chrono::Utc::now() - chrono::Duration::hours(24);
2008 let mention_since = kv_get_prefer_store(&Some(store), "mentions_last_checked")
2009 .await
2010 .unwrap_or_else(|| mention_fallback.format("%Y-%m-%dT%H:%M:%SZ").to_string());
2011 let shared_since = mention_since.clone();
2012
2013 let cached_router_config = router.read().await.config.clone();
2014 let mut combined_needs = needs_review_tasks.clone();
2016 combined_needs.extend(blocked_review_candidates.clone());
2017
2018 let (merge_result, comments_result, review_result) = tokio::join!(
2019 check_merged_prs(
2020 backend,
2021 repo,
2022 store,
2023 task_manager,
2024 &in_review_tasks,
2025 &combined_needs,
2026 ),
2027 fetch_comments_since(backend, &shared_since),
2028 async {
2029 let gh = match crate::github::http::GhHttp::new() {
2030 Ok(g) => g,
2031 Err(e) => {
2032 tracing::warn!(err = %e, "failed to create GitHub HTTP client for review poll");
2033 return Ok(());
2034 }
2035 };
2036 review_open_prs(
2037 backend,
2038 repo,
2039 config,
2040 task_manager,
2041 store,
2042 dispatching,
2043 auto_merge_in_flight,
2044 &in_review_tasks,
2045 &gh,
2046 &cached_router_config,
2047 )
2048 .await
2049 }
2050 );
2051
2052 if let Err(e) = merge_result {
2053 tracing::warn!(err = %e, "PR merge check failed");
2054 }
2055
2056 match comments_result {
2057 Ok(comments) => {
2058 let filtered = filter_mentions_by_since(&comments, &mention_since);
2059
2060 if let Err(e) =
2061 scan_comments(backend, Some(store), repo, task_manager, Some(&filtered)).await
2062 {
2063 tracing::warn!(err = %e, "comment scan failed");
2064 }
2065 }
2066 Err(e) => {
2067 tracing::warn!(err = %e, "comment fetch failed");
2068 }
2069 }
2070
2071 if let Err(e) = review_result {
2072 tracing::warn!(err = %e, "PR review failed");
2073 }
2074
2075 match prefetch_review_tasks(store, repo).await {
2076 Ok((latest_in_review, latest_needs_review, _latest_blocked_candidates)) => {
2077 in_review_tasks = latest_in_review;
2078 needs_review_tasks = latest_needs_review;
2079 }
2080 Err(e) => {
2081 tracing::warn!(err = %e, "failed to refresh review task cache after sync work");
2082 }
2083 }
2084 }
2085
2086 let enable_review = config::get("workflow.enable_review_agent")
2094 .map(|v| v != "false")
2095 .unwrap_or(true);
2096 if enable_review {
2097 let live_sessions: std::collections::HashSet<String> = tmux
2101 .list_sessions()
2102 .await
2103 .unwrap_or_default()
2104 .into_iter()
2105 .map(|s| s.name)
2106 .collect();
2107 for task in &in_review_tasks {
2108 let dispatch_key = format!("{}/{}", repo, task.task_id());
2110 {
2111 if dispatching.contains_key(&dispatch_key) {
2112 tracing::debug!(
2113 task_id = task.task_id(),
2114 "task locked by dispatch flow, skipping stale check"
2115 );
2116 continue;
2117 }
2118 }
2119 const MIN_STALE_MINUTES: i64 = 1;
2123 match chrono::DateTime::parse_from_rfc3339(&task.external.updated_at) {
2124 Ok(updated_at) => {
2125 let age = chrono::Utc::now() - updated_at.with_timezone(&chrono::Utc);
2126 if age.num_minutes() < MIN_STALE_MINUTES {
2127 tracing::debug!(
2128 task_id = task.task_id(),
2129 age_seconds = age.num_seconds(),
2130 "InReview task is too young to be considered stale, skipping"
2131 );
2132 continue;
2133 }
2134 }
2135 Err(e) => {
2136 tracing::warn!(
2137 task_id = task.task_id(),
2138 ts = %task.external.updated_at,
2139 err = %e,
2140 "invalid updated_at timestamp — treating task as potentially stale"
2141 );
2142 }
2143 }
2144
2145 let review_task_id = format!("{}-review", task.task_id());
2146 let review_session = tmux.session_name(repo, &review_task_id);
2147 if live_sessions.contains(&review_session) {
2148 continue;
2150 }
2151
2152 {
2156 tracing::warn!(
2157 task_id = task.task_id(),
2158 session = %review_session,
2159 "InReview task has no active review session — resetting to NeedsReview"
2160 );
2161 if let Err(e) = store::store_set_result(
2165 &Some(Arc::clone(store)),
2166 repo,
2167 task.task_id(),
2168 &[("review_agent_failures", serde_json::json!(0))],
2169 )
2170 .await
2171 {
2172 tracing::error!(task_id = task.task_id(), err = %e, "failed to write review_agent_failures to store");
2173 }
2174 match task_manager
2175 .update_task_status_if(&task.external.id, Status::NeedsReview, Status::InReview)
2176 .await
2177 {
2178 Err(e) => {
2179 tracing::error!(task_id = task.task_id(), err = %e, "failed to reset stale InReview task — task may be stuck in InReview indefinitely");
2180 }
2181 Ok(false) => {
2182 tracing::debug!(
2183 task_id = task.task_id(),
2184 "stale InReview reset skipped — task already transitioned (concurrent Done/Blocked/InProgress)"
2185 );
2186 }
2187 Ok(true) => {
2188 store::set_review_session_expected(store, repo, task.task_id(), false)
2189 .await;
2190 }
2191 }
2192 }
2193 }
2194 }
2195
2196 let in_progress_tasks: Vec<_> = match store.list_by_status(repo, TaskStatus::InProgress).await {
2202 Ok(tasks) => tasks
2203 .into_iter()
2204 .map(|stored| {
2205 let external = crate::engine::tasks::store_task_to_external(&stored);
2206 (stored, external)
2207 })
2208 .collect(),
2209 Err(e) => {
2210 tracing::warn!(err = %e, "failed to list in_progress tasks — skipping stale InProgress check");
2211 vec![]
2212 }
2213 };
2214
2215 if !in_progress_tasks.is_empty() {
2216 let live_sessions: std::collections::HashSet<String> = tmux
2218 .list_sessions()
2219 .await
2220 .unwrap_or_default()
2221 .into_iter()
2222 .map(|s| s.name)
2223 .collect();
2224
2225 for (_stored, external) in &in_progress_tasks {
2226 let task_id = &external.id.0;
2227
2228 let dispatch_key = format!("{}/{}", repo, task_id);
2230 {
2231 if dispatching.contains_key(&dispatch_key) {
2232 tracing::debug!(
2233 task_id = %task_id,
2234 "task locked by dispatch flow, skipping stale check"
2235 );
2236 continue;
2237 }
2238 }
2239
2240 const MIN_STALE_MINUTES: i64 = 5;
2244 match chrono::DateTime::parse_from_rfc3339(&external.updated_at) {
2245 Ok(updated_at) => {
2246 let age = chrono::Utc::now() - updated_at.with_timezone(&chrono::Utc);
2247 if age.num_minutes() < MIN_STALE_MINUTES {
2248 tracing::debug!(
2249 task_id = %task_id,
2250 age_seconds = age.num_seconds(),
2251 "InProgress task is too young to be considered stale, skipping"
2252 );
2253 continue;
2254 }
2255 }
2256 Err(e) => {
2257 tracing::warn!(
2258 task_id = %task_id,
2259 ts = %external.updated_at,
2260 err = %e,
2261 "invalid updated_at timestamp — treating task as potentially stale"
2262 );
2263 }
2264 }
2265
2266 let agent_session = tmux.session_name(repo, task_id);
2267 if live_sessions.contains(&agent_session) {
2268 continue;
2270 }
2271
2272 {
2276 tracing::warn!(
2277 task_id = %task_id,
2278 session = %agent_session,
2279 "InProgress task has no active agent session — resetting to Routed"
2280 );
2281 match task_manager
2282 .update_task_status_if(&external.id, Status::Routed, Status::InProgress)
2283 .await
2284 {
2285 Err(e) => {
2286 tracing::error!(task_id = %task_id, err = %e, "failed to reset stale InProgress task — task may be stuck in InProgress indefinitely");
2287 }
2288 Ok(false) => {
2289 tracing::debug!(
2290 task_id = %task_id,
2291 "stale InProgress reset skipped — task already transitioned (concurrent Done/Blocked/NeedsReview)"
2292 );
2293 }
2294 Ok(true) => {
2295 }
2297 }
2298 }
2299 }
2300 }
2301
2302 if enable_review {
2316 const MIN_STALE_NEEDS_REVIEW_MINUTES: i64 = 1;
2317 const MAX_NEEDS_REVIEW_REFIRE_ATTEMPTS: u64 = 5; let needs_review_count = needs_review_tasks.len();
2319 if needs_review_count > 0 {
2320 tracing::info!(
2321 count = needs_review_count,
2322 "sync catch-up: checking stale NeedsReview tasks"
2323 );
2324 } else {
2325 tracing::debug!(
2326 count = needs_review_count,
2327 "sync catch-up: checking stale NeedsReview tasks"
2328 );
2329 }
2330 let all_review_agents_cooled = {
2336 let r = router.read().await;
2337 !r.available_agents.is_empty() && r.healthy_agent_count("review") == 0
2338 };
2339 let skip_refires = all_review_agents_cooled && needs_review_count > 0;
2340 if skip_refires {
2341 tracing::info!(
2342 count = needs_review_count,
2343 "sync catch-up: all review agents currently cooled — skipping re-fire this tick (counter not incremented)"
2344 );
2345 }
2346 for task in &needs_review_tasks {
2347 if skip_refires {
2348 break;
2349 }
2350 let age_minutes = if let Ok(updated_at) =
2355 chrono::DateTime::parse_from_rfc3339(&task.external.updated_at)
2356 {
2357 let age = chrono::Utc::now() - updated_at.with_timezone(&chrono::Utc);
2358 if age.num_minutes() < MIN_STALE_NEEDS_REVIEW_MINUTES {
2359 continue;
2360 }
2361 Some(age.num_minutes())
2362 } else {
2363 None
2364 };
2365
2366 let dispatch_key = format!("{}/{}", repo, task.task_id());
2368 if dispatching.contains_key(&dispatch_key) {
2369 continue;
2370 }
2371 let current_refires = task.stored.needs_review_refires.max(0) as u64;
2373 let new_refires = current_refires + 1;
2374
2375 if new_refires > MAX_NEEDS_REVIEW_REFIRE_ATTEMPTS {
2380 tracing::warn!(
2381 task_id = task.task_id(),
2382 new_refires,
2383 "escalating NeedsReview task to Blocked after repeated refires"
2384 );
2385 let fields = [
2386 ("block_reason", serde_json::json!(REBROADCAST_BLOCK_REASON)),
2387 (
2388 "last_error",
2389 serde_json::json!(format!("escalated after {} retries", new_refires)),
2390 ),
2391 ];
2392 if let Err(e) = task_manager
2393 .update_task_status_and_result(&task.external.id, Status::Blocked, &fields)
2394 .await
2395 {
2396 tracing::error!(task_id = task.task_id(), err = %e, "update_task_status_and_result(Blocked) failed — skipping block to avoid silent auto-unblock loop");
2397 continue;
2398 }
2399 if let Err(e) = crate::store::store_increment(
2401 &Some(Arc::clone(store)),
2402 repo,
2403 task.task_id(),
2404 "needs_review_refires",
2405 )
2406 .await
2407 {
2408 tracing::warn!(
2409 task_id = task.task_id(),
2410 err = %e,
2411 "failed to increment needs_review_refires after escalation"
2412 );
2413 }
2414 continue;
2415 }
2416
2417 let required_minutes =
2422 MIN_STALE_NEEDS_REVIEW_MINUTES * (1i64 << (current_refires as u32));
2423
2424 let should_fire = match age_minutes {
2425 Some(age) => age >= required_minutes,
2426 None => true,
2427 };
2428
2429 if !should_fire {
2430 tracing::debug!(
2436 task_id = task.task_id(),
2437 age_minutes,
2438 current_refires,
2439 required_minutes,
2440 "sync catch-up: delaying NeedsReview re-fire due to backoff (no counter increment)"
2441 );
2442 continue;
2443 }
2444
2445 if let Err(e) = task_manager
2449 .update_task_status(&task.external.id, Status::NeedsReview)
2450 .await
2451 {
2452 tracing::warn!(
2453 task_id = task.task_id(),
2454 err = %e,
2455 "sync catch-up: failed to re-fire NeedsReview event — not incrementing counter"
2456 );
2457 continue;
2458 }
2459
2460 let fired_refires = match crate::store::store_increment(
2462 &Some(Arc::clone(store)),
2463 repo,
2464 task.task_id(),
2465 "needs_review_refires",
2466 )
2467 .await
2468 {
2469 Ok(v) => v,
2470 Err(e) => {
2471 tracing::warn!(
2472 task_id = task.task_id(),
2473 err = %e,
2474 "failed to increment needs_review_refires after re-fire"
2475 );
2476 current_refires + 1
2477 }
2478 };
2479
2480 tracing::info!(
2481 task_id = task.task_id(),
2482 age_minutes,
2483 refires = fired_refires,
2484 required_minutes,
2485 "sync catch-up: re-firing NeedsReview event for stale task"
2486 );
2487 }
2488 }
2489
2490 if let Err(e) =
2492 auto_unblock_blocked_tasks(repo, task_manager, store, dispatching, auto_merge_in_flight)
2493 .await
2494 {
2495 tracing::warn!(err = %e, "auto-unblock failed");
2496 }
2497
2498 if enable_review {
2500 if let Err(e) =
2501 auto_recover_rebroadcast_blocked_tasks(repo, router, task_manager, store).await
2502 {
2503 tracing::warn!(err = %e, "rebroadcast-recovery failed");
2504 }
2505 }
2506
2507 match skills_sync().await {
2509 Ok(()) => {
2510 let llm = router.read().await.llm_router_handle();
2515 llm.invalidate_skills_catalog().await;
2516 }
2517 Err(e) => {
2518 tracing::warn!(err = %e, "skills sync failed");
2519 }
2520 }
2521
2522 {
2527 let (available_agents, config) = {
2528 let r = router.read().await;
2529 (r.available_agents.clone(), r.config.clone())
2530 };
2531 let model_checker = |agent: &str| -> bool {
2532 for comp in &["simple", "medium", "complex", "review"] {
2533 if config.has_available_model_for_complexity(agent, comp) {
2534 return true;
2535 }
2536 }
2537 false
2538 };
2539 refresh_degraded_agents(
2540 store,
2541 &available_agents,
2542 &model_checker,
2543 crate::engine::router::config::health_check_window_hours(),
2544 crate::engine::router::config::degraded_rate_limit_threshold(),
2545 )
2546 .await;
2547 }
2548
2549 Ok(())
2550}
2551
2552async fn scan_comments(
2562 backend: &Arc<dyn ExternalBackend>,
2563 store: Option<&Arc<TaskStore>>,
2564 repo: &str,
2565 task_manager: &Arc<TaskManager>,
2566 prefetched_comments: Option<&[Mention]>,
2567) -> anyhow::Result<()> {
2568 let current_user = match backend.get_authenticated_user().await {
2569 Ok(Some(u)) => format!("@{}", u),
2570 Ok(None) => {
2571 tracing::debug!("backend does not support user identity, skipping comment scan");
2572 return Ok(());
2573 }
2574 Err(e) => {
2575 tracing::warn!(err = %e, "failed to get current user, skipping comment scan");
2576 return Ok(());
2577 }
2578 };
2579
2580 let comments = if let Some(c) = prefetched_comments {
2581 c.to_vec()
2582 } else {
2583 let fallback = chrono::Utc::now() - chrono::Duration::hours(24);
2584 let since_str = match kv_get_prefer_store(&store, "mentions_last_checked").await {
2585 Some(ts) => ts,
2586 None => fallback.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
2587 };
2588 match backend.get_mentions(&since_str).await {
2589 Ok(m) => m,
2590 Err(e) => {
2591 tracing::warn!(err = %e, "failed to get comments");
2592 return Ok(());
2593 }
2594 }
2595 };
2596
2597 let existing_mentions: std::collections::HashSet<String> = if let Some(s) = store {
2599 match s.list_source_ids_by_source(repo, "mention", 30).await {
2600 Ok(ids) => ids.into_iter().collect(),
2601 Err(e) => {
2602 tracing::warn!(err = %e, "failed to load mention source IDs — skipping to prevent duplicates");
2603 return Ok(());
2604 }
2605 }
2606 } else {
2607 std::collections::HashSet::new()
2608 };
2609
2610 let gh_cell: tokio::sync::OnceCell<crate::github::http::GhHttp> = tokio::sync::OnceCell::new();
2614 let get_gh = || async {
2615 gh_cell
2616 .get_or_try_init(|| async { crate::github::http::GhHttp::new() })
2617 .await
2618 };
2619
2620 let actions: Vec<CommentAction> = comments
2623 .iter()
2624 .map(|c| {
2625 classify_comment(
2626 &c.body,
2627 c.issue_url.as_deref(),
2628 ¤t_user,
2629 existing_mentions.contains(&c.id),
2630 )
2631 })
2632 .collect();
2633
2634 let pr_check_inputs: Vec<(usize, String)> = actions
2638 .iter()
2639 .enumerate()
2640 .filter_map(|(i, action)| {
2641 if let CommentAction::CreateMentionTask {
2642 issue_num: Some(ref num),
2643 } = action
2644 {
2645 Some((i, num.clone()))
2646 } else {
2647 None
2648 }
2649 })
2650 .collect();
2651
2652 let is_pr_values: Vec<bool> = if pr_check_inputs.is_empty() {
2654 Vec::new()
2655 } else {
2656 let gh = get_gh().await?;
2657 futures::future::join_all(
2658 pr_check_inputs
2659 .iter()
2660 .map(|(_, num)| async move { gh.is_pull_request(repo, num).await }),
2661 )
2662 .await
2663 };
2664
2665 let is_pr_map: std::collections::HashMap<usize, bool> = pr_check_inputs
2667 .iter()
2668 .map(|(i, _)| *i)
2669 .zip(is_pr_values)
2670 .collect();
2671
2672 let mut cursor = MentionCursor::default();
2673
2674 for (comment_idx, (comment, action)) in comments.iter().zip(actions).enumerate() {
2675 match action {
2676 CommentAction::Skip => {
2677 cursor.advance(&comment.created_at);
2678 }
2679
2680 CommentAction::ExecuteCommand { command, issue_num } => {
2681 let store_opt: Option<Arc<dyn CommandStoreOps>> =
2682 store.map(|s| Arc::clone(s) as Arc<dyn CommandStoreOps>);
2683 let gh = get_gh().await?;
2684 let _outcome = validate_and_run_command(
2685 backend,
2686 gh,
2687 repo,
2688 &issue_num,
2689 &command,
2690 &comment.author,
2691 &store_opt,
2692 task_manager,
2693 )
2694 .await;
2695 cursor.advance(&comment.created_at);
2698 }
2699
2700 CommentAction::ExecuteCommandForMention { command, .. } => {
2701 if let Err(e) = backend.acknowledge_mention(&comment.id).await {
2702 tracing::debug!(err = %e, mention_id = %comment.id, "failed to acknowledge mention");
2703 } else {
2707 let gh = get_gh().await?;
2710 let advanced_safely = handle_slash_command(
2711 backend,
2712 store,
2713 repo,
2714 task_manager,
2715 gh,
2716 comment,
2717 &command,
2718 &mut cursor,
2719 )
2720 .await?;
2721 if !advanced_safely {
2722 cursor.block_on_gap();
2723 break;
2724 }
2725 }
2726 }
2727
2728 CommentAction::CreateMentionTask { issue_num } => {
2729 if let Err(e) = backend.acknowledge_mention(&comment.id).await {
2730 tracing::debug!(err = %e, mention_id = %comment.id, "failed to acknowledge mention");
2731 } else {
2735 let is_pr = is_pr_map.get(&comment_idx).copied().unwrap_or(false);
2740
2741 let (title, task_body) = match (&issue_num, is_pr) {
2742 (Some(num), false) => (
2743 format!("Respond to mention by @{} on task #{num}", comment.author),
2744 format!(
2745 "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.",
2746 comment.author, comment.body
2747 ),
2748 ),
2749 (Some(num), true) => (
2750 format!("Respond to mention by @{} on PR #{num}", comment.author),
2751 format!(
2752 "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.",
2753 comment.author, comment.body
2754 ),
2755 ),
2756 (None, _) => (
2757 format!("Respond to mention by @{}", comment.author),
2758 format!("Mention by @{}:\n\n{}", comment.author, comment.body),
2759 ),
2760 };
2761
2762 if let Some(s) = store {
2763 let parent_id = match issue_num {
2766 Some(ref num) => {
2767 match resolve_mention_parent_id(s, repo, num, is_pr).await {
2768 Ok(id) => id,
2769 Err(e) => {
2770 tracing::warn!(
2771 mention_id = %comment.id,
2772 err = %e,
2773 "deferring mention task due to parent lookup failure"
2774 );
2775 return Err(anyhow::anyhow!(
2776 "deferring mention {} due to parent lookup failure",
2777 comment.id
2778 ));
2779 }
2780 }
2781 }
2782 None => None,
2783 };
2784 if !record_mention_task(
2785 s,
2786 repo,
2787 comment,
2788 &title,
2789 &task_body,
2790 parent_id,
2791 &mut cursor,
2792 )
2793 .await
2794 {
2795 cursor.block_on_gap();
2796 break;
2797 }
2798 }
2799 }
2800 }
2801 }
2802 }
2803
2804 if let Some(ts) = cursor.into_last_safe_ts() {
2805 kv_set_prefer_store(&store, "mentions_last_checked", &ts).await;
2806 }
2807
2808 Ok(())
2809}
2810
2811async fn skills_sync() -> anyhow::Result<()> {
2817 let skills = match config::get_skills() {
2818 Ok(s) => s,
2819 Err(e) => {
2820 tracing::debug!(err = %e, "no skills configured");
2821 return Ok(());
2822 }
2823 };
2824
2825 if skills.is_empty() {
2826 tracing::debug!("no skills configured, skipping sync");
2827 return Ok(());
2828 }
2829
2830 let skills_base = crate::home::skills_dir()?;
2831 let git_timeout = std::time::Duration::from_secs(60);
2832
2833 let futs: Vec<_> = skills
2836 .into_iter()
2837 .map(|skill| sync_one_skill(skill, skills_base.clone(), git_timeout))
2838 .collect();
2839 futures::future::join_all(futs).await;
2840
2841 Ok(())
2842}
2843
2844async fn sync_one_skill(
2849 skill: config::SkillConfig,
2850 skills_base: std::path::PathBuf,
2851 git_timeout: std::time::Duration,
2852) {
2853 if skill.repo.contains("..") || skill.repo.matches('/').count() != 1 {
2855 tracing::warn!(repo = %skill.repo, "invalid skill repo format, expected 'owner/repo'");
2856 return;
2857 }
2858
2859 let repo_dir = skills_base.join(&skill.repo);
2860 let repo_url = format!("https://github.com/{}.git", skill.repo);
2861
2862 let repo_exists = tokio::fs::metadata(&repo_dir)
2865 .await
2866 .map(|m| m.is_dir())
2867 .unwrap_or(false);
2868
2869 if repo_exists {
2870 tracing::debug!(repo = %skill.repo, "pulling skill repo");
2872 let pull_result = tokio::time::timeout(
2873 git_timeout,
2874 Command::new("git")
2875 .args(["pull", "--ff-only", "--prune"])
2876 .current_dir(&repo_dir)
2877 .output_with_context(),
2878 )
2879 .await;
2880
2881 match pull_result {
2882 Ok(Ok(output)) if !output.status.success() => {
2883 let stderr = String::from_utf8_lossy(&output.stderr);
2884 tracing::warn!(repo = %skill.repo, err = %stderr, "git pull failed");
2885 }
2886 Ok(Ok(_)) => {
2887 tracing::debug!(repo = %skill.repo, "skill repo updated");
2888 }
2889 Ok(Err(e)) => {
2890 tracing::warn!(repo = %skill.repo, err = %e, "git pull error");
2891 }
2892 Err(_) => {
2893 tracing::warn!(repo = %skill.repo, "git pull timed out after 60s");
2894 }
2895 }
2896 } else {
2897 tracing::debug!(repo = %skill.repo, "cloning skill repo");
2899 let Some(parent) = repo_dir.parent() else {
2900 tracing::warn!(repo = %skill.repo, "skill repo path has no parent directory");
2901 return;
2902 };
2903 if let Err(e) = tokio::fs::create_dir_all(parent).await {
2904 tracing::warn!(repo = %skill.repo, err = %e, "failed to create parent dir for skill");
2905 return;
2906 }
2907 let Some(repo_dir_str) = repo_dir.to_str() else {
2908 tracing::warn!(repo = %skill.repo, "skill repo path is not valid UTF-8");
2909 return;
2910 };
2911
2912 let clone_result = tokio::time::timeout(
2913 git_timeout,
2914 Command::new("git")
2915 .args(["clone", "--depth", "1", &repo_url, repo_dir_str])
2916 .output_with_context(),
2917 )
2918 .await;
2919
2920 match clone_result {
2921 Ok(Ok(output)) if !output.status.success() => {
2922 let stderr = String::from_utf8_lossy(&output.stderr);
2923 tracing::warn!(repo = %skill.repo, err = %stderr, "git clone failed");
2924 let _ = tokio::fs::remove_dir_all(&repo_dir).await;
2926 }
2927 Ok(Ok(_)) => {
2928 tracing::info!(repo = %skill.repo, "skill repo cloned");
2929 }
2930 Ok(Err(e)) => {
2931 tracing::warn!(repo = %skill.repo, err = %e, "git clone error");
2932 let _ = tokio::fs::remove_dir_all(&repo_dir).await;
2933 }
2934 Err(_) => {
2935 tracing::warn!(repo = %skill.repo, "git clone timed out after 60s");
2936 let _ = tokio::fs::remove_dir_all(&repo_dir).await;
2937 }
2938 }
2939 }
2940}
2941
2942struct DegradedAgentDetail {
2944 agent: String,
2946 reason: String,
2950 cooled_models: Vec<String>,
2953}
2954
2955struct AgentPoolHealth {
2957 agent: String,
2958 cooled_models: Vec<String>,
2959 persistent_failed_models: Vec<String>,
2960 total_configured_models: usize,
2961}
2962
2963pub(crate) async fn emit_degraded_agents_if_needed(
2972 available_agents: &[String],
2973 config: &crate::engine::router::RouterConfig,
2974 store: Option<&Arc<TaskStore>>,
2975) {
2976 const COMPLEXITIES: &[&str] = &["simple", "medium", "complex", "review"];
2978 const HEAVILY_COOLED_RATIO_THRESHOLD: f64 = 0.5;
2979
2980 let mut details: Vec<DegradedAgentDetail> = Vec::new();
2981 let mut pool_health: Vec<AgentPoolHealth> = Vec::new();
2982
2983 for agent in available_agents {
2984 let agent_in_cd = is_agent_in_cooldown(agent);
2985
2986 let mut cooled_models: Vec<String> = Vec::new();
2988 let mut persistent_failed_models: Vec<String> = Vec::new();
2989 let mut seen_models = std::collections::HashSet::new();
2990 for comp in COMPLEXITIES {
2991 if let Some(pool) = config.model_pool_for_complexity(agent, comp) {
2992 for model in pool {
2993 if seen_models.insert(model.clone()) && is_model_in_cooldown(agent, &model) {
2994 let key = format!("{agent}:{model}");
2995 if matches!(
2996 cooldown_reason(&key).as_deref(),
2997 Some("persistent_model_error") | Some("model_not_found")
2998 ) {
2999 persistent_failed_models.push(model.clone());
3000 }
3001 cooled_models.push(model);
3002 }
3003 }
3004 }
3005 }
3006 let total_configured_models = seen_models.len();
3007 pool_health.push(AgentPoolHealth {
3008 agent: agent.clone(),
3009 cooled_models: cooled_models.clone(),
3010 persistent_failed_models: persistent_failed_models.clone(),
3011 total_configured_models,
3012 });
3013
3014 let has_model = COMPLEXITIES
3017 .iter()
3018 .any(|comp| config.has_available_model_for_complexity(agent, comp));
3019
3020 if agent_in_cd || !has_model {
3021 let reason = if agent_in_cd {
3022 cooldown_reason(agent).unwrap_or_else(|| "agent_cooldown".to_string())
3023 } else {
3024 "no_available_model".to_string()
3025 };
3026 details.push(DegradedAgentDetail {
3027 agent: agent.clone(),
3028 reason,
3029 cooled_models,
3030 });
3031 }
3032 }
3033
3034 let count = details.len();
3035
3036 if let Err(e) = try_kv_set_prefer_store(
3039 &store,
3040 "metrics:orch.agents_degraded.count",
3041 &count.to_string(),
3042 )
3043 .await
3044 {
3045 tracing::warn!(err = %e, "failed to persist degraded agents count metric");
3046 }
3047
3048 if count >= 3 {
3049 let degraded_agents: Vec<&str> = details.iter().map(|d| d.agent.as_str()).collect();
3051 let cooled_models_dim: Vec<String> = details
3052 .iter()
3053 .filter(|d| !d.cooled_models.is_empty())
3054 .map(|d| format!("{}:[{}]", d.agent, d.cooled_models.join(",")))
3055 .collect();
3056 let reasons_dim: Vec<String> = details
3057 .iter()
3058 .map(|d| format!("{}={}", d.agent, d.reason))
3059 .collect();
3060
3061 tracing::warn!(
3062 degraded_count = count,
3063 degraded_agents = ?degraded_agents,
3064 cooled_models = %cooled_models_dim.join("; "),
3065 cooldown_reasons = %reasons_dim.join("; "),
3066 "multi-agent degradation detected"
3067 );
3068
3069 if let Err(e) =
3071 try_kv_set_prefer_store(&store, "metrics:orch.agents_degraded.alert", "1").await
3072 {
3073 tracing::warn!(err = %e, "failed to persist degraded agents alert metric");
3074 }
3075 } else if let Err(e) =
3076 try_kv_set_prefer_store(&store, "metrics:orch.agents_degraded.alert", "0").await
3077 {
3078 tracing::warn!(err = %e, "failed to clear degraded agents alert metric");
3080 }
3081
3082 let heavily_cooled_agents: Vec<String> = pool_health
3086 .iter()
3087 .filter_map(|d| {
3088 if d.total_configured_models == 0 || d.persistent_failed_models.is_empty() {
3089 return None;
3090 }
3091 let ratio = d.cooled_models.len() as f64 / d.total_configured_models as f64;
3092 (ratio >= HEAVILY_COOLED_RATIO_THRESHOLD).then(|| {
3093 format!(
3094 "{}:{}/{}:{}",
3095 d.agent,
3096 d.cooled_models.len(),
3097 d.total_configured_models,
3098 d.persistent_failed_models.join(",")
3099 )
3100 })
3101 })
3102 .collect();
3103
3104 const STALE_AGENTS_KV: &str = "metrics:orch.model_pool_stale_persistent.agents";
3107 let current_dim = heavily_cooled_agents.join(",");
3108 let prev_dim = kv_get_prefer_store(&store, STALE_AGENTS_KV)
3109 .await
3110 .unwrap_or_default();
3111
3112 if !heavily_cooled_agents.is_empty() {
3113 if current_dim != prev_dim {
3114 tracing::warn!(
3115 affected_agents = ?heavily_cooled_agents,
3116 "agent model pool appears stale: persistent model failures in heavily cooled pool"
3117 );
3118 }
3119 if let Err(e) = try_kv_set_prefer_store(&store, STALE_AGENTS_KV, ¤t_dim).await {
3120 tracing::warn!(err = %e, "failed to persist stale model pool agents state");
3121 }
3122 if let Err(e) = try_kv_set_prefer_store(
3123 &store,
3124 "metrics:orch.model_pool_stale_persistent.alert",
3125 "1",
3126 )
3127 .await
3128 {
3129 tracing::warn!(err = %e, "failed to persist stale model pool alert metric");
3130 }
3131 } else {
3132 if !prev_dim.is_empty() {
3133 tracing::info!("agent model pool stale condition cleared");
3134 }
3135 if let Err(e) = try_kv_set_prefer_store(&store, STALE_AGENTS_KV, "").await {
3136 tracing::warn!(err = %e, "failed to clear stale model pool agents state");
3137 }
3138 if let Err(e) = try_kv_set_prefer_store(
3139 &store,
3140 "metrics:orch.model_pool_stale_persistent.alert",
3141 "0",
3142 )
3143 .await
3144 {
3145 tracing::warn!(err = %e, "failed to clear stale model pool alert metric");
3146 }
3147 }
3148}
3149
3150pub(crate) async fn ingest_external_tasks(
3158 backend: &Arc<dyn ExternalBackend>,
3159 repo: &str,
3160 store: &Arc<TaskStore>,
3161) -> anyhow::Result<()> {
3162 #[derive(Clone)]
3163 struct DuplicateTarget {
3164 id: String,
3165 status_label: String,
3166 }
3167
3168 let mut existing_by_title: std::collections::HashMap<String, DuplicateTarget> =
3169 std::collections::HashMap::new();
3170 let mut accepted_by_title: std::collections::HashMap<String, DuplicateTarget> =
3171 std::collections::HashMap::new();
3172
3173 let cutoff = (chrono::Utc::now() - chrono::Duration::hours(24))
3174 .format("%Y-%m-%dT%H:%M:%SZ")
3175 .to_string();
3176 match store.list_for_doctor(repo, &cutoff).await {
3177 Ok(recent) => {
3178 let mut insert_target = |task: &crate::store::Task| {
3179 if task.origin == "internal" {
3180 return;
3181 }
3182 let Some(ext_id) = task.external_id.as_ref() else {
3183 return;
3184 };
3185 let status_label = task
3186 .labels
3187 .iter()
3188 .find(|l| l.starts_with("status:"))
3189 .cloned()
3190 .unwrap_or_default();
3191 existing_by_title
3192 .entry(task.title.clone())
3193 .or_insert(DuplicateTarget {
3194 id: ext_id.clone(),
3195 status_label,
3196 });
3197 };
3198
3199 for task in recent.iter().filter(|t| t.status != TaskStatus::Done) {
3201 insert_target(task);
3202 }
3203 for task in recent.iter().filter(|t| t.status == TaskStatus::Done) {
3204 insert_target(task);
3205 }
3206 }
3207 Err(e) => {
3208 tracing::warn!(repo, err = %e, "ingest: failed to load recent tasks for dedup");
3209 }
3210 }
3211
3212 let kv_key = format!("issues_last_ingested:{repo}");
3222 let fallback = (chrono::Utc::now() - chrono::Duration::hours(24))
3223 .format("%Y-%m-%dT%H:%M:%SZ")
3224 .to_string();
3225 let stored = store.kv_get(&kv_key).await.ok().flatten();
3226 let since_for_fetch: Option<String> = match stored.as_deref() {
3227 Some(STARTUP_RESCAN_SENTINEL) => None,
3228 Some(ts) => Some(ts.to_string()),
3229 None => Some(fallback),
3230 };
3231
3232 let all_tasks: Vec<(crate::backends::ExternalTask, Option<Status>)> = match backend
3238 .list_active_open_issues(since_for_fetch.as_deref())
3239 .await
3240 {
3241 Ok(tasks) => tasks,
3242 Err(e) => {
3243 tracing::debug!(
3244 ?e,
3245 "ingest: failed to list active open issues — skipping this cycle"
3246 );
3247 return Ok(());
3248 }
3249 };
3250
3251 let mut id_status_pairs: Vec<(i64, crate::backends::Status)> = Vec::new();
3254 let mut new_task_node_ids: Vec<(i64, String)> = Vec::new();
3257 let existing_ext_ids: std::collections::HashSet<String> = match store
3262 .existing_external_ids(repo)
3263 .await
3264 {
3265 Ok(s) => s,
3266 Err(e) => {
3267 tracing::warn!(repo = repo, err = %e, "ingest: failed to load existing external ids — proceeding");
3268 std::collections::HashSet::new()
3269 }
3270 };
3271 for (task, status) in &all_tasks {
3272 let is_new = !existing_ext_ids.contains(&task.id.0);
3275
3276 if is_new {
3277 let duplicate_target = existing_by_title
3278 .get(&task.title)
3279 .or_else(|| accepted_by_title.get(&task.title));
3280 if let Some(target) = duplicate_target {
3281 if target.id != task.id.0 {
3282 let label = target.status_label.as_str();
3283 let should_dedupe =
3284 match backend.has_open_issue_with_title(&task.title, label).await {
3285 Ok(true) => true,
3286 Ok(false) => false,
3287 Err(e) => {
3288 tracing::warn!(
3289 task_id = task.id.0,
3290 err = %e,
3291 "ingest: dedup check failed — defaulting to keep issue"
3292 );
3293 false
3294 }
3295 };
3296
3297 if should_dedupe {
3298 let comment = format!(
3299 "Duplicate of #{id} — closing to avoid duplicate work.{footer}",
3300 id = target.id,
3301 footer = crate::engine::orch_footer()
3302 );
3303 if let Err(e) = backend.post_comment(&task.id, &comment).await {
3304 tracing::warn!(
3305 task_id = task.id.0,
3306 err = %e,
3307 "ingest: failed to comment on duplicate issue"
3308 );
3309 }
3310 match backend.update_status(&task.id, Status::Done).await {
3311 Ok(()) => {
3312 tracing::info!(
3313 task_id = task.id.0,
3314 duplicate_of = target.id,
3315 "ingest: closed duplicate issue"
3316 );
3317 continue;
3318 }
3319 Err(e) => {
3320 tracing::warn!(
3321 task_id = task.id.0,
3322 err = %e,
3323 "ingest: failed to close duplicate issue — falling through to ingest normally"
3324 );
3325 }
3326 }
3327 }
3328 }
3329 }
3330 }
3331
3332 match store.ensure_external_task(repo, task).await {
3333 Ok(store_id) => {
3334 if let Ok(number) = task.id.0.parse::<i64>() {
3335 if let Err(err) = crate::engine::dependencies::sync_issue_dependencies(
3336 backend.as_ref(),
3337 store,
3338 store_id,
3339 repo,
3340 Some(number),
3341 &task.body,
3342 )
3343 .await
3344 {
3345 tracing::warn!(
3346 task_id = task.id.0,
3347 err = %err,
3348 "ingest: dependency sync failed"
3349 );
3350 }
3351 }
3352 if let Some(s) = status {
3353 id_status_pairs.push((store_id, *s));
3354 }
3355 if is_new {
3357 let status_label = task
3358 .labels
3359 .iter()
3360 .find(|l| l.starts_with("status:"))
3361 .cloned()
3362 .unwrap_or_default();
3363 accepted_by_title
3364 .entry(task.title.clone())
3365 .or_insert(DuplicateTarget {
3366 id: task.id.0.clone(),
3367 status_label,
3368 });
3369 if let Err(e) = backend.acknowledge_issue(&task.id).await {
3370 tracing::debug!(
3371 task_id = task.id.0,
3372 err = %e,
3373 "ingest: acknowledgment failed"
3374 );
3375 }
3376 let task_status = status.unwrap_or(Status::New);
3379 match backend.sync_to_project(&task.id, task_status).await {
3380 Ok(Some(node_id)) => {
3381 new_task_node_ids.push((store_id, node_id));
3382 }
3383 Ok(None) => {
3384 }
3386 Err(e) => {
3387 tracing::debug!(
3388 task_id = task.id.0,
3389 err = %e,
3390 "ingest: project board sync failed"
3391 );
3392 }
3393 }
3394 }
3395 }
3396 Err(e) => {
3397 tracing::debug!(task_id = task.id.0, ?e, "ingest: upsert failed");
3398 }
3399 }
3400 }
3401
3402 if !id_status_pairs.is_empty() {
3408 let all_ids: Vec<i64> = id_status_pairs.iter().map(|(id, _)| *id).collect();
3409 let existing_map = match store.get_batch(&all_ids).await {
3410 Ok(map) => map,
3411 Err(e) => {
3412 tracing::warn!(err = %e, task_count = all_ids.len(), "ingest: get_batch failed — skipping initial status sync for newly ingested tasks this tick");
3413 return Ok(());
3414 }
3415 };
3416 for (store_id, status) in id_status_pairs {
3417 if let Some(existing) = existing_map.get(&store_id) {
3418 if existing.status == TaskStatus::New {
3419 let db_status = status_to_task_status(status);
3420 if db_status != TaskStatus::New {
3421 if let Err(e) = store.update_status(store_id, db_status).await {
3422 tracing::debug!(?e, "ingest: status sync failed");
3423 }
3424 }
3425 }
3426 }
3427 }
3428 }
3429
3430 if !new_task_node_ids.is_empty() {
3433 let node_ids: Vec<String> = new_task_node_ids.iter().map(|(_, n)| n.clone()).collect();
3434 match backend.get_project_item_estimates(&node_ids).await {
3435 Ok(estimates) => {
3436 for (store_id, node_id) in new_task_node_ids {
3437 if let Some(&estimate) = estimates.get(&node_id) {
3438 if estimate > 0 {
3439 if let Err(e) = store.set_estimate_if_zero(store_id, estimate).await {
3440 tracing::debug!(
3441 store_id,
3442 estimate,
3443 err = %e,
3444 "ingest: failed to set estimate from project"
3445 );
3446 }
3447 }
3448 }
3449 }
3450 }
3451 Err(e) => {
3452 tracing::debug!(
3453 err = %e,
3454 "ingest: project estimate fetch failed — skipping estimate sync this tick"
3455 );
3456 }
3457 }
3458 }
3459
3460 if let Some(latest) = all_tasks.iter().map(|(t, _)| t.updated_at.as_str()).max() {
3472 if let Err(e) = store.kv_set(&kv_key, latest).await {
3473 tracing::error!(key = kv_key, err = %e, "ingest: failed to record last ingest time");
3474 }
3475 }
3476
3477 Ok(())
3478}
3479
3480const STARTUP_RESCAN_SENTINEL: &str = "__startup_full_rescan__";
3484
3485pub(crate) async fn clear_issues_last_ingested(store: &crate::store::TaskStore, repo: &str) {
3495 let kv_key = format!("issues_last_ingested:{repo}");
3496 match store.kv_set(&kv_key, STARTUP_RESCAN_SENTINEL).await {
3497 Ok(_) => tracing::info!(
3498 repo,
3499 "marked issues_last_ingested for unbounded full re-scan on startup"
3500 ),
3501 Err(e) => {
3502 tracing::warn!(repo, err = %e, "failed to mark issues_last_ingested for startup rescan")
3503 }
3504 }
3505}
3506
3507#[cfg(test)]
3508mod tests {
3509 use super::*;
3510 use crate::backends::{ExternalId, ExternalTask, Mention, Status};
3511 use crate::engine::cooldown::set_agent_cooldown;
3512 use crate::store::{RunTokenUsage, TaskStore};
3513 use async_trait::async_trait;
3514 use serial_test::serial;
3515 use std::sync::Arc;
3516
3517 struct IngestMockBackend {
3521 tasks: Vec<(String, Vec<ExternalTask>)>,
3523 dedup_result: bool,
3524 comments: tokio::sync::Mutex<Vec<(String, String)>>,
3525 status_updates: tokio::sync::Mutex<Vec<(String, Status)>>,
3526 project_syncs: tokio::sync::Mutex<Vec<(String, Status)>>,
3527 project_reconciliations: std::sync::atomic::AtomicUsize,
3528 since_calls: tokio::sync::Mutex<Vec<Option<String>>>,
3531 }
3532
3533 impl IngestMockBackend {
3534 fn with_tasks(tasks: Vec<(Status, ExternalTask)>) -> Arc<Self> {
3535 Self::with_tasks_and_dedup(tasks, false)
3536 }
3537
3538 fn with_tasks_and_dedup(
3539 tasks: Vec<(Status, ExternalTask)>,
3540 dedup_result: bool,
3541 ) -> Arc<Self> {
3542 let mut grouped: Vec<(String, Vec<ExternalTask>)> = Vec::new();
3543 for (status, task) in tasks {
3544 let label = status.as_label().to_string();
3545 if let Some(entry) = grouped.iter_mut().find(|(l, _)| l == &label) {
3546 entry.1.push(task);
3547 } else {
3548 grouped.push((label, vec![task]));
3549 }
3550 }
3551 Arc::new(Self {
3552 tasks: grouped,
3553 dedup_result,
3554 comments: tokio::sync::Mutex::new(Vec::new()),
3555 status_updates: tokio::sync::Mutex::new(Vec::new()),
3556 project_syncs: tokio::sync::Mutex::new(Vec::new()),
3557 project_reconciliations: std::sync::atomic::AtomicUsize::new(0),
3558 since_calls: tokio::sync::Mutex::new(Vec::new()),
3559 })
3560 }
3561 }
3562
3563 #[async_trait]
3564 impl ExternalBackend for IngestMockBackend {
3565 fn name(&self) -> &str {
3566 "ingest-mock"
3567 }
3568 async fn create_task(&self, _: &str, _: &str, _: &[String]) -> anyhow::Result<ExternalId> {
3569 Ok(ExternalId("new".into()))
3570 }
3571 async fn get_task(&self, _: &ExternalId) -> anyhow::Result<ExternalTask> {
3572 anyhow::bail!("not implemented")
3573 }
3574 async fn list_by_status(&self, status: Status) -> anyhow::Result<Vec<ExternalTask>> {
3575 let label = status.as_label().to_string();
3576 Ok(self
3577 .tasks
3578 .iter()
3579 .find(|(l, _)| l == &label)
3580 .map(|(_, t)| t.clone())
3581 .unwrap_or_default())
3582 }
3583 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
3584 Ok(vec![])
3585 }
3586 async fn list_active_open_issues(
3587 &self,
3588 since: Option<&str>,
3589 ) -> anyhow::Result<Vec<(ExternalTask, Option<Status>)>> {
3590 self.since_calls
3591 .lock()
3592 .await
3593 .push(since.map(|s| s.to_string()));
3594 let mut result = Vec::new();
3595 for (label, tasks) in &self.tasks {
3596 let status = match label.as_str() {
3597 "status:new" => None,
3598 "status:routed" => Some(Status::Routed),
3599 "status:in_progress" => Some(Status::InProgress),
3600 "status:needs_review" => Some(Status::NeedsReview),
3601 "status:in_review" => Some(Status::InReview),
3602 "status:blocked" => Some(Status::Blocked),
3603 _ => None,
3604 };
3605 for task in tasks {
3606 result.push((task.clone(), status));
3607 }
3608 }
3609 Ok(result)
3610 }
3611 async fn post_comment(&self, id: &ExternalId, body: &str) -> anyhow::Result<()> {
3612 self.comments
3613 .lock()
3614 .await
3615 .push((id.0.clone(), body.to_string()));
3616 Ok(())
3617 }
3618 async fn set_labels(&self, _: &ExternalId, _: &[String]) -> anyhow::Result<()> {
3619 Ok(())
3620 }
3621 async fn remove_label(&self, _: &ExternalId, _: &str) -> anyhow::Result<()> {
3622 Ok(())
3623 }
3624 async fn get_sub_issues(&self, _: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
3625 Ok(vec![])
3626 }
3627 async fn health_check(&self) -> anyhow::Result<()> {
3628 Ok(())
3629 }
3630 async fn get_mentions(&self, _: &str) -> anyhow::Result<Vec<Mention>> {
3631 Ok(vec![])
3632 }
3633 async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
3634 self.status_updates
3635 .lock()
3636 .await
3637 .push((id.0.clone(), status));
3638 Ok(())
3639 }
3640
3641 async fn has_open_issue_with_title(&self, _: &str, _: &str) -> anyhow::Result<bool> {
3642 Ok(self.dedup_result)
3643 }
3644
3645 async fn sync_to_project(
3646 &self,
3647 id: &ExternalId,
3648 status: Status,
3649 ) -> anyhow::Result<Option<String>> {
3650 self.project_syncs.lock().await.push((id.0.clone(), status));
3651 Ok(None) }
3653
3654 async fn reconcile_project_statuses(&self) -> anyhow::Result<()> {
3655 self.project_reconciliations
3656 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3657 Ok(())
3658 }
3659 }
3660
3661 fn make_ext_task(id: &str, title: &str) -> ExternalTask {
3662 ExternalTask {
3663 id: ExternalId(id.to_string()),
3664 title: title.to_string(),
3665 body: "".to_string(),
3666 state: "open".to_string(),
3667 labels: vec![],
3668 author: "user".to_string(),
3669 created_at: "2026-01-01T00:00:00Z".to_string(),
3670 updated_at: "2026-01-01T00:00:00Z".to_string(),
3671 url: format!("https://github.com/owner/repo/issues/{id}"),
3672 }
3673 }
3674
3675 #[serial(cooldown_state)]
3676 #[tokio::test]
3677 async fn ingest_upserts_tasks_into_store() {
3678 crate::engine::cooldown::reset_global_state().await;
3679 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![
3680 (Status::New, make_ext_task("1", "First issue")),
3681 (Status::InProgress, make_ext_task("2", "Second issue")),
3682 ]);
3683 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3684
3685 ingest_external_tasks(&backend, "owner/repo", &store)
3686 .await
3687 .unwrap();
3688
3689 let all = store.list_all("owner/repo").await.unwrap();
3691 assert_eq!(all.len(), 2);
3692
3693 let task1 = store
3694 .get_by_external_id("owner/repo", "1")
3695 .await
3696 .unwrap()
3697 .unwrap();
3698 assert_eq!(task1.title, "First issue");
3699 assert_eq!(task1.status, TaskStatus::New);
3700
3701 let task2 = store
3702 .get_by_external_id("owner/repo", "2")
3703 .await
3704 .unwrap()
3705 .unwrap();
3706 assert_eq!(task2.title, "Second issue");
3707 assert_eq!(task2.status, crate::store::TaskStatus::InProgress);
3708 }
3709
3710 #[serial(cooldown_state)]
3711 #[tokio::test]
3712 async fn ingest_advances_cursor_to_max_observed_updated_at_not_wall_clock() {
3713 crate::engine::cooldown::reset_global_state().await;
3719 let mut task = make_ext_task("1", "First issue");
3720 task.updated_at = "2026-01-01T00:00:00Z".to_string();
3721 let backend: Arc<dyn ExternalBackend> =
3722 IngestMockBackend::with_tasks(vec![(Status::New, task)]);
3723 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3724
3725 ingest_external_tasks(&backend, "owner/repo", &store)
3726 .await
3727 .unwrap();
3728
3729 let cursor = store
3730 .kv_get("issues_last_ingested:owner/repo")
3731 .await
3732 .unwrap();
3733 assert_eq!(
3734 cursor,
3735 Some("2026-01-01T00:00:00Z".to_string()),
3736 "cursor must match the max updated_at observed in the fetch, not wall-clock now()"
3737 );
3738 }
3739
3740 #[serial(cooldown_state)]
3741 #[tokio::test]
3742 async fn ingest_after_startup_rescan_marker_fetches_unbounded() {
3743 crate::engine::cooldown::reset_global_state().await;
3748 let backend = IngestMockBackend::with_tasks(vec![(Status::New, make_ext_task("1", "x"))]);
3749 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3750
3751 clear_issues_last_ingested(&store, "owner/repo").await;
3752
3753 let backend_dyn: Arc<dyn ExternalBackend> = backend.clone();
3754 ingest_external_tasks(&backend_dyn, "owner/repo", &store)
3755 .await
3756 .unwrap();
3757
3758 let calls = backend.since_calls.lock().await;
3759 assert_eq!(
3760 calls.as_slice(),
3761 [None],
3762 "startup rescan marker must produce an unbounded fetch (since = None)"
3763 );
3764 }
3765
3766 #[serial(cooldown_state)]
3767 #[tokio::test]
3768 async fn ingest_with_missing_cursor_uses_24h_fallback() {
3769 crate::engine::cooldown::reset_global_state().await;
3772 let backend = IngestMockBackend::with_tasks(vec![(Status::New, make_ext_task("1", "x"))]);
3773 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3774
3775 let backend_dyn: Arc<dyn ExternalBackend> = backend.clone();
3776 ingest_external_tasks(&backend_dyn, "owner/repo", &store)
3777 .await
3778 .unwrap();
3779
3780 let calls = backend.since_calls.lock().await;
3781 assert_eq!(calls.len(), 1);
3782 assert!(
3783 calls[0].is_some(),
3784 "missing cursor should fall back to a bounded 24h window, not an unbounded fetch"
3785 );
3786 }
3787
3788 #[serial(cooldown_state)]
3789 #[tokio::test]
3790 async fn ingest_updates_existing_tasks() {
3791 crate::engine::cooldown::reset_global_state().await;
3792 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![(
3793 Status::Routed,
3794 make_ext_task("42", "Updated title"),
3795 )]);
3796 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3797
3798 store
3800 .create(&crate::store::NewTask {
3801 external_id: Some("42".to_string()),
3802 repo: "owner/repo".to_string(),
3803 origin: "github".to_string(),
3804 title: "Original title".to_string(),
3805 ..Default::default()
3806 })
3807 .await
3808 .unwrap();
3809
3810 ingest_external_tasks(&backend, "owner/repo", &store)
3811 .await
3812 .unwrap();
3813
3814 let task = store
3816 .get_by_external_id("owner/repo", "42")
3817 .await
3818 .unwrap()
3819 .unwrap();
3820 assert_eq!(task.title, "Updated title");
3821 assert_eq!(task.status, crate::store::TaskStatus::Routed);
3822 }
3823
3824 #[serial(cooldown_state)]
3825 #[tokio::test]
3826 async fn ingest_closes_duplicate_titles() {
3827 crate::engine::cooldown::reset_global_state().await;
3828 use crate::store::{NewTask, TaskStore};
3829
3830 let backend = IngestMockBackend::with_tasks_and_dedup(
3831 vec![(Status::New, make_ext_task("2", "Duplicate issue"))],
3832 true,
3833 );
3834 let backend_dyn: Arc<dyn ExternalBackend> = backend.clone();
3835 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3836
3837 store
3838 .create(&NewTask {
3839 external_id: Some("1".to_string()),
3840 repo: "owner/repo".to_string(),
3841 origin: "github".to_string(),
3842 title: "Duplicate issue".to_string(),
3843 labels: vec!["status:new".to_string()],
3844 ..Default::default()
3845 })
3846 .await
3847 .unwrap();
3848
3849 ingest_external_tasks(&backend_dyn, "owner/repo", &store)
3850 .await
3851 .unwrap();
3852
3853 let all = store.list_all("owner/repo").await.unwrap();
3854 assert_eq!(all.len(), 1);
3855
3856 let comments = backend.comments.lock().await;
3857 assert_eq!(comments.len(), 1);
3858 assert!(comments[0].1.contains("Duplicate of #1"));
3859
3860 let status_updates = backend.status_updates.lock().await;
3861 assert_eq!(status_updates.len(), 1);
3862 assert_eq!(status_updates[0].0, "2");
3863 assert_eq!(status_updates[0].1, Status::Done);
3864 }
3865
3866 #[serial(cooldown_state)]
3867 #[tokio::test]
3868 async fn ingest_handles_empty_backend() {
3869 crate::engine::cooldown::reset_global_state().await;
3870 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
3871 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3872
3873 let result = ingest_external_tasks(&backend, "owner/repo", &store).await;
3875 assert!(result.is_ok());
3876
3877 let all = store.list_all("owner/repo").await.unwrap();
3878 assert!(all.is_empty());
3879 }
3880
3881 #[serial(cooldown_state)]
3882 #[tokio::test]
3883 async fn ingest_syncs_status_correctly_across_statuses() {
3884 crate::engine::cooldown::reset_global_state().await;
3885 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![
3886 (Status::New, make_ext_task("1", "New")),
3887 (Status::Routed, make_ext_task("2", "Routed")),
3888 (Status::InProgress, make_ext_task("3", "InProgress")),
3889 (Status::NeedsReview, make_ext_task("4", "NeedsReview")),
3890 (Status::InReview, make_ext_task("5", "InReview")),
3891 (Status::Blocked, make_ext_task("6", "Blocked")),
3892 ]);
3893 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3894
3895 ingest_external_tasks(&backend, "owner/repo", &store)
3896 .await
3897 .unwrap();
3898
3899 let counts = store.status_counts("owner/repo").await.unwrap();
3900 assert_eq!(counts.get("new"), Some(&1));
3901 assert_eq!(counts.get("routed"), Some(&1));
3902 assert_eq!(counts.get("in_progress"), Some(&1));
3903 assert_eq!(counts.get("needs_review"), Some(&1));
3904 assert_eq!(counts.get("in_review"), Some(&1));
3905 assert_eq!(counts.get("blocked"), Some(&1));
3906 }
3907
3908 #[serial(cooldown_state)]
3909 #[tokio::test]
3910 async fn ingest_does_not_overwrite_store_authoritative_status() {
3911 crate::engine::cooldown::reset_global_state().await;
3912 let backend: Arc<dyn ExternalBackend> =
3914 IngestMockBackend::with_tasks(vec![(Status::New, make_ext_task("99", "My task"))]);
3915 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3916
3917 let id = store
3919 .create(&crate::store::NewTask {
3920 external_id: Some("99".to_string()),
3921 repo: "owner/repo".to_string(),
3922 origin: "github".to_string(),
3923 title: "My task".to_string(),
3924 ..Default::default()
3925 })
3926 .await
3927 .unwrap();
3928 store
3929 .update_status(id, crate::store::TaskStatus::Routed)
3930 .await
3931 .unwrap();
3932
3933 ingest_external_tasks(&backend, "owner/repo", &store)
3935 .await
3936 .unwrap();
3937
3938 let task = store.get(id).await.unwrap();
3939 assert_eq!(
3940 task.status,
3941 crate::store::TaskStatus::Routed,
3942 "ingest must not overwrite store-authoritative status"
3943 );
3944 }
3945
3946 #[serial(cooldown_state)]
3947 #[tokio::test]
3948 async fn ingest_sync_to_project_called_only_for_new_tasks() {
3949 crate::engine::cooldown::reset_global_state().await;
3950 use crate::store::{NewTask, TaskStore};
3951
3952 let backend = IngestMockBackend::with_tasks(vec![
3954 (Status::New, make_ext_task("10", "Existing task")),
3955 (Status::New, make_ext_task("11", "New task")),
3956 ]);
3957 let backend_dyn: Arc<dyn ExternalBackend> = backend.clone();
3958 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3959
3960 store
3962 .create(&NewTask {
3963 external_id: Some("10".to_string()),
3964 repo: "owner/repo".to_string(),
3965 origin: "github".to_string(),
3966 title: "Existing task".to_string(),
3967 ..Default::default()
3968 })
3969 .await
3970 .unwrap();
3971
3972 ingest_external_tasks(&backend_dyn, "owner/repo", &store)
3973 .await
3974 .unwrap();
3975
3976 let syncs = backend.project_syncs.lock().await;
3977 assert_eq!(
3979 syncs.len(),
3980 1,
3981 "sync_to_project must be called exactly once (for new task only), got: {syncs:?}"
3982 );
3983 assert_eq!(syncs[0].0, "11", "sync_to_project must target the new task");
3984 }
3985
3986 #[serial(cooldown_state)]
3987 #[tokio::test]
3988 async fn ingest_handles_mixed_labeled_and_unlabeled_open_issues() {
3989 crate::engine::cooldown::reset_global_state().await;
3990 struct MixedBackend;
3994
3995 #[async_trait]
3996 impl ExternalBackend for MixedBackend {
3997 fn name(&self) -> &str {
3998 "mixed"
3999 }
4000 async fn create_task(
4001 &self,
4002 _: &str,
4003 _: &str,
4004 _: &[String],
4005 ) -> anyhow::Result<ExternalId> {
4006 Ok(ExternalId("x".into()))
4007 }
4008 async fn get_task(&self, _: &ExternalId) -> anyhow::Result<ExternalTask> {
4009 anyhow::bail!("not implemented")
4010 }
4011 async fn list_by_status(&self, _: Status) -> anyhow::Result<Vec<ExternalTask>> {
4012 Ok(vec![])
4013 }
4014 async fn list_active_open_issues(
4016 &self,
4017 _: Option<&str>,
4018 ) -> anyhow::Result<Vec<(ExternalTask, Option<Status>)>> {
4019 Ok(vec![
4020 (make_ext_task("100", "Unlabeled routable"), None),
4021 (
4022 make_ext_task("101", "In progress issue"),
4023 Some(Status::InProgress),
4024 ),
4025 ])
4026 }
4027 async fn post_comment(&self, _: &ExternalId, _: &str) -> anyhow::Result<()> {
4028 Ok(())
4029 }
4030 async fn set_labels(&self, _: &ExternalId, _: &[String]) -> anyhow::Result<()> {
4031 Ok(())
4032 }
4033 async fn remove_label(&self, _: &ExternalId, _: &str) -> anyhow::Result<()> {
4034 Ok(())
4035 }
4036 async fn get_sub_issues(&self, _: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
4037 Ok(vec![])
4038 }
4039 async fn health_check(&self) -> anyhow::Result<()> {
4040 Ok(())
4041 }
4042 async fn get_mentions(&self, _: &str) -> anyhow::Result<Vec<Mention>> {
4043 Ok(vec![])
4044 }
4045 async fn update_status(&self, _: &ExternalId, _: Status) -> anyhow::Result<()> {
4046 Ok(())
4047 }
4048 async fn has_open_issue_with_title(&self, _: &str, _: &str) -> anyhow::Result<bool> {
4049 Ok(false)
4050 }
4051 async fn sync_to_project(
4052 &self,
4053 _: &ExternalId,
4054 _: Status,
4055 ) -> anyhow::Result<Option<String>> {
4056 Ok(None)
4057 }
4058 }
4059
4060 let backend: Arc<dyn ExternalBackend> = Arc::new(MixedBackend);
4061 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4062
4063 ingest_external_tasks(&backend, "owner/repo", &store)
4064 .await
4065 .unwrap();
4066
4067 let all = store.list_all("owner/repo").await.unwrap();
4068 assert_eq!(all.len(), 2, "both issues must be ingested");
4069
4070 let unlabeled = store
4071 .get_by_external_id("owner/repo", "100")
4072 .await
4073 .unwrap()
4074 .unwrap();
4075 assert_eq!(
4076 unlabeled.status,
4077 crate::store::TaskStatus::New,
4078 "unlabeled issue should start as New"
4079 );
4080
4081 let in_progress = store
4082 .get_by_external_id("owner/repo", "101")
4083 .await
4084 .unwrap()
4085 .unwrap();
4086 assert_eq!(
4087 in_progress.status,
4088 crate::store::TaskStatus::InProgress,
4089 "labeled in_progress issue must be synced to InProgress"
4090 );
4091 }
4092
4093 #[serial(cooldown_state)]
4096 #[tokio::test]
4097 async fn kv_get_prefer_store_reads_from_store() {
4098 crate::engine::cooldown::reset_global_state().await;
4099 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4100
4101 store.kv_set("k1", "store_val").await.unwrap();
4102
4103 let opt = Some(&store);
4104 let val = kv_get_prefer_store(&opt, "k1").await;
4105 assert_eq!(val.as_deref(), Some("store_val"));
4106 }
4107
4108 #[serial(cooldown_state)]
4109 #[tokio::test]
4110 async fn kv_get_prefer_store_returns_none_without_store() {
4111 crate::engine::cooldown::reset_global_state().await;
4112 let opt: Option<&Arc<TaskStore>> = None;
4113 let val = kv_get_prefer_store(&opt, "k2").await;
4114 assert_eq!(val, None);
4115 }
4116
4117 #[serial(cooldown_state)]
4118 #[tokio::test]
4119 async fn kv_set_prefer_store_writes_to_store() {
4120 crate::engine::cooldown::reset_global_state().await;
4121 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4122
4123 let opt = Some(&store);
4124 kv_set_prefer_store(&opt, "k3", "val3").await;
4125
4126 assert_eq!(store.kv_get("k3").await.unwrap().as_deref(), Some("val3"));
4127 }
4128
4129 #[serial(cooldown_state)]
4130 #[tokio::test]
4131 async fn kv_set_prefer_store_noop_without_store() {
4132 crate::engine::cooldown::reset_global_state().await;
4133 let opt: Option<&Arc<TaskStore>> = None;
4134 kv_set_prefer_store(&opt, "k4", "val4").await;
4136 }
4137
4138 #[serial(cooldown_state)]
4141 #[tokio::test]
4142 async fn try_kv_get_prefer_store_returns_value_from_store() {
4143 crate::engine::cooldown::reset_global_state().await;
4144 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4145 store.kv_set("try_k1", "try_val1").await.unwrap();
4146
4147 let opt = Some(&store);
4148 let result = try_kv_get_prefer_store(&opt, "try_k1").await;
4149 assert_eq!(result.unwrap().as_deref(), Some("try_val1"));
4150 }
4151
4152 #[serial(cooldown_state)]
4153 #[tokio::test]
4154 async fn try_kv_get_prefer_store_returns_none_for_missing_key() {
4155 crate::engine::cooldown::reset_global_state().await;
4156 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4157 let opt = Some(&store);
4158
4159 let result = try_kv_get_prefer_store(&opt, "nonexistent_key").await;
4160 assert_eq!(result.unwrap(), None); }
4162
4163 #[serial(cooldown_state)]
4164 #[tokio::test]
4165 async fn try_kv_get_prefer_store_returns_none_without_store() {
4166 crate::engine::cooldown::reset_global_state().await;
4167 let opt: Option<&Arc<TaskStore>> = None;
4168 let result = try_kv_get_prefer_store(&opt, "try_k2").await;
4169 assert_eq!(result.unwrap(), None);
4170 }
4171
4172 #[serial(cooldown_state)]
4173 #[tokio::test]
4174 async fn try_kv_set_prefer_store_writes_to_store() {
4175 crate::engine::cooldown::reset_global_state().await;
4176 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4177 let opt = Some(&store);
4178
4179 let result = try_kv_set_prefer_store(&opt, "try_k3", "try_val3").await;
4180 assert!(result.is_ok());
4181 assert_eq!(
4182 store.kv_get("try_k3").await.unwrap().as_deref(),
4183 Some("try_val3")
4184 );
4185 }
4186
4187 #[serial(cooldown_state)]
4188 #[tokio::test]
4189 async fn try_kv_set_prefer_store_ok_without_store() {
4190 crate::engine::cooldown::reset_global_state().await;
4191 let opt: Option<&Arc<TaskStore>> = None;
4192 let result = try_kv_set_prefer_store(&opt, "try_k4", "try_val4").await;
4194 assert!(result.is_ok());
4195 }
4196
4197 #[serial(cooldown_state)]
4200 #[test]
4201 fn dispatching_set_blocks_duplicate_processing() {
4202 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4203
4204 let repo = "owner/repo";
4205 let task_id = "42";
4206 let dispatch_key = format!("{}/{}", repo, task_id);
4207
4208 assert!(!dispatching.contains_key(&dispatch_key));
4210
4211 dispatching.insert(dispatch_key.clone(), task_id.to_string());
4213
4214 assert!(
4216 dispatching.contains_key(&dispatch_key),
4217 "task should be locked while dispatching"
4218 );
4219
4220 dispatching.remove(&dispatch_key);
4222
4223 assert!(
4225 !dispatching.contains_key(&dispatch_key),
4226 "task should be unlocked after review completes"
4227 );
4228 }
4229
4230 #[serial(cooldown_state)]
4231 #[test]
4232 fn dispatching_set_does_not_block_other_tasks() {
4233 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4234
4235 let repo = "owner/repo";
4236
4237 let key_42 = format!("{}/42", repo);
4239 dispatching.insert(key_42.clone(), "42".to_string());
4240
4241 let key_43 = format!("{}/43", repo);
4243 assert!(
4244 dispatching.contains_key(&key_42),
4245 "task 42 should be locked"
4246 );
4247 assert!(
4248 !dispatching.contains_key(&key_43),
4249 "task 43 should not be locked"
4250 );
4251 }
4252
4253 #[serial(cooldown_state)]
4266 #[tokio::test]
4267 async fn dispatch_key_held_during_review_agent_execution() {
4268 crate::engine::cooldown::reset_global_state().await;
4269 use tokio::sync::oneshot;
4270
4271 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4272
4273 let dispatch_key = "owner/repo/42".to_string();
4274
4275 dispatching.insert(dispatch_key.clone(), "42".to_string());
4277
4278 let (agent_started_tx, agent_started_rx) = oneshot::channel::<()>();
4279 let (check_done_tx, check_done_rx) = oneshot::channel::<()>();
4280
4281 let dispatching_agent = Arc::clone(&dispatching);
4282 let key_agent = dispatch_key.clone();
4283
4284 let review_task = tokio::spawn(async move {
4286 agent_started_tx.send(()).unwrap();
4287 check_done_rx.await.unwrap();
4289 dispatching_agent.remove(&key_agent);
4291 });
4292
4293 agent_started_rx.await.unwrap();
4296 assert!(
4297 dispatching.contains_key(&dispatch_key),
4298 "dispatch_key must be visible in the dispatching set while the review \
4299 agent is running — without the a6d8b9a fix, review_open_prs would see \
4300 the key absent and re-dispatch the task, silently dropping \
4301 CHANGES_REQUESTED feedback"
4302 );
4303
4304 check_done_tx.send(()).unwrap();
4306 review_task.await.unwrap();
4307
4308 assert!(
4310 !dispatching.contains_key(&dispatch_key),
4311 "dispatch_key must be removed from the dispatching set after the \
4312 review agent completes so subsequent sync ticks can process the task"
4313 );
4314 }
4315
4316 #[serial(cooldown_state)]
4329 #[tokio::test]
4330 async fn dispatch_guard_releases_key_on_panic() {
4331 crate::engine::cooldown::reset_global_state().await;
4332 use crate::engine::dispatch_guard::DispatchGuard;
4333
4334 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4335 let key = "owner/repo/42".to_string();
4336
4337 dispatching.insert(key.clone(), "42".to_string());
4341
4342 let guard = DispatchGuard::new(Arc::clone(&dispatching), key.clone());
4344
4345 let handle = tokio::spawn(async move {
4347 let _guard = guard; panic!("simulated panic inside review_and_merge");
4349 });
4350
4351 let _ = handle.await;
4353
4354 assert!(
4356 !dispatching.contains_key(&key),
4357 "dispatch key must be removed from the dispatching set even when the \
4358 spawned review task panics — without DispatchGuard the key leaks and \
4359 the task gets stuck in a permanent review loop"
4360 );
4361 }
4362
4363 #[serial(cooldown_state)]
4364 #[tokio::test]
4365 async fn sync_tick_reconciles_board_without_ingesting_or_transitioning_tasks() {
4366 crate::engine::cooldown::reset_global_state().await;
4367 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4368 let mock = IngestMockBackend::with_tasks(vec![]);
4369 let backend: Arc<dyn ExternalBackend> = mock.clone();
4370 let task_manager = Arc::new(TaskManager::with_store(
4371 backend.clone(),
4372 store.clone(),
4373 "owner/repo".to_string(),
4374 ));
4375 let dispatching = Arc::new(DashMap::new());
4376 sync_tick(
4377 &backend,
4378 &Arc::new(TmuxManager::new()),
4379 "owner/repo",
4380 &EngineConfig::default(),
4381 &Arc::new(RwLock::new(crate::engine::router::Router::from_config())),
4382 &task_manager,
4383 &store,
4384 &dispatching,
4385 &Arc::new(DashSet::new()),
4386 )
4387 .await
4388 .unwrap();
4389 assert_eq!(
4390 mock.project_reconciliations
4391 .load(std::sync::atomic::Ordering::Relaxed),
4392 1
4393 );
4394 assert!(mock.project_syncs.lock().await.is_empty());
4395 assert!(mock.status_updates.lock().await.is_empty());
4396 assert!(dispatching.is_empty());
4397 assert!(store
4398 .existing_external_ids("owner/repo")
4399 .await
4400 .unwrap()
4401 .is_empty());
4402 }
4403
4404 #[serial(cooldown_state)]
4407 #[tokio::test]
4408 async fn stale_in_review_recovery_resets_orphaned_task_without_session() {
4409 crate::engine::cooldown::reset_global_state().await;
4410 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4411 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
4412 let task_manager = Arc::new(TaskManager::with_store(
4413 backend.clone(),
4414 store.clone(),
4415 "owner/repo".to_string(),
4416 ));
4417 let tmux = Arc::new(TmuxManager::new());
4418 let router = Arc::new(RwLock::new(crate::engine::router::Router::from_config()));
4419 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4420 let auto_merge_in_flight: Arc<DashSet<String>> = Arc::new(DashSet::new());
4421
4422 let id = store
4423 .upsert_external(&crate::store::UpsertExternal {
4424 repo: "owner/repo",
4425 ext_id: "55",
4426 title: "Orphaned review",
4427 body: "",
4428 author: "",
4429 url: "",
4430 labels: &[],
4431 origin: "github",
4432 })
4433 .await
4434 .unwrap();
4435 store
4436 .update_status(id, crate::store::TaskStatus::InReview)
4437 .await
4438 .unwrap();
4439 store
4440 .set_fields(id, &[("branch", serde_json::json!("feature/55"))])
4441 .await
4442 .unwrap();
4443 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
4445 .bind(id)
4446 .execute(store.pool())
4447 .await
4448 .unwrap();
4449
4450 sync_tick(
4451 &backend,
4452 &tmux,
4453 "owner/repo",
4454 &EngineConfig::default(),
4455 &router,
4456 &task_manager,
4457 &store,
4458 &dispatching,
4459 &auto_merge_in_flight,
4460 )
4461 .await
4462 .unwrap();
4463
4464 let task = store.get(id).await.unwrap();
4466 assert_eq!(task.status, crate::store::TaskStatus::NeedsReview);
4467 }
4468
4469 #[serial(cooldown_state)]
4470 #[tokio::test]
4471 async fn needs_review_refire_increments_and_fires_when_old_enough() {
4472 crate::engine::cooldown::reset_global_state().await;
4473 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4474 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
4475 let task_manager = Arc::new(TaskManager::with_store(
4476 backend.clone(),
4477 store.clone(),
4478 "owner/repo".to_string(),
4479 ));
4480 let tmux = Arc::new(TmuxManager::new());
4481 let router = Arc::new(RwLock::new(crate::engine::router::Router::from_config()));
4482 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4483 let auto_merge_in_flight: Arc<DashSet<String>> = Arc::new(DashSet::new());
4484
4485 let id = store
4487 .upsert_external(&crate::store::UpsertExternal {
4488 repo: "owner/repo",
4489 ext_id: "201",
4490 title: "Needs review old",
4491 body: "",
4492 author: "",
4493 url: "",
4494 labels: &[],
4495 origin: "github",
4496 })
4497 .await
4498 .unwrap();
4499 store
4500 .update_status(id, crate::store::TaskStatus::NeedsReview)
4501 .await
4502 .unwrap();
4503 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
4505 .bind(id)
4506 .execute(store.pool())
4507 .await
4508 .unwrap();
4509
4510 let t = store.get(id).await.unwrap();
4512 assert_eq!(t.needs_review_refires, 0);
4513
4514 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 t2 = store.get(id).await.unwrap();
4530 assert_eq!(t2.needs_review_refires, 1);
4531 assert_eq!(t2.status, crate::store::TaskStatus::NeedsReview);
4532 }
4533
4534 #[serial(cooldown_state)]
4535 #[tokio::test]
4536 async fn needs_review_backoff_delays_fire_but_increments_counter() {
4537 crate::engine::cooldown::reset_global_state().await;
4538 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4539 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
4540 let task_manager = Arc::new(TaskManager::with_store(
4541 backend.clone(),
4542 store.clone(),
4543 "owner/repo".to_string(),
4544 ));
4545 let tmux = Arc::new(TmuxManager::new());
4546 let router = Arc::new(RwLock::new(crate::engine::router::Router::from_config()));
4547 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4548 let auto_merge_in_flight: Arc<DashSet<String>> = Arc::new(DashSet::new());
4549
4550 let id = store
4551 .upsert_external(&crate::store::UpsertExternal {
4552 repo: "owner/repo",
4553 ext_id: "202",
4554 title: "Needs review backoff",
4555 body: "",
4556 author: "",
4557 url: "",
4558 labels: &[],
4559 origin: "github",
4560 })
4561 .await
4562 .unwrap();
4563 store
4564 .update_status(id, crate::store::TaskStatus::NeedsReview)
4565 .await
4566 .unwrap();
4567
4568 store
4570 .set_fields(id, &[("needs_review_refires", serde_json::json!(1))])
4571 .await
4572 .unwrap();
4573
4574 let now_minus_1 = (chrono::Utc::now() - chrono::Duration::minutes(1))
4576 .format("%Y-%m-%dT%H:%M:%SZ")
4577 .to_string();
4578 sqlx::query("UPDATE tasks SET updated_at = ? WHERE id = ?")
4579 .bind(now_minus_1.clone())
4580 .bind(id)
4581 .execute(store.pool())
4582 .await
4583 .unwrap();
4584
4585 let before = store.get(id).await.unwrap();
4586 assert_eq!(before.needs_review_refires, 1);
4587
4588 sync_tick(
4589 &backend,
4590 &tmux,
4591 "owner/repo",
4592 &EngineConfig::default(),
4593 &router,
4594 &task_manager,
4595 &store,
4596 &dispatching,
4597 &auto_merge_in_flight,
4598 )
4599 .await
4600 .unwrap();
4601
4602 let after = store.get(id).await.unwrap();
4603 assert_eq!(after.needs_review_refires, 1);
4606 assert_eq!(after.updated_at, now_minus_1);
4607 assert_eq!(after.status, crate::store::TaskStatus::NeedsReview);
4608 }
4609
4610 #[serial(cooldown_state)]
4611 #[tokio::test]
4612 async fn needs_review_escalates_to_blocked_after_max_refires() {
4613 crate::engine::cooldown::reset_global_state().await;
4614 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4615 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
4616 let task_manager = Arc::new(TaskManager::with_store(
4617 backend.clone(),
4618 store.clone(),
4619 "owner/repo".to_string(),
4620 ));
4621 let tmux = Arc::new(TmuxManager::new());
4622 let router = Arc::new(RwLock::new(crate::engine::router::Router::from_config()));
4623 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4624 let auto_merge_in_flight: Arc<DashSet<String>> = Arc::new(DashSet::new());
4625
4626 let id = store
4627 .upsert_external(&crate::store::UpsertExternal {
4628 repo: "owner/repo",
4629 ext_id: "203",
4630 title: "Needs review escalate",
4631 body: "",
4632 author: "",
4633 url: "",
4634 labels: &[],
4635 origin: "github",
4636 })
4637 .await
4638 .unwrap();
4639 store
4640 .update_status(id, crate::store::TaskStatus::NeedsReview)
4641 .await
4642 .unwrap();
4643
4644 store
4648 .set_fields(id, &[("needs_review_refires", serde_json::json!(5))])
4649 .await
4650 .unwrap();
4651 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
4653 .bind(id)
4654 .execute(store.pool())
4655 .await
4656 .unwrap();
4657
4658 sync_tick(
4659 &backend,
4660 &tmux,
4661 "owner/repo",
4662 &EngineConfig::default(),
4663 &router,
4664 &task_manager,
4665 &store,
4666 &dispatching,
4667 &auto_merge_in_flight,
4668 )
4669 .await
4670 .unwrap();
4671
4672 let after = store.get(id).await.unwrap();
4673 assert_eq!(after.status, crate::store::TaskStatus::Blocked);
4674 assert!(after.block_reason.is_some());
4675 }
4676
4677 #[serial(cooldown_state)]
4681 #[tokio::test]
4682 async fn needs_review_allows_max_refires_before_escalation() {
4683 crate::engine::cooldown::reset_global_state().await;
4684 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4685 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
4686 let task_manager = Arc::new(TaskManager::with_store(
4687 backend.clone(),
4688 store.clone(),
4689 "owner/repo".to_string(),
4690 ));
4691 let tmux = Arc::new(TmuxManager::new());
4692 let router = Arc::new(RwLock::new(crate::engine::router::Router::from_config()));
4693 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4694 let auto_merge_in_flight: Arc<DashSet<String>> = Arc::new(DashSet::new());
4695
4696 let id = store
4697 .upsert_external(&crate::store::UpsertExternal {
4698 repo: "owner/repo",
4699 ext_id: "204",
4700 title: "Max refires test",
4701 body: "",
4702 author: "",
4703 url: "",
4704 labels: &[],
4705 origin: "github",
4706 })
4707 .await
4708 .unwrap();
4709 store
4710 .update_status(id, crate::store::TaskStatus::NeedsReview)
4711 .await
4712 .unwrap();
4713
4714 store
4717 .set_fields(id, &[("needs_review_refires", serde_json::json!(4))])
4718 .await
4719 .unwrap();
4720 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
4722 .bind(id)
4723 .execute(store.pool())
4724 .await
4725 .unwrap();
4726
4727 sync_tick(
4728 &backend,
4729 &tmux,
4730 "owner/repo",
4731 &EngineConfig::default(),
4732 &router,
4733 &task_manager,
4734 &store,
4735 &dispatching,
4736 &auto_merge_in_flight,
4737 )
4738 .await
4739 .unwrap();
4740
4741 let after = store.get(id).await.unwrap();
4742 assert_eq!(after.status, crate::store::TaskStatus::NeedsReview);
4744 assert_eq!(after.needs_review_refires, 5);
4745 assert!(after.block_reason.is_none());
4746 }
4747
4748 fn router_with_healthy_review_agent() -> crate::engine::router::Router {
4751 use crate::engine::router::{Router, RouterConfig};
4752 let mut config = RouterConfig::default();
4753 config
4754 .model_map
4755 .entry("review".to_string())
4756 .or_default()
4757 .insert("claude".to_string(), vec!["sonnet".to_string()]);
4758 let mut router = Router::new(config);
4759 router.available_agents = vec!["claude".to_string()];
4760 router
4761 }
4762
4763 #[serial(cooldown_state)]
4770 #[tokio::test]
4771 async fn global_refire_sweep_refires_stale_task_from_inactive_repo() {
4772 crate::engine::cooldown::reset_global_state().await;
4773 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4774 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
4775 let task_manager = Arc::new(TaskManager::with_store(
4777 backend,
4778 store.clone(),
4779 "active/repo".to_string(),
4780 ));
4781 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4782 let router = router_with_healthy_review_agent();
4783
4784 let id = store
4785 .upsert_external(&crate::store::UpsertExternal {
4786 repo: "inactive/repo",
4787 ext_id: "490",
4788 title: "Stale needs_review from inactive repo",
4789 body: "",
4790 author: "",
4791 url: "",
4792 labels: &[],
4793 origin: "github",
4794 })
4795 .await
4796 .unwrap();
4797 store
4798 .update_status(id, crate::store::TaskStatus::NeedsReview)
4799 .await
4800 .unwrap();
4801 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
4802 .bind(id)
4803 .execute(store.pool())
4804 .await
4805 .unwrap();
4806
4807 refire_and_escalate_stale_needs_review_global(&task_manager, &store, &router, &dispatching)
4808 .await
4809 .unwrap();
4810
4811 let task = store.get(id).await.unwrap();
4812 assert_eq!(
4813 task.needs_review_refires, 1,
4814 "cross-repo task must be refired"
4815 );
4816 assert_eq!(task.status, crate::store::TaskStatus::NeedsReview);
4817 }
4818
4819 #[serial(cooldown_state)]
4822 #[tokio::test]
4823 async fn global_refire_sweep_escalates_cross_repo_task_to_blocked() {
4824 crate::engine::cooldown::reset_global_state().await;
4825 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4826 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
4827 let task_manager = Arc::new(TaskManager::with_store(
4828 backend,
4829 store.clone(),
4830 "active/repo".to_string(),
4831 ));
4832 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4833 let router = router_with_healthy_review_agent();
4834
4835 let id = store
4836 .upsert_external(&crate::store::UpsertExternal {
4837 repo: "inactive/repo",
4838 ext_id: "493",
4839 title: "Exhausted refires from inactive repo",
4840 body: "",
4841 author: "",
4842 url: "",
4843 labels: &[],
4844 origin: "github",
4845 })
4846 .await
4847 .unwrap();
4848 store
4849 .update_status(id, crate::store::TaskStatus::NeedsReview)
4850 .await
4851 .unwrap();
4852 store
4853 .set_fields(id, &[("needs_review_refires", serde_json::json!(5))])
4854 .await
4855 .unwrap();
4856 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
4857 .bind(id)
4858 .execute(store.pool())
4859 .await
4860 .unwrap();
4861
4862 refire_and_escalate_stale_needs_review_global(&task_manager, &store, &router, &dispatching)
4863 .await
4864 .unwrap();
4865
4866 let task = store.get(id).await.unwrap();
4867 assert_eq!(task.status, crate::store::TaskStatus::Blocked);
4868 assert_eq!(
4869 task.block_reason.as_deref(),
4870 Some("review agent rebroadcast escalated after repeated retries")
4871 );
4872 }
4873
4874 #[serial(cooldown_state)]
4879 #[tokio::test]
4880 async fn global_refire_sweep_skips_when_all_review_agents_cooled() {
4881 crate::engine::cooldown::reset_global_state().await;
4882 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4883 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
4884 let task_manager = Arc::new(TaskManager::with_store(
4885 backend,
4886 store.clone(),
4887 "active/repo".to_string(),
4888 ));
4889 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4890 let router = crate::engine::router::Router::new_for_test(
4893 crate::engine::router::RouterConfig::default(),
4894 vec!["claude".to_string()],
4895 );
4896
4897 let id = store
4898 .upsert_external(&crate::store::UpsertExternal {
4899 repo: "inactive/repo",
4900 ext_id: "494",
4901 title: "Stale needs_review while agents cooled",
4902 body: "",
4903 author: "",
4904 url: "",
4905 labels: &[],
4906 origin: "github",
4907 })
4908 .await
4909 .unwrap();
4910 store
4911 .update_status(id, crate::store::TaskStatus::NeedsReview)
4912 .await
4913 .unwrap();
4914 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
4915 .bind(id)
4916 .execute(store.pool())
4917 .await
4918 .unwrap();
4919
4920 refire_and_escalate_stale_needs_review_global(&task_manager, &store, &router, &dispatching)
4921 .await
4922 .unwrap();
4923
4924 let task = store.get(id).await.unwrap();
4925 assert_eq!(
4926 task.needs_review_refires, 0,
4927 "counter must not be touched while cooled"
4928 );
4929 assert_eq!(task.status, crate::store::TaskStatus::NeedsReview);
4930 }
4931
4932 #[serial(cooldown_state)]
4938 #[tokio::test]
4939 async fn auto_recover_rebroadcast_blocked_task_after_review_cycle() {
4940 crate::engine::cooldown::reset_global_state().await;
4941 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4942 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
4943 let task_manager = Arc::new(TaskManager::with_store(
4944 backend,
4945 store.clone(),
4946 "owner/repo".to_string(),
4947 ));
4948 let router = Arc::new(RwLock::new(router_with_healthy_review_agent()));
4949
4950 let id = store
4951 .upsert_external(&crate::store::UpsertExternal {
4952 repo: "owner/repo",
4953 ext_id: "511",
4954 title: "Rebroadcast blocked after review",
4955 body: "",
4956 author: "",
4957 url: "",
4958 labels: &[],
4959 origin: "github",
4960 })
4961 .await
4962 .unwrap();
4963 store
4964 .update_status(id, crate::store::TaskStatus::Blocked)
4965 .await
4966 .unwrap();
4967 store
4969 .set_block_reason(
4970 id,
4971 Some("review agent rebroadcast escalated after repeated retries"),
4972 )
4973 .await
4974 .unwrap();
4975 store
4976 .set_fields(id, &[("review_cycles", serde_json::json!(1))])
4977 .await
4978 .unwrap();
4979 store
4980 .set_fields(id, &[("needs_review_refires", serde_json::json!(6))])
4981 .await
4982 .unwrap();
4983 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
4985 .bind(id)
4986 .execute(store.pool())
4987 .await
4988 .unwrap();
4989
4990 auto_recover_rebroadcast_blocked_tasks("owner/repo", &router, &task_manager, &store)
4991 .await
4992 .unwrap();
4993
4994 let task = store.get(id).await.unwrap();
4995 assert_eq!(
4996 task.status,
4997 crate::store::TaskStatus::NeedsReview,
4998 "blocked rebroadcast task must be recovered to needs_review"
4999 );
5000 assert!(
5001 task.block_reason.is_none(),
5002 "block_reason must be cleared on recovery"
5003 );
5004 assert_eq!(
5005 task.needs_review_refires, 0,
5006 "needs_review_refires must be reset to 0 on recovery"
5007 );
5008 }
5009
5010 #[serial(cooldown_state)]
5013 #[tokio::test]
5014 async fn stale_in_review_recovery_skips_fresh_tasks() {
5015 crate::engine::cooldown::reset_global_state().await;
5016 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5017 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
5018 let task_manager = Arc::new(TaskManager::with_store(
5019 backend.clone(),
5020 store.clone(),
5021 "owner/repo".to_string(),
5022 ));
5023 let tmux = Arc::new(TmuxManager::new());
5024 let router = Arc::new(RwLock::new(crate::engine::router::Router::from_config()));
5025 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
5026 let auto_merge_in_flight: Arc<DashSet<String>> = Arc::new(DashSet::new());
5027
5028 let id = store
5029 .upsert_external(&crate::store::UpsertExternal {
5030 repo: "owner/repo",
5031 ext_id: "66",
5032 title: "Fresh review",
5033 body: "",
5034 author: "",
5035 url: "",
5036 labels: &[],
5037 origin: "github",
5038 })
5039 .await
5040 .unwrap();
5041 store
5042 .update_status(id, crate::store::TaskStatus::InReview)
5043 .await
5044 .unwrap();
5045 store
5046 .set_fields(id, &[("branch", serde_json::json!("feature/66"))])
5047 .await
5048 .unwrap();
5049 sync_tick(
5052 &backend,
5053 &tmux,
5054 "owner/repo",
5055 &EngineConfig::default(),
5056 &router,
5057 &task_manager,
5058 &store,
5059 &dispatching,
5060 &auto_merge_in_flight,
5061 )
5062 .await
5063 .unwrap();
5064
5065 let task = store.get(id).await.unwrap();
5067 assert_eq!(task.status, crate::store::TaskStatus::InReview);
5068 }
5069
5070 #[serial(cooldown_state)]
5071 #[tokio::test]
5072 async fn auto_unblock_routes_recoverable_failure() {
5073 crate::engine::cooldown::reset_global_state().await;
5074 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5075 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
5076 let task_manager = Arc::new(TaskManager::with_store(
5077 backend,
5078 store.clone(),
5079 "owner/repo".to_string(),
5080 ));
5081 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
5082
5083 let id = store
5084 .upsert_external(&crate::store::UpsertExternal {
5085 repo: "owner/repo",
5086 ext_id: "77",
5087 title: "Blocked task",
5088 body: "",
5089 author: "",
5090 url: "",
5091 labels: &[],
5092 origin: "github",
5093 })
5094 .await
5095 .unwrap();
5096 store
5097 .update_status(id, crate::store::TaskStatus::Blocked)
5098 .await
5099 .unwrap();
5100 store
5101 .set_fields(
5102 id,
5103 &[
5104 ("agent", serde_json::json!("codex")),
5105 ("model", serde_json::json!("gpt-5")),
5106 ],
5107 )
5108 .await
5109 .unwrap();
5110
5111 let run_id = store
5112 .start_run(&crate::store::StartRun {
5113 task_id: id,
5114 attempt: 1,
5115 run_type: "agent",
5116 agent: "codex",
5117 model: "gpt-5",
5118 command: "codex --model gpt-5",
5119 prompt: "do thing",
5120 })
5121 .await
5122 .unwrap();
5123 store
5124 .complete_run(&crate::store::CompleteRun {
5125 run_id,
5126 exit_code: Some(1),
5127 stdout: "",
5128 stderr: "",
5129 parsed: "",
5130 outcome: "rate_limit",
5131 error: "rate limit exceeded",
5132 tokens: RunTokenUsage::default(),
5133 })
5134 .await
5135 .unwrap();
5136
5137 auto_unblock_blocked_tasks(
5138 "owner/repo",
5139 &task_manager,
5140 &store,
5141 &dispatching,
5142 &Arc::new(DashSet::new()),
5143 )
5144 .await
5145 .unwrap();
5146
5147 let task = store.get(id).await.unwrap();
5148 assert_eq!(task.status, TaskStatus::New);
5149 assert!(task.agent.is_none());
5150 assert!(task.model.is_none());
5151 assert_eq!(task.auto_unblock_count, 1);
5152 assert!(!task.auto_unblock_last_at.is_empty());
5153 assert_eq!(task.auto_unblock_last_reason, "RateLimit");
5154 }
5155
5156 #[serial(cooldown_state)]
5157 #[tokio::test]
5158 async fn auto_unblock_skips_manual_block() {
5159 crate::engine::cooldown::reset_global_state().await;
5160 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5161 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
5162 let task_manager = Arc::new(TaskManager::with_store(
5163 backend,
5164 store.clone(),
5165 "owner/repo".to_string(),
5166 ));
5167 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
5168
5169 let id = store
5170 .upsert_external(&crate::store::UpsertExternal {
5171 repo: "owner/repo",
5172 ext_id: "88",
5173 title: "Manually blocked",
5174 body: "",
5175 author: "",
5176 url: "",
5177 labels: &[],
5178 origin: "github",
5179 })
5180 .await
5181 .unwrap();
5182 store
5183 .update_status(id, crate::store::TaskStatus::Blocked)
5184 .await
5185 .unwrap();
5186 store
5187 .set_block_reason(id, Some("waiting on input"))
5188 .await
5189 .unwrap();
5190
5191 let run_id = store
5192 .start_run(&crate::store::StartRun {
5193 task_id: id,
5194 attempt: 1,
5195 run_type: "agent",
5196 agent: "codex",
5197 model: "gpt-5",
5198 command: "codex --model gpt-5",
5199 prompt: "do thing",
5200 })
5201 .await
5202 .unwrap();
5203 store
5204 .complete_run(&crate::store::CompleteRun {
5205 run_id,
5206 exit_code: Some(1),
5207 stdout: "",
5208 stderr: "",
5209 parsed: "",
5210 outcome: "rate_limit",
5211 error: "rate limit exceeded",
5212 tokens: RunTokenUsage::default(),
5213 })
5214 .await
5215 .unwrap();
5216
5217 auto_unblock_blocked_tasks(
5218 "owner/repo",
5219 &task_manager,
5220 &store,
5221 &dispatching,
5222 &Arc::new(DashSet::new()),
5223 )
5224 .await
5225 .unwrap();
5226
5227 let task = store.get(id).await.unwrap();
5228 assert_eq!(task.status, crate::store::TaskStatus::Blocked);
5229 assert_eq!(task.auto_unblock_count, 0);
5230 }
5231
5232 #[serial(cooldown_state)]
5235 #[test]
5236 fn failure_category_as_str() {
5237 assert_eq!(FailureCategory::RateLimit.as_str(), "RateLimit");
5240 assert_eq!(FailureCategory::Timeout.as_str(), "Timeout");
5241 assert_eq!(
5242 FailureCategory::ModelUnavailable.as_str(),
5243 "ModelUnavailable"
5244 );
5245 assert_eq!(FailureCategory::MaxAttempts.as_str(), "MaxAttempts");
5246 assert_eq!(FailureCategory::Unknown.as_str(), "Unknown");
5247 assert_eq!(FailureCategory::SilentExit0.as_str(), "SilentExit0");
5248 assert_eq!(FailureCategory::ConnectionError.as_str(), "ConnectionError");
5249 assert_eq!(FailureCategory::CliFlagError.as_str(), "CliFlagError");
5250 assert_eq!(
5251 FailureCategory::AllAgentsExhausted.as_str(),
5252 "AllAgentsExhausted"
5253 );
5254 assert_eq!(FailureCategory::ParseError.as_str(), "ParseError");
5255 assert_eq!(FailureCategory::FalseFailure.as_str(), "FalseFailure");
5256 assert_eq!(FailureCategory::PushFailed.as_str(), "PushFailed");
5257 assert_eq!(FailureCategory::PrCreateFailed.as_str(), "PrCreateFailed");
5258 }
5259
5260 #[serial(cooldown_state)]
5263 #[test]
5264 fn classify_failure_model_unavailable_phrase_is_recoverable() {
5265 let category = classify_failure("model unavailable (anthropic/claude-sonnet-4-6): Model not found: anthropic/claude-sonnet-4-6", "error");
5266 assert_eq!(
5267 category,
5268 FailureCategory::ModelUnavailable,
5269 "\"model unavailable\" error must classify as ModelUnavailable"
5270 );
5271 assert!(
5272 category.is_recoverable(),
5273 "ModelUnavailable must be recoverable so auto-unblock can re-route"
5274 );
5275 }
5276
5277 #[serial(cooldown_state)]
5279 #[test]
5280 fn classify_failure_kimi_billing_cycle_is_credit_exhausted_not_rate_limit() {
5281 let error = "kimi rate limit: Failed to authenticate. API Error: 403 You've reached \
5288 your usage limit for this billing cycle. Your quota will be refreshed soon.";
5289 let category = classify_failure(error, "rate_limit");
5290 assert_eq!(
5291 category,
5292 FailureCategory::CreditExhausted,
5293 "billing-cycle exhaustion must classify as CreditExhausted, not RateLimit"
5294 );
5295 assert!(category.is_recoverable());
5296 }
5297
5298 #[serial(cooldown_state)]
5299 #[test]
5300 fn classify_failure_billing_cycle_exhausted_outcome_is_credit_exhausted() {
5301 let category = classify_failure("some billing message", "billing_cycle_exhausted");
5304 assert_eq!(category, FailureCategory::CreditExhausted);
5305 }
5306
5307 #[serial(cooldown_state)]
5308 #[test]
5309 fn classify_failure_generic_rate_limit_is_still_rate_limit() {
5310 let category = classify_failure("429 Too Many Requests", "rate_limit");
5313 assert_eq!(category, FailureCategory::RateLimit);
5314 }
5315
5316 #[serial(cooldown_state)]
5318 #[test]
5319 fn classify_failure_pr_create_failed_specific_patterns() {
5320 assert_eq!(
5322 classify_failure("create pr failed", ""),
5323 FailureCategory::PrCreateFailed
5324 );
5325 assert_eq!(
5326 classify_failure("failed to create pull request", ""),
5327 FailureCategory::PrCreateFailed
5328 );
5329 assert_eq!(
5330 classify_failure("pull request creation failed", ""),
5331 FailureCategory::PrCreateFailed
5332 );
5333
5334 assert_ne!(
5336 classify_failure("network failed while fetching pull request status", ""),
5337 FailureCategory::PrCreateFailed
5338 );
5339 assert_ne!(
5340 classify_failure("connection failed, retrying pull request check", ""),
5341 FailureCategory::PrCreateFailed
5342 );
5343 assert_ne!(
5344 classify_failure("pull request created but failed to merge", ""),
5345 FailureCategory::PrCreateFailed
5346 );
5347 }
5348
5349 #[serial(cooldown_state)]
5350 #[test]
5351 fn classify_failure_model_not_found_is_recoverable() {
5352 let category = classify_failure("Model not found: gpt-5-ultra", "error");
5353 assert_eq!(
5354 category,
5355 FailureCategory::ModelUnavailable,
5356 "\"model not found\" error must classify as ModelUnavailable"
5357 );
5358 assert!(category.is_recoverable());
5359 }
5360
5361 #[serial(cooldown_state)]
5362 #[test]
5363 fn classify_failure_model_does_not_exist_is_recoverable() {
5364 let category = classify_failure("The model `claude-opus-99` does not exist", "error");
5365 assert_eq!(category, FailureCategory::ModelUnavailable);
5366 assert!(category.is_recoverable());
5367 }
5368
5369 #[serial(cooldown_state)]
5370 #[tokio::test]
5371 async fn auto_unblock_routes_task_blocked_by_model_unavailable() {
5372 crate::engine::cooldown::reset_global_state().await;
5373 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5374 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
5375 let task_manager = Arc::new(TaskManager::with_store(
5376 backend,
5377 store.clone(),
5378 "owner/repo".to_string(),
5379 ));
5380 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
5381
5382 let id = store
5383 .upsert_external(&crate::store::UpsertExternal {
5384 repo: "owner/repo",
5385 ext_id: "1202",
5386 title: "Task blocked by model unavailable",
5387 body: "",
5388 author: "",
5389 url: "",
5390 labels: &[],
5391 origin: "github",
5392 })
5393 .await
5394 .unwrap();
5395 store
5396 .update_status(id, crate::store::TaskStatus::Blocked)
5397 .await
5398 .unwrap();
5399
5400 let run_id = store
5401 .start_run(&crate::store::StartRun {
5402 task_id: id,
5403 attempt: 1,
5404 run_type: "agent",
5405 agent: "opencode",
5406 model: "anthropic/claude-sonnet-4-6",
5407 command: "opencode",
5408 prompt: "fix the bug",
5409 })
5410 .await
5411 .unwrap();
5412 store
5413 .complete_run(&crate::store::CompleteRun {
5414 run_id,
5415 exit_code: Some(1),
5416 stdout: "",
5417 stderr: "",
5418 parsed: "",
5419 outcome: "error",
5420 error: "model unavailable (anthropic/claude-sonnet-4-6): Model not found: anthropic/claude-sonnet-4-6",
5421 tokens: RunTokenUsage::default(),
5422 })
5423 .await
5424 .unwrap();
5425
5426 auto_unblock_blocked_tasks(
5427 "owner/repo",
5428 &task_manager,
5429 &store,
5430 &dispatching,
5431 &Arc::new(DashSet::new()),
5432 )
5433 .await
5434 .unwrap();
5435
5436 let task = store.get(id).await.unwrap();
5437 assert_eq!(
5438 task.status,
5439 TaskStatus::New,
5440 "task blocked by model unavailable must be auto-unblocked and re-routed"
5441 );
5442 assert_eq!(task.auto_unblock_count, 1);
5443 assert_eq!(task.auto_unblock_last_reason, "ModelUnavailable");
5444 }
5445
5446 #[serial(cooldown_state)]
5449 #[tokio::test]
5450 async fn auto_unblock_resets_count_when_reason_changes() {
5451 crate::engine::cooldown::reset_global_state().await;
5452 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5456 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
5457 let task_manager = Arc::new(TaskManager::with_store(
5458 backend,
5459 store.clone(),
5460 "owner/repo".to_string(),
5461 ));
5462 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
5463
5464 let id = store
5465 .upsert_external(&crate::store::UpsertExternal {
5466 repo: "owner/repo",
5467 ext_id: "1227",
5468 title: "Task with different failure reason",
5469 body: "",
5470 author: "",
5471 url: "",
5472 labels: &[],
5473 origin: "github",
5474 })
5475 .await
5476 .unwrap();
5477 store
5478 .update_status(id, crate::store::TaskStatus::Blocked)
5479 .await
5480 .unwrap();
5481 store
5483 .set_fields(
5484 id,
5485 &[
5486 ("auto_unblock_count", serde_json::json!(1)),
5487 (
5488 "auto_unblock_last_reason",
5489 serde_json::json!("ModelUnavailable"),
5490 ),
5491 ],
5492 )
5493 .await
5494 .unwrap();
5495
5496 let run_id = store
5498 .start_run(&crate::store::StartRun {
5499 task_id: id,
5500 attempt: 2,
5501 run_type: "agent",
5502 agent: "claude",
5503 model: "opus",
5504 command: "claude",
5505 prompt: "fix the bug",
5506 })
5507 .await
5508 .unwrap();
5509 store
5510 .complete_run(&crate::store::CompleteRun {
5511 run_id,
5512 exit_code: Some(1),
5513 stdout: "",
5514 stderr: "",
5515 parsed: "",
5516 outcome: "error",
5517 error: "exceeded max attempts",
5518 tokens: RunTokenUsage::default(),
5519 })
5520 .await
5521 .unwrap();
5522
5523 auto_unblock_blocked_tasks(
5524 "owner/repo",
5525 &task_manager,
5526 &store,
5527 &dispatching,
5528 &Arc::new(DashSet::new()),
5529 )
5530 .await
5531 .unwrap();
5532
5533 let task = store.get(id).await.unwrap();
5534 assert_eq!(
5535 task.status,
5536 TaskStatus::New,
5537 "task must be auto-unblocked even with count=1 from a different reason"
5538 );
5539 assert_eq!(task.auto_unblock_count, 1);
5541 assert_eq!(
5542 task.auto_unblock_last_reason, "MaxAttempts",
5543 "reason must be updated to the new failure category"
5544 );
5545 }
5546
5547 #[serial(cooldown_state)]
5548 #[tokio::test]
5549 async fn auto_unblock_stores_reason_on_first_unblock() {
5550 crate::engine::cooldown::reset_global_state().await;
5551 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5554 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
5555 let task_manager = Arc::new(TaskManager::with_store(
5556 backend,
5557 store.clone(),
5558 "owner/repo".to_string(),
5559 ));
5560 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
5561
5562 let id = store
5563 .upsert_external(&crate::store::UpsertExternal {
5564 repo: "owner/repo",
5565 ext_id: "1227b",
5566 title: "Task for first-time reason storage",
5567 body: "",
5568 author: "",
5569 url: "",
5570 labels: &[],
5571 origin: "github",
5572 })
5573 .await
5574 .unwrap();
5575 store
5576 .update_status(id, crate::store::TaskStatus::Blocked)
5577 .await
5578 .unwrap();
5579
5580 let run_id = store
5581 .start_run(&crate::store::StartRun {
5582 task_id: id,
5583 attempt: 1,
5584 run_type: "agent",
5585 agent: "claude",
5586 model: "opus",
5587 command: "claude",
5588 prompt: "fix the bug",
5589 })
5590 .await
5591 .unwrap();
5592 store
5593 .complete_run(&crate::store::CompleteRun {
5594 run_id,
5595 exit_code: Some(1),
5596 stdout: "",
5597 stderr: "",
5598 parsed: "",
5599 outcome: "timeout",
5600 error: "task timed out",
5601 tokens: RunTokenUsage::default(),
5602 })
5603 .await
5604 .unwrap();
5605
5606 auto_unblock_blocked_tasks(
5607 "owner/repo",
5608 &task_manager,
5609 &store,
5610 &dispatching,
5611 &Arc::new(DashSet::new()),
5612 )
5613 .await
5614 .unwrap();
5615
5616 let task = store.get(id).await.unwrap();
5617 assert_eq!(task.status, TaskStatus::New);
5618 assert_eq!(task.auto_unblock_count, 1);
5619 assert_eq!(
5620 task.auto_unblock_last_reason, "Timeout",
5621 "reason must be stored on first auto-unblock"
5622 );
5623 }
5624
5625 #[serial(cooldown_state)]
5628 #[test]
5629 fn classify_failure_sparse_checkout_not_parse_error() {
5630 let category = classify_failure("error: cannot reset paths in a sparse checkout", "error");
5633 assert_ne!(
5634 category,
5635 FailureCategory::ParseError,
5636 "sparse checkout errors must NOT be classified as ParseError"
5637 );
5638 assert_eq!(
5639 category,
5640 FailureCategory::Unknown,
5641 "sparse checkout errors should be Unknown (not recoverable auto-unblock)"
5642 );
5643 }
5644
5645 #[serial(cooldown_state)]
5646 #[test]
5647 fn classify_failure_sparse_index_not_parse_error() {
5648 let category = classify_failure("failed due to sparse index configuration", "error");
5650 assert_ne!(category, FailureCategory::ParseError);
5651 assert_eq!(category, FailureCategory::Unknown);
5652 }
5653
5654 #[serial(cooldown_state)]
5661 #[test]
5662 fn classify_failure_truncated_is_recoverable() {
5663 let category = classify_failure(
5664 "truncated: review output was cut off before completion \
5665 (output/reasoning token budget exceeded)",
5666 "truncated",
5667 );
5668 assert_eq!(
5669 category,
5670 FailureCategory::ParseError,
5671 "truncated outcome must classify as a recoverable parse-style failure"
5672 );
5673 assert!(
5674 category.is_recoverable(),
5675 "truncated failures must be recoverable so auto-unblock can retry"
5676 );
5677 }
5678
5679 #[serial(cooldown_state)]
5680 #[test]
5681 fn dispatching_key_includes_repo_for_cross_project_isolation() {
5682 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
5683
5684 let key_a = "owner/repo-a/42".to_string();
5686 dispatching.insert(key_a.clone(), "42".to_string());
5687
5688 let key_b = "owner/repo-b/42".to_string();
5690 assert!(dispatching.contains_key(&key_a));
5691 assert!(
5692 !dispatching.contains_key(&key_b),
5693 "same task ID in different repo should not be blocked"
5694 );
5695 }
5696
5697 #[serial(cooldown_state)]
5698 #[tokio::test]
5699 async fn emit_degraded_agents_writes_metric_when_three_or_more_agents_degraded() {
5700 crate::engine::cooldown::reset_global_state().await;
5701 use crate::engine::router::{Router, RouterConfig};
5702
5703 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5704
5705 let mut config = RouterConfig::default();
5707 config
5708 .model_map
5709 .entry("medium".to_string())
5710 .or_default()
5711 .insert(
5712 "test-agent-3a".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-3b".to_string(),
5721 vec!["test-model-b".to_string()],
5722 );
5723 config
5724 .model_map
5725 .entry("medium".to_string())
5726 .or_default()
5727 .insert(
5728 "test-agent-3c".to_string(),
5729 vec!["test-model-c".to_string()],
5730 );
5731 config
5732 .model_map
5733 .entry("medium".to_string())
5734 .or_default()
5735 .insert(
5736 "test-agent-3d".to_string(),
5737 vec!["test-model-d".to_string()],
5738 );
5739 let mut router = Router::new(config);
5740 router.available_agents = vec![
5741 "test-agent-3a".to_string(),
5742 "test-agent-3b".to_string(),
5743 "test-agent-3c".to_string(),
5744 "test-agent-3d".to_string(),
5745 ];
5746
5747 set_agent_cooldown("test-agent-3a", 3600).await;
5749 set_agent_cooldown("test-agent-3b", 3600).await;
5750 set_agent_cooldown("test-agent-3c", 3600).await;
5751
5752 emit_degraded_agents_if_needed(&router.available_agents, &router.config, Some(&store))
5754 .await;
5755
5756 let count_val = store
5758 .kv_get("metrics:orch.agents_degraded.count")
5759 .await
5760 .unwrap();
5761 assert_eq!(count_val.as_deref(), Some("3"));
5762
5763 let alert_val = store
5765 .kv_get("metrics:orch.agents_degraded.alert")
5766 .await
5767 .unwrap();
5768 assert_eq!(alert_val.as_deref(), Some("1"));
5769 }
5770
5771 #[serial(cooldown_state)]
5772 #[tokio::test]
5773 async fn emit_degraded_agents_clears_alert_when_below_threshold() {
5774 crate::engine::cooldown::reset_global_state().await;
5775 use crate::engine::router::{Router, RouterConfig};
5776
5777 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5778
5779 let mut config = RouterConfig::default();
5780 config
5781 .model_map
5782 .entry("medium".to_string())
5783 .or_default()
5784 .insert(
5785 "test-agent-2a".to_string(),
5786 vec!["test-model-a".to_string()],
5787 );
5788 config
5789 .model_map
5790 .entry("medium".to_string())
5791 .or_default()
5792 .insert(
5793 "test-agent-2b".to_string(),
5794 vec!["test-model-b".to_string()],
5795 );
5796 let mut router = Router::new(config);
5797 router.available_agents = vec!["test-agent-2a".to_string(), "test-agent-2b".to_string()];
5798
5799 set_agent_cooldown("test-agent-2a", 3600).await;
5801 set_agent_cooldown("test-agent-2b", 3600).await;
5802
5803 emit_degraded_agents_if_needed(&router.available_agents, &router.config, Some(&store))
5804 .await;
5805
5806 let count_val = store
5808 .kv_get("metrics:orch.agents_degraded.count")
5809 .await
5810 .unwrap();
5811 assert_eq!(count_val.as_deref(), Some("2"));
5812
5813 let alert_val = store
5815 .kv_get("metrics:orch.agents_degraded.alert")
5816 .await
5817 .unwrap();
5818 assert_eq!(alert_val.as_deref(), Some("0"));
5819
5820 let stale_pool_alert = store
5821 .kv_get("metrics:orch.model_pool_stale_persistent.alert")
5822 .await
5823 .unwrap();
5824 assert_eq!(stale_pool_alert.as_deref(), Some("0"));
5825 }
5826
5827 #[serial(cooldown_state)]
5828 #[tokio::test]
5829 async fn emit_degraded_agents_sets_stale_pool_alert_for_persistent_model_failures() {
5830 crate::engine::cooldown::reset_global_state().await;
5831 use crate::engine::cooldown::record_persistent_model_failure;
5832 use crate::engine::router::{Router, RouterConfig};
5833
5834 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5835
5836 let mut config = RouterConfig::default();
5837 config
5838 .model_map
5839 .entry("medium".to_string())
5840 .or_default()
5841 .insert(
5842 "test-agent-stale".to_string(),
5843 vec!["model-a".to_string(), "model-b".to_string()],
5844 );
5845 let mut router = Router::new(config);
5846 router.available_agents = vec!["test-agent-stale".to_string()];
5847
5848 record_persistent_model_failure("test-agent-stale", "model-a").await;
5850
5851 emit_degraded_agents_if_needed(&router.available_agents, &router.config, Some(&store))
5852 .await;
5853
5854 let stale_pool_alert = store
5855 .kv_get("metrics:orch.model_pool_stale_persistent.alert")
5856 .await
5857 .unwrap();
5858 assert_eq!(stale_pool_alert.as_deref(), Some("1"));
5859 }
5860
5861 use super::{classify_comment, CommentAction, MentionCursor};
5864 use crate::engine::commands::OwnerCommand;
5865
5866 #[serial(cooldown_state)]
5867 #[test]
5868 fn classify_skip_already_processed() {
5869 let action = classify_comment(
5870 "@orch /retry",
5871 Some("https://api.github.com/repos/o/r/issues/1"),
5872 "@bot",
5873 true,
5874 );
5875 assert_eq!(action, CommentAction::Skip);
5876 }
5877
5878 #[serial(cooldown_state)]
5879 #[test]
5880 fn classify_skip_irrelevant_comment() {
5881 let action = classify_comment("just a regular comment", None, "@bot", false);
5882 assert_eq!(action, CommentAction::Skip);
5883 }
5884
5885 #[serial(cooldown_state)]
5886 #[test]
5887 fn classify_command_without_mention() {
5888 let action = classify_comment(
5889 "/retry",
5890 Some("https://api.github.com/repos/o/r/issues/42"),
5891 "@bot",
5892 false,
5893 );
5894 assert_eq!(
5895 action,
5896 CommentAction::ExecuteCommand {
5897 command: OwnerCommand::Retry,
5898 issue_num: "42".to_string(),
5899 }
5900 );
5901 }
5902
5903 #[serial(cooldown_state)]
5904 #[test]
5905 fn classify_command_with_mention() {
5906 let action = classify_comment(
5907 "@orch /close",
5908 Some("https://api.github.com/repos/o/r/issues/7"),
5909 "@bot",
5910 false,
5911 );
5912 assert_eq!(
5913 action,
5914 CommentAction::ExecuteCommandForMention {
5915 command: OwnerCommand::Close,
5916 issue_num: "7".to_string(),
5917 }
5918 );
5919 }
5920
5921 #[serial(cooldown_state)]
5922 #[test]
5923 fn classify_mention_without_command() {
5924 let action = classify_comment(
5925 "@orch can you look at this?",
5926 Some("https://api.github.com/repos/o/r/issues/99"),
5927 "@bot",
5928 false,
5929 );
5930 assert_eq!(
5931 action,
5932 CommentAction::CreateMentionTask {
5933 issue_num: Some("99".to_string()),
5934 }
5935 );
5936 }
5937
5938 #[serial(cooldown_state)]
5939 #[test]
5940 fn classify_mention_without_issue_url() {
5941 let action = classify_comment("@orch help me", None, "@bot", false);
5942 assert_eq!(action, CommentAction::CreateMentionTask { issue_num: None });
5943 }
5944
5945 #[serial(cooldown_state)]
5946 #[test]
5947 fn classify_command_no_mention_no_url_skips() {
5948 let action = classify_comment("/retry", None, "@bot", false);
5949 assert_eq!(action, CommentAction::Skip);
5950 }
5951
5952 #[serial(cooldown_state)]
5953 #[test]
5954 fn classify_mention_via_current_user() {
5955 let action = classify_comment(
5956 "@bot please help",
5957 Some("https://api.github.com/repos/o/r/issues/5"),
5958 "@bot",
5959 false,
5960 );
5961 assert_eq!(
5962 action,
5963 CommentAction::CreateMentionTask {
5964 issue_num: Some("5".to_string()),
5965 }
5966 );
5967 }
5968
5969 #[serial(cooldown_state)]
5970 #[test]
5971 fn classify_command_with_mention_no_url_creates_task() {
5972 let action = classify_comment("@orch /retry", None, "@bot", false);
5974 assert_eq!(action, CommentAction::CreateMentionTask { issue_num: None });
5975 }
5976
5977 #[serial(cooldown_state)]
5978 #[test]
5979 fn mention_cursor_stops_advancing_after_gap() {
5980 let mut cursor = MentionCursor::default();
5981 cursor.advance("2026-01-01T00:00:00Z");
5982 cursor.block_on_gap();
5983 cursor.advance("2026-01-02T00:00:00Z");
5984
5985 assert_eq!(
5986 cursor.into_last_safe_ts().as_deref(),
5987 Some("2026-01-01T00:00:00Z")
5988 );
5989 }
5990
5991 #[serial(cooldown_state)]
5992 #[test]
5993 fn mention_cursor_tracks_max_timestamp_until_gap() {
5994 let mut cursor = MentionCursor::default();
5995 cursor.advance("2026-01-01T00:00:00Z");
5996 cursor.advance("2026-01-01T00:05:00Z");
5997 cursor.block_on_gap();
5998 cursor.advance("2026-01-01T00:10:00Z");
5999
6000 assert_eq!(
6001 cursor.into_last_safe_ts().as_deref(),
6002 Some("2026-01-01T00:05:00Z")
6003 );
6004 }
6005
6006 struct AckTrackingBackend {
6010 ack_calls: tokio::sync::Mutex<Vec<String>>,
6011 ack_failure: bool,
6012 inner: crate::backends::test_helpers::NoopBackend,
6013 }
6014
6015 impl AckTrackingBackend {
6016 fn new(ack_failure: bool) -> Arc<Self> {
6017 Arc::new(Self {
6018 ack_calls: tokio::sync::Mutex::new(Vec::new()),
6019 ack_failure,
6020 inner: crate::backends::test_helpers::NoopBackend,
6021 })
6022 }
6023
6024 fn fail_on_ack(&self) -> bool {
6025 self.ack_failure
6026 }
6027 }
6028
6029 #[async_trait]
6030 impl ExternalBackend for AckTrackingBackend {
6031 fn name(&self) -> &str {
6032 "ack-tracking"
6033 }
6034
6035 async fn acknowledge_mention(&self, comment_id: &str) -> anyhow::Result<()> {
6036 self.ack_calls.lock().await.push(comment_id.to_string());
6037 if self.fail_on_ack() {
6038 anyhow::bail!("network error: temporary failure posting reaction");
6040 }
6041 Ok(())
6042 }
6043
6044 async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
6046 Ok(Some("bot".into()))
6047 }
6048
6049 async fn get_mentions(&self, _since: &str) -> anyhow::Result<Vec<Mention>> {
6051 Ok(vec![])
6052 }
6053
6054 async fn create_task(&self, t: &str, b: &str, l: &[String]) -> anyhow::Result<ExternalId> {
6056 self.inner.create_task(t, b, l).await
6057 }
6058 async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask> {
6059 self.inner.get_task(id).await
6060 }
6061 async fn list_by_status(&self, s: Status) -> anyhow::Result<Vec<ExternalTask>> {
6062 self.inner.list_by_status(s).await
6063 }
6064 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
6065 self.inner.list_routable().await
6066 }
6067 async fn post_comment(&self, id: &ExternalId, b: &str) -> anyhow::Result<()> {
6068 self.inner.post_comment(id, b).await
6069 }
6070 async fn set_labels(&self, id: &ExternalId, l: &[String]) -> anyhow::Result<()> {
6071 self.inner.set_labels(id, l).await
6072 }
6073 async fn remove_label(&self, id: &ExternalId, l: &str) -> anyhow::Result<()> {
6074 self.inner.remove_label(id, l).await
6075 }
6076 async fn get_sub_issues(&self, id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
6077 self.inner.get_sub_issues(id).await
6078 }
6079 async fn create_sub_task(
6080 &self,
6081 p: &ExternalId,
6082 t: &str,
6083 b: &str,
6084 l: &[String],
6085 ) -> anyhow::Result<ExternalId> {
6086 self.inner.create_sub_task(p, t, b, l).await
6087 }
6088 async fn ensure_status_label(&self, l: &str) -> anyhow::Result<()> {
6089 self.inner.ensure_status_label(l).await
6090 }
6091 async fn has_open_issue_with_title(&self, t: &str, l: &str) -> anyhow::Result<bool> {
6092 self.inner.has_open_issue_with_title(t, l).await
6093 }
6094 async fn health_check(&self) -> anyhow::Result<()> {
6095 self.inner.health_check().await
6096 }
6097 async fn is_pr_merged(&self, b: &str) -> anyhow::Result<bool> {
6098 self.inner.is_pr_merged(b).await
6099 }
6100 async fn update_status(&self, id: &ExternalId, s: Status) -> anyhow::Result<()> {
6101 self.inner.update_status(id, s).await
6102 }
6103 }
6104
6105 #[serial(cooldown_state)]
6108 #[tokio::test]
6109 async fn create_mention_task_skips_on_ack_failure() {
6110 crate::engine::cooldown::reset_global_state().await;
6111 let backend: Arc<AckTrackingBackend> = AckTrackingBackend::new(true);
6112 let store: Arc<TaskStore> = Arc::new(TaskStore::open_memory().await.unwrap());
6113
6114 let initial_ts = "2026-01-01T00:00:00Z";
6116 store
6117 .kv_set("mentions_last_checked", initial_ts)
6118 .await
6119 .unwrap();
6120
6121 let mention = Mention {
6122 id: "mention-abc".into(),
6123 body: "@bot can you help?".into(),
6124 author: "alice".into(),
6125 created_at: "2026-01-02T00:00:00Z".into(),
6126 issue_url: Some("https://api.github.com/repos/owner/repo/issues/42".into()),
6127 };
6128
6129 let backend_trait: Arc<dyn ExternalBackend> = backend.clone();
6130 let task_manager = Arc::new(TaskManager::new(backend_trait.clone()));
6131
6132 scan_comments(
6133 &backend_trait,
6134 Some(&store),
6135 "owner/repo",
6136 &task_manager,
6137 Some(&[mention]),
6138 )
6139 .await
6140 .unwrap();
6141
6142 let calls = backend.ack_calls.lock().await;
6144 assert_eq!(
6145 &**calls,
6146 &["mention-abc"],
6147 "acknowledge_mention must be called"
6148 );
6149
6150 let all = store.list_all("owner/repo").await.unwrap();
6152 assert!(
6153 all.is_empty(),
6154 "no task should be created when acknowledge_mention fails"
6155 );
6156
6157 let cursor = store.kv_get("mentions_last_checked").await.unwrap();
6159 assert_eq!(
6160 cursor.as_deref(),
6161 Some(initial_ts),
6162 "cursor must not advance when acknowledge_mention fails"
6163 );
6164 }
6165
6166 #[serial(cooldown_state)]
6169 #[tokio::test]
6170 async fn execute_command_for_mention_skips_on_ack_failure() {
6171 crate::engine::cooldown::reset_global_state().await;
6172 let backend: Arc<AckTrackingBackend> = AckTrackingBackend::new(true);
6173 let store: Arc<TaskStore> = Arc::new(TaskStore::open_memory().await.unwrap());
6174
6175 let initial_ts = "2026-01-01T00:00:00Z";
6176 store
6177 .kv_set("mentions_last_checked", initial_ts)
6178 .await
6179 .unwrap();
6180
6181 let mention = Mention {
6183 id: "mention-def".into(),
6184 body: "@bot /close".into(),
6185 author: "alice".into(),
6186 created_at: "2026-01-02T00:00:00Z".into(),
6187 issue_url: Some("https://api.github.com/repos/owner/repo/issues/42".into()),
6188 };
6189
6190 let backend_trait: Arc<dyn ExternalBackend> = backend.clone();
6191 let task_manager = Arc::new(TaskManager::new(backend_trait.clone()));
6192
6193 scan_comments(
6194 &backend_trait,
6195 Some(&store),
6196 "owner/repo",
6197 &task_manager,
6198 Some(&[mention]),
6199 )
6200 .await
6201 .unwrap();
6202
6203 let calls = backend.ack_calls.lock().await;
6205 assert_eq!(&**calls, &["mention-def"]);
6206
6207 let all = store.list_all("owner/repo").await.unwrap();
6209 assert!(
6210 all.is_empty(),
6211 "no task should be created when acknowledge_mention fails for ExecuteCommandForMention"
6212 );
6213
6214 let cursor = store.kv_get("mentions_last_checked").await.unwrap();
6216 assert_eq!(
6217 cursor.as_deref(),
6218 Some(initial_ts),
6219 "cursor must not advance when acknowledge_mention fails for ExecuteCommandForMention"
6220 );
6221 }
6222
6223 fn make_task(block_reason: Option<String>) -> crate::store::Task {
6226 crate::store::Task {
6227 id: 1,
6228 external_id: None,
6229 repo: "owner/repo".to_string(),
6230 origin: String::new(),
6231 title: String::new(),
6232 body: String::new(),
6233 status: crate::store::TaskStatus::Blocked,
6234 source: String::new(),
6235 source_id: String::new(),
6236 author: String::new(),
6237 url: String::new(),
6238 labels: vec![],
6239 agent: None,
6240 model: None,
6241 complexity: String::new(),
6242 estimate: 0,
6243 route_reason: String::new(),
6244 agent_profile: String::new(),
6245 selected_skills: String::new(),
6246 route_attempts: 0,
6247 attempts: 0,
6248 branch: String::new(),
6249 worktree: String::new(),
6250 worktree_cleaned: false,
6251 summary: String::new(),
6252 last_error: String::new(),
6253 parent_id: None,
6254 block_reason,
6255 pr_number: Some(42),
6256 pr_review_context: String::new(),
6257 last_review_ts: String::new(),
6258 review_ts_map: String::new(),
6259 last_comment_review_ts: String::new(),
6260 merge_conflict_retries: 0,
6261 ci_merge_failures: 0,
6262 pr_create_failures: 0,
6263 push_failures: 0,
6264 network_retries: 0,
6265 review_agent_failures: 0,
6266 review_cycles: 0,
6267 review_invocations: 0,
6268 review_session_expected: false,
6269 needs_review_refires: 0,
6270 input_tokens: 0,
6271 output_tokens: 0,
6272 input_cost_usd: 0.0,
6273 output_cost_usd: 0.0,
6274 total_cost_usd: 0.0,
6275 model_reroute_chain: String::new(),
6276 limit_reroute_chain: String::new(),
6277 memory: vec![],
6278 delegations: vec![],
6279 auto_unblock_count: 0,
6280 auto_unblock_last_at: String::new(),
6281 auto_unblock_last_reason: String::new(),
6282 ci_recovery_count: 0,
6283 no_code_reroutes: 0,
6284 no_code_last_agent: String::new(),
6285 created_at: String::new(),
6286 updated_at: String::new(),
6287 }
6288 }
6289
6290 #[serial(cooldown_state)]
6291 #[test]
6292 fn is_ci_failure_block_matches_ci_failure_limit() {
6293 let task = make_task(Some(
6294 "CI failure limit (3) reached during auto-merge".to_string(),
6295 ));
6296 assert!(is_ci_failure_block(&task));
6297 }
6298
6299 #[serial(cooldown_state)]
6300 #[test]
6301 fn is_ci_failure_block_matches_ci_checks_timeout() {
6302 let task = make_task(Some(
6303 "CI checks timed out after 3 auto-merge failures".to_string(),
6304 ));
6305 assert!(is_ci_failure_block(&task));
6306 }
6307
6308 #[serial(cooldown_state)]
6309 #[test]
6310 fn is_ci_failure_block_false_for_regular_block() {
6311 let task = make_task(Some("waiting on input".to_string()));
6312 assert!(!is_ci_failure_block(&task));
6313 }
6314
6315 #[serial(cooldown_state)]
6316 #[test]
6317 fn is_ci_failure_block_false_for_none_reason() {
6318 let task = make_task(None);
6319 assert!(!is_ci_failure_block(&task));
6320 }
6321
6322 #[serial(cooldown_state)]
6325 #[test]
6326 fn ci_failure_cooldown_zero_count_immediate() {
6327 let task = make_task(None);
6329 assert!(ci_failure_unblock_cooldown_elapsed(&task));
6330 }
6331
6332 #[serial(cooldown_state)]
6333 #[test]
6334 fn ci_failure_cooldown_empty_last_at_immediate() {
6335 let mut task = make_task(None);
6337 task.auto_unblock_count = 1;
6338 assert!(ci_failure_unblock_cooldown_elapsed(&task));
6339 }
6340
6341 #[serial(cooldown_state)]
6342 #[test]
6343 fn ci_failure_cooldown_invalid_timestamp_immediate() {
6344 let mut task = make_task(None);
6346 task.auto_unblock_count = 1;
6347 task.auto_unblock_last_at = "not-a-timestamp".to_string();
6348 assert!(ci_failure_unblock_cooldown_elapsed(&task));
6349 }
6350
6351 #[serial(cooldown_state)]
6352 #[test]
6353 fn ci_failure_cooldown_high_count_still_eventually_elapses() {
6354 let long_ago = (chrono::Utc::now() - chrono::Duration::days(30)).to_rfc3339();
6357 for count in [3i32, 5, 99] {
6358 let mut task = make_task(None);
6359 task.auto_unblock_count = count;
6360 task.auto_unblock_last_at = long_ago.clone();
6361 assert!(
6362 ci_failure_unblock_cooldown_elapsed(&task),
6363 "count={count} should eventually elapse"
6364 );
6365 }
6366 }
6367
6368 #[serial(cooldown_state)]
6369 #[test]
6370 fn ci_failure_cooldown_recent_timestamp_still_blocks() {
6371 let just_now = chrono::Utc::now().to_rfc3339();
6373 for count in [1i32, 2, 3, 99] {
6374 let mut task = make_task(None);
6375 task.auto_unblock_count = count;
6376 task.auto_unblock_last_at = just_now.clone();
6377 assert!(
6378 !ci_failure_unblock_cooldown_elapsed(&task),
6379 "count={count} should still be cooling down"
6380 );
6381 }
6382 }
6383
6384 #[serial(cooldown_state)]
6387 #[tokio::test]
6388 async fn global_ci_failure_sweep_returns_ok_with_no_blocked_tasks() {
6389 crate::engine::cooldown::reset_global_state().await;
6390 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6391 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
6392 let task_manager = Arc::new(TaskManager::with_store(
6393 backend,
6394 store.clone(),
6395 "owner/repo".to_string(),
6396 ));
6397 auto_unblock_ci_failure_blocked_tasks_global(
6398 &task_manager,
6399 &store,
6400 &Arc::new(DashSet::new()),
6401 )
6402 .await
6403 .unwrap();
6404 }
6405
6406 #[serial(cooldown_state)]
6407 #[tokio::test]
6408 async fn global_ci_failure_sweep_skips_tasks_in_cooldown() {
6409 crate::engine::cooldown::reset_global_state().await;
6412 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6413 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
6414 let task_manager = Arc::new(TaskManager::with_store(
6415 backend,
6416 store.clone(),
6417 "active/repo".to_string(),
6418 ));
6419
6420 let id = store
6422 .upsert_external(&crate::store::UpsertExternal {
6423 repo: "inactive/repo",
6424 ext_id: "10",
6425 title: "Old blocked task",
6426 body: "",
6427 author: "",
6428 url: "",
6429 labels: &[],
6430 origin: "github",
6431 })
6432 .await
6433 .unwrap();
6434 store
6435 .update_status(id, crate::store::TaskStatus::Blocked)
6436 .await
6437 .unwrap();
6438 let just_now = chrono::Utc::now().to_rfc3339();
6439 store
6440 .set_fields(
6441 id,
6442 &[
6443 (
6444 "block_reason",
6445 serde_json::json!("CI failure limit reached during auto-merge"),
6446 ),
6447 ("pr_number", serde_json::json!(99)),
6448 ("auto_unblock_count", serde_json::json!(1)),
6449 ("auto_unblock_last_at", serde_json::json!(just_now)),
6450 ],
6451 )
6452 .await
6453 .unwrap();
6454
6455 auto_unblock_ci_failure_blocked_tasks_global(
6456 &task_manager,
6457 &store,
6458 &Arc::new(DashSet::new()),
6459 )
6460 .await
6461 .unwrap();
6462
6463 let task = store.get(id).await.unwrap();
6465 assert_eq!(
6466 task.auto_unblock_count, 1,
6467 "task in cooldown must not be processed"
6468 );
6469 }
6470
6471 #[serial(cooldown_state)]
6472 #[tokio::test]
6473 async fn global_ci_failure_sweep_ignores_non_ci_failure_blocks() {
6474 crate::engine::cooldown::reset_global_state().await;
6475 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6476 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
6477 let task_manager = Arc::new(TaskManager::with_store(
6478 backend,
6479 store.clone(),
6480 "active/repo".to_string(),
6481 ));
6482
6483 let id = store
6485 .upsert_external(&crate::store::UpsertExternal {
6486 repo: "inactive/repo",
6487 ext_id: "20",
6488 title: "Manual block",
6489 body: "",
6490 author: "",
6491 url: "",
6492 labels: &[],
6493 origin: "github",
6494 })
6495 .await
6496 .unwrap();
6497 store
6498 .update_status(id, crate::store::TaskStatus::Blocked)
6499 .await
6500 .unwrap();
6501 store
6502 .set_fields(
6503 id,
6504 &[("block_reason", serde_json::json!("waiting for human input"))],
6505 )
6506 .await
6507 .unwrap();
6508
6509 auto_unblock_ci_failure_blocked_tasks_global(
6510 &task_manager,
6511 &store,
6512 &Arc::new(DashSet::new()),
6513 )
6514 .await
6515 .unwrap();
6516
6517 let task = store.get(id).await.unwrap();
6519 assert_eq!(task.status, crate::store::TaskStatus::Blocked);
6520 }
6521
6522 #[serial(cooldown_state)]
6525 #[tokio::test]
6526 async fn global_rebroadcast_sweep_returns_ok_with_no_blocked_tasks() {
6527 crate::engine::cooldown::reset_global_state().await;
6528 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6529 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
6530 let task_manager = Arc::new(TaskManager::with_store(
6531 backend,
6532 store.clone(),
6533 "owner/repo".to_string(),
6534 ));
6535 let router = router_with_healthy_review_agent();
6536 auto_recover_rebroadcast_blocked_tasks_global(&router, &task_manager, &store)
6537 .await
6538 .unwrap();
6539 }
6540
6541 #[serial(cooldown_state)]
6542 #[tokio::test]
6543 async fn global_rebroadcast_sweep_ignores_non_rebroadcast_blocks() {
6544 crate::engine::cooldown::reset_global_state().await;
6545 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6546 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
6547 let task_manager = Arc::new(TaskManager::with_store(
6548 backend,
6549 store.clone(),
6550 "active/repo".to_string(),
6551 ));
6552 let router = router_with_healthy_review_agent();
6553
6554 let id = store
6555 .upsert_external(&crate::store::UpsertExternal {
6556 repo: "inactive/repo",
6557 ext_id: "30",
6558 title: "Manual block",
6559 body: "",
6560 author: "",
6561 url: "",
6562 labels: &[],
6563 origin: "github",
6564 })
6565 .await
6566 .unwrap();
6567 store
6568 .update_status(id, crate::store::TaskStatus::Blocked)
6569 .await
6570 .unwrap();
6571 store
6572 .set_fields(
6573 id,
6574 &[("block_reason", serde_json::json!("waiting for human input"))],
6575 )
6576 .await
6577 .unwrap();
6578 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
6579 .bind(id)
6580 .execute(store.pool())
6581 .await
6582 .unwrap();
6583
6584 auto_recover_rebroadcast_blocked_tasks_global(&router, &task_manager, &store)
6585 .await
6586 .unwrap();
6587
6588 let task = store.get(id).await.unwrap();
6589 assert_eq!(task.status, crate::store::TaskStatus::Blocked);
6590 assert_eq!(
6591 task.block_reason.as_deref(),
6592 Some("waiting for human input")
6593 );
6594 }
6595
6596 #[serial(cooldown_state)]
6597 #[tokio::test]
6598 async fn global_rebroadcast_sweep_skips_freshly_blocked_tasks() {
6599 crate::engine::cooldown::reset_global_state().await;
6600 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6601 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
6602 let task_manager = Arc::new(TaskManager::with_store(
6603 backend,
6604 store.clone(),
6605 "active/repo".to_string(),
6606 ));
6607 let router = router_with_healthy_review_agent();
6608
6609 let id = store
6610 .upsert_external(&crate::store::UpsertExternal {
6611 repo: "inactive/repo",
6612 ext_id: "40",
6613 title: "Just escalated",
6614 body: "",
6615 author: "",
6616 url: "",
6617 labels: &[],
6618 origin: "github",
6619 })
6620 .await
6621 .unwrap();
6622 store
6623 .update_status(id, crate::store::TaskStatus::Blocked)
6624 .await
6625 .unwrap();
6626 store
6627 .set_fields(
6628 id,
6629 &[("block_reason", serde_json::json!(REBROADCAST_BLOCK_REASON))],
6630 )
6631 .await
6632 .unwrap();
6633 auto_recover_rebroadcast_blocked_tasks_global(&router, &task_manager, &store)
6637 .await
6638 .unwrap();
6639
6640 let task = store.get(id).await.unwrap();
6641 assert_eq!(task.status, crate::store::TaskStatus::Blocked);
6642 }
6643
6644 #[serial(cooldown_state)]
6651 #[tokio::test]
6652 async fn global_rebroadcast_sweep_recovers_task_from_inactive_repo() {
6653 crate::engine::cooldown::reset_global_state().await;
6654 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6655 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
6656 let task_manager = Arc::new(TaskManager::with_store(
6657 backend,
6658 store.clone(),
6659 "active/repo".to_string(),
6660 ));
6661 let router = router_with_healthy_review_agent();
6662
6663 let id = store
6664 .upsert_external(&crate::store::UpsertExternal {
6665 repo: "inactive/repo",
6666 ext_id: "458",
6667 title: "Rebroadcast blocked from inactive repo",
6668 body: "",
6669 author: "",
6670 url: "",
6671 labels: &[],
6672 origin: "github",
6673 })
6674 .await
6675 .unwrap();
6676 store
6677 .update_status(id, crate::store::TaskStatus::Blocked)
6678 .await
6679 .unwrap();
6680 store
6681 .set_fields(
6682 id,
6683 &[
6684 ("block_reason", serde_json::json!(REBROADCAST_BLOCK_REASON)),
6685 ("needs_review_refires", serde_json::json!(6)),
6686 ],
6687 )
6688 .await
6689 .unwrap();
6690 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
6691 .bind(id)
6692 .execute(store.pool())
6693 .await
6694 .unwrap();
6695
6696 auto_recover_rebroadcast_blocked_tasks_global(&router, &task_manager, &store)
6697 .await
6698 .unwrap();
6699
6700 let task = store.get(id).await.unwrap();
6701 assert_eq!(
6702 task.status,
6703 crate::store::TaskStatus::NeedsReview,
6704 "cross-repo rebroadcast-blocked task must be recovered to needs_review"
6705 );
6706 assert!(
6707 task.block_reason.is_none(),
6708 "block_reason must be cleared on recovery"
6709 );
6710 assert_eq!(
6711 task.needs_review_refires, 0,
6712 "needs_review_refires must be reset to 0 on recovery"
6713 );
6714 }
6715
6716 #[serial(cooldown_state)]
6720 #[tokio::test]
6721 async fn global_rebroadcast_sweep_skips_reset_without_routable_review_agent() {
6722 crate::engine::cooldown::reset_global_state().await;
6723 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6724 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
6725 let task_manager = Arc::new(TaskManager::with_store(
6726 backend,
6727 store.clone(),
6728 "active/repo".to_string(),
6729 ));
6730 let router = crate::engine::router::Router::new_for_test(
6733 crate::engine::router::RouterConfig::default(),
6734 vec!["claude".to_string()],
6735 );
6736
6737 let id = store
6738 .upsert_external(&crate::store::UpsertExternal {
6739 repo: "inactive/repo",
6740 ext_id: "459",
6741 title: "Rebroadcast blocked, no routable agents",
6742 body: "",
6743 author: "",
6744 url: "",
6745 labels: &[],
6746 origin: "github",
6747 })
6748 .await
6749 .unwrap();
6750 store
6751 .update_status(id, crate::store::TaskStatus::Blocked)
6752 .await
6753 .unwrap();
6754 store
6755 .set_fields(
6756 id,
6757 &[("block_reason", serde_json::json!(REBROADCAST_BLOCK_REASON))],
6758 )
6759 .await
6760 .unwrap();
6761 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
6762 .bind(id)
6763 .execute(store.pool())
6764 .await
6765 .unwrap();
6766
6767 auto_recover_rebroadcast_blocked_tasks_global(&router, &task_manager, &store)
6768 .await
6769 .unwrap();
6770
6771 let task = store.get(id).await.unwrap();
6772 assert_eq!(task.status, crate::store::TaskStatus::Blocked);
6773 assert_eq!(task.block_reason.as_deref(), Some(REBROADCAST_BLOCK_REASON));
6774 }
6775}