Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/cool-geese-wave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@voltagent/core": minor
---

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


Also adds workflow documentation updates with `startAsync()` usage examples in the workflow overview and streaming docs.
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export type {
WorkflowHookStatus,
WorkflowHooks,
WorkflowStats,
WorkflowStartAsyncResult,
WorkflowStateStore,
WorkflowStateUpdater,
WorkflowStepData,
Expand Down
42 changes: 42 additions & 0 deletions packages/core/src/workflow/chain.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,48 @@ describe.sequential("workflow.run", () => {
});
});

describe.sequential("workflow.startAsync", () => {
beforeEach(() => {
const registry = WorkflowRegistry.getInstance();
(registry as any).workflows.clear();
});

it("should start chain workflow execution in background", async () => {
const memory = new Memory({ storage: new InMemoryStorageAdapter() });

const workflow = createWorkflowChain({
id: "chain-start-async",
name: "Chain Start Async",
input: z.object({ value: z.number() }),
result: z.object({ result: z.number() }),
memory,
}).andThen({
id: "double",
execute: async ({ data }) => ({ result: data.value * 2 }),
});

const registry = WorkflowRegistry.getInstance();
registry.registerWorkflow(workflow.toWorkflow());

const startResult = await workflow.startAsync({ value: 10 });

expect(startResult).toEqual({
executionId: expect.any(String),
workflowId: "chain-start-async",
startAt: expect.any(Date),
});

let state = await memory.getWorkflowState(startResult.executionId);
for (let i = 0; i < 100 && state?.status !== "completed"; i += 1) {
await new Promise((resolve) => setTimeout(resolve, 10));
state = await memory.getWorkflowState(startResult.executionId);
}

expect(state?.status).toBe("completed");
expect(state?.output).toEqual({ result: 20 });
});
});

