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