Skip to content

feat(core): add workflow startAsync fire-and-forget API#1097

Merged
omeraplak merged 4 commits into
mainfrom
feat/workflow-start-async-fire-and-forget
Feb 21, 2026
Merged

feat(core): add workflow startAsync fire-and-forget API#1097
omeraplak merged 4 commits into
mainfrom
feat/workflow-start-async-fire-and-forget

Conversation

@omeraplak

@omeraplak omeraplak commented Feb 21, 2026

Copy link
Copy Markdown
Member

PR Checklist

Please check if your PR fulfills the following requirements:

Bugs / Features

What is the current behavior?

Workflow APIs support run() and stream(), but there is no fire-and-forget start method. Callers must wait for completion or manage stream lifecycle directly.

What is the new behavior?

Adds startAsync() to both workflow surfaces:

  • Workflow.startAsync(input, options?)
  • WorkflowChain.startAsync(input, options?)

startAsync() starts execution in the background and immediately returns:

  • executionId
  • workflowId
  • startedAt

Implementation details:

  • Reuses existing executeInternal execution path.
  • Preserves executionId from options when provided.
  • Persists terminal status in memory for background completion/error inspection.
  • Adds tests for core + chain async start behavior.

fixes (issue)

N/A

Notes for reviewers

  • Included only files for plan 01-async-start-fire-and-forget + changeset.
  • docs/workflow-parity-plans was intentionally excluded from this commit/PR.

Summary by cubic

Adds startAsync to workflows and chains for fire-and-forget execution that returns immediately while the run continues in the background. It returns executionId, workflowId, and startAt, persists terminal state for later inspection, and rejects resumeFrom to avoid overwriting suspended runs.

  • New Features

    • Adds startAsync(input, options?) on Workflow and WorkflowChain.
    • Returns executionId/workflowId/startAt immediately; run continues in background.
    • Exports WorkflowStartAsyncResult; adds tests and docs for background success/error, custom executionId, and resumeFrom rejection.
  • Bug Fixes

    • Improves startAsync state handling: pre-creates running state and uses internal skipStateInit; ensures early failures persist an error state.
    • Rejects resumeFrom in startAsync to preserve existing suspended executions.

Written for commit ce7eb63. Summary will update on new commits.

Summary by CodeRabbit

  • New Features

    • Added startAsync() to start workflows in fire-and-forget background mode; immediately returns execution metadata (executionId, workflowId, startAt).
    • Introduced a public startAsync result type for that metadata.
  • Tests

    • Added tests covering background execution, error persistence, and honoring provided execution IDs (duplicate test blocks noted).
  • Documentation

    • Updated workflow overview and streaming docs with startAsync() examples and behavior comparisons.
  • Chores

    • Minor package version bump.

@changeset-bot

changeset-bot Bot commented Feb 21, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: ce7eb63

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

This PR includes changesets to release 1 package
Name Type
@voltagent/core Minor

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

@ghost

This comment has been minimized.

@coderabbitai

coderabbitai Bot commented Feb 21, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

Adds a fire-and-forget startAsync() API to Workflow and WorkflowChain that returns execution metadata immediately, launches background execution, and persists terminal states (completed/error) in memory for later inspection.

Changes

Cohort / File(s) Summary
Type Definitions & Exports
packages/core/src/workflow/types.ts, packages/core/src/workflow/index.ts, packages/core/src/index.ts
Adds WorkflowStartAsyncResult type, adds startAsync signature to Workflow and WorkflowRunOptions.skipStateInit, and exports the new type through public surfaces.
Workflow Implementation
packages/core/src/workflow/core.ts, packages/core/src/workflow/chain.ts
Implements startAsync() on Workflow to generate/use an executionId, persist an initial "running" state, launch background execution (executeInternal) with skipStateInit, persist terminal error/completed state, and adds delegating startAsync() on WorkflowChain.
Tests
packages/core/src/workflow/core.spec.ts, packages/core/src/workflow/chain.spec.ts
Adds tests covering immediate return, background completion polling, error persistence, and respecting provided executionId. Note: duplicate test blocks appear in the diff and should be de-duplicated.
Docs
website/docs/workflows/overview.md, website/docs/workflows/streaming.md
Documents .startAsync() usage and WorkflowStartAsyncResult, updates run-method count to three, and extends the comparison matrix for .run(), .stream(), and .startAsync().
Release Metadata
.changeset/cool-geese-wave.md
Records a minor version bump for @voltagent/core and summarizes the startAsync() addition.

