1use 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;
20const BLOCKER_REFRESH_TTL_SECS: i64 = 300;
22const 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
29fn 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
42pub async fn clear_blocker_read_cache(store: &TaskStore, repo: &str, external_id: &str) {
49 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 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
82pub 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
127pub 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
209pub 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 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 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 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
336pub 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
346pub 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
401pub 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 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
520pub 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
555pub 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 ¬ices {
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(¬ice.repo, &id, ¬ice.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 reads: AtomicUsize,
925 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 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 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 refresh_blocker_snapshots(&backend, &store).await.unwrap();
1863 assert!(store.block_claim_if_waiting(b).await.unwrap().is_some());
1864
1865 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}