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:
@@ -12,11 +12,14 @@
|
||||
|
||||
#include <grpcpp/grpcpp.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
@@ -185,6 +188,23 @@ class EquipmentService final : public pb::Equipment::Service {
|
||||
[this](gem::ControlState, gem::ControlState, gem::ControlEvent) {
|
||||
bump_health();
|
||||
});
|
||||
|
||||
// Host commands -> the Subscribe stream (the HCACK-4 contract). For every
|
||||
// YAML-declared command, install a behaviour handler that pushes the
|
||||
// command to subscribed tool clients and answers the host immediately
|
||||
// with AcceptedWillFinishLater; with NO subscriber it returns the
|
||||
// command's declarative ack — i.e. exactly the pre-daemon behaviour, and
|
||||
// honest (never "will finish later" for work nobody will do). Runs on
|
||||
// the io thread; only briefly takes the subscriber mutex.
|
||||
for (const auto& rcmd : rt.model().commands.names()) {
|
||||
const auto declarative =
|
||||
rt.model().commands.spec(rcmd)->ack; // names() came from the map
|
||||
rt.on_command(rcmd, [this, declarative](
|
||||
const std::string& cmd,
|
||||
const std::vector<gem::CommandParameter>& params) {
|
||||
return forward_command(cmd, params, declarative);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
grpc::Status SetVariables(grpc::ServerContext*, const pb::VariableUpdate* req,
|
||||
@@ -289,6 +309,64 @@ class EquipmentService final : public pb::Equipment::Service {
|
||||
return grpc::Status::OK;
|
||||
}
|
||||
|
||||
// Everything the host asks of the tool, as a stream. v1 contract:
|
||||
// - firehose: every subscriber receives every host request;
|
||||
// - NO buffering: commands arriving with no subscriber take the
|
||||
// declarative ack instead (see the constructor's forwarding handler);
|
||||
// - the S2F42 already went out as HCACK-4 when a command appears here —
|
||||
// report the real outcome via FireEvent/SetAlarm; CompleteCommand
|
||||
// correlates/audits.
|
||||
grpc::Status Subscribe(grpc::ServerContext* ctx, const pb::SubscribeRequest* req,
|
||||
grpc::ServerWriter<pb::HostRequest>* writer) override {
|
||||
auto sub = std::make_shared<Subscriber>();
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(subs_mu_);
|
||||
subs_.push_back(sub);
|
||||
}
|
||||
rt_.log("tool client subscribed" +
|
||||
(req->client().empty() ? "" : " (" + req->client() + ")"));
|
||||
bool alive = true;
|
||||
while (alive && !ctx->IsCancelled()) {
|
||||
std::optional<pb::HostRequest> next;
|
||||
{
|
||||
std::unique_lock<std::mutex> lk(subs_mu_);
|
||||
subs_cv_.wait_for(lk, std::chrono::milliseconds(500),
|
||||
[&] { return !sub->queue.empty(); });
|
||||
if (!sub->queue.empty()) {
|
||||
next = std::move(sub->queue.front());
|
||||
sub->queue.pop_front();
|
||||
}
|
||||
}
|
||||
if (next) alive = writer->Write(*next); // write OUTSIDE the lock
|
||||
}
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(subs_mu_);
|
||||
subs_.erase(std::remove(subs_.begin(), subs_.end(), sub), subs_.end());
|
||||
}
|
||||
rt_.log("tool client unsubscribed");
|
||||
return grpc::Status::OK;
|
||||
}
|
||||
|
||||
grpc::Status CompleteCommand(grpc::ServerContext*, const pb::CommandResult* req,
|
||||
pb::Ack* resp) override {
|
||||
std::string rcmd;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(subs_mu_);
|
||||
auto it = pending_.find(req->id());
|
||||
if (it == pending_.end()) {
|
||||
resp->set_code(pb::Ack::PARAMETER_INVALID);
|
||||
resp->set_message("unknown command id '" + req->id() + "'");
|
||||
return grpc::Status::OK;
|
||||
}
|
||||
rcmd = it->second;
|
||||
pending_.erase(it);
|
||||
}
|
||||
rt_.log("command '" + rcmd + "' id=" + req->id() + " completed by tool (ack=" +
|
||||
std::to_string(static_cast<int>(req->ack().code())) + ")");
|
||||
resp->set_code(pb::Ack::ACCEPT);
|
||||
return grpc::Status::OK;
|
||||
}
|
||||
|
||||
// Operator-panel control-state transitions (e.g. "offline for maintenance").
|
||||
// Fires the operator events on the io thread and reports what the E30 table
|
||||
// actually did: ACCEPT iff the equipment landed in the requested state,
|
||||
@@ -370,6 +448,27 @@ class EquipmentService final : public pb::Equipment::Service {
|
||||
}
|
||||
|
||||
private:
|
||||
// The command-forwarding handler (runs on the io thread during S2F41/F21/
|
||||
// F49 dispatch). Subscriber present -> queue the command + HCACK 4; absent
|
||||
// -> the command's declarative YAML ack. Never blocks.
|
||||
gem::HostCmdAck forward_command(const std::string& rcmd,
|
||||
const std::vector<gem::CommandParameter>& params,
|
||||
gem::HostCmdAck declarative) {
|
||||
std::lock_guard<std::mutex> lk(subs_mu_);
|
||||
if (subs_.empty()) return declarative;
|
||||
const std::string id = std::to_string(next_command_id_++);
|
||||
pb::HostRequest hr;
|
||||
auto* cmd = hr.mutable_command();
|
||||
cmd->set_id(id);
|
||||
cmd->set_name(rcmd);
|
||||
for (const auto& p : params) (*cmd->mutable_params())[p.name] = from_item(p.value);
|
||||
pending_.emplace(id, rcmd);
|
||||
for (auto& sub : subs_) sub->queue.push_back(hr);
|
||||
subs_cv_.notify_all();
|
||||
rt_.log("command '" + rcmd + "' id=" + id + " -> tool stream (HCACK=4)");
|
||||
return gem::HostCmdAck::AcceptedWillFinishLater;
|
||||
}
|
||||
|
||||
void bump_health() {
|
||||
health_version_.fetch_add(1, std::memory_order_relaxed);
|
||||
health_cv_.notify_all();
|
||||
@@ -422,6 +521,17 @@ class EquipmentService final : public pb::Equipment::Service {
|
||||
std::atomic<uint64_t> health_version_{0};
|
||||
std::mutex health_mu_;
|
||||
std::condition_variable health_cv_;
|
||||
|
||||
// Subscribe plumbing: the io-thread forwarding handler queues HostRequests;
|
||||
// each Subscribe call drains its own queue on a gRPC thread.
|
||||
struct Subscriber {
|
||||
std::deque<pb::HostRequest> queue;
|
||||
};
|
||||
std::mutex subs_mu_;
|
||||
std::condition_variable subs_cv_;
|
||||
std::vector<std::shared_ptr<Subscriber>> subs_;
|
||||
std::map<std::string, std::string> pending_; // command id -> rcmd (audit)
|
||||
uint64_t next_command_id_ = 1; // guarded by subs_mu_
|
||||
};
|
||||
|
||||
} // namespace secsgem::daemon
|
||||
|
||||
Reference in New Issue
Block a user