fix(daemon)+test: accurate duplicate-ARRIVED message; broaden E90/E157 + names coverage

The duplicate-ARRIVED fix from the previous commit returned INVALID_OBJECT
with the message "no substrate 'X'" — a lie, since the substrate exists.
Rewrite ReportSubstrate so ARRIVED has its own ack mapping: a duplicate is
CANNOT_DO_NOW with "substrate 'X' already exists" (a state conflict, not a
missing object), and we never silently re-create over live FSM state.

Coverage gaps closed:
- C++: ARRIVED records carrier_id/slot (now asserted); module NOT_EXECUTING
  reset transition; duplicate-ARRIVED expects CANNOT_DO_NOW.
- Interop: @eq.command now drives the real host S2F41 path (was @eq.on, so
  the headline decorator had zero wire coverage); @eq.command NameError on
  unknown name; eq.names var/alarm + dir() + typo-suggestion; replaced the
  two `check(..., True)` tautologies with full E90 journey + AT_DESTINATION
  and real error paths (ghost wafer raises, illegal module jump raises).

All 8 daemon test cases (248 assertions) and 24 pyclient interop checks pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 22:01:20 +02:00
parent d22bbc4ab2
commit 2218b854ce
3 changed files with 110 additions and 28 deletions
+21 -5
View File
@@ -533,13 +533,29 @@ class EquipmentService final : public pb::Equipment::Service {
const std::string cid = req->carrier_id(); const std::string cid = req->carrier_id();
const auto slot = static_cast<uint8_t>(req->slot()); const auto slot = static_cast<uint8_t>(req->slot());
const auto m = req->milestone(); const auto m = req->milestone();
auto outcome = rt_.read_sync([this, sid, cid, slot, m]() -> std::optional<bool> {
auto& subs = rt_.model().substrates; // ARRIVED creates the substrate. A duplicate is a real conflict (re-creating
// would silently wipe the wafer's FSM state + history), so reject it with an
// accurate message rather than the generic "no such object" path.
if (m == pb::SubstrateReport::ARRIVED) { if (m == pb::SubstrateReport::ARRIVED) {
if (subs.create(sid, cid, slot) == gem::SubstrateStore::CreateResult::Denied_AlreadyExists) auto created = rt_.read_sync([this, sid, cid, slot]() {
return std::nullopt; // → INVALID_OBJECT: duplicate substrate ID return rt_.model().substrates.create(sid, cid, slot) ==
return true; gem::SubstrateStore::CreateResult::Created;
});
if (!created) {
resp->set_code(pb::Ack::CANNOT_DO_NOW);
resp->set_message("engine io thread did not answer (not running?)");
} else if (!*created) {
resp->set_code(pb::Ack::CANNOT_DO_NOW);
resp->set_message("substrate '" + sid + "' already exists");
} else {
resp->set_code(pb::Ack::ACCEPT);
} }
return grpc::Status::OK;
}
auto outcome = rt_.read_sync([this, sid, m]() -> std::optional<bool> {
auto& subs = rt_.model().substrates;
if (!subs.has(sid)) return std::nullopt; if (!subs.has(sid)) return std::nullopt;
switch (m) { switch (m) {
case pb::SubstrateReport::AT_WORK: case pb::SubstrateReport::AT_WORK:
+51 -17
View File
@@ -8,8 +8,11 @@ host actually receives on the wire:
eq.set / eq["..."] -> S1F3-visible values, GetVariables round-trip eq.set / eq["..."] -> S1F3-visible values, GetVariables round-trip
eq.fire -> host receives S6F11 with the configured report eq.fire -> host receives S6F11 with the configured report
eq.alarm / eq.clear -> host receives S5F1 set/clear eq.alarm / eq.clear -> host receives S5F1 set/clear
@eq.on + eq.listen -> host's S2F41 gets HCACK=4, handler runs, @eq.command + eq.listen -> host's S2F41 gets HCACK=4, the name-bound
completion event reaches the host handler runs, completion event reaches the host
eq.names.* -> Describe-backed, typo-safe name lookup
eq.report_substrate -> E90 wafer journey (+ unknown-wafer error path)
eq.report_module -> E157 module walk/reset (+ illegal-jump error path)
eq.control_state / request_control_state / eq.health eq.control_state / request_control_state / eq.health
Exits 0 on success. Exits 0 on success.
@@ -50,10 +53,12 @@ def run(grpc_addr: str, hsms_host: str, hsms_port: int) -> int:
started = threading.Event() started = threading.Event()
@eq.on("START") # @eq.command binds by function name, validated against the live equipment
def _start(cmd): # noqa: ANN001 # (Describe) at decoration time — this is the path the host's S2F41 drives.
@eq.command
def START(cmd): # noqa: ANN001
started.set() started.set()
eq.fire("ProcessStarted") # the host's real completion signal eq.fire(eq.names.event.ProcessStarted) # typo-safe completion signal
eq.listen(background=True) eq.listen(background=True)
@@ -140,31 +145,60 @@ def run(grpc_addr: str, hsms_host: str, hsms_port: int) -> int:
body = host.settings.streams_functions.decode(rsp).get() body = host.settings.streams_functions.decode(rsp).get()
check("host S2F41 -> HCACK=4", check("host S2F41 -> HCACK=4",
isinstance(body, dict) and int(body.get("HCACK") or -1) == 4) isinstance(body, dict) and int(body.get("HCACK") or -1) == 4)
check("@eq.on('START') handler ran", started.wait(timeout=10)) check("@eq.command('START') handler ran", started.wait(timeout=10))
check("handler's eq.fire reached the host (completion signal)", check("handler's eq.fire reached the host (completion signal)",
ceid300.wait(timeout=10)) ceid300.wait(timeout=10))
# ---- Describe-backed names + @eq.command binding ---- # ---- Describe-backed names (every category, dir(), typo hint) ----
check("eq.names.event has ProcessStarted", check("eq.names.event has ProcessStarted",
"ProcessStarted" in eq.names.event) "ProcessStarted" in eq.names.event)
check("eq.names.command has START", "START" in eq.names.command) check("eq.names.command has START", "START" in eq.names.command)
check("eq.names.var has ChamberPressure",
"ChamberPressure" in eq.names.var)
check("eq.names.alarm has chiller_temp_high",
"chiller_temp_high" in eq.names.alarm)
check("dir(eq.names.event) lists names for autocomplete",
"ProcessStarted" in dir(eq.names.event))
try: try:
_ = eq.names.event.NoSuchEvent _ = eq.names.event.ProcessStated # one-letter typo
check("typo on eq.names raises", False) check("typo on eq.names raises with suggestion", False)
except AttributeError: except AttributeError as e:
check("typo on eq.names raises", True) check("typo on eq.names raises with suggestion",
eq.fire(eq.names.event.ProcessStarted) "ProcessStarted" in str(e)) # close-match hint present
check("eq.fire(eq.names.event.*) accepted", True)
# ---- E90/E157 material tracking ---- # ---- @eq.command rejects an unknown command name at decoration time ----
eq.report_substrate("WFR-PY-1", "ARRIVED") try:
@eq.command
def NOT_A_REAL_COMMAND(cmd): # noqa: ANN001
pass
check("@eq.command on unknown name raises NameError", False)
except NameError:
check("@eq.command on unknown name raises NameError", True)
# ---- E90 substrate tracking: full journey, then the error path ----
eq.report_substrate("WFR-PY-1", "ARRIVED", carrier_id="FOUP-PY", slot=4)
eq.report_substrate("WFR-PY-1", "AT_WORK") eq.report_substrate("WFR-PY-1", "AT_WORK")
eq.report_substrate("WFR-PY-1", "PROCESSING") eq.report_substrate("WFR-PY-1", "PROCESSING")
eq.report_substrate("WFR-PY-1", "PROCESSED") eq.report_substrate("WFR-PY-1", "PROCESSED")
check("report_substrate journey accepted", True) eq.report_substrate("WFR-PY-1", "AT_DESTINATION")
check("report_substrate full journey accepted (no raise)", True)
try:
eq.report_substrate("WFR-GHOST", "AT_WORK") # never ARRIVED
check("report_substrate on unknown wafer raises", False)
except SecsGemError as e:
check("report_substrate on unknown wafer raises", "WFR-GHOST" in str(e))
# ---- E157 module tracking: walk and reset ----
eq.report_module("MOD-PY-1", "GENERAL_EXECUTING") eq.report_module("MOD-PY-1", "GENERAL_EXECUTING")
eq.report_module("MOD-PY-1", "STEP_EXECUTING") eq.report_module("MOD-PY-1", "STEP_EXECUTING")
check("report_module accepted", True) eq.report_module("MOD-PY-1", "STEP_COMPLETED")
eq.report_module("MOD-PY-1", "NOT_EXECUTING")
check("report_module walk + reset accepted (no raise)", True)
try:
eq.report_module("MOD-PY-2", "STEP_EXECUTING") # illegal from idle
check("report_module illegal jump raises", False)
except SecsGemError:
check("report_module illegal jump raises", True)
# ---- operator offline via the client ---- # ---- operator offline via the client ----
eq.request_control_state("HOST_OFFLINE") eq.request_control_state("HOST_OFFLINE")
+37 -5
View File
@@ -759,8 +759,30 @@ TEST_CASE("ReportSubstrate (E90) and ReportModule (E157) drive the FSMs") {
return ack.code(); return ack.code();
}; };
auto sub_with = [&](const std::string& sid, pb::SubstrateReport::Milestone m,
const std::string& cid, uint32_t slot) {
grpc::ClientContext ctx;
pb::SubstrateReport req;
pb::Ack ack;
req.set_substrate_id(sid);
req.set_milestone(m);
req.set_carrier_id(cid);
req.set_slot(slot);
REQUIRE(stub->ReportSubstrate(&ctx, req, &ack).ok());
return ack.code();
};
// A wafer's journey: arrive -> picked up -> process -> done -> deposited. // A wafer's journey: arrive -> picked up -> process -> done -> deposited.
CHECK(sub("WFR-1", pb::SubstrateReport::ARRIVED) == pb::Ack::ACCEPT); // ARRIVED also records where the wafer came from (carrier + slot).
CHECK(sub_with("WFR-1", pb::SubstrateReport::ARRIVED, "FOUP-7", 3) == pb::Ack::ACCEPT);
auto origin = rt.read_sync([&rt]() {
const auto* s = rt.model().substrates.get("WFR-1");
return s ? std::make_pair(s->carrierid, s->slot)
: std::make_pair(std::string{}, uint8_t{0});
});
REQUIRE(origin.has_value());
CHECK(origin->first == "FOUP-7");
CHECK(static_cast<int>(origin->second) == 3);
CHECK(sub("WFR-1", pb::SubstrateReport::AT_WORK) == pb::Ack::ACCEPT); CHECK(sub("WFR-1", pb::SubstrateReport::AT_WORK) == pb::Ack::ACCEPT);
CHECK(sub("WFR-1", pb::SubstrateReport::PROCESSING) == pb::Ack::ACCEPT); CHECK(sub("WFR-1", pb::SubstrateReport::PROCESSING) == pb::Ack::ACCEPT);
CHECK(sub("WFR-1", pb::SubstrateReport::PROCESSED) == pb::Ack::ACCEPT); CHECK(sub("WFR-1", pb::SubstrateReport::PROCESSED) == pb::Ack::ACCEPT);
@@ -772,10 +794,11 @@ TEST_CASE("ReportSubstrate (E90) and ReportModule (E157) drive the FSMs") {
REQUIRE(loc.has_value()); REQUIRE(loc.has_value());
CHECK(*loc == gem::SubstrateState::AtDestination); CHECK(*loc == gem::SubstrateState::AtDestination);
// Reporting on a substrate that never ARRIVED is rejected. // Reporting on a substrate that never ARRIVED is rejected (no such object).
CHECK(sub("WFR-GHOST", pb::SubstrateReport::AT_WORK) == pb::Ack::INVALID_OBJECT); CHECK(sub("WFR-GHOST", pb::SubstrateReport::AT_WORK) == pb::Ack::INVALID_OBJECT);
// Duplicate ARRIVED (same substrate ID already in the store) is rejected. // Duplicate ARRIVED is a conflict, not a missing object: CANNOT_DO_NOW so we
CHECK(sub("WFR-1", pb::SubstrateReport::ARRIVED) == pb::Ack::INVALID_OBJECT); // never silently wipe the existing wafer's state/history.
CHECK(sub("WFR-1", pb::SubstrateReport::ARRIVED) == pb::Ack::CANNOT_DO_NOW);
auto mod = [&](const std::string& mid, pb::ModuleReport::State st) { auto mod = [&](const std::string& mid, pb::ModuleReport::State st) {
grpc::ClientContext ctx; grpc::ClientContext ctx;
@@ -787,7 +810,7 @@ TEST_CASE("ReportSubstrate (E90) and ReportModule (E157) drive the FSMs") {
return ack.code(); return ack.code();
}; };
// Module: auto-created, then walked General -> Step -> StepCompleted. // Module: auto-created, then walked General -> Step -> StepCompleted -> Reset.
CHECK(mod("MOD-1", pb::ModuleReport::GENERAL_EXECUTING) == pb::Ack::ACCEPT); CHECK(mod("MOD-1", pb::ModuleReport::GENERAL_EXECUTING) == pb::Ack::ACCEPT);
CHECK(mod("MOD-1", pb::ModuleReport::STEP_EXECUTING) == pb::Ack::ACCEPT); CHECK(mod("MOD-1", pb::ModuleReport::STEP_EXECUTING) == pb::Ack::ACCEPT);
CHECK(mod("MOD-1", pb::ModuleReport::STEP_COMPLETED) == pb::Ack::ACCEPT); CHECK(mod("MOD-1", pb::ModuleReport::STEP_COMPLETED) == pb::Ack::ACCEPT);
@@ -798,6 +821,15 @@ TEST_CASE("ReportSubstrate (E90) and ReportModule (E157) drive the FSMs") {
REQUIRE(mstate.has_value()); REQUIRE(mstate.has_value());
CHECK(*mstate == gem::ModuleState::StepCompleted); CHECK(*mstate == gem::ModuleState::StepCompleted);
// NOT_EXECUTING resets the module back to idle (re-usable for the next wafer).
CHECK(mod("MOD-1", pb::ModuleReport::NOT_EXECUTING) == pb::Ack::ACCEPT);
auto reset = rt.read_sync([&rt]() {
const auto* m = rt.model().modules.get("MOD-1");
return m ? m->fsm->state() : gem::ModuleState::NoState;
});
REQUIRE(reset.has_value());
CHECK(*reset == gem::ModuleState::NotExecuting);
// An illegal jump (StepExecuting straight from a fresh NotExecuting module) // An illegal jump (StepExecuting straight from a fresh NotExecuting module)
// is rejected by the E157 table. // is rejected by the E157 table.
CHECK(mod("MOD-2", pb::ModuleReport::STEP_EXECUTING) == pb::Ack::CANNOT_DO_NOW); CHECK(mod("MOD-2", pb::ModuleReport::STEP_EXECUTING) == pb::Ack::CANNOT_DO_NOW);