Skip to content

[ULTRA EXPERIMENTAL — DO NOT REVIEW] Durable workflows exploration - #6912

Draft
Alek99 wants to merge 112 commits into
mainfrom
alek/workflows-mvp
Draft

[ULTRA EXPERIMENTAL — DO NOT REVIEW] Durable workflows exploration#6912
Alek99 wants to merge 112 commits into
mainfrom
alek/workflows-mvp

Conversation

@Alek99

@Alek99 Alek99 commented Aug 19, 2026

Copy link
Copy Markdown
Member

Warning

ULTRA EXPERIMENTAL — DO NOT REVIEW, DO NOT MERGE.
This is a messing-around branch for collaborating on ideas between agents. We are not at all sure any of this comes in. No review wanted; nothing here is final.

What this is

An exploration of durable workflows embedded in Reflex — a Temporal / Inngest / Trigger.dev / Zapier-class engine where a workflow is an rx.State subclass, steps are @rx.event(durable=True) handlers, and control flow is the handler's return value.

class Dunning(rx.State):
    __workflow__ = rx.WorkflowConfig(id="billing.dunning")
    attempts: int = 0

    @rx.event(durable=True, trigger=rx.webhook("stripe", verify=rx.hmac_signature(...)),
              retry=rx.Retry(max_attempts=5), effect="idempotent_write")
    async def start(self, evt: PaymentFailed):
        self.attempts += 1
        charge = await rx.step("charge", charge_card, evt.amount)   # recorded substep
        return rx.after("3d", Dunning.retry_charge)                 # durable timer

Core design bet: no replay — state is snapshotted per step, never rebuilt by re-executing user code, so handlers are ordinary Python with no determinism constraints.

What's in the branch (37 commits)

  • Strictly serial per-run mailbox; claims fenced by epoch + renewable leases; crash recovery
  • Stores: in-memory, SQLite (off-loop via worker threads), Postgres (FOR UPDATE ... SKIP LOCKED, multi-worker, schema-namespaced) + a runnable conformance suite (32 checks) any store must pass
  • One deployment knob: REFLEX_WORKFLOW_DATABASE resolves the store for the app, its workers, and the CLI alike
  • Triggers: manual, verified webhooks (raw-body HMAC, admit-before-ack, dedupe), cron schedules
  • Composition: typed signals/waits with deadlines, child-run fan-out (rx.parallel, incl. mode="first" racing), signed single-use approval links for email (GET never decides)
  • Flow control: singleton, debounce, rate limit, spaced throttle; per-process concurrency; worker queues (queue= + rx.App(workflow_queues=...))
  • rx.step recorded substeps (epoch-fenced journal replayed to retries and crash recoveries); rx.current_run() + retry-stable idempotency keys; policy keys reach into model payloads
  • Operator surface: rx.workflows.*, reflex workflows list/show/cancel/resume/check (SQLite path or Postgres URL; check --json is the generation-loop validator)
  • WorkflowTestHarness with a virtual clock; every workflow test runs against all three stores (~930 tests); Playwright integration tests prove the engine inside real dev and prod servers (browser → durable timer → completion; signed webhook over HTTP)

Notable war story: an intermittent teardown hang was root-caused to a swallowed task cancellation during lease release (task.cancelled() is never a valid "who cancelled" discriminator — third instance of that bug class here), fixed with a deterministic repro and verified over 30 clean suite runs.

🤖 Generated with Claude Code

Alek99 added 28 commits August 18, 2026 11:59
Implements the first slice of the Reflex Workflows design: durable
automation on the existing Reflex programming model.

Authoring contract:
- rx.WorkflowConfig on a workflow-focused rx.State class (reserved
  __workflow__ attribute, excluded from state schemas)
- @rx.event(durable=True, effect=...) with id/trigger/retry/timeout/
  queue/on_failure/on_timeout options, validated at decoration time
- rx.Retry with per-effect-class defaults materialized at compile
  (TransientWorkflowError is the explicit retryable signal)
- declarative triggers rx.manual() / rx.webhook() / rx.schedule()
- control returns rx.complete/fail/needs_attention and rx.after delays

Runtime:
- compile_workflow() validates the class contract (durable-only public
  handlers, no substates/backend vars/mixed scopes, resolvable hooks,
  stable unique ids) and produces an immutable digest-pinned definition
- app.add_workflow() registers the class and detaches it from the
  session state tree: no per-session instances, no browser setters, no
  frontend event dispatch to durable handlers
- WorkflowKernel executes runs against a RunStore: single-writer
  ordered mailbox with preallocated ordinals, fenced claims, atomic
  commit of state snapshot + successor slots + history, retry with
  exponential backoff and jitter as persisted timers, per-attempt
  execution timeouts, failure/timeout hooks after tombstoning,
  NEEDS_ATTENTION suspension for uncertain non-idempotent effects,
  drain-based cancellation, run deadlines, max_steps bounds, and
  orphan recovery with a separate infrastructure recovery budget
- MemoryRunStore for tests; SqliteRunStore (stdlib, WAL) for
  crash-safe local persistence, including admission dedupe by
  request_key surviving restarts
- rx.workflows.start/cancel/get_run namespace served by the app
  lifespan; WorkflowTestHarness runs definitions deterministically on
  virtual time

Deliberately out of scope for this slice (per the design's MVP/Beta
ledger): webhook/schedule ingress execution, the connector broker and
effect evidence records, mixed-scope classes, UI run projections,
operator commands beyond cancel, and multi-worker kernels.
A second worker starting while the first was mid-attempt reclaimed the live
claim and executed the same step concurrently: recovery treated every CLAIMED
row as an orphan left by a dead process. Proven with two OS processes against
one SQLite file, where the peer ran the handler a second time and the original
worker's commit was then discarded as stale.

Claims now carry a lease. claim_next stamps lease_expires_at, the executing
kernel renews it in the background while the attempt runs, and recovery
reclaims only claims whose lease has lapsed, so a peer mid-attempt is never
disturbed. Lease loss consumes the infrastructure recovery budget, never a
business attempt, and is recorded as attempt_abandoned evidence.

Also:
- Recovery is now periodic rather than startup-only, so a peer that dies long
  after this worker booted is still reclaimed.
- Renewal cadence runs on real time while expiry is measured on the injected
  clock, so virtual-time tests stay deterministic and a jumping clock cannot
  expire a live attempt (recover() renews own claims before sweeping).
- Cancellation is now disambiguated three ways: a lost lease abandons the
  attempt, an operator cancel releases the step as CANCELLED, and any other
  cancellation (worker shutdown) re-raises and leaves the step claimed for
  lease recovery. Previously every cancellation terminally cancelled the step
  and wedged the run at RUNNING with no path forward.
- SqliteRunStore migrates databases written before this change; a step left
  claimed by the previous binary has no lease and is a genuine orphan.
- The worker loop no longer dies on a transient store error.

Crash recovery is no longer instant: it is bounded below by the lease duration
(default 30s, sweeping every 15s). That is the price of not double-executing.

Multi-worker SQLite remains unsupported and is now documented as one worker
process per database file: the store's calls are synchronous, so cross-process
write contention blocks the caller's event loop including its own renewals.
rx.Retry(max_attempts=5) on a handler raising an ordinary exception executed
the handler exactly once. Both the effect-class defaults and any explicit
policy that did not name retry_on were resolved to retry only on
TransientWorkflowError, so a flaky HTTP call or a dropped connection failed
the run immediately while the declared policy promised five attempts.

Failures now retry by default: none, read, and idempotent_write resolve to
three attempts with exponential backoff on any Exception, and an explicit
policy without retry_on retries on Exception too. Narrow it with
do_not_retry_on to fail fast. non_idempotent_write still gets exactly one
attempt and routes to NEEDS_ATTENTION, since the runtime cannot prove the
external effect did not already land.

This is a deliberate divergence from the design doc's 'unknown code defects do
not retry by default'. That rule is incoherent with an idempotent_write
declaring three attempts that can never fire, and it makes the common case --
surviving a flaky dependency, which is the whole point of a durable step --
require boilerplate. Every comparable engine retries by default.

TransientWorkflowError remains as an explicit marker for intent and for
staying retryable under a narrowed policy.
Runs were pinned to a hash of the whole compiled definition, so adding a state
field, retuning a retry policy, or changing a timeout parked every live run of
that workflow in NEEDS_ATTENTION -- with no API to get it back. That fires on
the second deploy of any real app, not on an exotic one.

