Skip to content

Commit 9633fff

Browse files
fix(service-automation): RESUME_CHILD_PENDING's remedy names every run of the suspension chain (#28516)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XD327LjMjJZx2oCbKfZMtNc)_ Fixes #28202 Clause-②: no (message wording only) Metadata-protocol: none ## 维护者速读 - **改了什么**:批处理(`map`)运行的子项运行已消失、又没有 `cancelled` 历史时,恢复会被 `RESUME_CHILD_PENDING`(409)拒绝;拒绝文字里「要结束这批,请取消哪些运行」现在列出整条挂起链上的每一个运行,从 map 自己的运行起,依次往上,直到被恢复的那个运行。 - **为什么改**:卡 #28202(分诊 6528511645,来自 PR #28176 合同评审 S3/S4):取消一个运行不会连带取消等它的运行。map 在被恢复运行之下隔了不止一层 `subflow:` 时,旧文字只点名两头,运维照做后中间那层仍然挂着。 - **风险与回滚**:只改文字。错误码、状态码和合同文字都不变;两层链的文字与改前逐字相同。回滚即 revert。 - **先看**:`packages/services/service-automation/src/run-resume.ts`(新函数 `waitingRunsClause` 和它在 `continuePastCancelledMapItem` 里的唯一调用处);`packages/services/service-automation/src/map-cancelled-item-resume.test.ts`(新增两层、三层两条用例)。 - **两分钟验证**:`pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2 src/map-cancelled-item-resume.test.ts` → `Tests 9 passed (9)`。 - **需要你做的**:无,席位落地。 ## What changes `continuePastCancelledMapItem` ended its remedy with "To end the batch, cancel run 'MAP'", plus " and run 'RESUMED', which waits on it" when nested. It named `link.runId` and the resumed `runId` only. The `chain` it already receives (from `resolveSuspensionChain`, bounded by `MAX_SUSPENSION_CHAIN_DEPTH` = 32) holds every link, so the new helper `waitingRunsClause(chain)` names every run above the map, nearest first: | Chain | Remedy tail | | :--- | :--- | | MAP resumed directly | `cancel run 'MAP'` (unchanged) | | OUTER → MAP | `cancel run 'MAP' and run 'OUTER', which waits on it` (unchanged, byte for byte) | | OUTER → MID → MAP | `cancel run 'MAP' and runs 'MID' and 'OUTER', which wait on it in turn` | | deeper | `cancel run 'MAP' and runs 'A', 'B' and 'OUTER', which wait on it in turn` | The list needs no cap of its own: the chain walk's depth bound already bounds it. The code (`RESUME_CHILD_PENDING`), the 409 status, the log line and every other sentence of the message are unchanged. `refuseMapItemSkip` (the still-suspended item) names one run to resume, not runs to cancel, so it is untouched. ## Tests Two new cases in `map-cancelled-item-resume.test.ts`, inside the existing `#27971` describe and its fixtures: - **two-level** (OUTER → MAP, item row gone, history `paused`): pins the nested suffix `To end the batch, cancel run 'MAP' and run 'OUTER', which waits on it; `, plus code and nothing moved. Card S4 noted this suffix was not pinned before. - **three-level** (OUTER → MID → MAP): pins `... cancel run 'MAP' and runs 'MID' and 'OUTER', which wait on it in turn; `, code, and that the map state and all three suspensions are unchanged. The two direct pins (`To end the batch, cancel run '${paused.runId}'`, ≈L183/L200) are untouched and green. <details><summary>Red first on origin/main 1f1a369 (test only, no fix)</summary> ``` × names every run of a three-level chain in the remedy, from the map up to the resumed run Received: "... Nothing was changed. To end the batch, cancel run 'run_6e14…' and run 'run_2655…', which waits on it; if the run history store was unreachable, retry once it is readable" Tests 1 failed | 8 passed (9) ``` The base names MAP and OUTER only; MID is missing. The two-level case is green on the base (its text is unchanged). </details> <details><summary>Green and package suite</summary> - `vitest run src/map-cancelled-item-resume.test.ts` → `Tests 9 passed (9)`, exit 0. - Under the verify lock: `pnpm --filter @objectstack/service-automation build && … typecheck && … test` → `Test Files 249 passed (249)`, `Tests 3164 passed (3164)`, `VERDICT command-exit 0`. - `eslint packages/services/service-automation --no-inline-config` → exit 0. </details> <details><summary>Ablation (on the fix, committed before the mutation)</summary> Restored the two-name form through `node scripts/ablation-replace.mjs --hold` on `run-resume.ts` (injected-text count 1, removed-text count 0 on disk). The test imports `./run-resume.js` from `src/` relatively, so no `dist/` build sits on the path. Result: `× names every run of a three-level chain …`, `Tests 1 failed | 8 passed (9)`: the three-level case only. Restored with `--restore` + `git checkout HEAD -- PATH`; `git diff HEAD` empty and the file's hash equals the `HEAD` blob (`3db6ab0c`). </details> ## Acceptance notes - The message's lead still says "Run 'OUTER' is waiting on run 'MAP'" in a three-level chain, which is true transitively. The card scoped the change to the remedy, so the lead is left as is. ## Verification All read on head `8bb5561b` (same tree `0b745620` as the commit the gate loop ran on before the trailer reword). - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 61 derived families. Each run with its exit code captured before any pipe; `--ran` reconciliation: `✓ 61 derived famil(ies) accounted for — 60 run, 1 NOT-MEASURED`. - 60 exit 0. `node scripts/check-plugin-teardown-shape.mjs --self-test` first exited 3 on the shallow clone (positive-control commit unreachable); after `git fetch --depth=1 origin 621a487…` it exits 0 (`48 cases pass`). - NOT MEASURED: `pnpm check:dual-build-cjs-loads`, reason: PREREQUISITE NOT MET (39 packages outside this diff's build closure have no `dist/`); CI builds the workspace and runs it. The diff changes no export or entry. - `test:repo` not run locally (the diff touches no repo-project test); CI Test Core runs it. - Families CI runs beyond the derived list: read from this PR's check-runs. --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5m4fAx5zTbY8tmNPQm)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 35b178b commit 9633fff

