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