Osy#the first language built for agents
Agents firstAgentic appsWorkflowsDurable Execution — built inSecurityTestingThe editorThe UI modelOne program

Reference / Workflow

When a child is cancelled or fails

catch (WorkflowCancelled e) { … } · catch (WorkflowError e) { … } · terminal cancel X { } · terminal error X { }

A workflow you waited for can end badly — cancelled, or failed. `catch` is how you handle it; not catching it is how you let it travel onward, and the run then ends at the `terminal cancel` or `terminal error` you declared. The two outcomes stay distinct however far they travel, so a workflow three levels up can still tell "the work below me was cancelled" from "it broke".

stable1 example compiled by CIworkflowauthoring

Summary#

await Workflow.Run("child", child) waits for a workflow to finish. If it finishes badly — at a terminal cancel or a terminal error — that outcome arrives here as WorkflowCancelled or WorkflowError.

You have exactly two choices, and both are things you say in the code:

  • catch it — cancellation or failure stops here, and you decide what happens next.
  • Don't — it keeps going. This run ends at its own terminal cancel / terminal error, and whoever is waiting on this run receives the same kind in turn.

Not catching is a real choice, not an oversight. "If the work I'm waiting on is cancelled, I'm cancelled" is usually exactly right, and it is the same thing C# does with a cancellation that reaches the top of a task.

Signature#

try { await Workflow.Run("child", child); goto Done; }
catch (WorkflowCancelled e) { goto Withdrawn; }   // handle it — it stops here
catch (WorkflowError e)     { goto Rejected; }

terminal cancel Withdrawn { Message = "…"; }      // …or declare where an UNCAUGHT one lands
terminal error  Rejected  { Message = "…"; }

e.Message is the terminal's own Message — the sentence the workflow that ended wrote about why.

Description#

The two kinds mean different things, and the difference survives the trip#

WorkflowCancelled is a deliberate stop: the work was called off. WorkflowError is a failure: it went wrong. Keeping them apart is the whole reason there are two, and it matters most far from where it happened — an approval three workflows up should react differently to "the customer withdrew" than to "the payment provider rejected it".

So the kind is preserved every step of the way. A cancellation that travels up four runs is still a cancellation when it gets there, and the fourth run's catch (WorkflowCancelled e) is what fires.

Where an uncaught one lands, and why you have to say#

An uncaught outcome ends the run at the terminal you declared for it — a real transition: the tracked property moves, the audit records it, and the terminal's Message is what the next run up receives.

That is why the compiler asks you to declare one. If a wait can be cancelled and nothing catches it, the run is going to end there, and a run has to end somewhere you named. The rule is narrow on purpose — you are only asked when both are true:

  • nothing catches it on that path, and
  • the workflow you are waiting on can actually produce that outcome.

A child that declares no terminal cancel can never be cancelled, so you are never asked to declare a cancel terminal for it. You will not be made to write states that can't be reached.

Declaring two terminals of the same kind is also an error: an uncaught outcome would have two places to land, and the compiler will not guess. Keep one, or catch it and goto the one you mean.

You cannot raise these yourself#

WorkflowCancelled and WorkflowError are raised by the platform, and only by the platform. throwing one is a compile error.

They mean one precise thing — a workflow I waited for ended at a declared terminal — and everything above depends on that staying true. To end your own workflow that way, goto its terminal: that is declared, audited, moves the tracked property, and carries a Message. To fail an ordinary function, throw new Exception("…").

Examples#

A child that can end all three ways, and a parent that handles one of them and lets the other travel:

enum OrderStatus { Placed, Shipped, Rejected, Withdrawn }
enum PickStatus  { Waiting, Picked, Empty, Cancelled }

entity Order { [Required, MaxLength(40)] string Reference; OrderStatus Status = OrderStatus.Placed; }
entity Pick  { [Required] Order Order; PickStatus Status = PickStatus.Waiting; }

workflow PickFlow {
  Tracks = Pick.Status; Autostart = false; Initial = Waiting;
  event Report(bool inStock);
  state Waiting {
    subscribe Report(bool inStock);
    on Report(bool inStock) {
      when (inStock) { goto Picked; }
      default { goto Empty; }
    }
  }
  terminal success Picked    { }
  terminal error   Empty     { Message = "out of stock"; }
  terminal cancel  Cancelled { Message = "picking called off"; }
}

workflow OrderFlow {
  Tracks = Order.Status; Autostart = false; Initial = Placed;
  state Placed {
    enter {
      var pick = Workflow.Once("make-pick", () => new Pick { Order = this.Item });
      try { await Workflow.Run("pick", pick); goto Shipped; }
      catch (WorkflowError e) { goto Rejected; }     // a FAILURE stops here — we reject the order
    }
  }
  terminal success Shipped   { }
  terminal success Rejected  { }
  // A CANCELLATION is deliberately not caught: if picking was called off, the order is withdrawn. This is the
  // terminal that says where it lands — and without it, the code above would not compile.
  terminal cancel  Withdrawn { Message = "order withdrawn"; }
}

Read the Placed state as a sentence: ship it if the pick succeeds; reject it if the pick fails; if the pick is called off, so is the order. The third clause is the terminal, not a catch.

Notes#

  • Only these two outcomes behave this way. An ordinary failure — a dropped connection, a timeout, a bug — is not a workflow outcome, and the platform retries it rather than ending the run, because another attempt may well succeed. A cancelled or failed child is different: it has already finished, so trying again would read the same answer.
  • Compensations still run. If the run had a Workflow.BeginSaga (a compensating saga scope) scope open, leaving through an uncaught outcome disposes it on the way out, so the work it registered is undone before the run ends.
  • A catch (Exception e) catches these too, like any other. Reach for the specific ones when the difference between "called off" and "went wrong" changes what you do.

See also#

Related

Workflow.Run (start a workflow)

Start the workflow bound to an entity's type, on that entity. Bare — `Workflow.Run(order)` — is fire-and-forget: start…

Workflow.BeginSaga (a compensating saga scope)

Open a saga scope that couples each forward step with its compensation. `await saga.Run("step", step, () => Undo(…))`…

Parallel legs (start several, then wait for them)

Start several pieces of work at once, each with its own compensation, and wait for them together. Writing…