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:
+197
-2
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user