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 Some(s) = status {
3335 id_status_pairs.push((store_id, *s));
3336 }
3337 if is_new {
3339 let status_label = task
3340 .labels
3341 .iter()
3342 .find(|l| l.starts_with("status:"))
3343 .cloned()
3344 .unwrap_or_default();
3345 accepted_by_title
3346 .entry(task.title.clone())
3347 .or_insert(DuplicateTarget {
3348 id: task.id.0.clone(),
3349 status_label,
3350 });
3351 if let Err(e) = backend.acknowledge_issue(&task.id).await {
3352 tracing::debug!(
3353 task_id = task.id.0,
3354 err = %e,
3355 "ingest: acknowledgment failed"
3356 );
3357 }
3358 let task_status = status.unwrap_or(Status::New);
3361 match backend.sync_to_project(&task.id, task_status).await {
3362 Ok(Some(node_id)) => {
3363 new_task_node_ids.push((store_id, node_id));
3364 }
3365 Ok(None) => {
3366 }
3368 Err(e) => {
3369 tracing::debug!(
3370 task_id = task.id.0,
3371 err = %e,
3372 "ingest: project board sync failed"
3373 );
3374 }
3375 }
3376 }
3377 }
3378 Err(e) => {
3379 tracing::debug!(task_id = task.id.0, ?e, "ingest: upsert failed");
3380 }
3381 }
3382 }
3383
3384 if !id_status_pairs.is_empty() {
3390 let all_ids: Vec<i64> = id_status_pairs.iter().map(|(id, _)| *id).collect();
3391 let existing_map = match store.get_batch(&all_ids).await {
3392 Ok(map) => map,
3393 Err(e) => {
3394 tracing::warn!(err = %e, task_count = all_ids.len(), "ingest: get_batch failed — skipping initial status sync for newly ingested tasks this tick");
3395 return Ok(());
3396 }
3397 };
3398 for (store_id, status) in id_status_pairs {
3399 if let Some(existing) = existing_map.get(&store_id) {
3400 if existing.status == TaskStatus::New {
3401 let db_status = status_to_task_status(status);
3402 if db_status != TaskStatus::New {
3403 if let Err(e) = store.update_status(store_id, db_status).await {
3404 tracing::debug!(?e, "ingest: status sync failed");
3405 }
3406 }
3407 }
3408 }
3409 }
3410 }
3411
3412 if !new_task_node_ids.is_empty() {
3415 let node_ids: Vec<String> = new_task_node_ids.iter().map(|(_, n)| n.clone()).collect();
3416 match backend.get_project_item_estimates(&node_ids).await {
3417 Ok(estimates) => {
3418 for (store_id, node_id) in new_task_node_ids {
3419 if let Some(&estimate) = estimates.get(&node_id) {
3420 if estimate > 0 {
3421 if let Err(e) = store.set_estimate_if_zero(store_id, estimate).await {
3422 tracing::debug!(
3423 store_id,
3424 estimate,
3425 err = %e,
3426 "ingest: failed to set estimate from project"
3427 );
3428 }
3429 }
3430 }
3431 }
3432 }
3433 Err(e) => {
3434 tracing::debug!(
3435 err = %e,
3436 "ingest: project estimate fetch failed — skipping estimate sync this tick"
3437 );
3438 }
3439 }
3440 }
3441
3442 if let Some(latest) = all_tasks.iter().map(|(t, _)| t.updated_at.as_str()).max() {
3454 if let Err(e) = store.kv_set(&kv_key, latest).await {
3455 tracing::error!(key = kv_key, err = %e, "ingest: failed to record last ingest time");
3456 }
3457 }
3458
3459 Ok(())
3460}
3461
3462const STARTUP_RESCAN_SENTINEL: &str = "__startup_full_rescan__";
3466
3467pub(crate) async fn clear_issues_last_ingested(store: &crate::store::TaskStore, repo: &str) {
3477 let kv_key = format!("issues_last_ingested:{repo}");
3478 match store.kv_set(&kv_key, STARTUP_RESCAN_SENTINEL).await {
3479 Ok(_) => tracing::info!(
3480 repo,
3481 "marked issues_last_ingested for unbounded full re-scan on startup"
3482 ),
3483 Err(e) => {
3484 tracing::warn!(repo, err = %e, "failed to mark issues_last_ingested for startup rescan")
3485 }
3486 }
3487}
3488
3489#[cfg(test)]
3490mod tests {
3491 use super::*;
3492 use crate::backends::{ExternalId, ExternalTask, Mention, Status};
3493 use crate::engine::cooldown::set_agent_cooldown;
3494 use crate::store::{RunTokenUsage, TaskStore};
3495 use async_trait::async_trait;
3496 use serial_test::serial;
3497 use std::sync::Arc;
3498
3499 struct IngestMockBackend {
3503 tasks: Vec<(String, Vec<ExternalTask>)>,
3505 dedup_result: bool,
3506 comments: tokio::sync::Mutex<Vec<(String, String)>>,
3507 status_updates: tokio::sync::Mutex<Vec<(String, Status)>>,
3508 project_syncs: tokio::sync::Mutex<Vec<(String, Status)>>,
3509 project_reconciliations: std::sync::atomic::AtomicUsize,
3510 since_calls: tokio::sync::Mutex<Vec<Option<String>>>,
3513 }
3514
3515 impl IngestMockBackend {
3516 fn with_tasks(tasks: Vec<(Status, ExternalTask)>) -> Arc<Self> {
3517 Self::with_tasks_and_dedup(tasks, false)
3518 }
3519
3520 fn with_tasks_and_dedup(
3521 tasks: Vec<(Status, ExternalTask)>,
3522 dedup_result: bool,
3523 ) -> Arc<Self> {
3524 let mut grouped: Vec<(String, Vec<ExternalTask>)> = Vec::new();
3525 for (status, task) in tasks {
3526 let label = status.as_label().to_string();
3527 if let Some(entry) = grouped.iter_mut().find(|(l, _)| l == &label) {
3528 entry.1.push(task);
3529 } else {
3530 grouped.push((label, vec![task]));
3531 }
3532 }
3533 Arc::new(Self {
3534 tasks: grouped,
3535 dedup_result,
3536 comments: tokio::sync::Mutex::new(Vec::new()),
3537 status_updates: tokio::sync::Mutex::new(Vec::new()),
3538 project_syncs: tokio::sync::Mutex::new(Vec::new()),
3539 project_reconciliations: std::sync::atomic::AtomicUsize::new(0),
3540 since_calls: tokio::sync::Mutex::new(Vec::new()),
3541 })
3542 }
3543 }
3544
3545 #[async_trait]
3546 impl ExternalBackend for IngestMockBackend {
3547 fn name(&self) -> &str {
3548 "ingest-mock"
3549 }
3550 async fn create_task(&self, _: &str, _: &str, _: &[String]) -> anyhow::Result<ExternalId> {
3551 Ok(ExternalId("new".into()))
3552 }
3553 async fn get_task(&self, _: &ExternalId) -> anyhow::Result<ExternalTask> {
3554 anyhow::bail!("not implemented")
3555 }
3556 async fn list_by_status(&self, status: Status) -> anyhow::Result<Vec<ExternalTask>> {
3557 let label = status.as_label().to_string();
3558 Ok(self
3559 .tasks
3560 .iter()
3561 .find(|(l, _)| l == &label)
3562 .map(|(_, t)| t.clone())
3563 .unwrap_or_default())
3564 }
3565 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
3566 Ok(vec![])
3567 }
3568 async fn list_active_open_issues(
3569 &self,
3570 since: Option<&str>,
3571 ) -> anyhow::Result<Vec<(ExternalTask, Option<Status>)>> {
3572 self.since_calls
3573 .lock()
3574 .await
3575 .push(since.map(|s| s.to_string()));
3576 let mut result = Vec::new();
3577 for (label, tasks) in &self.tasks {
3578 let status = match label.as_str() {
3579 "status:new" => None,
3580 "status:routed" => Some(Status::Routed),
3581 "status:in_progress" => Some(Status::InProgress),
3582 "status:needs_review" => Some(Status::NeedsReview),
3583 "status:in_review" => Some(Status::InReview),
3584 "status:blocked" => Some(Status::Blocked),
3585 _ => None,
3586 };
3587 for task in tasks {
3588 result.push((task.clone(), status));
3589 }
3590 }
3591 Ok(result)
3592 }
3593 async fn post_comment(&self, id: &ExternalId, body: &str) -> anyhow::Result<()> {
3594 self.comments
3595 .lock()
3596 .await
3597 .push((id.0.clone(), body.to_string()));
3598 Ok(())
3599 }
3600 async fn set_labels(&self, _: &ExternalId, _: &[String]) -> anyhow::Result<()> {
3601 Ok(())
3602 }
3603 async fn remove_label(&self, _: &ExternalId, _: &str) -> anyhow::Result<()> {
3604 Ok(())
3605 }
3606 async fn get_sub_issues(&self, _: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
3607 Ok(vec![])
3608 }
3609 async fn health_check(&self) -> anyhow::Result<()> {
3610 Ok(())
3611 }
3612 async fn get_mentions(&self, _: &str) -> anyhow::Result<Vec<Mention>> {
3613 Ok(vec![])
3614 }
3615 async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
3616 self.status_updates
3617 .lock()
3618 .await
3619 .push((id.0.clone(), status));
3620 Ok(())
3621 }
3622
3623 async fn has_open_issue_with_title(&self, _: &str, _: &str) -> anyhow::Result<bool> {
3624 Ok(self.dedup_result)
3625 }
3626
3627 async fn sync_to_project(
3628 &self,
3629 id: &ExternalId,
3630 status: Status,
3631 ) -> anyhow::Result<Option<String>> {
3632 self.project_syncs.lock().await.push((id.0.clone(), status));
3633 Ok(None) }
3635
3636 async fn reconcile_project_statuses(&self) -> anyhow::Result<()> {
3637 self.project_reconciliations
3638 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3639 Ok(())
3640 }
3641 }
3642
3643 fn make_ext_task(id: &str, title: &str) -> ExternalTask {
3644 ExternalTask {
3645 id: ExternalId(id.to_string()),
3646 title: title.to_string(),
3647 body: "".to_string(),
3648 state: "open".to_string(),
3649 labels: vec![],
3650 author: "user".to_string(),
3651 created_at: "2026-01-01T00:00:00Z".to_string(),
3652 updated_at: "2026-01-01T00:00:00Z".to_string(),
3653 url: format!("https://github.com/owner/repo/issues/{id}"),
3654 }
3655 }
3656
3657 #[serial(cooldown_state)]
3658 #[tokio::test]
3659 async fn ingest_upserts_tasks_into_store() {
3660 crate::engine::cooldown::reset_global_state().await;
3661 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![
3662 (Status::New, make_ext_task("1", "First issue")),
3663 (Status::InProgress, make_ext_task("2", "Second issue")),
3664 ]);
3665 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3666
3667 ingest_external_tasks(&backend, "owner/repo", &store)
3668 .await
3669 .unwrap();
3670
3671 let all = store.list_all("owner/repo").await.unwrap();
3673 assert_eq!(all.len(), 2);
3674
3675 let task1 = store
3676 .get_by_external_id("owner/repo", "1")
3677 .await
3678 .unwrap()
3679 .unwrap();
3680 assert_eq!(task1.title, "First issue");
3681 assert_eq!(task1.status, TaskStatus::New);
3682
3683 let task2 = store
3684 .get_by_external_id("owner/repo", "2")
3685 .await
3686 .unwrap()
3687 .unwrap();
3688 assert_eq!(task2.title, "Second issue");
3689 assert_eq!(task2.status, crate::store::TaskStatus::InProgress);
3690 }
3691
3692 #[serial(cooldown_state)]
3693 #[tokio::test]
3694 async fn ingest_advances_cursor_to_max_observed_updated_at_not_wall_clock() {
3695 crate::engine::cooldown::reset_global_state().await;
3701 let mut task = make_ext_task("1", "First issue");
3702 task.updated_at = "2026-01-01T00:00:00Z".to_string();
3703 let backend: Arc<dyn ExternalBackend> =
3704 IngestMockBackend::with_tasks(vec![(Status::New, task)]);
3705 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3706
3707 ingest_external_tasks(&backend, "owner/repo", &store)
3708 .await
3709 .unwrap();
3710
3711 let cursor = store
3712 .kv_get("issues_last_ingested:owner/repo")
3713 .await
3714 .unwrap();
3715 assert_eq!(
3716 cursor,
3717 Some("2026-01-01T00:00:00Z".to_string()),
3718 "cursor must match the max updated_at observed in the fetch, not wall-clock now()"
3719 );
3720 }
3721
3722 #[serial(cooldown_state)]
3723 #[tokio::test]
3724 async fn ingest_after_startup_rescan_marker_fetches_unbounded() {
3725 crate::engine::cooldown::reset_global_state().await;
3730 let backend = IngestMockBackend::with_tasks(vec![(Status::New, make_ext_task("1", "x"))]);
3731 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3732
3733 clear_issues_last_ingested(&store, "owner/repo").await;
3734
3735 let backend_dyn: Arc<dyn ExternalBackend> = backend.clone();
3736 ingest_external_tasks(&backend_dyn, "owner/repo", &store)
3737 .await
3738 .unwrap();
3739
3740 let calls = backend.since_calls.lock().await;
3741 assert_eq!(
3742 calls.as_slice(),
3743 [None],
3744 "startup rescan marker must produce an unbounded fetch (since = None)"
3745 );
3746 }
3747
3748 #[serial(cooldown_state)]
3749 #[tokio::test]
3750 async fn ingest_with_missing_cursor_uses_24h_fallback() {
3751 crate::engine::cooldown::reset_global_state().await;
3754 let backend = IngestMockBackend::with_tasks(vec![(Status::New, make_ext_task("1", "x"))]);
3755 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3756
3757 let backend_dyn: Arc<dyn ExternalBackend> = backend.clone();
3758 ingest_external_tasks(&backend_dyn, "owner/repo", &store)
3759 .await
3760 .unwrap();
3761
3762 let calls = backend.since_calls.lock().await;
3763 assert_eq!(calls.len(), 1);
3764 assert!(
3765 calls[0].is_some(),
3766 "missing cursor should fall back to a bounded 24h window, not an unbounded fetch"
3767 );
3768 }
3769
3770 #[serial(cooldown_state)]
3771 #[tokio::test]
3772 async fn ingest_updates_existing_tasks() {
3773 crate::engine::cooldown::reset_global_state().await;
3774 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![(
3775 Status::Routed,
3776 make_ext_task("42", "Updated title"),
3777 )]);
3778 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3779
3780 store
3782 .create(&crate::store::NewTask {
3783 external_id: Some("42".to_string()),
3784 repo: "owner/repo".to_string(),
3785 origin: "github".to_string(),
3786 title: "Original title".to_string(),
3787 ..Default::default()
3788 })
3789 .await
3790 .unwrap();
3791
3792 ingest_external_tasks(&backend, "owner/repo", &store)
3793 .await
3794 .unwrap();
3795
3796 let task = store
3798 .get_by_external_id("owner/repo", "42")
3799 .await
3800 .unwrap()
3801 .unwrap();
3802 assert_eq!(task.title, "Updated title");
3803 assert_eq!(task.status, crate::store::TaskStatus::Routed);
3804 }
3805
3806 #[serial(cooldown_state)]
3807 #[tokio::test]
3808 async fn ingest_closes_duplicate_titles() {
3809 crate::engine::cooldown::reset_global_state().await;
3810 use crate::store::{NewTask, TaskStore};
3811
3812 let backend = IngestMockBackend::with_tasks_and_dedup(
3813 vec![(Status::New, make_ext_task("2", "Duplicate issue"))],
3814 true,
3815 );
3816 let backend_dyn: Arc<dyn ExternalBackend> = backend.clone();
3817 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3818
3819 store
3820 .create(&NewTask {
3821 external_id: Some("1".to_string()),
3822 repo: "owner/repo".to_string(),
3823 origin: "github".to_string(),
3824 title: "Duplicate issue".to_string(),
3825 labels: vec!["status:new".to_string()],
3826 ..Default::default()
3827 })
3828 .await
3829 .unwrap();
3830
3831 ingest_external_tasks(&backend_dyn, "owner/repo", &store)
3832 .await
3833 .unwrap();
3834
3835 let all = store.list_all("owner/repo").await.unwrap();
3836 assert_eq!(all.len(), 1);
3837
3838 let comments = backend.comments.lock().await;
3839 assert_eq!(comments.len(), 1);
3840 assert!(comments[0].1.contains("Duplicate of #1"));
3841
3842 let status_updates = backend.status_updates.lock().await;
3843 assert_eq!(status_updates.len(), 1);
3844 assert_eq!(status_updates[0].0, "2");
3845 assert_eq!(status_updates[0].1, Status::Done);
3846 }
3847
3848 #[serial(cooldown_state)]
3849 #[tokio::test]
3850 async fn ingest_handles_empty_backend() {
3851 crate::engine::cooldown::reset_global_state().await;
3852 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
3853 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3854
3855 let result = ingest_external_tasks(&backend, "owner/repo", &store).await;
3857 assert!(result.is_ok());
3858
3859 let all = store.list_all("owner/repo").await.unwrap();
3860 assert!(all.is_empty());
3861 }
3862
3863 #[serial(cooldown_state)]
3864 #[tokio::test]
3865 async fn ingest_syncs_status_correctly_across_statuses() {
3866 crate::engine::cooldown::reset_global_state().await;
3867 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![
3868 (Status::New, make_ext_task("1", "New")),
3869 (Status::Routed, make_ext_task("2", "Routed")),
3870 (Status::InProgress, make_ext_task("3", "InProgress")),
3871 (Status::NeedsReview, make_ext_task("4", "NeedsReview")),
3872 (Status::InReview, make_ext_task("5", "InReview")),
3873 (Status::Blocked, make_ext_task("6", "Blocked")),
3874 ]);
3875 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3876
3877 ingest_external_tasks(&backend, "owner/repo", &store)
3878 .await
3879 .unwrap();
3880
3881 let counts = store.status_counts("owner/repo").await.unwrap();
3882 assert_eq!(counts.get("new"), Some(&1));
3883 assert_eq!(counts.get("routed"), Some(&1));
3884 assert_eq!(counts.get("in_progress"), Some(&1));
3885 assert_eq!(counts.get("needs_review"), Some(&1));
3886 assert_eq!(counts.get("in_review"), Some(&1));
3887 assert_eq!(counts.get("blocked"), Some(&1));
3888 }
3889
3890 #[serial(cooldown_state)]
3891 #[tokio::test]
3892 async fn ingest_does_not_overwrite_store_authoritative_status() {
3893 crate::engine::cooldown::reset_global_state().await;
3894 let backend: Arc<dyn ExternalBackend> =
3896 IngestMockBackend::with_tasks(vec![(Status::New, make_ext_task("99", "My task"))]);
3897 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3898
3899 let id = store
3901 .create(&crate::store::NewTask {
3902 external_id: Some("99".to_string()),
3903 repo: "owner/repo".to_string(),
3904 origin: "github".to_string(),
3905 title: "My task".to_string(),
3906 ..Default::default()
3907 })
3908 .await
3909 .unwrap();
3910 store
3911 .update_status(id, crate::store::TaskStatus::Routed)
3912 .await
3913 .unwrap();
3914
3915 ingest_external_tasks(&backend, "owner/repo", &store)
3917 .await
3918 .unwrap();
3919
3920 let task = store.get(id).await.unwrap();
3921 assert_eq!(
3922 task.status,
3923 crate::store::TaskStatus::Routed,
3924 "ingest must not overwrite store-authoritative status"
3925 );
3926 }
3927
3928 #[serial(cooldown_state)]
3929 #[tokio::test]
3930 async fn ingest_sync_to_project_called_only_for_new_tasks() {
3931 crate::engine::cooldown::reset_global_state().await;
3932 use crate::store::{NewTask, TaskStore};
3933
3934 let backend = IngestMockBackend::with_tasks(vec![
3936 (Status::New, make_ext_task("10", "Existing task")),
3937 (Status::New, make_ext_task("11", "New task")),
3938 ]);
3939 let backend_dyn: Arc<dyn ExternalBackend> = backend.clone();
3940 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3941
3942 store
3944 .create(&NewTask {
3945 external_id: Some("10".to_string()),
3946 repo: "owner/repo".to_string(),
3947 origin: "github".to_string(),
3948 title: "Existing task".to_string(),
3949 ..Default::default()
3950 })
3951 .await
3952 .unwrap();
3953
3954 ingest_external_tasks(&backend_dyn, "owner/repo", &store)
3955 .await
3956 .unwrap();
3957
3958 let syncs = backend.project_syncs.lock().await;
3959 assert_eq!(
3961 syncs.len(),
3962 1,
3963 "sync_to_project must be called exactly once (for new task only), got: {syncs:?}"
3964 );
3965 assert_eq!(syncs[0].0, "11", "sync_to_project must target the new task");
3966 }
3967
3968 #[serial(cooldown_state)]
3969 #[tokio::test]
3970 async fn ingest_handles_mixed_labeled_and_unlabeled_open_issues() {
3971 crate::engine::cooldown::reset_global_state().await;
3972 struct MixedBackend;
3976
3977 #[async_trait]
3978 impl ExternalBackend for MixedBackend {
3979 fn name(&self) -> &str {
3980 "mixed"
3981 }
3982 async fn create_task(
3983 &self,
3984 _: &str,
3985 _: &str,
3986 _: &[String],
3987 ) -> anyhow::Result<ExternalId> {
3988 Ok(ExternalId("x".into()))
3989 }
3990 async fn get_task(&self, _: &ExternalId) -> anyhow::Result<ExternalTask> {
3991 anyhow::bail!("not implemented")
3992 }
3993 async fn list_by_status(&self, _: Status) -> anyhow::Result<Vec<ExternalTask>> {
3994 Ok(vec![])
3995 }
3996 async fn list_active_open_issues(
3998 &self,
3999 _: Option<&str>,
4000 ) -> anyhow::Result<Vec<(ExternalTask, Option<Status>)>> {
4001 Ok(vec![
4002 (make_ext_task("100", "Unlabeled routable"), None),
4003 (
4004 make_ext_task("101", "In progress issue"),
4005 Some(Status::InProgress),
4006 ),
4007 ])
4008 }
4009 async fn post_comment(&self, _: &ExternalId, _: &str) -> anyhow::Result<()> {
4010 Ok(())
4011 }
4012 async fn set_labels(&self, _: &ExternalId, _: &[String]) -> anyhow::Result<()> {
4013 Ok(())
4014 }
4015 async fn remove_label(&self, _: &ExternalId, _: &str) -> anyhow::Result<()> {
4016 Ok(())
4017 }
4018 async fn get_sub_issues(&self, _: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
4019 Ok(vec![])
4020 }
4021 async fn health_check(&self) -> anyhow::Result<()> {
4022 Ok(())
4023 }
4024 async fn get_mentions(&self, _: &str) -> anyhow::Result<Vec<Mention>> {
4025 Ok(vec![])
4026 }
4027 async fn update_status(&self, _: &ExternalId, _: Status) -> anyhow::Result<()> {
4028 Ok(())
4029 }
4030 async fn has_open_issue_with_title(&self, _: &str, _: &str) -> anyhow::Result<bool> {
4031 Ok(false)
4032 }
4033 async fn sync_to_project(
4034 &self,
4035 _: &ExternalId,
4036 _: Status,
4037 ) -> anyhow::Result<Option<String>> {
4038 Ok(None)
4039 }
4040 }
4041
4042 let backend: Arc<dyn ExternalBackend> = Arc::new(MixedBackend);
4043 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4044
4045 ingest_external_tasks(&backend, "owner/repo", &store)
4046 .await
4047 .unwrap();
4048
4049 let all = store.list_all("owner/repo").await.unwrap();
4050 assert_eq!(all.len(), 2, "both issues must be ingested");
4051
4052 let unlabeled = store
4053 .get_by_external_id("owner/repo", "100")
4054 .await
4055 .unwrap()
4056 .unwrap();
4057 assert_eq!(
4058 unlabeled.status,
4059 crate::store::TaskStatus::New,
4060 "unlabeled issue should start as New"
4061 );
4062
4063 let in_progress = store
4064 .get_by_external_id("owner/repo", "101")
4065 .await
4066 .unwrap()
4067 .unwrap();
4068 assert_eq!(
4069 in_progress.status,
4070 crate::store::TaskStatus::InProgress,
4071 "labeled in_progress issue must be synced to InProgress"
4072 );
4073 }
4074
4075 #[serial(cooldown_state)]
4078 #[tokio::test]
4079 async fn kv_get_prefer_store_reads_from_store() {
4080 crate::engine::cooldown::reset_global_state().await;
4081 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4082
4083 store.kv_set("k1", "store_val").await.unwrap();
4084
4085 let opt = Some(&store);
4086 let val = kv_get_prefer_store(&opt, "k1").await;
4087 assert_eq!(val.as_deref(), Some("store_val"));
4088 }
4089
4090 #[serial(cooldown_state)]
4091 #[tokio::test]
4092 async fn kv_get_prefer_store_returns_none_without_store() {
4093 crate::engine::cooldown::reset_global_state().await;
4094 let opt: Option<&Arc<TaskStore>> = None;
4095 let val = kv_get_prefer_store(&opt, "k2").await;
4096 assert_eq!(val, None);
4097 }
4098
4099 #[serial(cooldown_state)]
4100 #[tokio::test]
4101 async fn kv_set_prefer_store_writes_to_store() {
4102 crate::engine::cooldown::reset_global_state().await;
4103 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4104
4105 let opt = Some(&store);
4106 kv_set_prefer_store(&opt, "k3", "val3").await;
4107
4108 assert_eq!(store.kv_get("k3").await.unwrap().as_deref(), Some("val3"));
4109 }
4110
4111 #[serial(cooldown_state)]
4112 #[tokio::test]
4113 async fn kv_set_prefer_store_noop_without_store() {
4114 crate::engine::cooldown::reset_global_state().await;
4115 let opt: Option<&Arc<TaskStore>> = None;
4116 kv_set_prefer_store(&opt, "k4", "val4").await;
4118 }
4119
4120 #[serial(cooldown_state)]
4123 #[tokio::test]
4124 async fn try_kv_get_prefer_store_returns_value_from_store() {
4125 crate::engine::cooldown::reset_global_state().await;
4126 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4127 store.kv_set("try_k1", "try_val1").await.unwrap();
4128
4129 let opt = Some(&store);
4130 let result = try_kv_get_prefer_store(&opt, "try_k1").await;
4131 assert_eq!(result.unwrap().as_deref(), Some("try_val1"));
4132 }
4133
4134 #[serial(cooldown_state)]
4135 #[tokio::test]
4136 async fn try_kv_get_prefer_store_returns_none_for_missing_key() {
4137 crate::engine::cooldown::reset_global_state().await;
4138 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4139 let opt = Some(&store);
4140
4141 let result = try_kv_get_prefer_store(&opt, "nonexistent_key").await;
4142 assert_eq!(result.unwrap(), None); }
4144
4145 #[serial(cooldown_state)]
4146 #[tokio::test]
4147 async fn try_kv_get_prefer_store_returns_none_without_store() {
4148 crate::engine::cooldown::reset_global_state().await;
4149 let opt: Option<&Arc<TaskStore>> = None;
4150 let result = try_kv_get_prefer_store(&opt, "try_k2").await;
4151 assert_eq!(result.unwrap(), None);
4152 }
4153
4154 #[serial(cooldown_state)]
4155 #[tokio::test]
4156 async fn try_kv_set_prefer_store_writes_to_store() {
4157 crate::engine::cooldown::reset_global_state().await;
4158 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4159 let opt = Some(&store);
4160
4161 let result = try_kv_set_prefer_store(&opt, "try_k3", "try_val3").await;
4162 assert!(result.is_ok());
4163 assert_eq!(
4164 store.kv_get("try_k3").await.unwrap().as_deref(),
4165 Some("try_val3")
4166 );
4167 }
4168
4169 #[serial(cooldown_state)]
4170 #[tokio::test]
4171 async fn try_kv_set_prefer_store_ok_without_store() {
4172 crate::engine::cooldown::reset_global_state().await;
4173 let opt: Option<&Arc<TaskStore>> = None;
4174 let result = try_kv_set_prefer_store(&opt, "try_k4", "try_val4").await;
4176 assert!(result.is_ok());
4177 }
4178
4179 #[serial(cooldown_state)]
4182 #[test]
4183 fn dispatching_set_blocks_duplicate_processing() {
4184 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4185
4186 let repo = "owner/repo";
4187 let task_id = "42";
4188 let dispatch_key = format!("{}/{}", repo, task_id);
4189
4190 assert!(!dispatching.contains_key(&dispatch_key));
4192
4193 dispatching.insert(dispatch_key.clone(), task_id.to_string());
4195
4196 assert!(
4198 dispatching.contains_key(&dispatch_key),
4199 "task should be locked while dispatching"
4200 );
4201
4202 dispatching.remove(&dispatch_key);
4204
4205 assert!(
4207 !dispatching.contains_key(&dispatch_key),
4208 "task should be unlocked after review completes"
4209 );
4210 }
4211
4212 #[serial(cooldown_state)]
4213 #[test]
4214 fn dispatching_set_does_not_block_other_tasks() {
4215 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4216
4217 let repo = "owner/repo";
4218
4219 let key_42 = format!("{}/42", repo);
4221 dispatching.insert(key_42.clone(), "42".to_string());
4222
4223 let key_43 = format!("{}/43", repo);
4225 assert!(
4226 dispatching.contains_key(&key_42),
4227 "task 42 should be locked"
4228 );
4229 assert!(
4230 !dispatching.contains_key(&key_43),
4231 "task 43 should not be locked"
4232 );
4233 }
4234
4235 #[serial(cooldown_state)]
4248 #[tokio::test]
4249 async fn dispatch_key_held_during_review_agent_execution() {
4250 crate::engine::cooldown::reset_global_state().await;
4251 use tokio::sync::oneshot;
4252
4253 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4254
4255 let dispatch_key = "owner/repo/42".to_string();
4256
4257 dispatching.insert(dispatch_key.clone(), "42".to_string());
4259
4260 let (agent_started_tx, agent_started_rx) = oneshot::channel::<()>();
4261 let (check_done_tx, check_done_rx) = oneshot::channel::<()>();
4262
4263 let dispatching_agent = Arc::clone(&dispatching);
4264 let key_agent = dispatch_key.clone();
4265
4266 let review_task = tokio::spawn(async move {
4268 agent_started_tx.send(()).unwrap();
4269 check_done_rx.await.unwrap();
4271 dispatching_agent.remove(&key_agent);
4273 });
4274
4275 agent_started_rx.await.unwrap();
4278 assert!(
4279 dispatching.contains_key(&dispatch_key),
4280 "dispatch_key must be visible in the dispatching set while the review \
4281 agent is running — without the a6d8b9a fix, review_open_prs would see \
4282 the key absent and re-dispatch the task, silently dropping \
4283 CHANGES_REQUESTED feedback"
4284 );
4285
4286 check_done_tx.send(()).unwrap();
4288 review_task.await.unwrap();
4289
4290 assert!(
4292 !dispatching.contains_key(&dispatch_key),
4293 "dispatch_key must be removed from the dispatching set after the \
4294 review agent completes so subsequent sync ticks can process the task"
4295 );
4296 }
4297
4298 #[serial(cooldown_state)]
4311 #[tokio::test]
4312 async fn dispatch_guard_releases_key_on_panic() {
4313 crate::engine::cooldown::reset_global_state().await;
4314 use crate::engine::dispatch_guard::DispatchGuard;
4315
4316 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4317 let key = "owner/repo/42".to_string();
4318
4319 dispatching.insert(key.clone(), "42".to_string());
4323
4324 let guard = DispatchGuard::new(Arc::clone(&dispatching), key.clone());
4326
4327 let handle = tokio::spawn(async move {
4329 let _guard = guard; panic!("simulated panic inside review_and_merge");
4331 });
4332
4333 let _ = handle.await;
4335
4336 assert!(
4338 !dispatching.contains_key(&key),
4339 "dispatch key must be removed from the dispatching set even when the \
4340 spawned review task panics — without DispatchGuard the key leaks and \
4341 the task gets stuck in a permanent review loop"
4342 );
4343 }
4344
4345 #[serial(cooldown_state)]
4346 #[tokio::test]
4347 async fn sync_tick_reconciles_board_without_ingesting_or_transitioning_tasks() {
4348 crate::engine::cooldown::reset_global_state().await;
4349 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4350 let mock = IngestMockBackend::with_tasks(vec![]);
4351 let backend: Arc<dyn ExternalBackend> = mock.clone();
4352 let task_manager = Arc::new(TaskManager::with_store(
4353 backend.clone(),
4354 store.clone(),
4355 "owner/repo".to_string(),
4356 ));
4357 let dispatching = Arc::new(DashMap::new());
4358 sync_tick(
4359 &backend,
4360 &Arc::new(TmuxManager::new()),
4361 "owner/repo",
4362 &EngineConfig::default(),
4363 &Arc::new(RwLock::new(crate::engine::router::Router::from_config())),
4364 &task_manager,
4365 &store,
4366 &dispatching,
4367 &Arc::new(DashSet::new()),
4368 )
4369 .await
4370 .unwrap();
4371 assert_eq!(
4372 mock.project_reconciliations
4373 .load(std::sync::atomic::Ordering::Relaxed),
4374 1
4375 );
4376 assert!(mock.project_syncs.lock().await.is_empty());
4377 assert!(mock.status_updates.lock().await.is_empty());
4378 assert!(dispatching.is_empty());
4379 assert!(store
4380 .existing_external_ids("owner/repo")
4381 .await
4382 .unwrap()
4383 .is_empty());
4384 }
4385
4386 #[serial(cooldown_state)]
4389 #[tokio::test]
4390 async fn stale_in_review_recovery_resets_orphaned_task_without_session() {
4391 crate::engine::cooldown::reset_global_state().await;
4392 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4393 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
4394 let task_manager = Arc::new(TaskManager::with_store(
4395 backend.clone(),
4396 store.clone(),
4397 "owner/repo".to_string(),
4398 ));
4399 let tmux = Arc::new(TmuxManager::new());
4400 let router = Arc::new(RwLock::new(crate::engine::router::Router::from_config()));
4401 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4402 let auto_merge_in_flight: Arc<DashSet<String>> = Arc::new(DashSet::new());
4403
4404 let id = store
4405 .upsert_external(&crate::store::UpsertExternal {
4406 repo: "owner/repo",
4407 ext_id: "55",
4408 title: "Orphaned review",
4409 body: "",
4410 author: "",
4411 url: "",
4412 labels: &[],
4413 origin: "github",
4414 })
4415 .await
4416 .unwrap();
4417 store
4418 .update_status(id, crate::store::TaskStatus::InReview)
4419 .await
4420 .unwrap();
4421 store
4422 .set_fields(id, &[("branch", serde_json::json!("feature/55"))])
4423 .await
4424 .unwrap();
4425 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
4427 .bind(id)
4428 .execute(store.pool())
4429 .await
4430 .unwrap();
4431
4432 sync_tick(
4433 &backend,
4434 &tmux,
4435 "owner/repo",
4436 &EngineConfig::default(),
4437 &router,
4438 &task_manager,
4439 &store,
4440 &dispatching,
4441 &auto_merge_in_flight,
4442 )
4443 .await
4444 .unwrap();
4445
4446 let task = store.get(id).await.unwrap();
4448 assert_eq!(task.status, crate::store::TaskStatus::NeedsReview);
4449 }
4450
4451 #[serial(cooldown_state)]
4452 #[tokio::test]
4453 async fn needs_review_refire_increments_and_fires_when_old_enough() {
4454 crate::engine::cooldown::reset_global_state().await;
4455 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4456 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
4457 let task_manager = Arc::new(TaskManager::with_store(
4458 backend.clone(),
4459 store.clone(),
4460 "owner/repo".to_string(),
4461 ));
4462 let tmux = Arc::new(TmuxManager::new());
4463 let router = Arc::new(RwLock::new(crate::engine::router::Router::from_config()));
4464 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4465 let auto_merge_in_flight: Arc<DashSet<String>> = Arc::new(DashSet::new());
4466
4467 let id = store
4469 .upsert_external(&crate::store::UpsertExternal {
4470 repo: "owner/repo",
4471 ext_id: "201",
4472 title: "Needs review old",
4473 body: "",
4474 author: "",
4475 url: "",
4476 labels: &[],
4477 origin: "github",
4478 })
4479 .await
4480 .unwrap();
4481 store
4482 .update_status(id, crate::store::TaskStatus::NeedsReview)
4483 .await
4484 .unwrap();
4485 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
4487 .bind(id)
4488 .execute(store.pool())
4489 .await
4490 .unwrap();
4491
4492 let t = store.get(id).await.unwrap();
4494 assert_eq!(t.needs_review_refires, 0);
4495
4496 sync_tick(
4498 &backend,
4499 &tmux,
4500 "owner/repo",
4501 &EngineConfig::default(),
4502 &router,
4503 &task_manager,
4504 &store,
4505 &dispatching,
4506 &auto_merge_in_flight,
4507 )
4508 .await
4509 .unwrap();
4510
4511 let t2 = store.get(id).await.unwrap();
4512 assert_eq!(t2.needs_review_refires, 1);
4513 assert_eq!(t2.status, crate::store::TaskStatus::NeedsReview);
4514 }
4515
4516 #[serial(cooldown_state)]
4517 #[tokio::test]
4518 async fn needs_review_backoff_delays_fire_but_increments_counter() {
4519 crate::engine::cooldown::reset_global_state().await;
4520 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4521 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
4522 let task_manager = Arc::new(TaskManager::with_store(
4523 backend.clone(),
4524 store.clone(),
4525 "owner/repo".to_string(),
4526 ));
4527 let tmux = Arc::new(TmuxManager::new());
4528 let router = Arc::new(RwLock::new(crate::engine::router::Router::from_config()));
4529 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4530 let auto_merge_in_flight: Arc<DashSet<String>> = Arc::new(DashSet::new());
4531
4532 let id = store
4533 .upsert_external(&crate::store::UpsertExternal {
4534 repo: "owner/repo",
4535 ext_id: "202",
4536 title: "Needs review backoff",
4537 body: "",
4538 author: "",
4539 url: "",
4540 labels: &[],
4541 origin: "github",
4542 })
4543 .await
4544 .unwrap();
4545 store
4546 .update_status(id, crate::store::TaskStatus::NeedsReview)
4547 .await
4548 .unwrap();
4549
4550 store
4552 .set_fields(id, &[("needs_review_refires", serde_json::json!(1))])
4553 .await
4554 .unwrap();
4555
4556 let now_minus_1 = (chrono::Utc::now() - chrono::Duration::minutes(1))
4558 .format("%Y-%m-%dT%H:%M:%SZ")
4559 .to_string();
4560 sqlx::query("UPDATE tasks SET updated_at = ? WHERE id = ?")
4561 .bind(now_minus_1.clone())
4562 .bind(id)
4563 .execute(store.pool())
4564 .await
4565 .unwrap();
4566
4567 let before = store.get(id).await.unwrap();
4568 assert_eq!(before.needs_review_refires, 1);
4569
4570 sync_tick(
4571 &backend,
4572 &tmux,
4573 "owner/repo",
4574 &EngineConfig::default(),
4575 &router,
4576 &task_manager,
4577 &store,
4578 &dispatching,
4579 &auto_merge_in_flight,
4580 )
4581 .await
4582 .unwrap();
4583
4584 let after = store.get(id).await.unwrap();
4585 assert_eq!(after.needs_review_refires, 1);
4588 assert_eq!(after.updated_at, now_minus_1);
4589 assert_eq!(after.status, crate::store::TaskStatus::NeedsReview);
4590 }
4591
4592 #[serial(cooldown_state)]
4593 #[tokio::test]
4594 async fn needs_review_escalates_to_blocked_after_max_refires() {
4595 crate::engine::cooldown::reset_global_state().await;
4596 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4597 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
4598 let task_manager = Arc::new(TaskManager::with_store(
4599 backend.clone(),
4600 store.clone(),
4601 "owner/repo".to_string(),
4602 ));
4603 let tmux = Arc::new(TmuxManager::new());
4604 let router = Arc::new(RwLock::new(crate::engine::router::Router::from_config()));
4605 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4606 let auto_merge_in_flight: Arc<DashSet<String>> = Arc::new(DashSet::new());
4607
4608 let id = store
4609 .upsert_external(&crate::store::UpsertExternal {
4610 repo: "owner/repo",
4611 ext_id: "203",
4612 title: "Needs review escalate",
4613 body: "",
4614 author: "",
4615 url: "",
4616 labels: &[],
4617 origin: "github",
4618 })
4619 .await
4620 .unwrap();
4621 store
4622 .update_status(id, crate::store::TaskStatus::NeedsReview)
4623 .await
4624 .unwrap();
4625
4626 store
4630 .set_fields(id, &[("needs_review_refires", serde_json::json!(5))])
4631 .await
4632 .unwrap();
4633 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
4635 .bind(id)
4636 .execute(store.pool())
4637 .await
4638 .unwrap();
4639
4640 sync_tick(
4641 &backend,
4642 &tmux,
4643 "owner/repo",
4644 &EngineConfig::default(),
4645 &router,
4646 &task_manager,
4647 &store,
4648 &dispatching,
4649 &auto_merge_in_flight,
4650 )
4651 .await
4652 .unwrap();
4653
4654 let after = store.get(id).await.unwrap();
4655 assert_eq!(after.status, crate::store::TaskStatus::Blocked);
4656 assert!(after.block_reason.is_some());
4657 }
4658
4659 #[serial(cooldown_state)]
4663 #[tokio::test]
4664 async fn needs_review_allows_max_refires_before_escalation() {
4665 crate::engine::cooldown::reset_global_state().await;
4666 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4667 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
4668 let task_manager = Arc::new(TaskManager::with_store(
4669 backend.clone(),
4670 store.clone(),
4671 "owner/repo".to_string(),
4672 ));
4673 let tmux = Arc::new(TmuxManager::new());
4674 let router = Arc::new(RwLock::new(crate::engine::router::Router::from_config()));
4675 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4676 let auto_merge_in_flight: Arc<DashSet<String>> = Arc::new(DashSet::new());
4677
4678 let id = store
4679 .upsert_external(&crate::store::UpsertExternal {
4680 repo: "owner/repo",
4681 ext_id: "204",
4682 title: "Max refires test",
4683 body: "",
4684 author: "",
4685 url: "",
4686 labels: &[],
4687 origin: "github",
4688 })
4689 .await
4690 .unwrap();
4691 store
4692 .update_status(id, crate::store::TaskStatus::NeedsReview)
4693 .await
4694 .unwrap();
4695
4696 store
4699 .set_fields(id, &[("needs_review_refires", serde_json::json!(4))])
4700 .await
4701 .unwrap();
4702 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
4704 .bind(id)
4705 .execute(store.pool())
4706 .await
4707 .unwrap();
4708
4709 sync_tick(
4710 &backend,
4711 &tmux,
4712 "owner/repo",
4713 &EngineConfig::default(),
4714 &router,
4715 &task_manager,
4716 &store,
4717 &dispatching,
4718 &auto_merge_in_flight,
4719 )
4720 .await
4721 .unwrap();
4722
4723 let after = store.get(id).await.unwrap();
4724 assert_eq!(after.status, crate::store::TaskStatus::NeedsReview);
4726 assert_eq!(after.needs_review_refires, 5);
4727 assert!(after.block_reason.is_none());
4728 }
4729
4730 fn router_with_healthy_review_agent() -> crate::engine::router::Router {
4733 use crate::engine::router::{Router, RouterConfig};
4734 let mut config = RouterConfig::default();
4735 config
4736 .model_map
4737 .entry("review".to_string())
4738 .or_default()
4739 .insert("claude".to_string(), vec!["sonnet".to_string()]);
4740 let mut router = Router::new(config);
4741 router.available_agents = vec!["claude".to_string()];
4742 router
4743 }
4744
4745 #[serial(cooldown_state)]
4752 #[tokio::test]
4753 async fn global_refire_sweep_refires_stale_task_from_inactive_repo() {
4754 crate::engine::cooldown::reset_global_state().await;
4755 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4756 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
4757 let task_manager = Arc::new(TaskManager::with_store(
4759 backend,
4760 store.clone(),
4761 "active/repo".to_string(),
4762 ));
4763 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4764 let router = router_with_healthy_review_agent();
4765
4766 let id = store
4767 .upsert_external(&crate::store::UpsertExternal {
4768 repo: "inactive/repo",
4769 ext_id: "490",
4770 title: "Stale needs_review from inactive repo",
4771 body: "",
4772 author: "",
4773 url: "",
4774 labels: &[],
4775 origin: "github",
4776 })
4777 .await
4778 .unwrap();
4779 store
4780 .update_status(id, crate::store::TaskStatus::NeedsReview)
4781 .await
4782 .unwrap();
4783 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
4784 .bind(id)
4785 .execute(store.pool())
4786 .await
4787 .unwrap();
4788
4789 refire_and_escalate_stale_needs_review_global(&task_manager, &store, &router, &dispatching)
4790 .await
4791 .unwrap();
4792
4793 let task = store.get(id).await.unwrap();
4794 assert_eq!(
4795 task.needs_review_refires, 1,
4796 "cross-repo task must be refired"
4797 );
4798 assert_eq!(task.status, crate::store::TaskStatus::NeedsReview);
4799 }
4800
4801 #[serial(cooldown_state)]
4804 #[tokio::test]
4805 async fn global_refire_sweep_escalates_cross_repo_task_to_blocked() {
4806 crate::engine::cooldown::reset_global_state().await;
4807 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4808 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
4809 let task_manager = Arc::new(TaskManager::with_store(
4810 backend,
4811 store.clone(),
4812 "active/repo".to_string(),
4813 ));
4814 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4815 let router = router_with_healthy_review_agent();
4816
4817 let id = store
4818 .upsert_external(&crate::store::UpsertExternal {
4819 repo: "inactive/repo",
4820 ext_id: "493",
4821 title: "Exhausted refires from inactive repo",
4822 body: "",
4823 author: "",
4824 url: "",
4825 labels: &[],
4826 origin: "github",
4827 })
4828 .await
4829 .unwrap();
4830 store
4831 .update_status(id, crate::store::TaskStatus::NeedsReview)
4832 .await
4833 .unwrap();
4834 store
4835 .set_fields(id, &[("needs_review_refires", serde_json::json!(5))])
4836 .await
4837 .unwrap();
4838 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
4839 .bind(id)
4840 .execute(store.pool())
4841 .await
4842 .unwrap();
4843
4844 refire_and_escalate_stale_needs_review_global(&task_manager, &store, &router, &dispatching)
4845 .await
4846 .unwrap();
4847
4848 let task = store.get(id).await.unwrap();
4849 assert_eq!(task.status, crate::store::TaskStatus::Blocked);
4850 assert_eq!(
4851 task.block_reason.as_deref(),
4852 Some("review agent rebroadcast escalated after repeated retries")
4853 );
4854 }
4855
4856 #[serial(cooldown_state)]
4861 #[tokio::test]
4862 async fn global_refire_sweep_skips_when_all_review_agents_cooled() {
4863 crate::engine::cooldown::reset_global_state().await;
4864 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4865 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
4866 let task_manager = Arc::new(TaskManager::with_store(
4867 backend,
4868 store.clone(),
4869 "active/repo".to_string(),
4870 ));
4871 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4872 let router = crate::engine::router::Router::new_for_test(
4875 crate::engine::router::RouterConfig::default(),
4876 vec!["claude".to_string()],
4877 );
4878
4879 let id = store
4880 .upsert_external(&crate::store::UpsertExternal {
4881 repo: "inactive/repo",
4882 ext_id: "494",
4883 title: "Stale needs_review while agents cooled",
4884 body: "",
4885 author: "",
4886 url: "",
4887 labels: &[],
4888 origin: "github",
4889 })
4890 .await
4891 .unwrap();
4892 store
4893 .update_status(id, crate::store::TaskStatus::NeedsReview)
4894 .await
4895 .unwrap();
4896 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
4897 .bind(id)
4898 .execute(store.pool())
4899 .await
4900 .unwrap();
4901
4902 refire_and_escalate_stale_needs_review_global(&task_manager, &store, &router, &dispatching)
4903 .await
4904 .unwrap();
4905
4906 let task = store.get(id).await.unwrap();
4907 assert_eq!(
4908 task.needs_review_refires, 0,
4909 "counter must not be touched while cooled"
4910 );
4911 assert_eq!(task.status, crate::store::TaskStatus::NeedsReview);
4912 }
4913
4914 #[serial(cooldown_state)]
4920 #[tokio::test]
4921 async fn auto_recover_rebroadcast_blocked_task_after_review_cycle() {
4922 crate::engine::cooldown::reset_global_state().await;
4923 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4924 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
4925 let task_manager = Arc::new(TaskManager::with_store(
4926 backend,
4927 store.clone(),
4928 "owner/repo".to_string(),
4929 ));
4930 let router = Arc::new(RwLock::new(router_with_healthy_review_agent()));
4931
4932 let id = store
4933 .upsert_external(&crate::store::UpsertExternal {
4934 repo: "owner/repo",
4935 ext_id: "511",
4936 title: "Rebroadcast blocked after review",
4937 body: "",
4938 author: "",
4939 url: "",
4940 labels: &[],
4941 origin: "github",
4942 })
4943 .await
4944 .unwrap();
4945 store
4946 .update_status(id, crate::store::TaskStatus::Blocked)
4947 .await
4948 .unwrap();
4949 store
4951 .set_block_reason(
4952 id,
4953 Some("review agent rebroadcast escalated after repeated retries"),
4954 )
4955 .await
4956 .unwrap();
4957 store
4958 .set_fields(id, &[("review_cycles", serde_json::json!(1))])
4959 .await
4960 .unwrap();
4961 store
4962 .set_fields(id, &[("needs_review_refires", serde_json::json!(6))])
4963 .await
4964 .unwrap();
4965 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
4967 .bind(id)
4968 .execute(store.pool())
4969 .await
4970 .unwrap();
4971
4972 auto_recover_rebroadcast_blocked_tasks("owner/repo", &router, &task_manager, &store)
4973 .await
4974 .unwrap();
4975
4976 let task = store.get(id).await.unwrap();
4977 assert_eq!(
4978 task.status,
4979 crate::store::TaskStatus::NeedsReview,
4980 "blocked rebroadcast task must be recovered to needs_review"
4981 );
4982 assert!(
4983 task.block_reason.is_none(),
4984 "block_reason must be cleared on recovery"
4985 );
4986 assert_eq!(
4987 task.needs_review_refires, 0,
4988 "needs_review_refires must be reset to 0 on recovery"
4989 );
4990 }
4991
4992 #[serial(cooldown_state)]
4995 #[tokio::test]
4996 async fn stale_in_review_recovery_skips_fresh_tasks() {
4997 crate::engine::cooldown::reset_global_state().await;
4998 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4999 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
5000 let task_manager = Arc::new(TaskManager::with_store(
5001 backend.clone(),
5002 store.clone(),
5003 "owner/repo".to_string(),
5004 ));
5005 let tmux = Arc::new(TmuxManager::new());
5006 let router = Arc::new(RwLock::new(crate::engine::router::Router::from_config()));
5007 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
5008 let auto_merge_in_flight: Arc<DashSet<String>> = Arc::new(DashSet::new());
5009
5010 let id = store
5011 .upsert_external(&crate::store::UpsertExternal {
5012 repo: "owner/repo",
5013 ext_id: "66",
5014 title: "Fresh review",
5015 body: "",
5016 author: "",
5017 url: "",
5018 labels: &[],
5019 origin: "github",
5020 })
5021 .await
5022 .unwrap();
5023 store
5024 .update_status(id, crate::store::TaskStatus::InReview)
5025 .await
5026 .unwrap();
5027 store
5028 .set_fields(id, &[("branch", serde_json::json!("feature/66"))])
5029 .await
5030 .unwrap();
5031 sync_tick(
5034 &backend,
5035 &tmux,
5036 "owner/repo",
5037 &EngineConfig::default(),
5038 &router,
5039 &task_manager,
5040 &store,
5041 &dispatching,
5042 &auto_merge_in_flight,
5043 )
5044 .await
5045 .unwrap();
5046
5047 let task = store.get(id).await.unwrap();
5049 assert_eq!(task.status, crate::store::TaskStatus::InReview);
5050 }
5051
5052 #[serial(cooldown_state)]
5053 #[tokio::test]
5054 async fn auto_unblock_routes_recoverable_failure() {
5055 crate::engine::cooldown::reset_global_state().await;
5056 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5057 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
5058 let task_manager = Arc::new(TaskManager::with_store(
5059 backend,
5060 store.clone(),
5061 "owner/repo".to_string(),
5062 ));
5063 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
5064
5065 let id = store
5066 .upsert_external(&crate::store::UpsertExternal {
5067 repo: "owner/repo",
5068 ext_id: "77",
5069 title: "Blocked task",
5070 body: "",
5071 author: "",
5072 url: "",
5073 labels: &[],
5074 origin: "github",
5075 })
5076 .await
5077 .unwrap();
5078 store
5079 .update_status(id, crate::store::TaskStatus::Blocked)
5080 .await
5081 .unwrap();
5082 store
5083 .set_fields(
5084 id,
5085 &[
5086 ("agent", serde_json::json!("codex")),
5087 ("model", serde_json::json!("gpt-5")),
5088 ],
5089 )
5090 .await
5091 .unwrap();
5092
5093 let run_id = store
5094 .start_run(&crate::store::StartRun {
5095 task_id: id,
5096 attempt: 1,
5097 run_type: "agent",
5098 agent: "codex",
5099 model: "gpt-5",
5100 command: "codex --model gpt-5",
5101 prompt: "do thing",
5102 })
5103 .await
5104 .unwrap();
5105 store
5106 .complete_run(&crate::store::CompleteRun {
5107 run_id,
5108 exit_code: Some(1),
5109 stdout: "",
5110 stderr: "",
5111 parsed: "",
5112 outcome: "rate_limit",
5113 error: "rate limit exceeded",
5114 tokens: RunTokenUsage::default(),
5115 })
5116 .await
5117 .unwrap();
5118
5119 auto_unblock_blocked_tasks(
5120 "owner/repo",
5121 &task_manager,
5122 &store,
5123 &dispatching,
5124 &Arc::new(DashSet::new()),
5125 )
5126 .await
5127 .unwrap();
5128
5129 let task = store.get(id).await.unwrap();
5130 assert_eq!(task.status, TaskStatus::New);
5131 assert!(task.agent.is_none());
5132 assert!(task.model.is_none());
5133 assert_eq!(task.auto_unblock_count, 1);
5134 assert!(!task.auto_unblock_last_at.is_empty());
5135 assert_eq!(task.auto_unblock_last_reason, "RateLimit");
5136 }
5137
5138 #[serial(cooldown_state)]
5139 #[tokio::test]
5140 async fn auto_unblock_skips_manual_block() {
5141 crate::engine::cooldown::reset_global_state().await;
5142 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5143 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
5144 let task_manager = Arc::new(TaskManager::with_store(
5145 backend,
5146 store.clone(),
5147 "owner/repo".to_string(),
5148 ));
5149 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
5150
5151 let id = store
5152 .upsert_external(&crate::store::UpsertExternal {
5153 repo: "owner/repo",
5154 ext_id: "88",
5155 title: "Manually blocked",
5156 body: "",
5157 author: "",
5158 url: "",
5159 labels: &[],
5160 origin: "github",
5161 })
5162 .await
5163 .unwrap();
5164 store
5165 .update_status(id, crate::store::TaskStatus::Blocked)
5166 .await
5167 .unwrap();
5168 store
5169 .set_block_reason(id, Some("waiting on input"))
5170 .await
5171 .unwrap();
5172
5173 let run_id = store
5174 .start_run(&crate::store::StartRun {
5175 task_id: id,
5176 attempt: 1,
5177 run_type: "agent",
5178 agent: "codex",
5179 model: "gpt-5",
5180 command: "codex --model gpt-5",
5181 prompt: "do thing",
5182 })
5183 .await
5184 .unwrap();
5185 store
5186 .complete_run(&crate::store::CompleteRun {
5187 run_id,
5188 exit_code: Some(1),
5189 stdout: "",
5190 stderr: "",
5191 parsed: "",
5192 outcome: "rate_limit",
5193 error: "rate limit exceeded",
5194 tokens: RunTokenUsage::default(),
5195 })
5196 .await
5197 .unwrap();
5198
5199 auto_unblock_blocked_tasks(
5200 "owner/repo",
5201 &task_manager,
5202 &store,
5203 &dispatching,
5204 &Arc::new(DashSet::new()),
5205 )
5206 .await
5207 .unwrap();
5208
5209 let task = store.get(id).await.unwrap();
5210 assert_eq!(task.status, crate::store::TaskStatus::Blocked);
5211 assert_eq!(task.auto_unblock_count, 0);
5212 }
5213
5214 #[serial(cooldown_state)]
5217 #[test]
5218 fn failure_category_as_str() {
5219 assert_eq!(FailureCategory::RateLimit.as_str(), "RateLimit");
5222 assert_eq!(FailureCategory::Timeout.as_str(), "Timeout");
5223 assert_eq!(
5224 FailureCategory::ModelUnavailable.as_str(),
5225 "ModelUnavailable"
5226 );
5227 assert_eq!(FailureCategory::MaxAttempts.as_str(), "MaxAttempts");
5228 assert_eq!(FailureCategory::Unknown.as_str(), "Unknown");
5229 assert_eq!(FailureCategory::SilentExit0.as_str(), "SilentExit0");
5230 assert_eq!(FailureCategory::ConnectionError.as_str(), "ConnectionError");
5231 assert_eq!(FailureCategory::CliFlagError.as_str(), "CliFlagError");
5232 assert_eq!(
5233 FailureCategory::AllAgentsExhausted.as_str(),
5234 "AllAgentsExhausted"
5235 );
5236 assert_eq!(FailureCategory::ParseError.as_str(), "ParseError");
5237 assert_eq!(FailureCategory::FalseFailure.as_str(), "FalseFailure");
5238 assert_eq!(FailureCategory::PushFailed.as_str(), "PushFailed");
5239 assert_eq!(FailureCategory::PrCreateFailed.as_str(), "PrCreateFailed");
5240 }
5241
5242 #[serial(cooldown_state)]
5245 #[test]
5246 fn classify_failure_model_unavailable_phrase_is_recoverable() {
5247 let category = classify_failure("model unavailable (anthropic/claude-sonnet-4-6): Model not found: anthropic/claude-sonnet-4-6", "error");
5248 assert_eq!(
5249 category,
5250 FailureCategory::ModelUnavailable,
5251 "\"model unavailable\" error must classify as ModelUnavailable"
5252 );
5253 assert!(
5254 category.is_recoverable(),
5255 "ModelUnavailable must be recoverable so auto-unblock can re-route"
5256 );
5257 }
5258
5259 #[serial(cooldown_state)]
5261 #[test]
5262 fn classify_failure_kimi_billing_cycle_is_credit_exhausted_not_rate_limit() {
5263 let error = "kimi rate limit: Failed to authenticate. API Error: 403 You've reached \
5270 your usage limit for this billing cycle. Your quota will be refreshed soon.";
5271 let category = classify_failure(error, "rate_limit");
5272 assert_eq!(
5273 category,
5274 FailureCategory::CreditExhausted,
5275 "billing-cycle exhaustion must classify as CreditExhausted, not RateLimit"
5276 );
5277 assert!(category.is_recoverable());
5278 }
5279
5280 #[serial(cooldown_state)]
5281 #[test]
5282 fn classify_failure_billing_cycle_exhausted_outcome_is_credit_exhausted() {
5283 let category = classify_failure("some billing message", "billing_cycle_exhausted");
5286 assert_eq!(category, FailureCategory::CreditExhausted);
5287 }
5288
5289 #[serial(cooldown_state)]
5290 #[test]
5291 fn classify_failure_generic_rate_limit_is_still_rate_limit() {
5292 let category = classify_failure("429 Too Many Requests", "rate_limit");
5295 assert_eq!(category, FailureCategory::RateLimit);
5296 }
5297
5298 #[serial(cooldown_state)]
5300 #[test]
5301 fn classify_failure_pr_create_failed_specific_patterns() {
5302 assert_eq!(
5304 classify_failure("create pr failed", ""),
5305 FailureCategory::PrCreateFailed
5306 );
5307 assert_eq!(
5308 classify_failure("failed to create pull request", ""),
5309 FailureCategory::PrCreateFailed
5310 );
5311 assert_eq!(
5312 classify_failure("pull request creation failed", ""),
5313 FailureCategory::PrCreateFailed
5314 );
5315
5316 assert_ne!(
5318 classify_failure("network failed while fetching pull request status", ""),
5319 FailureCategory::PrCreateFailed
5320 );
5321 assert_ne!(
5322 classify_failure("connection failed, retrying pull request check", ""),
5323 FailureCategory::PrCreateFailed
5324 );
5325 assert_ne!(
5326 classify_failure("pull request created but failed to merge", ""),
5327 FailureCategory::PrCreateFailed
5328 );
5329 }
5330
5331 #[serial(cooldown_state)]
5332 #[test]
5333 fn classify_failure_model_not_found_is_recoverable() {
5334 let category = classify_failure("Model not found: gpt-5-ultra", "error");
5335 assert_eq!(
5336 category,
5337 FailureCategory::ModelUnavailable,
5338 "\"model not found\" error must classify as ModelUnavailable"
5339 );
5340 assert!(category.is_recoverable());
5341 }
5342
5343 #[serial(cooldown_state)]
5344 #[test]
5345 fn classify_failure_model_does_not_exist_is_recoverable() {
5346 let category = classify_failure("The model `claude-opus-99` does not exist", "error");
5347 assert_eq!(category, FailureCategory::ModelUnavailable);
5348 assert!(category.is_recoverable());
5349 }
5350
5351 #[serial(cooldown_state)]
5352 #[tokio::test]
5353 async fn auto_unblock_routes_task_blocked_by_model_unavailable() {
5354 crate::engine::cooldown::reset_global_state().await;
5355 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5356 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
5357 let task_manager = Arc::new(TaskManager::with_store(
5358 backend,
5359 store.clone(),
5360 "owner/repo".to_string(),
5361 ));
5362 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
5363
5364 let id = store
5365 .upsert_external(&crate::store::UpsertExternal {
5366 repo: "owner/repo",
5367 ext_id: "1202",
5368 title: "Task blocked by model unavailable",
5369 body: "",
5370 author: "",
5371 url: "",
5372 labels: &[],
5373 origin: "github",
5374 })
5375 .await
5376 .unwrap();
5377 store
5378 .update_status(id, crate::store::TaskStatus::Blocked)
5379 .await
5380 .unwrap();
5381
5382 let run_id = store
5383 .start_run(&crate::store::StartRun {
5384 task_id: id,
5385 attempt: 1,
5386 run_type: "agent",
5387 agent: "opencode",
5388 model: "anthropic/claude-sonnet-4-6",
5389 command: "opencode",
5390 prompt: "fix the bug",
5391 })
5392 .await
5393 .unwrap();
5394 store
5395 .complete_run(&crate::store::CompleteRun {
5396 run_id,
5397 exit_code: Some(1),
5398 stdout: "",
5399 stderr: "",
5400 parsed: "",
5401 outcome: "error",
5402 error: "model unavailable (anthropic/claude-sonnet-4-6): Model not found: anthropic/claude-sonnet-4-6",
5403 tokens: RunTokenUsage::default(),
5404 })
5405 .await
5406 .unwrap();
5407
5408 auto_unblock_blocked_tasks(
5409 "owner/repo",
5410 &task_manager,
5411 &store,
5412 &dispatching,
5413 &Arc::new(DashSet::new()),
5414 )
5415 .await
5416 .unwrap();
5417
5418 let task = store.get(id).await.unwrap();
5419 assert_eq!(
5420 task.status,
5421 TaskStatus::New,
5422 "task blocked by model unavailable must be auto-unblocked and re-routed"
5423 );
5424 assert_eq!(task.auto_unblock_count, 1);
5425 assert_eq!(task.auto_unblock_last_reason, "ModelUnavailable");
5426 }
5427
5428 #[serial(cooldown_state)]
5431 #[tokio::test]
5432 async fn auto_unblock_resets_count_when_reason_changes() {
5433 crate::engine::cooldown::reset_global_state().await;
5434 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5438 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
5439 let task_manager = Arc::new(TaskManager::with_store(
5440 backend,
5441 store.clone(),
5442 "owner/repo".to_string(),
5443 ));
5444 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
5445
5446 let id = store
5447 .upsert_external(&crate::store::UpsertExternal {
5448 repo: "owner/repo",
5449 ext_id: "1227",
5450 title: "Task with different failure reason",
5451 body: "",
5452 author: "",
5453 url: "",
5454 labels: &[],
5455 origin: "github",
5456 })
5457 .await
5458 .unwrap();
5459 store
5460 .update_status(id, crate::store::TaskStatus::Blocked)
5461 .await
5462 .unwrap();
5463 store
5465 .set_fields(
5466 id,
5467 &[
5468 ("auto_unblock_count", serde_json::json!(1)),
5469 (
5470 "auto_unblock_last_reason",
5471 serde_json::json!("ModelUnavailable"),
5472 ),
5473 ],
5474 )
5475 .await
5476 .unwrap();
5477
5478 let run_id = store
5480 .start_run(&crate::store::StartRun {
5481 task_id: id,
5482 attempt: 2,
5483 run_type: "agent",
5484 agent: "claude",
5485 model: "opus",
5486 command: "claude",
5487 prompt: "fix the bug",
5488 })
5489 .await
5490 .unwrap();
5491 store
5492 .complete_run(&crate::store::CompleteRun {
5493 run_id,
5494 exit_code: Some(1),
5495 stdout: "",
5496 stderr: "",
5497 parsed: "",
5498 outcome: "error",
5499 error: "exceeded max attempts",
5500 tokens: RunTokenUsage::default(),
5501 })
5502 .await
5503 .unwrap();
5504
5505 auto_unblock_blocked_tasks(
5506 "owner/repo",
5507 &task_manager,
5508 &store,
5509 &dispatching,
5510 &Arc::new(DashSet::new()),
5511 )
5512 .await
5513 .unwrap();
5514
5515 let task = store.get(id).await.unwrap();
5516 assert_eq!(
5517 task.status,
5518 TaskStatus::New,
5519 "task must be auto-unblocked even with count=1 from a different reason"
5520 );
5521 assert_eq!(task.auto_unblock_count, 1);
5523 assert_eq!(
5524 task.auto_unblock_last_reason, "MaxAttempts",
5525 "reason must be updated to the new failure category"
5526 );
5527 }
5528
5529 #[serial(cooldown_state)]
5530 #[tokio::test]
5531 async fn auto_unblock_stores_reason_on_first_unblock() {
5532 crate::engine::cooldown::reset_global_state().await;
5533 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5536 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
5537 let task_manager = Arc::new(TaskManager::with_store(
5538 backend,
5539 store.clone(),
5540 "owner/repo".to_string(),
5541 ));
5542 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
5543
5544 let id = store
5545 .upsert_external(&crate::store::UpsertExternal {
5546 repo: "owner/repo",
5547 ext_id: "1227b",
5548 title: "Task for first-time reason storage",
5549 body: "",
5550 author: "",
5551 url: "",
5552 labels: &[],
5553 origin: "github",
5554 })
5555 .await
5556 .unwrap();
5557 store
5558 .update_status(id, crate::store::TaskStatus::Blocked)
5559 .await
5560 .unwrap();
5561
5562 let run_id = store
5563 .start_run(&crate::store::StartRun {
5564 task_id: id,
5565 attempt: 1,
5566 run_type: "agent",
5567 agent: "claude",
5568 model: "opus",
5569 command: "claude",
5570 prompt: "fix the bug",
5571 })
5572 .await
5573 .unwrap();
5574 store
5575 .complete_run(&crate::store::CompleteRun {
5576 run_id,
5577 exit_code: Some(1),
5578 stdout: "",
5579 stderr: "",
5580 parsed: "",
5581 outcome: "timeout",
5582 error: "task timed out",
5583 tokens: RunTokenUsage::default(),
5584 })
5585 .await
5586 .unwrap();
5587
5588 auto_unblock_blocked_tasks(
5589 "owner/repo",
5590 &task_manager,
5591 &store,
5592 &dispatching,
5593 &Arc::new(DashSet::new()),
5594 )
5595 .await
5596 .unwrap();
5597
5598 let task = store.get(id).await.unwrap();
5599 assert_eq!(task.status, TaskStatus::New);
5600 assert_eq!(task.auto_unblock_count, 1);
5601 assert_eq!(
5602 task.auto_unblock_last_reason, "Timeout",
5603 "reason must be stored on first auto-unblock"
5604 );
5605 }
5606
5607 #[serial(cooldown_state)]
5610 #[test]
5611 fn classify_failure_sparse_checkout_not_parse_error() {
5612 let category = classify_failure("error: cannot reset paths in a sparse checkout", "error");
5615 assert_ne!(
5616 category,
5617 FailureCategory::ParseError,
5618 "sparse checkout errors must NOT be classified as ParseError"
5619 );
5620 assert_eq!(
5621 category,
5622 FailureCategory::Unknown,
5623 "sparse checkout errors should be Unknown (not recoverable auto-unblock)"
5624 );
5625 }
5626
5627 #[serial(cooldown_state)]
5628 #[test]
5629 fn classify_failure_sparse_index_not_parse_error() {
5630 let category = classify_failure("failed due to sparse index configuration", "error");
5632 assert_ne!(category, FailureCategory::ParseError);
5633 assert_eq!(category, FailureCategory::Unknown);
5634 }
5635
5636 #[serial(cooldown_state)]
5643 #[test]
5644 fn classify_failure_truncated_is_recoverable() {
5645 let category = classify_failure(
5646 "truncated: review output was cut off before completion \
5647 (output/reasoning token budget exceeded)",
5648 "truncated",
5649 );
5650 assert_eq!(
5651 category,
5652 FailureCategory::ParseError,
5653 "truncated outcome must classify as a recoverable parse-style failure"
5654 );
5655 assert!(
5656 category.is_recoverable(),
5657 "truncated failures must be recoverable so auto-unblock can retry"
5658 );
5659 }
5660
5661 #[serial(cooldown_state)]
5662 #[test]
5663 fn dispatching_key_includes_repo_for_cross_project_isolation() {
5664 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
5665
5666 let key_a = "owner/repo-a/42".to_string();
5668 dispatching.insert(key_a.clone(), "42".to_string());
5669
5670 let key_b = "owner/repo-b/42".to_string();
5672 assert!(dispatching.contains_key(&key_a));
5673 assert!(
5674 !dispatching.contains_key(&key_b),
5675 "same task ID in different repo should not be blocked"
5676 );
5677 }
5678
5679 #[serial(cooldown_state)]
5680 #[tokio::test]
5681 async fn emit_degraded_agents_writes_metric_when_three_or_more_agents_degraded() {
5682 crate::engine::cooldown::reset_global_state().await;
5683 use crate::engine::router::{Router, RouterConfig};
5684
5685 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5686
5687 let mut config = RouterConfig::default();
5689 config
5690 .model_map
5691 .entry("medium".to_string())
5692 .or_default()
5693 .insert(
5694 "test-agent-3a".to_string(),
5695 vec!["test-model-a".to_string()],
5696 );
5697 config
5698 .model_map
5699 .entry("medium".to_string())
5700 .or_default()
5701 .insert(
5702 "test-agent-3b".to_string(),
5703 vec!["test-model-b".to_string()],
5704 );
5705 config
5706 .model_map
5707 .entry("medium".to_string())
5708 .or_default()
5709 .insert(
5710 "test-agent-3c".to_string(),
5711 vec!["test-model-c".to_string()],
5712 );
5713 config
5714 .model_map
5715 .entry("medium".to_string())
5716 .or_default()
5717 .insert(
5718 "test-agent-3d".to_string(),
5719 vec!["test-model-d".to_string()],
5720 );
5721 let mut router = Router::new(config);
5722 router.available_agents = vec![
5723 "test-agent-3a".to_string(),
5724 "test-agent-3b".to_string(),
5725 "test-agent-3c".to_string(),
5726 "test-agent-3d".to_string(),
5727 ];
5728
5729 set_agent_cooldown("test-agent-3a", 3600).await;
5731 set_agent_cooldown("test-agent-3b", 3600).await;
5732 set_agent_cooldown("test-agent-3c", 3600).await;
5733
5734 emit_degraded_agents_if_needed(&router.available_agents, &router.config, Some(&store))
5736 .await;
5737
5738 let count_val = store
5740 .kv_get("metrics:orch.agents_degraded.count")
5741 .await
5742 .unwrap();
5743 assert_eq!(count_val.as_deref(), Some("3"));
5744
5745 let alert_val = store
5747 .kv_get("metrics:orch.agents_degraded.alert")
5748 .await
5749 .unwrap();
5750 assert_eq!(alert_val.as_deref(), Some("1"));
5751 }
5752
5753 #[serial(cooldown_state)]
5754 #[tokio::test]
5755 async fn emit_degraded_agents_clears_alert_when_below_threshold() {
5756 crate::engine::cooldown::reset_global_state().await;
5757 use crate::engine::router::{Router, RouterConfig};
5758
5759 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5760
5761 let mut config = RouterConfig::default();
5762 config
5763 .model_map
5764 .entry("medium".to_string())
5765 .or_default()
5766 .insert(
5767 "test-agent-2a".to_string(),
5768 vec!["test-model-a".to_string()],
5769 );
5770 config
5771 .model_map
5772 .entry("medium".to_string())
5773 .or_default()
5774 .insert(
5775 "test-agent-2b".to_string(),
5776 vec!["test-model-b".to_string()],
5777 );
5778 let mut router = Router::new(config);
5779 router.available_agents = vec!["test-agent-2a".to_string(), "test-agent-2b".to_string()];
5780
5781 set_agent_cooldown("test-agent-2a", 3600).await;
5783 set_agent_cooldown("test-agent-2b", 3600).await;
5784
5785 emit_degraded_agents_if_needed(&router.available_agents, &router.config, Some(&store))
5786 .await;
5787
5788 let count_val = store
5790 .kv_get("metrics:orch.agents_degraded.count")
5791 .await
5792 .unwrap();
5793 assert_eq!(count_val.as_deref(), Some("2"));
5794
5795 let alert_val = store
5797 .kv_get("metrics:orch.agents_degraded.alert")
5798 .await
5799 .unwrap();
5800 assert_eq!(alert_val.as_deref(), Some("0"));
5801
5802 let stale_pool_alert = store
5803 .kv_get("metrics:orch.model_pool_stale_persistent.alert")
5804 .await
5805 .unwrap();
5806 assert_eq!(stale_pool_alert.as_deref(), Some("0"));
5807 }
5808
5809 #[serial(cooldown_state)]
5810 #[tokio::test]
5811 async fn emit_degraded_agents_sets_stale_pool_alert_for_persistent_model_failures() {
5812 crate::engine::cooldown::reset_global_state().await;
5813 use crate::engine::cooldown::record_persistent_model_failure;
5814 use crate::engine::router::{Router, RouterConfig};
5815
5816 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5817
5818 let mut config = RouterConfig::default();
5819 config
5820 .model_map
5821 .entry("medium".to_string())
5822 .or_default()
5823 .insert(
5824 "test-agent-stale".to_string(),
5825 vec!["model-a".to_string(), "model-b".to_string()],
5826 );
5827 let mut router = Router::new(config);
5828 router.available_agents = vec!["test-agent-stale".to_string()];
5829
5830 record_persistent_model_failure("test-agent-stale", "model-a").await;
5832
5833 emit_degraded_agents_if_needed(&router.available_agents, &router.config, Some(&store))
5834 .await;
5835
5836 let stale_pool_alert = store
5837 .kv_get("metrics:orch.model_pool_stale_persistent.alert")
5838 .await
5839 .unwrap();
5840 assert_eq!(stale_pool_alert.as_deref(), Some("1"));
5841 }
5842
5843 use super::{classify_comment, CommentAction, MentionCursor};
5846 use crate::engine::commands::OwnerCommand;
5847
5848 #[serial(cooldown_state)]
5849 #[test]
5850 fn classify_skip_already_processed() {
5851 let action = classify_comment(
5852 "@orch /retry",
5853 Some("https://api.github.com/repos/o/r/issues/1"),
5854 "@bot",
5855 true,
5856 );
5857 assert_eq!(action, CommentAction::Skip);
5858 }
5859
5860 #[serial(cooldown_state)]
5861 #[test]
5862 fn classify_skip_irrelevant_comment() {
5863 let action = classify_comment("just a regular comment", None, "@bot", false);
5864 assert_eq!(action, CommentAction::Skip);
5865 }
5866
5867 #[serial(cooldown_state)]
5868 #[test]
5869 fn classify_command_without_mention() {
5870 let action = classify_comment(
5871 "/retry",
5872 Some("https://api.github.com/repos/o/r/issues/42"),
5873 "@bot",
5874 false,
5875 );
5876 assert_eq!(
5877 action,
5878 CommentAction::ExecuteCommand {
5879 command: OwnerCommand::Retry,
5880 issue_num: "42".to_string(),
5881 }
5882 );
5883 }
5884
5885 #[serial(cooldown_state)]
5886 #[test]
5887 fn classify_command_with_mention() {
5888 let action = classify_comment(
5889 "@orch /close",
5890 Some("https://api.github.com/repos/o/r/issues/7"),
5891 "@bot",
5892 false,
5893 );
5894 assert_eq!(
5895 action,
5896 CommentAction::ExecuteCommandForMention {
5897 command: OwnerCommand::Close,
5898 issue_num: "7".to_string(),
5899 }
5900 );
5901 }
5902
5903 #[serial(cooldown_state)]
5904 #[test]
5905 fn classify_mention_without_command() {
5906 let action = classify_comment(
5907 "@orch can you look at this?",
5908 Some("https://api.github.com/repos/o/r/issues/99"),
5909 "@bot",
5910 false,
5911 );
5912 assert_eq!(
5913 action,
5914 CommentAction::CreateMentionTask {
5915 issue_num: Some("99".to_string()),
5916 }
5917 );
5918 }
5919
5920 #[serial(cooldown_state)]
5921 #[test]
5922 fn classify_mention_without_issue_url() {
5923 let action = classify_comment("@orch help me", None, "@bot", false);
5924 assert_eq!(action, CommentAction::CreateMentionTask { issue_num: None });
5925 }
5926
5927 #[serial(cooldown_state)]
5928 #[test]
5929 fn classify_command_no_mention_no_url_skips() {
5930 let action = classify_comment("/retry", None, "@bot", false);
5931 assert_eq!(action, CommentAction::Skip);
5932 }
5933
5934 #[serial(cooldown_state)]
5935 #[test]
5936 fn classify_mention_via_current_user() {
5937 let action = classify_comment(
5938 "@bot please help",
5939 Some("https://api.github.com/repos/o/r/issues/5"),
5940 "@bot",
5941 false,
5942 );
5943 assert_eq!(
5944 action,
5945 CommentAction::CreateMentionTask {
5946 issue_num: Some("5".to_string()),
5947 }
5948 );
5949 }
5950
5951 #[serial(cooldown_state)]
5952 #[test]
5953 fn classify_command_with_mention_no_url_creates_task() {
5954 let action = classify_comment("@orch /retry", None, "@bot", false);
5956 assert_eq!(action, CommentAction::CreateMentionTask { issue_num: None });
5957 }
5958
5959 #[serial(cooldown_state)]
5960 #[test]
5961 fn mention_cursor_stops_advancing_after_gap() {
5962 let mut cursor = MentionCursor::default();
5963 cursor.advance("2026-01-01T00:00:00Z");
5964 cursor.block_on_gap();
5965 cursor.advance("2026-01-02T00:00:00Z");
5966
5967 assert_eq!(
5968 cursor.into_last_safe_ts().as_deref(),
5969 Some("2026-01-01T00:00:00Z")
5970 );
5971 }
5972
5973 #[serial(cooldown_state)]
5974 #[test]
5975 fn mention_cursor_tracks_max_timestamp_until_gap() {
5976 let mut cursor = MentionCursor::default();
5977 cursor.advance("2026-01-01T00:00:00Z");
5978 cursor.advance("2026-01-01T00:05:00Z");
5979 cursor.block_on_gap();
5980 cursor.advance("2026-01-01T00:10:00Z");
5981
5982 assert_eq!(
5983 cursor.into_last_safe_ts().as_deref(),
5984 Some("2026-01-01T00:05:00Z")
5985 );
5986 }
5987
5988 struct AckTrackingBackend {
5992 ack_calls: tokio::sync::Mutex<Vec<String>>,
5993 ack_failure: bool,
5994 inner: crate::backends::test_helpers::NoopBackend,
5995 }
5996
5997 impl AckTrackingBackend {
5998 fn new(ack_failure: bool) -> Arc<Self> {
5999 Arc::new(Self {
6000 ack_calls: tokio::sync::Mutex::new(Vec::new()),
6001 ack_failure,
6002 inner: crate::backends::test_helpers::NoopBackend,
6003 })
6004 }
6005
6006 fn fail_on_ack(&self) -> bool {
6007 self.ack_failure
6008 }
6009 }
6010
6011 #[async_trait]
6012 impl ExternalBackend for AckTrackingBackend {
6013 fn name(&self) -> &str {
6014 "ack-tracking"
6015 }
6016
6017 async fn acknowledge_mention(&self, comment_id: &str) -> anyhow::Result<()> {
6018 self.ack_calls.lock().await.push(comment_id.to_string());
6019 if self.fail_on_ack() {
6020 anyhow::bail!("network error: temporary failure posting reaction");
6022 }
6023 Ok(())
6024 }
6025
6026 async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
6028 Ok(Some("bot".into()))
6029 }
6030
6031 async fn get_mentions(&self, _since: &str) -> anyhow::Result<Vec<Mention>> {
6033 Ok(vec![])
6034 }
6035
6036 async fn create_task(&self, t: &str, b: &str, l: &[String]) -> anyhow::Result<ExternalId> {
6038 self.inner.create_task(t, b, l).await
6039 }
6040 async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask> {
6041 self.inner.get_task(id).await
6042 }
6043 async fn list_by_status(&self, s: Status) -> anyhow::Result<Vec<ExternalTask>> {
6044 self.inner.list_by_status(s).await
6045 }
6046 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
6047 self.inner.list_routable().await
6048 }
6049 async fn post_comment(&self, id: &ExternalId, b: &str) -> anyhow::Result<()> {
6050 self.inner.post_comment(id, b).await
6051 }
6052 async fn set_labels(&self, id: &ExternalId, l: &[String]) -> anyhow::Result<()> {
6053 self.inner.set_labels(id, l).await
6054 }
6055 async fn remove_label(&self, id: &ExternalId, l: &str) -> anyhow::Result<()> {
6056 self.inner.remove_label(id, l).await
6057 }
6058 async fn get_sub_issues(&self, id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
6059 self.inner.get_sub_issues(id).await
6060 }
6061 async fn create_sub_task(
6062 &self,
6063 p: &ExternalId,
6064 t: &str,
6065 b: &str,
6066 l: &[String],
6067 ) -> anyhow::Result<ExternalId> {
6068 self.inner.create_sub_task(p, t, b, l).await
6069 }
6070 async fn ensure_status_label(&self, l: &str) -> anyhow::Result<()> {
6071 self.inner.ensure_status_label(l).await
6072 }
6073 async fn has_open_issue_with_title(&self, t: &str, l: &str) -> anyhow::Result<bool> {
6074 self.inner.has_open_issue_with_title(t, l).await
6075 }
6076 async fn health_check(&self) -> anyhow::Result<()> {
6077 self.inner.health_check().await
6078 }
6079 async fn is_pr_merged(&self, b: &str) -> anyhow::Result<bool> {
6080 self.inner.is_pr_merged(b).await
6081 }
6082 async fn update_status(&self, id: &ExternalId, s: Status) -> anyhow::Result<()> {
6083 self.inner.update_status(id, s).await
6084 }
6085 }
6086
6087 #[serial(cooldown_state)]
6090 #[tokio::test]
6091 async fn create_mention_task_skips_on_ack_failure() {
6092 crate::engine::cooldown::reset_global_state().await;
6093 let backend: Arc<AckTrackingBackend> = AckTrackingBackend::new(true);
6094 let store: Arc<TaskStore> = Arc::new(TaskStore::open_memory().await.unwrap());
6095
6096 let initial_ts = "2026-01-01T00:00:00Z";
6098 store
6099 .kv_set("mentions_last_checked", initial_ts)
6100 .await
6101 .unwrap();
6102
6103 let mention = Mention {
6104 id: "mention-abc".into(),
6105 body: "@bot can you help?".into(),
6106 author: "alice".into(),
6107 created_at: "2026-01-02T00:00:00Z".into(),
6108 issue_url: Some("https://api.github.com/repos/owner/repo/issues/42".into()),
6109 };
6110
6111 let backend_trait: Arc<dyn ExternalBackend> = backend.clone();
6112 let task_manager = Arc::new(TaskManager::new(backend_trait.clone()));
6113
6114 scan_comments(
6115 &backend_trait,
6116 Some(&store),
6117 "owner/repo",
6118 &task_manager,
6119 Some(&[mention]),
6120 )
6121 .await
6122 .unwrap();
6123
6124 let calls = backend.ack_calls.lock().await;
6126 assert_eq!(
6127 &**calls,
6128 &["mention-abc"],
6129 "acknowledge_mention must be called"
6130 );
6131
6132 let all = store.list_all("owner/repo").await.unwrap();
6134 assert!(
6135 all.is_empty(),
6136 "no task should be created when acknowledge_mention fails"
6137 );
6138
6139 let cursor = store.kv_get("mentions_last_checked").await.unwrap();
6141 assert_eq!(
6142 cursor.as_deref(),
6143 Some(initial_ts),
6144 "cursor must not advance when acknowledge_mention fails"
6145 );
6146 }
6147
6148 #[serial(cooldown_state)]
6151 #[tokio::test]
6152 async fn execute_command_for_mention_skips_on_ack_failure() {
6153 crate::engine::cooldown::reset_global_state().await;
6154 let backend: Arc<AckTrackingBackend> = AckTrackingBackend::new(true);
6155 let store: Arc<TaskStore> = Arc::new(TaskStore::open_memory().await.unwrap());
6156
6157 let initial_ts = "2026-01-01T00:00:00Z";
6158 store
6159 .kv_set("mentions_last_checked", initial_ts)
6160 .await
6161 .unwrap();
6162
6163 let mention = Mention {
6165 id: "mention-def".into(),
6166 body: "@bot /close".into(),
6167 author: "alice".into(),
6168 created_at: "2026-01-02T00:00:00Z".into(),
6169 issue_url: Some("https://api.github.com/repos/owner/repo/issues/42".into()),
6170 };
6171
6172 let backend_trait: Arc<dyn ExternalBackend> = backend.clone();
6173 let task_manager = Arc::new(TaskManager::new(backend_trait.clone()));
6174
6175 scan_comments(
6176 &backend_trait,
6177 Some(&store),
6178 "owner/repo",
6179 &task_manager,
6180 Some(&[mention]),
6181 )
6182 .await
6183 .unwrap();
6184
6185 let calls = backend.ack_calls.lock().await;
6187 assert_eq!(&**calls, &["mention-def"]);
6188
6189 let all = store.list_all("owner/repo").await.unwrap();
6191 assert!(
6192 all.is_empty(),
6193 "no task should be created when acknowledge_mention fails for ExecuteCommandForMention"
6194 );
6195
6196 let cursor = store.kv_get("mentions_last_checked").await.unwrap();
6198 assert_eq!(
6199 cursor.as_deref(),
6200 Some(initial_ts),
6201 "cursor must not advance when acknowledge_mention fails for ExecuteCommandForMention"
6202 );
6203 }
6204
6205 fn make_task(block_reason: Option<String>) -> crate::store::Task {
6208 crate::store::Task {
6209 id: 1,
6210 external_id: None,
6211 repo: "owner/repo".to_string(),
6212 origin: String::new(),
6213 title: String::new(),
6214 body: String::new(),
6215 status: crate::store::TaskStatus::Blocked,
6216 source: String::new(),
6217 source_id: String::new(),
6218 author: String::new(),
6219 url: String::new(),
6220 labels: vec![],
6221 agent: None,
6222 model: None,
6223 complexity: String::new(),
6224 estimate: 0,
6225 route_reason: String::new(),
6226 agent_profile: String::new(),
6227 selected_skills: String::new(),
6228 route_attempts: 0,
6229 attempts: 0,
6230 branch: String::new(),
6231 worktree: String::new(),
6232 worktree_cleaned: false,
6233 summary: String::new(),
6234 last_error: String::new(),
6235 parent_id: None,
6236 block_reason,
6237 pr_number: Some(42),
6238 pr_review_context: String::new(),
6239 last_review_ts: String::new(),
6240 review_ts_map: String::new(),
6241 last_comment_review_ts: String::new(),
6242 merge_conflict_retries: 0,
6243 ci_merge_failures: 0,
6244 pr_create_failures: 0,
6245 push_failures: 0,
6246 network_retries: 0,
6247 review_agent_failures: 0,
6248 review_cycles: 0,
6249 review_invocations: 0,
6250 review_session_expected: false,
6251 needs_review_refires: 0,
6252 input_tokens: 0,
6253 output_tokens: 0,
6254 input_cost_usd: 0.0,
6255 output_cost_usd: 0.0,
6256 total_cost_usd: 0.0,
6257 model_reroute_chain: String::new(),
6258 limit_reroute_chain: String::new(),
6259 memory: vec![],
6260 delegations: vec![],
6261 auto_unblock_count: 0,
6262 auto_unblock_last_at: String::new(),
6263 auto_unblock_last_reason: String::new(),
6264 ci_recovery_count: 0,
6265 no_code_reroutes: 0,
6266 no_code_last_agent: String::new(),
6267 created_at: String::new(),
6268 updated_at: String::new(),
6269 }
6270 }
6271
6272 #[serial(cooldown_state)]
6273 #[test]
6274 fn is_ci_failure_block_matches_ci_failure_limit() {
6275 let task = make_task(Some(
6276 "CI failure limit (3) reached during auto-merge".to_string(),
6277 ));
6278 assert!(is_ci_failure_block(&task));
6279 }
6280
6281 #[serial(cooldown_state)]
6282 #[test]
6283 fn is_ci_failure_block_matches_ci_checks_timeout() {
6284 let task = make_task(Some(
6285 "CI checks timed out after 3 auto-merge failures".to_string(),
6286 ));
6287 assert!(is_ci_failure_block(&task));
6288 }
6289
6290 #[serial(cooldown_state)]
6291 #[test]
6292 fn is_ci_failure_block_false_for_regular_block() {
6293 let task = make_task(Some("waiting on input".to_string()));
6294 assert!(!is_ci_failure_block(&task));
6295 }
6296
6297 #[serial(cooldown_state)]
6298 #[test]
6299 fn is_ci_failure_block_false_for_none_reason() {
6300 let task = make_task(None);
6301 assert!(!is_ci_failure_block(&task));
6302 }
6303
6304 #[serial(cooldown_state)]
6307 #[test]
6308 fn ci_failure_cooldown_zero_count_immediate() {
6309 let task = make_task(None);
6311 assert!(ci_failure_unblock_cooldown_elapsed(&task));
6312 }
6313
6314 #[serial(cooldown_state)]
6315 #[test]
6316 fn ci_failure_cooldown_empty_last_at_immediate() {
6317 let mut task = make_task(None);
6319 task.auto_unblock_count = 1;
6320 assert!(ci_failure_unblock_cooldown_elapsed(&task));
6321 }
6322
6323 #[serial(cooldown_state)]
6324 #[test]
6325 fn ci_failure_cooldown_invalid_timestamp_immediate() {
6326 let mut task = make_task(None);
6328 task.auto_unblock_count = 1;
6329 task.auto_unblock_last_at = "not-a-timestamp".to_string();
6330 assert!(ci_failure_unblock_cooldown_elapsed(&task));
6331 }
6332
6333 #[serial(cooldown_state)]
6334 #[test]
6335 fn ci_failure_cooldown_high_count_still_eventually_elapses() {
6336 let long_ago = (chrono::Utc::now() - chrono::Duration::days(30)).to_rfc3339();
6339 for count in [3i32, 5, 99] {
6340 let mut task = make_task(None);
6341 task.auto_unblock_count = count;
6342 task.auto_unblock_last_at = long_ago.clone();
6343 assert!(
6344 ci_failure_unblock_cooldown_elapsed(&task),
6345 "count={count} should eventually elapse"
6346 );
6347 }
6348 }
6349
6350 #[serial(cooldown_state)]
6351 #[test]
6352 fn ci_failure_cooldown_recent_timestamp_still_blocks() {
6353 let just_now = chrono::Utc::now().to_rfc3339();
6355 for count in [1i32, 2, 3, 99] {
6356 let mut task = make_task(None);
6357 task.auto_unblock_count = count;
6358 task.auto_unblock_last_at = just_now.clone();
6359 assert!(
6360 !ci_failure_unblock_cooldown_elapsed(&task),
6361 "count={count} should still be cooling down"
6362 );
6363 }
6364 }
6365
6366 #[serial(cooldown_state)]
6369 #[tokio::test]
6370 async fn global_ci_failure_sweep_returns_ok_with_no_blocked_tasks() {
6371 crate::engine::cooldown::reset_global_state().await;
6372 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6373 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
6374 let task_manager = Arc::new(TaskManager::with_store(
6375 backend,
6376 store.clone(),
6377 "owner/repo".to_string(),
6378 ));
6379 auto_unblock_ci_failure_blocked_tasks_global(
6380 &task_manager,
6381 &store,
6382 &Arc::new(DashSet::new()),
6383 )
6384 .await
6385 .unwrap();
6386 }
6387
6388 #[serial(cooldown_state)]
6389 #[tokio::test]
6390 async fn global_ci_failure_sweep_skips_tasks_in_cooldown() {
6391 crate::engine::cooldown::reset_global_state().await;
6394 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6395 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
6396 let task_manager = Arc::new(TaskManager::with_store(
6397 backend,
6398 store.clone(),
6399 "active/repo".to_string(),
6400 ));
6401
6402 let id = store
6404 .upsert_external(&crate::store::UpsertExternal {
6405 repo: "inactive/repo",
6406 ext_id: "10",
6407 title: "Old blocked task",
6408 body: "",
6409 author: "",
6410 url: "",
6411 labels: &[],
6412 origin: "github",
6413 })
6414 .await
6415 .unwrap();
6416 store
6417 .update_status(id, crate::store::TaskStatus::Blocked)
6418 .await
6419 .unwrap();
6420 let just_now = chrono::Utc::now().to_rfc3339();
6421 store
6422 .set_fields(
6423 id,
6424 &[
6425 (
6426 "block_reason",
6427 serde_json::json!("CI failure limit reached during auto-merge"),
6428 ),
6429 ("pr_number", serde_json::json!(99)),
6430 ("auto_unblock_count", serde_json::json!(1)),
6431 ("auto_unblock_last_at", serde_json::json!(just_now)),
6432 ],
6433 )
6434 .await
6435 .unwrap();
6436
6437 auto_unblock_ci_failure_blocked_tasks_global(
6438 &task_manager,
6439 &store,
6440 &Arc::new(DashSet::new()),
6441 )
6442 .await
6443 .unwrap();
6444
6445 let task = store.get(id).await.unwrap();
6447 assert_eq!(
6448 task.auto_unblock_count, 1,
6449 "task in cooldown must not be processed"
6450 );
6451 }
6452
6453 #[serial(cooldown_state)]
6454 #[tokio::test]
6455 async fn global_ci_failure_sweep_ignores_non_ci_failure_blocks() {
6456 crate::engine::cooldown::reset_global_state().await;
6457 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6458 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
6459 let task_manager = Arc::new(TaskManager::with_store(
6460 backend,
6461 store.clone(),
6462 "active/repo".to_string(),
6463 ));
6464
6465 let id = store
6467 .upsert_external(&crate::store::UpsertExternal {
6468 repo: "inactive/repo",
6469 ext_id: "20",
6470 title: "Manual block",
6471 body: "",
6472 author: "",
6473 url: "",
6474 labels: &[],
6475 origin: "github",
6476 })
6477 .await
6478 .unwrap();
6479 store
6480 .update_status(id, crate::store::TaskStatus::Blocked)
6481 .await
6482 .unwrap();
6483 store
6484 .set_fields(
6485 id,
6486 &[("block_reason", serde_json::json!("waiting for human input"))],
6487 )
6488 .await
6489 .unwrap();
6490
6491 auto_unblock_ci_failure_blocked_tasks_global(
6492 &task_manager,
6493 &store,
6494 &Arc::new(DashSet::new()),
6495 )
6496 .await
6497 .unwrap();
6498
6499 let task = store.get(id).await.unwrap();
6501 assert_eq!(task.status, crate::store::TaskStatus::Blocked);
6502 }
6503
6504 #[serial(cooldown_state)]
6507 #[tokio::test]
6508 async fn global_rebroadcast_sweep_returns_ok_with_no_blocked_tasks() {
6509 crate::engine::cooldown::reset_global_state().await;
6510 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6511 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
6512 let task_manager = Arc::new(TaskManager::with_store(
6513 backend,
6514 store.clone(),
6515 "owner/repo".to_string(),
6516 ));
6517 let router = router_with_healthy_review_agent();
6518 auto_recover_rebroadcast_blocked_tasks_global(&router, &task_manager, &store)
6519 .await
6520 .unwrap();
6521 }
6522
6523 #[serial(cooldown_state)]
6524 #[tokio::test]
6525 async fn global_rebroadcast_sweep_ignores_non_rebroadcast_blocks() {
6526 crate::engine::cooldown::reset_global_state().await;
6527 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6528 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
6529 let task_manager = Arc::new(TaskManager::with_store(
6530 backend,
6531 store.clone(),
6532 "active/repo".to_string(),
6533 ));
6534 let router = router_with_healthy_review_agent();
6535
6536 let id = store
6537 .upsert_external(&crate::store::UpsertExternal {
6538 repo: "inactive/repo",
6539 ext_id: "30",
6540 title: "Manual block",
6541 body: "",
6542 author: "",
6543 url: "",
6544 labels: &[],
6545 origin: "github",
6546 })
6547 .await
6548 .unwrap();
6549 store
6550 .update_status(id, crate::store::TaskStatus::Blocked)
6551 .await
6552 .unwrap();
6553 store
6554 .set_fields(
6555 id,
6556 &[("block_reason", serde_json::json!("waiting for human input"))],
6557 )
6558 .await
6559 .unwrap();
6560 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
6561 .bind(id)
6562 .execute(store.pool())
6563 .await
6564 .unwrap();
6565
6566 auto_recover_rebroadcast_blocked_tasks_global(&router, &task_manager, &store)
6567 .await
6568 .unwrap();
6569
6570 let task = store.get(id).await.unwrap();
6571 assert_eq!(task.status, crate::store::TaskStatus::Blocked);
6572 assert_eq!(
6573 task.block_reason.as_deref(),
6574 Some("waiting for human input")
6575 );
6576 }
6577
6578 #[serial(cooldown_state)]
6579 #[tokio::test]
6580 async fn global_rebroadcast_sweep_skips_freshly_blocked_tasks() {
6581 crate::engine::cooldown::reset_global_state().await;
6582 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6583 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
6584 let task_manager = Arc::new(TaskManager::with_store(
6585 backend,
6586 store.clone(),
6587 "active/repo".to_string(),
6588 ));
6589 let router = router_with_healthy_review_agent();
6590
6591 let id = store
6592 .upsert_external(&crate::store::UpsertExternal {
6593 repo: "inactive/repo",
6594 ext_id: "40",
6595 title: "Just escalated",
6596 body: "",
6597 author: "",
6598 url: "",
6599 labels: &[],
6600 origin: "github",
6601 })
6602 .await
6603 .unwrap();
6604 store
6605 .update_status(id, crate::store::TaskStatus::Blocked)
6606 .await
6607 .unwrap();
6608 store
6609 .set_fields(
6610 id,
6611 &[("block_reason", serde_json::json!(REBROADCAST_BLOCK_REASON))],
6612 )
6613 .await
6614 .unwrap();
6615 auto_recover_rebroadcast_blocked_tasks_global(&router, &task_manager, &store)
6619 .await
6620 .unwrap();
6621
6622 let task = store.get(id).await.unwrap();
6623 assert_eq!(task.status, crate::store::TaskStatus::Blocked);
6624 }
6625
6626 #[serial(cooldown_state)]
6633 #[tokio::test]
6634 async fn global_rebroadcast_sweep_recovers_task_from_inactive_repo() {
6635 crate::engine::cooldown::reset_global_state().await;
6636 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6637 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
6638 let task_manager = Arc::new(TaskManager::with_store(
6639 backend,
6640 store.clone(),
6641 "active/repo".to_string(),
6642 ));
6643 let router = router_with_healthy_review_agent();
6644
6645 let id = store
6646 .upsert_external(&crate::store::UpsertExternal {
6647 repo: "inactive/repo",
6648 ext_id: "458",
6649 title: "Rebroadcast blocked from inactive repo",
6650 body: "",
6651 author: "",
6652 url: "",
6653 labels: &[],
6654 origin: "github",
6655 })
6656 .await
6657 .unwrap();
6658 store
6659 .update_status(id, crate::store::TaskStatus::Blocked)
6660 .await
6661 .unwrap();
6662 store
6663 .set_fields(
6664 id,
6665 &[
6666 ("block_reason", serde_json::json!(REBROADCAST_BLOCK_REASON)),
6667 ("needs_review_refires", serde_json::json!(6)),
6668 ],
6669 )
6670 .await
6671 .unwrap();
6672 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
6673 .bind(id)
6674 .execute(store.pool())
6675 .await
6676 .unwrap();
6677
6678 auto_recover_rebroadcast_blocked_tasks_global(&router, &task_manager, &store)
6679 .await
6680 .unwrap();
6681
6682 let task = store.get(id).await.unwrap();
6683 assert_eq!(
6684 task.status,
6685 crate::store::TaskStatus::NeedsReview,
6686 "cross-repo rebroadcast-blocked task must be recovered to needs_review"
6687 );
6688 assert!(
6689 task.block_reason.is_none(),
6690 "block_reason must be cleared on recovery"
6691 );
6692 assert_eq!(
6693 task.needs_review_refires, 0,
6694 "needs_review_refires must be reset to 0 on recovery"
6695 );
6696 }
6697
6698 #[serial(cooldown_state)]
6702 #[tokio::test]
6703 async fn global_rebroadcast_sweep_skips_reset_without_routable_review_agent() {
6704 crate::engine::cooldown::reset_global_state().await;
6705 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6706 let backend: Arc<dyn ExternalBackend> = IngestMockBackend::with_tasks(vec![]);
6707 let task_manager = Arc::new(TaskManager::with_store(
6708 backend,
6709 store.clone(),
6710 "active/repo".to_string(),
6711 ));
6712 let router = crate::engine::router::Router::new_for_test(
6715 crate::engine::router::RouterConfig::default(),
6716 vec!["claude".to_string()],
6717 );
6718
6719 let id = store
6720 .upsert_external(&crate::store::UpsertExternal {
6721 repo: "inactive/repo",
6722 ext_id: "459",
6723 title: "Rebroadcast blocked, no routable agents",
6724 body: "",
6725 author: "",
6726 url: "",
6727 labels: &[],
6728 origin: "github",
6729 })
6730 .await
6731 .unwrap();
6732 store
6733 .update_status(id, crate::store::TaskStatus::Blocked)
6734 .await
6735 .unwrap();
6736 store
6737 .set_fields(
6738 id,
6739 &[("block_reason", serde_json::json!(REBROADCAST_BLOCK_REASON))],
6740 )
6741 .await
6742 .unwrap();
6743 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
6744 .bind(id)
6745 .execute(store.pool())
6746 .await
6747 .unwrap();
6748
6749 auto_recover_rebroadcast_blocked_tasks_global(&router, &task_manager, &store)
6750 .await
6751 .unwrap();
6752
6753 let task = store.get(id).await.unwrap();
6754 assert_eq!(task.status, crate::store::TaskStatus::Blocked);
6755 assert_eq!(task.block_reason.as_deref(), Some(REBROADCAST_BLOCK_REASON));
6756 }
6757}