bunqueue 2.8.45 → 2.8.47

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.
Files changed (36) hide show
  1. package/dist/client/workflow/admission.d.ts +45 -0
  2. package/dist/client/workflow/admission.js +50 -0
  3. package/dist/client/workflow/clock.d.ts +58 -0
  4. package/dist/client/workflow/clock.js +104 -0
  5. package/dist/client/workflow/compensator.d.ts +56 -3
  6. package/dist/client/workflow/compensator.js +406 -27
  7. package/dist/client/workflow/emitter.d.ts +1 -1
  8. package/dist/client/workflow/emitter.js +4 -3
  9. package/dist/client/workflow/engine.d.ts +17 -0
  10. package/dist/client/workflow/engine.js +25 -0
  11. package/dist/client/workflow/executor.d.ts +40 -5
  12. package/dist/client/workflow/executor.js +227 -83
  13. package/dist/client/workflow/identity.d.ts +46 -0
  14. package/dist/client/workflow/identity.js +93 -0
  15. package/dist/client/workflow/index.d.ts +1 -1
  16. package/dist/client/workflow/loops.js +147 -14
  17. package/dist/client/workflow/recovery.d.ts +10 -1
  18. package/dist/client/workflow/recovery.js +36 -9
  19. package/dist/client/workflow/rollbackControl.d.ts +36 -0
  20. package/dist/client/workflow/rollbackControl.js +51 -0
  21. package/dist/client/workflow/runner.d.ts +22 -2
  22. package/dist/client/workflow/runner.js +125 -27
  23. package/dist/client/workflow/store.d.ts +64 -4
  24. package/dist/client/workflow/store.js +123 -30
  25. package/dist/client/workflow/storeCodec.d.ts +7 -0
  26. package/dist/client/workflow/storeCodec.js +16 -0
  27. package/dist/client/workflow/storeSignals.d.ts +61 -0
  28. package/dist/client/workflow/storeSignals.js +118 -0
  29. package/dist/client/workflow/types.d.ts +147 -4
  30. package/dist/client/workflow/unwindPlan.d.ts +87 -0
  31. package/dist/client/workflow/unwindPlan.js +142 -0
  32. package/dist/client/workflow/waitFor.d.ts +52 -0
  33. package/dist/client/workflow/waitFor.js +137 -0
  34. package/dist/client/workflow/workflow.d.ts +71 -1
  35. package/dist/client/workflow/workflow.js +184 -14
  36. package/package.json +16 -6
@@ -3,6 +3,99 @@
3
3
  * Handles doUntil, doWhile, forEach, and map node types.
4
4
  */
5
5
  import { executeStepWithRetry, buildContext } from './runner';
