@@ -2001,12 +2001,13 @@ export class AutomationEngine implements IAutomationService {
20012001 * the flow-runner that owns it. The service-side resume proves itself with
20022002 * an in-process symbol the transport cannot mint from a JSON body.
20032003 *
2004- * Resolves the EFFECTIVE suspension first: a run parked on a `subflow`
2005- * node delegates the signal to its suspended child, so the gate follows
2006- * that chain and judges the node the signal actually lands on. Anything it
2007- * cannot resolve (unknown run, missing flow, unregistered node type) is
2008- * left to `resumeInternal`, which reports the machine-state error — the
2009- * gate only ever speaks to authorization.
2004+ * Resolves the EFFECTIVE suspension first: a run parked on a `subflow` or
2005+ * `map` node is really waiting on a CHILD run, so the gate follows that
2006+ * chain and judges the node the signal lands on (subflow) or would advance
2007+ * past (map) — see {@link LINKED_RUN_PREFIXES}. Anything it cannot resolve
2008+ * (unknown run, missing flow, unregistered node type) is left to
2009+ * `resumeInternal`, which reports the machine-state error — the gate only
2010+ * ever speaks to authorization.
20102011 */
20112012 private async refuseGatedResume ( runId : string , signal ?: ResumeSignal ) : Promise < AutomationResult | null > {
20122013 const run = await this . resolveEffectiveSuspension ( runId ) ;
@@ -2019,17 +2020,21 @@ export class AutomationEngine implements IAutomationService {
20192020 // decision's tail, not a way around it.
20202021 if ( signal ?. [ RESUME_AUTHORITY_SERVICE ] ) return null ;
20212022
2022- const at = run . runId === runId ? `'${ run . nodeId } '` : `'${ run . nodeId } ' (subflow run '${ run . runId } ')` ;
2023+ const direct = run . runId === runId ;
2024+ const at = direct ? `'${ run . nodeId } '` : `'${ run . nodeId } ' (linked run '${ run . runId } ')` ;
20232025 this . logger . warn (
20242026 `[automation] refused resume of run '${ runId } ': parked on ${ nodeType } node ${ at } , which is resumable ` +
20252027 `only through its owning service (resumeAuthority: 'service')` ,
20262028 ) ;
20272029 return {
20282030 success : false ,
20292031 code : 'forbidden' ,
2030- error :
2031- `Run '${ runId } ' is paused at a '${ nodeType } ' node, which only its owning service may resume — ` +
2032- `drive it through that service's API (e.g. an approval decision), not a raw resume` ,
2032+ error : direct
2033+ ? `Run '${ runId } ' is paused at a '${ nodeType } ' node, which only its owning service may resume — ` +
2034+ `drive it through that service's API (e.g. an approval decision), not a raw resume`
2035+ : `Run '${ runId } ' is waiting on run '${ run . runId } ', which is paused at a '${ nodeType } ' node that ` +
2036+ `only its owning service may resume — resuming here would continue past a decision that has not ` +
2037+ `been made; drive it through that service's API instead` ,
20332038 } ;
20342039 }
20352040
@@ -2063,20 +2068,40 @@ export class AutomationEngine implements IAutomationService {
20632068 private static readonly MAX_ALIAS_HOPS = 4 ;
20642069
20652070 /**
2066- * Follow the subflow delegation chain from `runId` to the suspension a
2067- * resume signal would actually land on. A run paused at a `subflow` node
2068- * (correlation `subflow:<childRunId>`) forwards the signal down, so the
2069- * deepest reachable suspension — not the id the caller holds — is what a
2070- * resume really continues. Returns `null` when nothing is suspended under
2071- * that id; stops at the last resolvable link when a child row is gone
2072- * (that run is where `resumeInternal` will continue).
2071+ * Linked-run correlation prefixes the gate walks (#3853). Both park a
2072+ * parent on a child run, so in both the pending work — and therefore the
2073+ * authority that governs continuing it — belongs to the CHILD, even though
2074+ * `resumeInternal` handles the two oppositely:
2075+ *
2076+ * - `subflow:` — the signal is DELEGATED down to the child, so the child's
2077+ * node is literally where it lands.
2078+ * - `map:` — the signal is not delegated; the `map` node RE-RUNS, and since
2079+ * `$mapState.started` was advanced past the in-flight item before the
2080+ * suspend, continuing here advances the map *past* the item whose child
2081+ * is still parked. Judging the parent's own `map` node (always
2082+ * `resumeAuthority: 'any'`) let a raw resume skip a pending approval in a
2083+ * batch-approval flow — the gate has to read the item, not the loop.
2084+ */
2085+ private static readonly LINKED_RUN_PREFIXES = [ 'subflow:' , 'map:' ] as const ;
2086+
2087+ /**
2088+ * Follow the linked-run chain from `runId` to the suspension whose node
2089+ * actually governs a resume — see {@link LINKED_RUN_PREFIXES}. The deepest
2090+ * reachable suspension, not the id the caller holds, is what a resume
2091+ * really continues (or skips past). Returns `null` when nothing is
2092+ * suspended under that id; stops at the last resolvable link when a child
2093+ * row is gone (that run is where `resumeInternal` will continue).
20732094 */
20742095 private async resolveEffectiveSuspension ( runId : string ) : Promise < SuspendedRun | null > {
20752096 const seen = new Set < string > ( ) ;
20762097 let run = await this . loadSuspendedRun ( runId ) ;
20772098 for ( let depth = 0 ; run && depth < AutomationEngine . MAX_SUSPENSION_CHAIN_DEPTH ; depth ++ ) {
2078- if ( typeof run . correlation !== 'string' || ! run . correlation . startsWith ( 'subflow:' ) ) return run ;
2079- const childRunId = run . correlation . slice ( 'subflow:' . length ) ;
2099+ const correlation = run . correlation ;
2100+ const prefix = typeof correlation === 'string'
2101+ ? AutomationEngine . LINKED_RUN_PREFIXES . find ( p => correlation . startsWith ( p ) )
2102+ : undefined ;
2103+ if ( ! prefix ) return run ;
2104+ const childRunId = correlation ! . slice ( prefix . length ) ;
20802105 if ( ! childRunId || seen . has ( childRunId ) ) return run ;
20812106 seen . add ( childRunId ) ;
20822107 const child = await this . loadSuspendedRun ( childRunId ) ;
0 commit comments