@melra/runtime-core 0.3.0-alpha.11 → 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.
Files changed (45) hide show
  1. package/README.md +3 -2
  2. package/dist/task-controller.d.ts +81 -3
  3. package/dist/task-controller.d.ts.map +1 -1
  4. package/dist/task-controller.js +305 -52
  5. package/dist/task-controller.js.map +1 -1
  6. package/dist/workflow-controller.d.ts +19 -1
  7. package/dist/workflow-controller.d.ts.map +1 -1
  8. package/dist/workflow-controller.js +369 -92
  9. package/dist/workflow-controller.js.map +1 -1
  10. package/dist/workflow-graph.d.ts.map +1 -1
  11. package/dist/workflow-graph.js +25 -1
  12. package/dist/workflow-graph.js.map +1 -1
  13. package/package.json +8 -8
  14. package/dist/circuit-breaker.test.d.ts +0 -2
  15. package/dist/circuit-breaker.test.d.ts.map +0 -1
  16. package/dist/circuit-breaker.test.js +0 -51
  17. package/dist/circuit-breaker.test.js.map +0 -1
  18. package/dist/payload-cipher.test.d.ts +0 -2
  19. package/dist/payload-cipher.test.d.ts.map +0 -1
  20. package/dist/payload-cipher.test.js +0 -33
  21. package/dist/payload-cipher.test.js.map +0 -1
  22. package/dist/retry-guard.test.d.ts +0 -2
  23. package/dist/retry-guard.test.d.ts.map +0 -1
  24. package/dist/retry-guard.test.js +0 -45
  25. package/dist/retry-guard.test.js.map +0 -1
  26. package/dist/task-controller.test.d.ts +0 -2
  27. package/dist/task-controller.test.d.ts.map +0 -1
  28. package/dist/task-controller.test.js +0 -667
  29. package/dist/task-controller.test.js.map +0 -1
  30. package/dist/verification-gate.test.d.ts +0 -2
  31. package/dist/verification-gate.test.d.ts.map +0 -1
  32. package/dist/verification-gate.test.js +0 -42
  33. package/dist/verification-gate.test.js.map +0 -1
  34. package/dist/workflow-controller.test.d.ts +0 -2
  35. package/dist/workflow-controller.test.d.ts.map +0 -1
  36. package/dist/workflow-controller.test.js +0 -911
  37. package/dist/workflow-controller.test.js.map +0 -1
  38. package/dist/workflow-events.test.d.ts +0 -2
  39. package/dist/workflow-events.test.d.ts.map +0 -1
  40. package/dist/workflow-events.test.js +0 -113
  41. package/dist/workflow-events.test.js.map +0 -1
  42. package/dist/workflow-graph.test.d.ts +0 -2
  43. package/dist/workflow-graph.test.d.ts.map +0 -1
  44. package/dist/workflow-graph.test.js +0 -170
  45. package/dist/workflow-graph.test.js.map +0 -1
@@ -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;
@@ -53,7 +70,18 @@ export class WorkflowController {
53
70
  });
54
71
  const planned = applyWorkflowEvent(draft, plannedEvent);
55
72
  const redacted = WorkflowDefinitionSchema.parse(redactStructuredValue(parsed).value);
56
- this.store.createWorkflow(redacted, this.payloadCipher.seal(parsed, `workflow:${parsed.id}:${parsed.version}:definition`), planned, [created, plannedEvent]);
73
+ // A definition is stored once per id and version, and any number of runs
74
+ // may use it: planning the same file twice is a second run, not an error.
75
+ // The same id and version with different content is refused, because
76
+ // runs already planned from the stored one must keep meaning what they did.
77
+ const purpose = `workflow:${parsed.id}:${parsed.version}:definition`;
78
+ const stored = this.store.getWorkflowPayload(parsed.id, parsed.version);
79
+ if (stored !== undefined &&
80
+ canonicalJson(WorkflowDefinitionSchema.parse(this.payloadCipher.open(stored, purpose))) !==
81
+ canonicalJson(parsed)) {
82
+ throw new Error(`workflow_definition_conflict:${parsed.id}@${parsed.version}: a different definition is stored under this id and version; bump "version"`);
83
+ }
84
+ this.store.createWorkflow(redacted, stored === undefined ? this.payloadCipher.seal(parsed, purpose) : undefined, planned, [created, plannedEvent]);
57
85
  return planned;
58
86
  }
