Skip to main content

orch/engine/
sync.rs

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