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:
@@ -380,3 +380,110 @@ TEST_CASE("Subscribe: S2F41 -> stream -> HCACK 4; declarative fallback without s
|
||||
server->Shutdown();
|
||||
rt.stop();
|
||||
}
|
||||
|
||||
// Phase D — GEM300 in-the-loop (observe-and-report): job lifecycle, recipe
|
||||
// downloads, and EC writes flow host -> engine -> Subscribe stream; the tool
|
||||
// reports physical progress back and the engine drives the E40 FSM.
|
||||
TEST_CASE("Phase D: jobs, recipes, and EC changes on the stream; ReportProcessJob") {
|
||||
gem::EquipmentRuntime rt(test_config());
|
||||
gem::register_default_handlers(rt);
|
||||
dmn::EquipmentService svc(rt);
|
||||
rt.run_async();
|
||||
|
||||
grpc::ServerBuilder builder;
|
||||
builder.RegisterService(&svc);
|
||||
std::unique_ptr<grpc::Server> server(builder.BuildAndStart());
|
||||
REQUIRE(server);
|
||||
auto stub = pb::Equipment::NewStub(server->InProcessChannel(grpc::ChannelArguments{}));
|
||||
|
||||
grpc::ClientContext sub_ctx;
|
||||
pb::SubscribeRequest sreq;
|
||||
auto reader = stub->Subscribe(&sub_ctx, sreq);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100)); // registration
|
||||
|
||||
auto dispatch = [&](s2::Message msg) {
|
||||
auto reply = rt.read_sync([&rt, &msg]() { return rt.router().dispatch(msg); });
|
||||
REQUIRE(reply.has_value());
|
||||
REQUIRE(reply->has_value());
|
||||
};
|
||||
|
||||
// Host creates the PJ (S16F11), the tool sets up, the host starts it.
|
||||
gem::PRJobCreateRequest pj{"PJ-D-1", gem::MaterialFlag::Substrate,
|
||||
gem::ProcessRecipeMethod::RecipeOnly,
|
||||
gem::RecipeSpec{"RECIPE-A", {}}, {"WFR-D-1"}, {}};
|
||||
dispatch(gem::s16f11_pr_job_create(pj));
|
||||
auto setup = rt.read_sync([&rt]() {
|
||||
rt.model().process_jobs.fire_internal("PJ-D-1", gem::ProcessJobEvent::Select);
|
||||
return rt.model().process_jobs.fire_internal("PJ-D-1",
|
||||
gem::ProcessJobEvent::SetupComplete);
|
||||
});
|
||||
REQUIRE(setup.has_value());
|
||||
REQUIRE(*setup);
|
||||
dispatch(gem::s16f5_pr_job_command("PJ-D-1", "PJSTART"));
|
||||
|
||||
// The tool's stream receives the job with recipe + material bindings.
|
||||
pb::HostRequest hr;
|
||||
REQUIRE(reader->Read(&hr));
|
||||
REQUIRE(hr.has_process_job());
|
||||
CHECK(hr.process_job().job_id() == "PJ-D-1");
|
||||
CHECK(hr.process_job().action() == pb::ProcessJob::START);
|
||||
CHECK(hr.process_job().recipe() == "RECIPE-A");
|
||||
REQUIRE(hr.process_job().carriers_size() == 1);
|
||||
CHECK(hr.process_job().carriers(0) == "WFR-D-1");
|
||||
|
||||
// The tool finishes the physical work and reports; the engine's FSM follows.
|
||||
{
|
||||
grpc::ClientContext ctx;
|
||||
pb::ProcessJobState rep;
|
||||
pb::Ack ack;
|
||||
rep.set_job_id("PJ-D-1");
|
||||
rep.set_state(pb::ProcessJobState::COMPLETE);
|
||||
REQUIRE(stub->ReportProcessJob(&ctx, rep, &ack).ok());
|
||||
CHECK(ack.code() == pb::Ack::ACCEPT);
|
||||
}
|
||||
auto state = rt.read_sync(
|
||||
[&rt]() { return rt.model().process_jobs.state("PJ-D-1"); });
|
||||
REQUIRE(state.has_value());
|
||||
CHECK(*state == gem::ProcessJobState::ProcessComplete);
|
||||
|
||||
// Reporting against an unknown job / an illegal transition is rejected.
|
||||
{
|
||||
grpc::ClientContext ctx;
|
||||
pb::ProcessJobState rep;
|
||||
pb::Ack ack;
|
||||
rep.set_job_id("PJ-GHOST");
|
||||
rep.set_state(pb::ProcessJobState::COMPLETE);
|
||||
REQUIRE(stub->ReportProcessJob(&ctx, rep, &ack).ok());
|
||||
CHECK(ack.code() == pb::Ack::INVALID_OBJECT);
|
||||
}
|
||||
{
|
||||
grpc::ClientContext ctx;
|
||||
pb::ProcessJobState rep;
|
||||
pb::Ack ack;
|
||||
rep.set_job_id("PJ-D-1"); // already ProcessComplete
|
||||
rep.set_state(pb::ProcessJobState::COMPLETE);
|
||||
REQUIRE(stub->ReportProcessJob(&ctx, rep, &ack).ok());
|
||||
CHECK(ack.code() == pb::Ack::CANNOT_DO_NOW);
|
||||
}
|
||||
|
||||
// Host downloads a recipe (S7F3) -> ProcessProgram on the stream.
|
||||
dispatch(gem::s7f3_process_program_send("RECIPE-NEW", "step1\nstep2\n"));
|
||||
REQUIRE(reader->Read(&hr));
|
||||
REQUIRE(hr.has_process_program());
|
||||
CHECK(hr.process_program().ppid() == "RECIPE-NEW");
|
||||
CHECK(hr.process_program().body() == "step1\nstep2\n");
|
||||
|
||||
// Host writes an EC (S2F15) -> ConstantChange with the configured name.
|
||||
dispatch(gem::s2f15_ec_send({{10, s2::Item::u4(uint32_t{1})}}));
|
||||
REQUIRE(reader->Read(&hr));
|
||||
REQUIRE(hr.has_constant());
|
||||
CHECK(hr.constant().name() == "TimeFormat");
|
||||
CHECK(hr.constant().value().integer() == 1);
|
||||
|
||||
sub_ctx.TryCancel();
|
||||
pb::HostRequest drain;
|
||||
while (reader->Read(&drain)) {}
|
||||
(void)reader->Finish();
|
||||
server->Shutdown();
|
||||
rt.stop();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user