59
87
  status(workflowId) {
@@ -86,7 +114,40 @@ export class WorkflowController {
86
114
  // start duplicate side effects while this one is mid-flight. `transitionWorkflow`
87
115
  // still guards the commit; this guards the work leading up to it.
88
116
  async leasedAdvance(workflowId, approvals, inputs) {
89
- 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
+ }
90
151
  const now = Date.now();
91
152
  if (!this.store.acquireWorkflowLease(workflowId, this.owner, new Date(now).toISOString(), new Date(now + this.leaseDurationMs).toISOString())) {
92
153
  throw new Error("workflow_lease_held");
@@ -97,10 +158,12 @@ export class WorkflowController {
97
158
  this.store.renewWorkflowLease(workflowId, this.owner, new Date(Date.now() + this.leaseDurationMs).toISOString());
98
159
  }, Math.max(1_000, Math.floor(this.leaseDurationMs / 2)));
99
160
  renewal.unref();
161
+ this.leased.add(workflowId);
100
162
  try {
101
163
  return await this.advanceOnce(workflowId, approvals, inputs);
102
164
  }
103
165
  finally {
166
+ this.leased.delete(workflowId);
104
167
  clearInterval(renewal);
105
168
  this.store.releaseWorkflowLease(workflowId, this.owner);
106
169
  }
@@ -137,7 +200,7 @@ export class WorkflowController {
137
200
  ["failed", "recovery_required"].includes(current.nodes[node.id]?.status ?? ""))) {
138
201
  return { run: current, tasks: [], events: [] };
139
202
  }
140
- // 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
141
204
  // longer `pending`, so `readyNodeIds` will not return it. Re-offer it here
142
205
  // or the workflow would stall forever with the answer in hand.
143
206
  const awaiting = [...graph.nodes.values()]
@@ -153,6 +216,11 @@ export class WorkflowController {
153
216
  if (nodeIds.length === 0) {
154
217
  return { run: current, tasks: [], events: [] };
155
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;
156
224
  const results = await (rollback.length >
157
225
  0
158
226
  ? // Compensations undo each other's preconditions, so the order they run
@@ -163,13 +231,19 @@ export class WorkflowController {
163
231
  // continuing past a failed unwind would strand the saga in a state no
164
232
  // node describes.
165
233
  this.unwind(rollback, graph, definition, current, approvals, inputs)
166
- : Promise.all(nodeIds.map(async (nodeId) => {
234
+ : Promise.allSettled(nodeIds.map(async (nodeId) => {
167
235
  const node = graph.nodes.get(nodeId);
168
236
  return {
169
237
  nodeId,
170
238
  result: await this.advanceNode(node, definition, current.nodes[nodeId], current, approvals, inputs),
171
239
  };
172
- })));
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
+ }));
173
247
  // A *compensation* that failed must not open a second unwind. The saga is
174
248
  // already stranded, and undoing the next step while this inverse is still
175
249
  // outstanding is precisely what the halt exists to prevent — so only a
@@ -276,78 +350,223 @@ export class WorkflowController {
276
350
  createdAt: run.updatedAt,
277
351
  }));
278
352
  }
353
+ if (refused !== undefined)
354
+ throw refused.reason;
279
355
  return {
280
356
  run,
281
357
  tasks: results.flatMap(({ result }) => result.tasks),
282
358
  events: this.events(workflowId, current.stateVersion),
283
359
  };
284
360
  }
285
- async recoverInterrupted() {
286
- 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 [];
287
403
  const recovered = [];
288
404
  const allTasks = this.store.listTasks(100_000);
289
- for (const stored of this.store.listWorkflowRuns([
290
- "planned",
291
- "running",
292
- "awaiting_approval",
293
- "recovery_required",
294
- ])) {
295
- const replayed = this.replay(stored);
296
- if (replayed.stateVersion !== stored.stateVersion) {
297
- 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
+ }
458
+ }
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
+ });
298
530
  }
299
- const definition = this.loadDefinition(stored);
300
- let run = this.transition(stored, (draft, emit) => {
531
+ let status = this.deriveStatus(draft, definition);
532
+ if (status === "verified_complete") {
301
533
  for (const node of definition.nodes) {
302
- const state = recoveredNodeState(node, expectedTaskKeys(stored.id, node, definition)
303
- .map((key) => preferredTask(allTasks, key))
304
- .filter((task) => task !== undefined), replayed.nodes[node.id]);
305
- if (canonicalJson(draft.nodes[node.id]) === canonicalJson(state)) {
534
+ const state = draft.nodes[node.id];
535
+ if (node.type !== "compensation" || state.status !== "pending") {
306
536
  continue;
307
537
  }
308
- const from = draft.nodes[node.id].status;
309
- draft.nodes[node.id] = state;
538
+ state.status = "skipped";
310
539
  emit("workflow.node_changed", {
311
540
  nodeId: node.id,
312
- from,
541
+ from: "pending",
313
542
  state,
314
543
  });
315
544
  }
316
- let status = this.deriveStatus(draft, definition);
317
- if (status === "verified_complete") {
318
- for (const node of definition.nodes) {
319
- const state = draft.nodes[node.id];
320
- if (node.type !== "compensation" || state.status !== "pending") {
321
- continue;
322
- }
323
- state.status = "skipped";
324
- emit("workflow.node_changed", {
325
- nodeId: node.id,
326
- from: "pending",
327
- state,
328
- });
329
- }
330
- status = this.deriveStatus(draft, definition);
331
- }
332
- const from = draft.status;
333
- draft.status = status;
334
- if (status === "recovery_required") {
335
- draft.error = "workflow_contains_unreconciled_mutation";
336
- }
337
- else {
338
- delete draft.error;
339
- }
340
- emit(status === "recovery_required"
341
- ? "workflow.recovery_required"
342
- : "workflow.recovered", {
343
- from,
344
- to: status,
345
- ...(draft.error === undefined ? {} : { error: draft.error }),
346
- });
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 }),
347
568
  });
