feat(core): warn on uncommitted operations when workflow completes - #1185

Merged
pranaygp merged 22 commits into
mainfrom
pranaygp/pgp/pending-queue-warning
Feb 27, 2026
Merged

feat(core): warn on uncommitted operations when workflow completes#1185
pranaygp merged 22 commits into
mainfrom
pranaygp/pgp/pending-queue-warning

Conversation

@pranaygp

Copy link
Copy Markdown
Contributor

Log a warning when a workflow run completes or fails with uncommitted
operations still in the invocations queue (unawaited steps, hooks, or
sleeps). This helps users diagnose forgotten await calls.

Hook disposals are excluded from the warning since the backend
auto-disposes all hooks when a run reaches a terminal state.

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

@changeset-bot

changeset-botBot commented Feb 25, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 201fb65

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 25, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

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

❌ Failed Tests

🐘 Local Postgres (1 failed)

hono-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-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-stable4517
✅ nextjs-turbopack-canary5003
✅ nextjs-turbopack-stable5003
✅ 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: failure
  • Local Postgres: failure
  • Windows: success

Check the workflow run for details.

@pranaygpGraphite App

pranaygp commented Feb 25, 2026

Copy link
Copy Markdown
ContributorAuthor

Comment thread.changeset/twelve-olives-cross.md Outdated
@pranaygp
pranaygp marked this pull request as ready for review February 25, 2026 05:33
@pranaygp
pranaygp requested a review from a team as a code ownerFebruary 25, 2026 05:33
CopilotAI review requested due to automatic review settings February 25, 2026 05:33
@vercel

vercelBot commented Feb 25, 2026

Copy link
Copy Markdown
Contributor

claudeand others added 14 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>
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>

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 3 out of 3 changed files in this pull request and generated 5 comments.

Comments suppressed due to low confidence (1)

packages/core/src/workflow.test.ts:3943

  • This test doesn't seem to exercise the "exclude hook disposals" behavior: the provided events include hook_disposed, which removes the hook from invocationsQueue entirely, so the queue is empty at completion regardless of any filtering in warnPendingQueueItems(). If the intent is to assert that disposed-but-uncommitted hooks don't warn, the fixture should leave a disposed hook item in the queue (e.g., call hook.dispose() but omit the hook_disposed event).
 },
createdAt: new Date(),
},
{
eventId: 'event-2',
runId: workflowRun.runId,
eventType: 'hook_disposed',
correlationId: 'hook_01HK153X008RT6YEW43G8QX6JX',
createdAt: new Date(),
},
];
// Workflow creates hook, awaits one payload, hook is disposed
// (simulates `using` at function scope)
await runWorkflow(
`const createHook = globalThis[Symbol.for("WORKFLOW_CREATE_HOOK")];
async function workflow() {
const hook = createHook();
const result = await hook;
hook.dispose();
return result.message;
}${getWorkflowTransformCode('workflow')}`,
workflowRun,
events,
noEncryptionKey
);
const warnCalls = warnSpy.mock.calls.map((c) => c[0]);
expect(
warnCalls.some((msg: string) => msg.includes('uncommitted operation'))
).toBe(false);
} finally {
warnSpy.mockRestore();
}
});
it('should not warn when queue is empty on completion', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const ops: Promise<any>[] = [];
const workflowRun: WorkflowRun = {
runId: 'wrun_123',
workflowName: 'workflow',
status: 'running',
input: await dehydrateWorkflowArguments(
[],
'wrun_123',
noEncryptionKey,
ops
),
createdAt: new Date('2024-01-01T00:00:00.000Z'),
updatedAt: new Date('2024-01-01T00:00:00.000Z'),
startedAt: new Date('2024-01-01T00:00:00.000Z'),
deploymentId: 'test-deployment',
};
await runWorkflow(
`function workflow() { return "clean"; }${getWorkflowTransformCode('workflow')}`,
workflowRun,
[],
noEncryptionKey
);
const warnCalls = warnSpy.mock.calls.map((c) => c[0]);
expect(
warnCalls.some((msg: string) => msg.includes('uncommitted operation'))
).toBe(false);
} finally {
warnSpy.mockRestore();
}
});
});
});

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

Comment threadpackages/core/src/workflow.ts Outdated
Comment on lines +47 to +51
// Filter out hooks that are registered and alive (hasCreatedEvent && !disposed)
// — these are benign since the backend auto-disposes all hooks when a run
// reaches a terminal state
const items = [...pendingQueue.values()].filter(
(item) => !(item.type === 'hook' && item.hasCreatedEvent && !item.disposed)

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.

The hook filtering logic doesn't exclude hook disposals: hooks with disposed === true (set when hook.dispose() is called but before a hook_disposed event exists) will still be reported as "uncommitted", even though the PR description says hook disposals should be excluded because the backend auto-disposes at terminal state. Consider filtering out disposed hook queue items (and/or aligning the PR description/comment with the intended behavior).

Suggested change
// Filter out hooks that are registered and alive (hasCreatedEvent && !disposed)
// — these are benign since the backend auto-disposes all hooks when a run
// reaches a terminal state
constitems=[...pendingQueue.values()].filter(
(item)=>!(item.type==='hook'&&item.hasCreatedEvent&&!item.disposed)
// Filter out hooks that are either registered (hasCreatedEvent) or already
// disposed — these are benign since the backend auto-disposes all hooks
// when a run reaches a terminal state
constitems=[...pendingQueue.values()].filter(
(item)=>
!(
item.type==='hook'&&
(item.hasCreatedEvent||item.disposed)
)

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.

Fixed in 201fb65 — filter now excludes hooks with hasCreatedEvent || disposed, matching TooTallNate's suggestion below.

Comment on lines +58 to +61
return `step "${item.stepName}"`;
case 'hook':
return `hook (${item.correlationId})`;
case 'wait':

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 warning message includes the full hook token (hook (token: "...")). Hook tokens often act as shared secrets / auth credentials; logging them to console warnings can leak sensitive data. Prefer logging the hook correlationId, a redacted token (e.g., last 4 chars), or omit the token entirely.

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.

Went back to showing the token per TooTallNate's suggestion — this is a user-facing warning (not a server log), so the token is more meaningful than the correlationId. Fixed in 201fb65.

Comment on lines +55 to +64
const details = items.map((item) => {
switch (item.type) {
case 'step':
return `step "${item.stepName}"`;
case 'hook':
return `hook (${item.correlationId})`;
case 'wait':
return 'sleep';
default:
return `unknown (${(item as { type: string }).type})`;

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.

details relies on the switch being exhaustive. If a new QueueItem type is added later, this will silently produce undefined entries and log confusing output. Consider adding an explicit default that asserts never (or throws) so TypeScript enforces exhaustiveness here.

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.

Already fixed in the previous commit (86ce8f1) — there's a default case that returns unknown (${type}).

Comment on lines +3743 to +3776
// No step events — the unawaited step stays pending in the queue
const events: Event[] = [];

// Workflow calls step but doesn't await it, returns immediately
await runWorkflow(
`const add = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("add");
async function workflow() {
add(1, 2); // not awaited!
return "done";
}${getWorkflowTransformCode('workflow')}`,
workflowRun,
events,
noEncryptionKey
);

const warnCalls = warnSpy.mock.calls.map((c) => c[0]);
expect(
warnCalls.some(
(msg: string) =>
msg.includes('uncommitted operation') &&
msg.includes('step "add"')
)
).toBe(true);
expect(
warnCalls.some((msg: string) =>
msg.includes('Did you forget to `await`')
)
).toBe(true);
} finally {
warnSpy.mockRestore();
}
});

it('should warn when workflow fails with pending operations', async () => {

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 test provides a matching step_completed event for the invoked step. The step implementation deletes its invocationQueue entry on step_completed regardless of whether the returned Promise is awaited, so the queue will typically be empty by the time warnPendingQueueItems() runs and no warning will be emitted. To reliably test the new behavior, construct an event log where the step remains uncommitted at workflow completion (e.g., omit terminal events for that correlationId).

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.

Already fixed in 86ce8f1 — step events removed so the unawaited step stays pending.

Comment on lines +3817 to +3854
msg.includes('failed') && msg.includes('step "add"')
)
).toBe(true);
} finally {
warnSpy.mockRestore();
}
});

it('should not warn when only hook_disposed items remain in queue', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const ops: Promise<any>[] = [];
const workflowRun: WorkflowRun = {
runId: 'test-run-123',
workflowName: 'workflow',
status: 'running',
input: await dehydrateWorkflowArguments(
[],
'wrun_123',
noEncryptionKey,
ops
),
createdAt: new Date('2024-01-01T00:00:00.000Z'),
updatedAt: new Date('2024-01-01T00:00:00.000Z'),
startedAt: new Date('2024-01-01T00:00:00.000Z'),
deploymentId: 'test-deployment',
};

const events: Event[] = [
{
eventId: 'event-0',
runId: workflowRun.runId,
eventType: 'hook_created' as const,
correlationId: 'hook_01HK153X008RT6YEW43G8QX6JX',
eventData: {
token: 'test-token',
},
createdAt: new Date(),

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.

Similar to the previous test: because the events include step_completed, the step invocation is removed from invocationsQueue even if it isn't awaited. That means the warning may not be triggered, and the test may fail intermittently depending on scheduling. Consider changing the fixture so the step is still pending/uncommitted when the workflow errors (e.g., no terminal event for the invoked correlationId).

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.

Already fixed in 86ce8f1 — same fix as above.

- 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.

Clean, focused PR. The warning is a good DX improvement that will save users debugging time. The filter logic for hook disposals is correct, and the test coverage hits the important cases. A few minor suggestions inline.

Comment threadpackages/core/src/workflow.ts Outdated
// — these are benign since the backend auto-disposes all hooks when a run
// reaches a terminal state
const items = [...pendingQueue.values()].filter(
(item) => !(item.type === 'hook' && item.hasCreatedEvent && !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 filter condition !(item.type === 'hook' && item.hasCreatedEvent && !item.disposed) silences hooks that have been created and are actively waiting for payloads — correct, since the backend auto-disposes these.

But consider the edge case: a hook with hasCreatedEvent = undefined and disposed = undefined (i.e., a hook that was created in user code but the workflow completed before the first suspension could process it). This would not be filtered out — which is correct, since it represents a createHook() call that was never awaited. Just want to confirm this is intentional (I believe it is).

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.

Yes, that's intentional. A hook with hasCreatedEvent=undefined, disposed=undefined means createHook() was called but the workflow completed before any suspension — that's a forgotten await and should warn.

Comment threadpackages/core/src/workflow.ts Outdated
// — these are benign since the backend auto-disposes all hooks when a run
// reaches a terminal state
const items = [...pendingQueue.values()].filter(
(item) => !(item.type === 'hook' && item.hasCreatedEvent && !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.

Nit: Also worth noting — a hook with disposed = true but hasCreatedEvent = undefined (dispose called before first suspension) will pass through the filter and trigger a warning. This seems like a false positive: the user explicitly disposed the hook. The filter might be more precise as:

(item)=>!(item.type==='hook'&&(item.disposed||(item.hasCreatedEvent&&!item.disposed)))

Or equivalently, only warn for hooks that are neither disposed nor already created:

(item)=>!(item.type==='hook'&&(item.hasCreatedEvent||item.disposed))

This would skip warnings for any hook the user explicitly disposed, regardless of whether it was created yet. Though in practice, disposed && !hasCreatedEvent is rare (dispose before first suspension), so this may not matter.

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 catch — fixed in 201fb65. Filter is now \!(item.type === 'hook' && (item.hasCreatedEvent || item.disposed)), which skips warnings for any hook that's either alive or explicitly disposed.

Comment threadpackages/core/src/workflow.ts Outdated
Comment threadpackages/core/src/workflow.test.ts Outdated
}
});

it('should not warn when only hook_disposed items remain in queue', async () => {

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 test name says "hook_disposed items remain in queue" but the scenario is actually: hook_disposed is in the event log, which causes the hook consumer to remove the item from the queue (see hook.ts:124). So the queue ends up empty, not with disposed items. The test is correct in behavior — it verifies no warning fires — but the name is slightly misleading. Something like "should not warn when hooks are properly disposed" might be clearer.

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 201fb65 — renamed to "should not warn when hooks are properly disposed".


describe('pending queue warnings', () => {
it('should warn when workflow completes with an unawaited step', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});

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.

Suggestion: Consider extracting the WorkflowRun boilerplate into a helper at the top of this describe block, similar to how other test suites handle it. All four tests create nearly identical run objects. Something like:

functioncreateTestRun(overrides?: Partial<WorkflowRun>): WorkflowRun{ ... }

Not blocking — just a readability improvement.

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 suggestion — will leave for a follow-up since it's a readability improvement and doesn't affect behavior.

pranaygpand others added 7 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>
Log a warning when a workflow run completes or fails with uncommitted
operations still in the invocations queue (unawaited steps, hooks, or
sleeps). This helps users diagnose forgotten `await` calls.
Hook disposals are excluded from the warning since the backend
auto-disposes all hooks when a run reaches a terminal state.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Test that:
- Unawaited step triggers warning on successful completion
- Unawaited step triggers warning on workflow failure
- hook_disposed items are excluded from warning
- Clean workflow completion produces no warning
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Filter out hooks with hasCreatedEvent && !disposed (alive hooks) instead
of the removed hook_disposed queue item type.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove workflow from changeset (transitive via @workflow/core)
- Add default case to switch for exhaustiveness
- Use correlationId instead of token in warning message to avoid leaking
user-provided tokens into logs
- Remove step events from warning tests so unawaited steps stay pending
in the queue (step_completed would clear the item during replay)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Filter out disposed hooks from warning (not just created hooks)
- Show hook token instead of correlationId in warning (user-facing)
- Rename test to "should not warn when hooks are properly disposed"
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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.

5 participants

@pranaygp@TooTallNate@VaguelySerious@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n 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;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat(core): warn on uncommitted operations when workflow completes - #1185

Merged
pranaygp merged 22 commits into
mainfrom
pranaygp/pgp/pending-queue-warning
Feb 27, 2026
Merged

feat(core): warn on uncommitted operations when workflow completes#1185
pranaygp merged 22 commits into
mainfrom
pranaygp/pgp/pending-queue-warning

Conversation

@pranaygp

Copy link
Copy Markdown
Contributor

Log a warning when a workflow run completes or fails with uncommitted
operations still in the invocations queue (unawaited steps, hooks, or
sleeps). This helps users diagnose forgotten await calls.

Hook disposals are excluded from the warning since the backend
auto-disposes all hooks when a run reaches a terminal state.

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

@changeset-bot

changeset-botBot commented Feb 25, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 201fb65

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 25, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

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

❌ Failed Tests

🐘 Local Postgres (1 failed)

hono-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-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-stable4517
✅ nextjs-turbopack-canary5003
✅ nextjs-turbopack-stable5003
✅ 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: failure
  • Local Postgres: failure
  • Windows: success

Check the workflow run for details.

@pranaygpGraphite App

pranaygp commented Feb 25, 2026

Copy link
Copy Markdown
ContributorAuthor

Comment thread.changeset/twelve-olives-cross.md Outdated
@pranaygp
pranaygp marked this pull request as ready for review February 25, 2026 05:33
@pranaygp
pranaygp requested a review from a team as a code ownerFebruary 25, 2026 05:33
CopilotAI review requested due to automatic review settings February 25, 2026 05:33
@vercel

vercelBot commented Feb 25, 2026

Copy link
Copy Markdown
Contributor

claudeand others added 14 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>
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>

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 3 out of 3 changed files in this pull request and generated 5 comments.

Comments suppressed due to low confidence (1)

packages/core/src/workflow.test.ts:3943

  • This test doesn't seem to exercise the "exclude hook disposals" behavior: the provided events include hook_disposed, which removes the hook from invocationsQueue entirely, so the queue is empty at completion regardless of any filtering in warnPendingQueueItems(). If the intent is to assert that disposed-but-uncommitted hooks don't warn, the fixture should leave a disposed hook item in the queue (e.g., call hook.dispose() but omit the hook_disposed event).
 },
createdAt: new Date(),
},
{
eventId: 'event-2',
runId: workflowRun.runId,
eventType: 'hook_disposed',
correlationId: 'hook_01HK153X008RT6YEW43G8QX6JX',
createdAt: new Date(),
},
];
// Workflow creates hook, awaits one payload, hook is disposed
// (simulates `using` at function scope)
await runWorkflow(
`const createHook = globalThis[Symbol.for("WORKFLOW_CREATE_HOOK")];
async function workflow() {
const hook = createHook();
const result = await hook;
hook.dispose();
return result.message;
}${getWorkflowTransformCode('workflow')}`,
workflowRun,
events,
noEncryptionKey
);
const warnCalls = warnSpy.mock.calls.map((c) => c[0]);
expect(
warnCalls.some((msg: string) => msg.includes('uncommitted operation'))
).toBe(false);
} finally {
warnSpy.mockRestore();
}
});
it('should not warn when queue is empty on completion', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const ops: Promise<any>[] = [];
const workflowRun: WorkflowRun = {
runId: 'wrun_123',
workflowName: 'workflow',
status: 'running',
input: await dehydrateWorkflowArguments(
[],
'wrun_123',
noEncryptionKey,
ops
),
createdAt: new Date('2024-01-01T00:00:00.000Z'),
updatedAt: new Date('2024-01-01T00:00:00.000Z'),
startedAt: new Date('2024-01-01T00:00:00.000Z'),
deploymentId: 'test-deployment',
};
await runWorkflow(
`function workflow() { return "clean"; }${getWorkflowTransformCode('workflow')}`,
workflowRun,
[],
noEncryptionKey
);
const warnCalls = warnSpy.mock.calls.map((c) => c[0]);
expect(
warnCalls.some((msg: string) => msg.includes('uncommitted operation'))
).toBe(false);
} finally {
warnSpy.mockRestore();
}
});
});
});

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

