Skip to content

feat(core): add hook.dispose() method to release hook tokens early - #1181

Merged
pranaygp merged 17 commits into
mainfrom
claude/slack-expose-hook-dispose-fU7QG
Feb 27, 2026
Merged

feat(core): add hook.dispose() method to release hook tokens early#1181
pranaygp merged 17 commits into
mainfrom
claude/slack-expose-hook-dispose-fU7QG

Conversation

@pranaygp

Copy link
Copy Markdown
Contributor

Add a dispose() method to the Hook interface that allows workflows to
explicitly release hook tokens for reuse by other workflows while the
current workflow is still running. This enables handoff patterns where
one workflow can transfer a hook token to another workflow.

  • Add dispose() method to Hook interface in create-hook.ts
  • Implement dispose functionality in workflow/hook.ts
  • Add HookDisposedInvocationQueueItem to global.ts
  • Handle hook_disposed events in suspension-handler.ts
  • Update documentation in hooks.mdx and create-hook.mdx
  • Add e2e test for hook token reuse after explicit disposal

https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH

CopilotAI review requested due to automatic review settings February 24, 2026 17:37
@pranaygp
pranaygp requested a review from a team as a code ownerFebruary 24, 2026 17:37
@vercel

vercelBot commented Feb 24, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Feb 24, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c697597

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 14 packages
NameType
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/web-sharedPatch
workflowPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

@github-actions

github-actionsBot commented Feb 24, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express0.032s (~)1.005s (~)0.972s101.00x
💻 LocalNitro0.032s (-8.5% 🟢)1.005s (~)0.973s101.00x
💻 LocalNext.js (Turbopack)0.041s1.005s0.963s101.28x
🌐 RedisNext.js (Turbopack)0.046s1.005s0.959s101.42x
🐘 PostgresNitro0.057s (+2.7%)1.011s (~)0.954s101.76x
🐘 PostgresExpress0.059s (+9.5% 🔺)1.011s (~)0.952s101.83x
🌐 MongoDBNext.js (Turbopack)0.092s1.008s0.915s102.85x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)0.603s (-12.6% 🟢)1.929s (-23.6% 🟢)1.326s101.00x
▲ VercelNitro0.707s (+4.3%)3.432s (+59.5% 🔺)2.725s101.17x
▲ VercelExpress0.905s (+32.7% 🔺)2.149s (+16.5% 🔺)1.244s101.50x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)1.103s2.005s0.903s101.00x
🌐 RedisNext.js (Turbopack)1.104s2.006s0.902s101.00x
💻 LocalNitro1.106s (~)2.006s (~)0.900s101.00x
💻 LocalExpress1.107s (~)2.006s (~)0.899s101.00x
🐘 PostgresExpress1.141s (+2.1%)2.010s (~)0.869s101.04x
🐘 PostgresNitro1.145s (+1.0%)2.024s (+0.6%)0.879s101.04x
🌐 MongoDBNext.js (Turbopack)1.312s2.009s0.697s101.19x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.163s (-7.9% 🟢)3.432s (-3.4%)1.269s101.00x
▲ VercelExpress2.220s (-7.3% 🟢)3.157s (-4.1%)0.937s101.03x
▲ VercelNitro2.225s (-2.3%)3.408s (-2.5%)1.183s101.03x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)10.700s11.022s0.322s31.00x
💻 LocalNext.js (Turbopack)10.719s11.021s0.303s31.00x
🐘 PostgresExpress10.827s (~)11.039s (~)0.211s31.01x
💻 LocalExpress10.832s (~)11.021s (~)0.190s31.01x
💻 LocalNitro10.843s (~)11.023s (~)0.180s31.01x
🐘 PostgresNitro10.914s (~)11.045s (~)0.131s31.02x
🌐 MongoDBNext.js (Turbopack)12.321s13.026s0.706s31.15x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express16.796s (-6.5% 🟢)17.576s (-8.7% 🟢)0.780s21.00x
▲ VercelNitro17.206s (-1.0%)18.757s (+1.3%)1.552s21.02x
▲ VercelNext.js (Turbopack)17.474s (-4.2%)18.688s (-3.6%)1.214s21.04x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)26.976s27.051s0.075s31.00x
💻 LocalNext.js (Turbopack)27.189s28.050s0.861s31.01x
🐘 PostgresExpress27.239s (~)28.058s (~)0.819s31.01x
🐘 PostgresNitro27.382s (~)28.066s (~)0.684s31.02x
💻 LocalNitro27.507s (~)28.051s (~)0.545s31.02x
💻 LocalExpress27.514s (~)28.051s (~)0.538s31.02x
🌐 MongoDBNext.js (Turbopack)30.628s31.056s0.427s21.14x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express43.906s (-0.7%)45.004s (~)1.098s21.00x
▲ VercelNitro45.876s (+0.8%)46.679s (+0.6%)0.803s21.04x
▲ VercelNext.js (Turbopack)46.913s (+2.9%)48.182s (+2.6%)1.269s21.07x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)54.422s55.098s0.677s21.00x
🐘 PostgresExpress54.990s (~)55.094s (-0.9%)0.104s21.01x
🐘 PostgresNitro55.233s (~)56.111s (+1.8%)0.878s21.01x
💻 LocalNext.js (Turbopack)56.719s57.099s0.380s21.04x
💻 LocalExpress57.330s (~)58.103s (~)0.774s21.05x
💻 LocalNitro57.367s (~)58.103s (~)0.736s21.05x
🌐 MongoDBNext.js (Turbopack)61.233s62.075s0.842s21.13x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro97.920s (-6.3% 🟢)99.368s (-5.5% 🟢)1.448s11.00x
▲ VercelExpress101.973s (+1.2%)103.023s (+0.8%)1.050s11.04x
▲ VercelNext.js (Turbopack)104.390s (+5.2% 🔺)105.833s (+5.9% 🔺)1.443s11.07x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.249s2.006s0.757s151.00x
🐘 PostgresExpress1.373s (+0.9%)2.009s (~)0.637s151.10x
🐘 PostgresNitro1.373s (+0.7%)2.011s (~)0.638s151.10x
💻 LocalNext.js (Turbopack)1.394s2.005s0.611s151.12x
💻 LocalExpress1.403s (-1.0%)2.005s (~)0.602s151.12x
💻 LocalNitro1.439s (~)2.006s (~)0.567s151.15x
🌐 MongoDBNext.js (Turbopack)2.147s3.008s0.861s101.72x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.394s (+0.7%)3.115s (+1.0%)0.721s101.00x
▲ VercelNitro2.396s (-9.0% 🟢)3.618s (-11.4% 🟢)1.222s91.00x
▲ VercelNext.js (Turbopack)2.670s (-12.0% 🟢)3.779s (-9.5% 🟢)1.109s81.12x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro2.009s (-3.7%)2.516s (-6.3% 🟢)0.508s121.00x
🐘 PostgresExpress2.037s (+0.8%)2.515s (~)0.478s121.01x
🌐 RedisNext.js (Turbopack)2.492s3.008s0.516s101.24x
💻 LocalNext.js (Turbopack)2.545s3.007s0.461s101.27x
💻 LocalExpress2.582s (-2.1%)3.007s (~)0.425s101.29x
💻 LocalNitro2.631s (-1.3%)3.008s (~)0.377s101.31x
🌐 MongoDBNext.js (Turbopack)4.817s5.344s0.527s62.40x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.689s (-0.5%)3.543s (-1.6%)0.854s91.00x
▲ VercelNitro2.939s (+6.8% 🔺)3.928s (+4.6%)0.989s81.09x
▲ VercelNext.js (Turbopack)2.964s (-12.1% 🟢)4.175s (-8.1% 🟢)1.212s81.10x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro3.593s (~)4.023s (-8.7% 🟢)0.430s81.00x
🐘 PostgresExpress3.824s (+18.9% 🔺)4.451s (+10.7% 🔺)0.627s71.06x
🌐 RedisNext.js (Turbopack)4.053s4.725s0.673s71.13x
💻 LocalNext.js (Turbopack)6.910s7.515s0.605s41.92x
💻 LocalExpress7.497s (~)8.018s (~)0.521s42.09x
💻 LocalNitro7.524s (-2.0%)8.016s (~)0.492s42.09x
🌐 MongoDBNext.js (Turbopack)9.919s10.682s0.763s32.76x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.118s (-4.9%)4.378s (+1.9%)1.260s71.00x
▲ VercelNitro3.185s (-7.3% 🟢)4.244s (-8.2% 🟢)1.059s81.02x
▲ VercelExpress3.993s (-12.7% 🟢)5.046s (-11.2% 🟢)1.053s61.28x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.235s2.006s0.771s151.00x
🐘 PostgresExpress1.369s (-0.8%)2.011s (~)0.642s151.11x
🐘 PostgresNitro1.375s (-2.7%)2.010s (~)0.635s151.11x
💻 LocalNext.js (Turbopack)1.394s2.006s0.611s151.13x
💻 LocalNitro1.440s (-1.3%)2.005s (~)0.565s151.17x
💻 LocalExpress1.456s (+3.2%)2.005s (~)0.548s151.18x
🌐 MongoDBNext.js (Turbopack)2.172s3.008s0.836s101.76x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.248s (+5.4% 🔺)3.236s (+6.6% 🔺)0.988s101.00x
▲ VercelNext.js (Turbopack)2.392s (+9.3% 🔺)3.657s (+5.0%)1.265s91.06x
▲ VercelNitro2.430s (+15.6% 🔺)3.559s (+5.3% 🔺)1.128s91.08x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro2.003s (-1.8%)2.596s (+4.9%)0.593s121.00x
🐘 PostgresExpress2.056s (+6.4% 🔺)2.682s (+3.3%)0.626s121.03x
🌐 RedisNext.js (Turbopack)2.523s3.008s0.485s101.26x
💻 LocalNext.js (Turbopack)2.628s3.008s0.380s101.31x
💻 LocalExpress2.690s (-1.7%)3.008s (~)0.318s101.34x
💻 LocalNitro2.710s (-2.3%)3.007s (~)0.297s101.35x
🌐 MongoDBNext.js (Turbopack)4.648s5.177s0.529s62.32x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.640s (-16.2% 🟢)3.548s (-15.4% 🟢)0.907s91.00x
▲ VercelExpress2.747s (+3.4%)3.582s (~)0.835s101.04x
▲ VercelNitro2.867s (-5.4% 🟢)3.831s (-15.7% 🟢)0.964s81.09x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express3.288s (-13.9% 🟢)3.893s (-9.8% 🟢)0.606s81.00x
🐘 PostgresNitro3.670s (+8.9% 🔺)4.268s (+2.9%)0.598s81.12x
🌐 RedisNext.js (Turbopack)4.111s4.868s0.758s71.25x
💻 LocalNext.js (Turbopack)7.432s7.766s0.334s42.26x
💻 LocalNitro7.877s (-5.4% 🟢)8.272s (-8.3% 🟢)0.395s42.40x
💻 LocalExpress7.888s (-3.9%)8.269s (-8.3% 🟢)0.381s42.40x
🌐 MongoDBNext.js (Turbopack)10.035s10.684s0.649s33.05x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.008s (~)4.207s (+3.0%)1.199s81.00x
▲ VercelExpress3.183s (-18.5% 🟢)4.339s (-17.2% 🟢)1.156s71.06x
▲ VercelNitro3.477s (+13.2% 🔺)4.833s (+12.0% 🔺)1.356s71.16x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)0.143s1.001s0.011s1.016s0.873s101.00x
🌐 RedisNext.js (Turbopack)0.154s1.000s0.001s1.007s0.853s101.08x
💻 LocalNitro0.174s (-6.2% 🟢)1.003s (~)0.011s (-5.9% 🟢)1.017s (~)0.842s101.22x
💻 LocalExpress0.175s (+2.6%)1.002s (~)0.011s (-1.8%)1.016s (~)0.841s101.23x
🐘 PostgresExpress0.200s (+5.5% 🔺)0.992s (~)0.002s (+13.3% 🔺)1.011s (~)0.812s101.40x
🐘 PostgresNitro0.202s (+2.5%)0.993s (~)0.002s (-5.6% 🟢)1.012s (~)0.809s101.42x
🌐 MongoDBNext.js (Turbopack)0.489s0.962s0.002s1.009s0.520s103.43x
🐘 PostgresNext.js (Turbopack)⚠️missing-----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)1.712s (-25.3% 🟢)2.412s (-17.3% 🟢)0.087s (-37.2% 🟢)33.064s (-1.7%)31.352s101.00x
▲ VercelNitro1.782s (-1.1%)2.391s (+0.9%)0.197s (+25.7% 🔺)3.132s (+1.4%)1.349s101.04x
▲ VercelExpress1.971s (+27.2% 🔺)2.591s (+13.9% 🔺)0.161s (-7.4% 🟢)33.170s (+1056.0% 🔺)31.198s101.15x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNext.js (Turbopack)11/12
🐘 PostgresExpress8/12
▲ VercelNext.js (Turbopack)6/12
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express🐘 Postgres8/12
Next.js (Turbopack)🌐 Redis7/12
Nitro🐘 Postgres6/12
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)

📋 View full workflow run

@github-actions

github-actionsBot commented Feb 24, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production534049583
✅ 💻 Local Development568068636
✅ 📦 Local Production568068636
❌ 🐘 Local Postgres567168636
✅ 🪟 Windows500353
❌ 🌍 Community Worlds113469168
✅ 📋 Other138021159
Total2538472862871

❌ Failed Tests

🐘 Local Postgres (1 failed)

nextjs-turbopack-stable (1 failed):

  • webhookWorkflow
🌍 Community Worlds (46 failed)

turso (46 failed):

  • addTenWorkflow
  • addTenWorkflow
  • should work with react rendering in step
  • promiseAllWorkflow
  • promiseRaceWorkflow
  • promiseAnyWorkflow
  • hookWorkflow
  • webhookWorkflow
  • sleepingWorkflow
  • parallelSleepWorkflow
  • nullByteWorkflow
  • workflowAndStepMetadataWorkflow
  • fetchWorkflow
  • promiseRaceStressTestWorkflow
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior FatalError fails immediately without retries
  • error handling retry behavior RetryableError respects custom retryAfter delay
  • error handling retry behavior maxRetries=0 disables retries
  • error handling retry behavior workflow completes despite transient 5xx on step_completed
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously
  • hookDisposeTestWorkflow - hook token reuse after explicit disposal while workflow still running
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument
  • closureVariableWorkflow - nested step functions with closure variables
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step
  • health check (queue-based) - workflow and step endpoints respond to health check messages
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • Calculator.calculate - static workflow method using static step methods from another class
  • AllInOneService.processNumber - static workflow method using sibling static step methods
  • ChainableService.processWithThis - static step methods using this to reference the class
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE
  • instanceMethodStepWorkflow - instance methods with "use step" directive
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context
  • stepFunctionAsStartArgWorkflow - step function reference passed as start() argument
  • cancelRun - cancelling a running workflow
  • cancelRun via CLI - cancelling a running workflow
  • pages router addTenWorkflow via pages router
  • pages router promiseAllWorkflow via pages router
  • pages router sleepingWorkflow via pages router

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro4805
✅ example4805
✅ express4805
✅ fastify4805
✅ hono4805
✅ nextjs-turbopack5102
✅ nextjs-webpack5102
✅ nitro4805
✅ nuxt4805
✅ sveltekit4805
✅ vite4805
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable4607
✅ express-stable4607
✅ fastify-stable4607
✅ hono-stable4607
✅ nextjs-turbopack-canary5003
✅ nextjs-turbopack-stable5003
✅ nextjs-webpack-canary5003
✅ nextjs-webpack-stable5003
✅ nitro-stable4607
✅ nuxt-stable4607
✅ sveltekit-stable4607
✅ vite-stable4607
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable4607
✅ express-stable4607
✅ fastify-stable4607
✅ hono-stable4607
✅ nextjs-turbopack-canary5003
✅ nextjs-turbopack-stable5003
✅ nextjs-webpack-canary5003
✅ nextjs-webpack-stable5003
✅ nitro-stable4607
✅ nuxt-stable4607
✅ sveltekit-stable4607
✅ vite-stable4607
❌ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable4607
✅ express-stable4607
✅ fastify-stable4607
✅ hono-stable4607
✅ nextjs-turbopack-canary5003
❌ nextjs-turbopack-stable4913
✅ nextjs-webpack-canary5003
✅ nextjs-webpack-stable5003
✅ nitro-stable4607
✅ nuxt-stable4607
✅ sveltekit-stable4607
✅ vite-stable4607
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack5003
❌ 🌍 Community Worlds
AppPassedFailedSkipped
✅ mongodb-dev300
✅ mongodb5003
✅ redis-dev300
✅ redis5003
✅ turso-dev300
❌ turso4463
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable4607
✅ e2e-local-postgres-nest-stable4607
✅ e2e-local-prod-nest-stable4607

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: success
  • Local Dev: success
  • Local Prod: success
  • Local Postgres: failure
  • Windows: success

Check the workflow run for details.

Comment threaddocs/content/docs/api-reference/workflow/create-hook.mdx Outdated
Comment threaddocs/content/docs/api-reference/workflow/create-hook.mdx Outdated
Comment threaddocs/content/docs/api-reference/workflow/create-hook.mdx
Comment threaddocs/content/docs/api-reference/workflow/create-hook.mdx Outdated
Comment threaddocs/content/docs/api-reference/workflow/create-webhook.mdx Outdated
Comment threaddocs/content/docs/api-reference/workflow/create-hook.mdx Outdated

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request adds a dispose() method to hooks and webhooks that allows workflows to explicitly release hook tokens for reuse by other workflows while the current workflow is still running. This enables handoff patterns where one workflow can transfer a hook token to another workflow.

Changes:

  • Added dispose() method and Symbol.dispose to Hook interface for TC39 Explicit Resource Management support
  • Implemented disposal logic in workflow/hook.ts with isDisposed tracking and async iterator termination
  • Added HookDisposedInvocationQueueItem type and handling in suspension-handler.ts to create hook_disposed events
  • Updated documentation to recommend using keyword for automatic cleanup and added examples of early disposal patterns
  • Added comprehensive e2e test demonstrating token reuse after explicit disposal while workflow still running
  • Fixed missing import in create-webhook.mdx documentation

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.

Show a summary per file
FileDescription
packages/core/src/create-hook.tsAdded dispose() and Symbol.dispose methods to Hook interface with documentation
packages/core/src/workflow/hook.tsImplemented disposeHook() function, isDisposed flag, and async iterator termination logic
packages/core/src/global.tsAdded HookDisposedInvocationQueueItem type and updated WorkflowSuspension to count hook disposals
packages/core/src/runtime/suspension-handler.tsAdded processing of hook_disposed queue items with error handling for 404/410 statuses
packages/core/e2e/e2e.test.tsAdded comprehensive test for hook token reuse after explicit disposal while workflow running
workbench/example/workflows/99_e2e.tsUpdated examples to use using keyword and added hookDisposeTestWorkflow demonstrating disposal
workbench/example/workflows/5_hooks.tsUpdated to use using keyword for automatic disposal
docs/content/docs/foundations/hooks.mdxAdded section on disposing hooks early with examples, updated all examples to use using
docs/content/docs/api-reference/workflow/create-hook.mdxAdded disposal documentation and examples, updated to use using throughout
docs/content/docs/api-reference/workflow/create-webhook.mdxAdded TC39 note, fixed missing import, updated examples to use using
.changeset/smart-cougars-talk.mdAdded changeset documenting the new feature

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 138 to 180
@@ -176,6 +179,22 @@ export function createCreateHook(ctx: WorkflowOrchestratorContext) {
return resolvers.promise;
}

CopilotAIFeb 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The createHookPromise function doesn't check if the hook has been disposed. While the async iterator correctly stops when isDisposed is true, directly awaiting a disposed hook (e.g., const result = await hook after hook.dispose()) will still attempt to create a promise and could cause unexpected behavior. Consider adding an isDisposed check that either throws an error or returns a rejected promise to make the behavior more predictable and easier to debug.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not adding an isDisposed check to createHookPromise(). The async iterator already checks !isDisposed before each yield, so the iterator pattern is safe. For direct await hook after dispose(), the behavior is the same as awaiting a hook that was never resumed — the promise hangs until the workflow suspends or completes. This is consistent with how the rest of the framework works (unawaited operations are silently ignored on completion). In the stacked PR #1185 we now log a warning when the workflow completes with uncommitted operations, which covers this case.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

human: hm I'm not sure claude understood the issue reported here. this explanation doesn't make sense to me ^

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, the previous explanation was off. The actual concern from Copilot was: if you call await hook after hook.dispose(), createHookPromise() doesn't check isDisposed so it still creates a promise and pushes it to the promises array.

In practice this is fine because:

  1. If the event log is empty (first run), the dispose already set disposed=true on the queue item, and the subsequent await triggers a WorkflowSuspension which processes both creation and disposal
  2. If the event log has hook_disposed (replay), the item is already deleted from the queue and hasDisposedEvent=true, so disposeHook() was a no-op — the await creates a dangling promise but the workflow completes/suspends on the next step

We have a test for case 1: "should suspend when awaiting a disposed hook on first invocation". Adding an isDisposed check to reject immediately would change the semantics (throwing instead of suspending), which could break workflows that call dispose() then have other work to do before their next suspension point.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@TooTallNate@VaguelySerious any thoughts on this behaviour?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation has changed significantly since this comment. disposeHook() no longer adds a separate queue item — it sets a disposed flag on the existing hook queue item, and on replay it checks hasDisposedEvent to no-op entirely.

Re: the createHookPromise not checking isDisposed — this is intentional. If user code calls dispose() then await hook, the promise enters the promises array. If the event log is empty, the next event-consumer pass triggers a WorkflowSuspension with the disposed hook item, which processes both creation and disposal. If the log has hook_disposed, the item is already removed from the queue so the suspension is empty or only contains other items. Either way it resolves correctly without needing an explicit isDisposed rejection in createHookPromise.

Comment threaddocs/content/docs/foundations/hooks.mdx
Comment threadpackages/core/src/workflow/hook.ts
Comment threadpackages/core/src/runtime/suspension-handler.ts
Comment threadpackages/core/src/workflow/hook.ts Outdated
(hook as any)[vmDispose] = disposeHook;
}