Runs are now gated on what can actually strand a step: the handler it names is
gone, or its persisted payload no longer fits that handler's parameters. Both
suspend with a precise, actionable reason instead of a bare digest mismatch.
Everything else -- new fields, retuned retries and timeouts, changed hooks,
effects, and triggers -- deploys without disturbing work in flight. The
definition digest is still recorded on each run as provenance.

Adds rx.workflows.resume(run_id) so suspension is a door rather than a wall:
it clears the error, grants the frontier step a fresh attempt budget, and
makes it claimable. A handler that returns rx.needs_attention() now leaves its
step NEEDS_ATTENTION rather than SUCCEEDED, so resuming re-runs the handler
that suspended and lets it take a different branch once a human has acted.
Calling another durable handler directly (self.charge()) ran it inline inside
the caller's attempt: no retry policy of its own, no effect tracking, no step
in the mailbox, and a silent re-execution of its side effect whenever the
caller retried. The run still reported success, which is what makes it
dangerous -- and it is exactly the shape a code generator reaches for.

The compiler now parses each handler body and rejects two traps with an
actionable message: an inline call to a sibling durable handler (pointing at
'return MyFlow.charge' instead), and a return of a plain literal (listing the
transitions a durable handler may return). Both fail at compile time, before a
run exists. Handlers whose source is unavailable, as in a REPL, are skipped
rather than guessed at.
Runs could only be fetched one at a time by id, so labels were recorded but
never readable and nothing could build an operator view, a CLI listing, or a
customer-facing 'your jobs' page.

Adds RunQuery and RunStore.list_runs on both stores, surfaced as
rx.workflows.list_runs(workflow_id=..., statuses=..., labels=..., limit=...),
newest first with a created_before cursor for pagination. SQLite filters
labels through json_extract so it stays a single indexed scan rather than
loading every run.
rx.webhook(...) compiled but could never fire: only manual roots were
reachable, so the whole provider-driven half of the product was declarative
decoration.

Adds the ingress endpoint at POST /_workflow/webhook/{topic}, registered only
when a workflow actually declares a webhook root. It preserves the raw request
body, verifies the provider signature over those exact bytes, validates the
payload against the declared model, and durably admits the run before
acknowledging -- so a provider that never sees a 202 can safely redeliver.
Redelivery reaches the same run through dedupe_by rather than starting a
second one.

Authentication is not optional by default: a webhook trigger without a
verifier is a compile error naming the fix, and an endpoint that really is
public must say so with allow_unverified plus a reason. rx.hmac_signature()
covers the Stripe/GitHub/Shopify shape, reading the secret from the
environment at request time so it never enters workflow state, history, or a
browser bundle.

Trigger kind is now part of admission: a webhook root cannot be started by
application code, and a manual root is not reachable over HTTP.
rx.schedule(...) validated its five fields and then did nothing; scheduled
workflows never ran.

Adds a dependency-free UTC cron evaluator supporting the standard five fields
with ranges, steps and lists, including the day-of-month OR day-of-week rule
that every other cron implementation follows. Expressions are validated when
the workflow compiles, so a bad expression fails at add_workflow() rather than
silently never firing.

The kernel admits one run per occurrence under a request key derived from the
occurrence time, which reuses the existing dedupe path: a restart, a second
process, or an overlapping sweep all converge on exactly one run per
occurrence. Cursors are seeded when the kernel is constructed, so deploying a
schedule never backfills history, and catch-up after an outage is capped at
ten occurrences so a restart cannot stampede. The worker wakes for the next
occurrence rather than polling for it.
A run could only move forward on its own timers, so human approvals,
event-driven waits, and anything needing an answer from outside were simply
inexpressible -- the gap that most separated this from Temporal and Inngest.

A wait is now a BLOCKED slot at the run's frontier, which keeps the mailbox
strictly serial: no second open slot, no change to the per-run fence, no
concurrent commits. The slot carries the address a delivery must match, and
its due_at doubles as the deadline, so a wait timeout fires through the same
timer path that rx.after() already used and the virtual clock drives it with
no new machinery.

The race is settled on one row. A delivery compare-and-swaps the blocked slot
to ready and hands the payload to the resume handler; a deadline instead makes
the slot claimable, and claiming it *is* the timeout branch. Whichever lands
first erases the other's trigger, so a late signal to a finished run is
refused rather than silently dropped.

A signal that arrives before the run reaches its wait is buffered and consumed
by the arming commit itself, so a sender faster than the workflow cannot block
it forever. Deliveries never write run state or the state version, so a
delivery can never fence a live attempt.

BLOCKED is deliberately not a claimable status: a wait with no deadline would
otherwise report itself due at time zero and spin the worker against the
database, starving lease renewal. Claimability is now one predicate both
stores share, with a test asserting an unbounded wait leaves nothing claimable
and nothing scheduled.
One dunning workflow written the way a user would: a manual root, a flaky
charge with retries and a failure hook, a durable delay, a human decision with
a deadline, and completion, failure, and suspension outcomes -- plus a second
class reached by a verified webhook and a cron schedule.

It caught a real authoring subtlety worth keeping in front of us: run state
cannot count attempts, because a failed attempt's patch is discarded by
design. The example now models gateway flakiness outside the run, which is
where it actually lives.
Nothing about workflows was documented, which also matters because this page
is the surface a text-to-workflow generator will learn from.

Covers the whole shipped surface: durable steps and effect classes, retries and
timeouts, the transition table, waits and typed signals with the approval
example, manual/webhook/schedule triggers, inspecting and steering runs, the
virtual-clock harness, and what a redeploy does to runs in flight.

Every example was run against the real engine rather than written from
memory, which caught one error in the draft: rx.Base no longer exists on main,
so payload models use pydantic BaseModel.
Runs could only do one thing at a time: the mailbox is strictly serial, so
there was no way to enrich and score a lead concurrently, or to do anything
Temporal and Inngest express with child workflows.

Concurrency now lives in the run graph rather than the mailbox. rx.parallel()
commits a BLOCKED join slot in the parent and admits one child run per branch,
each with its own state, retries, timers, and history. A child that finishes
reports its outcome to the parent's join slot through a compare-and-swap on an
arrival counter, so a redelivered result cannot be counted twice, and the slot
becomes claimable exactly when the last expected branch lands. The join
handler receives one entry per branch carrying run_id, status, result, and
error.

Keeping each run's mailbox serial is what makes this safe: the parent never
has two open slots, so the per-run fence and the one-claim-per-run invariant
are untouched, and a failing branch fails its own run rather than the parent's.

Children are admitted after the parent's commit lands, so a crash in between
leaves a join with no children -- which recovery re-runs -- rather than
orphans with no parent.
Nothing bounded how often a root could start, so a chatty webhook produced one
run per delivery and two clicks produced two concurrent syncs of the same
customer. Flow control is the main thing Inngest sells and we had none of it.

A root now declares one start policy, applied at admission and grouped by a
payload field:

  singleton  one active run per key; a second start either returns the first
             (skip) or replaces it (cancel)
  debounce   a burst collapses into one run, each start pushing it out until
             things go quiet
  rate_limit starts beyond the cap are refused with retry_after, which is what
             you want when a provider can flood you
  throttle   the excess is delayed rather than dropped, for when every start
             matters but the downstream is slow

The grouping key must name a real parameter of the root, checked at compile
time, and a policy without a trigger is rejected since it governs starting.
Only one policy per root, so its behavior stays predictable.

throttle= and debounce= are overloaded by type rather than given second names:
an int is still the browser event action on a session handler, while the
policy object is the durable start policy. Same word, same meaning, and a
session handler passing an int is untouched.
RunStore is a public extension point -- a deployment can back workflows with
Postgres or a hosted kernel -- but the protocol's signatures say nothing about
the invariants that make durable execution correct. Two implementations were
already drifting apart with nothing but shared test files to hold them
together, across 22 methods.

reflex.workflow.CONFORMANCE_CHECKS is now the specification: 22 checks, each
taking a fresh store and asserting one property. Frontier ordering, atomic
commit, fenced claims discarding their work, failed attempts discarding state,
lease renewal sparing a live claim, recovery reclaiming only lapsed ones,
next_due never promising work that is not claimable, a deadline-less wait
never becoming due, delivery never touching run state, joins counting each
arrival once, finalize refusing while a step is claimed, and the queries start
policies depend on.

Both shipped stores pass all 22. Anyone adding a store runs the same suite;
it is exported and documented for that purpose.

Postgres is deliberately not in this commit: no server was available to test
against, and an unverified store implementation is worse than none.
Runs could only be reached from inside the app, so diagnosing one meant
writing a script against the store. That is the wrong tool at 2am.