Comment threadpackages/core/src/workflow.ts Outdated
Comment on lines +47 to +51
// Filter out hooks that are registered and alive (hasCreatedEvent && !disposed)
// — these are benign since the backend auto-disposes all hooks when a run
// reaches a terminal state
const items = [...pendingQueue.values()].filter(
(item) => !(item.type === 'hook' && item.hasCreatedEvent && !item.disposed)

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.

The hook filtering logic doesn't exclude hook disposals: hooks with disposed === true (set when hook.dispose() is called but before a hook_disposed event exists) will still be reported as "uncommitted", even though the PR description says hook disposals should be excluded because the backend auto-disposes at terminal state. Consider filtering out disposed hook queue items (and/or aligning the PR description/comment with the intended behavior).

Suggested change
// Filter out hooks that are registered and alive (hasCreatedEvent && !disposed)
// — these are benign since the backend auto-disposes all hooks when a run
// reaches a terminal state
constitems=[...pendingQueue.values()].filter(
(item)=>!(item.type==='hook'&&item.hasCreatedEvent&&!item.disposed)
// Filter out hooks that are either registered (hasCreatedEvent) or already
// disposed — these are benign since the backend auto-disposes all hooks
// when a run reaches a terminal state
constitems=[...pendingQueue.values()].filter(
(item)=>
!(
item.type==='hook'&&
(item.hasCreatedEvent||item.disposed)
)

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.

Fixed in 201fb65 — filter now excludes hooks with hasCreatedEvent || disposed, matching TooTallNate's suggestion below.

Comment on lines +58 to +61
return `step "${item.stepName}"`;
case 'hook':
return `hook (${item.correlationId})`;
case 'wait':

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 warning message includes the full hook token (hook (token: "...")). Hook tokens often act as shared secrets / auth credentials; logging them to console warnings can leak sensitive data. Prefer logging the hook correlationId, a redacted token (e.g., last 4 chars), or omit the token entirely.

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.

Went back to showing the token per TooTallNate's suggestion — this is a user-facing warning (not a server log), so the token is more meaningful than the correlationId. Fixed in 201fb65.

Comment on lines +55 to +64
const details = items.map((item) => {
switch (item.type) {
case 'step':
return `step "${item.stepName}"`;
case 'hook':
return `hook (${item.correlationId})`;
case 'wait':
return 'sleep';
default:
return `unknown (${(item as { type: string }).type})`;

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.

details relies on the switch being exhaustive. If a new QueueItem type is added later, this will silently produce undefined entries and log confusing output. Consider adding an explicit default that asserts never (or throws) so TypeScript enforces exhaustiveness here.

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.

Already fixed in the previous commit (86ce8f1) — there's a default case that returns unknown (${type}).

Comment on lines +3743 to +3776
// No step events — the unawaited step stays pending in the queue
const events: Event[] = [];

// Workflow calls step but doesn't await it, returns immediately
await runWorkflow(
`const add = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("add");
async function workflow() {
add(1, 2); // not awaited!
return "done";
}${getWorkflowTransformCode('workflow')}`,
workflowRun,
events,
noEncryptionKey
);

const warnCalls = warnSpy.mock.calls.map((c) => c[0]);
expect(
warnCalls.some(
(msg: string) =>
msg.includes('uncommitted operation') &&
msg.includes('step "add"')
)
).toBe(true);
expect(
warnCalls.some((msg: string) =>
msg.includes('Did you forget to `await`')
)
).toBe(true);
} finally {
warnSpy.mockRestore();
}
});

it('should warn when workflow fails with pending operations', async () => {

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 test provides a matching step_completed event for the invoked step. The step implementation deletes its invocationQueue entry on step_completed regardless of whether the returned Promise is awaited, so the queue will typically be empty by the time warnPendingQueueItems() runs and no warning will be emitted. To reliably test the new behavior, construct an event log where the step remains uncommitted at workflow completion (e.g., omit terminal events for that correlationId).

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.

Already fixed in 86ce8f1 — step events removed so the unawaited step stays pending.

Comment on lines +3817 to +3854
msg.includes('failed') && msg.includes('step "add"')
)
).toBe(true);
} finally {
warnSpy.mockRestore();
}
});

it('should not warn when only hook_disposed items remain in queue', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const ops: Promise<any>[] = [];
const workflowRun: WorkflowRun = {
runId: 'test-run-123',
workflowName: 'workflow',
status: 'running',
input: await dehydrateWorkflowArguments(
[],
'wrun_123',
noEncryptionKey,
ops
),
createdAt: new Date('2024-01-01T00:00:00.000Z'),
updatedAt: new Date('2024-01-01T00:00:00.000Z'),
startedAt: new Date('2024-01-01T00:00:00.000Z'),
deploymentId: 'test-deployment',
};

const events: Event[] = [
{
eventId: 'event-0',
runId: workflowRun.runId,
eventType: 'hook_created' as const,
correlationId: 'hook_01HK153X008RT6YEW43G8QX6JX',
eventData: {
token: 'test-token',
},
createdAt: new Date(),

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.

Similar to the previous test: because the events include step_completed, the step invocation is removed from invocationsQueue even if it isn't awaited. That means the warning may not be triggered, and the test may fail intermittently depending on scheduling. Consider changing the fixture so the step is still pending/uncommitted when the workflow errors (e.g., no terminal event for the invoked correlationId).

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.

Already fixed in 86ce8f1 — same fix as above.

- 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.

Clean, focused PR. The warning is a good DX improvement that will save users debugging time. The filter logic for hook disposals is correct, and the test coverage hits the important cases. A few minor suggestions inline.

Comment threadpackages/core/src/workflow.ts Outdated
// — these are benign since the backend auto-disposes all hooks when a run
// reaches a terminal state
const items = [...pendingQueue.values()].filter(
(item) => !(item.type === 'hook' && item.hasCreatedEvent && !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 filter condition !(item.type === 'hook' && item.hasCreatedEvent && !item.disposed) silences hooks that have been created and are actively waiting for payloads — correct, since the backend auto-disposes these.

But consider the edge case: a hook with hasCreatedEvent = undefined and disposed = undefined (i.e., a hook that was created in user code but the workflow completed before the first suspension could process it). This would not be filtered out — which is correct, since it represents a createHook() call that was never awaited. Just want to confirm this is intentional (I believe it is).

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.

Yes, that's intentional. A hook with hasCreatedEvent=undefined, disposed=undefined means createHook() was called but the workflow completed before any suspension — that's a forgotten await and should warn.

Comment threadpackages/core/src/workflow.ts Outdated
// — these are benign since the backend auto-disposes all hooks when a run
// reaches a terminal state
const items = [...pendingQueue.values()].filter(
(item) => !(item.type === 'hook' && item.hasCreatedEvent && !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.

Nit: Also worth noting — a hook with disposed = true but hasCreatedEvent = undefined (dispose called before first suspension) will pass through the filter and trigger a warning. This seems like a false positive: the user explicitly disposed the hook. The filter might be more precise as:

(item)=>!(item.type==='hook'&&(item.disposed||(item.hasCreatedEvent&&!item.disposed)))

Or equivalently, only warn for hooks that are neither disposed nor already created:

(item)=>!(item.type==='hook'&&(item.hasCreatedEvent||item.disposed))

This would skip warnings for any hook the user explicitly disposed, regardless of whether it was created yet. Though in practice, disposed && !hasCreatedEvent is rare (dispose before first suspension), so this may not matter.

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 catch — fixed in 201fb65. Filter is now \!(item.type === 'hook' && (item.hasCreatedEvent || item.disposed)), which skips warnings for any hook that's either alive or explicitly disposed.

Comment threadpackages/core/src/workflow.ts Outdated
Comment threadpackages/core/src/workflow.test.ts Outdated
}
});

it('should not warn when only hook_disposed items remain in queue', async () => {

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 test name says "hook_disposed items remain in queue" but the scenario is actually: hook_disposed is in the event log, which causes the hook consumer to remove the item from the queue (see hook.ts:124). So the queue ends up empty, not with disposed items. The test is correct in behavior — it verifies no warning fires — but the name is slightly misleading. Something like "should not warn when hooks are properly disposed" might be clearer.

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 201fb65 — renamed to "should not warn when hooks are properly disposed".


describe('pending queue warnings', () => {
it('should warn when workflow completes with an unawaited step', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});

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.

Suggestion: Consider extracting the WorkflowRun boilerplate into a helper at the top of this describe block, similar to how other test suites handle it. All four tests create nearly identical run objects. Something like:

functioncreateTestRun(overrides?: Partial<WorkflowRun>): WorkflowRun{ ... }

Not blocking — just a readability improvement.

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 suggestion — will leave for a follow-up since it's a readability improvement and doesn't affect behavior.

pranaygpand others added 7 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>
Log a warning when a workflow run completes or fails with uncommitted
operations still in the invocations queue (unawaited steps, hooks, or
sleeps). This helps users diagnose forgotten `await` calls.
Hook disposals are excluded from the warning since the backend
auto-disposes all hooks when a run reaches a terminal state.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Test that:
- Unawaited step triggers warning on successful completion
- Unawaited step triggers warning on workflow failure
- hook_disposed items are excluded from warning
- Clean workflow completion produces no warning
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Filter out hooks with hasCreatedEvent && !disposed (alive hooks) instead
of the removed hook_disposed queue item type.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove workflow from changeset (transitive via @workflow/core)
- Add default case to switch for exhaustiveness
- Use correlationId instead of token in warning message to avoid leaking
user-provided tokens into logs
- Remove step events from warning tests so unawaited steps stay pending
in the queue (step_completed would clear the item during replay)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Filter out disposed hooks from warning (not just created hooks)
- Show hook token instead of correlationId in warning (user-facing)
- Rename test to "should not warn when hooks are properly disposed"
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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.

5 participants

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

feat(core): warn on uncommitted operations when workflow completes - #1185

Merged
pranaygp merged 22 commits into
mainfrom
pranaygp/pgp/pending-queue-warning
Feb 27, 2026
Merged

feat(core): warn on uncommitted operations when workflow completes#1185
pranaygp merged 22 commits into
mainfrom
pranaygp/pgp/pending-queue-warning

Conversation

@pranaygp

Copy link
Copy Markdown
Contributor

Log a warning when a workflow run completes or fails with uncommitted
operations still in the invocations queue (unawaited steps, hooks, or
sleeps). This helps users diagnose forgotten await calls.

Hook disposals are excluded from the warning since the backend
auto-disposes all hooks when a run reaches a terminal state.

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

@changeset-bot

changeset-botBot commented Feb 25, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 201fb65

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 25, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

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

❌ Failed Tests

🐘 Local Postgres (1 failed)

hono-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-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-stable4517
✅ nextjs-turbopack-canary5003
✅ nextjs-turbopack-stable5003
✅ 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: failure
  • Local Postgres: failure
  • Windows: success

Check the workflow run for details.

@pranaygpGraphite App

pranaygp commented Feb 25, 2026

Copy link
Copy Markdown
ContributorAuthor

Comment thread.changeset/twelve-olives-cross.md Outdated
@pranaygp
pranaygp marked this pull request as ready for review February 25, 2026 05:33
@pranaygp
pranaygp requested a review from a team as a code ownerFebruary 25, 2026 05:33
CopilotAI review requested due to automatic review settings February 25, 2026 05:33
@vercel

vercelBot commented Feb 25, 2026

Copy link
Copy Markdown
Contributor

claudeand others added 14 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>
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>

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 3 out of 3 changed files in this pull request and generated 5 comments.

Comments suppressed due to low confidence (1)

packages/core/src/workflow.test.ts:3943

  • This test doesn't seem to exercise the "exclude hook disposals" behavior: the provided events include hook_disposed, which removes the hook from invocationsQueue entirely, so the queue is empty at completion regardless of any filtering in warnPendingQueueItems(). If the intent is to assert that disposed-but-uncommitted hooks don't warn, the fixture should leave a disposed hook item in the queue (e.g., call hook.dispose() but omit the hook_disposed event).
 },
createdAt: new Date(),
},
{
eventId: 'event-2',
runId: workflowRun.runId,
eventType: 'hook_disposed',
correlationId: 'hook_01HK153X008RT6YEW43G8QX6JX',
createdAt: new Date(),
},
];
// Workflow creates hook, awaits one payload, hook is disposed
// (simulates `using` at function scope)
await runWorkflow(
`const createHook = globalThis[Symbol.for("WORKFLOW_CREATE_HOOK")];
async function workflow() {
const hook = createHook();
const result = await hook;
hook.dispose();
return result.message;
}${getWorkflowTransformCode('workflow')}`,
workflowRun,
events,
noEncryptionKey
);
const warnCalls = warnSpy.mock.calls.map((c) => c[0]);
expect(
warnCalls.some((msg: string) => msg.includes('uncommitted operation'))
).toBe(false);
} finally {
warnSpy.mockRestore();
}
});
it('should not warn when queue is empty on completion', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const ops: Promise<any>[] = [];
const workflowRun: WorkflowRun = {
runId: 'wrun_123',
workflowName: 'workflow',
status: 'running',
input: await dehydrateWorkflowArguments(
[],
'wrun_123',
noEncryptionKey,
ops
),
createdAt: new Date('2024-01-01T00:00:00.000Z'),
updatedAt: new Date('2024-01-01T00:00:00.000Z'),
startedAt: new Date('2024-01-01T00:00:00.000Z'),
deploymentId: 'test-deployment',
};
await runWorkflow(
`function workflow() { return "clean"; }${getWorkflowTransformCode('workflow')}`,
workflowRun,
[],
noEncryptionKey
);
const warnCalls = warnSpy.mock.calls.map((c) => c[0]);
expect(
warnCalls.some((msg: string) => msg.includes('uncommitted operation'))
).toBe(false);
} finally {
warnSpy.mockRestore();
}
});
});
});

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