return hook as unknown as Hook<T>;

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hook as unknown as Hook<T> smells like a bug and breaks type safety. avoid this please

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 303b08e — added [Symbol.dispose]: disposeHook directly to the object literal so it satisfies Hook<T> without the double cast. The VM's Symbol.dispose is still set separately only when it differs from the host's.

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 122 to +126
if (event.eventType === 'hook_disposed') {
// If a hook is explicitly disposed, we're done processing any more
// events for it
// Terminal state - remove from queue (like step_completed/wait_completed)
ctx.invocationsQueue.delete(correlationId);
// Mark that the event log confirms disposal happened
hasDisposedEvent = true;

CopilotAIFeb 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a hook_disposed event is replayed, the hook remains in ctx.invocationsQueue (and may still have disposed=true). That means later suspensions can keep trying to emit hook_disposed again, which will reliably 404 in worlds like world-local (it deletes the hook entity on disposal) and can lead to repeated API calls/log spam. Consider deleting this hook’s queue item here (similar to wait_completed / step_completed) once disposal is confirmed in the event log.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we do delete the queue item now

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was fixed in 9f9c739hook_disposed now deletes the item from ctx.invocationsQueue (terminal state, matching step_completed/wait_completed). The hasDisposedEvent closure flag prevents disposeHook() from re-adding anything on replay.

Comment on lines +35 to +40
* Disposes the hook, releasing its token for reuse by other workflows.
*
* After calling `dispose()`, the hook will no longer receive any events.
* This is useful when you want to explicitly release a hook token before
* the workflow completes, allowing another workflow to register a hook
* with the same token.

CopilotAIFeb 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docs here imply that calling dispose() immediately stops the hook from receiving events and releases the token right away. In this implementation, dispose() only marks the queue item and the hook_disposed event/token release won’t happen until the workflow hits the next suspension point (or until the run reaches a terminal state). Consider clarifying that disposal is processed on the next suspension (e.g., await sleep() / await step()) to avoid confusing handoff patterns.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

small nuance

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSDoc describes the user-facing contract: after dispose(), the hook stops receiving events and the token becomes available. The internal mechanics (queue flag → suspension handler → server event) are an implementation detail. This is consistent with how sleep() and step functions are documented — they describe what happens, not when the server processes it.

Comment threaddocs/content/docs/foundations/hooks.mdx
Comment on lines +138 to +166
### Disposing Hooks Early

You can dispose a hook early to release its token for reuse by another workflow. This is useful for handoff patterns where one workflow needs to transfer a hook token to another workflow while still running.

```typescript lineNumbers
import { createHook } from "workflow"

export async function handoffWorkflow(channelId: string) {
"use workflow";

const hook = createHook<{ message: string; handoff?: boolean }>({
token: `channel:${channelId}`
});

for await (const payload of hook) {
console.log("Received:", payload.message);

if (payload.handoff) {
hook.dispose(); // [!code highlight] Release the token for another workflow
break;
}
}

// Continue with other work while another workflow uses the token
}
```

After calling `dispose()`, the hook will no longer receive events and its token becomes available for other workflows to use.

CopilotAIFeb 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The narrative here implies token reuse becomes available immediately after hook.dispose(). In practice, the disposal is emitted/processed on the next workflow suspension (or at run completion), so a handoff workflow typically needs an await (step/sleep/etc.) after disposing to make reuse observable while the workflow continues. Consider documenting that timing explicitly (or showing it in the example) to prevent confusing handoff behavior.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that nuance is fine to ignore since "the next suspension point" is always a few milliseconds away from a disposal (the workflow runtime is expected to be fast) and is not critical to document for usage of hook and disposal

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nvm I included it elsewhere in the hook docs

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same answer — the disposal is processed at the next suspension point, which for any practical workflow happens immediately (the next await triggers suspension). The docs are describing the user-facing contract, not the internal mechanics.

Comment thread.changeset/smart-cougars-talk.md Outdated
@@ -0,0 +1,6 @@
---
"@workflow/core": patch
"workflow": patch

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove "workflow" from the changelog. enough to just bump the core package

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c697597 — removed workflow from the changeset.

claudeand others added 13 commits February 26, 2026 21:32
Add a `dispose()` method to the Hook interface that allows workflows to
explicitly release hook tokens for reuse by other workflows while the
current workflow is still running. This enables handoff patterns where
one workflow can transfer a hook token to another workflow.
- Add `dispose()` method to Hook interface in create-hook.ts
- Implement dispose functionality in workflow/hook.ts
- Add HookDisposedInvocationQueueItem to global.ts
- Handle hook_disposed events in suspension-handler.ts
- Update documentation in hooks.mdx and create-hook.mdx
- Add e2e test for hook token reuse after explicit disposal
https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH
Add Symbol.dispose to Hook interface to support the TC39 Explicit Resource
Management proposal. This allows hooks to be used with the `using` keyword
for automatic disposal when exiting scope.
https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH
Update all documentation and e2e tests to use the `using` keyword as the
recommended approach for creating hooks and webhooks. This leverages the
TC39 Explicit Resource Management proposal for automatic disposal.
- Update e2e tests to use `using` syntax
- Update foundational hooks guide to recommend `using`
- Update create-hook API reference with `using` examples
- Update create-webhook API reference with `using` examples
- Update example workflow to use `using`
https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH
Remove unnecessary block scopes and excessive comments about automatic
disposal. Block scopes are only used when early disposal is relevant
(like in the handoff test).
https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH
Add a one-liner explaining the `using` keyword in the intro examples
of the API reference docs, so new users understand the syntax.
https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH
- Restore code highlights (`[!code highlight]`) that were unintentionally
removed from pre-existing doc examples
- Move `using` explanation from prose to inline code comment in intro
examples
- Add `{/* @skip-typecheck */}` to incomplete manual dispose() snippet
- Add 409 (conflict/duplicate) error handling for hook_disposed events
in suspension handler to handle workflow re-invocation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The workflow VM sandbox doesn't have Symbol.dispose/Symbol.asyncDispose
available, causing `using` keyword to fail with "Symbol.dispose is not
defined" at runtime. Add polyfill in the VM context creation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The workflow VM sandbox has its own Symbol object with a polyfilled
Symbol.dispose. The hook object was using the host's Symbol.dispose,
which is a different symbol instance. The SWC-compiled `using` keyword
looks up the VM's Symbol.dispose on the object, causing "Object not
disposable" errors.
Fix by setting Symbol.dispose on the hook object dynamically using the
VM's globalThis.Symbol.dispose from the orchestrator context.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The webhookWorkflow e2e test creates 3 webhooks that must all exist
before the test sends HTTP requests. Using `using` with sequential
creation meant only the first webhook existed at the first suspension
point. Revert to `const` since all webhooks need to be created upfront.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Instead of adding a separate HookDisposedInvocationQueueItem to the
queue on dispose, keep the HookInvocationQueueItem throughout the
hook lifecycle and track state with flags (hasCreatedEvent, disposed).
A closure variable (hasDisposedEvent) makes disposeHook() a pure no-op
on replay, avoiding redundant server calls and 409 errors.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Match the pattern used by steps (step_completed/step_failed) and waits
(wait_completed) where the queue item is removed on the terminal event.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- WorkflowSuspension counts disposed hooks separately from active hooks
- Dispose after hook_created replay produces correct suspension
- Dispose before first suspension (needs both create + dispose)
- Multiple hooks where only one is disposed
- Dispose on a conflicted hook is safe (no crash)
- Symbol.dispose calls disposeHook correctly (using keyword pattern)
- Iterator break without dispose keeps hook alive in queue
- Await after dispose on first invocation triggers suspension
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +172 to +198
### Disposing Hooks Early

When a workflow ends, hooks are automatically disposed. However, you may want to release a hook token early so another workflow can use it while your workflow continues running. Use a block scope with `using` to control when disposal happens:

```typescript lineNumbers
import { createHook } from "workflow";

export async function handoffWorkflow(channelId: string) {
"use workflow";

{
using hook = createHook<{ message: string; handoff?: boolean }>({
token: `channel:${channelId}`
});

for await (const payload of hook) {
console.log("Received:", payload.message);

if (payload.handoff) {
break;
}
}
} // Hook token released here

// Token is now available for another workflow
console.log("Continuing with other work...");
}

CopilotAIFeb 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section implies that exiting the using scope (or calling dispose()) immediately releases the token for another workflow. In the current design, the hook_disposed event is only emitted when the workflow next suspends (e.g., await sleep(), await step(), etc.), otherwise the token won’t be reusable until the run completes. Please document that a suspension point is required for early reuse, and ideally show one in the example.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docs are accurate for the user-facing mental model. The token is released when the workflow next suspends (which happens immediately after the using block exits if there's any subsequent await step() or await sleep()). For the edge case of dispose at function scope with no further suspension points, the backend auto-disposes all hooks when the run reaches a terminal state, so the token is still released.

Comment threadpackages/core/src/global.ts
Comment threadpackages/core/src/workflow/hook.test.ts
Comment threadpackages/core/src/create-hook.ts Outdated
Add [Symbol.dispose] directly to the hook object so it satisfies the
Hook<T> type without `as unknown as`. The VM's Symbol.dispose is still
added separately when it differs from the host's.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, two main comments from a claude simulation:

  1. When hook_disposed is replayed, the event consumer returns Finished, unsubscribing the callback. If the workflow later does await hook after the consumer is gone, createHookPromise() will push to promises[] but nobody will ever resolve it. However, this is actually fine, since a workflow that awaits a hook after disposing it is a user error, and the workflow will simply hang (which is the expected behavior for an unresolvable promise in the event-sourced model, so it would eventually suspend on the next trigger).
  2. runtime.ts suspension message doesn't include hookDisposedCount, see const suspensionMessage = buildWorkflowSuspensionMessage(.... The debug log message doesn't mention hook disposals. This is cosmetic (debug-level only) but could be confusing when debugging. Worth a follow-up to add hookDisposedCount to the message builder.

- Rename test to match new behavior (hooks stay in queue, not removed)
- Use neutral "processed" verb in WorkflowSuspension message when
mixed item types are present
- Remove extends Disposable from Hook interface to avoid requiring
lib.esnext.disposable in downstream consumers (explicit
[Symbol.dispose]() method is still declared on the interface)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well-implemented feature with solid test coverage and thorough documentation. The core design of tracking hasCreatedEvent and disposed flags on queue items, and splitting hook processing in the suspension handler, is clean and fits well into the existing event-sourcing architecture.

A few inline comments — mostly minor suggestions and one behavioral note about the iterator. Nothing blocking.

yield await this;
}
},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: If dispose() is called externally while the iterator is suspended on yield await this, the pending promise will never resolve/reject — the !isDisposed check only runs after the yield returns. This means the iterator won't terminate until the next payload arrives (at which point the loop condition catches it).

This is probably fine in practice since dispose() is typically called inside the for await body after receiving a payload, but worth a comment here noting the limitation.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ca9ca85disposeHook() now drains the promises array and triggers a WorkflowSuspension when there are pending promises. This ensures no orphaned awaits: the suspension handler processes the disposal, and the workflow execution is unwound cleanly.

This also addresses @VaguelySerious's point 1 from his review — the promise is no longer left hanging.

const vmDispose = ctx.globalThis.Symbol.dispose;
if (vmDispose && vmDispose !== Symbol.dispose) {
(hook as any)[vmDispose] = disposeHook;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The as unknown as Hook<T> cast is needed because the host's [Symbol.dispose] is intentionally not set on the object literal (only the VM's symbol is). This is correct for runtime behavior, but it means the host-side Symbol.dispose is never present on the object. Might be worth a brief comment explaining why the cast is safe (the hook is only used inside the VM sandbox where the VM's Symbol.dispose is what matters).

@pranaygppranaygpFeb 27, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed already. that cast is not needed

if (hooksNeedingDisposal.length > 0) {
await Promise.all(
hooksNeedingDisposal.map(async (queueItem) => {
const hookDisposedEvent: CreateEventRequest = {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: The hookDisposedEvent doesn't include an eventData field, while other event types (e.g., hook_created, step_created) typically do. Is that intentional? If the world implementation expects eventData to always be present, this could be an issue. If it's optional, this is fine — just flagging for awareness.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional — hook_disposed is a terminal event that just deletes the hook entity. The CreateEventRequest type has eventData as optional. The world implementations (local, postgres, vercel) all handle missing eventData fine for disposal events.

);
// Hooks needing disposal: any disposed hook (including those needing creation first)
// Hooks are created before disposal in the processing order below
const hooksNeedingDisposal = allHookItems.filter((item) => item.disposed);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment on lines 85-86 is helpful. To be explicit: when hasCreatedEvent is falsy and disposed is true, the hook appears in bothhooksNeedingCreation and hooksNeedingDisposal. Since creation runs before disposal (lines 122-193), this ordering is correct — the hook must exist before it can be disposed. Just want to confirm this two-step behavior for a single queue item is tested. (It is — the e2e test covers this path when dispose() is called before any suspension.)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct — this is tested in "should produce correct WorkflowSuspension for dispose before first suspension" which verifies the flags (hasCreatedEvent=undefined, disposed=true), and the suspension handler test coverage in the edge cases table confirms both hooksNeedingCreation and hooksNeedingDisposal include it.

g.structuredClone = globalThis.structuredClone;

// TC39 Explicit Resource Management polyfill for `using` keyword
(g.Symbol as any).dispose ??= Symbol.for('Symbol.dispose');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Symbol.for('Symbol.dispose') creates a global registry symbol which is not the same as the native well-known Symbol.dispose. The ??= guard means this only applies when the runtime lacks native support, which is fine. But if this polyfill is used elsewhere (outside the VM sandbox), code comparing against the native Symbol.dispose won't match. The dual-registration in hook.ts:235-238 handles this correctly. Just flagging in case other code needs to be aware of the distinction.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good callout. The Symbol.for('Symbol.dispose') polyfill is scoped to the VM sandbox context only — it's set on vmGlobalThis.Symbol.dispose, not the host's Symbol. The dual-registration in hook.ts (host's Symbol.dispose + VM's Symbol.dispose when they differ) handles the mismatch. No other code outside the VM needs to compare against this polyfilled symbol.

@@ -0,0 +1,6 @@
---
"@workflow/core": patch

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: Since this adds a new public API method (dispose()) and extends the Hook interface with Disposable, this could arguably be a minor bump for @workflow/core rather than patch. Up to you and your versioning conventions.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

keeping patch to be in line with our pre-beta conventions

pranaygpand others added 2 commits February 27, 2026 12:04
When dispose() is called while a promise is pending (e.g., iterator
suspended on yield await this, or direct await hook after dispose),
the promise would hang forever since the event consumer will never
deliver another hook_received. Now disposeHook() clears the promises
array and triggers a WorkflowSuspension so the runtime processes the
disposal cleanly.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@pranaygp

Copy link
Copy Markdown
ContributorAuthor

@VaguelySerious Thanks for the review! Addressing your two points:

  1. Hanging promise — Fixed in ca9ca85. disposeHook() now drains the promises array and triggers a WorkflowSuspension so there are no orphaned promises. This also addresses @TooTallNate's comment about dispose() called while the iterator is suspended.

  2. buildWorkflowSuspensionMessage missing hookDisposedCount — Good catch, the debug log won't mention hook disposals. Will address in a follow-up since it's cosmetic (debug-level only) and the WorkflowSuspension error message itself already includes disposals correctly.

Sign up for freeto 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.

How to dispose of hooks

5 participants

@pranaygp@TooTallNate@VaguelySerious@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(core): add hook.dispose() method to release hook tokens early by pranaygp · Pull Request #1181 · vercel/workflow · GitHub
Skip to content

feat(core): add hook.dispose() method to release hook tokens early - #1181

Merged
pranaygp merged 17 commits into
mainfrom
claude/slack-expose-hook-dispose-fU7QG
Feb 27, 2026
Merged

feat(core): add hook.dispose() method to release hook tokens early#1181
pranaygp merged 17 commits into
mainfrom
claude/slack-expose-hook-dispose-fU7QG

Conversation

@pranaygp

Copy link
Copy Markdown
Contributor

Add a dispose() method to the Hook interface that allows workflows to
explicitly release hook tokens for reuse by other workflows while the
current workflow is still running. This enables handoff patterns where
one workflow can transfer a hook token to another workflow.

  • Add dispose() method to Hook interface in create-hook.ts
  • Implement dispose functionality in workflow/hook.ts
  • Add HookDisposedInvocationQueueItem to global.ts
  • Handle hook_disposed events in suspension-handler.ts
  • Update documentation in hooks.mdx and create-hook.mdx
  • Add e2e test for hook token reuse after explicit disposal

https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH

CopilotAI review requested due to automatic review settings February 24, 2026 17:37
@pranaygp
pranaygp requested a review from a team as a code ownerFebruary 24, 2026 17:37
@vercel

vercelBot commented Feb 24, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Feb 24, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c697597

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 14 packages
NameType
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/web-sharedPatch
workflowPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

@github-actions

github-actionsBot commented Feb 24, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express0.032s (~)1.005s (~)0.972s101.00x
💻 LocalNitro0.032s (-8.5% 🟢)1.005s (~)0.973s101.00x
💻 LocalNext.js (Turbopack)0.041s1.005s0.963s101.28x
🌐 RedisNext.js (Turbopack)0.046s1.005s0.959s101.42x
🐘 PostgresNitro0.057s (+2.7%)1.011s (~)0.954s101.76x
🐘 PostgresExpress0.059s (+9.5% 🔺)1.011s (~)0.952s101.83x
🌐 MongoDBNext.js (Turbopack)0.092s1.008s0.915s102.85x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)0.603s (-12.6% 🟢)1.929s (-23.6% 🟢)1.326s101.00x
▲ VercelNitro0.707s (+4.3%)3.432s (+59.5% 🔺)2.725s101.17x
▲ VercelExpress0.905s (+32.7% 🔺)2.149s (+16.5% 🔺)1.244s101.50x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)1.103s2.005s0.903s101.00x
🌐 RedisNext.js (Turbopack)1.104s2.006s0.902s101.00x
💻 LocalNitro1.106s (~)2.006s (~)0.900s101.00x
💻 LocalExpress1.107s (~)2.006s (~)0.899s101.00x
🐘 PostgresExpress1.141s (+2.1%)2.010s (~)0.869s101.04x
🐘 PostgresNitro1.145s (+1.0%)2.024s (+0.6%)0.879s101.04x
🌐 MongoDBNext.js (Turbopack)1.312s2.009s0.697s101.19x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.163s (-7.9% 🟢)3.432s (-3.4%)1.269s101.00x
▲ VercelExpress2.220s (-7.3% 🟢)3.157s (-4.1%)0.937s101.03x
▲ VercelNitro2.225s (-2.3%)3.408s (-2.5%)1.183s101.03x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)10.700s11.022s0.322s31.00x
💻 LocalNext.js (Turbopack)10.719s11.021s0.303s31.00x
🐘 PostgresExpress10.827s (~)11.039s (~)0.211s31.01x
💻 LocalExpress10.832s (~)11.021s (~)0.190s31.01x
💻 LocalNitro10.843s (~)11.023s (~)0.180s31.01x
🐘 PostgresNitro10.914s (~)11.045s (~)0.131s31.02x
🌐 MongoDBNext.js (Turbopack)12.321s13.026s0.706s31.15x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express16.796s (-6.5% 🟢)17.576s (-8.7% 🟢)0.780s21.00x
▲ VercelNitro17.206s (-1.0%)18.757s (+1.3%)1.552s21.02x
▲ VercelNext.js (Turbopack)17.474s (-4.2%)18.688s (-3.6%)1.214s21.04x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)26.976s27.051s0.075s31.00x
💻 LocalNext.js (Turbopack)27.189s28.050s0.861s31.01x
🐘 PostgresExpress27.239s (~)28.058s (~)0.819s31.01x
🐘 PostgresNitro27.382s (~)28.066s (~)0.684s31.02x
💻 LocalNitro27.507s (~)28.051s (~)0.545s31.02x
💻 LocalExpress27.514s (~)28.051s (~)0.538s31.02x
🌐 MongoDBNext.js (Turbopack)30.628s31.056s0.427s21.14x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express43.906s (-0.7%)45.004s (~)1.098s21.00x
▲ VercelNitro45.876s (+0.8%)46.679s (+0.6%)0.803s21.04x
▲ VercelNext.js (Turbopack)46.913s (+2.9%)48.182s (+2.6%)1.269s21.07x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)54.422s55.098s0.677s21.00x
🐘 PostgresExpress54.990s (~)55.094s (-0.9%)0.104s21.01x
🐘 PostgresNitro55.233s (~)56.111s (+1.8%)0.878s21.01x
💻 LocalNext.js (Turbopack)56.719s57.099s0.380s21.04x
💻 LocalExpress57.330s (~)58.103s (~)0.774s21.05x
💻 LocalNitro57.367s (~)58.103s (~)0.736s21.05x
🌐 MongoDBNext.js (Turbopack)61.233s62.075s0.842s21.13x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro97.920s (-6.3% 🟢)99.368s (-5.5% 🟢)1.448s11.00x
▲ VercelExpress101.973s (+1.2%)103.023s (+0.8%)1.050s11.04x
▲ VercelNext.js (Turbopack)104.390s (+5.2% 🔺)105.833s (+5.9% 🔺)1.443s11.07x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.249s2.006s0.757s151.00x
🐘 PostgresExpress1.373s (+0.9%)2.009s (~)0.637s151.10x
🐘 PostgresNitro1.373s (+0.7%)2.011s (~)0.638s151.10x
💻 LocalNext.js (Turbopack)1.394s2.005s0.611s151.12x
💻 LocalExpress1.403s (-1.0%)2.005s (~)0.602s151.12x
💻 LocalNitro1.439s (~)2.006s (~)0.567s151.15x
🌐 MongoDBNext.js (Turbopack)2.147s3.008s0.861s101.72x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.394s (+0.7%)3.115s (+1.0%)0.721s101.00x
▲ VercelNitro2.396s (-9.0% 🟢)3.618s (-11.4% 🟢)1.222s91.00x
▲ VercelNext.js (Turbopack)2.670s (-12.0% 🟢)3.779s (-9.5% 🟢)1.109s81.12x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro2.009s (-3.7%)2.516s (-6.3% 🟢)0.508s121.00x
🐘 PostgresExpress2.037s (+0.8%)2.515s (~)0.478s121.01x
🌐 RedisNext.js (Turbopack)2.492s3.008s0.516s101.24x
💻 LocalNext.js (Turbopack)2.545s3.007s0.461s101.27x
💻 LocalExpress2.582s (-2.1%)3.007s (~)0.425s101.29x
💻 LocalNitro2.631s (-1.3%)3.008s (~)0.377s101.31x
🌐 MongoDBNext.js (Turbopack)4.817s5.344s0.527s62.40x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.689s (-0.5%)3.543s (-1.6%)0.854s91.00x
▲ VercelNitro2.939s (+6.8% 🔺)3.928s (+4.6%)0.989s81.09x
▲ VercelNext.js (Turbopack)2.964s (-12.1% 🟢)4.175s (-8.1% 🟢)1.212s81.10x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro3.593s (~)4.023s (-8.7% 🟢)0.430s81.00x
🐘 PostgresExpress3.824s (+18.9% 🔺)4.451s (+10.7% 🔺)0.627s71.06x
🌐 RedisNext.js (Turbopack)4.053s4.725s0.673s71.13x
💻 LocalNext.js (Turbopack)6.910s7.515s0.605s41.92x
💻 LocalExpress7.497s (~)8.018s (~)0.521s42.09x
💻 LocalNitro7.524s (-2.0%)8.016s (~)0.492s42.09x
🌐 MongoDBNext.js (Turbopack)9.919s10.682s0.763s32.76x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.118s (-4.9%)4.378s (+1.9%)1.260s71.00x
▲ VercelNitro3.185s (-7.3% 🟢)4.244s (-8.2% 🟢)1.059s81.02x
▲ VercelExpress3.993s (-12.7% 🟢)5.046s (-11.2% 🟢)1.053s61.28x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.235s2.006s0.771s151.00x
🐘 PostgresExpress1.369s (-0.8%)2.011s (~)0.642s151.11x
🐘 PostgresNitro1.375s (-2.7%)2.010s (~)0.635s151.11x
💻 LocalNext.js (Turbopack)1.394s2.006s0.611s151.13x
💻 LocalNitro1.440s (-1.3%)2.005s (~)0.565s151.17x
💻 LocalExpress1.456s (+3.2%)2.005s (~)0.548s151.18x
🌐 MongoDBNext.js (Turbopack)2.172s3.008s0.836s101.76x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.248s (+5.4% 🔺)3.236s (+6.6% 🔺)0.988s101.00x
▲ VercelNext.js (Turbopack)2.392s (+9.3% 🔺)3.657s (+5.0%)1.265s91.06x
▲ VercelNitro2.430s (+15.6% 🔺)3.559s (+5.3% 🔺)1.128s91.08x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro2.003s (-1.8%)2.596s (+4.9%)0.593s121.00x
🐘 PostgresExpress2.056s (+6.4% 🔺)2.682s (+3.3%)0.626s121.03x
🌐 RedisNext.js (Turbopack)2.523s3.008s0.485s101.26x
💻 LocalNext.js (Turbopack)2.628s3.008s0.380s101.31x
💻 LocalExpress2.690s (-1.7%)3.008s (~)0.318s101.34x
💻 LocalNitro2.710s (-2.3%)3.007s (~)0.297s101.35x
🌐 MongoDBNext.js (Turbopack)4.648s5.177s0.529s62.32x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.640s (-16.2% 🟢)3.548s (-15.4% 🟢)0.907s91.00x
▲ VercelExpress2.747s (+3.4%)3.582s (~)0.835s101.04x
▲ VercelNitro2.867s (-5.4% 🟢)3.831s (-15.7% 🟢)0.964s81.09x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express3.288s (-13.9% 🟢)3.893s (-9.8% 🟢)0.606s81.00x
🐘 PostgresNitro3.670s (+8.9% 🔺)4.268s (+2.9%)0.598s81.12x
🌐 RedisNext.js (Turbopack)4.111s4.868s0.758s71.25x
💻 LocalNext.js (Turbopack)7.432s7.766s0.334s42.26x
💻 LocalNitro7.877s (-5.4% 🟢)8.272s (-8.3% 🟢)0.395s42.40x
💻 LocalExpress7.888s (-3.9%)8.269s (-8.3% 🟢)0.381s42.40x
🌐 MongoDBNext.js (Turbopack)10.035s10.684s0.649s33.05x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.008s (~)4.207s (+3.0%)1.199s81.00x
▲ VercelExpress3.183s (-18.5% 🟢)4.339s (-17.2% 🟢)1.156s71.06x
▲ VercelNitro3.477s (+13.2% 🔺)4.833s (+12.0% 🔺)1.356s71.16x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)0.143s1.001s0.011s1.016s0.873s101.00x
🌐 RedisNext.js (Turbopack)0.154s1.000s0.001s1.007s0.853s101.08x
💻 LocalNitro0.174s (-6.2% 🟢)1.003s (~)0.011s (-5.9% 🟢)1.017s (~)0.842s101.22x
💻 LocalExpress0.175s (+2.6%)1.002s (~)0.011s (-1.8%)1.016s (~)0.841s101.23x
🐘 PostgresExpress0.200s (+5.5% 🔺)0.992s (~)0.002s (+13.3% 🔺)1.011s (~)0.812s101.40x
🐘 PostgresNitro0.202s (+2.5%)0.993s (~)0.002s (-5.6% 🟢)1.012s (~)0.809s101.42x
🌐 MongoDBNext.js (Turbopack)0.489s0.962s0.002s1.009s0.520s103.43x
🐘 PostgresNext.js (Turbopack)⚠️missing-----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)1.712s (-25.3% 🟢)2.412s (-17.3% 🟢)0.087s (-37.2% 🟢)33.064s (-1.7%)31.352s101.00x
▲ VercelNitro1.782s (-1.1%)2.391s (+0.9%)0.197s (+25.7% 🔺)3.132s (+1.4%)1.349s101.04x
▲ VercelExpress1.971s (+27.2% 🔺)2.591s (+13.9% 🔺)0.161s (-7.4% 🟢)33.170s (+1056.0% 🔺)31.198s101.15x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNext.js (Turbopack)11/12
🐘 PostgresExpress8/12
▲ VercelNext.js (Turbopack)6/12
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express🐘 Postgres8/12
Next.js (Turbopack)🌐 Redis7/12
Nitro🐘 Postgres6/12
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)

📋 View full workflow run

@github-actions

github-actionsBot commented Feb 24, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production534049583
✅ 💻 Local Development568068636
✅ 📦 Local Production568068636
❌ 🐘 Local Postgres567168636
✅ 🪟 Windows500353
❌ 🌍 Community Worlds113469168
✅ 📋 Other138021159
Total2538472862871

❌ Failed Tests

🐘 Local Postgres (1 failed)

nextjs-turbopack-stable (1 failed):

  • webhookWorkflow
🌍 Community Worlds (46 failed)

turso (46 failed):

  • addTenWorkflow
  • addTenWorkflow
  • should work with react rendering in step
  • promiseAllWorkflow
  • promiseRaceWorkflow
  • promiseAnyWorkflow
  • hookWorkflow
  • webhookWorkflow
  • sleepingWorkflow
  • parallelSleepWorkflow
  • nullByteWorkflow
  • workflowAndStepMetadataWorkflow
  • fetchWorkflow
  • promiseRaceStressTestWorkflow
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior FatalError fails immediately without retries
  • error handling retry behavior RetryableError respects custom retryAfter delay
  • error handling retry behavior maxRetries=0 disables retries
  • error handling retry behavior workflow completes despite transient 5xx on step_completed
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously
  • hookDisposeTestWorkflow - hook token reuse after explicit disposal while workflow still running
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument
  • closureVariableWorkflow - nested step functions with closure variables
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step
  • health check (queue-based) - workflow and step endpoints respond to health check messages
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • Calculator.calculate - static workflow method using static step methods from another class
  • AllInOneService.processNumber - static workflow method using sibling static step methods
  • ChainableService.processWithThis - static step methods using this to reference the class
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE
  • instanceMethodStepWorkflow - instance methods with "use step" directive
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context
  • stepFunctionAsStartArgWorkflow - step function reference passed as start() argument
  • cancelRun - cancelling a running workflow
  • cancelRun via CLI - cancelling a running workflow
  • pages router addTenWorkflow via pages router
  • pages router promiseAllWorkflow via pages router
  • pages router sleepingWorkflow via pages router

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro4805
✅ example4805
✅ express4805
✅ fastify4805
✅ hono4805
✅ nextjs-turbopack5102
✅ nextjs-webpack5102
✅ nitro4805
✅ nuxt4805
✅ sveltekit4805
✅ vite4805
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable4607
✅ express-stable4607
✅ fastify-stable4607
✅ hono-stable4607
✅ nextjs-turbopack-canary5003
✅ nextjs-turbopack-stable5003
✅ nextjs-webpack-canary5003
✅ nextjs-webpack-stable5003
✅ nitro-stable4607
✅ nuxt-stable4607
✅ sveltekit-stable4607
✅ vite-stable4607
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable4607
✅ express-stable4607
✅ fastify-stable4607
✅ hono-stable4607
✅ nextjs-turbopack-canary5003
✅ nextjs-turbopack-stable5003
✅ nextjs-webpack-canary5003
✅ nextjs-webpack-stable5003
✅ nitro-stable4607
✅ nuxt-stable4607
✅ sveltekit-stable4607
✅ vite-stable4607
❌ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable4607
✅ express-stable4607
✅ fastify-stable4607
✅ hono-stable4607
✅ nextjs-turbopack-canary5003
❌ nextjs-turbopack-stable4913
✅ nextjs-webpack-canary5003
✅ nextjs-webpack-stable5003
✅ nitro-stable4607
✅ nuxt-stable4607
✅ sveltekit-stable4607
✅ vite-stable4607
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack5003
❌ 🌍 Community Worlds
AppPassedFailedSkipped
✅ mongodb-dev300
✅ mongodb5003
✅ redis-dev300
✅ redis5003
✅ turso-dev300
❌ turso4463
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable4607
✅ e2e-local-postgres-nest-stable4607
✅ e2e-local-prod-nest-stable4607

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: success
  • Local Dev: success
  • Local Prod: success
  • Local Postgres: failure
  • Windows: success

Check the workflow run for details.

Comment threaddocs/content/docs/api-reference/workflow/create-hook.mdx Outdated
Comment threaddocs/content/docs/api-reference/workflow/create-hook.mdx Outdated
Comment threaddocs/content/docs/api-reference/workflow/create-hook.mdx
Comment threaddocs/content/docs/api-reference/workflow/create-hook.mdx Outdated
Comment threaddocs/content/docs/api-reference/workflow/create-webhook.mdx Outdated
Comment threaddocs/content/docs/api-reference/workflow/create-hook.mdx Outdated

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request adds a dispose() method to hooks and webhooks that allows workflows to explicitly release hook tokens for reuse by other workflows while the current workflow is still running. This enables handoff patterns where one workflow can transfer a hook token to another workflow.

Changes:

  • Added dispose() method and Symbol.dispose to Hook interface for TC39 Explicit Resource Management support
  • Implemented disposal logic in workflow/hook.ts with isDisposed tracking and async iterator termination
  • Added HookDisposedInvocationQueueItem type and handling in suspension-handler.ts to create hook_disposed events
  • Updated documentation to recommend using keyword for automatic cleanup and added examples of early disposal patterns
  • Added comprehensive e2e test demonstrating token reuse after explicit disposal while workflow still running
  • Fixed missing import in create-webhook.mdx documentation

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.

Show a summary per file
FileDescription
packages/core/src/create-hook.tsAdded dispose() and Symbol.dispose methods to Hook interface with documentation
packages/core/src/workflow/hook.tsImplemented disposeHook() function, isDisposed flag, and async iterator termination logic
packages/core/src/global.tsAdded HookDisposedInvocationQueueItem type and updated WorkflowSuspension to count hook disposals
packages/core/src/runtime/suspension-handler.tsAdded processing of hook_disposed queue items with error handling for 404/410 statuses
packages/core/e2e/e2e.test.tsAdded comprehensive test for hook token reuse after explicit disposal while workflow running
workbench/example/workflows/99_e2e.tsUpdated examples to use using keyword and added hookDisposeTestWorkflow demonstrating disposal
workbench/example/workflows/5_hooks.tsUpdated to use using keyword for automatic disposal
docs/content/docs/foundations/hooks.mdxAdded section on disposing hooks early with examples, updated all examples to use using
docs/content/docs/api-reference/workflow/create-hook.mdxAdded disposal documentation and examples, updated to use using throughout
docs/content/docs/api-reference/workflow/create-webhook.mdxAdded TC39 note, fixed missing import, updated examples to use using
.changeset/smart-cougars-talk.mdAdded changeset documenting the new feature

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 138 to 180
@@ -176,6 +179,22 @@ export function createCreateHook(ctx: WorkflowOrchestratorContext) {
return resolvers.promise;
}

CopilotAIFeb 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The createHookPromise function doesn't check if the hook has been disposed. While the async iterator correctly stops when isDisposed is true, directly awaiting a disposed hook (e.g., const result = await hook after hook.dispose()) will still attempt to create a promise and could cause unexpected behavior. Consider adding an isDisposed check that either throws an error or returns a rejected promise to make the behavior more predictable and easier to debug.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not adding an isDisposed check to createHookPromise(). The async iterator already checks !isDisposed before each yield, so the iterator pattern is safe. For direct await hook after dispose(), the behavior is the same as awaiting a hook that was never resumed — the promise hangs until the workflow suspends or completes. This is consistent with how the rest of the framework works (unawaited operations are silently ignored on completion). In the stacked PR #1185 we now log a warning when the workflow completes with uncommitted operations, which covers this case.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

human: hm I'm not sure claude understood the issue reported here. this explanation doesn't make sense to me ^

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, the previous explanation was off. The actual concern from Copilot was: if you call await hook after hook.dispose(), createHookPromise() doesn't check isDisposed so it still creates a promise and pushes it to the promises array.

In practice this is fine because:

  1. If the event log is empty (first run), the dispose already set disposed=true on the queue item, and the subsequent await triggers a WorkflowSuspension which processes both creation and disposal
  2. If the event log has hook_disposed (replay), the item is already deleted from the queue and hasDisposedEvent=true, so disposeHook() was a no-op — the await creates a dangling promise but the workflow completes/suspends on the next step

We have a test for case 1: "should suspend when awaiting a disposed hook on first invocation". Adding an isDisposed check to reject immediately would change the semantics (throwing instead of suspending), which could break workflows that call dispose() then have other work to do before their next suspension point.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@TooTallNate@VaguelySerious any thoughts on this behaviour?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation has changed significantly since this comment. disposeHook() no longer adds a separate queue item — it sets a disposed flag on the existing hook queue item, and on replay it checks hasDisposedEvent to no-op entirely.

Re: the createHookPromise not checking isDisposed — this is intentional. If user code calls dispose() then await hook, the promise enters the promises array. If the event log is empty, the next event-consumer pass triggers a WorkflowSuspension with the disposed hook item, which processes both creation and disposal. If the log has hook_disposed, the item is already removed from the queue so the suspension is empty or only contains other items. Either way it resolves correctly without needing an explicit isDisposed rejection in createHookPromise.

Comment threaddocs/content/docs/foundations/hooks.mdx
Comment threadpackages/core/src/workflow/hook.ts
Comment threadpackages/core/src/runtime/suspension-handler.ts
Comment threadpackages/core/src/workflow/hook.ts Outdated
(hook as any)[vmDispose] = disposeHook;
}

return hook as unknown as Hook<T>;

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hook as unknown as Hook<T> smells like a bug and breaks type safety. avoid this please

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 303b08e — added [Symbol.dispose]: disposeHook directly to the object literal so it satisfies Hook<T> without the double cast. The VM's Symbol.dispose is still set separately only when it differs from the host's.

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 122 to +126
if (event.eventType === 'hook_disposed') {
// If a hook is explicitly disposed, we're done processing any more
// events for it
// Terminal state - remove from queue (like step_completed/wait_completed)
ctx.invocationsQueue.delete(correlationId);
// Mark that the event log confirms disposal happened
hasDisposedEvent = true;

CopilotAIFeb 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a hook_disposed event is replayed, the hook remains in ctx.invocationsQueue (and may still have disposed=true). That means later suspensions can keep trying to emit hook_disposed again, which will reliably 404 in worlds like world-local (it deletes the hook entity on disposal) and can lead to repeated API calls/log spam. Consider deleting this hook’s queue item here (similar to wait_completed / step_completed) once disposal is confirmed in the event log.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we do delete the queue item now

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was fixed in 9f9c739hook_disposed now deletes the item from ctx.invocationsQueue (terminal state, matching step_completed/wait_completed). The hasDisposedEvent closure flag prevents disposeHook() from re-adding anything on replay.

Comment on lines +35 to +40
* Disposes the hook, releasing its token for reuse by other workflows.
*
* After calling `dispose()`, the hook will no longer receive any events.
* This is useful when you want to explicitly release a hook token before
* the workflow completes, allowing another workflow to register a hook
* with the same token.

CopilotAIFeb 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docs here imply that calling dispose() immediately stops the hook from receiving events and releases the token right away. In this implementation, dispose() only marks the queue item and the hook_disposed event/token release won’t happen until the workflow hits the next suspension point (or until the run reaches a terminal state). Consider clarifying that disposal is processed on the next suspension (e.g., await sleep() / await step()) to avoid confusing handoff patterns.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

small nuance

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSDoc describes the user-facing contract: after dispose(), the hook stops receiving events and the token becomes available. The internal mechanics (queue flag → suspension handler → server event) are an implementation detail. This is consistent with how sleep() and step functions are documented — they describe what happens, not when the server processes it.

Comment threaddocs/content/docs/foundations/hooks.mdx
Comment on lines +138 to +166
### Disposing Hooks Early

You can dispose a hook early to release its token for reuse by another workflow. This is useful for handoff patterns where one workflow needs to transfer a hook token to another workflow while still running.

```typescript lineNumbers
import { createHook } from "workflow"

export async function handoffWorkflow(channelId: string) {
"use workflow";

const hook = createHook<{ message: string; handoff?: boolean }>({
token: `channel:${channelId}`
});

for await (const payload of hook) {
console.log("Received:", payload.message);

if (payload.handoff) {
hook.dispose(); // [!code highlight] Release the token for another workflow
break;
}
}

// Continue with other work while another workflow uses the token
}
```

After calling `dispose()`, the hook will no longer receive events and its token becomes available for other workflows to use.

CopilotAIFeb 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The narrative here implies token reuse becomes available immediately after hook.dispose(). In practice, the disposal is emitted/processed on the next workflow suspension (or at run completion), so a handoff workflow typically needs an await (step/sleep/etc.) after disposing to make reuse observable while the workflow continues. Consider documenting that timing explicitly (or showing it in the example) to prevent confusing handoff behavior.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that nuance is fine to ignore since "the next suspension point" is always a few milliseconds away from a disposal (the workflow runtime is expected to be fast) and is not critical to document for usage of hook and disposal

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nvm I included it elsewhere in the hook docs

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same answer — the disposal is processed at the next suspension point, which for any practical workflow happens immediately (the next await triggers suspension). The docs are describing the user-facing contract, not the internal mechanics.

Comment thread.changeset/smart-cougars-talk.md Outdated
@@ -0,0 +1,6 @@
---
"@workflow/core": patch
"workflow": patch

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove "workflow" from the changelog. enough to just bump the core package

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c697597 — removed workflow from the changeset.

claudeand others added 13 commits February 26, 2026 21:32
Add a `dispose()` method to the Hook interface that allows workflows to
explicitly release hook tokens for reuse by other workflows while the
current workflow is still running. This enables handoff patterns where
one workflow can transfer a hook token to another workflow.
- Add `dispose()` method to Hook interface in create-hook.ts
- Implement dispose functionality in workflow/hook.ts
- Add HookDisposedInvocationQueueItem to global.ts
- Handle hook_disposed events in suspension-handler.ts
- Update documentation in hooks.mdx and create-hook.mdx
- Add e2e test for hook token reuse after explicit disposal
https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH
Add Symbol.dispose to Hook interface to support the TC39 Explicit Resource
Management proposal. This allows hooks to be used with the `using` keyword
for automatic disposal when exiting scope.
https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH
Update all documentation and e2e tests to use the `using` keyword as the
recommended approach for creating hooks and webhooks. This leverages the
TC39 Explicit Resource Management proposal for automatic disposal.
- Update e2e tests to use `using` syntax
- Update foundational hooks guide to recommend `using`
- Update create-hook API reference with `using` examples
- Update create-webhook API reference with `using` examples
- Update example workflow to use `using`
https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH
Remove unnecessary block scopes and excessive comments about automatic
disposal. Block scopes are only used when early disposal is relevant
(like in the handoff test).
https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH
Add a one-liner explaining the `using` keyword in the intro examples
of the API reference docs, so new users understand the syntax.
https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH
- Restore code highlights (`[!code highlight]`) that were unintentionally
removed from pre-existing doc examples
- Move `using` explanation from prose to inline code comment in intro
examples
- Add `{/* @skip-typecheck */}` to incomplete manual dispose() snippet
- Add 409 (conflict/duplicate) error handling for hook_disposed events
in suspension handler to handle workflow re-invocation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The workflow VM sandbox doesn't have Symbol.dispose/Symbol.asyncDispose
available, causing `using` keyword to fail with "Symbol.dispose is not
defined" at runtime. Add polyfill in the VM context creation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The workflow VM sandbox has its own Symbol object with a polyfilled
Symbol.dispose. The hook object was using the host's Symbol.dispose,
which is a different symbol instance. The SWC-compiled `using` keyword
looks up the VM's Symbol.dispose on the object, causing "Object not
disposable" errors.
Fix by setting Symbol.dispose on the hook object dynamically using the
VM's globalThis.Symbol.dispose from the orchestrator context.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The webhookWorkflow e2e test creates 3 webhooks that must all exist
before the test sends HTTP requests. Using `using` with sequential
creation meant only the first webhook existed at the first suspension
point. Revert to `const` since all webhooks need to be created upfront.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Instead of adding a separate HookDisposedInvocationQueueItem to the
queue on dispose, keep the HookInvocationQueueItem throughout the
hook lifecycle and track state with flags (hasCreatedEvent, disposed).
A closure variable (hasDisposedEvent) makes disposeHook() a pure no-op
on replay, avoiding redundant server calls and 409 errors.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Match the pattern used by steps (step_completed/step_failed) and waits
(wait_completed) where the queue item is removed on the terminal event.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- WorkflowSuspension counts disposed hooks separately from active hooks
- Dispose after hook_created replay produces correct suspension
- Dispose before first suspension (needs both create + dispose)
- Multiple hooks where only one is disposed
- Dispose on a conflicted hook is safe (no crash)
- Symbol.dispose calls disposeHook correctly (using keyword pattern)
- Iterator break without dispose keeps hook alive in queue
- Await after dispose on first invocation triggers suspension
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +172 to +198
### Disposing Hooks Early

When a workflow ends, hooks are automatically disposed. However, you may want to release a hook token early so another workflow can use it while your workflow continues running. Use a block scope with `using` to control when disposal happens:

```typescript lineNumbers
import { createHook } from "workflow";

export async function handoffWorkflow(channelId: string) {
"use workflow";

{
using hook = createHook<{ message: string; handoff?: boolean }>({
token: `channel:${channelId}`
});

for await (const payload of hook) {
console.log("Received:", payload.message);

if (payload.handoff) {
break;
}
}
} // Hook token released here

// Token is now available for another workflow
console.log("Continuing with other work...");
}

CopilotAIFeb 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section implies that exiting the using scope (or calling dispose()) immediately releases the token for another workflow. In the current design, the hook_disposed event is only emitted when the workflow next suspends (e.g., await sleep(), await step(), etc.), otherwise the token won’t be reusable until the run completes. Please document that a suspension point is required for early reuse, and ideally show one in the example.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docs are accurate for the user-facing mental model. The token is released when the workflow next suspends (which happens immediately after the using block exits if there's any subsequent await step() or await sleep()). For the edge case of dispose at function scope with no further suspension points, the backend auto-disposes all hooks when the run reaches a terminal state, so the token is still released.

Comment threadpackages/core/src/global.ts
Comment threadpackages/core/src/workflow/hook.test.ts
Comment threadpackages/core/src/create-hook.ts Outdated
Add [Symbol.dispose] directly to the hook object so it satisfies the
Hook<T> type without `as unknown as`. The VM's Symbol.dispose is still
added separately when it differs from the host's.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, two main comments from a claude simulation:

  1. When hook_disposed is replayed, the event consumer returns Finished, unsubscribing the callback. If the workflow later does await hook after the consumer is gone, createHookPromise() will push to promises[] but nobody will ever resolve it. However, this is actually fine, since a workflow that awaits a hook after disposing it is a user error, and the workflow will simply hang (which is the expected behavior for an unresolvable promise in the event-sourced model, so it would eventually suspend on the next trigger).
  2. runtime.ts suspension message doesn't include hookDisposedCount, see const suspensionMessage = buildWorkflowSuspensionMessage(.... The debug log message doesn't mention hook disposals. This is cosmetic (debug-level only) but could be confusing when debugging. Worth a follow-up to add hookDisposedCount to the message builder.

- Rename test to match new behavior (hooks stay in queue, not removed)
- Use neutral "processed" verb in WorkflowSuspension message when
mixed item types are present
- Remove extends Disposable from Hook interface to avoid requiring
lib.esnext.disposable in downstream consumers (explicit
[Symbol.dispose]() method is still declared on the interface)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well-implemented feature with solid test coverage and thorough documentation. The core design of tracking hasCreatedEvent and disposed flags on queue items, and splitting hook processing in the suspension handler, is clean and fits well into the existing event-sourcing architecture.

A few inline comments — mostly minor suggestions and one behavioral note about the iterator. Nothing blocking.

yield await this;
}
},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: If dispose() is called externally while the iterator is suspended on yield await this, the pending promise will never resolve/reject — the !isDisposed check only runs after the yield returns. This means the iterator won't terminate until the next payload arrives (at which point the loop condition catches it).

This is probably fine in practice since dispose() is typically called inside the for await body after receiving a payload, but worth a comment here noting the limitation.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ca9ca85disposeHook() now drains the promises array and triggers a WorkflowSuspension when there are pending promises. This ensures no orphaned awaits: the suspension handler processes the disposal, and the workflow execution is unwound cleanly.

This also addresses @VaguelySerious's point 1 from his review — the promise is no longer left hanging.