Sequence Diagram(s)

sequenceDiagram
    actor Caller
    participant Workflow
    participant Memory
    participant Background

    Caller->>Workflow: startAsync(input, options)
    Workflow->>Workflow: generate/use executionId, prepare start metadata
    Workflow->>Memory: persist initial "running" state (executionId, workflowId, startAt)
    Workflow->>Background: kick off executeInternal (non-blocking, skipStateInit)
    Workflow-->>Caller: return { executionId, workflowId, startAt }

    par Background execution
        Background->>Background: run workflow steps
        Background->>Memory: persist terminal state (completed/error) and error metadata if any
    and Caller continues
        Caller->>Caller: proceed without waiting
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐇 I nudged a run, then hopped away,
A little id to mark the play,
It hums in background, quiet and bright,
Will tuck its result for you to sight,
Hoppy async whispers — delight! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: adding a fire-and-forget (startAsync) API to the workflow core module. It is concise and directly related to the primary feature being implemented.
Description check ✅ Passed The description is comprehensive and covers all key template sections: checklist items completed, clear current/new behavior explanation, implementation details, test additions, changeset inclusion, and helpful reviewer notes about scope.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/workflow-start-async-fire-and-forget

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Feb 21, 2026

Copy link
Copy Markdown

Deploying voltagent with  Cloudflare Pages  Cloudflare Pages

Latest commit: ce7eb63
Status: ✅  Deploy successful!
Preview URL: https://64d34b01.voltagent.pages.dev
Branch Preview URL: https://feat-workflow-start-async-fi.voltagent.pages.dev

View logs

@cubic-dev-ai cubic-dev-ai Bot 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.

No issues found across 8 files

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 3

🧹 Nitpick comments (1)
packages/core/src/workflow/types.ts (1)

205-221: startedAt is inconsistent with startAt on related types.

WorkflowExecutionResult and WorkflowStreamResult both expose a startAt: Date field. Introducing startedAt on WorkflowStartAsyncResult creates a naming inconsistency in the public API that will trip up callers switching between methods.

🔧 Proposed rename
 export interface WorkflowStartAsyncResult {
   executionId: string;
   workflowId: string;
-  startedAt: Date;
+  startAt: Date;
 }

Also update the corresponding field in core.ts startAsync return value and the assertions in both spec files.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/workflow/types.ts` around lines 205 - 221, Rename the field
startedAt to startAt to match the existing API (WorkflowExecutionResult and
WorkflowStreamResult): update the WorkflowStartAsyncResult interface to use
startAt: Date, update the startAsync implementation in core.ts to populate
startAt instead of startedAt, and adjust all tests/spec assertions that
reference startedAt to expect startAt (search for WorkflowStartAsyncResult,
startAsync, and any spec files asserting startedAt and change them accordingly).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/core/src/workflow/core.spec.ts`:
- Around line 507-508: Replace the immediate assertion that checks runningState
with a polling loop (same pattern as the completion check) that repeatedly calls
memory.getWorkflowState(startResult.executionId) until status === "running" or a
timeout is reached; keep the gate held while polling and only call
releaseStep?.() after the polling confirms "running" (or times out) so the test
is robust to async adapters. Use the same timeout/interval pattern as the
existing completion check to implement the loop.

In `@packages/core/src/workflow/core.ts`:
- Around line 2146-2208: The outer .catch(() => {}) swallows any exceptions
thrown by the async error handler; move the initial logger.warn call (the one
logging "startAsync execution failed before terminal handling") out of the try
block so it always executes, then keep the try/catch solely around the
persistence calls (executionMemory.getWorkflowState / updateWorkflowState /
setWorkflowState) to handle persistence failures; finally replace the trailing
.catch(() => {}) with a handler that logs unexpected errors from the async
handler (or rethrows) so failures in executeInternal's error-handling logic
aren't silently discarded.
- Around line 2134-2215: startAsync currently returns an executionId before
executeInternal creates the initial "running" state, causing callers to see no
state for real DB backends; fix by having startAsync synchronously/awaitably
create the initial workflow state via executionMemory.setWorkflowState(...)
(with status: "running", id: executionId, workflowId: id, workflowName: name,
input, metadata, createdAt/updatedAt, etc.) before invoking executeInternal, and
pass a new flag on executionOptions (e.g., skipStateInit: true) so
executeInternal checks options.skipStateInit and skips its own initial
setWorkflowState call and instead only updates trace IDs/metadata via
updateWorkflowState to avoid double-creation.