Comment threadpackages/core/src/workflow.ts Outdated
Comment on lines +47 to +51
// Filter out hooks that are registered and alive (hasCreatedEvent && !disposed)
// — these are benign since the backend auto-disposes all hooks when a run
// reaches a terminal state
const items = [...pendingQueue.values()].filter(
(item) => !(item.type === 'hook' && item.hasCreatedEvent && !item.disposed)

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.

The hook filtering logic doesn't exclude hook disposals: hooks with disposed === true (set when hook.dispose() is called but before a hook_disposed event exists) will still be reported as "uncommitted", even though the PR description says hook disposals should be excluded because the backend auto-disposes at terminal state. Consider filtering out disposed hook queue items (and/or aligning the PR description/comment with the intended behavior).

Suggested change
// Filter out hooks that are registered and alive (hasCreatedEvent && !disposed)
// — these are benign since the backend auto-disposes all hooks when a run
// reaches a terminal state
constitems=[...pendingQueue.values()].filter(
(item)=>!(item.type==='hook'&&item.hasCreatedEvent&&!item.disposed)
// Filter out hooks that are either registered (hasCreatedEvent) or already
// disposed — these are benign since the backend auto-disposes all hooks
// when a run reaches a terminal state
constitems=[...pendingQueue.values()].filter(
(item)=>
!(
item.type==='hook'&&
(item.hasCreatedEvent||item.disposed)
)

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.

Fixed in 201fb65 — filter now excludes hooks with hasCreatedEvent || disposed, matching TooTallNate's suggestion below.

Comment on lines +58 to +61
return `step "${item.stepName}"`;
case 'hook':
return `hook (${item.correlationId})`;
case 'wait':

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 warning message includes the full hook token (hook (token: "...")). Hook tokens often act as shared secrets / auth credentials; logging them to console warnings can leak sensitive data. Prefer logging the hook correlationId, a redacted token (e.g., last 4 chars), or omit the token entirely.

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.

Went back to showing the token per TooTallNate's suggestion — this is a user-facing warning (not a server log), so the token is more meaningful than the correlationId. Fixed in 201fb65.

Comment on lines +55 to +64
const details = items.map((item) => {
switch (item.type) {
case 'step':
return `step "${item.stepName}"`;
case 'hook':
return `hook (${item.correlationId})`;
case 'wait':
return 'sleep';
default:
return `unknown (${(item as { type: string }).type})`;

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.

details relies on the switch being exhaustive. If a new QueueItem type is added later, this will silently produce undefined entries and log confusing output. Consider adding an explicit default that asserts never (or throws) so TypeScript enforces exhaustiveness here.

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.

Already fixed in the previous commit (86ce8f1) — there's a default case that returns unknown (${type}).

Comment on lines +3743 to +3776
// No step events — the unawaited step stays pending in the queue
const events: Event[] = [];

// Workflow calls step but doesn't await it, returns immediately
await runWorkflow(
`const add = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("add");
async function workflow() {
add(1, 2); // not awaited!
return "done";
}${getWorkflowTransformCode('workflow')}`,
workflowRun,
events,
noEncryptionKey
);

const warnCalls = warnSpy.mock.calls.map((c) => c[0]);
expect(
warnCalls.some(
(msg: string) =>
msg.includes('uncommitted operation') &&
msg.includes('step "add"')
)
).toBe(true);
expect(
warnCalls.some((msg: string) =>
msg.includes('Did you forget to `await`')
)
).toBe(true);
} finally {
warnSpy.mockRestore();
}
});

it('should warn when workflow fails with pending operations', async () => {

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 test provides a matching step_completed event for the invoked step. The step implementation deletes its invocationQueue entry on step_completed regardless of whether the returned Promise is awaited, so the queue will typically be empty by the time warnPendingQueueItems() runs and no warning will be emitted. To reliably test the new behavior, construct an event log where the step remains uncommitted at workflow completion (e.g., omit terminal events for that correlationId).

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.

Already fixed in 86ce8f1 — step events removed so the unawaited step stays pending.

Comment on lines +3817 to +3854
msg.includes('failed') && msg.includes('step "add"')
)
).toBe(true);
} finally {
warnSpy.mockRestore();
}
});

it('should not warn when only hook_disposed items remain in queue', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const ops: Promise<any>[] = [];
const workflowRun: WorkflowRun = {
runId: 'test-run-123',
workflowName: 'workflow',
status: 'running',
input: await dehydrateWorkflowArguments(
[],
'wrun_123',
noEncryptionKey,
ops
),
createdAt: new Date('2024-01-01T00:00:00.000Z'),
updatedAt: new Date('2024-01-01T00:00:00.000Z'),
startedAt: new Date('2024-01-01T00:00:00.000Z'),
deploymentId: 'test-deployment',
};

const events: Event[] = [
{
eventId: 'event-0',
runId: workflowRun.runId,
eventType: 'hook_created' as const,
correlationId: 'hook_01HK153X008RT6YEW43G8QX6JX',
eventData: {
token: 'test-token',
},
createdAt: new Date(),

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.

Similar to the previous test: because the events include step_completed, the step invocation is removed from invocationsQueue even if it isn't awaited. That means the warning may not be triggered, and the test may fail intermittently depending on scheduling. Consider changing the fixture so the step is still pending/uncommitted when the workflow errors (e.g., no terminal event for the invoked correlationId).

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.

Already fixed in 86ce8f1 — same fix as above.

- 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.

Clean, focused PR. The warning is a good DX improvement that will save users debugging time. The filter logic for hook disposals is correct, and the test coverage hits the important cases. A few minor suggestions inline.

Comment threadpackages/core/src/workflow.ts Outdated
// — these are benign since the backend auto-disposes all hooks when a run
// reaches a terminal state
const items = [...pendingQueue.values()].filter(
(item) => !(item.type === 'hook' && item.hasCreatedEvent && !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 filter condition !(item.type === 'hook' && item.hasCreatedEvent && !item.disposed) silences hooks that have been created and are actively waiting for payloads — correct, since the backend auto-disposes these.

But consider the edge case: a hook with hasCreatedEvent = undefined and disposed = undefined (i.e., a hook that was created in user code but the workflow completed before the first suspension could process it). This would not be filtered out — which is correct, since it represents a createHook() call that was never awaited. Just want to confirm this is intentional (I believe it is).

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.

Yes, that's intentional. A hook with hasCreatedEvent=undefined, disposed=undefined means createHook() was called but the workflow completed before any suspension — that's a forgotten await and should warn.

Comment threadpackages/core/src/workflow.ts Outdated
// — these are benign since the backend auto-disposes all hooks when a run
// reaches a terminal state
const items = [...pendingQueue.values()].filter(
(item) => !(item.type === 'hook' && item.hasCreatedEvent && !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.

Nit: Also worth noting — a hook with disposed = true but hasCreatedEvent = undefined (dispose called before first suspension) will pass through the filter and trigger a warning. This seems like a false positive: the user explicitly disposed the hook. The filter might be more precise as:

(item)=>!(item.type==='hook'&&(item.disposed||(item.hasCreatedEvent&&!item.disposed)))

Or equivalently, only warn for hooks that are neither disposed nor already created:

(item)=>!(item.type==='hook'&&(item.hasCreatedEvent||item.disposed))

This would skip warnings for any hook the user explicitly disposed, regardless of whether it was created yet. Though in practice, disposed && !hasCreatedEvent is rare (dispose before first suspension), so this may not matter.

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 catch — fixed in 201fb65. Filter is now \!(item.type === 'hook' && (item.hasCreatedEvent || item.disposed)), which skips warnings for any hook that's either alive or explicitly disposed.

Comment threadpackages/core/src/workflow.ts Outdated
Comment threadpackages/core/src/workflow.test.ts Outdated
}
});

it('should not warn when only hook_disposed items remain in queue', async () => {

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 test name says "hook_disposed items remain in queue" but the scenario is actually: hook_disposed is in the event log, which causes the hook consumer to remove the item from the queue (see hook.ts:124). So the queue ends up empty, not with disposed items. The test is correct in behavior — it verifies no warning fires — but the name is slightly misleading. Something like "should not warn when hooks are properly disposed" might be clearer.

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 201fb65 — renamed to "should not warn when hooks are properly disposed".


describe('pending queue warnings', () => {
it('should warn when workflow completes with an unawaited step', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});

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.

Suggestion: Consider extracting the WorkflowRun boilerplate into a helper at the top of this describe block, similar to how other test suites handle it. All four tests create nearly identical run objects. Something like:

functioncreateTestRun(overrides?: Partial<WorkflowRun>): WorkflowRun{ ... }

Not blocking — just a readability improvement.

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 suggestion — will leave for a follow-up since it's a readability improvement and doesn't affect behavior.

pranaygpand others added 7 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>
Log a warning when a workflow run completes or fails with uncommitted
operations still in the invocations queue (unawaited steps, hooks, or
sleeps). This helps users diagnose forgotten `await` calls.
Hook disposals are excluded from the warning since the backend
auto-disposes all hooks when a run reaches a terminal state.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Test that:
- Unawaited step triggers warning on successful completion
- Unawaited step triggers warning on workflow failure
- hook_disposed items are excluded from warning
- Clean workflow completion produces no warning
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Filter out hooks with hasCreatedEvent && !disposed (alive hooks) instead
of the removed hook_disposed queue item type.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove workflow from changeset (transitive via @workflow/core)
- Add default case to switch for exhaustiveness
- Use correlationId instead of token in warning message to avoid leaking
user-provided tokens into logs
- Remove step events from warning tests so unawaited steps stay pending
in the queue (step_completed would clear the item during replay)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Filter out disposed hooks from warning (not just created hooks)
- Show hook token instead of correlationId in warning (user-facing)
- Rename test to "should not warn when hooks are properly disposed"
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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.

5 participants

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

feat(core): warn on uncommitted operations when workflow completes - #1185

Merged
pranaygp merged 22 commits into
mainfrom
pranaygp/pgp/pending-queue-warning
Feb 27, 2026
Merged

feat(core): warn on uncommitted operations when workflow completes#1185
pranaygp merged 22 commits into
mainfrom
pranaygp/pgp/pending-queue-warning

Conversation

@pranaygp

Copy link
Copy Markdown
Contributor

Log a warning when a workflow run completes or fails with uncommitted
operations still in the invocations queue (unawaited steps, hooks, or
sleeps). This helps users diagnose forgotten await calls.

Hook disposals are excluded from the warning since the backend
auto-disposes all hooks when a run reaches a terminal state.

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

@changeset-bot

changeset-botBot commented Feb 25, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 201fb65

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 25, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

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

❌ Failed Tests

🐘 Local Postgres (1 failed)

hono-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-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-stable4517
✅ nextjs-turbopack-canary5003
✅ nextjs-turbopack-stable5003
✅ 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: failure
  • Local Postgres: failure
  • Windows: success

Check the workflow run for details.

@pranaygpGraphite App

pranaygp commented Feb 25, 2026

Copy link
Copy Markdown
ContributorAuthor

Comment thread.changeset/twelve-olives-cross.md Outdated
@pranaygp
pranaygp marked this pull request as ready for review February 25, 2026 05:33
@pranaygp
pranaygp requested a review from a team as a code ownerFebruary 25, 2026 05:33
CopilotAI review requested due to automatic review settings February 25, 2026 05:33
@vercel

vercelBot commented Feb 25, 2026

Copy link
Copy Markdown
Contributor

claudeand others added 14 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>
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>

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 3 out of 3 changed files in this pull request and generated 5 comments.

Comments suppressed due to low confidence (1)

packages/core/src/workflow.test.ts:3943

  • This test doesn't seem to exercise the "exclude hook disposals" behavior: the provided events include hook_disposed, which removes the hook from invocationsQueue entirely, so the queue is empty at completion regardless of any filtering in warnPendingQueueItems(). If the intent is to assert that disposed-but-uncommitted hooks don't warn, the fixture should leave a disposed hook item in the queue (e.g., call hook.dispose() but omit the hook_disposed event).
 },
createdAt: new Date(),
},
{
eventId: 'event-2',
runId: workflowRun.runId,
eventType: 'hook_disposed',
correlationId: 'hook_01HK153X008RT6YEW43G8QX6JX',
createdAt: new Date(),
},
];
// Workflow creates hook, awaits one payload, hook is disposed
// (simulates `using` at function scope)
await runWorkflow(
`const createHook = globalThis[Symbol.for("WORKFLOW_CREATE_HOOK")];
async function workflow() {
const hook = createHook();
const result = await hook;
hook.dispose();
return result.message;
}${getWorkflowTransformCode('workflow')}`,
workflowRun,
events,
noEncryptionKey
);
const warnCalls = warnSpy.mock.calls.map((c) => c[0]);
expect(
warnCalls.some((msg: string) => msg.includes('uncommitted operation'))
).toBe(false);
} finally {
warnSpy.mockRestore();
}
});
it('should not warn when queue is empty on completion', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const ops: Promise<any>[] = [];
const workflowRun: WorkflowRun = {
runId: 'wrun_123',
workflowName: 'workflow',
status: 'running',
input: await dehydrateWorkflowArguments(
[],
'wrun_123',
noEncryptionKey,
ops
),
createdAt: new Date('2024-01-01T00:00:00.000Z'),
updatedAt: new Date('2024-01-01T00:00:00.000Z'),
startedAt: new Date('2024-01-01T00:00:00.000Z'),
deploymentId: 'test-deployment',
};
await runWorkflow(
`function workflow() { return "clean"; }${getWorkflowTransformCode('workflow')}`,
workflowRun,
[],
noEncryptionKey
);
const warnCalls = warnSpy.mock.calls.map((c) => c[0]);
expect(
warnCalls.some((msg: string) => msg.includes('uncommitted operation'))
).toBe(false);
} finally {
warnSpy.mockRestore();
}
});
});
});

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

