Loom

Type to search across the guides, specification and decision records.

↑↓ navigate ↵ open esc close Pagefind

Decision records

ADR 0135 — waitForSuspend() rejects when the run terminates before it parks

  • Status: Accepted (2026-10-31) — owner decision on issue #710.
  • Date: 2026-10-31
  • Placement: @loomwork/workflow-runtime public handle contract only. Kernel unchanged: KERNEL_ABI and EVENT_SCHEMA unchanged (no event, fold or syscall changes; the handle is not part of the kernel manifest).

Context

ExecutionHandle.waitForSuspend() resolved only when the run parked. A run that ended without parking (it completed, failed, was cancelled, or continued as new) left it pending forever, so a caller that awaited it alone hung. Issue #710 reported this as “the second resume of a compacted execution never resolves”: the second resume answered the LAST gate, so the run completed. Compaction was not involved. Only the continueAsNew doc mentioned the behaviour (“never resolves waitForSuspend()”).

Decision

When the run terminates before parking, waitForSuspend() REJECTS with ExecutionTerminatedError (exported from @loomwork/workflow-runtime):

  • state: the folded state at the terminal (completed, failed, cancelled, continued); for a completion, the very state waitForCompletion() resolves with.
  • status: state.status.
  • cause: the error waitForCompletion() rejects with, for a failure or a cancellation.

Ordering: the waitForSuspend() call returns a DERIVED promise that settles at least one microtask after the internal suspend promise, and the internal rejection happens after waitForCompletion() has settled. So Promise.race([waitForCompletion(), waitForSuspend()]) (either array order) resolves with the completion, or rejects with the original failure, whether the caller attached before the run ended or after it had settled (returning the already-rejected promise itself would have let array order decide). The in-repo racers (runAgentOnce, the worker runtime, the timer service, orchestration, the CLI lifecycle) need no change; LocalWorkerRuntime.execute lists the completion first as defence in depth. A storage, lease or seal failure is not a termination: both promises still reject with that error. The internal no-op .catch on the suspend promise stays, so a caller that never calls waitForSuspend() gets no unhandled rejection.

Consequences

  • Behaviour change: code that awaited waitForSuspend() alone on a run that may finish used to hang and now throws. That is a visible failure in place of a silent one; such code should race the two promises or catch ExecutionTerminatedError.
  • Rejected alternative: resolve waitForSuspend() with the terminal state. It would make “suspended” and “finished” indistinguishable by promise alone, and a caller that then resumed would resume a terminal run.
  • Rejected alternative: leave it pending and document it. The hang is undetectable from the caller’s side.
  • A caller that attaches only .then to waitForSuspend() (no .catch) now gets an unhandled rejection when the run ends without parking. That is inherent to rejecting: attach a rejection handler. The internal guard covers only callers that never call the method. ExecutionTerminatedError.state is the plaintext folded state, so it is non-enumerable and stays out of JSON.stringify and error serialisers.
  • isTerminalToolError classifies ExecutionTerminatedError by its status: completed and continued are terminal; for failed and cancelled the cause is classified (a flow that threw undefined has none, and is feedback), so a tool awaiting a child’s waitForSuspend() gets the same verdict as one awaiting its waitForCompletion().
  • The derived promise also moves the PARK path by a microtask: when a waitForSuspend() caller resumes, the run’s handle cleanup (liveHandles, startRecords, the lease release) has already happened. Before, the caller usually ran first, by microtask accident. Nothing in the repo relied on that, and the new order is the safer one (a cancel right after a park takes the durable path, not the stale live handle), but a test must not assume the parked run is still live after await waitForSuspend().

Source: docs/adr/0135-wait-for-suspend-rejects-on-terminal-run.md in the loom repository.