reflex workflows list filters by workflow, status, and label; show renders a
run's state, steps with their attempt and recovery counts, and optionally its
history; cancel and resume steer a run without opening the app. Both list and
show take --json so the output is scriptable.

The commands read the same SQLite database the app writes, so they work
against a running deployment or a stopped one, and resume refuses a run that
is not actually suspended rather than pretending to act.
Runs recorded a full history but nothing could watch them happen: diagnosing a
production workflow meant querying the database after the fact, and there was
no way to get workflow activity into metrics or tracing.

WorkflowObserver receives every transition the kernel records -- admission,
each attempt and its outcome, retries, waits, joins, and terminal dispositions
-- with the correlation a durable system needs: run, workflow, step, and
attempt. Install one with rx.App(workflow_observer=...). LoggingObserver is
bundled for the common case.

Instrumentation is deliberately not allowed to break execution: an observer
that raises is reported and ignored, which a test asserts by running a
workflow to completion under an observer that always throws.
An audit of the whole feature reproduced four real problems, each of which
could stop a run permanently.

A handler that raised CancelledError itself -- as any handler wrapping its own
asyncio work might -- propagated past the cancellation branch and killed the
worker task, so every later run in the process silently never executed. The
kernel now distinguishes a task that was cancelled from a coroutine that
raised: the first is a control signal, the second is an ordinary handler
failure that retries.

A crash between a fan-out's commit and the creation of its children left a
join blocked on children that did not exist, with nothing to recover it; the
original comment claiming recovery handled this was simply wrong. Children are
now inserted in the same transaction as the join slot, so the window is gone
rather than merely narrowed.

A child that was cancelled or blew its run deadline never reported to its
parent's join, because reporting only happened on commit and those paths
finalize without one. Both now report, so a join can no longer wait forever on
a child that already stopped.

Singleton with mode='cancel' left the superseded run in CANCELLING, so a burst
of starts produced several simultaneously active runs under one key. The
replacement now waits for the old run to reach a terminal state first.

Also fixes a store divergence the conformance suite missed: the memory store
kept only the most recent buffered signal per wait key while SQLite queued
them, so a second early signal was silently dropped. Two conformance checks
now cover buffered-delivery ordering and children being created with their
join.
A second, adversarial pass over the whole feature reproduced four problems,
one of which was a fix from the previous commit that did not actually work.

The guard added for 'a handler that raises CancelledError kills the worker'
was a branch that could never be true: asyncio marks a task cancelled whether
the kernel cancelled it or the coroutine let CancelledError escape, so the
task's own flag cannot tell them apart. Verified directly -- both cases report
cancelled() is True -- so the worker still died and every later run in the
process silently never ran. The kernel now discriminates on its own control
signals plus whether cancellation was requested on the executing task: a
handler that raises is an ordinary failure, while a real shutdown still leaves
the step claimed for lease recovery. The worker loop no longer dies on any
exception, and a dead worker can be replaced.

Admission dedupe ran after start policies, so a provider redelivering an event
was judged as a new start: with singleton(mode='cancel') the redelivery
cancelled the very run it deduplicated to, and answered the provider 202. With
debounce, a provider retrying one event faster than the window starved it
forever. request_key is now resolved before any policy.

rx.fail(details=...) and rx.needs_attention(details=...) passed user values
straight to the store, so a datetime raised inside the transaction recording
the failure and left the run stuck RUNNING. Details are normalized first, and
an unserializable value is recorded as its repr rather than losing the failure.

timeout= on a synchronous handler was a lie: asyncio.wait_for cancels the
wrapper while the thread runs on, so a timed-out step kept executing and its
retries ran concurrently with it. It is now a compile error naming the fix.
Every kernel-level test used the in-memory store, so the harness was
certifying semantics production does not necessarily have: any divergence
between the two stores could ship green, and one already had.

Harness-based tests are now parametrised over both stores, doubling that
coverage to 478 workflow tests. Turning it on immediately caught a real
divergence: the memory store handed callers live references to the values it
was storing, so mutating a returned run's state silently changed committed
data -- something a database-backed store cannot do. Reads now detach their
mutable payloads, and a conformance check pins it.

It also exposed an order-dependent test of its own, which assumed the first
child listed was the branch that had already reported to the join.

This is the gap that let earlier store divergences through, so it goes in
before any further features.
Working through the confirmed findings, highest severity first.

A class with __workflow__ that was never passed to app.add_workflow() stayed a
session substate, so its durable handlers -- including non_idempotent_write --
remained dispatchable from a browser, and the only feedback was a later error
from rx.workflows.start(). Tying detachment to registration meant the omitted
line, exactly the one a generator drops, left money-moving handlers exposed.
Detachment now happens when the class is created, so registration only adds
the definition to the kernel.

A wait whose deadline had already fallen due still accepted its signal,
because delivery matched on BLOCKED alone and never compared the deadline. A
seven-day approval that expired three weeks ago would be approved, and a
sender that lost the race was told 'buffered' and then refused as 'duplicate'.
Delivery now refuses with a distinct 'expired' disposition, which also stops a
stale signal resolving a later wait on the same channel.

A child failed by exhausting its recovery budget never reported to its
parent's join: that path fails the run inside the store, and recover() only
returned a count. The parent, and every ancestor, waited forever.
recover_orphans now returns the runs it failed so the kernel can report them.

Start policies of the wrong type vanished instead of raising:
debounce='30s' -- a very plausible generation given every other duration in
the API is a string -- silently disabled debouncing, and singleton='cid'
failed only in production once two runs overlapped. All four are now
type-checked at decoration, discriminating the browser event action on int.

Run pagination used created_at alone, so runs sharing a timestamp -- the shape
every fan-out produces -- were silently skipped; the cursor is now
(created_at, run_id). The SQLite label filter interpolated user-supplied keys
into a JSON path expression and now matches them as values.
Two more confirmed findings.

Lease renewal failures were swallowed at debug level, so a store that kept
failing -- which a contended SQLite file does, raising after a five second
block -- left the attempt running while its lease quietly lapsed. Recovery
then handed the step to someone else and the external effect ran twice, with
a log line that is off by default as the only evidence. The kernel now tracks
when the lease actually expires and abandons the attempt once too little of
it remains to survive another failed round-trip: better to stop work you can
no longer prove you own than to race the worker that is about to take it.

rx.parallel resolved its branches through a path that skipped the trigger
check start() enforces, so a webhook-only root -- one that exists precisely
because only a verified provider may start it -- could be started from
application code by naming it as a branch. Branches are now held to the same
manual-root rule as a direct start.

Fanning out to a handler of your own class is now a compile error. Each branch
becomes a child run with fresh state, so a same-class branch cannot see
anything the parent did; it is the most natural spelling and it silently did
the wrong thing.
Every store call is synchronous on the caller's event loop, which also serves
HTTP, websockets, and session state. SQLite's default busy timeout is
multiple seconds, so a second process writing the same file could stall the
whole app before raising -- and the error then landed in lease renewal, where
a silent failure used to mean the kernel re-executed its own attempt.

The busy timeout is now short: contention surfaces quickly as a transient
error the kernel retries, rather than freezing everything first. Combined with
the previous commit, a store that cannot be reached now degrades to abandoning
the attempt instead of duplicating its effect.

This bounds the symptom rather than removing the cause. Offloading the store's
calls to a thread is the real fix, and one worker process per database file
remains the supported deployment -- now stated plainly in the docs alongside
why horizontal scale wants a different store.
The kernel claimed one step and awaited it before claiming again, so a single
process executed one step at a time no matter how many runs were waiting. One
ten-minute step stalled every other run in the deployment -- including their
timers and deadlines -- which is not a throughput story any durable engine can
be compared on.

The scheduler now fills up to max_concurrency slots, eight by default. This
needs no new locking: each run has exactly one claimable frontier step, so two
concurrent claims are necessarily different runs and a run's own mailbox stays
strictly serial. A test asserts both halves -- that attempts really do overlap,
and that one run's steps still complete in order.

Three details that had to be right: finished attempts are pruned synchronously
rather than by a done-callback, or the scheduler spins on work it already
finished; a round waits only on the attempts it started, so a second caller
pumping the same kernel is not blocked by an attempt it does not own; and
cancelling the scheduler cancels the attempts it started, since asyncio.wait
does not cancel what it waits on.

The test harness stays single-slot so virtual-clock tests remain
deterministic; production defaults to eight.
rx.parallel always waited for every branch, so the one shape everybody reaches
for -- ask two vendors, take whoever answers first -- could not be expressed.
Worse, the losing branch kept running and kept calling out to a vendor after
the order was already booked.

