1use crate::error::{EngineError, Result};
15use crate::paths::MissionPaths;
16use serde::{Deserialize, Serialize};
17use std::path::{Path, PathBuf};
18use std::sync::atomic::{AtomicU64, Ordering};
19use std::sync::Mutex;
20use std::time::Duration;
21
22const SEQ_WIDTH: usize = 20;
25
26pub const ENQUEUE_SOURCE_FILE: &str = "enqueue-source.json";
31
32pub const ENQUEUE_SOURCE_RETURNED_FILE: &str = "enqueue-source.returned.json";
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "camelCase")]
38pub struct EnqueueSource {
39 pub schema_version: u8,
40 pub mission_id: String,
41 pub producer: String,
42 pub external_ref: String,
43 #[serde(default)]
47 pub created_unix_secs: u64,
48}
49
50pub fn write_enqueue_source(
54 repo_root: &Path,
55 mission_id: &str,
56 producer: &str,
57 external_ref: &str,
58) -> Result<EnqueueSource> {
59 if !MissionPaths::is_safe_id(mission_id) {
60 return Err(EngineError::Config(format!(
61 "unsafe mission id for enqueue source: {mission_id:?}"
62 )));
63 }
64 if producer.trim().is_empty() || external_ref.trim().is_empty() {
65 return Err(EngineError::Config(
66 "enqueue source producer and external ref must be non-empty".to_string(),
67 ));
68 }
69 let source = EnqueueSource {
70 schema_version: 1,
71 mission_id: mission_id.to_string(),
72 producer: producer.to_string(),
73 external_ref: external_ref.to_string(),
74 created_unix_secs: std::time::SystemTime::now()
75 .duration_since(std::time::UNIX_EPOCH)
76 .unwrap_or_default()
77 .as_secs(),
78 };
79 let path = MissionPaths::new(repo_root, mission_id)
80 .mission_dir()
81 .join(ENQUEUE_SOURCE_FILE);
82 atomic_write(&path, serde_json::to_string_pretty(&source)?.as_bytes())?;
83 Ok(source)
84}
85
86pub fn read_enqueue_source(repo_root: &Path, mission_id: &str) -> Option<EnqueueSource> {
89 if !MissionPaths::is_safe_id(mission_id) {
90 return None;
91 }
92 let path = MissionPaths::new(repo_root, mission_id)
93 .mission_dir()
94 .join(ENQUEUE_SOURCE_FILE);
95 std::fs::read_to_string(path)
96 .ok()
97 .and_then(|text| serde_json::from_str::<EnqueueSource>(&text).ok())
98 .filter(|source| {
99 source.schema_version == 1
100 && source.mission_id == mission_id
101 && !source.producer.trim().is_empty()
102 && !source.external_ref.trim().is_empty()
103 })
104}
105
106pub fn remove_enqueue_source(repo_root: &Path, mission_id: &str) {
110 if !MissionPaths::is_safe_id(mission_id) {
111 return;
112 }
113 let path = MissionPaths::new(repo_root, mission_id)
114 .mission_dir()
115 .join(ENQUEUE_SOURCE_FILE);
116 let _ = std::fs::remove_file(path);
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121#[serde(rename_all = "camelCase")]
122pub struct QueueEntry {
123 pub mission_id: String,
124 #[serde(skip_serializing_if = "Option::is_none")]
125 pub ticket_slug: Option<String>,
126 pub priority: u8,
127 pub seq: u64,
128}
129
130impl QueueEntry {
131 fn file_name(&self) -> String {
133 format!(
134 "{:03}-{:0width$}-{}.json",
135 self.priority,
136 self.seq,
137 self.mission_id,
138 width = SEQ_WIDTH
139 )
140 }
141}
142
143pub fn queue_dir(repo_root: &Path) -> PathBuf {
145 repo_root.join(".kranz").join("queue")
146}
147
148fn seq_file(repo_root: &Path) -> PathBuf {
150 queue_dir(repo_root).join(".seq")
151}
152
153static LOCAL_MUTATION_LOCK: Mutex<()> = Mutex::new(());
156
157const LOCK_STALE: Duration = Duration::from_secs(10);
164
165struct MutationLock {
166 path: PathBuf,
167 token: String,
174}
175
176impl MutationLock {
177 fn acquire(repo_root: &Path) -> Result<MutationLock> {
178 let path = queue_dir(repo_root).join(".mutate.lock");
179 let deadline = std::time::Instant::now() + Duration::from_secs(5);
180 loop {
181 let token = format!(
182 "{}.{}",
183 std::process::id(),
184 LOCK_TOKEN_SEQ.fetch_add(1, Ordering::Relaxed)
185 );
186 match std::fs::OpenOptions::new()
187 .write(true)
188 .create_new(true)
189 .open(&path)
190 {
191 Ok(mut f) => {
192 use std::io::Write as _;
193 let _ = f.write_all(token.as_bytes());
194 return Ok(MutationLock { path, token });
195 }
196 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
197 let stale = std::fs::metadata(&path)
198 .and_then(|m| m.modified())
199 .ok()
200 .and_then(|t| t.elapsed().ok())
201 .is_some_and(|age| age > LOCK_STALE);
202 if stale {
203 let _ = std::fs::remove_file(&path);
204 continue;
205 }
206 if std::time::Instant::now() > deadline {
207 return Err(crate::error::EngineError::Io(std::io::Error::new(
208 std::io::ErrorKind::TimedOut,
209 format!("queue mutation lock busy: {}", path.display()),
210 )));
211 }
212 std::thread::sleep(Duration::from_millis(25));
213 }
214 Err(e) => return Err(e.into()),
215 }
216 }
217 }
218}
219
220impl Drop for MutationLock {
221 fn drop(&mut self) {
222 let ours = std::fs::read_to_string(&self.path)
223 .map(|c| c == self.token)
224 .unwrap_or(false);
225 if ours {
226 let _ = std::fs::remove_file(&self.path);
227 }
228 }
229}
230
231static LOCK_TOKEN_SEQ: AtomicU64 = AtomicU64::new(0);
234
235fn next_seq(repo_root: &Path) -> Result<u64> {
240 let path = seq_file(repo_root);
241 let from_file = std::fs::read_to_string(&path)
242 .ok()
243 .and_then(|s| s.trim().parse::<u64>().ok());
244
245 let next = match from_file {
246 Some(current) => current.saturating_add(1),
247 None => {
248 let max_existing = list(repo_root).iter().map(|e| e.seq).max();
249 max_existing.map(|m| m.saturating_add(1)).unwrap_or(0)
250 }
251 };
252
253 atomic_write(&path, next.to_string().as_bytes())?;
254 Ok(next)
255}
256
257pub fn enqueue(repo_root: &Path, entry: QueueEntry) -> Result<QueueEntry> {
261 let dir = queue_dir(repo_root);
262 std::fs::create_dir_all(&dir)?;
263
264 let _local = LOCAL_MUTATION_LOCK
267 .lock()
268 .unwrap_or_else(|p| p.into_inner());
269 let _cross = MutationLock::acquire(repo_root)?;
270
271 if contains(repo_root, &entry.mission_id) {
272 if let Some(existing) = list(repo_root)
274 .into_iter()
275 .find(|e| e.mission_id == entry.mission_id)
276 {
277 return Ok(existing);
278 }
279 }
280
281 let seq = next_seq(repo_root)?;
282 let entry = QueueEntry { seq, ..entry };
283 let json = serde_json::to_string_pretty(&entry)?;
284 atomic_write(&dir.join(entry.file_name()), json.as_bytes())?;
285 Ok(entry)
286}
287
288pub fn list(repo_root: &Path) -> Vec<QueueEntry> {
290 let dir = queue_dir(repo_root);
291 let mut out = Vec::new();
292 let Ok(rd) = std::fs::read_dir(&dir) else {
293 return out;
294 };
295 for entry in rd.flatten() {
296 let path = entry.path();
297 if path.extension().and_then(|e| e.to_str()) != Some("json") {
298 continue;
299 }
300 match std::fs::read_to_string(&path) {
301 Ok(text) => match serde_json::from_str::<QueueEntry>(&text) {
302 Ok(qe) => out.push(qe),
303 Err(e) => {
304 tracing::warn!(path = %path.display(), error = %e, "skipping unparseable queue entry");
305 }
306 },
307 Err(e) => {
308 tracing::warn!(path = %path.display(), error = %e, "unreadable queue entry, skipping");
309 }
310 }
311 }
312 out.sort_by(|a, b| a.priority.cmp(&b.priority).then_with(|| a.seq.cmp(&b.seq)));
313 out
314}
315
316pub fn peek(repo_root: &Path) -> Option<QueueEntry> {
318 list(repo_root).into_iter().next()
319}
320
321pub fn remove(repo_root: &Path, mission_id: &str) -> bool {
323 let dir = queue_dir(repo_root);
324 let Ok(rd) = std::fs::read_dir(&dir) else {
325 return false;
326 };
327 let mut removed = false;
328 for entry in rd.flatten() {
329 let path = entry.path();
330 if path.extension().and_then(|e| e.to_str()) != Some("json") {
331 continue;
332 }
333 let is_match = std::fs::read_to_string(&path)
334 .ok()
335 .and_then(|text| serde_json::from_str::<QueueEntry>(&text).ok())
336 .is_some_and(|qe| qe.mission_id == mission_id);
337 if is_match && std::fs::remove_file(&path).is_ok() {
338 removed = true;
339 }
340 }
341 removed
342}
343
344pub fn contains(repo_root: &Path, mission_id: &str) -> bool {
346 list(repo_root).iter().any(|e| e.mission_id == mission_id)
347}
348
349fn repo_busy_lock(repo_root: &Path) -> PathBuf {
354 queue_dir(repo_root).join(".repo.busy.lock")
355}
356
357fn repo_busy_mission_file(repo_root: &Path) -> PathBuf {
358 queue_dir(repo_root).join(".repo.busy.mission")
359}
360
361fn repo_busy_mission(repo_root: &Path) -> Option<String> {
362 std::fs::read_to_string(repo_busy_mission_file(repo_root))
363 .ok()
364 .map(|s| s.trim().to_string())
365 .filter(|s| !s.is_empty())
366}
367
368fn lock_held_for_repo(repo_root: &Path) -> EngineError {
369 let holder = is_repo_busy(repo_root).unwrap_or_else(|| "unknown".to_string());
370 EngineError::LockHeld(format!("repo is busy with mission {holder}"))
371}
372
373#[derive(Debug)]
374struct RepoBusyGuard {
375 lock_path: PathBuf,
376 mission_path: PathBuf,
377}
378
379impl RepoBusyGuard {
380 fn acquire(repo_root: &Path, mission_id: &str) -> Result<Self> {
381 Self::acquire_allowing_own_legacy(repo_root, mission_id, false)
382 }
383
384 fn acquire_allowing_own_legacy(
390 repo_root: &Path,
391 mission_id: &str,
392 allow_own_legacy: bool,
393 ) -> Result<Self> {
394 std::fs::create_dir_all(queue_dir(repo_root))?;
395 let lock_path = repo_busy_lock(repo_root);
396 let mission_path = repo_busy_mission_file(repo_root);
397
398 for _ in 0..16 {
399 if let Some(holder) = legacy_mission_lock_busy(repo_root) {
400 if !(allow_own_legacy && holder == mission_id) {
401 return Err(lock_held_for_repo(repo_root));
402 }
403 }
404
405 match std::fs::OpenOptions::new()
406 .write(true)
407 .create_new(true)
408 .open(&lock_path)
409 {
410 Ok(mut file) => {
411 use std::io::Write as _;
412 write!(file, "{}", crate::event_log::current_lock_holder_record())?;
413 file.sync_data()?;
414 if let Err(e) = atomic_write(&mission_path, mission_id.as_bytes()) {
415 let _ = std::fs::remove_file(&lock_path);
416 return Err(e);
417 }
418 return Ok(Self {
419 lock_path,
420 mission_path,
421 });
422 }
423 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
424 if lock_pid_is_alive(&lock_path) {
425 return Err(lock_held_for_repo(repo_root));
426 }
427 let _ = std::fs::remove_file(&mission_path);
428 let _ = std::fs::remove_file(&lock_path);
429 }
430 Err(e) => return Err(e.into()),
431 }
432 }
433
434 Err(EngineError::LockHeld(
435 "repo busy lock changed too often to acquire safely".to_string(),
436 ))
437 }
438}
439
440impl Drop for RepoBusyGuard {
441 fn drop(&mut self) {
442 let _ = std::fs::remove_file(&self.mission_path);
443 let _ = std::fs::remove_file(&self.lock_path);
444 }
445}
446
447#[derive(Debug)]
451pub struct RepoBusyHold {
452 _inner: RepoBusyGuard,
453}
454
455pub fn acquire_repo_busy(repo_root: &Path, mission_id: &str) -> Result<RepoBusyHold> {
462 Ok(RepoBusyHold {
463 _inner: RepoBusyGuard::acquire_allowing_own_legacy(repo_root, mission_id, true)?,
464 })
465}
466
467#[derive(Debug)]
477pub struct Claim {
478 pub entry: QueueEntry,
479 claimed_path: PathBuf,
480 original_path: PathBuf,
481 _repo_guard: Option<RepoBusyGuard>,
482}
483
484#[derive(Debug)]
486pub enum ClaimFront {
487 Empty,
488 LostRace,
489 Busy { mission_id: String },
490 Claimed(Claim),
491}
492
493pub fn claim_front(repo_root: &Path) -> Option<Claim> {
496 for _ in 0..16 {
499 let entry = peek(repo_root)?;
500 let original = queue_dir(repo_root).join(entry.file_name());
501 let claimed = queue_dir(repo_root).join(format!(
510 "{}.claimed.{}{}",
511 entry.file_name(),
512 std::process::id(),
513 claim_identity_suffix()
514 ));
515 match std::fs::rename(&original, &claimed) {
516 Ok(()) => {
517 return Some(Claim {
518 entry,
519 claimed_path: claimed,
520 original_path: original,
521 _repo_guard: None,
522 })
523 }
524 Err(_) => {
525 peek(repo_root)?;
528 }
529 }
530 }
531 tracing::warn!("claim_front: 16 consecutive claim failures; treating queue as unclaimable");
532 None
533}
534
535pub fn claim_front_when_repo_free(repo_root: &Path) -> Result<ClaimFront> {
540 let had_front = peek(repo_root).is_some();
541 let Some(mut claim) = claim_front(repo_root) else {
542 return Ok(if had_front {
543 ClaimFront::LostRace
544 } else {
545 ClaimFront::Empty
546 });
547 };
548
549 match RepoBusyGuard::acquire(repo_root, &claim.entry.mission_id) {
550 Ok(guard) => {
551 claim._repo_guard = Some(guard);
552 Ok(ClaimFront::Claimed(claim))
553 }
554 Err(EngineError::LockHeld(_)) => {
555 let mission_id = is_repo_busy(repo_root).unwrap_or_else(|| "unknown".to_string());
556 release_claim(claim);
557 Ok(ClaimFront::Busy { mission_id })
558 }
559 Err(e) => {
560 release_claim(claim);
561 Err(e)
562 }
563 }
564}
565
566pub fn finish_claim(mut claim: Claim) {
568 let _ = std::fs::remove_file(&claim.claimed_path);
569 claim.disarm();
570}
571
572pub fn release_claim(mut claim: Claim) {
575 if std::fs::rename(&claim.claimed_path, &claim.original_path).is_err() {
576 tracing::warn!(
577 path = %claim.claimed_path.display(),
578 "failed to release queue claim; entry remains claimed on disk"
579 );
580 }
581 claim.disarm();
582}
583
584impl Claim {
585 fn disarm(&mut self) {
588 self.claimed_path = PathBuf::new();
589 }
590}
591
592impl Drop for Claim {
593 fn drop(&mut self) {
594 if self.claimed_path.as_os_str().is_empty() {
598 return;
599 }
600 if self.claimed_path.exists()
601 && std::fs::rename(&self.claimed_path, &self.original_path).is_err()
602 {
603 tracing::warn!(
604 path = %self.claimed_path.display(),
605 "Claim::drop failed to release queue claim"
606 );
607 }
608 }
609}
610
611#[cfg_attr(not(unix), allow(dead_code))]
619#[derive(Debug, Clone, Copy, PartialEq, Eq)]
620enum ClaimPidLiveness {
621 Alive,
623 Dead,
625 Unknown,
628}
629
630fn probe_claim_pid(pid: i32) -> ClaimPidLiveness {
639 if pid <= 0 {
640 return ClaimPidLiveness::Unknown;
641 }
642 #[cfg(unix)]
643 {
644 if unsafe { libc::kill(pid, 0) } == 0 {
645 return ClaimPidLiveness::Alive;
646 }
647 match std::io::Error::last_os_error().raw_os_error() {
648 Some(libc::ESRCH) => ClaimPidLiveness::Dead,
649 _ => ClaimPidLiveness::Unknown,
650 }
651 }
652 #[cfg(not(unix))]
653 {
654 let _ = pid;
655 ClaimPidLiveness::Unknown
656 }
657}
658
659fn claim_identity_suffix() -> String {
663 crate::event_log::process_identity_token(std::process::id() as i32)
664 .map(|token| format!(".{}", identity_token_hash(&token)))
665 .unwrap_or_default()
666}
667
668fn identity_token_hash(token: &str) -> String {
675 use sha2::Digest as _;
676 let digest = sha2::Sha256::digest(token.as_bytes());
677 digest[..8].iter().map(|b| format!("{b:02x}")).collect()
678}
679
680pub fn recover_dead_claims(repo_root: &Path) -> usize {
694 let dir = queue_dir(repo_root);
695 let Ok(rd) = std::fs::read_dir(&dir) else {
696 return 0;
697 };
698 let mut recovered = 0;
699 for f in rd.flatten() {
700 let path = f.path();
701 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
702 continue;
703 };
704 let Some((entry_name, claim_suffix)) = name.split_once(".claimed.") else {
705 continue;
706 };
707 let (pid_str, recorded_token) = match claim_suffix.split_once('.') {
709 Some((pid, token)) => (pid, Some(token.to_string())),
710 None => (claim_suffix, None),
711 };
712 let aged_out = std::fs::metadata(&path)
713 .and_then(|m| m.modified())
714 .ok()
715 .and_then(|t| t.elapsed().ok())
716 .is_some_and(|age| age > Duration::from_secs(3600));
717 let dead = match pid_str.parse::<i32>() {
718 Ok(pid) => match probe_claim_pid(pid) {
719 ClaimPidLiveness::Alive => match recorded_token {
720 None => false,
724 Some(recorded) => match crate::event_log::process_identity_token(pid) {
725 Some(current) if identity_token_hash(¤t) != recorded => aged_out,
730 _ => false,
733 },
734 },
735 ClaimPidLiveness::Dead => true,
736 ClaimPidLiveness::Unknown => aged_out,
739 },
740 Err(_) => true,
744 };
745 if dead && std::fs::rename(&path, dir.join(entry_name)).is_ok() {
746 recovered += 1;
747 }
748 }
749 recovered
750}
751
752pub fn is_repo_busy(repo_root: &Path) -> Option<String> {
756 let repo_lock = repo_busy_lock(repo_root);
757 if repo_lock.exists() {
758 if lock_pid_is_alive(&repo_lock) {
759 return repo_busy_mission(repo_root).or_else(|| Some("unknown".to_string()));
760 }
761 let _ = std::fs::remove_file(repo_busy_mission_file(repo_root));
762 let _ = std::fs::remove_file(repo_lock);
763 }
764 legacy_mission_lock_busy(repo_root)
765}
766
767fn legacy_mission_lock_busy(repo_root: &Path) -> Option<String> {
768 let missions = repo_root.join(".kranz").join("missions");
769 let rd = std::fs::read_dir(&missions).ok()?;
770 for entry in rd.flatten() {
771 let dir = entry.path();
772 if !dir.is_dir() {
773 continue;
774 }
775 let lock = dir.join("events.jsonl.lock");
776 if !lock.exists() {
777 continue;
778 }
779 if lock_pid_is_alive(&lock) {
780 if let Some(id) = dir.file_name().and_then(|n| n.to_str()) {
781 return Some(id.to_string());
782 }
783 }
784 }
785 None
786}
787
788fn lock_pid_is_alive(lock_path: &Path) -> bool {
800 crate::event_log::lock_holder_is_alive(lock_path)
801}
802
803fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
805 let dir = path.parent().unwrap_or_else(|| Path::new("."));
806 std::fs::create_dir_all(dir)?;
807 let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("entry");
808 static TMP_SEQ: AtomicU64 = AtomicU64::new(0);
811 let tmp = dir.join(format!(
812 ".{file_name}.{}.{}.tmp",
813 std::process::id(),
814 TMP_SEQ.fetch_add(1, Ordering::Relaxed)
815 ));
816 std::fs::write(&tmp, bytes)?;
817 match std::fs::rename(&tmp, path) {
818 Ok(()) => Ok(()),
819 Err(_) if cfg!(windows) => {
820 let _ = std::fs::remove_file(path);
821 std::fs::rename(&tmp, path)?;
822 Ok(())
823 }
824 Err(e) => {
825 let _ = std::fs::remove_file(&tmp);
826 Err(e.into())
827 }
828 }
829}
830
831#[cfg(test)]
832mod tests {
833 use super::*;
834
835 fn entry(mission_id: &str) -> QueueEntry {
836 QueueEntry {
837 mission_id: mission_id.to_string(),
838 ticket_slug: None,
839 priority: 2,
840 seq: 0,
841 }
842 }
843
844 #[test]
845 fn enqueue_source_round_trips_and_rejects_mismatched_identity() {
846 let tmp = tempfile::tempdir().unwrap();
847 let source = write_enqueue_source(tmp.path(), "m-source", "gascity", "rig-1").unwrap();
848 assert!(source.created_unix_secs > 0);
849 assert_eq!(read_enqueue_source(tmp.path(), "m-source"), Some(source));
850
851 let path = MissionPaths::new(tmp.path(), "m-source")
852 .mission_dir()
853 .join(ENQUEUE_SOURCE_FILE);
854 std::fs::write(
855 &path,
856 r#"{"schemaVersion":1,"missionId":"m-other","producer":"gascity","externalRef":"rig-1"}"#,
857 )
858 .unwrap();
859 assert!(read_enqueue_source(tmp.path(), "m-source").is_none());
860
861 remove_enqueue_source(tmp.path(), "m-source");
862 assert!(!path.exists());
863 }
864
865 #[test]
866 fn claim_front_when_repo_free_holds_repo_busy_until_claim_finishes() {
867 let tmp = tempfile::tempdir().unwrap();
868 let repo = tmp.path();
869 enqueue(repo, entry("m-1")).unwrap();
870 enqueue(repo, entry("m-2")).unwrap();
871
872 let first = match claim_front_when_repo_free(repo).unwrap() {
873 ClaimFront::Claimed(claim) => claim,
874 other => panic!("expected first claim, got {other:?}"),
875 };
876 assert_eq!(first.entry.mission_id, "m-1");
877 assert_eq!(is_repo_busy(repo).as_deref(), Some("m-1"));
878
879 match claim_front_when_repo_free(repo).unwrap() {
880 ClaimFront::Busy { mission_id } => assert_eq!(mission_id, "m-1"),
881 other => panic!("expected repo-busy result, got {other:?}"),
882 }
883 assert!(
884 contains(repo, "m-2"),
885 "busy loser releases the queue claim instead of dropping work"
886 );
887
888 finish_claim(first);
889 assert_eq!(is_repo_busy(repo), None);
890
891 let second = match claim_front_when_repo_free(repo).unwrap() {
892 ClaimFront::Claimed(claim) => claim,
893 other => panic!("expected second claim after guard drop, got {other:?}"),
894 };
895 assert_eq!(second.entry.mission_id, "m-2");
896 finish_claim(second);
897 assert!(list(repo).is_empty());
898 assert_eq!(is_repo_busy(repo), None);
899 }
900
901 #[test]
902 fn acquire_repo_busy_holds_until_drop_and_conflicts_with_second() {
903 let tmp = tempfile::tempdir().unwrap();
904 let repo = tmp.path();
905
906 let hold = acquire_repo_busy(repo, "m-hosted").expect("first acquire");
907 assert_eq!(is_repo_busy(repo).as_deref(), Some("m-hosted"));
908
909 let err = acquire_repo_busy(repo, "m-other").expect_err("second must conflict");
910 assert!(
911 matches!(err, EngineError::LockHeld(_)),
912 "expected LockHeld, got {err:?}"
913 );
914
915 drop(hold);
916 assert_eq!(is_repo_busy(repo), None);
917 let again = acquire_repo_busy(repo, "m-hosted").expect("re-acquire after drop");
918 drop(again);
919 assert_eq!(is_repo_busy(repo), None);
920 }
921
922 #[test]
923 fn acquire_repo_busy_ignores_own_mission_events_lock() {
924 let tmp = tempfile::tempdir().unwrap();
925 let repo = tmp.path();
926 let paths = crate::paths::MissionPaths::new(repo, "m-self");
927 let _log = crate::event_log::EventLog::acquire(
929 &paths,
930 "m-self",
931 Duration::ZERO,
932 crate::event_log::LockForce::No,
933 )
934 .expect("mission lock");
935
936 let hold = acquire_repo_busy(repo, "m-self").expect("self legacy lock must not block");
937 assert_eq!(is_repo_busy(repo).as_deref(), Some("m-self"));
938
939 let err = acquire_repo_busy(repo, "m-other").expect_err("other must still conflict");
940 assert!(matches!(err, EngineError::LockHeld(_)));
941 drop(hold);
942 }
943}