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