Skip to main content

kranz_engine/
queue.rs

1//! Per-repo, priority-ordered execution queue (design: docs/backlog-and-slack.md §3).
2//!
3//! Approval enqueues a mission rather than starting it. Entries live as one
4//! JSON file each under `.kranz/queue/`, named
5//! `<priority>-<paddedSeq>-<missionId>.json` so plain lexicographic filename
6//! order equals `(priority, insertion order)`. A monotonic counter file
7//! (`.seq`) assigns the sequence number.
8//!
9//! Per-repo serialization is mandatory: missions share the working tree, so at
10//! most one may run at a time in a repo. Queue dispatchers acquire a repo-wide
11//! busy guard as part of claiming work, and [`is_repo_busy`] reports that guard
12//! (falling back to legacy live `events.jsonl.lock` detection).
13
14use 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
22/// Width of the zero-padded sequence field in a queue filename. u64 max is 20
23/// digits, so this keeps lexicographic order == numeric order for any seq.
24const SEQ_WIDTH: usize = 20;
25
26/// Durable producer binding written before an externally-owned queue entry
27/// becomes visible. Unlike the queue entry itself, this survives any valid
28/// dispatcher consuming the entry, so the producer can reconcile the
29/// mission's terminal state afterward.
30pub const ENQUEUE_SOURCE_FILE: &str = "enqueue-source.json";
31
32/// Archived binding after the external producer has confirmed its return
33/// transition. Kept beside the mission for audit, outside active scans.
34pub 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    /// Receipt-local clock for recovering a crash between this atomic write
44    /// and queue visibility. Never reuse an older external-system claim time
45    /// for that decision: planning may legitimately take longer than its TTL.
46    #[serde(default)]
47    pub created_unix_secs: u64,
48}
49
50/// Persist an external producer binding atomically. Callers must do this
51/// before [`enqueue`] so even a sibling dispatcher that claims immediately
52/// cannot erase the only mission-to-producer join.
53pub 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
86/// Read a live external producer binding. Malformed or absent files are not
87/// ownership evidence and return `None`.
88pub 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
106/// Roll back a source binding when the queue write itself fails. Once enqueue
107/// succeeds, producer-owned code retires the binding only after its external
108/// return is confirmed.
109pub 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/// One queued mission.
120#[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    /// Filename encoding `(priority, seq)` into lexicographic order.
132    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
143/// The `.kranz/queue/` directory for a repo.
144pub fn queue_dir(repo_root: &Path) -> PathBuf {
145    repo_root.join(".kranz").join("queue")
146}
147
148/// Path of the monotonic sequence counter file.
149fn seq_file(repo_root: &Path) -> PathBuf {
150    queue_dir(repo_root).join(".seq")
151}
152
153/// Same-process serialization of queue mutations (Slack spawns concurrent
154/// approval tasks in one process — review P1).
155static LOCAL_MUTATION_LOCK: Mutex<()> = Mutex::new(());
156
157/// Cross-process advisory lock: `.mutate.lock` created with `create_new`
158/// (exclusive). Held across the read-seq/write-seq/write-entry critical
159/// section so `kranz serve` and `kranz work` cannot interleave. A lock file
160/// older than [`LOCK_STALE`] is treated as a crash leftover and stolen —
161/// contention here is rare and short, so staleness is unambiguous at that
162/// age. Dropped = deleted.
163const LOCK_STALE: Duration = Duration::from_secs(10);
164
165struct MutationLock {
166    path: PathBuf,
167    /// Written into the lock file at acquire; Drop deletes the file only if
168    /// it still holds OUR token, so a holder whose lock was stale-stolen
169    /// cannot delete the stealer's lock and cascade-break mutual exclusion.
170    /// (The read-then-remove in Drop is itself a tiny TOCTOU window —
171    /// microseconds against a 10s staleness horizon — accepted for an
172    /// advisory lock on a low-contention queue.)
173    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
231/// Uniquifies lock tokens within one process (pid alone is shared by all
232/// tasks in the process).
233static LOCK_TOKEN_SEQ: AtomicU64 = AtomicU64::new(0);
234
235/// Reserve the next sequence number: read the counter file, increment, write
236/// it back. Falls back to `max(existing entry seq) + 1` if the counter file is
237/// missing or corrupt, so a wiped counter can never hand out a duplicate that
238/// would reorder existing entries.
239fn 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
257/// Enqueue a mission. The entry's `seq` is assigned here from the monotonic
258/// counter (any incoming `seq` is overwritten). Write is atomic (temp + rename).
259/// A no-op if the mission is already queued.
260pub 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    // Serialize the dedupe-check + seq-reserve + entry-write critical section
265    // against same-process tasks AND sibling processes (review P1).
266    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        // Already queued: return the existing entry unchanged.
273        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
288/// All queued entries, sorted by `(priority, seq)` (== filename order).
289pub 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
316/// The front of the queue (highest priority, then earliest insertion), if any.
317pub fn peek(repo_root: &Path) -> Option<QueueEntry> {
318    list(repo_root).into_iter().next()
319}
320
321/// Remove the entry for `mission_id`. Returns true if something was removed.
322pub 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
344/// Whether `mission_id` is currently queued.
345pub fn contains(repo_root: &Path, mission_id: &str) -> bool {
346    list(repo_root).iter().any(|e| e.mission_id == mission_id)
347}
348
349// ---------------------------------------------------------------------------
350// Repo busy guard: one RUNNING mission per working tree
351// ---------------------------------------------------------------------------
352
353fn 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    /// Acquire the repo-wide busy lock. When `allow_own_legacy` is true, a
385    /// live `events.jsonl.lock` for *this* `mission_id` is ignored (hosted
386    /// start already holds the single-writer lock); any other mission's
387    /// legacy lock still conflicts. Queue claims keep `allow_own_legacy =
388    /// false` so a live engine for the claimed id cannot be double-run.
389    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/// Public RAII hold on the repo-wide busy lock — same underlying guard the
448/// queue claim path uses. Drop (or end of scope) releases the lock so a
449/// sibling dispatcher or hosted start can proceed.
450#[derive(Debug)]
451pub struct RepoBusyHold {
452    _inner: RepoBusyGuard,
453}
454
455/// Acquire the repo-wide busy lock for `mission_id`. Returns
456/// [`EngineError::LockHeld`] when another live holder already owns it.
457///
458/// Unlike the queue claim path, this allows the caller to already hold
459/// `events.jsonl.lock` for `mission_id` (hosted start: the planning engine
460/// is about to become the run).
461pub 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// ---------------------------------------------------------------------------
468// Claims: crash-safe hand-off from queue to dispatcher (review P1)
469// ---------------------------------------------------------------------------
470
471/// A claimed queue entry: the entry file was atomically RENAMED to
472/// `<name>.json.claimed.<pid>`, so no sibling dispatcher can double-run it,
473/// and a crash before completion leaves a recoverable file instead of
474/// dropped work. Call [`finish_claim`] when the mission reached a terminal
475/// state (any outcome), or [`release_claim`] to put the entry back.
476#[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/// Result of atomically taking work with the repo-wide busy guard.
485#[derive(Debug)]
486pub enum ClaimFront {
487    Empty,
488    LostRace,
489    Busy { mission_id: String },
490    Claimed(Claim),
491}
492
493/// Atomically claim the front entry, if any. A lost rename race (a sibling
494/// claimed first) retries with the next front.
495pub fn claim_front(repo_root: &Path) -> Option<Claim> {
496    // Bounded: a lost race retries, but a PERSISTENT rename failure
497    // (read-only fs, permissions) must not spin forever.
498    for _ in 0..16 {
499        let entry = peek(repo_root)?;
500        let original = queue_dir(repo_root).join(entry.file_name());
501        // The claim name carries the claimant's process IDENTITY TOKEN (the
502        // event-log lock idiom) when the platform can compute one: after a
503        // crash, a recycled pid then proves itself different from the
504        // recorded claimant and the age backstop can fire (4th-pass review —
505        // "alive pid stands the claim" alone stranded claims forever behind
506        // unrelated long-lived processes). No token available: the legacy
507        // pid-only name, which forgoes reuse detection exactly like the
508        // legacy lock-file formats.
509        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                // Raced: the front changed under us. Re-peek; a missing queue
526                // means nothing left to claim.
527                peek(repo_root)?;
528            }
529        }
530    }
531    tracing::warn!("claim_front: 16 consecutive claim failures; treating queue as unclaimable");
532    None
533}
534
535/// Claim the front queue entry only if this repo is not already running a
536/// mission. The queue claim and repo busy guard travel together in [`Claim`],
537/// so the guard stays held until [`finish_claim`] or [`release_claim`] consumes
538/// it after the injected mission runner returns.
539pub 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
566/// The mission ran to a terminal state (any outcome): retire the claim.
567pub fn finish_claim(mut claim: Claim) {
568    let _ = std::fs::remove_file(&claim.claimed_path);
569    claim.disarm();
570}
571
572/// The mission could NOT be run (start failure, lock held, config error):
573/// put the entry back so the work is not lost.
574pub 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    /// Mark the claim as consumed so [`Drop`] does not re-release it.
586    /// The embedded [`RepoBusyGuard`] still drops normally.
587    fn disarm(&mut self) {
588        self.claimed_path = PathBuf::new();
589    }
590}
591
592impl Drop for Claim {
593    fn drop(&mut self) {
594        // RAII safety net: an early `?` in drain_queue used to leak the
595        // `.claimed.<pid>` rename + RepoBusyGuard. If the claimed file is
596        // still present when Claim goes out of scope, put the entry back.
597        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/// Liveness verdict for the pid recorded in a claim filename.
612///
613/// Same INVARIANT as the event-log lock probe: anything uncertain must never
614/// report [`ClaimPidLiveness::Dead`] — a false "dead" requeues a mission a
615/// live dispatcher is still running, executing it twice (review P2).
616// Alive/Dead are constructed only by the unix probe arm; silence dead_code
617// off-unix without masking it on unix (windows-latest clippy gates -D warnings).
618#[cfg_attr(not(unix), allow(dead_code))]
619#[derive(Debug, Clone, Copy, PartialEq, Eq)]
620enum ClaimPidLiveness {
621    /// `kill(pid, 0)` succeeded: the claiming process exists.
622    Alive,
623    /// `kill(pid, 0)` failed with ESRCH: no such process — positive proof.
624    Dead,
625    /// The probe cannot settle it on this platform/errno: the caller's age
626    /// backstop breaks the tie.
627    Unknown,
628}
629
630/// Probe the pid from a claim filename. unix: `kill(pid, 0)` == 0 → Alive;
631/// ESRCH → Dead; EPERM → Unknown (a process exists but is owned by another
632/// user — it cannot be our same-user dispatcher, yet its presence also means
633/// the pid was recycled, so let the age backstop decide); any other errno →
634/// Unknown. Non-positive pids → Unknown (never probe a process GROUP, and
635/// `kill(0, 0)` would match our own). Non-unix: no probe is wired up — the
636/// same posture as [`crate::event_log::lock_holder_is_alive`] — so every pid
637/// is Unknown and recovery keeps the conservative age-only rule there.
638fn 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
659/// The `.TOKENHASH` claim-name suffix for the current process (empty when
660/// the platform cannot compute an identity token — the legacy pid-only
661/// claim name, which forgoes reuse detection like the legacy lock formats).
662fn 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
668/// A STABLE digest of the identity token (5th-pass review): the claim name
669/// is persisted on disk and compared by a later — possibly upgraded —
670/// binary, so the hash must be a stable format across rustc versions.
671/// DefaultHasher's algorithm is explicitly not one. SHA-256 truncated to
672/// 16 hex chars is (sha2 is already a dependency), and equality is all
673/// that matters, matching the lock idiom's raw-equality compare.
674fn 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
680/// Recover claims left by dead dispatchers. A `*.claimed.<pid>[.<token>]`
681/// file is renamed back to its entry name when its claimant is provably
682/// gone — or, when liveness cannot be determined, when the file is over an
683/// hour old (the pid-REUSE backstop).
684///
685/// A pid probed ALIVE keeps its claim only while it is provably the SAME
686/// process that claimed: the claim name carries the claimant's identity
687/// token (the event-log lock idiom), so a recycled pid — alive but a
688/// different token — is NOT the claimant and the age backstop decides
689/// (4th-pass review: "alive stands the claim" alone stranded claims forever
690/// behind unrelated long-lived processes). Legacy tokenless claims keep the
691/// pre-token rule (alive stands at any age): a false stand delays work, a
692/// false requeue runs a mission twice.
693pub 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        // Suffix shape: `<pid>` (legacy) or `<pid>.<token-hash>`.
708        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                    // Alive AND provably the claimant: stands regardless of
721                    // age (the reorder: age must never requeue a mission a
722                    // live dispatcher is still running).
723                    None => false,
724                    Some(recorded) => match crate::event_log::process_identity_token(pid) {
725                        // Alive but a DIFFERENT process than the claimant:
726                        // the pid was recycled after a crash — the age
727                        // backstop decides, or the claim strands forever
728                        // behind an unrelated long-lived process.
729                        Some(current) if identity_token_hash(&current) != recorded => aged_out,
730                        // Token matches (provably the claimant), or the
731                        // token is unprobeable right now: alive stands.
732                        _ => false,
733                    },
734                },
735                ClaimPidLiveness::Dead => true,
736                // Ambiguous (unprobeable platform, EPERM, bad errno): age
737                // breaks the tie, as the pid-reuse backstop.
738                ClaimPidLiveness::Unknown => aged_out,
739            },
740            // A pid suffix that can't be parsed belongs to no probe-able
741            // dispatcher: recover immediately rather than strand the entry
742            // behind an unanswerable probe (existing rule, unchanged).
743            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
752/// The mission id currently RUNNING in this repo, if any: detected by any
753/// repo-wide busy guard, falling back to any legacy
754/// `.kranz/missions/*/events.jsonl.lock` whose recorded pid is still alive.
755pub 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
788/// True when a lock file records a holder that is still alive.
789///
790/// Delegates to the event-log module's canonical probe
791/// ([`crate::event_log::lock_holder_is_alive`]) — ONE source of truth for
792/// lock-file format and liveness semantics, so the queue can never diverge
793/// from what `EventLog::acquire` itself would decide. (A divergent local
794/// parser once read the entire file as a single integer, so any multi-line
795/// lock was "unparseable ⇒ busy" — a SIGKILL'd engine livelocked `kranz
796/// work` forever.) Genuinely unparseable-but-present locks still read as
797/// busy, conservatively: a false "busy" only delays a queued mission, while
798/// a false "free" could run two missions on one working tree.
799fn lock_pid_is_alive(lock_path: &Path) -> bool {
800    crate::event_log::lock_holder_is_alive(lock_path)
801}
802
803/// Atomic write via a sibling temp file + rename.
804fn 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    // Pid + process-wide counter: concurrent tasks in ONE process must not
809    // share a temp file (review P1 — Slack spawns parallel approvals).
810    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        // Hold the single-writer lock the way a hosted planning engine would.
928        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}