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