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