6
+ import { clock } from './clock';
7
+ /**
8
+ * Run one iteration of a loop body step, unless it already ran.
9
+ *
10
+ * Two properties depend on this, and both were broken:
11
+ *
12
+ * TRANSCRIPT. `exec.steps[name]` is overwritten every iteration, so on its own a
13
+ * loop keeps a sliding window of exactly one turn — by turn 3 the transcript of
14
+ * turn 1 is gone and an agent is asked to reason without its own history
15
+ * (test/workflow-ai-sdk-agent.test.ts measured prompt sizes 1, 3, 3 where they must
16
+ * be 1, 3, 5). Each iteration is therefore also kept under `name:iteration`.
17
+ *
18
+ * RESUME. A loop is a single job, so re-entering the node used to replay every
19
+ * iteration from zero: an agent killed at turn 7 re-paid for turns 1-6, and the
20
+ * restarted in-memory counter overwrote `turn:0` and `turn:1` with turn 2's content,
21
+ * corrupting the very transcript above. Memoising against the persisted per-iteration
22
+ * record fixes both at once — the counter realigns naturally, because iteration 0
23
+ * finds `turn:0` already complete and skips it.
24
+ *
25
+ * Only `completed` is skipped. An iteration left `running` by a crash is genuinely
26
+ * unfinished and must run again.
27
+ *
28
+ * The base name keeps holding the LAST iteration, which is the documented contract
29
+ * for downstream steps.
30
+ *
31
+ * The indexed copies ARE the unwind set: `findStepDef` resolves `turn:0` through a
32
+ * second, iteration-only pass, and `unwindSet` excludes the bare `turn` mirror so the
33
+ * last iteration is not compensated twice. An earlier version of this comment said
34
+ * the opposite, which was true only while a loop compensated its last iteration
35
+ * alone.
36
+ */
37
+ async function runIteration(step, exec, iteration, emitter, updateFn) {
38
+ const indexed = `${step.name}:${iteration}`;
39
+ const already = exec.steps[indexed];
40
+ if (already?.status === 'completed') {
41
+ // Restore the base name so the loop condition and downstream steps see this
42
+ // iteration's result without the body running a second time.
43
+ exec.steps[step.name] = { ...already };
44
+ updateFn(exec);
45
+ return;
46
+ }
47
+ let thrown;
48
+ let threw = false;
49
+ try {
50
+ await executeStepWithRetry(step, buildContext(exec), exec, { emitter, updateFn }, iteration);
51
+ }
52
+ catch (error) {
53
+ // Caught rather than left to a `finally`, because the mirror write below must not be
54
+ // able to REPLACE this error. An exception thrown from a `finally` supersedes the one
55
+ // in flight, so a store that refused the mirror write turned "provider timeout after
56
+ // the charge settled" into "SQLITE_BUSY": that message is what `failureReason`
57
+ // records, so the operator lost the only account of what actually went wrong
58
+ // (`test/repro-workflow-loop-failed-iteration.test.ts`).
59
+ thrown = error;
60
+ threw = true;
61
+ }
62
+ {
63
+ // Writing the mirror on BOTH paths is load-bearing, for the same reason
64
+ // `executeForEach` writes its own in a `finally`.
65
+ //
66
+ // The indexed copies ARE the unwind set, and `unwindSet` drops the bare mirror
67
+ // whenever a `${name}:0` sibling exists, because that mirror is the LAST iteration
68
+ // and compensating it as well would undo that iteration twice. Written only on
69
+ // success, the iteration that THREW existed under the bare name alone, so the two
70
+ // rules combined to lose it: a `doUntil` that charged on every turn and failed on
71
+ // turn 2 refunded turns 0 and 1, left turn 2's charge standing, and still reported
72
+ // `rollbackStatus: 'completed'`. That record was unreachable even by
73
+ // `abandonCompensation`, which walks the same set
74
+ // (`test/repro-workflow-loop-failed-iteration.test.ts`).
75
+ //
76
+ // A failed iteration is the one MOST likely to need undoing: a charge that reached
77
+ // the provider and then lost its response is recorded failed while the money has
78
+ // already moved.
79
+ const record = exec.steps[step.name];
80
+ if (record) {
81
+ // The in-memory copy is what the unwind reads, so it happens regardless. Only the
82
+ // PERSIST can fail, and when the step itself failed, that step's error is the cause
83
+ // and a write that fails while reporting it is downstream. With no step error to
84
+ // protect, a write that cannot be persisted is the failure and must surface: an
85
+ // iteration whose record never reached disk is one whose reversal runs again.
86
+ exec.steps[indexed] = { ...record };
87
+ try {
88
+ updateFn(exec);
89
+ }
90
+ catch (writeError) {
91
+ if (!threw)
92
+ throw writeError;
93
+ }
94
+ }
95
+ }
96
+ if (threw)
97
+ throw thrown;
98
+ }
6
99
  /** Execute a doUntil loop: run steps, then check condition. Repeat until condition returns true. */
7
100
  export async function executeDoUntil(def, exec, emitter, updateFn) {
8
101
  let iteration = 0;
@@ -12,8 +105,7 @@ export async function executeDoUntil(def, exec, emitter, updateFn) {
12
105
  throw new Error(`doUntil exceeded maxIterations (${def.maxIterations})`);
13
106
  }
14
107
  for (const step of def.steps) {
15
- const ctx = buildContext(exec);
16
- await executeStepWithRetry(step, ctx, exec, emitter, updateFn);
108
+ await runIteration(step, exec, iteration, emitter, updateFn);
17
109
  }
18
110
  iteration++;
19
111
  const ctx = buildContext(exec);
@@ -31,8 +123,7 @@ export async function executeDoWhile(def, exec, emitter, updateFn) {
31
123
  throw new Error(`doWhile exceeded maxIterations (${def.maxIterations})`);
32
124
  }
33
125
  for (const step of def.steps) {
34
- const stepCtx = buildContext(exec);
35
- await executeStepWithRetry(step, stepCtx, exec, emitter, updateFn);
126
+ await runIteration(step, exec, iteration, emitter, updateFn);
36
127
  }
37
128
  }
