The duplicate-ARRIVED fix from the previous commit returned INVALID_OBJECT
with the message "no substrate 'X'" — a lie, since the substrate exists.
Rewrite ReportSubstrate so ARRIVED has its own ack mapping: a duplicate is
CANNOT_DO_NOW with "substrate 'X' already exists" (a state conflict, not a
missing object), and we never silently re-create over live FSM state.
Coverage gaps closed:
- C++: ARRIVED records carrier_id/slot (now asserted); module NOT_EXECUTING
reset transition; duplicate-ARRIVED expects CANNOT_DO_NOW.
- Interop: @eq.command now drives the real host S2F41 path (was @eq.on, so
the headline decorator had zero wire coverage); @eq.command NameError on
unknown name; eq.names var/alarm + dir() + typo-suggestion; replaced the
two `check(..., True)` tautologies with full E90 journey + AT_DESTINATION
and real error paths (ghost wafer raises, illegal module jump raises).
All 8 daemon test cases (248 assertions) and 24 pyclient interop checks pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
C++ (equipment_service.hpp):
- ReportSubstrate ARRIVED: check CreateResult and return INVALID_OBJECT
when the substrate ID already exists, instead of silently doing nothing
- ReportSubstrate/ReportModule default switch branches: return false
(→ CANNOT_DO_NOW) for unknown enum values instead of silently accepting
Python (_client.py):
- @eq.command: raise NameError (client-side name validation) instead of
SecsGemError (which means "daemon declined a request") — wrong type
- Module docstring: update example to show @eq.command / eq.names API
Test (test_daemon_service.cpp):
- Add duplicate-ARRIVED check (expects INVALID_OBJECT)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Completes the daemon's GEM300 surface and adds two new test tiers.
D10 — E87 carriers: CarrierStore gains the HandlerSlot observer pattern
(add_id/slot_map/access_handler). The daemon's id-observer forwards host
S3F17 decisions onto the Subscribe stream as CarrierAction (PROCEED on a
Confirmed transition, CANCEL on CancelCarrier); ReportCarrier drives the
flow tool-side: WAITING creates the carrier + records the slot map,
IN_ACCESS/COMPLETE advance the access FSM (INVALID_OBJECT on unknown,
CANNOT_DO_NOW on an illegal transition).
E16 — operations RPCs: Describe (full name inventory: variables/events/
alarms/commands/constants + device header), FlushSpool (purge or drain),
SendTerminalMessage (S10F1 tool->host, honest CANNOT_DO_NOW when no host
and stream 10 isn't spoolable).
Stream responsiveness: Subscribe/WatchHealth poll at 100ms (was 500ms) so a
cancelled stream frees its sync-server worker thread promptly — this was
found by the new stress test, which hung under Subscribe churn at 500ms.
Tests:
- A randomized concurrent RPC stress case: 4 threads x 250 seeded ops
(set/get/fire/alarm/control-state/describe + Subscribe churn), asserts no
failed RPC and a still-responsive engine afterward; prints its seed; a
strong TSan target.
- A virtual fab (interop/virtual_fab.py + the `fab` compose service /
tools/spawn_fab.sh): N daemons, each with a secsgem-py host AND a
secsgem_client tool, driven by seeded random traffic with end-to-end
invariant checks (set/get round-trips, event->S6F11 and alarm->S5F1
delivery, command->tool->completion). Verified green at N=3 (~150 ops/eq,
all commands round-tripped, 0 violations). Wired into run_interop.sh
(now 13 steps).
Also fixes the CI break from the previous commit: the Python-client lane's
test_values.py step lacked PYTHONPATH=clients/python (now step-level env).
Two bugs found and fixed while building this, both mine from this batch:
1. carrier test hung on a CancelCarrier of a still-NotConfirmed carrier — a
self-transition the FSM doesn't signal, so the observer never fired and
the stream Read blocked forever. Fixed to cancel a Confirmed carrier;
the NotConfirmed edge is documented as a known E87 limitation.
2. the 500ms stream poll above.
Daemon suite 7 cases / 214 assertions; core 475 / 3097; virtual fab green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Semantics settled and documented: v1 is observe-and-report. The engine keeps
acking S16/S3/S7/S2F15 from its FSM tables — exactly the behaviour both
reference implementations validated — while the tool observes lifecycle
events on the Subscribe stream and reports physical progress back. Gating
stays the documented v2 deferred-reply item.
Engine: two new store observers (HandlerSlot pattern) — RecipeStore fires
(ppid, body) after an add (S7F3 downloads), EquipmentConstantStore fires
(id, value) on ACCEPTED S2F15 writes only. Unit-tested.
Daemon: the service registers PJ/recipe/EC observers (io thread; add_
observers coexist with register_default_handlers' primaries) and fans the
new HostRequest variants out via push_request (fire-and-forget, no-
buffering contract). ProcessJob carries action (Start->START, Resume->
RESUME, Paused->PAUSE, Stopping->STOP, Aborting->ABORT) + recipe + material
bindings read store-side on the io thread. ReportProcessJob maps SETTING_UP
->SetupComplete, COMPLETE->ProcessComplete, ABORTED->AbortComplete via
read_sync; PROCESSING is informational; unknown job => INVALID_OBJECT,
table-rejected transition => CANNOT_DO_NOW. Carriers deferred (CarrierStore
has no observer machinery; ReportCarrier stays UNIMPLEMENTED) — roadmap.
Python client: on_process_job / on_recipe / on_constant_change decorators +
report_job(job_id, state); ProcessJob dataclass exported.
Tests: daemon suite 141 -> 175 assertions — the full in-process loop
(S16F11 create -> tool setup -> S16F5 PJSTART -> stream ProcessJob with
recipe+carriers -> ReportProcessJob(COMPLETE) -> FSM at ProcessComplete),
rejection paths, S7F3 -> ProcessProgram, S2F15 -> ConstantChange with the
configured name. Core 475/3097 (observer units). Live regression: daemon
interop 20 checks + pyclient 13 checks still green against the running
daemon.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Item 8a — ConfigValidator warns on non-identifier variable/event/alarm/
command names ([A-Za-z_][A-Za-z0-9_]*): language bindings expose names as
kwargs/attributes, so 'Chamber Pressure' would be unusable in the planned
Python client. Warning not error — the wire doesn't care. Tested (4 warning
sites + good-name negative).
Item 4 tail — golden frames for S5F1 (Binary ALCD / U4 ALID / ASCII ALTX)
and a composed S6F11 (the production-critical report shape), bytes hand-
computed from E5 encoding rules: external pins on message composition.
Item 7 — equipment_service.hpp moved to include/secsgem/daemon/ (apps/
include-path hack removed) and a TSan daemon lane added locally + in CI.
tools/tsan.supp suppresses races whose accesses sit entirely inside the
UNinstrumented system libgrpc/libgpr/libabsl (epoll wakeups, absl Mutex
GraphCycles bookkeeping); our frames stay fully checked. The lane earned its
keep on first run: it caught a REAL threading-contract violation — a daemon
test reading model stores from the test thread while the io thread serviced
posted writes — fixed to use read_sync, exactly per the documented contract.
Now TSan-clean under halt_on_error=1 in the full production threading shape.
Suites: core 470/3068, daemon Release+TSan 125/125 each.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>