feat(gem): multi-observer state-change handlers via HandlerSlot

The single-slot set_*_handler pattern was a structural blocker, hit twice:
the daemon could not observe control-state changes because
register_default_handlers owns the slot, forcing GetControlState to read the
FSM cross-thread (a data race), and blocking WatchHealth and the Subscribe
stream's ControlStateChange variant.

HandlerSlot<Args...> keeps a primary slot with exact legacy semantics
(set_ replaces — one existing test depends on replacement) plus an
append-only observer list (add_) that survives set_ calls. Fire sites are
textually unchanged (operator bool / operator() / assign-from-function).

Applied to ControlStateMachine + ProcessJobStore + ControlJobStore (the
roadmap-critical three; the remaining single-slot classes follow the same
3-line pattern as needed). EquipmentRuntime gains an atomic control-state
mirror registered as an observer — control_state() is now safe from any
thread, retiring the GetControlState race — plus add_control_state_observer
and add_link_observer (selected/closed fan-out), the hooks WatchHealth and
Subscribe need.

Tests: observer ordering, set-replaces-primary-but-observers-survive,
observers-without-primary, PJ-store coexistence, and the runtime scenario
that was previously impossible (mirror + observer + default-handlers set_).
Core 464/464 (2816 assertions), daemon 16/16, live GEM300 demo passes with
single-fire control-state transitions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 18:57:53 +02:00
parent b067a76b80
commit 8a48ffeed4
10 changed files with 192 additions and 7 deletions
+13 -1
View File
@@ -17,6 +17,14 @@ EquipmentRuntime::EquipmentRuntime(const Config& cfg)
descriptor_ = config::load_equipment(cfg.equipment_yaml, *model_);
auto sm_cfg = config::load_control_state(cfg.control_state_yaml);
sm_ = std::make_shared<ControlStateMachine>(sm_cfg.table, sm_cfg.initial);
// Keep an atomic mirror of the control state so control_state() is safe
// from any thread. Registered as an observer (add_), so it survives the
// primary handler register_default_handlers installs later.
control_state_cache_.store(sm_->state(), std::memory_order_relaxed);
sm_->add_state_change_handler(
[this](ControlState, ControlState to, ControlEvent) {
control_state_cache_.store(to, std::memory_order_relaxed);
});
auto pj = config::load_process_job_state(cfg.process_job_yaml);
auto cj = config::load_control_job_state(cfg.control_job_yaml);
@@ -107,10 +115,14 @@ void EquipmentRuntime::clear_alarm(uint32_t alid) {
void EquipmentRuntime::wire_connection() {
server_->on_connection([this](std::shared_ptr<Connection> conn) {
*active_conn_ = conn;
conn->set_closed_handler([this](const std::string&) { active_conn_->reset(); });
conn->set_closed_handler([this](const std::string&) {
active_conn_->reset();
for (const auto& o : link_observers_) o(false);
});
conn->set_selected_handler([this]() {
log(std::string("host is online; control=") + control_state_name(sm_->state()));
for (const auto& o : link_observers_) o(true);
if (model_->spool.size() == 0) return;
asio::post(io_, [this]() {
auto c = active_conn_->lock();