@melra/runtime-core 0.3.0-alpha.12 → 0.3.0-alpha.13

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.
@@ -5,6 +5,17 @@ import { WorkflowDefinitionSchema, WorkflowEventSchema, WorkflowRunSchema, Workf
5
5
  import { canonicalJson, redactStructuredValue, sha256, } from "@melra/receipt-schema";
6
6
  import { applyWorkflowEvent, rebuildWorkflow, } from "./workflow-events.js";
7
7
  import { readyNodeIds, validateWorkflow, } from "./workflow-graph.js";
8
+ const RECOVERABLE = [
9
+ "planned",
10
+ "running",
11
+ "awaiting_approval",
12
+ "recovery_required",
13
+ ];
14
+ // A failed run's advance runs its compensations, so an advance that died on
15
+ // one leaves the same hazard as any other: a compensation it started, planned
16
+ // and run again. Repaired too, but only once an expired lease says an advance
17
+ // died there; the full startup pass leaves failed runs as they are.
18
+ const ABANDONABLE = [...RECOVERABLE, "failed"];
8
19
  export class WorkflowController {
9
20
  store;
10
21
  tasks;
@@ -15,6 +26,12 @@ export class WorkflowController {
15
26
  // process from racing itself without touching the disk on every call.
16
27
  advances = new Map();
17
28
  owner = `${process.pid}:${randomUUID()}`;
29
+ // Distinct from `owner`, so this process's own advance in flight also keeps
30
+ // its recovery away from the run.
31
+ recoveryOwner = `${this.owner}:recovery`;
32
+ // Runs an advance in this process holds the lease for. Recovery leaves them
33
+ // alone even once the lease has lapsed, say across a laptop's sleep.
34
+ leased = new Set();
18
35
  constructor(store, tasks, payloadCipher, leaseDurationMs = 60_000) {
19
36
  this.store = store;
20
37
  this.tasks = tasks;
@@ -97,7 +114,40 @@ export class WorkflowController {
97
114
  // start duplicate side effects while this one is mid-flight. `transitionWorkflow`
98
115
  // still guards the commit; this guards the work leading up to it.
99
116
  async leasedAdvance(workflowId, approvals, inputs) {
100
- this.status(workflowId);
117
+ const current = this.status(workflowId);
118
+ // Refused before the lease is touched: taking it would replace, and the
119
+ // release then delete, an expired lease a dead advance left, which is the
120
+ // only sign the run still needs recovering once it is resumed.
121
+ if (["paused", "suspended"].includes(current.status)) {
122
+ throw new Error(`workflow_halted:${current.status}`);
123
+ }
124
+ // Every task would refuse these anyway, but only once its node's turn came,
125
+ // after siblings in the same wave had already been cancelled, planned
126
+ // again or run on the strength of a request that is refused. Refused
127
+ // before anything moves.
128
+ if (this.tasks.enforced && approvals.length > 0)
129
+ throw new Error("approval_in_band_refused");
130
+ // An expired lease someone else holds is an advance that died mid-flight.
131
+ // Taking it over would read a projection from before that advance's tasks
132
+ // and plan them again under keys that were never committed, so the run is
133
+ // recovered first. A lease left exactly as it was means a task of that
134
+ // advance is still running somewhere: wait, as for a live holder.
135
+ const left = this.store.getWorkflowLease(workflowId);
136
+ if (left !== undefined &&
137
+ left.owner !== this.owner &&
138
+ left.expiresAt <= new Date().toISOString() &&
139
+ ABANDONABLE.includes(current.status)) {
140
+ // A repaired run has no lease left. One still there is a task still
141
+ // running, a live process that got in first, or a repair that failed.
142
+ // Only this run's own failure is reported as itself; the rest of the
143
+ // pass's belong to other runs or tasks and reach the sweep's warnings.
144
+ const pass = await this.recoveryPass(true);
145
+ if (this.store.getWorkflowLease(workflowId) !== undefined) {
146
+ if (pass.runFailures.has(workflowId))
147
+ throw pass.runFailures.get(workflowId);
148
+ throw new Error("workflow_lease_held");
149
+ }
150
+ }
101
151
  const now = Date.now();
102
152
  if (!this.store.acquireWorkflowLease(workflowId, this.owner, new Date(now).toISOString(), new Date(now + this.leaseDurationMs).toISOString())) {
103
153
  throw new Error("workflow_lease_held");
@@ -108,10 +158,12 @@ export class WorkflowController {
108
158
  this.store.renewWorkflowLease(workflowId, this.owner, new Date(Date.now() + this.leaseDurationMs).toISOString());
109
159
  }, Math.max(1_000, Math.floor(this.leaseDurationMs / 2)));
110
160
  renewal.unref();
161
+ this.leased.add(workflowId);
111
162
  try {
112
163
  return await this.advanceOnce(workflowId, approvals, inputs);
113
164
  }
114
165
  finally {
166
+ this.leased.delete(workflowId);
115
167
  clearInterval(renewal);
116
168
  this.store.releaseWorkflowLease(workflowId, this.owner);
117
169
  }
@@ -148,7 +200,7 @@ export class WorkflowController {
148
200
  ["failed", "recovery_required"].includes(current.nodes[node.id]?.status ?? ""))) {
149
201
  return { run: current, tasks: [], events: [] };
150
202
  }
151
- // A node parked for a human — approval phrase or supplied input — is no
203
+ // A node parked for a human — approval or supplied input — is no
152
204
  // longer `pending`, so `readyNodeIds` will not return it. Re-offer it here
153
205
  // or the workflow would stall forever with the answer in hand.
154
206
  const awaiting = [...graph.nodes.values()]
@@ -164,6 +216,11 @@ export class WorkflowController {
164
216
  if (nodeIds.length === 0) {
165
217
  return { run: current, tasks: [], events: [] };
166
218
  }
219
+ // A sibling's refusal is reported only after the others' changes are
220
+ // committed: a node another sibling cancelled and planned again in this
221
+ // wave would otherwise keep pointing at the task it cancelled, and stay
222
+ // cancelled for good.
223
+ let refused;
167
224
  const results = await (rollback.length >
168
225
  0
169
226
  ? // Compensations undo each other's preconditions, so the order they run
@@ -174,13 +231,19 @@ export class WorkflowController {
174
231
  // continuing past a failed unwind would strand the saga in a state no
175
232
  // node describes.
176
233
  this.unwind(rollback, graph, definition, current, approvals, inputs)
177
- : Promise.all(nodeIds.map(async (nodeId) => {
234
+ : Promise.allSettled(nodeIds.map(async (nodeId) => {
178
235
  const node = graph.nodes.get(nodeId);
179
236
  return {
180
237
  nodeId,
181
238
  result: await this.advanceNode(node, definition, current.nodes[nodeId], current, approvals, inputs),
182
239
  };
183
- })));
240
+ })).then((settled) => {
241
+ // Every sibling finishes before one's error is reported. Rejecting
242
+ // at the first throw released the lease while other adapters still
243
+ // ran, and a retry then read their tasks as failed.
244
+ refused = settled.find((outcome) => outcome.status === "rejected");
245
+ return settled.flatMap((outcome) => (outcome.status === "fulfilled" ? [outcome.value] : []));
246
+ }));
184
247
  // A *compensation* that failed must not open a second unwind. The saga is
185
248
  // already stranded, and undoing the next step while this inverse is still
186
249
  // outstanding is precisely what the halt exists to prevent — so only a
@@ -287,78 +350,223 @@ export class WorkflowController {
287
350
  createdAt: run.updatedAt,
288
351
  }));
289
352
  }
353
+ if (refused !== undefined)
354
+ throw refused.reason;
290
355
  return {
291
356
  run,
292
357
  tasks: results.flatMap(({ result }) => result.tasks),
293
358
  events: this.events(workflowId, current.stateVersion),
294
359
  };
295
360
  }
296
- async recoverInterrupted() {
297
- await this.tasks.recoverInterrupted();
361
+ /**
362
+ * Repairs runs whose advance is not coming back. A run is touched only under
363
+ * its lease, so a live advance in any process (this one included) keeps its
364
+ * run; and only when none of its tasks is still running, which after the
365
+ * task pass means a live process owns that task. `abandonedOnly` limits the
366
+ * pass to runs whose advance died holding the lease — the periodic sweep.
367
+ */
368
+ async recoverInterrupted({ abandonedOnly = false } = {}) {
369
+ // A task or run that cannot be recovered is reported once everything else
370
+ // has been: one bad record must not hold up the rest.
371
+ const pass = await this.recoveryPass(abandonedOnly);
372
+ if (pass.failures.length > 0)
373
+ throw pass.failures[0];
374
+ return pass.recovered;
375
+ }
376
+ async recoveryPass(abandonedOnly) {
377
+ const taskFailures = new Map();
378
+ const failures = [];
379
+ await this.tasks.recoverInterrupted(taskFailures).catch((error) => {
380
+ failures.push(error);
381
+ });
382
+ const runFailures = new Map();
383
+ const recovered = this.repairRuns(abandonedOnly, taskFailures, runFailures);
384
+ failures.push(...taskFailures.values(), ...runFailures.values());
385
+ return { recovered, failures, runFailures };
386
+ }
387
+ /**
388
+ * Repairs each run under its lease. `runFailures` gets each run's own reason
389
+ * for not being repaired, including a task of its that could not be.
390
+ */
391
+ repairRuns(abandonedOnly, taskFailures, runFailures) {
392
+ const at = Date.now();
393
+ // The sweep reads only runs with an expired lease, so a home's history of
394
+ // finished and failed runs costs it nothing.
395
+ const runs = abandonedOnly
396
+ ? this.store
397
+ .expiredWorkflowLeases(new Date(at).toISOString())
398
+ .map((id) => this.store.getWorkflowRun(id))
399
+ .filter((run) => run !== undefined && ABANDONABLE.includes(run.status))
400
+ : this.store.listWorkflowRuns(RECOVERABLE);
401
+ if (runs.length === 0)
402
+ return [];
298
403
  const recovered = [];
299
404
  const allTasks = this.store.listTasks(100_000);
300
- for (const stored of this.store.listWorkflowRuns([
301
- "planned",
302
- "running",
303
- "awaiting_approval",
304
- "recovery_required",
305
- ])) {
306
- const replayed = this.replay(stored);
307
- if (replayed.stateVersion !== stored.stateVersion) {
308
- throw new Error("workflow_event_history_invalid");
405
+ for (const stored of runs) {
406
+ if (this.leased.has(stored.id))
407
+ continue;
408
+ let definition;
409
+ try {
410
+ definition = this.loadDefinition(stored);
411
+ }
412
+ catch (error) {
413
+ runFailures.set(stored.id, error);
414
+ continue;
415
+ }
416
+ const keys = new Set(definition.nodes.flatMap((node) => expectedTaskKeys(stored.id, node, definition)));
417
+ // Skipped runs keep any abandoned lease, which is how the sweep finds
418
+ // them again once that task settles.
419
+ const unsettled = allTasks.filter((task) => task.idempotencyKey !== undefined &&
420
+ keys.has(task.idempotencyKey) &&
421
+ (task.status === "running" || task.status === "verifying"));
422
+ if (unsettled.length > 0) {
423
+ const failed = unsettled.find((task) => taskFailures.has(task.id));
424
+ if (failed !== undefined)
425
+ runFailures.set(stored.id, taskFailures.get(failed.id));
426
+ continue;
427
+ }
428
+ const taken = this.store.takeWorkflowLease(stored.id, this.recoveryOwner, new Date(at).toISOString(), new Date(at + this.leaseDurationMs).toISOString());
429
+ if (taken === "refused")
430
+ continue;
431
+ let repaired = false;
432
+ let failed = false;
433
+ try {
434
+ // Changed since the listing, by an advance or by an operator's pause,
435
+ // so the tasks read above may be stale.
436
+ if (this.store.getWorkflowRun(stored.id)?.stateVersion === stored.stateVersion) {
437
+ recovered.push(this.recoverRun(stored, definition, allTasks));
438
+ repaired = true;
439
+ }
440
+ }
441
+ catch (error) {
442
+ failed = true;
443
+ runFailures.set(stored.id, error);
444
+ }
445
+ finally {
446
+ // A run not repaired keeps a lease, expired rather than deleted, when
447
+ // it still needs one: its repair failed, or the lease it replaced was
448
+ // a dead advance's or an earlier repair's. That lease is how the sweep
449
+ // and the next advance know, and without it an advance would plan its
450
+ // tasks again. A lease this pass created replaced nothing: whoever
451
+ // held one last released it with the run left straight.
452
+ if (repaired || (!failed && taken === "created")) {
453
+ this.store.releaseWorkflowLease(stored.id, this.recoveryOwner);
454
+ }
455
+ else {
456
+ this.store.renewWorkflowLease(stored.id, this.recoveryOwner, new Date(0).toISOString());
457
+ }
309
458
  }
310
- const definition = this.loadDefinition(stored);
311
- let run = this.transition(stored, (draft, emit) => {
459
+ }
460
+ return recovered;
461
+ }
462
+ recoverRun(stored, definition, allTasks) {
463
+ const replayed = this.replay(stored);
464
+ if (replayed.stateVersion !== stored.stateVersion) {
465
+ throw new Error("workflow_event_history_invalid");
466
+ }
467
+ return this.transition(stored, (draft, emit) => {
468
+ for (const node of definition.nodes) {
469
+ // An advance on a failed run only ever runs compensations, so only
470
+ // they can be stale. Re-deriving the forward nodes would rank their
471
+ // tasks differently from the live path and could take the run out of
472
+ // failed: a branch awaiting approval beside a failed one reopened.
473
+ if (stored.status === "failed" && node.type !== "compensation")
474
+ continue;
475
+ const found = expectedTaskKeys(stored.id, node, definition).map((key) => preferredTask(allTasks, key));
476
+ let state = recoveredNodeState(node, found.filter((task) => task !== undefined), replayed.nodes[node.id]);
477
+ // An operation behind an approval shares its task with the approval
478
+ // node. Waiting for the approval is that node's state; mirrored onto
479
+ // the operation, both were re-offered, the approval ran the effect,
480
+ // and the operation read the running task as failed.
481
+ if (node.type === "operation" &&
482
+ state.status === "awaiting_approval" &&
483
+ definition.nodes.some((other) => other.type === "approval" && other.forNodeId === node.id)) {
484
+ state = replayed.nodes[node.id];
485
+ }
486
+ // A sequence whose finished steps all verified is not finished if
487
+ // steps remain: a crash between a loop's iterations, or between a
488
+ // branch's steps, recovered as complete with the rest never run.
489
+ // Pending, so the next advance carries on from the tasks it has.
490
+ // Unless the projection recorded it finished in a way only the live
491
+ // path finishes one short: a loop stopped by `until` at a whole
492
+ // iteration, or a condition whose chosen branch is empty. Reopened,
493
+ // `until` or the condition would be asked again of a world that has
494
+ // moved on. Anything else recorded finished but short was written by
495
+ // a recovery that never downgraded (alpha.12), and is downgraded now.
496
+ const recorded = replayed.nodes[node.id];
497
+ const finishedShort = recorded.status === "verified_complete" &&
498
+ canonicalJson(recorded.taskIds) === canonicalJson(state.taskIds) &&
499
+ ((node.type === "bounded_loop" &&
500
+ node.until !== undefined &&
501
+ state.taskIds.length > 0 &&
502
+ state.taskIds.length % node.body.length === 0) ||
503
+ (node.type === "condition" && state.taskIds.length === 0));
504
+ if (state.status === "verified_complete" && !sequenceFinished(node, found) && !finishedShort) {
505
+ state = {
506
+ status: "pending",
507
+ taskIds: state.taskIds,
508
+ // Whole iterations only: the loop resumes at the one in progress.
509
+ ...(node.type === "bounded_loop"
510
+ ? { iterations: Math.floor(state.taskIds.length / node.body.length) }
511
+ : {}),
512
+ };
513
+ }
514
+ // A loop that stopped on a step keeps the iteration it stopped in, as
515
+ // runLoop records it. Without it the next advance starts again at
516
+ // iteration 0 and asks `until` of a task long finished.
517
+ if (node.type === "bounded_loop" && state.iterations === undefined && state.taskIds.length > 0) {
518
+ state = { ...state, iterations: Math.floor((state.taskIds.length - 1) / node.body.length) };
519
+ }
520
+ if (canonicalJson(draft.nodes[node.id]) === canonicalJson(state)) {
521
+ continue;
522
+ }
523
+ const from = draft.nodes[node.id].status;
524
+ draft.nodes[node.id] = state;
525
+ emit("workflow.node_changed", {
526
+ nodeId: node.id,
527
+ from,
528
+ state,
529
+ });
530
+ }
531
+ let status = this.deriveStatus(draft, definition);
532
+ if (status === "verified_complete") {
312
533
  for (const node of definition.nodes) {
313
- const state = recoveredNodeState(node, expectedTaskKeys(stored.id, node, definition)
314
- .map((key) => preferredTask(allTasks, key))
315
- .filter((task) => task !== undefined), replayed.nodes[node.id]);
316
- if (canonicalJson(draft.nodes[node.id]) === canonicalJson(state)) {
534
+ const state = draft.nodes[node.id];
535
+ if (node.type !== "compensation" || state.status !== "pending") {
317
536
  continue;
318
537
  }
319
- const from = draft.nodes[node.id].status;
320
- draft.nodes[node.id] = state;
538
+ state.status = "skipped";
321
539
  emit("workflow.node_changed", {
322
540
  nodeId: node.id,
323
- from,
541
+ from: "pending",
324
542
  state,
325
543
  });
326
544
  }
327
- let status = this.deriveStatus(draft, definition);
328
- if (status === "verified_complete") {
329
- for (const node of definition.nodes) {
330
- const state = draft.nodes[node.id];
331
- if (node.type !== "compensation" || state.status !== "pending") {
332
- continue;
333
- }
334
- state.status = "skipped";
335
- emit("workflow.node_changed", {
336
- nodeId: node.id,
337
- from: "pending",
338
- state,
339
- });
340
- }
341
- status = this.deriveStatus(draft, definition);
342
- }
343
- const from = draft.status;
344
- draft.status = status;
345
- if (status === "recovery_required") {
346
- draft.error = "workflow_contains_unreconciled_mutation";
347
- }
348
- else {
349
- delete draft.error;
350
- }
351
- emit(status === "recovery_required"
352
- ? "workflow.recovery_required"
353
- : "workflow.recovered", {
354
- from,
355
- to: status,
356
- ...(draft.error === undefined ? {} : { error: draft.error }),
357
- });
545
+ status = this.deriveStatus(draft, definition);
546
+ }
547
+ const from = draft.status;
548
+ draft.status = status;
549
+ // Named the way advanceOnce names a stalled unwind, so a run reads the
550
+ // same whether its process died or not.
551
+ const stalled = definition.nodes.find((node) => node.type === "compensation" &&
552
+ ["failed", "recovery_required"].includes(draft.nodes[node.id]?.status ?? ""));
553
+ if (status === "recovery_required") {
554
+ draft.error =
555
+ stalled === undefined
556
+ ? "workflow_contains_unreconciled_mutation"
557
+ : `workflow_compensation_incomplete:${stalled.id}`;
558
+ }
559
+ else {
560
+ delete draft.error;
561
+ }
562
+ emit(status === "recovery_required"
563
+ ? "workflow.recovery_required"
564
+ : "workflow.recovered", {
565
+ from,
566
+ to: status,
567
+ ...(draft.error === undefined ? {} : { error: draft.error }),
358
568
  });
359
- recovered.push(run);
360
- }
361
- return recovered;
569
+ });
362
570
  }
363
571
  // Operator controls. `pause` is a soft stop an operator lifts with `resume`;
364
572
  // `suspend` is the same stop for an operator who wants the run parked
@@ -376,18 +584,22 @@ export class WorkflowController {
376
584
  if (!["paused", "suspended"].includes(current.status)) {
377
585
  throw new Error("workflow_not_paused");
378
586
  }
587
+ const definition = this.loadDefinition(current);
379
588
  return this.transition(current, (draft, emit) => {
380
589
  const from = draft.status;
381
590
  // Restore whatever the graph was actually blocked on before the halt,
382
- // so resuming a run parked on a human does not claim it is running.
383
- const nodes = Object.values(draft.nodes);
384
- draft.status = nodes.some((node) => node.status === "awaiting_approval")
385
- ? "awaiting_approval"
386
- : nodes.some((node) => node.status === "awaiting_input")
387
- ? "awaiting_input"
388
- : "running";
389
- delete draft.error;
390
- emit("workflow.resumed", { from, to: draft.status });
591
+ // so resuming a run parked on a human, or on an effect whose outcome is
592
+ // unknown, does not claim it is running.
593
+ draft.status = this.deriveStatus(draft, definition);
594
+ if (draft.status !== "recovery_required")
595
+ delete draft.error;
596
+ else
597
+ draft.error ??= "workflow_contains_unreconciled_mutation";
598
+ emit("workflow.resumed", {
599
+ from,
600
+ to: draft.status,
601
+ ...(draft.error === undefined ? {} : { error: draft.error }),
602
+ });
391
603
  });
392
604
  }
393
605
  halt(workflowId, to, eventType) {
@@ -400,7 +612,9 @@ export class WorkflowController {
400
612
  return this.transition(current, (draft, emit) => {
401
613
  const from = draft.status;
402
614
  draft.status = to;
403
- emit(eventType, { from, to });
615
+ // Kept: a run halted while it needed recovery still needs it, and the
616
+ // reducer drops an error the event does not carry.
617
+ emit(eventType, { from, to, ...(draft.error === undefined ? {} : { error: draft.error }) });
404
618
  });
405
619
  }
406
620
  cancel(workflowId) {
@@ -524,15 +738,16 @@ export class WorkflowController {
524
738
  if (sourceTaskId === undefined) {
525
739
  throw new Error(`workflow_condition_source_unavailable:${node.id}`);
526
740
  }
527
- const condition = await this.tasks.verifyPersisted(sourceTaskId, [
528
- node.predicate,
529
- ]);
530
- return await this.runSequential(condition.verified ? node.whenTrue : node.whenFalse, state, approvals, {
531
- workflowId: run.id,
532
- nodeId: node.id,
533
- iteration: 0,
534
- branch: condition.verified ? "true" : "false",
535
- });
741
+ // Decided once. A branch that has started keeps going: re-evaluating a
742
+ // live predicate on a later advance could flip it and reuse the other
743
+ // branch's tasks by position.
744
+ const identity = { workflowId: run.id, nodeId: node.id, iteration: 0 };
745
+ const started = state.taskIds[0] === undefined ? undefined : this.tasks.status(state.taskIds[0]).idempotencyKey;
746
+ const chosen = started === undefined
747
+ ? (await this.tasks.verifyPersisted(sourceTaskId, [node.predicate])).verified
748
+ : node.whenTrue.length > 0 &&
749
+ started === taskIdempotencyKey({ ...identity, branch: "true:0" }, node.whenTrue[0]);
750
+ return await this.runSequential(chosen ? node.whenTrue : node.whenFalse, state, approvals, { ...identity, branch: chosen ? "true" : "false" });
536
751
  }
537
752
  case "parallel":
538
753
  return await this.runParallel(node.branches, state, approvals, run.id, node.id);
@@ -634,11 +849,29 @@ export class WorkflowController {
634
849
  ? this.tasks.plan(request, {
635
850
  idempotencyKey: taskIdempotencyKey(identity, request),
636
851
  attempt: 1,
852
+ stored: true,
637
853
  })
638
854
  : this.tasks.status(existingTaskId);
855
+ // A challenge that expired before anyone answered it can never be
856
+ // answered, and reusing it left the node unapprovable for good. Ask again:
857
+ // the expired task is cancelled and the same request planned afresh.
858
+ // A challenge an approver answered in time is not asked again, however
859
+ // long the answer took to act on.
860
+ const decided = task.status === "awaiting_approval" && this.tasks.approvalDecision(task) !== undefined;
861
+ if (task.status === "awaiting_approval" &&
862
+ task.approval !== undefined &&
863
+ Date.parse(task.approval.expiresAt) <= Date.now() &&
864
+ !decided) {
865
+ this.tasks.cancel(task.id);
866
+ task = this.tasks.plan(request, {
867
+ idempotencyKey: taskIdempotencyKey(identity, request),
868
+ attempt: (task.attempt ?? 1) + 1,
869
+ stored: true,
870
+ });
871
+ }
639
872
  if (task.status === "awaiting_approval") {
640
873
  const approval = approvals.find((item) => item.approvalId === task.approval?.approvalId);
641
- if (approval === undefined) {
874
+ if (approval === undefined && !decided) {
642
875
  return {
643
876
  state: {
644
877
  status: "awaiting_approval",
@@ -699,6 +932,16 @@ export class WorkflowController {
699
932
  const taskIds = [...state.taskIds];
700
933
  const tasks = [];
701
934
  let iterations = state.iterations ?? 0;
935
+ // Resumed after recovery between iterations: the last one finished but its
936
+ // `until` was never checked, and running another would be one too many.
937
+ const finished = taskIds[iterations * node.body.length - 1];
938
+ if (iterations > 0 &&
939
+ taskIds.length === iterations * node.body.length &&
940
+ finished !== undefined &&
941
+ node.until !== undefined &&
942
+ (await this.tasks.verifyPersisted(finished, [node.until])).verified) {
943
+ return { state: { status: "verified_complete", taskIds, iterations }, tasks };
944
+ }
702
945
  while (iterations < node.maxIterations) {
703
946
  const offset = iterations * node.body.length;
704
947
  for (const [index, request] of node.body.entries()) {
@@ -892,6 +1135,25 @@ function expectedTaskKeys(workflowId, node, definition) {
892
1135
  return [];
893
1136
  }
894
1137
  }
1138
+ /**
1139
+ * Whether a recovered node that runs steps in sequence ran all of them, given
1140
+ * its expected tasks in `expectedTaskKeys` order (missing ones undefined).
1141
+ */
1142
+ function sequenceFinished(node, found) {
1143
+ if (node.type === "condition") {
1144
+ // Only one branch ever runs; finished means the branch that ran is whole.
1145
+ const whenTrue = found.slice(0, node.whenTrue.length);
1146
+ const whenFalse = found.slice(node.whenTrue.length);
1147
+ const whole = (steps) => steps.length > 0 && steps.every(Boolean);
1148
+ return whole(whenTrue) || whole(whenFalse);
1149
+ }
1150
+ if (node.type === "bounded_loop") {
1151
+ // Recovery cannot evaluate `until`, so short of the last iteration it is
1152
+ // left for the loop itself to decide.
1153
+ return found.every(Boolean);
1154
+ }
1155
+ return true;
1156
+ }
895
1157
  function preferredTask(tasks, idempotencyKey) {
896
1158
  return tasks
897
1159
  .filter((task) => task.idempotencyKey === idempotencyKey)
@@ -905,6 +1167,23 @@ function recoveredNodeState(node, tasks, previous) {
905
1167
  return previous;
906
1168
  const taskIds = tasks.map((task) => task.id);
907
1169
  const withStatus = (status) => tasks.find((task) => task.status === status);
1170
+ // Ranked as aggregateNodeState ranks a live node, failed first. A parallel
1171
+ // node with one failed branch and one awaiting approval was failed live and
1172
+ // came back from recovery awaiting approval, reopening the waiting branch on
1173
+ // a run whose rollback had begun.
1174
+ const failed = tasks.find((task) => [
1175
+ "failed",
1176
+ "partial",
1177
+ "budget_exhausted",
1178
+ "policy_blocked",
1179
+ ].includes(task.status));
1180
+ if (failed !== undefined) {
1181
+ return {
1182
+ status: "failed",
1183
+ taskIds,
1184
+ ...(failed.error === undefined ? {} : { error: failed.error }),
1185
+ };
1186
+ }
908
1187
  const recovery = withStatus("recovery_required");
909
1188
  if (recovery !== undefined) {
910
1189
  return {
@@ -923,19 +1202,6 @@ function recoveredNodeState(node, tasks, previous) {
923
1202
  : { approval: awaiting.approval }),
924
1203
  };
925
1204
  }
926
- const failed = tasks.find((task) => [
927
- "failed",
928
- "partial",
929
- "budget_exhausted",
930
- "policy_blocked",
931
- ].includes(task.status));
932
- if (failed !== undefined) {
933
- return {
934
- status: "failed",
935
- taskIds,
936
- ...(failed.error === undefined ? {} : { error: failed.error }),
937
- };
938
- }
939
1205
  if (tasks.some((task) => task.status === "planned")) {
940
1206
  return {
941
1207
  status: "pending",