feat(daemon): Subscribe command stream + CompleteCommand — the vendor loop closes
The HCACK-4 contract, implemented end to end. For every YAML-declared command the service registers a forwarding handler (new HostCommandRegistry names()/spec() accessors): with a subscribed tool client the command is queued onto the Subscribe stream (id + name + params via from_item) and the host is answered S2F42 HCACK=4 immediately — never blocking the io thread or the T3 window; with NO subscriber the command takes its declarative YAML ack (the honest pre-daemon behaviour). Settled + documented in the proto: v1 is a firehose with no buffering/replay. CompleteCommand correlates the pending id (audit; unknown id => PARAMETER_INVALID). Side effects stay suppressed on HCACK-4 (router applies them only on Accept), so the completion event the TOOL fires is the host's real signal — exactly E30's intent. Tests (daemon suite 101 -> 124 assertions): a real S2F41 dispatched through the full default-handler router ON the io thread under run_async — HCACK 4 with subscriber + params on the stream, declarative Accept without, CompleteCommand known/unknown, fallback restored after unsubscribe. Interop (now 20 checks, all green): the complete conformant loop against the secsgem-py reference host — S2F41 START -> S2F42 HCACK=4 -> tool receives Command(name=START, id=1) -> CompleteCommand -> FireEvent -> host receives S6F11. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,12 @@
|
||||
|
||||
#include <grpcpp/grpcpp.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <thread>
|
||||
|
||||
#include "equipment_service.hpp"
|
||||
#include "secsgem/gem/default_handlers.hpp"
|
||||
#include "secsgem/gem/messages.hpp"
|
||||
|
||||
using namespace secsgem;
|
||||
namespace gem = secsgem::gem;
|
||||
@@ -284,3 +289,88 @@ TEST_CASE("GetVariables round-trip under run_async (production threading mode)")
|
||||
server->Shutdown();
|
||||
rt.stop();
|
||||
}
|
||||
|
||||
// The Subscribe command stream, per the HCACK-4 contract: a real S2F41 is
|
||||
// dispatched through the full default-handler router ON the io thread, while
|
||||
// the gRPC tool client consumes the stream — the daemon's production shape.
|
||||
TEST_CASE("Subscribe: S2F41 -> stream -> HCACK 4; declarative fallback without subscriber") {
|
||||
gem::EquipmentRuntime rt(test_config());
|
||||
gem::register_default_handlers(rt); // the real S2F41/F21/F49 router path
|
||||
dmn::EquipmentService svc(rt); // registers the forwarding handlers
|
||||
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{}));
|
||||
|
||||
// Dispatch a wire-shaped S2F41 on the io thread (the model's owner) and
|
||||
// hand back the parsed S2F42 ack.
|
||||
auto send_s2f41 = [&](const std::string& rcmd,
|
||||
std::vector<gem::CommandParameter> params) {
|
||||
auto reply = rt.read_sync([&rt, &rcmd, ¶ms]() {
|
||||
return rt.router().dispatch(gem::s2f41_host_command(rcmd, params));
|
||||
});
|
||||
REQUIRE(reply.has_value()); // read_sync answered
|
||||
REQUIRE(reply->has_value()); // router produced an S2F42
|
||||
auto parsed = gem::parse_s2f42(**reply);
|
||||
REQUIRE(parsed.has_value());
|
||||
return parsed->hcack;
|
||||
};
|
||||
|
||||
// --- no subscriber: declarative ack (START is Accept in equipment.yaml) ---
|
||||
CHECK(send_s2f41("START", {}) == gem::HostCmdAck::Accept);
|
||||
|
||||
// --- subscriber connected: HCACK 4 + the command arrives on the stream ----
|
||||
grpc::ClientContext sub_ctx;
|
||||
pb::SubscribeRequest sreq;
|
||||
sreq.set_client("test-tool");
|
||||
auto reader = stub->Subscribe(&sub_ctx, sreq);
|
||||
|
||||
// Subscription registration races the dispatch below only in this test
|
||||
// (the registry insert happens on the gRPC server thread); give it a beat.
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
|
||||
CHECK(send_s2f41("START", {{"PPID", s2::Item::ascii("RECIPE-A")}}) ==
|
||||
gem::HostCmdAck::AcceptedWillFinishLater);
|
||||
|
||||
pb::HostRequest hr;
|
||||
REQUIRE(reader->Read(&hr));
|
||||
REQUIRE(hr.has_command());
|
||||
CHECK(hr.command().name() == "START");
|
||||
CHECK_FALSE(hr.command().id().empty());
|
||||
REQUIRE(hr.command().params().count("PPID") == 1);
|
||||
CHECK(hr.command().params().at("PPID").text() == "RECIPE-A");
|
||||
|
||||
// --- CompleteCommand: known id accepted, unknown rejected ------------------
|
||||
{
|
||||
grpc::ClientContext ctx;
|
||||
pb::CommandResult res;
|
||||
pb::Ack ack;
|
||||
res.set_id(hr.command().id());
|
||||
res.mutable_ack()->set_code(pb::Ack::ACCEPT);
|
||||
REQUIRE(stub->CompleteCommand(&ctx, res, &ack).ok());
|
||||
CHECK(ack.code() == pb::Ack::ACCEPT);
|
||||
}
|
||||
{
|
||||
grpc::ClientContext ctx;
|
||||
pb::CommandResult res;
|
||||
pb::Ack ack;
|
||||
res.set_id("no-such-id");
|
||||
REQUIRE(stub->CompleteCommand(&ctx, res, &ack).ok());
|
||||
CHECK(ack.code() == pb::Ack::PARAMETER_INVALID);
|
||||
}
|
||||
|
||||
// --- unsubscribe: the fallback returns ------------------------------------
|
||||
sub_ctx.TryCancel();
|
||||
pb::HostRequest drain;
|
||||
while (reader->Read(&drain)) {}
|
||||
(void)reader->Finish(); // CANCELLED — expected
|
||||
// The server-side Subscribe loop notices the cancel within its 500ms poll.
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(700));
|
||||
CHECK(send_s2f41("START", {}) == gem::HostCmdAck::Accept);
|
||||
|
||||
server->Shutdown();
|
||||
rt.stop();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user