* perf(core): immediate leading-edge dispatch for idle streams (flush window default 0)
Production producer-rate data (24h of client flush spans): most agents
average 1.03-1.21 chunks per flush with 87-98% single-chunk flushes and
>70% of chunks arriving more than 10ms after the previous request had
already settled — a fixed 10ms leading window batches almost nothing
for them while adding ~20% to isolated-chunk publish latency (~50ms
median RTT). The one bursty producer (avg ~4-8 chunks/flush) gets its
batching from in-flight accumulation, which does not depend on the
window at all.
The leading chunk of an idle sink now dispatches immediately by
default (window 0): first chunk goes out at once, chunks arriving
during its request coalesce into the next group, and each settle
dispatches the accumulated group immediately — path-independent
batching with no fixed tax on slow producers. A positive
WORKFLOW_STREAM_FLUSH_INTERVAL_MS (or world.streamFlushIntervalMs,
applying from the second group) opts into a windowed leading edge for
slow-but-steady producers that prefer larger groups over first-chunk
latency. Early-ack, the durability drain barrier, wire caps, and
backpressure bounds are unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Update packages/world/src/interfaces.ts
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Karthik Kalyan <105607645+karthikscale3@users.noreply.github.com>
* review: env var overrides world streamFlushIntervalMs; world option governs the leading edge too
WORKFLOW_STREAM_FLUSH_INTERVAL_MS, when set, now takes precedence over
world.streamFlushIntervalMs; otherwise the world option applies from the
very first chunk (no more second-group lazy quirk). Deciding waits for
the world when needed, which adds no latency: sendGroup awaits the same
promise before any request can leave.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Signed-off-by: Karthik Kalyan <105607645+karthikscale3@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
* perf(core): move stream write batching into WorkflowServerWritableStream (group commit)
Batching previously lived in flushablePipe's coalescing loop, so it only
engaged on paths that used flushablePipe (getWritable). A raw
ReadableStream crossing a workflow/step boundary is piped with native
pipeTo(), which does not pull chunk N+1 until write(chunk N) resolves —
and write() resolved only after the flush timer AND the server round
trip, so the buffer never held more than one chunk and every token
became its own server request.
The sink now group-commits:
- write() resolves when the chunk enters a bounded client buffer; the
bound counts buffered AND in-request chunks
(WORKFLOW_STREAM_MAX_INFLIGHT_CHUNKS, preserving its documented
meaning) plus a byte bound (WORKFLOW_STREAM_MAX_BUFFERED_BYTES, new,
default 8 MiB, documented in runtime-tuning). A full buffer applies
backpressure until a group lands durably.
- The flush interval is a real group-commit window; chunks arriving
while a request is in flight accumulate and form the next writeMulti
group. One request in flight at a time preserves chunk order.
- Per-request wire limits (1,000 chunks / 1 MiB) split groups exactly
as the coalescing pipe did; an oversized single chunk goes alone.
- Durability moved to an explicit barrier (STREAM_DRAIN_SYMBOL):
close() drains before closing; flushablePipe adopts the barrier so
lock-release completion (step completion) still means 'everything
written is durable'; abort() DRAINS the accepted prefix (never
closing) so a producer error after acked writes cannot lose data —
native pipeTo aborts the sink on source failure; and a failed pipe
drains before settling so a step failure is not persisted ahead of
the emitted prefix. A dispatch failure retains the group, poisons
the sink, and surfaces at the next write/close/drain.
flushablePipe is now a plain per-chunk pump responsible only for
lock-release completion and durability tracking; its coalescing
machinery and STREAM_WRITE_BATCH_SYMBOL are removed.
Covered: native-pipeTo batching (the regression), awaited per-chunk
loops coalescing into one writeMulti, in-flight accumulation, wire-cap
splits (count/byte/oversized), in-flight-inclusive backpressure for
both bounds, sequential fallback without writeMulti, source-error
prefix delivery through abort, failed-pipe drain-before-reject,
early-ack sticky errors, turbo run-ready barrier gating (incl. dwell
telemetry), drain-barrier adoption/rejection, and group-level flush
spans. 1,572 core unit tests pass; e2e tier requires a deployment and
was not run here.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(core): re-dispatch a chunk buffered in the request settle gap
Review (bot): a write landing between the dispatch loop's empty-buffer
exit and the reaction clearing the in-flight marker armed no timer
(scheduleGroupCommit saw the marker set) and was never dispatched on an
open stream — only a later write/close/drain would pick it up. The
settle reaction now re-dispatches when the buffer is non-empty, treating
the chunk as an in-request arrival; drain waiters settle with the new
chain. Regression test aims a write at the settle gap and asserts both
chunks flush without a close.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(core): document abort-drain boundedness and terminal-run conflict handling
Review note: the abort-path drain is deliberately un-timeboxed (a bound
would drop acked chunks); its worst case is owned by the World
transport's finite timeout/retry budget, and a teardown-driven drain
into an already-terminal run rejects into the existing catch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(core): poll instead of fixed sleeps for dispatch assertions
The native-pipeTo batching test flaked on a slow CI runner: a fixed
25ms wait raced the 10ms commit window plus scheduler jitter. All
'dispatch has happened' assertions now poll the expectation (bounded);
intentional negatives keep their fixed windows.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): poll the events readback in stepFunctionPassingWorkflow
The events listing prefers the analytics store, which ingests
asynchronously. Reading it immediately after run completion can miss
the freshest events (the page is non-empty, so the storage fallback
does not trigger), failing the step_completed assertion. Poll for up
to 20s so ingestion has time to land; the assertion itself is
unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): force storage-backed inspect listings via WORKFLOW_DISABLE_ANALYTICS_READS
The analytics store ingests asynchronously; e2e assertions read events
and steps immediately after run completion and can catch a page missing
the freshest rows (observed as stepFunctionPassingWorkflow's
step_completed readback returning empty, and the same race on steps
listings in other suites). Instead of polling each readback, disable
the analytics namespace for the e2e's CLI invocations so every inspect
listing is served read-your-writes from primary storage. Replaces the
earlier bounded poll with the deterministic mechanism.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Enforces the published per-run events limit, which was previously not enforced. The server supplies the limit on the run_started response (separate change); once a run's event log reaches it, the runtime throws MaxEventsExceededError at the top of the replay loop, and the existing terminal-error path records it as run_failed with a new MAX_EVENTS_EXCEEDED code — instead of letting a runaway workflow (e.g. an unbounded step loop) grow the event log without bound.
Adds a new client side WORKFLOW_MAX_EVENTS_OVERRIDE env var which can override the server side provided value (lower only).
* [core] Enforce maxRetries for steps that time out
A step that is hard-killed by the platform function timeout writes no
step_failed/step_retrying, so `step.error` stays null and the error-based
max-retries guards never fire. Each redelivery re-runs step_started
(incrementing the attempt), so a timing-out step retried without bound
instead of stopping at maxRetries.
Enforce the retry ceiling BEFORE running the body, via a new
`authoritativeAttempt` param on executeStep:
- Inline (combined handler): count the step_started events already in the
event log for the step (+1 for this attempt). The log is authoritative
because the optimistic-start path synthesizes step.attempt = 1.
- Background (queue-dispatched): the queue delivery count (metadata.attempt),
which increments on the visibility-timeout redelivery a timed-out step
produces.
When the attempt exceeds maxRetries + 1 the step is failed without starting
another attempt. Thrown-error exhaustion is unchanged — it still terminates
via the post-body guard one attempt earlier, with the thrown error as cause.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Apply suggestions from code review
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
* [core] Verify step_started count before failing a backgrounded step
`metadata.attempt` (queue delivery count) also advances for redeliveries
that never run the step body (ThrottleError, TooEarlyError, other pre-body
failures), so trusting it directly could fail a step as "exceeded max
retries" before the body ever ran.
Use the delivery count only as a fast gate: while it is at or under the
ceiling the step can't be exhausted, so proceed without touching the log.
Only once it crosses the ceiling, load the full event log and derive the
authoritative attempt from the recorded step_started count (which only real
attempts write) — excluding throttle/too-early redeliveries. The load also
primes the replay's cachedEvents/eventsCursor.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* [core] Skip the event-log scan for brand-new inline steps
Deriving an inline step's attempt number by scanning the cumulative event
log for step_started events ran for every inline execution, which is O(n²)
across a long sequential workflow.
A lazy inline step is brand-new by construction (it only enters the batch
with no step_created yet), so it has zero prior starts and is always attempt
1 — no scan needed. Reserve the scan for owned-recovery re-runs (this
message re-executing a step it crashed/timed out on), which are uncommon and
few per batch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* [world-vercel] Idempotent retry policy for stream close (5xx retriable)
Stream close is the one idempotent stream PUT: a duplicate close of a
completed stream early-returns on the server, and the close-barrier
protocol's durable `closing` fence is an if_not_exists stamp that a
re-entered close resumes. The barrier protocol relies on close retrying
5xx: transient reconciliation failures — and unsafe close shapes
awaiting in-flight backups — surface as retriable 503s with the stream
left durably closing, expecting the writer to close again. Under the
write dispatcher's no-5xx policy (correct for non-idempotent chunk
appends), that 503 rejected writer.close() outright and left the stream
fenced until run expiry.
Close now uses its own shared RetryAgent (429 + 5xx + transient
connection errors, Retry-After honored); chunk writes keep the narrowed
no-5xx policy unchanged. Contract pinned by tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* changeset for stream close retry
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* concise changeset
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* docs: replace migration guides with a Comparisons section
Add a Comparisons section (v4 + v5) with an index/snapshot across all frameworks and deep-dive pages for Temporal, Cloudflare Workflows, AWS Step Functions, AWS Bedrock AgentCore, Inngest, and trigger.dev. Remove the old migration-guides section, folding its concept-mapping and migration content into the relevant comparison pages, and repoint top-level nav in both versions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs: refresh comparison pages with current facts (July 2026)
Re-verified each comparison against the vendor's current public docs and
updated what changed since the June 2026 snapshot:
- Temporal: Worker Versioning is now GA; Serverless Workers (AWS Lambda,
pre-release) scale to zero, so soften the blanket "no scale-to-zero";
drop the unsubstantiated "Uber" customer claim (Uber is Cadence's origin,
not a Temporal customer).
- Cloudflare Workflows: note the new per-step billing dimension (500K/mo
included, then $0.80/100K) landing no earlier than Aug 10, 2026; note the
50K concurrency ceiling was raised from 4,500 at GA.
- AWS Bedrock AgentCore: add newer GA modules (Harness, Policy, Evaluations);
correct compliance (SOC/PCI/ISO under internal assessment, audits pending;
FedRAMP not yet authorized; drop GovCloud claim); refresh languages
(@aws/agentcore CLI scaffolds TS or Python); "some modules preview" is stale.
- Inngest: Pro pricing $75 -> $99/mo; encryption middleware now TS + Python;
AgentKit/Realtime are Developer Preview and Connect is public beta; self-host
is community/best-effort (not "unsupported"); Free-tier run duration 30 days
vs 366 on Pro; soften funding to ~$30M+.
- AWS Step Functions & trigger.dev: facts re-confirmed; date stamp only.
Bumped every "as of June 2026" stamp to July 2026. v4 and v5 kept identical.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: present comparisons in the present tense; v5-only; Pro usage-based pricing
Follow-up pass on the comparison pages:
- Remove all date references (past and future). Anything that lands on a
date is stated as already in effect: Cloudflare's per-step billing, Temporal
Serverless Workers and GA Worker Versioning, AgentCore's Harness/Policy/
Evaluations modules. Dropped "as of July 2026" stamps, founding/GA years,
funding round dates, and roadmap/"being added" phrasing.
- Workflow SDK: reference v5 only and treat it as GA (was "v4 GA / v5 beta").
- Pricing and limits: quote the Pro/paid tier only and usage-based rates only;
drop plan-included quotas and free-tier allowances (Step Functions 4K/mo free,
Cloudflare 500K steps/mo included, Inngest 50K free execs, Inngest/Free 30-day
run cap, Temporal $100/mo plan minimum).
v4 and v5 kept identical.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: tighten comparison maturity/status wording
- Drop dateless "upcoming change" phrasing: AgentCore compliance now states
current facts only (no "self-assessed"/audit-pending implication); remove
Inngest's "SSPL → Apache after 3 yrs" license-conversion note.
- Don't label the Workflow SDK "GA" — non-beta is the default; also drop bare
"GA" where it only meant "not beta" (Temporal "7 SDKs", Inngest "TypeScript",
competitor maturity cells).
- Maturity cells no longer cite version numbers; they describe backing/track
record instead (e.g. "Built and maintained by Vercel", "Backed by AWS").
v4 and v5 kept identical.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: make "features without a 1:1 equivalent" sections directional
Rename each heading to name the competitor that has the feature (e.g.
"Temporal features without a direct Workflow SDK equivalent") and add a
lead-in clarifying these are the competitor's capabilities the Workflow SDK
doesn't replicate one-to-one, with how to cover each on the Workflow SDK side.
v4 and v5 kept identical.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: point world links at the /worlds routes
The comparison pages linked to /docs/deploying/world/* and
/docs/deploying/building-a-world, which no longer exist in the docs
trees (the Docs Links check rejects them on v5 pages, where /docs hrefs
are render-rewritten and skip the legacy redirects). Link the canonical
/worlds/* routes directly, in both body links and frontmatter refs.
v4 and v5 kept identical.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: address toolbar review feedback on the comparison pages
- Drop the Maturity row from every at-a-glance table
- Add a "what the limits mean in practice" paragraph to each comparison,
spelling out what the competitor's caps mean for long-running AI
workloads, and link Vercel World limits to the pricing doc
- Security cells: lead with zero-config per-run E2E encryption and note
platform security is per-World, instead of the VM-sandbox framing
- Temporal: drop the throughput sentence and the still-in-preview
Serverless Workers mention from the performance cell
- Cloudflare: end the recommendation on "already all-in on Cloudflare"
- Convert the "features without a direct equivalent" bullet lists into
two-column tables so it's unambiguous which product owns each feature
- Fix the Inngest page's "no step cap" cell (Vercel World caps runs at
10K steps per the pricing doc)
v4 and v5 kept identical.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Peter Wielander <peter.wielander@vercel.com>
* test: regression coverage for hook.resume() from isolated route bundles (o2flow beta.26 incident)
Reproduces the o2flow v5 upgrade failure (workflow@5.0.0-beta.26, fixed by
#2752 in beta.28): a plain API route importing defineHook() from the root
`workflow` entry and calling .resume() failed with Turbopack's
"Cannot find module as expression is too dynamic" stub, because the world
registration was tree-shaken out of the route bundle and getWorldLazy()'s
dynamic-import fallback got stubbed.
The bug only manifests when a route bundle loads in isolation (a Vercel
lambda): local `next dev`/`next start` evaluates next.config.ts, whose
workflow/next import chain registers the world process-wide and masks it —
which is why no existing server-driven suite caught it.
- route-bundle-isolation.test.ts: production Turbopack build of the
nextjs-turbopack workbench, then loads ONLY the compiled route bundle in a
bare Node subprocess (cold-lambda simulation) and invokes its POST handler.
Fails with the exact incident error on regressed code; passes on main.
Wired into the build-error-messages CI job.
- e2e: plainModuleDoneHook round-trip through a plain API route on the two
Next workbenches (deployed matrix covers real lambda isolation).
- Workbench fixtures mirroring o2flow: a directive-less defineHook module
shared by a workflow (create) and a plain route (resume). The webpack
workbench gets a real route file because `next dev` (webpack) does not
serve directory-symlinked app routes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
* test: authenticate plain hook resume request
* test: address review — marker-based harness output parsing, changeset summary
- route-bundle-isolation: prefix the harness result line with a unique
marker and locate it explicitly instead of JSON.parse()ing the last
stdout line, so stray logging from the route bundle or the world can't
break parsing; failures now include the full subprocess stdout.
- changeset: add a human-readable summary to the (release-less) changeset.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
---------
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Karthik Kalyanaraman <karthik.kalyanaraman@vercel.com>
Co-authored-by: Karthik Kalyan <105607645+karthikscale3@users.noreply.github.com>
* docs: make /worlds the canonical home for World docs
The world pages (Local/Postgres/Vercel) and Building a World were
duplicated inside the v4 and v5 docs trees while /worlds/[id] rendered
the v4 copy — hiding v5-only content like multi-region and leaving two
diverging sources of truth.
- Move world docs to an unversioned docs/content/worlds/ collection
(based on the v5 copies, with inline 4.x callouts for factory naming
and 5.x-only env vars), rendered at /worlds/*
- Add /worlds/building-a-world; flatten the docs Deploying section to a
single intro page and drop its Rocket icon
- Point every link, frontmatter ref, and worlds-manifest docs field at
/worlds/*; add redirects for the removed v5 and building-a-world URLs
- Keep world docs on agent-facing surfaces: search, llms.txt,
sitemap.md/.xml, and .md exports now serve the worlds collection
- Extend the docs link linter to validate worlds pages (with heading
anchors) and their outgoing links
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
* docs: version the world docs like the docs trees (v4/v5 switcher)
Instead of a single unversioned copy, world docs now follow the same
versioning strategy as the docs pages: content/worlds/v4 is served at
/worlds/* (current) and content/worlds/v5 at /v5/worlds/*, restoring the
original per-version content. Each world detail page (and Building a
World) renders the docs version switcher — the worlds listing page has
no natural home for it, so it lives on the world pages themselves.
- Render-time href rewriting on v5 pages now covers /worlds/... links
(shared rewriteHrefForVersion helper, also used by the v5 docs and
cookbook routes), and the markdown-export rewrite does the same
- v5 world pages are noindexed with a canonical to /worlds/<id>;
community worlds stay unversioned (/v5/worlds/<id> redirects)
- /v5/docs/deploying/world/* redirects now land on /v5/worlds/*;
/v5/worlds and /v5/worlds/compare redirect to the unversioned pages
- Link linter models the versioned worlds URL spaces (v5 pages resolve
/worlds hrefs against the v5 collection); sitemap.md and the .md
export routes cover /v5/worlds/*
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
* docs: fix v4 multi-region anchor and tighten version-prefix matching
Address PR review:
- The v4 Deploying page linked /worlds/vercel#multi-region, but the
Multi-region section only exists on the v5 world page; use the
explicit cross-version /v5/worlds/vercel#multi-region link (this was
the Docs Links CI failure)
- rewriteHrefForVersion now uses the boundary-checked hasPathPrefix
(shared leaf module lib/geistdocs/path-prefix.ts, also used by
source.ts) instead of bare startsWith
- buildVersionUrl's shared-route fast path is segment-based rather than
substring includes()
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
---------
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>