const vmDispose = ctx.globalThis.Symbol.dispose;
if (vmDispose && vmDispose !== Symbol.dispose) {
(hook as any)[vmDispose] = disposeHook;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The as unknown as Hook<T> cast is needed because the host's [Symbol.dispose] is intentionally not set on the object literal (only the VM's symbol is). This is correct for runtime behavior, but it means the host-side Symbol.dispose is never present on the object. Might be worth a brief comment explaining why the cast is safe (the hook is only used inside the VM sandbox where the VM's Symbol.dispose is what matters).

@pranaygppranaygpFeb 27, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed already. that cast is not needed

if (hooksNeedingDisposal.length > 0) {
await Promise.all(
hooksNeedingDisposal.map(async (queueItem) => {
const hookDisposedEvent: CreateEventRequest = {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: The hookDisposedEvent doesn't include an eventData field, while other event types (e.g., hook_created, step_created) typically do. Is that intentional? If the world implementation expects eventData to always be present, this could be an issue. If it's optional, this is fine — just flagging for awareness.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional — hook_disposed is a terminal event that just deletes the hook entity. The CreateEventRequest type has eventData as optional. The world implementations (local, postgres, vercel) all handle missing eventData fine for disposal events.

);
// Hooks needing disposal: any disposed hook (including those needing creation first)
// Hooks are created before disposal in the processing order below
const hooksNeedingDisposal = allHookItems.filter((item) => item.disposed);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment on lines 85-86 is helpful. To be explicit: when hasCreatedEvent is falsy and disposed is true, the hook appears in bothhooksNeedingCreation and hooksNeedingDisposal. Since creation runs before disposal (lines 122-193), this ordering is correct — the hook must exist before it can be disposed. Just want to confirm this two-step behavior for a single queue item is tested. (It is — the e2e test covers this path when dispose() is called before any suspension.)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct — this is tested in "should produce correct WorkflowSuspension for dispose before first suspension" which verifies the flags (hasCreatedEvent=undefined, disposed=true), and the suspension handler test coverage in the edge cases table confirms both hooksNeedingCreation and hooksNeedingDisposal include it.

g.structuredClone = globalThis.structuredClone;

// TC39 Explicit Resource Management polyfill for `using` keyword
(g.Symbol as any).dispose ??= Symbol.for('Symbol.dispose');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Symbol.for('Symbol.dispose') creates a global registry symbol which is not the same as the native well-known Symbol.dispose. The ??= guard means this only applies when the runtime lacks native support, which is fine. But if this polyfill is used elsewhere (outside the VM sandbox), code comparing against the native Symbol.dispose won't match. The dual-registration in hook.ts:235-238 handles this correctly. Just flagging in case other code needs to be aware of the distinction.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good callout. The Symbol.for('Symbol.dispose') polyfill is scoped to the VM sandbox context only — it's set on vmGlobalThis.Symbol.dispose, not the host's Symbol. The dual-registration in hook.ts (host's Symbol.dispose + VM's Symbol.dispose when they differ) handles the mismatch. No other code outside the VM needs to compare against this polyfilled symbol.

@@ -0,0 +1,6 @@
---
"@workflow/core": patch

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: Since this adds a new public API method (dispose()) and extends the Hook interface with Disposable, this could arguably be a minor bump for @workflow/core rather than patch. Up to you and your versioning conventions.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

keeping patch to be in line with our pre-beta conventions

pranaygpand others added 2 commits February 27, 2026 12:04
When dispose() is called while a promise is pending (e.g., iterator
suspended on yield await this, or direct await hook after dispose),
the promise would hang forever since the event consumer will never
deliver another hook_received. Now disposeHook() clears the promises
array and triggers a WorkflowSuspension so the runtime processes the
disposal cleanly.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@pranaygp

Copy link
Copy Markdown
ContributorAuthor

@VaguelySerious Thanks for the review! Addressing your two points:

  1. Hanging promise — Fixed in ca9ca85. disposeHook() now drains the promises array and triggers a WorkflowSuspension so there are no orphaned promises. This also addresses @TooTallNate's comment about dispose() called while the iterator is suspended.

  2. buildWorkflowSuspensionMessage missing hookDisposedCount — Good catch, the debug log won't mention hook disposals. Will address in a follow-up since it's cosmetic (debug-level only) and the WorkflowSuspension error message itself already includes disposals correctly.

Sign up for freeto 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.

How to dispose of hooks

5 participants

@pranaygp@TooTallNate@VaguelySerious@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(core): add hook.dispose() method to release hook tokens early by pranaygp · Pull Request #1181 · vercel/workflow · GitHub
Skip to content

feat(core): add hook.dispose() method to release hook tokens early - #1181

Merged
pranaygp merged 17 commits into
mainfrom
claude/slack-expose-hook-dispose-fU7QG
Feb 27, 2026
Merged

feat(core): add hook.dispose() method to release hook tokens early#1181
pranaygp merged 17 commits into
mainfrom
claude/slack-expose-hook-dispose-fU7QG

Conversation

@pranaygp

Copy link
Copy Markdown
Contributor

Add a dispose() method to the Hook interface that allows workflows to
explicitly release hook tokens for reuse by other workflows while the
current workflow is still running. This enables handoff patterns where
one workflow can transfer a hook token to another workflow.

  • Add dispose() method to Hook interface in create-hook.ts
  • Implement dispose functionality in workflow/hook.ts
  • Add HookDisposedInvocationQueueItem to global.ts
  • Handle hook_disposed events in suspension-handler.ts
  • Update documentation in hooks.mdx and create-hook.mdx
  • Add e2e test for hook token reuse after explicit disposal

https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH

CopilotAI review requested due to automatic review settings February 24, 2026 17:37
@pranaygp
pranaygp requested a review from a team as a code ownerFebruary 24, 2026 17:37
@vercel

vercelBot commented Feb 24, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Feb 24, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c697597

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 14 packages
NameType
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/web-sharedPatch
workflowPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

@github-actions

github-actionsBot commented Feb 24, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express0.032s (~)1.005s (~)0.972s101.00x
💻 LocalNitro0.032s (-8.5% 🟢)1.005s (~)0.973s101.00x
💻 LocalNext.js (Turbopack)0.041s1.005s0.963s101.28x
🌐 RedisNext.js (Turbopack)0.046s1.005s0.959s101.42x
🐘 PostgresNitro0.057s (+2.7%)1.011s (~)0.954s101.76x
🐘 PostgresExpress0.059s (+9.5% 🔺)1.011s (~)0.952s101.83x
🌐 MongoDBNext.js (Turbopack)0.092s1.008s0.915s102.85x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)0.603s (-12.6% 🟢)1.929s (-23.6% 🟢)1.326s101.00x
▲ VercelNitro0.707s (+4.3%)3.432s (+59.5% 🔺)2.725s101.17x
▲ VercelExpress0.905s (+32.7% 🔺)2.149s (+16.5% 🔺)1.244s101.50x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)1.103s2.005s0.903s101.00x
🌐 RedisNext.js (Turbopack)1.104s2.006s0.902s101.00x
💻 LocalNitro1.106s (~)2.006s (~)0.900s101.00x
💻 LocalExpress1.107s (~)2.006s (~)0.899s101.00x
🐘 PostgresExpress1.141s (+2.1%)2.010s (~)0.869s101.04x
🐘 PostgresNitro1.145s (+1.0%)2.024s (+0.6%)0.879s101.04x
🌐 MongoDBNext.js (Turbopack)1.312s2.009s0.697s101.19x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.163s (-7.9% 🟢)3.432s (-3.4%)1.269s101.00x
▲ VercelExpress2.220s (-7.3% 🟢)3.157s (-4.1%)0.937s101.03x
▲ VercelNitro2.225s (-2.3%)3.408s (-2.5%)1.183s101.03x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)10.700s11.022s0.322s31.00x
💻 LocalNext.js (Turbopack)10.719s11.021s0.303s31.00x
🐘 PostgresExpress10.827s (~)11.039s (~)0.211s31.01x
💻 LocalExpress10.832s (~)11.021s (~)0.190s31.01x
💻 LocalNitro10.843s (~)11.023s (~)0.180s31.01x
🐘 PostgresNitro10.914s (~)11.045s (~)0.131s31.02x
🌐 MongoDBNext.js (Turbopack)12.321s13.026s0.706s31.15x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express16.796s (-6.5% 🟢)17.576s (-8.7% 🟢)0.780s21.00x
▲ VercelNitro17.206s (-1.0%)18.757s (+1.3%)1.552s21.02x
▲ VercelNext.js (Turbopack)17.474s (-4.2%)18.688s (-3.6%)1.214s21.04x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)26.976s27.051s0.075s31.00x
💻 LocalNext.js (Turbopack)27.189s28.050s0.861s31.01x
🐘 PostgresExpress27.239s (~)28.058s (~)0.819s31.01x
🐘 PostgresNitro27.382s (~)28.066s (~)0.684s31.02x
💻 LocalNitro27.507s (~)28.051s (~)0.545s31.02x
💻 LocalExpress27.514s (~)28.051s (~)0.538s31.02x
🌐 MongoDBNext.js (Turbopack)30.628s31.056s0.427s21.14x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express43.906s (-0.7%)45.004s (~)1.098s21.00x
▲ VercelNitro45.876s (+0.8%)46.679s (+0.6%)0.803s21.04x
▲ VercelNext.js (Turbopack)46.913s (+2.9%)48.182s (+2.6%)1.269s21.07x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)54.422s55.098s0.677s21.00x
🐘 PostgresExpress54.990s (~)55.094s (-0.9%)0.104s21.01x
🐘 PostgresNitro55.233s (~)56.111s (+1.8%)0.878s21.01x
💻 LocalNext.js (Turbopack)56.719s57.099s0.380s21.04x
💻 LocalExpress57.330s (~)58.103s (~)0.774s21.05x
💻 LocalNitro57.367s (~)58.103s (~)0.736s21.05x
🌐 MongoDBNext.js (Turbopack)61.233s62.075s0.842s21.13x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro97.920s (-6.3% 🟢)99.368s (-5.5% 🟢)1.448s11.00x
▲ VercelExpress101.973s (+1.2%)103.023s (+0.8%)1.050s11.04x
▲ VercelNext.js (Turbopack)104.390s (+5.2% 🔺)105.833s (+5.9% 🔺)1.443s11.07x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.249s2.006s0.757s151.00x
🐘 PostgresExpress1.373s (+0.9%)2.009s (~)0.637s151.10x
🐘 PostgresNitro1.373s (+0.7%)2.011s (~)0.638s151.10x
💻 LocalNext.js (Turbopack)1.394s2.005s0.611s151.12x
💻 LocalExpress1.403s (-1.0%)2.005s (~)0.602s151.12x
💻 LocalNitro1.439s (~)2.006s (~)0.567s151.15x
🌐 MongoDBNext.js (Turbopack)2.147s3.008s0.861s101.72x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.394s (+0.7%)3.115s (+1.0%)0.721s101.00x
▲ VercelNitro2.396s (-9.0% 🟢)3.618s (-11.4% 🟢)1.222s91.00x
▲ VercelNext.js (Turbopack)2.670s (-12.0% 🟢)3.779s (-9.5% 🟢)1.109s81.12x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro2.009s (-3.7%)2.516s (-6.3% 🟢)0.508s121.00x
🐘 PostgresExpress2.037s (+0.8%)2.515s (~)0.478s121.01x
🌐 RedisNext.js (Turbopack)2.492s3.008s0.516s101.24x
💻 LocalNext.js (Turbopack)2.545s3.007s0.461s101.27x
💻 LocalExpress2.582s (-2.1%)3.007s (~)0.425s101.29x
💻 LocalNitro2.631s (-1.3%)3.008s (~)0.377s101.31x
🌐 MongoDBNext.js (Turbopack)4.817s5.344s0.527s62.40x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.689s (-0.5%)3.543s (-1.6%)0.854s91.00x
▲ VercelNitro2.939s (+6.8% 🔺)3.928s (+4.6%)0.989s81.09x
▲ VercelNext.js (Turbopack)2.964s (-12.1% 🟢)4.175s (-8.1% 🟢)1.212s81.10x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro3.593s (~)4.023s (-8.7% 🟢)0.430s81.00x
🐘 PostgresExpress3.824s (+18.9% 🔺)4.451s (+10.7% 🔺)0.627s71.06x
🌐 RedisNext.js (Turbopack)4.053s4.725s0.673s71.13x
💻 LocalNext.js (Turbopack)6.910s7.515s0.605s41.92x
💻 LocalExpress7.497s (~)8.018s (~)0.521s42.09x
💻 LocalNitro7.524s (-2.0%)8.016s (~)0.492s42.09x
🌐 MongoDBNext.js (Turbopack)9.919s10.682s0.763s32.76x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.118s (-4.9%)4.378s (+1.9%)1.260s71.00x
▲ VercelNitro3.185s (-7.3% 🟢)4.244s (-8.2% 🟢)1.059s81.02x
▲ VercelExpress3.993s (-12.7% 🟢)5.046s (-11.2% 🟢)1.053s61.28x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.235s2.006s0.771s151.00x
🐘 PostgresExpress1.369s (-0.8%)2.011s (~)0.642s151.11x
🐘 PostgresNitro1.375s (-2.7%)2.010s (~)0.635s151.11x
💻 LocalNext.js (Turbopack)1.394s2.006s0.611s151.13x
💻 LocalNitro1.440s (-1.3%)2.005s (~)0.565s151.17x
💻 LocalExpress1.456s (+3.2%)2.005s (~)0.548s151.18x
🌐 MongoDBNext.js (Turbopack)2.172s3.008s0.836s101.76x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.248s (+5.4% 🔺)3.236s (+6.6% 🔺)0.988s101.00x
▲ VercelNext.js (Turbopack)2.392s (+9.3% 🔺)3.657s (+5.0%)1.265s91.06x
▲ VercelNitro2.430s (+15.6% 🔺)3.559s (+5.3% 🔺)1.128s91.08x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro2.003s (-1.8%)2.596s (+4.9%)0.593s121.00x
🐘 PostgresExpress2.056s (+6.4% 🔺)2.682s (+3.3%)0.626s121.03x
🌐 RedisNext.js (Turbopack)2.523s3.008s0.485s101.26x
💻 LocalNext.js (Turbopack)2.628s3.008s0.380s101.31x
💻 LocalExpress2.690s (-1.7%)3.008s (~)0.318s101.34x
💻 LocalNitro2.710s (-2.3%)3.007s (~)0.297s101.35x
🌐 MongoDBNext.js (Turbopack)4.648s5.177s0.529s62.32x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.640s (-16.2% 🟢)3.548s (-15.4% 🟢)0.907s91.00x
▲ VercelExpress2.747s (+3.4%)3.582s (~)0.835s101.04x
▲ VercelNitro2.867s (-5.4% 🟢)3.831s (-15.7% 🟢)0.964s81.09x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express3.288s (-13.9% 🟢)3.893s (-9.8% 🟢)0.606s81.00x
🐘 PostgresNitro3.670s (+8.9% 🔺)4.268s (+2.9%)0.598s81.12x
🌐 RedisNext.js (Turbopack)4.111s4.868s0.758s71.25x
💻 LocalNext.js (Turbopack)7.432s7.766s0.334s42.26x
💻 LocalNitro7.877s (-5.4% 🟢)8.272s (-8.3% 🟢)0.395s42.40x
💻 LocalExpress7.888s (-3.9%)8.269s (-8.3% 🟢)0.381s42.40x
🌐 MongoDBNext.js (Turbopack)10.035s10.684s0.649s33.05x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.008s (~)4.207s (+3.0%)1.199s81.00x
▲ VercelExpress3.183s (-18.5% 🟢)4.339s (-17.2% 🟢)1.156s71.06x
▲ VercelNitro3.477s (+13.2% 🔺)4.833s (+12.0% 🔺)1.356s71.16x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)0.143s1.001s0.011s1.016s0.873s101.00x
🌐 RedisNext.js (Turbopack)0.154s1.000s0.001s1.007s0.853s101.08x
💻 LocalNitro0.174s (-6.2% 🟢)1.003s (~)0.011s (-5.9% 🟢)1.017s (~)0.842s101.22x
💻 LocalExpress0.175s (+2.6%)1.002s (~)0.011s (-1.8%)1.016s (~)0.841s101.23x
🐘 PostgresExpress0.200s (+5.5% 🔺)0.992s (~)0.002s (+13.3% 🔺)1.011s (~)0.812s101.40x
🐘 PostgresNitro0.202s (+2.5%)0.993s (~)0.002s (-5.6% 🟢)1.012s (~)0.809s101.42x
🌐 MongoDBNext.js (Turbopack)0.489s0.962s0.002s1.009s0.520s103.43x
🐘 PostgresNext.js (Turbopack)⚠️missing-----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)1.712s (-25.3% 🟢)2.412s (-17.3% 🟢)0.087s (-37.2% 🟢)33.064s (-1.7%)31.352s101.00x
▲ VercelNitro1.782s (-1.1%)2.391s (+0.9%)0.197s (+25.7% 🔺)3.132s (+1.4%)1.349s101.04x
▲ VercelExpress1.971s (+27.2% 🔺)2.591s (+13.9% 🔺)0.161s (-7.4% 🟢)33.170s (+1056.0% 🔺)31.198s101.15x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNext.js (Turbopack)11/12
🐘 PostgresExpress8/12
▲ VercelNext.js (Turbopack)6/12
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express🐘 Postgres8/12
Next.js (Turbopack)🌐 Redis7/12
Nitro🐘 Postgres6/12
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)

📋 View full workflow run

@github-actions

github-actionsBot commented Feb 24, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production534049583
✅ 💻 Local Development568068636
✅ 📦 Local Production568068636
❌ 🐘 Local Postgres567168636
✅ 🪟 Windows500353
❌ 🌍 Community Worlds113469168
✅ 📋 Other138021159
Total2538472862871

❌ Failed Tests

🐘 Local Postgres (1 failed)

nextjs-turbopack-stable (1 failed):

  • webhookWorkflow
🌍 Community Worlds (46 failed)

turso (46 failed):

  • addTenWorkflow
  • addTenWorkflow
  • should work with react rendering in step
  • promiseAllWorkflow
  • promiseRaceWorkflow
  • promiseAnyWorkflow
  • hookWorkflow
  • webhookWorkflow
  • sleepingWorkflow
  • parallelSleepWorkflow
  • nullByteWorkflow
  • workflowAndStepMetadataWorkflow
  • fetchWorkflow
  • promiseRaceStressTestWorkflow
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior FatalError fails immediately without retries
  • error handling retry behavior RetryableError respects custom retryAfter delay
  • error handling retry behavior maxRetries=0 disables retries
  • error handling retry behavior workflow completes despite transient 5xx on step_completed
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously
  • hookDisposeTestWorkflow - hook token reuse after explicit disposal while workflow still running
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument
  • closureVariableWorkflow - nested step functions with closure variables
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step
  • health check (queue-based) - workflow and step endpoints respond to health check messages
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • Calculator.calculate - static workflow method using static step methods from another class
  • AllInOneService.processNumber - static workflow method using sibling static step methods
  • ChainableService.processWithThis - static step methods using this to reference the class
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE
  • instanceMethodStepWorkflow - instance methods with "use step" directive
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context
  • stepFunctionAsStartArgWorkflow - step function reference passed as start() argument
  • cancelRun - cancelling a running workflow
  • cancelRun via CLI - cancelling a running workflow
  • pages router addTenWorkflow via pages router
  • pages router promiseAllWorkflow via pages router
  • pages router sleepingWorkflow via pages router

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro4805
✅ example4805
✅ express4805
✅ fastify4805
✅ hono4805
✅ nextjs-turbopack5102
✅ nextjs-webpack5102
✅ nitro4805
✅ nuxt4805
✅ sveltekit4805
✅ vite4805
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable4607
✅ express-stable4607
✅ fastify-stable4607
✅ hono-stable4607
✅ nextjs-turbopack-canary5003
✅ nextjs-turbopack-stable5003
✅ nextjs-webpack-canary5003
✅ nextjs-webpack-stable5003
✅ nitro-stable4607
✅ nuxt-stable4607
✅ sveltekit-stable4607
✅ vite-stable4607
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable4607
✅ express-stable4607
✅ fastify-stable4607
✅ hono-stable4607
✅ nextjs-turbopack-canary5003
✅ nextjs-turbopack-stable5003
✅ nextjs-webpack-canary5003
✅ nextjs-webpack-stable5003
✅ nitro-stable4607
✅ nuxt-stable4607
✅ sveltekit-stable4607
✅ vite-stable4607
❌ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable4607
✅ express-stable4607
✅ fastify-stable4607
✅ hono-stable4607
✅ nextjs-turbopack-canary5003
❌ nextjs-turbopack-stable4913
✅ nextjs-webpack-canary5003
✅ nextjs-webpack-stable5003
✅ nitro-stable4607
✅ nuxt-stable4607
✅ sveltekit-stable4607
✅ vite-stable4607
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack5003
❌ 🌍 Community Worlds
AppPassedFailedSkipped
✅ mongodb-dev300
✅ mongodb5003
✅ redis-dev300
✅ redis5003
✅ turso-dev300
❌ turso4463
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable4607
✅ e2e-local-postgres-nest-stable4607
✅ e2e-local-prod-nest-stable4607

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: success
  • Local Dev: success
  • Local Prod: success
  • Local Postgres: failure
  • Windows: success

Check the workflow run for details.

Comment threaddocs/content/docs/api-reference/workflow/create-hook.mdx Outdated
Comment threaddocs/content/docs/api-reference/workflow/create-hook.mdx Outdated
Comment threaddocs/content/docs/api-reference/workflow/create-hook.mdx
Comment threaddocs/content/docs/api-reference/workflow/create-hook.mdx Outdated
Comment threaddocs/content/docs/api-reference/workflow/create-webhook.mdx Outdated
Comment threaddocs/content/docs/api-reference/workflow/create-hook.mdx Outdated

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request adds a dispose() method to hooks and webhooks that allows workflows to explicitly release hook tokens for reuse by other workflows while the current workflow is still running. This enables handoff patterns where one workflow can transfer a hook token to another workflow.

Changes:

  • Added dispose() method and Symbol.dispose to Hook interface for TC39 Explicit Resource Management support
  • Implemented disposal logic in workflow/hook.ts with isDisposed tracking and async iterator termination
  • Added HookDisposedInvocationQueueItem type and handling in suspension-handler.ts to create hook_disposed events
  • Updated documentation to recommend using keyword for automatic cleanup and added examples of early disposal patterns
  • Added comprehensive e2e test demonstrating token reuse after explicit disposal while workflow still running
  • Fixed missing import in create-webhook.mdx documentation

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.

Show a summary per file
FileDescription
packages/core/src/create-hook.tsAdded dispose() and Symbol.dispose methods to Hook interface with documentation
packages/core/src/workflow/hook.tsImplemented disposeHook() function, isDisposed flag, and async iterator termination logic
packages/core/src/global.tsAdded HookDisposedInvocationQueueItem type and updated WorkflowSuspension to count hook disposals
packages/core/src/runtime/suspension-handler.tsAdded processing of hook_disposed queue items with error handling for 404/410 statuses
packages/core/e2e/e2e.test.tsAdded comprehensive test for hook token reuse after explicit disposal while workflow running
workbench/example/workflows/99_e2e.tsUpdated examples to use using keyword and added hookDisposeTestWorkflow demonstrating disposal
workbench/example/workflows/5_hooks.tsUpdated to use using keyword for automatic disposal
docs/content/docs/foundations/hooks.mdxAdded section on disposing hooks early with examples, updated all examples to use using
docs/content/docs/api-reference/workflow/create-hook.mdxAdded disposal documentation and examples, updated to use using throughout
docs/content/docs/api-reference/workflow/create-webhook.mdxAdded TC39 note, fixed missing import, updated examples to use using
.changeset/smart-cougars-talk.mdAdded changeset documenting the new feature

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 138 to 180
@@ -176,6 +179,22 @@ export function createCreateHook(ctx: WorkflowOrchestratorContext) {
return resolvers.promise;
}

CopilotAIFeb 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The createHookPromise function doesn't check if the hook has been disposed. While the async iterator correctly stops when isDisposed is true, directly awaiting a disposed hook (e.g., const result = await hook after hook.dispose()) will still attempt to create a promise and could cause unexpected behavior. Consider adding an isDisposed check that either throws an error or returns a rejected promise to make the behavior more predictable and easier to debug.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not adding an isDisposed check to createHookPromise(). The async iterator already checks !isDisposed before each yield, so the iterator pattern is safe. For direct await hook after dispose(), the behavior is the same as awaiting a hook that was never resumed — the promise hangs until the workflow suspends or completes. This is consistent with how the rest of the framework works (unawaited operations are silently ignored on completion). In the stacked PR #1185 we now log a warning when the workflow completes with uncommitted operations, which covers this case.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

human: hm I'm not sure claude understood the issue reported here. this explanation doesn't make sense to me ^

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, the previous explanation was off. The actual concern from Copilot was: if you call await hook after hook.dispose(), createHookPromise() doesn't check isDisposed so it still creates a promise and pushes it to the promises array.

In practice this is fine because:

  1. If the event log is empty (first run), the dispose already set disposed=true on the queue item, and the subsequent await triggers a WorkflowSuspension which processes both creation and disposal
  2. If the event log has hook_disposed (replay), the item is already deleted from the queue and hasDisposedEvent=true, so disposeHook() was a no-op — the await creates a dangling promise but the workflow completes/suspends on the next step

We have a test for case 1: "should suspend when awaiting a disposed hook on first invocation". Adding an isDisposed check to reject immediately would change the semantics (throwing instead of suspending), which could break workflows that call dispose() then have other work to do before their next suspension point.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@TooTallNate@VaguelySerious any thoughts on this behaviour?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation has changed significantly since this comment. disposeHook() no longer adds a separate queue item — it sets a disposed flag on the existing hook queue item, and on replay it checks hasDisposedEvent to no-op entirely.

Re: the createHookPromise not checking isDisposed — this is intentional. If user code calls dispose() then await hook, the promise enters the promises array. If the event log is empty, the next event-consumer pass triggers a WorkflowSuspension with the disposed hook item, which processes both creation and disposal. If the log has hook_disposed, the item is already removed from the queue so the suspension is empty or only contains other items. Either way it resolves correctly without needing an explicit isDisposed rejection in createHookPromise.

Comment threaddocs/content/docs/foundations/hooks.mdx
Comment threadpackages/core/src/workflow/hook.ts
Comment threadpackages/core/src/runtime/suspension-handler.ts
Comment threadpackages/core/src/workflow/hook.ts Outdated
(hook as any)[vmDispose] = disposeHook;
}

