Skip to main content

orch/engine/
sync.rs

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