1use crate::backend_readiness::{self, DrainDecision};
12use crate::deps;
13use crate::event_log::EventLog;
14use crate::paths::MissionPaths;
15use crate::queue::{self, QueueEntry};
16use crate::reducer;
17use crate::ticket::{Ticket, TicketState};
18use crate::types::MissionStatus;
19use anyhow::Result;
20use std::future::Future;
21use std::path::Path;
22
23#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum WorkAction {
30 Empty,
32 Busy { mission_id: String },
34 Run {
36 mission_id: String,
37 ticket_slug: Option<String>,
38 },
39}
40
41pub fn next_work_action(front: Option<&QueueEntry>, busy_with: Option<&str>) -> WorkAction {
44 match front {
45 None => WorkAction::Empty,
46 Some(_) if busy_with.is_some() => WorkAction::Busy {
47 mission_id: busy_with.expect("checked is_some").to_string(),
48 },
49 Some(entry) => WorkAction::Run {
50 mission_id: entry.mission_id.clone(),
51 ticket_slug: entry.ticket_slug.clone(),
52 },
53 }
54}
55
56pub fn work_skip_for_failed_blocker(repo_root: &Path, slug: &str) -> Result<Option<String>> {
64 let unsatisfied = deps::unsatisfied_blockers(repo_root, slug)?;
65 for blocker in unsatisfied {
66 if Ticket::read_state(repo_root, &blocker) == TicketState::Failed {
67 return Ok(Some(blocker));
68 }
69 }
70 Ok(None)
71}
72
73pub fn ticket_state_for_mission(status: MissionStatus) -> TicketState {
77 match status {
78 MissionStatus::Complete => TicketState::Done,
79 MissionStatus::Failed => TicketState::Failed,
80 MissionStatus::Abandoned => TicketState::Failed,
81 MissionStatus::Blocked => TicketState::NeedsContext,
84 _ => TicketState::Failed,
85 }
86}
87
88pub fn reconcile_ticket_for_mission(
103 repo_root: &Path,
104 mission_id: &str,
105) -> crate::error::Result<Option<(String, TicketState)>> {
106 let Some(slug) = Ticket::slug_for_mission(repo_root, mission_id) else {
107 return Ok(None);
108 };
109
110 let paths = MissionPaths::new(repo_root, mission_id);
111 if !paths.events_file().is_file() {
112 return Ok(None);
113 }
114 let Ok(events) = EventLog::read_events(&paths.events_file()) else {
115 return Ok(None);
116 };
117 let Ok(state) = reducer::fold(&events) else {
118 return Ok(None);
119 };
120
121 let status = state.mission.status;
122 if !matches!(
123 status,
124 MissionStatus::Complete
125 | MissionStatus::Failed
126 | MissionStatus::Abandoned
127 | MissionStatus::Blocked
128 ) {
129 return Ok(None);
130 }
131
132 let mapped = ticket_state_for_mission(status);
133 let current = Ticket::read_state(repo_root, &slug);
134 if current == mapped {
135 return Ok(None);
136 }
137 Ticket::write_state(repo_root, &slug, mapped, None)?;
138 Ok(Some((slug, mapped)))
139}
140
141#[derive(Debug, Clone, Default, PartialEq, Eq)]
148pub struct DrainReport {
149 pub ran: Vec<String>,
150 pub skipped: Vec<String>,
151 pub parked: Vec<String>,
154 pub stopped_busy: bool,
160 pub expected_mismatch: Option<String>,
163}
164
165const RATE_LIMIT_ROTATE_CAP: u32 = 3;
168
169pub async fn drain_queue<R, Fut>(
186 repo_root: &Path,
187 once: bool,
188 run_mission: R,
189) -> Result<DrainReport>
190where
191 R: Fn(String) -> Fut,
192 Fut: Future<Output = Result<i32>>,
193{
194 drain_queue_expected(repo_root, once, None, run_mission).await
195}
196
197pub async fn drain_queue_expected<R, Fut>(
204 repo_root: &Path,
205 once: bool,
206 expected: Option<&str>,
207 run_mission: R,
208) -> Result<DrainReport>
209where
210 R: Fn(String) -> Fut,
211 Fut: Future<Output = Result<i32>>,
212{
213 drain_queue_with_probe_expected(
214 repo_root,
215 once,
216 expected,
217 run_mission,
218 backend_readiness::probe_mission,
219 )
220 .await
221}
222
223pub async fn drain_queue_with_probe<R, Fut, P>(
228 repo_root: &Path,
229 once: bool,
230 run_mission: R,
231 probe: P,
232) -> Result<DrainReport>
233where
234 R: Fn(String) -> Fut,
235 Fut: Future<Output = Result<i32>>,
236 P: Fn(&Path, &str) -> crate::error::Result<backend_readiness::ReadinessReport>,
237{
238 drain_queue_with_probe_expected(repo_root, once, None, run_mission, probe).await
239}
240
241async fn drain_queue_with_probe_expected<R, Fut, P>(
242 repo_root: &Path,
243 once: bool,
244 expected: Option<&str>,
245 run_mission: R,
246 probe: P,
247) -> Result<DrainReport>
248where
249 R: Fn(String) -> Fut,
250 Fut: Future<Output = Result<i32>>,
251 P: Fn(&Path, &str) -> crate::error::Result<backend_readiness::ReadinessReport>,
252{
253 let mut report = DrainReport::default();
254 let mut rate_limit_hits: std::collections::HashMap<String, u32> =
255 std::collections::HashMap::new();
256 queue::recover_dead_claims(repo_root);
257 loop {
258 match queue::claim_front_when_repo_free(repo_root)? {
259 queue::ClaimFront::Empty => return Ok(report),
260 queue::ClaimFront::LostRace => {
261 tokio::time::sleep(std::time::Duration::from_millis(250)).await;
262 continue;
263 }
264 queue::ClaimFront::Busy { .. } => {
265 if once {
266 report.stopped_busy = true;
267 return Ok(report);
268 }
269 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
270 continue;
271 }
272 queue::ClaimFront::Claimed(claim) => {
273 let mission_id = claim.entry.mission_id.clone();
274 if expected.is_some_and(|expected| expected != mission_id) {
275 queue::release_claim(claim);
276 report.expected_mismatch = Some(mission_id);
277 return Ok(report);
278 }
279 let ticket_slug = claim
280 .entry
281 .ticket_slug
282 .clone()
283 .or_else(|| Ticket::slug_for_mission(repo_root, &mission_id));
284
285 match probe(repo_root, &mission_id) {
287 Ok(readiness) => match readiness.drain_decision() {
288 DrainDecision::Proceed { warnings } => {
289 for w in warnings {
290 tracing::warn!(
291 mission = %mission_id,
292 warning = %w,
293 "backend readiness warning; proceeding"
294 );
295 }
296 }
297 DrainDecision::Park { reason } => {
298 tracing::warn!(
299 mission = %mission_id,
300 reason = %reason,
301 "parking claimed mission: backend not ready"
302 );
303 let stop = settle_parked_claim(
304 repo_root,
305 claim,
306 ticket_slug.as_deref(),
307 format!("parked (backend not ready): {reason}"),
308 )?;
309 report.parked.push(mission_id);
310 if once || stop {
311 return Ok(report);
312 }
313 continue;
314 }
315 DrainDecision::RequeueDelay { reason, delay } => {
316 let hits = rate_limit_hits.entry(mission_id.clone()).or_insert(0);
317 *hits += 1;
318 let hits = *hits;
319 tracing::warn!(
320 mission = %mission_id,
321 reason = %reason,
322 delay_secs = delay.as_secs(),
323 hits,
324 "backend rate-limited after claim"
325 );
326 if hits >= RATE_LIMIT_ROTATE_CAP {
327 let stop = settle_parked_claim(
328 repo_root,
329 claim,
330 ticket_slug.as_deref(),
331 format!("parked (rate-limited {hits}×): {reason}"),
332 )?;
333 report.parked.push(mission_id);
334 if once || stop {
335 return Ok(report);
336 }
337 continue;
338 }
339 let entry = claim.entry.clone();
343 queue::release_claim(claim);
344 rotate_entry_to_back(repo_root, &entry)?;
345 if once {
346 return Ok(report);
347 }
348 #[cfg(test)]
351 let delay = delay.min(std::time::Duration::from_millis(1));
352 tokio::time::sleep(delay).await;
353 continue;
354 }
355 },
356 Err(e) => {
357 tracing::warn!(
358 mission = %mission_id,
359 error = %e,
360 "backend readiness probe failed; proceeding"
361 );
362 }
363 }
364
365 if let crate::disk_preflight::DiskPreflight::Insufficient {
371 free_bytes,
372 estimate_bytes,
373 } = crate::disk_preflight::check(repo_root)
374 {
375 let reason = format!(
376 "insufficient disk for the mission build ladder: {} free < {} estimated \
377 (free space or raise the estimate)",
378 crate::disk_preflight::gib(free_bytes),
379 crate::disk_preflight::gib(estimate_bytes)
380 );
381 tracing::warn!(mission = %mission_id, reason = %reason, "parking claimed mission: disk preflight");
382 let stop = settle_parked_claim(
383 repo_root,
384 claim,
385 ticket_slug.as_deref(),
386 format!("parked (disk): {reason}"),
387 )?;
388 report.parked.push(mission_id);
389 if once || stop {
390 return Ok(report);
391 }
392 continue;
393 }
394
395 if let Some(slug) = &ticket_slug {
396 if let Some(blocker) = work_skip_for_failed_blocker(repo_root, slug)? {
397 queue::finish_claim(claim);
398 Ticket::write_state(
399 repo_root,
400 slug,
401 TicketState::Failed,
402 Some(format!("skipped: blocked-by {blocker} failed")),
403 )?;
404 report.skipped.push(mission_id);
405 continue;
406 }
407 Ticket::write_state(repo_root, slug, TicketState::Running, None)?;
408 }
409
410 let status = run_mission(mission_id.clone()).await;
411
412 match &status {
413 Ok(_) => queue::finish_claim(claim),
414 Err(_) if ticket_slug.is_some() => queue::finish_claim(claim),
415 Err(_) => queue::release_claim(claim),
416 }
417
418 if ticket_slug.is_some() {
419 reconcile_ticket_for_mission(repo_root, &mission_id)?;
420 } else {
421 status?;
422 }
423 report.ran.push(mission_id);
424
425 if once {
426 return Ok(report);
427 }
428 }
429 }
430 }
431}
432
433fn settle_parked_claim(
438 repo_root: &Path,
439 claim: queue::Claim,
440 ticket_slug: Option<&str>,
441 note: String,
442) -> Result<bool> {
443 if let Some(slug) = ticket_slug {
444 queue::finish_claim(claim);
445 Ticket::write_state(repo_root, slug, TicketState::Parked, Some(note))?;
446 Ok(false)
447 } else {
448 queue::release_claim(claim);
449 Ok(true)
450 }
451}
452
453fn rotate_entry_to_back(repo_root: &Path, entry: &QueueEntry) -> Result<()> {
456 queue::remove(repo_root, &entry.mission_id);
457 let _ = queue::enqueue(
458 repo_root,
459 QueueEntry {
460 mission_id: entry.mission_id.clone(),
461 ticket_slug: entry.ticket_slug.clone(),
462 priority: entry.priority,
463 seq: 0,
465 },
466 )?;
467 Ok(())
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473 use crate::events::{Event, EventKind};
474 use crate::types::MissionConfig;
475 use chrono::Utc;
476 use std::sync::atomic::{AtomicUsize, Ordering};
477 use std::sync::Arc;
478
479 #[test]
480 fn next_work_action_empty_when_no_front() {
481 assert_eq!(next_work_action(None, None), WorkAction::Empty);
482 }
483
484 #[test]
485 fn next_work_action_busy_takes_priority_over_front() {
486 let entry = QueueEntry {
487 mission_id: "m1".to_string(),
488 ticket_slug: None,
489 priority: 2,
490 seq: 0,
491 };
492 assert_eq!(
493 next_work_action(Some(&entry), Some("busy-mission")),
494 WorkAction::Busy {
495 mission_id: "busy-mission".to_string()
496 }
497 );
498 }
499
500 #[test]
501 fn next_work_action_runs_the_front_when_idle() {
502 let entry = QueueEntry {
503 mission_id: "m1".to_string(),
504 ticket_slug: Some("slug-a".to_string()),
505 priority: 2,
506 seq: 0,
507 };
508 assert_eq!(
509 next_work_action(Some(&entry), None),
510 WorkAction::Run {
511 mission_id: "m1".to_string(),
512 ticket_slug: Some("slug-a".to_string()),
513 }
514 );
515 }
516
517 #[test]
518 fn ticket_state_for_mission_maps_terminal_status() {
519 assert_eq!(
520 ticket_state_for_mission(MissionStatus::Complete),
521 TicketState::Done
522 );
523 assert_eq!(
524 ticket_state_for_mission(MissionStatus::Blocked),
525 TicketState::NeedsContext
526 );
527 assert_eq!(
528 ticket_state_for_mission(MissionStatus::Failed),
529 TicketState::Failed
530 );
531 assert_eq!(
532 ticket_state_for_mission(MissionStatus::Abandoned),
533 TicketState::Failed
534 );
535 }
536
537 fn write_ticket(repo: &Path, slug: &str, body: &str) {
538 let dir = Ticket::tickets_dir(repo);
539 std::fs::create_dir_all(&dir).unwrap();
540 std::fs::write(dir.join(format!("{slug}.md")), body).unwrap();
541 }
542
543 fn write_events(repo_root: &Path, mission_id: &str, kinds: Vec<EventKind>) {
546 let dir = repo_root.join(".kranz").join("missions").join(mission_id);
547 std::fs::create_dir_all(&dir).unwrap();
548 let mut lines = String::new();
549 for (i, kind) in kinds.into_iter().enumerate() {
550 let event = Event {
551 seq: (i + 1) as u64,
552 ts: Utc::now(),
553 mission_id: mission_id.to_string(),
554 kind,
555 };
556 lines.push_str(&serde_json::to_string(&event).unwrap());
557 lines.push('\n');
558 }
559 std::fs::write(dir.join("events.jsonl"), lines).unwrap();
560 }
561
562 fn created() -> EventKind {
563 EventKind::MissionCreated {
564 goal: "fixture mission".to_string(),
565 base_branch: "main".to_string(),
566 mission_branch: "kranz/mission-fixture".to_string(),
567 config: MissionConfig::default(),
568 }
569 }
570
571 fn scaffold_ticket(repo_root: &Path, slug: &str, mission_id: &str, state: TicketState) {
572 Ticket::scaffold(repo_root, slug, "fixture ticket", None, None).unwrap();
573 Ticket::record_mission(repo_root, slug, mission_id).unwrap();
574 Ticket::write_state(repo_root, slug, state, None).unwrap();
575 }
576
577 fn plan_with_one_milestone() -> crate::types::Plan {
578 crate::types::Plan {
579 goal: "fixture goal".to_string(),
580 validation_contract: vec![],
581 milestones: vec![crate::types::PlanMilestone {
582 title: "milestone one".to_string(),
583 features: vec![crate::types::PlanFeature {
584 title: "feature one".to_string(),
585 spec: "spec".to_string(),
586 validation_criteria: vec!["works".to_string()],
587 }],
588 }],
589 considered_alternatives: None,
590 command_grants: vec![],
591 touch_set: vec![],
592 standards_manifest: None,
593 reviewer_independence: None,
594 }
595 }
596
597 #[test]
598 fn reconcile_on_terminal_maps_complete_to_done() {
599 let tmp = tempfile::tempdir().unwrap();
600 let repo = tmp.path();
601 scaffold_ticket(repo, "my-ticket", "m1", TicketState::Running);
602 write_events(repo, "m1", vec![created(), EventKind::MissionCompleted {}]);
603
604 let result = reconcile_ticket_for_mission(repo, "m1").unwrap();
605 assert_eq!(result, Some(("my-ticket".to_string(), TicketState::Done)));
606 assert_eq!(Ticket::read_state(repo, "my-ticket"), TicketState::Done);
607 }
608
609 #[test]
610 fn reconcile_heals_failed_to_done() {
611 let tmp = tempfile::tempdir().unwrap();
612 let repo = tmp.path();
613 scaffold_ticket(repo, "my-ticket", "m1", TicketState::Failed);
614 write_events(repo, "m1", vec![created(), EventKind::MissionCompleted {}]);
615
616 let result = reconcile_ticket_for_mission(repo, "m1").unwrap();
617 assert_eq!(result, Some(("my-ticket".to_string(), TicketState::Done)));
618 assert_eq!(Ticket::read_state(repo, "my-ticket"), TicketState::Done);
619 }
620
621 #[test]
622 fn blocked_reconciles_to_needs_you() {
623 let tmp = tempfile::tempdir().unwrap();
624 let repo = tmp.path();
625 scaffold_ticket(repo, "my-ticket", "m1", TicketState::Running);
626 write_events(
627 repo,
628 "m1",
629 vec![
630 created(),
631 EventKind::PlanApproved {
632 plan: plan_with_one_milestone(),
633 base_sha: None,
634 },
635 EventKind::MilestoneBlocked {
636 block_context: None,
637 milestone_id: "ms-1".to_string(),
638 reason: "needs input".to_string(),
639 },
640 ],
641 );
642
643 let result = reconcile_ticket_for_mission(repo, "m1").unwrap();
644 assert_eq!(
645 result,
646 Some(("my-ticket".to_string(), TicketState::NeedsContext))
647 );
648 assert_eq!(
649 Ticket::read_state(repo, "my-ticket"),
650 TicketState::NeedsContext
651 );
652 }
653
654 #[test]
655 fn reconcile_returns_none_when_no_linked_ticket() {
656 let tmp = tempfile::tempdir().unwrap();
657 let repo = tmp.path();
658 write_events(repo, "m1", vec![created(), EventKind::MissionCompleted {}]);
659
660 assert_eq!(reconcile_ticket_for_mission(repo, "m1").unwrap(), None);
661 }
662
663 #[test]
664 fn reconcile_returns_none_when_mission_status_is_live() {
665 let tmp = tempfile::tempdir().unwrap();
666 let repo = tmp.path();
667 scaffold_ticket(repo, "my-ticket", "m1", TicketState::Running);
668 write_events(repo, "m1", vec![created()]);
669
670 assert_eq!(reconcile_ticket_for_mission(repo, "m1").unwrap(), None);
671 assert_eq!(Ticket::read_state(repo, "my-ticket"), TicketState::Running);
672 }
673
674 fn proceed_report(mission_id: &str) -> backend_readiness::ReadinessReport {
675 backend_readiness::ReadinessReport {
676 mission_id: mission_id.to_string(),
677 roles: vec![],
678 overall: backend_readiness::ReadinessStatus::Ok,
679 warnings: vec![],
680 }
681 }
682
683 fn always_proceed(
684 _repo: &Path,
685 id: &str,
686 ) -> crate::error::Result<backend_readiness::ReadinessReport> {
687 Ok(proceed_report(id))
688 }
689
690 fn park_report(mission_id: &str) -> backend_readiness::ReadinessReport {
691 backend_readiness::ReadinessReport {
692 mission_id: mission_id.to_string(),
693 roles: vec![backend_readiness::RoleReadiness {
694 role: "worker".into(),
695 backend: "claude".into(),
696 status: backend_readiness::ReadinessStatus::Missing,
697 detail: "no binary".into(),
698 next_action: "install".into(),
699 }],
700 overall: backend_readiness::ReadinessStatus::Missing,
701 warnings: vec![],
702 }
703 }
704
705 fn rate_limited_report(mission_id: &str) -> backend_readiness::ReadinessReport {
706 backend_readiness::ReadinessReport {
707 mission_id: mission_id.to_string(),
708 roles: vec![backend_readiness::RoleReadiness {
709 role: "orchestrator".into(),
710 backend: "claude".into(),
711 status: backend_readiness::ReadinessStatus::RateLimited,
712 detail: "429".into(),
713 next_action: "wait".into(),
714 }],
715 overall: backend_readiness::ReadinessStatus::RateLimited,
716 warnings: vec![],
717 }
718 }
719
720 #[tokio::test]
721 async fn drain_queue_runs_a_queued_mission_and_retires_its_claim() {
722 let tmp = tempfile::tempdir().unwrap();
723 let repo = tmp.path();
724 queue::enqueue(
725 repo,
726 QueueEntry {
727 mission_id: "mission-1".to_string(),
728 ticket_slug: None,
729 priority: 2,
730 seq: 0,
731 },
732 )
733 .unwrap();
734
735 let ran = Arc::new(AtomicUsize::new(0));
736 let ran_clone = ran.clone();
737 let report = drain_queue_with_probe(
738 repo,
739 false,
740 move |mission_id| {
741 let ran = ran_clone.clone();
742 async move {
743 assert_eq!(mission_id, "mission-1");
744 ran.fetch_add(1, Ordering::SeqCst);
745 Ok(0)
746 }
747 },
748 always_proceed,
749 )
750 .await
751 .unwrap();
752
753 assert_eq!(ran.load(Ordering::SeqCst), 1);
754 assert_eq!(report.ran, vec!["mission-1".to_string()]);
755 assert!(report.skipped.is_empty());
756 assert!(queue::list(repo).is_empty());
758 let dir = queue::queue_dir(repo);
759 let leftover: Vec<_> = std::fs::read_dir(&dir)
760 .unwrap()
761 .flatten()
762 .filter(|e| {
763 e.path()
764 .file_name()
765 .and_then(|n| n.to_str())
766 .is_some_and(|n| n.contains(".claimed."))
767 })
768 .collect();
769 assert!(leftover.is_empty(), "claim file was not retired");
770 }
771
772 #[tokio::test]
773 async fn drain_queue_skips_ticket_whose_blocker_failed() {
774 let tmp = tempfile::tempdir().unwrap();
775 let repo = tmp.path();
776
777 write_ticket(
778 repo,
779 "blocker",
780 "---\ntitle: blocker\npriority: 2\nschedule: once\n---\n\n## Goal\nblock\n",
781 );
782 write_ticket(
783 repo,
784 "dependent",
785 "---\ntitle: dependent\npriority: 2\nschedule: once\nblocked-by: [blocker]\n---\n\n## Goal\ndepend\n",
786 );
787 Ticket::write_state(repo, "blocker", TicketState::Failed, None).unwrap();
788
789 queue::enqueue(
790 repo,
791 QueueEntry {
792 mission_id: "mission-dep".to_string(),
793 ticket_slug: Some("dependent".to_string()),
794 priority: 2,
795 seq: 0,
796 },
797 )
798 .unwrap();
799
800 let ran = Arc::new(AtomicUsize::new(0));
801 let ran_clone = ran.clone();
802 let report = drain_queue_with_probe(
803 repo,
804 false,
805 move |_mission_id| {
806 let ran = ran_clone.clone();
807 async move {
808 ran.fetch_add(1, Ordering::SeqCst);
809 Ok(0)
810 }
811 },
812 always_proceed,
813 )
814 .await
815 .unwrap();
816
817 assert_eq!(
818 ran.load(Ordering::SeqCst),
819 0,
820 "the doomed entry must not run"
821 );
822 assert_eq!(report.skipped, vec!["mission-dep".to_string()]);
823 assert!(report.ran.is_empty());
824 assert_eq!(Ticket::read_state(repo, "dependent"), TicketState::Failed);
825 assert!(queue::list(repo).is_empty());
827 }
828
829 #[tokio::test]
830 async fn drain_queue_honors_once() {
831 let tmp = tempfile::tempdir().unwrap();
832 let repo = tmp.path();
833 for i in 0..2 {
834 queue::enqueue(
835 repo,
836 QueueEntry {
837 mission_id: format!("mission-{i}"),
838 ticket_slug: None,
839 priority: 2,
840 seq: 0,
841 },
842 )
843 .unwrap();
844 }
845
846 let ran = Arc::new(AtomicUsize::new(0));
847 let ran_clone = ran.clone();
848 let report = drain_queue_with_probe(
849 repo,
850 true,
851 move |_mission_id| {
852 let ran = ran_clone.clone();
853 async move {
854 ran.fetch_add(1, Ordering::SeqCst);
855 Ok(0)
856 }
857 },
858 always_proceed,
859 )
860 .await
861 .unwrap();
862
863 assert_eq!(
864 ran.load(Ordering::SeqCst),
865 1,
866 "--once must run exactly one entry"
867 );
868 assert_eq!(report.ran.len(), 1);
869 assert_eq!(queue::list(repo).len(), 1);
871 }
872
873 #[tokio::test]
874 async fn expected_front_mismatch_releases_claim_without_running() {
875 let tmp = tempfile::tempdir().unwrap();
876 let repo = tmp.path();
877 for mission_id in ["mission-front", "mission-expected"] {
878 queue::enqueue(
879 repo,
880 QueueEntry {
881 mission_id: mission_id.to_string(),
882 ticket_slug: None,
883 priority: 2,
884 seq: 0,
885 },
886 )
887 .unwrap();
888 }
889
890 let ran = Arc::new(AtomicUsize::new(0));
891 let ran_clone = ran.clone();
892 let report = drain_queue_with_probe_expected(
893 repo,
894 true,
895 Some("mission-expected"),
896 move |_mission_id| {
897 let ran = ran_clone.clone();
898 async move {
899 ran.fetch_add(1, Ordering::SeqCst);
900 Ok(0)
901 }
902 },
903 always_proceed,
904 )
905 .await
906 .unwrap();
907
908 assert_eq!(ran.load(Ordering::SeqCst), 0);
909 assert_eq!(report.expected_mismatch.as_deref(), Some("mission-front"));
910 assert_eq!(
911 queue::list(repo)
912 .into_iter()
913 .map(|entry| entry.mission_id)
914 .collect::<Vec<_>>(),
915 vec!["mission-front", "mission-expected"]
916 );
917 }
918
919 #[tokio::test]
920 async fn concurrent_drain_queue_once_reports_busy_without_running_second_entry() {
921 let tmp = tempfile::tempdir().unwrap();
922 let repo = tmp.path();
923 for i in 1..=2 {
924 queue::enqueue(
925 repo,
926 QueueEntry {
927 mission_id: format!("mission-{i}"),
928 ticket_slug: None,
929 priority: 2,
930 seq: 0,
931 },
932 )
933 .unwrap();
934 }
935
936 let first_runs = Arc::new(AtomicUsize::new(0));
937 let release_first = Arc::new(tokio::sync::Notify::new());
938 let first_repo = repo.to_path_buf();
939 let first = {
940 let first_runs = first_runs.clone();
941 let release_first = release_first.clone();
942 tokio::spawn(async move {
943 drain_queue_with_probe(
944 &first_repo,
945 true,
946 move |mission_id| {
947 let first_runs = first_runs.clone();
948 let release_first = release_first.clone();
949 async move {
950 assert_eq!(mission_id, "mission-1");
951 first_runs.fetch_add(1, Ordering::SeqCst);
952 release_first.notified().await;
953 Ok(0)
954 }
955 },
956 always_proceed,
957 )
958 .await
959 })
960 };
961
962 for _ in 0..50 {
963 if first_runs.load(Ordering::SeqCst) == 1 {
964 break;
965 }
966 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
967 }
968 assert_eq!(
969 first_runs.load(Ordering::SeqCst),
970 1,
971 "first drainer should be holding the repo guard"
972 );
973
974 let second_runs = Arc::new(AtomicUsize::new(0));
975 let second_runs_clone = second_runs.clone();
976 let second = drain_queue_with_probe(
977 repo,
978 true,
979 move |_mission_id| {
980 let second_runs = second_runs_clone.clone();
981 async move {
982 second_runs.fetch_add(1, Ordering::SeqCst);
983 Ok(0)
984 }
985 },
986 always_proceed,
987 )
988 .await
989 .unwrap();
990 assert!(second.stopped_busy);
991 assert!(second.ran.is_empty());
992 assert_eq!(
993 second_runs.load(Ordering::SeqCst),
994 0,
995 "busy loser must not run the next queued mission"
996 );
997 assert!(
998 queue::contains(repo, "mission-2"),
999 "busy loser releases its temporary claim"
1000 );
1001
1002 release_first.notify_waiters();
1003 let first = first.await.unwrap().unwrap();
1004 assert_eq!(first.ran, vec!["mission-1".to_string()]);
1005 assert!(queue::contains(repo, "mission-2"));
1006 }
1007
1008 #[tokio::test]
1009 async fn drain_queue_runs_a_ticket_born_entry_to_done() {
1010 let tmp = tempfile::tempdir().unwrap();
1011 let repo = tmp.path();
1012
1013 write_ticket(
1014 repo,
1015 "satisfiable",
1016 "---\ntitle: satisfiable\npriority: 2\nschedule: once\n---\n\n## Goal\nship\n",
1017 );
1018 Ticket::record_mission(repo, "satisfiable", "mission-ticket").unwrap();
1019
1020 queue::enqueue(
1021 repo,
1022 QueueEntry {
1023 mission_id: "mission-ticket".to_string(),
1024 ticket_slug: Some("satisfiable".to_string()),
1025 priority: 2,
1026 seq: 0,
1027 },
1028 )
1029 .unwrap();
1030
1031 let ran = Arc::new(AtomicUsize::new(0));
1032 let ran_clone = ran.clone();
1033 let repo_path = repo.to_path_buf();
1034 let report = drain_queue_with_probe(
1035 repo,
1036 false,
1037 move |mission_id| {
1038 let ran = ran_clone.clone();
1039 let repo_path = repo_path.clone();
1040 async move {
1041 assert_eq!(mission_id, "mission-ticket");
1042 ran.fetch_add(1, Ordering::SeqCst);
1043 write_events(
1044 &repo_path,
1045 &mission_id,
1046 vec![created(), EventKind::MissionCompleted {}],
1047 );
1048 Ok(0)
1049 }
1050 },
1051 always_proceed,
1052 )
1053 .await
1054 .unwrap();
1055
1056 assert_eq!(ran.load(Ordering::SeqCst), 1);
1057 assert_eq!(report.ran, vec!["mission-ticket".to_string()]);
1058 assert!(report.skipped.is_empty());
1059 assert_eq!(Ticket::read_state(repo, "satisfiable"), TicketState::Done);
1060 assert!(queue::list(repo).is_empty());
1062 let dir = queue::queue_dir(repo);
1063 let leftover: Vec<_> = std::fs::read_dir(&dir)
1064 .unwrap()
1065 .flatten()
1066 .filter(|e| {
1067 e.path()
1068 .file_name()
1069 .and_then(|n| n.to_str())
1070 .is_some_and(|n| n.contains(".claimed."))
1071 })
1072 .collect();
1073 assert!(leftover.is_empty(), "claim file was not retired");
1074 }
1075
1076 #[tokio::test]
1082 async fn drain_queue_resolves_a_bare_entry_to_its_linked_ticket() {
1083 let tmp = tempfile::tempdir().unwrap();
1084 let repo = tmp.path();
1085
1086 write_ticket(
1087 repo,
1088 "linked",
1089 "---\ntitle: linked\npriority: 2\nschedule: once\n---\n\n## Goal\nship\n",
1090 );
1091 Ticket::record_mission(repo, "linked", "m-linked").unwrap();
1094 Ticket::write_state(repo, "linked", TicketState::Queued, None).unwrap();
1095
1096 write_ticket(
1098 repo,
1099 "other",
1100 "---\ntitle: other\npriority: 2\nschedule: once\n---\n\n## Goal\nnope\n",
1101 );
1102 Ticket::record_mission(repo, "other", "m-other").unwrap();
1103
1104 queue::enqueue(
1105 repo,
1106 QueueEntry {
1107 mission_id: "m-linked".to_string(),
1108 ticket_slug: None, priority: 2,
1110 seq: 0,
1111 },
1112 )
1113 .unwrap();
1114
1115 let repo_path = repo.to_path_buf();
1116 let report = drain_queue_with_probe(
1117 repo,
1118 false,
1119 move |mission_id| {
1120 let repo_path = repo_path.clone();
1121 async move {
1122 assert_eq!(mission_id, "m-linked");
1123 write_events(
1124 &repo_path,
1125 &mission_id,
1126 vec![created(), EventKind::MissionCompleted {}],
1127 );
1128 Ok(0)
1129 }
1130 },
1131 always_proceed,
1132 )
1133 .await
1134 .unwrap();
1135
1136 assert_eq!(report.ran, vec!["m-linked".to_string()]);
1137 assert_eq!(
1138 Ticket::read_state(repo, "linked"),
1139 TicketState::Done,
1140 "the linked ticket must advance via the reverse lookup"
1141 );
1142 assert_eq!(
1143 Ticket::read_state(repo, "other"),
1144 TicketState::Drafting,
1145 "an unrelated ticket must be untouched (record_mission left it Drafting)"
1146 );
1147 assert!(queue::list(repo).is_empty());
1148 }
1149
1150 #[tokio::test]
1151 async fn drain_queue_parks_ticket_when_readiness_fails_after_claim() {
1152 let tmp = tempfile::tempdir().unwrap();
1153 let repo = tmp.path();
1154
1155 write_ticket(
1156 repo,
1157 "need-auth",
1158 "---\ntitle: need-auth\npriority: 2\nschedule: once\n---\n\n## Goal\nship\n",
1159 );
1160 queue::enqueue(
1161 repo,
1162 QueueEntry {
1163 mission_id: "m-park".to_string(),
1164 ticket_slug: Some("need-auth".to_string()),
1165 priority: 2,
1166 seq: 0,
1167 },
1168 )
1169 .unwrap();
1170
1171 let ran = Arc::new(AtomicUsize::new(0));
1172 let ran_clone = ran.clone();
1173 let report = drain_queue_with_probe(
1174 repo,
1175 false,
1176 move |_mission_id| {
1177 let ran = ran_clone.clone();
1178 async move {
1179 ran.fetch_add(1, Ordering::SeqCst);
1180 Ok(0)
1181 }
1182 },
1183 |_repo, id| Ok(park_report(id)),
1184 )
1185 .await
1186 .unwrap();
1187
1188 assert_eq!(ran.load(Ordering::SeqCst), 0);
1189 assert_eq!(report.parked, vec!["m-park".to_string()]);
1190 assert!(report.ran.is_empty());
1191 assert_eq!(Ticket::read_state(repo, "need-auth"), TicketState::Parked);
1192 assert!(queue::list(repo).is_empty());
1193 }
1194
1195 #[tokio::test]
1196 async fn drain_queue_preserves_ticketless_entry_when_readiness_parks() {
1197 let tmp = tempfile::tempdir().unwrap();
1198 let repo = tmp.path();
1199 queue::enqueue(
1200 repo,
1201 QueueEntry {
1202 mission_id: "m-external".to_string(),
1203 ticket_slug: None,
1204 priority: 2,
1205 seq: 0,
1206 },
1207 )
1208 .unwrap();
1209
1210 let report = drain_queue_with_probe(
1211 repo,
1212 false,
1213 |_mission_id| async { panic!("parked ticketless mission must not run") },
1214 |_repo, id| Ok(park_report(id)),
1215 )
1216 .await
1217 .unwrap();
1218
1219 assert_eq!(report.parked, vec!["m-external".to_string()]);
1220 assert_eq!(
1221 queue::list(repo)
1222 .into_iter()
1223 .map(|entry| entry.mission_id)
1224 .collect::<Vec<_>>(),
1225 vec!["m-external"]
1226 );
1227 }
1228
1229 #[tokio::test]
1230 async fn drain_queue_rate_limit_rotates_then_parks_after_cap() {
1231 let tmp = tempfile::tempdir().unwrap();
1232 let repo = tmp.path();
1233
1234 write_ticket(
1235 repo,
1236 "limited",
1237 "---\ntitle: limited\npriority: 2\nschedule: once\n---\n\n## Goal\na\n",
1238 );
1239 write_ticket(
1240 repo,
1241 "sibling",
1242 "---\ntitle: sibling\npriority: 2\nschedule: once\n---\n\n## Goal\nb\n",
1243 );
1244 Ticket::record_mission(repo, "sibling", "m-sibling").unwrap();
1245 queue::enqueue(
1246 repo,
1247 QueueEntry {
1248 mission_id: "m-limited".to_string(),
1249 ticket_slug: Some("limited".to_string()),
1250 priority: 2,
1251 seq: 0,
1252 },
1253 )
1254 .unwrap();
1255 queue::enqueue(
1256 repo,
1257 QueueEntry {
1258 mission_id: "m-sibling".to_string(),
1259 ticket_slug: Some("sibling".to_string()),
1260 priority: 2,
1261 seq: 0,
1262 },
1263 )
1264 .unwrap();
1265
1266 let probe = |_repo: &Path, id: &str| {
1268 if id == "m-limited" {
1269 Ok(rate_limited_report(id))
1270 } else {
1271 Ok(proceed_report(id))
1272 }
1273 };
1274
1275 let ran = Arc::new(AtomicUsize::new(0));
1276 let ran_clone = ran.clone();
1277 let repo_path = repo.to_path_buf();
1278 let report = drain_queue_with_probe(
1279 repo,
1280 false,
1281 move |mission_id| {
1282 let ran = ran_clone.clone();
1283 let repo_path = repo_path.clone();
1284 async move {
1285 assert_eq!(mission_id, "m-sibling");
1286 ran.fetch_add(1, Ordering::SeqCst);
1287 write_events(
1288 &repo_path,
1289 &mission_id,
1290 vec![created(), EventKind::MissionCompleted {}],
1291 );
1292 Ok(0)
1293 }
1294 },
1295 probe,
1296 )
1297 .await
1298 .unwrap();
1299
1300 assert_eq!(ran.load(Ordering::SeqCst), 1);
1301 assert_eq!(report.ran, vec!["m-sibling".to_string()]);
1302 assert_eq!(report.parked, vec!["m-limited".to_string()]);
1303 assert_eq!(Ticket::read_state(repo, "sibling"), TicketState::Done);
1304 assert_eq!(Ticket::read_state(repo, "limited"), TicketState::Parked);
1305 assert!(queue::list(repo).is_empty());
1306 }
1307
1308 #[tokio::test]
1313 async fn drain_reconciles_terminal_ticket_via_helper() {
1314 let tmp = tempfile::tempdir().unwrap();
1315 let repo = tmp.path();
1316
1317 write_ticket(
1318 repo,
1319 "done-ticket",
1320 "---\ntitle: done\npriority: 2\nschedule: once\n---\n\n## Goal\nship\n",
1321 );
1322 write_ticket(
1323 repo,
1324 "blocked-ticket",
1325 "---\ntitle: blocked\npriority: 2\nschedule: once\n---\n\n## Goal\nship\n",
1326 );
1327 Ticket::record_mission(repo, "done-ticket", "m-done").unwrap();
1328 Ticket::record_mission(repo, "blocked-ticket", "m-blocked").unwrap();
1329
1330 queue::enqueue(
1331 repo,
1332 QueueEntry {
1333 mission_id: "m-done".to_string(),
1334 ticket_slug: Some("done-ticket".to_string()),
1335 priority: 2,
1336 seq: 0,
1337 },
1338 )
1339 .unwrap();
1340 queue::enqueue(
1341 repo,
1342 QueueEntry {
1343 mission_id: "m-blocked".to_string(),
1344 ticket_slug: Some("blocked-ticket".to_string()),
1345 priority: 2,
1346 seq: 1,
1347 },
1348 )
1349 .unwrap();
1350
1351 let repo_path = repo.to_path_buf();
1352 let report = drain_queue_with_probe(
1353 repo,
1354 false,
1355 move |mission_id| {
1356 let repo_path = repo_path.clone();
1357 async move {
1358 if mission_id == "m-done" {
1359 write_events(
1360 &repo_path,
1361 &mission_id,
1362 vec![created(), EventKind::MissionCompleted {}],
1363 );
1364 } else {
1365 write_events(
1366 &repo_path,
1367 &mission_id,
1368 vec![
1369 created(),
1370 EventKind::PlanApproved {
1371 plan: plan_with_one_milestone(),
1372 base_sha: None,
1373 },
1374 EventKind::MilestoneBlocked {
1375 block_context: None,
1376 milestone_id: "ms-1".to_string(),
1377 reason: "needs input".to_string(),
1378 },
1379 ],
1380 );
1381 }
1382 Ok(0)
1385 }
1386 },
1387 always_proceed,
1388 )
1389 .await
1390 .unwrap();
1391
1392 assert_eq!(report.ran.len(), 2);
1393 assert_eq!(Ticket::read_state(repo, "done-ticket"), TicketState::Done);
1394 assert_eq!(
1395 Ticket::read_state(repo, "blocked-ticket"),
1396 TicketState::NeedsContext
1397 );
1398 }
1399}