Skip to content

Commit f0f88bb

Browse files
committed
enforce mutually exclusive start events on recover
1 parent 80ec068 commit f0f88bb

18 files changed

Lines changed: 303 additions & 41 deletions

AGENTS.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ This file provides guidance to coding agents (Claude Code, and any tool that rea
66

77
- **TDD is the default.** Red → green → refactor: write or adjust a failing test before changing implementation. Don't delete or weaken existing assertions to land a change — extend them.
88
- **Performance and coverage are the project's USP.** Avoid regressions in either. On hot paths (broker dispatch, flow traversal, activity activation, joins, multi-instance loops), prefer existing `Context` Maps/refs over rebuilt scans, and avoid per-message allocations/closures where they can be hoisted.
9+
- **JSDoc is concise.** Short intent descriptions are fine; never describe internal implementation.
910
- Before declaring done: `npm test` (full suite + lint + `dist` rebuild). For coverage-sensitive work, also `npm run cov:html`.
1011

1112
## Commands
@@ -46,6 +47,8 @@ An element type like `ServiceTask` is not a class. It is a factory function that
4647

4748
When an activity is activated, `ActivityExecution` instantiates the Behaviour and calls its `execute`. To replace an element type entirely, supply a new Behaviour — see `docs/Extend.md`.
4849

50+
To identify an element's kind at runtime, compare its `Behaviour` (`entity.Behaviour === StartEvent`) rather than the `type` string — type strings can be customized via the `types` extension.
51+
4952
### `Context` and `Environment`
5053

5154
- `src/Context.js` is a per-execution **registry and lazy factory**. It stores activities, flows, and processes in `refs` Maps and instantiates them on first access via `upsertActivity` / `upsertSequenceFlow` / `upsertProcess`. It bridges the parsed moddle context (from `bpmn-moddle` via `moddle-context-serializer`) to runtime instances and wires extensions through `ExtensionsMapper`. Contexts are cheap to clone and are isolated per execution scope.
@@ -62,10 +65,17 @@ Documented in `docs/Extend.md` and `docs/Extension.md`:
6265
1. **Replace a Behaviour** by passing `{ types: { 'bpmn:StartEvent': MyStartEvent } }` to `Definition`. Use when you need full control over an element's execution.
6366
2. **Non-invasive extension hooks** via `{ extensions: { myExt(activity, context) { … } } }`. Each extension runs once per activity after instantiation and typically attaches listeners or publishes format messages — used for cross-cutting concerns (forms, logging, output capture).
6467

68+
### State & behavioral invariants
69+
70+
- **No flow discards.** Outbound sequence flows are never discarded; flow and activity `discarded` counters stay `0`. There is no `skipDiscard` setting. Parallel joins rely on cached gateway peers, not on discarded flows.
71+
- **Multiple start events are mutually exclusive entry points.** The first start event to fire discards the others still armed, so two start branches can never both run.
72+
- **`stateVersion`.** `Definition.getState()` stamps `stateVersion` (the package major, hardcoded in `src/constants.js`); recovering an older major triggers migrations (e.g. start event reconciliation on resume). Unstamped legacy states are treated as version `0`. Bump the constant on each major release.
73+
6574
## Testing patterns
6675

6776
- Framework: mocha + `mocha-cakes-2` BDD UI. `Feature` / `Scenario` / `Given` / `When` / `Then` / `And` / `But` are globals in test files (declared in `eslint.config.js`). Chai `expect` is registered globally via `test/helpers/setup.js`.
6877
- Layout: scenario-style coverage in `test/feature/*.js`; unit tests mirror the `src/` directory tree (`test/activity`, `test/process`, `test/gateways`, `test/tasks`, `test/eventDefinitions`, `test/flows`, …).
6978
- BPMN sources: raw XML templates in `test/helpers/factory.js` (helpers like `factory.valid()`, `factory.userTask()`, `factory.resource('name')`) plus `.bpmn` files under `test/resources/`.
7079
- Primary helper: `test/helpers/testHelpers.js``context(source, options)` parses BPMN via `bpmn-moddle`, serializes via `moddle-context-serializer`, and returns a runtime `Context`. Also exposes `Logger`, `emptyContext`, and `AssertMessage` for asserting broker message sequences.
7180
- `test/helpers/JavaScripts.js` is a mock Scripts engine for isolating ScriptTask tests.
81+
- Don't assert on logging — captured `logger.warn`/`debug` output is not part of the tested contract.

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# Changelog
22

3+
## v18.0.1 - 2026-06-13
4+
5+
### Fixes
6+
7+
- enforce mutually exclusive start events on recover: a recovered state where one entry point already won, or a legacy state serialized before the `isStartEvent` flag existed, now correctly discards the start events still left armed instead of resuming them as live entry points
8+
9+
### Additions
10+
11+
- serialized definition state is stamped with a `stateVersion` tracking the package major; recovering an older major (legacy unstamped states are treated as version `0`) triggers migrations such as the start event reconciliation above
12+
313
## v18.0.0 - 2026-06-11
414

515
Refactor parallel converging and forking gateways, and treat multiple start events as mutually exclusive entry points. As a result of the parallel gateway keeping track of peers there is no need for discarding a sequence flows.

dist/constants.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
Object.defineProperty(exports, "__esModule", {
44
value: true
55
});
6-
exports.K_TARGETS = exports.K_STOPPED = exports.K_STATUS = exports.K_STATE_MESSAGE = exports.K_REFERENCE_INFO = exports.K_REFERENCE_ELEMENT = exports.K_MESSAGE_Q = exports.K_MESSAGE_HANDLERS = exports.K_EXTENSIONS = exports.K_EXECUTION = exports.K_EXECUTE_MESSAGE = exports.K_COUNTERS = exports.K_CONSUMING = exports.K_COMPLETED = exports.K_ACTIVATED = void 0;
6+
exports.STATE_VERSION = exports.K_TARGETS = exports.K_STOPPED = exports.K_STATUS = exports.K_STATE_MESSAGE = exports.K_REFERENCE_INFO = exports.K_REFERENCE_ELEMENT = exports.K_MESSAGE_Q = exports.K_MESSAGE_HANDLERS = exports.K_EXTENSIONS = exports.K_EXECUTION = exports.K_EXECUTE_MESSAGE = exports.K_COUNTERS = exports.K_CONSUMING = exports.K_COMPLETED = exports.K_ACTIVATED = void 0;
77
const K_ACTIVATED = exports.K_ACTIVATED = Symbol.for('activated');
88
const K_COMPLETED = exports.K_COMPLETED = Symbol.for('completed');
99
const K_CONSUMING = exports.K_CONSUMING = Symbol.for('consuming');
@@ -18,4 +18,10 @@ const K_REFERENCE_INFO = exports.K_REFERENCE_INFO = Symbol.for('referenceInfo');
1818
const K_STATE_MESSAGE = exports.K_STATE_MESSAGE = Symbol.for('stateMessage');
1919
const K_STATUS = exports.K_STATUS = Symbol.for('status');
2020
const K_STOPPED = exports.K_STOPPED = Symbol.for('stopped');
21-
const K_TARGETS = exports.K_TARGETS = Symbol.for('targets');
21+
const K_TARGETS = exports.K_TARGETS = Symbol.for('targets');
22+
23+
/**
24+
* State version. Tracks the package major; bump on each major. Recovering an older major triggers
25+
* migrations. Unstamped legacy states are treated as version 0.
26+
*/
27+
const STATE_VERSION = exports.STATE_VERSION = 18;

dist/definition/Definition.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ Definition.prototype.resume = function resume(callback) {
176176
*/
177177
Definition.prototype.getState = function getState() {
178178
return this._createMessage({
179+
stateVersion: _constants.STATE_VERSION,
179180
status: this.status,
180181
stopped: this.stopped,
181182
counters: this.counters,
@@ -194,6 +195,10 @@ Definition.prototype.getState = function getState() {
194195
Definition.prototype.recover = function recover(state) {
195196
if (this.isRunning) throw new Error('cannot recover running definition');
196197
if (!state) return this;
198+
const recoveredVersion = state.stateVersion || 0;
199+
if (recoveredVersion !== _constants.STATE_VERSION) {
200+
this.logger.debug(`<${this.id}> recover state version ${recoveredVersion} into runtime state version ${_constants.STATE_VERSION}`);
201+
}
197202
this[_constants.K_STOPPED] = !!state.stopped;
198203
this[_constants.K_STATUS] = state.status;
199204
const exec = this[_constants.K_EXECUTION];
@@ -206,7 +211,7 @@ Definition.prototype.recover = function recover(state) {
206211
}
207212
this.environment.recover(state.environment);
208213
if (state.execution) {
209-
exec.set('execution', new _DefinitionExecution.DefinitionExecution(this, this.context).recover(state.execution));
214+
exec.set('execution', new _DefinitionExecution.DefinitionExecution(this, this.context).recover(state.execution, recoveredVersion));
210215
}
211216
this.broker.recover(state.broker);
212217
return this;

dist/definition/DefinitionExecution.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,9 +186,10 @@ DefinitionExecution.prototype.resume = function resume() {
186186
/**
187187
* Restore execution state captured by getState. Reinstates running processes from the snapshot.
188188
* @param {import('#types').DefinitionExecutionState} [state]
189+
* @param {number} [recoveredVersion] State version
189190
* @returns {this}
190191
*/
191-
DefinitionExecution.prototype.recover = function recover(state) {
192+
DefinitionExecution.prototype.recover = function recover(state, recoveredVersion) {
192193
if (!state) return this;
193194
this.executionId = state.executionId;
194195
this[_constants.K_STOPPED] = state.stopped;
@@ -208,7 +209,7 @@ DefinitionExecution.prototype.recover = function recover(state) {
208209
}
209210
if (!bp) continue;
210211
ids.add(bpid);
211-
bp.recover(bpState);
212+
bp.recover(bpState, recoveredVersion);
212213
running.add(bp);
213214
}
214215
return this;

dist/gateways/ParallelGateway.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ function ParallelGateway(activityDef, context) {
3737
const cachedPeers = context.getShakenPeers(id);
3838
if (cachedPeers) {
3939
for (const [flowId, sourceIds] of cachedPeers) {
40-
const peer = peers.get(flowId);
40+
let peer = peers.get(flowId);
41+
if (!peer) peers.set(flowId, peer = new Set());
4142
for (const sourceId of sourceIds) peer.add(sourceId);
4243
}
4344
activity[K_PEERS_DISCOVERED] = true;

dist/process/Process.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,10 +191,11 @@ Process.prototype.getState = function getState() {
191191
/**
192192
* Restore process state captured by getState.
193193
* @param {import('#types').ProcessState} [state]
194+
* @param {number} [recoveredVersion] State version
194195
* @returns {this}
195196
* @throws {Error} when called on a running process
196197
*/
197-
Process.prototype.recover = function recover(state) {
198+
Process.prototype.recover = function recover(state, recoveredVersion) {
198199
if (this.isRunning) throw new Error(`cannot recover running process <${this.id}>`);
199200
if (!state) return this;
200201
this[_constants.K_STOPPED] = !!state.stopped;
@@ -207,7 +208,7 @@ Process.prototype.recover = function recover(state) {
207208
};
208209
this.environment.recover(state.environment);
209210
if (state.execution) {
210-
exec.set('execution', new _ProcessExecution.ProcessExecution(this, this.context).recover(state.execution));
211+
exec.set('execution', new _ProcessExecution.ProcessExecution(this, this.context).recover(state.execution, recoveredVersion));
211212
}
212213
this.broker.recover(state.broker);
213214
return this;

dist/process/ProcessExecution.js

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const K_ELEMENTS = Symbol.for('elements');
1414
const K_PARENT = Symbol.for('parent');
1515
const K_TRACKER = Symbol.for('activity tracker');
1616
const K_PEERS_DISCOVERED = Symbol.for('peers discovered');
17+
const K_RECOVERED_VERSION = Symbol.for('recovered version');
1718

1819
/**
1920
* Drives the execution of a single process or sub-process: activates children, routes activity
@@ -163,6 +164,8 @@ ProcessExecution.prototype.resume = function resume() {
163164
activity.resume();
164165
}
165166
if (this[_constants.K_COMPLETED]) return;
167+
this._reconcileStartEvents();
168+
if (this[_constants.K_COMPLETED]) return;
166169
if (!postponed.size && status === 'executing') return this._complete('completed');
167170
};
168171

@@ -208,11 +211,13 @@ ProcessExecution.prototype.getState = function getState() {
208211
/**
209212
* Restore execution state captured by getState.
210213
* @param {import('#types').ProcessExecutionState} [state]
214+
* @param {number} [recoveredVersion] State version
211215
* @returns {this}
212216
*/
213-
ProcessExecution.prototype.recover = function recover(state) {
217+
ProcessExecution.prototype.recover = function recover(state, recoveredVersion) {
214218
if (!state) return this;
215219
this.executionId = state.executionId;
220+
this[K_RECOVERED_VERSION] = recoveredVersion;
216221
this[_constants.K_STOPPED] = state.stopped;
217222
this[_constants.K_COMPLETED] = state.completed;
218223
this[_constants.K_STATUS] = state.status;
@@ -740,16 +745,9 @@ ProcessExecution.prototype._onChildMessage = function onChildMessage(routingKey,
740745
}
741746
case 'activity.end':
742747
{
743-
if (!content.isStartEvent) break;
744-
const elements = this[K_ELEMENTS];
745-
if (elements.startEventCount <= 1) break;
746-
const startPeers = new Set();
747-
for (const msg of elements.postponed) {
748-
const peerId = msg.content.id;
749-
if (peerId !== content.id && msg.content.isStartEvent) startPeers.add(msg);
750-
}
751-
elements.startEventCount = 0;
752-
for (const msg of startPeers) this._getChildApi(msg).discard();
748+
if (!(content.isStartEvent || this.getActivityById(content.id)?.isStartEvent)) break;
749+
if (this[K_ELEMENTS].startEventCount <= 1) break;
750+
this._discardArmedStartEvents(content.id);
753751
break;
754752
}
755753
case 'activity.error':
@@ -1041,6 +1039,42 @@ ProcessExecution.prototype._getChildById = function getChildById(childId) {
10411039
return this.getActivityById(childId) || this._getFlowById(childId);
10421040
};
10431041

1042+
/**
1043+
* Discard the other armed start events once one mutually exclusive entry point wins.
1044+
* Resolves the start-event flag from the live activity so recovered pre-flag state is handled.
1045+
* @internal
1046+
*/
1047+
ProcessExecution.prototype._discardArmedStartEvents = function discardArmedStartEvents(winnerId) {
1048+
const elements = this[K_ELEMENTS];
1049+
const startPeers = [];
1050+
for (const msg of elements.postponed) {
1051+
const peerId = msg.content.id;
1052+
if (peerId === winnerId) continue;
1053+
if (this.getActivityById(peerId)?.isStartEvent) startPeers.push(msg);
1054+
}
1055+
if (!startPeers.length) return;
1056+
elements.startEventCount = 0;
1057+
for (const msg of startPeers) this._getChildApi(msg).discard();
1058+
};
1059+
1060+
/**
1061+
* On resume of a state from an older major, discard start events left armed when another entry
1062+
* point already won before recovery. The winning start event's `activity.end` cannot replay, so
1063+
* the live discard trigger never fires.
1064+
* @internal
1065+
*/
1066+
ProcessExecution.prototype._reconcileStartEvents = function reconcileStartEvents() {
1067+
const elements = this[K_ELEMENTS];
1068+
if (elements.startEventCount <= 1) return;
1069+
if (!(this[K_RECOVERED_VERSION] < _constants.STATE_VERSION)) return;
1070+
for (const child of elements.children) {
1071+
if (child.isStartEvent && child.counters.taken) {
1072+
this._discardArmedStartEvents();
1073+
return;
1074+
}
1075+
}
1076+
};
1077+
10441078
/** @internal */
10451079
ProcessExecution.prototype._getChildApi = function getChildApi(message) {
10461080
const content = message.content;

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "bpmn-elements",
3-
"version": "18.0.0",
3+
"version": "18.0.1",
44
"description": "Executable workflow elements based on BPMN 2.0",
55
"type": "module",
66
"main": "./dist/index.js",

src/constants.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,9 @@ export const K_STATE_MESSAGE = Symbol.for('stateMessage');
1313
export const K_STATUS = Symbol.for('status');
1414
export const K_STOPPED = Symbol.for('stopped');
1515
export const K_TARGETS = Symbol.for('targets');
16+
17+
/**
18+
* State version. Tracks the package major; bump on each major. Recovering an older major triggers
19+
* migrations. Unstamped legacy states are treated as version 0.
20+
*/
21+
export const STATE_VERSION = 18;

0 commit comments

Comments
 (0)