@nanobpm/bojtos-kit 0.3.0 → 0.5.0

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 CHANGED
@@ -1,9 +1,9 @@
1
1
  # @nanobpm/bojtos-kit
2
2
 
3
3
  Framework-agnostic core of the **Bojtos** in-browser BPMN demo framework
4
- ([ADR 0043](../docs/adr/0043-bojtos-demo-framework.md)).
4
+ ([ADR 0043](../README.md#design)).
5
5
 
6
- It wraps [`@nanobpm/engine-wasm`](../engine-wasm) as a single scenario runner —
6
+ It wraps [`@nanobpm/engine-wasm`](https://www.npmjs.com/package/@nanobpm/engine-wasm) as a single scenario runner —
7
7
  the one runner the whole framework (and the console test-run panel) drives, so
8
8
  there is no second, drift-prone engine harness — and re-exports the engine's
9
9
  snapshot/event contract types.
@@ -29,5 +29,7 @@ lifecycle and reactive state on top of this kit.
29
29
 
30
30
  ## Build
31
31
 
32
- `dist/` (the tsc-emitted JS + `.d.ts`) is committed so `file:` consumers and CI
33
- need no build-on-install step. Regenerate with `npm run build`.
32
+ `dist/` (the tsc-emitted JS + `.d.ts`) is what ships, built by `prepack` on
33
+ publish and by `npm test` locally. It is **not** committed `.gitignore` covers
34
+ it — so build before pointing a `file:` consumer at this workspace. Regenerate
35
+ with `npm run build`.
package/dist/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  export { ensureWasm, createBojtosSession, type BojtosSession, type WasmSource, } from "./session.js";
2
- export { dispatchWorkers, dispatchRound, JobFailure, type JobHandler, type JobResult, type AgentHandler, type DispatchOptions, type DispatchResult, type RoundResult, } from "./worker.js";
3
- export type { Snapshot, InstanceDto, JobDto, ActivatedJob, IncidentDto, TimerDto, ActiveEl, AgentActivation, AgentResult, WasmEvent, } from "./types.js";
2
+ export { dispatchWorkers, dispatchRound, settleReason, unhandledJobTypes, JobFailure, type JobHandler, type JobResult, type AgentHandler, type DispatchOptions, type DispatchResult, type RoundResult, type SettleReason, } from "./worker.js";
3
+ export type { Snapshot, InstanceDto, JobDto, ActivatedJob, IncidentDto, TimerDto, UserTaskDto, MessageSubscriptionDto, SignalSubscriptionDto, ElementStatDto, SequenceFlowDto, DecisionInstanceDto, ActiveEl, ActivateInstruction, AgentActivation, AgentResult, WasmEvent, } from "./types.js";
package/dist/index.js CHANGED
@@ -2,4 +2,4 @@
2
2
  // framework (ADR 0043). Wraps the in-browser wasm engine as a single scenario
3
3
  // runner and re-exports the engine's snapshot/event contract types.
4
4
  export { ensureWasm, createBojtosSession, } from "./session.js";
5
- export { dispatchWorkers, dispatchRound, JobFailure, } from "./worker.js";
5
+ export { dispatchWorkers, dispatchRound, settleReason, unhandledJobTypes, JobFailure, } from "./worker.js";
package/dist/types.d.ts CHANGED
@@ -169,6 +169,15 @@ export interface Snapshot {
169
169
  signalSubscriptions: SignalSubscriptionDto[];
170
170
  elementStats: ElementStatDto[];
171
171
  takenSequenceFlows: SequenceFlowDto[];
172
+ /**
173
+ * Evaluated DMN decisions.
174
+ *
175
+ * **Reserved.** `BojtosSession.deploy` takes a BPMN resource only, so there is
176
+ * currently no way to deploy a decision and nothing can populate this — treat
177
+ * a consumer that renders it as writing for a future engine, not reading live
178
+ * data. Kept in the contract so the shape doesn't change when deployment of
179
+ * decision resources lands.
180
+ */
172
181
  decisionInstances: DecisionInstanceDto[];
173
182
  activeElementIds: string[];
174
183
  incidentElementIds: string[];
package/dist/worker.d.ts CHANGED
@@ -61,13 +61,72 @@ export interface DispatchOptions {
61
61
  * whole agent conversation to quiescence.
62
62
  */
63
63
  agents?: Record<string, AgentHandler>;
64
+ /**
65
+ * Let the drain loop move the virtual clock when it runs out of work but a
66
+ * timer is still pending: it jumps to the next due timer and keeps going.
67
+ *
68
+ * Off by default, because advancing time is a decision about what the demo is
69
+ * showing, not a detail. With it off, a model that waits on a timer settles
70
+ * with `reason: "timers"` — the loop is *done*, the process isn't — and the
71
+ * caller advances the clock itself.
72
+ *
73
+ * `true` advances as far as needed. `{ maxTotalMs }` sets a **budget for the
74
+ * whole drain**, not per jump — the loop advances while it can afford to and
75
+ * then settles with `reason: "timers"`, so "run for up to an hour of virtual
76
+ * time" is expressible and a `PT24H` timer can't be reached a second at a
77
+ * time.
78
+ */
79
+ advanceTimers?: boolean | {
80
+ maxTotalMs: number;
81
+ };
64
82
  }
83
+ /**
84
+ * Why a drain stopped. `handled === 0` alone can't say: a completed instance, a
85
+ * human step, a pending timer, a message that never arrived and a job type
86
+ * nobody registered all look identical from the outside, and each one needs a
87
+ * different response from the UI above.
88
+ */
89
+ export type SettleReason =
90
+ /** No live instances remain — every one completed or was terminated. */
91
+ "completed"
92
+ /** Waiting on a `userTask`; complete it with `session.completeUserTask`. */
93
+ | "userTasks"
94
+ /** Waiting on a timer; advance the clock (or pass `advanceTimers`). */
95
+ | "timers"
96
+ /** Waiting on a message subscription; publish with `correlateMessage`. */
97
+ | "messages"
98
+ /** Waiting on a signal subscription; publish with `broadcastSignal`. */
99
+ | "signals"
100
+ /** Jobs are waiting whose job type has no registered handler. */
101
+ | "unhandledJobs"
102
+ /** An incident is blocking progress; resolve it to continue. */
103
+ | "incidents"
104
+ /** Nothing is running and nothing is waiting — an empty or unstarted engine. */
105
+ | "idle";
106
+ /**
107
+ * Classify why the loop has nothing left to do. Pure, and exported so a consumer
108
+ * can label a snapshot it obtained some other way (and so it can be tested
109
+ * without an engine).
110
+ *
111
+ * Order matters: it reports the thing a caller can act on first. Incidents come
112
+ * before waiting states because an incident is why the wait will never end.
113
+ */
114
+ export declare function settleReason(snapshot: Snapshot, handledJobTypes?: Iterable<string>): SettleReason;
115
+ /** Job types with waiting jobs that no registered handler serves. */
116
+ export declare function unhandledJobTypes(snapshot: Snapshot, handledJobTypes?: Iterable<string>): string[];
65
117
  /** What one {@link dispatchRound} pass did. */
66
118
  export interface RoundResult {
67
119
  /** The snapshot after this pass. */
68
120
  snapshot: Snapshot;
69
121
  /** How many jobs were completed or failed in this pass. */
70
122
  handled: number;
123
+ /**
124
+ * Why there was nothing left to do, when `handled === 0`. Undefined while the
125
+ * round did work — the loop hasn't settled, so there is nothing to explain.
126
+ */
127
+ reason?: SettleReason;
128
+ /** Waiting job types no registered handler serves (usually a typo). */
129
+ unhandled?: string[];
71
130
  }
72
131
  /** What {@link dispatchWorkers} did. */
73
132
  export interface DispatchResult {
@@ -77,6 +136,15 @@ export interface DispatchResult {
77
136
  handled: number;
78
137
  /** How many activate rounds ran (including the final quiescent one). */
79
138
  rounds: number;
139
+ /**
140
+ * Why the drain stopped. Always set: a settled drain always has a reason, and
141
+ * "the loop finished" is not the same claim as "the process finished".
142
+ */
143
+ reason: SettleReason;
144
+ /** Waiting job types no registered handler serves (usually a typo). */
145
+ unhandled: string[];
146
+ /** How far the virtual clock was moved, when `advanceTimers` is on. */
147
+ advancedMs: number;
80
148
  }
81
149
  /**
82
150
  * Run one activate-and-handle pass: activate every registered job type's
package/dist/worker.js CHANGED
@@ -12,6 +12,40 @@ export class JobFailure extends Error {
12
12
  this.retries = opts?.retries;
13
13
  }
14
14
  }
15
+ /**
16
+ * Classify why the loop has nothing left to do. Pure, and exported so a consumer
17
+ * can label a snapshot it obtained some other way (and so it can be tested
18
+ * without an engine).
19
+ *
20
+ * Order matters: it reports the thing a caller can act on first. Incidents come
21
+ * before waiting states because an incident is why the wait will never end.
22
+ */
23
+ export function settleReason(snapshot, handledJobTypes = []) {
24
+ const live = snapshot.instances.filter((i) => !i.completed);
25
+ if (live.length === 0)
26
+ return snapshot.totalInstances > 0 ? "completed" : "idle";
27
+ if (snapshot.incidents.length > 0)
28
+ return "incidents";
29
+ const known = new Set(handledJobTypes);
30
+ if (snapshot.jobs.some((j) => !known.has(j.jobType)))
31
+ return "unhandledJobs";
32
+ if (snapshot.userTasks.some((t) => t.state === "Created"))
33
+ return "userTasks";
34
+ if (snapshot.timers.length > 0)
35
+ return "timers";
36
+ if (snapshot.messageSubscriptions.length > 0)
37
+ return "messages";
38
+ if (snapshot.signalSubscriptions.length > 0)
39
+ return "signals";
40
+ return "idle";
41
+ }
42
+ /** Job types with waiting jobs that no registered handler serves. */
43
+ export function unhandledJobTypes(snapshot, handledJobTypes = []) {
44
+ const known = new Set(handledJobTypes);
45
+ return [...new Set(snapshot.jobs.map((j) => j.jobType))]
46
+ .filter((t) => !known.has(t))
47
+ .sort();
48
+ }
15
49
  async function runOne(session, handler, job) {
16
50
  let payload;
17
51
  try {
@@ -112,9 +146,18 @@ export async function dispatchRound(session, workers, opts = {}) {
112
146
  for (const { handler, job } of agentBatch) {
113
147
  await runOneAgent(session, handler, job);
114
148
  }
149
+ const snapshot = session.snapshot();
150
+ const handled = jobBatch.length + agentBatch.length;
151
+ if (handled > 0)
152
+ return { snapshot, handled };
153
+ // Nothing left to do this round — say why, so the caller isn't left to infer
154
+ // "finished" from "quiet".
155
+ const known = [...Object.keys(workers), ...Object.keys(agents)];
115
156
  return {
116
- snapshot: session.snapshot(),
117
- handled: jobBatch.length + agentBatch.length,
157
+ snapshot,
158
+ handled,
159
+ reason: settleReason(snapshot, known),
160
+ unhandled: unhandledJobTypes(snapshot, known),
118
161
  };
119
162
  }
120
163
  /**
@@ -129,8 +172,16 @@ export async function dispatchRound(session, workers, opts = {}) {
129
172
  */
130
173
  export async function dispatchWorkers(session, workers, opts = {}) {
131
174
  const maxRounds = opts.maxRounds ?? 1000;
175
+ // `typeof null === "object"`, so guard against a JS caller passing `null`
176
+ // (via `any`) — otherwise reading `.maxTotalMs` off it throws.
177
+ const advanceTimers = opts.advanceTimers;
178
+ const timeBudgetMs = advanceTimers != null && typeof advanceTimers === "object"
179
+ ? advanceTimers.maxTotalMs
180
+ : Infinity;
181
+ const mayAdvance = advanceTimers != null && advanceTimers !== false;
132
182
  let handled = 0;
133
183
  let rounds = 0;
184
+ let advancedMs = 0;
134
185
  for (;;) {
135
186
  if (rounds >= maxRounds) {
136
187
  throw new Error(`dispatchWorkers exceeded maxRounds (${maxRounds}) — a handler may be creating work without end`);
@@ -138,8 +189,40 @@ export async function dispatchWorkers(session, workers, opts = {}) {
138
189
  rounds++;
139
190
  const round = await dispatchRound(session, workers, opts);
140
191
  handled += round.handled;
141
- if (round.handled === 0) {
142
- return { snapshot: round.snapshot, handled, rounds };
192
+ if (round.handled > 0)
193
+ continue;
194
+ // Out of jobs. If the only thing standing between here and more work is the
195
+ // clock, and the caller asked us to, jump to the next due timer and carry
196
+ // on — otherwise a timer-bearing model looks finished when it is waiting.
197
+ if (mayAdvance && round.reason === "timers") {
198
+ const due = round.snapshot.timers.reduce((min, t) => Math.min(min, t.dueInMs), Infinity);
199
+ // `dueInMs` can be <= 0 for a timer that is already due but hasn't been
200
+ // triggered; nudge by 1ms so the clock always moves and the loop can't spin.
201
+ const jump = Math.max(due, 1);
202
+ // Only jump if the whole hop fits the budget. A partial hop would burn the
203
+ // budget without firing anything, which is strictly worse than stopping
204
+ // and telling the caller a timer is still pending.
205
+ if (Number.isFinite(jump) && advancedMs + jump <= timeBudgetMs) {
206
+ session.advanceTime(jump);
207
+ advancedMs += jump;
208
+ continue;
209
+ }
143
210
  }
211
+ return {
212
+ snapshot: round.snapshot,
213
+ handled,
214
+ rounds,
215
+ // `round.reason` is always set once `round.handled === 0` (the only way to
216
+ // reach here), so the fallback is currently unreachable — but if it ever
217
+ // did fire it must use the same handler set as the round, or it would
218
+ // recompute against an empty set and flag every job type as unhandled.
219
+ reason: round.reason ??
220
+ settleReason(round.snapshot, [
221
+ ...Object.keys(workers),
222
+ ...Object.keys(opts.agents ?? {}),
223
+ ]),
224
+ unhandled: round.unhandled ?? [],
225
+ advancedMs,
226
+ };
144
227
  }
145
228
  }
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@nanobpm/bojtos-kit",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Framework-agnostic core of the Bojtos in-browser BPMN demo framework (ADR 0043): a single scenario runner over the @nanobpm/engine-wasm engine (deploy, start instances, complete/fail jobs, advance the clock, read snapshots and the event log), plus the engine's snapshot/event contract types. Consumed by @nanobpm/bojtos-react and the console test-run panel.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "git+https://github.com/Magikcraft/nano-bpm.git",
9
+ "url": "git+https://github.com/nanobpm/bojtos.git",
10
10
  "directory": "bojtos-kit"
11
11
  },
12
12
  "main": "./dist/index.js",
package/src/index.ts CHANGED
@@ -11,6 +11,8 @@ export {
11
11
  export {
12
12
  dispatchWorkers,
13
13
  dispatchRound,
14
+ settleReason,
15
+ unhandledJobTypes,
14
16
  JobFailure,
15
17
  type JobHandler,
16
18
  type JobResult,
@@ -18,7 +20,11 @@ export {
18
20
  type DispatchOptions,
19
21
  type DispatchResult,
20
22
  type RoundResult,
23
+ type SettleReason,
21
24
  } from "./worker.js";
25
+ // Every type reachable from `Snapshot` is exported: a consumer that can read
26
+ // `snapshot.userTasks` must also be able to name `UserTaskDto` to write a
27
+ // helper for it. Keep this list exhaustive when adding to `types.ts`.
22
28
  export type {
23
29
  Snapshot,
24
30
  InstanceDto,
@@ -26,7 +32,14 @@ export type {
26
32
  ActivatedJob,
27
33
  IncidentDto,
28
34
  TimerDto,
35
+ UserTaskDto,
36
+ MessageSubscriptionDto,
37
+ SignalSubscriptionDto,
38
+ ElementStatDto,
39
+ SequenceFlowDto,
40
+ DecisionInstanceDto,
29
41
  ActiveEl,
42
+ ActivateInstruction,
30
43
  AgentActivation,
31
44
  AgentResult,
32
45
  WasmEvent,
package/src/types.ts CHANGED
@@ -190,6 +190,15 @@ export interface Snapshot {
190
190
  signalSubscriptions: SignalSubscriptionDto[];
191
191
  elementStats: ElementStatDto[];
192
192
  takenSequenceFlows: SequenceFlowDto[];
193
+ /**
194
+ * Evaluated DMN decisions.
195
+ *
196
+ * **Reserved.** `BojtosSession.deploy` takes a BPMN resource only, so there is
197
+ * currently no way to deploy a decision and nothing can populate this — treat
198
+ * a consumer that renders it as writing for a future engine, not reading live
199
+ * data. Kept in the contract so the shape doesn't change when deployment of
200
+ * decision resources lands.
201
+ */
193
202
  decisionInstances: DecisionInstanceDto[];
194
203
  activeElementIds: string[];
195
204
  incidentElementIds: string[];
package/src/worker.ts CHANGED
@@ -72,6 +72,84 @@ export interface DispatchOptions {
72
72
  * whole agent conversation to quiescence.
73
73
  */
74
74
  agents?: Record<string, AgentHandler>;
75
+ /**
76
+ * Let the drain loop move the virtual clock when it runs out of work but a
77
+ * timer is still pending: it jumps to the next due timer and keeps going.
78
+ *
79
+ * Off by default, because advancing time is a decision about what the demo is
80
+ * showing, not a detail. With it off, a model that waits on a timer settles
81
+ * with `reason: "timers"` — the loop is *done*, the process isn't — and the
82
+ * caller advances the clock itself.
83
+ *
84
+ * `true` advances as far as needed. `{ maxTotalMs }` sets a **budget for the
85
+ * whole drain**, not per jump — the loop advances while it can afford to and
86
+ * then settles with `reason: "timers"`, so "run for up to an hour of virtual
87
+ * time" is expressible and a `PT24H` timer can't be reached a second at a
88
+ * time.
89
+ */
90
+ advanceTimers?: boolean | { maxTotalMs: number };
91
+ }
92
+
93
+ /**
94
+ * Why a drain stopped. `handled === 0` alone can't say: a completed instance, a
95
+ * human step, a pending timer, a message that never arrived and a job type
96
+ * nobody registered all look identical from the outside, and each one needs a
97
+ * different response from the UI above.
98
+ */
99
+ export type SettleReason =
100
+ /** No live instances remain — every one completed or was terminated. */
101
+ | "completed"
102
+ /** Waiting on a `userTask`; complete it with `session.completeUserTask`. */
103
+ | "userTasks"
104
+ /** Waiting on a timer; advance the clock (or pass `advanceTimers`). */
105
+ | "timers"
106
+ /** Waiting on a message subscription; publish with `correlateMessage`. */
107
+ | "messages"
108
+ /** Waiting on a signal subscription; publish with `broadcastSignal`. */
109
+ | "signals"
110
+ /** Jobs are waiting whose job type has no registered handler. */
111
+ | "unhandledJobs"
112
+ /** An incident is blocking progress; resolve it to continue. */
113
+ | "incidents"
114
+ /** Nothing is running and nothing is waiting — an empty or unstarted engine. */
115
+ | "idle";
116
+
117
+ /**
118
+ * Classify why the loop has nothing left to do. Pure, and exported so a consumer
119
+ * can label a snapshot it obtained some other way (and so it can be tested
120
+ * without an engine).
121
+ *
122
+ * Order matters: it reports the thing a caller can act on first. Incidents come
123
+ * before waiting states because an incident is why the wait will never end.
124
+ */
125
+ export function settleReason(
126
+ snapshot: Snapshot,
127
+ handledJobTypes: Iterable<string> = [],
128
+ ): SettleReason {
129
+ const live = snapshot.instances.filter((i) => !i.completed);
130
+ if (live.length === 0)
131
+ return snapshot.totalInstances > 0 ? "completed" : "idle";
132
+ if (snapshot.incidents.length > 0) return "incidents";
133
+
134
+ const known = new Set(handledJobTypes);
135
+ if (snapshot.jobs.some((j) => !known.has(j.jobType))) return "unhandledJobs";
136
+
137
+ if (snapshot.userTasks.some((t) => t.state === "Created")) return "userTasks";
138
+ if (snapshot.timers.length > 0) return "timers";
139
+ if (snapshot.messageSubscriptions.length > 0) return "messages";
140
+ if (snapshot.signalSubscriptions.length > 0) return "signals";
141
+ return "idle";
142
+ }
143
+
144
+ /** Job types with waiting jobs that no registered handler serves. */
145
+ export function unhandledJobTypes(
146
+ snapshot: Snapshot,
147
+ handledJobTypes: Iterable<string> = [],
148
+ ): string[] {
149
+ const known = new Set(handledJobTypes);
150
+ return [...new Set(snapshot.jobs.map((j) => j.jobType))]
151
+ .filter((t) => !known.has(t))
152
+ .sort();
75
153
  }
76
154
 
77
155
  /** What one {@link dispatchRound} pass did. */
@@ -80,6 +158,13 @@ export interface RoundResult {
80
158
  snapshot: Snapshot;
81
159
  /** How many jobs were completed or failed in this pass. */
82
160
  handled: number;
161
+ /**
162
+ * Why there was nothing left to do, when `handled === 0`. Undefined while the
163
+ * round did work — the loop hasn't settled, so there is nothing to explain.
164
+ */
165
+ reason?: SettleReason;
166
+ /** Waiting job types no registered handler serves (usually a typo). */
167
+ unhandled?: string[];
83
168
  }
84
169
 
85
170
  /** What {@link dispatchWorkers} did. */
@@ -90,6 +175,15 @@ export interface DispatchResult {
90
175
  handled: number;
91
176
  /** How many activate rounds ran (including the final quiescent one). */
92
177
  rounds: number;
178
+ /**
179
+ * Why the drain stopped. Always set: a settled drain always has a reason, and
180
+ * "the loop finished" is not the same claim as "the process finished".
181
+ */
182
+ reason: SettleReason;
183
+ /** Waiting job types no registered handler serves (usually a typo). */
184
+ unhandled: string[];
185
+ /** How far the virtual clock was moved, when `advanceTimers` is on. */
186
+ advancedMs: number;
93
187
  }
94
188
 
95
189
  async function runOne(
@@ -208,9 +302,17 @@ export async function dispatchRound(
208
302
  for (const { handler, job } of agentBatch) {
209
303
  await runOneAgent(session, handler, job);
210
304
  }
305
+ const snapshot = session.snapshot();
306
+ const handled = jobBatch.length + agentBatch.length;
307
+ if (handled > 0) return { snapshot, handled };
308
+ // Nothing left to do this round — say why, so the caller isn't left to infer
309
+ // "finished" from "quiet".
310
+ const known = [...Object.keys(workers), ...Object.keys(agents)];
211
311
  return {
212
- snapshot: session.snapshot(),
213
- handled: jobBatch.length + agentBatch.length,
312
+ snapshot,
313
+ handled,
314
+ reason: settleReason(snapshot, known),
315
+ unhandled: unhandledJobTypes(snapshot, known),
214
316
  };
215
317
  }
216
318
 
@@ -230,8 +332,18 @@ export async function dispatchWorkers(
230
332
  opts: DispatchOptions = {},
231
333
  ): Promise<DispatchResult> {
232
334
  const maxRounds = opts.maxRounds ?? 1000;
335
+ // `typeof null === "object"`, so guard against a JS caller passing `null`
336
+ // (via `any`) — otherwise reading `.maxTotalMs` off it throws.
337
+ const advanceTimers = opts.advanceTimers;
338
+ const timeBudgetMs =
339
+ advanceTimers != null && typeof advanceTimers === "object"
340
+ ? advanceTimers.maxTotalMs
341
+ : Infinity;
342
+ const mayAdvance = advanceTimers != null && advanceTimers !== false;
343
+
233
344
  let handled = 0;
234
345
  let rounds = 0;
346
+ let advancedMs = 0;
235
347
  for (;;) {
236
348
  if (rounds >= maxRounds) {
237
349
  throw new Error(
@@ -241,8 +353,45 @@ export async function dispatchWorkers(
241
353
  rounds++;
242
354
  const round = await dispatchRound(session, workers, opts);
243
355
  handled += round.handled;
244
- if (round.handled === 0) {
245
- return { snapshot: round.snapshot, handled, rounds };
356
+ if (round.handled > 0) continue;
357
+
358
+ // Out of jobs. If the only thing standing between here and more work is the
359
+ // clock, and the caller asked us to, jump to the next due timer and carry
360
+ // on — otherwise a timer-bearing model looks finished when it is waiting.
361
+ if (mayAdvance && round.reason === "timers") {
362
+ const due = round.snapshot.timers.reduce(
363
+ (min, t) => Math.min(min, t.dueInMs),
364
+ Infinity,
365
+ );
366
+ // `dueInMs` can be <= 0 for a timer that is already due but hasn't been
367
+ // triggered; nudge by 1ms so the clock always moves and the loop can't spin.
368
+ const jump = Math.max(due, 1);
369
+ // Only jump if the whole hop fits the budget. A partial hop would burn the
370
+ // budget without firing anything, which is strictly worse than stopping
371
+ // and telling the caller a timer is still pending.
372
+ if (Number.isFinite(jump) && advancedMs + jump <= timeBudgetMs) {
373
+ session.advanceTime(jump);
374
+ advancedMs += jump;
375
+ continue;
376
+ }
246
377
  }
378
+
379
+ return {
380
+ snapshot: round.snapshot,
381
+ handled,
382
+ rounds,
383
+ // `round.reason` is always set once `round.handled === 0` (the only way to
384
+ // reach here), so the fallback is currently unreachable — but if it ever
385
+ // did fire it must use the same handler set as the round, or it would
386
+ // recompute against an empty set and flag every job type as unhandled.
387
+ reason:
388
+ round.reason ??
389
+ settleReason(round.snapshot, [
390
+ ...Object.keys(workers),
391
+ ...Object.keys(opts.agents ?? {}),
392
+ ]),
393
+ unhandled: round.unhandled ?? [],
394
+ advancedMs,
395
+ };
247
396
  }
248
397
  }