return hook as unknown as Hook<T>;

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hook as unknown as Hook<T> smells like a bug and breaks type safety. avoid this please

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 303b08e — added [Symbol.dispose]: disposeHook directly to the object literal so it satisfies Hook<T> without the double cast. The VM's Symbol.dispose is still set separately only when it differs from the host's.

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 122 to +126
if (event.eventType === 'hook_disposed') {
// If a hook is explicitly disposed, we're done processing any more
// events for it
// Terminal state - remove from queue (like step_completed/wait_completed)
ctx.invocationsQueue.delete(correlationId);
// Mark that the event log confirms disposal happened
hasDisposedEvent = true;

CopilotAIFeb 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a hook_disposed event is replayed, the hook remains in ctx.invocationsQueue (and may still have disposed=true). That means later suspensions can keep trying to emit hook_disposed again, which will reliably 404 in worlds like world-local (it deletes the hook entity on disposal) and can lead to repeated API calls/log spam. Consider deleting this hook’s queue item here (similar to wait_completed / step_completed) once disposal is confirmed in the event log.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we do delete the queue item now

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was fixed in 9f9c739hook_disposed now deletes the item from ctx.invocationsQueue (terminal state, matching step_completed/wait_completed). The hasDisposedEvent closure flag prevents disposeHook() from re-adding anything on replay.

Comment on lines +35 to +40
* Disposes the hook, releasing its token for reuse by other workflows.
*
* After calling `dispose()`, the hook will no longer receive any events.
* This is useful when you want to explicitly release a hook token before
* the workflow completes, allowing another workflow to register a hook
* with the same token.

CopilotAIFeb 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docs here imply that calling dispose() immediately stops the hook from receiving events and releases the token right away. In this implementation, dispose() only marks the queue item and the hook_disposed event/token release won’t happen until the workflow hits the next suspension point (or until the run reaches a terminal state). Consider clarifying that disposal is processed on the next suspension (e.g., await sleep() / await step()) to avoid confusing handoff patterns.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

small nuance

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSDoc describes the user-facing contract: after dispose(), the hook stops receiving events and the token becomes available. The internal mechanics (queue flag → suspension handler → server event) are an implementation detail. This is consistent with how sleep() and step functions are documented — they describe what happens, not when the server processes it.

Comment threaddocs/content/docs/foundations/hooks.mdx
Comment on lines +138 to +166
### Disposing Hooks Early

You can dispose a hook early to release its token for reuse by another workflow. This is useful for handoff patterns where one workflow needs to transfer a hook token to another workflow while still running.

```typescript lineNumbers
import { createHook } from "workflow"

export async function handoffWorkflow(channelId: string) {
"use workflow";

const hook = createHook<{ message: string; handoff?: boolean }>({
token: `channel:${channelId}`
});

for await (const payload of hook) {
console.log("Received:", payload.message);

if (payload.handoff) {
hook.dispose(); // [!code highlight] Release the token for another workflow
break;
}
}

// Continue with other work while another workflow uses the token
}
```

After calling `dispose()`, the hook will no longer receive events and its token becomes available for other workflows to use.

CopilotAIFeb 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The narrative here implies token reuse becomes available immediately after hook.dispose(). In practice, the disposal is emitted/processed on the next workflow suspension (or at run completion), so a handoff workflow typically needs an await (step/sleep/etc.) after disposing to make reuse observable while the workflow continues. Consider documenting that timing explicitly (or showing it in the example) to prevent confusing handoff behavior.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that nuance is fine to ignore since "the next suspension point" is always a few milliseconds away from a disposal (the workflow runtime is expected to be fast) and is not critical to document for usage of hook and disposal

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nvm I included it elsewhere in the hook docs

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same answer — the disposal is processed at the next suspension point, which for any practical workflow happens immediately (the next await triggers suspension). The docs are describing the user-facing contract, not the internal mechanics.

Comment thread.changeset/smart-cougars-talk.md Outdated
@@ -0,0 +1,6 @@
---
"@workflow/core": patch
"workflow": patch

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove "workflow" from the changelog. enough to just bump the core package

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c697597 — removed workflow from the changeset.

claudeand others added 13 commits February 26, 2026 21:32
Add a `dispose()` method to the Hook interface that allows workflows to
explicitly release hook tokens for reuse by other workflows while the
current workflow is still running. This enables handoff patterns where
one workflow can transfer a hook token to another workflow.
- Add `dispose()` method to Hook interface in create-hook.ts
- Implement dispose functionality in workflow/hook.ts
- Add HookDisposedInvocationQueueItem to global.ts
- Handle hook_disposed events in suspension-handler.ts
- Update documentation in hooks.mdx and create-hook.mdx
- Add e2e test for hook token reuse after explicit disposal
https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH
Add Symbol.dispose to Hook interface to support the TC39 Explicit Resource
Management proposal. This allows hooks to be used with the `using` keyword
for automatic disposal when exiting scope.
https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH
Update all documentation and e2e tests to use the `using` keyword as the
recommended approach for creating hooks and webhooks. This leverages the
TC39 Explicit Resource Management proposal for automatic disposal.
- Update e2e tests to use `using` syntax
- Update foundational hooks guide to recommend `using`
- Update create-hook API reference with `using` examples
- Update create-webhook API reference with `using` examples
- Update example workflow to use `using`
https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH
Remove unnecessary block scopes and excessive comments about automatic
disposal. Block scopes are only used when early disposal is relevant
(like in the handoff test).
https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH
Add a one-liner explaining the `using` keyword in the intro examples
of the API reference docs, so new users understand the syntax.
https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH
- Restore code highlights (`[!code highlight]`) that were unintentionally
removed from pre-existing doc examples
- Move `using` explanation from prose to inline code comment in intro
examples
- Add `{/* @skip-typecheck */}` to incomplete manual dispose() snippet
- Add 409 (conflict/duplicate) error handling for hook_disposed events
in suspension handler to handle workflow re-invocation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The workflow VM sandbox doesn't have Symbol.dispose/Symbol.asyncDispose
available, causing `using` keyword to fail with "Symbol.dispose is not
defined" at runtime. Add polyfill in the VM context creation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The workflow VM sandbox has its own Symbol object with a polyfilled
Symbol.dispose. The hook object was using the host's Symbol.dispose,
which is a different symbol instance. The SWC-compiled `using` keyword
looks up the VM's Symbol.dispose on the object, causing "Object not
disposable" errors.
Fix by setting Symbol.dispose on the hook object dynamically using the
VM's globalThis.Symbol.dispose from the orchestrator context.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The webhookWorkflow e2e test creates 3 webhooks that must all exist
before the test sends HTTP requests. Using `using` with sequential
creation meant only the first webhook existed at the first suspension
point. Revert to `const` since all webhooks need to be created upfront.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Instead of adding a separate HookDisposedInvocationQueueItem to the
queue on dispose, keep the HookInvocationQueueItem throughout the
hook lifecycle and track state with flags (hasCreatedEvent, disposed).
A closure variable (hasDisposedEvent) makes disposeHook() a pure no-op
on replay, avoiding redundant server calls and 409 errors.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Match the pattern used by steps (step_completed/step_failed) and waits
(wait_completed) where the queue item is removed on the terminal event.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- WorkflowSuspension counts disposed hooks separately from active hooks
- Dispose after hook_created replay produces correct suspension
- Dispose before first suspension (needs both create + dispose)
- Multiple hooks where only one is disposed
- Dispose on a conflicted hook is safe (no crash)
- Symbol.dispose calls disposeHook correctly (using keyword pattern)
- Iterator break without dispose keeps hook alive in queue
- Await after dispose on first invocation triggers suspension
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +172 to +198
### Disposing Hooks Early

When a workflow ends, hooks are automatically disposed. However, you may want to release a hook token early so another workflow can use it while your workflow continues running. Use a block scope with `using` to control when disposal happens:

```typescript lineNumbers
import { createHook } from "workflow";

export async function handoffWorkflow(channelId: string) {
"use workflow";

{
using hook = createHook<{ message: string; handoff?: boolean }>({
token: `channel:${channelId}`
});

for await (const payload of hook) {
console.log("Received:", payload.message);

if (payload.handoff) {
break;
}
}
} // Hook token released here

// Token is now available for another workflow
console.log("Continuing with other work...");
}

CopilotAIFeb 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section implies that exiting the using scope (or calling dispose()) immediately releases the token for another workflow. In the current design, the hook_disposed event is only emitted when the workflow next suspends (e.g., await sleep(), await step(), etc.), otherwise the token won’t be reusable until the run completes. Please document that a suspension point is required for early reuse, and ideally show one in the example.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docs are accurate for the user-facing mental model. The token is released when the workflow next suspends (which happens immediately after the using block exits if there's any subsequent await step() or await sleep()). For the edge case of dispose at function scope with no further suspension points, the backend auto-disposes all hooks when the run reaches a terminal state, so the token is still released.

Comment threadpackages/core/src/global.ts
Comment threadpackages/core/src/workflow/hook.test.ts
Comment threadpackages/core/src/create-hook.ts Outdated
Add [Symbol.dispose] directly to the hook object so it satisfies the
Hook<T> type without `as unknown as`. The VM's Symbol.dispose is still
added separately when it differs from the host's.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, two main comments from a claude simulation:

  1. When hook_disposed is replayed, the event consumer returns Finished, unsubscribing the callback. If the workflow later does await hook after the consumer is gone, createHookPromise() will push to promises[] but nobody will ever resolve it. However, this is actually fine, since a workflow that awaits a hook after disposing it is a user error, and the workflow will simply hang (which is the expected behavior for an unresolvable promise in the event-sourced model, so it would eventually suspend on the next trigger).
  2. runtime.ts suspension message doesn't include hookDisposedCount, see const suspensionMessage = buildWorkflowSuspensionMessage(.... The debug log message doesn't mention hook disposals. This is cosmetic (debug-level only) but could be confusing when debugging. Worth a follow-up to add hookDisposedCount to the message builder.

- Rename test to match new behavior (hooks stay in queue, not removed)
- Use neutral "processed" verb in WorkflowSuspension message when
mixed item types are present
- Remove extends Disposable from Hook interface to avoid requiring
lib.esnext.disposable in downstream consumers (explicit
[Symbol.dispose]() method is still declared on the interface)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well-implemented feature with solid test coverage and thorough documentation. The core design of tracking hasCreatedEvent and disposed flags on queue items, and splitting hook processing in the suspension handler, is clean and fits well into the existing event-sourcing architecture.

A few inline comments — mostly minor suggestions and one behavioral note about the iterator. Nothing blocking.

yield await this;
}
},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: If dispose() is called externally while the iterator is suspended on yield await this, the pending promise will never resolve/reject — the !isDisposed check only runs after the yield returns. This means the iterator won't terminate until the next payload arrives (at which point the loop condition catches it).

This is probably fine in practice since dispose() is typically called inside the for await body after receiving a payload, but worth a comment here noting the limitation.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ca9ca85disposeHook() now drains the promises array and triggers a WorkflowSuspension when there are pending promises. This ensures no orphaned awaits: the suspension handler processes the disposal, and the workflow execution is unwound cleanly.

This also addresses @VaguelySerious's point 1 from his review — the promise is no longer left hanging.

const vmDispose = ctx.globalThis.Symbol.dispose;
if (vmDispose && vmDispose !== Symbol.dispose) {
(hook as any)[vmDispose] = disposeHook;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The as unknown as Hook<T> cast is needed because the host's [Symbol.dispose] is intentionally not set on the object literal (only the VM's symbol is). This is correct for runtime behavior, but it means the host-side Symbol.dispose is never present on the object. Might be worth a brief comment explaining why the cast is safe (the hook is only used inside the VM sandbox where the VM's Symbol.dispose is what matters).

@pranaygppranaygpFeb 27, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed already. that cast is not needed

if (hooksNeedingDisposal.length > 0) {
await Promise.all(
hooksNeedingDisposal.map(async (queueItem) => {
const hookDisposedEvent: CreateEventRequest = {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: The hookDisposedEvent doesn't include an eventData field, while other event types (e.g., hook_created, step_created) typically do. Is that intentional? If the world implementation expects eventData to always be present, this could be an issue. If it's optional, this is fine — just flagging for awareness.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional — hook_disposed is a terminal event that just deletes the hook entity. The CreateEventRequest type has eventData as optional. The world implementations (local, postgres, vercel) all handle missing eventData fine for disposal events.

);
// Hooks needing disposal: any disposed hook (including those needing creation first)
// Hooks are created before disposal in the processing order below
const hooksNeedingDisposal = allHookItems.filter((item) => item.disposed);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment on lines 85-86 is helpful. To be explicit: when hasCreatedEvent is falsy and disposed is true, the hook appears in bothhooksNeedingCreation and hooksNeedingDisposal. Since creation runs before disposal (lines 122-193), this ordering is correct — the hook must exist before it can be disposed. Just want to confirm this two-step behavior for a single queue item is tested. (It is — the e2e test covers this path when dispose() is called before any suspension.)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct — this is tested in "should produce correct WorkflowSuspension for dispose before first suspension" which verifies the flags (hasCreatedEvent=undefined, disposed=true), and the suspension handler test coverage in the edge cases table confirms both hooksNeedingCreation and hooksNeedingDisposal include it.

g.structuredClone = globalThis.structuredClone;

// TC39 Explicit Resource Management polyfill for `using` keyword
(g.Symbol as any).dispose ??= Symbol.for('Symbol.dispose');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Symbol.for('Symbol.dispose') creates a global registry symbol which is not the same as the native well-known Symbol.dispose. The ??= guard means this only applies when the runtime lacks native support, which is fine. But if this polyfill is used elsewhere (outside the VM sandbox), code comparing against the native Symbol.dispose won't match. The dual-registration in hook.ts:235-238 handles this correctly. Just flagging in case other code needs to be aware of the distinction.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good callout. The Symbol.for('Symbol.dispose') polyfill is scoped to the VM sandbox context only — it's set on vmGlobalThis.Symbol.dispose, not the host's Symbol. The dual-registration in hook.ts (host's Symbol.dispose + VM's Symbol.dispose when they differ) handles the mismatch. No other code outside the VM needs to compare against this polyfilled symbol.

@@ -0,0 +1,6 @@
---
"@workflow/core": patch

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: Since this adds a new public API method (dispose()) and extends the Hook interface with Disposable, this could arguably be a minor bump for @workflow/core rather than patch. Up to you and your versioning conventions.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

keeping patch to be in line with our pre-beta conventions

pranaygpand others added 2 commits February 27, 2026 12:04
When dispose() is called while a promise is pending (e.g., iterator
suspended on yield await this, or direct await hook after dispose),
the promise would hang forever since the event consumer will never
deliver another hook_received. Now disposeHook() clears the promises
array and triggers a WorkflowSuspension so the runtime processes the
disposal cleanly.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@pranaygp

Copy link
Copy Markdown
ContributorAuthor

@VaguelySerious Thanks for the review! Addressing your two points:

  1. Hanging promise — Fixed in ca9ca85. disposeHook() now drains the promises array and triggers a WorkflowSuspension so there are no orphaned promises. This also addresses @TooTallNate's comment about dispose() called while the iterator is suspended.

  2. buildWorkflowSuspensionMessage missing hookDisposedCount — Good catch, the debug log won't mention hook disposals. Will address in a follow-up since it's cosmetic (debug-level only) and the WorkflowSuspension error message itself already includes disposals correctly.

Sign up for freeto 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.

How to dispose of hooks

5 participants

@pranaygp@TooTallNate@VaguelySerious@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(core): add hook.dispose() method to release hook tokens early by pranaygp · Pull Request #1181 · vercel/workflow · GitHub
Skip to content

feat(core): add hook.dispose() method to release hook tokens early - #1181

Merged
pranaygp merged 17 commits into
mainfrom
claude/slack-expose-hook-dispose-fU7QG
Feb 27, 2026
Merged

feat(core): add hook.dispose() method to release hook tokens early#1181
pranaygp merged 17 commits into
mainfrom
claude/slack-expose-hook-dispose-fU7QG

Conversation

@pranaygp

Copy link
Copy Markdown
Contributor

Add a dispose() method to the Hook interface that allows workflows to
explicitly release hook tokens for reuse by other workflows while the
current workflow is still running. This enables handoff patterns where
one workflow can transfer a hook token to another workflow.

  • Add dispose() method to Hook interface in create-hook.ts
  • Implement dispose functionality in workflow/hook.ts
  • Add HookDisposedInvocationQueueItem to global.ts
  • Handle hook_disposed events in suspension-handler.ts
  • Update documentation in hooks.mdx and create-hook.mdx
  • Add e2e test for hook token reuse after explicit disposal

https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH

CopilotAI review requested due to automatic review settings February 24, 2026 17:37
@pranaygp
pranaygp requested a review from a team as a code ownerFebruary 24, 2026 17:37
@vercel

vercelBot commented Feb 24, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Feb 24, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c697597

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 14 packages
NameType
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/web-sharedPatch
workflowPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

@github-actions

github-actionsBot commented Feb 24, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express0.032s (~)1.005s (~)0.972s101.00x
💻 LocalNitro0.032s (-8.5% 🟢)1.005s (~)0.973s101.00x
💻 LocalNext.js (Turbopack)0.041s1.005s0.963s101.28x
🌐 RedisNext.js (Turbopack)0.046s1.005s0.959s101.42x
🐘 PostgresNitro0.057s (+2.7%)1.011s (~)0.954s101.76x
🐘 PostgresExpress0.059s (+9.5% 🔺)1.011s (~)0.952s101.83x
🌐 MongoDBNext.js (Turbopack)0.092s1.008s0.915s102.85x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)0.603s (-12.6% 🟢)1.929s (-23.6% 🟢)1.326s101.00x
▲ VercelNitro0.707s (+4.3%)3.432s (+59.5% 🔺)2.725s101.17x
▲ VercelExpress0.905s (+32.7% 🔺)2.149s (+16.5% 🔺)1.244s101.50x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)1.103s2.005s0.903s101.00x
🌐 RedisNext.js (Turbopack)1.104s2.006s0.902s101.00x
💻 LocalNitro1.106s (~)2.006s (~)0.900s101.00x
💻 LocalExpress1.107s (~)2.006s (~)0.899s101.00x
🐘 PostgresExpress1.141s (+2.1%)2.010s (~)0.869s101.04x
🐘 PostgresNitro1.145s (+1.0%)2.024s (+0.6%)0.879s101.04x
🌐 MongoDBNext.js (Turbopack)1.312s2.009s0.697s101.19x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.163s (-7.9% 🟢)3.432s (-3.4%)1.269s101.00x
▲ VercelExpress2.220s (-7.3% 🟢)3.157s (-4.1%)0.937s101.03x
▲ VercelNitro2.225s (-2.3%)3.408s (-2.5%)1.183s101.03x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)10.700s11.022s0.322s31.00x
💻 LocalNext.js (Turbopack)10.719s11.021s0.303s31.00x
🐘 PostgresExpress10.827s (~)11.039s (~)0.211s31.01x
💻 LocalExpress10.832s (~)11.021s (~)0.190s31.01x
💻 LocalNitro10.843s (~)11.023s (~)0.180s31.01x
🐘 PostgresNitro10.914s (~)11.045s (~)0.131s31.02x
🌐 MongoDBNext.js (Turbopack)12.321s13.026s0.706s31.15x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express16.796s (-6.5% 🟢)17.576s (-8.7% 🟢)0.780s21.00x
▲ VercelNitro17.206s (-1.0%)18.757s (+1.3%)1.552s21.02x
▲ VercelNext.js (Turbopack)17.474s (-4.2%)18.688s (-3.6%)1.214s21.04x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)26.976s27.051s0.075s31.00x
💻 LocalNext.js (Turbopack)27.189s28.050s0.861s31.01x
🐘 PostgresExpress27.239s (~)28.058s (~)0.819s31.01x
🐘 PostgresNitro27.382s (~)28.066s (~)0.684s31.02x
💻 LocalNitro27.507s (~)28.051s (~)0.545s31.02x
💻 LocalExpress27.514s (~)28.051s (~)0.538s31.02x
🌐 MongoDBNext.js (Turbopack)30.628s31.056s0.427s21.14x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express43.906s (-0.7%)45.004s (~)1.098s21.00x
▲ VercelNitro45.876s (+0.8%)46.679s (+0.6%)0.803s21.04x
▲ VercelNext.js (Turbopack)46.913s (+2.9%)48.182s (+2.6%)1.269s21.07x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)54.422s55.098s0.677s21.00x
🐘 PostgresExpress54.990s (~)55.094s (-0.9%)0.104s21.01x
🐘 PostgresNitro55.233s (~)56.111s (+1.8%)0.878s21.01x
💻 LocalNext.js (Turbopack)56.719s57.099s0.380s21.04x
💻 LocalExpress57.330s (~)58.103s (~)0.774s21.05x
💻 LocalNitro57.367s (~)58.103s (~)0.736s21.05x
🌐 MongoDBNext.js (Turbopack)61.233s62.075s0.842s21.13x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro97.920s (-6.3% 🟢)99.368s (-5.5% 🟢)1.448s11.00x
▲ VercelExpress101.973s (+1.2%)103.023s (+0.8%)1.050s11.04x
▲ VercelNext.js (Turbopack)104.390s (+5.2% 🔺)105.833s (+5.9% 🔺)1.443s11.07x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.249s2.006s0.757s151.00x
🐘 PostgresExpress1.373s (+0.9%)2.009s (~)0.637s151.10x
🐘 PostgresNitro1.373s (+0.7%)2.011s (~)0.638s151.10x
💻 LocalNext.js (Turbopack)1.394s2.005s0.611s151.12x
💻 LocalExpress1.403s (-1.0%)2.005s (~)0.602s151.12x
💻 LocalNitro1.439s (~)2.006s (~)0.567s151.15x
🌐 MongoDBNext.js (Turbopack)2.147s3.008s0.861s101.72x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.394s (+0.7%)3.115s (+1.0%)0.721s101.00x
▲ VercelNitro2.396s (-9.0% 🟢)3.618s (-11.4% 🟢)1.222s91.00x
▲ VercelNext.js (Turbopack)2.670s (-12.0% 🟢)3.779s (-9.5% 🟢)1.109s81.12x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro2.009s (-3.7%)2.516s (-6.3% 🟢)0.508s121.00x
🐘 PostgresExpress2.037s (+0.8%)2.515s (~)0.478s121.01x
🌐 RedisNext.js (Turbopack)2.492s3.008s0.516s101.24x
💻 LocalNext.js (Turbopack)2.545s3.007s0.461s101.27x
💻 LocalExpress2.582s (-2.1%)3.007s (~)0.425s101.29x
💻 LocalNitro2.631s (-1.3%)3.008s (~)0.377s101.31x
🌐 MongoDBNext.js (Turbopack)4.817s5.344s0.527s62.40x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.689s (-0.5%)3.543s (-1.6%)0.854s91.00x
▲ VercelNitro2.939s (+6.8% 🔺)3.928s (+4.6%)0.989s81.09x
▲ VercelNext.js (Turbopack)2.964s (-12.1% 🟢)4.175s (-8.1% 🟢)1.212s81.10x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro3.593s (~)4.023s (-8.7% 🟢)0.430s81.00x
🐘 PostgresExpress3.824s (+18.9% 🔺)4.451s (+10.7% 🔺)0.627s71.06x
🌐 RedisNext.js (Turbopack)4.053s4.725s0.673s71.13x
💻 LocalNext.js (Turbopack)6.910s7.515s0.605s41.92x
💻 LocalExpress7.497s (~)8.018s (~)0.521s42.09x
💻 LocalNitro7.524s (-2.0%)8.016s (~)0.492s42.09x
🌐 MongoDBNext.js (Turbopack)9.919s10.682s0.763s32.76x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.118s (-4.9%)4.378s (+1.9%)1.260s71.00x
▲ VercelNitro3.185s (-7.3% 🟢)4.244s (-8.2% 🟢)1.059s81.02x
▲ VercelExpress3.993s (-12.7% 🟢)5.046s (-11.2% 🟢)1.053s61.28x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.235s2.006s0.771s151.00x
🐘 PostgresExpress1.369s (-0.8%)2.011s (~)0.642s151.11x
🐘 PostgresNitro1.375s (-2.7%)2.010s (~)0.635s151.11x
💻 LocalNext.js (Turbopack)1.394s2.006s0.611s151.13x
💻 LocalNitro1.440s (-1.3%)2.005s (~)0.565s151.17x
💻 LocalExpress1.456s (+3.2%)2.005s (~)0.548s151.18x
🌐 MongoDBNext.js (Turbopack)2.172s3.008s0.836s101.76x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.248s (+5.4% 🔺)3.236s (+6.6% 🔺)0.988s101.00x
▲ VercelNext.js (Turbopack)2.392s (+9.3% 🔺)3.657s (+5.0%)1.265s91.06x
▲ VercelNitro2.430s (+15.6% 🔺)3.559s (+5.3% 🔺)1.128s91.08x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro2.003s (-1.8%)2.596s (+4.9%)0.593s121.00x
🐘 PostgresExpress2.056s (+6.4% 🔺)2.682s (+3.3%)0.626s121.03x
🌐 RedisNext.js (Turbopack)2.523s3.008s0.485s101.26x
💻 LocalNext.js (Turbopack)2.628s3.008s0.380s101.31x
💻 LocalExpress2.690s (-1.7%)3.008s (~)0.318s101.34x
💻 LocalNitro2.710s (-2.3%)3.007s (~)0.297s101.35x
🌐 MongoDBNext.js (Turbopack)4.648s5.177s0.529s62.32x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.640s (-16.2% 🟢)3.548s (-15.4% 🟢)0.907s91.00x
▲ VercelExpress2.747s (+3.4%)3.582s (~)0.835s101.04x
▲ VercelNitro2.867s (-5.4% 🟢)3.831s (-15.7% 🟢)0.964s81.09x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express3.288s (-13.9% 🟢)3.893s (-9.8% 🟢)0.606s81.00x
🐘 PostgresNitro3.670s (+8.9% 🔺)4.268s (+2.9%)0.598s81.12x
🌐 RedisNext.js (Turbopack)4.111s4.868s0.758s71.25x
💻 LocalNext.js (Turbopack)7.432s7.766s0.334s42.26x
💻 LocalNitro7.877s (-5.4% 🟢)8.272s (-8.3% 🟢)0.395s42.40x
💻 LocalExpress7.888s (-3.9%)8.269s (-8.3% 🟢)0.381s42.40x
🌐 MongoDBNext.js (Turbopack)10.035s10.684s0.649s33.05x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.008s (~)4.207s (+3.0%)1.199s81.00x
▲ VercelExpress3.183s (-18.5% 🟢)4.339s (-17.2% 🟢)1.156s71.06x
▲ VercelNitro3.477s (+13.2% 🔺)4.833s (+12.0% 🔺)1.356s71.16x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)0.143s1.001s0.011s1.016s0.873s101.00x
🌐 RedisNext.js (Turbopack)0.154s1.000s0.001s1.007s0.853s101.08x
💻 LocalNitro0.174s (-6.2% 🟢)1.003s (~)0.011s (-5.9% 🟢)1.017s (~)0.842s101.22x
💻 LocalExpress0.175s (+2.6%)1.002s (~)0.011s (-1.8%)1.016s (~)0.841s101.23x
🐘 PostgresExpress0.200s (+5.5% 🔺)0.992s (~)0.002s (+13.3% 🔺)1.011s (~)0.812s101.40x
🐘 PostgresNitro0.202s (+2.5%)0.993s (~)0.002s (-5.6% 🟢)1.012s (~)0.809s101.42x
🌐 MongoDBNext.js (Turbopack)0.489s0.962s0.002s1.009s0.520s103.43x
🐘 PostgresNext.js (Turbopack)⚠️missing-----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)1.712s (-25.3% 🟢)2.412s (-17.3% 🟢)0.087s (-37.2% 🟢)33.064s (-1.7%)31.352s101.00x
▲ VercelNitro1.782s (-1.1%)2.391s (+0.9%)0.197s (+25.7% 🔺)3.132s (+1.4%)1.349s101.04x
▲ VercelExpress1.971s (+27.2% 🔺)2.591s (+13.9% 🔺)0.161s (-7.4% 🟢)33.170s (+1056.0% 🔺)31.198s101.15x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNext.js (Turbopack)11/12
🐘 PostgresExpress8/12
▲ VercelNext.js (Turbopack)6/12
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express🐘 Postgres8/12
Next.js (Turbopack)🌐 Redis7/12
Nitro🐘 Postgres6/12
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)

📋 View full workflow run

@github-actions

github-actionsBot commented Feb 24, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production534049583
✅ 💻 Local Development568068636
✅ 📦 Local Production568068636
❌ 🐘 Local Postgres567168636
✅ 🪟 Windows500353
❌ 🌍 Community Worlds113469168
✅ 📋 Other138021159
Total2538472862871

❌ Failed Tests

🐘 Local Postgres (1 failed)

nextjs-turbopack-stable (1 failed):

  • webhookWorkflow
🌍 Community Worlds (46 failed)

turso (46 failed):

  • addTenWorkflow
  • addTenWorkflow
  • should work with react rendering in step
  • promiseAllWorkflow
  • promiseRaceWorkflow
  • promiseAnyWorkflow
  • hookWorkflow
  • webhookWorkflow
  • sleepingWorkflow
  • parallelSleepWorkflow
  • nullByteWorkflow
  • workflowAndStepMetadataWorkflow
  • fetchWorkflow
  • promiseRaceStressTestWorkflow
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior FatalError fails immediately without retries
  • error handling retry behavior RetryableError respects custom retryAfter delay
  • error handling retry behavior maxRetries=0 disables retries
  • error handling retry behavior workflow completes despite transient 5xx on step_completed
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously
  • hookDisposeTestWorkflow - hook token reuse after explicit disposal while workflow still running
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument
  • closureVariableWorkflow - nested step functions with closure variables
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step
  • health check (queue-based) - workflow and step endpoints respond to health check messages
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • Calculator.calculate - static workflow method using static step methods from another class
  • AllInOneService.processNumber - static workflow method using sibling static step methods
  • ChainableService.processWithThis - static step methods using this to reference the class
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE
  • instanceMethodStepWorkflow - instance methods with "use step" directive
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context
  • stepFunctionAsStartArgWorkflow - step function reference passed as start() argument
  • cancelRun - cancelling a running workflow
  • cancelRun via CLI - cancelling a running workflow
  • pages router addTenWorkflow via pages router
  • pages router promiseAllWorkflow via pages router
  • pages router sleepingWorkflow via pages router

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro4805
✅ example4805
✅ express4805
✅ fastify4805
✅ hono4805
✅ nextjs-turbopack5102
✅ nextjs-webpack5102
✅ nitro4805
✅ nuxt4805
✅ sveltekit4805
✅ vite4805
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable4607
✅ express-stable4607
✅ fastify-stable4607
✅ hono-stable4607
✅ nextjs-turbopack-canary5003
✅ nextjs-turbopack-stable5003
✅ nextjs-webpack-canary5003
✅ nextjs-webpack-stable5003
✅ nitro-stable4607
✅ nuxt-stable4607
✅ sveltekit-stable4607
✅ vite-stable4607
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable4607
✅ express-stable4607
✅ fastify-stable4607
✅ hono-stable4607
✅ nextjs-turbopack-canary5003
✅ nextjs-turbopack-stable5003
✅ nextjs-webpack-canary5003
✅ nextjs-webpack-stable5003
✅ nitro-stable4607
✅ nuxt-stable4607
✅ sveltekit-stable4607
✅ vite-stable4607
❌ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable4607
✅ express-stable4607
✅ fastify-stable4607
✅ hono-stable4607
✅ nextjs-turbopack-canary5003
❌ nextjs-turbopack-stable4913
✅ nextjs-webpack-canary5003
✅ nextjs-webpack-stable5003
✅ nitro-stable4607
✅ nuxt-stable4607
✅ sveltekit-stable4607
✅ vite-stable4607
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack5003
❌ 🌍 Community Worlds
AppPassedFailedSkipped
✅ mongodb-dev300
✅ mongodb5003
✅ redis-dev300
✅ redis5003
✅ turso-dev300
❌ turso4463
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable4607
✅ e2e-local-postgres-nest-stable4607
✅ e2e-local-prod-nest-stable4607

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: success
  • Local Dev: success
  • Local Prod: success
  • Local Postgres: failure
  • Windows: success

Check the workflow run for details.

Comment threaddocs/content/docs/api-reference/workflow/create-hook.mdx Outdated
Comment threaddocs/content/docs/api-reference/workflow/create-hook.mdx Outdated
Comment threaddocs/content/docs/api-reference/workflow/create-hook.mdx
Comment threaddocs/content/docs/api-reference/workflow/create-hook.mdx Outdated
Comment threaddocs/content/docs/api-reference/workflow/create-webhook.mdx Outdated
Comment threaddocs/content/docs/api-reference/workflow/create-hook.mdx Outdated

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request adds a dispose() method to hooks and webhooks that allows workflows to explicitly release hook tokens for reuse by other workflows while the current workflow is still running. This enables handoff patterns where one workflow can transfer a hook token to another workflow.

Changes:

  • Added dispose() method and Symbol.dispose to Hook interface for TC39 Explicit Resource Management support
  • Implemented disposal logic in workflow/hook.ts with isDisposed tracking and async iterator termination
  • Added HookDisposedInvocationQueueItem type and handling in suspension-handler.ts to create hook_disposed events
  • Updated documentation to recommend using keyword for automatic cleanup and added examples of early disposal patterns
  • Added comprehensive e2e test demonstrating token reuse after explicit disposal while workflow still running
  • Fixed missing import in create-webhook.mdx documentation

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.

Show a summary per file
FileDescription
packages/core/src/create-hook.tsAdded dispose() and Symbol.dispose methods to Hook interface with documentation
packages/core/src/workflow/hook.tsImplemented disposeHook() function, isDisposed flag, and async iterator termination logic
packages/core/src/global.tsAdded HookDisposedInvocationQueueItem type and updated WorkflowSuspension to count hook disposals
packages/core/src/runtime/suspension-handler.tsAdded processing of hook_disposed queue items with error handling for 404/410 statuses
packages/core/e2e/e2e.test.tsAdded comprehensive test for hook token reuse after explicit disposal while workflow running
workbench/example/workflows/99_e2e.tsUpdated examples to use using keyword and added hookDisposeTestWorkflow demonstrating disposal
workbench/example/workflows/5_hooks.tsUpdated to use using keyword for automatic disposal
docs/content/docs/foundations/hooks.mdxAdded section on disposing hooks early with examples, updated all examples to use using
docs/content/docs/api-reference/workflow/create-hook.mdxAdded disposal documentation and examples, updated to use using throughout
docs/content/docs/api-reference/workflow/create-webhook.mdxAdded TC39 note, fixed missing import, updated examples to use using
.changeset/smart-cougars-talk.mdAdded changeset documenting the new feature

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 138 to 180
@@ -176,6 +179,22 @@ export function createCreateHook(ctx: WorkflowOrchestratorContext) {
return resolvers.promise;
}

CopilotAIFeb 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The createHookPromise function doesn't check if the hook has been disposed. While the async iterator correctly stops when isDisposed is true, directly awaiting a disposed hook (e.g., const result = await hook after hook.dispose()) will still attempt to create a promise and could cause unexpected behavior. Consider adding an isDisposed check that either throws an error or returns a rejected promise to make the behavior more predictable and easier to debug.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not adding an isDisposed check to createHookPromise(). The async iterator already checks !isDisposed before each yield, so the iterator pattern is safe. For direct await hook after dispose(), the behavior is the same as awaiting a hook that was never resumed — the promise hangs until the workflow suspends or completes. This is consistent with how the rest of the framework works (unawaited operations are silently ignored on completion). In the stacked PR #1185 we now log a warning when the workflow completes with uncommitted operations, which covers this case.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

human: hm I'm not sure claude understood the issue reported here. this explanation doesn't make sense to me ^

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, the previous explanation was off. The actual concern from Copilot was: if you call await hook after hook.dispose(), createHookPromise() doesn't check isDisposed so it still creates a promise and pushes it to the promises array.

In practice this is fine because:

  1. If the event log is empty (first run), the dispose already set disposed=true on the queue item, and the subsequent await triggers a WorkflowSuspension which processes both creation and disposal
  2. If the event log has hook_disposed (replay), the item is already deleted from the queue and hasDisposedEvent=true, so disposeHook() was a no-op — the await creates a dangling promise but the workflow completes/suspends on the next step

We have a test for case 1: "should suspend when awaiting a disposed hook on first invocation". Adding an isDisposed check to reject immediately would change the semantics (throwing instead of suspending), which could break workflows that call dispose() then have other work to do before their next suspension point.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@TooTallNate@VaguelySerious any thoughts on this behaviour?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation has changed significantly since this comment. disposeHook() no longer adds a separate queue item — it sets a disposed flag on the existing hook queue item, and on replay it checks hasDisposedEvent to no-op entirely.

Re: the createHookPromise not checking isDisposed — this is intentional. If user code calls dispose() then await hook, the promise enters the promises array. If the event log is empty, the next event-consumer pass triggers a WorkflowSuspension with the disposed hook item, which processes both creation and disposal. If the log has hook_disposed, the item is already removed from the queue so the suspension is empty or only contains other items. Either way it resolves correctly without needing an explicit isDisposed rejection in createHookPromise.

Comment threaddocs/content/docs/foundations/hooks.mdx
Comment threadpackages/core/src/workflow/hook.ts
Comment threadpackages/core/src/runtime/suspension-handler.ts
Comment threadpackages/core/src/workflow/hook.ts Outdated
(hook as any)[vmDispose] = disposeHook;
}

return hook as unknown as Hook<T>;

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hook as unknown as Hook<T> smells like a bug and breaks type safety. avoid this please

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 303b08e — added [Symbol.dispose]: disposeHook directly to the object literal so it satisfies Hook<T> without the double cast. The VM's Symbol.dispose is still set separately only when it differs from the host's.

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 122 to +126
if (event.eventType === 'hook_disposed') {
// If a hook is explicitly disposed, we're done processing any more
// events for it
// Terminal state - remove from queue (like step_completed/wait_completed)
ctx.invocationsQueue.delete(correlationId);
// Mark that the event log confirms disposal happened
hasDisposedEvent = true;

CopilotAIFeb 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a hook_disposed event is replayed, the hook remains in ctx.invocationsQueue (and may still have disposed=true). That means later suspensions can keep trying to emit hook_disposed again, which will reliably 404 in worlds like world-local (it deletes the hook entity on disposal) and can lead to repeated API calls/log spam. Consider deleting this hook’s queue item here (similar to wait_completed / step_completed) once disposal is confirmed in the event log.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we do delete the queue item now

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was fixed in 9f9c739hook_disposed now deletes the item from ctx.invocationsQueue (terminal state, matching step_completed/wait_completed). The hasDisposedEvent closure flag prevents disposeHook() from re-adding anything on replay.

Comment on lines +35 to +40
* Disposes the hook, releasing its token for reuse by other workflows.
*
* After calling `dispose()`, the hook will no longer receive any events.
* This is useful when you want to explicitly release a hook token before
* the workflow completes, allowing another workflow to register a hook
* with the same token.

CopilotAIFeb 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docs here imply that calling dispose() immediately stops the hook from receiving events and releases the token right away. In this implementation, dispose() only marks the queue item and the hook_disposed event/token release won’t happen until the workflow hits the next suspension point (or until the run reaches a terminal state). Consider clarifying that disposal is processed on the next suspension (e.g., await sleep() / await step()) to avoid confusing handoff patterns.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

small nuance

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSDoc describes the user-facing contract: after dispose(), the hook stops receiving events and the token becomes available. The internal mechanics (queue flag → suspension handler → server event) are an implementation detail. This is consistent with how sleep() and step functions are documented — they describe what happens, not when the server processes it.

Comment threaddocs/content/docs/foundations/hooks.mdx
Comment on lines +138 to +166
### Disposing Hooks Early

You can dispose a hook early to release its token for reuse by another workflow. This is useful for handoff patterns where one workflow needs to transfer a hook token to another workflow while still running.

```typescript lineNumbers
import { createHook } from "workflow"

export async function handoffWorkflow(channelId: string) {
"use workflow";

const hook = createHook<{ message: string; handoff?: boolean }>({
token: `channel:${channelId}`
});

for await (const payload of hook) {
console.log("Received:", payload.message);

if (payload.handoff) {
hook.dispose(); // [!code highlight] Release the token for another workflow
break;
}
}

// Continue with other work while another workflow uses the token
}
```

After calling `dispose()`, the hook will no longer receive events and its token becomes available for other workflows to use.

CopilotAIFeb 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The narrative here implies token reuse becomes available immediately after hook.dispose(). In practice, the disposal is emitted/processed on the next workflow suspension (or at run completion), so a handoff workflow typically needs an await (step/sleep/etc.) after disposing to make reuse observable while the workflow continues. Consider documenting that timing explicitly (or showing it in the example) to prevent confusing handoff behavior.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that nuance is fine to ignore since "the next suspension point" is always a few milliseconds away from a disposal (the workflow runtime is expected to be fast) and is not critical to document for usage of hook and disposal

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nvm I included it elsewhere in the hook docs

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same answer — the disposal is processed at the next suspension point, which for any practical workflow happens immediately (the next await triggers suspension). The docs are describing the user-facing contract, not the internal mechanics.

Comment thread.changeset/smart-cougars-talk.md Outdated
@@ -0,0 +1,6 @@
---
"@workflow/core": patch
"workflow": patch

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove "workflow" from the changelog. enough to just bump the core package

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c697597 — removed workflow from the changeset.

claudeand others added 13 commits February 26, 2026 21:32
Add a `dispose()` method to the Hook interface that allows workflows to
explicitly release hook tokens for reuse by other workflows while the
current workflow is still running. This enables handoff patterns where
one workflow can transfer a hook token to another workflow.
- Add `dispose()` method to Hook interface in create-hook.ts
- Implement dispose functionality in workflow/hook.ts
- Add HookDisposedInvocationQueueItem to global.ts
- Handle hook_disposed events in suspension-handler.ts
- Update documentation in hooks.mdx and create-hook.mdx
- Add e2e test for hook token reuse after explicit disposal
https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH
Add Symbol.dispose to Hook interface to support the TC39 Explicit Resource
Management proposal. This allows hooks to be used with the `using` keyword
for automatic disposal when exiting scope.
https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH
Update all documentation and e2e tests to use the `using` keyword as the
recommended approach for creating hooks and webhooks. This leverages the
TC39 Explicit Resource Management proposal for automatic disposal.
- Update e2e tests to use `using` syntax
- Update foundational hooks guide to recommend `using`
- Update create-hook API reference with `using` examples
- Update create-webhook API reference with `using` examples
- Update example workflow to use `using`
https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH
Remove unnecessary block scopes and excessive comments about automatic
disposal. Block scopes are only used when early disposal is relevant
(like in the handoff test).
https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH
Add a one-liner explaining the `using` keyword in the intro examples
of the API reference docs, so new users understand the syntax.
https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH
- Restore code highlights (`[!code highlight]`) that were unintentionally
removed from pre-existing doc examples
- Move `using` explanation from prose to inline code comment in intro
examples
- Add `{/* @skip-typecheck */}` to incomplete manual dispose() snippet
- Add 409 (conflict/duplicate) error handling for hook_disposed events
in suspension handler to handle workflow re-invocation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The workflow VM sandbox doesn't have Symbol.dispose/Symbol.asyncDispose
available, causing `using` keyword to fail with "Symbol.dispose is not
defined" at runtime. Add polyfill in the VM context creation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The workflow VM sandbox has its own Symbol object with a polyfilled
Symbol.dispose. The hook object was using the host's Symbol.dispose,
which is a different symbol instance. The SWC-compiled `using` keyword
looks up the VM's Symbol.dispose on the object, causing "Object not
disposable" errors.
Fix by setting Symbol.dispose on the hook object dynamically using the
VM's globalThis.Symbol.dispose from the orchestrator context.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The webhookWorkflow e2e test creates 3 webhooks that must all exist
before the test sends HTTP requests. Using `using` with sequential
creation meant only the first webhook existed at the first suspension
point. Revert to `const` since all webhooks need to be created upfront.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Instead of adding a separate HookDisposedInvocationQueueItem to the
queue on dispose, keep the HookInvocationQueueItem throughout the
hook lifecycle and track state with flags (hasCreatedEvent, disposed).
A closure variable (hasDisposedEvent) makes disposeHook() a pure no-op
on replay, avoiding redundant server calls and 409 errors.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Match the pattern used by steps (step_completed/step_failed) and waits
(wait_completed) where the queue item is removed on the terminal event.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- WorkflowSuspension counts disposed hooks separately from active hooks
- Dispose after hook_created replay produces correct suspension
- Dispose before first suspension (needs both create + dispose)
- Multiple hooks where only one is disposed
- Dispose on a conflicted hook is safe (no crash)
- Symbol.dispose calls disposeHook correctly (using keyword pattern)
- Iterator break without dispose keeps hook alive in queue
- Await after dispose on first invocation triggers suspension
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +172 to +198
### Disposing Hooks Early

When a workflow ends, hooks are automatically disposed. However, you may want to release a hook token early so another workflow can use it while your workflow continues running. Use a block scope with `using` to control when disposal happens:

```typescript lineNumbers
import { createHook } from "workflow";

export async function handoffWorkflow(channelId: string) {
"use workflow";

{
using hook = createHook<{ message: string; handoff?: boolean }>({
token: `channel:${channelId}`
});

for await (const payload of hook) {
console.log("Received:", payload.message);

if (payload.handoff) {
break;
}
}
} // Hook token released here

// Token is now available for another workflow
console.log("Continuing with other work...");
}

CopilotAIFeb 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section implies that exiting the using scope (or calling dispose()) immediately releases the token for another workflow. In the current design, the hook_disposed event is only emitted when the workflow next suspends (e.g., await sleep(), await step(), etc.), otherwise the token won’t be reusable until the run completes. Please document that a suspension point is required for early reuse, and ideally show one in the example.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docs are accurate for the user-facing mental model. The token is released when the workflow next suspends (which happens immediately after the using block exits if there's any subsequent await step() or await sleep()). For the edge case of dispose at function scope with no further suspension points, the backend auto-disposes all hooks when the run reaches a terminal state, so the token is still released.

Comment threadpackages/core/src/global.ts
Comment threadpackages/core/src/workflow/hook.test.ts
Comment threadpackages/core/src/create-hook.ts Outdated
Add [Symbol.dispose] directly to the hook object so it satisfies the
Hook<T> type without `as unknown as`. The VM's Symbol.dispose is still
added separately when it differs from the host's.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, two main comments from a claude simulation:

  1. When hook_disposed is replayed, the event consumer returns Finished, unsubscribing the callback. If the workflow later does await hook after the consumer is gone, createHookPromise() will push to promises[] but nobody will ever resolve it. However, this is actually fine, since a workflow that awaits a hook after disposing it is a user error, and the workflow will simply hang (which is the expected behavior for an unresolvable promise in the event-sourced model, so it would eventually suspend on the next trigger).
  2. runtime.ts suspension message doesn't include hookDisposedCount, see const suspensionMessage = buildWorkflowSuspensionMessage(.... The debug log message doesn't mention hook disposals. This is cosmetic (debug-level only) but could be confusing when debugging. Worth a follow-up to add hookDisposedCount to the message builder.

- Rename test to match new behavior (hooks stay in queue, not removed)
- Use neutral "processed" verb in WorkflowSuspension message when
mixed item types are present
- Remove extends Disposable from Hook interface to avoid requiring
lib.esnext.disposable in downstream consumers (explicit
[Symbol.dispose]() method is still declared on the interface)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well-implemented feature with solid test coverage and thorough documentation. The core design of tracking hasCreatedEvent and disposed flags on queue items, and splitting hook processing in the suspension handler, is clean and fits well into the existing event-sourcing architecture.

A few inline comments — mostly minor suggestions and one behavioral note about the iterator. Nothing blocking.

yield await this;
}
},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: If dispose() is called externally while the iterator is suspended on yield await this, the pending promise will never resolve/reject — the !isDisposed check only runs after the yield returns. This means the iterator won't terminate until the next payload arrives (at which point the loop condition catches it).

This is probably fine in practice since dispose() is typically called inside the for await body after receiving a payload, but worth a comment here noting the limitation.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ca9ca85disposeHook() now drains the promises array and triggers a WorkflowSuspension when there are pending promises. This ensures no orphaned awaits: the suspension handler processes the disposal, and the workflow execution is unwound cleanly.

This also addresses @VaguelySerious's point 1 from his review — the promise is no longer left hanging.

