Skip to main content

orch/engine/
dependencies.rs

1//! Sync GitHub blocked-by links into SQLite and hold tasks until those links are done.
2//!
3//! Native `blocked_by` links and `Depends on` lines in the issue body are both
4//! stored, deduplicated by repository and number. A failed read leaves stored
5//! rows in place.
6
7use crate::backends::{BlockedByFetch, ExternalBackend, ExternalId, PullRequestCompletion};
8use crate::channels::notification::TaskNotification;
9use crate::channels::transport::Transport;
10use crate::engine;
11use crate::store::{
12    BlockerSnapshot, DepSource, NewDependency, ObservedPullRequest, TaskStore,
13    blocker_failure_kind, blocker_is_satisfied, dep_label,
14};
15use std::collections::hash_map::DefaultHasher;
16use std::collections::{HashMap, HashSet};
17use std::hash::{Hash, Hasher};
18
19const DEP_SYNC_TTL_SECS: i64 = 45;
20// Each pass costs one to four REST calls per open blocker, so 40 blockers at 45s would pass 5000 an hour
21const BLOCKER_REFRESH_TTL_SECS: i64 = 300;
22// Every entry is first read in the same tick after a restart, so a fixed TTL expires them together
23const BLOCKER_REFRESH_SPREAD_SECS: i64 = 120;
24
25fn blocker_read_key(repo: &str, number: i64) -> String {
26    format!("dep_blocker_read_at:{repo}#{number}")
27}
28
29/**
30 * How long a blocker's snapshot is reused, spread over `BLOCKER_REFRESH_SPREAD_SECS`. The
31 * offset is a hash of the blocker, not a fresh random number, so each blocker keeps its
32 * own slot across ticks instead of drifting on every read
33 **/
34fn blocker_refresh_ttl_secs(repo: &str, number: i64) -> i64 {
35    let mut hasher = DefaultHasher::new();
36    repo.hash(&mut hasher);
37    number.hash(&mut hasher);
38    let offset = i64::try_from(hasher.finish() % BLOCKER_REFRESH_SPREAD_SECS as u64).unwrap_or(0);
39    BLOCKER_REFRESH_TTL_SECS + offset
40}
41
42/**
43 * Forget when a blocker was last read, so the next refresh reads it at once. Called when a
44 * task reaches `done`. Without it a blocker that finishes right after a read holds its
45 * dependents for the rest of the TTL, and a chain pays that at each step. A blocker orch
46 * does not manage has no task to finish, so it keeps the full TTL
47 **/
48pub async fn clear_blocker_read_cache(store: &TaskStore, repo: &str, external_id: &str) {
49    // An internal id never names a GitHub issue, so it can never be a blocker
50    let Ok(number) = external_id.parse::<i64>() else {
51        return;
52    };
53    if let Err(err) = store.kv_delete(&blocker_read_key(repo, number)).await {
54        tracing::warn!(repo, number, err = %err, "clearing the blocker read cache failed");
55    }
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct DepRef {
60    pub repo: String,
61    pub number: i64,
62}
63
64#[derive(Debug, Clone)]
65pub struct SyncOutcome {
66    pub cache: bool,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct DepNotice {
71    pub repo: String,
72    pub issue_number: i64,
73    pub comment: String,
74    pub notify: bool,
75    pub summary: String,
76    pub task_id: i64,
77    pub title: String,
78}
79
80/// Parse `Depends on` and `Blocked by` lines.
81///
82/// Bare `#12` uses `default_repo`. `owner/repo#12` keeps that repository.
83/// Lines inside fenced code blocks are ignored
84pub fn parse_body_dependencies(body: &str, default_repo: &str) -> Vec<DepRef> {
85    let mut out = Vec::new();
86    let mut seen = HashSet::new();
87    let mut in_fence = false;
88    for line in body.lines() {
89        let trimmed = line.trim();
90        if trimmed.starts_with("```") {
91            in_fence = !in_fence;
92            continue;
93        }
94        if in_fence {
95            continue;
96        }
97        let Some(rest) = dependency_line_rest(trimmed) else {
98            continue;
99        };
100        for dep in extract_refs(rest, default_repo) {
101            if seen.insert((dep.repo.clone(), dep.number)) {
102                out.push(dep);
103            }
104        }
105    }
106    out
107}
108
109fn dependency_line_rest(trimmed: &str) -> Option<&str> {
110    let lower = trimmed.to_ascii_lowercase();
111    let prefix = if lower.starts_with("depends on") {
112        "depends on".len()
113    } else if lower.starts_with("blocked by") {
114        "blocked by".len()
115    } else {
116        return None;
117    };
118    let rest = trimmed[prefix..]
119        .trim_start()
120        .trim_start_matches(':')
121        .trim();
122    if rest.is_empty() { None } else { Some(rest) }
123}
124
125/// One `--depends-on` token: a bare issue number, `#12`, or `owner/repo#12`
126pub fn parse_depends_on_token(token: &str, default_repo: &str) -> Option<DepRef> {
127    let trimmed = token.trim().trim_matches(',');
128    if trimmed.is_empty() {
129        return None;
130    }
131    if let Ok(number) = trimmed.parse::<i64>()
132        && number > 0
133        && !default_repo.is_empty()
134    {
135        return Some(DepRef {
136            repo: default_repo.to_string(),
137            number,
138        });
139    }
140    extract_refs(trimmed, default_repo).into_iter().next()
141}
142
143fn extract_refs(text: &str, default_repo: &str) -> Vec<DepRef> {
144    let mut out = Vec::new();
145    let bytes = text.as_bytes();
146    let mut index = 0;
147    while index < bytes.len() {
148        if bytes[index] != b'#' {
149            index += 1;
150            continue;
151        }
152        let digits: String = text[index + 1..]
153            .chars()
154            .take_while(char::is_ascii_digit)
155            .collect();
156        if digits.is_empty() {
157            index += 1;
158            continue;
159        }
160        let number: i64 = match digits.parse() {
161            Ok(number) if number > 0 => number,
162            _ => {
163                index += 1 + digits.len();
164                continue;
165            }
166        };
167        let repo = repo_before(text, index).unwrap_or(default_repo).to_string();
168        if !repo.is_empty() {
169            out.push(DepRef { repo, number });
170        }
171        index += 1 + digits.len();
172    }
173    out
174}
175
176fn repo_before(text: &str, hash_at: usize) -> Option<&str> {
177    let before = &text[..hash_at];
178    let mut start = hash_at;
179    let bytes = before.as_bytes();
180    while start > 0 {
181        let ch = bytes[start - 1] as char;
182        if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '-' | '/') {
183            start -= 1;
184        } else {
185            break;
186        }
187    }
188    let candidate = &before[start..];
189    let mut parts = candidate.split('/');
190    let owner = parts.next().unwrap_or("");
191    let name = parts.next().unwrap_or("");
192    if parts.next().is_some() || owner.is_empty() || name.is_empty() {
193        return None;
194    }
195    if !owner
196        .chars()
197        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '-'))
198        || !name
199            .chars()
200            .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '-'))
201    {
202        return None;
203    }
204    Some(candidate)
205}
206
207/// Replace one issue's dependency rows from the native blocked-by links and the issue body.
208///
209/// Both sources are stored, deduplicated by repository and number. A `cli` row
210/// is carried over, and a failed read keeps every stored row
211pub async fn sync_issue_dependencies(
212    backend: &dyn ExternalBackend,
213    store: &TaskStore,
214    task_id: i64,
215    repo: &str,
216    issue_number: Option<i64>,
217    body: &str,
218) -> anyhow::Result<SyncOutcome> {
219    let Some(issue_number) = issue_number.filter(|number| *number > 0) else {
220        return Ok(SyncOutcome { cache: false });
221    };
222    let fetch = match backend.blocked_by(repo, issue_number).await {
223        Ok(fetch) => fetch,
224        Err(err) => BlockedByFetch::Failed(err.to_string()),
225    };
226    let native = match fetch {
227        BlockedByFetch::Native(list) => list,
228        BlockedByFetch::Unavailable => Vec::new(),
229        BlockedByFetch::Failed(message) => {
230            tracing::warn!(
231                task_id,
232                repo,
233                issue_number,
234                error = %message,
235                "dependency read failed, keeping stored rows"
236            );
237            return Ok(SyncOutcome { cache: false });
238        }
239    };
240    // An issue naming itself would wait forever, so it never becomes a row
241    let mut seen = HashSet::from([(repo.to_string(), issue_number)]);
242    let mut rows = Vec::new();
243    for item in &native {
244        if !item.state.is_empty() {
245            store
246                .note_blocker_issue_state(
247                    &item.repo,
248                    item.number,
249                    &item.state,
250                    item.state_reason.as_deref(),
251                )
252                .await?;
253        }
254        if seen.insert((item.repo.clone(), item.number)) {
255            rows.push(NewDependency {
256                repo: item.repo.clone(),
257                number: item.number,
258                source: DepSource::Github,
259            });
260        }
261    }
262    for dep in parse_body_dependencies(body, repo) {
263        if !seen.insert((dep.repo.clone(), dep.number)) {
264            continue;
265        }
266        // Seed a snapshot only once, `refresh_blocker_snapshots` keeps it current from then on
267        if store
268            .blocker_snapshot(&dep.repo, dep.number)
269            .await?
270            .is_none()
271            && store
272                .get_by_external_id(&dep.repo, &dep.number.to_string())
273                .await?
274                .is_none()
275            && let Ok(Some((state, reason))) =
276                backend.issue_close_state(&dep.repo, dep.number).await
277        {
278            store
279                .note_blocker_issue_state(&dep.repo, dep.number, &state, reason.as_deref())
280                .await?;
281        }
282        rows.push(NewDependency {
283            repo: dep.repo,
284            number: dep.number,
285            source: DepSource::Body,
286        });
287    }
288    for dep in store.list_dependencies(task_id).await? {
289        if dep.source == DepSource::Cli.as_str()
290            && seen.insert((dep.depends_on_repo.clone(), dep.depends_on_number))
291        {
292            rows.push(NewDependency {
293                repo: dep.depends_on_repo,
294                number: dep.depends_on_number,
295                source: DepSource::Cli,
296            });
297        }
298    }
299    store.replace_dependencies(task_id, &rows).await?;
300    store.link_known_task(task_id, repo, issue_number).await?;
301    Ok(SyncOutcome { cache: true })
302}
303
304/// Move `new` and `routed` tasks with open dependencies to `waiting`, and release
305/// `waiting` tasks whose dependencies are done
306pub async fn apply_claim_gates(store: &TaskStore) -> anyhow::Result<()> {
307    let tasks = store.list_schedulable().await?;
308    for task in tasks {
309        store.block_claim_if_waiting(task.id).await?;
310    }
311    Ok(())
312}
313
314/// Refresh blocked-by links for schedulable tasks, then hold or release them.
315///
316/// A successful read is cached for 45 seconds so a 10 second tick does not
317/// fetch every task every time. Sync from ingest calls `sync_issue_dependencies`
318/// directly and does not use this cache
319pub async fn reconcile_dependencies(
320    backend: &dyn ExternalBackend,
321    store: &TaskStore,
322    transport: Option<&Transport>,
323) -> anyhow::Result<()> {
324    let tasks = store.list_schedulable().await?;
325    let now = chrono::Utc::now().timestamp();
326    for task in &tasks {
327        let Some(number) = task
328            .external_id
329            .as_deref()
330            .and_then(|id| id.parse::<i64>().ok())
331            .filter(|number| *number > 0)
332        else {
333            continue;
334        };
335        let key = format!("dep_synced_at:{}", task.id);
336        if let Ok(Some(prev)) = store.kv_get(&key).await
337            && let Ok(prev) = prev.parse::<i64>()
338            && now.saturating_sub(prev) < DEP_SYNC_TTL_SECS
339        {
340            continue;
341        }
342        match sync_issue_dependencies(
343            backend,
344            store,
345            task.id,
346            &task.repo,
347            Some(number),
348            &task.body,
349        )
350        .await
351        {
352            Ok(outcome) if outcome.cache => {
353                if let Err(err) = store.kv_set(&key, &now.to_string()).await {
354                    tracing::warn!(task_id = task.id, err = %err, "failed to cache dependency sync time");
355                }
356            }
357            Ok(_) => {}
358            Err(err) => {
359                tracing::warn!(task_id = task.id, repo = %task.repo, err = %err, "dependency sync failed");
360            }
361        }
362    }
363    if let Err(err) = refresh_blocker_snapshots(backend, store).await {
364        tracing::warn!(err = %err, "blocker snapshot refresh failed");
365    }
366    apply_claim_gates(store).await?;
367    emit_dependency_notices(backend, store, transport).await?;
368    Ok(())
369}
370
371/// Read each blocker's issue and pull request, then store the snapshot the claim gate reads.
372///
373/// A backend that returns no issue state and no pull request number leaves the
374/// stored snapshot in place. A known pull request that cannot be read is stored
375/// as open and unmerged
376pub async fn refresh_blocker_snapshots(
377    backend: &dyn ExternalBackend,
378    store: &TaskStore,
379) -> anyhow::Result<()> {
380    let edges = store.list_dependency_edges().await?;
381    let mut seen = HashSet::new();
382    for edge in edges {
383        if !seen.insert((edge.depends_on_repo.clone(), edge.depends_on_number)) {
384            continue;
385        }
386        if let Err(err) = refresh_one_blocker(
387            backend,
388            store,
389            &edge.depends_on_repo,
390            edge.depends_on_number,
391        )
392        .await
393        {
394            tracing::warn!(
395                repo = %edge.depends_on_repo,
396                number = edge.depends_on_number,
397                err = %err,
398                "blocker snapshot refresh failed"
399            );
400        }
401    }
402    Ok(())
403}
404
405async fn refresh_one_blocker(
406    backend: &dyn ExternalBackend,
407    store: &TaskStore,
408    repo: &str,
409    number: i64,
410) -> anyhow::Result<()> {
411    // A satisfied blocker is terminal: the issue closed as completed, or its pull request
412    // merged into the default branch. Re-reading it cost four REST calls per tick for an
413    // answer that cannot change, and once the hourly quota dropped under the reserve the
414    // throttle refused the issue ingest with it. A reopen still lands here through
415    // `note_blocker_issue_state`, which `sync_issue_dependencies` calls with live state
416    if store
417        .blocker_snapshot(repo, number)
418        .await?
419        .is_some_and(|snap| snap.is_satisfied())
420    {
421        return Ok(());
422    }
423
424    let key = blocker_read_key(repo, number);
425    let now = chrono::Utc::now().timestamp();
426    if let Ok(Some(prev)) = store.kv_get(&key).await
427        && let Ok(prev) = prev.parse::<i64>()
428        && now.saturating_sub(prev) < blocker_refresh_ttl_secs(repo, number)
429    {
430        return Ok(());
431    }
432
433    store.kv_set(&key, &now.to_string()).await?;
434    let close = match backend.issue_close_state(repo, number).await {
435        Ok(state) => state,
436        Err(err) => {
437            tracing::warn!(repo, number, err = %err, "issue close state read failed");
438            None
439        }
440    };
441    let task = store.get_by_external_id(repo, &number.to_string()).await?;
442    let pr_number = task
443        .as_ref()
444        .and_then(|row| row.pr_number)
445        .filter(|n| *n > 0);
446    if close.is_none() && pr_number.is_none() {
447        return Ok(());
448    }
449    let (issue_state, reason) = match close {
450        Some((state, reason)) => (state, reason),
451        None => match store.blocker_snapshot(repo, number).await? {
452            Some(snap) => (snap.issue_state, snap.state_reason),
453            None => ("open".to_string(), None),
454        },
455    };
456    let observed = if let Some(pr) = pr_number {
457        let completion = match backend.pull_request_completion(repo, i64::from(pr)).await {
458            Ok(value) => value,
459            Err(err) => {
460                tracing::warn!(repo, number, pr, err = %err, "pull request completion read failed");
461                None
462            }
463        };
464        Some(observe_completion(completion))
465    } else {
466        None
467    };
468    let snap = BlockerSnapshot::observe(&issue_state, reason.as_deref(), observed.as_ref());
469    store.save_blocker_snapshot(repo, number, &snap).await
470}
471
472fn observe_completion(completion: Option<PullRequestCompletion>) -> ObservedPullRequest {
473    match completion {
474        Some(done) => ObservedPullRequest {
475            merged: done.merged,
476            base_is_default: !done.default_branch.is_empty()
477                && done.base_ref == done.default_branch,
478            approved: done.approved,
479            closed_unmerged: done.closed && !done.merged,
480        },
481        None => ObservedPullRequest {
482            merged: false,
483            base_is_default: false,
484            approved: false,
485            closed_unmerged: false,
486        },
487    }
488}
489
490/// True when dispatch must not start this task. A check error also skips dispatch
491pub async fn dispatch_is_waiting(store: &TaskStore, repo: &str, external_id: &str) -> bool {
492    let id = match store.resolve_id_by_external(repo, external_id).await {
493        Ok(Some(id)) => id,
494        Ok(None) => return false,
495        Err(err) => {
496            tracing::warn!(
497                task_id = external_id,
498                err = %err,
499                "dependency check failed, skipping dispatch"
500            );
501            return true;
502        }
503    };
504    match store.block_claim_if_waiting(id).await {
505        Ok(Some(blockers)) => {
506            tracing::info!(
507                task_id = external_id,
508                blockers = %blockers.join(", "),
509                "skipping dispatch, waiting on a dependency"
510            );
511            true
512        }
513        Ok(None) => false,
514        Err(err) => {
515            tracing::warn!(
516                task_id = external_id,
517                err = %err,
518                "dependency check failed, skipping dispatch"
519            );
520            true
521        }
522    }
523}
524
525/// Stop a manual route, run, or retry while dependencies are open.
526pub async fn refuse_if_waiting(store: &TaskStore, task_id: i64) -> anyhow::Result<()> {
527    if let Some(blockers) = store.block_claim_if_waiting(task_id).await? {
528        anyhow::bail!("task {task_id} is waiting on {}", blockers.join(", "));
529    }
530    Ok(())
531}
532
533pub async fn emit_dependency_notices(
534    backend: &dyn ExternalBackend,
535    store: &TaskStore,
536    transport: Option<&Transport>,
537) -> anyhow::Result<Vec<DepNotice>> {
538    let mut notices = Vec::new();
539    notices.extend(cycle_notices(store).await?);
540    notices.extend(failure_notices(store).await?);
541    for notice in &notices {
542        if notice.issue_number > 0 && !notice.comment.is_empty() {
543            let id = ExternalId(notice.issue_number.to_string());
544            if let Err(err) = backend
545                .post_comment_in_repo(&notice.repo, &id, &notice.comment)
546                .await
547            {
548                tracing::warn!(
549                    repo = %notice.repo,
550                    issue = notice.issue_number,
551                    err = %err,
552                    "failed to comment on dependency notice"
553                );
554            }
555        }
556        if notice.notify
557            && let Some(transport) = transport
558        {
559            transport.push_notification(TaskNotification {
560                task_id: if notice.issue_number > 0 {
561                    notice.issue_number.to_string()
562                } else {
563                    notice.task_id.to_string()
564                },
565                title: notice.title.clone(),
566                status: "failed".to_string(),
567                agent: "orch".to_string(),
568                duration_seconds: 0.0,
569                summary: notice.summary.clone(),
570                repo: Some(notice.repo.clone()),
571                notify_target: None,
572                pr_number: None,
573            });
574        }
575    }
576    Ok(notices)
577}
578
579async fn cycle_notices(store: &TaskStore) -> anyhow::Result<Vec<DepNotice>> {
580    let edges = store.list_dependency_edges().await?;
581    let mut snap_cache: HashMap<(String, i64), Option<BlockerSnapshot>> = HashMap::new();
582    let mut adj: HashMap<String, Vec<String>> = HashMap::new();
583    let mut issue_for_node: HashMap<String, (String, i64, i64, String)> = HashMap::new();
584
585    for edge in &edges {
586        let waiter = match edge
587            .task_external_id
588            .as_deref()
589            .and_then(|id| id.parse::<i64>().ok())
590        {
591            Some(number) if number > 0 => format!("{}#{}", edge.task_repo, number),
592            _ => format!("task:{}", edge.task_id),
593        };
594        if let Some(number) = edge
595            .task_external_id
596            .as_deref()
597            .and_then(|id| id.parse::<i64>().ok())
598            .filter(|number| *number > 0)
599        {
600            issue_for_node.insert(
601                waiter.clone(),
602                (
603                    edge.task_repo.clone(),
604                    number,
605                    edge.task_id,
606                    edge.task_title.clone(),
607                ),
608            );
609        }
610        let blocker_node = format!("{}#{}", edge.depends_on_repo, edge.depends_on_number);
611        let snap = cached_snapshot(
612            store,
613            &mut snap_cache,
614            &edge.depends_on_repo,
615            edge.depends_on_number,
616        )
617        .await?;
618        if blocker_is_satisfied(snap.as_ref()) {
619            continue;
620        }
621        adj.entry(waiter).or_default().push(blocker_node);
622    }
623
624    let mut notices = Vec::new();
625    for cycle in find_cycles(&adj) {
626        let signature = {
627            let mut nodes = cycle.clone();
628            nodes.sort();
629            nodes.join(",")
630        };
631        let mut named = cycle.clone();
632        if let Some(first) = named.first().cloned() {
633            named.push(first);
634        }
635        let path = named.join(" -> ");
636        let comment = format!(
637            "Dependency cycle: {path}. Orch will not run these tasks until the cycle is removed.{}",
638            engine::orch_footer()
639        );
640        for node in &cycle {
641            let Some((repo, number, task_id, title)) = issue_for_node.get(node) else {
642                continue;
643            };
644            let key = format!("dep_cycle_comment:{repo}#{number}:{signature}");
645            if store.kv_get(&key).await?.is_some() {
646                continue;
647            }
648            store.kv_set(&key, "1").await?;
649            notices.push(DepNotice {
650                repo: repo.clone(),
651                issue_number: *number,
652                comment: comment.clone(),
653                notify: false,
654                summary: comment.clone(),
655                task_id: *task_id,
656                title: title.clone(),
657            });
658        }
659    }
660    Ok(notices)
661}
662
663async fn failure_notices(store: &TaskStore) -> anyhow::Result<Vec<DepNotice>> {
664    let edges = store.list_dependency_edges().await?;
665    let mut status_cache: HashMap<(String, i64), Option<String>> = HashMap::new();
666    let mut snap_cache: HashMap<(String, i64), Option<BlockerSnapshot>> = HashMap::new();
667    let mut notices = Vec::new();
668    for edge in edges {
669        let status = blocker_status(
670            store,
671            &mut status_cache,
672            &edge.depends_on_repo,
673            edge.depends_on_number,
674        )
675        .await?;
676        let snap = cached_snapshot(
677            store,
678            &mut snap_cache,
679            &edge.depends_on_repo,
680            edge.depends_on_number,
681        )
682        .await?;
683        let Some(kind) = blocker_failure_kind(status.as_deref(), snap.as_ref()) else {
684            continue;
685        };
686        let key = format!(
687            "dep_blocker_notice:{}:{}#{}:{kind}",
688            edge.task_id, edge.depends_on_repo, edge.depends_on_number
689        );
690        if store.kv_get(&key).await?.is_some() {
691            continue;
692        }
693        store.kv_set(&key, "1").await?;
694        let label = dep_label(
695            &edge.task_repo,
696            &edge.depends_on_repo,
697            edge.depends_on_number,
698        );
699        let (comment, summary) = if kind == "not_planned" {
700            let text = format!(
701                "Blocked by {label}, which was closed as not planned. This task stays waiting."
702            );
703            (format!("{text}{}", engine::orch_footer()), text)
704        } else {
705            let text = format!(
706                "Blocked by {label}, which is blocked and needs a person. This task stays waiting."
707            );
708            (format!("{text}{}", engine::orch_footer()), text)
709        };
710        let issue_number = edge
711            .task_external_id
712            .as_deref()
713            .and_then(|id| id.parse::<i64>().ok())
714            .filter(|number| *number > 0)
715            .unwrap_or(0);
716        notices.push(DepNotice {
717            repo: edge.task_repo,
718            issue_number,
719            comment,
720            notify: true,
721            summary,
722            task_id: edge.task_id,
723            title: edge.task_title,
724        });
725    }
726    Ok(notices)
727}
728
729async fn cached_snapshot(
730    store: &TaskStore,
731    cache: &mut HashMap<(String, i64), Option<BlockerSnapshot>>,
732    repo: &str,
733    number: i64,
734) -> anyhow::Result<Option<BlockerSnapshot>> {
735    if let Some(found) = cache.get(&(repo.to_string(), number)) {
736        return Ok(found.clone());
737    }
738    let snap = store.blocker_snapshot(repo, number).await?;
739    cache.insert((repo.to_string(), number), snap.clone());
740    Ok(snap)
741}
742
743async fn blocker_status(
744    store: &TaskStore,
745    cache: &mut HashMap<(String, i64), Option<String>>,
746    repo: &str,
747    number: i64,
748) -> anyhow::Result<Option<String>> {
749    if let Some(status) = cache.get(&(repo.to_string(), number)) {
750        return Ok(status.clone());
751    }
752    let status = store
753        .get_by_external_id(repo, &number.to_string())
754        .await?
755        .map(|task| task.status.as_str().to_string());
756    cache.insert((repo.to_string(), number), status.clone());
757    Ok(status)
758}
759
760fn find_cycles(adj: &HashMap<String, Vec<String>>) -> Vec<Vec<String>> {
761    let mut nodes: Vec<String> = adj.keys().cloned().collect();
762    for targets in adj.values() {
763        for target in targets {
764            if !nodes.iter().any(|node| node == target) {
765                nodes.push(target.clone());
766            }
767        }
768    }
769    nodes.sort();
770    let mut color: HashMap<String, u8> = HashMap::new();
771    let mut stack = Vec::new();
772    let mut cycles = Vec::new();
773    let mut seen = HashSet::new();
774    for node in nodes {
775        if color.get(&node).copied().unwrap_or(0) == 0 {
776            visit(&node, adj, &mut color, &mut stack, &mut cycles, &mut seen);
777        }
778    }
779    cycles
780}
781
782fn visit(
783    node: &str,
784    adj: &HashMap<String, Vec<String>>,
785    color: &mut HashMap<String, u8>,
786    stack: &mut Vec<String>,
787    cycles: &mut Vec<Vec<String>>,
788    seen: &mut HashSet<String>,
789) {
790    color.insert(node.to_string(), 1);
791    stack.push(node.to_string());
792    if let Some(nexts) = adj.get(node) {
793        for next in nexts {
794            match color.get(next).copied().unwrap_or(0) {
795                1 => {
796                    if let Some(start) = stack.iter().position(|item| item == next) {
797                        let mut cycle = stack[start..].to_vec();
798                        if let Some(min_at) = cycle
799                            .iter()
800                            .enumerate()
801                            .min_by(|left, right| left.1.cmp(right.1))
802                            .map(|(index, _)| index)
803                        {
804                            cycle.rotate_left(min_at);
805                        }
806                        let signature = {
807                            let mut sorted = cycle.clone();
808                            sorted.sort();
809                            sorted.join(",")
810                        };
811                        if seen.insert(signature) {
812                            cycles.push(cycle);
813                        }
814                    }
815                }
816                0 => visit(next, adj, color, stack, cycles, seen),
817                _ => {}
818            }
819        }
820    }
821    stack.pop();
822    color.insert(node.to_string(), 2);
823}
824
825#[cfg(test)]
826mod tests {
827    use super::*;
828    use crate::backends::PullRequestCompletion;
829    use crate::backends::github;
830    use crate::backends::github::AutomatedReviewComment;
831    use crate::backends::test_helpers::NoopBackend;
832    use crate::backends::{BlockedByIssue, ExternalTask, Status};
833    use crate::engine::cooldown;
834    use crate::engine::router::Router;
835    use crate::engine::router::RouterConfig;
836    use crate::engine::tasks;
837    use crate::engine::tasks::TaskManager;
838    use crate::engine::tasks::store_task_to_external;
839    use crate::engine::tick::RoutingTickQuota;
840    use crate::engine::tick::tick_route_tasks;
841    use crate::store::WaitingOnDependency;
842    use crate::store::{NewTask, TaskStatus};
843    use async_trait::async_trait;
844    use std::sync::atomic::AtomicUsize;
845    use std::sync::atomic::Ordering;
846    use std::sync::{Arc, Mutex};
847
848    #[test]
849    fn body_parser_reads_the_three_forms_and_skips_fences() {
850        let body = "\
851Depends on #12
852Blocked by #12, #13
853Depends on owner/repo#12
854```
855Depends on #99
856```
857See #4 in the notes
858";
859        let deps = parse_body_dependencies(body, "acme/app");
860        assert_eq!(
861            deps,
862            vec![
863                DepRef {
864                    repo: "acme/app".into(),
865                    number: 12
866                },
867                DepRef {
868                    repo: "acme/app".into(),
869                    number: 13
870                },
871                DepRef {
872                    repo: "owner/repo".into(),
873                    number: 12
874                },
875            ]
876        );
877    }
878
879    #[derive(Clone)]
880    struct ScriptedReviewComment {
881        login: String,
882        body: String,
883        created_at: String,
884    }
885
886    struct ScriptedBackend {
887        blocked: Mutex<HashMap<i64, BlockedByFetch>>,
888        comments: Mutex<Vec<(String, String)>>,
889        close_state: Mutex<HashMap<i64, (String, Option<String>)>>,
890        pulls: Mutex<HashMap<i64, Option<PullRequestCompletion>>>,
891        review_comments: Mutex<HashMap<i64, Vec<ScriptedReviewComment>>>,
892        reviewer: Mutex<Option<String>>,
893        /// Counts the blocker reads that cost a GitHub call.
894        reads: AtomicUsize,
895    }
896
897    impl ScriptedBackend {
898        fn new(blocked: HashMap<i64, BlockedByFetch>) -> Self {
899            Self {
900                blocked: Mutex::new(blocked),
901                comments: Mutex::new(Vec::new()),
902                close_state: Mutex::new(HashMap::new()),
903                pulls: Mutex::new(HashMap::new()),
904                review_comments: Mutex::new(HashMap::new()),
905                reads: AtomicUsize::new(0),
906                reviewer: Mutex::new(None),
907            }
908        }
909    }
910
911    #[async_trait]
912    impl ExternalBackend for ScriptedBackend {
913        fn name(&self) -> &'static str {
914            "scripted"
915        }
916
917        async fn create_task(
918            &self,
919            _title: &str,
920            _body: &str,
921            _labels: &[String],
922        ) -> anyhow::Result<ExternalId> {
923            Ok(ExternalId("1".into()))
924        }
925
926        async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask> {
927            Ok(ExternalTask {
928                id: id.clone(),
929                title: "t".into(),
930                body: String::new(),
931                state: "open".into(),
932                labels: vec![],
933                author: "bot".into(),
934                created_at: "2026-01-01T00:00:00Z".into(),
935                updated_at: "2026-01-01T00:00:00Z".into(),
936                url: String::new(),
937            })
938        }
939
940        async fn list_by_status(&self, _status: Status) -> anyhow::Result<Vec<ExternalTask>> {
941            Ok(vec![])
942        }
943
944        async fn post_comment(&self, id: &ExternalId, body: &str) -> anyhow::Result<()> {
945            self.comments
946                .lock()
947                .unwrap()
948                .push((id.0.clone(), body.to_string()));
949            Ok(())
950        }
951
952        async fn set_labels(&self, _id: &ExternalId, _labels: &[String]) -> anyhow::Result<()> {
953            Ok(())
954        }
955
956        async fn remove_label(&self, _id: &ExternalId, _label: &str) -> anyhow::Result<()> {
957            Ok(())
958        }
959
960        async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
961            Ok(vec![])
962        }
963
964        async fn health_check(&self) -> anyhow::Result<()> {
965            Ok(())
966        }
967
968        async fn blocked_by(
969            &self,
970            _repo: &str,
971            issue_number: i64,
972        ) -> anyhow::Result<BlockedByFetch> {
973            Ok(self
974                .blocked
975                .lock()
976                .unwrap()
977                .get(&issue_number)
978                .cloned()
979                .unwrap_or(BlockedByFetch::Unavailable))
980        }
981
982        async fn issue_close_state(
983            &self,
984            _repo: &str,
985            issue_number: i64,
986        ) -> anyhow::Result<Option<(String, Option<String>)>> {
987            self.reads.fetch_add(1, Ordering::Relaxed);
988            Ok(self.close_state.lock().unwrap().get(&issue_number).cloned())
989        }
990
991        async fn pull_request_completion(
992            &self,
993            _repo: &str,
994            pr_number: i64,
995        ) -> anyhow::Result<Option<PullRequestCompletion>> {
996            self.reads.fetch_add(1, Ordering::Relaxed);
997            let mut completion = self
998                .pulls
999                .lock()
1000                .unwrap()
1001                .get(&pr_number)
1002                .cloned()
1003                .unwrap_or(None);
1004            if let Some(ref mut done) = completion
1005                && !done.approved
1006            {
1007                let comments = self
1008                    .review_comments
1009                    .lock()
1010                    .unwrap()
1011                    .get(&pr_number)
1012                    .cloned()
1013                    .unwrap_or_default();
1014                let reviewer = self.reviewer.lock().unwrap().clone().unwrap_or_default();
1015                let mapped: Vec<AutomatedReviewComment<'_>> = comments
1016                    .iter()
1017                    .map(|comment| crate::backends::github::AutomatedReviewComment {
1018                        login: &comment.login,
1019                        body: &comment.body,
1020                        created_at: &comment.created_at,
1021                    })
1022                    .collect();
1023                done.approved = github::latest_automated_review_approves(&mapped, &reviewer);
1024            }
1025            Ok(completion)
1026        }
1027    }
1028
1029    async fn insert(store: &TaskStore, repo: &str, number: &str, title: &str, body: &str) -> i64 {
1030        store
1031            .create(&NewTask {
1032                external_id: Some(number.to_string()),
1033                repo: repo.to_string(),
1034                origin: "github".to_string(),
1035                title: title.to_string(),
1036                body: body.to_string(),
1037                source: "manual".to_string(),
1038                source_id: String::new(),
1039                author: String::new(),
1040                url: String::new(),
1041                labels: vec![],
1042                parent_id: None,
1043            })
1044            .await
1045            .unwrap()
1046    }
1047
1048    fn native(repo: &str, number: i64, state: &str, reason: Option<&str>) -> BlockedByIssue {
1049        BlockedByIssue {
1050            github_id: number,
1051            repo: repo.to_string(),
1052            number,
1053            state: state.to_string(),
1054            state_reason: reason.map(str::to_string),
1055        }
1056    }
1057
1058    #[tokio::test]
1059    async fn native_list_replaces_rows_and_empty_native_clears_them() {
1060        let store = TaskStore::open_memory().await.unwrap();
1061        let b = insert(&store, "owner/repo", "2", "B", "Depends on #1").await;
1062        let mut blocked = HashMap::new();
1063        blocked.insert(
1064            2,
1065            BlockedByFetch::Native(vec![native("owner/repo", 1, "open", None)]),
1066        );
1067        let backend = ScriptedBackend::new(blocked);
1068        sync_issue_dependencies(&backend, &store, b, "owner/repo", Some(2), "Depends on #1")
1069            .await
1070            .unwrap();
1071        let deps = store.list_dependencies(b).await.unwrap();
1072        assert_eq!(deps.len(), 1);
1073        assert_eq!(deps[0].source, "github");
1074
1075        backend
1076            .blocked
1077            .lock()
1078            .unwrap()
1079            .insert(2, BlockedByFetch::Native(vec![]));
1080        sync_issue_dependencies(&backend, &store, b, "owner/repo", Some(2), "")
1081            .await
1082            .unwrap();
1083        assert!(store.list_dependencies(b).await.unwrap().is_empty());
1084        apply_claim_gates(&store).await.unwrap();
1085        assert_eq!(store.get(b).await.unwrap().status, TaskStatus::New);
1086    }
1087
1088    #[tokio::test]
1089    async fn unavailable_keeps_rows_when_the_body_has_no_deps_and_failed_keeps_rows() {
1090        let store = TaskStore::open_memory().await.unwrap();
1091        let b = insert(&store, "owner/repo", "2", "B", "").await;
1092        store
1093            .replace_dependencies(
1094                b,
1095                &[NewDependency {
1096                    repo: "owner/repo".into(),
1097                    number: 1,
1098                    source: DepSource::Cli,
1099                }],
1100            )
1101            .await
1102            .unwrap();
1103        let backend = ScriptedBackend::new(HashMap::new());
1104        sync_issue_dependencies(&backend, &store, b, "owner/repo", Some(2), "")
1105            .await
1106            .unwrap();
1107        assert_eq!(store.list_dependencies(b).await.unwrap()[0].source, "cli");
1108
1109        backend
1110            .blocked
1111            .lock()
1112            .unwrap()
1113            .insert(2, BlockedByFetch::Failed("boom".into()));
1114        sync_issue_dependencies(&backend, &store, b, "owner/repo", Some(2), "Depends on #9")
1115            .await
1116            .unwrap();
1117        assert_eq!(
1118            store.list_dependencies(b).await.unwrap()[0].depends_on_number,
1119            1
1120        );
1121
1122        backend
1123            .blocked
1124            .lock()
1125            .unwrap()
1126            .insert(2, BlockedByFetch::Native(vec![]));
1127        sync_issue_dependencies(&backend, &store, b, "owner/repo", Some(2), "")
1128            .await
1129            .unwrap();
1130        assert_eq!(store.list_dependencies(b).await.unwrap()[0].source, "cli");
1131    }
1132
1133    #[tokio::test]
1134    async fn body_lines_become_rows_in_all_three_forms() {
1135        let store = TaskStore::open_memory().await.unwrap();
1136        let b = insert(
1137            &store,
1138            "acme/app",
1139            "8",
1140            "B",
1141            "Blocked by #12, #13\nDepends on owner/repo#12",
1142        )
1143        .await;
1144        let backend = ScriptedBackend::new(HashMap::new());
1145        sync_issue_dependencies(
1146            &backend,
1147            &store,
1148            b,
1149            "acme/app",
1150            Some(8),
1151            "Blocked by #12, #13\nDepends on owner/repo#12",
1152        )
1153        .await
1154        .unwrap();
1155        let deps = store.list_dependencies(b).await.unwrap();
1156        let labels: Vec<_> = deps
1157            .iter()
1158            .map(|dep| format!("{}#{}", dep.depends_on_repo, dep.depends_on_number))
1159            .collect();
1160        assert_eq!(
1161            labels,
1162            vec![
1163                "acme/app#12".to_string(),
1164                "acme/app#13".to_string(),
1165                "owner/repo#12".to_string()
1166            ]
1167        );
1168        assert!(deps.iter().all(|dep| dep.source == "body"));
1169    }
1170
1171    #[tokio::test]
1172    async fn body_dependency_is_stored_when_the_native_list_is_empty() {
1173        let store = TaskStore::open_memory().await.unwrap();
1174        let b = insert(&store, "owner/repo", "2", "B", "Depends on #1").await;
1175        let mut blocked = HashMap::new();
1176        blocked.insert(2, BlockedByFetch::Native(vec![]));
1177        let backend = ScriptedBackend::new(blocked);
1178        sync_issue_dependencies(&backend, &store, b, "owner/repo", Some(2), "Depends on #1")
1179            .await
1180            .unwrap();
1181        let deps = store.list_dependencies(b).await.unwrap();
1182        assert_eq!(deps.len(), 1);
1183        assert_eq!(deps[0].depends_on_number, 1);
1184        assert_eq!(deps[0].source, "body");
1185        apply_claim_gates(&store).await.unwrap();
1186        assert_eq!(store.get(b).await.unwrap().status, TaskStatus::Waiting);
1187    }
1188
1189    #[tokio::test]
1190    async fn native_and_body_dependencies_are_both_stored() {
1191        let store = TaskStore::open_memory().await.unwrap();
1192        let body = "Depends on #3\nBlocked by #1";
1193        let b = insert(&store, "owner/repo", "2", "B", body).await;
1194        let mut blocked = HashMap::new();
1195        blocked.insert(
1196            2,
1197            BlockedByFetch::Native(vec![native("owner/repo", 1, "open", None)]),
1198        );
1199        let backend = ScriptedBackend::new(blocked);
1200        sync_issue_dependencies(&backend, &store, b, "owner/repo", Some(2), body)
1201            .await
1202            .unwrap();
1203        let deps = store.list_dependencies(b).await.unwrap();
1204        let rows: Vec<_> = deps
1205            .iter()
1206            .map(|dep| (dep.depends_on_number, dep.source.as_str()))
1207            .collect();
1208        assert_eq!(rows, vec![(1, "github"), (3, "body")]);
1209    }
1210
1211    #[tokio::test]
1212    async fn removing_the_body_line_drops_only_the_body_row() {
1213        let store = TaskStore::open_memory().await.unwrap();
1214        let b = insert(&store, "owner/repo", "2", "B", "Depends on #3").await;
1215        let mut blocked = HashMap::new();
1216        blocked.insert(
1217            2,
1218            BlockedByFetch::Native(vec![native("owner/repo", 1, "open", None)]),
1219        );
1220        let backend = ScriptedBackend::new(blocked);
1221        sync_issue_dependencies(&backend, &store, b, "owner/repo", Some(2), "Depends on #3")
1222            .await
1223            .unwrap();
1224        assert_eq!(store.list_dependencies(b).await.unwrap().len(), 2);
1225
1226        sync_issue_dependencies(&backend, &store, b, "owner/repo", Some(2), "")
1227            .await
1228            .unwrap();
1229        let deps = store.list_dependencies(b).await.unwrap();
1230        assert_eq!(deps.len(), 1);
1231        assert_eq!(deps[0].depends_on_number, 1);
1232        assert_eq!(deps[0].source, "github");
1233    }
1234
1235    #[tokio::test]
1236    async fn an_issue_naming_itself_in_the_body_stores_no_row() {
1237        let store = TaskStore::open_memory().await.unwrap();
1238        let b = insert(&store, "owner/repo", "2", "B", "Depends on #2").await;
1239        let backend = ScriptedBackend::new(HashMap::new());
1240        sync_issue_dependencies(&backend, &store, b, "owner/repo", Some(2), "Depends on #2")
1241            .await
1242            .unwrap();
1243        assert!(store.list_dependencies(b).await.unwrap().is_empty());
1244    }
1245
1246    #[tokio::test]
1247    async fn cycle_comments_once_and_neither_task_is_claimable() {
1248        let store = TaskStore::open_memory().await.unwrap();
1249        let a = insert(&store, "owner/repo", "1", "A", "").await;
1250        let b = insert(&store, "owner/repo", "2", "B", "").await;
1251        let mut blocked = HashMap::new();
1252        blocked.insert(
1253            1,
1254            BlockedByFetch::Native(vec![native("owner/repo", 2, "open", None)]),
1255        );
1256        blocked.insert(
1257            2,
1258            BlockedByFetch::Native(vec![native("owner/repo", 1, "open", None)]),
1259        );
1260        let backend = ScriptedBackend::new(blocked);
1261        sync_issue_dependencies(&backend, &store, a, "owner/repo", Some(1), "")
1262            .await
1263            .unwrap();
1264        sync_issue_dependencies(&backend, &store, b, "owner/repo", Some(2), "")
1265            .await
1266            .unwrap();
1267        apply_claim_gates(&store).await.unwrap();
1268        assert_eq!(store.get(a).await.unwrap().status, TaskStatus::Waiting);
1269        assert_eq!(store.get(b).await.unwrap().status, TaskStatus::Waiting);
1270        let first = emit_dependency_notices(&backend, &store, None)
1271            .await
1272            .unwrap();
1273        assert_eq!(first.len(), 2);
1274        assert!(
1275            first
1276                .iter()
1277                .all(|notice| notice.comment.contains("owner/repo#1"))
1278        );
1279        assert!(
1280            first
1281                .iter()
1282                .all(|notice| notice.comment.contains("owner/repo#2"))
1283        );
1284        let second = emit_dependency_notices(&backend, &store, None)
1285            .await
1286            .unwrap();
1287        assert!(second.is_empty(), "{second:?}");
1288        assert_eq!(backend.comments.lock().unwrap().len(), 2);
1289    }
1290
1291    #[tokio::test]
1292    async fn not_planned_keeps_waiting_and_notifies_once() {
1293        let store = TaskStore::open_memory().await.unwrap();
1294        let b = insert(&store, "owner/repo", "2", "B", "").await;
1295        let mut blocked = HashMap::new();
1296        blocked.insert(
1297            2,
1298            BlockedByFetch::Native(vec![native("owner/repo", 1, "closed", Some("not_planned"))]),
1299        );
1300        let backend = ScriptedBackend::new(blocked);
1301        let transport = Transport::new();
1302        let mut rx = transport.subscribe_notifications();
1303        sync_issue_dependencies(&backend, &store, b, "owner/repo", Some(2), "")
1304            .await
1305            .unwrap();
1306        apply_claim_gates(&store).await.unwrap();
1307        assert_eq!(store.get(b).await.unwrap().status, TaskStatus::Waiting);
1308        let notices = emit_dependency_notices(&backend, &store, Some(&transport))
1309            .await
1310            .unwrap();
1311        assert_eq!(notices.len(), 1);
1312        assert!(notices[0].notify);
1313        assert!(notices[0].summary.contains("not planned"));
1314        let again = emit_dependency_notices(&backend, &store, Some(&transport))
1315            .await
1316            .unwrap();
1317        assert!(again.is_empty(), "{again:?}");
1318        let note = rx.try_recv().unwrap();
1319        assert_eq!(note.status, "failed");
1320        assert!(rx.try_recv().is_err());
1321        assert_eq!(backend.comments.lock().unwrap().len(), 1);
1322    }
1323
1324    #[tokio::test]
1325    async fn waiting_task_is_not_routed_until_the_blocker_is_done() {
1326        cooldown::reset_global_state().await;
1327        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1328        let a = insert(&store, "owner/repo", "1", "A", "").await;
1329        let b = insert(&store, "owner/repo", "2", "B", "").await;
1330        store
1331            .replace_dependencies(
1332                b,
1333                &[NewDependency {
1334                    repo: "owner/repo".into(),
1335                    number: 1,
1336                    source: DepSource::Github,
1337                }],
1338            )
1339            .await
1340            .unwrap();
1341
1342        let backend: Arc<dyn ExternalBackend> = Arc::new(NoopBackend);
1343        let task_manager = Arc::new(TaskManager::with_store(
1344            Arc::clone(&backend),
1345            Arc::clone(&store),
1346            "owner/repo".into(),
1347        ));
1348        let mut tier = HashMap::new();
1349        tier.insert("claude".to_string(), vec!["haiku".to_string()]);
1350        let mut model_map = HashMap::new();
1351        model_map.insert("simple".into(), tier.clone());
1352        model_map.insert("medium".into(), tier.clone());
1353        model_map.insert("complex".into(), tier);
1354        let round_robin = RouterConfig {
1355            mode: "round_robin".into(),
1356            agents: vec!["claude".into()],
1357            model_map: model_map.clone(),
1358            ..Default::default()
1359        };
1360        let mut router = Router::new_for_test(round_robin, vec!["claude".into()]);
1361        let mut quota = RoutingTickQuota::new(10);
1362        tick_route_tasks(
1363            &backend,
1364            &task_manager,
1365            &mut router,
1366            &store,
1367            "owner/repo",
1368            &mut quota,
1369        )
1370        .await
1371        .unwrap();
1372        let waiting = store.get(b).await.unwrap();
1373        assert_eq!(waiting.status, TaskStatus::Waiting);
1374        assert!(waiting.agent.is_none());
1375
1376        let llm = RouterConfig {
1377            mode: "llm".into(),
1378            max_route_attempts: 0,
1379            agents: vec!["claude".into()],
1380            model_map,
1381            ..Default::default()
1382        };
1383        let mut llm_router = Router::new_for_test(llm, vec!["claude".into()]);
1384        let ext = tasks::store_task_to_external(&store.get(b).await.unwrap());
1385        store.update_status(b, TaskStatus::New).await.unwrap();
1386        let err = llm_router
1387            .route(&ext, &store, "owner/repo")
1388            .await
1389            .unwrap_err();
1390        assert!(err.downcast_ref::<WaitingOnDependency>().is_some());
1391        assert_eq!(store.get(b).await.unwrap().status, TaskStatus::Waiting);
1392        assert_eq!(store.get(b).await.unwrap().route_attempts, 0);
1393
1394        store.update_status(a, TaskStatus::Done).await.unwrap();
1395        let mut quota = RoutingTickQuota::new(10);
1396        tick_route_tasks(
1397            &backend,
1398            &task_manager,
1399            &mut router,
1400            &store,
1401            "owner/repo",
1402            &mut quota,
1403        )
1404        .await
1405        .unwrap();
1406        assert_eq!(store.get(b).await.unwrap().status, TaskStatus::Waiting);
1407
1408        store
1409            .save_blocker_snapshot(
1410                "owner/repo",
1411                1,
1412                &BlockerSnapshot::observe(
1413                    "closed",
1414                    Some("completed"),
1415                    Some(&ObservedPullRequest {
1416                        merged: true,
1417                        base_is_default: true,
1418                        approved: true,
1419                        closed_unmerged: false,
1420                    }),
1421                ),
1422            )
1423            .await
1424            .unwrap();
1425        let mut quota = RoutingTickQuota::new(10);
1426        tick_route_tasks(
1427            &backend,
1428            &task_manager,
1429            &mut router,
1430            &store,
1431            "owner/repo",
1432            &mut quota,
1433        )
1434        .await
1435        .unwrap();
1436        let routed = store.get(b).await.unwrap();
1437        assert_eq!(routed.status, TaskStatus::Routed);
1438        assert!(routed.agent.is_some());
1439    }
1440
1441    #[tokio::test]
1442    async fn round_robin_route_returns_waiting_on_dependency() {
1443        cooldown::reset_global_state().await;
1444        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1445        let b = insert(&store, "owner/repo", "2", "B", "").await;
1446        store
1447            .replace_dependencies(
1448                b,
1449                &[NewDependency {
1450                    repo: "owner/repo".into(),
1451                    number: 1,
1452                    source: DepSource::Github,
1453                }],
1454            )
1455            .await
1456            .unwrap();
1457        let ext = store_task_to_external(&store.get(b).await.unwrap());
1458        let mut tier = HashMap::new();
1459        tier.insert("claude".to_string(), vec!["haiku".to_string()]);
1460        let mut model_map = HashMap::new();
1461        model_map.insert("medium".into(), tier);
1462        let config = RouterConfig {
1463            mode: "round_robin".into(),
1464            agents: vec!["claude".into()],
1465            model_map,
1466            ..Default::default()
1467        };
1468        let mut router = Router::new_for_test(config, vec!["claude".into()]);
1469        let err = router.route(&ext, &store, "owner/repo").await.unwrap_err();
1470        assert!(err.downcast_ref::<WaitingOnDependency>().is_some());
1471        assert_eq!(store.get(b).await.unwrap().status, TaskStatus::Waiting);
1472        assert_eq!(store.get(b).await.unwrap().route_attempts, 0);
1473    }
1474
1475    async fn refresh_uncached(backend: &ScriptedBackend, store: &TaskStore) {
1476        store
1477            .kv_delete("dep_blocker_read_at:owner/repo#1")
1478            .await
1479            .unwrap();
1480        refresh_blocker_snapshots(backend, store).await.unwrap();
1481    }
1482
1483    #[tokio::test]
1484    async fn an_open_blocker_is_read_once_per_ttl() {
1485        let store = TaskStore::open_memory().await.unwrap();
1486        insert(&store, "owner/repo", "1", "A", "").await;
1487        let b = insert(&store, "owner/repo", "2", "B", "").await;
1488        store
1489            .replace_dependencies(
1490                b,
1491                &[NewDependency {
1492                    repo: "owner/repo".into(),
1493                    number: 1,
1494                    source: DepSource::Github,
1495                }],
1496            )
1497            .await
1498            .unwrap();
1499        let backend = ScriptedBackend::new(HashMap::new());
1500        backend
1501            .close_state
1502            .lock()
1503            .unwrap()
1504            .insert(1, ("open".into(), None));
1505        let reads = || backend.reads.load(Ordering::Relaxed);
1506
1507        refresh_blocker_snapshots(&backend, &store).await.unwrap();
1508        let first = reads();
1509        assert!(first > 0, "the first refresh has to read the blocker");
1510
1511        refresh_blocker_snapshots(&backend, &store).await.unwrap();
1512        refresh_blocker_snapshots(&backend, &store).await.unwrap();
1513        assert_eq!(
1514            reads(),
1515            first,
1516            "a refresh inside the TTL must not call GitHub"
1517        );
1518
1519        let stale = chrono::Utc::now().timestamp()
1520            - (BLOCKER_REFRESH_TTL_SECS + BLOCKER_REFRESH_SPREAD_SECS);
1521        store
1522            .kv_set("dep_blocker_read_at:owner/repo#1", &stale.to_string())
1523            .await
1524            .unwrap();
1525        refresh_blocker_snapshots(&backend, &store).await.unwrap();
1526        assert!(
1527            reads() > first,
1528            "an expired entry has to read the blocker again"
1529        );
1530    }
1531
1532    /// Set up `#1` as an open blocker of `#2` and read it once, so the TTL is live
1533    async fn read_open_blocker_once(store: &TaskStore) -> Arc<ScriptedBackend> {
1534        let b = insert(store, "owner/repo", "2", "B", "").await;
1535        store
1536            .replace_dependencies(
1537                b,
1538                &[NewDependency {
1539                    repo: "owner/repo".into(),
1540                    number: 1,
1541                    source: DepSource::Github,
1542                }],
1543            )
1544            .await
1545            .unwrap();
1546        let backend = Arc::new(ScriptedBackend::new(HashMap::new()));
1547        backend
1548            .close_state
1549            .lock()
1550            .unwrap()
1551            .insert(1, ("open".into(), None));
1552        refresh_blocker_snapshots(&*backend, store).await.unwrap();
1553        backend
1554    }
1555
1556    #[tokio::test]
1557    async fn a_blockers_task_going_done_makes_the_next_refresh_read_it() {
1558        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1559        insert(&store, "owner/repo", "1", "A", "").await;
1560        let backend = read_open_blocker_once(&store).await;
1561        let reads = || backend.reads.load(Ordering::Relaxed);
1562        let first = reads();
1563        assert!(first > 0, "the first refresh has to read the blocker");
1564
1565        let task_manager = TaskManager::with_store(
1566            Arc::clone(&backend) as Arc<dyn ExternalBackend>,
1567            Arc::clone(&store),
1568            "owner/repo".into(),
1569        );
1570        task_manager
1571            .update_task_status(&ExternalId("1".into()), Status::Done)
1572            .await
1573            .unwrap();
1574
1575        refresh_blocker_snapshots(&*backend, &store).await.unwrap();
1576        assert!(
1577            reads() > first,
1578            "a blocker whose task is done has to be read inside the TTL"
1579        );
1580    }
1581
1582    #[tokio::test]
1583    async fn a_blocker_orch_does_not_manage_keeps_its_ttl() {
1584        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1585        let backend = read_open_blocker_once(&store).await;
1586        let reads = || backend.reads.load(Ordering::Relaxed);
1587        let first = reads();
1588        assert!(first > 0, "the first refresh has to read the blocker");
1589
1590        // `#1` has no task row here, so finishing the dependent must not clear its entry
1591        let task_manager = TaskManager::with_store(
1592            Arc::clone(&backend) as Arc<dyn ExternalBackend>,
1593            Arc::clone(&store),
1594            "owner/repo".into(),
1595        );
1596        task_manager
1597            .update_task_status(&ExternalId("2".into()), Status::Done)
1598            .await
1599            .unwrap();
1600
1601        refresh_blocker_snapshots(&*backend, &store).await.unwrap();
1602        assert_eq!(
1603            reads(),
1604            first,
1605            "a blocker orch does not manage is read once per TTL"
1606        );
1607    }
1608
1609    #[test]
1610    fn blocker_ttls_spread_within_a_bounded_range() {
1611        let ttls: Vec<i64> = (1..200)
1612            .map(|n| blocker_refresh_ttl_secs("owner/repo", n))
1613            .collect();
1614        for ttl in &ttls {
1615            assert!(
1616                (BLOCKER_REFRESH_TTL_SECS..BLOCKER_REFRESH_TTL_SECS + BLOCKER_REFRESH_SPREAD_SECS)
1617                    .contains(ttl),
1618                "{ttl} is outside the TTL range"
1619            );
1620        }
1621        let spread: HashSet<i64> = ttls.iter().copied().collect();
1622        assert!(spread.len() > 50, "the TTLs have to spread the reads out");
1623        assert_eq!(
1624            blocker_refresh_ttl_secs("owner/repo", 1),
1625            blocker_refresh_ttl_secs("owner/repo", 1),
1626            "one blocker keeps the same TTL across ticks"
1627        );
1628    }
1629
1630    #[tokio::test]
1631    async fn a_blocker_already_known_done_costs_no_github_call() {
1632        let store = TaskStore::open_memory().await.unwrap();
1633        insert(&store, "owner/repo", "1", "A", "").await;
1634        let b = insert(&store, "owner/repo", "2", "B", "").await;
1635        store
1636            .replace_dependencies(
1637                b,
1638                &[NewDependency {
1639                    repo: "owner/repo".into(),
1640                    number: 1,
1641                    source: DepSource::Github,
1642                }],
1643            )
1644            .await
1645            .unwrap();
1646        let backend = ScriptedBackend::new(HashMap::new());
1647        backend
1648            .close_state
1649            .lock()
1650            .unwrap()
1651            .insert(1, ("closed".into(), Some("completed".into())));
1652
1653        refresh_blocker_snapshots(&backend, &store).await.unwrap();
1654        let first = backend.reads.load(Ordering::Relaxed);
1655        assert!(first > 0, "the first refresh has to read the blocker");
1656        assert!(store.block_claim_if_waiting(b).await.unwrap().is_none());
1657
1658        refresh_blocker_snapshots(&backend, &store).await.unwrap();
1659        refresh_blocker_snapshots(&backend, &store).await.unwrap();
1660        assert_eq!(
1661            backend.reads.load(Ordering::Relaxed),
1662            first,
1663            "a blocker closed as completed must not be read again"
1664        );
1665    }
1666
1667    #[tokio::test]
1668    async fn refresh_holds_until_the_pr_is_merged_into_the_default_branch() {
1669        let store = TaskStore::open_memory().await.unwrap();
1670        let a = insert(&store, "owner/repo", "1", "A", "").await;
1671        let b = insert(&store, "owner/repo", "2", "B", "").await;
1672        store
1673            .set_fields(a, &[("pr_number", serde_json::json!(10))])
1674            .await
1675            .unwrap();
1676        store
1677            .replace_dependencies(
1678                b,
1679                &[NewDependency {
1680                    repo: "owner/repo".into(),
1681                    number: 1,
1682                    source: DepSource::Github,
1683                }],
1684            )
1685            .await
1686            .unwrap();
1687        let backend = ScriptedBackend::new(HashMap::new());
1688        backend
1689            .close_state
1690            .lock()
1691            .unwrap()
1692            .insert(1, ("closed".into(), Some("completed".into())));
1693
1694        let open_pr = PullRequestCompletion {
1695            merged: false,
1696            base_ref: "main".into(),
1697            default_branch: "main".into(),
1698            approved: false,
1699            closed: false,
1700        };
1701        backend.pulls.lock().unwrap().insert(10, Some(open_pr));
1702        refresh_blocker_snapshots(&backend, &store).await.unwrap();
1703        assert!(store.block_claim_if_waiting(b).await.unwrap().is_some());
1704
1705        backend.pulls.lock().unwrap().insert(
1706            10,
1707            Some(PullRequestCompletion {
1708                merged: false,
1709                base_ref: "main".into(),
1710                default_branch: "main".into(),
1711                approved: true,
1712                closed: false,
1713            }),
1714        );
1715        refresh_uncached(&backend, &store).await;
1716        assert!(store.block_claim_if_waiting(b).await.unwrap().is_some());
1717
1718        backend.pulls.lock().unwrap().insert(
1719            10,
1720            Some(PullRequestCompletion {
1721                merged: true,
1722                base_ref: "feature".into(),
1723                default_branch: "main".into(),
1724                approved: true,
1725                closed: true,
1726            }),
1727        );
1728        refresh_uncached(&backend, &store).await;
1729        assert!(store.block_claim_if_waiting(b).await.unwrap().is_some());
1730
1731        backend.pulls.lock().unwrap().insert(
1732            10,
1733            Some(PullRequestCompletion {
1734                merged: false,
1735                base_ref: "main".into(),
1736                default_branch: "main".into(),
1737                approved: true,
1738                closed: true,
1739            }),
1740        );
1741        refresh_uncached(&backend, &store).await;
1742        assert!(store.block_claim_if_waiting(b).await.unwrap().is_some());
1743
1744        backend.pulls.lock().unwrap().insert(
1745            10,
1746            Some(PullRequestCompletion {
1747                merged: true,
1748                base_ref: "main".into(),
1749                default_branch: "main".into(),
1750                approved: false,
1751                closed: true,
1752            }),
1753        );
1754        refresh_uncached(&backend, &store).await;
1755        assert_eq!(store.block_claim_if_waiting(b).await.unwrap(), None);
1756        assert_eq!(store.get(b).await.unwrap().status, TaskStatus::New);
1757    }
1758
1759    #[tokio::test]
1760    async fn superseded_blocker_releases_the_waiter_but_a_human_close_does_not() {
1761        let store = TaskStore::open_memory().await.unwrap();
1762        let a = insert(&store, "owner/repo", "1", "A", "").await;
1763        let b = insert(&store, "owner/repo", "2", "B", "").await;
1764        store
1765            .set_fields(a, &[("pr_number", serde_json::json!(10))])
1766            .await
1767            .unwrap();
1768        store
1769            .replace_dependencies(
1770                b,
1771                &[NewDependency {
1772                    repo: "owner/repo".into(),
1773                    number: 1,
1774                    source: DepSource::Github,
1775                }],
1776            )
1777            .await
1778            .unwrap();
1779        let backend = ScriptedBackend::new(HashMap::new());
1780        backend
1781            .close_state
1782            .lock()
1783            .unwrap()
1784            .insert(1, ("closed".into(), Some("completed".into())));
1785        backend.pulls.lock().unwrap().insert(
1786            10,
1787            Some(PullRequestCompletion {
1788                merged: false,
1789                base_ref: "main".into(),
1790                default_branch: "main".into(),
1791                approved: true,
1792                closed: true,
1793            }),
1794        );
1795        // A person closed the PR unmerged, so the PR stays on the task
1796        refresh_blocker_snapshots(&backend, &store).await.unwrap();
1797        assert!(store.block_claim_if_waiting(b).await.unwrap().is_some());
1798
1799        // The superseded path clears the PR from the task
1800        store
1801            .set_fields(a, &[("pr_number", serde_json::json!(null))])
1802            .await
1803            .unwrap();
1804        refresh_uncached(&backend, &store).await;
1805        assert_eq!(store.block_claim_if_waiting(b).await.unwrap(), None);
1806    }
1807
1808    #[tokio::test]
1809    async fn merged_unapproved_pr_releases_the_waiter() {
1810        let store = TaskStore::open_memory().await.unwrap();
1811        let a = insert(&store, "owner/repo", "1", "A", "").await;
1812        let b = insert(&store, "owner/repo", "2", "B", "").await;
1813        store
1814            .set_fields(a, &[("pr_number", serde_json::json!(10))])
1815            .await
1816            .unwrap();
1817        store
1818            .replace_dependencies(
1819                b,
1820                &[NewDependency {
1821                    repo: "owner/repo".into(),
1822                    number: 1,
1823                    source: DepSource::Github,
1824                }],
1825            )
1826            .await
1827            .unwrap();
1828        let backend = ScriptedBackend::new(HashMap::new());
1829        backend
1830            .close_state
1831            .lock()
1832            .unwrap()
1833            .insert(1, ("closed".into(), Some("completed".into())));
1834        backend.pulls.lock().unwrap().insert(
1835            10,
1836            Some(PullRequestCompletion {
1837                merged: true,
1838                base_ref: "main".into(),
1839                default_branch: "main".into(),
1840                approved: false,
1841                closed: true,
1842            }),
1843        );
1844        refresh_blocker_snapshots(&backend, &store).await.unwrap();
1845        assert_eq!(store.block_claim_if_waiting(b).await.unwrap(), None);
1846        assert_eq!(store.get(b).await.unwrap().status, TaskStatus::New);
1847    }
1848
1849    #[tokio::test]
1850    async fn blocker_closed_before_import_releases_on_refresh() {
1851        let store = TaskStore::open_memory().await.unwrap();
1852        let waiter = insert(&store, "owner/repo", "24", "later", "").await;
1853        let mut blocked = HashMap::new();
1854        blocked.insert(
1855            24,
1856            BlockedByFetch::Native(vec![native("owner/repo", 14, "open", None)]),
1857        );
1858        let backend = ScriptedBackend::new(blocked);
1859        backend
1860            .close_state
1861            .lock()
1862            .unwrap()
1863            .insert(14, ("closed".into(), Some("completed".into())));
1864        reconcile_dependencies(&backend, &store, None)
1865            .await
1866            .unwrap();
1867        assert_eq!(store.get(waiter).await.unwrap().status, TaskStatus::New);
1868        let snap = store
1869            .blocker_snapshot("owner/repo", 14)
1870            .await
1871            .unwrap()
1872            .unwrap();
1873        assert!(snap.is_satisfied());
1874        assert_eq!(snap.issue_state, "closed");
1875    }
1876
1877    #[test]
1878    fn depends_on_token_accepts_a_bare_number_and_a_cross_repo_ref() {
1879        assert_eq!(
1880            parse_depends_on_token("12", "acme/app"),
1881            Some(DepRef {
1882                repo: "acme/app".into(),
1883                number: 12
1884            })
1885        );
1886        assert_eq!(
1887            parse_depends_on_token("owner/repo#12", "acme/app"),
1888            Some(DepRef {
1889                repo: "owner/repo".into(),
1890                number: 12
1891            })
1892        );
1893    }
1894
1895    #[tokio::test]
1896    async fn two_route_calls_cannot_claim_a_waiting_task() {
1897        cooldown::reset_global_state().await;
1898        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1899        let b = insert(&store, "owner/repo", "2", "B", "").await;
1900        store
1901            .replace_dependencies(
1902                b,
1903                &[NewDependency {
1904                    repo: "owner/repo".into(),
1905                    number: 1,
1906                    source: DepSource::Github,
1907                }],
1908            )
1909            .await
1910            .unwrap();
1911        let ext = store_task_to_external(&store.get(b).await.unwrap());
1912        let mut tier = HashMap::new();
1913        tier.insert("claude".to_string(), vec!["haiku".to_string()]);
1914        let mut model_map = HashMap::new();
1915        model_map.insert("medium".into(), tier);
1916        let config = RouterConfig {
1917            mode: "round_robin".into(),
1918            agents: vec!["claude".into()],
1919            model_map,
1920            ..Default::default()
1921        };
1922        let left_store = Arc::clone(&store);
1923        let right_store = Arc::clone(&store);
1924        let left_task = ext.clone();
1925        let right_task = ext;
1926        let right_config = config.clone();
1927        let left = tokio::spawn(async move {
1928            let mut router = Router::new_for_test(config, vec!["claude".into()]);
1929            router.route(&left_task, &left_store, "owner/repo").await
1930        });
1931        let right = tokio::spawn(async move {
1932            let mut router = Router::new_for_test(right_config, vec!["claude".into()]);
1933            router.route(&right_task, &right_store, "owner/repo").await
1934        });
1935        assert!(left.await.unwrap().is_err());
1936        assert!(right.await.unwrap().is_err());
1937        let task = store.get(b).await.unwrap();
1938        assert_eq!(task.status, TaskStatus::Waiting);
1939        assert!(task.agent.is_none());
1940    }
1941
1942    #[tokio::test]
1943    async fn refuse_if_waiting_blocks_manual_claim() {
1944        let store = TaskStore::open_memory().await.unwrap();
1945        let b = insert(&store, "owner/repo", "2", "B", "").await;
1946        store
1947            .replace_dependencies(
1948                b,
1949                &[NewDependency {
1950                    repo: "owner/repo".into(),
1951                    number: 1,
1952                    source: DepSource::Cli,
1953                }],
1954            )
1955            .await
1956            .unwrap();
1957        let err = refuse_if_waiting(&store, b).await.unwrap_err();
1958        assert!(err.to_string().contains("#1"));
1959        assert_eq!(store.get(b).await.unwrap().status, TaskStatus::Waiting);
1960    }
1961}