Skip to main content

orch/engine/
sync.rs

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