1use super::{TaskStatus, TaskStore};
13use crate::cluster::Command;
14use anyhow::Context;
15use sqlx::Row;
16use sqlx::SqliteConnection;
17use std::collections::HashMap;
18
19#[derive(Debug, thiserror::Error)]
23#[error("waiting on {}", .blockers.join(", "))]
24pub struct WaitingOnDependency {
25 pub blockers: Vec<String>,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
29pub enum DepSource {
30 Github,
31 Body,
32 Cli,
33}
34
35impl DepSource {
36 pub fn as_str(self) -> &'static str {
37 match self {
38 Self::Github => "github",
39 Self::Body => "body",
40 Self::Cli => "cli",
41 }
42 }
43
44 pub fn parse(value: &str) -> Option<Self> {
45 match value {
46 "github" => Some(Self::Github),
47 "body" => Some(Self::Body),
48 "cli" => Some(Self::Cli),
49 _ => None,
50 }
51 }
52}
53
54#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
55pub struct NewDependency {
56 pub repo: String,
57 pub number: i64,
58 pub source: DepSource,
59}
60
61#[derive(Debug, Clone)]
62pub struct TaskDependency {
63 pub task_id: i64,
64 pub depends_on_task_id: Option<i64>,
65 pub depends_on_repo: String,
66 pub depends_on_number: i64,
67 pub source: String,
68 pub blocker_status: Option<String>,
69}
70
71#[derive(Debug, Clone)]
72pub struct SchedulableTask {
73 pub id: i64,
74 pub repo: String,
75 pub status: String,
76 pub external_id: Option<String>,
77 pub body: String,
78 pub title: String,
79}
80
81#[derive(Debug, Clone)]
82pub struct DepEdge {
83 pub task_id: i64,
84 pub task_repo: String,
85 pub task_external_id: Option<String>,
86 pub task_title: String,
87 pub depends_on_repo: String,
88 pub depends_on_number: i64,
89}
90
91#[derive(Debug, Clone)]
92struct BlockerView {
93 label: String,
94 satisfied: bool,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
102#[expect(
103 clippy::struct_excessive_bools,
104 reason = "each bool mirrors a github field and the struct is stored as json"
105)]
106pub struct ObservedPullRequest {
107 pub merged: bool,
108 pub base_is_default: bool,
109 pub approved: bool,
110 pub closed_unmerged: bool,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
117#[expect(
118 clippy::struct_excessive_bools,
119 reason = "each bool mirrors a github field and the struct is stored as json"
120)]
121pub struct BlockerSnapshot {
122 pub issue_state: String,
123 pub state_reason: Option<String>,
124 pub has_pr: bool,
125 pub pr_merged: bool,
126 pub pr_merged_into_default: bool,
127 pub pr_approved: bool,
128 pub pr_closed: bool,
129}
130
131impl BlockerSnapshot {
132 pub fn observe(
133 issue_state: &str,
134 state_reason: Option<&str>,
135 pr: Option<&ObservedPullRequest>,
136 ) -> Self {
137 let normalized = normalize_close_reason(issue_state, state_reason);
138 let (has_pr, merged, into_default, approved, pr_closed) = match pr {
139 Some(pr) => (
140 true,
141 pr.merged,
142 pr.base_is_default,
143 pr.approved,
144 pr.closed_unmerged,
145 ),
146 None => (false, false, false, false, false),
147 };
148 Self {
149 issue_state: if issue_state.eq_ignore_ascii_case("closed") {
150 "closed".to_string()
151 } else {
152 "open".to_string()
153 },
154 state_reason: Some(normalized.to_string()),
155 has_pr,
156 pr_merged: merged,
157 pr_merged_into_default: into_default,
158 pr_approved: approved,
159 pr_closed,
160 }
161 }
162
163 pub fn note_issue_state(&mut self, issue_state: &str, state_reason: Option<&str>) {
167 let normalized = normalize_close_reason(issue_state, state_reason);
168 self.issue_state = if issue_state.eq_ignore_ascii_case("closed") {
169 "closed".to_string()
170 } else {
171 "open".to_string()
172 };
173 self.state_reason = Some(normalized.to_string());
174 }
175
176 pub fn is_satisfied(&self) -> bool {
177 let reason = self.state_reason.as_deref().unwrap_or("");
178 if reason == "not_planned" || reason == "duplicate" {
179 return false;
180 }
181 if self.has_pr {
182 return self.pr_merged && self.pr_merged_into_default;
184 }
185 self.issue_state == "closed" && (reason.is_empty() || reason == "completed")
186 }
187}
188
189pub(crate) fn normalize_close_reason(state: &str, state_reason: Option<&str>) -> &'static str {
193 if !state.eq_ignore_ascii_case("closed") {
194 return "open";
195 }
196 match state_reason
197 .unwrap_or("")
198 .trim()
199 .to_ascii_lowercase()
200 .as_str()
201 {
202 "not_planned" => "not_planned",
203 "duplicate" => "duplicate",
204 "reopened" => "open",
205 _ => "completed",
206 }
207}
208
209pub fn blocker_snapshot_key(repo: &str, number: i64) -> String {
210 format!("dep_blocker:{repo}#{number}")
211}
212
213pub fn worktree_refresh_key(task_id: i64) -> String {
214 format!("dep_worktree_refresh:{task_id}")
215}
216
217pub fn dep_label(task_repo: &str, dep_repo: &str, number: i64) -> String {
218 if task_repo == dep_repo {
219 format!("#{number}")
220 } else {
221 format!("{dep_repo}#{number}")
222 }
223}
224
225pub fn blocker_is_satisfied(snapshot: Option<&BlockerSnapshot>) -> bool {
227 snapshot.is_some_and(BlockerSnapshot::is_satisfied)
228}
229
230pub fn blocker_failure_kind(
234 task_status: Option<&str>,
235 snapshot: Option<&BlockerSnapshot>,
236) -> Option<&'static str> {
237 let snap = snapshot?;
238 if snap.state_reason.as_deref() == Some("not_planned") {
239 return Some("not_planned");
240 }
241 if task_status == Some("blocked") && !snap.is_satisfied() {
242 return Some("blocked");
243 }
244 None
245}
246
247impl TaskStore {
248 pub async fn move_repo(&self, old: &str, new: &str) -> anyhow::Result<u64> {
252 let cmd = Command::MoveRepo {
253 old: old.to_string(),
254 new: new.to_string(),
255 };
256 Ok(u64::try_from(self.write_int(cmd).await?)?)
257 }
258
259 pub async fn replace_dependencies(
261 &self,
262 task_id: i64,
263 rows: &[NewDependency],
264 ) -> anyhow::Result<()> {
265 let cmd = Command::DepReplace {
266 task_id,
267 rows: rows.to_vec(),
268 };
269 self.write(cmd).await.map(|_| ())
270 }
271
272 pub async fn link_known_task(
274 &self,
275 task_id: i64,
276 repo: &str,
277 number: i64,
278 ) -> anyhow::Result<()> {
279 let cmd = Command::DepLinkKnown {
280 task_id,
281 repo: repo.to_string(),
282 number,
283 };
284 self.write(cmd).await.map(|_| ())
285 }
286
287 pub async fn list_dependencies(&self, task_id: i64) -> anyhow::Result<Vec<TaskDependency>> {
288 let rows = sqlx::query(
289 "SELECT d.task_id, d.depends_on_task_id, d.depends_on_repo, d.depends_on_number, d.source,
290 t.status AS blocker_status
291 FROM task_dependencies d
292 LEFT JOIN tasks t
293 ON t.repo = d.depends_on_repo
294 AND t.external_id = CAST(d.depends_on_number AS TEXT)
295 WHERE d.task_id = ?
296 ORDER BY d.depends_on_repo, d.depends_on_number",
297 )
298 .bind(task_id)
299 .fetch_all(&self.pool)
300 .await?;
301 let mut out = Vec::with_capacity(rows.len());
302 for row in rows {
303 out.push(TaskDependency {
304 task_id: row.try_get("task_id")?,
305 depends_on_task_id: row.try_get("depends_on_task_id")?,
306 depends_on_repo: row.try_get("depends_on_repo")?,
307 depends_on_number: row.try_get("depends_on_number")?,
308 source: row.try_get("source")?,
309 blocker_status: row.try_get("blocker_status")?,
310 });
311 }
312 Ok(out)
313 }
314
315 pub async fn list_dependents(
316 &self,
317 repo: &str,
318 number: i64,
319 ) -> anyhow::Result<Vec<TaskDependency>> {
320 let rows = sqlx::query(
321 "SELECT d.task_id, d.depends_on_task_id, d.depends_on_repo, d.depends_on_number, d.source,
322 t.status AS blocker_status
323 FROM task_dependencies d
324 JOIN tasks t ON t.id = d.task_id
325 WHERE d.depends_on_repo = ? AND d.depends_on_number = ?
326 ORDER BY d.task_id",
327 )
328 .bind(repo)
329 .bind(number)
330 .fetch_all(&self.pool)
331 .await?;
332 let mut out = Vec::with_capacity(rows.len());
333 for row in rows {
334 out.push(TaskDependency {
335 task_id: row.try_get("task_id")?,
336 depends_on_task_id: row.try_get("depends_on_task_id")?,
337 depends_on_repo: row.try_get("depends_on_repo")?,
338 depends_on_number: row.try_get("depends_on_number")?,
339 source: row.try_get("source")?,
340 blocker_status: row.try_get("blocker_status")?,
341 });
342 }
343 Ok(out)
344 }
345
346 pub async fn list_schedulable(&self) -> anyhow::Result<Vec<SchedulableTask>> {
347 let rows = sqlx::query(
348 "SELECT id, repo, status, external_id, body, title
349 FROM tasks
350 WHERE status IN ('new', 'routed', 'waiting')
351 ORDER BY id",
352 )
353 .fetch_all(&self.pool)
354 .await?;
355 let mut out = Vec::with_capacity(rows.len());
356 for row in rows {
357 out.push(SchedulableTask {
358 id: row.try_get("id")?,
359 repo: row.try_get("repo")?,
360 status: row.try_get("status")?,
361 external_id: row.try_get("external_id")?,
362 body: row.try_get("body")?,
363 title: row.try_get("title")?,
364 });
365 }
366 Ok(out)
367 }
368
369 pub async fn list_dependency_edges(&self) -> anyhow::Result<Vec<DepEdge>> {
370 let rows = sqlx::query(
371 "SELECT d.task_id, t.repo AS task_repo, t.external_id AS task_external_id, t.title AS task_title,
372 d.depends_on_repo, d.depends_on_number
373 FROM task_dependencies d
374 JOIN tasks t ON t.id = d.task_id",
375 )
376 .fetch_all(&self.pool)
377 .await?;
378 let mut out = Vec::with_capacity(rows.len());
379 for row in rows {
380 out.push(DepEdge {
381 task_id: row.try_get("task_id")?,
382 task_repo: row.try_get("task_repo")?,
383 task_external_id: row.try_get("task_external_id")?,
384 task_title: row.try_get("task_title")?,
385 depends_on_repo: row.try_get("depends_on_repo")?,
386 depends_on_number: row.try_get("depends_on_number")?,
387 });
388 }
389 Ok(out)
390 }
391
392 pub async fn unmet_labels_by_task(&self) -> anyhow::Result<HashMap<i64, Vec<String>>> {
394 let mut conn = self.pool.acquire().await?;
395 let ids: Vec<i64> = sqlx::query("SELECT DISTINCT task_id FROM task_dependencies")
396 .fetch_all(&mut *conn)
397 .await?
398 .into_iter()
399 .map(|row| row.try_get("task_id"))
400 .collect::<Result<Vec<_>, _>>()?;
401 let mut out = HashMap::new();
402 for task_id in ids {
403 let repo: String = sqlx::query("SELECT repo FROM tasks WHERE id = ?")
404 .bind(task_id)
405 .fetch_optional(&mut *conn)
406 .await?
407 .and_then(|row| row.try_get("repo").ok())
408 .unwrap_or_default();
409 let views = load_blockers(&mut conn, task_id, &repo).await?;
410 let labels: Vec<String> = views
411 .into_iter()
412 .filter(|view| !view.satisfied)
413 .map(|view| view.label)
414 .collect();
415 if !labels.is_empty() {
416 out.insert(task_id, labels);
417 }
418 }
419 Ok(out)
420 }
421
422 pub async fn unmet_blocker_labels(&self, task_id: i64) -> anyhow::Result<Vec<String>> {
423 let mut conn = self.pool.acquire().await?;
424 let repo: String = sqlx::query("SELECT repo FROM tasks WHERE id = ?")
425 .bind(task_id)
426 .fetch_optional(&mut *conn)
427 .await?
428 .and_then(|row| row.try_get("repo").ok())
429 .unwrap_or_default();
430 let views = load_blockers(&mut conn, task_id, &repo).await?;
431 Ok(views
432 .into_iter()
433 .filter(|view| !view.satisfied)
434 .map(|view| view.label)
435 .collect())
436 }
437
438 pub async fn save_blocker_snapshot(
439 &self,
440 repo: &str,
441 number: i64,
442 snap: &BlockerSnapshot,
443 ) -> anyhow::Result<()> {
444 let raw = serde_json::to_string(snap).context("encode blocker snapshot")?;
445 self.kv_set(&blocker_snapshot_key(repo, number), &raw).await
446 }
447
448 pub async fn blocker_snapshot(
449 &self,
450 repo: &str,
451 number: i64,
452 ) -> anyhow::Result<Option<BlockerSnapshot>> {
453 let Some(raw) = self.kv_get(&blocker_snapshot_key(repo, number)).await? else {
454 return Ok(None);
455 };
456 Ok(serde_json::from_str(&raw).ok())
457 }
458
459 pub async fn note_blocker_issue_state(
461 &self,
462 repo: &str,
463 number: i64,
464 state: &str,
465 reason: Option<&str>,
466 ) -> anyhow::Result<()> {
467 let mut snap = match self.blocker_snapshot(repo, number).await? {
468 Some(existing) => existing,
469 None => BlockerSnapshot::observe(state, reason, None),
470 };
471 snap.note_issue_state(state, reason);
472 self.save_blocker_snapshot(repo, number, &snap).await
473 }
474
475 pub async fn block_claim_if_waiting(
482 &self,
483 task_id: i64,
484 ) -> anyhow::Result<Option<Vec<String>>> {
485 match self.write(Command::DepBlockClaim { task_id }).await? {
486 crate::cluster::Reply::Unmet(unmet) => Ok(unmet),
487 other => anyhow::bail!("block_claim_if_waiting got {other:?}"),
488 }
489 }
490
491 pub async fn status_after_unblock(&self, task_id: i64) -> anyhow::Result<TaskStatus> {
493 match self.block_claim_if_waiting(task_id).await? {
494 Some(_) => Ok(TaskStatus::Waiting),
495 None => Ok(TaskStatus::New),
496 }
497 }
498}
499
500pub(crate) fn blocked_external_from_body(body: &str) -> Vec<String> {
502 let mut ids = Vec::new();
503 let mut push = |token: &str| {
504 let token = token.trim();
505 if token.is_empty() {
506 return;
507 }
508 if !ids.iter().any(|have| have == token) {
509 ids.push(token.to_string());
510 }
511 };
512 for line in body.lines() {
513 let trimmed = line.trim();
514 if let Some(rest) = trimmed.strip_prefix("<!--") {
515 let rest = rest.trim();
516 let Some(rest) = rest.strip_prefix("orch-blocked-external:") else {
517 continue;
518 };
519 let rest = rest.trim().trim_end_matches("-->").trim();
520 for token in rest.split(',') {
521 push(token);
522 }
523 continue;
524 }
525 let lower = trimmed.to_ascii_lowercase();
526 if !lower.starts_with("blocked-external") {
527 continue;
528 }
529 let rest = trimmed["blocked-external".len()..]
530 .trim_start()
531 .trim_start_matches(':')
532 .trim();
533 let rest = rest.strip_suffix("(no issue yet)").unwrap_or(rest).trim();
534 for token in rest.split(',') {
535 push(token);
536 }
537 }
538 ids
539}
540
541async fn load_blockers(
542 conn: &mut SqliteConnection,
543 task_id: i64,
544 task_repo: &str,
545) -> anyhow::Result<Vec<BlockerView>> {
546 let rows = sqlx::query(
547 "SELECT depends_on_repo, depends_on_number
548 FROM task_dependencies
549 WHERE task_id = ?
550 ORDER BY depends_on_repo, depends_on_number",
551 )
552 .bind(task_id)
553 .fetch_all(&mut *conn)
554 .await?;
555 let mut views = Vec::with_capacity(rows.len());
556 for row in rows {
557 let dep_repo: String = row.try_get("depends_on_repo")?;
558 let number: i64 = row.try_get("depends_on_number")?;
559 let raw: Option<String> = sqlx::query("SELECT value FROM kv WHERE key = ?")
560 .bind(blocker_snapshot_key(&dep_repo, number))
561 .fetch_optional(&mut *conn)
562 .await?
563 .and_then(|found| found.try_get("value").ok());
564 let snap = raw.and_then(|text| serde_json::from_str::<BlockerSnapshot>(&text).ok());
565 views.push(BlockerView {
566 label: dep_label(task_repo, &dep_repo, number),
567 satisfied: snap.as_ref().is_some_and(BlockerSnapshot::is_satisfied),
568 });
569 }
570 Ok(views)
571}
572
573#[cfg(test)]
574mod tests {
575 use super::*;
576 use crate::store::{NewTask, TaskStore};
577
578 async fn task(store: &TaskStore, repo: &str, number: &str, title: &str) -> i64 {
579 task_with_body(store, repo, number, title, "").await
580 }
581
582 async fn task_with_body(
583 store: &TaskStore,
584 repo: &str,
585 number: &str,
586 title: &str,
587 body: &str,
588 ) -> i64 {
589 store
590 .create(&NewTask {
591 external_id: Some(number.to_string()),
592 repo: repo.to_string(),
593 origin: "github".to_string(),
594 title: title.to_string(),
595 body: body.to_string(),
596 source: "manual".to_string(),
597 source_id: String::new(),
598 author: String::new(),
599 url: String::new(),
600 labels: vec![],
601 parent_id: None,
602 })
603 .await
604 .unwrap()
605 }
606
607 fn approved_merge(into_default: bool) -> BlockerSnapshot {
608 BlockerSnapshot::observe(
609 "closed",
610 Some("completed"),
611 Some(&ObservedPullRequest {
612 merged: true,
613 base_is_default: into_default,
614 approved: true,
615 closed_unmerged: false,
616 }),
617 )
618 }
619
620 #[tokio::test]
621 async fn unmet_dependency_demotes_new_and_promotes_when_the_pr_is_done() {
622 let store = TaskStore::open_memory().await.unwrap();
623 let a = task(&store, "owner/repo", "1", "A").await;
624 let b = task(&store, "owner/repo", "2", "B").await;
625 store
626 .set_fields(
627 b,
628 &[
629 ("branch", serde_json::json!("old-branch")),
630 ("worktree", serde_json::json!("/tmp/old")),
631 ],
632 )
633 .await
634 .unwrap();
635 store
636 .replace_dependencies(
637 b,
638 &[NewDependency {
639 repo: "owner/repo".to_string(),
640 number: 1,
641 source: DepSource::Github,
642 }],
643 )
644 .await
645 .unwrap();
646
647 let held = store.block_claim_if_waiting(b).await.unwrap();
648 assert_eq!(held, Some(vec!["#1".to_string()]));
649 assert_eq!(store.get(b).await.unwrap().status, TaskStatus::Waiting);
650
651 store.update_status(a, TaskStatus::Done).await.unwrap();
652 assert!(store.block_claim_if_waiting(b).await.unwrap().is_some());
653 assert_eq!(store.get(b).await.unwrap().status, TaskStatus::Waiting);
654
655 store
656 .save_blocker_snapshot("owner/repo", 1, &approved_merge(true))
657 .await
658 .unwrap();
659 let released = store.block_claim_if_waiting(b).await.unwrap();
660 assert_eq!(released, None);
661 let task = store.get(b).await.unwrap();
662 assert_eq!(task.status, TaskStatus::New);
663 assert_eq!(task.branch, "");
664 assert_eq!(task.worktree, "");
665 assert_eq!(
666 store
667 .kv_get(&worktree_refresh_key(b))
668 .await
669 .unwrap()
670 .as_deref(),
671 Some("1")
672 );
673 }
674
675 #[tokio::test]
676 async fn not_planned_beats_a_merged_pr_and_a_completed_issue_with_no_pr_is_done() {
677 let store = TaskStore::open_memory().await.unwrap();
678 let a = task(&store, "owner/repo", "1", "A").await;
679 let b = task(&store, "owner/repo", "2", "B").await;
680 store.update_status(a, TaskStatus::Done).await.unwrap();
681 let mut not_planned = approved_merge(true);
682 not_planned.state_reason = Some("not_planned".to_string());
683 store
684 .save_blocker_snapshot("owner/repo", 1, ¬_planned)
685 .await
686 .unwrap();
687 store
688 .replace_dependencies(
689 b,
690 &[NewDependency {
691 repo: "owner/repo".to_string(),
692 number: 1,
693 source: DepSource::Github,
694 }],
695 )
696 .await
697 .unwrap();
698 assert!(store.block_claim_if_waiting(b).await.unwrap().is_some());
699
700 let c = task(&store, "owner/repo", "3", "C").await;
701 store
702 .save_blocker_snapshot(
703 "other/repo",
704 9,
705 &BlockerSnapshot::observe("closed", Some("completed"), None),
706 )
707 .await
708 .unwrap();
709 store
710 .replace_dependencies(
711 c,
712 &[NewDependency {
713 repo: "other/repo".to_string(),
714 number: 9,
715 source: DepSource::Body,
716 }],
717 )
718 .await
719 .unwrap();
720 assert_eq!(store.block_claim_if_waiting(c).await.unwrap(), None);
721 }
722
723 #[test]
724 fn pull_request_completion_cases() {
725 let open = BlockerSnapshot::observe("open", None, None);
726 assert!(!open.is_satisfied());
727
728 let pr_open = BlockerSnapshot::observe(
729 "open",
730 None,
731 Some(&ObservedPullRequest {
732 merged: false,
733 base_is_default: true,
734 approved: false,
735 closed_unmerged: false,
736 }),
737 );
738 assert!(!pr_open.is_satisfied());
739
740 let approved_open = BlockerSnapshot::observe(
741 "open",
742 None,
743 Some(&ObservedPullRequest {
744 merged: false,
745 base_is_default: true,
746 approved: true,
747 closed_unmerged: false,
748 }),
749 );
750 assert!(!approved_open.is_satisfied());
751
752 assert!(approved_merge(true).is_satisfied());
753 assert!(!approved_merge(false).is_satisfied());
754
755 let closed_unmerged = BlockerSnapshot::observe(
756 "closed",
757 Some("completed"),
758 Some(&ObservedPullRequest {
759 merged: false,
760 base_is_default: true,
761 approved: true,
762 closed_unmerged: true,
763 }),
764 );
765 assert!(!closed_unmerged.is_satisfied());
766
767 let merged_unapproved = BlockerSnapshot::observe(
768 "closed",
769 Some("completed"),
770 Some(&ObservedPullRequest {
771 merged: true,
772 base_is_default: true,
773 approved: false,
774 closed_unmerged: false,
775 }),
776 );
777 assert!(merged_unapproved.is_satisfied());
778
779 assert!(BlockerSnapshot::observe("closed", None, None).is_satisfied());
780 assert!(BlockerSnapshot::observe("closed", Some("completed"), None).is_satisfied());
781 assert!(!BlockerSnapshot::observe("closed", Some("not_planned"), None).is_satisfied());
782 assert!(!BlockerSnapshot::observe("closed", Some("duplicate"), None).is_satisfied());
783 }
784
785 #[tokio::test]
786 async fn blocked_and_duplicate_stay_unmet_and_in_progress_is_not_rewritten() {
787 let store = TaskStore::open_memory().await.unwrap();
788 let a = task(&store, "owner/repo", "1", "A").await;
789 let b = task(&store, "owner/repo", "2", "B").await;
790 store.update_status(a, TaskStatus::Blocked).await.unwrap();
791 store
792 .replace_dependencies(
793 b,
794 &[NewDependency {
795 repo: "owner/repo".to_string(),
796 number: 1,
797 source: DepSource::Cli,
798 }],
799 )
800 .await
801 .unwrap();
802 store
803 .update_status(b, TaskStatus::InProgress)
804 .await
805 .unwrap();
806 assert!(store.block_claim_if_waiting(b).await.unwrap().is_some());
807 assert_eq!(store.get(b).await.unwrap().status, TaskStatus::InProgress);
808
809 store
810 .save_blocker_snapshot(
811 "owner/repo",
812 1,
813 &BlockerSnapshot::observe("closed", Some("duplicate"), None),
814 )
815 .await
816 .unwrap();
817 store.update_status(a, TaskStatus::Done).await.unwrap();
818 assert!(store.block_claim_if_waiting(b).await.unwrap().is_some());
819 assert_eq!(store.get(b).await.unwrap().status, TaskStatus::InProgress);
820 }
821
822 #[tokio::test]
823 async fn concurrent_claims_both_report_waiting() {
824 let store = std::sync::Arc::new(TaskStore::open_memory().await.unwrap());
825 let b = task(&store, "owner/repo", "2", "B").await;
826 store
827 .replace_dependencies(
828 b,
829 &[NewDependency {
830 repo: "owner/repo".to_string(),
831 number: 1,
832 source: DepSource::Github,
833 }],
834 )
835 .await
836 .unwrap();
837 let left = std::sync::Arc::clone(&store);
838 let right = std::sync::Arc::clone(&store);
839 let (a, c) = tokio::join!(
840 left.block_claim_if_waiting(b),
841 right.block_claim_if_waiting(b)
842 );
843 assert!(a.unwrap().is_some());
844 assert!(c.unwrap().is_some());
845 assert_eq!(store.get(b).await.unwrap().status, TaskStatus::Waiting);
846 }
847
848 #[tokio::test]
849 async fn unblock_selection_skips_waiting_and_blocked_with_deps_stays_waiting() {
850 let store = TaskStore::open_memory().await.unwrap();
851 let a = task(&store, "owner/repo", "1", "A").await;
852 let b = task(&store, "owner/repo", "2", "B").await;
853 store.update_status(a, TaskStatus::Blocked).await.unwrap();
854 store
855 .replace_dependencies(
856 b,
857 &[NewDependency {
858 repo: "owner/repo".to_string(),
859 number: 1,
860 source: DepSource::Github,
861 }],
862 )
863 .await
864 .unwrap();
865 store.block_claim_if_waiting(b).await.unwrap();
866 let blocked = store
867 .list_by_status("owner/repo", TaskStatus::Blocked)
868 .await
869 .unwrap();
870 assert!(blocked.iter().all(|task| task.id != b));
871 assert_eq!(store.get(b).await.unwrap().status, TaskStatus::Waiting);
872 assert_eq!(
873 store.status_after_unblock(a).await.unwrap(),
874 TaskStatus::New
875 );
876 store.update_status(a, TaskStatus::Blocked).await.unwrap();
877 store
878 .replace_dependencies(
879 a,
880 &[NewDependency {
881 repo: "owner/repo".to_string(),
882 number: 9,
883 source: DepSource::Cli,
884 }],
885 )
886 .await
887 .unwrap();
888 assert_eq!(
889 store.status_after_unblock(a).await.unwrap(),
890 TaskStatus::Waiting
891 );
892 }
893
894 #[tokio::test]
895 async fn entering_waiting_clears_a_stored_branch() {
896 let store = TaskStore::open_memory().await.unwrap();
897 let b = task(&store, "owner/repo", "2", "B").await;
898 store
899 .set_fields(
900 b,
901 &[
902 ("branch", serde_json::json!("old-branch")),
903 ("worktree", serde_json::json!("/tmp/old")),
904 ],
905 )
906 .await
907 .unwrap();
908 store
909 .replace_dependencies(
910 b,
911 &[NewDependency {
912 repo: "owner/repo".to_string(),
913 number: 1,
914 source: DepSource::Github,
915 }],
916 )
917 .await
918 .unwrap();
919 store.block_claim_if_waiting(b).await.unwrap();
920 let task = store.get(b).await.unwrap();
921 assert_eq!(task.status, TaskStatus::Waiting);
922 assert_eq!(task.branch, "");
923 assert_eq!(task.worktree, "");
924 }
925
926 #[test]
927 fn blocked_external_from_body_reads_the_note_and_the_marker() {
928 let body = concat!(
929 "blocked-external: M1-05, M1-01 (no issue yet)\n\n",
930 "<!-- orch-blocked-external: M1-05, M1-01 -->\n",
931 "<!-- orch-plan: M6-01 -->\n",
932 );
933 assert_eq!(
934 blocked_external_from_body(body),
935 vec!["M1-05".to_string(), "M1-01".to_string()]
936 );
937 let blocked = blocked_external_from_body("<!-- orch-plan: M6-01 -->");
938 assert!(blocked.is_empty(), "{blocked:?}");
939 }
940
941 #[tokio::test]
942 async fn blocked_external_note_holds_the_task_and_clears_when_removed() {
943 let store = TaskStore::open_memory().await.unwrap();
944 let body = concat!(
945 "blocked-external: M3-01 (no issue yet)\n\n",
946 "<!-- orch-blocked-external: M3-01 -->\n",
947 );
948 let b = task_with_body(&store, "owner/repo", "4", "M4-01 API", body).await;
949 let held = store.block_claim_if_waiting(b).await.unwrap();
950 assert_eq!(held, Some(vec!["M3-01".to_string()]));
951 assert_eq!(store.get(b).await.unwrap().status, TaskStatus::Waiting);
952
953 store
954 .upsert_external(&crate::store::UpsertExternal {
955 repo: "owner/repo",
956 ext_id: "4",
957 title: "M4-01 API",
958 body: "<!-- orch-plan: M4-01 -->\n",
959 author: "",
960 url: "",
961 labels: &[],
962 origin: "github",
963 })
964 .await
965 .unwrap();
966 let released = store.block_claim_if_waiting(b).await.unwrap();
967 assert_eq!(released, None);
968 assert_eq!(store.get(b).await.unwrap().status, TaskStatus::New);
969 }
970
971 #[tokio::test]
972 async fn migration_028_applies_over_existing_tasks() {
973 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("migrations");
974 let options = sqlx::sqlite::SqliteConnectOptions::new()
975 .filename(":memory:")
976 .pragma("foreign_keys", "ON");
977 let pool = sqlx::sqlite::SqlitePoolOptions::new()
978 .max_connections(1)
979 .connect_with(options)
980 .await
981 .unwrap();
982 let mut files: Vec<_> = std::fs::read_dir(&dir)
983 .unwrap()
984 .map(|entry| entry.unwrap().path())
985 .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("sql"))
986 .collect();
987 files.sort();
988 for path in &files {
989 let name = path.file_name().unwrap().to_string_lossy();
990 if name.starts_with("028") {
991 continue;
992 }
993 let sql = std::fs::read_to_string(path).unwrap();
994 sqlx::raw_sql(sqlx::AssertSqlSafe(sql.as_str()))
995 .execute(&pool)
996 .await
997 .unwrap_or_else(|err| panic!("migration {name} failed: {err}"));
998 }
999 sqlx::query("INSERT INTO tasks (repo, title) VALUES ('owner/repo', 'existing')")
1000 .execute(&pool)
1001 .await
1002 .unwrap();
1003 let sql = std::fs::read_to_string(dir.join("028_task_dependencies.sql")).unwrap();
1004 sqlx::raw_sql(sqlx::AssertSqlSafe(sql.as_str()))
1005 .execute(&pool)
1006 .await
1007 .unwrap();
1008 let (id, title): (i64, String) = sqlx::query_as("SELECT id, title FROM tasks")
1009 .fetch_one(&pool)
1010 .await
1011 .unwrap();
1012 assert_eq!(title, "existing");
1013 sqlx::query(
1014 "INSERT INTO task_dependencies (task_id, depends_on_repo, depends_on_number, source)
1015 VALUES (?, 'owner/repo', 1, 'cli')",
1016 )
1017 .bind(id)
1018 .execute(&pool)
1019 .await
1020 .unwrap();
1021 let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM task_dependencies")
1022 .fetch_one(&pool)
1023 .await
1024 .unwrap();
1025 assert_eq!(count, 1);
1026 }
1027}
1028
1029pub(crate) mod apply {
1031 use super::{
1032 Context, NewDependency, Row, SqliteConnection, blocked_external_from_body, load_blockers,
1033 worktree_refresh_key,
1034 };
1035
1036 pub(crate) async fn move_repo(
1037 conn: &mut SqliteConnection,
1038 old: &str,
1039 new: &str,
1040 ) -> anyhow::Result<u64> {
1041 let existing: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM tasks WHERE repo = ?")
1042 .bind(new)
1043 .fetch_one(&mut *conn)
1044 .await?;
1045 if existing > 0 {
1046 anyhow::bail!("{new} already has {existing} task(s), refusing to move {old} onto it");
1047 }
1048 let moved = sqlx::query("UPDATE tasks SET repo = ? WHERE repo = ?")
1049 .bind(new)
1050 .bind(old)
1051 .execute(&mut *conn)
1052 .await?
1053 .rows_affected();
1054 for sql in [
1055 "UPDATE task_dependencies SET depends_on_repo = ? WHERE depends_on_repo = ?",
1056 "UPDATE task_metrics SET repo = ? WHERE repo = ?",
1057 "UPDATE job_state SET repo = ? WHERE repo = ?",
1058 "UPDATE channel_subscriptions SET repo = ? WHERE repo = ?",
1059 ] {
1060 sqlx::query(sql)
1061 .bind(new)
1062 .bind(old)
1063 .execute(&mut *conn)
1064 .await?;
1065 }
1066 move_repo_kv(&mut *conn, old, new).await?;
1067 Ok(moved)
1068 }
1069
1070 async fn move_repo_kv(conn: &mut SqliteConnection, old: &str, new: &str) -> anyhow::Result<()> {
1078 for prefix in [
1079 format!("dep_blocker:{old}#"),
1080 format!("dep_blocker_read_at:{old}#"),
1081 ] {
1082 sqlx::query("DELETE FROM kv WHERE substr(key, 1, ?) = ?")
1083 .bind(prefix_len(&prefix)?)
1084 .bind(&prefix)
1085 .execute(&mut *conn)
1086 .await?;
1087 }
1088 sqlx::query("DELETE FROM kv WHERE key = ?")
1089 .bind(format!("review_batch_fail:{old}"))
1090 .execute(&mut *conn)
1091 .await?;
1092 for (from, to) in [
1093 (
1094 format!("dep_cycle_comment:{old}#"),
1095 format!("dep_cycle_comment:{new}#"),
1096 ),
1097 (
1098 format!("review_approved_sha:{old}#"),
1099 format!("review_approved_sha:{new}#"),
1100 ),
1101 (format!("ci_check_ts:{old}#"), format!("ci_check_ts:{new}#")),
1102 (
1103 format!("ci_pending_since:{old}#"),
1104 format!("ci_pending_since:{new}#"),
1105 ),
1106 ] {
1107 sqlx::query(
1108 "UPDATE OR REPLACE kv SET key = ? || substr(key, ?) WHERE substr(key, 1, ?) = ?",
1109 )
1110 .bind(&to)
1111 .bind(prefix_len(&from)? + 1)
1112 .bind(prefix_len(&from)?)
1113 .bind(&from)
1114 .execute(&mut *conn)
1115 .await?;
1116 }
1117 sqlx::query(
1119 "UPDATE OR REPLACE kv SET key = replace(key, ?, ?)
1120 WHERE key GLOB 'dep_blocker_notice:*' AND instr(key, ?) > 0",
1121 )
1122 .bind(format!(":{old}#"))
1123 .bind(format!(":{new}#"))
1124 .bind(format!(":{old}#"))
1125 .execute(&mut *conn)
1126 .await?;
1127 sqlx::query("UPDATE OR REPLACE kv SET key = ? WHERE key = ?")
1128 .bind(format!("issues_last_ingested:{new}"))
1129 .bind(format!("issues_last_ingested:{old}"))
1130 .execute(&mut *conn)
1131 .await?;
1132 Ok(())
1133 }
1134
1135 fn prefix_len(prefix: &str) -> anyhow::Result<i64> {
1137 Ok(i64::try_from(prefix.chars().count())?)
1138 }
1139
1140 pub(crate) async fn replace_dependencies(
1141 conn: &mut SqliteConnection,
1142 task_id: i64,
1143 rows: &[NewDependency],
1144 ) -> anyhow::Result<()> {
1145 sqlx::query("DELETE FROM task_dependencies WHERE task_id = ?")
1146 .bind(task_id)
1147 .execute(&mut *conn)
1148 .await?;
1149 for row in rows {
1150 if row.number <= 0 || row.repo.is_empty() {
1151 continue;
1152 }
1153 sqlx::query(
1154 "INSERT INTO task_dependencies
1155 (task_id, depends_on_repo, depends_on_number, source)
1156 VALUES (?, ?, ?, ?)",
1157 )
1158 .bind(task_id)
1159 .bind(&row.repo)
1160 .bind(row.number)
1161 .bind(row.source.as_str())
1162 .execute(&mut *conn)
1163 .await?;
1164 }
1165 sqlx::query(
1166 "UPDATE task_dependencies
1167 SET depends_on_task_id = (
1168 SELECT id FROM tasks
1169 WHERE tasks.repo = task_dependencies.depends_on_repo
1170 AND tasks.external_id = CAST(task_dependencies.depends_on_number AS TEXT)
1171 LIMIT 1
1172 )
1173 WHERE task_id = ?",
1174 )
1175 .bind(task_id)
1176 .execute(&mut *conn)
1177 .await?;
1178 Ok(())
1179 }
1180
1181 pub(crate) async fn link_known_task(
1182 conn: &mut SqliteConnection,
1183 task_id: i64,
1184 repo: &str,
1185 number: i64,
1186 ) -> anyhow::Result<()> {
1187 sqlx::query(
1188 "UPDATE task_dependencies
1189 SET depends_on_task_id = ?
1190 WHERE depends_on_repo = ? AND depends_on_number = ?",
1191 )
1192 .bind(task_id)
1193 .bind(repo)
1194 .bind(number)
1195 .execute(conn)
1196 .await?;
1197 Ok(())
1198 }
1199
1200 pub(crate) async fn block_claim(
1201 conn: &mut SqliteConnection,
1202 now: &str,
1203 task_id: i64,
1204 ) -> anyhow::Result<Option<Vec<String>>> {
1205 sqlx::query("UPDATE tasks SET updated_at = updated_at WHERE id = ?")
1208 .bind(task_id)
1209 .execute(&mut *conn)
1210 .await
1211 .context("lock task for dependency claim")?;
1212 let row = sqlx::query("SELECT status, agent, model, repo, body FROM tasks WHERE id = ?")
1213 .bind(task_id)
1214 .fetch_optional(&mut *conn)
1215 .await?;
1216 let Some(row) = row else {
1217 return Ok(None);
1218 };
1219 let status: String = row.try_get("status")?;
1220 let agent: Option<String> = row.try_get("agent")?;
1221 let model: Option<String> = row.try_get("model")?;
1222 let repo: String = row.try_get("repo")?;
1223 let body: String = row.try_get("body").unwrap_or_default();
1224 let views = load_blockers(conn, task_id, &repo).await?;
1225 let mut unmet: Vec<String> = views
1226 .iter()
1227 .filter(|view| !view.satisfied)
1228 .map(|view| view.label.clone())
1229 .collect();
1230 for id in blocked_external_from_body(&body) {
1231 if !unmet.iter().any(|have| have == &id) {
1232 unmet.push(id);
1233 }
1234 }
1235
1236 let holdable = matches!(
1237 status.as_str(),
1238 "new" | "routed" | "waiting" | "blocked" | "needs_review"
1239 );
1240 if unmet.is_empty() {
1241 if status == "waiting" {
1242 sqlx::query(
1243 "UPDATE tasks
1244 SET status = 'new', block_reason = NULL, branch = '', worktree = '',
1245 updated_at = ?
1246 WHERE id = ?",
1247 )
1248 .bind(now)
1249 .bind(task_id)
1250 .execute(&mut *conn)
1251 .await?;
1252 sqlx::query(
1253 "INSERT INTO kv (key, value, updated_at)
1254 VALUES (?, '1', ?)
1255 ON CONFLICT(key) DO UPDATE
1256 SET value = excluded.value, updated_at = excluded.updated_at",
1257 )
1258 .bind(worktree_refresh_key(task_id))
1259 .bind(now)
1260 .execute(&mut *conn)
1261 .await?;
1262 crate::store::tasks::apply::activity(
1263 conn,
1264 task_id,
1265 "status_change",
1266 Some("waiting"),
1267 Some("new"),
1268 agent.as_deref(),
1269 model.as_deref(),
1270 None,
1271 )
1272 .await?;
1273 }
1274 return Ok(None);
1275 }
1276
1277 if holdable && status != "waiting" {
1278 sqlx::query(
1279 "UPDATE tasks
1280 SET status = 'waiting', branch = '', worktree = '', block_reason = NULL,
1281 updated_at = ?
1282 WHERE id = ?",
1283 )
1284 .bind(now)
1285 .bind(task_id)
1286 .execute(&mut *conn)
1287 .await?;
1288 crate::store::tasks::apply::activity(
1289 conn,
1290 task_id,
1291 "status_change",
1292 Some(&status),
1293 Some("waiting"),
1294 agent.as_deref(),
1295 model.as_deref(),
1296 None,
1297 )
1298 .await?;
1299 }
1300 Ok(Some(unmet))
1301 }
1302}