Skip to main content

orch/engine/
sync.rs

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