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:
@@ -201,6 +201,51 @@ def run(grpc_addr: str, hsms_host: str, hsms_port: int, session_id: int,
|
||||
f"body={last_s5f1}")
|
||||
stub.ClearAlarm(pb.Alarm(name="chiller_temp_high"))
|
||||
|
||||
# --- the full conformant command loop (HCACK-4 contract) ---
|
||||
# Tool subscribes; host sends S2F41 START; daemon answers HCACK=4 and
|
||||
# forwards the command to the tool; tool completes it and fires the
|
||||
# event; host receives S6F11 — command -> tool -> outcome, end to end.
|
||||
received_cmd = {}
|
||||
cmd_seen = threading.Event()
|
||||
|
||||
def consume_stream():
|
||||
try:
|
||||
for hr in stub.Subscribe(pb.SubscribeRequest(client="interop-tool")):
|
||||
if hr.HasField("command"):
|
||||
received_cmd["cmd"] = hr.command
|
||||
cmd_seen.set()
|
||||
return
|
||||
except grpc.RpcError:
|
||||
pass # stream cancelled at teardown
|
||||
|
||||
tool_thread = threading.Thread(target=consume_stream, daemon=True)
|
||||
tool_thread.start()
|
||||
time.sleep(0.5) # let the subscription register
|
||||
|
||||
ceid300.clear()
|
||||
rsp = client.send_and_waitfor_response(
|
||||
F.SecsS02F41({"RCMD": "START", "PARAMS": []}))
|
||||
body = client.settings.streams_functions.decode(rsp).get()
|
||||
hcack = body.get("HCACK") if isinstance(body, dict) else None
|
||||
check("host got S2F42 HCACK=4 (accepted, will finish later)",
|
||||
int(hcack or -1) == 4, f"HCACK={hcack!r}")
|
||||
|
||||
got_cmd = cmd_seen.wait(timeout=10)
|
||||
check("tool received START on the Subscribe stream", got_cmd)
|
||||
if got_cmd:
|
||||
cmd = received_cmd["cmd"]
|
||||
check("streamed command carries name + id",
|
||||
cmd.name == "START" and bool(cmd.id),
|
||||
f"name={cmd.name} id={cmd.id}")
|
||||
ack = stub.CompleteCommand(pb.CommandResult(
|
||||
id=cmd.id, ack=pb.Ack(code=pb.Ack.ACCEPT)))
|
||||
check("CompleteCommand(id) -> ACCEPT",
|
||||
ack.code == pb.Ack.ACCEPT, pb.Ack.Code.Name(ack.code))
|
||||
# The tool reports the real outcome as an event; the host sees it.
|
||||
stub.FireEvent(pb.Event(name="ProcessStarted"))
|
||||
check("host received S6F11 after tool completed the command",
|
||||
ceid300.wait(timeout=10))
|
||||
|
||||
# --- operator-panel control state: take the tool offline via gRPC ---
|
||||
ack = stub.RequestControlState(pb.ControlStateRequest(
|
||||
desired=pb.ControlState.HOST_OFFLINE))
|
||||
|
||||
Reference in New Issue
Block a user