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:
2026-06-10 20:27:18 +02:00
parent 1da56f973f
commit e6ee927900
6 changed files with 288 additions and 13 deletions
+110
View File
@@ -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