38
129
  }
@@ -40,6 +131,16 @@ export async function executeDoWhile(def, exec, emitter, updateFn) {
40
131
  export async function executeForEach(def, exec, emitter, updateFn) {
41
132
  const ctx = buildContext(exec);
42
133
  const items = def.items(ctx);
134
+ // Anything with a `length` used to be accepted, and JavaScript is generous about
135
+ // what has one. A number iterated ZERO times and the run reported `completed`, so a
136
+ // batch that processed nothing was indistinguishable from a batch with nothing to
137
+ // do. A string iterated its CHARACTERS, so an id list that arrived as `'u1,u2'`
138
+ // silently processed five "items" nobody passed. `null`/`undefined` were caught only
139
+ // by accident, because reading `.length` throws. The likely production shapes were
140
+ // exactly the ones that passed (`test/repro-workflow-foreach-non-array.test.ts`).
141
+ if (!Array.isArray(items)) {
142
+ throw new Error(`forEach items must be an array, got ${items === null ? 'null' : typeof items}`);
143
+ }
43
144
  if (items.length > def.maxIterations) {
44
145
  throw new Error(`forEach items (${items.length}) exceeds maxIterations (${def.maxIterations})`);
45
146
  }
@@ -57,15 +158,47 @@ export async function executeForEach(def, exec, emitter, updateFn) {
57
158
  return def.step.handler(enrichedCtx);
58
159
  },
59
160
  };
161
+ // Memoised the same way as doUntil/doWhile: an item already provisioned before a
162
+ // crash must not be provisioned again when the node is re-entered.
163
+ if (exec.steps[indexedName]?.status === 'completed')
164
+ continue;
60
165
  const stepCtx = buildContext(exec);
61
- await executeStepWithRetry(indexedStep, stepCtx, exec, emitter, updateFn);
62
- // Persist this iteration's __item/__index alongside the step record so
63
- // saga compensation can restore the correct per-iteration context later.
64
- // executeStepWithRetry has just written this record (it throws otherwise).
65
- const record = exec.steps[indexedName];
66
- record.loopItem = item;
67
- record.loopIndex = i;
68
- updateFn(exec);
166
+ let thrown;
167
+ let threw = false;
168
+ try {
169
+ await executeStepWithRetry(indexedStep, stepCtx, exec, { emitter, updateFn }, i);
170
+ }
171
+ catch (error) {
172
+ // Same reason `runIteration` catches instead of using a `finally`: a write that
173
+ // fails below must not REPLACE the step's own error, which is what `failureReason`
174
+ // records and the only account of what went wrong.
175
+ thrown = error;
176
+ threw = true;
177
+ }
178
+ {
179
+ // Persist this iteration's __item/__index alongside the step record so saga
180
+ // compensation can restore the correct per-iteration context later.
181
+ //
182
+ // Recording on BOTH paths is load-bearing: the iteration that THROWS is itself
183
+ // eligible for compensation, and without its item recorded its compensate handler
184
+ // is handed an undefined `__item` and silently releases nothing — the reservation
185
+ // it had already taken at the warehouse leaks
186
+ // (test/workflow-saga-extreme.test.ts).
187
+ const record = exec.steps[indexedName];
188
+ if (record) {
189
+ record.loopItem = item;
190
+ record.loopIndex = i;
191
+ try {
192
+ updateFn(exec);
193
+ }
194
+ catch (writeError) {
195
+ if (!threw)
196
+ throw writeError;
197
+ }
198
+ }
199
+ }
200
+ if (threw)
201
+ throw thrown;
69
202
  }
70
203
  }
71
204
  /** Execute a map node: transform step results into a new value */
