This document describes the Python reference implementation of the FFL runtime. For the formal specification, see runtime.md.
Implements compiler.md and runtime.md §2.
FFL source files are compiled by the FFL compiler (afl/cli.py):
FFL Source → Lark Parser → AST → JSON Emitter → MongoDB / JSON file
The compiled output contains:
- WorkflowDecl - Named entrypoints with starting steps
- FacetDecl / EventFacetDecl - Component templates with typed attributes
- StepStmt - Individual operations (statements)
- AndThenBlock - Control flow constructs with sequential execution
Implements runtime.md §9–10.
The Evaluator (afl/runtime/evaluator.py) orchestrates execution:
Evaluator.run()
└── iterate() until fixed point
└── Process each eligible step via StateChanger
└── Dispatch to StateHandler per state
Implements runtime.md §6.
Each step follows a state machine defined in afl/runtime/states.py:
Created
↓
FacetInitializationBegin → FacetInitializationEnd
↓
FacetScriptsBegin → FacetScriptsEnd
↓
MixinBlocksBegin → MixinBlocksContinue → MixinBlocksEnd
↓
MixinCaptureBegin → MixinCaptureEnd
↓
EventTransmit
↓
StatementBlocksBegin → StatementBlocksContinue → StatementBlocksEnd
↓
StatementCaptureBegin → StatementCaptureEnd
↓
StatementEnd → StatementComplete
Error State: StatementError (terminal state for failures)
- Facet Initialization:
state.facet.initialization.Begin/End - Facet Scripts:
state.facet.scripts.Begin/End - Statement Scripts:
state.statement.scripts.Begin/End - Mixin Blocks:
state.mixin.blocks.Begin/Continue/End - Mixin Capture:
state.mixin.capture.Begin/End - Statement Blocks:
state.statement.blocks.Begin/Continue/End - Block Execution:
state.block.execution.Begin/Continue/End - Statement Capture:
state.statement.capture.Begin/End - Completion:
state.statement.End/Complete
Implements runtime.md §6, §8, §9.
Location: afl/runtime/changers/base.py
The StateChanger drives the state machine in a loop:
class StateChanger(ABC):
"""Abstract base for state machine orchestrators."""
def __init__(self, step: StepDefinition, context: ExecutionContext):
self.step = step
self.context = context
def process(self) -> StateChangeResult:
if self.step.is_complete:
return StateChangeResult(step=self.step, continue_processing=False)
while True:
if self.step.is_requesting_state_change:
next_state = self.select_state()
if next_state and next_state != self.step.current_state:
self.step.change_state(next_state)
result = self.execute_state(self.step.current_state)
self.step = result.step
if self.step.is_terminal:
return StateChangeResult(step=self.step, continue_processing=False)
if not self.step.is_requesting_state_change:
break
return StateChangeResult(
step=self.step,
continue_processing=self.step.transition.is_requesting_push,
)
@abstractmethod
def select_state(self) -> Optional[str]: ...
@abstractmethod
def execute_state(self, state: str) -> StateChangeResult: ...Three StateChanger implementations handle different step types:
| Type | Handles | State Machine |
|---|---|---|
StepStateChanger |
VariableAssignment |
Full state machine (all states) |
BlockStateChanger |
AndThen, AndMap, AndMatch |
Simplified: BlockExecutionBegin → Continue → End |
YieldStateChanger |
YieldAssignment |
Skips to end after facet initialization |
| Schema steps | SchemaInstantiation |
Minimal: Created → FacetInit → End → Complete (uses SCHEMA_TRANSITIONS) |
Factory function (in afl/runtime/evaluator.py):
def create_state_changer(
step: StepDefinition, context: ExecutionContext
) -> StateChanger:
if step.object_type == ObjectType.VARIABLE_ASSIGNMENT:
return StepStateChanger(step, context)
elif step.object_type == ObjectType.YIELD_ASSIGNMENT:
return YieldStateChanger(step, context)
elif ObjectType.is_block(step.object_type):
return BlockStateChanger(step, context)Location: afl/runtime/states.py
class StepStateChanger(StateChanger):
"""State changer for VariableAssignment steps.
Implements the full state machine with all phases:
facet initialization, facet scripts, mixin blocks, mixin capture,
event transmit, statement blocks, statement capture, completion.
"""
def select_state(self) -> Optional[str]:
"""Select next state using full transition table."""
current = self.step.current_state
next_state = STEP_TRANSITIONS.get(current)
if next_state is None or next_state == current:
return None
return next_state
def execute_state(self, state: str) -> StateChangeResult:
"""Dispatch to the appropriate handler for the current state."""
handler = get_handler(state, self.step, self.context)
if handler is None:
self.step.request_state_change(True)
return StateChangeResult(step=self.step)
return handler.process()STEP_TRANSITIONS: dict[str, str] = {
StepState.CREATED: StepState.FACET_INIT_BEGIN,
StepState.FACET_INIT_BEGIN: StepState.FACET_INIT_END,
StepState.FACET_INIT_END: StepState.FACET_SCRIPTS_BEGIN,
StepState.FACET_SCRIPTS_BEGIN: StepState.FACET_SCRIPTS_END,
StepState.FACET_SCRIPTS_END: StepState.MIXIN_BLOCKS_BEGIN,
StepState.MIXIN_BLOCKS_BEGIN: StepState.MIXIN_BLOCKS_CONTINUE,
StepState.MIXIN_BLOCKS_CONTINUE: StepState.MIXIN_BLOCKS_END,
StepState.MIXIN_BLOCKS_END: StepState.MIXIN_CAPTURE_BEGIN,
StepState.MIXIN_CAPTURE_BEGIN: StepState.MIXIN_CAPTURE_END,
StepState.MIXIN_CAPTURE_END: StepState.EVENT_TRANSMIT,
StepState.EVENT_TRANSMIT: StepState.STATEMENT_BLOCKS_BEGIN,
StepState.STATEMENT_BLOCKS_BEGIN: StepState.STATEMENT_BLOCKS_CONTINUE,
StepState.STATEMENT_BLOCKS_CONTINUE: StepState.STATEMENT_BLOCKS_END,
StepState.STATEMENT_BLOCKS_END: StepState.STATEMENT_CAPTURE_BEGIN,
StepState.CATCH_BEGIN: StepState.CATCH_CONTINUE,
StepState.CATCH_CONTINUE: StepState.CATCH_END,
StepState.CATCH_END: StepState.STATEMENT_CAPTURE_BEGIN,
StepState.STATEMENT_CAPTURE_BEGIN: StepState.STATEMENT_CAPTURE_END,
StepState.STATEMENT_CAPTURE_END: StepState.STATEMENT_END,
StepState.STATEMENT_END: StepState.STATEMENT_COMPLETE,
}class YieldStateChanger(StateChanger):
"""State changer for YieldAssignment steps.
Implements minimal state machine — skips blocks, goes directly
from facet scripts to statement end.
"""
def select_state(self) -> Optional[str]:
current = self.step.current_state
next_state = YIELD_TRANSITIONS.get(current)
if next_state is None or next_state == current:
return None
return next_stateYIELD_TRANSITIONS: dict[str, str] = {
StepState.CREATED: StepState.FACET_INIT_BEGIN,
StepState.FACET_INIT_BEGIN: StepState.FACET_INIT_END,
StepState.FACET_INIT_END: StepState.FACET_SCRIPTS_BEGIN,
StepState.FACET_SCRIPTS_BEGIN: StepState.FACET_SCRIPTS_END,
StepState.FACET_SCRIPTS_END: StepState.STATEMENT_END, # Skip blocks
StepState.STATEMENT_END: StepState.STATEMENT_COMPLETE,
}Schema instantiation steps use a simplified state machine that evaluates arguments and stores them as returns (not params). See runtime.md §8.5 for the normative semantics.
SCHEMA_TRANSITIONS: dict[str, str] = {
StepState.CREATED: StepState.FACET_INIT_BEGIN,
StepState.FACET_INIT_BEGIN: StepState.FACET_INIT_END,
StepState.FACET_INIT_END: StepState.STATEMENT_END,
StepState.STATEMENT_END: StepState.STATEMENT_COMPLETE,
}class BlockStateChanger(StateChanger):
"""State changer for block steps (AndThen, AndMap, etc.).
Simplified state machine: Created → BlockExecution → End → Complete.
"""
def select_state(self) -> Optional[str]:
current = self.step.current_state
next_state = BLOCK_TRANSITIONS.get(current)
if next_state is None or next_state == current:
return None
return next_stateBLOCK_TRANSITIONS: dict[str, str] = {
StepState.CREATED: StepState.BLOCK_EXECUTION_BEGIN,
StepState.BLOCK_EXECUTION_BEGIN: StepState.BLOCK_EXECUTION_CONTINUE,
StepState.BLOCK_EXECUTION_CONTINUE: StepState.BLOCK_EXECUTION_END,
StepState.BLOCK_EXECUTION_END: StepState.STATEMENT_END,
StepState.STATEMENT_END: StepState.STATEMENT_COMPLETE,
}Implements runtime.md §6 state guarantees.
Location: afl/runtime/step.py
The StepTransition dataclass manages state transitions with control flags:
@dataclass
class StepTransition:
"""Manages state transition control for a step."""
original_state: str
current_state: str
changed: bool = False
request_transition: bool = False
push_me: bool = False
error: Optional[Exception] = None
def request_state_change(self, request: bool = True) -> None:
self.request_transition = request
if request:
self.changed = True
def change_and_transition(self) -> None:
self.changed = True
self.request_transition = True
def set_push_me(self, push: bool) -> None:
self.push_me = push| Method | Effect |
|---|---|
request_state_change() |
Trigger select_state() to advance to next state |
set_push_me(True) |
Re-queue step for continued processing (polling loop) |
change_and_transition() |
Mark changed + request transition |
request_transition: WhenTrue, StateChanger invokesselect_state()to determine next statepush_me: WhenTrue, step is re-queued for continued processing (loops in same state)changed: Marks step as modified for persistenceerror: Contains error if step fails
Implements runtime.md §6 state execution.
Location: afl/runtime/handlers/base.py
class StateHandler(ABC):
"""Abstract base for state handlers."""
def __init__(self, step: StepDefinition, context: ExecutionContext):
self.step = step
self.context = context
def process(self) -> StateChangeResult:
self.context.telemetry.log_state_begin(self.step, self.state_name)
try:
result = self.process_state()
self.context.telemetry.log_state_end(self.step, self.state_name)
return result
except Exception as e:
self.context.telemetry.log_error(self.step, self.state_name, e)
return StateChangeResult(step=self.step, success=False, error=e)
@abstractmethod
def process_state(self) -> StateChangeResult: ...
def transition(self) -> StateChangeResult:
"""Request transition to next state."""
self.step.request_state_change(True)
return StateChangeResult(step=self.step)
def stay(self, push: bool = False) -> StateChangeResult:
"""Stay in current state, optionally re-queuing."""
self.step.request_state_change(False)
self.step.transition.set_push_me(push)
return StateChangeResult(step=self.step, continue_processing=push)Implements runtime.md §8, §11.
Blocks (AndThen, AndMap, AndMatch) follow a simplified state machine:
Created → BlockExecutionBegin → BlockExecutionContinue (loop) → BlockExecutionEnd → StatementEnd → StatementComplete
Location: afl/runtime/handlers/block_execution.py
class BlockExecutionBeginHandler(StateHandler):
"""Initialize block execution: build dependency graph, create ready steps."""
def process_state(self) -> StateChangeResult:
block_ast = self.context.get_block_ast(self.step)
if block_ast is None:
self.step.request_state_change(True)
return StateChangeResult(step=self.step)
graph = DependencyGraph.from_ast(
block_ast, self._get_workflow_inputs(),
program_ast=self.context.program_ast,
)
self.context.set_block_graph(self.step.id, graph)
self._create_ready_steps(graph, completed=set())
self.step.request_state_change(True)
return StateChangeResult(step=self.step)Location: afl/runtime/handlers/block_execution.py
Polls until all child steps complete:
class BlockExecutionContinueHandler(StateHandler):
"""Poll block progress, create newly eligible steps."""
def process_state(self) -> StateChangeResult:
graph = self.context.get_block_graph(self.step.id)
steps = list(self.context.persistence.get_steps_by_block(self.step.id))
analysis = StepAnalysis.load(
block=self.step,
statements=graph.get_all_statements(),
steps=steps,
)
if analysis.done:
self.step.request_state_change(True)
return StateChangeResult(step=self.step)
completed_ids = {
str(s.statement_id) for s in analysis.completed if s.statement_id
}
self._create_ready_steps(graph, completed_ids)
return self.stay(push=True) # Re-queue for next iterationLocation: afl/runtime/handlers/block_execution.py
def _create_ready_steps(
self,
graph: DependencyGraph,
completed: set[str],
) -> None:
ready = graph.get_ready_statements(completed)
for stmt in ready:
if self.context.persistence.step_exists(stmt.id, self.step.id):
continue
step = StepDefinition.create(
workflow_id=self.step.workflow_id,
object_type=stmt.object_type,
facet_name=stmt.facet_name,
statement_id=stmt.id,
block_id=self.step.id,
container_id=self.step.container_id,
root_id=self.step.root_id or self.step.container_id,
)
self.context.changes.add_created_step(step)Implements runtime.md §7, §11.
Location: afl/runtime/block.py
The StepAnalysis dataclass tracks block execution state:
@dataclass
class StepAnalysis:
"""Analysis of step execution state within a block."""
block: StepDefinition
statements: Sequence[StatementDefinition]
missing: list[StatementDefinition] = field(default_factory=list)
steps: list[StepDefinition] = field(default_factory=list)
completed: list[StepDefinition] = field(default_factory=list)
requesting_push: list[StepDefinition] = field(default_factory=list)
requesting_transition: list[StepDefinition] = field(default_factory=list)
pending_event: list[StepDefinition] = field(default_factory=list)
pending_mixin: list[StepDefinition] = field(default_factory=list)
pending_blocks: list[StepDefinition] = field(default_factory=list)
done: bool = Falsecan_be_created() determines which statements can have steps created:
def can_be_created(self) -> Sequence[StatementDefinition]:
"""Return statements whose dependencies are all satisfied."""
completed_ids = {
str(s.statement_id) for s in self.completed if s.statement_id
}
ready = []
for stmt in self.missing:
if stmt.dependencies.issubset(completed_ids):
ready.append(stmt)
return readyA step is created only when all its dependencies point to completed steps.
Implements runtime.md §8.2.
Both follow the same Begin → Continue → End pattern:
Implements the aliased mixin sub-step lifecycle described in runtime.md §8.x. Each aliased mixin on a facet sig becomes a real persisted sub-step that runs the mixin facet's body in parallel with sibling mixin sub-steps, before the parent body runs.
- MixinBlocksBegin — For each aliased mixin in the parent facet's
sig, creates a
VARIABLE_ASSIGNMENTsub-step inCREATEDstate. The sub-step'sstatement_nameis the alias, itsfacet_nameis the mixin target, itscontainer_idis the parent step, and itsattributes.paramsis seeded fromparent.params[alias](the nested dictFacetInitializationBeginHandleralready produced from the mixin's evaluated sig-args). Un-aliased mixins are skipped — their sig-args remain a flat-merge on parent.params (the v0.21.0 contract). - MixinBlocksContinue — Waits on the parent's aliased sub-steps
(children with
statement_namein the parent facet's mixin aliases). When any sub-step is inSTATEMENT_ERROR, the parent enters error with the first sub-step's error message. When all are terminal, the parent advances. - MixinBlocksEnd — Pass-through to
MixinCaptureBegin.
The sub-step itself walks the full STEP_TRANSITIONS table.
FacetInitializationBeginHandler detects the pre-bound mixin
sub-step (stmt_def is None and container_id is not None and statement_name != "") and routes to _init_mixin_sub_step, which
skips call-arg evaluation and applies facet defaults only for
attributes the parent didn't bind. The mixin sub-step's body
executes through the normal block handlers, with one twist:
_resolve_inputs recognises a mixin sub-step ancestor (_is_mixin_sub_step)
and returns ONLY the mixin's own attributes — no workflow root
inheritance, no parent reach-out. This enforces scope isolation at
runtime; a $.x referencing an out-of-scope name raises
ReferenceError.
- MixinCaptureBegin — For each aliased mixin sub-step, builds a
merged
{params, returns}dict (returns shadow params on key collision) and writes it toparent.attributes.params[alias], overwriting theFACET_INIT_BEGIN-era sig-arg dict. This is what the parent's andThen body reads as$.alias.field. - MixinCaptureEnd — Pass-through.
FacetRef consumers (downstream steps holding a StepReference to
the parent) bypass this snapshot. The resolver
(expression._resolve_path) tries the live persisted mixin sub-step
first via get_mixin_step_by_alias, falling through to attrs.returns
and attrs.params only when no alias matches. This ensures that
parent-yield overrides applied to a mixin sub-step at parent
STATEMENT_CAPTURE_BEGIN (see §11 below) are visible to FacetRef
consumers.
Execute statement-level blocks (from andThen bodies):
- StatementBlocksBegin - Creates block steps for each
AndThenBlock - StatementBlocksContinue - Polls with
BlockAnalysis.load(step, blocks, mixins=False) - StatementBlocksEnd - Advances to capture phase
Implements runtime.md §8.4.
When a step errors and has a catch clause, execution enters the catch phase instead of transitioning to STATEMENT_ERROR. This allows error recovery.
Location: afl/runtime/handlers/catch_execution.py
Error path without catch:
... → error → STATEMENT_ERROR (terminal)
Error path with catch:
... → error → CATCH_BEGIN → CATCH_CONTINUE → CATCH_END → STATEMENT_CAPTURE_BEGIN
↓ (catch fails)
STATEMENT_ERROR
Two places check for catch before calling mark_error():
StatementBlocksContinueHandler— when child blocks errorStateChanger.process()— when event handler errors
- Stores error info as pseudo-returns:
step.set_attribute("error", ...)andstep.set_attribute("error_type", ...) - Simple catch: creates a single sub-block (
object_type=AND_CATCH,statement_id="catch-block-0") - Catch when: evaluates conditions, creates sub-blocks per matching case (
statement_id="catch-case-{i}")
- Polls catch sub-blocks (same pattern as
BlockExecutionContinueHandler) - All complete → transition to
CATCH_END - Any errored →
mark_error()(catch itself failed, propagate) - Not done →
stay(push=True)
- Pass-through: transitions to
STATEMENT_CAPTURE_BEGINto resume normal flow
Catch sub-blocks use ObjectType.AND_CATCH = "AndCatch" (included in is_block()).
Implements runtime.md §11.1.
Location: facetwork/runtime/handlers/capture.py
Routes each completed yield in the parent's andThen blocks to its destination:
class StatementCaptureBeginHandler(StateHandler):
"""Route yields to parent returns OR an aliased mixin sub-step."""
def process_state(self) -> StateChangeResult:
# 1. Cache parent facet's alias map (alias → target facet,
# plus inverse for unique-target back-compat).
# 2. For each yield collected under the parent's blocks,
# call _route_yield to pick a destination.
...
def _route_yield(self, yield_step):
target = (yield_step.facet_name or "").split(".")[-1]
if _names_match(yield_step.facet_name, self.step.facet_name):
# Parent destination — merge into parent.attributes.returns.
self._merge_yield_into_parent(yield_step)
elif target in self._mixin_aliases:
# Alias-by-name form: `yield aliasName(...)`.
self._merge_yield_into_mixin_substep(yield_step, alias=target)
else:
unique = [a for a in self._target_to_aliases.get(target, []) if a]
if len(unique) == 1:
# Bare-target back-compat: `yield F(...) with M(...)`
# when M is uniquely aliased on F.
self._merge_yield_into_mixin_substep(yield_step, alias=unique[0])
# else: not for this scope — dropped.Routing rules (mirror runtime.md §11.x):
- Yield target matches parent facet name → merge into
parent.attributes.returns. - Yield target matches a declared alias on the parent's facet
→ merge into the mixin sub-step's
attributes.returns. - Yield target matches a mixin facet name with exactly one
alias on the parent → that single sub-step. The validator's
YIELD_TARGET_AMBIGUOUSrule rejects the multi-alias case at compile time, so the runtime never has to disambiguate. - Otherwise → silently dropped (an inner-facet-body yield will be captured by its own scope).
Merge semantics within a destination follow _merge_yield_value:
lists concat, sets/frozensets union, everything else overwrites.
Mixin sub-step updates go through add_updated_step so the iteration
commit persists the override. Working copies are cached on the handler
(_mixin_substep_working) so multiple yields targeting the same alias
accumulate in one update.
Implements runtime.md §12.
Location: afl/runtime/handlers/completion.py
class StatementCompleteHandler(StateHandler):
"""Mark step as complete and notify containing block."""
def process_state(self) -> StateChangeResult:
self.step.mark_completed()
self._notify_container()
return StateChangeResult(step=self.step, continue_processing=False)
def _notify_container(self) -> None:
# Container notification is handled implicitly through iteration:
# completed steps unblock dependent steps in the next iteration.
passIn the Python implementation, container notification is handled implicitly by the iterative evaluator. When a step completes, the evaluator's next iteration detects that dependent steps are now unblocked and schedules them. This replaces the explicit event-based NotifyContainingBlock pattern with dependency-driven scheduling.
Location: afl/runtime/types.py
class ObjectType:
"""Object type constants for step classification."""
VARIABLE_ASSIGNMENT = "VariableAssignment" # Regular statement
YIELD_ASSIGNMENT = "YieldAssignment" # Capture/output statement
SCHEMA_INSTANTIATION = "SchemaInstantiation" # Schema data object creation
WORKFLOW = "Workflow"
# Block types:
AND_THEN = "AndThen" # Sequential execution
AND_MAP = "AndMap" # Parallel/mapping
AND_MATCH = "AndMatch" # Conditional/pattern matching
FACET = "Facet" # Mixin/facet type
BEFORE = "Before" # Mixin hook
AFTER = "After" # Mixin hook
BLOCK = "Block"
@classmethod
def is_block(cls, object_type: str) -> bool:
return object_type in (cls.AND_THEN, cls.AND_MAP, cls.AND_MATCH, cls.BLOCK)
@classmethod
def is_statement(cls, object_type: str) -> bool:
return object_type in (cls.VARIABLE_ASSIGNMENT, cls.YIELD_ASSIGNMENT)Location: afl/runtime/step.py
@dataclass
class StepDefinition:
"""Persistent step definition representing a runtime step instance."""
id: StepId
object_type: str
# Hierarchy
workflow_id: WorkflowId
statement_id: Optional[StatementId] = None
container_type: Optional[str] = None
container_id: Optional[StepId] = None
block_id: Optional[BlockId] = None
root_id: Optional[StepId] = None
# State machine
state: str = field(default=StepState.CREATED)
transition: StepTransition = field(default_factory=StepTransition.initial)
# Data
facet_name: str = ""
attributes: FacetAttributes = field(default_factory=FacetAttributes)
@classmethod
def create(cls, workflow_id, object_type, facet_name="",
statement_id=None, container_id=None,
block_id=None, root_id=None, **kwargs) -> "StepDefinition":
return cls(
id=step_id(),
object_type=object_type,
workflow_id=workflow_id,
statement_id=statement_id,
container_id=container_id,
block_id=block_id,
root_id=root_id,
facet_name=facet_name,
)Workflow Start
│
▼
┌─────────────────────────────────────────────────┐
│ StepStateChanger (VariableAssignment) │
│ ┌─────────────────────────────────────────┐ │
│ │ FacetInit → FacetScripts → MixinBlocks │ │
│ │ ↓ │ │
│ │ EventTransmit → StatementBlocks │ │
│ │ ↓ │ │
│ │ StatementCapture → Complete │ │
│ └─────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────┐ │
│ │ BlockStateChanger (AndThen block) │ │
│ │ BlockBegin → BlockContinue (loop) │ │
│ │ ↓ ↑ │ │
│ │ Create child poll until │ │
│ │ steps all done │ │
│ │ ↓ │ │
│ │ BlockEnd → Complete │ │
│ └─────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
│
▼
Workflow Complete
- State Machine Per Step: Each step instance follows its own state machine lifecycle
- Hierarchical Nesting: Steps contain blocks, which contain statements, which contain steps (recursive)
- Dependency Graph: Next steps determined by
DependencyGraphreferences between statements - Polling/Looping:
BlockExecutionContinueandStatementBlocksContinueuseset_push_me(True)to re-queue for polling - Iterative Completion: When a step completes, the evaluator's next iteration detects newly unblocked steps
- Yield Merging: Capture handlers merge yield step attributes into the containing step's returns
Long-running distributed workflows face compounding failure modes that don't appear in short test runs. This section documents the mechanisms that allow workflows to self-heal and run to completion despite infrastructure failures, process crashes, and transient errors.
PENDING ──claim_task()──► RUNNING ──handler succeeds──► COMPLETED
▲ │
│ ├── handler fails ──► FAILED
│ │ │
│ ├── no handler here ─► PENDING (backoff) ─► … ─► FAILED (no runner can service it)
│ │
│ ├── server dies ────┐ │
│ │ │ │
│ └── stuck (4h) ─────┤ │
│ │ │
└────── reaper / watchdog / dashboard ───────┘ │
└────── manual retry / dashboard retry ──────────┘
A runner only ever claims a task it can actually run. Concretely:
claim_task(task_names, task_list)is name-filtered server-side. The query matches a pending task only if itsnameis one oftask_namesexactly or starts with"<name>:"(so"fw:execute"claims"fw:execute:MyWorkflow"). A runner passes three name sets per poll cycle — the facet names it has handlers for,["fw:resume"], and["fw:execute"]— so it never picks up a task outside that set.- Registry-mode runners advertise only loadable handlers. A runner started
with
--registryloads handler registrations from the DB, but registers a proxy (and therefore claims tasks) for a facet only if its handler module is importable in this process (RegistryDispatcher.preload(verify=True)). In a one-runner-per-example deployment this means each runner claims just its own example's facets —runner-anthropicwill not grabosm.*tasks, etc. - Built-in protocol tasks (
fw:execute[:Workflow],fw:resume[:Facet]) are claimed by every runner —fw:executeis registered on everyRunnerService, andfw:resumeneeds no facet handler (the external agent already wrote the step's results). - Defence in depth: if a task does end up on a runner with no handler for it
(e.g. a stale registration), the runner releases it back to
pendingwith exponential backoff (retry_count++) rather than failing it, so a runner that does have the handler can pick it up. It is failed for good only onceretry_countis exhausted — i.e. no runner in the fleet could service it.
Beyond name-filtering, tasks also carry task_list_name for workload isolation —
a flooded osm queue can't starve the anthropic queue. The label is derived
from the facet's top-level namespace, not configured, and the same derivation
runs on both sides of the queue so they can never desync:
- Producing —
task_list_routing.namespace_of(name)returns the top-level segment (osm.cache.Download→osm).handlers/completion.py:_create_event_tasktags each child event task with the namespace of its own facet; submission (submit.py, dashboardworkflows.py/flows.py) tags the bootstrap with the workflow's namespace. - Consuming — a runner polls
namespaces_for(handler_names)(the distinct namespaces of the handlers it loaded) plus its--task-list(default"default", for shared/unnamespaced work). Anosm.*runner pollsosm.claim_taskmatchestask_list_name ∈ {poll lists}via$in.
A task is claimed only if it's on one of the runner's namespace lists and the
runner advertises its facet — so the queue label always follows the handler.
This eliminates the prior failure mode where a child task could land on a list
whose runners lacked the handler (deadlock). Continuation tasks
(CONTINUATION_TASK_LIST = "_fw_continue") are exempt — every runner polls that
shared queue. The (state, name, task_list_name) compound index keeps per-list
claims cheap. (Productionized from a prototype; the old prefix-map
FW_WORKFLOW_TASK_LIST_MAP and runner_task_list inheritance were removed.)
Each runner is an autonomous process. There is no shared in-memory dispatcher, work queue, leader, or coordinator across runners — every cross-runner interaction goes through MongoDB. The consequences are worth stating explicitly because they're load-bearing for the operational model:
Polling is independent. Each runner runs its own _poll_cycle on its own
clock (FW_POLL_INTERVAL_MS, default 1s). RunnerA polling once per second has
no effect on RunnerB's poll timing. A runner that's busy on a long task is the
only thread blocked — other runners keep polling normally.
Claiming is atomic, not coordinated. claim_task() is a single Mongo
find_one_and_update({state: pending, …}, {$set: {state: running, …}})
operation. Mongo's per-document atomicity guarantees that exactly one runner
wins for any given document — no in-process locks, no leader election, no
distributed consensus. The "loser" simply gets None back and polls again
next cycle.
Different filters → fully disjoint pools. RunnerA polling for osm.* and
RunnerB polling for anthropic.* issue Mongo queries with different
name_filter and possibly different task_list_name clauses. Their result
sets don't overlap, so they can't even race — they're querying different rows.
This is the common case in per-example deployments: routing is deterministic
because exactly one runner's filter matches a given task.
Same filters → race, not queue. When N runners can handle the same task,
they race for it. There's no fairness guarantee, no FIFO across runners, no
"earliest-queued runner gets first dibs" — whichever poll's find_one_and_update
hits Mongo first wins. From any individual runner's perspective, the pool of
eligible tasks is just "whatever's pending right now"; the runner's own task
backlog is irrelevant to other runners' work.
Enqueueing is lock-free too. Tasks are created by save_task() which is a
single replace_one(upsert=True) per task. The orchestrating runner is the
sole writer for its own workflow's step tasks (each task has a unique uuid),
so step-task creation never contends with any other runner. Bootstrap tasks
(fw:execute:<Workflow>) are written once by the submitter — also single-writer
per document.
Continuations parallelise the same way. The shared _fw_continue list is
hit by every runner, but each claim is the same atomic op — N runners can
drain continuations in parallel without coordination. A runner doesn't "own"
any continuation; whichever runner's poll grabs it processes it.
The practical takeaway: a 1000-task backlog on RunnerA's pool does not delay RunnerB by a single millisecond, because RunnerB never queries that pool. Polls are local, claims are atomic, and there are no cross-runner data structures to contend on.
Problem: A runner crashes (OOM, SIGKILL, power loss) without graceful shutdown. Its in-flight tasks remain in running state forever.
Mechanism: Every 60s, each runner's poll loop calls reap_orphaned_tasks():
- Query servers where
state ∈ {running, startup}ANDping_time < now - 5min - Find tasks where
server_id ∈ dead_serversAND(task_heartbeat missing OR stale) - Atomically reset matching tasks to
pendingwith emptyserver_id - Write step log entries for audit visibility
Files: mongo_store.py:reap_orphaned_tasks(), runner/service.py:_maybe_reap_orphaned_tasks()
Safety: Servers in shutdown state (graceful drain) are not reaped. The 5-minute threshold avoids false positives from temporary network hiccups.
Problem: A runner is alive and pinging, but a handler is blocked indefinitely (e.g. waiting for a database connection during PostgreSQL WAL recovery). The orphan reaper won't catch this because the server isn't dead.
Mechanism: reap_stuck_tasks() runs in the same 60s cycle:
- Pass 1 (explicit timeout): Tasks with
timeout_ms > 0wherenow - max(task_heartbeat, updated) > timeout_ms - Pass 2 (default timeout): Tasks without explicit timeout where
now - max(task_heartbeat, updated) > FW_STUCK_TIMEOUT_MS(default: 4 hours)
Heartbeat-aware: Handlers calling update_task_heartbeat() during long operations (e.g. PostGIS bulk import) keep their tasks alive even if the server heartbeat is stale due to I/O contention.
Files: mongo_store.py:reap_stuck_tasks(), runner/service.py, agent_poller.py
Problem: The 5-minute reaper threshold is too slow for some failure modes. A runner that crashes during task execution leaves the task locked for 5 minutes before recovery.
Mechanism:
- Tasks have a
lease_expirestimestamp set at claim time - Runners renew leases via heartbeat during execution
- Expired leases allow other runners to reclaim without waiting for the full reaper cycle
- Execution timeout (default: 15 min,
FW_EXECUTION_TIMEOUT_MS) kills hung futures and releases capacity _safe_save_task()retries with exponential backoff on transient MongoDB errors
Problem: A step fails (e.g. database connection refused during PostgreSQL restart). The step moves to STATEMENT_ERROR. Later, the task is reset to pending and a runner retries it. The handler succeeds. But continue_step() sees the step is already in a terminal state and silently skips. The step remains in STATEMENT_ERROR forever. Downstream steps never execute. The workflow is permanently stuck with no visible errors.
Mechanism: continue_step() now detects when step.state == STATEMENT_ERROR and a result is provided:
- Reset step state to
EVENT_TRANSMIT - Clear the step's error field
- Apply the result as return attributes
- Advance the step to
STATEMENT_BLOCKS_BEGIN(next state in transition table) - Continue normal state machine processing (blocks, capture, completion)
Files: evaluator.py:continue_step()
Problem: After a handler completes at EventTransmit, the workflow needs to advance the step through its remaining states and cascade completion up to parent blocks. The original approach was to call evaluator.resume() which scans ALL steps in the workflow and iterates until a fixed point. For a 303-step workflow, this is O(N²) MongoDB queries per iteration — each iteration loads all non-terminal steps, and each BlockExecutionContinueHandler queries its children. Combined with MongoDB connection timeouts (30s each), a single resume could take hours or hang indefinitely.
Mechanism: The runner uses evaluator.resume_step() (O(depth)) instead of evaluator.resume() (O(all steps)). Rather than walking the ancestor chain, resume_step() uses a parent notification cascade:
Handler completes
→ continue_step(step_id, result)
Advances step past EventTransmit to StatementBlocksBegin
Saves directly to persistence (step is no longer at EventTransmit)
→ resume_step(workflow_id, step_id, ...)
Round 1: process the continued step
StatementBlocksBeginHandler creates andThen children (if any)
Step advances through blocks → capture → complete
_process_step notifies parent: marks block_id + container_id dirty
Round 2: process only the notified parents
Parent block checks children → all done → completes
Notifies its own parent
Round 3+: cascade continues until no more notifications
Each round only loads steps that received a child-completion notification. Steps that don't need re-evaluation are never touched.
Key design decisions:
-
Step state advanced before save, without request_transition:
continue_step()advances the step toSTATEMENT_BLOCKS_BEGIN(the next state afterEventTransmit) before saving to persistence but does NOT setrequest_transition=True. This is critical for two reasons: (a) the step is pastEventTransmitso it won't trigger a duplicate task on crash recovery, and (b) theStatementBlocksBeginHandlermust execute (to create andThen children) before the step transitions toStatementBlocksContinue. Settingrequest_transition=Truewould cause the state changer loop to skip the Begin handler entirely. -
Per-workflow locking: A per-workflow in-memory
threading.Lockprevents concurrentresume_step()calls from sibling handler threads. Non-blocking: if the lock is held, the call is skipped — the active resume will see all completed children when it checks the block. -
Always complete the task:
_process_event_task()always marks the task asCOMPLETEDafter the handler returns a result, even ifcontinue_step()orresume_step()throws. This ensures the thread future always finishes and capacity is always freed. If the resume failed, the stuck-step sweep will retry. -
Resume timeout:
_resume_workflow()(the fullresume()fallback) runs with a configurable timeout (FW_RESUME_TIMEOUT_S, default 10 min). On timeout, the resume is abandoned and the sweep retries on the next cycle.
Performance comparison (303-step Africa OSM import):
| Approach | Steps processed | Queries | Time |
|---|---|---|---|
resume() |
All 303 per iteration | O(N²) | 2+ min per iteration, hangs with MongoDB issues |
resume_step() |
~4-6 (notified parents) | O(depth) | 22ms |
Files: runner/service.py:_resume_workflow_for_step(), evaluator.py:resume_step()
Problem: The resume_step() mechanism (§17.5.1) is O(depth) per step but still requires per-workflow locking — only one server can resume a given workflow at a time. For large distributed deployments (100+ servers processing a foreach workflow with 50 states), this lock becomes a bottleneck. Server B's handler completion must wait for Server A's resume to finish before it can advance its own step.
Mechanism: process_single_step() replaces per-workflow locking with per-step atomic operations and continuation events:
Handler completes on Server A:
→ continue_step(step_id, result) # advances past EventTransmit
→ process_single_step(step_id, ...) # per-step, no workflow lock
Round 1: process target step
Creates andThen children, processes them (inline dispatch if available)
Marks parent block_id + container_id as dirty
Commits atomically: step updates + created steps + tasks + continuations
Round 2: process dirty parents (from work_queue)
Parent block checks children → not all done → stays at Continue
No progress → no more dirty blocks → exit
Remaining dirty blocks get continuation tasks
Key components:
-
process_single_step()inevaluator.py:- Processes one step and cascades up through dirty-block notifications
- Multiple rounds within a single call (max 50), each committing atomically
- Generates continuation events only for dirty blocks not processed locally
- Bumps
version.sequenceon all updated steps for optimistic concurrency
-
Continuation events (
continuation.py):- Generates
TaskDefinitionentries on the_afl_continuetask list - Each continuation carries only
step_idandreason(lightweight) - Deduplicated per target step — at most one pending continuation per step (see Continuation coalescing below)
- Committed atomically alongside step changes (no partial state)
- Generates
Continuation coalescing (per-block, generation-time + claim-time):
A continuation says "a child of block B progressed — re-evaluate B." One re-evaluation of B already inspects all of B's children, so N children progressing must collapse into one continuation, not N. Without this, a large foreach fan-out (hundreds of sub-blocks, each re-dirtying the parent block as it progresses) produces O(N²) duplicate continuations that flood the runner thread pool and starve event-task claiming — the runner spins re-evaluating the same block (resume_step done iterations=0) and stops dispatching handlers (the foreach livelock). Coalescing happens at two points:
- Generation-time dedup —
process_single_step()(evaluator.py) filtersremaining_dirtyagainstPersistence.get_pending_continuation_step_ids(workflow_id)before callinggenerate_continuation_events(), so a block that already has a PENDING continuation gets no second one. The default implementation scans the workflow's tasks; stores may override with an indexed query. - Claim-time coalescing —
_process_continuation()(registry_runner.py) callsPersistence.delete_pending_continuations_for_step(step_id, except_task_id=task.uuid)the moment it begins processing. That single re-evaluation satisfies every other continuation already queued for the step, so the redundant siblings are deleted (mongodelete_manyinmongo_store/tasks.py; in-memory filter inmemory_store.py; base no-op onPersistenceAPI) rather than each claimed and processed to a no-op. This also closes the generation-time race: if two continuations for the same block are enqueued concurrently, whichever is claimed first collapses the rest.
Both check PENDING state only. A child event that arrives after a continuation has been claimed (i.e. its sibling is no longer pending) still generates a fresh continuation — coalescing only ever removes redundant in-flight notifications, never a genuinely-new one, so no completion is lost.
-
Optimistic concurrency (
version.sequence):- Each
StepDefinition.versionhas asequencecounter (monotonic) process_single_step()increments the sequence before committingMongoStore._commit_changes()uses conditionalreplace_onewith version check- If two servers process the same step concurrently, only one write succeeds
- The loser's write falls back to unconditional update (safe — the winner already advanced the step)
- Each
-
RegistryRunner integration:
_poll_cycle()claims both handler tasks (fromdefaulttask list) and continuation tasks (from_afl_continuetask list)poll_once()also processes continuations (for testing)_process_continuation()first coalesces redundant sibling continuations (delete_pending_continuations_for_step), then callsprocess_single_step()on the target step_process_event()callscontinue_step()then falls back to_resume_workflow()for inline dispatch compatibility
Multi-server execution model:
Server A (claims handler task for step X):
1. Handler runs → produces result
2. continue_step(X, result) → step X at StatementBlocksBegin
3. process_single_step(X) → X completes → parent block notified
4. Continuation task created for parent block → committed to DB
Server B (claims continuation task for parent block):
1. process_single_step(parent_block) → checks children → 3/5 done
2. No progress → returns (idempotent, safe)
Server C (claims handler task for step Y in same workflow):
1. Handler runs → Y completes
2. continue_step(Y, result) → process_single_step(Y)
3. Parent block notified → continuation task created
Server D (claims continuation for parent block again):
1. process_single_step(parent_block) → checks children → 5/5 done
2. Block completes → workflow root notified → continuation created
Server E (claims continuation for workflow root):
1. process_single_step(root) → all blocks done → workflow COMPLETED
No server holds a lock on the workflow. Each processes its step independently. The continuation task queue coordinates parent notification across servers.
Performance characteristics:
| Deployment | Approach | Throughput |
|---|---|---|
| 1 server | resume() (O(N²)) |
Sequential, limited by scan cost |
| 1 server | resume_step() (O(depth)) |
Sequential, 22ms per step |
| 100 servers | process_single_step() |
Parallel — each server processes independently |
Files: evaluator.py:process_single_step(), persistence.py:get_pending_continuation_step_ids() / delete_pending_continuations_for_step(), continuation.py, registry_runner.py:_process_continuation(), mongo_store/tasks.py, memory_store.py
Problem: The event-driven processing (§17.5.1, §17.5.3) handles the 99% case, but edge cases can leave steps stuck: MongoDB goes down during commit, the runner crashes between continue_step() and process_single_step(), continuation events are lost, or tasks are never created for new EventTransmit steps.
Mechanism: The sweep runs every 5s on the registry runner (runner/service.py uses a longer interval) as a safety net:
- Finds all workflows with steps at intermediate states (
EventTransmitwithrequest_transition,blocks.Begin,block.execution.Begin) - For each stuck step: calls
process_single_step()directly to cascade completion and generate continuation events - For
EventTransmitsteps with event facets but no pending/running task: creates a new task so the handler can run
The sweep never calls full resume() — it processes each stuck step individually via process_single_step(), avoiding O(N²) scans and generating continuation events for any remaining dirty blocks.
Bounded sweep (yields to event claiming). process_single_step() runs synchronously on the poll thread, so an unbounded sweep over a large foreach fan-out runs longer than the poll interval and starves _poll_cycle()'s event-task claiming — the steps it tries to unstick never get their handler dispatched, so the next sweep re-finds them and the runner livelocks (busy sweeping, ~0 events claimed). _maybe_sweep_stuck_steps() therefore:
- Skips entirely when all worker slots are busy (
_active_count() >= max_concurrent) — event handlers take priority over the safety net when there is real work in flight. - Caps the work per invocation at
SWEEP_MAX_STEPS = 25steps /SWEEP_MAX_MS = 1500ms; the remainder is handled by the next sweep, by which point normal claiming has advanced the backlog.
Files: registry_runner.py:_maybe_sweep_stuck_steps(), runner/service.py:_maybe_sweep_stuck_steps()
Problem: All runners are at capacity with stale futures (the deadlock scenario). No runner can run the reaper because the reaper runs inside the poll loop which is gated by capacity. The system is stuck.
Mechanism: The dashboard runs an independent asyncio background task:
- Every 60s (configurable:
FW_DASHBOARD_REAP_INTERVAL_S), callsreap_orphaned_tasks()andreap_stuck_tasks() - Completely independent of runners — runs in the FastAPI lifespan
- Breaks the deadlock: dashboard resets orphaned tasks → capacity freed → runners resume claiming
Files: dashboard/app.py:_reaper_loop()
| Failure Mode | Example | Recovery Layer |
|---|---|---|
| Runner crash (OOM, kill -9) | Process killed during import | Layer 1: Orphan reaper (5 min) |
| Handler blocked on dead resource | PostgreSQL in WAL recovery, DNS failure | Layer 2: Stuck watchdog (4h default) |
| Handler timeout | Infinite loop, deadlocked connection | Layer 3: Execution timeout (15 min) |
| Transient error, retry succeeds | Network blip, brief DB maintenance | Layer 4: Step recovery on retry |
| All runners at capacity with stale futures | Handler succeeds but continue_step skipped | Layer 5: Dashboard reaper (60s) |
| Missing dependency on runner | psycopg2 not installed on remote machine |
Manual: restart runner after install |
| Concurrent resource contention | CREATE EXTENSION race condition |
Code fix: catch both exception types |
| Stale module state | Runner started before dep installed | Manual: restart runner |
| Variable | Default | Description |
|---|---|---|
FW_REAPER_TIMEOUT_MS |
300,000 (5 min) | Server heartbeat stale threshold |
FW_STUCK_TIMEOUT_MS |
14,400,000 (4h) | Default stuck task timeout |
FW_EXECUTION_TIMEOUT_MS |
900,000 (15 min) | Per-task execution timeout |
FW_DASHBOARD_REAP_INTERVAL_S |
60 | Dashboard reaper cycle interval |
FW_MAX_CONCURRENT |
2 | Max concurrent tasks per runner |
FW_POLL_INTERVAL_MS |
1,000 | Runner poll cycle interval |
All source files are located in afl/runtime/.
afl/runtime/mongo_store.py—reap_orphaned_tasks(),reap_stuck_tasks(),claim_task()with leaseafl/runtime/runner/service.py—_maybe_reap_orphaned_tasks(), stuck watchdog, execution timeoutafl/runtime/agent_poller.py— parallel reaper/watchdog for standalone pollersafl/runtime/evaluator.py—continue_step()with errored step recoveryafl/dashboard/app.py—_reaper_loop()independent background reaper
afl/runtime/handlers/base.py—StateHandlerabstract base classafl/runtime/changers/base.py—StateChangerorchestrator +StateChangeResultafl/runtime/changers/step_changer.py—StepStateChanger(full state machine)afl/runtime/changers/block_changer.py—BlockStateChanger(block state machine)afl/runtime/changers/yield_changer.py—YieldStateChanger(yield state machine)
afl/runtime/handlers/block_execution.py—BlockExecutionBeginHandler,BlockExecutionContinueHandler,BlockExecutionEndHandlerafl/runtime/block.py—StepAnalysis,BlockAnalysis,StatementDefinition
afl/runtime/handlers/capture.py—StatementCaptureBeginHandler,MixinCaptureBeginHandlerafl/runtime/handlers/completion.py—StatementCompleteHandler,EventTransmitHandler
afl/runtime/states.py—StepStateconstants, transition tables (STEP_TRANSITIONS,BLOCK_TRANSITIONS,YIELD_TRANSITIONS,SCHEMA_TRANSITIONS)afl/runtime/step.py—StepDefinition,StepTransitionafl/runtime/types.py—ObjectType,FacetAttributes,AttributeValue, ID types
afl/runtime/evaluator.py—Evaluator,ExecutionContext, iteration loop,process_single_step()afl/runtime/continuation.py— Continuation event generation for distributed step processingafl/runtime/dependency.py—DependencyGraphfrom compiled ASTafl/runtime/persistence.py—PersistenceAPIprotocol,IterationChanges(withcontinuation_tasks)afl/runtime/memory_store.py— In-memory persistence for testingafl/runtime/mongo_store.py— MongoDB persistence with optimistic concurrency (version.sequence)