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
Copy link
Copy Markdown

changeset-bot Bot commented Feb 25, 2026

🦋 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
Name Type
@workflow/core Patch
@workflow/builders Patch
@workflow/cli Patch
@workflow/next Patch
@workflow/nitro Patch
@workflow/web-shared Patch
workflow Patch
@workflow/world-testing Patch
@workflow/astro Patch
@workflow/nest Patch
@workflow/rollup Patch
@workflow/sveltekit Patch
@workflow/vite Patch
@workflow/nuxt Patch

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
Copy link
Copy Markdown
Contributor

github-actions Bot commented Feb 25, 2026

🧪 E2E Test Results

Some tests failed

Summary

Passed Failed Skipped Total
✅ ▲ Vercel Production 534 0 49 583
✅ 💻 Local Development 568 0 68 636
✅ 📦 Local Production 518 0 65 583
❌ 🐘 Local Postgres 567 1 68 636
✅ 🪟 Windows 50 0 3 53
❌ 🌍 Community Worlds 113 46 9 168
✅ 📋 Other 138 0 21 159
Total 2488 47 283 2818

❌ 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
App Passed Failed Skipped
✅ astro 48 0 5
✅ example 48 0 5
✅ express 48 0 5
✅ fastify 48 0 5
✅ hono 48 0 5
✅ nextjs-turbopack 51 0 2
✅ nextjs-webpack 51 0 2
✅ nitro 48 0 5
✅ nuxt 48 0 5
✅ sveltekit 48 0 5
✅ vite 48 0 5
✅ 💻 Local Development
App Passed Failed Skipped
✅ astro-stable 46 0 7
✅ express-stable 46 0 7
✅ fastify-stable 46 0 7
✅ hono-stable 46 0 7
✅ nextjs-turbopack-canary 50 0 3
✅ nextjs-turbopack-stable 50 0 3
✅ nextjs-webpack-canary 50 0 3
✅ nextjs-webpack-stable 50 0 3
✅ nitro-stable 46 0 7
✅ nuxt-stable 46 0 7
✅ sveltekit-stable 46 0 7
✅ vite-stable 46 0 7
✅ 📦 Local Production
App Passed Failed Skipped
✅ astro-stable 46 0 7
✅ express-stable 46 0 7
✅ fastify-stable 46 0 7
✅ hono-stable 46 0 7
✅ nextjs-turbopack-stable 50 0 3
✅ nextjs-webpack-canary 50 0 3
✅ nextjs-webpack-stable 50 0 3
✅ nitro-stable 46 0 7
✅ nuxt-stable 46 0 7
✅ sveltekit-stable 46 0 7
✅ vite-stable 46 0 7
❌ 🐘 Local Postgres
App Passed Failed Skipped
✅ astro-stable 46 0 7
✅ express-stable 46 0 7
✅ fastify-stable 46 0 7
❌ hono-stable 45 1 7
✅ nextjs-turbopack-canary 50 0 3
✅ nextjs-turbopack-stable 50 0 3
✅ nextjs-webpack-canary 50 0 3
✅ nextjs-webpack-stable 50 0 3
✅ nitro-stable 46 0 7
✅ nuxt-stable 46 0 7
✅ sveltekit-stable 46 0 7
✅ vite-stable 46 0 7
✅ 🪟 Windows
App Passed Failed Skipped
✅ nextjs-turbopack 50 0 3
❌ 🌍 Community Worlds
App Passed Failed Skipped
✅ mongodb-dev 3 0 0
✅ mongodb 50 0 3
✅ redis-dev 3 0 0
✅ redis 50 0 3
✅ turso-dev 3 0 0
❌ turso 4 46 3
✅ 📋 Other
App Passed Failed Skipped
✅ e2e-local-dev-nest-stable 46 0 7
✅ e2e-local-postgres-nest-stable 46 0 7
✅ e2e-local-prod-nest-stable 46 0 7

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

Copy link
Copy Markdown
Contributor Author

pranaygp commented Feb 25, 2026

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 owner February 25, 2026 05:33
Copilot AI review requested due to automatic review settings February 25, 2026 05:33
@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented Feb 25, 2026

claude and 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>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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 thread packages/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)
Copy link

Copilot AI Feb 27, 2026

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
const items = [...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
const items = [...pendingQueue.values()].filter(
(item) =>
!(
item.type === 'hook' &&
(item.hasCreatedEvent || item.disposed)
)

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

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':
Copy link

Copilot AI Feb 27, 2026

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
Contributor Author

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})`;
Copy link

Copilot AI Feb 27, 2026

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
Contributor Author

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 () => {
Copy link

Copilot AI Feb 27, 2026

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
Contributor Author

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(),
Copy link

Copilot AI Feb 27, 2026

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
Contributor Author

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>
Copy link
Copy Markdown
Member

@TooTallNate TooTallNate left a comment

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 thread packages/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
Contributor Author

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 thread packages/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
Contributor Author

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 thread packages/core/src/workflow.ts Outdated
Comment thread packages/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
Contributor Author

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:

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

Not blocking — just a readability improvement.

Copy link
Copy Markdown
Contributor Author

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.

pranaygp and 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 free to 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