Comment threadpackages/core/src/workflow.ts Outdated
Comment on lines +47 to +51
// Filter out hooks that are registered and alive (hasCreatedEvent && !disposed)
// — these are benign since the backend auto-disposes all hooks when a run
// reaches a terminal state
const items = [...pendingQueue.values()].filter(
(item) => !(item.type === 'hook' && item.hasCreatedEvent && !item.disposed)

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.

The hook filtering logic doesn't exclude hook disposals: hooks with disposed === true (set when hook.dispose() is called but before a hook_disposed event exists) will still be reported as "uncommitted", even though the PR description says hook disposals should be excluded because the backend auto-disposes at terminal state. Consider filtering out disposed hook queue items (and/or aligning the PR description/comment with the intended behavior).

Suggested change
// Filter out hooks that are registered and alive (hasCreatedEvent && !disposed)
// — these are benign since the backend auto-disposes all hooks when a run
// reaches a terminal state
constitems=[...pendingQueue.values()].filter(
(item)=>!(item.type==='hook'&&item.hasCreatedEvent&&!item.disposed)
// Filter out hooks that are either registered (hasCreatedEvent) or already
// disposed — these are benign since the backend auto-disposes all hooks
// when a run reaches a terminal state
constitems=[...pendingQueue.values()].filter(
(item)=>
!(
item.type==='hook'&&
(item.hasCreatedEvent||item.disposed)
)

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.

Fixed in 201fb65 — filter now excludes hooks with hasCreatedEvent || disposed, matching TooTallNate's suggestion below.

Comment on lines +58 to +61
return `step "${item.stepName}"`;
case 'hook':
return `hook (${item.correlationId})`;
case 'wait':

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 warning message includes the full hook token (hook (token: "...")). Hook tokens often act as shared secrets / auth credentials; logging them to console warnings can leak sensitive data. Prefer logging the hook correlationId, a redacted token (e.g., last 4 chars), or omit the token entirely.

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.

Went back to showing the token per TooTallNate's suggestion — this is a user-facing warning (not a server log), so the token is more meaningful than the correlationId. Fixed in 201fb65.

Comment on lines +55 to +64
const details = items.map((item) => {
switch (item.type) {
case 'step':
return `step "${item.stepName}"`;
case 'hook':
return `hook (${item.correlationId})`;
case 'wait':
return 'sleep';
default:
return `unknown (${(item as { type: string }).type})`;

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.

details relies on the switch being exhaustive. If a new QueueItem type is added later, this will silently produce undefined entries and log confusing output. Consider adding an explicit default that asserts never (or throws) so TypeScript enforces exhaustiveness here.

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.

Already fixed in the previous commit (86ce8f1) — there's a default case that returns unknown (${type}).

Comment on lines +3743 to +3776
// No step events — the unawaited step stays pending in the queue
const events: Event[] = [];

// Workflow calls step but doesn't await it, returns immediately
await runWorkflow(
`const add = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("add");
async function workflow() {
add(1, 2); // not awaited!
return "done";
}${getWorkflowTransformCode('workflow')}`,
workflowRun,
events,
noEncryptionKey
);

const warnCalls = warnSpy.mock.calls.map((c) => c[0]);
expect(
warnCalls.some(
(msg: string) =>
msg.includes('uncommitted operation') &&
msg.includes('step "add"')
)
).toBe(true);
expect(
warnCalls.some((msg: string) =>
msg.includes('Did you forget to `await`')
)
).toBe(true);
} finally {
warnSpy.mockRestore();
}
});

it('should warn when workflow fails with pending operations', async () => {

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 test provides a matching step_completed event for the invoked step. The step implementation deletes its invocationQueue entry on step_completed regardless of whether the returned Promise is awaited, so the queue will typically be empty by the time warnPendingQueueItems() runs and no warning will be emitted. To reliably test the new behavior, construct an event log where the step remains uncommitted at workflow completion (e.g., omit terminal events for that correlationId).

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.

Already fixed in 86ce8f1 — step events removed so the unawaited step stays pending.

Comment on lines +3817 to +3854
msg.includes('failed') && msg.includes('step "add"')
)
).toBe(true);
} finally {
warnSpy.mockRestore();
}
});

it('should not warn when only hook_disposed items remain in queue', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const ops: Promise<any>[] = [];
const workflowRun: WorkflowRun = {
runId: 'test-run-123',
workflowName: 'workflow',
status: 'running',
input: await dehydrateWorkflowArguments(
[],
'wrun_123',
noEncryptionKey,
ops
),
createdAt: new Date('2024-01-01T00:00:00.000Z'),
updatedAt: new Date('2024-01-01T00:00:00.000Z'),
startedAt: new Date('2024-01-01T00:00:00.000Z'),
deploymentId: 'test-deployment',
};

const events: Event[] = [
{
eventId: 'event-0',
runId: workflowRun.runId,
eventType: 'hook_created' as const,
correlationId: 'hook_01HK153X008RT6YEW43G8QX6JX',
eventData: {
token: 'test-token',
},
createdAt: new Date(),

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.

Similar to the previous test: because the events include step_completed, the step invocation is removed from invocationsQueue even if it isn't awaited. That means the warning may not be triggered, and the test may fail intermittently depending on scheduling. Consider changing the fixture so the step is still pending/uncommitted when the workflow errors (e.g., no terminal event for the invoked correlationId).

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.

Already fixed in 86ce8f1 — same fix as above.

- 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.

Clean, focused PR. The warning is a good DX improvement that will save users debugging time. The filter logic for hook disposals is correct, and the test coverage hits the important cases. A few minor suggestions inline.

Comment threadpackages/core/src/workflow.ts Outdated
// — these are benign since the backend auto-disposes all hooks when a run
// reaches a terminal state
const items = [...pendingQueue.values()].filter(
(item) => !(item.type === 'hook' && item.hasCreatedEvent && !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 filter condition !(item.type === 'hook' && item.hasCreatedEvent && !item.disposed) silences hooks that have been created and are actively waiting for payloads — correct, since the backend auto-disposes these.

But consider the edge case: a hook with hasCreatedEvent = undefined and disposed = undefined (i.e., a hook that was created in user code but the workflow completed before the first suspension could process it). This would not be filtered out — which is correct, since it represents a createHook() call that was never awaited. Just want to confirm this is intentional (I believe it is).

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.

Yes, that's intentional. A hook with hasCreatedEvent=undefined, disposed=undefined means createHook() was called but the workflow completed before any suspension — that's a forgotten await and should warn.

Comment threadpackages/core/src/workflow.ts Outdated
// — these are benign since the backend auto-disposes all hooks when a run
// reaches a terminal state
const items = [...pendingQueue.values()].filter(
(item) => !(item.type === 'hook' && item.hasCreatedEvent && !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.

Nit: Also worth noting — a hook with disposed = true but hasCreatedEvent = undefined (dispose called before first suspension) will pass through the filter and trigger a warning. This seems like a false positive: the user explicitly disposed the hook. The filter might be more precise as:

(item)=>!(item.type==='hook'&&(item.disposed||(item.hasCreatedEvent&&!item.disposed)))

Or equivalently, only warn for hooks that are neither disposed nor already created:

(item)=>!(item.type==='hook'&&(item.hasCreatedEvent||item.disposed))

This would skip warnings for any hook the user explicitly disposed, regardless of whether it was created yet. Though in practice, disposed && !hasCreatedEvent is rare (dispose before first suspension), so this may not matter.

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 catch — fixed in 201fb65. Filter is now \!(item.type === 'hook' && (item.hasCreatedEvent || item.disposed)), which skips warnings for any hook that's either alive or explicitly disposed.

Comment threadpackages/core/src/workflow.ts Outdated
Comment threadpackages/core/src/workflow.test.ts Outdated
}
});

it('should not warn when only hook_disposed items remain in queue', async () => {

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 test name says "hook_disposed items remain in queue" but the scenario is actually: hook_disposed is in the event log, which causes the hook consumer to remove the item from the queue (see hook.ts:124). So the queue ends up empty, not with disposed items. The test is correct in behavior — it verifies no warning fires — but the name is slightly misleading. Something like "should not warn when hooks are properly disposed" might be clearer.

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 201fb65 — renamed to "should not warn when hooks are properly disposed".


describe('pending queue warnings', () => {
it('should warn when workflow completes with an unawaited step', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});

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.

Suggestion: Consider extracting the WorkflowRun boilerplate into a helper at the top of this describe block, similar to how other test suites handle it. All four tests create nearly identical run objects. Something like:

functioncreateTestRun(overrides?: Partial<WorkflowRun>): WorkflowRun{ ... }

Not blocking — just a readability improvement.

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 suggestion — will leave for a follow-up since it's a readability improvement and doesn't affect behavior.

pranaygpand others added 7 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>
Log a warning when a workflow run completes or fails with uncommitted
operations still in the invocations queue (unawaited steps, hooks, or
sleeps). This helps users diagnose forgotten `await` calls.
Hook disposals are excluded from the warning since the backend
auto-disposes all hooks when a run reaches a terminal state.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Test that:
- Unawaited step triggers warning on successful completion
- Unawaited step triggers warning on workflow failure
- hook_disposed items are excluded from warning
- Clean workflow completion produces no warning
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Filter out hooks with hasCreatedEvent && !disposed (alive hooks) instead
of the removed hook_disposed queue item type.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove workflow from changeset (transitive via @workflow/core)
- Add default case to switch for exhaustiveness
- Use correlationId instead of token in warning message to avoid leaking
user-provided tokens into logs
- Remove step events from warning tests so unawaited steps stay pending
in the queue (step_completed would clear the item during replay)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Filter out disposed hooks from warning (not just created hooks)
- Show hook token instead of correlationId in warning (user-facing)
- Rename test to "should not warn when hooks are properly disposed"
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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.

5 participants

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

feat(core): warn on uncommitted operations when workflow completes - #1185

Merged
pranaygp merged 22 commits into
mainfrom
pranaygp/pgp/pending-queue-warning
Feb 27, 2026
Merged

feat(core): warn on uncommitted operations when workflow completes#1185
pranaygp merged 22 commits into
mainfrom
pranaygp/pgp/pending-queue-warning

Conversation

@pranaygp

Copy link
Copy Markdown
Contributor

Log a warning when a workflow run completes or fails with uncommitted
operations still in the invocations queue (unawaited steps, hooks, or
sleeps). This helps users diagnose forgotten await calls.

Hook disposals are excluded from the warning since the backend
auto-disposes all hooks when a run reaches a terminal state.

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

@changeset-bot

changeset-botBot commented Feb 25, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 201fb65

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 25, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

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

❌ Failed Tests

🐘 Local Postgres (1 failed)

hono-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-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-stable4517
✅ nextjs-turbopack-canary5003
✅ nextjs-turbopack-stable5003
✅ 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: failure
  • Local Postgres: failure
  • Windows: success

Check the workflow run for details.

@pranaygpGraphite App

pranaygp commented Feb 25, 2026

Copy link
Copy Markdown
ContributorAuthor

Comment thread.changeset/twelve-olives-cross.md Outdated
@pranaygp
pranaygp marked this pull request as ready for review February 25, 2026 05:33
@pranaygp
pranaygp requested a review from a team as a code ownerFebruary 25, 2026 05:33
CopilotAI review requested due to automatic review settings February 25, 2026 05:33
@vercel

vercelBot commented Feb 25, 2026

Copy link
Copy Markdown
Contributor

claudeand others added 14 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>
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>

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 3 out of 3 changed files in this pull request and generated 5 comments.

Comments suppressed due to low confidence (1)

packages/core/src/workflow.test.ts:3943

  • This test doesn't seem to exercise the "exclude hook disposals" behavior: the provided events include hook_disposed, which removes the hook from invocationsQueue entirely, so the queue is empty at completion regardless of any filtering in warnPendingQueueItems(). If the intent is to assert that disposed-but-uncommitted hooks don't warn, the fixture should leave a disposed hook item in the queue (e.g., call hook.dispose() but omit the hook_disposed event).
 },
createdAt: new Date(),
},
{
eventId: 'event-2',
runId: workflowRun.runId,
eventType: 'hook_disposed',
correlationId: 'hook_01HK153X008RT6YEW43G8QX6JX',
createdAt: new Date(),
},
];
// Workflow creates hook, awaits one payload, hook is disposed
// (simulates `using` at function scope)
await runWorkflow(
`const createHook = globalThis[Symbol.for("WORKFLOW_CREATE_HOOK")];
async function workflow() {
const hook = createHook();
const result = await hook;
hook.dispose();
return result.message;
}${getWorkflowTransformCode('workflow')}`,
workflowRun,
events,
noEncryptionKey
);
const warnCalls = warnSpy.mock.calls.map((c) => c[0]);
expect(
warnCalls.some((msg: string) => msg.includes('uncommitted operation'))
).toBe(false);
} finally {
warnSpy.mockRestore();
}
});
it('should not warn when queue is empty on completion', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const ops: Promise<any>[] = [];
const workflowRun: WorkflowRun = {
runId: 'wrun_123',
workflowName: 'workflow',
status: 'running',
input: await dehydrateWorkflowArguments(
[],
'wrun_123',
noEncryptionKey,
ops
),
createdAt: new Date('2024-01-01T00:00:00.000Z'),
updatedAt: new Date('2024-01-01T00:00:00.000Z'),
startedAt: new Date('2024-01-01T00:00:00.000Z'),
deploymentId: 'test-deployment',
};
await runWorkflow(
`function workflow() { return "clean"; }${getWorkflowTransformCode('workflow')}`,
workflowRun,
[],
noEncryptionKey
);
const warnCalls = warnSpy.mock.calls.map((c) => c[0]);
expect(
warnCalls.some((msg: string) => msg.includes('uncommitted operation'))
).toBe(false);
} finally {
warnSpy.mockRestore();
}
});
});
});

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