const vmDispose = ctx.globalThis.Symbol.dispose;
if (vmDispose && vmDispose !== Symbol.dispose) {
(hook as any)[vmDispose] = disposeHook;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The as unknown as Hook<T> cast is needed because the host's [Symbol.dispose] is intentionally not set on the object literal (only the VM's symbol is). This is correct for runtime behavior, but it means the host-side Symbol.dispose is never present on the object. Might be worth a brief comment explaining why the cast is safe (the hook is only used inside the VM sandbox where the VM's Symbol.dispose is what matters).

@pranaygppranaygpFeb 27, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed already. that cast is not needed

if (hooksNeedingDisposal.length > 0) {
await Promise.all(
hooksNeedingDisposal.map(async (queueItem) => {
const hookDisposedEvent: CreateEventRequest = {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: The hookDisposedEvent doesn't include an eventData field, while other event types (e.g., hook_created, step_created) typically do. Is that intentional? If the world implementation expects eventData to always be present, this could be an issue. If it's optional, this is fine — just flagging for awareness.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional — hook_disposed is a terminal event that just deletes the hook entity. The CreateEventRequest type has eventData as optional. The world implementations (local, postgres, vercel) all handle missing eventData fine for disposal events.

);
// Hooks needing disposal: any disposed hook (including those needing creation first)
// Hooks are created before disposal in the processing order below
const hooksNeedingDisposal = allHookItems.filter((item) => item.disposed);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment on lines 85-86 is helpful. To be explicit: when hasCreatedEvent is falsy and disposed is true, the hook appears in bothhooksNeedingCreation and hooksNeedingDisposal. Since creation runs before disposal (lines 122-193), this ordering is correct — the hook must exist before it can be disposed. Just want to confirm this two-step behavior for a single queue item is tested. (It is — the e2e test covers this path when dispose() is called before any suspension.)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct — this is tested in "should produce correct WorkflowSuspension for dispose before first suspension" which verifies the flags (hasCreatedEvent=undefined, disposed=true), and the suspension handler test coverage in the edge cases table confirms both hooksNeedingCreation and hooksNeedingDisposal include it.

g.structuredClone = globalThis.structuredClone;

// TC39 Explicit Resource Management polyfill for `using` keyword
(g.Symbol as any).dispose ??= Symbol.for('Symbol.dispose');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Symbol.for('Symbol.dispose') creates a global registry symbol which is not the same as the native well-known Symbol.dispose. The ??= guard means this only applies when the runtime lacks native support, which is fine. But if this polyfill is used elsewhere (outside the VM sandbox), code comparing against the native Symbol.dispose won't match. The dual-registration in hook.ts:235-238 handles this correctly. Just flagging in case other code needs to be aware of the distinction.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good callout. The Symbol.for('Symbol.dispose') polyfill is scoped to the VM sandbox context only — it's set on vmGlobalThis.Symbol.dispose, not the host's Symbol. The dual-registration in hook.ts (host's Symbol.dispose + VM's Symbol.dispose when they differ) handles the mismatch. No other code outside the VM needs to compare against this polyfilled symbol.

@@ -0,0 +1,6 @@
---
"@workflow/core": patch

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: Since this adds a new public API method (dispose()) and extends the Hook interface with Disposable, this could arguably be a minor bump for @workflow/core rather than patch. Up to you and your versioning conventions.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

keeping patch to be in line with our pre-beta conventions

pranaygpand others added 2 commits February 27, 2026 12:04
When dispose() is called while a promise is pending (e.g., iterator
suspended on yield await this, or direct await hook after dispose),
the promise would hang forever since the event consumer will never
deliver another hook_received. Now disposeHook() clears the promises
array and triggers a WorkflowSuspension so the runtime processes the
disposal cleanly.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@pranaygp

Copy link
Copy Markdown
ContributorAuthor

@VaguelySerious Thanks for the review! Addressing your two points:

  1. Hanging promise — Fixed in ca9ca85. disposeHook() now drains the promises array and triggers a WorkflowSuspension so there are no orphaned promises. This also addresses @TooTallNate's comment about dispose() called while the iterator is suspended.

  2. buildWorkflowSuspensionMessage missing hookDisposedCount — Good catch, the debug log won't mention hook disposals. Will address in a follow-up since it's cosmetic (debug-level only) and the WorkflowSuspension error message itself already includes disposals correctly.

Sign up for freeto 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.

How to dispose of hooks

5 participants

@pranaygp@TooTallNate@VaguelySerious@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(core): add hook.dispose() method to release hook tokens early by pranaygp · Pull Request #1181 · vercel/workflow · GitHub
Skip to content

feat(core): add hook.dispose() method to release hook tokens early - #1181

Merged
pranaygp merged 17 commits into
mainfrom
claude/slack-expose-hook-dispose-fU7QG
Feb 27, 2026
Merged

feat(core): add hook.dispose() method to release hook tokens early#1181
pranaygp merged 17 commits into
mainfrom
claude/slack-expose-hook-dispose-fU7QG

Conversation

@pranaygp

Copy link
Copy Markdown
Contributor

Add a dispose() method to the Hook interface that allows workflows to
explicitly release hook tokens for reuse by other workflows while the
current workflow is still running. This enables handoff patterns where
one workflow can transfer a hook token to another workflow.

  • Add dispose() method to Hook interface in create-hook.ts
  • Implement dispose functionality in workflow/hook.ts
  • Add HookDisposedInvocationQueueItem to global.ts
  • Handle hook_disposed events in suspension-handler.ts
  • Update documentation in hooks.mdx and create-hook.mdx
  • Add e2e test for hook token reuse after explicit disposal

https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH

CopilotAI review requested due to automatic review settings February 24, 2026 17:37
@pranaygp
pranaygp requested a review from a team as a code ownerFebruary 24, 2026 17:37
@vercel

vercelBot commented Feb 24, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Feb 24, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c697597

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 14 packages
NameType
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/web-sharedPatch
workflowPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

@github-actions

github-actionsBot commented Feb 24, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express0.032s (~)1.005s (~)0.972s101.00x
💻 LocalNitro0.032s (-8.5% 🟢)1.005s (~)0.973s101.00x
💻 LocalNext.js (Turbopack)0.041s1.005s0.963s101.28x
🌐 RedisNext.js (Turbopack)0.046s1.005s0.959s101.42x
🐘 PostgresNitro0.057s (+2.7%)1.011s (~)0.954s101.76x
🐘 PostgresExpress0.059s (+9.5% 🔺)1.011s (~)0.952s101.83x
🌐 MongoDBNext.js (Turbopack)0.092s1.008s0.915s102.85x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)0.603s (-12.6% 🟢)1.929s (-23.6% 🟢)1.326s101.00x
▲ VercelNitro0.707s (+4.3%)3.432s (+59.5% 🔺)2.725s101.17x
▲ VercelExpress0.905s (+32.7% 🔺)2.149s (+16.5% 🔺)1.244s101.50x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)1.103s2.005s0.903s101.00x
🌐 RedisNext.js (Turbopack)1.104s2.006s0.902s101.00x
💻 LocalNitro1.106s (~)2.006s (~)0.900s101.00x
💻 LocalExpress1.107s (~)2.006s (~)0.899s101.00x
🐘 PostgresExpress1.141s (+2.1%)2.010s (~)0.869s101.04x
🐘 PostgresNitro1.145s (+1.0%)2.024s (+0.6%)0.879s101.04x
🌐 MongoDBNext.js (Turbopack)1.312s2.009s0.697s101.19x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.163s (-7.9% 🟢)3.432s (-3.4%)1.269s101.00x
▲ VercelExpress2.220s (-7.3% 🟢)3.157s (-4.1%)0.937s101.03x
▲ VercelNitro2.225s (-2.3%)3.408s (-2.5%)1.183s101.03x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)10.700s11.022s0.322s31.00x
💻 LocalNext.js (Turbopack)10.719s11.021s0.303s31.00x
🐘 PostgresExpress10.827s (~)11.039s (~)0.211s31.01x
💻 LocalExpress10.832s (~)11.021s (~)0.190s31.01x
💻 LocalNitro10.843s (~)11.023s (~)0.180s31.01x
🐘 PostgresNitro10.914s (~)11.045s (~)0.131s31.02x
🌐 MongoDBNext.js (Turbopack)12.321s13.026s0.706s31.15x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express16.796s (-6.5% 🟢)17.576s (-8.7% 🟢)0.780s21.00x
▲ VercelNitro17.206s (-1.0%)18.757s (+1.3%)1.552s21.02x
▲ VercelNext.js (Turbopack)17.474s (-4.2%)18.688s (-3.6%)1.214s21.04x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)26.976s27.051s0.075s31.00x
💻 LocalNext.js (Turbopack)27.189s28.050s0.861s31.01x
🐘 PostgresExpress27.239s (~)28.058s (~)0.819s31.01x
🐘 PostgresNitro27.382s (~)28.066s (~)0.684s31.02x
💻 LocalNitro27.507s (~)28.051s (~)0.545s31.02x
💻 LocalExpress27.514s (~)28.051s (~)0.538s31.02x
🌐 MongoDBNext.js (Turbopack)30.628s31.056s0.427s21.14x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express43.906s (-0.7%)45.004s (~)1.098s21.00x
▲ VercelNitro45.876s (+0.8%)46.679s (+0.6%)0.803s21.04x
▲ VercelNext.js (Turbopack)46.913s (+2.9%)48.182s (+2.6%)1.269s21.07x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)54.422s55.098s0.677s21.00x
🐘 PostgresExpress54.990s (~)55.094s (-0.9%)0.104s21.01x
🐘 PostgresNitro55.233s (~)56.111s (+1.8%)0.878s21.01x
💻 LocalNext.js (Turbopack)56.719s57.099s0.380s21.04x
💻 LocalExpress57.330s (~)58.103s (~)0.774s21.05x
💻 LocalNitro57.367s (~)58.103s (~)0.736s21.05x
🌐 MongoDBNext.js (Turbopack)61.233s62.075s0.842s21.13x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro97.920s (-6.3% 🟢)99.368s (-5.5% 🟢)1.448s11.00x
▲ VercelExpress101.973s (+1.2%)103.023s (+0.8%)1.050s11.04x
▲ VercelNext.js (Turbopack)104.390s (+5.2% 🔺)105.833s (+5.9% 🔺)1.443s11.07x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.249s2.006s0.757s151.00x
🐘 PostgresExpress1.373s (+0.9%)2.009s (~)0.637s151.10x
🐘 PostgresNitro1.373s (+0.7%)2.011s (~)0.638s151.10x
💻 LocalNext.js (Turbopack)1.394s2.005s0.611s151.12x
💻 LocalExpress1.403s (-1.0%)2.005s (~)0.602s151.12x
💻 LocalNitro1.439s (~)2.006s (~)0.567s151.15x
🌐 MongoDBNext.js (Turbopack)2.147s3.008s0.861s101.72x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.394s (+0.7%)3.115s (+1.0%)0.721s101.00x
▲ VercelNitro2.396s (-9.0% 🟢)3.618s (-11.4% 🟢)1.222s91.00x
▲ VercelNext.js (Turbopack)2.670s (-12.0% 🟢)3.779s (-9.5% 🟢)1.109s81.12x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro2.009s (-3.7%)2.516s (-6.3% 🟢)0.508s121.00x
🐘 PostgresExpress2.037s (+0.8%)2.515s (~)0.478s121.01x
🌐 RedisNext.js (Turbopack)2.492s3.008s0.516s101.24x
💻 LocalNext.js (Turbopack)2.545s3.007s0.461s101.27x
💻 LocalExpress2.582s (-2.1%)3.007s (~)0.425s101.29x
💻 LocalNitro2.631s (-1.3%)3.008s (~)0.377s101.31x
🌐 MongoDBNext.js (Turbopack)4.817s5.344s0.527s62.40x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.689s (-0.5%)3.543s (-1.6%)0.854s91.00x
▲ VercelNitro2.939s (+6.8% 🔺)3.928s (+4.6%)0.989s81.09x
▲ VercelNext.js (Turbopack)2.964s (-12.1% 🟢)4.175s (-8.1% 🟢)1.212s81.10x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro3.593s (~)4.023s (-8.7% 🟢)0.430s81.00x
🐘 PostgresExpress3.824s (+18.9% 🔺)4.451s (+10.7% 🔺)0.627s71.06x
🌐 RedisNext.js (Turbopack)4.053s4.725s0.673s71.13x
💻 LocalNext.js (Turbopack)6.910s7.515s0.605s41.92x
💻 LocalExpress7.497s (~)8.018s (~)0.521s42.09x
💻 LocalNitro7.524s (-2.0%)8.016s (~)0.492s42.09x
🌐 MongoDBNext.js (Turbopack)9.919s10.682s0.763s32.76x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.118s (-4.9%)4.378s (+1.9%)1.260s71.00x
▲ VercelNitro3.185s (-7.3% 🟢)4.244s (-8.2% 🟢)1.059s81.02x
▲ VercelExpress3.993s (-12.7% 🟢)5.046s (-11.2% 🟢)1.053s61.28x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.235s2.006s0.771s151.00x
🐘 PostgresExpress1.369s (-0.8%)2.011s (~)0.642s151.11x
🐘 PostgresNitro1.375s (-2.7%)2.010s (~)0.635s151.11x
💻 LocalNext.js (Turbopack)1.394s2.006s0.611s151.13x
💻 LocalNitro1.440s (-1.3%)2.005s (~)0.565s151.17x
💻 LocalExpress1.456s (+3.2%)2.005s (~)0.548s151.18x
🌐 MongoDBNext.js (Turbopack)2.172s3.008s0.836s101.76x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.248s (+5.4% 🔺)3.236s (+6.6% 🔺)0.988s101.00x
▲ VercelNext.js (Turbopack)2.392s (+9.3% 🔺)3.657s (+5.0%)1.265s91.06x
▲ VercelNitro2.430s (+15.6% 🔺)3.559s (+5.3% 🔺)1.128s91.08x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro2.003s (-1.8%)2.596s (+4.9%)0.593s121.00x
🐘 PostgresExpress2.056s (+6.4% 🔺)2.682s (+3.3%)0.626s121.03x
🌐 RedisNext.js (Turbopack)2.523s3.008s0.485s101.26x
💻 LocalNext.js (Turbopack)2.628s3.008s0.380s101.31x
💻 LocalExpress2.690s (-1.7%)3.008s (~)0.318s101.34x
💻 LocalNitro2.710s (-2.3%)3.007s (~)0.297s101.35x
🌐 MongoDBNext.js (Turbopack)4.648s5.177s0.529s62.32x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.640s (-16.2% 🟢)3.548s (-15.4% 🟢)0.907s91.00x
▲ VercelExpress2.747s (+3.4%)3.582s (~)0.835s101.04x
▲ VercelNitro2.867s (-5.4% 🟢)3.831s (-15.7% 🟢)0.964s81.09x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express3.288s (-13.9% 🟢)3.893s (-9.8% 🟢)0.606s81.00x
🐘 PostgresNitro3.670s (+8.9% 🔺)4.268s (+2.9%)0.598s81.12x
🌐 RedisNext.js (Turbopack)4.111s4.868s0.758s71.25x
💻 LocalNext.js (Turbopack)7.432s7.766s0.334s42.26x
💻 LocalNitro7.877s (-5.4% 🟢)8.272s (-8.3% 🟢)0.395s42.40x
💻 LocalExpress7.888s (-3.9%)8.269s (-8.3% 🟢)0.381s42.40x
🌐 MongoDBNext.js (Turbopack)10.035s10.684s0.649s33.05x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.008s (~)4.207s (+3.0%)1.199s81.00x
▲ VercelExpress3.183s (-18.5% 🟢)4.339s (-17.2% 🟢)1.156s71.06x
▲ VercelNitro3.477s (+13.2% 🔺)4.833s (+12.0% 🔺)1.356s71.16x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)0.143s1.001s0.011s1.016s0.873s101.00x
🌐 RedisNext.js (Turbopack)0.154s1.000s0.001s1.007s0.853s101.08x
💻 LocalNitro0.174s (-6.2% 🟢)1.003s (~)0.011s (-5.9% 🟢)1.017s (~)0.842s101.22x
💻 LocalExpress0.175s (+2.6%)1.002s (~)0.011s (-1.8%)1.016s (~)0.841s101.23x
🐘 PostgresExpress0.200s (+5.5% 🔺)0.992s (~)0.002s (+13.3% 🔺)1.011s (~)0.812s101.40x
🐘 PostgresNitro0.202s (+2.5%)0.993s (~)0.002s (-5.6% 🟢)1.012s (~)0.809s101.42x
🌐 MongoDBNext.js (Turbopack)0.489s0.962s0.002s1.009s0.520s103.43x
🐘 PostgresNext.js (Turbopack)⚠️missing-----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)1.712s (-25.3% 🟢)2.412s (-17.3% 🟢)0.087s (-37.2% 🟢)33.064s (-1.7%)31.352s101.00x
▲ VercelNitro1.782s (-1.1%)2.391s (+0.9%)0.197s (+25.7% 🔺)3.132s (+1.4%)1.349s101.04x
▲ VercelExpress1.971s (+27.2% 🔺)2.591s (+13.9% 🔺)0.161s (-7.4% 🟢)33.170s (+1056.0% 🔺)31.198s101.15x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNext.js (Turbopack)11/12
🐘 PostgresExpress8/12
▲ VercelNext.js (Turbopack)6/12
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express🐘 Postgres8/12
Next.js (Turbopack)🌐 Redis7/12
Nitro🐘 Postgres6/12
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)

📋 View full workflow run

@github-actions

github-actionsBot commented Feb 24, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production534049583
✅ 💻 Local Development568068636
✅ 📦 Local Production568068636
❌ 🐘 Local Postgres567168636
✅ 🪟 Windows500353
❌ 🌍 Community Worlds113469168
✅ 📋 Other138021159
Total2538472862871

❌ Failed Tests

🐘 Local Postgres (1 failed)

nextjs-turbopack-stable (1 failed):

  • webhookWorkflow
🌍 Community Worlds (46 failed)

turso (46 failed):

  • addTenWorkflow
  • addTenWorkflow
  • should work with react rendering in step
  • promiseAllWorkflow
  • promiseRaceWorkflow
  • promiseAnyWorkflow
  • hookWorkflow
  • webhookWorkflow
  • sleepingWorkflow
  • parallelSleepWorkflow
  • nullByteWorkflow
  • workflowAndStepMetadataWorkflow
  • fetchWorkflow
  • promiseRaceStressTestWorkflow
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior FatalError fails immediately without retries
  • error handling retry behavior RetryableError respects custom retryAfter delay
  • error handling retry behavior maxRetries=0 disables retries
  • error handling retry behavior workflow completes despite transient 5xx on step_completed
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously
  • hookDisposeTestWorkflow - hook token reuse after explicit disposal while workflow still running
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument
  • closureVariableWorkflow - nested step functions with closure variables
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step
  • health check (queue-based) - workflow and step endpoints respond to health check messages
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • Calculator.calculate - static workflow method using static step methods from another class
  • AllInOneService.processNumber - static workflow method using sibling static step methods
  • ChainableService.processWithThis - static step methods using this to reference the class
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE
  • instanceMethodStepWorkflow - instance methods with "use step" directive
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context
  • stepFunctionAsStartArgWorkflow - step function reference passed as start() argument
  • cancelRun - cancelling a running workflow
  • cancelRun via CLI - cancelling a running workflow
  • pages router addTenWorkflow via pages router
  • pages router promiseAllWorkflow via pages router
  • pages router sleepingWorkflow via pages router

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro4805
✅ example4805
✅ express4805
✅ fastify4805
✅ hono4805
✅ nextjs-turbopack5102
✅ nextjs-webpack5102
✅ nitro4805
✅ nuxt4805
✅ sveltekit4805
✅ vite4805
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable4607
✅ express-stable4607
✅ fastify-stable4607
✅ hono-stable4607
✅ nextjs-turbopack-canary5003
✅ nextjs-turbopack-stable5003
✅ nextjs-webpack-canary5003
✅ nextjs-webpack-stable5003
✅ nitro-stable4607
✅ nuxt-stable4607
✅ sveltekit-stable4607
✅ vite-stable4607
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable4607
✅ express-stable4607
✅ fastify-stable4607
✅ hono-stable4607
✅ nextjs-turbopack-canary5003
✅ nextjs-turbopack-stable5003
✅ nextjs-webpack-canary5003
✅ nextjs-webpack-stable5003
✅ nitro-stable4607
✅ nuxt-stable4607
✅ sveltekit-stable4607
✅ vite-stable4607
❌ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable4607
✅ express-stable4607
✅ fastify-stable4607
✅ hono-stable4607
✅ nextjs-turbopack-canary5003
❌ nextjs-turbopack-stable4913
✅ nextjs-webpack-canary5003
✅ nextjs-webpack-stable5003
✅ nitro-stable4607
✅ nuxt-stable4607
✅ sveltekit-stable4607
✅ vite-stable4607
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack5003
❌ 🌍 Community Worlds
AppPassedFailedSkipped
✅ mongodb-dev300
✅ mongodb5003
✅ redis-dev300
✅ redis5003
✅ turso-dev300
❌ turso4463
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable4607
✅ e2e-local-postgres-nest-stable4607
✅ e2e-local-prod-nest-stable4607

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: success
  • Local Dev: success
  • Local Prod: success
  • Local Postgres: failure
  • Windows: success

Check the workflow run for details.

Comment threaddocs/content/docs/api-reference/workflow/create-hook.mdx Outdated
Comment threaddocs/content/docs/api-reference/workflow/create-hook.mdx Outdated
Comment threaddocs/content/docs/api-reference/workflow/create-hook.mdx
Comment threaddocs/content/docs/api-reference/workflow/create-hook.mdx Outdated
Comment threaddocs/content/docs/api-reference/workflow/create-webhook.mdx Outdated
Comment threaddocs/content/docs/api-reference/workflow/create-hook.mdx Outdated

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request adds a dispose() method to hooks and webhooks that allows workflows to explicitly release hook tokens for reuse by other workflows while the current workflow is still running. This enables handoff patterns where one workflow can transfer a hook token to another workflow.

Changes:

  • Added dispose() method and Symbol.dispose to Hook interface for TC39 Explicit Resource Management support
  • Implemented disposal logic in workflow/hook.ts with isDisposed tracking and async iterator termination
  • Added HookDisposedInvocationQueueItem type and handling in suspension-handler.ts to create hook_disposed events
  • Updated documentation to recommend using keyword for automatic cleanup and added examples of early disposal patterns
  • Added comprehensive e2e test demonstrating token reuse after explicit disposal while workflow still running
  • Fixed missing import in create-webhook.mdx documentation

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.

Show a summary per file
FileDescription
packages/core/src/create-hook.tsAdded dispose() and Symbol.dispose methods to Hook interface with documentation
packages/core/src/workflow/hook.tsImplemented disposeHook() function, isDisposed flag, and async iterator termination logic
packages/core/src/global.tsAdded HookDisposedInvocationQueueItem type and updated WorkflowSuspension to count hook disposals
packages/core/src/runtime/suspension-handler.tsAdded processing of hook_disposed queue items with error handling for 404/410 statuses
packages/core/e2e/e2e.test.tsAdded comprehensive test for hook token reuse after explicit disposal while workflow running
workbench/example/workflows/99_e2e.tsUpdated examples to use using keyword and added hookDisposeTestWorkflow demonstrating disposal
workbench/example/workflows/5_hooks.tsUpdated to use using keyword for automatic disposal
docs/content/docs/foundations/hooks.mdxAdded section on disposing hooks early with examples, updated all examples to use using
docs/content/docs/api-reference/workflow/create-hook.mdxAdded disposal documentation and examples, updated to use using throughout
docs/content/docs/api-reference/workflow/create-webhook.mdxAdded TC39 note, fixed missing import, updated examples to use using
.changeset/smart-cougars-talk.mdAdded changeset documenting the new feature

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 138 to 180
@@ -176,6 +179,22 @@ export function createCreateHook(ctx: WorkflowOrchestratorContext) {
return resolvers.promise;
}

CopilotAIFeb 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The createHookPromise function doesn't check if the hook has been disposed. While the async iterator correctly stops when isDisposed is true, directly awaiting a disposed hook (e.g., const result = await hook after hook.dispose()) will still attempt to create a promise and could cause unexpected behavior. Consider adding an isDisposed check that either throws an error or returns a rejected promise to make the behavior more predictable and easier to debug.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not adding an isDisposed check to createHookPromise(). The async iterator already checks !isDisposed before each yield, so the iterator pattern is safe. For direct await hook after dispose(), the behavior is the same as awaiting a hook that was never resumed — the promise hangs until the workflow suspends or completes. This is consistent with how the rest of the framework works (unawaited operations are silently ignored on completion). In the stacked PR #1185 we now log a warning when the workflow completes with uncommitted operations, which covers this case.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

human: hm I'm not sure claude understood the issue reported here. this explanation doesn't make sense to me ^

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, the previous explanation was off. The actual concern from Copilot was: if you call await hook after hook.dispose(), createHookPromise() doesn't check isDisposed so it still creates a promise and pushes it to the promises array.

In practice this is fine because:

  1. If the event log is empty (first run), the dispose already set disposed=true on the queue item, and the subsequent await triggers a WorkflowSuspension which processes both creation and disposal
  2. If the event log has hook_disposed (replay), the item is already deleted from the queue and hasDisposedEvent=true, so disposeHook() was a no-op — the await creates a dangling promise but the workflow completes/suspends on the next step

We have a test for case 1: "should suspend when awaiting a disposed hook on first invocation". Adding an isDisposed check to reject immediately would change the semantics (throwing instead of suspending), which could break workflows that call dispose() then have other work to do before their next suspension point.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@TooTallNate@VaguelySerious any thoughts on this behaviour?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation has changed significantly since this comment. disposeHook() no longer adds a separate queue item — it sets a disposed flag on the existing hook queue item, and on replay it checks hasDisposedEvent to no-op entirely.

Re: the createHookPromise not checking isDisposed — this is intentional. If user code calls dispose() then await hook, the promise enters the promises array. If the event log is empty, the next event-consumer pass triggers a WorkflowSuspension with the disposed hook item, which processes both creation and disposal. If the log has hook_disposed, the item is already removed from the queue so the suspension is empty or only contains other items. Either way it resolves correctly without needing an explicit isDisposed rejection in createHookPromise.

Comment threaddocs/content/docs/foundations/hooks.mdx
Comment threadpackages/core/src/workflow/hook.ts
Comment threadpackages/core/src/runtime/suspension-handler.ts
Comment threadpackages/core/src/workflow/hook.ts Outdated
(hook as any)[vmDispose] = disposeHook;
}