3 files changed

Lines changed: 80 additions & 1 deletion

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/service-automation': patch
3+
---
4+
5+
fix(service-automation): the `RESUME_CHILD_PENDING` remedy names every run of the suspension chain
6+
7+
Clause-②: no (message wording only)
8+
Metadata-protocol: none
9+
10+
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.

‎packages/services/service-automation/src/map-cancelled-item-resume.test.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,61 @@ describe('a parent-id resume of a map whose item run was cancelled (#27971)', ()
201201
expect(mapState(paused.runId!)).toEqual({ started: 1, results: [] });
202202
});
203203

204+
// [#28202] Cancelling a run never cascades, so the remedy names every run
205+
// the operator must cancel: the map's own, then each run waiting on it, up
206+
// to the one they resumed.
207+
const callerFlow = (name: string, callee: string, input?: Record<string, unknown>) => engine.registerFlow(name, {
208+
name, label: name, type: 'autolaunched',
209+
nodes: [
210+
{ id: 'cs', type: 'start', label: 'Start' },
211+
{ id: 'call', type: 'subflow', label: `Call ${callee}`, config: { flowName: callee, ...(input ? { input } : {}) } },
212+
{ id: 'ce', type: 'end', label: 'End' },
213+
],
214+
edges: [{ id: 'c1', source: 'cs', target: 'call' }, { id: 'c2', source: 'call', target: 'ce' }],
215+
} as never);
216+
217+
it('names the waiting run in the remedy when the map sits one subflow link below the resumed run', async () => {
218+
callerFlow('outer_flow', 'batch_flow', { items: [{ id: 't1' }] });
219+
const outer = await engine.execute('outer_flow');
220+
const [map] = parked('batch_flow');
221+
const [item1] = parked('one_item');
222+
(engine as any).suspendedRuns.delete(item1!.runId);
223+
224+
const refused = await engine.resume(outer.runId!, {});
225+
226+
expect(refused.success).toBe(false);
227+
expect(refused.code).toBe(MAP_ITEM_PENDING_REFUSAL_CODE);
228+
expect(refused.error).toContain(
229+
`To end the batch, cancel run '${map!.runId}' and run '${outer.runId}', which waits on it; `,
230+
);
231+
expect(parked('outer_flow').map(r => r.runId)).toEqual([outer.runId]);
232+
expect(parked('batch_flow').map(r => r.runId)).toEqual([map!.runId]);
233+
});
234+
235+
it('names every run of a three-level chain in the remedy, from the map up to the resumed run', async () => {
236+
callerFlow('mid_flow', 'batch_flow', { items: [{ id: 't1' }] });
237+
callerFlow('outer_flow', 'mid_flow');
238+
const outer = await engine.execute('outer_flow');
239+
const [mid] = parked('mid_flow');
240+
const [map] = parked('batch_flow');
241+
const [item1] = parked('one_item');
242+
(engine as any).suspendedRuns.delete(item1!.runId);
243+
244+
const refused = await engine.resume(outer.runId!, {});
245+
246+
expect(refused.success).toBe(false);
247+
expect(refused.code).toBe(MAP_ITEM_PENDING_REFUSAL_CODE);
248+
expect(refused.error).toContain(
249+
`To end the batch, cancel run '${map!.runId}' and runs '${mid!.runId}' and '${outer.runId}', ` +
250+
`which wait on it in turn; `,
251+
);
252+
// Nothing moved at any level.
253+
expect(mapState(map!.runId)).toEqual({ started: 1, results: [] });
254+
expect(parked('outer_flow').map(r => r.runId)).toEqual([outer.runId]);
255+
expect(parked('mid_flow').map(r => r.runId)).toEqual([mid!.runId]);
256+
expect(parked('batch_flow').map(r => r.runId)).toEqual([map!.runId]);
257+
});
258+
204259
it('still refuses a caller signal that writes the engine namespace, so the marker cannot be forged', async () => {
205260
const paused = await launch();
206261
const [item1] = parked('one_item');

‎packages/services/service-automation/src/run-resume.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -586,7 +586,7 @@ async function continuePastCancelledMapItem(
586586
`map node '${link.nodeId}' on item run '${itemRunId}', which is no longer suspended, and ${found}. ` +
587587
`The map records an item only when it completes or when its run was cancelled, so resuming here ` +
588588
`would advance the map past that item with nothing recorded for it. Nothing was changed. To end the ` +
589-
`batch, cancel run '${link.runId}'${link.runId === runId ? '' : ` and run '${runId}', which waits on it`}; ` +
589+
`batch, cancel run '${link.runId}'${waitingRunsClause(chain)}; ` +
590590
`if the run history store was unreachable, retry once it is readable`,
591591
};
592592
}
@@ -600,6 +600,20 @@ async function continuePastCancelledMapItem(
600600
};
601601
}
602602