describe.sequential("workflow writer API", () => {
beforeEach(() => {
const registry = WorkflowRegistry.getInstance();
Expand Down
16 changes: 16 additions & 0 deletions packages/core/src/workflow/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import type {
WorkflowExecutionResult,
WorkflowInput,
WorkflowRunOptions,
WorkflowStartAsyncResult,
WorkflowStateStore,
WorkflowStateUpdater,
WorkflowStepData,
Expand Down Expand Up @@ -970,6 +971,21 @@ export class WorkflowChain<
>;
}

/**
* Start the workflow in the background without waiting for completion
*/
async startAsync(
input: WorkflowInput<INPUT_SCHEMA>,
options?: WorkflowRunOptions,
): Promise<WorkflowStartAsyncResult> {
const workflow = createWorkflow<INPUT_SCHEMA, RESULT_SCHEMA, SUSPEND_SCHEMA, RESUME_SCHEMA>(
this.config,
// @ts-expect-error - upstream types work and this is nature of how the createWorkflow function is typed using variadic args
...this.steps,
);
return workflow.startAsync(input, options);
}

/**
* Execute the workflow with streaming support
*/
Expand Down
201 changes: 201 additions & 0 deletions packages/core/src/workflow/core.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,207 @@ describe.sequential("workflow streaming", () => {
});
});

describe.sequential("workflow.startAsync", () => {
beforeEach(() => {
const registry = WorkflowRegistry.getInstance();
(registry as any).workflows.clear();
});

it("should return immediately and complete in the background", async () => {
const memory = new Memory({ storage: new InMemoryStorageAdapter() });
let releaseStep: (() => void) | undefined;
const stepGate = new Promise<void>((resolve) => {
releaseStep = resolve;
});

const workflow = createWorkflow(
{
id: "start-async-background",
name: "Start Async Background",
input: z.object({ value: z.number() }),
result: z.object({ result: z.number() }),
memory,
},
andThen({
id: "wait-for-release",
execute: async ({ data }) => {
await stepGate;
return { result: data.value * 2 };
},
}),
);

const registry = WorkflowRegistry.getInstance();
registry.registerWorkflow(workflow);

const startResult = await workflow.startAsync({ value: 21 });

expect(startResult).toEqual({
executionId: expect.any(String),
workflowId: "start-async-background",
startAt: expect.any(Date),
});

let runningState = await memory.getWorkflowState(startResult.executionId);
for (let i = 0; i < 100 && runningState?.status !== "running"; i += 1) {
await new Promise((resolve) => setTimeout(resolve, 10));
runningState = await memory.getWorkflowState(startResult.executionId);
}
expect(runningState?.status).toBe("running");

releaseStep?.();

let completedState = await memory.getWorkflowState(startResult.executionId);
for (let i = 0; i < 100 && completedState?.status !== "completed"; i += 1) {
await new Promise((resolve) => setTimeout(resolve, 10));
completedState = await memory.getWorkflowState(startResult.executionId);
}

expect(completedState?.status).toBe("completed");
expect(completedState?.output).toEqual({ result: 42 });
});

it("should persist error state when background execution fails", async () => {
const memory = new Memory({ storage: new InMemoryStorageAdapter() });

const workflow = createWorkflow(
{
id: "start-async-error",
name: "Start Async Error",
input: z.object({ value: z.number() }),
result: z.object({ result: z.number() }),
memory,
},
andThen({
id: "throw-error",
execute: async () => {
throw new Error("startAsync failure");
},
}),
);

const registry = WorkflowRegistry.getInstance();
registry.registerWorkflow(workflow);

const startResult = await workflow.startAsync({ value: 1 });
let erroredState = await memory.getWorkflowState(startResult.executionId);

for (let i = 0; i < 100 && erroredState?.status !== "error"; i += 1) {
await new Promise((resolve) => setTimeout(resolve, 10));
erroredState = await memory.getWorkflowState(startResult.executionId);
}

expect(erroredState?.status).toBe("error");
expect(erroredState?.metadata).toEqual(
expect.objectContaining({
errorMessage: "startAsync failure",
}),
);
});

it("should respect executionId passed in options", async () => {
const memory = new Memory({ storage: new InMemoryStorageAdapter() });

const workflow = createWorkflow(
{
id: "start-async-execution-id",
name: "Start Async Execution ID",
input: z.object({ value: z.number() }),
result: z.object({ value: z.number() }),
memory,
},
andThen({
id: "echo",
execute: async ({ data }) => data,
}),
);

const registry = WorkflowRegistry.getInstance();
registry.registerWorkflow(workflow);

const executionId = "execution-id-start-async";
const startResult = await workflow.startAsync(
{ value: 5 },
{
executionId,
},
);

expect(startResult.executionId).toBe(executionId);

let state = await memory.getWorkflowState(executionId);
for (let i = 0; i < 100 && state?.status !== "completed"; i += 1) {
await new Promise((resolve) => setTimeout(resolve, 10));
state = await memory.getWorkflowState(executionId);
}

expect(state?.status).toBe("completed");
expect(state?.output).toEqual({ value: 5 });
});

it("should reject resumeFrom options to avoid overwriting suspended runs", async () => {
const memory = new Memory({ storage: new InMemoryStorageAdapter() });

const workflow = createWorkflow(
{
id: "start-async-reject-resume-from",
name: "Start Async Reject Resume From",
input: z.object({ value: z.number() }),
result: z.object({ value: z.number() }),
memory,
},
andThen({
id: "echo",
execute: async ({ data }) => data,
}),
);

const registry = WorkflowRegistry.getInstance();
registry.registerWorkflow(workflow);

const executionId = "suspended-run";
const suspendedAt = new Date();
await memory.setWorkflowState(executionId, {
id: executionId,
workflowId: "start-async-reject-resume-from",
workflowName: "Start Async Reject Resume From",
status: "suspended",
input: { value: 10 },
workflowState: { stage: "waiting" },
suspension: {
suspendedAt,
reason: "awaiting-input",
stepIndex: 0,
},
metadata: {
marker: "preserve-me",
},
createdAt: suspendedAt,
updatedAt: suspendedAt,
});

await expect(
workflow.startAsync(
{ value: 5 },
{
resumeFrom: {
executionId,
resumeStepIndex: 0,
},
},
),
).rejects.toThrow("startAsync does not support resumeFrom");

const persisted = await memory.getWorkflowState(executionId);
expect(persisted?.status).toBe("suspended");
expect(persisted?.metadata).toEqual(
expect.objectContaining({
marker: "preserve-me",
}),
);
});
});

describe.sequential("workflow memory defaults", () => {
beforeEach(() => {
const registry = AgentRegistry.getInstance();
Expand Down
Loading