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
+107
View File
@@ -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();
}