Comment threadpackages/core/src/workflow.ts Outdated
Comment on lines +47 to +51
// Filter out hooks that are registered and alive (hasCreatedEvent && !disposed)
// — these are benign since the backend auto-disposes all hooks when a run
// reaches a terminal state
const items = [...pendingQueue.values()].filter(
(item) => !(item.type === 'hook' && item.hasCreatedEvent && !item.disposed)

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.

The hook filtering logic doesn't exclude hook disposals: hooks with disposed === true (set when hook.dispose() is called but before a hook_disposed event exists) will still be reported as "uncommitted", even though the PR description says hook disposals should be excluded because the backend auto-disposes at terminal state. Consider filtering out disposed hook queue items (and/or aligning the PR description/comment with the intended behavior).

Suggested change
// Filter out hooks that are registered and alive (hasCreatedEvent && !disposed)
// — these are benign since the backend auto-disposes all hooks when a run
// reaches a terminal state
constitems=[...pendingQueue.values()].filter(
(item)=>!(item.type==='hook'&&item.hasCreatedEvent&&!item.disposed)
// Filter out hooks that are either registered (hasCreatedEvent) or already
// disposed — these are benign since the backend auto-disposes all hooks
// when a run reaches a terminal state
constitems=[...pendingQueue.values()].filter(
(item)=>
!(
item.type==='hook'&&
(item.hasCreatedEvent||item.disposed)
)

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.

Fixed in 201fb65 — filter now excludes hooks with hasCreatedEvent || disposed, matching TooTallNate's suggestion below.

Comment on lines +58 to +61
return `step "${item.stepName}"`;
case 'hook':
return `hook (${item.correlationId})`;
case 'wait':

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 warning message includes the full hook token (hook (token: "...")). Hook tokens often act as shared secrets / auth credentials; logging them to console warnings can leak sensitive data. Prefer logging the hook correlationId, a redacted token (e.g., last 4 chars), or omit the token entirely.

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.

Went back to showing the token per TooTallNate's suggestion — this is a user-facing warning (not a server log), so the token is more meaningful than the correlationId. Fixed in 201fb65.

Comment on lines +55 to +64
const details = items.map((item) => {
switch (item.type) {
case 'step':
return `step "${item.stepName}"`;
case 'hook':
return `hook (${item.correlationId})`;
case 'wait':
return 'sleep';
default:
return `unknown (${(item as { type: string }).type})`;

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.

details relies on the switch being exhaustive. If a new QueueItem type is added later, this will silently produce undefined entries and log confusing output. Consider adding an explicit default that asserts never (or throws) so TypeScript enforces exhaustiveness here.

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.

Already fixed in the previous commit (86ce8f1) — there's a default case that returns unknown (${type}).

Comment on lines +3743 to +3776
// No step events — the unawaited step stays pending in the queue
const events: Event[] = [];

// Workflow calls step but doesn't await it, returns immediately
await runWorkflow(
`const add = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("add");
async function workflow() {
add(1, 2); // not awaited!
return "done";
}${getWorkflowTransformCode('workflow')}`,
workflowRun,
events,
noEncryptionKey
);

const warnCalls = warnSpy.mock.calls.map((c) => c[0]);
expect(
warnCalls.some(
(msg: string) =>
msg.includes('uncommitted operation') &&
msg.includes('step "add"')
)
).toBe(true);
expect(
warnCalls.some((msg: string) =>
msg.includes('Did you forget to `await`')
)
).toBe(true);
} finally {
warnSpy.mockRestore();
}
});

it('should warn when workflow fails with pending operations', async () => {

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 test provides a matching step_completed event for the invoked step. The step implementation deletes its invocationQueue entry on step_completed regardless of whether the returned Promise is awaited, so the queue will typically be empty by the time warnPendingQueueItems() runs and no warning will be emitted. To reliably test the new behavior, construct an event log where the step remains uncommitted at workflow completion (e.g., omit terminal events for that correlationId).

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.

Already fixed in 86ce8f1 — step events removed so the unawaited step stays pending.

Comment on lines +3817 to +3854
msg.includes('failed') && msg.includes('step "add"')
)
).toBe(true);
} finally {
warnSpy.mockRestore();
}
});

it('should not warn when only hook_disposed items remain in queue', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const ops: Promise<any>[] = [];
const workflowRun: WorkflowRun = {
runId: 'test-run-123',
workflowName: 'workflow',
status: 'running',
input: await dehydrateWorkflowArguments(
[],
'wrun_123',
noEncryptionKey,
ops
),
createdAt: new Date('2024-01-01T00:00:00.000Z'),
updatedAt: new Date('2024-01-01T00:00:00.000Z'),
startedAt: new Date('2024-01-01T00:00:00.000Z'),
deploymentId: 'test-deployment',
};

const events: Event[] = [
{
eventId: 'event-0',
runId: workflowRun.runId,
eventType: 'hook_created' as const,
correlationId: 'hook_01HK153X008RT6YEW43G8QX6JX',
eventData: {
token: 'test-token',
},
createdAt: new Date(),

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.

Similar to the previous test: because the events include step_completed, the step invocation is removed from invocationsQueue even if it isn't awaited. That means the warning may not be triggered, and the test may fail intermittently depending on scheduling. Consider changing the fixture so the step is still pending/uncommitted when the workflow errors (e.g., no terminal event for the invoked correlationId).

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.

Already fixed in 86ce8f1 — same fix as above.

- 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.

Clean, focused PR. The warning is a good DX improvement that will save users debugging time. The filter logic for hook disposals is correct, and the test coverage hits the important cases. A few minor suggestions inline.

Comment threadpackages/core/src/workflow.ts Outdated
// — these are benign since the backend auto-disposes all hooks when a run
// reaches a terminal state
const items = [...pendingQueue.values()].filter(
(item) => !(item.type === 'hook' && item.hasCreatedEvent && !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 filter condition !(item.type === 'hook' && item.hasCreatedEvent && !item.disposed) silences hooks that have been created and are actively waiting for payloads — correct, since the backend auto-disposes these.

But consider the edge case: a hook with hasCreatedEvent = undefined and disposed = undefined (i.e., a hook that was created in user code but the workflow completed before the first suspension could process it). This would not be filtered out — which is correct, since it represents a createHook() call that was never awaited. Just want to confirm this is intentional (I believe it is).

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.

Yes, that's intentional. A hook with hasCreatedEvent=undefined, disposed=undefined means createHook() was called but the workflow completed before any suspension — that's a forgotten await and should warn.

Comment threadpackages/core/src/workflow.ts Outdated
// — these are benign since the backend auto-disposes all hooks when a run
// reaches a terminal state
const items = [...pendingQueue.values()].filter(
(item) => !(item.type === 'hook' && item.hasCreatedEvent && !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.

Nit: Also worth noting — a hook with disposed = true but hasCreatedEvent = undefined (dispose called before first suspension) will pass through the filter and trigger a warning. This seems like a false positive: the user explicitly disposed the hook. The filter might be more precise as:

(item)=>!(item.type==='hook'&&(item.disposed||(item.hasCreatedEvent&&!item.disposed)))

Or equivalently, only warn for hooks that are neither disposed nor already created:

(item)=>!(item.type==='hook'&&(item.hasCreatedEvent||item.disposed))

This would skip warnings for any hook the user explicitly disposed, regardless of whether it was created yet. Though in practice, disposed && !hasCreatedEvent is rare (dispose before first suspension), so this may not matter.

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 catch — fixed in 201fb65. Filter is now \!(item.type === 'hook' && (item.hasCreatedEvent || item.disposed)), which skips warnings for any hook that's either alive or explicitly disposed.

Comment threadpackages/core/src/workflow.ts Outdated
Comment threadpackages/core/src/workflow.test.ts Outdated
}
});

it('should not warn when only hook_disposed items remain in queue', async () => {

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 test name says "hook_disposed items remain in queue" but the scenario is actually: hook_disposed is in the event log, which causes the hook consumer to remove the item from the queue (see hook.ts:124). So the queue ends up empty, not with disposed items. The test is correct in behavior — it verifies no warning fires — but the name is slightly misleading. Something like "should not warn when hooks are properly disposed" might be clearer.

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 201fb65 — renamed to "should not warn when hooks are properly disposed".


describe('pending queue warnings', () => {
it('should warn when workflow completes with an unawaited step', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});

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.

Suggestion: Consider extracting the WorkflowRun boilerplate into a helper at the top of this describe block, similar to how other test suites handle it. All four tests create nearly identical run objects. Something like:

functioncreateTestRun(overrides?: Partial<WorkflowRun>): WorkflowRun{ ... }

Not blocking — just a readability improvement.

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 suggestion — will leave for a follow-up since it's a readability improvement and doesn't affect behavior.

pranaygpand others added 7 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>
Log a warning when a workflow run completes or fails with uncommitted
operations still in the invocations queue (unawaited steps, hooks, or
sleeps). This helps users diagnose forgotten `await` calls.
Hook disposals are excluded from the warning since the backend
auto-disposes all hooks when a run reaches a terminal state.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Test that:
- Unawaited step triggers warning on successful completion
- Unawaited step triggers warning on workflow failure
- hook_disposed items are excluded from warning
- Clean workflow completion produces no warning
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Filter out hooks with hasCreatedEvent && !disposed (alive hooks) instead
of the removed hook_disposed queue item type.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove workflow from changeset (transitive via @workflow/core)
- Add default case to switch for exhaustiveness
- Use correlationId instead of token in warning message to avoid leaking
user-provided tokens into logs
- Remove step events from warning tests so unawaited steps stay pending
in the queue (step_completed would clear the item during replay)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Filter out disposed hooks from warning (not just created hooks)
- Show hook token instead of correlationId in warning (user-facing)
- Rename test to "should not warn when hooks are properly disposed"
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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.

5 participants

@pranaygp@TooTallNate@VaguelySerious@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(core): warn on uncommitted operations when workflow completes - #1185

Merged
pranaygp merged 22 commits into
mainfrom
pranaygp/pgp/pending-queue-warning
Feb 27, 2026
Merged

feat(core): warn on uncommitted operations when workflow completes#1185
pranaygp merged 22 commits into
mainfrom
pranaygp/pgp/pending-queue-warning

Conversation

@pranaygp

Copy link
Copy Markdown
Contributor

Log a warning when a workflow run completes or fails with uncommitted
operations still in the invocations queue (unawaited steps, hooks, or
sleeps). This helps users diagnose forgotten await calls.

Hook disposals are excluded from the warning since the backend
auto-disposes all hooks when a run reaches a terminal state.

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

@changeset-bot

changeset-botBot commented Feb 25, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 201fb65

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 25, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

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

❌ Failed Tests

🐘 Local Postgres (1 failed)

hono-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-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-stable4517
✅ nextjs-turbopack-canary5003
✅ nextjs-turbopack-stable5003
✅ 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: failure
  • Local Postgres: failure
  • Windows: success

Check the workflow run for details.

@pranaygpGraphite App

pranaygp commented Feb 25, 2026

Copy link
Copy Markdown
ContributorAuthor

Comment thread.changeset/twelve-olives-cross.md Outdated
@pranaygp
pranaygp marked this pull request as ready for review February 25, 2026 05:33
@pranaygp
pranaygp requested a review from a team as a code ownerFebruary 25, 2026 05:33
CopilotAI review requested due to automatic review settings February 25, 2026 05:33
@vercel

vercelBot commented Feb 25, 2026

Copy link
Copy Markdown
Contributor

claudeand others added 14 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>
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>

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 3 out of 3 changed files in this pull request and generated 5 comments.

Comments suppressed due to low confidence (1)

packages/core/src/workflow.test.ts:3943

  • This test doesn't seem to exercise the "exclude hook disposals" behavior: the provided events include hook_disposed, which removes the hook from invocationsQueue entirely, so the queue is empty at completion regardless of any filtering in warnPendingQueueItems(). If the intent is to assert that disposed-but-uncommitted hooks don't warn, the fixture should leave a disposed hook item in the queue (e.g., call hook.dispose() but omit the hook_disposed event).
 },
createdAt: new Date(),
},
{
eventId: 'event-2',
runId: workflowRun.runId,
eventType: 'hook_disposed',
correlationId: 'hook_01HK153X008RT6YEW43G8QX6JX',
createdAt: new Date(),
},
];
// Workflow creates hook, awaits one payload, hook is disposed
// (simulates `using` at function scope)
await runWorkflow(
`const createHook = globalThis[Symbol.for("WORKFLOW_CREATE_HOOK")];
async function workflow() {
const hook = createHook();
const result = await hook;
hook.dispose();
return result.message;
}${getWorkflowTransformCode('workflow')}`,
workflowRun,
events,
noEncryptionKey
);
const warnCalls = warnSpy.mock.calls.map((c) => c[0]);
expect(
warnCalls.some((msg: string) => msg.includes('uncommitted operation'))
).toBe(false);
} finally {
warnSpy.mockRestore();
}
});
it('should not warn when queue is empty on completion', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const ops: Promise<any>[] = [];
const workflowRun: WorkflowRun = {
runId: 'wrun_123',
workflowName: 'workflow',
status: 'running',
input: await dehydrateWorkflowArguments(
[],
'wrun_123',
noEncryptionKey,
ops
),
createdAt: new Date('2024-01-01T00:00:00.000Z'),
updatedAt: new Date('2024-01-01T00:00:00.000Z'),
startedAt: new Date('2024-01-01T00:00:00.000Z'),
deploymentId: 'test-deployment',
};
await runWorkflow(
`function workflow() { return "clean"; }${getWorkflowTransformCode('workflow')}`,
workflowRun,
[],
noEncryptionKey
);
const warnCalls = warnSpy.mock.calls.map((c) => c[0]);
expect(
warnCalls.some((msg: string) => msg.includes('uncommitted operation'))
).toBe(false);
} finally {
warnSpy.mockRestore();
}
});
});
});

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

