@kiberon-labs/behave-graph-suspendable 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,13 @@
1
+ # Behave-Graph Suspendable
2
+
3
+ This is an extension to behaviour graphs to implement a suspendable engine. While behaviour graphs are great, they have issues with serializing them during an existing execution. This implements a suspendable engine, which relies on nodes implementing the suspendable interface. The engine can be suspended mid execution, serialized and then unsuspended.
4
+
5
+ This is a work in progress as there are a number of edge cases that need to be accounted for, namely serialzing nodes that don't implement the required interface as well as hydration issues.
6
+
7
+ To use it you can do the following :
8
+
9
+ ```ts
10
+ import
11
+
12
+ ```
13
+
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@kiberon-labs/behave-graph-suspendable",
3
+ "description": "An auxiliary engine for the behaviour graph that can be suspended mid execution.",
4
+ "version": "1.0.1",
5
+ "type": "module",
6
+ "types": "./dist/index.d.ts",
7
+ "main": "./dist/index.js",
8
+ "source": "./src/index.ts",
9
+ "author": "kiberonlabsdev",
10
+ "scripts": {
11
+ "build": "tsdown",
12
+ "check": "oxfmt --check",
13
+ "format": "oxfmt",
14
+ "test": "vitest --passWithNoTests",
15
+ "dev": "tsdown --watch src",
16
+ "lint": "oxlint",
17
+ "typecheck": "tsc"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/kiberon-labs/behave-graph",
22
+ "directory": "packages/scene"
23
+ },
24
+ "bugs": "https://github.com/kiberon-labs/behave-graph/issues",
25
+ "devDependencies": {
26
+ "@kiberon-labs/behave-graph": "workspace:*",
27
+ "@types/node": "^24.10.1",
28
+ "oxfmt": "^0.14.0",
29
+ "oxlint": "^1.29.0",
30
+ "tsdown": "^0.16.5",
31
+ "vitest": "^4.0.10"
32
+ },
33
+ "dependencies": {},
34
+ "license": "ISC",
35
+ "peerDependencies": {
36
+ "@kiberon-labs/behave-graph": "workspace:*"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ }
41
+ }
package/src/engine.ts ADDED
@@ -0,0 +1,237 @@
1
+ import {
2
+ Assert,
3
+ Engine,
4
+ EventEmitter,
5
+ isAsyncNode,
6
+ isEventNode,
7
+ Link,
8
+ isFlowNode,
9
+ type FiberListenerInner,
10
+ type GraphInstance,
11
+ type INode,
12
+ type IRegistry
13
+ } from '@kiberon-labs/behave-graph';
14
+ import { SuspendableFiber } from './fiber';
15
+ import { isSuspendable, type SerializedSuspension } from './types';
16
+
17
+ type StatefulNode = INode & {
18
+ getState(): unknown;
19
+ setState(value: unknown): void;
20
+ };
21
+
22
+ /** Concrete nodes carry getState/setState (from the Node base class) even
23
+ * though INode does not declare them. */
24
+ const hasNodeState = (node: INode): node is StatefulNode => {
25
+ const candidate = node as Partial<StatefulNode>;
26
+ return (
27
+ typeof candidate.getState === 'function' &&
28
+ typeof candidate.setState === 'function'
29
+ );
30
+ };
31
+
32
+ export class SuspendableEngine extends Engine {
33
+ protected override fiberQueue: SuspendableFiber[] = [];
34
+ public readonly onSuspension = new EventEmitter<SerializedSuspension>();
35
+
36
+ constructor(graph: GraphInstance, registry: IRegistry) {
37
+ super(graph, registry);
38
+ }
39
+
40
+ //Note this is a weak point in the system to completely override the existing logic
41
+ override commitToNewFiber(
42
+ node: INode,
43
+ outputFlowSocketName: string,
44
+ fiberCompletedListener: FiberListenerInner = undefined
45
+ ) {
46
+ try {
47
+ Assert.mustBeTrue(isEventNode(node) || isAsyncNode(node));
48
+ const outputSocket = node.outputs.find(
49
+ (socket) => socket.name === outputFlowSocketName
50
+ );
51
+ if (outputSocket === undefined) {
52
+ throw new Error(`no socket with the name ${outputFlowSocketName}`);
53
+ }
54
+ if (outputSocket.links.length > 1) {
55
+ throw new Error(
56
+ 'invalid for an output flow socket to have multiple downstream links:' +
57
+ `${node.description.typeName}.${outputSocket.name} has ${outputSocket.links.length} downlinks`
58
+ );
59
+ }
60
+ if (outputSocket.links.length === 1) {
61
+ const fiber = new SuspendableFiber(
62
+ this,
63
+ outputSocket.links[0]!,
64
+ fiberCompletedListener
65
+ );
66
+ this.onNodeCommit.emit({ node, socket: outputFlowSocketName });
67
+
68
+ this.fiberQueue.push(fiber);
69
+ }
70
+ } catch (error) {
71
+ this.onNodeExecutionError.emit({ node, error });
72
+ throw error;
73
+ }
74
+ }
75
+
76
+ commitContinuedFiber(node: INode) {
77
+ const fiber = new SuspendableFiber(this, new Link(node.id, 'flow'));
78
+ this.fiberQueue.push(fiber);
79
+ }
80
+
81
+ suspend(): SerializedSuspension {
82
+ const serializedNodes: Record<string, any> = {};
83
+ Object.entries(this.nodes).forEach(([id, node]) => {
84
+ if (isSuspendable(node)) {
85
+ serializedNodes[id] = node.suspend();
86
+ return;
87
+ }
88
+ // Flow nodes that keep their cursor in node state (e.g. flow/forLoop,
89
+ // flow/sequence, flow/counter) are captured generically. Event/async
90
+ // node state can hold live listeners or timers, so those must opt in
91
+ // via ISuspendable instead.
92
+ if (isFlowNode(node) && hasNodeState(node)) {
93
+ const state = node.getState();
94
+ if (state !== undefined) {
95
+ serializedNodes[id] = structuredClone(state);
96
+ }
97
+ }
98
+ });
99
+
100
+ const serializedFiberQueue = this.fiberQueue.map((fiber) =>
101
+ fiber.serialize()
102
+ );
103
+
104
+ // Snapshot the downstream links of each node's output sockets so that
105
+ // connections made at runtime survive the suspension round-trip.
106
+ const sockets: SerializedSuspension['sockets'] = {};
107
+ Object.entries(this.nodes).forEach(([id, node]) => {
108
+ const linkedOutputs: SerializedSuspension['sockets'][string] = {};
109
+ node.outputs.forEach((socket) => {
110
+ if (socket.links.length > 0) {
111
+ linkedOutputs[socket.name] = socket.links.map((link) => ({
112
+ nodeId: link.nodeId,
113
+ socketName: link.socketName
114
+ }));
115
+ }
116
+ });
117
+ if (Object.keys(linkedOutputs).length > 0) {
118
+ sockets[id] = linkedOutputs;
119
+ }
120
+ });
121
+
122
+ // Snapshot non-flow output socket values: resumed flows may read an
123
+ // upstream output (e.g. a loop's `index`) before that node re-executes.
124
+ const socketValues: SerializedSuspension['socketValues'] = {};
125
+ Object.entries(this.nodes).forEach(([id, node]) => {
126
+ const values: Record<string, any> = {};
127
+ node.outputs.forEach((socket) => {
128
+ if (socket.valueTypeName === 'flow' || socket.value === undefined) {
129
+ return;
130
+ }
131
+ const valueType = this.registry.values[socket.valueTypeName];
132
+ values[socket.name] = valueType
133
+ ? valueType.serialize(socket.value)
134
+ : socket.value;
135
+ });
136
+ if (Object.keys(values).length > 0) {
137
+ socketValues[id] = values;
138
+ }
139
+ });
140
+
141
+ const variables: SerializedSuspension['variables'] = {};
142
+ Object.entries(this.graph.variables).forEach(([id, variable]) => {
143
+ const valueType = this.registry.values[variable.valueTypeName];
144
+ variables[id] = {
145
+ type: variable.valueTypeName,
146
+ value: valueType ? valueType.serialize(variable.get()) : variable.get()
147
+ };
148
+ });
149
+
150
+ return {
151
+ fiberQueue: serializedFiberQueue,
152
+ nodes: serializedNodes,
153
+ sockets,
154
+ socketValues,
155
+ variables
156
+ };
157
+ }
158
+
159
+ unsuspend(suspension: SerializedSuspension, continuanceData: any) {
160
+ Object.entries(suspension.variables).forEach(([id, serialized]) => {
161
+ const variable = this.graph.variables[id];
162
+ if (!variable) {
163
+ throw new Error(`Could not find missing variable ${id}`);
164
+ }
165
+ const valueType = this.registry.values[serialized.type];
166
+ variable.set(
167
+ valueType ? valueType.deserialize(serialized.value) : serialized.value
168
+ );
169
+ });
170
+
171
+ Object.entries(suspension.sockets).forEach(([nodeId, linkedOutputs]) => {
172
+ const node = this.nodes[nodeId];
173
+ if (!node) {
174
+ throw new Error(`Could not find missing node ${nodeId}`);
175
+ }
176
+ Object.entries(linkedOutputs).forEach(([socketName, links]) => {
177
+ const socket = node.outputs.find((s) => s.name === socketName);
178
+ if (!socket) {
179
+ throw new Error(
180
+ `Could not find missing socket ${socketName} on node ${nodeId}`
181
+ );
182
+ }
183
+ socket.links.splice(
184
+ 0,
185
+ socket.links.length,
186
+ ...links.map((link) => new Link(link.nodeId, link.socketName))
187
+ );
188
+ });
189
+ });
190
+
191
+ Object.entries(suspension.socketValues ?? {}).forEach(
192
+ ([nodeId, values]) => {
193
+ const node = this.nodes[nodeId];
194
+ if (!node) {
195
+ throw new Error(`Could not find missing node ${nodeId}`);
196
+ }
197
+ Object.entries(values).forEach(([socketName, serialized]) => {
198
+ const socket = node.outputs.find((s) => s.name === socketName);
199
+ if (!socket) {
200
+ throw new Error(
201
+ `Could not find missing socket ${socketName} on node ${nodeId}`
202
+ );
203
+ }
204
+ const valueType = this.registry.values[socket.valueTypeName];
205
+ socket.value = valueType
206
+ ? valueType.deserialize(serialized)
207
+ : serialized;
208
+ });
209
+ }
210
+ );
211
+
212
+ Object.entries(suspension.nodes).forEach(([id, serialized]) => {
213
+ const node = this.nodes[id];
214
+ if (!node) {
215
+ throw new Error(`Could not find missing node ${id}`);
216
+ }
217
+
218
+ if (isSuspendable(node)) {
219
+ node.hydrate?.(serialized);
220
+ } else if (isFlowNode(node) && hasNodeState(node)) {
221
+ node.setState(serialized);
222
+ }
223
+ });
224
+
225
+ //Recreate the fiberqueue
226
+ this.fiberQueue = suspension.fiberQueue.map((x) =>
227
+ SuspendableFiber.rehydrate(this, x)
228
+ );
229
+
230
+ // Only fibers suspended at an async node need an explicit continuation;
231
+ // fibers suspended between steps resume through normal execution.
232
+ const first = this.fiberQueue[0];
233
+ if (first?.canContinue()) {
234
+ first.continue(continuanceData);
235
+ }
236
+ }
237
+ }
package/src/fiber.ts ADDED
@@ -0,0 +1,162 @@
1
+ import {
2
+ Assert,
3
+ Fiber,
4
+ isAsyncNode,
5
+ Link,
6
+ type FiberListenerInner,
7
+ type IAsyncNode
8
+ } from '@kiberon-labs/behave-graph';
9
+ import type { SuspendableEngine } from './engine';
10
+ import { isSuspendable, type IAsyncSuspendable } from './types';
11
+
12
+ type SerializedLink = {
13
+ nodeId: string;
14
+ socketName: string;
15
+ };
16
+
17
+ export type SerializedSuspendedFiber = {
18
+ curr?: SerializedLink;
19
+ link?: SerializedLink;
20
+ queue: string[];
21
+ };
22
+
23
+ export class SuspendableFiber extends Fiber {
24
+ protected currentLink?: Link;
25
+ declare public readonly engine: SuspendableEngine;
26
+
27
+ constructor(
28
+ engine: SuspendableEngine,
29
+ nextEval: Link | null,
30
+ fiberCompletedListener: FiberListenerInner = undefined
31
+ ) {
32
+ super(engine, nextEval, fiberCompletedListener);
33
+ }
34
+
35
+ static rehydrate(
36
+ engine: SuspendableEngine,
37
+ serialized: SerializedSuspendedFiber
38
+ ): SuspendableFiber {
39
+ const nextlink = serialized.link
40
+ ? new Link(serialized.link.nodeId, serialized.link.socketName)
41
+ : null;
42
+ const fiber = new SuspendableFiber(engine, nextlink);
43
+ fiber.currentLink = serialized.curr
44
+ ? new Link(serialized.curr.nodeId, serialized.curr.socketName)
45
+ : undefined;
46
+
47
+ //Rehydrate the listener stack
48
+ serialized.queue.forEach((nodeId) => {
49
+ fiber.fiberCompletedListenerStack.push({
50
+ nodeId,
51
+ cb: () => engine.commitContinuedFiber(engine.nodes[nodeId]!)
52
+ });
53
+ });
54
+
55
+ return fiber;
56
+ }
57
+
58
+ serialize(): SerializedSuspendedFiber {
59
+ return {
60
+ queue: this.fiberCompletedListenerStack
61
+ .map((l) => l.nodeId!)
62
+ .filter((id) => !!id),
63
+ curr: this.currentLink
64
+ ? {
65
+ nodeId: this.currentLink.nodeId,
66
+ socketName: this.currentLink.socketName
67
+ }
68
+ : undefined,
69
+ link: this.nextEval
70
+ ? {
71
+ nodeId: this.nextEval.nodeId,
72
+ socketName: this.nextEval.socketName
73
+ }
74
+ : undefined
75
+ };
76
+ }
77
+
78
+ clear() {
79
+ this.fiberCompletedListenerStack.splice(
80
+ 0,
81
+ this.fiberCompletedListenerStack.length
82
+ );
83
+ this.currentLink = undefined;
84
+ this.nextEval = null;
85
+ }
86
+
87
+ /** Whether this fiber was suspended at an async node that must be resumed
88
+ * via `continue()`. Fibers suspended between steps have no current link and
89
+ * simply resume through normal engine execution. */
90
+ canContinue(): boolean {
91
+ return !!this.currentLink;
92
+ }
93
+
94
+ /**
95
+ * Track the link while executing an async suspendable node. Such nodes can
96
+ * park the fiber on a pending promise; if the engine is suspended while
97
+ * parked, `serialize()` must record where execution stopped so `continue()`
98
+ * can resume the node with continuance data. The link is cleared once the
99
+ * node completes normally.
100
+ */
101
+ override async executeStep() {
102
+ const link = this.nextEval;
103
+ if (link) {
104
+ const node = this.nodes[link.nodeId];
105
+ if (node && isAsyncNode(node) && isSuspendable(node)) {
106
+ this.currentLink = link;
107
+ }
108
+ }
109
+
110
+ await super.executeStep();
111
+
112
+ if (link && this.currentLink === link) {
113
+ this.currentLink = undefined;
114
+ }
115
+ }
116
+
117
+ continue(continuanceData: any) {
118
+ Assert.mustBeTrue(
119
+ !!this.currentLink,
120
+ 'Cannot continue fiber if no current link'
121
+ );
122
+
123
+ const nodeID = this.currentLink?.nodeId;
124
+ const node = this.nodes[nodeID!] as IAsyncSuspendable;
125
+
126
+ if (!node) {
127
+ throw new Error(`Missing node for continuance: ${nodeID}`);
128
+ }
129
+ const socketName = this.currentLink?.socketName;
130
+ Assert.mustBeTrue(
131
+ !!socketName,
132
+ 'Cannot continue fiber if no current socket name'
133
+ );
134
+
135
+ this.engine.onNodeExecutionStart.emit(node!);
136
+ return node.unsuspend(continuanceData, this.engine, socketName!, () => {
137
+ //Only on successful unsuspend do we clear the current link
138
+ //This is to allow a node to immediately re-suspend if needed
139
+ this.currentLink = undefined;
140
+ this.engine.onNodeExecutionEnd.emit(node);
141
+
142
+ //remove from the list of pending async nodes (it will not be present
143
+ //on a freshly rehydrated engine, where the node was never triggered)
144
+ const index = this.engine.asyncNodes.indexOf(
145
+ node as unknown as IAsyncNode
146
+ );
147
+ if (index >= 0) {
148
+ this.engine.asyncNodes.splice(index, 1);
149
+ }
150
+ this.executionSteps++;
151
+
152
+ //There might be pending callbacks on the queue
153
+ while (this.fiberCompletedListenerStack.length > 0) {
154
+ const awaitingCallback = this.fiberCompletedListenerStack.pop();
155
+ if (awaitingCallback === undefined) {
156
+ throw new Error('awaitingCallback is empty');
157
+ }
158
+ awaitingCallback.cb();
159
+ }
160
+ });
161
+ }
162
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from './engine';
package/src/types.ts ADDED
@@ -0,0 +1,67 @@
1
+ import type { INode } from '@kiberon-labs/behave-graph';
2
+ import type { SuspendableEngine } from './engine';
3
+ import type { SerializedSuspendedFiber } from './fiber';
4
+
5
+ export interface IAsyncSuspendable<T = any, State = any>
6
+ extends ISuspendable<State> {
7
+ /**
8
+ * Continue from suspension
9
+ */
10
+ unsuspend(
11
+ data: T,
12
+ engine: SuspendableEngine,
13
+ triggeringSocketName: string,
14
+ cb: () => void
15
+ ): Promise<void>;
16
+ triggered(
17
+ engine: SuspendableEngine,
18
+ triggeringSocketName: string,
19
+ finished: () => void
20
+ ): unknown;
21
+ }
22
+
23
+ export interface ISuspendable<T = any> extends INode {
24
+ /**
25
+ * Suspend and create a copy of your internal state
26
+ */
27
+ suspend(): T;
28
+ /**
29
+ * Rehydrate from suspended data
30
+ */
31
+ hydrate?(data: T): void;
32
+ }
33
+
34
+ export function isSuspendable(node: unknown): node is ISuspendable {
35
+ if (node == null || typeof node !== 'object') {
36
+ return false;
37
+ }
38
+ const candidate = node as Record<string, unknown>;
39
+ // ISuspendable only requires suspend(); unsuspend() belongs to
40
+ // IAsyncSuspendable and is checked at the point of continuation.
41
+ return typeof candidate.suspend === 'function';
42
+ }
43
+
44
+ export type SerializedSuspension = {
45
+ fiberQueue: SerializedSuspendedFiber[];
46
+ /**
47
+ * Serialized state of the nodes
48
+ */
49
+ nodes: Record<string, any>;
50
+ sockets: Record<
51
+ string,
52
+ Record<string, { nodeId: string; socketName: string }[]>
53
+ >;
54
+ /**
55
+ * Serialized output socket values per node. Resumed flows may read an
56
+ * upstream node's output (e.g. a loop's `index`) before that node is
57
+ * re-triggered, so the values present at suspension time must be restored.
58
+ */
59
+ socketValues: Record<string, Record<string, any>>;
60
+ variables: Record<
61
+ string,
62
+ {
63
+ type: string;
64
+ value: any;
65
+ }
66
+ >;
67
+ };
@@ -0,0 +1,124 @@
1
+ import type { GraphJSON } from '@kiberon-labs/behave-graph';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { makeRecorderDescription, WaitForSignal } from './fixtures/testNodes';
4
+ import { makeTestEngine } from './testUtils';
5
+
6
+ const COMPLETED = 999;
7
+
8
+ /**
9
+ * onStart -> forLoop [0,1) -> waitForSignal (async, parks the fiber) ->
10
+ * recorder reading the delivered signal value. The orphan time/delay node is
11
+ * present only to assert async nodes are excluded from generic state capture.
12
+ */
13
+ const waitGraph: GraphJSON = {
14
+ variables: [],
15
+ customEvents: [],
16
+ nodes: [
17
+ {
18
+ type: 'lifecycle/onStart',
19
+ id: '0',
20
+ flows: { flow: { nodeId: 'loop', socket: 'flow' } }
21
+ },
22
+ {
23
+ type: 'flow/forLoop',
24
+ id: 'loop',
25
+ parameters: {
26
+ startIndex: { value: 0 },
27
+ endIndex: { value: 1 }
28
+ },
29
+ flows: {
30
+ loopBody: { nodeId: 'wait', socket: 'flow' },
31
+ completed: { nodeId: 'done', socket: 'flow' }
32
+ }
33
+ },
34
+ {
35
+ type: 'test/waitForSignal',
36
+ id: 'wait',
37
+ flows: { flow: { nodeId: 'rec', socket: 'flow' } }
38
+ },
39
+ {
40
+ type: 'test/recorder',
41
+ id: 'rec',
42
+ parameters: { index: { link: { nodeId: 'wait', socket: 'value' } } }
43
+ },
44
+ {
45
+ type: 'test/recorder',
46
+ id: 'done',
47
+ parameters: { index: { value: COMPLETED } }
48
+ },
49
+ {
50
+ type: 'time/delay',
51
+ id: 'orphanDelay'
52
+ }
53
+ ]
54
+ };
55
+
56
+ const makeWaitEngine = (record: (index: number) => void) =>
57
+ makeTestEngine(waitGraph, {
58
+ 'test/recorder': makeRecorderDescription(record),
59
+ [WaitForSignal.Description.typeName]: WaitForSignal.Description
60
+ });
61
+
62
+ describe('async node suspension (vs flow nodes)', () => {
63
+ it('parks the fiber at the async node and resumes via unsuspend with continuance data', async () => {
64
+ const firstRun: number[] = [];
65
+ const first = makeWaitEngine((i) => firstRun.push(i));
66
+
67
+ first.start();
68
+ // do not await: the async node parks execution on a pending promise
69
+ void first.engine.executeAllSync();
70
+ const waitNode = first.graph.nodes['wait'] as WaitForSignal;
71
+ await waitNode.parked;
72
+
73
+ // nothing downstream of the wait has run
74
+ expect(firstRun).toEqual([]);
75
+
76
+ const suspension = first.engine.suspend();
77
+ first.engine.dispose();
78
+
79
+ // ASYNC nodes are captured through ISuspendable.suspend()...
80
+ expect(suspension.nodes['wait']).toEqual({ waiting: true });
81
+ // ...and the fiber records the async node as its resume point, unlike
82
+ // flow-node suspensions which carry no current link
83
+ expect(suspension.fiberQueue[0]!.curr).toEqual({
84
+ nodeId: 'wait',
85
+ socketName: 'flow'
86
+ });
87
+ // the enclosing loop's continuation is preserved alongside it
88
+ expect(suspension.fiberQueue[0]!.queue).toEqual(['loop']);
89
+
90
+ const persisted = JSON.parse(JSON.stringify(suspension));
91
+
92
+ // resume on a fresh engine, delivering the awaited data as continuance
93
+ const secondRun: number[] = [];
94
+ const second = makeWaitEngine((i) => secondRun.push(i));
95
+ second.engine.unsuspend(persisted, 42);
96
+ await second.engine.executeAllSync();
97
+
98
+ // the delivered data flows downstream of the wait node, then the
99
+ // enclosing loop finishes without restarting
100
+ expect(secondRun).toEqual([42, COMPLETED]);
101
+ });
102
+
103
+ it('excludes async and event node state from generic capture', async () => {
104
+ const firstRun: number[] = [];
105
+ const first = makeWaitEngine((i) => firstRun.push(i));
106
+
107
+ first.start();
108
+ void first.engine.executeAllSync();
109
+ await (first.graph.nodes['wait'] as WaitForSignal).parked;
110
+
111
+ const suspension = first.engine.suspend();
112
+ first.engine.dispose();
113
+
114
+ // flow nodes are captured generically from node state
115
+ expect(suspension.nodes['loop']).toEqual({ nextIndex: 1 });
116
+
117
+ // event nodes hold live listeners — never serialized
118
+ expect(suspension.nodes['0']).toBeUndefined();
119
+
120
+ // async nodes can hold timers, so they are only captured when they opt in
121
+ // via ISuspendable. time/delay has node state but no suspend() — excluded.
122
+ expect(suspension.nodes['orphanDelay']).toBeUndefined();
123
+ });
124
+ });