mode="first" sets the join's expected count to one. The arrival that resolves
the join now says so, and the kernel cancels the siblings still in flight,
found through a new list_children(parent_run_id, parent_ordinal) store query
rather than a scan of the run table -- indexed in sqlite, and covered by a
conformance check so any future store has to answer it too.

Two compile-time diagnostics came out of writing the tests, both failure modes
a code generator will hit:

self.book passed as then= is a bound method, not a routable transition, so it
failed at runtime -- as a retrying durable step, which is the worst place to
learn about a typo. The handler-body guard already rejected self.step() calls;
it now rejects bare self.step references too and names the class form.

Passing a list of workflow classes where varargs were expected died with
"cannot use 'list' as a dict key" from inside the registry. register() now
says what it wanted.

The docs example was executed before committing.
Throttle deferred every excess start by exactly one window, so a burst of a
hundred with limit=10 admitted ten now and scheduled ninety for the same
instant one window later. That is not throttling, it is a delay line: the
downstream the throttle exists to protect sees the same spike, just later, and
the next window then holds ninety starts against a limit of ten.

Each start is now placed at least a window after the limit-th most recent
scheduled start under its key, which spaces the backlog at exactly the
configured rate and holds the sliding-window bound rather than a per-window
one: any window of length period contains at most limit starts, because a
start is always a full window after its limit-th predecessor.

This needs a new store query, nth_recent_start, since counting admissions in a
window cannot see where the deferred ones are already scheduled. A run's
scheduled start is when its root slot comes due, so a debounced or throttled
run counts at the time it will run, not the time it was admitted. Covered by a
conformance check, so a future store has to answer it the same way.

The regression test asserts the schedule directly (0, 0, 10, 10, 20, 20 for
six starts at limit=2 over ten seconds) and then advances the clock to confirm
the runs execute on it.
SQLite takes one writer, so a deployment was capped at one worker process per
database file. That is the ceiling that keeps this from being comparable to
Temporal or Inngest at all, and it is not something the kernel can fix -- it is
the store.

PostgresRunStore claims a run's frontier step with FOR UPDATE ... SKIP LOCKED,
so workers never queue behind each other and never take the same step. Adding
a process adds throughput. A worker that dies mid-step holds a lease that
another worker may only reclaim once it lapses, so a slow step is never
duplicated -- there is a test for exactly that, and one asserting twenty
non_idempotent_write runs across two kernels execute exactly once each, with
both kernels provably taking a share of the work.

The store is not trusted on assertion: the 29 conformance checks run against
it, and the whole workflow suite -- 260 harness tests -- now runs a third time
against a real server whenever REFLEX_TEST_POSTGRES names one. That third
parameter immediately paid for itself twice. It caught a test of mine that
asserted a race always starts every branch, which is not an invariant: a loser
cancelled before its first step is the better outcome, and only Postgres's
ordering exposed the assumption. And driving the real CLI against a Postgres
URL surfaced that each command ran its own asyncio.run, which is fine for a
file and fails for a pool, whose connections belong to the loop that opened
them. Commands now run in one loop.

Two things came out of the port. Pyright rejected the schema name spliced into
DDL, since psycopg types raw SQL as LiteralString; the name is now composed as
an identifier, which is a better guarantee than the check it replaces. And the
observer turned out to see only admissions and commits -- not attempt starts,
which are the spans a tracer actually wants -- so every recording site now
reports, correlated to its workflow.

A throwaway schema per test isolates them. Dropping one first evicts its own
backends, because a test loop that dies mid-transaction leaves a pooled
connection idle holding locks, and the DROP would otherwise wait on it
forever. That was a real hang, reproducible only under random test ordering.

Postgres is optional: pip install 'psycopg[binary,pool]'.
A run waiting on a decision is the most common human-in-the-loop shape, and
the naive implementation -- a URL carrying a run id -- is an open door. An
approval link here is an HMAC token over run, channel, payload, delivery key,
and expiry, so an edited link is refused rather than believed; it is spent
once; and it never decides on GET, because mail clients and scanners fetch
URLs before a person reads the message, so a link that approved on GET would
approve itself in transit. The secret comes from
REFLEX_WORKFLOW_APPROVAL_SECRET with deliberately no default, and a server
missing it says 'not configured' instead of masquerading as an expired link.
Executing the docs example before committing caught a real defect: a channel
declared with a pydantic model failed to serialize into the token -- and every
realistic channel is typed -- so payloads now go through the same reduction
the signal path uses.

Links need to know which run built them, which is a capability handlers were
missing generally. rx.current_run() now exposes the attempt's identity --
run, workflow, slot, attempt, epoch -- bound per attempt via a ContextVar
that to_thread carries into sync handlers, plus idempotency_key(): stable
across retries of a step, distinct across steps, which is exactly the
contract a payment API's idempotency header wants.

Chasing an intermittent test hang also root-caused a real defect: the worker
loop's  treated CancelledError as a retryable error, so
anything that cancelled the worker task without calling aclose() -- a task
group, a supervisor, an event loop tearing down -- waited forever on a task
that had gone back to polling. The hang reproduced with the fix removed and
disappears with it. The kernel also cancels its in-flight attempts on close
instead of leaving them running against a store nobody reads, and the test
harness closes stores it created, which previously leaked a Postgres pool
into every later test in the process.
A handler is the unit of retry, so a handler that makes three calls and fails
after the second repeats the first two on retry -- three charges for one
order. rx.step(name, fn, ...) runs a callable once, records its result
durably at the moment it returns, and replays it to every later attempt of
the same handler, including one recovered from a crashed worker. The journal
is epoch-fenced: an attempt whose lease was reclaimed cannot record, so a
zombie stops instead of duplicating a side effect. Results round-trip through
serialization before the handler ever sees them, so the first execution and a
replay produce identical shapes -- a difference there would only surface
during retries, the worst place to find it. Async handlers await the call;
sync handlers call it bare and block, bounded so a stalled loop fails the
step rather than pinning the worker thread forever. A name reused in a loop
is numbered per occurrence. Recorded keys appear in run history.

queue= on a durable handler has been accepted since the first commit and
silently ignored, which is the worst state a parameter can be in. It now
routes: every step is stamped with its handler's queue ('default' when none),
a worker claims only from queues it serves (rx.App(workflow_queues=...)), and
per-run order holds across queues -- a run whose frontier sits on an unserved
queue waits for the right worker rather than running the step somewhere it
was configured not to. Wait and join slots take the queue of the handler that
resumes them; children take their root's.

Both are covered by conformance checks, so all three stores answer the same
way, and the whole workflow suite passes against memory, SQLite, and
Postgres. The teardown watchdog added to the workflow conftest (REFLEX_DEBUG_HANG=1)
names any task that outlives its cancellation instead of hanging the run
silently -- built while chasing an intermittent suite hang that so far only
reproduces when several suites contend for one database.
@greptile-apps

greptile-apps Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR introduces an experimental durable-workflow engine for Reflex, including persistent stores, leased execution, triggers, flow control, composition, operator APIs, testing utilities, and documentation.

  • Adds workflow definition and runtime APIs built around durable rx.State handlers.
  • Adds in-memory, SQLite, and Postgres stores with recovery and conformance support.
  • Adds webhook, schedule, approval, signal, parallel-run, queue, retry, and operator-control functionality.
  • Adds extensive unit and integration coverage plus workflow documentation.

Confidence Score: 3/5

The PR is not safe to merge because two outstanding same-run lifecycle races can leave attempts untracked during shutdown or remove a live replacement attempt’s lease renewal.

_spawn still replaces _inflight[run_id], while _release_lease still removes _leases[run_id] without checking lease identity; overlapping teardown and reclamation can therefore evade shutdown tracking and allow a replacement lease to expire and be reclaimed.

Files Needing Attention: reflex/workflow/kernel.py and focused lease/shutdown regression tests in tests/units/workflow/test_lease.py

Important Files Changed

Filename Overview
reflex/workflow/kernel.py Implements concurrent leased workflow execution, recovery, retries, transitions, shutdown, and worker scheduling.
reflex/workflow/store.py Defines the workflow store contract and the in-memory and SQLite persistence implementations.
reflex/workflow/postgres.py Adds the multi-worker Postgres implementation with transactional claiming and schema namespacing.
packages/reflex-base/src/reflex_base/workflow.py Adds public authoring-time workflow configuration, trigger, policy, and transition value types.
reflex/workflow/definition.py Compiles workflow state classes and durable handlers into validated runtime definitions.
reflex/workflow/ingress.py Adds durable webhook admission with payload validation, signature verification, and deduplication.
tests/units/workflow/test_lease.py Covers lease renewal, loss, recovery, and shutdown behavior, but does not exercise overlapping attempts for one run.

