E40 Process Jobs + E94 Control Jobs + E30 communication state

GEM300 layer: SEMI E40-0705 Process Job and E94-0705 Control Job
state machines, plus the E30 §6.1 communication-state machine that
sits between HSMS SELECT and full GEM communication. Data-driven
via data/process_job_state.yaml and data/control_job_state.yaml,
mirroring the existing control_state.yaml pattern.

Wire coverage:
  S14F9/F10   CreateObject (CJ)              host -> equipment
  S14F11/F12  DeleteObject (CJ)              host -> equipment
  S16F5/F6    PRJobCommand                   host -> equipment
  S16F9       PRJobAlert                     equipment -> host
  S16F11/F12  PRJobCreate (simplified body)  host -> equipment
  S16F13/F14  PRJobDequeue                   host -> equipment
  S16F27/F28  CJobCommand                    host -> equipment

Process Job FSM exposes 8 states matching PRJOBSTATE bytes (E40 §10.3.2);
HOQ is reorder-aware (move-to-head against an insertion-order vector);
Stop/Abort on a Queued PJ routes through ABORTING so the host observes
PRJOBSTATE=7 on the wire (§6.3); alert_enabled is settable per-PJ for
PRALERT control; FSM dispatches through ProcessJobStore::on_change_
dynamically so a late set_state_change_handler() reaches existing PJs.

Hardening: loader rejects NoState (sentinel) as initial/from/to and
rejects `on: created` rows; static_asserts pin enum values to wire
bytes; ProcessJobStore is non-movable to keep the per-PJ this-capture
safe.

Server simulator cascades the full CJ -> PJ lifecycle on CJSTART so
the wire trace exercises every legal state. CEIDs 400/401 fire on CJ
state changes via the existing event-report pipeline.

