diff --git a/agent/operational_state.py b/agent/operational_state.py new file mode 100644 index 000000000000..d4fa5dca697a --- /dev/null +++ b/agent/operational_state.py @@ -0,0 +1,347 @@ +"""R4.1 §9.1 operational state machine — enums + transition table. + +**KR-P2-I-skeleton bucket — code-only, no emit, no wire-in.** Chain-event +emission on transition lands in KR-P2-I-integration once substrate-round +Bucket C adds the operational-state event-type literals +(``kora.boot.ready``, ``kora.boot.failed``, etc.) to the +``event_log_event_type_check`` constraint set. The agent-loop integration +also waits on KR-P2-H (boot gates module). + +Why ship the skeleton now: CC#2's Operational State admin panel imports +:class:`PrimaryState`, :class:`DegradationReason`, and +:class:`ClaimPermission` for type safety. Decoupling that frontend work +from the substrate-round shortens the critical path. + +# What's here + +- :class:`PrimaryState` — 5-member primary-state enum + (``BOOTING``/``READY``/``ACTIVE``/``PAUSED``/``STOPPED``). +- :class:`DegradationReason` — 8-member reason enum; multiple may be + active simultaneously (set membership in + :attr:`OperationalState.degradation_reasons`). +- :class:`ClaimPermission` — 3-member permission enum + (``none``/``critical_only``/``normal``). +- :class:`OperationalState` — frozen dataclass snapshot of the runtime + posture (primary_state + degradation_reasons + claim_permission). +- :class:`StateTransition` — frozen dataclass for one transition-table + row. +- :data:`TRANSITION_TABLE` — tuple of allowed transitions per R4.1 §9.1, + with the doc's ``any → X`` shorthand expanded into one row per + concrete ``from_state``. +- :func:`is_valid_transition`, :func:`transitions_from`, + :func:`transitions_to` — read-only query helpers. + +# DEGRADED is not a primary_state + +Per R4.1 §9.1: *"DEGRADED is the presence of ``degradation_reasons``, +not a ``primary_state``."* It is modeled here as the derived flag +:meth:`OperationalState.is_degraded`, never as an enum member. + +# What's deliberately NOT here + +- No append-event MCP calls (emit on transition lands in + KR-P2-I-integration after substrate-round Bucket C). +- No agent-loop wire-in (also KR-P2-I-integration). +- No startup hooks anywhere (``gateway/run.py``, ``kora_cli/__main__.py``, + etc.). +- No IsoKron memory-provider integration or actor-loop coupling. +- No ``OperationalStateManager`` singleton / service-locator. +- No persistence — in-memory only. + +Enum string values are part of the public contract: CC#2's admin panel +and the future substrate-side chain-event payloads consume them +verbatim. Treat ``Enum.value`` strings as load-bearing wire format. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import FrozenSet + + +class PrimaryState(Enum): + """R4.1 §9.1 primary-state alphabet. + + Five members; values are the lower-case strings used in the chain + event payloads and the admin-panel API. + """ + + BOOTING = "booting" + READY = "ready" + ACTIVE = "active" + PAUSED = "paused" + STOPPED = "stopped" + + +class DegradationReason(Enum): + """Reasons the runtime is degraded. + + Multiple may be active simultaneously — modeled as set membership in + :attr:`OperationalState.degradation_reasons`. Per R4.1 §9.1 the + ``DEGRADED`` flag is the *presence* of any reason, not its own + primary_state. + """ + + COST = "cost" + AUTH = "auth" + DISPATCH = "dispatch" + SUBSTRATE = "substrate" + MIGRATION = "migration" + OPERATOR = "operator" + TOKEN_EXPIRING = "token_expiring" + RETRY_CEILING = "retry_ceiling" + + +class ClaimPermission(Enum): + """Whether the consumer loop may mint new claims. + + STOP-KORA L1 (per R4.1 §9.1) flips this to ``NONE`` while a held + claim is allowed to finish; DEGRADED-flagged states can flip to + ``CRITICAL_ONLY`` per the dispatch/substrate/auth check rules in + §9.8. + """ + + NONE = "none" + CRITICAL_ONLY = "critical_only" + NORMAL = "normal" + + +@dataclass(frozen=True, slots=True) +class OperationalState: + """Snapshot of Kora's runtime operational state. + + Immutable; use the :meth:`with_*` factory methods to produce derived + states. No emit logic here — chain event emission on transition + lives in the KR-P2-I-integration follow-on bucket. + """ + + primary_state: PrimaryState + degradation_reasons: FrozenSet[DegradationReason] = field(default_factory=frozenset) + claim_permission: ClaimPermission = ClaimPermission.NORMAL + + def is_degraded(self) -> bool: + """True iff at least one degradation reason is active. + + Per R4.1 §9.1: DEGRADED is *the presence of* + ``degradation_reasons``, not a primary_state. + """ + return bool(self.degradation_reasons) + + def with_added_reason( + self, reason: DegradationReason + ) -> "OperationalState": + """Return a derived state with ``reason`` added to the set.""" + return OperationalState( + primary_state=self.primary_state, + degradation_reasons=self.degradation_reasons | {reason}, + claim_permission=self.claim_permission, + ) + + def with_removed_reason( + self, reason: DegradationReason + ) -> "OperationalState": + """Return a derived state with ``reason`` removed from the set. + + No-op (returns an equal-shape instance) if ``reason`` is not + currently present — matches the set-difference semantics. + """ + return OperationalState( + primary_state=self.primary_state, + degradation_reasons=self.degradation_reasons - {reason}, + claim_permission=self.claim_permission, + ) + + def with_primary_state( + self, new_state: PrimaryState + ) -> "OperationalState": + """Return a derived state with the primary_state replaced.""" + return OperationalState( + primary_state=new_state, + degradation_reasons=self.degradation_reasons, + claim_permission=self.claim_permission, + ) + + def with_claim_permission( + self, new_permission: ClaimPermission + ) -> "OperationalState": + """Return a derived state with the claim_permission replaced.""" + return OperationalState( + primary_state=self.primary_state, + degradation_reasons=self.degradation_reasons, + claim_permission=new_permission, + ) + + +@dataclass(frozen=True, slots=True) +class StateTransition: + """One row of the R4.1 §9.1 transition table. + + ``trigger`` / ``guard`` / ``recovery_owner`` are human-readable + strings matching the R4.1 column wording. They are not parsed — + they document operator-facing semantics and surface in admin-panel + "why this transition" tooltips. An empty string means the R4.1 + cell was a dash (no guard / no specific recovery owner). + """ + + from_state: PrimaryState + to_state: PrimaryState + trigger: str + guard: str + recovery_owner: str + + +# R4.1 §9.1 expanded transition table. The R4.1 doc uses ``any → PAUSED`` +# and ``any → STOPPED`` shorthand; we expand each into one row per +# concrete ``from_state`` so :func:`is_valid_transition` can do a single +# scan. PAUSED → PAUSED via STOP-KORA L1–3 is intentionally omitted +# (PAUSED is already paused — no observable change). +# +# Duplicate ``(from_state, to_state)`` pairs are expected and represent +# the same arrow reached through different triggers (e.g. +# BOOTING → STOPPED via "invariant gate failure" vs "STOP-KORA L4/L5"; +# BOOTING → PAUSED via "gate 3b epoch mismatch" vs "STOP-KORA L1–3"). +TRANSITION_TABLE: tuple[StateTransition, ...] = ( + # ── Cold boot (§9.2) ──────────────────────────────────────────── + StateTransition( + from_state=PrimaryState.BOOTING, + to_state=PrimaryState.READY, + trigger="all §9.2 gates pass", + guard="", + recovery_owner="", + ), + StateTransition( + from_state=PrimaryState.BOOTING, + to_state=PrimaryState.BOOTING, + trigger="transient gate failure", + guard="retry budget not exhausted", + recovery_owner="self (backoff)", + ), + StateTransition( + from_state=PrimaryState.BOOTING, + to_state=PrimaryState.STOPPED, + trigger="invariant gate failure, or retry budget exhausted", + guard="", + recovery_owner="operator", + ), + StateTransition( + from_state=PrimaryState.BOOTING, + to_state=PrimaryState.PAUSED, + trigger="gate 3b epoch mismatch (§9.8)", + guard="", + recovery_owner="operator", + ), + # ── READY ↔ ACTIVE (claim acquire/release) ─────────────────────── + StateTransition( + from_state=PrimaryState.READY, + to_state=PrimaryState.ACTIVE, + trigger="claim acquired", + guard="", + recovery_owner="self", + ), + StateTransition( + from_state=PrimaryState.ACTIVE, + to_state=PrimaryState.READY, + trigger="claim released", + guard="", + recovery_owner="self", + ), + # ── any → PAUSED (STOP-KORA L1–3, cost 100%, operator) ────────── + StateTransition( + from_state=PrimaryState.READY, + to_state=PrimaryState.PAUSED, + trigger="STOP-KORA L1–3, cost 100%, operator", + guard="", + recovery_owner="per reason", + ), + StateTransition( + from_state=PrimaryState.ACTIVE, + to_state=PrimaryState.PAUSED, + trigger="STOP-KORA L1–3, cost 100%, operator", + guard="", + recovery_owner="per reason", + ), + StateTransition( + from_state=PrimaryState.BOOTING, + to_state=PrimaryState.PAUSED, + trigger="STOP-KORA L1–3, cost 100%, operator", + guard="", + recovery_owner="per reason", + ), + # ── PAUSED → READY (cost-clear / operator-clear) ──────────────── + StateTransition( + from_state=PrimaryState.PAUSED, + to_state=PrimaryState.READY, + trigger="monthly credit refresh confirmed", + guard=( + "reconciled spend < threshold; " + "claim already safe-released (§9.6)" + ), + recovery_owner="self (ramped, §9.6)", + ), + StateTransition( + from_state=PrimaryState.PAUSED, + to_state=PrimaryState.READY, + trigger="operator clears via kora_control reset", + guard="", + recovery_owner="operator", + ), + # ── any → STOPPED (STOP-KORA L4/L5) ───────────────────────────── + StateTransition( + from_state=PrimaryState.READY, + to_state=PrimaryState.STOPPED, + trigger="STOP-KORA L4/L5", + guard="", + recovery_owner="operator (new boot)", + ), + StateTransition( + from_state=PrimaryState.ACTIVE, + to_state=PrimaryState.STOPPED, + trigger="STOP-KORA L4/L5", + guard="", + recovery_owner="operator (new boot)", + ), + StateTransition( + from_state=PrimaryState.PAUSED, + to_state=PrimaryState.STOPPED, + trigger="STOP-KORA L4/L5", + guard="", + recovery_owner="operator (new boot)", + ), + StateTransition( + from_state=PrimaryState.BOOTING, + to_state=PrimaryState.STOPPED, + trigger="STOP-KORA L4/L5", + guard="", + recovery_owner="operator (new boot)", + ), +) + + +def is_valid_transition( + from_state: PrimaryState, to_state: PrimaryState +) -> bool: + """Return ``True`` iff at least one :data:`TRANSITION_TABLE` row + matches ``(from_state, to_state)``. + + Multiple rows may match the same pair (same arrow, different + triggers); a single match suffices. + """ + return any( + row.from_state is from_state and row.to_state is to_state + for row in TRANSITION_TABLE + ) + + +def transitions_from( + state: PrimaryState, +) -> tuple[StateTransition, ...]: + """All :data:`TRANSITION_TABLE` rows originating at ``state``.""" + return tuple(row for row in TRANSITION_TABLE if row.from_state is state) + + +def transitions_to( + state: PrimaryState, +) -> tuple[StateTransition, ...]: + """All :data:`TRANSITION_TABLE` rows arriving at ``state``.""" + return tuple(row for row in TRANSITION_TABLE if row.to_state is state) diff --git a/tests/test_operational_state.py b/tests/test_operational_state.py new file mode 100644 index 000000000000..218c06b5eea7 --- /dev/null +++ b/tests/test_operational_state.py @@ -0,0 +1,319 @@ +"""Unit tests for ``agent/operational_state.py`` (KR-P2-I-skeleton). + +Covers: + - Enum string values match R4.1 §9.1 verbatim + - Enum cardinality (5 PrimaryState / 8 DegradationReason / 3 ClaimPermission) + - OperationalState immutability + ``with_*`` factory semantics + - ``is_degraded()`` truth table + - Transition-table query helpers + - Enum round-trip via ``.value`` / ``Enum(value)`` +""" + +from __future__ import annotations + +import dataclasses + +import pytest + +from agent.operational_state import ( + ClaimPermission, + DegradationReason, + OperationalState, + PrimaryState, + StateTransition, + TRANSITION_TABLE, + is_valid_transition, + transitions_from, + transitions_to, +) + + +# --------------------------------------------------------------------------- +# Enum value strings (load-bearing wire format) +# --------------------------------------------------------------------------- + + +def test_primary_state_values_match_r41_section_9_1(): + """Five members, lower-case strings — see R4.1 §9.1.""" + assert {m.value for m in PrimaryState} == { + "booting", + "ready", + "active", + "paused", + "stopped", + } + assert len(list(PrimaryState)) == 5 + + +def test_degradation_reason_values_match_r41_section_9_1(): + assert {m.value for m in DegradationReason} == { + "cost", + "auth", + "dispatch", + "substrate", + "migration", + "operator", + "token_expiring", + "retry_ceiling", + } + assert len(list(DegradationReason)) == 8 + + +def test_claim_permission_values_match_r41_section_9_1(): + assert {m.value for m in ClaimPermission} == { + "none", + "critical_only", + "normal", + } + assert len(list(ClaimPermission)) == 3 + + +# --------------------------------------------------------------------------- +# OperationalState shape + immutability +# --------------------------------------------------------------------------- + + +def test_operational_state_is_frozen(): + state = OperationalState(primary_state=PrimaryState.BOOTING) + with pytest.raises(dataclasses.FrozenInstanceError): + state.primary_state = PrimaryState.READY # type: ignore[misc] + + +def test_operational_state_defaults(): + state = OperationalState(primary_state=PrimaryState.READY) + assert state.primary_state is PrimaryState.READY + assert state.degradation_reasons == frozenset() + assert state.claim_permission is ClaimPermission.NORMAL + + +def test_is_degraded_truth_table(): + assert ( + OperationalState(primary_state=PrimaryState.READY).is_degraded() + is False + ) + assert ( + OperationalState( + primary_state=PrimaryState.READY, + degradation_reasons=frozenset({DegradationReason.COST}), + ).is_degraded() + is True + ) + assert ( + OperationalState( + primary_state=PrimaryState.ACTIVE, + degradation_reasons=frozenset( + {DegradationReason.AUTH, DegradationReason.DISPATCH} + ), + ).is_degraded() + is True + ) + + +# --------------------------------------------------------------------------- +# with_* factory methods — produce new instances; original untouched +# --------------------------------------------------------------------------- + + +def test_with_added_reason_returns_new_instance_with_reason_added(): + original = OperationalState(primary_state=PrimaryState.READY) + derived = original.with_added_reason(DegradationReason.COST) + assert derived is not original + assert derived.degradation_reasons == frozenset({DegradationReason.COST}) + # Original unchanged + assert original.degradation_reasons == frozenset() + + +def test_with_added_reason_is_idempotent_for_already_present(): + original = OperationalState( + primary_state=PrimaryState.READY, + degradation_reasons=frozenset({DegradationReason.COST}), + ) + derived = original.with_added_reason(DegradationReason.COST) + assert derived.degradation_reasons == frozenset({DegradationReason.COST}) + + +def test_with_removed_reason_returns_new_instance_with_reason_removed(): + original = OperationalState( + primary_state=PrimaryState.READY, + degradation_reasons=frozenset( + {DegradationReason.COST, DegradationReason.AUTH} + ), + ) + derived = original.with_removed_reason(DegradationReason.COST) + assert derived is not original + assert derived.degradation_reasons == frozenset({DegradationReason.AUTH}) + # Original unchanged + assert original.degradation_reasons == frozenset( + {DegradationReason.COST, DegradationReason.AUTH} + ) + + +def test_with_removed_reason_is_no_op_when_absent(): + original = OperationalState( + primary_state=PrimaryState.READY, + degradation_reasons=frozenset({DegradationReason.AUTH}), + ) + derived = original.with_removed_reason(DegradationReason.COST) + assert derived.degradation_reasons == frozenset({DegradationReason.AUTH}) + + +def test_with_primary_state_replaces_primary_state_only(): + original = OperationalState( + primary_state=PrimaryState.READY, + degradation_reasons=frozenset({DegradationReason.COST}), + claim_permission=ClaimPermission.CRITICAL_ONLY, + ) + derived = original.with_primary_state(PrimaryState.ACTIVE) + assert derived.primary_state is PrimaryState.ACTIVE + # Other fields preserved + assert derived.degradation_reasons == frozenset({DegradationReason.COST}) + assert derived.claim_permission is ClaimPermission.CRITICAL_ONLY + + +def test_with_claim_permission_replaces_claim_permission_only(): + original = OperationalState( + primary_state=PrimaryState.ACTIVE, + degradation_reasons=frozenset({DegradationReason.COST}), + ) + derived = original.with_claim_permission(ClaimPermission.NONE) + assert derived.claim_permission is ClaimPermission.NONE + assert derived.primary_state is PrimaryState.ACTIVE + assert derived.degradation_reasons == frozenset({DegradationReason.COST}) + + +# --------------------------------------------------------------------------- +# Transition-table query helpers +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "from_state,to_state", + [ + (PrimaryState.BOOTING, PrimaryState.READY), + (PrimaryState.BOOTING, PrimaryState.BOOTING), # self-loop retry + (PrimaryState.BOOTING, PrimaryState.STOPPED), + (PrimaryState.BOOTING, PrimaryState.PAUSED), + (PrimaryState.READY, PrimaryState.ACTIVE), + (PrimaryState.ACTIVE, PrimaryState.READY), + (PrimaryState.READY, PrimaryState.PAUSED), + (PrimaryState.ACTIVE, PrimaryState.PAUSED), + (PrimaryState.PAUSED, PrimaryState.READY), + (PrimaryState.READY, PrimaryState.STOPPED), + (PrimaryState.ACTIVE, PrimaryState.STOPPED), + (PrimaryState.PAUSED, PrimaryState.STOPPED), + ], +) +def test_is_valid_transition_returns_true_for_known_transitions( + from_state, to_state +): + assert is_valid_transition(from_state, to_state) is True + + +@pytest.mark.parametrize( + "from_state,to_state", + [ + # STOPPED is terminal — operator must re-boot (no STOPPED→X rows) + (PrimaryState.STOPPED, PrimaryState.ACTIVE), + (PrimaryState.STOPPED, PrimaryState.READY), + (PrimaryState.STOPPED, PrimaryState.BOOTING), + (PrimaryState.STOPPED, PrimaryState.PAUSED), + # PAUSED can't directly skip to ACTIVE — must go via READY + (PrimaryState.PAUSED, PrimaryState.ACTIVE), + # READY can't self-loop + (PrimaryState.READY, PrimaryState.READY), + # ACTIVE can't loop or go to BOOTING + (PrimaryState.ACTIVE, PrimaryState.ACTIVE), + (PrimaryState.ACTIVE, PrimaryState.BOOTING), + # PAUSED can't go to BOOTING + (PrimaryState.PAUSED, PrimaryState.BOOTING), + # READY can't go directly to BOOTING + (PrimaryState.READY, PrimaryState.BOOTING), + ], +) +def test_is_valid_transition_returns_false_for_unknown_transitions( + from_state, to_state +): + assert is_valid_transition(from_state, to_state) is False + + +def test_transitions_from_booting_yields_all_booting_origin_rows(): + """BOOTING-origin rows (expanded ``any → X`` semantics): + 1. BOOTING → READY (gates pass) + 2. BOOTING → BOOTING (retry) + 3. BOOTING → STOPPED (invariant fail / retry exhausted) + 4. BOOTING → PAUSED (gate 3b epoch mismatch) + 5. BOOTING → PAUSED (STOP-KORA L1–3) + 6. BOOTING → STOPPED (STOP-KORA L4/L5) + + (4) and (5) are distinct same-arrow rows differing only by trigger; + similarly (3) and (6). The bucket spec's "4 BOOTING-origin" count + aligns with the R4.1 unexpanded shorthand; the expanded table has + 6 rows because ``any → PAUSED`` and ``any → STOPPED`` each + contribute a BOOTING row. + """ + booting_rows = transitions_from(PrimaryState.BOOTING) + assert len(booting_rows) == 6 + assert all(row.from_state is PrimaryState.BOOTING for row in booting_rows) + to_states = {row.to_state for row in booting_rows} + assert to_states == { + PrimaryState.READY, + PrimaryState.BOOTING, + PrimaryState.STOPPED, + PrimaryState.PAUSED, + } + + +def test_transitions_to_stopped_yields_all_stopped_arrival_rows(): + """STOPPED-arrival rows (expanded ``any → STOPPED`` semantics): + 1. BOOTING → STOPPED (invariant fail / retry exhausted) + 2. READY → STOPPED (STOP-KORA L4/L5) + 3. ACTIVE → STOPPED (STOP-KORA L4/L5) + 4. PAUSED → STOPPED (STOP-KORA L4/L5) + 5. BOOTING → STOPPED (STOP-KORA L4/L5) + + BOOTING appears twice (rows 1 and 5) — same arrow, different + triggers. The bucket spec's "4 STOPPED-arrival" count aligns with + the R4.1 unexpanded shorthand. + """ + stopped_rows = transitions_to(PrimaryState.STOPPED) + assert len(stopped_rows) == 5 + assert all(row.to_state is PrimaryState.STOPPED for row in stopped_rows) + from_states = {row.from_state for row in stopped_rows} + assert from_states == { + PrimaryState.BOOTING, + PrimaryState.READY, + PrimaryState.ACTIVE, + PrimaryState.PAUSED, + } + + +def test_transitions_from_stopped_is_empty_terminal(): + """STOPPED is terminal — operator must initiate a new boot.""" + assert transitions_from(PrimaryState.STOPPED) == () + + +def test_transition_table_rows_are_state_transition_instances(): + """Defensive: catches accidental tuple/dict drift in the table.""" + assert TRANSITION_TABLE + for row in TRANSITION_TABLE: + assert isinstance(row, StateTransition) + + +# --------------------------------------------------------------------------- +# Round-trip — every enum value can be serialized and re-parsed +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("member", list(PrimaryState)) +def test_primary_state_round_trips_through_value_string(member): + assert PrimaryState(member.value) is member + + +@pytest.mark.parametrize("member", list(DegradationReason)) +def test_degradation_reason_round_trips_through_value_string(member): + assert DegradationReason(member.value) is member + + +@pytest.mark.parametrize("member", list(ClaimPermission)) +def test_claim_permission_round_trips_through_value_string(member): + assert ClaimPermission(member.value) is member