Comment threadpackages/core/src/workflow.ts Outdated
Comment on lines +47 to +51
// Filter out hooks that are registered and alive (hasCreatedEvent && !disposed)
// — these are benign since the backend auto-disposes all hooks when a run
// reaches a terminal state
const items = [...pendingQueue.values()].filter(
(item) => !(item.type === 'hook' && item.hasCreatedEvent && !item.disposed)

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.

The hook filtering logic doesn't exclude hook disposals: hooks with disposed === true (set when hook.dispose() is called but before a hook_disposed event exists) will still be reported as "uncommitted", even though the PR description says hook disposals should be excluded because the backend auto-disposes at terminal state. Consider filtering out disposed hook queue items (and/or aligning the PR description/comment with the intended behavior).

Suggested change
// Filter out hooks that are registered and alive (hasCreatedEvent && !disposed)
// — these are benign since the backend auto-disposes all hooks when a run
// reaches a terminal state
constitems=[...pendingQueue.values()].filter(
(item)=>!(item.type==='hook'&&item.hasCreatedEvent&&!item.disposed)
// Filter out hooks that are either registered (hasCreatedEvent) or already
// disposed — these are benign since the backend auto-disposes all hooks
// when a run reaches a terminal state
constitems=[...pendingQueue.values()].filter(
(item)=>
!(
item.type==='hook'&&
(item.hasCreatedEvent||item.disposed)
)

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.

Fixed in 201fb65 — filter now excludes hooks with hasCreatedEvent || disposed, matching TooTallNate's suggestion below.

Comment on lines +58 to +61
return `step "${item.stepName}"`;
case 'hook':
return `hook (${item.correlationId})`;
case 'wait':

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 warning message includes the full hook token (hook (token: "...")). Hook tokens often act as shared secrets / auth credentials; logging them to console warnings can leak sensitive data. Prefer logging the hook correlationId, a redacted token (e.g., last 4 chars), or omit the token entirely.

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.

Went back to showing the token per TooTallNate's suggestion — this is a user-facing warning (not a server log), so the token is more meaningful than the correlationId. Fixed in 201fb65.

Comment on lines +55 to +64
const details = items.map((item) => {
switch (item.type) {
case 'step':
return `step "${item.stepName}"`;
case 'hook':
return `hook (${item.correlationId})`;
case 'wait':
return 'sleep';
default:
return `unknown (${(item as { type: string }).type})`;

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.

details relies on the switch being exhaustive. If a new QueueItem type is added later, this will silently produce undefined entries and log confusing output. Consider adding an explicit default that asserts never (or throws) so TypeScript enforces exhaustiveness here.

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.

Already fixed in the previous commit (86ce8f1) — there's a default case that returns unknown (${type}).

Comment on lines +3743 to +3776
// No step events — the unawaited step stays pending in the queue
const events: Event[] = [];

// Workflow calls step but doesn't await it, returns immediately
await runWorkflow(
`const add = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("add");
async function workflow() {
add(1, 2); // not awaited!
return "done";
}${getWorkflowTransformCode('workflow')}`,
workflowRun,
events,
noEncryptionKey
);

const warnCalls = warnSpy.mock.calls.map((c) => c[0]);
expect(
warnCalls.some(
(msg: string) =>
msg.includes('uncommitted operation') &&
msg.includes('step "add"')
)
).toBe(true);
expect(
warnCalls.some((msg: string) =>
msg.includes('Did you forget to `await`')
)
).toBe(true);
} finally {
warnSpy.mockRestore();
}
});

it('should warn when workflow fails with pending operations', async () => {

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 test provides a matching step_completed event for the invoked step. The step implementation deletes its invocationQueue entry on step_completed regardless of whether the returned Promise is awaited, so the queue will typically be empty by the time warnPendingQueueItems() runs and no warning will be emitted. To reliably test the new behavior, construct an event log where the step remains uncommitted at workflow completion (e.g., omit terminal events for that correlationId).

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.

Already fixed in 86ce8f1 — step events removed so the unawaited step stays pending.

Comment on lines +3817 to +3854
msg.includes('failed') && msg.includes('step "add"')
)
).toBe(true);
} finally {
warnSpy.mockRestore();
}
});

it('should not warn when only hook_disposed items remain in queue', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const ops: Promise<any>[] = [];
const workflowRun: WorkflowRun = {
runId: 'test-run-123',
workflowName: 'workflow',
status: 'running',
input: await dehydrateWorkflowArguments(
[],
'wrun_123',
noEncryptionKey,
ops
),
createdAt: new Date('2024-01-01T00:00:00.000Z'),
updatedAt: new Date('2024-01-01T00:00:00.000Z'),
startedAt: new Date('2024-01-01T00:00:00.000Z'),
deploymentId: 'test-deployment',
};

const events: Event[] = [
{
eventId: 'event-0',
runId: workflowRun.runId,
eventType: 'hook_created' as const,
correlationId: 'hook_01HK153X008RT6YEW43G8QX6JX',
eventData: {
token: 'test-token',
},
createdAt: new Date(),

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.

Similar to the previous test: because the events include step_completed, the step invocation is removed from invocationsQueue even if it isn't awaited. That means the warning may not be triggered, and the test may fail intermittently depending on scheduling. Consider changing the fixture so the step is still pending/uncommitted when the workflow errors (e.g., no terminal event for the invoked correlationId).

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.

Already fixed in 86ce8f1 — same fix as above.

- 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.

Clean, focused PR. The warning is a good DX improvement that will save users debugging time. The filter logic for hook disposals is correct, and the test coverage hits the important cases. A few minor suggestions inline.

Comment threadpackages/core/src/workflow.ts Outdated
// — these are benign since the backend auto-disposes all hooks when a run
// reaches a terminal state
const items = [...pendingQueue.values()].filter(
(item) => !(item.type === 'hook' && item.hasCreatedEvent && !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 filter condition !(item.type === 'hook' && item.hasCreatedEvent && !item.disposed) silences hooks that have been created and are actively waiting for payloads — correct, since the backend auto-disposes these.

But consider the edge case: a hook with hasCreatedEvent = undefined and disposed = undefined (i.e., a hook that was created in user code but the workflow completed before the first suspension could process it). This would not be filtered out — which is correct, since it represents a createHook() call that was never awaited. Just want to confirm this is intentional (I believe it is).

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.

Yes, that's intentional. A hook with hasCreatedEvent=undefined, disposed=undefined means createHook() was called but the workflow completed before any suspension — that's a forgotten await and should warn.

Comment threadpackages/core/src/workflow.ts Outdated
// — these are benign since the backend auto-disposes all hooks when a run
// reaches a terminal state
const items = [...pendingQueue.values()].filter(
(item) => !(item.type === 'hook' && item.hasCreatedEvent && !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.

Nit: Also worth noting — a hook with disposed = true but hasCreatedEvent = undefined (dispose called before first suspension) will pass through the filter and trigger a warning. This seems like a false positive: the user explicitly disposed the hook. The filter might be more precise as:

(item)=>!(item.type==='hook'&&(item.disposed||(item.hasCreatedEvent&&!item.disposed)))

Or equivalently, only warn for hooks that are neither disposed nor already created:

(item)=>!(item.type==='hook'&&(item.hasCreatedEvent||item.disposed))

This would skip warnings for any hook the user explicitly disposed, regardless of whether it was created yet. Though in practice, disposed && !hasCreatedEvent is rare (dispose before first suspension), so this may not matter.

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 catch — fixed in 201fb65. Filter is now \!(item.type === 'hook' && (item.hasCreatedEvent || item.disposed)), which skips warnings for any hook that's either alive or explicitly disposed.

Comment threadpackages/core/src/workflow.ts Outdated
Comment threadpackages/core/src/workflow.test.ts Outdated
}
});

it('should not warn when only hook_disposed items remain in queue', async () => {

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 test name says "hook_disposed items remain in queue" but the scenario is actually: hook_disposed is in the event log, which causes the hook consumer to remove the item from the queue (see hook.ts:124). So the queue ends up empty, not with disposed items. The test is correct in behavior — it verifies no warning fires — but the name is slightly misleading. Something like "should not warn when hooks are properly disposed" might be clearer.

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 201fb65 — renamed to "should not warn when hooks are properly disposed".


describe('pending queue warnings', () => {
it('should warn when workflow completes with an unawaited step', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});

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.

Suggestion: Consider extracting the WorkflowRun boilerplate into a helper at the top of this describe block, similar to how other test suites handle it. All four tests create nearly identical run objects. Something like:

functioncreateTestRun(overrides?: Partial<WorkflowRun>): WorkflowRun{ ... }

Not blocking — just a readability improvement.

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 suggestion — will leave for a follow-up since it's a readability improvement and doesn't affect behavior.

pranaygpand others added 7 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>
Log a warning when a workflow run completes or fails with uncommitted
operations still in the invocations queue (unawaited steps, hooks, or
sleeps). This helps users diagnose forgotten `await` calls.
Hook disposals are excluded from the warning since the backend
auto-disposes all hooks when a run reaches a terminal state.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Test that:
- Unawaited step triggers warning on successful completion
- Unawaited step triggers warning on workflow failure
- hook_disposed items are excluded from warning
- Clean workflow completion produces no warning
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Filter out hooks with hasCreatedEvent && !disposed (alive hooks) instead
of the removed hook_disposed queue item type.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove workflow from changeset (transitive via @workflow/core)
- Add default case to switch for exhaustiveness
- Use correlationId instead of token in warning message to avoid leaking
user-provided tokens into logs
- Remove step events from warning tests so unawaited steps stay pending
in the queue (step_completed would clear the item during replay)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Filter out disposed hooks from warning (not just created hooks)
- Show hook token instead of correlationId in warning (user-facing)
- Rename test to "should not warn when hooks are properly disposed"
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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.

5 participants

@pranaygp@TooTallNate@VaguelySerious@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(core): warn on uncommitted operations when workflow completes - #1185

Merged
pranaygp merged 22 commits into
mainfrom
pranaygp/pgp/pending-queue-warning
Feb 27, 2026
Merged

feat(core): warn on uncommitted operations when workflow completes#1185
pranaygp merged 22 commits into
mainfrom
pranaygp/pgp/pending-queue-warning

Conversation

@pranaygp

Copy link
Copy Markdown
Contributor

Log a warning when a workflow run completes or fails with uncommitted
operations still in the invocations queue (unawaited steps, hooks, or
sleeps). This helps users diagnose forgotten await calls.

Hook disposals are excluded from the warning since the backend
auto-disposes all hooks when a run reaches a terminal state.

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

@changeset-bot

changeset-botBot commented Feb 25, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 201fb65

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 25, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

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

❌ Failed Tests

🐘 Local Postgres (1 failed)

hono-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-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-stable4517
✅ nextjs-turbopack-canary5003
✅ nextjs-turbopack-stable5003
✅ 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: failure
  • Local Postgres: failure
  • Windows: success

Check the workflow run for details.

@pranaygpGraphite App

pranaygp commented Feb 25, 2026

Copy link
Copy Markdown
ContributorAuthor

Comment thread.changeset/twelve-olives-cross.md Outdated
@pranaygp
pranaygp marked this pull request as ready for review February 25, 2026 05:33
@pranaygp
pranaygp requested a review from a team as a code ownerFebruary 25, 2026 05:33
CopilotAI review requested due to automatic review settings February 25, 2026 05:33
@vercel

vercelBot commented Feb 25, 2026

Copy link
Copy Markdown
Contributor

claudeand others added 14 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>
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>

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 3 out of 3 changed files in this pull request and generated 5 comments.

Comments suppressed due to low confidence (1)

packages/core/src/workflow.test.ts:3943

  • This test doesn't seem to exercise the "exclude hook disposals" behavior: the provided events include hook_disposed, which removes the hook from invocationsQueue entirely, so the queue is empty at completion regardless of any filtering in warnPendingQueueItems(). If the intent is to assert that disposed-but-uncommitted hooks don't warn, the fixture should leave a disposed hook item in the queue (e.g., call hook.dispose() but omit the hook_disposed event).
 },
createdAt: new Date(),
},
{
eventId: 'event-2',
runId: workflowRun.runId,
eventType: 'hook_disposed',
correlationId: 'hook_01HK153X008RT6YEW43G8QX6JX',
createdAt: new Date(),
},
];
// Workflow creates hook, awaits one payload, hook is disposed
// (simulates `using` at function scope)
await runWorkflow(
`const createHook = globalThis[Symbol.for("WORKFLOW_CREATE_HOOK")];
async function workflow() {
const hook = createHook();
const result = await hook;
hook.dispose();
return result.message;
}${getWorkflowTransformCode('workflow')}`,
workflowRun,
events,
noEncryptionKey
);
const warnCalls = warnSpy.mock.calls.map((c) => c[0]);
expect(
warnCalls.some((msg: string) => msg.includes('uncommitted operation'))
).toBe(false);
} finally {
warnSpy.mockRestore();
}
});
it('should not warn when queue is empty on completion', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const ops: Promise<any>[] = [];
const workflowRun: WorkflowRun = {
runId: 'wrun_123',
workflowName: 'workflow',
status: 'running',
input: await dehydrateWorkflowArguments(
[],
'wrun_123',
noEncryptionKey,
ops
),
createdAt: new Date('2024-01-01T00:00:00.000Z'),
updatedAt: new Date('2024-01-01T00:00:00.000Z'),
startedAt: new Date('2024-01-01T00:00:00.000Z'),
deploymentId: 'test-deployment',
};
await runWorkflow(
`function workflow() { return "clean"; }${getWorkflowTransformCode('workflow')}`,
workflowRun,
[],
noEncryptionKey
);
const warnCalls = warnSpy.mock.calls.map((c) => c[0]);
expect(
warnCalls.some((msg: string) => msg.includes('uncommitted operation'))
).toBe(false);
} finally {
warnSpy.mockRestore();
}
});
});
});

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

