Skip to main content

orch/engine/
dependencies.rs

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