fix: harden scheduler tests and FreeBSD store isolation
This commit is contained in:
parent
ceaeaee658
commit
a48afa1c0a
4 changed files with 92 additions and 13 deletions
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue