diff --git a/crates/spt-daemon/src/resting.rs b/crates/spt-daemon/src/resting.rs index 562fa02e..3a8ac426 100644 --- a/crates/spt-daemon/src/resting.rs +++ b/crates/spt-daemon/src/resting.rs @@ -1,38 +1,34 @@ -//! The resting-state model (D9-2, REQ-INST-3) — the explicit, daemon-owned -//! instance state machine behind dormant (warm) / suspended (cold). +//! The instance-axis state machine (D9-2 REQ-INST-3, reworked by +//! INSTANCE-AXES releases#348) — the explicit, daemon-owned model behind +//! active / dormant (warm) / suspended (cold). //! -//! CONTEXT §dormant/suspended defines the model this module implements: +//! CONTEXT §instance state + §dormant/suspended define the model this module +//! implements. **Two axes, not one** (REQ-INSTANCE-AXIS-SPLIT): whether a +//! controller is attached is the separate CONTROLLED axis (`info.json` +//! `controlled`, broker-stamped, gossiped) — attach and detach NEVER move the +//! instance axis, and this machine reads the controlled axis only through +//! auto-suspend. On the instance axis: //! -//! - **active** = the most-recently-interacted instance for the ID *and* a -//! driver attached. **No idle timer** — a session stays active while it -//! holds both properties. -//! - **active → dormant**: the driver detaches, or attention shifts (another -//! instance of the same id becomes the most-recently-interacted one). -//! - **dormant → suspended**: manual (`spt suspend` / shell -//! `api owner-shutdown`), or opt-in auto-suspend after an -//! `auto-suspend-after` threshold counted **from dormancy onset** — never -//! from last activity (the [`InfoJson::dormant_since_ms`] anchor). -//! - **wake (→ active)**: re-activation in place — the state is already -//! there; wake is an edge, not a spawn. +//! - **→ active** only through an activation trigger (REQ-ACTIVATION-TRIGGERS): +//! user input, a stealing message, an explicit wake ([`RestEvent::Wake`]), or +//! coming warm with no active sibling ([`RestEvent::ComeWarm`]). Every edge +//! into active takes a fresh ACTIVATION COUNTER — the highest seen for the id +//! plus one (REQ-ACTIVATION-COUNTER) — which is how every other node orders it. +//! - **active → dormant** has exactly ONE cause: a sibling became active +//! ([`RestEvent::SiblingActivated`]). Never a detach, never idleness. +//! - **→ suspended**: manual (`spt endpoint suspend` / shell +//! `api owner-shutdown`), or auto-suspend under its `{mode, after}` setting +//! (REQ-AUTO-SUSPEND-MODES). Suspending the ACTIVE instance VACATES active; +//! nothing passes it on to a dormant sibling. //! //! The transition function is **pure** ([`transition`]): state + event + -//! resolved knob in, the new state out (`None` = no edge — the event does not -//! move this state). Durability is one field pair on the perch's `info.json` -//! ([`read_rest`] / [`write_rest`] over [`spt_store::info::set_rest_state`]); -//! effects (the transition echo commune, the wake resurface + freshness pull -//! — REQ-INST-4) hang off the *edges* and are wired by the transition host, +//! the injected facts in, the new state out (`None` = no edge). Durability is +//! the perch's `info.json` (`rest_state`, `dormant_since_ms`, `activation`); +//! effects (the transition echo commune, the wake resurface + freshness pull — +//! REQ-INST-4) hang off the *edges* and are wired by the transition host, //! not here. Keeping the table pure is what makes the CONTEXT state diagram //! a unit test instead of a prose promise. //! -//! ## The auto-suspend knob chain (global → node → endpoint, default OFF) -//! -//! Auto-suspend is **opt-in, default OFF globally** (the compiled default: -//! no knob anywhere ⇒ `None` ⇒ never auto-suspends). A node may enable it -//! (`daemon.json` `auto_suspend_after_ms` — a constrained node defaults it -//! ON); an endpoint may override the node either way -//! (`info.json` `auto_suspend_after_ms`, with `0` = explicitly OFF even when -//! the node enables it). [`effective_auto_suspend`] resolves the chain. -//! //! ## What "suspended" does NOT do here //! //! Suspension's session mechanics (closing the harness seat, resume-on-wake) @@ -41,11 +37,14 @@ //! resolves to and whether a wake is owed first; it is never an input to a //! delivery window. What a delivery window reads is the activity sentinel //! (REQ-DELIVERY-WINDOW-IS-ACTIVITY-AXIS-ONLY, releases#341): this state -//! machine's events carry no activity input, so no reading of it can decide one. +//! machine reads the sentinel only for auto-suspend's idle leg, and no +//! reading of this machine can decide a window. // [impl->REQ-INST-3] +// [impl->REQ-INSTANCE-AXIS-SPLIT] use std::path::Path; +use spt_store::autosuspend::{AutoSuspend, AutoSuspendMode}; use spt_store::info; use spt_store::perch::{self, ParentHint}; @@ -73,10 +72,10 @@ pub const NOT_A_HOSTED_PERCH_MARKER: &str = "is not hosted on this node"; /// machine transitions an instance into. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RestState { - /// Most-recently-interacted for the id + a driver attached. + /// The instance holding the id's attention. Active ⇒ warm and online. Active, - /// Resting warm: the harness session stays running — instant - /// re-activation. The default resting state. + /// Resting warm: the harness session stays running while a sibling holds + /// attention. Dormant, /// Resting cold: the harness session is closed, resumed on wake. Suspended, @@ -104,72 +103,164 @@ impl RestState { } } -/// The events that can move an instance between resting states. Each names a -/// real observation point in the daemon (the production feeds), not a poll: -/// the model has **no idle timer** by design. +/// The events that can move an instance on the instance axis. Each names a +/// real observation point (the production feeds), not a poll. There is NO +/// detach event: the controlled axis never moves this one +/// (REQ-INSTANCE-AXIS-SPLIT). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RestEvent { - /// The driver detached (viewport ended, harness seat released). - Detach, - /// Another instance of the same id became the most-recently-interacted - /// one (CONTEXT: driving `ling@laptop` makes `ling@desktop` dormant). - AttentionShift, - /// Manual suspend (`spt suspend` / shell `api owner-shutdown`). + /// A sibling of the same id became active — observed through the + /// registry's activation order (REQ-ACTIVATION-COUNTER). The ONLY edge + /// into dormant. + SiblingActivated, + /// Manual suspend (`spt endpoint suspend` / shell `api owner-shutdown`). Suspend, - /// The daemon's cadence check against the opt-in auto-suspend threshold. - /// Carries the clock so the table stays pure. - AutoSuspendTick { now_ms: u64 }, - /// A driver attached / `spt wake` / interaction — re-activate in place. + /// The pulse loop's auto-suspend check. Carries the clock and the + /// unbroken-condition facts so the table stays pure. + AutoSuspendTick { now_ms: u64, facts: SuspendFacts }, + /// An activation trigger that always lands ACTIVE: an explicit wake + /// (`spt endpoint wake`, a shell wake-watcher), user input, or a stealing + /// message (REQ-ACTIVATION-TRIGGERS 1, 2, 5). Wake, + /// Coming warm — boot, or a controller attaching. Lands ACTIVE when no + /// sibling is active (the vacancy rule, no time window), else DORMANT + /// from suspended and no edge from dormant (REQ-ACTIVATION-TRIGGERS 4 + + /// wake landing by cause). + ComeWarm, + /// A SAME-id message (a sibling's normal message) reaching this instance. + /// It never steals; it only wakes a SUSPENDED instance, and wakes it + /// DORMANT (wake landing by cause). + SiblingMessage, +} + +/// The auto-suspend condition's inputs, observed by the host at tick time +/// (REQ-AUTO-SUSPEND-MODES): the controlled axis and the activity sentinel, +/// each with the instant it last took its current value. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct SuspendFacts { + /// A controller is attached (the controlled axis). + pub controlled: bool, + /// When `controlled` last changed. `None` (a pre-#348 record) ⇒ this leg + /// contributes no anchor. + pub controlled_since_ms: Option, + /// The agent is idle (the activity sentinel, busy|idle). + pub idle: bool, + /// When the agent went idle. `None` ⇒ the instant is unknown and the tick + /// never fires (fail toward warm, the cheap mistake). + pub idle_since_ms: Option, +} + +/// What the host knows about the id's OTHER instances when an event lands: +/// whether one is active, and the highest activation counter seen for the id +/// (the floor a new activation must exceed). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct SiblingView { + pub active_sibling: bool, + pub max_activation: u64, } /// The pure transition table. `dormant_since_ms` is the dormancy-onset anchor -/// (present iff `state` is dormant); `auto_suspend_after_ms` is the RESOLVED -/// knob ([`effective_auto_suspend`] — `None` = OFF). Returns the new state on -/// a real edge, `None` when the event does not move this state (idempotent -/// re-delivery of any event is a no-op by construction — an event-feed replay -/// can never double-fire an edge's effects). +/// (present iff `state` is dormant); `auto_suspend` is the RESOLVED setting +/// (`spt_store::autosuspend::effective`); `active_sibling` is whether another +/// instance of the id is active. Returns the new state on a real edge, `None` +/// when the event does not move this state (idempotent re-delivery of any +/// event is a no-op by construction — an event-feed replay can never +/// double-fire an edge's effects). // [impl->REQ-INST-3] +// [impl->REQ-INSTANCE-AXIS-SPLIT] +// [impl->REQ-ACTIVATION-TRIGGERS] pub fn transition( state: RestState, dormant_since_ms: Option, event: RestEvent, - auto_suspend_after_ms: Option, + auto_suspend: AutoSuspend, + active_sibling: bool, ) -> Option { + use RestState::{Active, Dormant, Suspended}; match (state, event) { - // active → dormant: the driver detaches or attention shifts. - (RestState::Active, RestEvent::Detach) => Some(RestState::Dormant), - (RestState::Active, RestEvent::AttentionShift) => Some(RestState::Dormant), - // Manual suspend works from active too (the user said cold — the - // active→suspended edge still fires the transition echo, KH 3.3). - (RestState::Active, RestEvent::Suspend) => Some(RestState::Suspended), - (RestState::Dormant, RestEvent::Suspend) => Some(RestState::Suspended), - // Opt-in auto-suspend: threshold counted from DORMANCY ONSET. A - // dormant record without its anchor (torn legacy state) never - // auto-suspends — fail toward warm, the cheap mistake. - (RestState::Dormant, RestEvent::AutoSuspendTick { now_ms }) => { - let after = auto_suspend_after_ms.filter(|ms| *ms > 0)?; - let since = dormant_since_ms?; - (now_ms.saturating_sub(since) >= after).then_some(RestState::Suspended) + // The ONE edge into dormant: a sibling became active. + (Active, RestEvent::SiblingActivated) => Some(Dormant), + // Manual suspend from either warm state. From active it VACATES + // active — nothing is passed on (the echo still fires, KH 3.3). + (Active | Dormant, RestEvent::Suspend) => Some(Suspended), + (Active | Dormant, RestEvent::AutoSuspendTick { now_ms, facts }) => { + auto_suspend_due(state, dormant_since_ms, auto_suspend, now_ms, facts) + .then_some(Suspended) } - // wake = re-activation in place, from either resting state. - (RestState::Dormant, RestEvent::Wake) => Some(RestState::Active), - (RestState::Suspended, RestEvent::Wake) => Some(RestState::Active), - // Everything else: no edge. Notably — Wake on an already-active - // instance (just a recency stamp, not a transition), Detach / - // AttentionShift on a resting one, Suspend on suspended (idempotent), - // AutoSuspendTick anywhere but dormant. + // Explicit wake / user input / stealing message: always active. + (Dormant | Suspended, RestEvent::Wake) => Some(Active), + // Coming warm: the vacancy rule decides. + (Suspended, RestEvent::ComeWarm) => Some(if active_sibling { Dormant } else { Active }), + (Dormant, RestEvent::ComeWarm) => (!active_sibling).then_some(Active), + // A sibling's message wakes a suspended instance, never to active. + (Suspended, RestEvent::SiblingMessage) => Some(Dormant), + // Everything else: no edge. Notably — any trigger on an already + // active instance, SiblingActivated on a resting one, Suspend on + // suspended (idempotent), a tick on suspended. _ => None, } } -/// Resolve the auto-suspend knob chain (REQ-INST-3): global default **OFF** -/// (`None`), node knob from `daemon.json`, endpoint override from the perch's -/// `info.json` — `Some(0)` at either level is an explicit OFF (an endpoint -/// can opt out of a node that defaults ON). Endpoint wins when present. -// [impl->REQ-INST-3] -pub fn effective_auto_suspend(node_ms: Option, endpoint_ms: Option) -> Option { - endpoint_ms.or(node_ms).filter(|ms| *ms > 0) +/// Whether the auto-suspend condition has held UNBROKEN for the setting's +/// `after` (REQ-AUTO-SUSPEND-MODES): uncontrolled AND idle AND (dormant, under +/// `dormant-enable`). The timer anchor is the LATEST instant any leg took its +/// current value, so a break in any leg restarts it. +// [impl->REQ-AUTO-SUSPEND-MODES] +fn auto_suspend_due( + state: RestState, + dormant_since_ms: Option, + setting: AutoSuspend, + now_ms: u64, + facts: SuspendFacts, +) -> bool { + let dormant_leg = match setting.mode { + AutoSuspendMode::Disable => return false, + AutoSuspendMode::Enable => None, + AutoSuspendMode::DormantEnable => { + if state != RestState::Dormant { + return false; + } + // A dormant record without its anchor (torn legacy state) never + // auto-suspends — fail toward warm. + let Some(since) = dormant_since_ms else { + return false; + }; + Some(since) + } + }; + if facts.controlled || !facts.idle { + return false; + } + let Some(idle_since) = facts.idle_since_ms else { + return false; + }; + let anchor = idle_since + .max(facts.controlled_since_ms.unwrap_or(0)) + .max(dormant_leg.unwrap_or(0)); + now_ms.saturating_sub(anchor) >= setting.after_ms +} + +/// The pure CLASSIFIER for a message reaching a locally hosted instance at +/// ADMISSION (REQ-ACTIVATION-TRIGGERS 2 + wake landing by cause) — the ONE +/// place the stealing rule is read. Both admission points (local `spt send`, +/// WAN receive) call it and feed its answer to the transition host; neither +/// decides anything itself. +/// +/// - busy-only (`active_only`) window ⇒ never a trigger; +/// - empty sender (shell → owner frames, anonymous re-spools) ⇒ never; +/// - a DIFFERENT id ⇒ [`RestEvent::Wake`] (a stealing message lands active); +/// - the SAME id (a sibling's normal message, a self-send) ⇒ never steals; +/// [`RestEvent::SiblingMessage`] only wakes a suspended instance DORMANT. +// [impl->REQ-ACTIVATION-TRIGGERS] +pub fn message_trigger(target: &str, sender: &str, window: &str) -> Option { + if window == spt_store::spool::WINDOW_ACTIVE_ONLY || sender.is_empty() { + return None; + } + if sender == target { + Some(RestEvent::SiblingMessage) + } else { + Some(RestEvent::Wake) + } } /// The wake-edge freshness-pull marker file (under `identity/`). A @@ -239,26 +330,31 @@ pub fn effective_rest_state(alive: bool, unbound: bool, intent: OptionREQ-INST-4] /// 2. persists the landing state ([`write_rest`] — the dormancy anchor moves -/// with it); -/// 3. **→ active (wake)**: runs `on_wake` — the resurface-at-boundary + +/// with it). An edge INTO active takes the next activation counter — +/// `max(own, highest seen for the id) + 1` — in the same write +/// (REQ-ACTIVATION-COUNTER); +/// 3. **→ active**: runs `on_wake` — the resurface-at-boundary + /// freshness-pull effects (re-activation in place catches up immediately). /// /// `Ok(None)` = no edge (the pure table's idempotence: replaying any event /// against a state it does not move can never double-fire effects). // [impl->REQ-INST-3] +// [impl->REQ-ACTIVATION-COUNTER] pub fn apply_event( perch_path: &Path, event: RestEvent, - node_auto_suspend_ms: Option, + node_auto_suspend: Option, + siblings: SiblingView, now_ms: u64, on_rest_edge: impl FnOnce() -> Result<(), String>, on_wake: impl FnOnce(), @@ -266,7 +362,8 @@ pub fn apply_event( apply_event_with_liveness( perch_path, event, - node_auto_suspend_ms, + node_auto_suspend, + siblings, now_ms, on_rest_edge, on_wake, @@ -287,7 +384,8 @@ pub fn apply_event( pub fn apply_event_with_liveness( perch_path: &Path, event: RestEvent, - node_auto_suspend_ms: Option, + node_auto_suspend: Option, + siblings: SiblingView, now_ms: u64, on_rest_edge: impl FnOnce() -> Result<(), String>, on_wake: impl FnOnce(), @@ -313,14 +411,15 @@ pub fn apply_event_with_liveness( spt_store::liveness::is_perch_unbound(perch_path), rec.rest_state.as_deref().and_then(RestState::from_tag), ); - let knob = effective_auto_suspend(node_auto_suspend_ms, rec.auto_suspend_after_ms); - let Some(to) = transition(from, rec.dormant_since_ms, event, knob) else { + let (setting, _) = spt_store::autosuspend::effective(node_auto_suspend, rec.auto_suspend); + let Some(to) = transition(from, rec.dormant_since_ms, event, setting, siblings.active_sibling) + else { return Ok(None); }; // The transition echo (REQ-INST-4, KH 3.3): at the active → resting edge, // before the flip persists. Failure is captured, not short-circuited — - // the state change is a fact about the driver, not about the echo. + // the state change is a fact about the instance, not about the echo. // [impl->REQ-INST-4] let mut echo_err: Option = None; let echo_fired = from == RestState::Active && to != RestState::Active; @@ -330,10 +429,13 @@ pub fn apply_event_with_liveness( } } - write_rest(perch_path, to, now_ms).map_err(|e| format!("persist rest state: {e}"))?; - if to == RestState::Active { + let activation = next_activation(rec.activation, siblings.max_activation); + info::set_rest_state_activating(perch_path, activation) + .map_err(|e| format!("persist rest state: {e}"))?; on_wake(); + } else { + write_rest(perch_path, to, now_ms).map_err(|e| format!("persist rest state: {e}"))?; } if let Some(e) = echo_err { @@ -346,6 +448,96 @@ pub fn apply_event_with_liveness( })) } +/// Where a perch coming warm from nothing (a boot: a fresh life or a new +/// perch) lands, and the activation counter it carries — the table's +/// `ComeWarm` from cold (REQ-ACTIVATION-TRIGGERS 4): ACTIVE with the next +/// counter when no sibling is active, DORMANT (counter unchanged) otherwise. +// [impl->REQ-ACTIVATION-TRIGGERS] +pub fn come_warm_landing(siblings: SiblingView, own_activation: u64) -> (RestState, u64) { + match transition( + RestState::Suspended, + None, + RestEvent::ComeWarm, + spt_store::autosuspend::DEFAULT_AUTO_SUSPEND, + siblings.active_sibling, + ) { + Some(RestState::Active) => { + (RestState::Active, next_activation(own_activation, siblings.max_activation)) + } + _ => (RestState::Dormant, own_activation), + } +} + +/// Wall-clock epoch ms (the dormancy anchor's clock). +pub fn wall_now_ms() -> u64 { + now_ms() +} + +/// The counter an activation takes: the highest this instance has seen for +/// the id — its own last value or any sibling's — plus one +/// (REQ-ACTIVATION-COUNTER). Saturates rather than wrapping. +// [impl->REQ-ACTIVATION-COUNTER] +pub fn next_activation(own: u64, max_seen: u64) -> u64 { + own.max(max_seen).saturating_add(1) +} + +/// Observe the auto-suspend condition's facts for a perch +/// (REQ-AUTO-SUSPEND-MODES): the controlled axis off `info.json` (the broker's +/// stamp + its flip instant) and the activity sentinel off the perch +/// (`.idle` + the `.activity` stamp's instant). +// [impl->REQ-AUTO-SUSPEND-MODES] +pub fn suspend_facts(perch_path: &Path) -> SuspendFacts { + let rec = info::read_info(perch_path); + let (idle, idle_since_ms) = perch::read_activity_at(perch_path); + SuspendFacts { + controlled: rec.as_ref().is_some_and(|r| r.controlled), + controlled_since_ms: rec.and_then(|r| r.controlled_changed_ms), + idle, + idle_since_ms, + } +} + +/// Whether a sibling's active claim outranks the local instance — the +/// activation ORDER (REQ-ACTIVATION-COUNTER): counter first, node id breaks +/// a tie. Strictly greater: a claim equal to our own rank is our own row. +// [impl->REQ-ACTIVATION-COUNTER] +pub fn sibling_outranks(claim: (u64, &str), local: (u64, &str)) -> bool { + claim > local +} + +/// The [`SiblingView`] for `id` from the registry snapshots: every OTHER +/// node's row for the id, across every visible subnet. Reads the snapshot +/// files (not the in-memory registry) because the transition host runs in +/// the CLI process too (`spt endpoint wake`, `api state busy`, `spt send`). +/// Stale-tolerant by contract: a missing snapshot reads as no siblings. +// [impl->REQ-ACTIVATION-COUNTER] +pub fn sibling_view(id: &str) -> SiblingView { + sibling_view_from( + &crate::presence::load_registry_snapshots(&crate::presence::registry_snapshot_dir()), + id, + &crate::presence::local_node_hex(), + ) +} + +/// Pure core of [`sibling_view`]. +// [impl->REQ-ACTIVATION-COUNTER] +pub fn sibling_view_from( + regs: &std::collections::BTreeMap, + id: &str, + own_node: &str, +) -> SiblingView { + let mut view = SiblingView::default(); + for reg in regs.values() { + for inst in reg.instances(id).iter().filter(|i| i.node != own_node) { + view.max_activation = view.max_activation.max(inst.activation); + if inst.status == spt_net::net::registry::Status::Active { + view.active_sibling = true; + } + } + } + view +} + /// Arm the endpoint's echo-gate sentinel — the **cross-process transition /// echo trigger** (REQ-INST-4). The daemon's feed points (the attach arm's /// detach, the registry arm's attention shift, `spt suspend`) hold no @@ -402,9 +594,9 @@ pub fn fire_wake_effects(id: &str) { pub fn daemon_rest_event( id: &str, event: RestEvent, - node_auto_suspend_ms: Option, + node_auto_suspend: Option, ) -> Result, String> { - daemon_rest_event_with_liveness(id, event, node_auto_suspend_ms, None) + daemon_rest_event_with_liveness(id, event, node_auto_suspend, None) } /// [`daemon_rest_event`] with the ONE-liveness-authority override (ADR-0041 @@ -416,14 +608,15 @@ pub fn daemon_rest_event( pub fn daemon_rest_event_with_liveness( id: &str, event: RestEvent, - node_auto_suspend_ms: Option, + node_auto_suspend: Option, alive_hint: Option, ) -> Result, String> { let perch_path = perch::resolve_perch_path(id, ParentHint::Infer); let report = apply_event_with_liveness( &perch_path, event, - node_auto_suspend_ms, + node_auto_suspend, + sibling_view(id), now_ms(), || { arm_transition_echo(id); @@ -486,7 +679,9 @@ pub struct RestRecord { /// Dormancy-onset anchor (present iff `state` is dormant). pub dormant_since_ms: Option, /// The endpoint leg of the auto-suspend knob chain. - pub auto_suspend_after_ms: Option, + pub auto_suspend: Option, + /// The activation counter this instance last took. + pub activation: u64, } /// Read a perch's resting record. `None` ⇒ no record (a pre-D9-2 / interim @@ -499,7 +694,8 @@ pub fn read_rest(perch_path: &Path) -> Option { Some(RestRecord { state, dormant_since_ms: rec.dormant_since_ms, - auto_suspend_after_ms: rec.auto_suspend_after_ms, + auto_suspend: rec.auto_suspend, + activation: rec.activation, }) } @@ -620,7 +816,7 @@ mod tests { // Daemon-internal event on the fallback path → NoRemoteArm (before load). assert!(matches!( route_rest_event( - RestEvent::AutoSuspendTick { now_ms: 0 }, + RestEvent::AutoSuspendTick { now_ms: 0, facts: SuspendFacts::default() }, true, miss, no_candidates_expected @@ -674,6 +870,7 @@ mod tests { std::path::Path::new("no-such-perch-dir-drift-pin"), RestEvent::Wake, None, + SiblingView::default(), 0, || Ok(()), || {}, @@ -685,116 +882,197 @@ mod tests { ); } - // [unit->REQ-INST-3] THE transition table — the CONTEXT state diagram as - // assertions: detach and attention-shift rest an active instance warm, - // suspend rests it cold from either live state, wake re-activates in - // place from either resting state, and everything else is a no-edge. + // [unit->REQ-INST-3] [unit->REQ-INSTANCE-AXIS-SPLIT] THE transition table — + // the INSTANCE-AXES model as assertions (releases#348). REPINNED from the + // D9-2 table: detach no longer exists as an event (the controlled axis + // never moves this one), and the ONLY edge into dormant is a sibling + // becoming active. Suspend from either warm state (from active it + // vacates); explicit wake from either resting state; everything else a + // no-edge, so a replayed feed can never double-fire an edge. #[test] fn transition_table_matches_the_context_model() { use RestEvent::*; use RestState::*; - - // active → dormant on detach / attention shift. - assert_eq!(transition(Active, None, Detach, None), Some(Dormant)); - assert_eq!( - transition(Active, None, AttentionShift, None), - Some(Dormant) - ); - // manual suspend from active AND from dormant. - assert_eq!(transition(Active, None, Suspend, None), Some(Suspended)); - assert_eq!(transition(Dormant, Some(5), Suspend, None), Some(Suspended)); - // wake re-activates from either resting state. - assert_eq!(transition(Dormant, Some(5), Wake, None), Some(Active)); - assert_eq!(transition(Suspended, None, Wake, None), Some(Active)); - - // No-edges: events are idempotent against a state they don't move — - // a replayed feed can never double-fire an edge. - assert_eq!(transition(Active, None, Wake, None), None, "already active"); - assert_eq!(transition(Dormant, Some(5), Detach, None), None); - assert_eq!(transition(Dormant, Some(5), AttentionShift, None), None); + let d = spt_store::autosuspend::DEFAULT_AUTO_SUSPEND; + + // The ONE edge into dormant. + assert_eq!(transition(Active, None, SiblingActivated, d, true), Some(Dormant)); + // Manual suspend from active (vacates) AND from dormant. + assert_eq!(transition(Active, None, Suspend, d, false), Some(Suspended)); + assert_eq!(transition(Dormant, Some(5), Suspend, d, true), Some(Suspended)); + // Explicit wake / user input / stealing message: active from either + // resting state, whether or not a sibling is active. + assert_eq!(transition(Dormant, Some(5), Wake, d, true), Some(Active)); + assert_eq!(transition(Suspended, None, Wake, d, true), Some(Active)); + assert_eq!(transition(Suspended, None, Wake, d, false), Some(Active)); + + // No-edges. + assert_eq!(transition(Active, None, Wake, d, false), None, "already active"); + assert_eq!(transition(Dormant, Some(5), SiblingActivated, d, true), None); + assert_eq!(transition(Suspended, None, SiblingActivated, d, true), None); + assert_eq!(transition(Suspended, None, Suspend, d, false), None, "idempotent"); + // Idleness is not an edge into dormant: an idle, uncontrolled ACTIVE + // instance under the default mode stays active however long it waits. + let idle = SuspendFacts { + controlled: false, + controlled_since_ms: Some(0), + idle: true, + idle_since_ms: Some(0), + }; assert_eq!( - transition(Suspended, None, Suspend, None), + transition(Active, None, AutoSuspendTick { now_ms: u64::MAX, facts: idle }, d, true), None, - "suspend is idempotent" + "dormant-enable never suspends the active instance, and nothing makes it dormant" ); - assert_eq!(transition(Suspended, None, Detach, None), None); - assert_eq!(transition(Suspended, None, AttentionShift, None), None); } - // [unit->REQ-INST-3] auto-suspend: opt-in (no knob ⇒ never), threshold - // counted from DORMANCY ONSET, fires only at/past the threshold, only - // from dormant, and a dormant record missing its anchor fails warm. + // [unit->REQ-ACTIVATION-TRIGGERS] coming warm follows the VACANCY rule, + // with no time window: from suspended it lands active when no sibling is + // active and dormant when one is (attach to a suspended instance); from + // dormant it takes active only during a vacancy. A sibling's (same-id) + // message only ever wakes a SUSPENDED instance, and wakes it dormant. #[test] - fn auto_suspend_counts_from_dormancy_onset_and_is_opt_in() { - use RestEvent::AutoSuspendTick; + fn come_warm_and_sibling_message_follow_the_vacancy_rule() { + use RestEvent::*; use RestState::*; + let d = spt_store::autosuspend::DEFAULT_AUTO_SUSPEND; + + assert_eq!(transition(Suspended, None, ComeWarm, d, false), Some(Active)); + assert_eq!(transition(Suspended, None, ComeWarm, d, true), Some(Dormant)); + assert_eq!(transition(Dormant, Some(1), ComeWarm, d, false), Some(Active)); + assert_eq!(transition(Dormant, Some(1), ComeWarm, d, true), None); + assert_eq!(transition(Active, None, ComeWarm, d, false), None); + + assert_eq!(transition(Suspended, None, SiblingMessage, d, true), Some(Dormant)); + assert_eq!(transition(Suspended, None, SiblingMessage, d, false), Some(Dormant)); + assert_eq!(transition(Dormant, Some(1), SiblingMessage, d, false), None); + assert_eq!(transition(Active, None, SiblingMessage, d, true), None); + + // The boot landing is the same rule, carrying the next counter. + let vacant = SiblingView { active_sibling: false, max_activation: 7 }; + assert_eq!(come_warm_landing(vacant, 3), (Active, 8)); + let taken = SiblingView { active_sibling: true, max_activation: 7 }; + assert_eq!(come_warm_landing(taken, 3), (Dormant, 3)); + } - // Default OFF: no knob ⇒ no edge no matter how stale. - assert_eq!( - transition(Dormant, Some(0), AutoSuspendTick { now_ms: u64::MAX }, None), - None - ); - - // Opt-in: dormant at t=1000, threshold 500. - let knob = Some(500); - assert_eq!( - transition( - Dormant, - Some(1_000), - AutoSuspendTick { now_ms: 1_499 }, - knob - ), - None, - "inside the threshold — stays warm" - ); - assert_eq!( - transition( - Dormant, - Some(1_000), - AutoSuspendTick { now_ms: 1_500 }, - knob - ), - Some(Suspended), - "at the threshold — suspends" - ); - - // The anchor is dormancy onset, NOT last activity: a missing anchor - // (torn legacy record) fails toward warm. + // [unit->REQ-ACTIVATION-TRIGGERS] the ONE stealing-message classifier: + // busy-only never; empty sender (shell → owner) never; a different id + // steals (Wake); the same id never steals (SiblingMessage). The + // admission points only feed its answer. + #[test] + fn message_trigger_classifies_by_window_and_sender_id() { + use spt_store::spool::{WINDOW_ACTIVE_ONLY, WINDOW_DEFAULT, WINDOW_IDLE_ONLY}; + assert_eq!(message_trigger("ling", "doyle", WINDOW_DEFAULT), Some(RestEvent::Wake)); + assert_eq!(message_trigger("ling", "doyle", WINDOW_IDLE_ONLY), Some(RestEvent::Wake)); + assert_eq!(message_trigger("ling", "doyle", WINDOW_ACTIVE_ONLY), None, "busy-only"); + assert_eq!(message_trigger("ling", "", WINDOW_DEFAULT), None, "shell frames"); assert_eq!( - transition(Dormant, None, AutoSuspendTick { now_ms: u64::MAX }, knob), - None + message_trigger("ling", "ling", WINDOW_DEFAULT), + Some(RestEvent::SiblingMessage), + "same id never steals" ); + assert_eq!(message_trigger("ling", "ling", WINDOW_ACTIVE_ONLY), None); + } - // The tick moves nothing else. - assert_eq!( - transition(Active, None, AutoSuspendTick { now_ms: u64::MAX }, knob), - None - ); + // [unit->REQ-ACTIVATION-COUNTER] the counter: an activation takes the + // highest seen (own or sibling) + 1; the order is (counter, node) with the + // node id breaking a tie; strictly greater outranks (an equal rank is our + // own row). The sibling view reads only OTHER nodes' rows, and an N-1 row + // (no field) reads as 0. + #[test] + fn activation_counter_orders_by_counter_then_node() { + use spt_net::net::registry::{Status, SubnetRegistry}; + assert_eq!(next_activation(0, 0), 1); + assert_eq!(next_activation(4, 9), 10); + assert_eq!(next_activation(9, 4), 10); + assert_eq!(next_activation(u64::MAX, 0), u64::MAX, "saturates"); + + assert!(sibling_outranks((5, "aa"), (4, "zz")), "counter first"); + assert!(sibling_outranks((5, "bb"), (5, "aa")), "tie → node id"); + assert!(!sibling_outranks((5, "aa"), (5, "bb"))); + assert!(!sibling_outranks((5, "aa"), (5, "aa")), "own rank never outranks"); + + let row = |node: &str, status: Status, activation: u64| { + let mut inst: spt_net::net::registry::Instance = serde_json::from_str(&format!( + r#"{{"node":"{node}","status":"Active","epoch":1}}"# + )) + .expect("an N-1 row without the field parses"); + assert_eq!(inst.activation, 0, "absent field reads as 0"); + inst.status = status; + inst.activation = activation; + inst + }; + let mut reg = SubnetRegistry::default(); + reg.merge_instance("ling", row("own", Status::Active, 50)); + reg.merge_instance("ling", row("n2", Status::Dormant, 7)); + reg.merge_instance("ling", row("n3", Status::Active, 3)); + let regs = std::collections::BTreeMap::from([("home".to_string(), reg)]); assert_eq!( - transition(Suspended, None, AutoSuspendTick { now_ms: u64::MAX }, knob), - None + sibling_view_from(®s, "ling", "own"), + SiblingView { active_sibling: true, max_activation: 7 }, + "own row excluded; max over siblings; any active sibling counts" ); + assert_eq!(sibling_view_from(®s, "other", "own"), SiblingView::default()); } - // [unit->REQ-INST-3] the knob chain: global default OFF, node enables, - // endpoint overrides the node either way (0 = explicit endpoint OFF). + // [unit->REQ-AUTO-SUSPEND-MODES] the three modes and the UNBROKEN + // condition. disable: never. dormant-enable: dormant + uncontrolled + + // idle, timed from the LATEST of dormancy onset, uncontrolled-since and + // idle-since. enable: the active instance too (vacating active). Any leg + // broken ⇒ no suspend; an unknown idle instant fails warm. #[test] - fn knob_chain_resolves_global_node_endpoint() { - // Global default OFF — nothing set anywhere. - assert_eq!(effective_auto_suspend(None, None), None); - // Node enables. - assert_eq!(effective_auto_suspend(Some(60_000), None), Some(60_000)); - // Endpoint overrides the node's value. + fn auto_suspend_modes_and_the_unbroken_condition() { + use spt_store::autosuspend::{AutoSuspend, AutoSuspendMode::*}; + use RestState::*; + let tick = |now_ms, facts| RestEvent::AutoSuspendTick { now_ms, facts }; + let facts = |controlled, csince, idle, isince| SuspendFacts { + controlled, + controlled_since_ms: csince, + idle, + idle_since_ms: isince, + }; + let de = AutoSuspend { mode: DormantEnable, after_ms: 500 }; + let en = AutoSuspend { mode: Enable, after_ms: 500 }; + let off = AutoSuspend { mode: Disable, after_ms: 500 }; + let quiet = facts(false, Some(100), true, Some(200)); + + // dormant-enable: anchor = max(dormant 1000, uncontrolled 100, idle 200). + assert_eq!(transition(Dormant, Some(1_000), tick(1_499, quiet), de, true), None); + assert_eq!(transition(Dormant, Some(1_000), tick(1_500, quiet), de, true), Some(Suspended)); + // The latest leg sets the anchor: idle only since 1200. + let late_idle = facts(false, Some(100), true, Some(1_200)); + assert_eq!(transition(Dormant, Some(1_000), tick(1_699, late_idle), de, true), None); assert_eq!( - effective_auto_suspend(Some(60_000), Some(5_000)), - Some(5_000) + transition(Dormant, Some(1_000), tick(1_700, late_idle), de, true), + Some(Suspended) ); - // Endpoint 0 = explicit OFF even when the node enables. - assert_eq!(effective_auto_suspend(Some(60_000), Some(0)), None); - // Node 0 behaves as OFF too (symmetric). - assert_eq!(effective_auto_suspend(Some(0), None), None); - // Endpoint alone can enable. - assert_eq!(effective_auto_suspend(None, Some(9)), Some(9)); + // Uncontrolled only since 1300 (a detach) restarts the timer. + let late_detach = facts(false, Some(1_300), true, Some(200)); + assert_eq!(transition(Dormant, Some(1_000), tick(1_799, late_detach), de, true), None); + // Any leg broken ⇒ never. + let driven = facts(true, Some(100), true, Some(200)); + let busy = facts(false, Some(100), false, None); + assert_eq!(transition(Dormant, Some(0), tick(u64::MAX, driven), de, true), None); + assert_eq!(transition(Dormant, Some(0), tick(u64::MAX, busy), de, true), None); + // Unknown idle instant / missing dormancy anchor ⇒ fail warm. + let unknown = facts(false, None, true, None); + assert_eq!(transition(Dormant, Some(0), tick(u64::MAX, unknown), de, true), None); + assert_eq!(transition(Dormant, None, tick(u64::MAX, quiet), de, true), None); + // A pre-#348 record (no controlled instant) contributes no anchor. + let legacy = facts(false, None, true, Some(200)); + assert_eq!(transition(Dormant, Some(0), tick(700, legacy), de, true), Some(Suspended)); + // dormant-enable never touches the active instance. + assert_eq!(transition(Active, None, tick(u64::MAX, quiet), de, false), None); + + // enable: the active instance too, from the same unbroken condition. + assert_eq!(transition(Active, None, tick(699, quiet), en, false), None); + assert_eq!(transition(Active, None, tick(700, quiet), en, false), Some(Suspended)); + assert_eq!(transition(Dormant, Some(0), tick(700, quiet), en, true), Some(Suspended)); + // disable: never. + assert_eq!(transition(Dormant, Some(0), tick(u64::MAX, quiet), off, true), None); + assert_eq!(transition(Active, None, tick(u64::MAX, quiet), off, false), None); + // Suspended: the tick moves nothing. + assert_eq!(transition(Suspended, None, tick(u64::MAX, quiet), en, false), None); } // [unit->REQ-INST-4] the transition echo fires EXACTLY ONCE per @@ -823,8 +1101,9 @@ mod tests { // Recordless live perch starts Active: detach = the rest edge. let report = apply_event( d.path(), - RestEvent::Detach, + RestEvent::SiblingActivated, None, + SiblingView::default(), 1_000, || { echoes += 1; @@ -852,8 +1131,9 @@ mod tests { // Replay: no edge, no second echo — idempotent by the pure table. let replay = apply_event( d.path(), - RestEvent::Detach, + RestEvent::SiblingActivated, None, + SiblingView::default(), 1_100, || { echoes += 1; @@ -871,6 +1151,7 @@ mod tests { d.path(), RestEvent::Suspend, None, + SiblingView::default(), 1_200, || { echoes += 1; @@ -889,6 +1170,7 @@ mod tests { d.path(), RestEvent::Wake, None, + SiblingView::default(), 1_300, || { echoes += 1; @@ -968,6 +1250,7 @@ mod tests { d.path(), RestEvent::Wake, None, + SiblingView::default(), 1_000, || Ok(()), || woke = true, @@ -1009,7 +1292,7 @@ mod tests { // WITHOUT the hint: perch-derived cold ⇒ Suspended ⇒ Suspend NO_EDGEs