Reviews (16): Last reviewed commit: "Pin two more contract clauses with confo..." | Re-trigger Greptile

Comment thread reflex/workflow/kernel.py
Alek99 added 23 commits August 19, 2026 12:26
I told the reviewer I did not understand this one, so here is what it
actually is. Returning a list from a handler preallocates an immediate
sequential chain -- kernel._interpret_return -- so [middle(), finish()]
creates both slots up front. A terminal failure in middle tombstones
finish behind it, and retry() reopens only the failed step. The retried
step succeeds, returns nothing to allocate, and the run completes having
never run the finalizer the chain existed for.

My earlier reasoning was wrong because I assumed chains are built a step
at a time, which is true of rx.after and not of a returned list.

Captured as a strict xfail rather than fixed: the fix belongs in
retry_run and skip_step in all three stores, restoring the successors
that failure tombstoned without reviving anything an unrelated
cancellation cancelled, and that is not a change to start on a budget
that cannot finish it.

The first version of this test xfailed for the wrong reason -- the test
harness has no retry(), so it was failing on AttributeError, not on the
defect. It goes through the kernel now. That the harness exposes cancel
but not retry/skip/resume is its own gap: operator recovery is exactly
what someone would want to test.
A provider numbers its events per object, not per topic, so
invoice_failed and invoice_paid for one invoice both arrive carrying
that invoice's id. The ingress used that value as the whole admission
key, and stores enforce uniqueness on (workflow_id, request_key), so the
payment was taken for a redelivery of the failure and dropped. One
workflow class serving an object's lifecycle -- the shape the API
invites -- silently loses events.

Keys are webhook:<handler_id>:<value> now. Two deliveries are the same
event only if they would start the same handler.

Changing a key format is itself a hazard: every run admitted under the
old spelling would stop being found, and the provider's next redelivery
would start it again. kernel.start() takes superseded_keys, matched for
deduplication and never written, and the ingress passes the old bare
value. Nothing replays across the upgrade.

Found by a live-fire review over real sockets and processes, which also
confirmed the parts that hold: 401 on forged signatures, 64 concurrent
redeliveries collapsing to one run across two servers, and recovery of a
WAITING run across a full process restart.
My own regression, an hour old. Scoping fresh webhook keys by handler
fixed the cross-topic collision; the compatibility lookup I added in the
same commit matched the old unqualified key against any run in the
workflow, which is precisely the collision again -- invoice_paid
deduplicating against a legacy invoice_failed run and being dropped.

A superseded key is a less specific spelling than the one that replaced
it, so a match on it means the same event only if it started the same
root. It is checked against the existing run's first step now.

Caught by a live-fire review over real sockets, which reproduced it as
202 deduplicated pointing at the wrong run.
The production-readiness review's P0: every advertised start policy
violated its invariant in 50 of 50 synchronized trials across two OS
processes and independent PostgreSQL pools. The policy decision was a
read in one store call and a write in another, guarded by an asyncio
lock -- which serializes one process, and a fleet is not one process.
Postgres row locks cannot fix it alone: with no active run there is no
row to lock, so both admitters count zero and both insert.

The whole decision -- dedupe, every policy read, any policy mutation,
and the insert -- now executes inside one store transaction under a
durable lock on (workflow_id, flow_key): pg_advisory_xact_lock on
Postgres, BEGIN IMMEDIATE's write lock on SQLite, the store lock in
memory. The kernel builds a FlowGate and hands the store the records;
admit(max_active=) is gone, one contract with one enforcer.

Semantics pinned by conformance checks and by cross-instance race tests
that open two stores -- two pools, no shared Python state -- over one
database: skip admits exactly one; rate limit of one rejects the loser;
throttle spaces a racing pair a full window apart; debounce coalesces
the loser into the winner and, fixing the review's P1 alongside, takes
the burst's LATEST payload; singleton cancel leaves at most one
uncancelled run. All pass against real Postgres 16.

Fan-out branches are written by the parent's commit, not through policy
admission, so a policy-decorated branch root is now refused with a
teaching error instead of having its policy silently bypassed -- and
because that is a definition problem, WorkflowDefinitionError joins
BUG_EXCEPTIONS and fails the run on attempt one instead of retrying a
deterministic error.

Singleton cancel changes shape slightly: the incumbent's cancellation
intent is recorded in the admitting transaction and the replacement is
admitted immediately, so at most one non-cancelling run exists per key
at every instant, from any number of processes; incumbents drain to
CANCELLED asynchronously as any cancelled run does. The contract states
all of this in section 1.
The review's second P0. A handler returning a list preallocates an
immediate sequential chain, so [middle(), finish()] creates both slots
up front; middle's terminal failure tombstones finish, and retry or skip
reopened only middle. The retried step succeeded, returned nothing to
allocate, and the run completed COMPLETED/None having never run the
finalizer -- an operator repair that silently dropped the cleanup step
is worse than the failure it repaired.

Both actions now restore every CANCELLED slot in the run, with fresh
budgets, recorded as step_restored. The causality argument for restoring
all of them rather than tracking which failure cancelled what: these
actions accept only FAILED or NEEDS_ATTENTION runs, run-level
cancellation ends in a CANCELLED run they refuse, force-finalization
leaves no failed or suspended step for them to target, and suspension
tombstones nothing -- so a CANCELLED slot in an acceptable run has
exactly one possible source. No schema change needed.

Fixed in memory, SQLite and Postgres; the strict xfail that pinned the
defect is now the passing regression test, joined by the skip variant,
and the operator suite passes against real Postgres 16.
The review's SQLite scaling cliff. claim_next selected every active run
and loaded each run's steps until it found a claimable frontier;
next_due did the same with one more query per run. Ten thousand
durable timers -- the thing a workflow engine exists to hold -- meant
~114ms per idle poll, a worker burning a third to half a core doing
nothing, and burst drains that looked quadratic (5,000 roots: 188s).

Both queries are now one SQL statement: filter to steps whose status
and due time can wake -- through a new (status, due_at, queue) index --
then pay the frontier check (NOT EXISTS a lower unresolved ordinal,
a primary-key lookup) only for those candidates, and stop at the first
winner. A store full of sleepers answers from the index range and
touches nothing: measured on the reviewer's 10k-sleeper shape,
next_due 61.8ms -> 4.8ms and claim_next 88.1ms -> 0.05ms, both under
their proposed 5ms release gate.

Claim order among concurrently-due runs changes from run creation time
to due time, which is scheduling fairness the contract does not promise
and arguably the better order -- oldest-due first.

A plan-pinning test asserts EXPLAIN QUERY PLAN uses the wake index for
both query shapes, because a timing assertion flakes on CI and the
plan is the actual performance contract. The full workflow suite,
conformance included, passes on all three stores with Postgres real.
Three review findings, one boundary.

dedupe_by could only name a payload field, and GitHub's canonical
delivery identity is the X-GitHub-Delivery header -- it appears nowhere
in the body. Keyed on a payload field, two distinct deliveries sharing
that field collapsed into one run, and with no dedupe_by at all a true
redelivery executed twice. dedupe_by="header:Name" reads the header;
distinct GUIDs are distinct runs and a repeated GUID deduplicates,
pinned by test.

A configured identity that could not be extracted silently disabled
deduplication -- the request was admitted with no key, so every
redelivery of that event ran again, defeating the exact thing the
configuration asked for, invisibly. That is a 400 now, naming the
missing source, so it surfaces as the config problem it is.

rx.hmac_signature claimed to cover Stripe and cannot: Stripe signs
timestamp.body, sends a structured header, and requires a replay
window. The claim is corrected -- with a test that the docstring stays
honest -- and rx.stripe_signature() implements the real scheme:
t=/v1= parsing, multiple v1 digests for secret rotation, tolerance
enforced both directions, constant-time comparison.
Fan-out children had no admission history: their record began at
attempt_started, so runs_started reported one start for a four-run
graph and every history reader met runs that were apparently never
admitted. The creating transaction now writes run_admitted and
step_scheduled per child -- one shared helper, three stores -- and the
kernel reports the same events to the observer, so the dashboard's
starts reconcile with its terminals.