---

Nitpick comments:
In `@packages/core/src/workflow/types.ts`:
- Around line 205-221: Rename the field startedAt to startAt to match the
existing API (WorkflowExecutionResult and WorkflowStreamResult): update the
WorkflowStartAsyncResult interface to use startAt: Date, update the startAsync
implementation in core.ts to populate startAt instead of startedAt, and adjust
all tests/spec assertions that reference startedAt to expect startAt (search for
WorkflowStartAsyncResult, startAsync, and any spec files asserting startedAt and
change them accordingly).

Comment thread packages/core/src/workflow/core.spec.ts Outdated
Comment thread packages/core/src/workflow/core.ts
Comment on lines +2146 to +2208
executeInternal(input, executionOptions)
.catch(async (error) => {
const errorMessage = error instanceof Error ? error.message : String(error);

logger.warn("startAsync execution failed before terminal handling", {
executionId,
error,
});

try {
const existingState = await executionMemory.getWorkflowState(executionId);
if (existingState) {
await executionMemory.updateWorkflowState(executionId, {
status: "error",
metadata: {
...(existingState.metadata ?? {}),
errorMessage,
},
updatedAt: new Date(),
});
return;
}

const contextEntries =
options?.context instanceof Map
? Array.from(options.context.entries())
: options?.context
? Array.from(Object.entries(options.context))
: undefined;
const optionMetadata =
options?.metadata &&
typeof options.metadata === "object" &&
!Array.isArray(options.metadata)
? options.metadata
: undefined;

await executionMemory.setWorkflowState(executionId, {
id: executionId,
workflowId: id,
workflowName: name,
status: "error",
input,
context: contextEntries,
workflowState: options?.workflowState ?? {},
userId: options?.userId,
conversationId: options?.conversationId,
metadata: {
...(optionMetadata ?? {}),
errorMessage,
},
createdAt: startedAt,
updatedAt: new Date(),
});
} catch (persistenceError) {
logger.warn("Failed to persist startAsync background failure", {
executionId,
error: persistenceError,
});
}
})
.catch(() => {
// Prevent unhandled rejections from the async catch handler.
});

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.

⚠️ Potential issue | 🟡 Minor

Second .catch() silently discards errors from the async error-handler itself.

The pattern .catch(async handler).catch(() => {}) is correct for suppressing unhandled-rejection warnings, but the outer .catch(() => {}) swallows all exceptions thrown by the async inner handler — including any unanticipated logic errors that escape the inner try/catch. If, for example, logger.warn(...) throws or error instanceof Error check somehow fails, the problem would be invisible.

