Skip to main content

orch/engine/
sync.rs

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