Opening the SQLite store ran CREATEs and an immediate-mode migration
unconditionally, so an operator's read-only stats against a busy worker
failed 4 times in 5 with 'database is locked'. The schema version is
stamped in PRAGMA user_version now; a current database opens with no
write lock at all, and a locked store renders as an actionable one-line
error instead of a traceback. The legacy-migration test now also resets
user_version in its simulation, because a genuinely old database has no
stamp -- that is the very thing that triggers DDL.

There was no retention surface: terminal data grew forever at ~1.7KB a
run unless an operator did out-of-band SQL. purge_runs(before) deletes
stale terminal runs and their steps, history, inbox, substeps and
dedupe rows in one transaction on all three stores, conformance-pinned,
with 'reflex workflows purge --older-than 30d' in front of it. The
documented tradeoff: purging forgets request keys, so retention must
exceed the provider's redelivery horizon.

run_until_idle() could return with attempts from its own scheduling
round still live -- a round starts several and waits only for the
first -- handing tests a half-processed graph whose survivors the
harness then cancelled. It now drains exactly the attempts it started,
tracked per pump so a concurrent caller's work is never waited on;
the first version of this fix waited on all in-flight attempts and
deadlocked the lease tests, which own deliberately-hanging handlers.
The review's P3. GitHub can be configured to deliver
application/x-www-form-urlencoded bodies carrying payload=<json>, and
ingress unconditionally json.loads'd the raw body, so a correctly
signed delivery came back 400 'payload is not JSON' -- an error that
tells the operator nothing about which knob to turn. The signature was
never the problem: it covers the raw form bytes and is checked against
exactly those bytes before any parsing.

Form bodies now unwrap the payload field before JSON parsing; a form
body without one is a 400 that names it.
A 48-agent review over the six commits -- four lenses, two refuters per
finding -- confirmed five defects in the new code itself.

Postgres singleton-cancel could resurrect a finished run: the active
SELECT is an unlocked snapshot, workers committing runs never hold the
flow lock, and the cancel UPDATE had no non-terminal guard -- so under
READ COMMITTED it waits out a concurrent completing commit and flips
the COMPLETED run back to CANCELLING, which the next finalize sweep
drives to CANCELLED over a terminal status. The guard request_cancel
always had is now on this UPDATE too, and the cancelled list comes from
RETURNING instead of the stale snapshot, so the kernel is never told a
run was cancelled that actually completed.

Postgres admit_flow's dedupe reservation was a plain INSERT after a
plain SELECT; the advisory lock is keyed on the flow key, and two
admissions can share a request key while computing different flow keys,
so the loser raised UniqueViolation out of kernel.start where the
contract promises a deduplicated admission. Same ON CONFLICT shape as
admit() now.

Memory purge_runs never deleted substep journals -- and popping by
run_id would have silently missed anyway, since the journal is keyed by
(run_id, ordinal). SQLite and Postgres purged theirs; parity restored.

run_until_idle kept every finished task in its own-set for the whole
call and its tail wait had no CancelledError handler, so cancelling a
pump parked there left its attempts running unsupervised -- the exact
contract _tick's wait already honors.

FlowGate refuses combined policies in __post_init__. The decorator
already enforces one policy per root, which is why the review's three
divergence findings about combinations were refuted as unreachable --
but the stores genuinely disagree on what a half-applied combination
leaves behind, and unreachable-because-callers-are-polite is not an
invariant. Now it is unrepresentable.

stripe_signature rejects non-finite timestamps: NaN compares false
against both window bounds and sailed through -- harmless today only
because the signature check follows, and the same NaN class bug was
already found once in approval expiry.
The adversarial review's last confirmed finding was a contradiction
between two sentences this branch added: section 1 promised a singleton
holds 'at every instant', and section 9's retry re-opens a failed run
without re-checking the gate -- so an operator retrying next to an
already-admitted replacement puts two runs on one key.

The behavior is the right one: a policy governs admissions, and an
operator re-opening a run is a human override, not an admission. An
engine that silently refused a repair because a policy would have is
harder to operate, not safer. The contract now says exactly that, in
both places, instead of promising an instant-by-instant invariant the
operator surface deliberately does not enforce.
Third live review round, three regressions from tonight's own commits.

The Postgres flow-gate reserved its dedupe key AFTER the policy
mutations. Two deliveries of one event can compute different flow keys
-- different advisory locks, no serialization between them -- so the
loser ran the singleton-cancel branch against the OTHER key's incumbent,
then hit the reservation conflict, returned 'deduplicated', and
committed the cancellation anyway: an unrelated live run killed by an
event that had already been handled, 20 times in 20 trials. The
reservation is now the first write, exactly as in admit(), so a
duplicate exits before it has mutated anything. Regression test races
the exact scenario on all three stores and fails on the old ordering.

The user_version gate used inequality, so an older binary opening a
newer database re-ran its DDL and stamped its own OLDER version over
the newer one -- a silent downgrade that would make the newer binary
re-migrate. Strictly upward now; a future stamp is left alone.

Both cancellation-cleanup sites in the kernel -- _tick's wait and
run_until_idle's tail wait -- called _cancel_inflight(), which stops
every attempt in the kernel, including ones a concurrent pump owns. One
caller's timeout became another's abandoned work. Each site now cancels
only the attempts that pump started; aclose() still sweeps everything,
which is its job.
The reviewer's time-edge list starts with NaN/infinity durations, and
this is the third bug of exactly this class in the engine -- approval
expiry and Stripe timestamps had it first. NaN compares false against
every bound, so float('nan') sailed past parse_duration's negativity
check and poisoned every due-time comparison downstream; infinity
turned timers into never-fires.

parse_duration is the single choke point every timeout, retry delay,
debounce window, rx.after target, and tolerance flows through, so the
guard lives there: non-finite is a WorkflowDefinitionError, which since
tonight also fails a run on its first attempt instead of retrying.
The verified reviewer's top remaining blocker: worker wall clocks skew,
and every scheduling comparison assumed they did not. A worker running
fast saw a peer's live lease as lapsed and reclaimed the claim --
duplicating exactly the work leases exist to prevent -- and admitted
schedule occurrences before their time.

The store is the one thing every worker shares, so its clock is the
authority. RunStore.epoch_time() answers with the store's own time --
clock_timestamp() on Postgres; None from SQLite and memory, whose
single-host process clock already is the shared authority. A kernel
constructed with the DEFAULT clock measures its offset against that
answer, taken against the request midpoint so the measurement is off by
at most half a round trip, at startup and again on every recovery pass.
All time reads go through the offset, so lease expiry, due times, and
occurrence keys use one time base across the fleet; measured offset
against a real Postgres 16 was +9.7ms.

Injectability is preserved exactly: an explicitly provided clock -- the
test harness's virtual time, the dev CLI's fast-forward -- is
authoritative as given and never synced, which is why all ~1,560
existing workflow tests pass unchanged. Regression tests pin the sync
math, that store time decides due-ness rather than the worker's local
clock, that injected clocks are never second-guessed, and that a store
with no clock of its own is asked exactly once.
The verified reviewer's second blocker: work could commit after its
deadline, and a signal could report success and then be discarded.

Both were the same hole. The contract says a run past its deadline
finalizes TIMED_OUT once drained -- but nothing guaranteed drained. An
attempt that outran cooperative cancellation committed anyway, so a run
the caller was told had timed out could quietly become COMPLETED. And a
delivery to a past-deadline run was answered 'resolved' even though the
continuation could never execute: claims exclude past-deadline runs, so
the sweep finalized TIMED_OUT and the recorded decision evaporated.

Commit now re-reads the run's deadline inside the same transaction that
validates the claim fence -- one added column on the existing claim
check, no extra round trip -- and refuses with DeadlinePassedError when
it has passed. The kernel abandons the attempt (recorded substeps stand,
crash-equivalent), releases the slot so the run drains immediately
instead of waiting out a lease, and the sweep's TIMED_OUT is the only
reachable outcome. Deliveries to past-deadline runs are refused as
'expired' in all three stores, so 'resolved' is never said of a decision
about to be discarded.

The contract's failure matrix gains both rows -- caught first by its own
bidirectional reason guard, which is exactly the drift it exists to
stop. Both fences are regression-tested and fail on the unfenced tree;
the full workflow suite passes on all three stores against real
Postgres 16.
Three from the reviewer's list, each verified before fixing.

Retry backoff overflowed: multiplier ** 499 raises OverflowError, and
delay_for_attempt runs inside the kernel's completion path, so a
long-retrying step would have broken the worker loop rather than the
run. The overflow saturates to max_delay, which is what a delay
astronomically past the cap means anyway.

