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