diff --git a/README.md b/README.md index 67b15d7..e84b118 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ The Clawdie control plane core — a small, cross-platform (FreeBSD + Linux) Rus daemon that unifies coordination (task board, agent registry, skills catalog) with cache-first cost discipline (byte-stable prompt prefixes, cache-hit metering). -**Status:** 8 crates, 72 tests, clippy-clean. Phase 3 (coordination core) in progress. +**Status:** 8 crates; workspace gates are expected to be fmt/clippy/test/release green. Avoid fixed test-count status here — run the gate commands below for the current count. Phase 3 (coordination core) is in progress. Design doc + cutover plan: `docs/COLIBRI-CUTOVER-PLAN.md`. diff --git a/crates/colibri-daemon/src/daemon.rs b/crates/colibri-daemon/src/daemon.rs index c23d6bb..a3bfdc6 100644 --- a/crates/colibri-daemon/src/daemon.rs +++ b/crates/colibri-daemon/src/daemon.rs @@ -372,11 +372,21 @@ mod tests { #[test] fn test_daemon_state_creation() { - let config = DaemonConfig::from_env(); + let data_dir = std::env::temp_dir().join(format!( + "colibri-daemon-state-test-{}", + uuid::Uuid::new_v4() + )); + let mut config = DaemonConfig::from_env(); + config.data_dir = data_dir.clone(); + config.socket_path = data_dir.join("colibri.sock"); + config.db_path = data_dir.join("colibri.sqlite"); + let state = DaemonState::new(config); assert_eq!(state.sessions.len(), 0); assert_eq!(state.agents.len(), 0); assert_eq!(state.shutdown_tx.receiver_count(), 1); + + let _ = std::fs::remove_dir_all(data_dir); } } diff --git a/crates/colibri-daemon/src/scheduler.rs b/crates/colibri-daemon/src/scheduler.rs index 0df09e7..5f1d1e5 100644 --- a/crates/colibri-daemon/src/scheduler.rs +++ b/crates/colibri-daemon/src/scheduler.rs @@ -54,7 +54,17 @@ impl Schedule { .is_ok_and(|d| d >= dur), } } - Schedule::Cron { expr } => cron_matches(expr, now).unwrap_or(false), + Schedule::Cron { expr } => { + if !cron_matches(expr, now).unwrap_or(false) { + return false; + } + // Daemon ticks can be more frequent than one minute. Cron jobs + // should fire at most once per matching minute. + match last_run { + None => true, + Some(last) => !same_utc_minute(last, now), + } + } } } } @@ -249,14 +259,20 @@ pub fn pick_agent<'a>( required: &[String], agents: &'a [colibri_store::Agent], ) -> Option<&'a colibri_store::Agent> { - agents + let picked = agents .iter() .filter(|a| a.status == "idle" || a.status == "active") - .max_by_key(|a| { + .map(|a| { let caps: Vec = serde_json::from_value(a.capabilities.clone()).unwrap_or_default(); - capability_match_score(required, &caps) + (a, capability_match_score(required, &caps)) }) + .max_by_key(|(_, score)| *score); + + match picked { + Some((agent, score)) if required.is_empty() || score > 0 => Some(agent), + _ => None, + } } // --------------------------------------------------------------------------- @@ -288,9 +304,7 @@ fn cron_matches(expr: &str, now: DateTime) -> Result { if field == "*" { return true; } - let value = value.trim_start_matches('0'); - let value = if value.is_empty() { "0" } else { value }; - field == value + normalize_cron_number(field) == normalize_cron_number(value) }; Ok(matches_field(min, &now_min) @@ -300,6 +314,19 @@ fn cron_matches(expr: &str, now: DateTime) -> Result { && matches_field(dow, &now_dow)) } +fn normalize_cron_number(value: &str) -> &str { + let value = value.trim_start_matches('0'); + if value.is_empty() { + "0" + } else { + value + } +} + +fn same_utc_minute(a: DateTime, b: DateTime) -> bool { + a.format("%Y-%m-%dT%H:%M").to_string() == b.format("%Y-%m-%dT%H:%M").to_string() +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -373,6 +400,23 @@ mod tests { assert!(!cron_matches("0 13 * * 1", now).unwrap()); } + #[test] + fn test_cron_matches_leading_zero_fields() { + let now = test_now(); // 12:00 UTC, day=01, month=06, dow=1 + assert!(cron_matches("00 12 01 06 01", now).unwrap()); + } + + #[test] + fn test_cron_fires_only_once_per_matching_minute() { + let sched = Schedule::Cron { + expr: "* * * * *".to_string(), + }; + let now = test_now() + chrono::Duration::seconds(30); + assert!(sched.should_fire(None, now)); + assert!(!sched.should_fire(Some(test_now()), now)); + assert!(sched.should_fire(Some(now - chrono::Duration::minutes(1)), now)); + } + #[test] fn test_capability_match_score() { let required = vec!["rust".to_string(), "freebsd".to_string()]; @@ -434,4 +478,29 @@ mod tests { }]; assert!(pick_agent(&required, &agents).is_none()); } + + #[test] + fn test_pick_agent_returns_none_when_required_caps_do_not_match() { + let required = vec!["freebsd".to_string()]; + let agents = vec![colibri_store::Agent { + id: "a1".into(), + name: "linux-bot".into(), + capabilities: serde_json::json!(["linux"]), + status: "idle".into(), + created_at: "2026-01-01T00:00:00Z".into(), + }]; + assert!(pick_agent(&required, &agents).is_none()); + } + + #[test] + fn test_pick_agent_allows_empty_required_caps() { + let agents = vec![colibri_store::Agent { + id: "a1".into(), + name: "generalist".into(), + capabilities: serde_json::json!([]), + status: "idle".into(), + created_at: "2026-01-01T00:00:00Z".into(), + }]; + assert_eq!(pick_agent(&[], &agents).unwrap().name, "generalist"); + } } diff --git a/docs/COLIBRI-CUTOVER-PLAN.md b/docs/COLIBRI-CUTOVER-PLAN.md index 2d808b6..6ceef09 100644 --- a/docs/COLIBRI-CUTOVER-PLAN.md +++ b/docs/COLIBRI-CUTOVER-PLAN.md @@ -28,7 +28,7 @@ The earlier draft asserted several things that don't match the repos. Fixed here | Repo | Branch | Baseline | Notes | |------|--------|----------|-------| -| `colibri` | main | `ebc1b99` | 72 tests / 0 fail; `clippy -D warnings` clean; 8 crates (added `colibri-store`) | +| `colibri` | main | `ceaeaee` + follow-up | T1.2/T1.3 landed (`colibri-store` + scheduler); 8 crates; verify current counts with `cargo test --workspace`; `clippy -D warnings` clean after follow-up. | | `clawdie-ai` | main | `169bee2` | TS control plane (the cutover *source*); **no Rust crates** | | `clawdie-iso` | main | `b9803d8` | has a Colibri dependency section in `AGENTS.md` | | `herdr` | main | `ede2059` *(per Hermes; not verified from domedog)* | Linux/macOS display client | @@ -194,8 +194,8 @@ Lane 3 (ISO build) ───────────→ Colibri in ISO ─── ## Next actions -1. Lane 1 T1.3 — add execution & scheduling (scheduler module, cron/interval/one-shot, - leader/delegate matching, intake-task socket command). ~~Done at `ebc1b99` — 72 tests.~~ - TODO: push the T1.3 commit. +1. Lane 1 T1.3 — execution & scheduling landed: scheduler module, + cron/interval/one-shot, leader/delegate matching, intake-task socket command. + Follow-up fixed FreeBSD test isolation and scheduler edge cases. 2. Complete Lane 2 T2.2 (debby→domedog herdr attach) per the runbook. 3. Lane 1 T1.4 — Phase 5 cache-first prompt discipline (3-region assembler).