The run lifecycle guard, as ONE implementation with two consumers.
A PipelineRun must reach a terminal status on every exit path — success, a
named failure, a raise, an exit and a throw. use ALLM.Pipeline
generates that guarantee for a declared pipeline; this module is where the
guarantee actually lives, so a hand-written entry point can have it by
calling owned_run/4 instead of growing a third hand-rolled copy of
try/rescue/catch + complete/2 + fail_pipeline_run/2.
That was not a hypothetical. Before Phase 4 the tree carried four orchestrator
entry points that terminated their run on no path, and two more with no
rescue at all; the DSL closed the declared ones and left the hand-written
*_single helpers — which resolve their own working set and therefore have no
declaration to express — one copy short.
The two consumers
| Consumer | Uses |
|---|---|
ALLM.Pipeline.Dsl.Runtime.execute/4 | guard/2 and settle/4 separately, because resource teardown (Phase 4 D3) runs between them |
| A hand-written entry point | owned_run/4, which composes create → guard/2 → settle/4 |
Splitting guard/2 from settle/4 is what lets D3's ordering — stages →
outcome computed → teardown → terminal write — be expressed as three
statements in Runtime rather than as a flag threaded through one function.
Versus ALLM.Pipeline.Executor.finish_run/2
There are two complete-or-fail tails in this framework and the boundary is which one owns creation and the guard:
Executor.finish_run/2 | Lifecycle.owned_run/4 | |
|---|---|---|
| Creates the run | no — you hand it one you already own | yes |
| Metadata argument | no | yes, from the body's return |
| Guards raise / exit / throw | no | yes |
| Teardown-error merging | no | via settle/4 |
| Returns | your result, unchanged | {:ok, value} / {:error, reason} |
So a hand-written entry point that creates its own run wants
owned_run/4: finish_run/2 on its own leaves every raise, exit and throw
between the create and the tail unterminated, which is the defect that put
four such entry points in the tree. finish_run/2 remains right for a caller
that already holds an owning handle, has no metadata to write, and is already
inside somebody else's guard.
Ownership
owned_run/4 mints its handle through ALLM.Pipeline.Executor.create_pipeline_run/3
and hands the body the borrowed projection, exactly as the generated
run/1 does. The owning handle never leaves this module, so a body cannot
complete its own parent run, and this module introduces no third mint point
(PipelineRun.create/3 and PipelineRun.assume_ownership/1 remain the only
two).
Summary
Types
What settle/4 writes. {:ok, value, metadata} completes the run with
metadata and returns value; {:error, reason} and {:raised, …} fail it.
Functions
Run fun, converting a raise, an exit or a throw into {:raised, …}.
Run fun under a fresh, fully-guarded PipelineRun.
Write the run's terminal status and return the caller's value.
Types
@type guarded(value) :: {:ok, value} | {:raised, :error | :exit | :throw, term(), Exception.stacktrace()}
What guard/2 produced. {:raised, …} carries the kind so settle/4 can
re-raise it unchanged — reraise alone cannot, because an exit is not an
exception.
@type settlement() :: {:ok, term(), map()} | {:error, term()} | {:raised, :error | :exit | :throw, term(), Exception.stacktrace()}
What settle/4 writes. {:ok, value, metadata} completes the run with
metadata and returns value; {:error, reason} and {:raised, …} fail it.
Functions
Run fun, converting a raise, an exit or a throw into {:raised, …}.
rescue alone is insufficient and that is the whole point: two orchestrators
in this tree had only a rescue, so an exit or a throw stranded their run at
status = :running. label names the unit in the log line.
The failure is logged here and re-raised by settle/4, never swallowed.
@spec owned_run( String.t(), map(), keyword(), (ALLM.Pipeline.PipelineRun.t() -> {:ok, term(), map()} | {:ok, term()} | {:error, term()}) ) :: {:ok, term()} | {:error, term()}
Run fun under a fresh, fully-guarded PipelineRun.
The framework affordance for a hand-written entry point — one that
resolves its own working set and therefore has no use ALLM.Pipeline
declaration to express. It gives that entry point the same guarantee the
generated run/1 has: the run reaches a terminal status on every exit path,
including an exit and a throw.
def run_single(record, opts) do
Lifecycle.owned_run("meeting_single", %{id: record.id}, [], fn run ->
case process(run, record, opts) do
{:ok, stats} -> {:ok, stats, serialize(stats)}
{:error, reason} -> {:error, reason}
end
end)
endfun receives the borrowed run and returns {:ok, value, metadata}
(complete the run with metadata, return {:ok, value}), {:ok, value}
(complete with %{}), or {:error, reason} (fail the run, return
{:error, reason}). attrs reaches Executor.create_pipeline_run/3 — that
is where :parent_run_id and :trigger go.
@spec settle(String.t(), ALLM.Pipeline.PipelineRun.t(), settlement(), [ ALLM.Pipeline.Dsl.Resource.teardown_error() ]) :: {:ok, term(), ALLM.Pipeline.PipelineRun.t()} | {:error, term()}
Write the run's terminal status and return the caller's value.
owning must be an owning handle. teardown_errors are
ALLM.Pipeline.Dsl.Resource.release/2's output; when non-empty they are
merged into the run's metadata under "resource_teardown_errors" on either
terminal path, and they never change the status — a leaked handle is an
operational fault recorded beside the work's outcome, not a failure of it
(Phase 4 D3).
Returns {:ok, value, completed_run} on success and {:error, reason} on a
named failure. A {:raised, …} settlement fails the run and then re-raises
the original kind/reason/stacktrace, so a caller's own error handling is
unaffected — this adds the terminal write those paths were missing, and
nothing else.
If the terminal write itself does not land — a refused non-owning handle, or a
failed update — this returns {:error, {:not_completed, reason}} rather than
the work's value. Reporting {:ok, …} there would be a fail-open: the row is
left at :running with no completed_at, so a success report is false, and
a false success on the one function whose entire job is the terminal write is
the shape hardest to notice. Not reachable from either in-tree consumer, both
of which hold an owning handle by construction.