Files
Peter Wielander 855e47990c [core] Make a duplicate attr_set inert instead of terminal (#3849)
* [core] Make a duplicate attr_set inert instead of terminal

A workflow-body attribute write draws a correlation id that resolves exactly
once: the dispatcher's consumer takes the matching event and deregisters. A
second event under that id therefore has no callback left and never will.

`attr_set` had no entry in ENTITY_EVENT_CLASS_BY_TYPE, so the duplicate skip
could not take it, and `PARKABLE_EVENT_TYPES` does list the type, so it was
parked for a consumer that could never come. Parking is settled by the workflow
function returning, and a survivor there is reported through `strandedEvent` as
a replay divergence. So the run did all of its work, every step succeeded, and
the final replay failed it, deterministically enough to burn the whole
replay-divergence recovery budget and terminate with CORRUPTED_EVENT_LOG.

Give `attr_set` a class so the straggler is skipped like every other one:
committed but inert. Parking still covers the first arrival, for a replay that
walks past an attribute event before the body reaches the call that claims it.
An attribute write from a step body carries no correlation id and is consumed by
the structural lifecycle consumer, so it is unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* [core] Release a parked duplicate, and agree with the UI about one

The class map alone decides a straggler only where the walk meets it after a
consumption recorded the class. When neither copy has a consumer yet both
park — the walk steps over the first and re-enters in the same tick, with
nothing consumed and so no class recorded — and the drain then claims one and
holds the other for a callback that will never be registered. That survivor is
`strandedEvent`, which is the CORRUPTED_EVENT_LOG this branch set out to stop,
reached by the other road. `dropParkedDuplicates` releases it on the same terms
the walk skips one. Not an `attr_set` property: `wait_completed` parks in pairs
too, and `ONE_SHOT_EVENT_TYPES` only sees the order where the consumption came
first.

Giving `attr_set` a class also moved the observability UI, which reads the same
`entityEventClass` to grey out events a run passed over. It kept treating the
straggler as live, because its terminal-class set had no `attr_set` while the
dispatcher's consumer does deregister on the first event under an id. The two
now share `classifyEntityEvent` and `TERMINAL_EVENT_CLASSES` rather than each
keeping a copy of the rule.

That sharing needs the entity rule to be exact, because a step-written
`attr_set` carries no correlation id: keyed on the run it would collapse every
attribute write a run made into one class, and a captured production log in
`__fixtures__` holds forty. `classifyEntityEvent` gives such an event no class
at all, so neither side can read the second as a repeat of the first.

The shared fixture corpus had nothing for `attr_set`, which is why the drift
between the two halves went unseen. It has four now, and each of them fails on
both sides without the fix above it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Pranay Prakash <pranay.gp@gmail.com>
2026-08-28 11:45:56 -07:00
..
2026-08-26 12:36:41 -07:00
2026-08-26 12:36:41 -07:00

@workflow/world

Core interfaces and types for Workflow SDK storage backends.

This package defines the World interface that abstracts workflow storage, queuing, authentication, and streaming operations. Implementation packages like @workflow/world-local and @workflow/world-vercel provide concrete implementations.

Used internally by @workflow/core and world implementations. Should not be used directly in application code.

Implementation constraint: no mutable module state

A World implementation must not keep mutable state at module scope. Hold it on the World instance, or, when it is genuinely process-wide (an ID generator whose sequence must not fork, a log-once latch), on globalThis via globalSingleton() from @workflow/utils.

@workflow/world-local and @workflow/world-vercel are bundled into the host application's server build, and a bundler keys module identity on (resource, layer): Next.js alone compiles instrument, app-route, ssr and edge as separate module graphs, so one process holds one copy of every module in these packages per layer. A top-level let, or a const holding a Map, is therefore per-copy state rather than the singleton it reads as.

A world loaded at runtime through WORKFLOW_TARGET_WORLD is deduped by Node's module cache and does not have this problem today, but that is a property of how it is loaded, not of how it is written, and it has changed before (vercel/workflow#3493). scripts/lint/module-scope-state.mjs enforces the rule across every published world package; see docs/content/worlds/*/building-a-world.mdx for the author-facing version.