Skip to main content

orch/engine/
sync.rs

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