Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
93bf3a3
Slot event identity: client half
VaguelySerious Aug 3, 2026
ac429ed
world-local: one hook_received per delivery, dense across a converged…
VaguelySerious Aug 3, 2026
20e2752
Make slot event identity unconditional and squash the slot changesets
VaguelySerious Aug 3, 2026
b00b729
Drop two changesets folded into the slot identity entry
VaguelySerious Aug 3, 2026
9d3c7b3
Merge origin/main into peter/slot-event-identity
VaguelySerious Aug 3, 2026
5e38605
Merge remote-tracking branch 'origin/main' into peter/slot-event-iden…
VaguelySerious Aug 3, 2026
ee83cb4
Make per-kind correlation ids the only scheme
VaguelySerious Aug 4, 2026
5cb2cd0
Regenerate correlation-id fixtures against per-kind sequences
VaguelySerious Aug 4, 2026
dc1ef9e
Merge remote-tracking branch 'origin/main' into peter/slot-event-iden…
VaguelySerious Aug 4, 2026
4505f69
fix(world-local): keep a hook retry pinned to the canonical event pos…
VaguelySerious Aug 4, 2026
d28a8b8
test(world-postgres): issue a batch of slot claims the way the client…
VaguelySerious Aug 4, 2026
98692e4
fix(core): preserve QuickJS hook retention
NathanColosimo Aug 4, 2026
342c64c
fix(core): reject webhook hook retention in QuickJS
NathanColosimo Aug 4, 2026
91e709b
Merge branch 'codex/fix-quickjs-hook-retention' into peter/slot-event…
VaguelySerious Aug 4, 2026
1f33f51
Fix: The QuickJS workflow engine accepts `experimental_minRetention` …
vercel[bot] Aug 4, 2026
29c3515
style: wrap two long lines biome would reformat
VaguelySerious Aug 4, 2026
bd175d4
fix(core): commit QuickJS hook writes before dispatching steps
VaguelySerious Aug 4, 2026
36a789b
Latch a rejected slot claim for the rest of the batch
VaguelySerious Aug 4, 2026
54efccb
Merge remote-tracking branch 'origin/main' into peter/slot-event-iden…
VaguelySerious Aug 4, 2026
d0c49a8
Merge origin/main into peter/slot-event-identity
VaguelySerious Aug 5, 2026
acd3c0f
Merge remote-tracking branch 'origin/main' into peter/slot-event-iden…
VaguelySerious Aug 5, 2026
f029086
fix(core): number every slotted write off the replay's log
VaguelySerious Aug 6, 2026
a5d4219
fix(core): order a step's unfenced writes without claiming a slot
VaguelySerious Aug 6, 2026
1f27510
Bound a slot at the range where its arithmetic is still exact
VaguelySerious Aug 6, 2026
851bd1d
Merge remote-tracking branch 'origin/main' into peter/slot-event-iden…
VaguelySerious Aug 6, 2026
b82d8dc
Answer the review on the client half of slot identity
VaguelySerious Aug 10, 2026
ed04e5e
Merge remote-tracking branch 'origin/main' into peter/slot-event-iden…
VaguelySerious Aug 11, 2026
fcb6404
Merge remote-tracking branch 'origin/main' into peter/slot-event-iden…
VaguelySerious Aug 11, 2026
9ba7137
Merge origin/main, resetting to its slot-identity implementation
VaguelySerious Aug 11, 2026
4422988
Let the writer name the event slot it takes
VaguelySerious Aug 11, 2026
89482cf
Claim the event slot in its own wire field
VaguelySerious Aug 11, 2026
f5a2301
Name only the position above the log the write holds
VaguelySerious Aug 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/writer-named-event-slots.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@workflow/core': patch
'@workflow/world': patch
'@workflow/world-vercel': patch
'workflow': patch
---