348
- recovered.push(run);
349
- }
350
- return recovered;
569
+ });
351
570
  }
352
571
  // Operator controls. `pause` is a soft stop an operator lifts with `resume`;
353
572
  // `suspend` is the same stop for an operator who wants the run parked
@@ -365,18 +584,22 @@ export class WorkflowController {
365
584
  if (!["paused", "suspended"].includes(current.status)) {
366
585
  throw new Error("workflow_not_paused");
367
586
  }
587
+ const definition = this.loadDefinition(current);
368
588
  return this.transition(current, (draft, emit) => {
369
589
  const from = draft.status;
370
590
  // Restore whatever the graph was actually blocked on before the halt,
371
- // so resuming a run parked on a human does not claim it is running.
372
- const nodes = Object.values(draft.nodes);
373
- draft.status = nodes.some((node) => node.status === "awaiting_approval")
374
- ? "awaiting_approval"
375
- : nodes.some((node) => node.status === "awaiting_input")
376
- ? "awaiting_input"
377
- : "running";
378
- delete draft.error;
379
- 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
+ });
380
603
  });
381
604
  }
382
605
  halt(workflowId, to, eventType) {
@@ -389,7 +612,9 @@ export class WorkflowController {
389
612
  return this.transition(current, (draft, emit) => {
390
613
  const from = draft.status;
391
614
  draft.status = to;
392
- 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 }) });
393
618
  });
394
619
  }
395
620
  cancel(workflowId) {
@@ -513,15 +738,16 @@ export class WorkflowController {
513
738
  if (sourceTaskId === undefined) {
514
739
  throw new Error(`workflow_condition_source_unavailable:${node.id}`);
515
740
  }
516
- const condition = await this.tasks.verifyPersisted(sourceTaskId, [
517
- node.predicate,
518
- ]);
519
- return await this.runSequential(condition.verified ? node.whenTrue : node.whenFalse, state, approvals, {
520
- workflowId: run.id,
521
- nodeId: node.id,
522
- iteration: 0,
523
- branch: condition.verified ? "true" : "false",
524
- });
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" });
525
751
  }
526
752
  case "parallel":
527
753
  return await this.runParallel(node.branches, state, approvals, run.id, node.id);
@@ -623,11 +849,29 @@ export class WorkflowController {
623
849
  ? this.tasks.plan(request, {
624
850
  idempotencyKey: taskIdempotencyKey(identity, request),
625
851
  attempt: 1,
852
+ stored: true,
626
853
  })
627
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
+ }
628
872
  if (task.status === "awaiting_approval") {
629
873
  const approval = approvals.find((item) => item.approvalId === task.approval?.approvalId);
630
- if (approval === undefined) {
874
+ if (approval === undefined && !decided) {
631
875
  return {
632
876
  state: {
633
877
  status: "awaiting_approval",
@@ -688,6 +932,16 @@ export class WorkflowController {
688
932
  const taskIds = [...state.taskIds];
689
933
  const tasks = [];
690
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
+ }
691
945
  while (iterations < node.maxIterations) {
692
946
  const offset = iterations * node.body.length;
693
947
  for (const [index, request] of node.body.entries()) {
@@ -881,6 +1135,25 @@ function expectedTaskKeys(workflowId, node, definition) {
881
1135
  return [];
882
1136
  }
883
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
+ }
884
1157
  function preferredTask(tasks, idempotencyKey) {
885
1158
  return tasks
886
1159
  .filter((task) => task.idempotencyKey === idempotencyKey)
@@ -894,6 +1167,23 @@ function recoveredNodeState(node, tasks, previous) {
894
1167
  return previous;
895
1168
  const taskIds = tasks.map((task) => task.id);
896
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
+ }
897
1187
  const recovery = withStatus("recovery_required");
898
1188
  if (recovery !== undefined) {
899
1189
  return {
@@ -912,19 +1202,6 @@ function recoveredNodeState(node, tasks, previous) {
912
1202
  : { approval: awaiting.approval }),
913
1203
  };
914
1204
  }
915
- const failed = tasks.find((task) => [
916
- "failed",
917
- "partial",
918
- "budget_exhausted",
919
- "policy_blocked",
920
- ].includes(task.status));
921
- if (failed !== undefined) {
922
- return {
923
- status: "failed",
924
- taskIds,
925
- ...(failed.error === undefined ? {} : { error: failed.error }),
926
- };
927
- }
928
1205
  if (tasks.some((task) => task.status === "planned")) {
929
1206
  return {
930
1207
  status: "pending",