Repository navigation
feat(workflow-executor): run a Sub-workflow once per record of a Multiple step - #2060
Conversation
9 new issues
|
| call.frameOpenedAt === pinnedBy?.frameOpenedAt && | ||
| call.callRecordIndex !== callRecordIndex, |
There was a problem hiding this comment.
🟡 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.
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
Claude Opus 5.5 (claude-opus-5-5): Spec (PRD-1694): conforms to merge-order entry 1.
PRD-1694: 6 unvalidated decisions: not final.
| runStore: new CallRecordRunStore( | ||
| builtContext.runStore, | ||
| callRecord, | ||
| step.callScope?.recordCall?.stepIndexes ?? [], |
There was a problem hiding this comment.
🟠 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.
| 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:
toCallScopeexcludes entries at or beyondpending.stepIndex, so itsrecordCall.stepIndexesnever 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 persistediterations(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 oridempotencyPhase: '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.
There was a problem hiding this comment.
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]; |
There was a problem hiding this comment.
🟠 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.
| 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.
There was a problem hiding this comment.
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.
|
Coverage Impact This PR will not change total coverage. Modified Files with Diff Coverage (9)
🛟 Help
|
655947a to
e29023f
Compare
…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>
8717d1b to
181482a
Compare

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
loadedRecordCountwhen it loaded at least one record, and nothing when it loaded none.callRecordIndexresolves "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.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.
context.loadedRecordCount: numberon the update-step report of a Multiple step that loaded at least one record.context.callRecordIndex: numberon eachstart-sub-workflowandclose-sub-workflowentry of a call on a Multiple step, 0-based in that step'sexecutionResult.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
mainthen. 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
Tests
Seen red before any production code (
Tests: 9 failed, 340 passed, 349 total, and the update-step mapper suite failing on the missingloadedRecordCount), 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
Definition of Done
General
Security
🤖 Generated with Claude Code
Note
Run a Sub-workflow once per record of a Multiple step in
workflow-executorCallScopeSchema(record index, pinned step, frame indexes) and resolves the call's record from the pinned Load Related Record step's execution inStepExecutorFactory.create.CallRecordRunStorefor 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 exportednarrowToRecordhelper in iteration-run-store.ts.LoadRelatedRecordStepExecutornow reportsloadedRecordCounton successful multiple-record loads, and this count flows into the step-update context.CallRecordNotLoadedErrorwhen the call's record index is not among the loaded records, andSourceRunsOncePerRecordErrorwhen the call is pinned to a step that itself runs once per record.toCallScopein run-to-available-step-mapper.ts tracks record-call indexes and excludes steps from closed per-record calls in the active call's frame.ConditionStepExecutorresolves Read Record sources only within the current call frame via the newfindLatestStepInFramehelper.errorKindonWorkflowExecutorError.Macroscope summarized 181482a.