The async handle value model

Some workflows reach a point where two pieces of work do not depend on each other. For example, an analysis and a build can run at the same time. The two could overlap, but only if the runtime keeps a record of every piece of work that has started and waits for each one to finish before the workflow ends.

Jaiph provides run async and a value type called Handle<T> for overlapping independent work. The value model covers what a handle represents, when it turns into a real string, and how it interacts with recovery and joins. For the surface syntax see Language, run async and Grammar, run async. For the runtime implementation see Architecture, Core components.

What a handle is

run async ref(args) schedules the same target a synchronous run would have called, either a workflow or a script, but it does not block the current step list. The return value is a Handle<T>, where T is whatever a synchronous run would have produced, which is a workflow’s return value, or a script’s trimmed stdout on success.

In the runtime variable map the handle is stored as an opaque token of the form __JAIPH_HANDLE__<id>. The token is bookkeeping, not a value. The first step that needs the value awaits the scheduled work and then replaces the binding with the resolved string. After that, the variable behaves like any other string.

The model separates when work starts from when its value is read:

  1. Eager start. Work is scheduled the moment run async runs.
  2. Lazy resolve. The handle is not the value yet. The token can stay in its binding while later steps run, and the wait happens at the first resolving read or at the implicit join.

Separating the start from the read is what lets run async overlap work. You start the work and keep going, and you only pay the wait cost at the step that depends on the result.

Passthrough versus reads that force resolution

The runtime scans for ${name} substrings in the places where a handle’s contents would matter, and it resolves any binding that still holds a token. There are two cases:

There is no await keyword, and there is no way to copy a handle without reading it. To keep work overlapping, read the handle late. Hold it in the original binding, and avoid ${…}, bare-identifier arguments to run or ensure, and if or match subjects until you need the value. When a resolving read reaches a handle whose underlying run failed with a non-zero exit, the read itself fails. The error then propagates exactly like a failed synchronous run, and the reading step does not continue with an empty value. As a side effect of the failed resolve, the runtime empties the handle’s binding in that scope.

for_lines is the one exception. It reads the loop source as a plain variable value and does not pass it through handle resolution. If the source is still a handle token, the loop reads the token itself and iterates over the wrong text. Resolve the value first with const text = "${h}", then iterate over text.

Implicit join

When a step list runs to its normal end, meaning every step ran without an early return, fail, or error exit, the runtime awaits every run async handle created in that list, whether or not it was captured. The runtime calls the wait the implicit join, and the unit of joining is one executeSteps call, not the whole workflow. A handle created inside an if body is joined at the end of that inner block, before control continues after the if. An early return or fail leaves the list right away and does not run the join for handles already scheduled there.

The rule that uncaptured handles still join is part of the value model. On the normal-exit path there is no opt-out, so starting async work without storing the handle does not skip the wait. The runtime keeps a list of every handle created in the current step list, and on normal exit it awaits each one in creation order, one at a time.

The guarantee is simple. When a step list reaches its normal end, every piece of async work it scheduled has finished. The rest of the workflow, including return values, channel drains, and parent step lists, can then treat the work as complete without tracking background tasks. For an entry workflow frame the order is fixed. First the step list runs, then the implicit join runs, and then that frame’s channel queue drains (see Inbox). If several joined branches end with a catch return, the first such branch in creation order supplies the parent workflow return value. Only catch return values propagate this way. A value returned from a recover body settles that branch, but the runtime does not adopt it as the parent return.

If any joined handle ended with a non-zero status, the join itself fails. When several handles fail, the runtime combines them into a single error.

Recover and catch on async handles

Async handles work with the same two error-handling forms that a synchronous run uses:

Both forms work only with the statement form of run async. A captured const h = run async foo() cannot carry a recover or catch block, because the parser allows only a plain call there. If you need both a captured handle and a recover loop around its target, wrap the target in a separate workflow and call that.

Why there is no await keyword

await is not part of the language because the implicit join already marks the synchronization point on the normal-exit path, which is the end of the step list. Adding await would create a second way to express the same boundary. It would also add a third state, “started but neither read nor joined yet”, that you would have to reason about. The model keeps only two states, a token in the variable map or a resolved string, which keeps the number of failure modes small.

The trade-off is that overlapping a long-running async task with later steps takes care. Read the handle late rather than early, because an early read makes the workflow wait at that point and removes the overlap.

Where async handles are allowed

run async is a workflow-only construct on purpose:

The parser or the validator enforces all three restrictions at compile time, not at runtime.

Async indices and the progress tree

The runtime tags each concurrent branch with a chain of 1-based indices. It stores the chain as async_indices on the STEP_START, STEP_END, LOG, LOGWARN, and LOGERR events. The CLI shows the chain as a subscript prefix on the live event stream, so interleaved branches stay readable in the progress tree. The runtime builds the chain with AsyncLocalStorage, so nested async work, such as a run async inside another run async, gets a deeper chain instead of colliding with its parent.

Resolving a handle does not emit a separate event. The branch’s own step and log events are the timeline, and the resolve is only the point where one consumer stopped passing the token along.

The design in short

Async handles in Jaiph are a token bookkeeping model built on top of a normal run. They start eagerly, resolve lazily, and must be joined when a step list reaches its normal end. Once resolved, they behave like any other synchronous value. There is no scheduler, no thread pool, no await, and no detached background task. The model is a small contract that lets steps overlap until a step needs the answer.