[docs] Patterns: a tiered library of tested, installable Workflow patterns - #1858
[docs] Patterns: a tiered library of tested, installable Workflow patterns#1858karthikscale3 wants to merge 48 commits into
Conversation
🦋 Changeset detectedLatest commit: 7228352 The changes in this PR will be included in the next version bump. This PR includes changesets to release 0 packagesWhen changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
📊 Workflow Benchmarkscommit Backend:
📜 Previous results (2)b3042f5Tue, 28 Jul 2026 13:11:39 GMT · run logs
9794b81Tue, 28 Jul 2026 07:29:52 GMT · run logs
ℹ️ Metric definitions & methodologyBest/P75/P90/P99 deltas compare against the most recent benchmark run on Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window) Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost 🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000 · STSO (1-20) 20/30/60 · STSO (101-120) 30/45/90 · STSO (1001-1020) 40/60/120 All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor ( Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the |
🧪 E2E Test Results✅ All tests passed E2E Test SummarySummary
Details by Category✅ ▲ Vercel Production
✅ 💻 Local Development
✅ 📦 Local Production
✅ 🐘 Local Postgres
✅ 🪟 Windows
✅ 📋 Other
✅ vercel-multi-region
|
8ad4dc6 to
36b9eb0
Compare
…ry-2 Resolved conflicts from the v4/v5 docs restructure on main: - v4 cookbook pages kept intact from main (legacy docs unchanged) - v5 cookbook pattern pages deleted (replaced by the Patterns tab), including child-workflows and upgrading-workflows which are now patterns - v5 cookbook keeps only advanced/serializable-steps and advanced/publishing-libraries - updated v5 links from /cookbook/common-patterns/workflow-composition to /patterns/workflow-composition
…terns Each pattern declares which SDK majors it works with via a new required `versions` field. Badges render on the listing card and detail hero, are searchable (e.g. "v5"), and are included in the copy-page text. All 19 current patterns use APIs available in both v4 and v5.
Nav items support an optional badge label, rendered as a blue pill in both the desktop and mobile menus.
…anch - ai-sdk (#2099): tools inside the per-turn step are not individually durable — remove the misleading "use step" directives and durability claims, rewrite when-to-use, add the pitfall and corrected streamText-vs-DurableAgent comparison - child-workflows (#2100): replace spawn-and-poll (getRun().status loop) with startAndWait() + withChildCompletionHook() hook resume - durable-agent + agent-cancellation + human-in-the-loop (#2285): DurableAgent is deprecated in v5 — add warn callout and notes pointing at AI SDK v7's WorkflowAgent - ai-sdk: add deployment-pinning versioning callout (#2010)
…-limits, fix resend install, concept-only tutorials - distributed-abort-controller -> kill-switch: v5 ships native AbortController support, so the old name collided. KillSwitch is the cross-process, named, durable complement and the copy now explains the relationship. - rate-limiting -> handling-rate-limits: the pattern teaches 429 handling with RetryableError, not rate limiting; frees the name for a real rate limiter component. - resend: install paths now follow the workflows/ convention — its /r payload previously contained zero files (caption didn't match the workflows/ filter). - sequential-and-parallel + workflow-composition: marked installable:false (all-placeholder code); Installation section hidden, /r returns a pointer to the readable page. - redirects for all renamed slugs (incl. /r/*).
…terns Phase 1: required patternType (component/template/example) with tier badges on cards + detail hero, a type filter row, search matching, and copy-page text. Phase 2: child-workflows ships a reusable component file + worked example; scheduling exposes cancellableSleep(); both promoted to component tier. Phase 3 (components, all on the coordination-workflow + single event-channel architecture — timeouts injected as timer-child messages, senders ensure-start coordinators, waiters self-heal with fresh reply hooks): semaphore (withPermit/withLock), rate-limiter (withRateLimit), circuit-breaker (withBreaker), debounce (debounceSend), batch-aggregator (aggregatorSend), singleton-run (getOrStart/sendToSingleton). Phase 4 (templates): polling, dead-letter-queue, recurring-cron. Phase 5 (provider examples): stripe (dunning), slack-approval (HITL). ROADMAP.md updated with implementation status and open items.
…ry-2 * origin/main: Version Packages (beta) (#2364) Animate in-progress segments in the timeline (#2383) Render queued span time as a lead-in connector on the trace timeline. (#2381) fix(docs): right-align sidebar folder carets consistently (#2377) [core] Add wire-level framing for byte streams (#1853) docs: move World SDK and getWorld under workflow/runtime, split out workflow/observability (#2375) Replace hook.hasConflict with hook.getConflict() returning the conflicting Run (#2373) ci: use claude-fable-5 for backport AI model (#2370) Add `hook.hasConflict` for early hook conflict detection (#2015) [core] V2: unify wait+step queue dispatch in suspension processing (#1925) fix(world-local,world-postgres): make duplicate hook_created idempotent (#2295) docs(observability): remove MVP implementation detail bullet (#2367) fix: settle aborted parallel steps before completing abortParallelWorkflow (#2244)
- Merge origin/main to pick up hook.getConflict() (#2015/#2373 — also backported to v4 stable, so version badges are unchanged) - All six coordinators + the singleton example claim their channel token with getConflict() at startup: a lost start race now returns { dedupedTo: winnerRunId } cleanly instead of dying with HookConflictError, and the early claim shrinks the start->registration window the send-with-ensure retry papers over - kill-switch: fix loser-runId bug — after start(), adopt the REGISTERED owner's runId via getHookByToken instead of the started run's (a racing create() could previously hand back a handle whose .signal never fires) - batch-aggregator: optional stable id on aggregatorSend(); coordinator dedupes — sends from retried steps are at-least-once - docs honesty: aggregator/debounce acknowledged-but-dropped flush window, child-workflows at-least-once spawns, recurring-cron's generation-keyed token doubling as a schedule-fork guard - ROADMAP: record residual gaps needing SDK support (atomic keyed start, native resumeOrStart, channel close+drain) with pointer to #2376
…version) Pattern workflow code now lives as real, compiled, executable files in workbench/vitest/workflows/patterns/ (19 files). docs snippet strings are GENERATED from them by docs/scripts/sync-pattern-source.ts into docs/lib/patterns/generated/ — the manifest imports display (header- stripped) and full (install) variants from there; the hand-maintained template-literal copies are deleted. First proven test suite: patterns-semaphore (3 behavioral tests green under @workflow/vitest — max-concurrency bound, release-on-throw, mutex serialization). TESTING_NOTES.md captures the harness rules learned: stepId-deduped side-effect counters (step execution is at-least-once), unique keys per run (world persists across vitest invocations), afterAll coordinator cancellation, waitForSleep/wakeUp over constant-shrinking.
- docs/scripts/validate-registry.ts: structural validation of every /r payload — files parse as TS, files[] metadata matches emissions, npm imports covered by the new dependencies field, env vars declared, DOCS links resolve. Found and fixed on first run: chat-sdk's lib/ files were never installed (the /r route filtered them out — broken install), sandbox emitted an undeclared file, 9 patterns missing dependency declarations, undeclared env vars. - /r route now emits lib/-prefixed files (target: project root) and a dependencies array (cross-checked by the validator). - workbench/install-fixture: committed Next.js app fixture; patterns are materialized (or CLI-installed) into it, then typechecked and built with the workflow SWC plugin. First run caught real API drift: sandbox pattern used Sandbox.sandboxId, current @vercel/sandbox identifies by .name; plus a stale @/app/workflows/chat-sdk import. - docs/scripts/materialize-patterns.ts + install-e2e.sh (real shadcn CLI; verified locally: 28 patterns, 33 files, typecheck + next build green). - .github/workflows/patterns-checks.yml: registry validation + codegen freshness + fixture typecheck/build + CLI install e2e on PRs. - .github/workflows/patterns-nightly.yml: nightly prod-registry install with unpinned shadcn; failures open/update a tracking issue. - Detail pages: Usage section (consumer API) now structurally separated from Source (full implementation, education-framed); usage snippets added for child-workflows and scheduling.
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
… green) 22 suites / 74 tests under @workflow/vitest covering every canonical pattern: semaphore (concurrency bound, FIFO, release-on-throw, mutex), rate-limiter (grant spacing), circuit-breaker (threshold trip, instant open rejection, reset-on-success, half-open recovery), debounce (burst → one fire with latest payload, fresh burst after exit), batch-aggregator (size flush, deadline flush, id dedupe), singleton-run (getOrStart dedupe, mailbox ordering, race → dedupedTo), kill-switch (cross-process abort, TTL expiry, create() races adopt the winner), child-workflows (typed results, failure propagation, allSettled isolation), scheduling (cancel/execute/cancellableSleep), recurring-cron (drift-corrected ticks, clean stop, full continue-as-new generation handoff), plus polling, DLQ, saga, batching, idempotency, timeouts, webhooks. Tests found and fixed three shipped bugs: - recurring-cron: racing stop.then() per iteration registered stale channel awaiters that swallowed the stop payload — any stop after the first tick deadlocked. Single shared stop promise now. - child-workflows + recurring-cron: deploymentId 'latest' throws on worlds without resolveLatestDeploymentId (incl. local dev) — removed, kept as Vercel-scoped guidance. - kill-switch: SWC step-mode transform strips module-level consts referenced only by the retained class -> ReferenceError on defaults; inlined. (Possible upstream swc-plugin issue, noted in ROADMAP.) Replaceable action steps are now self-contained runnable demos (real API calls kept as comments) so installed patterns work on first run. Superseded cookbook copies retired; their unique coverage migrated. Canonical edits re-synced to docs (sync-pattern-source); registry validation, fixture typecheck and next build all green.
…enerated/ from biome lint-staged formats canonical pattern files at commit time, which made generated output drift from what a pre-commit sync produced. Generated modules are now biome-ignored (they're codegen output; the clean-check compares raw generator output) and re-synced against the formatted canonical files.
Resolved conflicts: the six v5 cookbook pages main touched in #2391 (link repair) stay deleted on this branch — they're replaced by the Patterns tab. Audited the patterns against #2391's link fixes and corrected stale internal doc links the now-working link linter flagged (use-step, use-workflow, get-step-metadata API-reference moves). Taught the link linter about /patterns/<id> detail routes (mirrors /worlds/<id>).
…ry-2 * origin/main: (292 commits) feat(core): seal forwarded stream writes to the owner's public key (#3098) feat(core): seal hook payloads to the target run's public key (#3096) [e2e] Rebuild the event-log corruption repro around step-count divergence (#3147) feat: decrypt sealed payloads in the dashboard and CLI (#3146) Prewarm only appended replay payloads (#3131) feat: publish each run's X25519 public key on the run entity (#3095) feat(core): route sealed envelopes through the serialization layer (#3094) docs: redirect retired migration-guides URLs to comparisons (#3127) feat(core): add `encp` sealed-box encryption primitive (#3093) chore(core): clarify runtime comments (#3111) Remove obsolete world factory aliases (#3112) feat(core): deterministic sandbox hardening (#3045) Remove retired v1 step route plumbing (#3061) [core] Don't count racing invocations' duplicate step_started events toward the maxRetries ceiling (#3069) [world-testing] Isolate each spawned test server's data directory (#3055) fix: upgrade postcss to >=8.5.18 to address GHSA-r28c-9q8g-f849 (#3102) [next] Respect .gitignore in dev watcher to avoid EMFILE on large monorepos (#3085) [ci] Backport only stability fixes to `stable`, default to claude-opus-5 (#3092) perf(core): immediate leading-edge dispatch for idle streams (flush window default 0) (#3088) Optimize `processImportSpecifier` by computing `shouldFollowImportsFromFile` once per file (#3052) ... # Conflicts: # docs/components/geistdocs/desktop-menu.tsx # docs/components/geistdocs/mobile-menu.tsx # docs/content/docs/v5/cookbook/advanced/child-workflows.mdx # docs/content/docs/v5/cookbook/advanced/upgrading-workflows.mdx # docs/content/docs/v5/cookbook/agent-patterns/agent-cancellation.mdx # docs/content/docs/v5/cookbook/agent-patterns/durable-agent.mdx # docs/content/docs/v5/cookbook/agent-patterns/human-in-the-loop.mdx # docs/content/docs/v5/cookbook/common-patterns/batching.mdx # docs/content/docs/v5/cookbook/common-patterns/idempotency.mdx # docs/content/docs/v5/cookbook/common-patterns/rate-limiting.mdx # docs/content/docs/v5/cookbook/common-patterns/saga.mdx # docs/content/docs/v5/cookbook/common-patterns/scheduling.mdx # docs/content/docs/v5/cookbook/common-patterns/sequential-and-parallel.mdx # docs/content/docs/v5/cookbook/common-patterns/timeouts.mdx # docs/content/docs/v5/cookbook/common-patterns/webhooks.mdx # docs/content/docs/v5/cookbook/common-patterns/workflow-composition.mdx # docs/content/docs/v5/cookbook/index.mdx # docs/content/docs/v5/cookbook/integrations/ai-sdk.mdx # docs/content/docs/v5/cookbook/integrations/chat-sdk.mdx # docs/content/docs/v5/cookbook/integrations/sandbox.mdx # docs/next.config.ts # docs/proxy.ts # docs/scripts/lint.ts # pnpm-lock.yaml # pnpm-workspace.yaml
Post-merge fixes for main's #2222, which replaced the local geistdocs chrome with the published @vercel/geistdocs package and deleted the components the patterns pages imported. - Point Button/Input/Separator/Mermaid/CodeBlock imports at the package. - Rebuild RegistryDetailToc against the package: use its AskAIButton, inline ScrollTop and CopyPage (their deps survive the migration), and drop Feedback + Open-in-Chat, whose entire infrastructure (app actions, ai-elements, ui/popover, ui/textarea) was deleted and is only reachable through the package's non-exported internals. - Restore the sitemap link in the llms.mdx route via the package's transform hook, keeping agent-discoverable sitemap behavior intact. - Port #2616's DurableAgent -> WorkflowAgent deprecation notes into the pattern manifest prose. - Fix a validate-registry type error: use the narrowed caption local instead of the optional snippet.caption.
|
All alerts resolved. Learn more about Socket for GitHub. This PR previously contained dependency changes with security issues that have been resolved, removed, or ignored. |
The fixture pinned next@16.2.1 / react@19.2.4 / @types/react@19.2.4,
which predate main's bump to next@16.2.11 / react@19.2.7. Because the
lockfile is shared across the workspace, that pulled a second copy of
@types/react into the graph and broke two checks:
- Patterns Fixture Typecheck: NextConfig from next@16.2.1 was not
assignable to NextConfig from next@16.2.11 (compiler.define variance).
- nextjs-turbopack / nextjs-webpack Vercel builds: motion's .d.ts
resolves react via the hoisted copy under .pnpm/node_modules, which
diverged from the 19.1.13 those workspaces symlink directly. Two
CSSProperties identities => "Type 'CSSProperties' is not assignable
to type 'MotionStyle'" in ai-elements/shimmer.tsx, a file this
branch never touches.
Pins now match nextjs-turbopack/nextjs-webpack, and @types/react
collapses to a single version (19.1.13) across the lockfile.
Also drops workbench/install-fixture/tsconfig.tsbuildinfo, a TS
incremental cache committed by accident in 671fb1b (the only
tsbuildinfo tracked in the repo), and gitignores it.
Lockfile regeneration incidentally bumped monaco-editor 0.55.1->0.56.0,
@monaco-editor/react 1.6.2->1.6.7 and dompurify 3.2.7->3.4.8:
workbench/swc-playground pins both monaco specifiers as "latest", so
they re-resolve on any install.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…erns hero copy The lockfile regeneration in 763fd34 re-resolved swc-playground's "monaco-editor": "latest" from 0.55.1 to 0.56.0, which added an exports map rooted at esm/vs/ ("./*": "./esm/vs/*.js"). That makes monaco-vim's legacy deep imports resolve to doubled paths (esm/vs/esm/vs/editor/editor.api.js) and broke the Turbopack build. monaco-vim@0.4.4 is the newest release and has no exports-map-aware version, so pin monaco-editor to 0.55.1 — what main already resolves. Also addresses preview toolbar feedback on the patterns pages: - saga listed getWritable() under Key APIs and described optional emit()/ SagaEvent streaming; the pattern source uses neither. Swapped in the getStepMetadata() it actually imports. Verified the other six patterns listing getWritable() do use it. - patterns index said "for popular providers", which misdescribes the library. - "Submit your recipe" pointed at the repo root instead of the directory where recipes live. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…in-run calls `Vitest Plugin Tests` was failing on the semaphore's in-run fan-out with a ReplayDivergenceError (`Replay could not consume event: eventType=wait_created`), not a timeout — the run diverged at 3.2s. Root cause: durable correlation IDs come from a monotonic ULID factory seeded with a fixed timestamp, so the Nth allocation in a replay must be the Nth allocation in the original run. `sleep()` allocates its wait eagerly at call time, so a `sleep()` written *after* an `await` inherits step-completion order. When several `withPermit()` / `withRateLimit()` / `withBreaker()` calls run concurrently inside one run, step completions interleave nondeterministically and the wait IDs get reassigned on replay. Fix: allocate the hook and its timer in one synchronous prefix, before the first await. Allocation then follows call order, which is stable. As a side effect the timeout budget now covers the request/check step too — that bounds the whole acquire/admission, which is the behaviour the docs already describe. The runtime's delivery barriers order hook deliveries against wait deliveries but do not include step results, which is why the recipes have to carry this themselves for now. Upstream: #3137 (repro) / #3139 (fix), both open. Also in this change: - Coordinator start is now at-most-once per send, with exponential backoff. Previously every failed `resume` fired another `start()`, so N concurrent cold callers produced N x attempts throwaway runs — `start()` resolves on enqueue, but the token isn't claimed until the run executes. The breaker's send budget is deliberately held under its CHECK_TIMEOUT (5 attempts, 250ms->2s, ~5.75s < 10s): the send is awaited before the verdict race, so a longer budget would stretch the fail-open path past the deadline the pattern promises. - Tests now cover both shapes. Cross-run fan-out is the contract these coordinators actually advertise (one permit/slot cluster-wide); in-run fan-out is the regression guard for the divergence above. The earlier cross-run conversion had dropped the in-run cases entirely — they are restored as additional tests, so coverage is strictly better than before. - Driver counters dedupe by stepId. Step execution is at-least-once, so an undeduped counter double-counts on a transparent retry and the assertion silently lies. Generated docs sources re-synced via `pnpm sync-pattern-source`.
… can't mint capacity
sendSemaphoreEvent retries resume() in a catch-and-retry loop, and the step
wrapping it is itself at-least-once, so a release that actually landed can
look like a failure to its sender and be sent again. The coordinator's
release branch was unkeyed:
} else {
inFlight = Math.max(0, inFlight - 1);
}
so it could not tell a duplicate from a real release. Each extra copy handed
back capacity nobody gave up and let one more holder into the critical
section — max = maxConcurrent + 1, the 4 > 3 the fan-out test was
intermittently reporting.
Both events now carry the holder's grantToken and the coordinator keeps a
held Set as an idempotency ledger: inFlight is its size, and every mutation
is guarded by a membership test, so replaying any event is a no-op.
Adds a deterministic regression test that delivers a release for a token the
coordinator never granted while a holder occupies the only permit, then
asserts no second holder gets in. It discriminates — with the membership
guard stubbed out it fails "expected 2 to be 1" (same maxConcurrent + 1
signature) and passes with the guard in place.
Validated with 6 consecutive full-suite runs at --maxWorkers=4 (25 files,
82 tests, all green); the pre-fix baseline failed 2 of 4.
Summary
Adds a Patterns section to the docs (
/patterns) — a tiered library of 30 Workflow patterns, most installable via the shadcn CLI. What began as a flat list of 19 recipes is now organized by what the installed code is worth after adaptation, backed by real, executable, tested source files rather than hand-maintained snippet strings, with a CI pipeline that proves every pattern compiles, runs, and installs.The taxonomy
Every pattern declares a
patternType:kill-switch,semaphore,rate-limiter,circuit-breaker,debounce,batch-aggregator,singleton-run,child-workflows,schedulingsaga,batching,idempotency,timeouts,webhooks,polling,dead-letter-queue,recurring-cron,ai-sdk,durable-agent, …stripe,slack-approval,resend,chat-sdk,sandbox,sequential-and-parallel,workflow-composition,handling-rate-limitsThe tier shows as a badge on each card and detail page and is a filter on the listing page. Component is the bar new patterns aim for.
Source of truth: tested files, generated snippets
The core architectural change. Pattern code lives as real
.tsfiles inworkbench/vitest/workflows/patterns/(19 canonical files) — compiled, executed, and tested against the actual runtime. The docs snippet strings are generated from them (docs/scripts/sync-pattern-source.ts→docs/lib/patterns/generated/); a CI check fails if the generated output drifts. This kills the class of bug where snippet strings silently rot as the SDK moves (which this PR hit three times before the inversion).@workflow/vitest— observed max-concurrency for the semaphore, breaker state transitions, debounce latest-payload, aggregator size/deadline/dedupe flushes, racingKillSwitch.create(), a full continue-as-new cron generation handoff, etc.Writing the tests found and fixed three shipped bugs: a recurring-cron stop-hook deadlock (stale channel awaiters swallowed the stop payload),
deploymentId: "latest"throwing on non-Vercel worlds in two patterns, and an SWC step-transform bug stripping module consts referenced only from destructuring defaults — filed upstream as #2396 with a verified minimal repro.shadcn registry + install pipeline
GET /r/[name]returnsregistry-item.json; emitsworkflows/files (→app/workflows/) andlib/files (→ project root), plus adependenciesarray.docs/scripts/validate-registry.ts(CI, every PR): every payload's files parse as TS,files[]metadata matches emissions, npm imports are covered bydependencies, env vars are declared, andDOCS:links resolve. On first run it caught chat-sdk'slib/files never being emitted (broken install), an undeclared sandbox file, and 9 missing dependency declarations.workbench/install-fixture/: a real Next.js app; patterns are materialized (or CLI-installed) into it, then typechecked andnext build-verified with the workflow SWC plugin. Caught real API drift (Sandbox.sandboxId→.name)..github/workflows/patterns-checks.yml: registry validation + codegen freshness + fixture build + realshadcn adde2e on PRs..github/workflows/patterns-nightly.yml: nightly install from the production registry with unpinnedshadcn@latest; opens a tracking issue on failure.Detail-page UX: Usage vs Source
Component pages now separate Usage (the consumer API — "import it and call it") from Source (the full implementation, framed as education — "not a black box; read it, then adapt"). Each pattern also shows version-compatibility badges (
v4/v5), searchable and in the copy-page text.Idempotency hardening
Reviewed the coordination patterns against the run-idempotency work (#2015 / #2373 / #2376):
getConflict()— a lost start race returns{ dedupedTo: winnerRunId }cleanly instead of dying withHookConflictError.kill-switchadopts the registered owner's runId after racingcreate()calls (a loser handle's.signalwould otherwise never fire).batch-aggregatordedupes items by id (sends from retried steps are at-least-once).resumeHook) posted to Idempotency follow-ups: atomic keyed start(), post-completion dedupe, and attribute-based run lookup #2376.Semaphore: releases are keyed, so a re-sent release can't mint capacity
The cross-run fan-out test was intermittently reporting
max = 4againstmaxConcurrent = 3. Not a flaky assertion — a real over-admission bug in therecipe.
sendSemaphoreEventretriessemaphoreEvents.resume(...)in a catch-and-retryloop, and the step wrapping it is itself at-least-once — so a release that
actually landed can look like a failure to its sender and be sent again. The
coordinator applied both copies:
Each extra copy hands back capacity nobody gave up, and one more holder slips
into the critical section — exactly the
maxConcurrent + 1signature observed.Both events now carry the holder's
grantToken, and the coordinator keeps aheld: Set<string>as an idempotency ledger.inFlightis just its size, andevery mutation is guarded by a membership test, so replaying any event is a
no-op:
A new deterministic test covers the invariant — it delivers a release for a
token the coordinator never granted while a holder occupies the only permit,
then checks that no second holder gets in. It discriminates: with the
membership guard stubbed out it fails
expected 2 to be 1(the samemaxConcurrent + 1signature), and passes with the guard in place.Validated under load, 4 workers,
.workflow-datacleared each time:6/6 iterations clean post-fix (25 files / 82 tests). The same loop on the
pre-fix tree failed 2 of 4 iterations, both on the concurrency bound.
Replay-safety fix in the coordination patterns
Merging
mainturned the vitest job red — not a flake, a hard failure in ~3s:Three shipped recipes had the same latent bug.
withPermit,withRateLimit, andwithBreakereach allocated their timeout after awaiting a step:Durable correlation IDs (
step_…,wait_…,hook_…) come from a monotonic ULID factory seeded with a fixed timestamp, so the Nth allocation in a run always gets the Nth ID — order is the identity. Asleep()created after anawaittherefore inherits step-completion order, which isn't stable across replays when several holders run concurrently in one run: replay handswait_…to a different caller than the one that logged it, and the run dies withCORRUPTED_EVENT_LOG.Fix: hoist the
sleep()into the same synchronous prefix ascreateHook(), so both IDs are allocated in call order before anything awaits. This also sharpens the semantics — the timeout now bounds the whole acquire (send + wait) rather than just the wait for the verdict.The underlying runtime gap is separate and tracked upstream: the delivery barrier in
packages/coreorders hook↔wait deliveries against each other but not against step completions (repro #3137, fix #3139). This PR fixes the recipes, not the runtime.That runtime gap did surface once in CI on this branch, on the in-run fan-out
test:
step_started for step_… belongs to exitCritical, but the current step consumer is holdBriefly— a step/hook interleaving flipping across replays,which is precisely what #3139 orders and what no barrier covers today
(
registerDeliveryBarriertakes only'hook'and'wait'participants).That observation predates the keyed-release fix above, and the two interact:
over-admission puts more chains inside the critical section than
maxConcurrentallows, multiplying the interleavings that can flip. The testis deliberately left enabled rather than skipped — it is the regression
guard for the allocation-order hoist, and that regression also manifests as
ReplayDivergenceError, so tolerating the signature would gut the guard whilelooking like it still worked. If CI diverges again now that admission is
bounded, that's a genuine #3139 sighting and worth quarantining explicitly;
until then it stays honest.
Coordinator start: at most once per sender, with backoff
sendBreakerEvent/sendSemaphoreEvent/sendSlotRequestcalledstart(coordinator, …)on every failedresume.start()resolves on enqueue rather than execution, and the coordinator's hook token isn't claimed until it runs its first instruction — so N callers hitting a cold key spawned N × attempts throwaway runs, all but one immediately deduping. Each sender now starts the coordinator at most once and backs off exponentially, giving a cold coordinator room to boot and claim its token on a busy queue.The budget is sized per pattern, against the deadline that pattern advertises. The semaphore and limiter retry 250ms → 4s over 10 attempts (~28s), well inside their
ACQUIRE_RETRY_TIMEOUT(60s) andSLOT_RETRY_TIMEOUT(120s). The breaker is deliberately tighter — 250ms → 2s over 5 attempts (~6s) — because its send is awaited before the verdict race, so a longer budget would stretch the fail-open path past theCHECK_TIMEOUT(10s) the pattern promises.Test shapes: cross-run is the contract, in-run is the regression guard
The semaphore and limiter concurrency tests fanned out inside a single run, which isn't the contract these patterns advertise — "at most N concurrent across all runs and machines". Those fan-outs are now separate workflow runs, with the shared counters living in the step bundle's module scope and read back through a small reader workflow. Each holder is then a single linear chain, which is also how you'd really deploy this.
The in-run
Promise.all(items.map(i => withPermit(…)))shape is kept as an additional test for both patterns — it's what users will actually write against a documented recipe, and it's precisely the shape the hoist above protects. Both ran 5/5 clean locally.Driver counters are deduped by
stepIdnow, too: step execution is at-least-once, so a transparent runtime retry would double-count and the assertion would quietly lie.Navigation, renames & redirects
/patterns/*and/r/*):distributed-abort-controller→kill-switch(v5 shipped native AbortController, so the old name collided),rate-limiting→handling-rate-limits(it teaches 429 handling, freeing the name for the real limiter). Old/registry/*→/patterns/*./cookbook/*and/v5/cookbook/*→/patterns/*redirects; the three substantive cookbook fixes that landed onmainafter this branched (ai-sdk durability, child-workflows hook-resume rewrite, DurableAgent deprecation) were ported into the patterns./patterns/<id>routes (mirrors the existing/worlds/<id>handling).Brought up to date with
mainThis branch had gone stale, so
mainwas merged in and the conflicts resolved. Notable fallout, all fixed here:@types/reactdivergence.install-fixturepinned oldernext/react/@types/reactthan the workbench apps. pnpm hoists a single copy of a package intonode_modules/.pnpm/node_modules/, so a second@types/reactput twoCSSPropertiesidentities in the graph and broke the turbopack + webpack workbench deployments (CSSPropertiesnot assignable toMotionStyle). Fixed by aligning the fixture's pins and loosening@types/reactto^19.monaco-editor: "latest". Regenerating the lockfile re-resolved it 0.55.1 → 0.56.0, which added an exports map rooted atesm/vs/, somonaco-vim's legacy deep imports resolved to doubled paths and the swc-playground build failed.monaco-vim@0.4.4is the newest release and has no exports-map-aware version, somonaco-editoris pinned to 0.55.1. Worth notingswc-playgroundstill carries three other"latest"specifiers (@monaco-editor/react,@vercel/analytics,next-themes) — the same landmine.getWritable()and an optionalemit()/SagaEventstreaming note; the pattern source uses neither, so both were corrected to thegetStepMetadata()it actually imports (the other six patterns listinggetWritable()were checked and do use it). The patterns hero no longer says "for popular providers", and "Submit your recipe" now points at the recipe directory rather than the repo root./docs/foundations/workflows-and-stepslink flagged as a 404 was real at the time — that page was added tomainin docs: split v4/v5 content trees and fix version switcher end-to-end #1948 a day after the comment. The merge brings it in; all 16 Key-API links now resolve.Known gaps from the geistdocs migration
main's move to@vercel/geistdocs(#2222) removed two things this branch previously had, with no supported replacement in@vercel/geistdocs@1.14.0:Both are cosmetic and can be restored if/when geistdocs exposes the hooks.
Test plan
pnpm dlx shadcn@latest add https://workflow-sdk.dev/r/semaphoreinstalls the component and it typechecks/builds/r/[name]returns validregistry-item.jsonwith correct targets +dependenciespatterns-checksCI green: registry validation, codegen freshness, fixture typecheck + build, shadcn install e2eworkbench/vitestpatterns suites green (52 tests across 17 suites), and the full workbench suite (25 files / 82 tests) clean 6/6 under load/cookbook/*,/v5/cookbook/*,/registry/*, and renamed pattern URLs redirect correctlyE2E note: the
E2E Required Checkfailures on this branch match knownmainflakes rather than anything the merge introduced —
mainitself was red on alleight of its most recent
tests.ymlruns, the failing app rotates run to run,and the individual tests/timeouts line up with the same failures on main
(
webhookWorkflowat ~120s, HMR body-only at ~340s). The one Vercel-prodfailure passed on rerun.
Docs Preview
Base:
https://workflow-docs-git-karthik-shadcn-registry-2.vercel.sh/patterns/sagaChanged
docs/contentpages:foundations/starting-workflowsfoundations/versioningapi-reference/workflow-api/startThe preview sits behind deployment protection, so these require Vercel team access.
🤖 Generated with Claude Code