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