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