Bind each event write on a slot-numbered run to the position its replay named, so a write that races another is rejected and retried instead of landing out of order.
10 changes: 5 additions & 5 deletions packages/world-vercel/src/events-v4.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1340,7 +1340,7 @@ describe('createWorkflowRunEventV4 over HTTP', () => {
agent.assertNoPendingInterceptors();
});

it('forwards maxSlot in the frame meta', async () => {
it('forwards slot in the frame meta', async () => {
const origin =
WORKFLOW_SERVER_URL_OVERRIDE || 'https://vercel-workflow.com';
const agent = new MockAgent();
Expand Down Expand Up @@ -1388,16 +1388,16 @@ describe('createWorkflowRunEventV4 over HTTP', () => {
eventType: 'wait_created',
specVersion: 6,
correlationId: 'wait_1',
maxSlot: 12,
slot: 12,
},
{ token: 'test-token', dispatcher: agent }
);

expect(capturedMeta?.maxSlot).toBe(12);
expect(capturedMeta?.slot).toBe(12);
agent.assertNoPendingInterceptors();
});

it('omits maxSlot from the frame meta when not set', async () => {
it('omits slot from the frame meta when not set', async () => {
const origin =
WORKFLOW_SERVER_URL_OVERRIDE || 'https://vercel-workflow.com';
const agent = new MockAgent();
Expand Down Expand Up @@ -1449,7 +1449,7 @@ describe('createWorkflowRunEventV4 over HTTP', () => {
{ token: 'test-token', dispatcher: agent }
);

expect('maxSlot' in (capturedMeta ?? {})).toBe(false);
expect('slot' in (capturedMeta ?? {})).toBe(false);
agent.assertNoPendingInterceptors();
});

Expand Down
18 changes: 9 additions & 9 deletions packages/world-vercel/src/events-v4.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,18 +245,18 @@ interface CreateEventV4InputBase {
*/
stateCursor?: string;
/**
* Highest event slot the writer had loaded, i.e. the length of its loaded
* log under slot identity. Named `maxSlot` on the wire because the meta
* already carries an unrelated telemetry `eventCount`.
* The event slot this write claims under slot identity: one above the highest
* the writer had loaded, plus however many slots the writes issued before it
* off the same log already took.
*
* Supersedes the `stateUpdatedAt`/`stateEventCount`/`stateCursor` triple for
* slot-identity runs: with dense positions one integer says everything the
* watermark approximated. The server allocates from the tail regardless, and
* uses this only to report which slots the write skipped over (returned on
* the success response as `events`/`cursor`/`hasMore`). Older servers ignore
* it.
* watermark approximated. The write commits on this slot or not at all, so the
* insert that occupies it is also the fence: a slot another writer already took
* answers 412 with the events this writer had not seen, rather than committing
* somewhere else. Older backends ignore it and number the write themselves.
*/
maxSlot?: number;
slot?: number;
/** Number of consecutive replay divergences resolved by this write. */
replayDivergenceCount?: number;
/** Content digest of the serialized resume payload. Forwarded alongside
Expand Down Expand Up @@ -463,7 +463,7 @@ function buildPostFrameMeta(
meta.stateEventCount = input.stateEventCount;
}
if (input.stateCursor !== undefined) meta.stateCursor = input.stateCursor;
if (input.maxSlot !== undefined) meta.maxSlot = input.maxSlot;
if (input.slot !== undefined) meta.slot = input.slot;
if (input.replayDivergenceCount !== undefined) {
meta.replayDivergenceCount = input.replayDivergenceCount;
}
Expand Down
12 changes: 6 additions & 6 deletions packages/world-vercel/src/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,11 +346,11 @@ describe('createWorkflowRunEvent precondition snapshot wire fields', () => {
agent.assertNoPendingInterceptors();
});

it('renames eventCount to maxSlot', async () => {
// The runtime sends `eventCount` once a run's own ids are slot-shaped. It
// cannot ride under that name: the v4 meta already has an unrelated
// telemetry `eventCount`, so the backend would read a progress counter as
// a log position.
it('sends eventCount as the slot above it', async () => {
// The runtime sends `eventCount` once a run's own ids are slot-shaped: the
// highest slot the write accounts for, so the slot it claims is the next
// one. It cannot ride under that name either, because the v4 meta already
// has an unrelated telemetry `eventCount`.
const agent = mockAgent();
let capturedMeta: Record<string, unknown> | undefined;

Expand Down Expand Up @@ -384,7 +384,7 @@ describe('createWorkflowRunEvent precondition snapshot wire fields', () => {
{ token: 'test-token', dispatcher: agent }
);

expect(capturedMeta?.maxSlot).toBe(9);
expect(capturedMeta?.slot).toBe(10);
agent.assertNoPendingInterceptors();
});

Expand Down
14 changes: 10 additions & 4 deletions packages/world-vercel/src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -607,10 +607,16 @@ async function createWorkflowRunEventInner(
stateEventCount: params?.stateEventCount,
...(params?.stateCursor ? { stateCursor: params.stateCursor } : {}),
// Slot-identity snapshot. The runtime sends `eventCount` instead of the
// watermark triple once the run's own ids are slot-shaped; it rides as
// `maxSlot` because the v4 meta already has an unrelated telemetry
// `eventCount`.
...(params?.eventCount !== undefined ? { maxSlot: params.eventCount } : {}),
// watermark triple once the run's own ids are slot-shaped: the highest slot
// this write has accounted for, so the slot it claims is the one above.
//
// The claim rides in its own meta field rather than reusing the one that
// reports how much of the log the writer holds, because a backend cannot
// tell the two apart from the number: an SDK that only reports would have
// every write of a flush but the first bound to a slot it lost.
...(params?.eventCount !== undefined
? { slot: params.eventCount + 1 }
: {}),
replayDivergenceCount: params?.replayDivergenceCount,
occurredAt: params?.occurredAt ?? new Date(),
// Opt-in inline-delta: forward the cursor the runtime held before
Expand Down
5 changes: 4 additions & 1 deletion packages/world-vercel/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ import { version } from './version.js';
* `main` — rewritten by external CI for branch-deployment testing.
* Prefer `VERCEL_WORKFLOW_SERVER_URL` for deployment-time configuration.
*/
export const WORKFLOW_SERVER_URL_OVERRIDE = '';
// TEMPORARY: points e2e at the paired backend branch deployment so this PR's
// writer-named slots are honoured. Revert to '' before merge.
export const WORKFLOW_SERVER_URL_OVERRIDE =
'https://workflow-server-git-peter-slot-event-identity.vercel.sh';

/**
* HTTP methods that are safe to transparently re-issue inside the adapter.
Expand Down
44 changes: 22 additions & 22 deletions packages/world/src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -820,13 +820,13 @@ export interface CreateEventParams {
*
* Only meaningful against a World that declares
* `WorldCapabilities.slotEventIds`, where slots are dense and 1-based so a
* count and a position are the same number. Such a World attempts
* `eventCount + 1`, and on contention **bumps** to the next free slot and
* commits there anyway — a stale count never rejects a write. What it does
* instead is report: when the committed slot is higher than the one asked
* for, the events occupying the skipped slots come back on the success
* response in {@link EventResult.events} / `cursor` / `hasMore`, so the
* writer learns exactly what it had not seen.
* count and a position are the same number. Such a World binds the write to
* `eventCount + 1` and commits it there or not at all: the insert that
* occupies the position proves in one operation both that nothing else took it
* and that the log the write was decided from is still the whole log. A
* position already taken is rejected with a 412, optionally carrying the
* events the writer had not seen (see {@link stateCursor}), and the writer
* merges them and replays rather than committing a decision made without them.
*
* This supersedes the {@link stateUpdatedAt} / {@link stateEventCount} /
* {@link stateCursor} triple for slot Worlds. That triple approximates a
Expand All @@ -836,14 +836,13 @@ export interface CreateEventParams {
* fence fires. A dense position has no such blind spot. Worlds without slots
* ignore this field and keep using the triple.
*
* A batch of writes issued from one snapshot starts from the same
* `eventCount`; they land on consecutive slots in whatever order the World
* serializes them, which is why they can stay a parallel fan-out instead of
* a chain of round-trips. The count a given write sends is the writer's
* position *at that moment*, so it advances mid-batch as reported events are
* folded back into the loaded log: a write issued after a sibling's
* bump-and-report already holds the slots that report named, and asks for a
* slot above them.
* The position is the whole fence, so a writer may only ever name the one
* directly above the log it holds. Several writes issued from one snapshot
* therefore name one position between them and exactly one of them takes it;
* the rest are rejected, merge what they were missing, and name the position
* above that. Handing out consecutive positions locally instead would let a
* write commit at a position two above a log it never re-read, which is the
* decision-without-the-event this field exists to prevent.
*/
eventCount?: number;
/**
Expand Down Expand Up @@ -990,13 +989,14 @@ export type EventResult<T extends EventType = EventType> = {
* log through the canonical `hook_received`, so the lazy hook queue
* consumer can skip both the `run_started` write and the initial
* `events.list`.
* - On any response from a slot-allocating World (see
* `WorldCapabilities.slotEventIds`) whose committed slot came out
* higher than the one {@link CreateEventParams.eventCount} asked for:
* the events occupying the slots that were skipped over, in slot
* order. This is the "report" half of bump-and-report — the write
* succeeded, and these are the events the writer had not seen when it
* decided to make it.
* - On a response from a slot-numbering World (see
* `WorldCapabilities.slotEventIds`) that picked the committed slot
* itself and came out above the position the writer reported in
* {@link CreateEventParams.eventCount}: the events occupying the slots
* in between, in slot order — what the writer had not seen when it
* decided to write. A writer that binds itself to a position instead
* (which is what sending `eventCount` does) is rejected rather than
* moved, so it reads its missing events off the 412.
*/
events: Event[];
/** Pagination cursor for `events`, matching events.list semantics. */
Expand Down
20 changes: 11 additions & 9 deletions packages/world/src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,21 +417,23 @@ export interface WorldCapabilities {
deploymentAffinity?: boolean;

/**
* The World allocates **slot-numbered** event ids: `evnt_` plus the event's
* dense, 1-based position in its run's log, zero-padded to 26 characters
* (see `slot-identity.ts`). Two guarantees come with it, and the runtime
* relies on both:
* The World numbers events by **slot**: `evnt_` plus the event's dense,
* 1-based position in its run's log, zero-padded to 26 characters (see
* `slot-identity.ts`). Two guarantees come with it, and the runtime relies on
* both:
*
* - **Density.** A run's slots are contiguous from 1, so the number of
* events a reader holds *is* the position of the last one. That is what
* makes {@link CreateEventParams.eventCount} a complete statement of the
* writer's snapshot, where the `stateUpdatedAt` / `stateEventCount`
* watermark pair could only approximate it.
* - **Bump and report.** A create never fails because its requested slot is
* taken. The World advances to the next free slot, commits there, and
* returns the events occupying the slots it skipped over on the success
* response (see {@link EventResult.events}). The writer learns its
* snapshot was stale without the write being rejected.
* - **The position is the fence.** A writer that holds the log names the
* position its write takes ({@link CreateEventParams.eventCount} plus one),
* and the write commits there or is rejected with a 412. One insert
* therefore settles both questions the runtime has: nothing else took this
* position, and nothing happened that the write was decided without. A
* writer that holds no log (a step completion, a delivered hook) sends no
* position and the World picks one for it.
*
* A run's scheme is pinned by the run, not by this flag: it is readable off
* the shape of the run's own first event id, so a World that turns slots on
Expand Down
Loading