603+
/**
604+
* [#28202] The remedy's tail after "cancel run 'MAP'": every run above the map
605+
* in `chain`, nearest first, up to the one the caller resumed. Cancelling a run
606+
* never cascades to the runs waiting on it, so a run left out of this list
607+
* stays parked after the operator follows the remedy. Empty when the map's run
608+
* is the one resumed. The list is bounded by the chain walk's own depth bound.
609+
*/
610+
function waitingRunsClause(chain: readonly SuspendedRun[]): string {
611+
const waiting = chain.slice(0, -1).map(r => `'${r.runId}'`).reverse();
612+
if (waiting.length === 0) return '';
613+
if (waiting.length === 1) return ` and run ${waiting[0]}, which waits on it`;
614+
return ` and runs ${waiting.slice(0, -1).join(', ')} and ${waiting[waiting.length - 1]}, which wait on it in turn`;
615+
}
616+
603617
/** The body of {@link AutomationEngine.refuseUndeclaredSuspension}, moved unedited; that method's docblock is its contract. */
604618
export function refuseUndeclaredSuspension(this: Pick<RunResumeHost, 'resolveCanonicalDescriptor'>, nodeType: string): NodeExecutionResult | null {
605619
const descriptor = this.resolveCanonicalDescriptor(nodeType);

0 commit comments

Comments
 (0)