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