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
+2
View File
@@ -5,6 +5,7 @@
#include "secsgem/gem/store/control_jobs.hpp"
#include "secsgem/gem/store/equipment_constants.hpp"
#include "secsgem/gem/store/event_reports.hpp"
#include "secsgem/gem/store/exceptions.hpp"
#include "secsgem/gem/store/host_commands.hpp"
#include "secsgem/gem/store/limits.hpp"
#include "secsgem/gem/store/process_jobs.hpp"
@@ -34,6 +35,7 @@ struct EquipmentDataModel {
TraceStore traces;
ProcessJobStore process_jobs;
ControlJobStore control_jobs;
ExceptionStore exceptions;
// Convenience: VID -> value lookup spanning SVIDs and DVIDs.
std::optional<s2::Item> vid_value(uint32_t vid) const {
+110
View File
@@ -0,0 +1,110 @@
#pragma once
#include <cstdint>
#include <functional>
#include <optional>
#include <string>
#include <vector>
#include "secsgem/gem/store/alarms.hpp" // AlarmAck (ACKC5)
// E5 §9 Exception lifecycle.
//
// An *exception* (distinct from a plain *alarm*) is a recoverable
// equipment fault whose handling involves a host/equipment dialogue:
// the equipment Posts an S5F9 with one or more candidate EXRECVRA
// recovery actions; the host picks one via S5F13 to drive equipment
// recovery; equipment Notifies completion via S5F15 (with EXRESULT
// signalling success or failure); the host may abort an in-progress
// recovery via S5F17.
//
// This state machine lives per-EXID inside an `ExceptionStore`; the
// transitions mirror the request/response cadence on the wire so the
// store can be wired into the server's S5F13 / S5F17 / internal
// recovery hooks with a single dispatch through `on_host_command`
// (host-initiated) or `on_internal` (equipment-initiated).
//
// The Posted/Cleared distinction matches E5 §9.3 figure 9 — when an
// exception's underlying condition clears autonomously without ever
// being recovered (rare but legal), the FSM transitions Posted →
// Cleared directly via the `Clear` event.
namespace secsgem::gem {
enum class ExceptionState : uint8_t {
Posted = 0, // S5F9 sent, awaiting host action or autonomous clear
Recovering = 1, // S5F13 accepted; equipment running the recovery
RecoverFailed = 2, // S5F15 reported a failed recovery; retry possible
Cleared = 3, // exception resolved (terminal; store removes the row)
NoState = 255,
};
const char* exception_state_name(ExceptionState s);
std::optional<ExceptionState> parse_exception_state(const std::string& s);
enum class ExceptionEvent {
Created, // synthetic NoState -> Posted (observer signal)
Recover, // host's S5F13 — Posted/RecoverFailed -> Recovering
RecoveryComplete, // internal — Recovering -> Cleared
RecoveryFailed, // internal — Recovering -> RecoverFailed
RecoveryAbort, // host's S5F17 — Recovering -> Posted
Clear, // internal — Posted/RecoverFailed -> Cleared
};
const char* exception_event_name(ExceptionEvent e);
std::optional<ExceptionEvent> parse_exception_event(const std::string& s);
struct ExceptionTransition {
ExceptionState from;
ExceptionEvent on;
std::optional<ExceptionState> to;
std::optional<uint8_t> ack_code; // ACKC5 (AlarmAck) when applicable
};
class ExceptionTransitionTable {
public:
void add(ExceptionTransition row);
const ExceptionTransition* find(ExceptionState from, ExceptionEvent on) const;
std::size_t size() const { return rows_.size(); }
const std::vector<ExceptionTransition>& rows() const { return rows_; }
// The default table matches data/exception_state.yaml (when present);
// kept in code so tests don't depend on the YAML being readable.
static ExceptionTransitionTable default_table();
private:
std::vector<ExceptionTransition> rows_;
};
class ExceptionStateMachine {
public:
using StateChangeHandler =
std::function<void(ExceptionState from, ExceptionState to,
ExceptionEvent trigger)>;
ExceptionStateMachine();
explicit ExceptionStateMachine(ExceptionTransitionTable table,
ExceptionState initial = ExceptionState::Posted);
ExceptionState state() const { return state_; }
void set_state_change_handler(StateChangeHandler h) { on_change_ = std::move(h); }
// Host-initiated event (S5F13 / S5F17). Returns AlarmAck::Accept on a
// valid transition, AlarmAck::Error when no row matches. The default
// table is permissive — every host event either advances state or is
// silently accepted.
AlarmAck on_host_command(ExceptionEvent event);
// Equipment-internal event (RecoveryComplete / RecoveryFailed / Clear).
// Returns true if a row fired.
bool on_internal(ExceptionEvent event);
private:
const ExceptionTransition* fire(ExceptionEvent on);
void transition(ExceptionState next, ExceptionEvent trigger);
ExceptionTransitionTable table_;
ExceptionState state_;
StateChangeHandler on_change_;
};
} // namespace secsgem::gem
+133
View File
@@ -0,0 +1,133 @@
#pragma once
#include <map>
#include <memory>
#include <string>
#include <utility>
#include <vector>
#include "secsgem/gem/exception_state.hpp"
namespace secsgem::gem {
// One Exception record. EXID keys the row; EXTYPE / EXMESSAGE are E5
// §9 metadata; EXRECVRA lists the recovery-action names the host may
// pick from on S5F13. The FSM is heap-allocated so the per-exception
// state-change handler can capture a stable pointer.
struct Exception {
uint32_t exid;
std::string extype;
std::string exmessage;
std::vector<std::string> exrecvra;
std::unique_ptr<ExceptionStateMachine> fsm;
};
class ExceptionStore {
public:
using TransitionTableFactory =
std::function<ExceptionTransitionTable()>;
using StateChangeHandler =
std::function<void(uint32_t exid, ExceptionState from, ExceptionState to,
ExceptionEvent trigger)>;
ExceptionStore()
: factory_([] { return ExceptionTransitionTable::default_table(); }) {}
// Same `this`-capture caveat as ProcessJobStore: keep a stable address.
ExceptionStore(const ExceptionStore&) = delete;
ExceptionStore& operator=(const ExceptionStore&) = delete;
ExceptionStore(ExceptionStore&&) = delete;
ExceptionStore& operator=(ExceptionStore&&) = delete;
void set_table_factory(TransitionTableFactory f) { factory_ = std::move(f); }
void set_state_change_handler(StateChangeHandler h) { on_change_ = std::move(h); }
enum class PostResult {
Posted,
Denied_AlreadyExists,
};
// Equipment posts a new exception (S5F9). Returns Denied_AlreadyExists
// if an entry for `exid` is already live. Fires a synthetic NoState ->
// Posted change so the dispatcher can decide whether to actually emit
// the S5F9 (skip if alerts disabled, etc.).
PostResult post(uint32_t exid, std::string extype, std::string exmessage,
std::vector<std::string> exrecvra) {
if (by_id_.count(exid)) return PostResult::Denied_AlreadyExists;
auto fsm = std::make_unique<ExceptionStateMachine>(factory_(),
ExceptionState::Posted);
fsm->set_state_change_handler(
[this, exid](ExceptionState from, ExceptionState to, ExceptionEvent trig) {
if (on_change_) on_change_(exid, from, to, trig);
});
by_id_.emplace(exid, Exception{exid, std::move(extype), std::move(exmessage),
std::move(exrecvra), std::move(fsm)});
if (on_change_) {
on_change_(exid, ExceptionState::NoState, ExceptionState::Posted,
ExceptionEvent::Created);
}
return PostResult::Posted;
}
bool has(uint32_t exid) const { return by_id_.count(exid) > 0; }
const Exception* get(uint32_t exid) const {
auto it = by_id_.find(exid);
return it == by_id_.end() ? nullptr : &it->second;
}
Exception* get(uint32_t exid) {
auto it = by_id_.find(exid);
return it == by_id_.end() ? nullptr : &it->second;
}
ExceptionState state(uint32_t exid) const {
auto it = by_id_.find(exid);
return it == by_id_.end() ? ExceptionState::NoState : it->second.fsm->state();
}
// S5F13 recovery dispatch. Validates EXRECVRA matches one the post
// advertised; returns AlarmAck::Error on unknown EXID or unknown action.
AlarmAck on_recover(uint32_t exid, const std::string& exrecvra) {
auto* ex = get(exid);
if (!ex) return AlarmAck::Error;
bool known = false;
for (const auto& a : ex->exrecvra) if (a == exrecvra) { known = true; break; }
if (!known) return AlarmAck::Error;
return ex->fsm->on_host_command(ExceptionEvent::Recover);
}
// S5F17 recovery-abort dispatch.
AlarmAck on_recover_abort(uint32_t exid) {
auto* ex = get(exid);
if (!ex) return AlarmAck::Error;
return ex->fsm->on_host_command(ExceptionEvent::RecoveryAbort);
}
// Equipment-internal lifecycle events. Cleared transitions remove the
// entry — exceptions are not retained past clearance, matching the
// E5 §9.3 model where Cleared is the lifecycle's terminus.
bool fire_internal(uint32_t exid, ExceptionEvent event) {
auto* ex = get(exid);
if (!ex) return false;
const bool fired = ex->fsm->on_internal(event);
if (fired && ex->fsm->state() == ExceptionState::Cleared) {
by_id_.erase(exid);
}
return fired;
}
std::size_t size() const { return by_id_.size(); }
std::vector<uint32_t> ids() const {
std::vector<uint32_t> out;
out.reserve(by_id_.size());
for (const auto& kv : by_id_) out.push_back(kv.first);
return out;
}
private:
std::map<uint32_t, Exception> by_id_;
TransitionTableFactory factory_;
StateChangeHandler on_change_;
};
} // namespace secsgem::gem