@particle-academy/fancy-flow 0.72.1 → 0.74.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/dist/durable.cjs CHANGED
@@ -72,6 +72,10 @@ var InMemoryClaimStore = class {
72
72
  /**
73
73
  * Drop a paused node's claim so a recorded answer can re-run it.
74
74
  *
75
+ * A PAUSED row holds one of the run's dispatch slots (see `selectDispatch`),
76
+ * so this is also what frees the slot: until the row is released, a serial
77
+ * run's `advance()` hands out nothing, the gate included.
78
+ *
75
79
  * Not part of the interface: resuming a human gate is the host's decision and
76
80
  * its storage's business. Provided here because the in-memory store is also
77
81
  * what the tests resume through.
@@ -2734,6 +2738,31 @@ var Frontier = {
2734
2738
  }
2735
2739
  };
2736
2740
 
2741
+ // src/durable/dispatch.ts
2742
+ var UNLIMITED_CONCURRENCY = 0;
2743
+ var DEFAULT_MAX_CONCURRENT = 1;
2744
+ function assertMaxConcurrent(value) {
2745
+ if (typeof value === "number" && Number.isInteger(value) && value >= 0) return value;
2746
+ throw new RangeError(
2747
+ `maxConcurrent must be a positive integer, or UNLIMITED_CONCURRENCY (0) for the whole ready frontier, or unset for serial; got ${describe(value)}.`
2748
+ );
2749
+ }
2750
+ function selectDispatch(ready, state, maxConcurrent) {
2751
+ const limit = assertMaxConcurrent(maxConcurrent);
2752
+ if (limit === UNLIMITED_CONCURRENCY) return [...ready];
2753
+ let held = 0;
2754
+ for (const entry of Object.values(state)) {
2755
+ if (entry.status === NodeRunStatus.CLAIMED || entry.status === NodeRunStatus.PAUSED) held++;
2756
+ }
2757
+ return ready.slice(0, Math.max(0, limit - held));
2758
+ }
2759
+ function describe(value) {
2760
+ if (typeof value === "number") return String(value);
2761
+ if (typeof value === "string") return JSON.stringify(value);
2762
+ if (value === null) return "null";
2763
+ return typeof value;
2764
+ }
2765
+
2737
2766
  // src/durable/replay.ts
2738
2767
  var BOUNDARY = "fancy-flow:node-boundary";
2739
2768
  var FENCE_PORT = "fancy-flow:fenced";
@@ -2933,6 +2962,7 @@ var Coordinator = class {
2933
2962
  this.store = options.store ?? new InMemoryClaimStore();
2934
2963
  this.initialInputs = options.initialInputs ?? {};
2935
2964
  this.retry = options.retry ?? new RetryPolicy();
2965
+ this.maxConcurrent = assertMaxConcurrent(options.maxConcurrent ?? DEFAULT_MAX_CONCURRENT);
2936
2966
  this.onEvent = options.onEvent;
2937
2967
  }
2938
2968
  get runKey() {
@@ -2942,6 +2972,13 @@ var Coordinator = class {
2942
2972
  /**
2943
2973
  * Which nodes may be dispatched right now.
2944
2974
  *
2975
+ * The ready frontier, cut to the run's {@link maxConcurrent} budget: the
2976
+ * first `maxConcurrent - held` ready ids in declaration order, where `held`
2977
+ * counts CLAIMED and PAUSED rows. Under the serial default that is at most one
2978
+ * id, and none while a node is still running or a gate is waiting on a person.
2979
+ * An empty result with work held is a throttled run, not a stalled one: the
2980
+ * held node's settle is what calls this again.
2981
+ *
2945
2982
  * Also settles the skip cascade, because a skip is a decision the frontier
2946
2983
  * just made and a second caller must not make it again.
2947
2984
  *
@@ -2957,7 +2994,12 @@ var Coordinator = class {
2957
2994
  const frontier = Frontier.compute(this.graph, state);
2958
2995
  const settled = await Frontier.settleSkips(this.store, this.runKey, frontier.skipped);
2959
2996
  this.warnForSkipped(settled, state);
2960
- return frontier.ready;
2997
+ const after = { ...state };
2998
+ for (const nodeId of frontier.skipped) {
2999
+ const row = state[nodeId];
3000
+ after[nodeId] = row ? { ...row, status: NodeRunStatus.SKIPPED, ports: [] } : { status: NodeRunStatus.SKIPPED, ports: [], attempts: 0, firstAttemptAt: "" };
3001
+ }
3002
+ return selectDispatch(frontier.ready, after, this.maxConcurrent);
2961
3003
  }
2962
3004
  /**
2963
3005
  * Claim, execute and checkpoint one node.
@@ -3019,6 +3061,12 @@ var Coordinator = class {
3019
3061
  /**
3020
3062
  * Drive the graph here, in this process, one node at a time.
3021
3063
  *
3064
+ * It asks `advance()` exactly as a queue adapter does, so it runs nodes in the
3065
+ * order a queued run under the same `maxConcurrent` would dispatch them. A run
3066
+ * that finishes produces the same outputs under every limit. A run that stops
3067
+ * on a pause or a failure can have run a different set of nodes before it
3068
+ * stopped, because the order among nodes that are ready together differs.
3069
+ *
3022
3070
  * Every checkpoint is written exactly as a queued run writes it, so a crash
3023
3071
  * mid-loop resumes from the same place a crashed worker would.
3024
3072
  *
@@ -3027,7 +3075,7 @@ var Coordinator = class {
3027
3075
  * claim with the same owner token — so the step key it derives is unchanged,
3028
3076
  * which is what makes the retry idempotent rather than duplicative.
3029
3077
  */
3030
- async runToCompletion(maxPasses = 1e4) {
3078
+ async runToCompletion(maxPasses = Math.max(1e4, this.graph.nodes.length + 1)) {
3031
3079
  for (let pass = 0; pass < maxPasses; pass++) {
3032
3080
  const ready = await this.advance();
3033
3081
  if (ready.length === 0) break;
@@ -3185,11 +3233,13 @@ exports.NotAwaitingHuman = NotAwaitingHuman;
3185
3233
  exports.RetryPolicy = RetryPolicy;
3186
3234
  exports.SETTLED = SETTLED;
3187
3235
  exports.Submissions = Submissions;
3236
+ exports.UNLIMITED_CONCURRENCY = UNLIMITED_CONCURRENCY;
3188
3237
  exports.UNSAFE_TO_REPLAY = UNSAFE_TO_REPLAY;
3189
3238
  exports.durableApproval = durableApproval;
3190
3239
  exports.durableUserInput = durableUserInput;
3191
3240
  exports.isBoundary = isBoundary;
3192
3241
  exports.isSettled = isSettled;
3193
3242
  exports.replayUpTo = replayUpTo;
3243
+ exports.selectDispatch = selectDispatch;
3194
3244
  //# sourceMappingURL=durable.cjs.map
3195
3245
  //# sourceMappingURL=durable.cjs.map