Skip to content

feat(workflow-executor): run a Sub-workflow once per record of a Multiple step - #2060

Draft
hercemer42 wants to merge 6 commits into
feature/prd-1693-phase-1-multiple-related-records-load-several-records-andfrom
feature/prd-1694-phase-2-multiple-related-records-run-a-sub-workflow-once-per
Draft

hercemer42 wants to merge 6 commits into
feature/prd-1693-phase-1-multiple-related-records-load-several-records-andfrom
feature/prd-1694-phase-2-multiple-related-records-run-a-sub-workflow-once-per

Conversation

@hercemer42

@hercemer42 hercemer42 commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Phase 2 of PRD-24 (PRD-1694), Forest Runtime part: a Sub-workflow on a Multiple step runs the called workflow once per loaded record. The orchestrator opens the calls (PRD-1694's next pull request); this one gives it the record count and makes each call run on its own record.

What changes

  1. A Multiple step's report. Before: a Multiple Load Related Record step reported success with no count. After: it reports loadedRecordCount when it loaded at least one record, and nothing when it loaded none.
  2. "Workflow start" inside a call opened once per record. Before: every step of a Sub-workflow pinned to a Multiple step failed with "loads several records". After: a call whose start entry carries callRecordIndex resolves "workflow start" to the Multiple step's record at that index, outwards through nested calls pinning "workflow start" too. A call without an index still fails as before, and so do an unreadable record and a pin on a step that runs once per record without being Multiple, each naming the step to change.
  3. What a step sees inside such a call, nested calls included. Before: an AI-chosen record was picked among every record the run had loaded, and earlier steps listed every call. After: the calling workflow's Multiple step and per-record steps read as the call's record alone, as an iteration of a per-record step sees them, and every other per-record call of the calling workflow is left out of earlier steps.
  4. A Decision inside any call. Before: it read the latest Get Data with its id anywhere in the run, so a later call of the same workflow could read the previous call's value. After: it reads only its own call's Get Data, and one on a branch the call did not take reads as not reached.

Contract

This is the first pull request in PRD-1694's merge order, so the Contract is checked here: the orchestrator and the front build on it once this merges. Its line on the ticket is unticked.

  • Record count (provided here): context.loadedRecordCount: number on the update-step report of a Multiple step that loaded at least one record.
  • Call record (provided by the orchestrator, read here): context.callRecordIndex: number on each start-sub-workflow and close-sub-workflow entry of a call on a Multiple step, 0-based in that step's executionResult.records.

Release plan

Stacked on phase 1: this branch forks from ForestAdmin/agent-nodejs#2026 (phase 1, the per-record steps) and targets it. It merges after #2026 and is retargeted on main then. The Forest Runtime release carrying it becomes the floor the go-live sets (PRD-1695), and it is released before the orchestrator's pull request merges.

Decisions

  • PRD-1694: 2 functional, 1 architectural · 3 unticked

Tests

Seen red before any production code (Tests: 9 failed, 340 passed, 349 total, and the update-step mapper suite failing on the missing loadedRecordCount), then green, and every line the change adds is covered. Unverified end to end: a call opened once per record needs the orchestrator's pull request, so the cross-repository steps are the ticket's Test cases.

Squash message

feat(workflow-executor): run a Sub-workflow once per record of a Multiple step

A Sub-workflow pinned to a Multiple step failed, since a call could only take one record. The
runtime now reports how many records a Multiple step loaded, and inside each call the orchestrator
opens per record, "workflow start" is that record, the calling workflow's per-record data reads as
that record's alone, and a Decision reads only its own call's Get Data.

Definition of Done

General

  • Write an explicit title for the Pull Request, following Conventional Commits specification
  • Test manually the implemented changes
  • Validate the code quality (indentation, syntax, style, simplicity, readability)

Security

  • Consider the security impact of the changes made

🤖 Generated with Claude Code

Note

Run a Sub-workflow once per record of a Multiple step in workflow-executor

  • Adds per-record call metadata to CallScopeSchema (record index, pinned step, frame indexes) and resolves the call's record from the pinned Load Related Record step's execution in StepExecutorFactory.create.
  • Wraps the run store in a new CallRecordRunStore for steps inside a per-record call. It narrows prior Multiple-step rows to the call's record and delegates saves and deletes to the base store. Shared narrowing logic lives in the exported narrowToRecord helper in iteration-run-store.ts.
  • LoadRelatedRecordStepExecutor now reports loadedRecordCount on successful multiple-record loads, and this count flows into the step-update context.
  • Adds call-site errors in errors.ts: CallRecordNotLoadedError when the call's record index is not among the loaded records, and SourceRunsOncePerRecordError when the call is pinned to a step that itself runs once per record.
  • toCallScope in run-to-available-step-mapper.ts tracks record-call indexes and excludes steps from closed per-record calls in the active call's frame. ConditionStepExecutor resolves Read Record sources only within the current call frame via the new findLatestStepInFrame helper.
  • Behavioral Change: condition resolution inside a call no longer picks a same-ID step from another call's run; previous steps seen by AI selection are narrowed to the call's record. The factory's error fallback now preserves errorKind on WorkflowExecutorError.

Macroscope summarized 181482a.

@linear-code

linear-code Bot commented Nov 4, 2026

Copy link
Copy Markdown

PRD-1694

@qltysh

qltysh Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

9 new issues

Tool Category Rule Count
qlty Structure Function with many returns (count = 4): toCallScope 5
qlty Structure Function with high complexity (count = 14): resolveStepRecordRef 2
qlty Structure High total complexity (count = 70) 1
qlty Structure Function with many parameters (count = 4): findLatestStepInFrame 1

Comment on lines +234 to +235
call.frameOpenedAt === pinnedBy?.frameOpenedAt &&
call.callRecordIndex !== callRecordIndex,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium adapters/run-to-available-step-mapper.ts:234

When a different root Sub-workflow is running, this filter excludes completed calls from the other root Sub-workflow if their callRecordIndex differs, so unrelated results disappear from previousSteps and the AI context. The filter groups only by frameOpenedAt; include the originating Sub-workflow step (the call-site stepIndex) so it excludes only sibling record iterations from the same source.

-              call.frameOpenedAt === pinnedBy?.frameOpenedAt &&
+              call.frameOpenedAt === pinnedBy?.frameOpenedAt &&
+              call.stepIndex === pinnedBy?.stepIndex &&
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/workflow-executor/src/adapters/run-to-available-step-mapper.ts around lines 234-235:

When a different root Sub-workflow is running, this filter excludes completed calls from the other root Sub-workflow if their `callRecordIndex` differs, so unrelated results disappear from `previousSteps` and the AI context. The filter groups only by `frameOpenedAt`; include the originating Sub-workflow step (the call-site `stepIndex`) so it excludes only sibling record iterations from the same source.

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.

Claude Opus 5.5 (claude-opus-5-5): Matching the call's stepIndex would exclude no sibling, since every call's start entry has its own history index.

Why

The fix leaves out every other per-record call of the frame, since an index cannot say which record another step's call ran for.
Calls on a single record are still kept.

@hercemer42 hercemer42 left a comment

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.

Claude Opus 5.5 (claude-opus-5-5): Spec (PRD-1694): conforms to merge-order entry 1.

PRD-1694: 6 unvalidated decisions: not final.

@hercemer42

hercemer42 commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor Author

Forest Validator · Verify fixes: 9 closed · 1 regression

When (UTC) Commit Step Result
04/11 15:27 f188940 Verify fixes 9 closed · 1 regression
10 findings⚪ no verdict yet · Must fix: a step running once per record inside a UBO's call loses its own progress on every resume…
✅ closed · Should fix: a UBO's call keeps another UBO's run in the AI's view whenever that run came from anoth…
✅ closed · Should fix: a step running one call deeper than a UBO's call sees the other UBOs' runs, and loses r…
✅ closed · Should fix: an operator told to revise the Sub-workflow step finds no Revise on the call-site card,…
✅ closed · Should fix: an Admin who follows this message and pins the Multiple step sends UBOs to a workflow b…
✅ closed · Should fix: the next change to how a step id resolves inside a call must be made twice, or Decision…
✅ closed · Violates conventions: dropping a per-record load of the calling workflow from the AI's candidates h…
✅ closed · Violates conventions: folding a nested call's steps into the per-record call holding it has no test…
✅ closed · Preferential: an index beyond the safe integer range from the orchestrator makes the whole run unma…
✅ closed · Preferential: inside a UBO's call, the AI loses that UBO's own records loaded in the calling workfl…
· run 2060-20261104152501-hercemer42-verify
1 earlier step
When (UTC) Commit Step Result
04/11 15:18 620fb70 Review findings posted · run 2060-20261104150036-hercemer42

runStore: new CallRecordRunStore(
builtContext.runStore,
callRecord,
step.callScope?.recordCall?.stepIndexes ?? [],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High executors/step-executor-factory.ts:167

On a resumed indexed record call, this wrapper narrows the pending per-record step's persisted iterations to the enclosing call record, discarding its progress and allowing completed actions to run again. recordCall.stepIndexes contains only historical steps, so CallRecordRunStore does not exempt the current step; include step.stepIndex in the exemption list.

Suggested change
step.callScope?.recordCall?.stepIndexes ?? [],
[...(step.callScope?.recordCall?.stepIndexes ?? []), step.stepIndex],
Also found in 1 other location(s)

packages/workflow-executor/src/executors/call-record-run-store.ts:32

The current pending step is also narrowed as though it belonged to the caller: toCallScope excludes entries at or beyond pending.stepIndex, so its recordCall.stepIndexes never contains this step. If an indexed Sub-workflow loads its own Multiple records and runs an Update/Action once per record, this wrapper drops that pending step's persisted iterations (their source records differ from the call record). On resume, PerRecordStepExecutor.run() therefore rebuilds and persists a fresh iteration list instead of recovering completed iterations or idempotencyPhase: 'executing'. A pause, yield or restart can consequently replay already-applied mutations and overwrite their recovery markers. Preserve the pending step's own execution unchanged as well as the historical call steps.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/workflow-executor/src/executors/step-executor-factory.ts around line 167:

On a resumed indexed record call, this wrapper narrows the pending per-record step's persisted `iterations` to the enclosing call record, discarding its progress and allowing completed actions to run again. `recordCall.stepIndexes` contains only historical steps, so `CallRecordRunStore` does not exempt the current step; include `step.stepIndex` in the exemption list.

Also found in 1 other location(s):
- packages/workflow-executor/src/executors/call-record-run-store.ts:32 -- The current pending step is also narrowed as though it belonged to the caller: `toCallScope` excludes entries at or beyond `pending.stepIndex`, so its `recordCall.stepIndexes` never contains this step. If an indexed Sub-workflow loads its own Multiple records and runs an Update/Action once per record, this wrapper drops that pending step's persisted `iterations` (their source records differ from the call record). On resume, `PerRecordStepExecutor.run()` therefore rebuilds and persists a fresh iteration list instead of recovering completed iterations or `idempotencyPhase: 'executing'`. A pause, yield or restart can consequently replay already-applied mutations and overwrite their recovery markers. Preserve the pending step's own execution unchanged as well as the historical call steps.

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.

Claude Opus 5.5 (claude-opus-5-5): CallRecordRunStore now keeps every row written since the call's start entry, so the pending step's own iterations read as saved.
96afcc8

'records' in row.executionResult
) {
const loaded = row.executionResult.records.find(r => isSameRecord(r, record));
if (!loaded) return [row];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High executors/iteration-run-store.ts:51

When a call is scoped to a record absent from a load-related-record result, narrowToRecord returns the entire row, so RecordStepExecutor.getAvailableRecordRefs exposes records from that unrelated load to reads, actions, and updates inside the call. Return no row for an unmatched load so its records cannot escape the call's record scope.

Suggested change
if (!loaded) return [row];
if (!loaded) return [];
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/workflow-executor/src/executors/iteration-run-store.ts around line 51:

When a call is scoped to a record absent from a `load-related-record` result, `narrowToRecord` returns the entire row, so `RecordStepExecutor.getAvailableRecordRefs` exposes records from that unrelated load to reads, actions, and updates inside the call. Return no row for an unmatched load so its records cannot escape the call's record scope.

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.

Claude Opus 5.5 (claude-opus-5-5): Another Multiple step's records are not another record's data: they belong to every call, as a single load does, so they stay whole.

Why

The call's scope is the other records of the Multiple step it runs over, and those are narrowed to its own record.
narrowToRecord is also phase 1's iteration view, where an unrelated Multiple load stays visible by design.

@qltysh

qltysh Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

Qlty


Coverage Impact

This PR will not change total coverage.

Modified Files with Diff Coverage (9)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
packages/workflow-executor/src/executors/iteration-run-store.ts100.0%
Coverage rating: A Coverage rating: A
packages/workflow-executor/src/executors/step-executor-factory.ts100.0%
Coverage rating: A Coverage rating: A
...ow-executor/src/executors/load-related-record-step-executor.ts100.0%
Coverage rating: A Coverage rating: A
packages/workflow-executor/src/executors/record-step-executor.ts100.0%
Coverage rating: A Coverage rating: A
...ges/workflow-executor/src/executors/condition-step-executor.ts100.0%
Coverage rating: A Coverage rating: A
...workflow-executor/src/adapters/run-to-available-step-mapper.ts100.0%
Coverage rating: A Coverage rating: A
packages/workflow-executor/src/errors.ts100.0%
Coverage rating: A Coverage rating: A
...ow-executor/src/adapters/step-outcome-to-update-step-mapper.ts100.0%
New file Coverage rating: A
packages/workflow-executor/src/executors/call-record-run-store.ts100.0%
Total100.0%
🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

@hercemer42
hercemer42 marked this pull request as ready for review November 4, 2026 16:30
@hercemer42
hercemer42 force-pushed the feature/prd-1693-phase-1-multiple-related-records-load-several-records-and branch from 655947a to e29023f Compare November 5, 2026 13:49
hercemer42 and others added 6 commits November 5, 2026 14:59
…iple step

A Sub-workflow pinned to a Multiple Load Related Record step failed at the call site, since a
call could only take one record. The orchestrator now opens one call per loaded record and
names the record each call is for, so the runtime reports how many records a Multiple step
loaded and resolves "workflow start" inside each call to that record.

Inside a call opened for one record, the AI is offered that record instead of every record the
calling workflow loaded several at a time, and no longer sees the other records' calls among
earlier steps. A Decision inside any call reads only its own call's Get Data steps, since
another call of the same workflow repeats their ids. A call pinned to a step that runs once per
record without being Multiple fails naming the Sub-workflow step.

Refs: PRD-1694

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… Sub-workflow calls

Inside a call opened once per record, another record's run still reached the AI: the exclusion
matched calls by index alone, and a call nested one level deeper got no narrowing at all. The
mapper now scopes from the nearest enclosing per-record call and leaves out every other per-record
call of its calling frame, and the factory wraps the run store so the calling workflow's Multiple
and per-record steps read as the call's record alone, as an iteration already sees them.

A Decision's Get Data lookup shares the record steps' frame-scoped lookup instead of copying it.
The two new call-site errors name remedies the reader can take, and an index beyond the safe
integer range is dropped rather than failing the whole run.

Refs: PRD-1694

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, the pending step's included

The call-record run store narrowed every row outside the steps the call had already run, which
left out the pending step's own row: a step running once per record inside a call opened once per
record lost its progress on every resume. It now narrows only the rows written before the call's
start entry, so everything since, revisions included, reads as written. Also fixes the import
order and formatting CI's lint refused.

Refs: PRD-1694

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refs: PRD-1694

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… cannot be read

A call opened once per record whose Multiple step's record could not be read ran its steps with
the calling workflow's records unnarrowed, so an AI-picked step could act on another record. The
factory now fails the step at the call site, naming the Multiple step, and its error outcome keeps
the error's kind as the executors' own outcomes do.

Refs: PRD-1694

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…erage gate found untested

Covers an unrelated Multiple load staying whole inside a call, the call-record store's open and
close, the factory refusing a call whose pinned step never ran, the new call-site errors without
a source title, and an unbalanced close. The fallback for a close with no call open could never
run, so a guard replaces it.

Refs: PRD-1694

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hercemer42
hercemer42 force-pushed the feature/prd-1694-phase-2-multiple-related-records-run-a-sub-workflow-once-per branch from 8717d1b to 181482a Compare November 5, 2026 14:01
@hercemer42
hercemer42 marked this pull request as draft November 5, 2026 14:02

This branch has not been deployed

No deployments
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