orchestrator-sm: fold gated+retries into per-component ComponentStatus Replace the gated set and retries map with a single statuses array parallel to chain, each entry a ComponentLifecycle (Nominal/Isolated) plus a retry count. Add status_index/gate_one helpers; rewrite is_gated, bump_retry, clear_retry, gate_by_policy, cascade_hold, and the Ready entry action to index into statuses. State enum and behavior unchanged.
diff --git a/services/orchestrator/sm/src/lib.rs b/services/orchestrator/sm/src/lib.rs index cecf4b4..cc6fbfe 100644 --- a/services/orchestrator/sm/src/lib.rs +++ b/services/orchestrator/sm/src/lib.rs
@@ -125,27 +125,52 @@ NotGated, } +/// Per-component service lifecycle — the ComponentStatus prototype. One +/// [`ComponentLifecycle`] plus a retry count is stored per chain component, +/// replacing the former `gated` set and `retries` map. NOTE: this prototype does +/// **not** move the walk-phase payloads (`AwaitingReady`/`Recovering`) off the +/// global [`State`]; see the evaluation for why those largely stay global. Named +/// for the *component service* axis to keep it distinct from fwmanager's +/// trial-boot/commit (update-slot) lifecycle, which is a separate concern. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum ComponentLifecycle { + /// In the normal flow: held, under verification, or released. The global + /// `State` still carries which of those the walk is in. + Nominal, + /// Durably gated out of service — was membership in `gated`. Has a live + /// `AssertReset` and is skipped on every chain walk. + Isolated, +} + +/// One per-component record (see [`Rot::statuses`]). +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +struct ComponentStatus { + lifecycle: ComponentLifecycle, + /// Consecutive failed-restore count (INV7: consecutive only). + retry: u8, +} + +impl Default for ComponentStatus { + fn default() -> Self { + Self { + lifecycle: ComponentLifecycle::Nominal, + retry: 0, + } + } +} + /// Shared storage: data that persists across events. `N` is the chain capacity /// and `E` the effect-buffer size — both board choices; the core sets no /// default. `E` must be at least `2 * N + 2` (enforced in [`Rot::new`]). pub struct Rot<const N: usize, const E: usize> { chain: heapless::Vec<(ComponentId, ComponentAttrs), N>, cursor: u8, - /// Components physically held in reset by a policy decision: runtime - /// corruption of a non-required component, or recovery exhausted under - /// `FailurePolicy::Isolable` or `Cascading`. Each id here has a live - /// `AssertReset` and is skipped on every chain walk. This is a **durable** - /// trust-boundary gate: it persists across a return to `Ready` and is only - /// cleared by a fresh `Rot` on `PowerOnReset`. - gated: heapless::Vec<ComponentId, N>, - /// Per-component consecutive failed-restore counts. An entry is present only - /// for a component with at least one recorded attempt; absent means zero. - /// Keyed by `ComponentId` so interleaved recoveries of different components - /// never share a retry budget — CSA frames recovery-attempt exhaustion per - /// device, not as a single global counter. A component's entry is cleared - /// when it passes verification (recovered) or is gated, and the whole map is - /// cleared on a clean return to `Ready`. - retries: heapless::Vec<(ComponentId, u8), N>, + /// One record per chain component (parallel to `chain` by index). Folds the + /// former `gated` set and `retries` map into a single per-component + /// [`ComponentStatus`]: `lifecycle` replaces membership in `gated` + /// (`Isolated`), and `retry` replaces the `retries` count. Durable across a + /// return to `Ready`; only a fresh `Rot` on `PowerOnReset` resets it. + statuses: heapless::Vec<ComponentStatus, N>, max_retry: u8, /// Ties the effect-buffer size `E` to this type (zero-sized). _effect_cap: PhantomData<[u8; E]>, @@ -163,11 +188,16 @@ pub fn new(chain: Chain<N>, max_retry: u8) -> Self { let () = Self::EFFECT_CAP_OK; + let chain = chain.into_entries(); + // One status record per chain component, parallel by index. + let mut statuses = heapless::Vec::new(); + for _ in 0..chain.len() { + let _ = statuses.push(ComponentStatus::default()); + } Self { - chain: chain.into_entries(), + chain, cursor: 0, - gated: heapless::Vec::new(), - retries: heapless::Vec::new(), + statuses, max_retry, _effect_cap: PhantomData, } @@ -182,30 +212,52 @@ .map(|(_, a)| *a) } + /// Index of `id` in the parallel `chain`/`statuses` arrays. `None` if the + /// id is not in the chain (should never happen for core-produced ids). + fn status_index(&self, id: ComponentId) -> Option<usize> { + self.chain.iter().position(|(cid, _)| *cid == id) + } + fn is_gated(&self, id: ComponentId) -> bool { - self.gated.iter().any(|h| *h == id) + self.status_index(id) + .is_some_and(|i| self.statuses[i].lifecycle == ComponentLifecycle::Isolated) + } + + /// Gate one component out of service, returning `true` if this newly gated + /// it (`false` if already `Isolated`). Emits the paired `AssertReset` + + /// `ReportIsolated` only on the newly-gated transition, so reporting is + /// idempotent per component. + fn gate_one(&mut self, ctx: &mut Sink<E>, id: ComponentId) -> bool { + if self.is_gated(id) { + return false; + } + ctx.emit(Effect::AssertReset(id)); + ctx.emit(Effect::ReportIsolated(id)); + if let Some(i) = self.status_index(id) { + self.statuses[i].lifecycle = ComponentLifecycle::Isolated; + } + true } /// Increment `id`'s consecutive failed-restore count and return the new /// value. Counts are kept per component so that interleaved recovery /// episodes for different components never share a budget. fn bump_retry(&mut self, id: ComponentId) -> u8 { - for entry in self.retries.iter_mut() { - if entry.0 == id { - entry.1 = entry.1.saturating_add(1); - return entry.1; + match self.status_index(id) { + Some(i) => { + self.statuses[i].retry = self.statuses[i].retry.saturating_add(1); + self.statuses[i].retry } + None => 0, } - let _ = self.retries.push((id, 1)); - 1 } /// Drop `id`'s retry count. Called when the component recovers (passes /// verification) or is gated — either way its consecutive-failure streak /// ends, so a future failure starts counting fresh (INV7: consecutive only). fn clear_retry(&mut self, id: ComponentId) { - if let Some(pos) = self.retries.iter().position(|e| e.0 == id) { - let _ = self.retries.swap_remove(pos); + if let Some(i) = self.status_index(id) { + self.statuses[i].retry = 0; } } @@ -259,11 +311,7 @@ Gating::Gated } Some(FailurePolicy::Isolable) => { - if !self.is_gated(id) { - ctx.emit(Effect::AssertReset(id)); - ctx.emit(Effect::ReportIsolated(id)); - let _ = self.gated.push(id); - } + self.gate_one(ctx, id); Gating::Gated } // `Required`, or an unknown/missing id: not a gating policy. @@ -291,13 +339,15 @@ /// each newly gated component, including `root` itself — every isolated /// device is reported, not just the one that failed. fn cascade_hold(&mut self, ctx: &mut Sink<E>, root: ComponentId) { - if !self.is_gated(root) { - ctx.emit(Effect::AssertReset(root)); - ctx.emit(Effect::ReportIsolated(root)); - let _ = self.gated.push(root); + // BFS over the growing isolation front. `frontier` holds the components + // gated so far whose dependents still need visiting — the same order the + // old `gated`-Vec walk used, now that `gated` is folded into `statuses`. + let mut frontier: heapless::Vec<ComponentId, N> = heapless::Vec::new(); + if self.gate_one(ctx, root) { + let _ = frontier.push(root); } let mut i = 0; - while let Some(&holder) = self.gated.get(i) { + while let Some(&holder) = frontier.get(i) { i += 1; let mut newly_gated: heapless::Vec<ComponentId, N> = heapless::Vec::new(); for &(id, attrs) in self.chain.iter() { @@ -306,9 +356,9 @@ } } for id in newly_gated { - ctx.emit(Effect::AssertReset(id)); - ctx.emit(Effect::ReportIsolated(id)); - let _ = self.gated.push(id); + if self.gate_one(ctx, id) { + let _ = frontier.push(id); + } } } } @@ -576,8 +626,10 @@ // // Retry counts, by contrast, ARE cleared: a clean boot means no // recovery episode is in flight, so every per-component streak - // resets to zero. - self.retries.clear(); + // resets to zero. (The `Isolated` lifecycle is preserved.) + for s in self.statuses.iter_mut() { + s.retry = 0; + } } _ => {} }