fix: harden scheduler tests and FreeBSD store isolation

This commit is contained in:
Sam & Claude 2026-05-27 19:13:20 +02:00
parent ceaeaee658
commit a48afa1c0a
4 changed files with 92 additions and 13 deletions

View file

@ -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`.

View file

@ -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);
}
}

View file

@ -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<String> =
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<Utc>) -> Result<bool, String> {
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<Utc>) -> Result<bool, String> {
&& 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<Utc>, b: DateTime<Utc>) -> 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");
}
}

View file

@ -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).