fix: conditional abort recovery — don't auto-transition on routine aborts

The unconditional transition to 'operational' after every movement abort
caused a bounce loop when MGC called abortActiveMovements on each demand
tick: abort→operational→new-flowmovement→abort→operational→... endlessly.
Pumps never reached their setpoint.

Fix: abortCurrentMovement now takes an options.returnToOperational flag
(default false). Routine MGC aborts leave the pump in accelerating/
decelerating — the pump continues its residual movement and reaches
operational naturally. Shutdown/emergency-stop paths pass
returnToOperational:true so the FSM unblocks for the stopping transition.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
znetsixe
2026-04-14 12:01:41 +02:00
parent 086e5fe751
commit 693517cc8f

View File

@@ -85,17 +85,24 @@ class state{
this.emitter.emit("movementComplete", { position: targetPosition });
await this.transitionToState("operational");
} catch (error) {
// Abort path: return to 'operational' so a subsequent shutdown/emergency
// sequence can proceed. Without this, the FSM remains stuck in
// accelerating/decelerating and blocks stopping/idle transitions.
// Abort path: only return to 'operational' when explicitly requested
// (shutdown/emergency-stop needs it to unblock the FSM). Routine MGC
// demand-update aborts must NOT auto-transition — doing so causes a
// bounce loop where every tick aborts → operational → new move →
// abort → operational → ... and the pump never reaches its setpoint.
const msg = typeof error === 'string' ? error : error?.message;
if (msg === 'Transition aborted' || msg === 'Movement aborted') {
this.logger.debug(`Movement aborted; returning to 'operational' to unblock further transitions.`);
try {
await this.transitionToState("operational");
} catch (e) {
this.logger.debug(`Post-abort transition to operational failed: ${e?.message || e}`);
if (this._returnToOperationalOnAbort) {
this.logger.debug(`Movement aborted; returning to 'operational' (requested by caller).`);
try {
await this.transitionToState("operational");
} catch (e) {
this.logger.debug(`Post-abort transition to operational failed: ${e?.message || e}`);
}
} else {
this.logger.debug(`Movement aborted; staying in current state (routine abort).`);
}
this._returnToOperationalOnAbort = false;
this.emitter.emit("movementAborted", { position: targetPosition });
} else {
this.logger.error(error);
@@ -105,9 +112,19 @@ class state{
// -------- State Transition Methods -------- //
abortCurrentMovement(reason = "group override") {
/**
* @param {string} reason - human-readable abort reason
* @param {object} [options]
* @param {boolean} [options.returnToOperational=false] - when true the FSM
* transitions back to 'operational' after the abort so a subsequent
* shutdown/emergency-stop sequence can proceed. Set to false (default)
* for routine demand updates where the caller will send a new movement
* immediately — auto-transitioning would cause a bounce loop.
*/
abortCurrentMovement(reason = "group override", options = {}) {
if (this.abortController && !this.abortController.signal.aborted) {
this.logger.warn(`Aborting movement: ${reason}`);
this._returnToOperationalOnAbort = Boolean(options.returnToOperational);
this.abortController.abort();
}
}