Comment threadpackages/core/src/workflow.ts Outdated
Comment on lines +47 to +51
// Filter out hooks that are registered and alive (hasCreatedEvent && !disposed)
// — these are benign since the backend auto-disposes all hooks when a run
// reaches a terminal state
const items = [...pendingQueue.values()].filter(
(item) => !(item.type === 'hook' && item.hasCreatedEvent && !item.disposed)

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.

The hook filtering logic doesn't exclude hook disposals: hooks with disposed === true (set when hook.dispose() is called but before a hook_disposed event exists) will still be reported as "uncommitted", even though the PR description says hook disposals should be excluded because the backend auto-disposes at terminal state. Consider filtering out disposed hook queue items (and/or aligning the PR description/comment with the intended behavior).

Suggested change
// Filter out hooks that are registered and alive (hasCreatedEvent && !disposed)
// — these are benign since the backend auto-disposes all hooks when a run
// reaches a terminal state
constitems=[...pendingQueue.values()].filter(
(item)=>!(item.type==='hook'&&item.hasCreatedEvent&&!item.disposed)
// Filter out hooks that are either registered (hasCreatedEvent) or already
// disposed — these are benign since the backend auto-disposes all hooks
// when a run reaches a terminal state
constitems=[...pendingQueue.values()].filter(
(item)=>
!(
item.type==='hook'&&
(item.hasCreatedEvent||item.disposed)
)

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.

Fixed in 201fb65 — filter now excludes hooks with hasCreatedEvent || disposed, matching TooTallNate's suggestion below.

Comment on lines +58 to +61
return `step "${item.stepName}"`;
case 'hook':
return `hook (${item.correlationId})`;
case 'wait':

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 warning message includes the full hook token (hook (token: "...")). Hook tokens often act as shared secrets / auth credentials; logging them to console warnings can leak sensitive data. Prefer logging the hook correlationId, a redacted token (e.g., last 4 chars), or omit the token entirely.

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.

Went back to showing the token per TooTallNate's suggestion — this is a user-facing warning (not a server log), so the token is more meaningful than the correlationId. Fixed in 201fb65.

Comment on lines +55 to +64
const details = items.map((item) => {
switch (item.type) {
case 'step':
return `step "${item.stepName}"`;
case 'hook':
return `hook (${item.correlationId})`;
case 'wait':
return 'sleep';
default:
return `unknown (${(item as { type: string }).type})`;

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.

details relies on the switch being exhaustive. If a new QueueItem type is added later, this will silently produce undefined entries and log confusing output. Consider adding an explicit default that asserts never (or throws) so TypeScript enforces exhaustiveness here.

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.

Already fixed in the previous commit (86ce8f1) — there's a default case that returns unknown (${type}).

Comment on lines +3743 to +3776
// No step events — the unawaited step stays pending in the queue
const events: Event[] = [];

// Workflow calls step but doesn't await it, returns immediately
await runWorkflow(
`const add = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("add");
async function workflow() {
add(1, 2); // not awaited!
return "done";
}${getWorkflowTransformCode('workflow')}`,
workflowRun,
events,
noEncryptionKey
);

const warnCalls = warnSpy.mock.calls.map((c) => c[0]);
expect(
warnCalls.some(
(msg: string) =>
msg.includes('uncommitted operation') &&
msg.includes('step "add"')
)
).toBe(true);
expect(
warnCalls.some((msg: string) =>
msg.includes('Did you forget to `await`')
)
).toBe(true);
} finally {
warnSpy.mockRestore();
}
});

it('should warn when workflow fails with pending operations', async () => {

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 test provides a matching step_completed event for the invoked step. The step implementation deletes its invocationQueue entry on step_completed regardless of whether the returned Promise is awaited, so the queue will typically be empty by the time warnPendingQueueItems() runs and no warning will be emitted. To reliably test the new behavior, construct an event log where the step remains uncommitted at workflow completion (e.g., omit terminal events for that correlationId).

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.

Already fixed in 86ce8f1 — step events removed so the unawaited step stays pending.

Comment on lines +3817 to +3854
msg.includes('failed') && msg.includes('step "add"')
)
).toBe(true);
} finally {
warnSpy.mockRestore();
}
});

it('should not warn when only hook_disposed items remain in queue', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const ops: Promise<any>[] = [];
const workflowRun: WorkflowRun = {
runId: 'test-run-123',
workflowName: 'workflow',
status: 'running',
input: await dehydrateWorkflowArguments(
[],
'wrun_123',
noEncryptionKey,
ops
),
createdAt: new Date('2024-01-01T00:00:00.000Z'),
updatedAt: new Date('2024-01-01T00:00:00.000Z'),
startedAt: new Date('2024-01-01T00:00:00.000Z'),
deploymentId: 'test-deployment',
};

const events: Event[] = [
{
eventId: 'event-0',
runId: workflowRun.runId,
eventType: 'hook_created' as const,
correlationId: 'hook_01HK153X008RT6YEW43G8QX6JX',
eventData: {
token: 'test-token',
},
createdAt: new Date(),

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.

Similar to the previous test: because the events include step_completed, the step invocation is removed from invocationsQueue even if it isn't awaited. That means the warning may not be triggered, and the test may fail intermittently depending on scheduling. Consider changing the fixture so the step is still pending/uncommitted when the workflow errors (e.g., no terminal event for the invoked correlationId).

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.

Already fixed in 86ce8f1 — same fix as above.

- 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.

Clean, focused PR. The warning is a good DX improvement that will save users debugging time. The filter logic for hook disposals is correct, and the test coverage hits the important cases. A few minor suggestions inline.

Comment threadpackages/core/src/workflow.ts Outdated
// — these are benign since the backend auto-disposes all hooks when a run
// reaches a terminal state
const items = [...pendingQueue.values()].filter(
(item) => !(item.type === 'hook' && item.hasCreatedEvent && !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 filter condition !(item.type === 'hook' && item.hasCreatedEvent && !item.disposed) silences hooks that have been created and are actively waiting for payloads — correct, since the backend auto-disposes these.

But consider the edge case: a hook with hasCreatedEvent = undefined and disposed = undefined (i.e., a hook that was created in user code but the workflow completed before the first suspension could process it). This would not be filtered out — which is correct, since it represents a createHook() call that was never awaited. Just want to confirm this is intentional (I believe it is).

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.

Yes, that's intentional. A hook with hasCreatedEvent=undefined, disposed=undefined means createHook() was called but the workflow completed before any suspension — that's a forgotten await and should warn.

Comment threadpackages/core/src/workflow.ts Outdated
// — these are benign since the backend auto-disposes all hooks when a run
// reaches a terminal state
const items = [...pendingQueue.values()].filter(
(item) => !(item.type === 'hook' && item.hasCreatedEvent && !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.

Nit: Also worth noting — a hook with disposed = true but hasCreatedEvent = undefined (dispose called before first suspension) will pass through the filter and trigger a warning. This seems like a false positive: the user explicitly disposed the hook. The filter might be more precise as:

(item)=>!(item.type==='hook'&&(item.disposed||(item.hasCreatedEvent&&!item.disposed)))

Or equivalently, only warn for hooks that are neither disposed nor already created:

(item)=>!(item.type==='hook'&&(item.hasCreatedEvent||item.disposed))

This would skip warnings for any hook the user explicitly disposed, regardless of whether it was created yet. Though in practice, disposed && !hasCreatedEvent is rare (dispose before first suspension), so this may not matter.

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 catch — fixed in 201fb65. Filter is now \!(item.type === 'hook' && (item.hasCreatedEvent || item.disposed)), which skips warnings for any hook that's either alive or explicitly disposed.

Comment threadpackages/core/src/workflow.ts Outdated
Comment threadpackages/core/src/workflow.test.ts Outdated
}
});

it('should not warn when only hook_disposed items remain in queue', async () => {

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 test name says "hook_disposed items remain in queue" but the scenario is actually: hook_disposed is in the event log, which causes the hook consumer to remove the item from the queue (see hook.ts:124). So the queue ends up empty, not with disposed items. The test is correct in behavior — it verifies no warning fires — but the name is slightly misleading. Something like "should not warn when hooks are properly disposed" might be clearer.

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 201fb65 — renamed to "should not warn when hooks are properly disposed".


describe('pending queue warnings', () => {
it('should warn when workflow completes with an unawaited step', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});

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.

Suggestion: Consider extracting the WorkflowRun boilerplate into a helper at the top of this describe block, similar to how other test suites handle it. All four tests create nearly identical run objects. Something like:

functioncreateTestRun(overrides?: Partial<WorkflowRun>): WorkflowRun{ ... }

Not blocking — just a readability improvement.

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 suggestion — will leave for a follow-up since it's a readability improvement and doesn't affect behavior.

pranaygpand others added 7 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>
Log a warning when a workflow run completes or fails with uncommitted
operations still in the invocations queue (unawaited steps, hooks, or
sleeps). This helps users diagnose forgotten `await` calls.
Hook disposals are excluded from the warning since the backend
auto-disposes all hooks when a run reaches a terminal state.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Test that:
- Unawaited step triggers warning on successful completion
- Unawaited step triggers warning on workflow failure
- hook_disposed items are excluded from warning
- Clean workflow completion produces no warning
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Filter out hooks with hasCreatedEvent && !disposed (alive hooks) instead
of the removed hook_disposed queue item type.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove workflow from changeset (transitive via @workflow/core)
- Add default case to switch for exhaustiveness
- Use correlationId instead of token in warning message to avoid leaking
user-provided tokens into logs
- Remove step events from warning tests so unawaited steps stay pending
in the queue (step_completed would clear the item during replay)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Filter out disposed hooks from warning (not just created hooks)
- Show hook token instead of correlationId in warning (user-facing)
- Rename test to "should not warn when hooks are properly disposed"
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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.

5 participants

@pranaygp@TooTallNate@VaguelySerious@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat(core): warn on uncommitted operations when workflow completes - #1185

Merged
pranaygp merged 22 commits into
mainfrom
pranaygp/pgp/pending-queue-warning
Feb 27, 2026
Merged

feat(core): warn on uncommitted operations when workflow completes#1185
pranaygp merged 22 commits into
mainfrom
pranaygp/pgp/pending-queue-warning

Conversation

@pranaygp

Copy link
Copy Markdown
Contributor

Log a warning when a workflow run completes or fails with uncommitted
operations still in the invocations queue (unawaited steps, hooks, or
sleeps). This helps users diagnose forgotten await calls.

Hook disposals are excluded from the warning since the backend
auto-disposes all hooks when a run reaches a terminal state.

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

@changeset-bot

changeset-botBot commented Feb 25, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 201fb65

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 25, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

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

❌ Failed Tests

🐘 Local Postgres (1 failed)

hono-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-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-stable4517
✅ nextjs-turbopack-canary5003
✅ nextjs-turbopack-stable5003
✅ 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: failure
  • Local Postgres: failure
  • Windows: success

Check the workflow run for details.

@pranaygpGraphite App

pranaygp commented Feb 25, 2026

Copy link
Copy Markdown
ContributorAuthor

Comment thread.changeset/twelve-olives-cross.md Outdated
@pranaygp
pranaygp marked this pull request as ready for review February 25, 2026 05:33
@pranaygp
pranaygp requested a review from a team as a code ownerFebruary 25, 2026 05:33
CopilotAI review requested due to automatic review settings February 25, 2026 05:33
@vercel

vercelBot commented Feb 25, 2026

Copy link
Copy Markdown
Contributor

claudeand others added 14 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>
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>

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 3 out of 3 changed files in this pull request and generated 5 comments.

Comments suppressed due to low confidence (1)

packages/core/src/workflow.test.ts:3943

  • This test doesn't seem to exercise the "exclude hook disposals" behavior: the provided events include hook_disposed, which removes the hook from invocationsQueue entirely, so the queue is empty at completion regardless of any filtering in warnPendingQueueItems(). If the intent is to assert that disposed-but-uncommitted hooks don't warn, the fixture should leave a disposed hook item in the queue (e.g., call hook.dispose() but omit the hook_disposed event).
 },
createdAt: new Date(),
},
{
eventId: 'event-2',
runId: workflowRun.runId,
eventType: 'hook_disposed',
correlationId: 'hook_01HK153X008RT6YEW43G8QX6JX',
createdAt: new Date(),
},
];
// Workflow creates hook, awaits one payload, hook is disposed
// (simulates `using` at function scope)
await runWorkflow(
`const createHook = globalThis[Symbol.for("WORKFLOW_CREATE_HOOK")];
async function workflow() {
const hook = createHook();
const result = await hook;
hook.dispose();
return result.message;
}${getWorkflowTransformCode('workflow')}`,
workflowRun,
events,
noEncryptionKey
);
const warnCalls = warnSpy.mock.calls.map((c) => c[0]);
expect(
warnCalls.some((msg: string) => msg.includes('uncommitted operation'))
).toBe(false);
} finally {
warnSpy.mockRestore();
}
});
it('should not warn when queue is empty on completion', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const ops: Promise<any>[] = [];
const workflowRun: WorkflowRun = {
runId: 'wrun_123',
workflowName: 'workflow',
status: 'running',
input: await dehydrateWorkflowArguments(
[],
'wrun_123',
noEncryptionKey,
ops
),
createdAt: new Date('2024-01-01T00:00:00.000Z'),
updatedAt: new Date('2024-01-01T00:00:00.000Z'),
startedAt: new Date('2024-01-01T00:00:00.000Z'),
deploymentId: 'test-deployment',
};
await runWorkflow(
`function workflow() { return "clean"; }${getWorkflowTransformCode('workflow')}`,
workflowRun,
[],
noEncryptionKey
);
const warnCalls = warnSpy.mock.calls.map((c) => c[0]);
expect(
warnCalls.some((msg: string) => msg.includes('uncommitted operation'))
).toBe(false);
} finally {
warnSpy.mockRestore();
}
});
});
});

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

