Skip to content
docsv0.1.0

ProcessRunner

Drives a process instance forward automatically until it either reaches a terminal state or a state that needs an external trigger: a human decision (a gate) or a whole child process (a {@see SubprocessSpec}). {@see Reducer} only APPLIES events already in the log — something has to decide WHICH automated transition event to append next, whether a gated state's gate is already open before opening it again, and whether a subprocess state's child has already been started before starting another one; `ProcessRunner` is that driver. Every step re-reads `$instance->currentState($store)` fresh (never cached) — "state is a * projection", the same invariant every other class in this engine holds. **The terminal seam.** When a run lands on a terminal state, this class does NOT run any domain side effect itself (it is process-definition-agnostic — it has no idea what "reaching * `published`" should DO to a domain entity). Instead it appends a `ProcessTerminalReached` audit event (idempotency marker — see {@see self::fireTerminalOnce()}) and, the first time only, dispatches a `process.terminal` event via the injected {@see MilpaEventDispatcherInterface} with payload `{instance_id, final_state, context}`. A consumer subscribes to `process.terminal` to run whatever domain effect reaching that terminal state should trigger (e.g. publishing a post) — see the class docblock's own "terminal seam" note for why this lives here rather than inside a tool. The SAME `ProcessTerminalReached` marker also guards subprocess ROUTING (below) from firing twice — reaching terminal happens exactly once per instance, ever, and everything that happens as a consequence of it is tied to that single occurrence. **The subprocess seam — entering.** When `advance()` finds the current state is a subprocess state (`$definition->subprocessFor($state) !== null`), it resolves the child {@see ProcessDefinition} by name through the injected {@see ProcessDefinitionRegistry}, builds the child's starting inputs by projecting `SubprocessSpec::$inputsMap` out of the PARENT's own context, and {@see ProcessInstance::start()}s the child on a brand-new stream — stamping `parent_instance_id`, `parent_state`, and a fresh `correlation_id` into the child's `ProcessStarted` payload (mirroring how {@see \Milpa\Orchestrator\Tools\ProcessInstantiateTool} stamps `_requester`/`_definition`; this method stamps THOSE two on the child as well, so the child is fully discoverable through the same tool-layer conventions — including by `process_list_pending_approvals`, at any nesting depth). It then recursively advances the CHILD to ITS OWN gate or terminal state. The PARENT itself always stops the moment it enters a subprocess state — see {@see self::enterSubprocessOnce()}'s idempotency guard for why calling `advance()` again on an already-waiting parent never starts a second child. **The subprocess seam — routing back.** When a CHILD instance (one carrying `parent_instance_id` in its context) reaches ITS OWN terminal state, {@see self::fireTerminalOnce()} — after firing `process.terminal` for the child's own domain effects — additionally appends an event to the PARENT's stream and advances the parent again (see {@see self::routeSubprocessDoneToParent()}). That appended event's `type` is the child's OUTCOME (its terminal state's code) — NOT the literal string `subprocess_done` — so it satisfies {@see Reducer}'s unchanged `event.type === transition.name` matching rule with zero changes to `Reducer` or {@see DefinitionContract}: **a subprocess state's outgoing transitions in the PARENT definition must be named after the child's possible outcome(s), exactly like a gate's outgoing transitions are named after its decision options.** The payload nonetheless carries `subprocess_done: true` (plus `outcome`, `child_instance_id`, and the declared `outputs`) so the routing event is unambiguously identifiable by payload for audit/testing purposes even though its wire `type` varies per outcome. Because this routing recursively calls `advance()` on the parent, and a parent reaching ITS OWN terminal state triggers the exact same routing again if IT is itself a subprocess child, the chain recurses all the way up to the root — bounded by {@see self::MAX_SUBPROCESS_DEPTH}.

ProcessRunner::__construct()

public function __construct(Milpa\Interfaces\Event\MilpaEventDispatcherInterface $dispatcher, ?Milpa\Orchestrator\ProcessDefinitionRegistry $registry = null):

Parameters

Parameters of __construct()
NameTypeDescription
$dispatcherMilpa\Interfaces\Event\MilpaEventDispatcherInterface
$registry?Milpa\Orchestrator\ProcessDefinitionRegistry

ProcessRunner::advance()

public function advance(Milpa\EventStore\EventStoreInterface $store, Milpa\Orchestrator\ProcessInstance $instance, Milpa\Orchestrator\HumanGate $gate, string $requester, int $depth = 0): void

Advances `$instance` for as long as its current state is automated (has no gate and is not a subprocess state): appends that state's single outgoing transition as the advancing {@see Event} (payload `{}`) and loops. Stops the moment the current state is one of: - terminal ({@see ProcessDefinition::isTerminal()}) — fires the terminal seam (see the class docblock) and returns; - a subprocess state ({@see ProcessDefinition::subprocessFor()} non-null) — starts the child process UNLESS it was already started for this visit to the state (see {@see self::enterSubprocessOnce()}) and returns; the parent never auto-advances past this state on its own — only a routed `subprocess_done` (see the class docblock) moves it on; - gated ({@see ProcessDefinition::gateFor()} non-null) — a human decision is needed; opens the gate via {@see HumanGate::openFor()} UNLESS {@see HumanGate::pendingFor()} already finds an unresolved `GateOpened` for it, so calling `advance()` again on an already-awaiting instance never appends a redundant `GateOpened` event.

Parameters

Parameters of advance()
NameTypeDescription
$storeMilpa\EventStore\EventStoreInterface
$instanceMilpa\Orchestrator\ProcessInstance
$gateMilpa\Orchestrator\HumanGate
$requesterstringthe principal {@see HumanGate::openFor()} should record as the requester of any gate this call opens, and the `_requester` this call stamps on any subprocess child it starts
$depthintcurrent subprocess nesting depth; callers should omit this (defaults to `0`) — it is incremented on every recursive call this class makes into itself, whether descending into a fresh child or routing back up to a parent, and checked against {@see self::MAX_SUBPROCESS_DEPTH} at the top of every call

Throws

\RuntimeException when `$depth` exceeds {@see self::MAX_SUBPROCESS_DEPTH}, when a subprocess state is entered without a {@see ProcessDefinitionRegistry} configured, or when a terminal instance's `parent_instance_id` cannot be resolved back to a registered definition