Consider promoting the inner logger call outside the try-catch so at minimum the error is logged before any persistence attempt:

 .catch(async (error) => {
   const errorMessage = error instanceof Error ? error.message : String(error);
+
+  logger.warn("startAsync execution failed before terminal handling", {
+    executionId,
+    error,
+  });

   try {
-    logger.warn("startAsync execution failed before terminal handling", {
-      executionId,
-      error,
-    });

     const existingState = await executionMemory.getWorkflowState(executionId);

This way, even if the persistence block fails, the warning is always emitted.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/workflow/core.ts` around lines 2146 - 2208, The outer
.catch(() => {}) swallows any exceptions thrown by the async error handler; move
the initial logger.warn call (the one logging "startAsync execution failed
before terminal handling") out of the try block so it always executes, then keep
the try/catch solely around the persistence calls
(executionMemory.getWorkflowState / updateWorkflowState / setWorkflowState) to
handle persistence failures; finally replace the trailing .catch(() => {}) with
a handler that logs unexpected errors from the async handler (or rethrows) so
failures in executeInternal's error-handling logic aren't silently discarded.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
website/docs/workflows/streaming.md (1)

45-86: ⚠️ Potential issue | 🟡 Minor

Method ordering in the bullet list and code block is inconsistent.

The bullet list (lines 47–49) orders the methods as .run().startAsync().stream(), but the code block labels them Method 1 = .stream(), Method 2 = .run(), Method 3 = .startAsync(). Align either the bullet list order or the "Method N" comments so readers can cross-reference without confusion.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@website/docs/workflows/streaming.md` around lines 45 - 86, The documentation
lists methods in one order but labels them differently in the code block; align
the bullet list and the code comments so readers can cross-reference. Update
either the bullets to list `.stream()`, `.run()`, `.startAsync()` or change the
code comments so Method 1 = `.run()`, Method 2 = `.startAsync()`, Method 3 =
`.stream()` (ensure references to `workflow.stream`, `workflow.run`,
`workflow.startAsync`, and `stream.result` remain correct and consistent).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@website/docs/workflows/overview.md`:
- Around line 273-282: The snippet incorrectly references
greeterChain.toWorkflow() before greeterChain is defined; remove the unnecessary
toWorkflow() call and call startAsync directly on greeterChain (use
greeterChain.startAsync({...})) and then use
greeterChain.memory.getWorkflowState(executionId) to query status; update the
console.log lines accordingly so the snippet is self-contained and consistent
with the earlier examples that use WorkflowChain.startAsync and
memory.getWorkflowState.

In `@website/docs/workflows/streaming.md`:
- Around line 783-788: The interface WorkflowStartAsyncResult is inconsistent
with WorkflowExecutionResult and WorkflowStreamResult because it exposes
startedAt instead of startAt; change the property name in
WorkflowStartAsyncResult from startedAt: Date to startAt: Date, update all
references/usages and any serialization/deserialization or tests that rely on
startedAt, and run/typecheck to ensure consumers now use workflowResult.startAt
consistently across WorkflowStartAsyncResult, WorkflowExecutionResult, and
WorkflowStreamResult.

---

Outside diff comments:
In `@website/docs/workflows/streaming.md`:
- Around line 45-86: The documentation lists methods in one order but labels
them differently in the code block; align the bullet list and the code comments
so readers can cross-reference. Update either the bullets to list `.stream()`,
`.run()`, `.startAsync()` or change the code comments so Method 1 = `.run()`,
Method 2 = `.startAsync()`, Method 3 = `.stream()` (ensure references to
`workflow.stream`, `workflow.run`, `workflow.startAsync`, and `stream.result`
remain correct and consistent).

Comment thread website/docs/workflows/overview.md
Comment thread website/docs/workflows/streaming.md

@cubic-dev-ai cubic-dev-ai Bot 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.

1 issue found across 7 files (changes from recent commits).

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name=".changeset/cool-geese-wave.md">

<violation number="1" location=".changeset/cool-geese-wave.md:7">
P3: The changeset now documents `startAt`, but the API uses `startedAt` per the PR description. This inconsistency will mislead consumers about the correct response field.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.


Add `startAsync()` to workflow and workflow chain APIs for fire-and-forget execution.

`startAsync()` starts a workflow run in the background and returns `{ executionId, workflowId, startAt }` immediately. The run keeps existing execution semantics, respects provided `executionId`, and persists terminal states in memory for later inspection.

@cubic-dev-ai cubic-dev-ai Bot Feb 21, 2026

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.

P3: The changeset now documents startAt, but the API uses startedAt per the PR description. This inconsistency will mislead consumers about the correct response field.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .changeset/cool-geese-wave.md, line 7:

<comment>The changeset now documents `startAt`, but the API uses `startedAt` per the PR description. This inconsistency will mislead consumers about the correct response field.</comment>

<file context>
@@ -4,6 +4,6 @@
 Add `startAsync()` to workflow and workflow chain APIs for fire-and-forget execution.
 
-`startAsync()` starts a workflow run in the background and returns `{ executionId, workflowId, startedAt }` immediately. The run keeps existing execution semantics, respects provided `executionId`, and persists terminal states in memory for later inspection.
+`startAsync()` starts a workflow run in the background and returns `{ executionId, workflowId, startAt }` immediately. The run keeps existing execution semantics, respects provided `executionId`, and persists terminal states in memory for later inspection.
 
 Also adds workflow documentation updates with `startAsync()` usage examples in the workflow overview and streaming docs.
</file context>
Suggested change
`startAsync()` starts a workflow run in the background and returns `{ executionId, workflowId, startAt }` immediately. The run keeps existing execution semantics, respects provided `executionId`, and persists terminal states in memory for later inspection.
`startAsync()` starts a workflow run in the background and returns `{ executionId, workflowId, startedAt }` immediately. The run keeps existing execution semantics, respects provided `executionId`, and persists terminal states in memory for later inspection.
Fix with Cubic

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/core/src/workflow/core.ts`:
- Around line 2171-2175: The startAsync implementation currently uses
options?.resumeFrom?.executionId to derive executionId which allows callers to
pass resumeFrom into the start API and accidentally overwrite a suspended run
when setWorkflowState writes status:"running"; update startAsync (and related
logic around executionId derivation) to reject or ignore resumeFrom:
specifically, remove using resumeFrom.executionId when deriving executionId (use
options.executionId or randomUUID only), and add a guard at the top of
startAsync to detect if options.resumeFrom is present and either throw an error
or refuse to proceed if a stored execution exists with status "suspended" (check
via the same storage lookup used by setWorkflowState) so the suspended
checkpoint and events are not overwritten; keep resume behavior confined to the
dedicated resume/executeInternal path.

In `@website/docs/workflows/overview.md`:
- Around line 292-297: The docs example incorrectly accesses greeterChain.memory
which doesn’t exist on WorkflowChain; update the snippet to call
greeterChain.toWorkflow() and then use the public memory API to query state
(i.e., call
greeterChain.toWorkflow().memory.getWorkflowState(started.executionId)); keep
use of started = await greeterChain.startAsync({ name: "Alice" }) and
started.executionId/startAt unchanged and only replace the memory access with
the toWorkflow().memory.getWorkflowState call.

Comment thread packages/core/src/workflow/core.ts Outdated
Comment thread website/docs/workflows/overview.md

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/core/src/workflow/core.ts (1)

2177-2189: Unnecessary async storage round-trip in resumeFrom guard.

Both branches always throw — the getWorkflowState call is made solely to pick between two error messages. If storage itself fails here, the caller receives a confusing storage error instead of the expected "resumeFrom not supported" error.

♻️ Simplified guard (no storage I/O on the fast-reject path)
-    if (options?.resumeFrom) {
-      const resumeExecutionId = options.resumeFrom.executionId;
-      const resumeState = await executionMemory.getWorkflowState(resumeExecutionId);
-      if (resumeState?.status === "suspended") {
-        throw new Error(
-          `startAsync does not support resumeFrom for suspended execution ${resumeExecutionId}. Use workflow.run(...) or workflow.stream(...) with resumeFrom.`,
-        );
-      }
-
-      throw new Error(
-        "startAsync does not support resumeFrom. Use workflow.run(...) or workflow.stream(...) with resumeFrom.",
-      );
-    }
+    if (options?.resumeFrom) {
+      throw new Error(
+        "startAsync does not support resumeFrom. Use workflow.run(...) or workflow.stream(...) with resumeFrom.",
+      );
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/workflow/core.ts` around lines 2177 - 2189, The resumeFrom
guard is performing unnecessary storage I/O by calling
executionMemory.getWorkflowState (resumeExecutionId/resumeState) even though
both branches always throw; remove the executionMemory.getWorkflowState call and
the suspended-status branch and instead immediately throw the generic
"startAsync does not support resumeFrom. Use workflow.run(...) or
workflow.stream(...) with resumeFrom." when options?.resumeFrom is present
(references: options?.resumeFrom, startAsync, executionMemory.getWorkflowState,
resumeExecutionId, resumeState).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/core/src/workflow/core.ts`:
- Around line 2223-2227: Public API type WorkflowRunOptions currently exposes
internal flag skipStateInit, allowing callers of run()/stream()/startAsync() to
bypass state initialization; fix by creating a separate internal options type
(e.g., InternalWorkflowRunOptions) that extends WorkflowRunOptions but adds
skipStateInit, update executeInternal signature to accept
InternalWorkflowRunOptions (or a private overload), and ensure all internal call
sites such as startAsync and the call at executionOptions creation replace
WorkflowRunOptions with the new internal type so skipStateInit is no longer
present on the public WorkflowRunOptions surface.

---

Nitpick comments:
In `@packages/core/src/workflow/core.ts`:
- Around line 2177-2189: The resumeFrom guard is performing unnecessary storage
I/O by calling executionMemory.getWorkflowState (resumeExecutionId/resumeState)
even though both branches always throw; remove the
executionMemory.getWorkflowState call and the suspended-status branch and
instead immediately throw the generic "startAsync does not support resumeFrom.
Use workflow.run(...) or workflow.stream(...) with resumeFrom." when
options?.resumeFrom is present (references: options?.resumeFrom, startAsync,
executionMemory.getWorkflowState, resumeExecutionId, resumeState).

Comment on lines +2223 to +2227
const executionOptions: WorkflowRunOptions = {
...options,
executionId,
skipStateInit: true,
};

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.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check where skipStateInit is declared
rg -n 'skipStateInit' --type=ts -B 2 -A 2

# Confirm whether WorkflowRunOptions is exported from the public index
rg -n 'WorkflowRunOptions' packages/core/src/index.ts packages/core/src/workflow/index.ts 2>/dev/null || true

Repository: VoltAgent/voltagent

Length of output: 1019


🏁 Script executed:

#!/bin/bash
# Get the full WorkflowRunOptions interface definition
rg -n 'interface WorkflowRunOptions|export.*WorkflowRunOptions' --type=ts -A 30 packages/core/src/workflow/types.ts

Repository: VoltAgent/voltagent

Length of output: 1068


🏁 Script executed:

#!/bin/bash
# Get more of the WorkflowRunOptions interface, including skipStateInit
rg -n 'export interface WorkflowRunOptions' --type=ts -A 80 packages/core/src/workflow/types.ts | head -100

Repository: VoltAgent/voltagent

Length of output: 2651


skipStateInit is exposed in the public WorkflowRunOptions type without type-level enforcement.

skipStateInit is declared in WorkflowRunOptions (line 318, types.ts) with only an @internal JSDoc comment. Since WorkflowRunOptions is publicly exported and used as the parameter type for run(), stream(), and startAsync(), any caller can pass skipStateInit: true. This will bypass initial state creation (line 930, core.ts) and fail with a confusing "Workflow state X not found" error if no pre-created state exists.

Recommend creating a separate internal type for executeInternal:

♻️ Proposed fix — separate internal options type
+// In types.ts (internal, not exported):
+export interface WorkflowInternalRunOptions extends WorkflowRunOptions {
+  skipStateInit?: boolean;
+}

Then update executeInternal's signature:

-  const executeInternal = async (
-    input: WorkflowInput<INPUT_SCHEMA>,
-    options?: WorkflowRunOptions,
+  const executeInternal = async (
+    input: WorkflowInput<INPUT_SCHEMA>,
+    options?: WorkflowInternalRunOptions,

And the startAsync call site (line 2223):

-      const executionOptions: WorkflowRunOptions = {
+      const executionOptions: WorkflowInternalRunOptions = {
         ...options,
         executionId,
         skipStateInit: true,
       };

This enforces type safety by keeping internal flags out of the public API surface.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/workflow/core.ts` around lines 2223 - 2227, Public API type
WorkflowRunOptions currently exposes internal flag skipStateInit, allowing
callers of run()/stream()/startAsync() to bypass state initialization; fix by
creating a separate internal options type (e.g., InternalWorkflowRunOptions)
that extends WorkflowRunOptions but adds skipStateInit, update executeInternal
signature to accept InternalWorkflowRunOptions (or a private overload), and
ensure all internal call sites such as startAsync and the call at
executionOptions creation replace WorkflowRunOptions with the new internal type
so skipStateInit is no longer present on the public WorkflowRunOptions surface.

@omeraplak
omeraplak merged commit e15bb6e into main Feb 21, 2026
23 checks passed
@omeraplak
omeraplak deleted the feat/workflow-start-async-fire-and-forget branch February 21, 2026 20:08
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.

1 participant