Describe the Bug
Upstream issue draft — nested (sub-workflow) suspend is reported as two suspended steps
Target: https://github.com/mastra-ai/mastra/issues/new
Status: draft, not submitted. Everything below was verified locally on 2026-08-11; the
"Verified / Not verified" markers are deliberate — please keep them honest if you edit.
Title
getWorkflowRunById reports one nested (sub-workflow) suspend as two suspended steps, so Studio renders two resume forms
Body
Summary
When a step inside a nested sub-workflow (e.g. a .dountil(...) loop used via .then(subWorkflow))
suspends, workflow.getWorkflowRunById(runId).steps contains two entries with
status: 'suspended' for that single suspension:
- the parent step entry —
inner-loop
- the flattened nested entry —
inner-loop.inner-step
snapshot.suspendedPaths is correct (one key). Only the steps map is doubled.
Because Studio renders one "Step suspended / Needs input" resume form per suspended step, a single
pending question can show up as two forms, and stale entries from an already-resumed nested step can
linger next to a genuinely-suspended later step.
There are three related data-quality problems in the same area, all reproduced by the same script
(details + repro output below):
- Double reporting — one nested suspend → two
status: 'suspended' entries.
suspendPayload / suspendedAt are retained after a step reaches success — so
"does this step have a suspend payload?" is not a usable signal for "is it waiting on input?".
startedAt can be later than suspendedAt on a looped nested step, because a subsequent
loop iteration overwrites startedAt while suspendedAt stays from the earlier iteration.
Studio renders this as a negative duration.
Additionally, the two transports disagree on the identity of a nested step:
- REST /
getWorkflowRunById → dotted key, inner-loop.inner-step
- stream / watch events on
workflow.events.v2.${runId} → plain payload.id = 'inner-step'
Nested runs share the parent runId, so a parent-level watcher receives nested step events under the
plain id. Any client that merges "snapshot + live events" into one keyed map (Studio does) ends up
holding two identities for the same physical step, and a success event under one key does not clear
the suspended record under the other.
Reproduction
Self-contained script — a parent workflow with one nested dountil sub-workflow whose inner step
suspends once.
// repro.mjs — deps: @mastra/core, @mastra/libsql, zod ("type": "module")
import { rmSync } from 'node:fs';
import { z } from 'zod';
import { Mastra } from '@mastra/core';
import { createStep, createWorkflow } from '@mastra/core/workflows';
import { LibSQLStore } from '@mastra/libsql';
const DB = new URL('./repro.db', import.meta.url).pathname;
for (const s of ['', '-shm', '-wal']) rmSync(DB + s, { force: true });
const state = z.object({ round: z.number(), done: z.boolean() });
const inner = createStep({
id: 'inner-step',
inputSchema: state,
outputSchema: state,
suspendSchema: z.object({ question: z.string() }),
resumeSchema: z.object({ answer: z.string() }),
execute: async ({ inputData, resumeData, suspend }) => {
if (resumeData) {
// EXTRA_ITER=1: answering does not end the loop, so the loop re-enters and the
// step body runs a second time (this is what triggers problem 3).
return { round: inputData.round + 1, done: process.env.EXTRA_ITER !== '1' };
}
if (inputData.round > 0) return { round: inputData.round, done: true };
return await suspend({ question: 'pick one' });
},
});
const innerLoop = createWorkflow({ id: 'inner-loop', inputSchema: state, outputSchema: state })
.dountil(inner, async ({ inputData }) => inputData.done)
.commit();
const after = createStep({
id: 'after-step',
inputSchema: state,
outputSchema: state,
execute: async ({ inputData }) => inputData,
});
const parent = createWorkflow({ id: 'parent-wf', inputSchema: state, outputSchema: state })
.then(innerLoop)
.then(after)
.commit();
const mastra = new Mastra({
storage: new LibSQLStore({ id: 'repro', url: `file:${DB}` }),
workflows: { parent },
logger: false,
});
const wf = mastra.getWorkflow('parent');
function report(label, st) {
const suspended = Object.entries(st.steps ?? {})
.filter(([k, v]) => k !== 'input' && v?.status === 'suspended')
.map(([k]) => k);
console.log(`\n--- ${label} ---`);
console.log(`run status : ${st.status}`);
console.log(`suspendedPaths : ${JSON.stringify(st.suspendedPaths ?? {})}`);
console.log(`>>> steps with status==="suspended": ${suspended.length} ${JSON.stringify(suspended)}`);
for (const [k, v] of Object.entries(st.steps ?? {})) {
if (k === 'input' || !v || typeof v !== 'object') continue;
console.log(
` ${k}: status=${v.status} startedAt=${v.startedAt} suspendedAt=${v.suspendedAt}` +
` resumedAt=${v.resumedAt} endedAt=${v.endedAt} hasSuspendPayload=${!!v.suspendPayload}`,
);
if (v.suspendedAt && v.startedAt && v.suspendedAt < v.startedAt) {
console.log(` >>> TIMESTAMP INVERSION on "${k}" (delta ${v.suspendedAt - v.startedAt}ms)`);
}
}
}
const run = await wf.createRun();
console.log(JSON.stringify(wf.serializedStepGraph?.map((e) => ({ type: e.type, id: e.id, stepId: e.step?.id }))));
await run.start({ inputData: { round: 0, done: false } });
report('after start — exactly ONE suspend happened', await wf.getWorkflowRunById(run.runId));
const rootStep = Object.keys((await wf.getWorkflowRunById(run.runId)).suspendedPaths ?? {})[0];
if (Number(process.env.DELAY_MS ?? 0) > 0) {
await new Promise((r) => setTimeout(r, Number(process.env.DELAY_MS)));
}
// COLD=1 resumes via a fresh Run handle rebuilt from the snapshot, i.e. what an HTTP resume does.
const resumer = process.env.COLD === '1' ? await wf.createRun({ runId: run.runId }) : run;
await resumer.resume({ step: [rootStep, 'inner-step'], resumeData: { answer: 'a' } });
report('after resume — nothing should be suspended', await wf.getWorkflowRunById(run.runId));
process.exit(0);
Run:
node repro.mjs # problems 1 and 2
COLD=1 DELAY_MS=3000 EXTRA_ITER=1 node repro.mjs # additionally problem 3
Actual output (@mastra/core@1.57.0)
--- after start — exactly ONE suspend happened ---
run status : suspended
suspendedPaths : {"inner-loop":[0]}
>>> steps with status==="suspended": 2 ["inner-loop","inner-loop.inner-step"]
inner-loop: status=suspended ... hasSuspendPayload=true
inner-loop.inner-step: status=suspended ... hasSuspendPayload=true
--- after resume — nothing should be suspended ---
run status : success
>>> steps with status==="suspended": 0 []
inner-loop: status=success ... suspendedAt=...300 hasSuspendPayload=true # payload retained
inner-loop.inner-step: status=success ... suspendedAt=...300 hasSuspendPayload=true # payload retained
With COLD=1 DELAY_MS=3000 EXTRA_ITER=1:
inner-loop.inner-step: status=success startedAt=1786427069305 suspendedAt=1786427066300 ...
>>> TIMESTAMP INVERSION on "inner-loop.inner-step" (delta -3005ms)
Expected
steps should identify a nested step once, under one stable key, and only the step that is
actually awaiting input should carry status: 'suspended' (the parent container entry should not
duplicate it).
- The same step should have the same identity in
getWorkflowRunById and in the
workflow.events.v2.* watch stream, so a UI can merge snapshot + live events without duplicates.
suspendPayload / suspendedAt should be cleared (or clearly scoped to the current attempt) once
a step reaches a terminal status.
startedAt <= suspendedAt should hold for whatever attempt the record describes.
Environment
|
|
@mastra/core |
1.57.0 (also reproduced on 1.50.1, see below) |
mastra (CLI + Studio) |
1.23.0 |
@mastra/libsql |
1.19.0 |
| Node |
25.3.0 |
| OS |
macOS 14.4.1 (darwin, arm64) |
| storage |
LibSQLStore, local file |
Is this a regression?
No — verified. I ran the identical script against @mastra/core@1.50.1 + @mastra/libsql@1.15.1
and against 1.57.0 + 1.19.0. Problems 1, 2 and 3 reproduce identically in both. The dotted
`${step}.${key}` merge and the plain-step.id stream payloads exist in 1.50.1 too.
The one thing that did change in this area is the serialized graph shape for nested
sub-workflows (documented under 1.56.0, "Nested workflows as a first-class serialized step type"):
1.50.1: [{"type":"step","stepId":"inner-loop"}, {"type":"step","stepId":"after-step"}]
1.57.0: [{"type":"workflow","id":"inner-loop"}, {"type":"step","stepId":"after-step"}]
So the underlying data problems are long-standing; what changed for us is that after upgrading
(mastra 1.18.2 → 1.23.0, core 1.50.1 → 1.57.0) the Studio UI started surfacing them. Concretely, in
Studio we now see, on a real run with one nested suspend:
- the resume form titled with the dotted id
aisearch-clarify-loop.aisearch-clarify
- a Timeline listing both
Aisearch Clarify Loop and Aisearch Clarify Loop Aisearch Clarify
- a negative duration on the nested entry:
Aisearch Clarify Loop Aisearch Clarify -0.001s
(and 1190000s on its parent)
Not verified
We also observed, on a long-running real workflow, two resume forms at once (one for an
already-answered nested clarify step, one for a genuinely-suspended later step) plus an
out-of-order / incomplete node list — but only in a browser tab kept open across a resume; a hard
reload always renders correctly. We could not capture that client state before it was lost, so we are
not claiming a specific Studio-side cause here. The four data problems above are what we can prove,
and they are sufficient to explain a client that merges snapshot + stream into one keyed map ending
up with stale suspended records.
Local notes (not part of the issue)
- Working repro harness:
data/repro-nested/ (gitignored). old/ and new/ are sibling sandboxes
whose node_modules/@mastra/{core,libsql} symlink into node_modules/.pnpm/... for the two
version pairs, so both can run the same repro.mjs. Recreate with the ln -s block from the
session, or delete the directory — nothing else depends on it.
- Our own affected code:
getWorkflowState() in
src/mastra/common/libs/workflow/run-snapshot.ts merges every context[*].output in insertion
order, and the dotted nested entries now land after their parent. On the run we inspected the
parent and nested outputs were byte-identical, so nothing broke — but if a loop's last inner output
ever diverges from the loop output, the dotted entry would silently win. Worth a guard that skips
keys containing ..
Steps To Reproduce
// repro.mjs — deps: @mastra/core, @mastra/libsql, zod ("type": "module")
import { rmSync } from 'node:fs';
import { z } from 'zod';
import { Mastra } from '@mastra/core';
import { createStep, createWorkflow } from '@mastra/core/workflows';
import { LibSQLStore } from '@mastra/libsql';
const DB = new URL('./repro.db', import.meta.url).pathname;
for (const s of ['', '-shm', '-wal']) rmSync(DB + s, { force: true });
const state = z.object({ round: z.number(), done: z.boolean() });
const inner = createStep({
id: 'inner-step',
inputSchema: state,
outputSchema: state,
suspendSchema: z.object({ question: z.string() }),
resumeSchema: z.object({ answer: z.string() }),
execute: async ({ inputData, resumeData, suspend }) => {
if (resumeData) {
// EXTRA_ITER=1: answering does not end the loop, so the loop re-enters and the
// step body runs a second time (this is what triggers problem 3).
return { round: inputData.round + 1, done: process.env.EXTRA_ITER !== '1' };
}
if (inputData.round > 0) return { round: inputData.round, done: true };
return await suspend({ question: 'pick one' });
},
});
const innerLoop = createWorkflow({ id: 'inner-loop', inputSchema: state, outputSchema: state })
.dountil(inner, async ({ inputData }) => inputData.done)
.commit();
const after = createStep({
id: 'after-step',
inputSchema: state,
outputSchema: state,
execute: async ({ inputData }) => inputData,
});
const parent = createWorkflow({ id: 'parent-wf', inputSchema: state, outputSchema: state })
.then(innerLoop)
.then(after)
.commit();
const mastra = new Mastra({
storage: new LibSQLStore({ id: 'repro', url: file:${DB} }),
workflows: { parent },
logger: false,
});
const wf = mastra.getWorkflow('parent');
function report(label, st) {
const suspended = Object.entries(st.steps ?? {})
.filter(([k, v]) => k !== 'input' && v?.status === 'suspended')
.map(([k]) => k);
console.log(\n--- ${label} ---);
console.log(run status : ${st.status});
console.log(suspendedPaths : ${JSON.stringify(st.suspendedPaths ?? {})});
console.log(>>> steps with status==="suspended": ${suspended.length} ${JSON.stringify(suspended)});
for (const [k, v] of Object.entries(st.steps ?? {})) {
if (k === 'input' || !v || typeof v !== 'object') continue;
console.log(
${k}: status=${v.status} startedAt=${v.startedAt} suspendedAt=${v.suspendedAt} +
resumedAt=${v.resumedAt} endedAt=${v.endedAt} hasSuspendPayload=${!!v.suspendPayload},
);
if (v.suspendedAt && v.startedAt && v.suspendedAt < v.startedAt) {
console.log( >>> TIMESTAMP INVERSION on "${k}" (delta ${v.suspendedAt - v.startedAt}ms));
}
}
}
const run = await wf.createRun();
console.log(JSON.stringify(wf.serializedStepGraph?.map((e) => ({ type: e.type, id: e.id, stepId: e.step?.id }))));
await run.start({ inputData: { round: 0, done: false } });
report('after start — exactly ONE suspend happened', await wf.getWorkflowRunById(run.runId));
const rootStep = Object.keys((await wf.getWorkflowRunById(run.runId)).suspendedPaths ?? {})[0];
if (Number(process.env.DELAY_MS ?? 0) > 0) {
await new Promise((r) => setTimeout(r, Number(process.env.DELAY_MS)));
}
// COLD=1 resumes via a fresh Run handle rebuilt from the snapshot, i.e. what an HTTP resume does.
const resumer = process.env.COLD === '1' ? await wf.createRun({ runId: run.runId }) : run;
await resumer.resume({ step: [rootStep, 'inner-step'], resumeData: { answer: 'a' } });
report('after resume — nothing should be suspended', await wf.getWorkflowRunById(run.runId));
process.exit(0);
Link to Minimal Reproducible Example
https://github.com/mastra-ai/mastra/issues/new?template=bug_report.yml
Expected Behavior
no
Environment Information
Verification
Describe the Bug
Upstream issue draft — nested (sub-workflow) suspend is reported as two suspended steps
Target: https://github.com/mastra-ai/mastra/issues/new
Status: draft, not submitted. Everything below was verified locally on 2026-08-11; the
"Verified / Not verified" markers are deliberate — please keep them honest if you edit.
Title
getWorkflowRunByIdreports one nested (sub-workflow) suspend as twosuspendedsteps, so Studio renders two resume formsBody
Summary
When a step inside a nested sub-workflow (e.g. a
.dountil(...)loop used via.then(subWorkflow))suspends,
workflow.getWorkflowRunById(runId).stepscontains two entries withstatus: 'suspended'for that single suspension:inner-loopinner-loop.inner-stepsnapshot.suspendedPathsis correct (one key). Only thestepsmap is doubled.Because Studio renders one "Step suspended / Needs input" resume form per suspended step, a single
pending question can show up as two forms, and stale entries from an already-resumed nested step can
linger next to a genuinely-suspended later step.
There are three related data-quality problems in the same area, all reproduced by the same script
(details + repro output below):
status: 'suspended'entries.suspendPayload/suspendedAtare retained after a step reachessuccess— so"does this step have a suspend payload?" is not a usable signal for "is it waiting on input?".
startedAtcan be later thansuspendedAton a looped nested step, because a subsequentloop iteration overwrites
startedAtwhilesuspendedAtstays from the earlier iteration.Studio renders this as a negative duration.
Additionally, the two transports disagree on the identity of a nested step:
getWorkflowRunById→ dotted key,inner-loop.inner-stepworkflow.events.v2.${runId}→ plainpayload.id = 'inner-step'Nested runs share the parent
runId, so a parent-level watcher receives nested step events under theplain id. Any client that merges "snapshot + live events" into one keyed map (Studio does) ends up
holding two identities for the same physical step, and a
successevent under one key does not clearthe
suspendedrecord under the other.Reproduction
Self-contained script — a parent workflow with one nested
dountilsub-workflow whose inner stepsuspends once.
Run:
Actual output (
@mastra/core@1.57.0)With
COLD=1 DELAY_MS=3000 EXTRA_ITER=1:Expected
stepsshould identify a nested step once, under one stable key, and only the step that isactually awaiting input should carry
status: 'suspended'(the parent container entry should notduplicate it).
getWorkflowRunByIdand in theworkflow.events.v2.*watch stream, so a UI can merge snapshot + live events without duplicates.suspendPayload/suspendedAtshould be cleared (or clearly scoped to the current attempt) oncea step reaches a terminal status.
startedAt <= suspendedAtshould hold for whatever attempt the record describes.Environment
@mastra/core1.57.0(also reproduced on1.50.1, see below)mastra(CLI + Studio)1.23.0@mastra/libsql1.19.025.3.0LibSQLStore, local fileIs this a regression?
No — verified. I ran the identical script against
@mastra/core@1.50.1+@mastra/libsql@1.15.1and against
1.57.0+1.19.0. Problems 1, 2 and 3 reproduce identically in both. The dotted`${step}.${key}`merge and the plain-step.idstream payloads exist in1.50.1too.The one thing that did change in this area is the serialized graph shape for nested
sub-workflows (documented under
1.56.0, "Nested workflows as a first-class serialized step type"):So the underlying data problems are long-standing; what changed for us is that after upgrading
(
mastra1.18.2 → 1.23.0, core 1.50.1 → 1.57.0) the Studio UI started surfacing them. Concretely, inStudio we now see, on a real run with one nested suspend:
aisearch-clarify-loop.aisearch-clarifyAisearch Clarify LoopandAisearch Clarify Loop Aisearch ClarifyAisearch Clarify Loop Aisearch Clarify -0.001s(and
1190000son its parent)Not verified
We also observed, on a long-running real workflow, two resume forms at once (one for an
already-answered nested
clarifystep, one for a genuinely-suspended later step) plus anout-of-order / incomplete node list — but only in a browser tab kept open across a resume; a hard
reload always renders correctly. We could not capture that client state before it was lost, so we are
not claiming a specific Studio-side cause here. The four data problems above are what we can prove,
and they are sufficient to explain a client that merges snapshot + stream into one keyed map ending
up with stale
suspendedrecords.Local notes (not part of the issue)
data/repro-nested/(gitignored).old/andnew/are sibling sandboxeswhose
node_modules/@mastra/{core,libsql}symlink intonode_modules/.pnpm/...for the twoversion pairs, so both can run the same
repro.mjs. Recreate with theln -sblock from thesession, or delete the directory — nothing else depends on it.
getWorkflowState()insrc/mastra/common/libs/workflow/run-snapshot.tsmerges everycontext[*].outputin insertionorder, and the dotted nested entries now land after their parent. On the run we inspected the
parent and nested outputs were byte-identical, so nothing broke — but if a loop's last inner output
ever diverges from the loop output, the dotted entry would silently win. Worth a guard that skips
keys containing
..Steps To Reproduce
// repro.mjs — deps: @mastra/core, @mastra/libsql, zod ("type": "module")
import { rmSync } from 'node:fs';
import { z } from 'zod';
import { Mastra } from '@mastra/core';
import { createStep, createWorkflow } from '@mastra/core/workflows';
import { LibSQLStore } from '@mastra/libsql';
const DB = new URL('./repro.db', import.meta.url).pathname;
for (const s of ['', '-shm', '-wal']) rmSync(DB + s, { force: true });
const state = z.object({ round: z.number(), done: z.boolean() });
const inner = createStep({
id: 'inner-step',
inputSchema: state,
outputSchema: state,
suspendSchema: z.object({ question: z.string() }),
resumeSchema: z.object({ answer: z.string() }),
execute: async ({ inputData, resumeData, suspend }) => {
if (resumeData) {
// EXTRA_ITER=1: answering does not end the loop, so the loop re-enters and the
// step body runs a second time (this is what triggers problem 3).
return { round: inputData.round + 1, done: process.env.EXTRA_ITER !== '1' };
}
if (inputData.round > 0) return { round: inputData.round, done: true };
return await suspend({ question: 'pick one' });
},
});
const innerLoop = createWorkflow({ id: 'inner-loop', inputSchema: state, outputSchema: state })
.dountil(inner, async ({ inputData }) => inputData.done)
.commit();
const after = createStep({
id: 'after-step',
inputSchema: state,
outputSchema: state,
execute: async ({ inputData }) => inputData,
});
const parent = createWorkflow({ id: 'parent-wf', inputSchema: state, outputSchema: state })
.then(innerLoop)
.then(after)
.commit();
const mastra = new Mastra({
storage: new LibSQLStore({ id: 'repro', url:
file:${DB}}),workflows: { parent },
logger: false,
});
const wf = mastra.getWorkflow('parent');
function report(label, st) {
const suspended = Object.entries(st.steps ?? {})
.filter(([k, v]) => k !== 'input' && v?.status === 'suspended')
.map(([k]) => k);
console.log(
\n--- ${label} ---);console.log(
run status : ${st.status});console.log(
suspendedPaths : ${JSON.stringify(st.suspendedPaths ?? {})});console.log(
>>> steps with status==="suspended": ${suspended.length} ${JSON.stringify(suspended)});for (const [k, v] of Object.entries(st.steps ?? {})) {
if (k === 'input' || !v || typeof v !== 'object') continue;
console.log(
${k}: status=${v.status} startedAt=${v.startedAt} suspendedAt=${v.suspendedAt}+resumedAt=${v.resumedAt} endedAt=${v.endedAt} hasSuspendPayload=${!!v.suspendPayload},);
if (v.suspendedAt && v.startedAt && v.suspendedAt < v.startedAt) {
console.log(
>>> TIMESTAMP INVERSION on "${k}" (delta ${v.suspendedAt - v.startedAt}ms));}
}
}
const run = await wf.createRun();
console.log(JSON.stringify(wf.serializedStepGraph?.map((e) => ({ type: e.type, id: e.id, stepId: e.step?.id }))));
await run.start({ inputData: { round: 0, done: false } });
report('after start — exactly ONE suspend happened', await wf.getWorkflowRunById(run.runId));
const rootStep = Object.keys((await wf.getWorkflowRunById(run.runId)).suspendedPaths ?? {})[0];
if (Number(process.env.DELAY_MS ?? 0) > 0) {
await new Promise((r) => setTimeout(r, Number(process.env.DELAY_MS)));
}
// COLD=1 resumes via a fresh Run handle rebuilt from the snapshot, i.e. what an HTTP resume does.
const resumer = process.env.COLD === '1' ? await wf.createRun({ runId: run.runId }) : run;
await resumer.resume({ step: [rootStep, 'inner-step'], resumeData: { answer: 'a' } });
report('after resume — nothing should be suspended', await wf.getWorkflowRunById(run.runId));
process.exit(0);
Link to Minimal Reproducible Example
https://github.com/mastra-ai/mastra/issues/new?template=bug_report.yml
Expected Behavior
no
Environment Information
Verification