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
+45
View File
@@ -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))