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
+53
View File
@@ -151,3 +151,56 @@ TEST_CASE("custom table: a row that only sets ack, no transition") {
CHECK(sm.on_host_request_online() == OnlineAck::NotAccept);
CHECK(sm.state() == ControlState::HostOffline);
}
// ---- multi-observer (HandlerSlot) ------------------------------------------
TEST_CASE("add_state_change_handler observers fire alongside the primary, in order") {
ControlStateMachine sm;
std::vector<int> order;
Recorder primary, obs1, obs2;
sm.set_state_change_handler([&](ControlState f, ControlState t, ControlEvent e) {
order.push_back(0);
primary.handler()(f, t, e);
});
sm.add_state_change_handler([&](ControlState f, ControlState t, ControlEvent e) {
order.push_back(1);
obs1.handler()(f, t, e);
});
sm.add_state_change_handler([&](ControlState f, ControlState t, ControlEvent e) {
order.push_back(2);
obs2.handler()(f, t, e);
});
CHECK(sm.on_host_request_online() == OnlineAck::Accept); // 2 transitions
// All three saw both transitions, primary before observers each time.
CHECK(primary.changes.size() == 2);
CHECK(obs1.changes.size() == 2);
CHECK(obs2.changes.size() == 2);
REQUIRE(order.size() == 6);
CHECK(order == std::vector<int>{0, 1, 2, 0, 1, 2});
}
TEST_CASE("set_state_change_handler replaces the primary but observers survive") {
ControlStateMachine sm;
Recorder old_primary, new_primary, observer;
sm.set_state_change_handler(old_primary.handler());
sm.add_state_change_handler(observer.handler());
sm.set_state_change_handler(new_primary.handler()); // replaces old_primary ONLY
sm.on_host_request_online();
CHECK(old_primary.changes.empty()); // replaced
CHECK(new_primary.changes.size() == 2); // active primary
CHECK(observer.changes.size() == 2); // survived the re-set
}
TEST_CASE("observers work with no primary handler installed") {
ControlStateMachine sm;
Recorder observer;
sm.add_state_change_handler(observer.handler());
sm.on_host_request_online();
CHECK(observer.changes.size() == 2);
}