C1: ExceptionStateMachine FSM + ExceptionStore

Per-EXID exception lifecycle for E5 §9.  States mirror the wire flow:

  Posted          equipment sent S5F9, awaiting host or autonomous clear
  Recovering      host's S5F13 accepted; equipment running recovery
  RecoverFailed   S5F15 reported a failed result; host may retry
  Cleared         terminal — store removes the row

Events:
  Created          synthetic NoState->Posted observer signal
  Recover          host's S5F13 (Posted/RecoverFailed -> Recovering)
  RecoveryComplete equipment internal (Recovering -> Cleared)
  RecoveryFailed   equipment internal (Recovering -> RecoverFailed)
  RecoveryAbort    host's S5F17 (Recovering -> Posted)
  Clear            equipment internal (Posted/RecoverFailed -> Cleared)

ExceptionStore mirrors ProcessJobStore: per-EXID FSMs heap-allocated via
unique_ptr, non-movable to keep `this`-captures safe, synthetic Created
fires after the row lands so observers can decide whether to emit S5F9
out of band.  on_recover validates EXRECVRA against the candidates the
post advertised.

The store joins EquipmentDataModel alongside process_jobs / control_jobs.
S5F9-F18 server-side dispatch lands in C2.

Tests (12 cases) cover FSM transitions including retry, abort, and
autonomous clear, plus store-level duplicate-rejection, EXRECVRA
validation, and Cleared-removes-the-row semantics.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 23:41:51 +02:00
parent 4fec4297e2
commit a1f7da4a7d
6 changed files with 465 additions and 0 deletions
+114
View File
@@ -0,0 +1,114 @@
#include "secsgem/gem/exception_state.hpp"
namespace secsgem::gem {
const char* exception_state_name(ExceptionState s) {
switch (s) {
case ExceptionState::Posted: return "Posted";
case ExceptionState::Recovering: return "Recovering";
case ExceptionState::RecoverFailed: return "RecoverFailed";
case ExceptionState::Cleared: return "Cleared";
case ExceptionState::NoState: return "NoState";
}
return "?";
}
std::optional<ExceptionState> parse_exception_state(const std::string& s) {
if (s == "Posted") return ExceptionState::Posted;
if (s == "Recovering") return ExceptionState::Recovering;
if (s == "RecoverFailed") return ExceptionState::RecoverFailed;
if (s == "Cleared") return ExceptionState::Cleared;
if (s == "NoState") return ExceptionState::NoState;
return std::nullopt;
}
const char* exception_event_name(ExceptionEvent e) {
switch (e) {
case ExceptionEvent::Created: return "Created";
case ExceptionEvent::Recover: return "Recover";
case ExceptionEvent::RecoveryComplete: return "RecoveryComplete";
case ExceptionEvent::RecoveryFailed: return "RecoveryFailed";
case ExceptionEvent::RecoveryAbort: return "RecoveryAbort";
case ExceptionEvent::Clear: return "Clear";
}
return "?";
}
std::optional<ExceptionEvent> parse_exception_event(const std::string& s) {
// `created` is synthetic — never legal in a table row.
if (s == "recover") return ExceptionEvent::Recover;
if (s == "recovery_complete") return ExceptionEvent::RecoveryComplete;
if (s == "recovery_failed") return ExceptionEvent::RecoveryFailed;
if (s == "recovery_abort") return ExceptionEvent::RecoveryAbort;
if (s == "clear") return ExceptionEvent::Clear;
return std::nullopt;
}
void ExceptionTransitionTable::add(ExceptionTransition row) {
rows_.push_back(row);
}
const ExceptionTransition* ExceptionTransitionTable::find(
ExceptionState from, ExceptionEvent on) const {
for (const auto& r : rows_) {
if (r.from == from && r.on == on) return &r;
}
return nullptr;
}
ExceptionTransitionTable ExceptionTransitionTable::default_table() {
using S = ExceptionState;
using E = ExceptionEvent;
ExceptionTransitionTable t;
// POSTED ----------------------------------------------------------
t.add({S::Posted, E::Recover, S::Recovering, std::nullopt});
t.add({S::Posted, E::Clear, S::Cleared, std::nullopt});
// RECOVERING ------------------------------------------------------
t.add({S::Recovering, E::RecoveryComplete, S::Cleared, std::nullopt});
t.add({S::Recovering, E::RecoveryFailed, S::RecoverFailed, std::nullopt});
t.add({S::Recovering, E::RecoveryAbort, S::Posted, std::nullopt});
// RECOVER-FAILED --------------------------------------------------
t.add({S::RecoverFailed, E::Recover, S::Recovering, std::nullopt});
t.add({S::RecoverFailed, E::Clear, S::Cleared, std::nullopt});
// CLEARED is terminal — the store removes the exception entry.
return t;
}
ExceptionStateMachine::ExceptionStateMachine()
: ExceptionStateMachine(ExceptionTransitionTable::default_table(),
ExceptionState::Posted) {}
ExceptionStateMachine::ExceptionStateMachine(ExceptionTransitionTable table,
ExceptionState initial)
: table_(std::move(table)), state_(initial) {}
void ExceptionStateMachine::transition(ExceptionState next, ExceptionEvent trig) {
if (state_ == next) return;
const ExceptionState prev = state_;
state_ = next;
if (on_change_) on_change_(prev, next, trig);
}
const ExceptionTransition* ExceptionStateMachine::fire(ExceptionEvent on) {
const ExceptionTransition* row = table_.find(state_, on);
if (!row) return nullptr;
if (row->to) transition(*row->to, on);
return row;
}
AlarmAck ExceptionStateMachine::on_host_command(ExceptionEvent event) {
const auto* row = fire(event);
if (!row) return AlarmAck::Error;
if (row->ack_code) return static_cast<AlarmAck>(*row->ack_code);
return AlarmAck::Accept;
}
bool ExceptionStateMachine::on_internal(ExceptionEvent event) {
return fire(event) != nullptr;
}
} // namespace secsgem::gem