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
10 changes: 10 additions & 0 deletions .changeset/28202-resume-remedy-names-whole-chain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@objectstack/service-automation': patch
---

fix(service-automation): the `RESUME_CHILD_PENDING` remedy names every run of the suspension chain

Clause-②: no (message wording only)
Metadata-protocol: none

A resume of a batch (`map`) run whose item run is gone with no `cancelled` history is refused with `RESUME_CHILD_PENDING` (409), and the message tells the operator which runs to cancel to end the batch. When the map sat more than one `subflow:` link below the run they resumed, it named only the map's run and the resumed run. Cancelling a run never cascades, so each run in between stayed parked after the operator followed it. The remedy now names the map's run, then every run waiting on it in order, up to the resumed run. The code, the status and the contract text are unchanged.
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,61 @@ describe('a parent-id resume of a map whose item run was cancelled (#27971)', ()
expect(mapState(paused.runId!)).toEqual({ started: 1, results: [] });
});

// [#28202] Cancelling a run never cascades, so the remedy names every run
// the operator must cancel: the map's own, then each run waiting on it, up
// to the one they resumed.
const callerFlow = (name: string, callee: string, input?: Record<string, unknown>) => engine.registerFlow(name, {
name, label: name, type: 'autolaunched',
nodes: [
{ id: 'cs', type: 'start', label: 'Start' },
{ id: 'call', type: 'subflow', label: `Call ${callee}`, config: { flowName: callee, ...(input ? { input } : {}) } },
{ id: 'ce', type: 'end', label: 'End' },
],
edges: [{ id: 'c1', source: 'cs', target: 'call' }, { id: 'c2', source: 'call', target: 'ce' }],
} as never);

it('names the waiting run in the remedy when the map sits one subflow link below the resumed run', async () => {
callerFlow('outer_flow', 'batch_flow', { items: [{ id: 't1' }] });
const outer = await engine.execute('outer_flow');
const [map] = parked('batch_flow');
const [item1] = parked('one_item');
(engine as any).suspendedRuns.delete(item1!.runId);

const refused = await engine.resume(outer.runId!, {});

expect(refused.success).toBe(false);
expect(refused.code).toBe(MAP_ITEM_PENDING_REFUSAL_CODE);
expect(refused.error).toContain(
`To end the batch, cancel run '${map!.runId}' and run '${outer.runId}', which waits on it; `,
);
expect(parked('outer_flow').map(r => r.runId)).toEqual([outer.runId]);
expect(parked('batch_flow').map(r => r.runId)).toEqual([map!.runId]);
});

it('names every run of a three-level chain in the remedy, from the map up to the resumed run', async () => {
callerFlow('mid_flow', 'batch_flow', { items: [{ id: 't1' }] });
callerFlow('outer_flow', 'mid_flow');
const outer = await engine.execute('outer_flow');
const [mid] = parked('mid_flow');
const [map] = parked('batch_flow');
const [item1] = parked('one_item');
(engine as any).suspendedRuns.delete(item1!.runId);

const refused = await engine.resume(outer.runId!, {});

expect(refused.success).toBe(false);
expect(refused.code).toBe(MAP_ITEM_PENDING_REFUSAL_CODE);
expect(refused.error).toContain(
`To end the batch, cancel run '${map!.runId}' and runs '${mid!.runId}' and '${outer.runId}', ` +
`which wait on it in turn; `,
);
// Nothing moved at any level.
expect(mapState(map!.runId)).toEqual({ started: 1, results: [] });
expect(parked('outer_flow').map(r => r.runId)).toEqual([outer.runId]);
expect(parked('mid_flow').map(r => r.runId)).toEqual([mid!.runId]);
expect(parked('batch_flow').map(r => r.runId)).toEqual([map!.runId]);
});

it('still refuses a caller signal that writes the engine namespace, so the marker cannot be forged', async () => {
const paused = await launch();
const [item1] = parked('one_item');
Expand Down
16 changes: 15 additions & 1 deletion packages/services/service-automation/src/run-resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -586,7 +586,7 @@ async function continuePastCancelledMapItem(
`map node '${link.nodeId}' on item run '${itemRunId}', which is no longer suspended, and ${found}. ` +
`The map records an item only when it completes or when its run was cancelled, so resuming here ` +
`would advance the map past that item with nothing recorded for it. Nothing was changed. To end the ` +
`batch, cancel run '${link.runId}'${link.runId === runId ? '' : ` and run '${runId}', which waits on it`}; ` +
`batch, cancel run '${link.runId}'${waitingRunsClause(chain)}; ` +
`if the run history store was unreachable, retry once it is readable`,
};
}
Expand All @@ -600,6 +600,20 @@ async function continuePastCancelledMapItem(
};
}

/**
* [#28202] The remedy's tail after "cancel run 'MAP'": every run above the map
* in `chain`, nearest first, up to the one the caller resumed. Cancelling a run
* never cascades to the runs waiting on it, so a run left out of this list
* stays parked after the operator follows the remedy. Empty when the map's run
* is the one resumed. The list is bounded by the chain walk's own depth bound.
*/
function waitingRunsClause(chain: readonly SuspendedRun[]): string {
const waiting = chain.slice(0, -1).map(r => `'${r.runId}'`).reverse();
if (waiting.length === 0) return '';
if (waiting.length === 1) return ` and run ${waiting[0]}, which waits on it`;
return ` and runs ${waiting.slice(0, -1).join(', ')} and ${waiting[waiting.length - 1]}, which wait on it in turn`;
}

/** The body of {@link AutomationEngine.refuseUndeclaredSuspension}, moved unedited; that method's docblock is its contract. */
export function refuseUndeclaredSuspension(this: Pick<RunResumeHost, 'resolveCanonicalDescriptor'>, nodeType: string): NodeExecutionResult | null {
const descriptor = this.resolveCanonicalDescriptor(nodeType);
Expand Down
Loading