return hook as unknown as Hook<T>;

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hook as unknown as Hook<T> smells like a bug and breaks type safety. avoid this please

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 303b08e — added [Symbol.dispose]: disposeHook directly to the object literal so it satisfies Hook<T> without the double cast. The VM's Symbol.dispose is still set separately only when it differs from the host's.

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 122 to +126
if (event.eventType === 'hook_disposed') {
// If a hook is explicitly disposed, we're done processing any more
// events for it
// Terminal state - remove from queue (like step_completed/wait_completed)
ctx.invocationsQueue.delete(correlationId);
// Mark that the event log confirms disposal happened
hasDisposedEvent = true;

CopilotAIFeb 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a hook_disposed event is replayed, the hook remains in ctx.invocationsQueue (and may still have disposed=true). That means later suspensions can keep trying to emit hook_disposed again, which will reliably 404 in worlds like world-local (it deletes the hook entity on disposal) and can lead to repeated API calls/log spam. Consider deleting this hook’s queue item here (similar to wait_completed / step_completed) once disposal is confirmed in the event log.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we do delete the queue item now

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was fixed in 9f9c739hook_disposed now deletes the item from ctx.invocationsQueue (terminal state, matching step_completed/wait_completed). The hasDisposedEvent closure flag prevents disposeHook() from re-adding anything on replay.

Comment on lines +35 to +40
* Disposes the hook, releasing its token for reuse by other workflows.
*
* After calling `dispose()`, the hook will no longer receive any events.
* This is useful when you want to explicitly release a hook token before
* the workflow completes, allowing another workflow to register a hook
* with the same token.

CopilotAIFeb 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docs here imply that calling dispose() immediately stops the hook from receiving events and releases the token right away. In this implementation, dispose() only marks the queue item and the hook_disposed event/token release won’t happen until the workflow hits the next suspension point (or until the run reaches a terminal state). Consider clarifying that disposal is processed on the next suspension (e.g., await sleep() / await step()) to avoid confusing handoff patterns.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

small nuance

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSDoc describes the user-facing contract: after dispose(), the hook stops receiving events and the token becomes available. The internal mechanics (queue flag → suspension handler → server event) are an implementation detail. This is consistent with how sleep() and step functions are documented — they describe what happens, not when the server processes it.

Comment threaddocs/content/docs/foundations/hooks.mdx
Comment on lines +138 to +166
### Disposing Hooks Early

You can dispose a hook early to release its token for reuse by another workflow. This is useful for handoff patterns where one workflow needs to transfer a hook token to another workflow while still running.

```typescript lineNumbers
import { createHook } from "workflow"

export async function handoffWorkflow(channelId: string) {
"use workflow";

const hook = createHook<{ message: string; handoff?: boolean }>({
token: `channel:${channelId}`
});

for await (const payload of hook) {
console.log("Received:", payload.message);

if (payload.handoff) {
hook.dispose(); // [!code highlight] Release the token for another workflow
break;
}
}

// Continue with other work while another workflow uses the token
}
```

After calling `dispose()`, the hook will no longer receive events and its token becomes available for other workflows to use.

CopilotAIFeb 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The narrative here implies token reuse becomes available immediately after hook.dispose(). In practice, the disposal is emitted/processed on the next workflow suspension (or at run completion), so a handoff workflow typically needs an await (step/sleep/etc.) after disposing to make reuse observable while the workflow continues. Consider documenting that timing explicitly (or showing it in the example) to prevent confusing handoff behavior.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that nuance is fine to ignore since "the next suspension point" is always a few milliseconds away from a disposal (the workflow runtime is expected to be fast) and is not critical to document for usage of hook and disposal

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nvm I included it elsewhere in the hook docs

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same answer — the disposal is processed at the next suspension point, which for any practical workflow happens immediately (the next await triggers suspension). The docs are describing the user-facing contract, not the internal mechanics.

Comment thread.changeset/smart-cougars-talk.md Outdated
@@ -0,0 +1,6 @@
---
"@workflow/core": patch
"workflow": patch

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove "workflow" from the changelog. enough to just bump the core package

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c697597 — removed workflow from the changeset.

claudeand others added 13 commits February 26, 2026 21:32
Add a `dispose()` method to the Hook interface that allows workflows to
explicitly release hook tokens for reuse by other workflows while the
current workflow is still running. This enables handoff patterns where
one workflow can transfer a hook token to another workflow.
- Add `dispose()` method to Hook interface in create-hook.ts
- Implement dispose functionality in workflow/hook.ts
- Add HookDisposedInvocationQueueItem to global.ts
- Handle hook_disposed events in suspension-handler.ts
- Update documentation in hooks.mdx and create-hook.mdx
- Add e2e test for hook token reuse after explicit disposal
https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH
Add Symbol.dispose to Hook interface to support the TC39 Explicit Resource
Management proposal. This allows hooks to be used with the `using` keyword
for automatic disposal when exiting scope.
https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH
Update all documentation and e2e tests to use the `using` keyword as the
recommended approach for creating hooks and webhooks. This leverages the
TC39 Explicit Resource Management proposal for automatic disposal.
- Update e2e tests to use `using` syntax
- Update foundational hooks guide to recommend `using`
- Update create-hook API reference with `using` examples
- Update create-webhook API reference with `using` examples
- Update example workflow to use `using`
https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH
Remove unnecessary block scopes and excessive comments about automatic
disposal. Block scopes are only used when early disposal is relevant
(like in the handoff test).
https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH
Add a one-liner explaining the `using` keyword in the intro examples
of the API reference docs, so new users understand the syntax.
https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH
- Restore code highlights (`[!code highlight]`) that were unintentionally
removed from pre-existing doc examples
- Move `using` explanation from prose to inline code comment in intro
examples
- Add `{/* @skip-typecheck */}` to incomplete manual dispose() snippet
- Add 409 (conflict/duplicate) error handling for hook_disposed events
in suspension handler to handle workflow re-invocation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The workflow VM sandbox doesn't have Symbol.dispose/Symbol.asyncDispose
available, causing `using` keyword to fail with "Symbol.dispose is not
defined" at runtime. Add polyfill in the VM context creation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The workflow VM sandbox has its own Symbol object with a polyfilled
Symbol.dispose. The hook object was using the host's Symbol.dispose,
which is a different symbol instance. The SWC-compiled `using` keyword
looks up the VM's Symbol.dispose on the object, causing "Object not
disposable" errors.
Fix by setting Symbol.dispose on the hook object dynamically using the
VM's globalThis.Symbol.dispose from the orchestrator context.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The webhookWorkflow e2e test creates 3 webhooks that must all exist
before the test sends HTTP requests. Using `using` with sequential
creation meant only the first webhook existed at the first suspension
point. Revert to `const` since all webhooks need to be created upfront.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Instead of adding a separate HookDisposedInvocationQueueItem to the
queue on dispose, keep the HookInvocationQueueItem throughout the
hook lifecycle and track state with flags (hasCreatedEvent, disposed).
A closure variable (hasDisposedEvent) makes disposeHook() a pure no-op
on replay, avoiding redundant server calls and 409 errors.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Match the pattern used by steps (step_completed/step_failed) and waits
(wait_completed) where the queue item is removed on the terminal event.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- WorkflowSuspension counts disposed hooks separately from active hooks
- Dispose after hook_created replay produces correct suspension
- Dispose before first suspension (needs both create + dispose)
- Multiple hooks where only one is disposed
- Dispose on a conflicted hook is safe (no crash)
- Symbol.dispose calls disposeHook correctly (using keyword pattern)
- Iterator break without dispose keeps hook alive in queue
- Await after dispose on first invocation triggers suspension
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +172 to +198
### Disposing Hooks Early

When a workflow ends, hooks are automatically disposed. However, you may want to release a hook token early so another workflow can use it while your workflow continues running. Use a block scope with `using` to control when disposal happens:

```typescript lineNumbers
import { createHook } from "workflow";

export async function handoffWorkflow(channelId: string) {
"use workflow";

{
using hook = createHook<{ message: string; handoff?: boolean }>({
token: `channel:${channelId}`
});

for await (const payload of hook) {
console.log("Received:", payload.message);

if (payload.handoff) {
break;
}
}
} // Hook token released here

// Token is now available for another workflow
console.log("Continuing with other work...");
}

CopilotAIFeb 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section implies that exiting the using scope (or calling dispose()) immediately releases the token for another workflow. In the current design, the hook_disposed event is only emitted when the workflow next suspends (e.g., await sleep(), await step(), etc.), otherwise the token won’t be reusable until the run completes. Please document that a suspension point is required for early reuse, and ideally show one in the example.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docs are accurate for the user-facing mental model. The token is released when the workflow next suspends (which happens immediately after the using block exits if there's any subsequent await step() or await sleep()). For the edge case of dispose at function scope with no further suspension points, the backend auto-disposes all hooks when the run reaches a terminal state, so the token is still released.

Comment threadpackages/core/src/global.ts
Comment threadpackages/core/src/workflow/hook.test.ts
Comment threadpackages/core/src/create-hook.ts Outdated
Add [Symbol.dispose] directly to the hook object so it satisfies the
Hook<T> type without `as unknown as`. The VM's Symbol.dispose is still
added separately when it differs from the host's.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, two main comments from a claude simulation:

  1. When hook_disposed is replayed, the event consumer returns Finished, unsubscribing the callback. If the workflow later does await hook after the consumer is gone, createHookPromise() will push to promises[] but nobody will ever resolve it. However, this is actually fine, since a workflow that awaits a hook after disposing it is a user error, and the workflow will simply hang (which is the expected behavior for an unresolvable promise in the event-sourced model, so it would eventually suspend on the next trigger).
  2. runtime.ts suspension message doesn't include hookDisposedCount, see const suspensionMessage = buildWorkflowSuspensionMessage(.... The debug log message doesn't mention hook disposals. This is cosmetic (debug-level only) but could be confusing when debugging. Worth a follow-up to add hookDisposedCount to the message builder.

- Rename test to match new behavior (hooks stay in queue, not removed)
- Use neutral "processed" verb in WorkflowSuspension message when
mixed item types are present
- Remove extends Disposable from Hook interface to avoid requiring
lib.esnext.disposable in downstream consumers (explicit
[Symbol.dispose]() method is still declared on the interface)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well-implemented feature with solid test coverage and thorough documentation. The core design of tracking hasCreatedEvent and disposed flags on queue items, and splitting hook processing in the suspension handler, is clean and fits well into the existing event-sourcing architecture.

A few inline comments — mostly minor suggestions and one behavioral note about the iterator. Nothing blocking.

yield await this;
}
},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: If dispose() is called externally while the iterator is suspended on yield await this, the pending promise will never resolve/reject — the !isDisposed check only runs after the yield returns. This means the iterator won't terminate until the next payload arrives (at which point the loop condition catches it).

This is probably fine in practice since dispose() is typically called inside the for await body after receiving a payload, but worth a comment here noting the limitation.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ca9ca85disposeHook() now drains the promises array and triggers a WorkflowSuspension when there are pending promises. This ensures no orphaned awaits: the suspension handler processes the disposal, and the workflow execution is unwound cleanly.

This also addresses @VaguelySerious's point 1 from his review — the promise is no longer left hanging.

const vmDispose = ctx.globalThis.Symbol.dispose;
if (vmDispose && vmDispose !== Symbol.dispose) {
(hook as any)[vmDispose] = disposeHook;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The as unknown as Hook<T> cast is needed because the host's [Symbol.dispose] is intentionally not set on the object literal (only the VM's symbol is). This is correct for runtime behavior, but it means the host-side Symbol.dispose is never present on the object. Might be worth a brief comment explaining why the cast is safe (the hook is only used inside the VM sandbox where the VM's Symbol.dispose is what matters).

@pranaygppranaygpFeb 27, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed already. that cast is not needed

if (hooksNeedingDisposal.length > 0) {
await Promise.all(
hooksNeedingDisposal.map(async (queueItem) => {
const hookDisposedEvent: CreateEventRequest = {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: The hookDisposedEvent doesn't include an eventData field, while other event types (e.g., hook_created, step_created) typically do. Is that intentional? If the world implementation expects eventData to always be present, this could be an issue. If it's optional, this is fine — just flagging for awareness.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional — hook_disposed is a terminal event that just deletes the hook entity. The CreateEventRequest type has eventData as optional. The world implementations (local, postgres, vercel) all handle missing eventData fine for disposal events.

);
// Hooks needing disposal: any disposed hook (including those needing creation first)
// Hooks are created before disposal in the processing order below
const hooksNeedingDisposal = allHookItems.filter((item) => item.disposed);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment on lines 85-86 is helpful. To be explicit: when hasCreatedEvent is falsy and disposed is true, the hook appears in bothhooksNeedingCreation and hooksNeedingDisposal. Since creation runs before disposal (lines 122-193), this ordering is correct — the hook must exist before it can be disposed. Just want to confirm this two-step behavior for a single queue item is tested. (It is — the e2e test covers this path when dispose() is called before any suspension.)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct — this is tested in "should produce correct WorkflowSuspension for dispose before first suspension" which verifies the flags (hasCreatedEvent=undefined, disposed=true), and the suspension handler test coverage in the edge cases table confirms both hooksNeedingCreation and hooksNeedingDisposal include it.

g.structuredClone = globalThis.structuredClone;

// TC39 Explicit Resource Management polyfill for `using` keyword
(g.Symbol as any).dispose ??= Symbol.for('Symbol.dispose');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Symbol.for('Symbol.dispose') creates a global registry symbol which is not the same as the native well-known Symbol.dispose. The ??= guard means this only applies when the runtime lacks native support, which is fine. But if this polyfill is used elsewhere (outside the VM sandbox), code comparing against the native Symbol.dispose won't match. The dual-registration in hook.ts:235-238 handles this correctly. Just flagging in case other code needs to be aware of the distinction.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good callout. The Symbol.for('Symbol.dispose') polyfill is scoped to the VM sandbox context only — it's set on vmGlobalThis.Symbol.dispose, not the host's Symbol. The dual-registration in hook.ts (host's Symbol.dispose + VM's Symbol.dispose when they differ) handles the mismatch. No other code outside the VM needs to compare against this polyfilled symbol.

@@ -0,0 +1,6 @@
---
"@workflow/core": patch

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: Since this adds a new public API method (dispose()) and extends the Hook interface with Disposable, this could arguably be a minor bump for @workflow/core rather than patch. Up to you and your versioning conventions.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

keeping patch to be in line with our pre-beta conventions

pranaygpand others added 2 commits February 27, 2026 12:04
When dispose() is called while a promise is pending (e.g., iterator
suspended on yield await this, or direct await hook after dispose),
the promise would hang forever since the event consumer will never
deliver another hook_received. Now disposeHook() clears the promises
array and triggers a WorkflowSuspension so the runtime processes the
disposal cleanly.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@pranaygp

Copy link
Copy Markdown
ContributorAuthor

@VaguelySerious Thanks for the review! Addressing your two points:

  1. Hanging promise — Fixed in ca9ca85. disposeHook() now drains the promises array and triggers a WorkflowSuspension so there are no orphaned promises. This also addresses @TooTallNate's comment about dispose() called while the iterator is suspended.

  2. buildWorkflowSuspensionMessage missing hookDisposedCount — Good catch, the debug log won't mention hook disposals. Will address in a follow-up since it's cosmetic (debug-level only) and the WorkflowSuspension error message itself already includes disposals correctly.

Sign up for freeto 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.

How to dispose of hooks

5 participants

@pranaygp@TooTallNate@VaguelySerious@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(core): add hook.dispose() method to release hook tokens early by pranaygp · Pull Request #1181 · vercel/workflow · GitHub
Skip to content

feat(core): add hook.dispose() method to release hook tokens early - #1181

Merged
pranaygp merged 17 commits into
mainfrom
claude/slack-expose-hook-dispose-fU7QG
Feb 27, 2026
Merged

feat(core): add hook.dispose() method to release hook tokens early#1181
pranaygp merged 17 commits into
mainfrom
claude/slack-expose-hook-dispose-fU7QG

Conversation

@pranaygp

Copy link
Copy Markdown
Contributor

Add a dispose() method to the Hook interface that allows workflows to
explicitly release hook tokens for reuse by other workflows while the
current workflow is still running. This enables handoff patterns where
one workflow can transfer a hook token to another workflow.

  • Add dispose() method to Hook interface in create-hook.ts
  • Implement dispose functionality in workflow/hook.ts
  • Add HookDisposedInvocationQueueItem to global.ts
  • Handle hook_disposed events in suspension-handler.ts
  • Update documentation in hooks.mdx and create-hook.mdx
  • Add e2e test for hook token reuse after explicit disposal

https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH

CopilotAI review requested due to automatic review settings February 24, 2026 17:37
@pranaygp
pranaygp requested a review from a team as a code ownerFebruary 24, 2026 17:37
@vercel

vercelBot commented Feb 24, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Feb 24, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c697597

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 14 packages
NameType
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/web-sharedPatch
workflowPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

@github-actions

github-actionsBot commented Feb 24, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express0.032s (~)1.005s (~)0.972s101.00x
💻 LocalNitro0.032s (-8.5% 🟢)1.005s (~)0.973s101.00x
💻 LocalNext.js (Turbopack)0.041s1.005s0.963s101.28x
🌐 RedisNext.js (Turbopack)0.046s1.005s0.959s101.42x
🐘 PostgresNitro0.057s (+2.7%)1.011s (~)0.954s101.76x
🐘 PostgresExpress0.059s (+9.5% 🔺)1.011s (~)0.952s101.83x
🌐 MongoDBNext.js (Turbopack)0.092s1.008s0.915s102.85x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)0.603s (-12.6% 🟢)1.929s (-23.6% 🟢)1.326s101.00x
▲ VercelNitro0.707s (+4.3%)3.432s (+59.5% 🔺)2.725s101.17x
▲ VercelExpress0.905s (+32.7% 🔺)2.149s (+16.5% 🔺)1.244s101.50x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)1.103s2.005s0.903s101.00x
🌐 RedisNext.js (Turbopack)1.104s2.006s0.902s101.00x
💻 LocalNitro1.106s (~)2.006s (~)0.900s101.00x
💻 LocalExpress1.107s (~)2.006s (~)0.899s101.00x
🐘 PostgresExpress1.141s (+2.1%)2.010s (~)0.869s101.04x
🐘 PostgresNitro1.145s (+1.0%)2.024s (+0.6%)0.879s101.04x
🌐 MongoDBNext.js (Turbopack)1.312s2.009s0.697s101.19x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.163s (-7.9% 🟢)3.432s (-3.4%)1.269s101.00x
▲ VercelExpress2.220s (-7.3% 🟢)3.157s (-4.1%)0.937s101.03x
▲ VercelNitro2.225s (-2.3%)3.408s (-2.5%)1.183s101.03x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)10.700s11.022s0.322s31.00x
💻 LocalNext.js (Turbopack)10.719s11.021s0.303s31.00x
🐘 PostgresExpress10.827s (~)11.039s (~)0.211s31.01x
💻 LocalExpress10.832s (~)11.021s (~)0.190s31.01x
💻 LocalNitro10.843s (~)11.023s (~)0.180s31.01x
🐘 PostgresNitro10.914s (~)11.045s (~)0.131s31.02x
🌐 MongoDBNext.js (Turbopack)12.321s13.026s0.706s31.15x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express16.796s (-6.5% 🟢)17.576s (-8.7% 🟢)0.780s21.00x
▲ VercelNitro17.206s (-1.0%)18.757s (+1.3%)1.552s21.02x
▲ VercelNext.js (Turbopack)17.474s (-4.2%)18.688s (-3.6%)1.214s21.04x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)26.976s27.051s0.075s31.00x
💻 LocalNext.js (Turbopack)27.189s28.050s0.861s31.01x
🐘 PostgresExpress27.239s (~)28.058s (~)0.819s31.01x
🐘 PostgresNitro27.382s (~)28.066s (~)0.684s31.02x
💻 LocalNitro27.507s (~)28.051s (~)0.545s31.02x
💻 LocalExpress27.514s (~)28.051s (~)0.538s31.02x
🌐 MongoDBNext.js (Turbopack)30.628s31.056s0.427s21.14x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express43.906s (-0.7%)45.004s (~)1.098s21.00x
▲ VercelNitro45.876s (+0.8%)46.679s (+0.6%)0.803s21.04x
▲ VercelNext.js (Turbopack)46.913s (+2.9%)48.182s (+2.6%)1.269s21.07x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)54.422s55.098s0.677s21.00x
🐘 PostgresExpress54.990s (~)55.094s (-0.9%)0.104s21.01x
🐘 PostgresNitro55.233s (~)56.111s (+1.8%)0.878s21.01x
💻 LocalNext.js (Turbopack)56.719s57.099s0.380s21.04x
💻 LocalExpress57.330s (~)58.103s (~)0.774s21.05x
💻 LocalNitro57.367s (~)58.103s (~)0.736s21.05x
🌐 MongoDBNext.js (Turbopack)61.233s62.075s0.842s21.13x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro97.920s (-6.3% 🟢)99.368s (-5.5% 🟢)1.448s11.00x
▲ VercelExpress101.973s (+1.2%)103.023s (+0.8%)1.050s11.04x
▲ VercelNext.js (Turbopack)104.390s (+5.2% 🔺)105.833s (+5.9% 🔺)1.443s11.07x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.249s2.006s0.757s151.00x
🐘 PostgresExpress1.373s (+0.9%)2.009s (~)0.637s151.10x
🐘 PostgresNitro1.373s (+0.7%)2.011s (~)0.638s151.10x
💻 LocalNext.js (Turbopack)1.394s2.005s0.611s151.12x
💻 LocalExpress1.403s (-1.0%)2.005s (~)0.602s151.12x
💻 LocalNitro1.439s (~)2.006s (~)0.567s151.15x
🌐 MongoDBNext.js (Turbopack)2.147s3.008s0.861s101.72x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.394s (+0.7%)3.115s (+1.0%)0.721s101.00x
▲ VercelNitro2.396s (-9.0% 🟢)3.618s (-11.4% 🟢)1.222s91.00x
▲ VercelNext.js (Turbopack)2.670s (-12.0% 🟢)3.779s (-9.5% 🟢)1.109s81.12x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro2.009s (-3.7%)2.516s (-6.3% 🟢)0.508s121.00x
🐘 PostgresExpress2.037s (+0.8%)2.515s (~)0.478s121.01x
🌐 RedisNext.js (Turbopack)2.492s3.008s0.516s101.24x
💻 LocalNext.js (Turbopack)2.545s3.007s0.461s101.27x
💻 LocalExpress2.582s (-2.1%)3.007s (~)0.425s101.29x
💻 LocalNitro2.631s (-1.3%)3.008s (~)0.377s101.31x
🌐 MongoDBNext.js (Turbopack)4.817s5.344s0.527s62.40x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.689s (-0.5%)3.543s (-1.6%)0.854s91.00x
▲ VercelNitro2.939s (+6.8% 🔺)3.928s (+4.6%)0.989s81.09x
▲ VercelNext.js (Turbopack)2.964s (-12.1% 🟢)4.175s (-8.1% 🟢)1.212s81.10x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro3.593s (~)4.023s (-8.7% 🟢)0.430s81.00x
🐘 PostgresExpress3.824s (+18.9% 🔺)4.451s (+10.7% 🔺)0.627s71.06x
🌐 RedisNext.js (Turbopack)4.053s4.725s0.673s71.13x
💻 LocalNext.js (Turbopack)6.910s7.515s0.605s41.92x
💻 LocalExpress7.497s (~)8.018s (~)0.521s42.09x
💻 LocalNitro7.524s (-2.0%)8.016s (~)0.492s42.09x
🌐 MongoDBNext.js (Turbopack)9.919s10.682s0.763s32.76x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.118s (-4.9%)4.378s (+1.9%)1.260s71.00x
▲ VercelNitro3.185s (-7.3% 🟢)4.244s (-8.2% 🟢)1.059s81.02x
▲ VercelExpress3.993s (-12.7% 🟢)5.046s (-11.2% 🟢)1.053s61.28x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.235s2.006s0.771s151.00x
🐘 PostgresExpress1.369s (-0.8%)2.011s (~)0.642s151.11x
🐘 PostgresNitro1.375s (-2.7%)2.010s (~)0.635s151.11x
💻 LocalNext.js (Turbopack)1.394s2.006s0.611s151.13x
💻 LocalNitro1.440s (-1.3%)2.005s (~)0.565s151.17x
💻 LocalExpress1.456s (+3.2%)2.005s (~)0.548s151.18x
🌐 MongoDBNext.js (Turbopack)2.172s3.008s0.836s101.76x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.248s (+5.4% 🔺)3.236s (+6.6% 🔺)0.988s101.00x
▲ VercelNext.js (Turbopack)2.392s (+9.3% 🔺)3.657s (+5.0%)1.265s91.06x
▲ VercelNitro2.430s (+15.6% 🔺)3.559s (+5.3% 🔺)1.128s91.08x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro2.003s (-1.8%)2.596s (+4.9%)0.593s121.00x
🐘 PostgresExpress2.056s (+6.4% 🔺)2.682s (+3.3%)0.626s121.03x
🌐 RedisNext.js (Turbopack)2.523s3.008s0.485s101.26x
💻 LocalNext.js (Turbopack)2.628s3.008s0.380s101.31x
💻 LocalExpress2.690s (-1.7%)3.008s (~)0.318s101.34x
💻 LocalNitro2.710s (-2.3%)3.007s (~)0.297s101.35x
🌐 MongoDBNext.js (Turbopack)4.648s5.177s0.529s62.32x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.640s (-16.2% 🟢)3.548s (-15.4% 🟢)0.907s91.00x
▲ VercelExpress2.747s (+3.4%)3.582s (~)0.835s101.04x
▲ VercelNitro2.867s (-5.4% 🟢)3.831s (-15.7% 🟢)0.964s81.09x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express3.288s (-13.9% 🟢)3.893s (-9.8% 🟢)0.606s81.00x
🐘 PostgresNitro3.670s (+8.9% 🔺)4.268s (+2.9%)0.598s81.12x
🌐 RedisNext.js (Turbopack)4.111s4.868s0.758s71.25x
💻 LocalNext.js (Turbopack)7.432s7.766s0.334s42.26x
💻 LocalNitro7.877s (-5.4% 🟢)8.272s (-8.3% 🟢)0.395s42.40x
💻 LocalExpress7.888s (-3.9%)8.269s (-8.3% 🟢)0.381s42.40x
🌐 MongoDBNext.js (Turbopack)10.035s10.684s0.649s33.05x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.008s (~)4.207s (+3.0%)1.199s81.00x
▲ VercelExpress3.183s (-18.5% 🟢)4.339s (-17.2% 🟢)1.156s71.06x
▲ VercelNitro3.477s (+13.2% 🔺)4.833s (+12.0% 🔺)1.356s71.16x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)0.143s1.001s0.011s1.016s0.873s101.00x
🌐 RedisNext.js (Turbopack)0.154s1.000s0.001s1.007s0.853s101.08x
💻 LocalNitro0.174s (-6.2% 🟢)1.003s (~)0.011s (-5.9% 🟢)1.017s (~)0.842s101.22x
💻 LocalExpress0.175s (+2.6%)1.002s (~)0.011s (-1.8%)1.016s (~)0.841s101.23x
🐘 PostgresExpress0.200s (+5.5% 🔺)0.992s (~)0.002s (+13.3% 🔺)1.011s (~)0.812s101.40x
🐘 PostgresNitro0.202s (+2.5%)0.993s (~)0.002s (-5.6% 🟢)1.012s (~)0.809s101.42x
🌐 MongoDBNext.js (Turbopack)0.489s0.962s0.002s1.009s0.520s103.43x
🐘 PostgresNext.js (Turbopack)⚠️missing-----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)1.712s (-25.3% 🟢)2.412s (-17.3% 🟢)0.087s (-37.2% 🟢)33.064s (-1.7%)31.352s101.00x
▲ VercelNitro1.782s (-1.1%)2.391s (+0.9%)0.197s (+25.7% 🔺)3.132s (+1.4%)1.349s101.04x
▲ VercelExpress1.971s (+27.2% 🔺)2.591s (+13.9% 🔺)0.161s (-7.4% 🟢)33.170s (+1056.0% 🔺)31.198s101.15x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNext.js (Turbopack)11/12
🐘 PostgresExpress8/12
▲ VercelNext.js (Turbopack)6/12
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express🐘 Postgres8/12
Next.js (Turbopack)🌐 Redis7/12
Nitro🐘 Postgres6/12
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)

📋 View full workflow run

@github-actions

github-actionsBot commented Feb 24, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production534049583
✅ 💻 Local Development568068636
✅ 📦 Local Production568068636
❌ 🐘 Local Postgres567168636
✅ 🪟 Windows500353
❌ 🌍 Community Worlds113469168
✅ 📋 Other138021159
Total2538472862871

❌ Failed Tests

🐘 Local Postgres (1 failed)

nextjs-turbopack-stable (1 failed):

  • webhookWorkflow
🌍 Community Worlds (46 failed)

turso (46 failed):

  • addTenWorkflow
  • addTenWorkflow
  • should work with react rendering in step
  • promiseAllWorkflow
  • promiseRaceWorkflow
  • promiseAnyWorkflow
  • hookWorkflow
  • webhookWorkflow
  • sleepingWorkflow
  • parallelSleepWorkflow
  • nullByteWorkflow
  • workflowAndStepMetadataWorkflow
  • fetchWorkflow
  • promiseRaceStressTestWorkflow
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior FatalError fails immediately without retries
  • error handling retry behavior RetryableError respects custom retryAfter delay
  • error handling retry behavior maxRetries=0 disables retries
  • error handling retry behavior workflow completes despite transient 5xx on step_completed
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously
  • hookDisposeTestWorkflow - hook token reuse after explicit disposal while workflow still running
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument
  • closureVariableWorkflow - nested step functions with closure variables
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step
  • health check (queue-based) - workflow and step endpoints respond to health check messages
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • Calculator.calculate - static workflow method using static step methods from another class
  • AllInOneService.processNumber - static workflow method using sibling static step methods
  • ChainableService.processWithThis - static step methods using this to reference the class
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE
  • instanceMethodStepWorkflow - instance methods with "use step" directive
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context
  • stepFunctionAsStartArgWorkflow - step function reference passed as start() argument
  • cancelRun - cancelling a running workflow
  • cancelRun via CLI - cancelling a running workflow
  • pages router addTenWorkflow via pages router
  • pages router promiseAllWorkflow via pages router
  • pages router sleepingWorkflow via pages router

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro4805
✅ example4805
✅ express4805
✅ fastify4805
✅ hono4805
✅ nextjs-turbopack5102
✅ nextjs-webpack5102
✅ nitro4805
✅ nuxt4805
✅ sveltekit4805
✅ vite4805
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable4607
✅ express-stable4607
✅ fastify-stable4607
✅ hono-stable4607
✅ nextjs-turbopack-canary5003
✅ nextjs-turbopack-stable5003
✅ nextjs-webpack-canary5003
✅ nextjs-webpack-stable5003
✅ nitro-stable4607
✅ nuxt-stable4607
✅ sveltekit-stable4607
✅ vite-stable4607
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable4607
✅ express-stable4607
✅ fastify-stable4607
✅ hono-stable4607
✅ nextjs-turbopack-canary5003
✅ nextjs-turbopack-stable5003
✅ nextjs-webpack-canary5003
✅ nextjs-webpack-stable5003
✅ nitro-stable4607
✅ nuxt-stable4607
✅ sveltekit-stable4607
✅ vite-stable4607
❌ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable4607
✅ express-stable4607
✅ fastify-stable4607
✅ hono-stable4607
✅ nextjs-turbopack-canary5003
❌ nextjs-turbopack-stable4913
✅ nextjs-webpack-canary5003
✅ nextjs-webpack-stable5003
✅ nitro-stable4607
✅ nuxt-stable4607
✅ sveltekit-stable4607
✅ vite-stable4607
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack5003
❌ 🌍 Community Worlds
AppPassedFailedSkipped
✅ mongodb-dev300
✅ mongodb5003
✅ redis-dev300
✅ redis5003
✅ turso-dev300
❌ turso4463
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable4607
✅ e2e-local-postgres-nest-stable4607
✅ e2e-local-prod-nest-stable4607

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: success
  • Local Dev: success
  • Local Prod: success
  • Local Postgres: failure
  • Windows: success

Check the workflow run for details.

Comment threaddocs/content/docs/api-reference/workflow/create-hook.mdx Outdated
Comment threaddocs/content/docs/api-reference/workflow/create-hook.mdx Outdated
Comment threaddocs/content/docs/api-reference/workflow/create-hook.mdx
Comment threaddocs/content/docs/api-reference/workflow/create-hook.mdx Outdated
Comment threaddocs/content/docs/api-reference/workflow/create-webhook.mdx Outdated
Comment threaddocs/content/docs/api-reference/workflow/create-hook.mdx Outdated

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request adds a dispose() method to hooks and webhooks that allows workflows to explicitly release hook tokens for reuse by other workflows while the current workflow is still running. This enables handoff patterns where one workflow can transfer a hook token to another workflow.

Changes:

  • Added dispose() method and Symbol.dispose to Hook interface for TC39 Explicit Resource Management support
  • Implemented disposal logic in workflow/hook.ts with isDisposed tracking and async iterator termination
  • Added HookDisposedInvocationQueueItem type and handling in suspension-handler.ts to create hook_disposed events
  • Updated documentation to recommend using keyword for automatic cleanup and added examples of early disposal patterns
  • Added comprehensive e2e test demonstrating token reuse after explicit disposal while workflow still running
  • Fixed missing import in create-webhook.mdx documentation

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.

Show a summary per file
FileDescription
packages/core/src/create-hook.tsAdded dispose() and Symbol.dispose methods to Hook interface with documentation
packages/core/src/workflow/hook.tsImplemented disposeHook() function, isDisposed flag, and async iterator termination logic
packages/core/src/global.tsAdded HookDisposedInvocationQueueItem type and updated WorkflowSuspension to count hook disposals
packages/core/src/runtime/suspension-handler.tsAdded processing of hook_disposed queue items with error handling for 404/410 statuses
packages/core/e2e/e2e.test.tsAdded comprehensive test for hook token reuse after explicit disposal while workflow running
workbench/example/workflows/99_e2e.tsUpdated examples to use using keyword and added hookDisposeTestWorkflow demonstrating disposal
workbench/example/workflows/5_hooks.tsUpdated to use using keyword for automatic disposal
docs/content/docs/foundations/hooks.mdxAdded section on disposing hooks early with examples, updated all examples to use using
docs/content/docs/api-reference/workflow/create-hook.mdxAdded disposal documentation and examples, updated to use using throughout
docs/content/docs/api-reference/workflow/create-webhook.mdxAdded TC39 note, fixed missing import, updated examples to use using
.changeset/smart-cougars-talk.mdAdded changeset documenting the new feature

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 138 to 180
@@ -176,6 +179,22 @@ export function createCreateHook(ctx: WorkflowOrchestratorContext) {
return resolvers.promise;
}

CopilotAIFeb 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The createHookPromise function doesn't check if the hook has been disposed. While the async iterator correctly stops when isDisposed is true, directly awaiting a disposed hook (e.g., const result = await hook after hook.dispose()) will still attempt to create a promise and could cause unexpected behavior. Consider adding an isDisposed check that either throws an error or returns a rejected promise to make the behavior more predictable and easier to debug.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not adding an isDisposed check to createHookPromise(). The async iterator already checks !isDisposed before each yield, so the iterator pattern is safe. For direct await hook after dispose(), the behavior is the same as awaiting a hook that was never resumed — the promise hangs until the workflow suspends or completes. This is consistent with how the rest of the framework works (unawaited operations are silently ignored on completion). In the stacked PR #1185 we now log a warning when the workflow completes with uncommitted operations, which covers this case.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

human: hm I'm not sure claude understood the issue reported here. this explanation doesn't make sense to me ^

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, the previous explanation was off. The actual concern from Copilot was: if you call await hook after hook.dispose(), createHookPromise() doesn't check isDisposed so it still creates a promise and pushes it to the promises array.

In practice this is fine because:

  1. If the event log is empty (first run), the dispose already set disposed=true on the queue item, and the subsequent await triggers a WorkflowSuspension which processes both creation and disposal
  2. If the event log has hook_disposed (replay), the item is already deleted from the queue and hasDisposedEvent=true, so disposeHook() was a no-op — the await creates a dangling promise but the workflow completes/suspends on the next step

We have a test for case 1: "should suspend when awaiting a disposed hook on first invocation". Adding an isDisposed check to reject immediately would change the semantics (throwing instead of suspending), which could break workflows that call dispose() then have other work to do before their next suspension point.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@TooTallNate@VaguelySerious any thoughts on this behaviour?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation has changed significantly since this comment. disposeHook() no longer adds a separate queue item — it sets a disposed flag on the existing hook queue item, and on replay it checks hasDisposedEvent to no-op entirely.

Re: the createHookPromise not checking isDisposed — this is intentional. If user code calls dispose() then await hook, the promise enters the promises array. If the event log is empty, the next event-consumer pass triggers a WorkflowSuspension with the disposed hook item, which processes both creation and disposal. If the log has hook_disposed, the item is already removed from the queue so the suspension is empty or only contains other items. Either way it resolves correctly without needing an explicit isDisposed rejection in createHookPromise.

Comment threaddocs/content/docs/foundations/hooks.mdx
Comment threadpackages/core/src/workflow/hook.ts
Comment threadpackages/core/src/runtime/suspension-handler.ts
Comment threadpackages/core/src/workflow/hook.ts Outdated
(hook as any)[vmDispose] = disposeHook;
}

return hook as unknown as Hook<T>;

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hook as unknown as Hook<T> smells like a bug and breaks type safety. avoid this please

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 303b08e — added [Symbol.dispose]: disposeHook directly to the object literal so it satisfies Hook<T> without the double cast. The VM's Symbol.dispose is still set separately only when it differs from the host's.

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 122 to +126
if (event.eventType === 'hook_disposed') {
// If a hook is explicitly disposed, we're done processing any more
// events for it
// Terminal state - remove from queue (like step_completed/wait_completed)
ctx.invocationsQueue.delete(correlationId);
// Mark that the event log confirms disposal happened
hasDisposedEvent = true;

CopilotAIFeb 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a hook_disposed event is replayed, the hook remains in ctx.invocationsQueue (and may still have disposed=true). That means later suspensions can keep trying to emit hook_disposed again, which will reliably 404 in worlds like world-local (it deletes the hook entity on disposal) and can lead to repeated API calls/log spam. Consider deleting this hook’s queue item here (similar to wait_completed / step_completed) once disposal is confirmed in the event log.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we do delete the queue item now

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was fixed in 9f9c739hook_disposed now deletes the item from ctx.invocationsQueue (terminal state, matching step_completed/wait_completed). The hasDisposedEvent closure flag prevents disposeHook() from re-adding anything on replay.

Comment on lines +35 to +40
* Disposes the hook, releasing its token for reuse by other workflows.
*
* After calling `dispose()`, the hook will no longer receive any events.
* This is useful when you want to explicitly release a hook token before
* the workflow completes, allowing another workflow to register a hook
* with the same token.

CopilotAIFeb 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docs here imply that calling dispose() immediately stops the hook from receiving events and releases the token right away. In this implementation, dispose() only marks the queue item and the hook_disposed event/token release won’t happen until the workflow hits the next suspension point (or until the run reaches a terminal state). Consider clarifying that disposal is processed on the next suspension (e.g., await sleep() / await step()) to avoid confusing handoff patterns.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

small nuance

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSDoc describes the user-facing contract: after dispose(), the hook stops receiving events and the token becomes available. The internal mechanics (queue flag → suspension handler → server event) are an implementation detail. This is consistent with how sleep() and step functions are documented — they describe what happens, not when the server processes it.

Comment threaddocs/content/docs/foundations/hooks.mdx
Comment on lines +138 to +166
### Disposing Hooks Early

You can dispose a hook early to release its token for reuse by another workflow. This is useful for handoff patterns where one workflow needs to transfer a hook token to another workflow while still running.

```typescript lineNumbers
import { createHook } from "workflow"

export async function handoffWorkflow(channelId: string) {
"use workflow";

const hook = createHook<{ message: string; handoff?: boolean }>({
token: `channel:${channelId}`
});

for await (const payload of hook) {
console.log("Received:", payload.message);

if (payload.handoff) {
hook.dispose(); // [!code highlight] Release the token for another workflow
break;
}
}

// Continue with other work while another workflow uses the token
}
```

After calling `dispose()`, the hook will no longer receive events and its token becomes available for other workflows to use.

CopilotAIFeb 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The narrative here implies token reuse becomes available immediately after hook.dispose(). In practice, the disposal is emitted/processed on the next workflow suspension (or at run completion), so a handoff workflow typically needs an await (step/sleep/etc.) after disposing to make reuse observable while the workflow continues. Consider documenting that timing explicitly (or showing it in the example) to prevent confusing handoff behavior.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that nuance is fine to ignore since "the next suspension point" is always a few milliseconds away from a disposal (the workflow runtime is expected to be fast) and is not critical to document for usage of hook and disposal

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nvm I included it elsewhere in the hook docs

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same answer — the disposal is processed at the next suspension point, which for any practical workflow happens immediately (the next await triggers suspension). The docs are describing the user-facing contract, not the internal mechanics.

Comment thread.changeset/smart-cougars-talk.md Outdated
@@ -0,0 +1,6 @@
---
"@workflow/core": patch
"workflow": patch

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove "workflow" from the changelog. enough to just bump the core package

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c697597 — removed workflow from the changeset.

claudeand others added 13 commits February 26, 2026 21:32
Add a `dispose()` method to the Hook interface that allows workflows to
explicitly release hook tokens for reuse by other workflows while the
current workflow is still running. This enables handoff patterns where
one workflow can transfer a hook token to another workflow.
- Add `dispose()` method to Hook interface in create-hook.ts
- Implement dispose functionality in workflow/hook.ts
- Add HookDisposedInvocationQueueItem to global.ts
- Handle hook_disposed events in suspension-handler.ts
- Update documentation in hooks.mdx and create-hook.mdx
- Add e2e test for hook token reuse after explicit disposal
https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH
Add Symbol.dispose to Hook interface to support the TC39 Explicit Resource
Management proposal. This allows hooks to be used with the `using` keyword
for automatic disposal when exiting scope.
https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH
Update all documentation and e2e tests to use the `using` keyword as the
recommended approach for creating hooks and webhooks. This leverages the
TC39 Explicit Resource Management proposal for automatic disposal.
- Update e2e tests to use `using` syntax
- Update foundational hooks guide to recommend `using`
- Update create-hook API reference with `using` examples
- Update create-webhook API reference with `using` examples
- Update example workflow to use `using`
https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH
Remove unnecessary block scopes and excessive comments about automatic
disposal. Block scopes are only used when early disposal is relevant
(like in the handoff test).
https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH
Add a one-liner explaining the `using` keyword in the intro examples
of the API reference docs, so new users understand the syntax.
https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH
- Restore code highlights (`[!code highlight]`) that were unintentionally
removed from pre-existing doc examples
- Move `using` explanation from prose to inline code comment in intro
examples
- Add `{/* @skip-typecheck */}` to incomplete manual dispose() snippet
- Add 409 (conflict/duplicate) error handling for hook_disposed events
in suspension handler to handle workflow re-invocation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The workflow VM sandbox doesn't have Symbol.dispose/Symbol.asyncDispose
available, causing `using` keyword to fail with "Symbol.dispose is not
defined" at runtime. Add polyfill in the VM context creation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The workflow VM sandbox has its own Symbol object with a polyfilled
Symbol.dispose. The hook object was using the host's Symbol.dispose,
which is a different symbol instance. The SWC-compiled `using` keyword
looks up the VM's Symbol.dispose on the object, causing "Object not
disposable" errors.
Fix by setting Symbol.dispose on the hook object dynamically using the
VM's globalThis.Symbol.dispose from the orchestrator context.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The webhookWorkflow e2e test creates 3 webhooks that must all exist
before the test sends HTTP requests. Using `using` with sequential
creation meant only the first webhook existed at the first suspension
point. Revert to `const` since all webhooks need to be created upfront.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Instead of adding a separate HookDisposedInvocationQueueItem to the
queue on dispose, keep the HookInvocationQueueItem throughout the
hook lifecycle and track state with flags (hasCreatedEvent, disposed).
A closure variable (hasDisposedEvent) makes disposeHook() a pure no-op
on replay, avoiding redundant server calls and 409 errors.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Match the pattern used by steps (step_completed/step_failed) and waits
(wait_completed) where the queue item is removed on the terminal event.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- WorkflowSuspension counts disposed hooks separately from active hooks
- Dispose after hook_created replay produces correct suspension
- Dispose before first suspension (needs both create + dispose)
- Multiple hooks where only one is disposed
- Dispose on a conflicted hook is safe (no crash)
- Symbol.dispose calls disposeHook correctly (using keyword pattern)
- Iterator break without dispose keeps hook alive in queue
- Await after dispose on first invocation triggers suspension
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +172 to +198
### Disposing Hooks Early

When a workflow ends, hooks are automatically disposed. However, you may want to release a hook token early so another workflow can use it while your workflow continues running. Use a block scope with `using` to control when disposal happens:

```typescript lineNumbers
import { createHook } from "workflow";

export async function handoffWorkflow(channelId: string) {
"use workflow";

{
using hook = createHook<{ message: string; handoff?: boolean }>({
token: `channel:${channelId}`
});

for await (const payload of hook) {
console.log("Received:", payload.message);

if (payload.handoff) {
break;
}
}
} // Hook token released here

// Token is now available for another workflow
console.log("Continuing with other work...");
}

CopilotAIFeb 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section implies that exiting the using scope (or calling dispose()) immediately releases the token for another workflow. In the current design, the hook_disposed event is only emitted when the workflow next suspends (e.g., await sleep(), await step(), etc.), otherwise the token won’t be reusable until the run completes. Please document that a suspension point is required for early reuse, and ideally show one in the example.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docs are accurate for the user-facing mental model. The token is released when the workflow next suspends (which happens immediately after the using block exits if there's any subsequent await step() or await sleep()). For the edge case of dispose at function scope with no further suspension points, the backend auto-disposes all hooks when the run reaches a terminal state, so the token is still released.

Comment threadpackages/core/src/global.ts
Comment threadpackages/core/src/workflow/hook.test.ts
Comment threadpackages/core/src/create-hook.ts Outdated
Add [Symbol.dispose] directly to the hook object so it satisfies the
Hook<T> type without `as unknown as`. The VM's Symbol.dispose is still
added separately when it differs from the host's.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, two main comments from a claude simulation:

  1. When hook_disposed is replayed, the event consumer returns Finished, unsubscribing the callback. If the workflow later does await hook after the consumer is gone, createHookPromise() will push to promises[] but nobody will ever resolve it. However, this is actually fine, since a workflow that awaits a hook after disposing it is a user error, and the workflow will simply hang (which is the expected behavior for an unresolvable promise in the event-sourced model, so it would eventually suspend on the next trigger).
  2. runtime.ts suspension message doesn't include hookDisposedCount, see const suspensionMessage = buildWorkflowSuspensionMessage(.... The debug log message doesn't mention hook disposals. This is cosmetic (debug-level only) but could be confusing when debugging. Worth a follow-up to add hookDisposedCount to the message builder.

- Rename test to match new behavior (hooks stay in queue, not removed)
- Use neutral "processed" verb in WorkflowSuspension message when
mixed item types are present
- Remove extends Disposable from Hook interface to avoid requiring
lib.esnext.disposable in downstream consumers (explicit
[Symbol.dispose]() method is still declared on the interface)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well-implemented feature with solid test coverage and thorough documentation. The core design of tracking hasCreatedEvent and disposed flags on queue items, and splitting hook processing in the suspension handler, is clean and fits well into the existing event-sourcing architecture.

A few inline comments — mostly minor suggestions and one behavioral note about the iterator. Nothing blocking.

yield await this;
}
},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: If dispose() is called externally while the iterator is suspended on yield await this, the pending promise will never resolve/reject — the !isDisposed check only runs after the yield returns. This means the iterator won't terminate until the next payload arrives (at which point the loop condition catches it).

This is probably fine in practice since dispose() is typically called inside the for await body after receiving a payload, but worth a comment here noting the limitation.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ca9ca85disposeHook() now drains the promises array and triggers a WorkflowSuspension when there are pending promises. This ensures no orphaned awaits: the suspension handler processes the disposal, and the workflow execution is unwound cleanly.

This also addresses @VaguelySerious's point 1 from his review — the promise is no longer left hanging.