Schedule catch-up loss was silent AND the contract lied about it: the
failure matrix claimed the skipped remainder got a history record, and
no such record was ever written -- there is no run to attach one to. A
worker back from a week of downtime now warns with the count and the
window when it skips past the cap, the cursor jump is no longer
mistakable for coverage, and the contract describes what actually
happens. My bidirectional reason guard could not catch this drift --
no reason string involved -- which is a useful reminder of its limits.

The sync-handler drain question resolves to wording, not code: a thread
cannot be interrupted, so no drain budget can bound how fast a sync
handler stops -- the same fact that makes timeout= a compile error on
sync handlers. The contract now says the budget bounds how long
cancellable work is waited for, never how fast a thread can be made to
stop, and points long sync work at async-around-rx.step.
The bounded half of the reviewer's typed-durability item, shippable
without the wire-format decision the rest of it needs.

Decimal('10.10') was stored as float 10.1: the serializer registry
converts it, so the type people reach for to avoid precision loss lost
precision silently -- in a refund handler that is a money bug with no
error anywhere on its path. bytes and bytearray became lists of
integers nothing ever turns back into bytes. Both are refused at record
time with errors that name the fix (str+Decimal or integer minor units;
explicit base64), and since TypeError is a bug-class error the attempt
fails once instead of burning retries. Tuples and sets still become
lists -- that is JSON's shape, round-trips losslessly enough, and
refusing it would break every workflow returning a tuple.

The HTTP start endpoint accepted arguments the handler's signature
refuses -- a 202 and a poison run instead of the 400 that names the
caller's bug. Supplied arguments now validate against the handler's
declared type hints; an exotic hint pydantic cannot adapt skips
validation rather than blaming the caller for it.

What remains of typed durability -- a type-preserving state encoding
and schema evolution -- changes the wire format and intersects the
API freeze, and stays a design decision rather than a patch.
Cancelling a rollout left its regional deploys deploying. Two unrelated
real-world scenarios -- a CI/CD rollback and a fleet rollout -- hit this
as a bug, and they are right: an operator presses cancel to stop the
blast radius, and a button that stops only the bookkeeping run has not
done the one thing it exists to do. Section 5 had chosen the other side
deliberately ("delegation is not ownership"); that default is wrong and
this changes it before anyone depends on it.

Any terminal transition of a run -- cancelled, failed, timed out,
force-finalized, completed -- now requests cancellation of every branch
it fanned out that is still running, in the SAME store transaction. Not
follow-up: a worker that dies mid-follow-up is exactly the case where
the deploys keep going. rx.parallel(..., parent_close="abandon") keeps
the old behaviour for work that should genuinely outlive its starter.

Each level closes only its own branches. That looks like it could
deadlock -- a tier waiting on shards that only get closed once the tier
finalizes -- but a run blocked on its own join holds no claim, so it is
control-pending the moment it is marked, finalizes, and closes the level
beneath it. A three-level test pins that.

Also backstops mode="first" race losers, whose cancellation was
best-effort follow-up from one worker; a loser is still not fenced at
commit while the parent lives on, so the side-effect warning stands.

The conformance suite -- the mechanism that is supposed to keep the
three stores identical -- was only wired to memory and sqlite. Postgres,
the one store whose SQL is hand-written, was not in it. It now joins
whenever REFLEX_TEST_POSTGRES is set; all 48 checks pass against a real
server, including the two new cascade checks. Overriding the harness
store fixture drops the run from 432 cases to 144 by not crossing two
independent store parameters.

The old all-mode test that asserted branches were left alone now runs
under parent_close="abandon", where the bug it actually guards -- a
tombstoned join misread as a decided race -- still shows.
retry, skip, force_complete and force_fail, matching what the CLI
exposes. Rehearsing repair is most of what a workflow test needs to do,
and reaching through harness.kernel for it read as private API -- which
also meant a test reaching for a helper that was not there failed with
AttributeError, and an xfail swallows that as a pass. That has bitten
this suite before.

The test that covers them documents three engine behaviours that make
the obvious version of it wrong: a failed attempt discards its state, so
an attempt counter has to live outside the run; skip only restores a
successor that was preallocated, so the fixture needs a real chain; and
force_* is refused on a terminal run, so it needs a run that is
nonterminal and drained -- one sitting on a timer.
Section 4 documents that the engine does not pin a run to the code that
started it, which invites the reading that pinning is a missing feature.
It is not: it is a deployment concern, done by routing admissions, and
the engine rules hold underneath whatever routing exists. Saying so
keeps the contract and the (unbuilt) deploy layer from drifting into two
different answers to the same question.
The suite simulated crashes in-process -- abandon a claim, expire a
lease, commit behind a fence. That tests the store's logic and says
nothing about what reached the disk, because the process that was
supposed to have died is still there to tidy up. "Kill any process at
any boundary" was an untested claim.

These SIGKILL a worker subprocess at a named boundary and make a fresh
process produce the documented outcome:

  before the effect      -> work simply undone, runs once on recovery
  after an unguarded one -> repeats (section 2, and the reason rx.step exists)
  after a journal write  -> replays, never charges twice
  after parent finalize  -> branches already CANCELLING on disk

The last is the one the cascade design turns on: the process that
finalized dies with no chance to do anything else, so if branch
cancellation were follow-up work the regions would still deploy.
Deleting the _close_children_sql call makes exactly that test fail,
which is the check that it is measuring what it claims to.

Two ways this kind of test lies, both closed: an effect lost to the page
cache would make a repeat look exactly-once, so the ledger is fsynced
before each kill; and a worker that exits cleanly would pass every
assertion that follows, so each scenario asserts it died by signal.

Contract section 8 now says which claims rest on simulation and which on
real kills, because the difference is the whole point.
Auditing section 1's exit criterion by re-reading the prose was not an
audit. Enumerating what the engine can actually show an operator -- run
statuses, step statuses, start and delivery dispositions, history events
-- and checking each against CONTRACT.md found real holes, in both
directions.

wait_expired was declared and never emitted. A resolved wait records
wait_resolved; an expired one recorded nothing, so "did the approval
come through, or did nobody answer?" -- the exact question history
exists to answer -- had to be inferred from which handler ran next. Now
recorded, with the wait key and the timeout branch that ran.

A signal deduplicated by sender key was silent too, which makes "the
provider says it delivered" indistinguishable from a delivery that never
arrived. Recorded now in the store's own transaction, so it is durable
like every other history event, in all three stores. Getting there took
two wrong patches: the memory and Postgres deliver() paths sit next to
_apply_arrival(), whose duplicate branch is textually identical and
means something else entirely -- a duplicate child arrival, not a
duplicate signal. The conformance check added here is what caught the
Postgres one, on a real server.

signal_delivered is deleted rather than emitted: every accepted delivery
already records wait_resolved or signal_buffered, and a third event for
the same fact is noise. A vocabulary word that never appears makes its
own absence uninformative, which is worse than not having it.

Section 10 now defines the whole vocabulary, and
test_contract_vocabulary.py fails if a member is added without being
documented, or documented without anything emitting it. Both directions
are mutation-checked. That turns "every failure scenario has one
documented outcome" from a claim into something the suite enforces.
Two phase-3 items, found by walking the thing as a new developer rather
than by reading it.

RunHandle.result() returned Any. A result crosses the store as JSON, so
callers got dicts back whatever the handler passed to rx.complete, and
the type checker had nothing to say about it. It is now generic, and
result(as_type=Receipt) returns a Receipt -- pyright infers it (checked
with reveal_type) and pydantic validates it, so a result that does not
fit raises here naming the run instead of becoming an AttributeError in
the caller two frames later.

Full inference from Orders.place(order) to RunHandle[Receipt] would mean
threading generics through Reflex's @rx.event descriptor machinery,
which every Reflex app shares. That is a bigger and riskier change than
this one and is Alek's call, not mine.

The CLI took full run ids only. "workflows dev" prints eight-character
prefixes and "workflows list" prints full ids, so reading one and typing
into the other -- the normal way these get used together -- gave "No run
'ca40d354' in this database" for an id the tool had just printed. Every
command that takes a run id now resolves a prefix the way git does. An
exact id is looked up directly and pays nothing; an ambiguous prefix
refuses and names the candidates, because acting on the wrong run is
worse than being asked to be specific.

