flybrain/services/flysim/crates/fly-session/src/phase.rs
acamilo 5defb6766d feat(session): fly-session, the SESSION-01 synthetic sequential transaction over flybus
The lockstep coordinator, its phase machine and a synthetic composition, as a new
workspace member. Every worker method is a Flybus RPC to an incarnation-pinned
service; the domain request ids and result caches of ipc-v1 section 5 sit in front of
every mutation.

- coordinator: the step-v1 section 3 transaction in order -- prepare every agent
  concurrently, run one executor per agent in sorted agent-id order, assemble all port
  controls in descriptor order, send exactly one Environment.Advance, evaluate the task
  once, commit every agent concurrently, and move the committed boundary only when all
  of them succeeded. Sequential, concurrent and reversed dispatch are selectable and
  must agree.
- phase: the section 2 machine as an explicit edge table, Paused and Failed included,
  with a committed-boundary predicate that gates pauses, captures and publication.
- clock: checked rational accumulation. A 60 Hz world with a 1 ms model tick runs
  16, 17, 17 ticks and comes back to a remainder of exactly zero.
- dedup: operation keys, the cached reply with its own artifact holds, CONFLICT,
  IN_PROGRESS, RESULT_EXPIRED, STALE_STEP, the lifecycle bound and Worker.Acknowledge.
- worker: the dispatch shell. Admission order and the result cache live here; the
  endpoint mutex is the worker's simulation lock, so one mutation runs at a time while
  Worker.Status answers from a separate cell.
- agent, environment, task: a fake model with an explicit seed and a mutation counter
  the tests read, a fixed readout stub, a counter arena that seals one immutable frame
  per boundary, a deterministic task and the identity executor.
- fly_session_types: the CONTRACT-01 domain types as a local stand-in, reconciled with
  the shared crate in a following commit.

Tests run twice, over the in-memory transport and over a Unix socket, through the same
router: the SESSION-01 acceptance bullets and every section 4 failure-injection row
that applies to this slice.
2026-09-22 11:46:57 +00:00

223 lines
7.9 KiB
Rust

//! The session state machine of `step-v1` section 2, as an explicit edge table.
//!
//! ```text
//! Starting -> Ready(k) -> Preparing(k) -> Applying(k) -> Observing(k+1)
//! ^ |
//! +---------------- Ready(k+1) <- Committing(k)
//!
//! Ready(k) -> Paused(k) -> Ready(k)
//! Ready(k) / Paused(k) -> Capturing(k) -> same boundary
//! any unresolved partial failure -> Failed -> Restoring(new epoch) -> Paused(k)
//! terminal episode -> Paused(k) -> Resetting(new epoch) -> Ready(0)
//! ```
//!
//! A transition the table does not list is a bug, not a recoverable condition, so it returns
//! INVALID_PHASE rather than being silently applied.
use crate::types::{DomainError, ErrorCode};
/// Where the session is. The number is the committed boundary the phase belongs to.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Phase {
Starting,
Ready(u64),
Preparing(u64),
Applying(u64),
/// `Observing(k+1)`: the world has reached `k+1` but nothing is committed yet.
Observing(u64),
/// `Committing(k)`: completing the transition `k -> k+1`.
Committing(u64),
Paused(u64),
Capturing(u64),
Failed,
/// `Restoring(k)`: installing a coherent checkpoint of boundary `k` under a new epoch.
Restoring(u64),
/// `Resetting(k)`: leaving boundary `k` for a new epoch and episode at step 0.
Resetting(u64),
}
impl Phase {
pub fn label(&self) -> String {
match self {
Phase::Starting => "Starting".to_owned(),
Phase::Ready(k) => format!("Ready({k})"),
Phase::Preparing(k) => format!("Preparing({k})"),
Phase::Applying(k) => format!("Applying({k})"),
Phase::Observing(k) => format!("Observing({k})"),
Phase::Committing(k) => format!("Committing({k})"),
Phase::Paused(k) => format!("Paused({k})"),
Phase::Capturing(k) => format!("Capturing({k})"),
Phase::Failed => "Failed".to_owned(),
Phase::Restoring(k) => format!("Restoring({k})"),
Phase::Resetting(k) => format!("Resetting({k})"),
}
}
/// The committed boundary, where the phase has one. `Observing(k+1)` does not: the world
/// has moved but nothing is committed, so the committed boundary is still `k`.
pub fn committed_boundary(&self) -> Option<u64> {
match self {
Phase::Ready(k) | Phase::Paused(k) | Phase::Capturing(k) => Some(*k),
_ => None,
}
}
/// Only a committed boundary is eligible for a checkpoint or a normal pause.
pub fn is_committed_boundary(&self) -> bool {
matches!(self, Phase::Ready(_) | Phase::Paused(_))
}
}
/// The phase, plus the edge check that guards every change to it.
#[derive(Clone, Debug)]
pub struct PhaseMachine {
phase: Phase,
/// Where a `Capturing(k)` must return to.
capture_origin: Option<Phase>,
}
impl Default for PhaseMachine {
fn default() -> PhaseMachine {
PhaseMachine::new()
}
}
impl PhaseMachine {
pub fn new() -> PhaseMachine {
PhaseMachine { phase: Phase::Starting, capture_origin: None }
}
pub fn phase(&self) -> Phase {
self.phase
}
/// True when `next` is an edge of the section 2 machine.
pub fn allows(&self, next: Phase) -> bool {
use Phase::*;
// Any unresolved partial failure fails the epoch, from wherever the session was.
if next == Failed {
return self.phase != Failed;
}
match (self.phase, next) {
(Starting, Ready(0)) => true,
(Ready(k), Preparing(j)) => k == j,
(Preparing(k), Applying(j)) => k == j,
(Applying(k), Observing(j)) => j == k + 1,
(Observing(j), Committing(k)) => j == k + 1,
(Committing(k), Ready(j)) => j == k + 1,
(Ready(k), Paused(j)) => k == j,
(Paused(k), Ready(j)) => k == j,
(Ready(k), Capturing(j)) | (Paused(k), Capturing(j)) => k == j,
// Capturing returns to the boundary it came from, and only to that one.
(Capturing(k), Ready(j)) => k == j && self.capture_origin == Some(Ready(k)),
(Capturing(k), Paused(j)) => k == j && self.capture_origin == Some(Paused(k)),
(Failed, Restoring(_)) => true,
(Restoring(k), Paused(j)) => k == j,
(Paused(k), Resetting(j)) => k == j,
(Resetting(_), Ready(0)) => true,
_ => false,
}
}
/// Applies an edge, reporting the old and new labels for the trace.
pub fn to(&mut self, next: Phase) -> Result<(String, String), DomainError> {
if !self.allows(next) {
return Err(DomainError::before(
ErrorCode::InvalidPhase,
format!("{} cannot move to {}", self.phase.label(), next.label()),
));
}
let from = self.phase.label();
if matches!(next, Phase::Capturing(_)) {
self.capture_origin = Some(self.phase);
} else if !matches!(self.phase, Phase::Capturing(_)) {
self.capture_origin = None;
}
self.phase = next;
Ok((from, next.label()))
}
/// Fails the epoch from wherever the session was.
pub fn fail(&mut self) -> (String, String) {
let from = self.phase.label();
self.phase = Phase::Failed;
self.capture_origin = None;
(from, self.phase.label())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_happy_path_walks_the_section_2_diagram() {
let mut m = PhaseMachine::new();
m.to(Phase::Ready(0)).unwrap();
m.to(Phase::Preparing(0)).unwrap();
m.to(Phase::Applying(0)).unwrap();
m.to(Phase::Observing(1)).unwrap();
m.to(Phase::Committing(0)).unwrap();
m.to(Phase::Ready(1)).unwrap();
assert_eq!(m.phase(), Phase::Ready(1));
}
#[test]
fn a_step_cannot_be_skipped_or_rewound() {
let mut m = PhaseMachine::new();
m.to(Phase::Ready(0)).unwrap();
assert!(m.to(Phase::Preparing(1)).is_err());
m.to(Phase::Preparing(0)).unwrap();
assert!(m.to(Phase::Observing(1)).is_err());
m.to(Phase::Applying(0)).unwrap();
assert!(m.to(Phase::Observing(0)).is_err());
}
#[test]
fn only_a_committed_boundary_pauses_or_captures() {
let mut m = PhaseMachine::new();
m.to(Phase::Ready(0)).unwrap();
m.to(Phase::Preparing(0)).unwrap();
assert!(m.to(Phase::Paused(0)).is_err());
assert!(m.to(Phase::Capturing(0)).is_err());
assert!(!Phase::Preparing(0).is_committed_boundary());
assert!(Phase::Ready(0).is_committed_boundary());
assert!(Phase::Paused(3).is_committed_boundary());
}
#[test]
fn a_capture_returns_to_the_boundary_it_came_from() {
let mut m = PhaseMachine::new();
m.to(Phase::Ready(0)).unwrap();
m.to(Phase::Paused(0)).unwrap();
m.to(Phase::Capturing(0)).unwrap();
assert!(m.to(Phase::Ready(0)).is_err());
m.to(Phase::Paused(0)).unwrap();
}
#[test]
fn failure_leads_to_restore_and_then_to_a_paused_boundary() {
let mut m = PhaseMachine::new();
m.to(Phase::Ready(0)).unwrap();
m.to(Phase::Preparing(0)).unwrap();
m.fail();
assert_eq!(m.phase(), Phase::Failed);
assert!(m.to(Phase::Ready(0)).is_err());
m.to(Phase::Restoring(0)).unwrap();
m.to(Phase::Paused(0)).unwrap();
}
#[test]
fn an_episode_reset_leaves_a_pause_and_lands_on_step_zero() {
let mut m = PhaseMachine::new();
m.to(Phase::Ready(0)).unwrap();
m.to(Phase::Preparing(0)).unwrap();
m.to(Phase::Applying(0)).unwrap();
m.to(Phase::Observing(1)).unwrap();
m.to(Phase::Committing(0)).unwrap();
m.to(Phase::Ready(1)).unwrap();
m.to(Phase::Paused(1)).unwrap();
m.to(Phase::Resetting(1)).unwrap();
m.to(Phase::Ready(0)).unwrap();
}
}