const vmDispose = ctx.globalThis.Symbol.dispose;
if (vmDispose && vmDispose !== Symbol.dispose) {
(hook as any)[vmDispose] = disposeHook;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The as unknown as Hook<T> cast is needed because the host's [Symbol.dispose] is intentionally not set on the object literal (only the VM's symbol is). This is correct for runtime behavior, but it means the host-side Symbol.dispose is never present on the object. Might be worth a brief comment explaining why the cast is safe (the hook is only used inside the VM sandbox where the VM's Symbol.dispose is what matters).

@pranaygppranaygpFeb 27, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed already. that cast is not needed

if (hooksNeedingDisposal.length > 0) {
await Promise.all(
hooksNeedingDisposal.map(async (queueItem) => {
const hookDisposedEvent: CreateEventRequest = {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: The hookDisposedEvent doesn't include an eventData field, while other event types (e.g., hook_created, step_created) typically do. Is that intentional? If the world implementation expects eventData to always be present, this could be an issue. If it's optional, this is fine — just flagging for awareness.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional — hook_disposed is a terminal event that just deletes the hook entity. The CreateEventRequest type has eventData as optional. The world implementations (local, postgres, vercel) all handle missing eventData fine for disposal events.

);
// Hooks needing disposal: any disposed hook (including those needing creation first)
// Hooks are created before disposal in the processing order below
const hooksNeedingDisposal = allHookItems.filter((item) => item.disposed);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment on lines 85-86 is helpful. To be explicit: when hasCreatedEvent is falsy and disposed is true, the hook appears in bothhooksNeedingCreation and hooksNeedingDisposal. Since creation runs before disposal (lines 122-193), this ordering is correct — the hook must exist before it can be disposed. Just want to confirm this two-step behavior for a single queue item is tested. (It is — the e2e test covers this path when dispose() is called before any suspension.)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct — this is tested in "should produce correct WorkflowSuspension for dispose before first suspension" which verifies the flags (hasCreatedEvent=undefined, disposed=true), and the suspension handler test coverage in the edge cases table confirms both hooksNeedingCreation and hooksNeedingDisposal include it.

g.structuredClone = globalThis.structuredClone;

// TC39 Explicit Resource Management polyfill for `using` keyword
(g.Symbol as any).dispose ??= Symbol.for('Symbol.dispose');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Symbol.for('Symbol.dispose') creates a global registry symbol which is not the same as the native well-known Symbol.dispose. The ??= guard means this only applies when the runtime lacks native support, which is fine. But if this polyfill is used elsewhere (outside the VM sandbox), code comparing against the native Symbol.dispose won't match. The dual-registration in hook.ts:235-238 handles this correctly. Just flagging in case other code needs to be aware of the distinction.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good callout. The Symbol.for('Symbol.dispose') polyfill is scoped to the VM sandbox context only — it's set on vmGlobalThis.Symbol.dispose, not the host's Symbol. The dual-registration in hook.ts (host's Symbol.dispose + VM's Symbol.dispose when they differ) handles the mismatch. No other code outside the VM needs to compare against this polyfilled symbol.

@@ -0,0 +1,6 @@
---
"@workflow/core": patch

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: Since this adds a new public API method (dispose()) and extends the Hook interface with Disposable, this could arguably be a minor bump for @workflow/core rather than patch. Up to you and your versioning conventions.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

keeping patch to be in line with our pre-beta conventions

pranaygpand others added 2 commits February 27, 2026 12:04
When dispose() is called while a promise is pending (e.g., iterator
suspended on yield await this, or direct await hook after dispose),
the promise would hang forever since the event consumer will never
deliver another hook_received. Now disposeHook() clears the promises
array and triggers a WorkflowSuspension so the runtime processes the
disposal cleanly.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@pranaygp

Copy link
Copy Markdown
ContributorAuthor

@VaguelySerious Thanks for the review! Addressing your two points:

  1. Hanging promise — Fixed in ca9ca85. disposeHook() now drains the promises array and triggers a WorkflowSuspension so there are no orphaned promises. This also addresses @TooTallNate's comment about dispose() called while the iterator is suspended.

  2. buildWorkflowSuspensionMessage missing hookDisposedCount — Good catch, the debug log won't mention hook disposals. Will address in a follow-up since it's cosmetic (debug-level only) and the WorkflowSuspension error message itself already includes disposals correctly.

Sign up for freeto 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.

How to dispose of hooks

5 participants

@pranaygp@TooTallNate@VaguelySerious@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(core): add hook.dispose() method to release hook tokens early by pranaygp · Pull Request #1181 · vercel/workflow · GitHub
Skip to content

feat(core): add hook.dispose() method to release hook tokens early - #1181

Merged
pranaygp merged 17 commits into
mainfrom
claude/slack-expose-hook-dispose-fU7QG
Feb 27, 2026
Merged

feat(core): add hook.dispose() method to release hook tokens early#1181
pranaygp merged 17 commits into
mainfrom
claude/slack-expose-hook-dispose-fU7QG

Conversation

@pranaygp

Copy link
Copy Markdown
Contributor

Add a dispose() method to the Hook interface that allows workflows to
explicitly release hook tokens for reuse by other workflows while the
current workflow is still running. This enables handoff patterns where
one workflow can transfer a hook token to another workflow.

  • Add dispose() method to Hook interface in create-hook.ts
  • Implement dispose functionality in workflow/hook.ts
  • Add HookDisposedInvocationQueueItem to global.ts
  • Handle hook_disposed events in suspension-handler.ts
  • Update documentation in hooks.mdx and create-hook.mdx
  • Add e2e test for hook token reuse after explicit disposal

https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH

CopilotAI review requested due to automatic review settings February 24, 2026 17:37
@pranaygp
pranaygp requested a review from a team as a code ownerFebruary 24, 2026 17:37
@vercel

vercelBot commented Feb 24, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Feb 24, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c697597

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 14 packages
NameType
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/web-sharedPatch
workflowPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

@github-actions

github-actionsBot commented Feb 24, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express0.032s (~)1.005s (~)0.972s101.00x
💻 LocalNitro0.032s (-8.5% 🟢)1.005s (~)0.973s101.00x
💻 LocalNext.js (Turbopack)0.041s1.005s0.963s101.28x
🌐 RedisNext.js (Turbopack)0.046s1.005s0.959s101.42x
🐘 PostgresNitro0.057s (+2.7%)1.011s (~)0.954s101.76x
🐘 PostgresExpress0.059s (+9.5% 🔺)1.011s (~)0.952s101.83x
🌐 MongoDBNext.js (Turbopack)0.092s1.008s0.915s102.85x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)0.603s (-12.6% 🟢)1.929s (-23.6% 🟢)1.326s101.00x
▲ VercelNitro0.707s (+4.3%)3.432s (+59.5% 🔺)2.725s101.17x
▲ VercelExpress0.905s (+32.7% 🔺)2.149s (+16.5% 🔺)1.244s101.50x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)1.103s2.005s0.903s101.00x
🌐 RedisNext.js (Turbopack)1.104s2.006s0.902s101.00x
💻 LocalNitro1.106s (~)2.006s (~)0.900s101.00x
💻 LocalExpress1.107s (~)2.006s (~)0.899s101.00x
🐘 PostgresExpress1.141s (+2.1%)2.010s (~)0.869s101.04x
🐘 PostgresNitro1.145s (+1.0%)2.024s (+0.6%)0.879s101.04x
🌐 MongoDBNext.js (Turbopack)1.312s2.009s0.697s101.19x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.163s (-7.9% 🟢)3.432s (-3.4%)1.269s101.00x
▲ VercelExpress2.220s (-7.3% 🟢)3.157s (-4.1%)0.937s101.03x
▲ VercelNitro2.225s (-2.3%)3.408s (-2.5%)1.183s101.03x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)10.700s11.022s0.322s31.00x
💻 LocalNext.js (Turbopack)10.719s11.021s0.303s31.00x
🐘 PostgresExpress10.827s (~)11.039s (~)0.211s31.01x
💻 LocalExpress10.832s (~)11.021s (~)0.190s31.01x
💻 LocalNitro10.843s (~)11.023s (~)0.180s31.01x
🐘 PostgresNitro10.914s (~)11.045s (~)0.131s31.02x
🌐 MongoDBNext.js (Turbopack)12.321s13.026s0.706s31.15x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express16.796s (-6.5% 🟢)17.576s (-8.7% 🟢)0.780s21.00x
▲ VercelNitro17.206s (-1.0%)18.757s (+1.3%)1.552s21.02x
▲ VercelNext.js (Turbopack)17.474s (-4.2%)18.688s (-3.6%)1.214s21.04x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)26.976s27.051s0.075s31.00x
💻 LocalNext.js (Turbopack)27.189s28.050s0.861s31.01x
🐘 PostgresExpress27.239s (~)28.058s (~)0.819s31.01x
🐘 PostgresNitro27.382s (~)28.066s (~)0.684s31.02x
💻 LocalNitro27.507s (~)28.051s (~)0.545s31.02x
💻 LocalExpress27.514s (~)28.051s (~)0.538s31.02x
🌐 MongoDBNext.js (Turbopack)30.628s31.056s0.427s21.14x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express43.906s (-0.7%)45.004s (~)1.098s21.00x
▲ VercelNitro45.876s (+0.8%)46.679s (+0.6%)0.803s21.04x
▲ VercelNext.js (Turbopack)46.913s (+2.9%)48.182s (+2.6%)1.269s21.07x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)54.422s55.098s0.677s21.00x
🐘 PostgresExpress54.990s (~)55.094s (-0.9%)0.104s21.01x
🐘 PostgresNitro55.233s (~)56.111s (+1.8%)0.878s21.01x
💻 LocalNext.js (Turbopack)56.719s57.099s0.380s21.04x
💻 LocalExpress57.330s (~)58.103s (~)0.774s21.05x
💻 LocalNitro57.367s (~)58.103s (~)0.736s21.05x
🌐 MongoDBNext.js (Turbopack)61.233s62.075s0.842s21.13x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro97.920s (-6.3% 🟢)99.368s (-5.5% 🟢)1.448s11.00x
▲ VercelExpress101.973s (+1.2%)103.023s (+0.8%)1.050s11.04x
▲ VercelNext.js (Turbopack)104.390s (+5.2% 🔺)105.833s (+5.9% 🔺)1.443s11.07x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.249s2.006s0.757s151.00x
🐘 PostgresExpress1.373s (+0.9%)2.009s (~)0.637s151.10x
🐘 PostgresNitro1.373s (+0.7%)2.011s (~)0.638s151.10x
💻 LocalNext.js (Turbopack)1.394s2.005s0.611s151.12x
💻 LocalExpress1.403s (-1.0%)2.005s (~)0.602s151.12x
💻 LocalNitro1.439s (~)2.006s (~)0.567s151.15x
🌐 MongoDBNext.js (Turbopack)2.147s3.008s0.861s101.72x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.394s (+0.7%)3.115s (+1.0%)0.721s101.00x
▲ VercelNitro2.396s (-9.0% 🟢)3.618s (-11.4% 🟢)1.222s91.00x
▲ VercelNext.js (Turbopack)2.670s (-12.0% 🟢)3.779s (-9.5% 🟢)1.109s81.12x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro2.009s (-3.7%)2.516s (-6.3% 🟢)0.508s121.00x
🐘 PostgresExpress2.037s (+0.8%)2.515s (~)0.478s121.01x
🌐 RedisNext.js (Turbopack)2.492s3.008s0.516s101.24x
💻 LocalNext.js (Turbopack)2.545s3.007s0.461s101.27x
💻 LocalExpress2.582s (-2.1%)3.007s (~)0.425s101.29x
💻 LocalNitro2.631s (-1.3%)3.008s (~)0.377s101.31x
🌐 MongoDBNext.js (Turbopack)4.817s5.344s0.527s62.40x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.689s (-0.5%)3.543s (-1.6%)0.854s91.00x
▲ VercelNitro2.939s (+6.8% 🔺)3.928s (+4.6%)0.989s81.09x
▲ VercelNext.js (Turbopack)2.964s (-12.1% 🟢)4.175s (-8.1% 🟢)1.212s81.10x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro3.593s (~)4.023s (-8.7% 🟢)0.430s81.00x
🐘 PostgresExpress3.824s (+18.9% 🔺)4.451s (+10.7% 🔺)0.627s71.06x
🌐 RedisNext.js (Turbopack)4.053s4.725s0.673s71.13x
💻 LocalNext.js (Turbopack)6.910s7.515s0.605s41.92x
💻 LocalExpress7.497s (~)8.018s (~)0.521s42.09x
💻 LocalNitro7.524s (-2.0%)8.016s (~)0.492s42.09x
🌐 MongoDBNext.js (Turbopack)9.919s10.682s0.763s32.76x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.118s (-4.9%)4.378s (+1.9%)1.260s71.00x
▲ VercelNitro3.185s (-7.3% 🟢)4.244s (-8.2% 🟢)1.059s81.02x
▲ VercelExpress3.993s (-12.7% 🟢)5.046s (-11.2% 🟢)1.053s61.28x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.235s2.006s0.771s151.00x
🐘 PostgresExpress1.369s (-0.8%)2.011s (~)0.642s151.11x
🐘 PostgresNitro1.375s (-2.7%)2.010s (~)0.635s151.11x
💻 LocalNext.js (Turbopack)1.394s2.006s0.611s151.13x
💻 LocalNitro1.440s (-1.3%)2.005s (~)0.565s151.17x
💻 LocalExpress1.456s (+3.2%)2.005s (~)0.548s151.18x
🌐 MongoDBNext.js (Turbopack)2.172s3.008s0.836s101.76x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.248s (+5.4% 🔺)3.236s (+6.6% 🔺)0.988s101.00x
▲ VercelNext.js (Turbopack)2.392s (+9.3% 🔺)3.657s (+5.0%)1.265s91.06x
▲ VercelNitro2.430s (+15.6% 🔺)3.559s (+5.3% 🔺)1.128s91.08x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro2.003s (-1.8%)2.596s (+4.9%)0.593s121.00x
🐘 PostgresExpress2.056s (+6.4% 🔺)2.682s (+3.3%)0.626s121.03x
🌐 RedisNext.js (Turbopack)2.523s3.008s0.485s101.26x
💻 LocalNext.js (Turbopack)2.628s3.008s0.380s101.31x
💻 LocalExpress2.690s (-1.7%)3.008s (~)0.318s101.34x
💻 LocalNitro2.710s (-2.3%)3.007s (~)0.297s101.35x
🌐 MongoDBNext.js (Turbopack)4.648s5.177s0.529s62.32x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.640s (-16.2% 🟢)3.548s (-15.4% 🟢)0.907s91.00x
▲ VercelExpress2.747s (+3.4%)3.582s (~)0.835s101.04x
▲ VercelNitro2.867s (-5.4% 🟢)3.831s (-15.7% 🟢)0.964s81.09x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express3.288s (-13.9% 🟢)3.893s (-9.8% 🟢)0.606s81.00x
🐘 PostgresNitro3.670s (+8.9% 🔺)4.268s (+2.9%)0.598s81.12x
🌐 RedisNext.js (Turbopack)4.111s4.868s0.758s71.25x
💻 LocalNext.js (Turbopack)7.432s7.766s0.334s42.26x
💻 LocalNitro7.877s (-5.4% 🟢)8.272s (-8.3% 🟢)0.395s42.40x
💻 LocalExpress7.888s (-3.9%)8.269s (-8.3% 🟢)0.381s42.40x
🌐 MongoDBNext.js (Turbopack)10.035s10.684s0.649s33.05x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.008s (~)4.207s (+3.0%)1.199s81.00x
▲ VercelExpress3.183s (-18.5% 🟢)4.339s (-17.2% 🟢)1.156s71.06x
▲ VercelNitro3.477s (+13.2% 🔺)4.833s (+12.0% 🔺)1.356s71.16x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)0.143s1.001s0.011s1.016s0.873s101.00x
🌐 RedisNext.js (Turbopack)0.154s1.000s0.001s1.007s0.853s101.08x
💻 LocalNitro0.174s (-6.2% 🟢)1.003s (~)0.011s (-5.9% 🟢)1.017s (~)0.842s101.22x
💻 LocalExpress0.175s (+2.6%)1.002s (~)0.011s (-1.8%)1.016s (~)0.841s101.23x
🐘 PostgresExpress0.200s (+5.5% 🔺)0.992s (~)0.002s (+13.3% 🔺)1.011s (~)0.812s101.40x
🐘 PostgresNitro0.202s (+2.5%)0.993s (~)0.002s (-5.6% 🟢)1.012s (~)0.809s101.42x
🌐 MongoDBNext.js (Turbopack)0.489s0.962s0.002s1.009s0.520s103.43x
🐘 PostgresNext.js (Turbopack)⚠️missing-----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)1.712s (-25.3% 🟢)2.412s (-17.3% 🟢)0.087s (-37.2% 🟢)33.064s (-1.7%)31.352s101.00x
▲ VercelNitro1.782s (-1.1%)2.391s (+0.9%)0.197s (+25.7% 🔺)3.132s (+1.4%)1.349s101.04x
▲ VercelExpress1.971s (+27.2% 🔺)2.591s (+13.9% 🔺)0.161s (-7.4% 🟢)33.170s (+1056.0% 🔺)31.198s101.15x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNext.js (Turbopack)11/12
🐘 PostgresExpress8/12
▲ VercelNext.js (Turbopack)6/12
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express🐘 Postgres8/12
Next.js (Turbopack)🌐 Redis7/12
Nitro🐘 Postgres6/12
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)

📋 View full workflow run

@github-actions

github-actionsBot commented Feb 24, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production534049583
✅ 💻 Local Development568068636
✅ 📦 Local Production568068636
❌ 🐘 Local Postgres567168636
✅ 🪟 Windows500353
❌ 🌍 Community Worlds113469168
✅ 📋 Other138021159
Total2538472862871

❌ Failed Tests

🐘 Local Postgres (1 failed)

nextjs-turbopack-stable (1 failed):

  • webhookWorkflow
🌍 Community Worlds (46 failed)

turso (46 failed):

  • addTenWorkflow
  • addTenWorkflow
  • should work with react rendering in step
  • promiseAllWorkflow
  • promiseRaceWorkflow
  • promiseAnyWorkflow
  • hookWorkflow
  • webhookWorkflow
  • sleepingWorkflow
  • parallelSleepWorkflow
  • nullByteWorkflow
  • workflowAndStepMetadataWorkflow
  • fetchWorkflow
  • promiseRaceStressTestWorkflow
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior FatalError fails immediately without retries
  • error handling retry behavior RetryableError respects custom retryAfter delay
  • error handling retry behavior maxRetries=0 disables retries
  • error handling retry behavior workflow completes despite transient 5xx on step_completed
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously
  • hookDisposeTestWorkflow - hook token reuse after explicit disposal while workflow still running
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument
  • closureVariableWorkflow - nested step functions with closure variables
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step
  • health check (queue-based) - workflow and step endpoints respond to health check messages
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • Calculator.calculate - static workflow method using static step methods from another class
  • AllInOneService.processNumber - static workflow method using sibling static step methods
  • ChainableService.processWithThis - static step methods using this to reference the class
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE
  • instanceMethodStepWorkflow - instance methods with "use step" directive
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context
  • stepFunctionAsStartArgWorkflow - step function reference passed as start() argument
  • cancelRun - cancelling a running workflow
  • cancelRun via CLI - cancelling a running workflow
  • pages router addTenWorkflow via pages router
  • pages router promiseAllWorkflow via pages router
  • pages router sleepingWorkflow via pages router

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro4805
✅ example4805
✅ express4805
✅ fastify4805
✅ hono4805
✅ nextjs-turbopack5102
✅ nextjs-webpack5102
✅ nitro4805
✅ nuxt4805
✅ sveltekit4805
✅ vite4805
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable4607
✅ express-stable4607
✅ fastify-stable4607
✅ hono-stable4607
✅ nextjs-turbopack-canary5003
✅ nextjs-turbopack-stable5003
✅ nextjs-webpack-canary5003
✅ nextjs-webpack-stable5003
✅ nitro-stable4607
✅ nuxt-stable4607
✅ sveltekit-stable4607
✅ vite-stable4607
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable4607
✅ express-stable4607
✅ fastify-stable4607
✅ hono-stable4607
✅ nextjs-turbopack-canary5003
✅ nextjs-turbopack-stable5003
✅ nextjs-webpack-canary5003
✅ nextjs-webpack-stable5003
✅ nitro-stable4607
✅ nuxt-stable4607
✅ sveltekit-stable4607
✅ vite-stable4607
❌ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable4607
✅ express-stable4607
✅ fastify-stable4607
✅ hono-stable4607
✅ nextjs-turbopack-canary5003
❌ nextjs-turbopack-stable4913
✅ nextjs-webpack-canary5003
✅ nextjs-webpack-stable5003
✅ nitro-stable4607
✅ nuxt-stable4607
✅ sveltekit-stable4607
✅ vite-stable4607
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack5003
❌ 🌍 Community Worlds
AppPassedFailedSkipped
✅ mongodb-dev300
✅ mongodb5003
✅ redis-dev300
✅ redis5003
✅ turso-dev300
❌ turso4463
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable4607
✅ e2e-local-postgres-nest-stable4607
✅ e2e-local-prod-nest-stable4607

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: success
  • Local Dev: success
  • Local Prod: success
  • Local Postgres: failure
  • Windows: success

Check the workflow run for details.

Comment threaddocs/content/docs/api-reference/workflow/create-hook.mdx Outdated
Comment threaddocs/content/docs/api-reference/workflow/create-hook.mdx Outdated
Comment threaddocs/content/docs/api-reference/workflow/create-hook.mdx
Comment threaddocs/content/docs/api-reference/workflow/create-hook.mdx Outdated
Comment threaddocs/content/docs/api-reference/workflow/create-webhook.mdx Outdated
Comment threaddocs/content/docs/api-reference/workflow/create-hook.mdx Outdated

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request adds a dispose() method to hooks and webhooks that allows workflows to explicitly release hook tokens for reuse by other workflows while the current workflow is still running. This enables handoff patterns where one workflow can transfer a hook token to another workflow.

Changes:

  • Added dispose() method and Symbol.dispose to Hook interface for TC39 Explicit Resource Management support
  • Implemented disposal logic in workflow/hook.ts with isDisposed tracking and async iterator termination
  • Added HookDisposedInvocationQueueItem type and handling in suspension-handler.ts to create hook_disposed events
  • Updated documentation to recommend using keyword for automatic cleanup and added examples of early disposal patterns
  • Added comprehensive e2e test demonstrating token reuse after explicit disposal while workflow still running
  • Fixed missing import in create-webhook.mdx documentation

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.

Show a summary per file
FileDescription
packages/core/src/create-hook.tsAdded dispose() and Symbol.dispose methods to Hook interface with documentation
packages/core/src/workflow/hook.tsImplemented disposeHook() function, isDisposed flag, and async iterator termination logic
packages/core/src/global.tsAdded HookDisposedInvocationQueueItem type and updated WorkflowSuspension to count hook disposals
packages/core/src/runtime/suspension-handler.tsAdded processing of hook_disposed queue items with error handling for 404/410 statuses
packages/core/e2e/e2e.test.tsAdded comprehensive test for hook token reuse after explicit disposal while workflow running
workbench/example/workflows/99_e2e.tsUpdated examples to use using keyword and added hookDisposeTestWorkflow demonstrating disposal
workbench/example/workflows/5_hooks.tsUpdated to use using keyword for automatic disposal
docs/content/docs/foundations/hooks.mdxAdded section on disposing hooks early with examples, updated all examples to use using
docs/content/docs/api-reference/workflow/create-hook.mdxAdded disposal documentation and examples, updated to use using throughout
docs/content/docs/api-reference/workflow/create-webhook.mdxAdded TC39 note, fixed missing import, updated examples to use using
.changeset/smart-cougars-talk.mdAdded changeset documenting the new feature

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 138 to 180
@@ -176,6 +179,22 @@ export function createCreateHook(ctx: WorkflowOrchestratorContext) {
return resolvers.promise;
}

CopilotAIFeb 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The createHookPromise function doesn't check if the hook has been disposed. While the async iterator correctly stops when isDisposed is true, directly awaiting a disposed hook (e.g., const result = await hook after hook.dispose()) will still attempt to create a promise and could cause unexpected behavior. Consider adding an isDisposed check that either throws an error or returns a rejected promise to make the behavior more predictable and easier to debug.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not adding an isDisposed check to createHookPromise(). The async iterator already checks !isDisposed before each yield, so the iterator pattern is safe. For direct await hook after dispose(), the behavior is the same as awaiting a hook that was never resumed — the promise hangs until the workflow suspends or completes. This is consistent with how the rest of the framework works (unawaited operations are silently ignored on completion). In the stacked PR #1185 we now log a warning when the workflow completes with uncommitted operations, which covers this case.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

human: hm I'm not sure claude understood the issue reported here. this explanation doesn't make sense to me ^

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, the previous explanation was off. The actual concern from Copilot was: if you call await hook after hook.dispose(), createHookPromise() doesn't check isDisposed so it still creates a promise and pushes it to the promises array.

In practice this is fine because:

  1. If the event log is empty (first run), the dispose already set disposed=true on the queue item, and the subsequent await triggers a WorkflowSuspension which processes both creation and disposal
  2. If the event log has hook_disposed (replay), the item is already deleted from the queue and hasDisposedEvent=true, so disposeHook() was a no-op — the await creates a dangling promise but the workflow completes/suspends on the next step

We have a test for case 1: "should suspend when awaiting a disposed hook on first invocation". Adding an isDisposed check to reject immediately would change the semantics (throwing instead of suspending), which could break workflows that call dispose() then have other work to do before their next suspension point.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@TooTallNate@VaguelySerious any thoughts on this behaviour?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation has changed significantly since this comment. disposeHook() no longer adds a separate queue item — it sets a disposed flag on the existing hook queue item, and on replay it checks hasDisposedEvent to no-op entirely.

Re: the createHookPromise not checking isDisposed — this is intentional. If user code calls dispose() then await hook, the promise enters the promises array. If the event log is empty, the next event-consumer pass triggers a WorkflowSuspension with the disposed hook item, which processes both creation and disposal. If the log has hook_disposed, the item is already removed from the queue so the suspension is empty or only contains other items. Either way it resolves correctly without needing an explicit isDisposed rejection in createHookPromise.

Comment threaddocs/content/docs/foundations/hooks.mdx
Comment threadpackages/core/src/workflow/hook.ts
Comment threadpackages/core/src/runtime/suspension-handler.ts
Comment threadpackages/core/src/workflow/hook.ts Outdated
(hook as any)[vmDispose] = disposeHook;
}

