Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/dwarves

crates/dwarves/src/brokkr/queue.rs

//! The build queue admission model — *what may start now, and if not, why?*
//!
//! The host backs this with the nornir-jobs ledger (each queued run is a
//! `CI_BUILD` record) and the process-global exclusive gate (`jobs::is_exclusive`
//! → at most one exclusive job at a time). This leaf owns the pure admission
//! decision: an item is admitted when it is not blocked by a running exclusive
//! job AND an idle capable agent exists; otherwise it stays queued *with a
//! reason* (never a silent stall). Exclusivity is carried as a per-item flag so
//! the dep-light leaf need not depend on nornir-jobs.

use std::collections::VecDeque;

use crate::brokkr::agent::AgentPool;

/// A queued build (one pipeline run, or one planned step-instance).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QueueItem {
    /// Stable id (run id / planned-step label).
    pub id: String,
    /// Whether this item needs the exclusive gate (box-saturating work).
    pub exclusive: bool,
    /// Capability tags the running agent must advertise.
    pub requirements: Vec<String>,
}

/// The admission verdict for a queued item.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Admission {
    /// May start now, on this agent.
    Ready {
        /// The chosen agent's name.
        agent: String,
    },
    /// Stays queued, with a human reason (RAGNARÖK: never a silent stall).
    Blocked {
        /// Why the item cannot start yet.
        reason: String,
    },
}

impl Admission {
    /// Convenience: is this a ready verdict?
    pub fn is_ready(&self) -> bool {
        matches!(self, Admission::Ready { .. })
    }
}

/// Decide whether `item` may start, given whether an exclusive job is already
/// running and the current agent pool.
pub fn admit(item: &QueueItem, exclusive_running: bool, pool: &AgentPool) -> Admission {
    if item.exclusive && exclusive_running {
        return Admission::Blocked {
            reason: "an exclusive job is already running (waiting for the gate)".to_string(),
        };
    }
    match pool.pick(&item.requirements) {
        Some(a) => Admission::Ready {
            agent: a.name.clone(),
        },
        None => {
            let capable = pool.capable(&item.requirements);
            let reason = if capable.is_empty() {
                format!(
                    "no agent advertises the required capabilities {:?}",
                    item.requirements
                )
            } else {
                format!(
                    "all {} capable agent(s) are busy (requirements {:?})",
                    capable.len(),
                    item.requirements
                )
            };
            Admission::Blocked { reason }
        }
    }
}

/// A FIFO build queue. [`Self::admissible`] walks it in order and returns the
/// items that may start now (each with its assigned agent), leaving the blocked
/// ones queued.
#[derive(Debug, Clone, Default)]
pub struct BuildQueue {
    items: VecDeque<QueueItem>,
}

impl BuildQueue {
    /// Enqueue an item at the back.
    pub fn push(&mut self, item: QueueItem) {
        self.items.push_back(item);
    }

    /// The number of queued items.
    pub fn len(&self) -> usize {
        self.items.len()
    }

    /// Whether the queue is empty.
    pub fn is_empty(&self) -> bool {
        self.items.is_empty()
    }

    /// Walk the queue in FIFO order; return each item's admission verdict.
    /// Admitting one exclusive item flips the running flag so a second exclusive
    /// item behind it stays queued (the gate admits one at a time).
    pub fn verdicts(
        &self,
        mut exclusive_running: bool,
        pool: &AgentPool,
    ) -> Vec<(QueueItem, Admission)> {
        let mut out = Vec::with_capacity(self.items.len());
        for item in &self.items {
            let v = admit(item, exclusive_running, pool);
            if v.is_ready() && item.exclusive {
                exclusive_running = true;
            }
            out.push((item.clone(), v));
        }
        out
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::brokkr::agent::Agent;

    fn pool() -> AgentPool {
        let mut p = AgentPool::default();
        p.register(Agent::new("linux-1", ["linux", "cargo"]));
        p
    }

    #[test]
    fn admits_a_matched_item_and_reports_the_agent() {
        let p = pool();
        let item = QueueItem {
            id: "r1".into(),
            exclusive: false,
            requirements: vec!["linux".into()],
        };
        assert_eq!(
            admit(&item, false, &p),
            Admission::Ready {
                agent: "linux-1".into()
            }
        );
    }

    #[test]
    fn unmatched_item_is_queued_with_a_reason() {
        let p = pool();
        let item = QueueItem {
            id: "r1".into(),
            exclusive: false,
            requirements: vec!["windows".into()],
        };
        match admit(&item, false, &p) {
            Admission::Blocked { reason } => assert!(reason.contains("no agent"), "{reason}"),
            other => panic!("expected blocked, got {other:?}"),
        }
    }

    #[test]
    fn exclusive_gate_admits_one_at_a_time() {
        let p = pool();
        let mut q = BuildQueue::default();
        q.push(QueueItem {
            id: "bench-1".into(),
            exclusive: true,
            requirements: vec!["cargo".into()],
        });
        q.push(QueueItem {
            id: "bench-2".into(),
            exclusive: true,
            requirements: vec!["cargo".into()],
        });
        let v = q.verdicts(false, &p);
        assert!(v[0].1.is_ready(), "the first exclusive item starts");
        match &v[1].1 {
            Admission::Blocked { reason } => assert!(reason.contains("exclusive"), "{reason}"),
            other => panic!("second exclusive item must stay queued, got {other:?}"),
        }
    }
}