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