@melra/runtime-core 0.3.0-alpha.14 → 0.3.0-alpha.15

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,12 +5,29 @@ 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
+ /**
9
+ * A node one of whose tasks refused (a wrong approval phrase, say) after the
10
+ * others had run, or whose check could not be asked after its steps had: what
11
+ * they did is recorded with the node before the refusal is reported, as a
12
+ * sibling node's would be.
13
+ */
14
+ class NodeRefused extends Error {
15
+ reason;
16
+ advance;
17
+ constructor(reason, advance) {
18
+ super(reason instanceof Error ? reason.message : String(reason));
19
+ this.reason = reason;
20
+ this.advance = advance;
21
+ }
22
+ }
8
23
  const RECOVERABLE = [
9
24
  "planned",
10
25
  "running",
11
26
  "awaiting_approval",
12
27
  "recovery_required",
13
28
  ];
29
+ // A condition whose other branch holds a task that may have had an effect.
30
+ const BRANCH_CONFLICT = "workflow_condition_branch_conflict";
14
31
  // A failed run's advance runs its compensations, so an advance that died on
15
32
  // one leaves the same hazard as any other: a compensation it started, planned
16
33
  // and run again. Repaired too, but only once an expired lease says an advance
@@ -26,9 +43,13 @@ export class WorkflowController {
26
43
  // process from racing itself without touching the disk on every call.
27
44
  advances = new Map();
28
45
  owner = `${process.pid}:${randomUUID()}`;
46
+ /** Whether a task's effect happened and verified, for `targetDone`. */
47
+ ran = (taskId) => this.tasks.status(taskId).status === "verified_success";
29
48
  // Distinct from `owner`, so this process's own advance in flight also keeps
30
49
  // its recovery away from the run.
31
50
  recoveryOwner = `${this.owner}:recovery`;
51
+ // An operator's halt or cancel, likewise refused by this process's own advance.
52
+ controlOwner = `${this.owner}:control`;
32
53
  // Runs an advance in this process holds the lease for. Recovery leaves them
33
54
  // alone even once the lease has lapsed, say across a laptop's sleep.
34
55
  leased = new Set();
@@ -42,11 +63,26 @@ export class WorkflowController {
42
63
  const parsed = WorkflowDefinitionSchema.parse(definition);
43
64
  const graph = validateWorkflow(parsed);
44
65
  for (const node of graph.nodes.values()) {
66
+ // No node state exists for a task waiting on a verdict: a waiting task
67
+ // would read as failed and have an effect that held compensated, and a
68
+ // condition, loop or delegation would read an unanswered question as
69
+ // false. Refused until workflows can wait on one (#54).
70
+ if (declaresSemantic(node))
71
+ throw new Error(`workflow_semantic_evidence_unsupported:${node.id}`);
45
72
  for (const request of requestsFor(node)) {
46
73
  if (this.tasks.preflight(request).outcome === "deny") {
47
74
  throw new Error(`workflow_policy_blocked:${node.id}`);
48
75
  }
49
76
  }
77
+ // A check no registered verifier can ask is never answered. Refused
78
+ // here, as a step's evidence is, not found out mid-run with effects done.
79
+ this.tasks.assertVerifiable(node.type === "condition"
80
+ ? [node.predicate]
81
+ : node.type === "bounded_loop"
82
+ ? (node.until === undefined ? [] : [node.until])
83
+ : node.type === "delegation"
84
+ ? node.requiredEvidence
85
+ : []);
50
86
  }
51
87
  const createdAt = new Date().toISOString();
52
88
  const id = randomUUID();
@@ -69,7 +105,9 @@ export class WorkflowController {
69
105
  to: "planned",
70
106
  });
71
107
  const planned = applyWorkflowEvent(draft, plannedEvent);
72
- const redacted = WorkflowDefinitionSchema.parse(redactStructuredValue(parsed).value);
108
+ // Not parsed again: redaction may rewrite a node id or URL shaped like a
109
+ // key, and the stored copy is only for reading.
110
+ const redacted = redactStructuredValue(parsed).value;
73
111
  // A definition is stored once per id and version, and any number of runs
74
112
  // may use it: planning the same file twice is a second run, not an error.
75
113
  // The same id and version with different content is refused, because
@@ -155,7 +193,21 @@ export class WorkflowController {
155
193
  // A long advance must not let its own lease lapse and invite a second
156
194
  // process in, so renew at half the duration while the adapters run.
157
195
  const renewal = setInterval(() => {
158
- this.store.renewWorkflowLease(workflowId, this.owner, new Date(Date.now() + this.leaseDurationMs).toISOString());
196
+ // Thrown from a timer, nothing catches it and the process dies: a store
197
+ // closed under a long advance (a shutdown) stops the renewing instead,
198
+ // and the advance fails on its own next write. Any other failure (a busy
199
+ // database) is tried again next tick: stopping would let the lease lapse
200
+ // mid-advance and a second process run the same step.
201
+ try {
202
+ const renewed = this.store.renewWorkflowLease(workflowId, this.owner, new Date(Date.now() + this.leaseDurationMs).toISOString());
203
+ // Taken over: the next step's own renewal finds that and stops.
204
+ if (!renewed)
205
+ clearInterval(renewal);
206
+ }
207
+ catch (error) {
208
+ if (error.code === "ERR_INVALID_STATE")
209
+ clearInterval(renewal);
210
+ }
159
211
  }, Math.max(1_000, Math.floor(this.leaseDurationMs / 2)));
160
212
  renewal.unref();
161
213
  this.leased.add(workflowId);
@@ -169,52 +221,91 @@ export class WorkflowController {
169
221
  }
170
222
  }
171
223
  async advanceOnce(workflowId, approvals, inputs) {
172
- const current = this.status(workflowId);
173
- if (["verified_complete", "cancelled"].includes(current.status)) {
174
- return { run: current, tasks: [], events: [] };
224
+ const stored = this.status(workflowId);
225
+ if (["verified_complete", "cancelled"].includes(stored.status)) {
226
+ return { run: stored, tasks: [], events: [] };
175
227
  }
176
228
  // A halted workflow is a deliberate operator decision. Failing loudly beats
177
229
  // returning an unchanged run, which reads to a caller like "nothing to do".
178
- if (["paused", "suspended"].includes(current.status)) {
179
- throw new Error(`workflow_halted:${current.status}`);
230
+ if (["paused", "suspended"].includes(stored.status)) {
231
+ throw new Error(`workflow_halted:${stored.status}`);
180
232
  }
181
- const definition = this.loadDefinition(current);
233
+ const definition = this.loadDefinition(stored);
182
234
  const graph = validateWorkflow(definition);
235
+ const since = stored.stateVersion;
236
+ // An earlier version failed a run beside a step whose outcome is unknown
237
+ // and would now undo what that step may depend on. Halt it as a run that
238
+ // gets there now halts, so recovery settles the step first. Repaired
239
+ // before any parked step is settled: settled first, the step no longer
240
+ // reads unknown, and its skipped compensation is never offered again.
241
+ const repaired = this.repairLegacy(definition, stored);
242
+ if (repaired !== undefined) {
243
+ return { run: repaired, tasks: [], events: this.events(workflowId, since) };
244
+ }
245
+ // A step parked as recovery_required is settled outside the run, by
246
+ // melra_execute reconciling its task against the provider. Its tasks are
247
+ // read again here, as recovery reads them, so the run moves on in this
248
+ // process instead of at the next restart.
249
+ const current = this.resettleParked(stored, definition);
183
250
  const rollback = current.status === "failed"
184
251
  ? definition.nodes
185
252
  .filter((node) => node.type === "compensation" &&
186
253
  ["pending", "awaiting_approval"].includes(current.nodes[node.id]?.status ?? "") &&
187
- current.nodes[node.forNodeId]?.status ===
188
- "verified_complete")
254
+ targetDone(definition, (id) => current.nodes[id], node.forNodeId, this.ran))
189
255
  .map((node) => node.id)
190
256
  .reverse()
191
257
  : [];
192
- if (current.status === "failed" && rollback.length === 0) {
193
- return { run: current, tasks: [], events: [] };
194
- }
195
258
  // An unwind that stopped half-done is not a base to build more effects on:
196
259
  // a forward effect happened, its declared inverse did not, and no node
197
260
  // describes that. Refused here rather than left to `readyNodeIds`, which
198
261
  // would happily start an independent branch on top of it.
199
- if (definition.nodes.some((node) => node.type === "compensation" &&
200
- ["failed", "recovery_required"].includes(current.nodes[node.id]?.status ?? ""))) {
201
- return { run: current, tasks: [], events: [] };
262
+ const stalled = stalledCompensation(definition, current.nodes);
263
+ if (stalled !== undefined) {
264
+ if (current.status === "recovery_required") {
265
+ return { run: current, tasks: [], events: this.events(workflowId, since) };
266
+ }
267
+ // An earlier version recorded a denied compensation and left the run
268
+ // `failed`; say so, as a run that stalls now does, rather than return it
269
+ // unchanged as if nothing were outstanding.
270
+ const run = this.transition(current, (draft, emit) => {
271
+ const from = draft.status;
272
+ draft.status = "recovery_required";
273
+ draft.error = `workflow_compensation_incomplete:${stalled.id}`;
274
+ emit("workflow.status_changed", { from, to: draft.status, error: draft.error });
275
+ });
276
+ return { run, tasks: [], events: this.events(workflowId, since) };
277
+ }
278
+ // A run with a failed or denied step unwinds once nothing is unknown. Until
279
+ // then it is halted, and offers no forward step: not one newly ready, nor
280
+ // one parked for approval, whose effect would only be undone.
281
+ if (this.hasFailure(definition, current.nodes) && rollback.length === 0) {
282
+ return { run: current, tasks: [], events: this.events(workflowId, since) };
202
283
  }
203
284
  // A node parked for a human — approval or supplied input — is no
204
285
  // longer `pending`, so `readyNodeIds` will not return it. Re-offer it here
205
- // or the workflow would stall forever with the answer in hand.
286
+ // or the workflow would stall forever with the answer in hand. Beside the
287
+ // ready nodes, not instead of them: a branch that does not wait on it
288
+ // carries on (#247).
206
289
  const awaiting = [...graph.nodes.values()]
207
290
  .filter((node) => node.type !== "compensation" &&
208
291
  ["awaiting_approval", "awaiting_input"].includes(current.nodes[node.id]?.status ?? ""))
209
292
  .map((node) => node.id)
210
293
  .sort();
294
+ // Unless a parked task has settled since (an operator's Deny, a peer's
295
+ // execute): the run still reads it as waiting, so hasFailure cannot see a
296
+ // denial yet. Those nodes alone, then, so this wave records it before any
297
+ // newly ready step is challenged or run on a run it fails.
298
+ const parkedSettled = awaiting.some((nodeId) => current.nodes[nodeId].taskIds.some((taskId) => !["planned", "awaiting_approval", "verified_success"].includes(this.store.getTask(taskId)?.status ?? "")));
211
299
  const nodeIds = rollback.length > 0
212
300
  ? rollback
213
- : awaiting.length > 0
301
+ : parkedSettled
214
302
  ? awaiting
215
- : readyNodeIds(graph, current.nodes);
303
+ : [...awaiting, ...readyNodeIds(graph, current.nodes)];
216
304
  if (nodeIds.length === 0) {
217
- return { run: current, tasks: [], events: [] };
305
+ // Nothing to run, but a step's certificate the last advance could not
306
+ // write may be written now, and a run with nothing left is complete.
307
+ const run = this.transition(current, (draft, emit) => this.settleStatus(draft, definition, emit));
308
+ return { run, tasks: [], events: this.events(workflowId, since) };
218
309
  }
219
310
  // A sibling's refusal is reported only after the others' changes are
220
311
  // committed: a node another sibling cancelled and planned again in this
@@ -241,23 +332,44 @@ export class WorkflowController {
241
332
  // Every sibling finishes before one's error is reported. Rejecting
242
333
  // at the first throw released the lease while other adapters still
243
334
  // 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] : []));
335
+ const rejected = settled.find((outcome) => outcome.status === "rejected");
336
+ if (rejected !== undefined) {
337
+ refused = {
338
+ reason: rejected.reason instanceof NodeRefused ? rejected.reason.reason : rejected.reason,
339
+ };
340
+ }
341
+ return settled.flatMap((outcome, index) => outcome.status === "fulfilled"
342
+ ? [outcome.value]
343
+ : outcome.reason instanceof NodeRefused
344
+ ? [{ nodeId: nodeIds[index], result: outcome.reason.advance }]
345
+ : []);
246
346
  }));
247
347
  // A *compensation* that failed must not open a second unwind. The saga is
248
348
  // already stranded, and undoing the next step while this inverse is still
249
349
  // outstanding is precisely what the halt exists to prevent — so only a
250
- // forward node failing starts compensating.
251
- if (results.some(({ nodeId, result }) => result.state.status === "failed" &&
252
- graph.nodes.get(nodeId)?.type !== "compensation")) {
350
+ // forward node failing, denied or cancelled starts compensating. A denied
351
+ // step is never offered again, so the run can only end by unwinding.
352
+ const statusAfter = (nodeId) => results.find((entry) => entry.nodeId === nodeId)?.result.state.status ??
353
+ current.nodes[nodeId]?.status;
354
+ // Not past a step whose outcome is unknown or still unsettled: the run
355
+ // halts for an operator, as deriveStatus says, rather than undo what that
356
+ // effect may depend on.
357
+ if (!hasUnknownOutcome(definition, statusAfter) &&
358
+ results.some(({ nodeId, result }) => ["failed", "cancelled"].includes(result.state.status) &&
359
+ graph.nodes.get(nodeId)?.type !== "compensation")) {
253
360
  for (const node of [...definition.nodes].reverse()) {
254
361
  if (node.type !== "compensation" ||
255
362
  current.nodes[node.id]?.status !== "pending") {
256
363
  continue;
257
364
  }
258
- const targetState = results.find(({ nodeId }) => nodeId === node.forNodeId)?.result
259
- .state ?? current.nodes[node.forNodeId];
260
- if (targetState?.status !== "verified_complete") {
365
+ // Not once the run has moved on under this advance (cancelled,
366
+ // paused): its commit is going to fail, and a compensation planned now
367
+ // would be a live challenge for a run that no longer offers it.
368
+ // Checked before each one, since one that ran gives a cancel time.
369
+ if (this.status(workflowId).stateVersion !== current.stateVersion)
370
+ break;
371
+ const stateOf = (id) => results.find(({ nodeId }) => nodeId === id)?.result.state ?? current.nodes[id];
372
+ if (!targetDone(definition, stateOf, node.forNodeId, this.ran)) {
261
373
  results.push({
262
374
  nodeId: node.id,
263
375
  result: {
@@ -288,8 +400,14 @@ export class WorkflowController {
288
400
  break;
289
401
  }
290
402
  }
403
+ this.assertAdvanceLease(workflowId);
291
404
  let run = this.transition(current, (draft, emit) => {
292
405
  for (const { nodeId, result } of results) {
406
+ // A node offered again and still waiting (an approval nobody has
407
+ // answered) did not change. Recorded anyway, every poll bumped the
408
+ // run's version and added an event.
409
+ if (canonicalJson(result.state) === canonicalJson(draft.nodes[nodeId]))
410
+ continue;
293
411
  const from = draft.nodes[nodeId].status;
294
412
  draft.nodes[nodeId] = result.state;
295
413
  emit("workflow.node_changed", {
@@ -298,43 +416,7 @@ export class WorkflowController {
298
416
  state: result.state,
299
417
  });
300
418
  }
301
- let status = this.deriveStatus(draft, definition);
302
- if (status === "verified_complete") {
303
- for (const node of definition.nodes) {
304
- const state = draft.nodes[node.id];
305
- if (node.type !== "compensation" || state.status !== "pending") {
306
- continue;
307
- }
308
- state.status = "skipped";
309
- emit("workflow.node_changed", {
310
- nodeId: node.id,
311
- from: "pending",
312
- state,
313
- });
314
- }
315
- status = this.deriveStatus(draft, definition);
316
- }
317
- if (status !== draft.status) {
318
- const from = draft.status;
319
- draft.status = status;
320
- // Name the step, or "recovery_required" tells an operator that
321
- // something needs hands without saying which effect is half-undone.
322
- const stalled = definition.nodes.find((node) => node.type === "compensation" &&
323
- ["failed", "recovery_required"].includes(draft.nodes[node.id]?.status ?? ""));
324
- if (status === "recovery_required" && stalled !== undefined) {
325
- draft.error = `workflow_compensation_incomplete:${stalled.id}`;
326
- }
327
- else {
328
- // The reducer clears `error` on every status change, so the draft has
329
- // to as well or replay no longer reproduces the projection.
330
- delete draft.error;
331
- }
332
- emit("workflow.status_changed", {
333
- from,
334
- to: status,
335
- ...(draft.error === undefined ? {} : { error: draft.error }),
336
- });
337
- }
419
+ this.settleStatus(draft, definition, emit);
338
420
  });
339
421
  if (results.some(({ result }) => result.checkpoint === true)) {
340
422
  run = this.transition(run, (draft, emit) => {
@@ -355,7 +437,7 @@ export class WorkflowController {
355
437
  return {
356
438
  run,
357
439
  tasks: results.flatMap(({ result }) => result.tasks),
358
- events: this.events(workflowId, current.stateVersion),
440
+ events: this.events(workflowId, since),
359
441
  };
360
442
  }
361
443
  /**
@@ -401,7 +483,6 @@ export class WorkflowController {
401
483
  if (runs.length === 0)
402
484
  return [];
403
485
  const recovered = [];
404
- const allTasks = this.store.listTasks(100_000);
405
486
  for (const stored of runs) {
406
487
  if (this.leased.has(stored.id))
407
488
  continue;
@@ -416,9 +497,9 @@ export class WorkflowController {
416
497
  const keys = new Set(definition.nodes.flatMap((node) => expectedTaskKeys(stored.id, node, definition)));
417
498
  // Skipped runs keep any abandoned lease, which is how the sweep finds
418
499
  // 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"));
500
+ const unsettled = [...keys]
501
+ .flatMap((key) => this.store.listTasksByKey(key))
502
+ .filter((task) => task.status === "running" || task.status === "verifying");
422
503
  if (unsettled.length > 0) {
423
504
  const failed = unsettled.find((task) => taskFailures.has(task.id));
424
505
  if (failed !== undefined)
@@ -434,7 +515,7 @@ export class WorkflowController {
434
515
  // Changed since the listing, by an advance or by an operator's pause,
435
516
  // so the tasks read above may be stale.
436
517
  if (this.store.getWorkflowRun(stored.id)?.stateVersion === stored.stateVersion) {
437
- recovered.push(this.recoverRun(stored, definition, allTasks));
518
+ recovered.push(this.recoverRun(stored, definition));
438
519
  repaired = true;
439
520
  }
440
521
  }
@@ -459,75 +540,316 @@ export class WorkflowController {
459
540
  }
460
541
  return recovered;
461
542
  }
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");
543
+ /**
544
+ * A node's state read again from its tasks: what recovery does at startup,
545
+ * and what an advance does for a step parked as recovery_required.
546
+ */
547
+ recoveredNode(runId, node, definition, recorded) {
548
+ const keys = expectedTaskKeys(runId, node, definition);
549
+ const ignored = new Set();
550
+ if (node.type === "condition") {
551
+ // Only one branch ever runs (#268). The one with a committed step, as
552
+ // the live path decides; else the run's own record: a condition
553
+ // finished with no task took its empty branch, and its first task names
554
+ // its branch (not a conflict's, whose tasks are the other branch's).
555
+ const trueKeys = keys.slice(0, node.whenTrue.length);
556
+ const committed = (branch) => branch.some((key) => this.store.getIdempotencyCommit(key) !== undefined);
557
+ const first = recorded.error === BRANCH_CONFLICT || recorded.taskIds[0] === undefined
558
+ ? undefined
559
+ : this.store.getTask(recorded.taskIds[0])?.idempotencyKey;
560
+ const chosen = committed(trueKeys)
561
+ ? true
562
+ : committed(keys.slice(node.whenTrue.length))
563
+ ? false
564
+ : recorded.status === "verified_complete" && recorded.taskIds.length === 0
565
+ ? node.whenTrue.length === 0
566
+ : first === undefined || !keys.includes(first)
567
+ ? undefined
568
+ : trueKeys.includes(first);
569
+ const all = keys.map((key) => preferredTask(this.store.listTasksByKey(key), key));
570
+ if (chosen === undefined) {
571
+ // Nothing decided it. A conflict stands while a task in it may have
572
+ // had an effect.
573
+ const cleared = recorded.error === BRANCH_CONFLICT &&
574
+ recorded.taskIds.every((taskId) => {
575
+ const task = this.store.getTask(taskId);
576
+ return task !== undefined && hadNoEffect(task);
577
+ });
578
+ if (recorded.error === BRANCH_CONFLICT && !cleared)
579
+ return { state: recorded, found: all };
580
+ // A step a lost advance ran whose outcome is unknown, on either
581
+ // branch, is read as the node's, so it halts the run here rather than
582
+ // a failed sibling unwinding past it (#270). Anything else it left (a
583
+ // challenge, or a step that took effect but failed its evidence) waits
584
+ // for the next advance, which decides as it would with no restart.
585
+ const unknown = this.unsettledUnder(keys);
586
+ if (unknown.length > 0)
587
+ return { state: recoveredNodeState(node, unknown, recorded), found: all };
588
+ return { state: cleared ? { status: "pending", taskIds: [] } : recorded, found: all };
589
+ }
590
+ const halted = this.abandonBranch(node, keys, chosen);
591
+ if (halted !== undefined)
592
+ return { state: halted, found: all };
593
+ for (const key of chosen ? keys.slice(node.whenTrue.length) : trueKeys)
594
+ ignored.add(key);
466
595
  }
467
- return this.transition(stored, (draft, emit) => {
596
+ const found = keys.map((key) => ignored.has(key) ? undefined : preferredTask(this.store.listTasksByKey(key), key));
597
+ let state = recoveredNodeState(node, found.filter((task) => task !== undefined), recorded);
598
+ // An operation behind an approval shares its task with the approval
599
+ // node. Waiting for the approval is that node's state; mirrored onto
600
+ // the operation, both were re-offered, the approval ran the effect,
601
+ // and the operation read the running task as failed.
602
+ if (node.type === "operation" &&
603
+ state.status === "awaiting_approval" &&
604
+ definition.nodes.some((other) => other.type === "approval" && other.forNodeId === node.id)) {
605
+ state = recorded;
606
+ }
607
+ // A sequence whose finished steps all verified is not finished if
608
+ // steps remain: a crash between a loop's iterations, or between a
609
+ // branch's steps, recovered as complete with the rest never run.
610
+ // Pending, so the next advance carries on from the tasks it has.
611
+ // Unless the projection recorded it finished in a way only the live
612
+ // path finishes one short: a loop stopped by `until` at a whole
613
+ // iteration, or a condition whose chosen branch is empty. Reopened,
614
+ // `until` or the condition would be asked again of a world that has
615
+ // moved on. Anything else recorded finished but short was written by
616
+ // a recovery that never downgraded (alpha.12), and is downgraded now.
617
+ const finishedShort = recorded.status === "verified_complete" &&
618
+ canonicalJson(recorded.taskIds) === canonicalJson(state.taskIds) &&
619
+ ((node.type === "bounded_loop" &&
620
+ node.until !== undefined &&
621
+ state.taskIds.length > 0 &&
622
+ state.taskIds.length % node.body.length === 0) ||
623
+ (node.type === "condition" && state.taskIds.length === 0));
624
+ if (state.status === "verified_complete" && !sequenceFinished(node, found) && !finishedShort) {
625
+ state = {
626
+ status: "pending",
627
+ taskIds: state.taskIds,
628
+ // Whole iterations only: the loop resumes at the one in progress.
629
+ ...(node.type === "bounded_loop"
630
+ ? { iterations: Math.floor(state.taskIds.length / node.body.length) }
631
+ : {}),
632
+ };
633
+ }
634
+ // A loop that stopped on a step keeps the iteration it stopped in, as
635
+ // runLoop records it. Without it the next advance starts again at
636
+ // iteration 0 and asks `until` of a task long finished.
637
+ if (node.type === "bounded_loop" && state.iterations === undefined && state.taskIds.length > 0) {
638
+ state = { ...state, iterations: Math.floor((state.taskIds.length - 1) / node.body.length) };
639
+ }
640
+ return { state, found };
641
+ }
642
+ /** Tasks under these keys whose outcome is unknown or still unsettled. */
643
+ unsettledUnder(keys) {
644
+ return keys
645
+ .map((key) => preferredTask(this.store.listTasksByKey(key), key))
646
+ .filter((task) => task !== undefined && ["recovery_required", "running", "verifying"].includes(task.status));
647
+ }
648
+ /**
649
+ * The branch a condition did not take. An advance that lost its commit (a
650
+ * pause landed) can have left tasks there. A challenge is cancelled, and
651
+ * the cancel read back, so nobody approves it later. A task that may have
652
+ * had an effect, or one started before its cancel landed, halts the node:
653
+ * both branches would stand. Reconciled as never applied, it no longer
654
+ * counts, and the node moves on.
655
+ */
656
+ abandonBranch(node, keys, chosen) {
657
+ const other = chosen ? keys.slice(node.whenTrue.length) : keys.slice(0, node.whenTrue.length);
658
+ const conflicting = new Set();
659
+ for (const key of other) {
660
+ const commit = this.store.getIdempotencyCommit(key);
661
+ if (commit !== undefined)
662
+ conflicting.add(commit.taskId);
663
+ }
664
+ for (const task of this.cancelChallenges(other)) {
665
+ if (!hadNoEffect(task))
666
+ conflicting.add(task.id);
667
+ }
668
+ return conflicting.size === 0
669
+ ? undefined
670
+ : // Sorted, so reading it again in another order records nothing new.
671
+ { status: "recovery_required", taskIds: [...conflicting].sort(), error: BRANCH_CONFLICT };
672
+ }
673
+ /**
674
+ * Every task under these keys, read again, with each challenge among them
675
+ * cancelled and the cancel read back, so nobody approves it later.
676
+ */
677
+ cancelChallenges(keys) {
678
+ // Each key once: an approval node's is its operation's.
679
+ return [...new Set(keys)]
680
+ .flatMap((key) => this.store.listTasksByKey(key))
681
+ .map((listed) => {
682
+ // Read again: the listing can predate an approver starting it.
683
+ const task = this.tasks.status(listed.id);
684
+ if (task.status !== "planned" && task.status !== "awaiting_approval")
685
+ return task;
686
+ this.tasks.cancel(task.id);
687
+ return this.tasks.status(task.id);
688
+ });
689
+ }
690
+ /**
691
+ * settleInFlight once a cancel's stopped tasks have ended. Another holder
692
+ * of the run's lease is brief on a cancelled run (an advance returning
693
+ * early, a cancel's sweep, a halt refused), or lapses and is taken over, so
694
+ * it is tried again for about as long as a lease lasts. Any other failure
695
+ * (the store closed) leaves the steps in flight, as the cancel recorded
696
+ * them; cancelling again settles them (#325).
697
+ */
698
+ settleStopped(workflowId, definition, waited = 0) {
699
+ try {
700
+ this.underLease(workflowId, (run) => this.settleInFlight(run, definition));
701
+ }
702
+ catch (error) {
703
+ if (error.message !== "workflow_lease_held" || waited > this.leaseDurationMs)
704
+ return;
705
+ const delay = Math.min(1_000, Math.max(10, waited));
706
+ setTimeout(() => this.settleStopped(workflowId, definition, waited + delay), delay).unref();
707
+ }
708
+ }
709
+ /**
710
+ * A cancelled run's steps recorded still in flight, read again from their
711
+ * tasks once these have ended, as its cancel reads a step. One whose task
712
+ * runs on is left as it is.
713
+ */
714
+ settleInFlight(run, definition) {
715
+ if (run.status !== "cancelled")
716
+ return run;
717
+ return this.transition(run, (draft, emit) => {
468
718
  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")
719
+ const recorded = draft.nodes[node.id];
720
+ if (recorded.error !== "task_still_in_flight")
474
721
  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)) {
722
+ const { state, found } = this.recoveredNode(run.id, node, definition, recorded);
723
+ if (found.some((task) => task !== undefined && ["running", "verifying"].includes(task.status)))
724
+ continue;
725
+ const settled = ["pending", "ready", "awaiting_approval"].includes(state.status)
726
+ ? { ...state, status: "cancelled" }
727
+ : state;
728
+ if (canonicalJson(settled) === canonicalJson(recorded))
729
+ continue;
730
+ draft.nodes[node.id] = settled;
731
+ emit("workflow.node_changed", { nodeId: node.id, from: recorded.status, state: settled });
732
+ }
733
+ });
734
+ }
735
+ /**
736
+ * Parked steps whose tasks have since settled, recorded as such: forward
737
+ * steps, and compensations an unwind stopped at. One still in flight is left
738
+ * for recovery, which waits for its owner as at startup.
739
+ */
740
+ resettleParked(run, definition) {
741
+ const parked = definition.nodes.filter((node) => {
742
+ const state = run.nodes[node.id];
743
+ if (state?.status !== "recovery_required")
744
+ return false;
745
+ // Read again only once its recorded tasks would move it: a harness polls
746
+ // a parked run, and reading every task each time is not free. A parallel
747
+ // branch that settled beside one still unknown moves nothing.
748
+ if (state.taskIds.length === 0)
749
+ return true;
750
+ const recorded = state.taskIds.map((taskId) => this.store.getTask(taskId));
751
+ if (recorded.some((task) => task === undefined))
752
+ return true;
753
+ if (recorded.some((task) => ["running", "verifying"].includes(task.status)))
754
+ return false;
755
+ return recoveredNodeState(node, recorded, state).status !== "recovery_required";
756
+ });
757
+ if (parked.length === 0)
758
+ return run;
759
+ const changed = parked.flatMap((node) => {
760
+ const { state, found } = this.recoveredNode(run.id, node, definition, run.nodes[node.id]);
761
+ if (found.some((task) => task !== undefined && ["running", "verifying"].includes(task.status)))
762
+ return [];
763
+ return canonicalJson(state) === canonicalJson(run.nodes[node.id]) ? [] : [{ nodeId: node.id, state }];
764
+ });
765
+ if (changed.length === 0)
766
+ return run;
767
+ return this.transition(run, (draft, emit) => {
768
+ for (const { nodeId, state } of changed) {
769
+ const from = draft.nodes[nodeId].status;
770
+ draft.nodes[nodeId] = state;
771
+ emit("workflow.node_changed", { nodeId, from, state });
772
+ }
773
+ this.settleStatus(draft, definition, emit);
774
+ });
775
+ }
776
+ /** The run's status from its nodes, with a finished run's unused compensations skipped. */
777
+ settleStatus(draft, definition, emit) {
778
+ let status = this.deriveStatus(draft, definition);
779
+ if (status === "verified_complete") {
780
+ for (const node of definition.nodes) {
781
+ const state = draft.nodes[node.id];
782
+ if (node.type !== "compensation" || state.status !== "pending") {
521
783
  continue;
522
784
  }
523
- const from = draft.nodes[node.id].status;
524
- draft.nodes[node.id] = state;
785
+ state.status = "skipped";
525
786
  emit("workflow.node_changed", {
526
787
  nodeId: node.id,
527
- from,
788
+ from: "pending",
528
789
  state,
529
790
  });
530
791
  }
792
+ status = this.deriveStatus(draft, definition);
793
+ }
794
+ if (status !== draft.status) {
795
+ const from = draft.status;
796
+ draft.status = status;
797
+ // Name the step, or "recovery_required" tells an operator that
798
+ // something needs hands without saying which effect is half-undone.
799
+ const reason = status === "recovery_required" ? haltReason(definition, draft.nodes) : undefined;
800
+ if (reason !== undefined) {
801
+ draft.error = reason;
802
+ }
803
+ else {
804
+ // The reducer clears `error` on every status change, so the draft has
805
+ // to as well or replay no longer reproduces the projection.
806
+ delete draft.error;
807
+ }
808
+ emit("workflow.status_changed", {
809
+ from,
810
+ to: status,
811
+ ...(draft.error === undefined ? {} : { error: draft.error }),
812
+ });
813
+ }
814
+ }
815
+ /**
816
+ * Each node of `stored` read again from the tasks under its keys, as
817
+ * recovery reads it, into `draft`. Returns the ids of the tasks now on the
818
+ * nodes it changed.
819
+ */
820
+ recoverNodes(stored, definition, draft, emit) {
821
+ const replayed = this.replay(stored);
822
+ if (replayed.stateVersion !== stored.stateVersion) {
823
+ throw new Error("workflow_event_history_invalid");
824
+ }
825
+ const found = [];
826
+ for (const node of definition.nodes) {
827
+ // An advance on a failed run only ever runs compensations, so only
828
+ // they can be stale. Re-deriving the forward nodes would rank their
829
+ // tasks differently from the live path and could take the run out of
830
+ // failed: a branch awaiting approval beside a failed one reopened.
831
+ if (stored.status === "failed" && node.type !== "compensation")
832
+ continue;
833
+ const { state } = this.recoveredNode(stored.id, node, definition, replayed.nodes[node.id]);
834
+ if (canonicalJson(draft.nodes[node.id]) === canonicalJson(state)) {
835
+ continue;
836
+ }
837
+ const from = draft.nodes[node.id].status;
838
+ // A copy: the caller goes on to change the node in the draft (cancels
839
+ // it, or skips a compensation), which must not rewrite this event.
840
+ draft.nodes[node.id] = structuredClone(state);
841
+ emit("workflow.node_changed", {
842
+ nodeId: node.id,
843
+ from,
844
+ state,
845
+ });
846
+ found.push(...state.taskIds);
847
+ }
848
+ return found;
849
+ }
850
+ recoverRun(stored, definition) {
851
+ return this.transition(stored, (draft, emit) => {
852
+ this.recoverNodes(stored, definition, draft, emit);
531
853
  let status = this.deriveStatus(draft, definition);
532
854
  if (status === "verified_complete") {
533
855
  for (const node of definition.nodes) {
@@ -548,13 +870,8 @@ export class WorkflowController {
548
870
  draft.status = status;
549
871
  // Named the way advanceOnce names a stalled unwind, so a run reads the
550
872
  // 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
873
  if (status === "recovery_required") {
554
- draft.error =
555
- stalled === undefined
556
- ? "workflow_contains_unreconciled_mutation"
557
- : `workflow_compensation_incomplete:${stalled.id}`;
874
+ draft.error = haltReason(definition, draft.nodes) ?? "workflow_contains_unreconciled_mutation";
558
875
  }
559
876
  else {
560
877
  delete draft.error;
@@ -580,70 +897,169 @@ export class WorkflowController {
580
897
  return this.halt(workflowId, "suspended", "workflow.suspended");
581
898
  }
582
899
  resume(workflowId) {
583
- const current = this.status(workflowId);
584
- if (!["paused", "suspended"].includes(current.status)) {
585
- throw new Error("workflow_not_paused");
586
- }
587
- const definition = this.loadDefinition(current);
588
- return this.transition(current, (draft, emit) => {
589
- const from = draft.status;
590
- // Restore whatever the graph was actually blocked on before the halt,
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 }),
900
+ return this.underLease(workflowId, (current) => {
901
+ if (!["paused", "suspended"].includes(current.status)) {
902
+ throw new Error("workflow_not_paused");
903
+ }
904
+ const definition = this.loadDefinition(current);
905
+ return this.transition(current, (draft, emit) => {
906
+ const from = draft.status;
907
+ // Restore whatever the graph was actually blocked on before the halt,
908
+ // so resuming a run parked on a human, or on an effect whose outcome is
909
+ // unknown, does not claim it is running.
910
+ draft.status = this.deriveStatus(draft, definition);
911
+ if (draft.status !== "recovery_required")
912
+ delete draft.error;
913
+ else
914
+ draft.error ??= "workflow_contains_unreconciled_mutation";
915
+ emit("workflow.resumed", {
916
+ from,
917
+ to: draft.status,
918
+ ...(draft.error === undefined ? {} : { error: draft.error }),
919
+ });
602
920
  });
603
921
  });
604
922
  }
605
923
  halt(workflowId, to, eventType) {
606
- const current = this.status(workflowId);
607
- if (current.status === to)
608
- return current;
609
- if (["verified_complete", "cancelled", "failed"].includes(current.status)) {
610
- throw new Error("workflow_not_haltable");
611
- }
612
- return this.transition(current, (draft, emit) => {
613
- const from = draft.status;
614
- draft.status = 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 }) });
924
+ return this.underLease(workflowId, (current) => {
925
+ if (current.status === to)
926
+ return current;
927
+ if (["verified_complete", "cancelled", "failed"].includes(current.status)) {
928
+ throw new Error("workflow_not_haltable");
929
+ }
930
+ return this.transition(current, (draft, emit) => {
931
+ const from = draft.status;
932
+ draft.status = to;
933
+ // Kept: a run halted while it needed recovery still needs it, and the
934
+ // reducer drops an error the event does not carry.
935
+ emit(eventType, { from, to, ...(draft.error === undefined ? {} : { error: draft.error }) });
936
+ });
618
937
  });
619
938
  }
620
939
  cancel(workflowId) {
621
- const current = this.status(workflowId);
622
- if (current.status === "verified_complete" ||
623
- current.status === "cancelled") {
624
- return current;
625
- }
626
- return this.transition(current, (draft, emit) => {
627
- for (const [nodeId, node] of Object.entries(draft.nodes)) {
628
- if (!["pending", "ready", "awaiting_approval"].includes(node.status)) {
629
- continue;
630
- }
631
- for (const taskId of node.taskIds)
632
- this.tasks.cancel(taskId);
633
- const from = node.status;
634
- node.status = "cancelled";
635
- emit("workflow.node_changed", {
636
- nodeId,
637
- from,
638
- state: node,
940
+ return this.underLease(workflowId, (current) => {
941
+ if (current.status === "verified_complete")
942
+ return current;
943
+ let definition;
944
+ try {
945
+ definition = this.loadDefinition(current);
946
+ }
947
+ catch {
948
+ definition = undefined;
949
+ }
950
+ const stopped = [];
951
+ // Cancelled again: a step recorded in flight whose task has since ended
952
+ // (here, after its settle gave up, or in another process) is recorded
953
+ // as it ended.
954
+ const run = current.status === "cancelled"
955
+ ? definition === undefined
956
+ ? current
957
+ : this.settleInFlight(current, definition)
958
+ : this.transition(current, (draft, emit) => {
959
+ // After an advance that stalled, died or lost its commit, the run
960
+ // names none of the steps that advance planned or ran, and
961
+ // nothing recovers a cancelled run: read again from the tasks
962
+ // under its keys first, as the next advance would, so they are on
963
+ // its nodes. A run already whole reads the same and records
964
+ // nothing more, but for an operation whose approval node ran its
965
+ // task, which reads that task on the next wave: recorded done
966
+ // here. A task still running there is stopped as the run's own,
967
+ // its step recorded as still in flight. One that cannot be
968
+ // recovered (its history no longer replays) is cancelled as it
969
+ // reads, as for a definition that no longer opens: refused, it
970
+ // could never end.
971
+ if (definition !== undefined) {
972
+ try {
973
+ stopped.push(...this.recoverNodes(current, definition, draft, emit));
974
+ }
975
+ catch {
976
+ // Cancelled as recorded.
977
+ }
978
+ }
979
+ // A step an earlier advance already recorded in flight reads the
980
+ // same and is not among those, yet its task may run here (a
981
+ // direct melra_execute): stopped too. One in another process is
982
+ // left running by tasks.cancel.
983
+ for (const node of Object.values(draft.nodes)) {
984
+ if (node.error === "task_still_in_flight")
985
+ stopped.push(...node.taskIds);
986
+ }
987
+ for (const [nodeId, node] of Object.entries(draft.nodes)) {
988
+ if (!["pending", "ready", "awaiting_approval"].includes(node.status)) {
989
+ continue;
990
+ }
991
+ stopped.push(...node.taskIds);
992
+ const from = node.status;
993
+ node.status = "cancelled";
994
+ emit("workflow.node_changed", {
995
+ nodeId,
996
+ from,
997
+ state: node,
998
+ });
999
+ }
1000
+ const from = draft.status;
1001
+ draft.status = "cancelled";
1002
+ delete draft.error;
1003
+ emit("workflow.cancelled", { from, to: "cancelled" });
639
1004
  });
1005
+ // Tasks are cancelled only once the run is: cancelled before a commit
1006
+ // that then failed, a step's challenge read as a denial on a run that
1007
+ // went on, and the run unwound.
1008
+ const here = stopped.filter((taskId) => this.store.inFlight.has(taskId));
1009
+ for (const taskId of stopped)
1010
+ this.tasks.cancel(taskId);
1011
+ // One running in this process is only told to stop. Its step is read
1012
+ // again once it has ended, as recorded: cancelled, or done all the same.
1013
+ // One running in another process, or whose process died, stays in
1014
+ // flight: nothing here learns how it ends until the run is cancelled
1015
+ // again.
1016
+ if (here.length > 0 && definition !== undefined) {
1017
+ const opened = definition;
1018
+ void Promise.all(here.map((taskId) => this.tasks.settled(taskId)))
1019
+ .then(() => this.settleStopped(workflowId, opened))
1020
+ .catch(() => undefined);
640
1021
  }
641
- const from = draft.status;
642
- draft.status = "cancelled";
643
- delete draft.error;
644
- emit("workflow.cancelled", { from, to: "cancelled" });
1022
+ // A challenge a lost advance left is in no node's taskIds: a condition's
1023
+ // is never adopted (it would follow its branch without asking, #268),
1024
+ // and another step's is taken up only when the run advances again
1025
+ // (#319). So the run cancels what is planned or waiting under every
1026
+ // node's keys too, and cancelling a cancelled run sweeps again.
1027
+ // A store failure is reported, so the operator cancels again and that
1028
+ // sweeps; only a definition that can no longer be opened goes unswept,
1029
+ // and still lets the run end.
1030
+ if (definition !== undefined) {
1031
+ const opened = definition;
1032
+ this.cancelChallenges(opened.nodes.flatMap((node) => expectedTaskKeys(current.id, node, opened)));
1033
+ }
1034
+ return run;
645
1035
  });
646
1036
  }
1037
+ /**
1038
+ * An operator's change to a run, made under its lease as recovery repairs
1039
+ * one, so it never lands under an advance: the advance's commit would fail
1040
+ * and the effects it ran drop out of the run (#244). Refused with
1041
+ * workflow_lease_held while an advance in any process holds the lease. A
1042
+ * lapsed lease is taken over, so the advance that held it stops with
1043
+ * workflow_lease_lost before its next step, and is left expired, as it was:
1044
+ * whoever advances the run next recovers it first.
1045
+ */
1046
+ underLease(workflowId, change) {
1047
+ this.status(workflowId);
1048
+ const at = Date.now();
1049
+ const taken = this.store.takeWorkflowLease(workflowId, this.controlOwner, new Date(at).toISOString(), new Date(at + this.leaseDurationMs).toISOString());
1050
+ if (taken === "refused")
1051
+ throw new Error("workflow_lease_held");
1052
+ try {
1053
+ // Read under the lease: an advance may have committed since.
1054
+ return change(this.status(workflowId));
1055
+ }
1056
+ finally {
1057
+ if (taken === "created")
1058
+ this.store.releaseWorkflowLease(workflowId, this.controlOwner);
1059
+ else
1060
+ this.store.renewWorkflowLease(workflowId, this.controlOwner, new Date(0).toISOString());
1061
+ }
1062
+ }
647
1063
  transition(current, mutate) {
648
1064
  const draft = structuredClone(current);
649
1065
  const events = [];
@@ -699,6 +1115,10 @@ export class WorkflowController {
699
1115
  async unwind(nodeIds, graph, definition, run, approvals, inputs) {
700
1116
  const results = [];
701
1117
  for (const nodeId of nodeIds) {
1118
+ // A cancel or pause landed under this advance: its commit will fail, and
1119
+ // a compensation started now would run for a run that no longer asks.
1120
+ if (this.status(run.id).stateVersion !== run.stateVersion)
1121
+ break;
702
1122
  const result = await this.advanceNode(graph.nodes.get(nodeId), definition, run.nodes[nodeId], run, approvals, inputs);
703
1123
  results.push({ nodeId, result });
704
1124
  if (result.state.status !== "compensated")
@@ -739,14 +1159,51 @@ export class WorkflowController {
739
1159
  throw new Error(`workflow_condition_source_unavailable:${node.id}`);
740
1160
  }
741
1161
  // 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.
1162
+ // live predicate on a later advance could flip it and run the other
1163
+ // branch as well.
744
1164
  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]);
1165
+ const keys = expectedTaskKeys(run.id, node, definition);
1166
+ const trueKeys = keys.slice(0, node.whenTrue.length);
1167
+ const falseKeys = keys.slice(node.whenTrue.length);
1168
+ // A branch with a committed step was chosen, though the advance that
1169
+ // chose it may have lost its commit (a pause landed) and left no task
1170
+ // here. Asking the predicate again would read a workspace that branch
1171
+ // already changed, and could run the other one as well.
1172
+ const committed = (branch) => branch.some((key) => this.store.getIdempotencyCommit(key) !== undefined);
1173
+ const trueCommitted = committed(trueKeys);
1174
+ const falseCommitted = committed(falseKeys);
1175
+ const recorded = state.taskIds.map((taskId) => this.tasks.status(taskId).idempotencyKey ?? "");
1176
+ const chosen = trueCommitted
1177
+ ? true
1178
+ : falseCommitted
1179
+ ? false
1180
+ : recorded.length > 0
1181
+ ? trueKeys.includes(recorded[0])
1182
+ : await answered(node.id, this.tasks.verifyPersisted(sourceTaskId, [node.predicate]), {
1183
+ state,
1184
+ tasks: [],
1185
+ }).catch((error) => {
1186
+ // Not asked, so nothing decided it, but an advance that lost
1187
+ // its commit may have run a step under it whose outcome is
1188
+ // unknown. The node halts on that step, as recovery reads it
1189
+ // (#270), rather than wait or fail naming nothing while a
1190
+ // failed sibling unwinds past it (#318).
1191
+ const unknown = this.unsettledUnder(keys);
1192
+ if (!(error instanceof NodeRefused) || unknown.length === 0)
1193
+ throw error;
1194
+ throw new NodeRefused(error.reason, { state: recoveredNodeState(node, unknown, state), tasks: [] });
1195
+ });
1196
+ // The other branch is settled by the advance that decides, or one that
1197
+ // finds the run naming it: only then can an advance that lost its
1198
+ // commit have left a task there.
1199
+ const own = chosen ? trueKeys : falseKeys;
1200
+ if ((chosen ? falseCommitted : trueCommitted) ||
1201
+ recorded.length === 0 ||
1202
+ recorded.some((key) => !own.includes(key))) {
1203
+ const halted = this.abandonBranch(node, keys, chosen);
1204
+ if (halted !== undefined)
1205
+ return { state: halted, tasks: [] };
1206
+ }
750
1207
  return await this.runSequential(chosen ? node.whenTrue : node.whenFalse, state, approvals, { ...identity, branch: chosen ? "true" : "false" });
751
1208
  }
752
1209
  case "parallel":
@@ -803,11 +1260,13 @@ export class WorkflowController {
803
1260
  };
804
1261
  }
805
1262
  case "delegation": {
806
- const supplied = inputs.find((item) => item.nodeId === node.id);
1263
+ // A report whose evidence went unanswered waits on the node, so the
1264
+ // next advance asks again without the worker reporting twice.
1265
+ const report = inputs.find((item) => item.nodeId === node.id)?.value ?? state.input;
807
1266
  // Node state caps `prompt` at 2,000 chars; assignee + goal can exceed
808
1267
  // that, and a truncated label is better than a schema rejection.
809
1268
  const prompt = `${node.assignee}: ${node.goal}`.slice(0, 2_000);
810
- if (supplied === undefined) {
1269
+ if (report === undefined) {
811
1270
  return {
812
1271
  state: { status: "awaiting_input", taskIds: [], prompt },
813
1272
  tasks: [],
@@ -817,8 +1276,11 @@ export class WorkflowController {
817
1276
  // predicates, they decide — a node whose evidence fails is `failed`,
818
1277
  // never complete, exactly as for an operation node.
819
1278
  if (node.requiredEvidence.length > 0) {
820
- const verification = await this.tasks.verifyStandalone(node.requiredEvidence);
821
- if (!verification.verified) {
1279
+ const verified = await answered(node.id, this.tasks.verifyStandalone(node.requiredEvidence), {
1280
+ state: { status: "awaiting_input", taskIds: [], prompt, input: recordedInput(report) },
1281
+ tasks: [],
1282
+ });
1283
+ if (!verified) {
822
1284
  return {
823
1285
  state: {
824
1286
  status: "failed",
@@ -835,7 +1297,7 @@ export class WorkflowController {
835
1297
  status: "verified_complete",
836
1298
  taskIds: [],
837
1299
  prompt,
838
- input: recordedInput(supplied.value),
1300
+ input: recordedInput(report),
839
1301
  },
840
1302
  tasks: [],
841
1303
  };
@@ -845,29 +1307,51 @@ export class WorkflowController {
845
1307
  }
846
1308
  }
847
1309
  async runTask(request, existingTaskId, approvals, executePlanned = true, identity) {
848
- let task = existingTaskId === undefined
849
- ? this.tasks.plan(request, {
850
- idempotencyKey: taskIdempotencyKey(identity, request),
851
- attempt: 1,
852
- stored: true,
853
- })
854
- : this.tasks.status(existingTaskId);
1310
+ this.assertAdvanceLease(identity.workflowId);
1311
+ const key = taskIdempotencyKey(identity, request);
1312
+ let task;
1313
+ if (existingTaskId !== undefined) {
1314
+ task = this.tasks.status(existingTaskId);
1315
+ }
1316
+ else {
1317
+ // A step the run names no task for may still have one: an advance that
1318
+ // lost its commit planned or ran it (#319). Planned afresh, a leftover
1319
+ // challenge stayed approvable beside the new one, a step that took
1320
+ // effect but failed its evidence ran again, and one that failed or was
1321
+ // denied ran after all, past a compensation already run for it.
1322
+ // Planned only when its key holds no task at all.
1323
+ task =
1324
+ this.takenUp(key) ??
1325
+ this.tasks.plan(request, { idempotencyKey: key, attempt: 1, stored: true });
1326
+ }
1327
+ // A challenge this run asked again after it expired leaves its first
1328
+ // attempt cancelled; when the advance that asked again did not commit, the
1329
+ // run still names that attempt. Take the newest attempt for the key, as
1330
+ // recovery does, so only a cancel nobody followed reads as a denial.
1331
+ if (task.status === "cancelled" && task.idempotencyKey !== undefined) {
1332
+ task = preferredTask(this.store.listTasksByKey(task.idempotencyKey), task.idempotencyKey) ?? task;
1333
+ }
855
1334
  // A challenge that expired before anyone answered it can never be
856
1335
  // answered, and reusing it left the node unapprovable for good. Ask again:
857
1336
  // the expired task is cancelled and the same request planned afresh.
858
1337
  // A challenge an approver answered in time is not asked again, however
859
- // long the answer took to act on.
1338
+ // long the answer took to act on. One started meanwhile by another
1339
+ // process (an execute right at expiry) keeps its cancel from landing: it
1340
+ // is the step's task, read as it now stands, not one to ask beside.
860
1341
  const decided = task.status === "awaiting_approval" && this.tasks.approvalDecision(task) !== undefined;
861
1342
  if (task.status === "awaiting_approval" &&
862
1343
  task.approval !== undefined &&
863
1344
  Date.parse(task.approval.expiresAt) <= Date.now() &&
864
1345
  !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
- });
1346
+ const expired = this.tasks.cancel(task.id);
1347
+ task =
1348
+ expired.status !== "cancelled"
1349
+ ? expired
1350
+ : this.tasks.plan(request, {
1351
+ idempotencyKey: key,
1352
+ attempt: (task.attempt ?? 1) + 1,
1353
+ stored: true,
1354
+ });
871
1355
  }
872
1356
  if (task.status === "awaiting_approval") {
873
1357
  const approval = approvals.find((item) => item.approvalId === task.approval?.approvalId);
@@ -893,13 +1377,44 @@ export class WorkflowController {
893
1377
  tasks: [task],
894
1378
  };
895
1379
  }
1380
+ // The renewal itself, not a read of the lease: a read passes a lapsed lease
1381
+ // nobody has taken yet, and a takeover landing just after it runs the same
1382
+ // step a second time. The update matches on owner and serializes with
1383
+ // `takeWorkflowLease`, so a takeover either lands first (nothing renewed:
1384
+ // stop) or after, and then finds a fresh lease and is refused. A renewal
1385
+ // that throws (a busy database) fails the advance here too: a lease this
1386
+ // advance cannot confirm is not one to start a step under.
1387
+ assertAdvanceLease(workflowId) {
1388
+ if (!this.store.renewWorkflowLease(workflowId, this.owner, new Date(Date.now() + this.leaseDurationMs).toISOString())) {
1389
+ throw new Error("workflow_lease_lost");
1390
+ }
1391
+ }
1392
+ /**
1393
+ * The task a step takes up rather than plan one: the one committed, else
1394
+ * the one recovery prefers of every task under the key, one that did not
1395
+ * happen (failed, denied, out of budget, refused) included, so the step
1396
+ * reads as it would after a restart. Undefined only when there is none.
1397
+ */
1398
+ takenUp(key) {
1399
+ const committed = this.store.getIdempotencyCommit(key)?.taskId;
1400
+ return committed === undefined ? preferredTask(this.store.listTasksByKey(key), key) : this.tasks.status(committed);
1401
+ }
896
1402
  async runSequential(requests, state, approvals, identity) {
897
- const taskIds = [...state.taskIds];
1403
+ const taskIds = [];
898
1404
  const tasks = [];
899
1405
  for (const [index, request] of requests.entries()) {
900
- const result = await this.runTask(request, taskIds[index], approvals, true, { ...identity, branch: `${identity.branch}:${index}` });
1406
+ const taskIdentity = { ...identity, branch: `${identity.branch}:${index}` };
1407
+ // The step's task is the one under its key. The run may name another
1408
+ // branch's or step's (an earlier version merged both branches), or none
1409
+ // (the advance that planned it lost its commit): runTask then takes up
1410
+ // the one under its key.
1411
+ const named = state.taskIds[index];
1412
+ const existing = named !== undefined && this.tasks.status(named).idempotencyKey === taskIdempotencyKey(taskIdentity, request)
1413
+ ? named
1414
+ : undefined;
1415
+ const result = await this.runTask(request, existing, approvals, true, taskIdentity);
901
1416
  tasks.push(...result.tasks);
902
- taskIds[index] = result.state.taskIds[0];
1417
+ taskIds.push(result.state.taskIds[0]);
903
1418
  if (result.state.status !== "verified_complete") {
904
1419
  return {
905
1420
  state: { ...result.state, taskIds },
@@ -913,25 +1428,65 @@ export class WorkflowController {
913
1428
  };
914
1429
  }
915
1430
  async runParallel(branches, state, approvals, workflowId, nodeId) {
916
- const requests = branches.flatMap((branch, branchIndex) => branch.map((request, requestIndex) => ({
917
- request,
918
- branch: `${branchIndex}:${requestIndex}`,
919
- })));
920
- const results = await Promise.all(requests.map((item, index) => this.runTask(item.request, state.taskIds[index], approvals, true, {
921
- workflowId,
922
- nodeId,
923
- iteration: 0,
924
- branch: item.branch,
925
- })));
926
- return {
927
- state: aggregateNodeState(results.map((result) => result.state)),
928
- tasks: results.flatMap((result) => result.tasks),
1431
+ // Branches side by side, each one's steps in order (#245). A branch's
1432
+ // slice of the node's tasks is only a hint: runSequential checks each
1433
+ // against its step's key, so one shifted by a branch that stopped short
1434
+ // is taken up by key instead.
1435
+ let offset = 0;
1436
+ // Every branch finishes before one's refusal is reported. Rejecting at
1437
+ // the first throw left a sibling's adapter running and the node recorded
1438
+ // as it was, so a failure beside it unwound past whatever that sibling did.
1439
+ const settled = await Promise.allSettled(branches.map((branch, index) => {
1440
+ const taskIds = state.taskIds.slice(offset, offset + branch.length);
1441
+ offset += branch.length;
1442
+ return this.runSequential(branch, { ...state, taskIds }, approvals, {
1443
+ workflowId,
1444
+ nodeId,
1445
+ iteration: 0,
1446
+ branch: String(index),
1447
+ });
1448
+ }));
1449
+ const refused = settled.find((outcome) => outcome.status === "rejected");
1450
+ // A refused branch stays as the attempts it acted on stand, found by key
1451
+ // as runTask finds them: one planned in this call is not recorded yet,
1452
+ // and the run may still name a cancelled attempt that was asked again.
1453
+ // Read as runSequential reads it, up to the first step not complete; one
1454
+ // it never reached leaves it pending, not complete.
1455
+ const states = settled.flatMap((outcome, branchIndex) => {
1456
+ if (outcome.status === "fulfilled")
1457
+ return [outcome.value.state];
1458
+ const steps = [];
1459
+ for (const [index, request] of branches[branchIndex].entries()) {
1460
+ const key = taskIdempotencyKey({ workflowId, nodeId, iteration: 0, branch: `${branchIndex}:${index}` }, request);
1461
+ const task = preferredTask(this.store.listTasksByKey(key), key);
1462
+ const step = task === undefined ? { status: "pending", taskIds: [] } : nodeStateForTask(task, false);
1463
+ steps.push(step);
1464
+ if (step.status !== "verified_complete")
1465
+ break;
1466
+ }
1467
+ return steps;
1468
+ });
1469
+ const advance = {
1470
+ state: aggregateNodeState(states),
1471
+ tasks: settled.flatMap((outcome) => (outcome.status === "fulfilled" ? outcome.value.tasks : [])),
929
1472
  };
1473
+ if (refused !== undefined)
1474
+ throw new NodeRefused(refused.reason, advance);
1475
+ return advance;
930
1476
  }
931
1477
  async runLoop(node, state, approvals, workflowId) {
932
1478
  const taskIds = [...state.taskIds];
933
1479
  const tasks = [];
934
1480
  let iterations = state.iterations ?? 0;
1481
+ // An advance that lost its commit may have gone on to this iteration, so
1482
+ // `until` said no before it: asked again, of a workspace that iteration
1483
+ // changed, it could stop the loop short of an iteration that ran.
1484
+ const wentOn = (iteration) => {
1485
+ if (iteration >= node.maxIterations)
1486
+ return false;
1487
+ const key = taskIdempotencyKey({ workflowId, nodeId: node.id, iteration, branch: "loop:0" }, node.body[0]);
1488
+ return this.takenUp(key) !== undefined;
1489
+ };
935
1490
  // Resumed after recovery between iterations: the last one finished but its
936
1491
  // `until` was never checked, and running another would be one too many.
937
1492
  const finished = taskIds[iterations * node.body.length - 1];
@@ -939,7 +1494,8 @@ export class WorkflowController {
939
1494
  taskIds.length === iterations * node.body.length &&
940
1495
  finished !== undefined &&
941
1496
  node.until !== undefined &&
942
- (await this.tasks.verifyPersisted(finished, [node.until])).verified) {
1497
+ !wentOn(iterations) &&
1498
+ (await answered(node.id, this.tasks.verifyPersisted(finished, [node.until]), { state: { status: "pending", taskIds, iterations }, tasks }, "no"))) {
943
1499
  return { state: { status: "verified_complete", taskIds, iterations }, tasks };
944
1500
  }
945
1501
  while (iterations < node.maxIterations) {
@@ -966,8 +1522,13 @@ export class WorkflowController {
966
1522
  }
967
1523
  iterations += 1;
968
1524
  const lastTaskId = taskIds[offset + node.body.length - 1];
1525
+ // Unanswered, the iteration is recorded and `until` asked again on the
1526
+ // next advance, where the check above picks it up. Not asked after the
1527
+ // last iteration, which ends the loop whatever the answer.
969
1528
  if (node.until !== undefined &&
970
- (await this.tasks.verifyPersisted(lastTaskId, [node.until])).verified) {
1529
+ iterations < node.maxIterations &&
1530
+ !wentOn(iterations) &&
1531
+ (await answered(node.id, this.tasks.verifyPersisted(lastTaskId, [node.until]), { state: { status: "pending", taskIds, iterations }, tasks }, "no"))) {
971
1532
  break;
972
1533
  }
973
1534
  }
@@ -980,6 +1541,99 @@ export class WorkflowController {
980
1541
  tasks,
981
1542
  };
982
1543
  }
1544
+ /**
1545
+ * What an earlier version recorded beside a step whose outcome is unknown.
1546
+ * It ranked a failure above an unknown outcome, so a parallel, condition or
1547
+ * loop node holding both read `failed`, and the run failed and unwound. That
1548
+ * unwind skipped the unknown step's own compensation, the step not being
1549
+ * done; offered again, it runs if the step settles as done. Nothing records
1550
+ * either now, so a current run is never changed here.
1551
+ */
1552
+ repairLegacy(definition, current) {
1553
+ if (current.status !== "failed" && current.status !== "recovery_required")
1554
+ return undefined;
1555
+ const reranked = definition.nodes.flatMap((node) => {
1556
+ const state = current.nodes[node.id];
1557
+ if (node.type === "compensation" || (state?.status !== "failed" && state?.status !== "cancelled"))
1558
+ return [];
1559
+ const unknown = state.taskIds
1560
+ .map((taskId) => this.store.getTask(taskId))
1561
+ .find((task) => task?.status === "recovery_required");
1562
+ return unknown === undefined ? [] : [{ node, state, unknown }];
1563
+ });
1564
+ const isUnknown = (nodeId) => current.nodes[nodeId]?.status === "recovery_required" || reranked.some(({ node }) => node.id === nodeId);
1565
+ // An approval node runs its step's task itself, so the step may still
1566
+ // read `pending` while the approval holds the unknown outcome. A target
1567
+ // that has since settled as done counts too: recovery at startup may have
1568
+ // settled it before this ran, and this version skips a compensation only
1569
+ // when its target is not done.
1570
+ const unskipped = definition.nodes.filter((node) => node.type === "compensation" &&
1571
+ current.nodes[node.id]?.status === "skipped" &&
1572
+ (isUnknown(node.forNodeId) ||
1573
+ targetDone(definition, (id) => current.nodes[id], node.forNodeId, this.ran) ||
1574
+ definition.nodes.some((other) => other.type === "approval" && other.forNodeId === node.forNodeId && isUnknown(other.id))));
1575
+ const halt = current.status === "failed" &&
1576
+ (reranked.length > 0 || hasUnknownOutcome(definition, (id) => current.nodes[id]?.status));
1577
+ if (reranked.length === 0 && unskipped.length === 0 && !halt)
1578
+ return undefined;
1579
+ return this.transition(current, (draft, emit) => {
1580
+ for (const { node, state: previous, unknown } of reranked) {
1581
+ const state = {
1582
+ status: "recovery_required",
1583
+ taskIds: previous.taskIds,
1584
+ ...(unknown.error === undefined ? {} : { error: unknown.error }),
1585
+ };
1586
+ emit("workflow.node_changed", { nodeId: node.id, from: previous.status, state });
1587
+ draft.nodes[node.id] = state;
1588
+ }
1589
+ for (const node of unskipped) {
1590
+ const state = { status: "pending", taskIds: [] };
1591
+ emit("workflow.node_changed", { nodeId: node.id, from: "skipped", state });
1592
+ draft.nodes[node.id] = state;
1593
+ }
1594
+ // A run with nothing unknown stays failed, so its unwind takes up what
1595
+ // was offered again.
1596
+ if (halt) {
1597
+ const from = draft.status;
1598
+ draft.status = "recovery_required";
1599
+ const stalled = stalledCompensation(definition, draft.nodes);
1600
+ if (stalled === undefined)
1601
+ delete draft.error;
1602
+ else
1603
+ draft.error = `workflow_compensation_incomplete:${stalled.id}`;
1604
+ emit("workflow.status_changed", {
1605
+ from,
1606
+ to: draft.status,
1607
+ ...(draft.error === undefined ? {} : { error: draft.error }),
1608
+ });
1609
+ }
1610
+ });
1611
+ }
1612
+ /**
1613
+ * Whether a forward step failed or was denied. Also inside a node that an
1614
+ * unknown branch ranks as recovery_required: a parallel node with one branch
1615
+ * unknown and one failed reads recovery_required, and its failure is in its
1616
+ * tasks.
1617
+ */
1618
+ hasFailure(definition, nodes) {
1619
+ return definition.nodes.some((node) => {
1620
+ if (node.type === "compensation")
1621
+ return false;
1622
+ const state = nodes[node.id];
1623
+ if (state === undefined)
1624
+ return false;
1625
+ if (state.status === "failed" || state.status === "cancelled")
1626
+ return true;
1627
+ if (state.status !== "recovery_required")
1628
+ return false;
1629
+ return state.taskIds.some((taskId) => {
1630
+ const task = this.store.getTask(taskId);
1631
+ return (task !== undefined &&
1632
+ (["failed", "partial", "budget_exhausted", "policy_blocked"].includes(task.status) ||
1633
+ (task.status === "cancelled" && task.error !== "duplicate_attempt_prevented")));
1634
+ });
1635
+ });
1636
+ }
983
1637
  deriveStatus(run, definition) {
984
1638
  const required = definition.nodes.filter((node) => node.type !== "compensation");
985
1639
  // Compensation nodes are excluded from every check below, because a
@@ -987,15 +1641,25 @@ export class WorkflowController {
987
1641
  // the forward effect happened and its declared inverse did not, which
988
1642
  // leaves the world in a state no single node describes. Checked first, so
989
1643
  // an incomplete unwind is never reported as a clean failure.
990
- if (definition.nodes.some((node) => node.type === "compensation" &&
991
- ["failed", "recovery_required"].includes(run.nodes[node.id]?.status ?? ""))) {
1644
+ if (stalledCompensation(definition, run.nodes) !== undefined) {
1645
+ return "recovery_required";
1646
+ }
1647
+ // Above a failure: unwinding now could undo what an effect that may have
1648
+ // happened depends on (refund a charge whose goods may be in transit). The
1649
+ // run halts until that step is settled, and only then fails and unwinds.
1650
+ if (hasUnknownOutcome(definition, (id) => run.nodes[id]?.status)) {
992
1651
  return "recovery_required";
993
1652
  }
994
1653
  if (required.some((node) => run.nodes[node.id]?.status === "failed")) {
995
1654
  return "failed";
996
1655
  }
997
- if (required.some((node) => run.nodes[node.id]?.status === "recovery_required")) {
998
- return "recovery_required";
1656
+ // A step whose task was denied or cancelled did not happen and is never
1657
+ // offered again (`readyNodeIds` takes pending nodes, the re-offer awaiting
1658
+ // ones), so the run cannot finish: it fails, and unwinds as for a failure.
1659
+ // A run-level `cancelled` belongs to `cancel()`, whose runs are not
1660
+ // derived.
1661
+ if (required.some((node) => run.nodes[node.id]?.status === "cancelled")) {
1662
+ return "failed";
999
1663
  }
1000
1664
  if (required.some((node) => run.nodes[node.id]?.status === "awaiting_approval")) {
1001
1665
  return "awaiting_approval";
@@ -1009,8 +1673,9 @@ export class WorkflowController {
1009
1673
  const state = run.nodes[node.id];
1010
1674
  return (state !== undefined &&
1011
1675
  ["verified_complete", "skipped", "compensated"].includes(state.status) &&
1012
- state.taskIds.every((taskId) => this.tasks.receipts({ taskId }).certificate?.result ===
1013
- "VERIFIED_SUCCESS"));
1676
+ // Every caller writes the run, so a step's certificate that
1677
+ // failed to write is written here rather than on a read.
1678
+ state.taskIds.every((taskId) => this.tasks.certify(taskId)?.result === "VERIFIED_SUCCESS"));
1014
1679
  })) {
1015
1680
  return "verified_complete";
1016
1681
  }
@@ -1029,9 +1694,49 @@ function makeEvent(run, sequence, type, data) {
1029
1694
  occurredAt: new Date().toISOString(),
1030
1695
  });
1031
1696
  }
1697
+ /**
1698
+ * A workflow check's answer. One the verifier could not ask (a provider that
1699
+ * answered 503, or not at all) is not a "no": nothing is decided on it. The
1700
+ * node is recorded as `waiting`, with the check as its error, and the next
1701
+ * advance asks again; the same check again writes nothing. Read as a no, a
1702
+ * condition took its other branch for good, a loop ran another iteration, and
1703
+ * a delegation failed its run. One the kernel refuses to ask (`unanswerable`)
1704
+ * is refused again of the same result, so the node fails; `until` passes
1705
+ * `refusedIs: "no"`, since the next iteration's result may answer it.
1706
+ */
1707
+ async function answered(nodeId, check, waiting, refusedIs = "failure") {
1708
+ const failed = (await check).evidence.filter((item) => !item.passed);
1709
+ const refused = (code, items, status) => new NodeRefused(new Error(code), {
1710
+ ...waiting,
1711
+ state: { ...waiting.state, status, error: recordedInput(`${code}: ${items.map(asked).join("; ")}`) },
1712
+ });
1713
+ const unanswerable = failed.filter((item) => item.unanswerable === true);
1714
+ if (unanswerable.length > 0 && refusedIs === "failure") {
1715
+ throw refused(`workflow_check_unresolvable:${nodeId}`, unanswerable, "failed");
1716
+ }
1717
+ // Every item must pass, so a definite no decides, whatever an unanswered
1718
+ // item would have said.
1719
+ if (failed.some((item) => item.inconclusive !== true || item.unanswerable === true))
1720
+ return false;
1721
+ if (failed.length === 0)
1722
+ return true;
1723
+ throw refused(`workflow_check_inconclusive:${nodeId}`, failed, waiting.state.status);
1724
+ }
1725
+ /**
1726
+ * A check's item as a node's error names it: its type, its source redacted as
1727
+ * a receipt's URL is (a query can carry a token), and the kernel's own
1728
+ * refusal. Not a provider's words, which can change between polls (a
1729
+ * transport message listing addresses in resolver order) and would write a
1730
+ * waiting run again on each.
1731
+ */
1732
+ function asked(item) {
1733
+ const source = item.source === undefined ? "" : ` ${redactStructuredValue({ url: item.source }).value.url}`;
1734
+ return `${item.type}${source}${item.unanswerable === true ? `: ${item.summary}` : ""}`;
1735
+ }
1032
1736
  // Human- and delegate-supplied text is untrusted like any other input, so it is
1033
- // redacted before it reaches persisted state. Redaction can lengthen a value
1034
- // (a short secret becomes a longer marker), hence the cap the state schema wants.
1737
+ // redacted before it reaches persisted state, as is the check a node waits
1738
+ // on. Redaction can lengthen a value (a short secret becomes a longer
1739
+ // marker), hence the cap the state schema wants.
1035
1740
  function recordedInput(value) {
1036
1741
  return String(redactStructuredValue(value).value).slice(0, 10_000);
1037
1742
  }
@@ -1056,6 +1761,15 @@ function requestsFor(node) {
1056
1761
  return [];
1057
1762
  }
1058
1763
  }
1764
+ /** Whether a node asks a semantic question anywhere (#54). */
1765
+ function declaresSemantic(node) {
1766
+ return [
1767
+ ...requestsFor(node).flatMap((request) => [...request.requiredEvidence, ...request.reconciliation]),
1768
+ ...(node.type === "condition" ? [node.predicate] : []),
1769
+ ...(node.type === "bounded_loop" && node.until !== undefined ? [node.until] : []),
1770
+ ...(node.type === "delegation" ? node.requiredEvidence : []),
1771
+ ].some((predicate) => predicate.type === "semantic");
1772
+ }
1059
1773
  function nodeStateForTask(task, plannedIsComplete) {
1060
1774
  switch (task.status) {
1061
1775
  case "verified_success":
@@ -1078,7 +1792,20 @@ function nodeStateForTask(task, plannedIsComplete) {
1078
1792
  ...(task.error === undefined ? {} : { error: task.error }),
1079
1793
  };
1080
1794
  case "cancelled":
1081
- return { status: "cancelled", taskIds: [task.id] };
1795
+ // Cancelled as a duplicate, the effect committed under an attempt this
1796
+ // run does not record: an advance lost its commit (a pause landed while
1797
+ // the step ran). Not a denial. The run halts, and recovery, which reads
1798
+ // every attempt of every slot, settles it as it always has.
1799
+ return task.error === "duplicate_attempt_prevented"
1800
+ ? { status: "recovery_required", taskIds: [task.id], error: task.error }
1801
+ : { status: "cancelled", taskIds: [task.id] };
1802
+ case "running":
1803
+ case "verifying":
1804
+ // Still in flight elsewhere, or interrupted after its effect (its key may
1805
+ // be committed). Not a failure of its own: read as one, its run would
1806
+ // unwind past an effect that may stand. Task recovery settles it, and
1807
+ // the run is derived again.
1808
+ return { status: "recovery_required", taskIds: [task.id], error: "task_still_in_flight" };
1082
1809
  default:
1083
1810
  return {
1084
1811
  status: "failed",
@@ -1089,17 +1816,79 @@ function nodeStateForTask(task, plannedIsComplete) {
1089
1816
  }
1090
1817
  function aggregateNodeState(states) {
1091
1818
  const taskIds = states.flatMap((state) => state.taskIds);
1819
+ // A failed or denied branch ends the node before another branch's approval
1820
+ // can run a step on a run that is already failing. An unknown outcome ranks
1821
+ // above both: the run halts for an operator rather than unwind past an
1822
+ // effect that may have happened.
1092
1823
  for (const status of [
1093
- "failed",
1094
1824
  "recovery_required",
1095
- "awaiting_approval",
1825
+ "failed",
1096
1826
  "cancelled",
1827
+ "awaiting_approval",
1097
1828
  ]) {
1098
1829
  const state = states.find((candidate) => candidate.status === status);
1099
1830
  if (state !== undefined)
1100
1831
  return { ...state, taskIds };
1101
1832
  }
1102
- return { status: "verified_complete", taskIds };
1833
+ // Complete only when every branch is: one that has not run yet (a refused
1834
+ // branch whose task is still planned) leaves the node to be offered again.
1835
+ return states.every((state) => state.status === "verified_complete")
1836
+ ? { status: "verified_complete", taskIds }
1837
+ : { status: "pending", taskIds };
1838
+ }
1839
+ /**
1840
+ * Whether a compensation's target step happened. An approval node runs its
1841
+ * operation's task itself, so the operation still reads `pending` for a wave
1842
+ * after its effect; skipping the compensation then would undo the steps
1843
+ * before it while this one stands. Only when that task did run, though: an
1844
+ * approval that policy let through (unsafe-local, or a read) reads
1845
+ * `verified_complete` with the task merely planned, and the effect is not
1846
+ * there to undo.
1847
+ */
1848
+ function targetDone(definition, stateOf, nodeId, ran) {
1849
+ return (stateOf(nodeId)?.status === "verified_complete" ||
1850
+ definition.nodes.some((node) => node.type === "approval" &&
1851
+ node.forNodeId === nodeId &&
1852
+ stateOf(node.id)?.status === "verified_complete" &&
1853
+ (stateOf(node.id)?.taskIds ?? []).some(ran)));
1854
+ }
1855
+ /**
1856
+ * Whether a forward step's outcome is unknown or unsettled (an effect that may
1857
+ * have happened, a task still in flight, or one cancelled as a duplicate of an
1858
+ * attempt that committed). Nothing is undone past one.
1859
+ */
1860
+ function hasUnknownOutcome(definition, statusOf) {
1861
+ return definition.nodes.some((node) => node.type !== "compensation" && statusOf(node.id) === "recovery_required");
1862
+ }
1863
+ /**
1864
+ * The compensation an unwind stopped at: it failed, its outcome is unknown, or
1865
+ * an operator denied it (Deny cancels its task). Its step was not undone, so
1866
+ * nothing older is undone either.
1867
+ */
1868
+ function stalledCompensation(definition, nodes) {
1869
+ return definition.nodes.find((node) => node.type === "compensation" &&
1870
+ ["failed", "recovery_required", "cancelled"].includes(nodes[node.id]?.status ?? ""));
1871
+ }
1872
+ /**
1873
+ * Why a halted run needs hands, naming the step: an unwind that stopped, or a
1874
+ * condition both of whose branches may have had an effect.
1875
+ */
1876
+ function haltReason(definition, nodes) {
1877
+ const stalled = stalledCompensation(definition, nodes);
1878
+ if (stalled !== undefined)
1879
+ return `workflow_compensation_incomplete:${stalled.id}`;
1880
+ const conflict = definition.nodes.find((node) => nodes[node.id]?.error === BRANCH_CONFLICT);
1881
+ return conflict === undefined ? undefined : `${BRANCH_CONFLICT}:${conflict.id}`;
1882
+ }
1883
+ /**
1884
+ * Whether a task is known to have changed nothing, as the task layer records
1885
+ * it: an adapter that may have acted reports an unknown outcome, which parks
1886
+ * the task as recovery_required, so failed (the adapter said it failed, or
1887
+ * reconciliation found it never applied), budget_exhausted, cancelled and
1888
+ * policy_blocked all say it did not happen. Anything else may have.
1889
+ */
1890
+ function hadNoEffect(task) {
1891
+ return ["failed", "budget_exhausted", "cancelled", "policy_blocked"].includes(task.status);
1103
1892
  }
1104
1893
  function taskIdempotencyKey(identity, request) {
1105
1894
  return sha256({ ...identity, request });
@@ -1152,6 +1941,9 @@ function sequenceFinished(node, found) {
1152
1941
  // left for the loop itself to decide.
1153
1942
  return found.every(Boolean);
1154
1943
  }
1944
+ // Every branch runs to its end, each step only after the one before it.
1945
+ if (node.type === "parallel")
1946
+ return found.every(Boolean);
1155
1947
  return true;
1156
1948
  }
1157
1949
  function preferredTask(tasks, idempotencyKey) {
@@ -1160,17 +1952,33 @@ function preferredTask(tasks, idempotencyKey) {
1160
1952
  .sort((left, right) => Number(right.status === "verified_success") -
1161
1953
  Number(left.status === "verified_success") ||
1162
1954
  (right.attempt ?? 1) - (left.attempt ?? 1) ||
1163
- right.updatedAt.localeCompare(left.updatedAt))[0];
1955
+ // The newest planned, not the newest touched: cancelling a stale
1956
+ // duplicate challenge must not make it the one the run names.
1957
+ right.createdAt.localeCompare(left.createdAt))[0];
1164
1958
  }
1165
1959
  function recoveredNodeState(node, tasks, previous) {
1166
1960
  if (tasks.length === 0)
1167
1961
  return previous;
1168
1962
  const taskIds = tasks.map((task) => task.id);
1169
1963
  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.
1964
+ // Ranked as aggregateNodeState ranks a live node: an unknown outcome, then a
1965
+ // failure. A parallel node with one failed branch and one awaiting approval
1966
+ // was failed live and came back from recovery awaiting approval, reopening
1967
+ // the waiting branch on a run whose rollback had begun.
1968
+ const recovery = withStatus("recovery_required");
1969
+ if (recovery !== undefined) {
1970
+ return {
1971
+ status: "recovery_required",
1972
+ taskIds,
1973
+ ...(recovery.error === undefined ? {} : { error: recovery.error }),
1974
+ };
1975
+ }
1976
+ // Still running, in a live process or one that died mid-effect: unknown, as
1977
+ // nodeStateForTask reads it. Read as cancelled, a cancel (which reads every
1978
+ // step again) recorded the effect as cancelled on its run for good.
1979
+ if (tasks.some((task) => task.status === "running" || task.status === "verifying")) {
1980
+ return { status: "recovery_required", taskIds, error: "task_still_in_flight" };
1981
+ }
1174
1982
  const failed = tasks.find((task) => [
1175
1983
  "failed",
1176
1984
  "partial",
@@ -1184,14 +1992,10 @@ function recoveredNodeState(node, tasks, previous) {
1184
1992
  ...(failed.error === undefined ? {} : { error: failed.error }),
1185
1993
  };
1186
1994
  }
1187
- const recovery = withStatus("recovery_required");
1188
- if (recovery !== undefined) {
1189
- return {
1190
- status: "recovery_required",
1191
- taskIds,
1192
- ...(recovery.error === undefined ? {} : { error: recovery.error }),
1193
- };
1194
- }
1995
+ // Ranked as aggregateNodeState ranks it, so a denied branch reads the same
1996
+ // after a restart and its sibling's challenge is not offered again.
1997
+ if (withStatus("cancelled") !== undefined)
1998
+ return { status: "cancelled", taskIds };
1195
1999
  const awaiting = withStatus("awaiting_approval");
1196
2000
  if (awaiting !== undefined) {
1197
2001
  return {