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