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