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