Comment threadpackages/core/src/workflow.ts Outdated
Comment on lines +47 to +51
// Filter out hooks that are registered and alive (hasCreatedEvent && !disposed)
// — these are benign since the backend auto-disposes all hooks when a run
// reaches a terminal state
const items = [...pendingQueue.values()].filter(
(item) => !(item.type === 'hook' && item.hasCreatedEvent && !item.disposed)

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.

The hook filtering logic doesn't exclude hook disposals: hooks with disposed === true (set when hook.dispose() is called but before a hook_disposed event exists) will still be reported as "uncommitted", even though the PR description says hook disposals should be excluded because the backend auto-disposes at terminal state. Consider filtering out disposed hook queue items (and/or aligning the PR description/comment with the intended behavior).

Suggested change
// Filter out hooks that are registered and alive (hasCreatedEvent && !disposed)
// — these are benign since the backend auto-disposes all hooks when a run
// reaches a terminal state
constitems=[...pendingQueue.values()].filter(
(item)=>!(item.type==='hook'&&item.hasCreatedEvent&&!item.disposed)
// Filter out hooks that are either registered (hasCreatedEvent) or already
// disposed — these are benign since the backend auto-disposes all hooks
// when a run reaches a terminal state
constitems=[...pendingQueue.values()].filter(
(item)=>
!(
item.type==='hook'&&
(item.hasCreatedEvent||item.disposed)
)

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.

Fixed in 201fb65 — filter now excludes hooks with hasCreatedEvent || disposed, matching TooTallNate's suggestion below.

Comment on lines +58 to +61
return `step "${item.stepName}"`;
case 'hook':
return `hook (${item.correlationId})`;
case 'wait':

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 warning message includes the full hook token (hook (token: "...")). Hook tokens often act as shared secrets / auth credentials; logging them to console warnings can leak sensitive data. Prefer logging the hook correlationId, a redacted token (e.g., last 4 chars), or omit the token entirely.

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.

Went back to showing the token per TooTallNate's suggestion — this is a user-facing warning (not a server log), so the token is more meaningful than the correlationId. Fixed in 201fb65.

Comment on lines +55 to +64
const details = items.map((item) => {
switch (item.type) {
case 'step':
return `step "${item.stepName}"`;
case 'hook':
return `hook (${item.correlationId})`;
case 'wait':
return 'sleep';
default:
return `unknown (${(item as { type: string }).type})`;

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.

details relies on the switch being exhaustive. If a new QueueItem type is added later, this will silently produce undefined entries and log confusing output. Consider adding an explicit default that asserts never (or throws) so TypeScript enforces exhaustiveness here.

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.

Already fixed in the previous commit (86ce8f1) — there's a default case that returns unknown (${type}).

Comment on lines +3743 to +3776
// No step events — the unawaited step stays pending in the queue
const events: Event[] = [];

// Workflow calls step but doesn't await it, returns immediately
await runWorkflow(
`const add = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("add");
async function workflow() {
add(1, 2); // not awaited!
return "done";
}${getWorkflowTransformCode('workflow')}`,
workflowRun,
events,
noEncryptionKey
);

const warnCalls = warnSpy.mock.calls.map((c) => c[0]);
expect(
warnCalls.some(
(msg: string) =>
msg.includes('uncommitted operation') &&
msg.includes('step "add"')
)
).toBe(true);
expect(
warnCalls.some((msg: string) =>
msg.includes('Did you forget to `await`')
)
).toBe(true);
} finally {
warnSpy.mockRestore();
}
});

it('should warn when workflow fails with pending operations', async () => {

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 test provides a matching step_completed event for the invoked step. The step implementation deletes its invocationQueue entry on step_completed regardless of whether the returned Promise is awaited, so the queue will typically be empty by the time warnPendingQueueItems() runs and no warning will be emitted. To reliably test the new behavior, construct an event log where the step remains uncommitted at workflow completion (e.g., omit terminal events for that correlationId).

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.

Already fixed in 86ce8f1 — step events removed so the unawaited step stays pending.

Comment on lines +3817 to +3854
msg.includes('failed') && msg.includes('step "add"')
)
).toBe(true);
} finally {
warnSpy.mockRestore();
}
});

it('should not warn when only hook_disposed items remain in queue', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const ops: Promise<any>[] = [];
const workflowRun: WorkflowRun = {
runId: 'test-run-123',
workflowName: 'workflow',
status: 'running',
input: await dehydrateWorkflowArguments(
[],
'wrun_123',
noEncryptionKey,
ops
),
createdAt: new Date('2024-01-01T00:00:00.000Z'),
updatedAt: new Date('2024-01-01T00:00:00.000Z'),
startedAt: new Date('2024-01-01T00:00:00.000Z'),
deploymentId: 'test-deployment',
};

const events: Event[] = [
{
eventId: 'event-0',
runId: workflowRun.runId,
eventType: 'hook_created' as const,
correlationId: 'hook_01HK153X008RT6YEW43G8QX6JX',
eventData: {
token: 'test-token',
},
createdAt: new Date(),

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.

Similar to the previous test: because the events include step_completed, the step invocation is removed from invocationsQueue even if it isn't awaited. That means the warning may not be triggered, and the test may fail intermittently depending on scheduling. Consider changing the fixture so the step is still pending/uncommitted when the workflow errors (e.g., no terminal event for the invoked correlationId).

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.

Already fixed in 86ce8f1 — same fix as above.

- 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.

Clean, focused PR. The warning is a good DX improvement that will save users debugging time. The filter logic for hook disposals is correct, and the test coverage hits the important cases. A few minor suggestions inline.

Comment threadpackages/core/src/workflow.ts Outdated
// — these are benign since the backend auto-disposes all hooks when a run
// reaches a terminal state
const items = [...pendingQueue.values()].filter(
(item) => !(item.type === 'hook' && item.hasCreatedEvent && !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 filter condition !(item.type === 'hook' && item.hasCreatedEvent && !item.disposed) silences hooks that have been created and are actively waiting for payloads — correct, since the backend auto-disposes these.

But consider the edge case: a hook with hasCreatedEvent = undefined and disposed = undefined (i.e., a hook that was created in user code but the workflow completed before the first suspension could process it). This would not be filtered out — which is correct, since it represents a createHook() call that was never awaited. Just want to confirm this is intentional (I believe it is).

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.

Yes, that's intentional. A hook with hasCreatedEvent=undefined, disposed=undefined means createHook() was called but the workflow completed before any suspension — that's a forgotten await and should warn.

Comment threadpackages/core/src/workflow.ts Outdated
// — these are benign since the backend auto-disposes all hooks when a run
// reaches a terminal state
const items = [...pendingQueue.values()].filter(
(item) => !(item.type === 'hook' && item.hasCreatedEvent && !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.

Nit: Also worth noting — a hook with disposed = true but hasCreatedEvent = undefined (dispose called before first suspension) will pass through the filter and trigger a warning. This seems like a false positive: the user explicitly disposed the hook. The filter might be more precise as:

(item)=>!(item.type==='hook'&&(item.disposed||(item.hasCreatedEvent&&!item.disposed)))

Or equivalently, only warn for hooks that are neither disposed nor already created:

(item)=>!(item.type==='hook'&&(item.hasCreatedEvent||item.disposed))

This would skip warnings for any hook the user explicitly disposed, regardless of whether it was created yet. Though in practice, disposed && !hasCreatedEvent is rare (dispose before first suspension), so this may not matter.

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 catch — fixed in 201fb65. Filter is now \!(item.type === 'hook' && (item.hasCreatedEvent || item.disposed)), which skips warnings for any hook that's either alive or explicitly disposed.

Comment threadpackages/core/src/workflow.ts Outdated
Comment threadpackages/core/src/workflow.test.ts Outdated
}
});

it('should not warn when only hook_disposed items remain in queue', async () => {

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 test name says "hook_disposed items remain in queue" but the scenario is actually: hook_disposed is in the event log, which causes the hook consumer to remove the item from the queue (see hook.ts:124). So the queue ends up empty, not with disposed items. The test is correct in behavior — it verifies no warning fires — but the name is slightly misleading. Something like "should not warn when hooks are properly disposed" might be clearer.

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 201fb65 — renamed to "should not warn when hooks are properly disposed".


describe('pending queue warnings', () => {
it('should warn when workflow completes with an unawaited step', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});

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.

Suggestion: Consider extracting the WorkflowRun boilerplate into a helper at the top of this describe block, similar to how other test suites handle it. All four tests create nearly identical run objects. Something like:

functioncreateTestRun(overrides?: Partial<WorkflowRun>): WorkflowRun{ ... }

Not blocking — just a readability improvement.

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 suggestion — will leave for a follow-up since it's a readability improvement and doesn't affect behavior.

pranaygpand others added 7 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>
Log a warning when a workflow run completes or fails with uncommitted
operations still in the invocations queue (unawaited steps, hooks, or
sleeps). This helps users diagnose forgotten `await` calls.
Hook disposals are excluded from the warning since the backend
auto-disposes all hooks when a run reaches a terminal state.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Test that:
- Unawaited step triggers warning on successful completion
- Unawaited step triggers warning on workflow failure
- hook_disposed items are excluded from warning
- Clean workflow completion produces no warning
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Filter out hooks with hasCreatedEvent && !disposed (alive hooks) instead
of the removed hook_disposed queue item type.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove workflow from changeset (transitive via @workflow/core)
- Add default case to switch for exhaustiveness
- Use correlationId instead of token in warning message to avoid leaking
user-provided tokens into logs
- Remove step events from warning tests so unawaited steps stay pending
in the queue (step_completed would clear the item during replay)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Filter out disposed hooks from warning (not just created hooks)
- Show hook token instead of correlationId in warning (user-facing)
- Rename test to "should not warn when hooks are properly disposed"
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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.

5 participants

@pranaygp@TooTallNate@VaguelySerious@claude