Skip to main content

orch/engine/
dependencies.rs

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