That also splits an error that used to run three causes together. A run
that does not exist now says so; "unknown, already finished, or held by
a worker" stays for a run that does exist and still cannot be finalized.
@Alek99
Alek99 force-pushed the alek/workflows-mvp branch from b775638 to 399277b Compare August 20, 2026 18:14
Alek99 added 6 commits August 20, 2026 11:20
The observer seam was built for this -- MetricsObserver's docstring
promises "a metrics endpoint or an OpenTelemetry exporter is a few lines
over snapshot()" -- and nothing had cashed it.

A span here covers one ATTEMPT, not one run. That is the whole design
question and it only has one answer: a span is in-process and
time-bounded, and a durable run can wait a day, survive a restart, and
execute its steps on different machines. Modelling a run as a span would
mean holding one open across processes, which OpenTelemetry cannot do
and no backend would draw. Every span carries workflow.run_id instead,
so "everything that happened to this run" is an attribute search across
however many attempts it took. Steps that never ran produce no spans,
because nothing executed to time.

Counters reuse MetricsObserver's event-to-name mapping rather than
restating it. Two exporters that disagree about one deployment are worse
than one exporter, and a test runs both observers over the same runs and
asserts they report the same numbers.

OpenTelemetry is not a Reflex dependency and this does not make it one:
the import is lazy, so importing the module without it works, and
constructing the observer raises with the pip line. Both paths are
verified. It is in the dev group so CI actually exercises the code
rather than skipping the file and shipping it untested.

Tested through the real SDK's in-memory exporter and reader, not a mock
-- whether a span actually ends, whether a failure survives into its
status, and whether the counters agree are not questions a mock can
answer.
Phase 5's bar is that each feature graduates only once its crash, race,
authorization and versioning semantics are tested. Checking that
directly, rather than assuming the general tests cover the specific
features, turned up two gaps and one pleasant surprise.

Authorization: I expected an unverified webhook to be an open
run-starter. It is not -- compiling refuses one outright unless someone
passes allow_unverified with a non-empty reason, which is better than a
warning. But once opted in, doctor said nothing, and doctor exists for
"the things whose absence is silent". The refusal protects whoever
writes the webhook; whoever deploys it a year later is a different
person reading a different surface. It is a note, not an error, because
they did opt in -- it just should not be invisible.

Versioning: the existing tests all deploy over plain sequential steps. A
wait's continuation and a fan-out's join reach their handler by another
route, carrying an injected payload or results list, so the
compatibility gate applying to them as well was an unverified
assumption. It does hold, and both are now pinned: removing the handler
suspends the run as unknown_handler instead of raising inside a worker
that is serving every other run in the deployment.

The join test is mutation-checked -- restoring the removed handler makes
it fail -- because a versioning test that passes for the wrong reason
looks exactly like one that passes.
I had been reporting reflex deploy as blocked in "the hosting repo".
That was wrong and I should have checked earlier: the deploy command is
in reflex/reflex.py and the hosting CLI is vendored at
packages/reflex-hosting-cli. Reading them settles what is actually
external and what is not.

What is not: the first thing a workflow-only project hits is
assert_in_reflex_dir(), failing with "rxconfig.py not found. Move to the
root folder of your project, or run reflex init to start a new project."
For someone who just ran `reflex workflows init` -- which writes one
module and no rxconfig.py on purpose, because there is no frontend to
configure -- that instruction means scaffolding the web app they
deliberately did not ask for. Deploy now recognises the case, names the
modules holding workflows, says why it cannot proceed, and gives the
command that does work.

What is external, and stays external: hosting zips a backend and then
unconditionally zips a frontend (cli.py, the two export_fn calls), and
the service has no notion of an app that is workers plus a headless
ingress. Making that work is a change to a running service I cannot
reach or verify against from here, so this refuses honestly rather than
half-implementing a path that would fail further in.

The guard reads the .py files in the working directory, so it tolerates
one that is not decodable text -- a check that crashed deploy before it
started would be worse than the message it replaces.
All reproduced first, all fixed, all verified against a real server.

1. Deadline signals lied on Postgres. deliver() selected only status,
never deadline, so a delivery to a past-deadline run answered "resolved"
where Memory and SQLite answered "expired". The run then finalized
TIMED_OUT and dropped the payload -- a person clicking approve was told
their decision landed, moments before it was discarded. The conformance
suite had no check for it, which is why three stores diverged quietly;
it has one now and it failed on Postgres alone before the fix.

2. Cancelling a fan-out deadlocked against its branches. Closing a
parent takes the parent row then its children; a branch reporting home
takes itself then its parent. Same two rows, opposite orders. A probe
measured 130 aborted transactions across 40 rounds.

Fixed by ordering, not by retrying: both transactions now take branch
rows first, so everything acquires children before self before parent --
deeper before shallower, which cannot cycle. 0 in 150 rounds after.
Retrying would have left the latency spike and, where recovery had to
converge, a cancelled rollout still running until a lease lapsed, which
is the outcome cascade cancellation exists to prevent.

3. Store time moved when the wall clock moved. The offset was added to
time.time() on every read, so an NTP step or a resumed snapshot carried
the worker with it; it would renew its lease to a moment the store
considered past, lose the claim mid-attempt, and let a peer reclaim the
step. Time is now carried from the last sync by time.monotonic, which
cannot jump. Only real drift remains, and the next recovery corrects it.

The test for 3 initially passed while measuring nothing: the kernel
decides whether to sync by identity against time.time, and
monkeypatching it replaces the object the default argument was bound to,
so the sync path silently turned off. It now passes the clock
explicitly and asserts the path is live before testing it.
Five smaller defects, all reproduced before fixing.

force_complete took the operator's result unchecked, so Memory stored a
live Decimal that no other store could hold and SQLite raised a bare "not
JSON serializable" from inside json.dumps. Same input, three behaviours,
none of them actionable -- and the Memory case only surfaced on the day
someone moved to Postgres. It now goes through the same strict serde as
a handler's result and all three refuse identically.

CLI prefix resolution scanned the newest 10,000 runs and treated a
single hit as unique. On a bigger store another run just outside that
window could share the prefix, and the next thing the resolver feeds is
cancel or complete. There is no prefix query in the store protocol to do
better, so it now refuses what it cannot prove and says to pass the full
id.

Retry.multiplier accepted nan and inf, because every comparison against
nan is False and "< 1.0" was the only guard. The backoff then scheduled
a step for a moment that never arrives.

Cron could not see across a skipped leap century: 2100 is not a leap
year, so 2096 to 2104 is eight years and the 1500-day horizon reported
no occurrence for a perfectly good expression. Separately, 0 0 30 2 *
parsed happily and then never fired, which is indistinguishable from a
schedule that is merely waiting -- now a definition error. A weekday
restriction still makes it legal, because cron matches day-of-month OR
day-of-week and the date has a second path.

Occurrences dropped past the catch-up cap now increment a counter on
both exporters. They have no run to attach history to, so a log line was
the only trace, and noticing that a nightly job silently stopped a week
ago is exactly what a counter is for.
Every item reproduced before fixing, verified against a real server.

Arrival deadline parity. record_arrival accepted arrivals to a
past-deadline parent on all three stores, and then the atomic path
disagreed: Postgres refused, Memory and SQLite resolved the join. The
same fan-out behaved differently depending on what was behind it, which
is worse than either answer alone. All four paths now refuse it as
expired, and a conformance check covers them.

Schedule seeding. _started_at was captured in __init__, before the
first clock sync, so a worker running slow seeded a brand new schedule
behind store time and backfilled occurrences from before the deploy.
Taken at the end of the first recovery pass instead -- still "when this
worker started", now on the store's clock. My first attempt moved it to
the first sweep, which is not the same thing and quietly broke restart
catch-up; the schedule tests caught it.

Empty run id. `cancel "$RUN_ID"` with the variable unset arrives as an
empty string, which prefixes every run, and with one run in the database
it cancelled it and reported success. That was mine, introduced with
prefix support. Refused now.

The second Postgres deadlock. recover_orphans locked step rows and then
updated their runs; commit takes the run and then the step. The store's
stated invariant is run-first and recovery was the single path breaking
it, so it now locks the run rows -- which serializes exactly as well,
because every writer already obeys that order. 56 aborts in 30 rounds
before, 0 in 60 after. Ordering again, not retries.

Also: forced-failure error payloads go through strict serde like
results; the prefix scan reads one past its cap so a database holding
exactly the cap is not treated as truncated; missed occurrences are
counted without a ceiling, because the count is what an alert fires on
and the bound existed to limit catch-up, not accounting.

And Postgres runs in CI. The conformance suite tests every store it can
reach, so with no server the Postgres rows skipped and said nothing --
which is how store-specific divergence got as far as review. The
workflow suite now runs a second time against a real server on Linux.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant