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:
@@ -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
|
||||
Reference in New Issue
Block a user