From 6a3ecc4813dc30c57ca8138c2620392921b06715 Mon Sep 17 00:00:00 2001 From: rysweet Date: Wed, 22 Jul 2026 09:52:04 +0000 Subject: [PATCH] fix(#4437): exempt standing/perpetual goals from the claim-reaper staleness sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The periodic claim-reaper judged engineer-claim liveness purely from worktree presence + newest-file mtime idle age and never inherited the `is_perpetual()` benign-idle exemption the OODA no-progress breaker already applies (`no_progress.rs`, #2589). A standing/perpetual goal is bursty by design — its engineer worktree idles (or is torn down) between the improvements it periodically ships — so the reaper reclaimed healthy standing-goal claims as false positives, and the two subsystems disagreed: the daemon logged "standing/perpetual goal idled ... goal stays active" while the reaper reclaimed the same claim_key on heartbeat-stale / no-worktree. Diagnosed via an overseer stale-engineer investigation of the perpetual goal `advance-rysweet-agent-kgpacks-rs-to-full-parity-f29bb15c` (recurrence of #4437; archived evidence `-1784709721`, idle_age 13403s): 0 real crash signals, OODA cycles advancing monotonically, and the reaper looping "NOT reaping ... investigation verdict=pending" for ~4.5h. Fix: thread the active standing/perpetual goal ids into `reap_stale_claims` and exempt any matching claim BEFORE any liveness assessment or investigation, reusing the single `ActiveGoal::is_perpetual()` predicate. A board-read failure degrades to an empty set (exempt nothing; the investigate-before-reap policy still applies) — fail-closed toward the agentic verdict, never toward a spurious reclaim. - src/overseer/sensor.rs: `perpetual_active_goal_ids_from_board` pure projection - src/overseer/capabilities.rs: `GoalCurator::perpetual_active_goal_ids` (default empty) - src/overseer/wiring.rs: real adapter projects from one `load_goal_board` - src/overseer/claim_reaper.rs: `perpetual_goal_ids` param + exemption + telemetry - src/overseer/mod.rs: read the exemption set before the sweep, pass it in Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/overseer/capabilities.rs | 12 ++ src/overseer/claim_reaper.rs | 279 +++++++++++++++++++++++++++++++++-- src/overseer/mod.rs | 23 +++ src/overseer/sensor.rs | 46 ++++++ src/overseer/wiring.rs | 5 + 5 files changed, 351 insertions(+), 14 deletions(-) diff --git a/src/overseer/capabilities.rs b/src/overseer/capabilities.rs index 1441c9e61..890b721e9 100644 --- a/src/overseer/capabilities.rs +++ b/src/overseer/capabilities.rs @@ -676,6 +676,18 @@ pub trait GoalCurator { fn unblock(&self, _goal_id: &str) -> Result<(), OverseerError> { Ok(()) } + + /// The ids of the active goals that are standing/perpetual + /// ([`ActiveGoal::is_perpetual`], #2589/#2609) — the staleness-reap EXEMPTION + /// set the claim reaper consults so it never reclaims a healthy standing + /// goal's live engineer claim (issue #4437), mirroring the OODA no-progress + /// breaker's benign-idle exemption. Read-only; a board-read failure degrades + /// to an empty list (the reaper then exempts nothing — fail-closed toward the + /// existing investigate-before-reap policy, never toward a spurious reclaim). + /// The default returns an empty list for fakes that do not model a board. + fn perpetual_active_goal_ids(&self) -> Result, OverseerError> { + Ok(Vec::new()) + } } /// Run a quality-audit loop (crusty-old-engineer-gated). diff --git a/src/overseer/claim_reaper.rs b/src/overseer/claim_reaper.rs index 35bb04125..ed54756f6 100644 --- a/src/overseer/claim_reaper.rs +++ b/src/overseer/claim_reaper.rs @@ -246,6 +246,14 @@ pub struct ReapSummary { pub reclaimed: Vec, /// Claims left untouched (live/fresh/unknown/still-investigating), fail-closed. pub skipped: usize, + /// Claims exempted from the staleness sweep because their goal is + /// standing/perpetual (issue #4437). A standing/perpetual goal is inherently + /// bursty — its engineer worktree idles (or is torn down) between the + /// improvements it periodically ships — so an idle/absent worktree is NORMAL, + /// not death. These claims are NEVER assessed, investigated, or reaped by the + /// heuristic; their lifecycle is owned by the OODA goal loop. Counted here + /// (not in `skipped`) so the exemption is observable in tick telemetry. + pub perpetual_exempt: usize, /// Claims whose reclaim hit an error and were contained (not aborted). pub errors: usize, /// Self-improvement interventions the investigations returned this sweep, @@ -261,6 +269,15 @@ pub struct ReapSummary { /// ever reaping it (issue #4400), independent of per-goal polling. /// /// Policy applied per claim (see module docs): +/// * Goal is standing/perpetual (`goal_id ∈ perpetual_goal_ids`, issue #4437) +/// ⇒ EXEMPT — never assessed, investigated, or reaped. A standing/perpetual +/// goal is inherently bursty (it ships a durable improvement periodically and +/// idles — or tears its worktree down — between), so an idle/absent worktree +/// is NORMAL, not death. This mirrors the `is_perpetual()` benign-idle +/// exemption the OODA no-progress breaker already applies +/// (`src/ooda_loop/no_progress.rs`, issue #2589) so the reaper and the breaker +/// can never disagree about a standing goal's liveness. The claim's lifecycle +/// is owned by the OODA goal loop, not this heuristic. /// * `enabled == false` ⇒ no-op (off switch; not even `NoWorktree` reclaimed; /// no investigation launched). /// * [`ClaimLiveness::Dead`] `{ NoWorktree, .. }` ⇒ reclaim IMMEDIATELY — there @@ -286,6 +303,7 @@ pub fn reap_stale_claims( investigator: &dyn StaleEngineerInvestigator, enabled: bool, stale_secs: u64, + perpetual_goal_ids: &std::collections::HashSet, ) -> ReapSummary { let mut summary = ReapSummary::default(); @@ -297,6 +315,26 @@ pub fn reap_stale_claims( } for claim_key in ledger.list_engineer_claims() { + // Standing/perpetual exemption (issue #4437). A standing/perpetual goal is + // bursty by design: its engineer worktree idles — or is torn down and + // re-created — between the improvements it periodically ships, so an + // idle/absent worktree is NORMAL, not death. Exempt it BEFORE any liveness + // assessment or investigation so the reaper can never reclaim a healthy + // standing goal's live claim (the false positive #4437 fixes) and can + // never disagree with the OODA no-progress breaker's `is_perpetual()` + // benign-idle exemption (`no_progress.rs`, issue #2589). The claim's + // lifecycle is owned by the OODA goal loop, not this heuristic. + if perpetual_goal_ids.contains(goal_id_from_claim_key(&claim_key)) { + summary.perpetual_exempt += 1; + tracing::info!( + target: "simard::claim_reaper", + claim_key = %claim_key, + "[simard] claim-reaper: standing/perpetual goal exempt from \ + staleness reap (normal, not a fault) — claim + worktree preserved", + ); + continue; + } + // Classify the claim's engineer. Fail-closed: anything short of a // CONFIDENT dead verdict (Live, or a `HeartbeatStale` age at/under the // threshold, or an unknown age) is skipped and never reclaimed. @@ -1374,6 +1412,13 @@ mod tests { const STALE_SECS: u64 = 1800; + /// The empty standing/perpetual-goal exemption set (issue #4437): the common + /// case for tests that do not exercise a perpetual goal, so no claim is + /// exempted and the existing staleness policy applies unchanged. + fn no_perpetual() -> std::collections::HashSet { + std::collections::HashSet::new() + } + fn dead(reason: DeadReason, age: Option) -> ClaimLiveness { ClaimLiveness::Dead { reason, @@ -1476,6 +1521,107 @@ mod tests { } } + /// Build a standing/perpetual-goal exemption set from goal ids (issue #4437). + fn perpetual_set(goal_ids: &[&str]) -> std::collections::HashSet { + goal_ids.iter().map(|g| g.to_string()).collect() + } + + // ----- #4437: standing/perpetual goals are EXEMPT from the staleness reap --- + + #[test] + fn perpetual_goal_claim_is_exempt_even_when_heartbeat_stale_beyond_threshold() { + // A standing/perpetual goal's engineer worktree idled far past the + // staleness threshold — the exact false positive #4437 fixes. It MUST NOT + // be assessed-for-reap, investigated, or reclaimed. + let key = "rysweet/Simard:advance-parity-f29bb15c"; + let ledger = FakeLedger::new(&[key]); + let probe = MapProbe::new(&[(key, dead(DeadReason::HeartbeatStale, Some(999_999)))]); + let cleanup = FakeCleanup::new(); + let investigator = FakeInvestigator::dead_unknown(); + + let summary = reap_stale_claims( + &ledger, + &probe, + &cleanup, + &investigator, + true, + STALE_SECS, + &perpetual_set(&["advance-parity-f29bb15c"]), + ); + + // Claim + worktree preserved; counted as a perpetual exemption, NOT a reap. + assert!( + summary.reclaimed.is_empty(), + "perpetual claim must not be reaped" + ); + assert_eq!(summary.perpetual_exempt, 1); + assert_eq!(summary.skipped, 0); + assert!( + ledger.released.borrow().is_empty(), + "no release for a perpetual claim" + ); + assert!( + cleanup.cleaned.lock().expect("cleaned mutex").is_empty(), + "no worktree cleanup for a perpetual claim", + ); + // Exempted BEFORE the investigate-before-reap seam — evidence untouched. + assert!( + investigator.investigated().is_empty(), + "a perpetual goal is exempt before investigation runs", + ); + } + + #[test] + fn perpetual_goal_claim_is_exempt_even_with_no_worktree() { + // Even a `NoWorktree` classification (worktree torn down between bursts) is + // normal for a standing/perpetual goal — exempt, never an unconditional reap. + let key = "rysweet/Simard:advance-parity-f29bb15c"; + let ledger = FakeLedger::new(&[key]); + let probe = MapProbe::new(&[(key, dead(DeadReason::NoWorktree, None))]); + let cleanup = FakeCleanup::new(); + let investigator = FakeInvestigator::dead_unknown(); + + let summary = reap_stale_claims( + &ledger, + &probe, + &cleanup, + &investigator, + true, + STALE_SECS, + &perpetual_set(&["advance-parity-f29bb15c"]), + ); + + assert!(summary.reclaimed.is_empty()); + assert_eq!(summary.perpetual_exempt, 1); + assert!(ledger.released.borrow().is_empty()); + assert!(investigator.investigated().is_empty()); + } + + #[test] + fn non_perpetual_stale_claim_still_reaps_when_exemption_set_is_disjoint() { + // The exemption is scoped to the named goal only: a DIFFERENT stale goal + // must still follow the normal investigate-then-reap path unchanged. + let key = "rysweet/Simard:some-terminal-goal"; + let ledger = FakeLedger::new(&[key]); + let probe = MapProbe::new(&[(key, dead(DeadReason::HeartbeatStale, Some(3600)))]); + let cleanup = FakeCleanup::new(); + let investigator = FakeInvestigator::dead_unknown(); + + let summary = reap_stale_claims( + &ledger, + &probe, + &cleanup, + &investigator, + true, + STALE_SECS, + &perpetual_set(&["a-different-perpetual-goal"]), + ); + + assert_eq!(summary.reclaimed, vec![key.to_string()]); + assert_eq!(summary.perpetual_exempt, 0); + assert_eq!(investigator.investigated(), vec![key.to_string()]); + } + // ----- T1: no-worktree ⇒ reaped immediately ------------------------------ #[test] @@ -1492,6 +1638,7 @@ mod tests { &FakeInvestigator::dead_unknown(), true, STALE_SECS, + &no_perpetual(), ); assert_eq!(summary.reclaimed, vec![key.to_string()]); @@ -1520,6 +1667,7 @@ mod tests { &FakeInvestigator::dead_unknown(), true, STALE_SECS, + &no_perpetual(), ); assert!( @@ -1548,6 +1696,7 @@ mod tests { &FakeInvestigator::dead_unknown(), true, STALE_SECS, + &no_perpetual(), ); assert_eq!(summary.reclaimed, vec![key.to_string()]); @@ -1572,6 +1721,7 @@ mod tests { &FakeInvestigator::dead_unknown(), true, STALE_SECS, + &no_perpetual(), ); assert!(summary.reclaimed.is_empty()); @@ -1598,6 +1748,7 @@ mod tests { &FakeInvestigator::dead_unknown(), true, STALE_SECS, + &no_perpetual(), ); // Exactly the dead claim reclaimed — via release_engineer_claim ONLY. @@ -1625,6 +1776,7 @@ mod tests { &FakeInvestigator::dead_unknown(), false, STALE_SECS, + &no_perpetual(), ); assert!( @@ -1652,6 +1804,7 @@ mod tests { &FakeInvestigator::dead_unknown(), true, STALE_SECS, + &no_perpetual(), ); assert!(summary.reclaimed.is_empty()); @@ -1674,6 +1827,7 @@ mod tests { &FakeInvestigator::dead_unknown(), true, STALE_SECS, + &no_perpetual(), ); assert!(summary.reclaimed.is_empty()); @@ -1702,6 +1856,7 @@ mod tests { &FakeInvestigator::dead_unknown(), true, STALE_SECS, + &no_perpetual(), ); let mut reclaimed = summary.reclaimed.clone(); @@ -1732,6 +1887,7 @@ mod tests { &FakeInvestigator::dead_unknown(), true, STALE_SECS, + &no_perpetual(), ); // The good claim is still reclaimed despite the bad claim erroring. @@ -1765,6 +1921,7 @@ mod tests { &FakeInvestigator::dead_unknown(), true, STALE_SECS, + &no_perpetual(), ); assert!(summary.reclaimed.contains(&good.to_string())); @@ -1948,7 +2105,15 @@ mod tests { let cleanup = FakeCleanup::new().with_order_log(Arc::clone(&log)); let investigator = FakeInvestigator::dead_unknown().with_order_log(Arc::clone(&log)); - let summary = reap_stale_claims(&ledger, &probe, &cleanup, &investigator, true, STALE_SECS); + let summary = reap_stale_claims( + &ledger, + &probe, + &cleanup, + &investigator, + true, + STALE_SECS, + &no_perpetual(), + ); assert_eq!(summary.reclaimed, vec![key.to_string()]); let events = log.lock().expect("order log").clone(); @@ -1976,7 +2141,15 @@ mod tests { let cleanup = FakeCleanup::new(); let investigator = FakeInvestigator::dead_unknown(); - let summary = reap_stale_claims(&ledger, &probe, &cleanup, &investigator, true, STALE_SECS); + let summary = reap_stale_claims( + &ledger, + &probe, + &cleanup, + &investigator, + true, + STALE_SECS, + &no_perpetual(), + ); assert_eq!(summary.reclaimed, vec![key.to_string()]); assert!( @@ -1998,7 +2171,15 @@ mod tests { let investigator = FakeInvestigator::dead_unknown() .with_outcome(key, outcome(InvestigationVerdict::Pending, Vec::new())); - let summary = reap_stale_claims(&ledger, &probe, &cleanup, &investigator, true, STALE_SECS); + let summary = reap_stale_claims( + &ledger, + &probe, + &cleanup, + &investigator, + true, + STALE_SECS, + &no_perpetual(), + ); assert!( summary.reclaimed.is_empty(), @@ -2033,7 +2214,15 @@ mod tests { let investigator = FakeInvestigator::dead_unknown() .with_outcome(key, outcome(InvestigationVerdict::StillAlive, Vec::new())); - let summary = reap_stale_claims(&ledger, &probe, &cleanup, &investigator, true, STALE_SECS); + let summary = reap_stale_claims( + &ledger, + &probe, + &cleanup, + &investigator, + true, + STALE_SECS, + &no_perpetual(), + ); assert!(summary.reclaimed.is_empty()); assert!(ledger.released.borrow().is_empty()); @@ -2077,7 +2266,15 @@ mod tests { ), ); - let summary = reap_stale_claims(&ledger, &probe, &cleanup, &investigator, true, STALE_SECS); + let summary = reap_stale_claims( + &ledger, + &probe, + &cleanup, + &investigator, + true, + STALE_SECS, + &no_perpetual(), + ); assert!( summary.reclaimed.is_empty(), @@ -2117,7 +2314,15 @@ mod tests { ), ); - let summary = reap_stale_claims(&ledger, &probe, &cleanup, &investigator, true, STALE_SECS); + let summary = reap_stale_claims( + &ledger, + &probe, + &cleanup, + &investigator, + true, + STALE_SECS, + &no_perpetual(), + ); // Still reaped, via the shared release chokepoint + cleanup. assert_eq!(summary.reclaimed, vec![key.to_string()]); @@ -2146,7 +2351,15 @@ mod tests { ), ); - let summary = reap_stale_claims(&ledger, &probe, &cleanup, &investigator, true, STALE_SECS); + let summary = reap_stale_claims( + &ledger, + &probe, + &cleanup, + &investigator, + true, + STALE_SECS, + &no_perpetual(), + ); assert_eq!(summary.reclaimed, vec![key.to_string()]); assert_eq!(*ledger.released.borrow(), vec![key.to_string()]); @@ -2174,7 +2387,15 @@ mod tests { outcome(InvestigationVerdict::StillAlive, vec![note.clone()]), ); - let summary = reap_stale_claims(&ledger, &probe, &cleanup, &investigator, true, STALE_SECS); + let summary = reap_stale_claims( + &ledger, + &probe, + &cleanup, + &investigator, + true, + STALE_SECS, + &no_perpetual(), + ); assert!(summary.reclaimed.is_empty(), "StillAlive is never reaped"); assert_eq!( @@ -2199,7 +2420,15 @@ mod tests { let investigator = FakeInvestigator::dead_unknown().with_outcome(key, InvestigationOutcome::default()); - let summary = reap_stale_claims(&ledger, &probe, &cleanup, &investigator, true, STALE_SECS); + let summary = reap_stale_claims( + &ledger, + &probe, + &cleanup, + &investigator, + true, + STALE_SECS, + &no_perpetual(), + ); assert!( summary.reclaimed.is_empty(), @@ -2222,8 +2451,15 @@ mod tests { let cleanup = FakeCleanup::new(); let investigator = FakeInvestigator::dead_unknown(); - let summary = - reap_stale_claims(&ledger, &probe, &cleanup, &investigator, false, STALE_SECS); + let summary = reap_stale_claims( + &ledger, + &probe, + &cleanup, + &investigator, + false, + STALE_SECS, + &no_perpetual(), + ); assert!(summary.reclaimed.is_empty()); assert!(summary.pending_interventions.is_empty()); @@ -2467,8 +2703,15 @@ mod tests { let probe = MapProbe::new(&[(key, dead(DeadReason::HeartbeatStale, Some(9000)))]); let cleanup = FakeCleanup::new(); - let summary = - reap_stale_claims(&ledger, &probe, &cleanup, &investigator, true, STALE_SECS); + let summary = reap_stale_claims( + &ledger, + &probe, + &cleanup, + &investigator, + true, + STALE_SECS, + &no_perpetual(), + ); assert!( summary.reclaimed.is_empty(), @@ -2524,7 +2767,15 @@ mod tests { let state = tempfile::tempdir().expect("tempdir"); let investigator = RecipeStaleEngineerInvestigator::new(state.path(), "rysweet/Simard"); - let summary = reap_stale_claims(&ledger, &probe, &cleanup, &investigator, true, STALE_SECS); + let summary = reap_stale_claims( + &ledger, + &probe, + &cleanup, + &investigator, + true, + STALE_SECS, + &no_perpetual(), + ); assert_eq!( summary.reclaimed, vec![ghost.to_string()], diff --git a/src/overseer/mod.rs b/src/overseer/mod.rs index 90bfe15c1..e82ffd36f 100644 --- a/src/overseer/mod.rs +++ b/src/overseer/mod.rs @@ -1385,6 +1385,28 @@ impl Overseer { let Some(reaper) = self.claim_reaper.as_ref() else { return; }; + // Standing/perpetual exemption set (issue #4437): the ids of the active + // goals that are standing/perpetual, so the reaper never reclaims a healthy + // standing goal's live engineer claim — mirroring the OODA no-progress + // breaker's `is_perpetual()` benign-idle exemption. A board-read failure + // degrades to an EMPTY set (logged): the reaper then exempts nothing and + // falls back to the existing investigate-before-reap policy (fail-closed + // toward the agentic verdict, never toward a spurious reclaim). + let perpetual_goal_ids: std::collections::HashSet = self + .caps + .goals + .perpetual_active_goal_ids() + .unwrap_or_else(|error| { + tracing::warn!( + target: "simard::claim_reaper", + error = %error, + "[simard] claim-reaper: perpetual-goal exemption read failed \ + (degrading to empty set; investigate-before-reap still applies)", + ); + Vec::new() + }) + .into_iter() + .collect(); let summary = claim_reaper::reap_stale_claims( reaper.ledger.as_ref(), reaper.probe.as_ref(), @@ -1392,6 +1414,7 @@ impl Overseer { reaper.investigator.as_ref(), self.claim_reap_enabled, self.claim_reap_stale_secs, + &perpetual_goal_ids, ); // Stage the investigations' self-improvement interventions (issue #4400) // for dispatch through the SAME gated Act path health-review uses; they diff --git a/src/overseer/sensor.rs b/src/overseer/sensor.rs index e9abf08e5..9d9914a8a 100644 --- a/src/overseer/sensor.rs +++ b/src/overseer/sensor.rs @@ -230,6 +230,20 @@ pub fn blocked_goals_from_board(board: &GoalBoard) -> Vec { board.active.iter().filter_map(blocked_goal_of).collect() } +/// Project the ids of the active goals that are standing/perpetual +/// ([`ActiveGoal::is_perpetual`], #2589/#2609) — the staleness-reap EXEMPTION set +/// the claim reaper needs (issue #4437). Pure and read-only. Reuses the SAME +/// `is_perpetual()` predicate `blocked_goals_from_board` and the OODA no-progress +/// breaker key on, so there is exactly one notion of "standing/perpetual". +pub fn perpetual_active_goal_ids_from_board(board: &GoalBoard) -> Vec { + board + .active + .iter() + .filter(|g| g.is_perpetual()) + .map(|g| g.id.clone()) + .collect() +} + /// Project one active goal onto a [`BlockedGoal`] when it is `Blocked`. fn blocked_goal_of(goal: &ActiveGoal) -> Option { let GoalProgress::Blocked(reason) = &goal.status else { @@ -1001,4 +1015,36 @@ mod tests { fn in_flight_from_empty_board_is_empty() { assert!(in_flight_from_board(&GoalBoard::new()).is_empty()); } + + #[test] + fn perpetual_active_goal_ids_returns_only_standing_goals() { + // Two standing/perpetual goals + one ordinary terminal goal on the board. + // Only the standing ids are the reaper's exemption set (issue #4437). + let mut board = GoalBoard::new(); + board.active.push( + ActiveGoal::new("advance-parity-f29bb15c", "advance kgpacks parity", 1).mark_standing(), + ); + board + .active + .push(ActiveGoal::new("terminal-goal", "ship one PR then done", 2)); + board.active.push( + ActiveGoal::new("research-cognition", "continuously research cognition", 1) + .mark_standing(), + ); + + let ids = perpetual_active_goal_ids_from_board(&board); + + assert_eq!(ids.len(), 2, "only the two standing goals are exempt"); + assert!(ids.contains(&"advance-parity-f29bb15c".to_string())); + assert!(ids.contains(&"research-cognition".to_string())); + assert!( + !ids.contains(&"terminal-goal".to_string()), + "a terminal goal is never exempt from the staleness reap", + ); + } + + #[test] + fn perpetual_active_goal_ids_empty_board_is_empty() { + assert!(perpetual_active_goal_ids_from_board(&GoalBoard::new()).is_empty()); + } } diff --git a/src/overseer/wiring.rs b/src/overseer/wiring.rs index dd67498dc..fba459dfa 100644 --- a/src/overseer/wiring.rs +++ b/src/overseer/wiring.rs @@ -58,6 +58,7 @@ use crate::overseer::notify::DualChannelNotifier; use crate::overseer::observer::StewardshipIssueFiler; use crate::overseer::sensor::{ SnapshotStatusReader, blocked_goals_from_board, detect_workstream_gaps, in_flight_from_board, + perpetual_active_goal_ids_from_board, }; use crate::overseer::signal::{DETAIL_CAP, GapItem, Signal, sanitize_detail}; use crate::overseer::{ActOutcome, Capabilities, CycleReport, Overseer}; @@ -828,6 +829,10 @@ impl GoalCurator for BoardGoalCurator { Ok(blocked_goals_from_board(&self.load()?)) } + fn perpetual_active_goal_ids(&self) -> Result, OverseerError> { + Ok(perpetual_active_goal_ids_from_board(&self.load()?)) + } + fn observe_board(&self) -> Result<(Vec, Vec), OverseerError> { // Single board read projected two ways. The split // `blocked_goals()` + `in_flight()` path the Observe pass would