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