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