@@ -76,8 +209,8 @@ export async function executeMap(def, exec, emitter, updateFn) {
76
209
  exec.steps[def.name] = {
77
210
  status: 'completed',
78
211
  result,
79
- startedAt: Date.now(),
80
- completedAt: Date.now(),
212
+ startedAt: clock().now(),
213
+ completedAt: clock().now(),
81
214
  };
82
215
  updateFn(exec);
83
216
  emitter?.emitStep('step:completed', exec.id, exec.workflowName, def.name, { result });
@@ -11,12 +11,21 @@ import type { Workflow } from './workflow';
11
11
  import type { WorkflowStore } from './store';
12
12
  import type { WorkflowEmitter } from './emitter';
13
13
  import type { RecoverResult } from './types';
14
+ import { type TimerHandle } from './clock';
14
15
  export interface RecoverDeps {
15
16
  store: WorkflowStore;
16
17
  queue: Queue;
17
18
  workflows: Map<string, Workflow>;
18
19
  emitter: WorkflowEmitter | null;
19
- timeoutTimers: Map<string, ReturnType<typeof setTimeout>>;
20
+ timeoutTimers: Map<string, TimerHandle>;
20
21
  scheduleTimeoutCheck: (id: string, wfName: string, nodeIdx: number, ms: number) => void;
22
+ /**
23
+ * Nodes this engine is executing right now. Recovery consults it for the same reason
24
+ * the `compensating` branch consults the unwind claim: re-enqueueing a node that is
25
+ * already in flight produces a job the admission check will reject, and counting it
26
+ * as a recovery reports work that did not happen. A step running without a bound is
27
+ * the ordinary way to reach this.
28
+ */
29
+ nodesInFlight: ReadonlySet<string>;
21
30
  }
22
31
  export declare function recoverExecutions(deps: RecoverDeps): Promise<RecoverResult>;
@@ -7,25 +7,47 @@
7
7
  * - 'compensating': re-run compensation from the beginning (must be idempotent)
8
8
  */
9
9
  import { runCompensation } from './compensator';
10
+ import { hasSignal } from './storeSignals';
11
+ import { clock } from './clock';
12
+ import { decideAdmission } from './admission';
10
13
  export async function recoverExecutions(deps) {
11
14
  const { store, workflows } = deps;
12
15
  const executions = store.listRecoverable();
13
16
  const result = { running: 0, waiting: 0, compensating: 0, total: 0 };
14
- for (const exec of executions) {
15
- const wf = workflows.get(exec.workflowName);
17
+ for (const snapshot of executions) {
18
+ const wf = workflows.get(snapshot.workflowName);
16
19
  if (!wf)
17
20
  continue;
21
+ // Re-read before driving it. The list above is a single snapshot taken before the
22
+ // loop, and each iteration awaits: driving a parent runs its sub-workflow's unwind
23
+ // and settles the CHILD's records in the store, but this loop still holds the
24
+ // child's pre-unwind copy. Using that copy, every `if (record.compensation)`
25
+ // guard reads an unsettled snapshot and dispatches the same handlers a second
26
+ // time, issuing a duplicate refund and leaving no trace: both rows end `failed`
27
+ // with `rollbackStatus: 'completed'`. The in-flight `inFlight` claim does not
28
+ // help here, because the two unwinds are sequential, not concurrent.
29
+ const exec = store.get(snapshot.id);
30
+ if (!exec)
31
+ continue;
18
32
  if (exec.state === 'running') {
19
- await enqueueExecution(exec, deps.queue);
20
- result.running++;
33
+ const admission = decideAdmission(exec, exec.currentNodeIndex, deps.nodesInFlight);
34
+ // Only `already-in-flight` is reachable here: the state is `running` by the
35
+ // branch and the index is the execution's own cursor.
36
+ if (admission.kind === 'run') {
37
+ await enqueueExecution(exec, deps.queue);
38
+ result.running++;
39
+ }
21
40
  }
22
41
  else if (exec.state === 'waiting') {
23
42
  await recoverWaiting(exec, wf, deps);
24
43
  result.waiting++;
25
44
  }
26
45
  else if (exec.state === 'compensating') {
27
- await runCompensation(exec, wf, store, deps.emitter);
28
- result.compensating++;
46
+ // A lost claim means another driver owns this unwind, so nothing happened here
47
+ // and counting it as a recovery would overstate what recover() did.
48
+ const outcome = await runCompensation(exec, wf, store, deps.emitter, deps.workflows);
49
+ if (outcome === 'ran')
50
+ result.compensating++;
29
51
  }
30
52
  }
31
53
  result.total = result.running + result.waiting + result.compensating;
@@ -37,6 +59,10 @@ async function enqueueExecution(exec, queue) {
37
59
  workflowName: exec.workflowName,
38
60
  nodeIndex: exec.currentNodeIndex,
39
61
  };
62
+ // No deterministic jobId, for the reason documented in WorkflowExecutor.enqueue:
63
+ // the duplicate this path can create is neutralised by the cursor guard in
64
+ // processStep, and dedup here risks swallowing a legitimate re-enqueue after a
65
+ // restart, which wedges the run for good.
40
66
  await queue.add('wf:step', jobData);
41
67
  }
42
68
  async function recoverWaiting(exec, wf, deps) {
@@ -45,8 +71,9 @@ async function recoverWaiting(exec, wf, deps) {
45
71
  await enqueueExecution(exec, deps.queue);
46
72
  return;
47
73
  }
48
- // Check if signal already arrived while we were down
49
- if (exec.signals[node.event] !== undefined) {
74
+ // Check if signal already arrived while we were down. Key presence, not value:
75
+ // a payload-less signal records the key with an `undefined` value (see hasSignal).
76
+ if (hasSignal(exec.signals, node.event)) {
50
77
  exec.state = 'running';
51
78
  deps.store.update(exec);
52
79
  await enqueueExecution(exec, deps.queue);
@@ -59,7 +86,7 @@ async function recoverWaiting(exec, wf, deps) {
59
86
  const waitKey = `__waitFor:${node.event}`;
60
87
  const waitRecord = exec.steps[waitKey];
61
88
  const waitingSince = waitRecord?.startedAt ?? exec.updatedAt;
62
- const elapsed = Date.now() - waitingSince;
89
+ const elapsed = clock().now() - waitingSince;
63
90
  const remaining = node.timeout - elapsed;
64
91
  if (remaining <= 0) {
65
92
  exec.state = 'running';
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Operator actions on a parked unwind.
3
+ *
4
+ * A `compensation-stuck` run is waiting for a human decision, which is why the state
5
+ * is not terminal: the engine has done everything it safely can, and the remaining
6
+ * choice, was the reversal fixed or do we accept a partial rollback, is not one it can
7
+ * make. Both exits are explicit, and both leave the execution record saying exactly
8
+ * what happened.
9
+ */
10
+ import type { Workflow } from './workflow';
11
+ import type { WorkflowStore } from './store';
12
+ import type { WorkflowEmitter } from './emitter';
13
+ export interface RollbackDeps {
14
+ store: WorkflowStore;
15
+ emitter: WorkflowEmitter | null;
16
+ workflows: Map<string, Workflow>;
17
+ }
18
+ /**
19
+ * Retry the compensation that parked the run, then carry on with the rest.
20
+ *
21
+ * The retry is asked for with a FLAG rather than by clearing the failed outcome first.
22
+ * Clearing worked, and it cost more than it bought: it wiped the operator's
23
+ * `compensation-failed` record in memory and persisted that wipe BEFORE running
24
+ * anything, so a resume that then met a failing store left a run whose durable row had
25
+ * lost the very diagnostic the operator was acting on, sitting in `compensating` and
26
+ * therefore handed back by `listRecoverable()` at every subsequent startup. Guarding
27
+ * that needed a deep snapshot, a restore path, and a second write which could itself
28
+ * throw and mask the original error.
29
+ *
30
+ * None of that machinery is needed if nothing is destroyed in the first place. The
31
+ * record stays until a real outcome replaces it, and a store that fails simply leaves
32
+ * the parked run exactly as the operator found it.
33
+ */
34
+ export declare function resumeCompensation(deps: RollbackDeps, executionId: string): Promise<void>;
35
+ /** Accept a partial rollback: the outstanding steps are recorded as skipped. */
36
+ export declare function abandonParkedCompensation(deps: RollbackDeps, executionId: string): void;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Operator actions on a parked unwind.
3
+ *
4
+ * A `compensation-stuck` run is waiting for a human decision, which is why the state
5
+ * is not terminal: the engine has done everything it safely can, and the remaining
6
+ * choice, was the reversal fixed or do we accept a partial rollback, is not one it can
7
+ * make. Both exits are explicit, and both leave the execution record saying exactly
8
+ * what happened.
9
+ */
10
+ import { abandonCompensation, runCompensation } from './compensator';
11
+ /**
12
+ * Retry the compensation that parked the run, then carry on with the rest.
13
+ *
14
+ * The retry is asked for with a FLAG rather than by clearing the failed outcome first.
15
+ * Clearing worked, and it cost more than it bought: it wiped the operator's
16
+ * `compensation-failed` record in memory and persisted that wipe BEFORE running
17
+ * anything, so a resume that then met a failing store left a run whose durable row had
18
+ * lost the very diagnostic the operator was acting on, sitting in `compensating` and
19
+ * therefore handed back by `listRecoverable()` at every subsequent startup. Guarding
20
+ * that needed a deep snapshot, a restore path, and a second write which could itself
21
+ * throw and mask the original error.
22
+ *
23
+ * None of that machinery is needed if nothing is destroyed in the first place. The
24
+ * record stays until a real outcome replaces it, and a store that fails simply leaves
25
+ * the parked run exactly as the operator found it.
26
+ */
27
+ export async function resumeCompensation(deps, executionId) {
28
+ const { exec, wf } = parked(deps, executionId);
29
+ const outcome = await runCompensation(exec, wf, deps.store, deps.emitter, deps.workflows, { retryFailed: true });
30
+ if (outcome === 'claim-lost') {
31
+ // Nothing was mutated: `runCompensation` returns this before the unwind begins.
32
+ throw new Error(`execution "${executionId}" is already being rolled back by another driver`);
33
+ }
34
+ }
35
+ /** Accept a partial rollback: the outstanding steps are recorded as skipped. */
36
+ export function abandonParkedCompensation(deps, executionId) {
37
+ const { exec, wf } = parked(deps, executionId);
38
+ abandonCompensation(exec, wf, deps.store, deps.emitter);
39
+ }
40
+ function parked(deps, executionId) {
41
+ const exec = deps.store.get(executionId);
42
+ if (!exec)
43
+ throw new Error(`Execution "${executionId}" not found`);
44
+ if (exec.state !== 'compensation-stuck') {
45
+ throw new Error(`Execution "${executionId}" is "${exec.state}", not a parked unwind ("compensation-stuck")`);
46
+ }
47
+ const wf = deps.workflows.get(exec.workflowName);
48
+ if (!wf)
49
+ throw new Error(`Workflow "${exec.workflowName}" not registered`);
50
+ return { exec, wf };
51
+ }
@@ -6,14 +6,34 @@ import type { Workflow } from './workflow';
6
6
  import type { WorkflowEmitter } from './emitter';
7
7
  /** Run a promise with a timeout */
8
8
  export declare function runWithTimeout<T>(promise: Promise<T> | T, timeoutMs: number): Promise<T>;
9
+ /** Engine hooks a step needs; they always travel together. */
10
+ export interface StepHooks {
11
+ emitter: WorkflowEmitter | null;
12
+ updateFn: (exec: Execution) => void;
13
+ }
9
14
  /** Execute a step with retry logic and exponential backoff */
10
- export declare function executeStepWithRetry(def: StepDefinition, ctx: StepContext, exec: Execution, emitter: WorkflowEmitter | null, updateFn: (exec: Execution) => void): Promise<void>;
15
+ export declare function executeStepWithRetry(def: StepDefinition, ctx: StepContext, exec: Execution, hooks: StepHooks, occurrence?: number): Promise<void>;
11
16
  /** Execute multiple steps in parallel via Promise.allSettled */
12
17
  export declare function executeParallelSteps(steps: StepDefinition[], ctx: StepContext, exec: Execution, emitter: WorkflowEmitter | null, updateFn: (exec: Execution) => void): Promise<void>;
13
18
  /** Execute a sub-workflow by starting it and polling for completion */
14
19
  export declare function executeSubWorkflow(workflowName: string, input: unknown, startFn: (name: string, input: unknown) => Promise<{
15
20
  id: string;
16
- }>, getFn: (id: string) => Execution | null, pollIntervalMs?: number): Promise<Record<string, unknown>>;
21
+ }>, getFn: (id: string) => Execution | null, pollIntervalMs?: number,
22
+ /**
23
+ * A child this node already started, from an earlier entry into the same node.
24
+ *
25
+ * Without it the node started a BRAND NEW child every time it was re-entered, and
26
+ * re-entry is routine: a restart followed by `recover()` re-enqueues the parent's
27
+ * current node. Measured across one restart, the child ran twice and both rows were
28
+ * left `running` forever, since a child is excluded from recovery while its parent
29
+ * exists and `cleanup`/`archive` only reap terminal states. Duplicated work, not just
30
+ * a leaked row: a child that provisions a resource provisioned it twice
31
+ * (`test/repro-workflow-orphan-child.test.ts`).
32
+ */
33
+ existingChildId?: string): Promise<{
34
+ results: Record<string, unknown>;
35
+ executionId: string;
36
+ }>;
17
37
  /** Find a step definition by name across all node types */
18
38
  export declare function findStepDef(wf: Workflow, name: string): StepDefinition | null;
19
39
  /** Build a StepContext from the current execution state */