feat(daemon): Phase D — GEM300 in-the-loop (jobs, recipes, EC changes)

Semantics settled and documented: v1 is observe-and-report. The engine keeps
acking S16/S3/S7/S2F15 from its FSM tables — exactly the behaviour both
reference implementations validated — while the tool observes lifecycle
events on the Subscribe stream and reports physical progress back. Gating
stays the documented v2 deferred-reply item.

Engine: two new store observers (HandlerSlot pattern) — RecipeStore fires
(ppid, body) after an add (S7F3 downloads), EquipmentConstantStore fires
(id, value) on ACCEPTED S2F15 writes only. Unit-tested.

Daemon: the service registers PJ/recipe/EC observers (io thread; add_
observers coexist with register_default_handlers' primaries) and fans the
new HostRequest variants out via push_request (fire-and-forget, no-
buffering contract). ProcessJob carries action (Start->START, Resume->
RESUME, Paused->PAUSE, Stopping->STOP, Aborting->ABORT) + recipe + material
bindings read store-side on the io thread. ReportProcessJob maps SETTING_UP
->SetupComplete, COMPLETE->ProcessComplete, ABORTED->AbortComplete via
read_sync; PROCESSING is informational; unknown job => INVALID_OBJECT,
table-rejected transition => CANNOT_DO_NOW. Carriers deferred (CarrierStore
has no observer machinery; ReportCarrier stays UNIMPLEMENTED) — roadmap.

Python client: on_process_job / on_recipe / on_constant_change decorators +
report_job(job_id, state); ProcessJob dataclass exported.

Tests: daemon suite 141 -> 175 assertions — the full in-process loop
(S16F11 create -> tool setup -> S16F5 PJSTART -> stream ProcessJob with
recipe+carriers -> ReportProcessJob(COMPLETE) -> FSM at ProcessComplete),
rejection paths, S7F3 -> ProcessProgram, S2F15 -> ConstantChange with the
configured name. Core 475/3097 (observer units). Live regression: daemon
interop 20 checks + pyclient 13 checks still green against the running
daemon.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 23:53:45 +02:00
parent b30443089f
commit b1772cfefd
10 changed files with 333 additions and 10 deletions
+32
View File
@@ -444,3 +444,35 @@ TEST_CASE("spool persistence: clear deletes files") {
fs::remove_all(dir);
}
TEST_CASE("RecipeStore added-observer fires after the store is updated") {
RecipeStore r;
std::string seen_ppid, seen_body;
r.add_added_handler([&](const std::string& p, const std::string& b) {
seen_ppid = p;
seen_body = b;
});
r.add("R-1", "body");
CHECK(seen_ppid == "R-1");
CHECK(seen_body == "body");
CHECK(r.get("R-1").has_value()); // observer saw the post-update store
}
TEST_CASE("EquipmentConstantStore changed-observer fires on Accept only") {
EquipmentConstantStore ecids;
ecids.add({10, "Knob", "", s2::Item::u4(uint32_t{1}),
s2::Item::u4(uint32_t{1}), "0", "5"});
int fired = 0;
ecids.add_changed_handler([&](uint32_t id, const s2::Item& v) {
++fired;
CHECK(id == 10);
CHECK(v == s2::Item::u4(uint32_t{3}));
});
CHECK(ecids.set_value(10, s2::Item::u4(uint32_t{3})) == EquipmentAck::Accept);
CHECK(fired == 1);
CHECK(ecids.set_value(10, s2::Item::u4(uint32_t{99})) ==
EquipmentAck::Denied_OutOfRange);
CHECK(ecids.set_value(999, s2::Item::u4(uint32_t{1})) ==
EquipmentAck::Denied_UnknownEcid);
CHECK(fired == 1); // rejected writes never fire
}