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