fix(#1643): filter destroyed-agent targets from dashboard schedule view

This commit is contained in:
damocles 2026-06-13 15:58:44 +02:00
commit e08122a206
4 changed files with 117 additions and 8 deletions

View file

@ -831,6 +831,27 @@ pub fn schedule_to_wire_public(s: crate::scheduled_prompts::Schedule) -> hive_sh
schedule_to_wire(s)
}
/// Drop schedule targets that point at agents which no longer exist, so
/// the dashboard's schedule table doesn't render ghost columns for
/// destroyed agents. `live` is the set of logical agent names from the
/// last `nixos-container list` scan (stopped agents included, destroyed
/// ones absent); the `operator` pseudo-target is always retained since
/// it isn't a container. Applied only to the dashboard wire paths
/// (`api_schedules` + the `SchedulesChanged` SSE emit) — the
/// manager-facing `list_schedules` stays unfiltered so agents can still
/// see and cancel stale targets. This is a view filter: the underlying
/// schedule rows keep every target, so a re-spawned agent's targets
/// reappear on their own.
pub(crate) fn filter_ghost_schedule_targets(
schedules: &mut [hive_sh4re::WireSchedule],
live: &std::collections::HashSet<String>,
) {
for s in schedules.iter_mut() {
s.targets
.retain(|t| t.target == hive_sh4re::OPERATOR_RECIPIENT || live.contains(&t.target));
}
}
fn schedule_to_wire(s: crate::scheduled_prompts::Schedule) -> hive_sh4re::WireSchedule {
hive_sh4re::WireSchedule {
id: s.id,
@ -906,7 +927,57 @@ pub fn spawn_question_watchdog(coord: &Arc<Coordinator>, id: i64, ttl_secs: u64)
#[cfg(test)]
mod tests {
use super::validate_commit_ref;
use super::{filter_ghost_schedule_targets, validate_commit_ref};
fn target(name: &str) -> hive_sh4re::WireScheduleTarget {
hive_sh4re::WireScheduleTarget {
target: name.to_owned(),
cancelled_at_unix: None,
last_fired_at_unix: None,
last_result: None,
}
}
fn schedule(targets: &[&str]) -> hive_sh4re::WireSchedule {
hive_sh4re::WireSchedule {
id: 1,
owner: "operator".to_owned(),
body: "ping".to_owned(),
interval_seconds: None,
next_fire_at_unix: 0,
created_at_unix: 0,
source: hive_sh4re::WireScheduleSource::Operator,
cancelled_at_unix: None,
description: None,
targets: targets.iter().map(|t| target(t)).collect(),
}
}
#[test]
fn ghost_filter_drops_dead_agents_keeps_live_and_operator() {
let live: std::collections::HashSet<String> = ["iris".to_owned(), "damocles".to_owned()]
.into_iter()
.collect();
let mut schedules = vec![schedule(&["iris", "ghost", "operator", "damocles"])];
filter_ghost_schedule_targets(&mut schedules, &live);
let kept: Vec<&str> = schedules[0]
.targets
.iter()
.map(|t| t.target.as_str())
.collect();
// `ghost` (destroyed) dropped; live agents + operator pseudo-target kept.
assert_eq!(kept, vec!["iris", "operator", "damocles"]);
}
#[test]
fn ghost_filter_can_empty_targets_when_all_dead() {
let live: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut schedules = vec![schedule(&["gone1", "gone2"])];
filter_ghost_schedule_targets(&mut schedules, &live);
// operator is never in the live set but is always retained; here
// there's no operator target, so everything drops.
assert!(schedules[0].targets.is_empty());
}
#[test]
fn accepts_short_and_full_sha() {