Tests: 60+ new assertions across test_process_jobs, test_control_jobs,
test_communication_state, test_hsms_connection, plus loader and
messages round-trip coverage.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 21:00:32 +02:00
parent 1f67aad985
commit 90c177b7ce
33 changed files with 3122 additions and 62 deletions
+74 -2
View File
@@ -40,6 +40,8 @@ constexpr uint32_t kDataIdReports = 7;
constexpr uint32_t kRptidStatus = 1000;
constexpr uint32_t kCeidProcessStarted = 300;
constexpr uint32_t kCeidAlarmSetEvent = 200;
constexpr uint32_t kCeidCJExecuting = 400;
constexpr uint32_t kCeidCJCompleted = 401;
constexpr uint32_t kAlarmChiller = 1;
struct Sequence : std::enable_shared_from_this<Sequence> {
@@ -102,6 +104,16 @@ int main(int argc, char** argv) {
logfn("SPOOL READY: " + std::to_string(n.value_or(0)) + " queued messages");
return gem::s6f26_spool_data_ready_ack(gem::EventReportAck::Accept);
}
// S16F9: E40 PRJob alert (one-way, E->H).
if (msg.stream == 16 && msg.function == 9) {
auto a = gem::parse_s16f9(msg);
if (a) {
logfn("PJ ALERT " + a->prjobid + " state=" +
gem::process_job_state_name(a->prjobstate));
}
if (msg.reply_expected) return s2::Message(16, 0, false);
return std::nullopt;
}
// S5F1: alarm send from equipment.
if (msg.stream == 5 && msg.function == 1) {
auto a = gem::parse_s5f1(msg);
@@ -241,7 +253,9 @@ int main(int argc, char** argv) {
conn->send_request(
gem::s2f35_link_event_report(kDataIdReports,
{{kCeidProcessStarted, {kRptidStatus}},
{kCeidAlarmSetEvent, {kRptidStatus}}}),
{kCeidAlarmSetEvent, {kRptidStatus}},
{kCeidCJExecuting, {kRptidStatus}},
{kCeidCJCompleted, {kRptidStatus}}}),
[logfn, fail, next](std::error_code ec, const s2::Message& reply) {
if (ec) { fail("S2F35", ec); return; }
auto a = gem::ack_byte(reply);
@@ -253,7 +267,8 @@ int main(int argc, char** argv) {
// 8. Enable the linked CEIDs.
seq->steps.push_back([conn, logfn, fail](auto next) {
conn->send_request(
gem::s2f37_enable_event(true, {kCeidProcessStarted, kCeidAlarmSetEvent}),
gem::s2f37_enable_event(true, {kCeidProcessStarted, kCeidAlarmSetEvent,
kCeidCJExecuting, kCeidCJCompleted}),
[logfn, fail, next](std::error_code ec, const s2::Message& reply) {
if (ec) { fail("S2F37", ec); return; }
auto a = gem::ack_byte(reply);
@@ -391,6 +406,63 @@ int main(int argc, char** argv) {
});
});
// ---- E40/E94: create a PJ, wrap it in a CJ, start the CJ ----------
// 14a. S16F11 PRJobCreate PJ-1 with recipe RECIPE-A and 2 wafers.
seq->steps.push_back([conn, logfn, fail](auto next) {
conn->send_request(
gem::s16f11_pr_job_create("PJ-1", "RECIPE-A", {"WFR-1", "WFR-2"}),
[logfn, fail, next](std::error_code ec, const s2::Message& reply) {
if (ec) { fail("S16F11", ec); return; }
auto a = gem::ack_byte(reply);
logfn("S16F12 HCACK=" + (a ? std::to_string(*a) : "?"));
next();
});
});
// 14b. S14F9 CreateControlJob CJ-1 containing [PJ-1].
seq->steps.push_back([conn, logfn, fail](auto next) {
conn->send_request(
gem::s14f9_create_control_job("CJ-1", {"PJ-1"}),
[logfn, fail, next](std::error_code ec, const s2::Message& reply) {
if (ec) { fail("S14F9", ec); return; }
auto r = gem::parse_s14f10(reply);
if (r)
logfn("S14F10 CJ=" + r->ctljobid + " OBJACK=" +
std::to_string(static_cast<int>(r->ack)));
next();
});
});
// 14c. S16F27 CJSTART CJ-1 -> equipment cascades the PJ through every
// state, host sees a burst of S16F9 alerts + S6F11(CEID=400/401).
seq->steps.push_back([conn, logfn, fail](auto next) {
conn->send_request(
gem::s16f27_cj_command("CJ-1", "CJSTART"),
[logfn, fail, next](std::error_code ec, const s2::Message& reply) {
if (ec) { fail("S16F27", ec); return; }
auto a = gem::ack_byte(reply);
logfn("S16F28 HCACK=" + (a ? std::to_string(*a) : "?"));
next();
});
});
// 14d. Pace 200ms so the asynchronous S16F9 / S6F11 alerts arrive
// before we move on to terminal display.
seq->steps.push_back([pause_then](auto next) {
pause_then(std::chrono::milliseconds(200), [next] { next(); });
});
// 14e. Tidy up: delete the CJ via S14F11. The contained PJs are now
// ProcessComplete; the equipment leaves them in the store until
// explicitly dequeued (which can be wired similarly via S16F13).
seq->steps.push_back([conn, logfn, fail](auto next) {
conn->send_request(
gem::s14f11_delete_control_job("CJ-1"),
[logfn, fail, next](std::error_code ec, const s2::Message& reply) {
if (ec) { fail("S14F11", ec); return; }
auto a = gem::ack_byte(reply);
logfn("S14F12 OBJACK=" + (a ? std::to_string(*a) : "?"));
next();
});
});
// 15. Send a terminal display to the equipment.
seq->steps.push_back([conn, logfn, fail](auto next) {
conn->send_request(gem::s10f1_terminal_display_single(0, "Hello equipment!"),
+197 -2
View File
@@ -91,7 +91,14 @@ int main(int argc, char** argv) {
logfn("spool: " + what + " dropped (stream not spoolable, no host)");
return false;
}
conn->send_request(std::move(msg), [](std::error_code, const s2::Message&) {});
// W=1 primaries use send_request (transaction tracking); W=0 primaries
// (e.g. S16F9 PRJobAlert) go via send_data so we don't register a
// never-arriving "reply" and time out on T3.
if (msg.reply_expected) {
conn->send_request(std::move(msg), [](std::error_code, const s2::Message&) {});
} else {
conn->send_data(std::move(msg));
}
return true;
};
@@ -133,6 +140,98 @@ int main(int argc, char** argv) {
if (desc.emit_on_control_change) emit_event(*desc.emit_on_control_change);
});
// ---- E40/E94: load the PJ/CJ transition tables and wire emitters -----
const auto pj_state_yaml = arg(argc, argv, "--pj-state-table",
"/app/data/process_job_state.yaml");
const auto cj_state_yaml = arg(argc, argv, "--cj-state-table",
"/app/data/control_job_state.yaml");
config::ProcessJobStateConfig pj_cfg;
config::ControlJobStateConfig cj_cfg;
try {
pj_cfg = config::load_process_job_state(pj_state_yaml);
cj_cfg = config::load_control_job_state(cj_state_yaml);
} catch (const std::exception& e) {
std::cerr << "[equip] E40/E94 config error: " << e.what() << std::endl;
return 1;
}
// Each new PJ/CJ gets a fresh copy of the loaded transition table.
model->process_jobs.set_table_factory([t = pj_cfg.table]() { return t; });
model->control_jobs.set_table_factory([t = cj_cfg.table]() { return t; });
// Emit S16F9 PRJobAlert (E40-0705 §10.3). Equipment-initiated; the spec
// is silent on reply expectation, so we send it as a primary one-way.
auto emit_pj_alert = [&io, model, logfn, deliver_or_spool](
const std::string& prjobid,
gem::ProcessJobState state) {
asio::post(io, [model, logfn, deliver_or_spool, prjobid, state]() {
const auto* pj = model->process_jobs.get(prjobid);
if (pj && !pj->alert_enabled) return;
logfn("emit S16F9 PJ=" + prjobid + " state=" +
gem::process_job_state_name(state));
deliver_or_spool(gem::s16f9_pr_job_alert(prjobid, state),
"S16F9 PJ=" + prjobid);
});
};
// PJ state-change handler: log + emit S16F9 (skip the synthetic
// NoState->Queued so we don't alert on a freshly-created PJ that's
// still being acked).
model->process_jobs.set_state_change_handler(
[logfn, emit_pj_alert](const std::string& prjobid,
gem::ProcessJobState from,
gem::ProcessJobState to,
gem::ProcessJobEvent trig) {
logfn(std::string("PJ ") + prjobid + ": " +
gem::process_job_state_name(from) + " -> " +
gem::process_job_state_name(to) + " (" +
gem::process_job_event_name(trig) + ")");
if (from != gem::ProcessJobState::NoState) emit_pj_alert(prjobid, to);
});
// CEIDs the equipment.yaml is expected to register for CJ state
// changes (best-effort: if missing they're silently no-ops via the
// existing CEID-not-enabled guard in emit_event).
constexpr uint32_t kCeidCJExecuting = 400;
constexpr uint32_t kCeidCJCompleted = 401;
model->control_jobs.set_state_change_handler(
[logfn, emit_event](const std::string& ctljobid,
gem::ControlJobState from,
gem::ControlJobState to,
gem::ControlJobEvent trig) {
logfn(std::string("CJ ") + ctljobid + ": " +
gem::control_job_state_name(from) + " -> " +
gem::control_job_state_name(to) + " (" +
gem::control_job_event_name(trig) + ")");
if (to == gem::ControlJobState::Executing) emit_event(kCeidCJExecuting);
if (to == gem::ControlJobState::Completed) emit_event(kCeidCJCompleted);
});
// Drive the contained PJs through the demo lifecycle when the host
// tells the CJ to start. Real equipment would step PJs one at a time
// as material arrives; our simulator cascades through every legal
// state so the wire trace exercises the whole FSM.
auto run_cj_lifecycle = [&io, model, logfn](const std::string& ctljobid) {
asio::post(io, [model, logfn, ctljobid]() {
auto* cj = model->control_jobs.get(ctljobid);
if (!cj) return;
// CJ promotion path: Queued -> Selected -> WaitingForStart -> Executing.
model->control_jobs.fire_internal(ctljobid, gem::ControlJobEvent::Select);
model->control_jobs.fire_internal(ctljobid, gem::ControlJobEvent::SetupComplete);
model->control_jobs.fire_internal(ctljobid, gem::ControlJobEvent::Start);
// Cascade every contained PJ through to ProcessComplete.
for (const auto& pjid : cj->prjobids) {
model->process_jobs.fire_internal(pjid, gem::ProcessJobEvent::Select);
model->process_jobs.fire_internal(pjid, gem::ProcessJobEvent::SetupComplete);
model->process_jobs.fire_internal(pjid, gem::ProcessJobEvent::Start);
model->process_jobs.fire_internal(pjid, gem::ProcessJobEvent::ProcessComplete);
}
// All PJs done -> CJ Completed.
model->control_jobs.fire_internal(ctljobid, gem::ControlJobEvent::AllJobsComplete);
logfn("CJ " + ctljobid + " lifecycle complete");
});
};
// ---- Build the SECS dispatch table once -------------------------------
gem::Router router;
@@ -367,7 +466,11 @@ int main(int argc, char** argv) {
auto conn = active_conn->lock();
if (!conn) return;
for (auto& m : drained) {
conn->send_request(std::move(m), [](std::error_code, const s2::Message&) {});
const bool w = m.reply_expected;
if (w)
conn->send_request(std::move(m), [](std::error_code, const s2::Message&) {});
else
conn->send_data(std::move(m));
}
});
return gem::s6f24_request_spool_data_ack(gem::SpoolRequestAck::Accept);
@@ -426,6 +529,98 @@ int main(int argc, char** argv) {
return gem::s7f20_current_eppd_data(list);
});
// ---- E40 / E94 -------------------------------------------------------
router.on(14, 9, [model, logfn, run_cj_lifecycle](const s2::Message& msg) {
(void)run_cj_lifecycle;
auto req = gem::parse_s14f9(msg);
if (!req) {
logfn("S14F9 -> S14F10 Error (malformed body)");
return gem::s14f10_create_control_job_ack("", gem::ObjectAck::Error);
}
auto r = model->control_jobs.create(
req->ctljobid, req->prjobids,
[model](const std::string& id) { return model->process_jobs.has(id); });
gem::ObjectAck ack = gem::ObjectAck::Success;
switch (r) {
case gem::ControlJobStore::CreateResult::Created: ack = gem::ObjectAck::Success; break;
case gem::ControlJobStore::CreateResult::Denied_AlreadyExists:
ack = gem::ObjectAck::Denied_AlreadyExists; break;
case gem::ControlJobStore::CreateResult::Denied_UnknownPRJob:
ack = gem::ObjectAck::Denied_UnknownObject; break;
case gem::ControlJobStore::CreateResult::Denied_Empty:
ack = gem::ObjectAck::Denied_InvalidAttribute; break;
}
logfn("S14F9 CJ=" + req->ctljobid + " -> S14F10 OBJACK=" +
std::to_string(static_cast<int>(ack)));
return gem::s14f10_create_control_job_ack(req->ctljobid, ack);
});
router.on(14, 11, [model, logfn](const s2::Message& msg) {
auto id = gem::parse_s14f11(msg);
if (!id) return gem::s14f12_delete_control_job_ack(gem::ObjectAck::Error);
const auto removed = model->control_jobs.remove(*id);
logfn("S14F11 delete CJ=" + *id + " -> S14F12 " +
(removed ? "Success" : "UnknownObject"));
return gem::s14f12_delete_control_job_ack(
removed ? gem::ObjectAck::Success : gem::ObjectAck::Denied_UnknownObject);
});
router.on(16, 11, [model, logfn](const s2::Message& msg) {
auto req = gem::parse_s16f11(msg);
if (!req) return gem::s16f12_pr_job_create_ack(gem::HostCmdAck::ParameterInvalid);
auto r = model->process_jobs.create(
req->prjobid, req->ppid, req->mtrloutspec,
[model](const std::string& ppid) {
return model->recipes.get(ppid).has_value();
});
gem::HostCmdAck ack = gem::HostCmdAck::Accept;
switch (r) {
case gem::ProcessJobStore::CreateResult::Created: ack = gem::HostCmdAck::Accept; break;
case gem::ProcessJobStore::CreateResult::Denied_AlreadyExists:
ack = gem::HostCmdAck::Rejected; break;
case gem::ProcessJobStore::CreateResult::Denied_InvalidPpid:
ack = gem::HostCmdAck::ParameterInvalid; break;
}
logfn("S16F11 PJ=" + req->prjobid + " PPID=" + req->ppid +
" -> S16F12 HCACK=" + std::to_string(static_cast<int>(ack)));
return gem::s16f12_pr_job_create_ack(ack);
});
router.on(16, 13, [model, logfn](const s2::Message& msg) {
auto id = gem::parse_s16f13(msg);
auto ack = id ? model->process_jobs.dequeue(*id) : gem::HostCmdAck::ParameterInvalid;
logfn("S16F13 PJ=" + (id ? *id : std::string{"?"}) +
" -> S16F14 HCACK=" + std::to_string(static_cast<int>(ack)));
return gem::s16f14_pr_job_dequeue_ack(ack);
});
router.on(16, 5, [model, logfn](const s2::Message& msg) {
auto req = gem::parse_s16f5(msg);
if (!req) return gem::s16f6_pr_job_command_ack(gem::HostCmdAck::ParameterInvalid);
auto ev = gem::pr_cmd_to_event(req->prcmd);
if (!ev) return gem::s16f6_pr_job_command_ack(gem::HostCmdAck::InvalidCommand);
auto ack = model->process_jobs.on_host_command(req->prjobid, *ev);
logfn("S16F5 PJ=" + req->prjobid + " " + req->prcmd +
" -> S16F6 HCACK=" + std::to_string(static_cast<int>(ack)));
return gem::s16f6_pr_job_command_ack(ack);
});
router.on(16, 27, [model, logfn, run_cj_lifecycle](const s2::Message& msg) {
auto req = gem::parse_s16f27(msg);
if (!req) return gem::s16f28_cj_command_ack(gem::HostCmdAck::ParameterInvalid);
auto ev = gem::ctl_cmd_to_event(req->ctljobcmd);
if (!ev) return gem::s16f28_cj_command_ack(gem::HostCmdAck::InvalidCommand);
// CJSTART semantics: implicit Select -> SetupComplete -> Start
// cascade so a Queued CJ can be started in one host action. The
// cascade is the equipment policy; the FSM rules still gate every
// step.
auto ack = gem::HostCmdAck::Accept;
if (*ev == gem::ControlJobEvent::Start) {
run_cj_lifecycle(req->ctljobid);
} else {
ack = model->control_jobs.on_host_command(req->ctljobid, *ev);
}
logfn("S16F27 CJ=" + req->ctljobid + " " + req->ctljobcmd +
" -> S16F28 HCACK=" + std::to_string(static_cast<int>(ack)));
return gem::s16f28_cj_command_ack(ack);
});
router.on(10, 1, [logfn](const s2::Message& msg) {
auto td = gem::parse_s10f1(msg);
if (td) logfn("TERMINAL[" + std::to_string(td->tid) + "] " + td->text);