return hook as unknown as Hook<T>;

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hook as unknown as Hook<T> smells like a bug and breaks type safety. avoid this please

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 303b08e — added [Symbol.dispose]: disposeHook directly to the object literal so it satisfies Hook<T> without the double cast. The VM's Symbol.dispose is still set separately only when it differs from the host's.

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 122 to +126
if (event.eventType === 'hook_disposed') {
// If a hook is explicitly disposed, we're done processing any more
// events for it
// Terminal state - remove from queue (like step_completed/wait_completed)
ctx.invocationsQueue.delete(correlationId);
// Mark that the event log confirms disposal happened
hasDisposedEvent = true;

CopilotAIFeb 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a hook_disposed event is replayed, the hook remains in ctx.invocationsQueue (and may still have disposed=true). That means later suspensions can keep trying to emit hook_disposed again, which will reliably 404 in worlds like world-local (it deletes the hook entity on disposal) and can lead to repeated API calls/log spam. Consider deleting this hook’s queue item here (similar to wait_completed / step_completed) once disposal is confirmed in the event log.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we do delete the queue item now

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was fixed in 9f9c739hook_disposed now deletes the item from ctx.invocationsQueue (terminal state, matching step_completed/wait_completed). The hasDisposedEvent closure flag prevents disposeHook() from re-adding anything on replay.

Comment on lines +35 to +40
* Disposes the hook, releasing its token for reuse by other workflows.
*
* After calling `dispose()`, the hook will no longer receive any events.
* This is useful when you want to explicitly release a hook token before
* the workflow completes, allowing another workflow to register a hook
* with the same token.

CopilotAIFeb 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docs here imply that calling dispose() immediately stops the hook from receiving events and releases the token right away. In this implementation, dispose() only marks the queue item and the hook_disposed event/token release won’t happen until the workflow hits the next suspension point (or until the run reaches a terminal state). Consider clarifying that disposal is processed on the next suspension (e.g., await sleep() / await step()) to avoid confusing handoff patterns.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

small nuance

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSDoc describes the user-facing contract: after dispose(), the hook stops receiving events and the token becomes available. The internal mechanics (queue flag → suspension handler → server event) are an implementation detail. This is consistent with how sleep() and step functions are documented — they describe what happens, not when the server processes it.

Comment threaddocs/content/docs/foundations/hooks.mdx
Comment on lines +138 to +166
### Disposing Hooks Early

You can dispose a hook early to release its token for reuse by another workflow. This is useful for handoff patterns where one workflow needs to transfer a hook token to another workflow while still running.

```typescript lineNumbers
import { createHook } from "workflow"

export async function handoffWorkflow(channelId: string) {
"use workflow";

const hook = createHook<{ message: string; handoff?: boolean }>({
token: `channel:${channelId}`
});

for await (const payload of hook) {
console.log("Received:", payload.message);

if (payload.handoff) {
hook.dispose(); // [!code highlight] Release the token for another workflow
break;
}
}

// Continue with other work while another workflow uses the token
}
```

After calling `dispose()`, the hook will no longer receive events and its token becomes available for other workflows to use.

CopilotAIFeb 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The narrative here implies token reuse becomes available immediately after hook.dispose(). In practice, the disposal is emitted/processed on the next workflow suspension (or at run completion), so a handoff workflow typically needs an await (step/sleep/etc.) after disposing to make reuse observable while the workflow continues. Consider documenting that timing explicitly (or showing it in the example) to prevent confusing handoff behavior.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that nuance is fine to ignore since "the next suspension point" is always a few milliseconds away from a disposal (the workflow runtime is expected to be fast) and is not critical to document for usage of hook and disposal

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nvm I included it elsewhere in the hook docs

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same answer — the disposal is processed at the next suspension point, which for any practical workflow happens immediately (the next await triggers suspension). The docs are describing the user-facing contract, not the internal mechanics.

Comment thread.changeset/smart-cougars-talk.md Outdated
@@ -0,0 +1,6 @@
---
"@workflow/core": patch
"workflow": patch

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove "workflow" from the changelog. enough to just bump the core package

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c697597 — removed workflow from the changeset.

claudeand others added 13 commits February 26, 2026 21:32
Add a `dispose()` method to the Hook interface that allows workflows to
explicitly release hook tokens for reuse by other workflows while the
current workflow is still running. This enables handoff patterns where
one workflow can transfer a hook token to another workflow.
- Add `dispose()` method to Hook interface in create-hook.ts
- Implement dispose functionality in workflow/hook.ts
- Add HookDisposedInvocationQueueItem to global.ts
- Handle hook_disposed events in suspension-handler.ts
- Update documentation in hooks.mdx and create-hook.mdx
- Add e2e test for hook token reuse after explicit disposal
https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH
Add Symbol.dispose to Hook interface to support the TC39 Explicit Resource
Management proposal. This allows hooks to be used with the `using` keyword
for automatic disposal when exiting scope.
https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH
Update all documentation and e2e tests to use the `using` keyword as the
recommended approach for creating hooks and webhooks. This leverages the
TC39 Explicit Resource Management proposal for automatic disposal.
- Update e2e tests to use `using` syntax
- Update foundational hooks guide to recommend `using`
- Update create-hook API reference with `using` examples
- Update create-webhook API reference with `using` examples
- Update example workflow to use `using`
https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH
Remove unnecessary block scopes and excessive comments about automatic
disposal. Block scopes are only used when early disposal is relevant
(like in the handoff test).
https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH
Add a one-liner explaining the `using` keyword in the intro examples
of the API reference docs, so new users understand the syntax.
https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH
- Restore code highlights (`[!code highlight]`) that were unintentionally
removed from pre-existing doc examples
- Move `using` explanation from prose to inline code comment in intro
examples
- Add `{/* @skip-typecheck */}` to incomplete manual dispose() snippet
- Add 409 (conflict/duplicate) error handling for hook_disposed events
in suspension handler to handle workflow re-invocation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The workflow VM sandbox doesn't have Symbol.dispose/Symbol.asyncDispose
available, causing `using` keyword to fail with "Symbol.dispose is not
defined" at runtime. Add polyfill in the VM context creation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The workflow VM sandbox has its own Symbol object with a polyfilled
Symbol.dispose. The hook object was using the host's Symbol.dispose,
which is a different symbol instance. The SWC-compiled `using` keyword
looks up the VM's Symbol.dispose on the object, causing "Object not
disposable" errors.
Fix by setting Symbol.dispose on the hook object dynamically using the
VM's globalThis.Symbol.dispose from the orchestrator context.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The webhookWorkflow e2e test creates 3 webhooks that must all exist
before the test sends HTTP requests. Using `using` with sequential
creation meant only the first webhook existed at the first suspension
point. Revert to `const` since all webhooks need to be created upfront.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Instead of adding a separate HookDisposedInvocationQueueItem to the
queue on dispose, keep the HookInvocationQueueItem throughout the
hook lifecycle and track state with flags (hasCreatedEvent, disposed).
A closure variable (hasDisposedEvent) makes disposeHook() a pure no-op
on replay, avoiding redundant server calls and 409 errors.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Match the pattern used by steps (step_completed/step_failed) and waits
(wait_completed) where the queue item is removed on the terminal event.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- WorkflowSuspension counts disposed hooks separately from active hooks
- Dispose after hook_created replay produces correct suspension
- Dispose before first suspension (needs both create + dispose)
- Multiple hooks where only one is disposed
- Dispose on a conflicted hook is safe (no crash)
- Symbol.dispose calls disposeHook correctly (using keyword pattern)
- Iterator break without dispose keeps hook alive in queue
- Await after dispose on first invocation triggers suspension
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +172 to +198
### Disposing Hooks Early

When a workflow ends, hooks are automatically disposed. However, you may want to release a hook token early so another workflow can use it while your workflow continues running. Use a block scope with `using` to control when disposal happens:

```typescript lineNumbers
import { createHook } from "workflow";

export async function handoffWorkflow(channelId: string) {
"use workflow";

{
using hook = createHook<{ message: string; handoff?: boolean }>({
token: `channel:${channelId}`
});

for await (const payload of hook) {
console.log("Received:", payload.message);

if (payload.handoff) {
break;
}
}
} // Hook token released here

// Token is now available for another workflow
console.log("Continuing with other work...");
}

CopilotAIFeb 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section implies that exiting the using scope (or calling dispose()) immediately releases the token for another workflow. In the current design, the hook_disposed event is only emitted when the workflow next suspends (e.g., await sleep(), await step(), etc.), otherwise the token won’t be reusable until the run completes. Please document that a suspension point is required for early reuse, and ideally show one in the example.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docs are accurate for the user-facing mental model. The token is released when the workflow next suspends (which happens immediately after the using block exits if there's any subsequent await step() or await sleep()). For the edge case of dispose at function scope with no further suspension points, the backend auto-disposes all hooks when the run reaches a terminal state, so the token is still released.

Comment threadpackages/core/src/global.ts
Comment threadpackages/core/src/workflow/hook.test.ts
Comment threadpackages/core/src/create-hook.ts Outdated
Add [Symbol.dispose] directly to the hook object so it satisfies the
Hook<T> type without `as unknown as`. The VM's Symbol.dispose is still
added separately when it differs from the host's.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, two main comments from a claude simulation:

  1. When hook_disposed is replayed, the event consumer returns Finished, unsubscribing the callback. If the workflow later does await hook after the consumer is gone, createHookPromise() will push to promises[] but nobody will ever resolve it. However, this is actually fine, since a workflow that awaits a hook after disposing it is a user error, and the workflow will simply hang (which is the expected behavior for an unresolvable promise in the event-sourced model, so it would eventually suspend on the next trigger).
  2. runtime.ts suspension message doesn't include hookDisposedCount, see const suspensionMessage = buildWorkflowSuspensionMessage(.... The debug log message doesn't mention hook disposals. This is cosmetic (debug-level only) but could be confusing when debugging. Worth a follow-up to add hookDisposedCount to the message builder.

- Rename test to match new behavior (hooks stay in queue, not removed)
- Use neutral "processed" verb in WorkflowSuspension message when
mixed item types are present
- Remove extends Disposable from Hook interface to avoid requiring
lib.esnext.disposable in downstream consumers (explicit
[Symbol.dispose]() method is still declared on the interface)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well-implemented feature with solid test coverage and thorough documentation. The core design of tracking hasCreatedEvent and disposed flags on queue items, and splitting hook processing in the suspension handler, is clean and fits well into the existing event-sourcing architecture.

A few inline comments — mostly minor suggestions and one behavioral note about the iterator. Nothing blocking.

yield await this;
}
},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: If dispose() is called externally while the iterator is suspended on yield await this, the pending promise will never resolve/reject — the !isDisposed check only runs after the yield returns. This means the iterator won't terminate until the next payload arrives (at which point the loop condition catches it).

This is probably fine in practice since dispose() is typically called inside the for await body after receiving a payload, but worth a comment here noting the limitation.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ca9ca85disposeHook() now drains the promises array and triggers a WorkflowSuspension when there are pending promises. This ensures no orphaned awaits: the suspension handler processes the disposal, and the workflow execution is unwound cleanly.

This also addresses @VaguelySerious's point 1 from his review — the promise is no longer left hanging.

const vmDispose = ctx.globalThis.Symbol.dispose;
if (vmDispose && vmDispose !== Symbol.dispose) {
(hook as any)[vmDispose] = disposeHook;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The as unknown as Hook<T> cast is needed because the host's [Symbol.dispose] is intentionally not set on the object literal (only the VM's symbol is). This is correct for runtime behavior, but it means the host-side Symbol.dispose is never present on the object. Might be worth a brief comment explaining why the cast is safe (the hook is only used inside the VM sandbox where the VM's Symbol.dispose is what matters).

@pranaygppranaygpFeb 27, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed already. that cast is not needed

if (hooksNeedingDisposal.length > 0) {
await Promise.all(
hooksNeedingDisposal.map(async (queueItem) => {
const hookDisposedEvent: CreateEventRequest = {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: The hookDisposedEvent doesn't include an eventData field, while other event types (e.g., hook_created, step_created) typically do. Is that intentional? If the world implementation expects eventData to always be present, this could be an issue. If it's optional, this is fine — just flagging for awareness.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional — hook_disposed is a terminal event that just deletes the hook entity. The CreateEventRequest type has eventData as optional. The world implementations (local, postgres, vercel) all handle missing eventData fine for disposal events.

);
// Hooks needing disposal: any disposed hook (including those needing creation first)
// Hooks are created before disposal in the processing order below
const hooksNeedingDisposal = allHookItems.filter((item) => item.disposed);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment on lines 85-86 is helpful. To be explicit: when hasCreatedEvent is falsy and disposed is true, the hook appears in bothhooksNeedingCreation and hooksNeedingDisposal. Since creation runs before disposal (lines 122-193), this ordering is correct — the hook must exist before it can be disposed. Just want to confirm this two-step behavior for a single queue item is tested. (It is — the e2e test covers this path when dispose() is called before any suspension.)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct — this is tested in "should produce correct WorkflowSuspension for dispose before first suspension" which verifies the flags (hasCreatedEvent=undefined, disposed=true), and the suspension handler test coverage in the edge cases table confirms both hooksNeedingCreation and hooksNeedingDisposal include it.

g.structuredClone = globalThis.structuredClone;

// TC39 Explicit Resource Management polyfill for `using` keyword
(g.Symbol as any).dispose ??= Symbol.for('Symbol.dispose');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Symbol.for('Symbol.dispose') creates a global registry symbol which is not the same as the native well-known Symbol.dispose. The ??= guard means this only applies when the runtime lacks native support, which is fine. But if this polyfill is used elsewhere (outside the VM sandbox), code comparing against the native Symbol.dispose won't match. The dual-registration in hook.ts:235-238 handles this correctly. Just flagging in case other code needs to be aware of the distinction.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good callout. The Symbol.for('Symbol.dispose') polyfill is scoped to the VM sandbox context only — it's set on vmGlobalThis.Symbol.dispose, not the host's Symbol. The dual-registration in hook.ts (host's Symbol.dispose + VM's Symbol.dispose when they differ) handles the mismatch. No other code outside the VM needs to compare against this polyfilled symbol.

@@ -0,0 +1,6 @@
---
"@workflow/core": patch

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: Since this adds a new public API method (dispose()) and extends the Hook interface with Disposable, this could arguably be a minor bump for @workflow/core rather than patch. Up to you and your versioning conventions.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

keeping patch to be in line with our pre-beta conventions

pranaygpand others added 2 commits February 27, 2026 12:04
When dispose() is called while a promise is pending (e.g., iterator
suspended on yield await this, or direct await hook after dispose),
the promise would hang forever since the event consumer will never
deliver another hook_received. Now disposeHook() clears the promises
array and triggers a WorkflowSuspension so the runtime processes the
disposal cleanly.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@pranaygp

Copy link
Copy Markdown
ContributorAuthor

@VaguelySerious Thanks for the review! Addressing your two points:

  1. Hanging promise — Fixed in ca9ca85. disposeHook() now drains the promises array and triggers a WorkflowSuspension so there are no orphaned promises. This also addresses @TooTallNate's comment about dispose() called while the iterator is suspended.

  2. buildWorkflowSuspensionMessage missing hookDisposedCount — Good catch, the debug log won't mention hook disposals. Will address in a follow-up since it's cosmetic (debug-level only) and the WorkflowSuspension error message itself already includes disposals correctly.

Sign up for freeto 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.

How to dispose of hooks

5 participants

@pranaygp@TooTallNate@VaguelySerious@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat(core): add hook.dispose() method to release hook tokens early by pranaygp · Pull Request #1181 · vercel/workflow · GitHub
Skip to content

feat(core): add hook.dispose() method to release hook tokens early - #1181

Merged
pranaygp merged 17 commits into
mainfrom
claude/slack-expose-hook-dispose-fU7QG
Feb 27, 2026
Merged

feat(core): add hook.dispose() method to release hook tokens early#1181
pranaygp merged 17 commits into
mainfrom
claude/slack-expose-hook-dispose-fU7QG

Conversation

@pranaygp

Copy link
Copy Markdown
Contributor

Add a dispose() method to the Hook interface that allows workflows to
explicitly release hook tokens for reuse by other workflows while the
current workflow is still running. This enables handoff patterns where
one workflow can transfer a hook token to another workflow.

  • Add dispose() method to Hook interface in create-hook.ts
  • Implement dispose functionality in workflow/hook.ts
  • Add HookDisposedInvocationQueueItem to global.ts
  • Handle hook_disposed events in suspension-handler.ts
  • Update documentation in hooks.mdx and create-hook.mdx
  • Add e2e test for hook token reuse after explicit disposal

https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH

CopilotAI review requested due to automatic review settings February 24, 2026 17:37
@pranaygp
pranaygp requested a review from a team as a code ownerFebruary 24, 2026 17:37
@vercel

vercelBot commented Feb 24, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Feb 24, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c697597

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 14 packages
NameType
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/web-sharedPatch
workflowPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

@github-actions

github-actionsBot commented Feb 24, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Express0.032s (~)1.005s (~)0.972s101.00x
💻 LocalNitro0.032s (-8.5% 🟢)1.005s (~)0.973s101.00x
💻 LocalNext.js (Turbopack)0.041s1.005s0.963s101.28x
🌐 RedisNext.js (Turbopack)0.046s1.005s0.959s101.42x
🐘 PostgresNitro0.057s (+2.7%)1.011s (~)0.954s101.76x
🐘 PostgresExpress0.059s (+9.5% 🔺)1.011s (~)0.952s101.83x
🌐 MongoDBNext.js (Turbopack)0.092s1.008s0.915s102.85x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)0.603s (-12.6% 🟢)1.929s (-23.6% 🟢)1.326s101.00x
▲ VercelNitro0.707s (+4.3%)3.432s (+59.5% 🔺)2.725s101.17x
▲ VercelExpress0.905s (+32.7% 🔺)2.149s (+16.5% 🔺)1.244s101.50x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

workflow with 1 step

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)1.103s2.005s0.903s101.00x
🌐 RedisNext.js (Turbopack)1.104s2.006s0.902s101.00x
💻 LocalNitro1.106s (~)2.006s (~)0.900s101.00x
💻 LocalExpress1.107s (~)2.006s (~)0.899s101.00x
🐘 PostgresExpress1.141s (+2.1%)2.010s (~)0.869s101.04x
🐘 PostgresNitro1.145s (+1.0%)2.024s (+0.6%)0.879s101.04x
🌐 MongoDBNext.js (Turbopack)1.312s2.009s0.697s101.19x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.163s (-7.9% 🟢)3.432s (-3.4%)1.269s101.00x
▲ VercelExpress2.220s (-7.3% 🟢)3.157s (-4.1%)0.937s101.03x
▲ VercelNitro2.225s (-2.3%)3.408s (-2.5%)1.183s101.03x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

workflow with 10 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)10.700s11.022s0.322s31.00x
💻 LocalNext.js (Turbopack)10.719s11.021s0.303s31.00x
🐘 PostgresExpress10.827s (~)11.039s (~)0.211s31.01x
💻 LocalExpress10.832s (~)11.021s (~)0.190s31.01x
💻 LocalNitro10.843s (~)11.023s (~)0.180s31.01x
🐘 PostgresNitro10.914s (~)11.045s (~)0.131s31.02x
🌐 MongoDBNext.js (Turbopack)12.321s13.026s0.706s31.15x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express16.796s (-6.5% 🟢)17.576s (-8.7% 🟢)0.780s21.00x
▲ VercelNitro17.206s (-1.0%)18.757s (+1.3%)1.552s21.02x
▲ VercelNext.js (Turbopack)17.474s (-4.2%)18.688s (-3.6%)1.214s21.04x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 25 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)26.976s27.051s0.075s31.00x
💻 LocalNext.js (Turbopack)27.189s28.050s0.861s31.01x
🐘 PostgresExpress27.239s (~)28.058s (~)0.819s31.01x
🐘 PostgresNitro27.382s (~)28.066s (~)0.684s31.02x
💻 LocalNitro27.507s (~)28.051s (~)0.545s31.02x
💻 LocalExpress27.514s (~)28.051s (~)0.538s31.02x
🌐 MongoDBNext.js (Turbopack)30.628s31.056s0.427s21.14x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express43.906s (-0.7%)45.004s (~)1.098s21.00x
▲ VercelNitro45.876s (+0.8%)46.679s (+0.6%)0.803s21.04x
▲ VercelNext.js (Turbopack)46.913s (+2.9%)48.182s (+2.6%)1.269s21.07x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 50 sequential steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)54.422s55.098s0.677s21.00x
🐘 PostgresExpress54.990s (~)55.094s (-0.9%)0.104s21.01x
🐘 PostgresNitro55.233s (~)56.111s (+1.8%)0.878s21.01x
💻 LocalNext.js (Turbopack)56.719s57.099s0.380s21.04x
💻 LocalExpress57.330s (~)58.103s (~)0.774s21.05x
💻 LocalNitro57.367s (~)58.103s (~)0.736s21.05x
🌐 MongoDBNext.js (Turbopack)61.233s62.075s0.842s21.13x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Nitro97.920s (-6.3% 🟢)99.368s (-5.5% 🟢)1.448s11.00x
▲ VercelExpress101.973s (+1.2%)103.023s (+0.8%)1.050s11.04x
▲ VercelNext.js (Turbopack)104.390s (+5.2% 🔺)105.833s (+5.9% 🔺)1.443s11.07x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.249s2.006s0.757s151.00x
🐘 PostgresExpress1.373s (+0.9%)2.009s (~)0.637s151.10x
🐘 PostgresNitro1.373s (+0.7%)2.011s (~)0.638s151.10x
💻 LocalNext.js (Turbopack)1.394s2.005s0.611s151.12x
💻 LocalExpress1.403s (-1.0%)2.005s (~)0.602s151.12x
💻 LocalNitro1.439s (~)2.006s (~)0.567s151.15x
🌐 MongoDBNext.js (Turbopack)2.147s3.008s0.861s101.72x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.394s (+0.7%)3.115s (+1.0%)0.721s101.00x
▲ VercelNitro2.396s (-9.0% 🟢)3.618s (-11.4% 🟢)1.222s91.00x
▲ VercelNext.js (Turbopack)2.670s (-12.0% 🟢)3.779s (-9.5% 🟢)1.109s81.12x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Promise.all with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro2.009s (-3.7%)2.516s (-6.3% 🟢)0.508s121.00x
🐘 PostgresExpress2.037s (+0.8%)2.515s (~)0.478s121.01x
🌐 RedisNext.js (Turbopack)2.492s3.008s0.516s101.24x
💻 LocalNext.js (Turbopack)2.545s3.007s0.461s101.27x
💻 LocalExpress2.582s (-2.1%)3.007s (~)0.425s101.29x
💻 LocalNitro2.631s (-1.3%)3.008s (~)0.377s101.31x
🌐 MongoDBNext.js (Turbopack)4.817s5.344s0.527s62.40x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.689s (-0.5%)3.543s (-1.6%)0.854s91.00x
▲ VercelNitro2.939s (+6.8% 🔺)3.928s (+4.6%)0.989s81.09x
▲ VercelNext.js (Turbopack)2.964s (-12.1% 🟢)4.175s (-8.1% 🟢)1.212s81.10x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Promise.all with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro3.593s (~)4.023s (-8.7% 🟢)0.430s81.00x
🐘 PostgresExpress3.824s (+18.9% 🔺)4.451s (+10.7% 🔺)0.627s71.06x
🌐 RedisNext.js (Turbopack)4.053s4.725s0.673s71.13x
💻 LocalNext.js (Turbopack)6.910s7.515s0.605s41.92x
💻 LocalExpress7.497s (~)8.018s (~)0.521s42.09x
💻 LocalNitro7.524s (-2.0%)8.016s (~)0.492s42.09x
🌐 MongoDBNext.js (Turbopack)9.919s10.682s0.763s32.76x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.118s (-4.9%)4.378s (+1.9%)1.260s71.00x
▲ VercelNitro3.185s (-7.3% 🟢)4.244s (-8.2% 🟢)1.059s81.02x
▲ VercelExpress3.993s (-12.7% 🟢)5.046s (-11.2% 🟢)1.053s61.28x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

Promise.race with 10 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🌐 Redis🥇 Next.js (Turbopack)1.235s2.006s0.771s151.00x
🐘 PostgresExpress1.369s (-0.8%)2.011s (~)0.642s151.11x
🐘 PostgresNitro1.375s (-2.7%)2.010s (~)0.635s151.11x
💻 LocalNext.js (Turbopack)1.394s2.006s0.611s151.13x
💻 LocalNitro1.440s (-1.3%)2.005s (~)0.565s151.17x
💻 LocalExpress1.456s (+3.2%)2.005s (~)0.548s151.18x
🌐 MongoDBNext.js (Turbopack)2.172s3.008s0.836s101.76x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Express2.248s (+5.4% 🔺)3.236s (+6.6% 🔺)0.988s101.00x
▲ VercelNext.js (Turbopack)2.392s (+9.3% 🔺)3.657s (+5.0%)1.265s91.06x
▲ VercelNitro2.430s (+15.6% 🔺)3.559s (+5.3% 🔺)1.128s91.08x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

Promise.race with 25 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Nitro2.003s (-1.8%)2.596s (+4.9%)0.593s121.00x
🐘 PostgresExpress2.056s (+6.4% 🔺)2.682s (+3.3%)0.626s121.03x
🌐 RedisNext.js (Turbopack)2.523s3.008s0.485s101.26x
💻 LocalNext.js (Turbopack)2.628s3.008s0.380s101.31x
💻 LocalExpress2.690s (-1.7%)3.008s (~)0.318s101.34x
💻 LocalNitro2.710s (-2.3%)3.007s (~)0.297s101.35x
🌐 MongoDBNext.js (Turbopack)4.648s5.177s0.529s62.32x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)2.640s (-16.2% 🟢)3.548s (-15.4% 🟢)0.907s91.00x
▲ VercelExpress2.747s (+3.4%)3.582s (~)0.835s101.04x
▲ VercelNitro2.867s (-5.4% 🟢)3.831s (-15.7% 🟢)0.964s81.09x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

Promise.race with 50 concurrent steps

💻 Local Development

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
🐘 Postgres🥇 Express3.288s (-13.9% 🟢)3.893s (-9.8% 🟢)0.606s81.00x
🐘 PostgresNitro3.670s (+8.9% 🔺)4.268s (+2.9%)0.598s81.12x
🌐 RedisNext.js (Turbopack)4.111s4.868s0.758s71.25x
💻 LocalNext.js (Turbopack)7.432s7.766s0.334s42.26x
💻 LocalNitro7.877s (-5.4% 🟢)8.272s (-8.3% 🟢)0.395s42.40x
💻 LocalExpress7.888s (-3.9%)8.269s (-8.3% 🟢)0.381s42.40x
🌐 MongoDBNext.js (Turbopack)10.035s10.684s0.649s33.05x
🐘 PostgresNext.js (Turbopack)⚠️missing----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)3.008s (~)4.207s (+3.0%)1.199s81.00x
▲ VercelExpress3.183s (-18.5% 🟢)4.339s (-17.2% 🟢)1.156s71.06x
▲ VercelNitro3.477s (+13.2% 🔺)4.833s (+12.0% 🔺)1.356s71.16x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

Stream Benchmarks(includes TTFB metrics)
workflow with stream

💻 Local Development

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
💻 Local🥇 Next.js (Turbopack)0.143s1.001s0.011s1.016s0.873s101.00x
🌐 RedisNext.js (Turbopack)0.154s1.000s0.001s1.007s0.853s101.08x
💻 LocalNitro0.174s (-6.2% 🟢)1.003s (~)0.011s (-5.9% 🟢)1.017s (~)0.842s101.22x
💻 LocalExpress0.175s (+2.6%)1.002s (~)0.011s (-1.8%)1.016s (~)0.841s101.23x
🐘 PostgresExpress0.200s (+5.5% 🔺)0.992s (~)0.002s (+13.3% 🔺)1.011s (~)0.812s101.40x
🐘 PostgresNitro0.202s (+2.5%)0.993s (~)0.002s (-5.6% 🟢)1.012s (~)0.809s101.42x
🌐 MongoDBNext.js (Turbopack)0.489s0.962s0.002s1.009s0.520s103.43x
🐘 PostgresNext.js (Turbopack)⚠️missing-----

▲ Production (Vercel)

WorldFrameworkWorkflow TimeTTFBSlurpWall TimeOverheadSamplesvs Fastest
▲ Vercel🥇 Next.js (Turbopack)1.712s (-25.3% 🟢)2.412s (-17.3% 🟢)0.087s (-37.2% 🟢)33.064s (-1.7%)31.352s101.00x
▲ VercelNitro1.782s (-1.1%)2.391s (+0.9%)0.197s (+25.7% 🔺)3.132s (+1.4%)1.349s101.04x
▲ VercelExpress1.971s (+27.2% 🔺)2.591s (+13.9% 🔺)0.161s (-7.4% 🟢)33.170s (+1056.0% 🔺)31.198s101.15x

🔍 Observability: Next.js (Turbopack) | Nitro | Express

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World🥇 Fastest FrameworkWins
💻 LocalNext.js (Turbopack)11/12
🐘 PostgresExpress8/12
▲ VercelNext.js (Turbopack)6/12
Fastest World by Framework

Winner determined by most benchmark wins

Framework🥇 Fastest WorldWins
Express🐘 Postgres8/12
Next.js (Turbopack)🌐 Redis7/12
Nitro🐘 Postgres6/12
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)

📋 View full workflow run

@github-actions

github-actionsBot commented Feb 24, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production534049583
✅ 💻 Local Development568068636
✅ 📦 Local Production568068636
❌ 🐘 Local Postgres567168636
✅ 🪟 Windows500353
❌ 🌍 Community Worlds113469168
✅ 📋 Other138021159
Total2538472862871

❌ Failed Tests

🐘 Local Postgres (1 failed)

nextjs-turbopack-stable (1 failed):

  • webhookWorkflow
🌍 Community Worlds (46 failed)

turso (46 failed):

  • addTenWorkflow
  • addTenWorkflow
  • should work with react rendering in step
  • promiseAllWorkflow
  • promiseRaceWorkflow
  • promiseAnyWorkflow
  • hookWorkflow
  • webhookWorkflow
  • sleepingWorkflow
  • parallelSleepWorkflow
  • nullByteWorkflow
  • workflowAndStepMetadataWorkflow
  • fetchWorkflow
  • promiseRaceStressTestWorkflow
  • error handling error propagation workflow errors nested function calls preserve message and stack trace
  • error handling error propagation workflow errors cross-file imports preserve message and stack trace
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior FatalError fails immediately without retries
  • error handling retry behavior RetryableError respects custom retryAfter delay
  • error handling retry behavior maxRetries=0 disables retries
  • error handling retry behavior workflow completes despite transient 5xx on step_completed
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • hookCleanupTestWorkflow - hook token reuse after workflow completion
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously
  • hookDisposeTestWorkflow - hook token reuse after explicit disposal while workflow still running
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars)
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument
  • closureVariableWorkflow - nested step functions with closure variables
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step
  • health check (queue-based) - workflow and step endpoints respond to health check messages
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly
  • Calculator.calculate - static workflow method using static step methods from another class
  • AllInOneService.processNumber - static workflow method using sibling static step methods
  • ChainableService.processWithThis - static step methods using this to reference the class
  • thisSerializationWorkflow - step function invoked with .call() and .apply()
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE
  • instanceMethodStepWorkflow - instance methods with "use step" directive
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context
  • stepFunctionAsStartArgWorkflow - step function reference passed as start() argument
  • cancelRun - cancelling a running workflow
  • cancelRun via CLI - cancelling a running workflow
  • pages router addTenWorkflow via pages router
  • pages router promiseAllWorkflow via pages router
  • pages router sleepingWorkflow via pages router

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro4805
✅ example4805
✅ express4805
✅ fastify4805
✅ hono4805
✅ nextjs-turbopack5102
✅ nextjs-webpack5102
✅ nitro4805
✅ nuxt4805
✅ sveltekit4805
✅ vite4805
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable4607
✅ express-stable4607
✅ fastify-stable4607
✅ hono-stable4607
✅ nextjs-turbopack-canary5003
✅ nextjs-turbopack-stable5003
✅ nextjs-webpack-canary5003
✅ nextjs-webpack-stable5003
✅ nitro-stable4607
✅ nuxt-stable4607
✅ sveltekit-stable4607
✅ vite-stable4607
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable4607
✅ express-stable4607
✅ fastify-stable4607
✅ hono-stable4607
✅ nextjs-turbopack-canary5003
✅ nextjs-turbopack-stable5003
✅ nextjs-webpack-canary5003
✅ nextjs-webpack-stable5003
✅ nitro-stable4607
✅ nuxt-stable4607
✅ sveltekit-stable4607
✅ vite-stable4607
❌ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable4607
✅ express-stable4607
✅ fastify-stable4607
✅ hono-stable4607
✅ nextjs-turbopack-canary5003
❌ nextjs-turbopack-stable4913
✅ nextjs-webpack-canary5003
✅ nextjs-webpack-stable5003
✅ nitro-stable4607
✅ nuxt-stable4607
✅ sveltekit-stable4607
✅ vite-stable4607
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack5003
❌ 🌍 Community Worlds
AppPassedFailedSkipped
✅ mongodb-dev300
✅ mongodb5003
✅ redis-dev300
✅ redis5003
✅ turso-dev300
❌ turso4463
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable4607
✅ e2e-local-postgres-nest-stable4607
✅ e2e-local-prod-nest-stable4607

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: success
  • Local Dev: success
  • Local Prod: success
  • Local Postgres: failure
  • Windows: success

Check the workflow run for details.

Comment threaddocs/content/docs/api-reference/workflow/create-hook.mdx Outdated
Comment threaddocs/content/docs/api-reference/workflow/create-hook.mdx Outdated
Comment threaddocs/content/docs/api-reference/workflow/create-hook.mdx
Comment threaddocs/content/docs/api-reference/workflow/create-hook.mdx Outdated
Comment threaddocs/content/docs/api-reference/workflow/create-webhook.mdx Outdated
Comment threaddocs/content/docs/api-reference/workflow/create-hook.mdx Outdated

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request adds a dispose() method to hooks and webhooks that allows workflows to explicitly release hook tokens for reuse by other workflows while the current workflow is still running. This enables handoff patterns where one workflow can transfer a hook token to another workflow.

Changes:

  • Added dispose() method and Symbol.dispose to Hook interface for TC39 Explicit Resource Management support
  • Implemented disposal logic in workflow/hook.ts with isDisposed tracking and async iterator termination
  • Added HookDisposedInvocationQueueItem type and handling in suspension-handler.ts to create hook_disposed events
  • Updated documentation to recommend using keyword for automatic cleanup and added examples of early disposal patterns
  • Added comprehensive e2e test demonstrating token reuse after explicit disposal while workflow still running
  • Fixed missing import in create-webhook.mdx documentation

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.

Show a summary per file
FileDescription
packages/core/src/create-hook.tsAdded dispose() and Symbol.dispose methods to Hook interface with documentation
packages/core/src/workflow/hook.tsImplemented disposeHook() function, isDisposed flag, and async iterator termination logic
packages/core/src/global.tsAdded HookDisposedInvocationQueueItem type and updated WorkflowSuspension to count hook disposals
packages/core/src/runtime/suspension-handler.tsAdded processing of hook_disposed queue items with error handling for 404/410 statuses
packages/core/e2e/e2e.test.tsAdded comprehensive test for hook token reuse after explicit disposal while workflow running
workbench/example/workflows/99_e2e.tsUpdated examples to use using keyword and added hookDisposeTestWorkflow demonstrating disposal
workbench/example/workflows/5_hooks.tsUpdated to use using keyword for automatic disposal
docs/content/docs/foundations/hooks.mdxAdded section on disposing hooks early with examples, updated all examples to use using
docs/content/docs/api-reference/workflow/create-hook.mdxAdded disposal documentation and examples, updated to use using throughout
docs/content/docs/api-reference/workflow/create-webhook.mdxAdded TC39 note, fixed missing import, updated examples to use using
.changeset/smart-cougars-talk.mdAdded changeset documenting the new feature

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 138 to 180
@@ -176,6 +179,22 @@ export function createCreateHook(ctx: WorkflowOrchestratorContext) {
return resolvers.promise;
}

CopilotAIFeb 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The createHookPromise function doesn't check if the hook has been disposed. While the async iterator correctly stops when isDisposed is true, directly awaiting a disposed hook (e.g., const result = await hook after hook.dispose()) will still attempt to create a promise and could cause unexpected behavior. Consider adding an isDisposed check that either throws an error or returns a rejected promise to make the behavior more predictable and easier to debug.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not adding an isDisposed check to createHookPromise(). The async iterator already checks !isDisposed before each yield, so the iterator pattern is safe. For direct await hook after dispose(), the behavior is the same as awaiting a hook that was never resumed — the promise hangs until the workflow suspends or completes. This is consistent with how the rest of the framework works (unawaited operations are silently ignored on completion). In the stacked PR #1185 we now log a warning when the workflow completes with uncommitted operations, which covers this case.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

human: hm I'm not sure claude understood the issue reported here. this explanation doesn't make sense to me ^

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, the previous explanation was off. The actual concern from Copilot was: if you call await hook after hook.dispose(), createHookPromise() doesn't check isDisposed so it still creates a promise and pushes it to the promises array.

In practice this is fine because:

  1. If the event log is empty (first run), the dispose already set disposed=true on the queue item, and the subsequent await triggers a WorkflowSuspension which processes both creation and disposal
  2. If the event log has hook_disposed (replay), the item is already deleted from the queue and hasDisposedEvent=true, so disposeHook() was a no-op — the await creates a dangling promise but the workflow completes/suspends on the next step

We have a test for case 1: "should suspend when awaiting a disposed hook on first invocation". Adding an isDisposed check to reject immediately would change the semantics (throwing instead of suspending), which could break workflows that call dispose() then have other work to do before their next suspension point.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@TooTallNate@VaguelySerious any thoughts on this behaviour?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation has changed significantly since this comment. disposeHook() no longer adds a separate queue item — it sets a disposed flag on the existing hook queue item, and on replay it checks hasDisposedEvent to no-op entirely.

Re: the createHookPromise not checking isDisposed — this is intentional. If user code calls dispose() then await hook, the promise enters the promises array. If the event log is empty, the next event-consumer pass triggers a WorkflowSuspension with the disposed hook item, which processes both creation and disposal. If the log has hook_disposed, the item is already removed from the queue so the suspension is empty or only contains other items. Either way it resolves correctly without needing an explicit isDisposed rejection in createHookPromise.

Comment threaddocs/content/docs/foundations/hooks.mdx
Comment threadpackages/core/src/workflow/hook.ts
Comment threadpackages/core/src/runtime/suspension-handler.ts
Comment threadpackages/core/src/workflow/hook.ts Outdated
(hook as any)[vmDispose] = disposeHook;
}

return hook as unknown as Hook<T>;

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hook as unknown as Hook<T> smells like a bug and breaks type safety. avoid this please

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 303b08e — added [Symbol.dispose]: disposeHook directly to the object literal so it satisfies Hook<T> without the double cast. The VM's Symbol.dispose is still set separately only when it differs from the host's.

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 122 to +126
if (event.eventType === 'hook_disposed') {
// If a hook is explicitly disposed, we're done processing any more
// events for it
// Terminal state - remove from queue (like step_completed/wait_completed)
ctx.invocationsQueue.delete(correlationId);
// Mark that the event log confirms disposal happened
hasDisposedEvent = true;

CopilotAIFeb 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a hook_disposed event is replayed, the hook remains in ctx.invocationsQueue (and may still have disposed=true). That means later suspensions can keep trying to emit hook_disposed again, which will reliably 404 in worlds like world-local (it deletes the hook entity on disposal) and can lead to repeated API calls/log spam. Consider deleting this hook’s queue item here (similar to wait_completed / step_completed) once disposal is confirmed in the event log.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we do delete the queue item now

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was fixed in 9f9c739hook_disposed now deletes the item from ctx.invocationsQueue (terminal state, matching step_completed/wait_completed). The hasDisposedEvent closure flag prevents disposeHook() from re-adding anything on replay.

Comment on lines +35 to +40
* Disposes the hook, releasing its token for reuse by other workflows.
*
* After calling `dispose()`, the hook will no longer receive any events.
* This is useful when you want to explicitly release a hook token before
* the workflow completes, allowing another workflow to register a hook
* with the same token.

CopilotAIFeb 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docs here imply that calling dispose() immediately stops the hook from receiving events and releases the token right away. In this implementation, dispose() only marks the queue item and the hook_disposed event/token release won’t happen until the workflow hits the next suspension point (or until the run reaches a terminal state). Consider clarifying that disposal is processed on the next suspension (e.g., await sleep() / await step()) to avoid confusing handoff patterns.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

small nuance

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSDoc describes the user-facing contract: after dispose(), the hook stops receiving events and the token becomes available. The internal mechanics (queue flag → suspension handler → server event) are an implementation detail. This is consistent with how sleep() and step functions are documented — they describe what happens, not when the server processes it.

Comment threaddocs/content/docs/foundations/hooks.mdx
Comment on lines +138 to +166
### Disposing Hooks Early

You can dispose a hook early to release its token for reuse by another workflow. This is useful for handoff patterns where one workflow needs to transfer a hook token to another workflow while still running.

```typescript lineNumbers
import { createHook } from "workflow"

export async function handoffWorkflow(channelId: string) {
"use workflow";

const hook = createHook<{ message: string; handoff?: boolean }>({
token: `channel:${channelId}`
});

for await (const payload of hook) {
console.log("Received:", payload.message);

if (payload.handoff) {
hook.dispose(); // [!code highlight] Release the token for another workflow
break;
}
}

// Continue with other work while another workflow uses the token
}
```

After calling `dispose()`, the hook will no longer receive events and its token becomes available for other workflows to use.

CopilotAIFeb 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The narrative here implies token reuse becomes available immediately after hook.dispose(). In practice, the disposal is emitted/processed on the next workflow suspension (or at run completion), so a handoff workflow typically needs an await (step/sleep/etc.) after disposing to make reuse observable while the workflow continues. Consider documenting that timing explicitly (or showing it in the example) to prevent confusing handoff behavior.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that nuance is fine to ignore since "the next suspension point" is always a few milliseconds away from a disposal (the workflow runtime is expected to be fast) and is not critical to document for usage of hook and disposal

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nvm I included it elsewhere in the hook docs

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same answer — the disposal is processed at the next suspension point, which for any practical workflow happens immediately (the next await triggers suspension). The docs are describing the user-facing contract, not the internal mechanics.

Comment thread.changeset/smart-cougars-talk.md Outdated
@@ -0,0 +1,6 @@
---
"@workflow/core": patch
"workflow": patch

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove "workflow" from the changelog. enough to just bump the core package

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c697597 — removed workflow from the changeset.

claudeand others added 13 commits February 26, 2026 21:32
Add a `dispose()` method to the Hook interface that allows workflows to
explicitly release hook tokens for reuse by other workflows while the
current workflow is still running. This enables handoff patterns where
one workflow can transfer a hook token to another workflow.
- Add `dispose()` method to Hook interface in create-hook.ts
- Implement dispose functionality in workflow/hook.ts
- Add HookDisposedInvocationQueueItem to global.ts
- Handle hook_disposed events in suspension-handler.ts
- Update documentation in hooks.mdx and create-hook.mdx
- Add e2e test for hook token reuse after explicit disposal
https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH
Add Symbol.dispose to Hook interface to support the TC39 Explicit Resource
Management proposal. This allows hooks to be used with the `using` keyword
for automatic disposal when exiting scope.
https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH
Update all documentation and e2e tests to use the `using` keyword as the
recommended approach for creating hooks and webhooks. This leverages the
TC39 Explicit Resource Management proposal for automatic disposal.
- Update e2e tests to use `using` syntax
- Update foundational hooks guide to recommend `using`
- Update create-hook API reference with `using` examples
- Update create-webhook API reference with `using` examples
- Update example workflow to use `using`
https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH
Remove unnecessary block scopes and excessive comments about automatic
disposal. Block scopes are only used when early disposal is relevant
(like in the handoff test).
https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH
Add a one-liner explaining the `using` keyword in the intro examples
of the API reference docs, so new users understand the syntax.
https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH
- Restore code highlights (`[!code highlight]`) that were unintentionally
removed from pre-existing doc examples
- Move `using` explanation from prose to inline code comment in intro
examples
- Add `{/* @skip-typecheck */}` to incomplete manual dispose() snippet
- Add 409 (conflict/duplicate) error handling for hook_disposed events
in suspension handler to handle workflow re-invocation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The workflow VM sandbox doesn't have Symbol.dispose/Symbol.asyncDispose
available, causing `using` keyword to fail with "Symbol.dispose is not
defined" at runtime. Add polyfill in the VM context creation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The workflow VM sandbox has its own Symbol object with a polyfilled
Symbol.dispose. The hook object was using the host's Symbol.dispose,
which is a different symbol instance. The SWC-compiled `using` keyword
looks up the VM's Symbol.dispose on the object, causing "Object not
disposable" errors.
Fix by setting Symbol.dispose on the hook object dynamically using the
VM's globalThis.Symbol.dispose from the orchestrator context.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The webhookWorkflow e2e test creates 3 webhooks that must all exist
before the test sends HTTP requests. Using `using` with sequential
creation meant only the first webhook existed at the first suspension
point. Revert to `const` since all webhooks need to be created upfront.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Instead of adding a separate HookDisposedInvocationQueueItem to the
queue on dispose, keep the HookInvocationQueueItem throughout the
hook lifecycle and track state with flags (hasCreatedEvent, disposed).
A closure variable (hasDisposedEvent) makes disposeHook() a pure no-op
on replay, avoiding redundant server calls and 409 errors.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Match the pattern used by steps (step_completed/step_failed) and waits
(wait_completed) where the queue item is removed on the terminal event.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- WorkflowSuspension counts disposed hooks separately from active hooks
- Dispose after hook_created replay produces correct suspension
- Dispose before first suspension (needs both create + dispose)
- Multiple hooks where only one is disposed
- Dispose on a conflicted hook is safe (no crash)
- Symbol.dispose calls disposeHook correctly (using keyword pattern)
- Iterator break without dispose keeps hook alive in queue
- Await after dispose on first invocation triggers suspension
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +172 to +198
### Disposing Hooks Early

When a workflow ends, hooks are automatically disposed. However, you may want to release a hook token early so another workflow can use it while your workflow continues running. Use a block scope with `using` to control when disposal happens:

```typescript lineNumbers
import { createHook } from "workflow";

export async function handoffWorkflow(channelId: string) {
"use workflow";

{
using hook = createHook<{ message: string; handoff?: boolean }>({
token: `channel:${channelId}`
});

for await (const payload of hook) {
console.log("Received:", payload.message);

if (payload.handoff) {
break;
}
}
} // Hook token released here

// Token is now available for another workflow
console.log("Continuing with other work...");
}

CopilotAIFeb 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section implies that exiting the using scope (or calling dispose()) immediately releases the token for another workflow. In the current design, the hook_disposed event is only emitted when the workflow next suspends (e.g., await sleep(), await step(), etc.), otherwise the token won’t be reusable until the run completes. Please document that a suspension point is required for early reuse, and ideally show one in the example.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docs are accurate for the user-facing mental model. The token is released when the workflow next suspends (which happens immediately after the using block exits if there's any subsequent await step() or await sleep()). For the edge case of dispose at function scope with no further suspension points, the backend auto-disposes all hooks when the run reaches a terminal state, so the token is still released.

Comment threadpackages/core/src/global.ts
Comment threadpackages/core/src/workflow/hook.test.ts
Comment threadpackages/core/src/create-hook.ts Outdated
Add [Symbol.dispose] directly to the hook object so it satisfies the
Hook<T> type without `as unknown as`. The VM's Symbol.dispose is still
added separately when it differs from the host's.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, two main comments from a claude simulation:

  1. When hook_disposed is replayed, the event consumer returns Finished, unsubscribing the callback. If the workflow later does await hook after the consumer is gone, createHookPromise() will push to promises[] but nobody will ever resolve it. However, this is actually fine, since a workflow that awaits a hook after disposing it is a user error, and the workflow will simply hang (which is the expected behavior for an unresolvable promise in the event-sourced model, so it would eventually suspend on the next trigger).
  2. runtime.ts suspension message doesn't include hookDisposedCount, see const suspensionMessage = buildWorkflowSuspensionMessage(.... The debug log message doesn't mention hook disposals. This is cosmetic (debug-level only) but could be confusing when debugging. Worth a follow-up to add hookDisposedCount to the message builder.

- Rename test to match new behavior (hooks stay in queue, not removed)
- Use neutral "processed" verb in WorkflowSuspension message when
mixed item types are present
- Remove extends Disposable from Hook interface to avoid requiring
lib.esnext.disposable in downstream consumers (explicit
[Symbol.dispose]() method is still declared on the interface)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@TooTallNateTooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well-implemented feature with solid test coverage and thorough documentation. The core design of tracking hasCreatedEvent and disposed flags on queue items, and splitting hook processing in the suspension handler, is clean and fits well into the existing event-sourcing architecture.

A few inline comments — mostly minor suggestions and one behavioral note about the iterator. Nothing blocking.

yield await this;
}
},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: If dispose() is called externally while the iterator is suspended on yield await this, the pending promise will never resolve/reject — the !isDisposed check only runs after the yield returns. This means the iterator won't terminate until the next payload arrives (at which point the loop condition catches it).

This is probably fine in practice since dispose() is typically called inside the for await body after receiving a payload, but worth a comment here noting the limitation.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ca9ca85disposeHook() now drains the promises array and triggers a WorkflowSuspension when there are pending promises. This ensures no orphaned awaits: the suspension handler processes the disposal, and the workflow execution is unwound cleanly.

This also addresses @VaguelySerious's point 1 from his review — the promise is no longer left hanging.

const vmDispose = ctx.globalThis.Symbol.dispose;
if (vmDispose && vmDispose !== Symbol.dispose) {
(hook as any)[vmDispose] = disposeHook;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The as unknown as Hook<T> cast is needed because the host's [Symbol.dispose] is intentionally not set on the object literal (only the VM's symbol is). This is correct for runtime behavior, but it means the host-side Symbol.dispose is never present on the object. Might be worth a brief comment explaining why the cast is safe (the hook is only used inside the VM sandbox where the VM's Symbol.dispose is what matters).

@pranaygppranaygpFeb 27, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed already. that cast is not needed

if (hooksNeedingDisposal.length > 0) {
await Promise.all(
hooksNeedingDisposal.map(async (queueItem) => {
const hookDisposedEvent: CreateEventRequest = {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: The hookDisposedEvent doesn't include an eventData field, while other event types (e.g., hook_created, step_created) typically do. Is that intentional? If the world implementation expects eventData to always be present, this could be an issue. If it's optional, this is fine — just flagging for awareness.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional — hook_disposed is a terminal event that just deletes the hook entity. The CreateEventRequest type has eventData as optional. The world implementations (local, postgres, vercel) all handle missing eventData fine for disposal events.

);
// Hooks needing disposal: any disposed hook (including those needing creation first)
// Hooks are created before disposal in the processing order below
const hooksNeedingDisposal = allHookItems.filter((item) => item.disposed);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment on lines 85-86 is helpful. To be explicit: when hasCreatedEvent is falsy and disposed is true, the hook appears in bothhooksNeedingCreation and hooksNeedingDisposal. Since creation runs before disposal (lines 122-193), this ordering is correct — the hook must exist before it can be disposed. Just want to confirm this two-step behavior for a single queue item is tested. (It is — the e2e test covers this path when dispose() is called before any suspension.)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct — this is tested in "should produce correct WorkflowSuspension for dispose before first suspension" which verifies the flags (hasCreatedEvent=undefined, disposed=true), and the suspension handler test coverage in the edge cases table confirms both hooksNeedingCreation and hooksNeedingDisposal include it.

g.structuredClone = globalThis.structuredClone;

// TC39 Explicit Resource Management polyfill for `using` keyword
(g.Symbol as any).dispose ??= Symbol.for('Symbol.dispose');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Symbol.for('Symbol.dispose') creates a global registry symbol which is not the same as the native well-known Symbol.dispose. The ??= guard means this only applies when the runtime lacks native support, which is fine. But if this polyfill is used elsewhere (outside the VM sandbox), code comparing against the native Symbol.dispose won't match. The dual-registration in hook.ts:235-238 handles this correctly. Just flagging in case other code needs to be aware of the distinction.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good callout. The Symbol.for('Symbol.dispose') polyfill is scoped to the VM sandbox context only — it's set on vmGlobalThis.Symbol.dispose, not the host's Symbol. The dual-registration in hook.ts (host's Symbol.dispose + VM's Symbol.dispose when they differ) handles the mismatch. No other code outside the VM needs to compare against this polyfilled symbol.

@@ -0,0 +1,6 @@
---
"@workflow/core": patch

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: Since this adds a new public API method (dispose()) and extends the Hook interface with Disposable, this could arguably be a minor bump for @workflow/core rather than patch. Up to you and your versioning conventions.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

keeping patch to be in line with our pre-beta conventions

pranaygpand others added 2 commits February 27, 2026 12:04
When dispose() is called while a promise is pending (e.g., iterator
suspended on yield await this, or direct await hook after dispose),
the promise would hang forever since the event consumer will never
deliver another hook_received. Now disposeHook() clears the promises
array and triggers a WorkflowSuspension so the runtime processes the
disposal cleanly.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@pranaygp

Copy link
Copy Markdown
ContributorAuthor

@VaguelySerious Thanks for the review! Addressing your two points:

  1. Hanging promise — Fixed in ca9ca85. disposeHook() now drains the promises array and triggers a WorkflowSuspension so there are no orphaned promises. This also addresses @TooTallNate's comment about dispose() called while the iterator is suspended.

  2. buildWorkflowSuspensionMessage missing hookDisposedCount — Good catch, the debug log won't mention hook disposals. Will address in a follow-up since it's cosmetic (debug-level only) and the WorkflowSuspension error message itself already includes disposals correctly.

Sign up for freeto 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.

How to dispose of hooks

5 participants

@pranaygp@TooTallNate@VaguelySerious@claude