Skip to content

Commit ea169b3

Browse files
committed
fix(automation): report a subflow parent stranded by a failed up-bubble at error, not warn
An approval hosted in a subflow child was driven end to end for the first time (the card was filed NOT MEASURED). It reproduces: the child's resume completes, `bubbleToParent` resumes the parent, the parent's downstream node throws, and `resumeInternal` answers the #13937 stranded exit — `{ success: false, status: 'stranded' }`, no `code` — which consumed the parent's suspension, journalled a repair snapshot and recorded the run `failed`. Nothing in the engine moves it again; only `restoreConsumedSuspension` can. The seam's #4632 verdict was FUNCTIONAL for the whole arm, on the enumeration "the parent either failed terminally or stays visibly parked and resumable". The measurement falsifies it: a stranded parent is neither, the approval row is durably terminal, and the decision door answered its caller success. That is AGENTS.md's DURABILITY class verbatim, and the rule's third legal answer (a failure handed to the CALLER) does not apply because no caller is told. The level is now graded by the engine's own discriminator, never by this seam's guess: `status === 'stranded'` reports at `error`, naming the run and the repair verb; every other exit stays `warn` unchanged, because escalating a parent that is still parked is how `error` becomes unreadable. ⚠️ The log half only. What the child's resumer — and through it the approvals decision door — is TOLD still reads as full success; making that truthful moves a public contract and is #15556's open decision. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
1 parent 1b0b4cf commit ea169b3

4 files changed

Lines changed: 455 additions & 64 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/service-automation': patch
3+
---
4+
5+
automation: a subflow parent left STRANDED by a failed up-bubble is reported at `error`, not `warn`
6+
7+
When an approval (or any pause) sits inside a subflow child, resuming the child
8+
bubbles up to the parent. If the parent's own continuation then fails on the
9+
engine's stranded exit — its suspension consumed, a repair snapshot journalled,
10+
the run recorded `failed` — nothing but a `warn` said so, while the child's
11+
resumer (an approvals decision door, a wait timer) was told the resume
12+
succeeded. Persisted state and runtime state disagree and nothing looks broken
13+
from the outside, which is the durability class.
14+
15+
`bubbleToParent` now grades that record by the engine's own
16+
`AutomationResult.status` discriminator: `'stranded'` is reported at `error`,
17+
naming the parent run and the `restoreConsumedSuspension` verb that repairs it.
18+
Every other parent-resume failure — a concurrent resume, an unreachable store,
19+
a thrown resume — stays at `warn` unchanged, because on those exits the parent
20+
is still parked and resumable.
21+
22+
⚠️ This is the log half only. What the child's resumer is told is unchanged.

packages/plugins/plugin-approvals/src/subflow-bubble-strand.test.ts renamed to packages/plugins/plugin-approvals/src/subflow-hosted-approval-strand.test.ts

Lines changed: 129 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,44 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
/**
4-
* REPRODUCTION PROBE for #15556 — an approval hosted inside a SUBFLOW CHILD.
5-
* Not a deliverable yet: this file exists to measure what the decision door
6-
* actually answers when `bubbleToParent` fails, with its controls in the
7-
* same run.
4+
* #15556 — the REPRODUCTION: an approval hosted inside a SUBFLOW CHILD, whose
5+
* parent's continuation fails.
6+
*
7+
* The card was filed NOT MEASURED — the seam and its swallowing `catch` were
8+
* found by reading `engine.ts`, and nobody had driven the composition. This
9+
* file is that drive, with its controls in the same run, and it reproduces.
10+
*
11+
* ## The composition
12+
*
13+
* `deal_parent` parks at a `subflow` node; the child `deal_approval` parks at
14+
* an `approval` node, so the approvals row names the CHILD run. The decision
15+
* door resumes the child, the child completes, `bubbleToParent` resumes the
16+
* parent, and the parent's own downstream node throws.
17+
*
18+
* ## What is measured, and what is only characterised
19+
*
20+
* MEASURED FACT, now fixed engine-side: the parent lands on the engine's
21+
* stranded exit — `{ success: false, status: 'stranded' }`, journalled and
22+
* repairable — and `bubbleToParent` logged that at `warn`. The level is now
23+
* graded by that discriminator (`subflow-bubble-strand-log-level.test.ts` in
24+
* `service-automation` holds the pins, both directions).
25+
*
26+
* ⚠️ CHARACTERISED, NOT BLESSED: the decision door still answers full success.
27+
* Its resume-facing answer is IDENTICAL to the one a healthy composition
28+
* produces, so no caller can tell the two apart, and the `runId` it hands back
29+
* names the CHILD — which completed — never the stranded parent. Making that
30+
* truthful moves a public contract (`AutomationResult`,
31+
* `ApprovalDecisionResult`) and is #15556's open decision, the sibling one
32+
* level up of the #13807 ruling (maintainer 2026-09-04, decision batch #37).
33+
* ⛔ The assertions below record what the door does TODAY; whatever ruling
34+
* lands must turn them red on purpose.
35+
*
36+
* ## The control that makes the reading trustworthy
37+
*
38+
* `CONTROL` drives the #13807 shape through the SAME door in the same run — no
39+
* subflow, the child's own branch throws — and the door throws `RESUME_FAILED`
40+
* with its stranded envelope. So the absence of a throw above is a fact about
41+
* the composition, not about a mis-wired harness.
842
*/
943

1044
import { describe, it, expect, beforeEach } from 'vitest';
@@ -16,6 +50,17 @@ import { registerApprovalNode } from './approval-node.js';
1650

1751
const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as any;
1852

53+
/** The card's own downstream failure text, in shape. */
54+
const DOWNSTREAM_FAILURE = 'update_record(crm_leave_request) failed: Record 9SEmlyRfw8D9-J7Z not found';
55+
56+
/**
57+
* The resume-facing answer a caller reads, minus the run id. Asserted by BOTH
58+
* the stranded composition and the healthy one — that shared literal, and not
59+
* a value smuggled between tests, is what carries the claim that the two are
60+
* indistinguishable at the door.
61+
*/
62+
const FULL_SUCCESS = { finalized: true, decision: 'approve', resumed: true, resumeError: undefined };
63+
1964
/** Records every level so the "only artefact is a log line" claim is measurable. */
2065
function recordingLogger() {
2166
const lines: Array<{ level: string; msg: string; meta?: unknown }> = [];
@@ -116,7 +161,7 @@ const PARENT = {
116161
],
117162
};
118163

119-
describe('#15556 probe — approval inside a subflow child, parent bubble fails', () => {
164+
describe('#15556 — an approval hosted in a subflow child, whose parent bubble fails', () => {
120165
let data: ReturnType<typeof makeFakeEngine>;
121166
let service: ApprovalService;
122167
let logger: ReturnType<typeof recordingLogger>;
@@ -153,27 +198,30 @@ describe('#15556 probe — approval inside a subflow child, parent bubble fails'
153198
const pendingRequest = async () =>
154199
(await data.find('sys_approval_request', { where: { status: 'pending' } }))[0];
155200

156-
it('MEASUREMENT — parent bubble fails: what does the door answer?', async () => {
157-
throwOn.after_sub = 'update_record(crm_leave_request) failed: Record 9SEmlyRfw8D9-J7Z not found';
201+
/** The resume-facing projection of the door's answer — everything a caller reads about the run. */
202+
const resumeFacing = (r: any) => ({
203+
finalized: r.finalized, decision: r.decision, resumed: r.resumed, resumeError: r.resumeError,
204+
});
205+
206+
it('the parent STRANDS while the door answers full success', async () => {
207+
throwOn.after_sub = DOWNSTREAM_FAILURE;
158208
const automation = boot();
159209

160210
const started = await automation.execute('deal_parent', {
161211
object: 'crm_deal', record: { id: 'd1', amount: 100 }, userId: 'submitter',
162212
} as never);
163-
// eslint-disable-next-line no-console
164-
console.log('PROBE started =', JSON.stringify(started));
165213
const parentRunId = (started as any).runId as string;
214+
expect((started as any).status, 'the parent parks at its subflow node').toBe('paused');
166215

167216
const req = await pendingRequest();
168-
// eslint-disable-next-line no-console
169-
console.log('PROBE request =', JSON.stringify(req && { id: req.id, run: req.flow_run_id, status: req.status }));
170217
const childRunId = req?.flow_run_id as string;
171-
expect(childRunId, 'the request must name the CHILD run').toBeTruthy();
218+
expect(childRunId, 'the request names the CHILD run, never the parent').toBeTruthy();
172219
expect(childRunId).not.toBe(parentRunId);
173220
expect(await automation.hasSuspendedRun(parentRunId)).toBe(true);
174221

175-
// Capture the envelope `bubbleToParent` receives for the PARENT resume —
176-
// the thing the swallowing catch throws away.
222+
// The envelope `bubbleToParent` receives for the PARENT — the thing this
223+
// card is about. Captured through `resumeInternal` because the up-bubble
224+
// never goes through the public `resume` door.
177225
const bubbled: any[] = [];
178226
const realInternal = (automation as any).resumeInternal.bind(automation);
179227
(automation as any).resumeInternal = async (...args: any[]) => {
@@ -186,61 +234,88 @@ describe('#15556 probe — approval inside a subflow child, parent bubble fails'
186234
.decide(req.id, { decision: 'approve', actorId: 'u1' }, SYSTEM_CTX)
187235
.then(r => ({ ok: true as const, r }), (e: Error) => ({ ok: false as const, e }));
188236

189-
// eslint-disable-next-line no-console
190-
console.log('PROBE door =', JSON.stringify(outcome.ok ? outcome.r : { threw: outcome.e.message, details: strandedDecisionDetails(outcome.e) }));
191-
// eslint-disable-next-line no-console
192-
console.log('PROBE marks =', JSON.stringify(marks));
193-
// eslint-disable-next-line no-console
194-
console.log('PROBE parent suspended?', await automation.hasSuspendedRun(parentRunId));
195-
// eslint-disable-next-line no-console
196-
console.log('PROBE parent resume =', JSON.stringify(await automation.resume(parentRunId)));
197-
const parentRow = await automation.getRun(parentRunId);
198-
// eslint-disable-next-line no-console
199-
console.log('PROBE parent run row =', JSON.stringify(parentRow && {
200-
status: (parentRow as any).status, error: (parentRow as any).error,
201-
consumedSuspension: Boolean((parentRow as any).consumedSuspension),
202-
}));
203-
// eslint-disable-next-line no-console
204-
console.log('PROBE child run row =', JSON.stringify(await automation.getRun(childRunId).then(r => r && { status: (r as any).status })));
205-
// eslint-disable-next-line no-console
206-
console.log('PROBE request row =', JSON.stringify((await data.find('sys_approval_request', { where: { id: req.id } }))[0]?.status));
207-
// eslint-disable-next-line no-console
208-
console.log('PROBE parent bubble envelope =', JSON.stringify(bubbled.map(b => ({ success: b.success, code: b.code, status: b.status, error: b.error }))));
209-
// eslint-disable-next-line no-console
210-
console.log('PROBE parent restore =', JSON.stringify(await automation.restoreConsumedSuspension(parentRunId, { requestedBy: 'probe' })));
211-
// eslint-disable-next-line no-console
212-
console.log('PROBE log lines =', JSON.stringify(logger.lines.filter((l: any) => l.level !== 'debug' && l.level !== 'info').map((l: any) => [l.level, l.msg])));
237+
// ── The parent's resume answers the #13937 discriminator, and no `code`.
238+
expect(bubbled.length).toBe(1);
239+
expect(bubbled[0].success).toBe(false);
240+
expect(bubbled[0].status, "the producer's own verdict").toBe('stranded');
241+
expect(bubbled[0].code, 'and it names no code at all').toBeUndefined();
242+
expect(bubbled[0].error).toBe(DOWNSTREAM_FAILURE);
243+
244+
// ── The parent really is dead, and really is repairable.
245+
expect(await automation.hasSuspendedRun(parentRunId)).toBe(false);
246+
expect((await automation.resume(parentRunId)).code).toBe('RUN_NOT_FOUND');
247+
expect((await automation.getRun(parentRunId))?.status).toBe('failed');
248+
249+
// ── The child is fine, and so is the decision: both halves of the
250+
// divergence are real, which is what makes it a divergence.
251+
expect((await automation.getRun(childRunId))?.status).toBe('completed');
252+
expect(marks, 'the child advanced; the parent died on the node after the subflow')
253+
.toEqual(['on_approved']);
254+
expect((await data.find('sys_approval_request', { where: { id: req.id } }))[0].status).toBe('approved');
255+
256+
// ⚠️ CHARACTERISED, NOT BLESSED — see the file header. The door does not
257+
// throw, reports `resumed: true`, carries no `resumeError`, and the run it
258+
// names is the CHILD, which completed. Nothing in the response reaches the
259+
// stranded parent.
260+
expect(outcome.ok, 'today the door does not throw').toBe(true);
261+
const answer = outcome.ok ? outcome.r : (undefined as never);
262+
expect(resumeFacing(answer)).toEqual(FULL_SUCCESS);
263+
expect(answer.runId, 'the id handed back is the CHILD — the run that is fine').toBe(childRunId);
264+
expect(strandedDecisionDetails(answer as unknown)).toBeUndefined();
265+
266+
// The one artefact the operator gets, at the level AGENTS.md's durability
267+
// rule requires, naming the run and the repair verb (#15556's shipped half).
268+
const durability = logger.lines.filter(
269+
(l: any) => l.level === 'error' && String(l.msg).includes('STRANDED'),
270+
);
271+
expect(durability.length, 'exactly one, at `error`').toBe(1);
272+
expect(durability[0].msg).toContain(`restoreConsumedSuspension('${parentRunId}')`);
273+
274+
// …and the repair verb it promises actually works.
275+
expect((await automation.restoreConsumedSuspension(parentRunId, { requestedBy: 'ops' })).restored).toBe(true);
276+
213277
});
214278

215-
it('CONTROL A — same composition, parent downstream node healthy', async () => {
279+
it('CONTROL A — the healthy composition answers IDENTICALLY, which is the defect', async () => {
216280
const automation = boot();
217281
const started = await automation.execute('deal_parent', {
218-
object: 'crm_deal', record: { id: 'd2', amount: 100 }, userId: 'submitter',
282+
object: 'crm_deal', record: { id: 'd1', amount: 100 }, userId: 'submitter',
219283
} as never);
220284
const parentRunId = (started as any).runId as string;
221285
const req = await pendingRequest();
222-
const outcome = await service
223-
.decide(req.id, { decision: 'approve', actorId: 'u1' }, SYSTEM_CTX)
224-
.then(r => ({ ok: true as const, r }), (e: Error) => ({ ok: false as const, e }));
225-
// eslint-disable-next-line no-console
226-
console.log('CTRL-A door =', JSON.stringify(outcome.ok ? outcome.r : { threw: outcome.e.message }));
227-
// eslint-disable-next-line no-console
228-
console.log('CTRL-A marks =', JSON.stringify(marks));
229-
// eslint-disable-next-line no-console
230-
console.log('CTRL-A parent run row =', JSON.stringify(await automation.getRun(parentRunId).then(r => r && { status: (r as any).status })));
286+
287+
const answer = await service.decide(req.id, { decision: 'approve', actorId: 'u1' }, SYSTEM_CTX);
288+
289+
// The parent ran to completion this time — the only thing that changed.
290+
expect(marks).toEqual(['on_approved', 'after_sub']);
291+
expect((await automation.getRun(parentRunId))?.status).toBe('completed');
292+
expect(logger.lines.filter((l: any) => l.level === 'error')).toEqual([]);
293+
294+
// ⭐ The sharpest statement of the defect: the SAME literal the stranded
295+
// composition asserted. A caller comparing the two answers has nothing to
296+
// compare — only the run ids differ, and both name a healthy child.
297+
expect(resumeFacing(answer)).toEqual(FULL_SUCCESS);
298+
expect(answer.runId).toBe(req.flow_run_id);
231299
});
232300

233-
it('CONTROL B — no subflow: the #13807 shape still throws at this door', async () => {
301+
it("CONTROL B — the #13807 shape still throws at this door, so the harness is live", async () => {
302+
// Without this control, "the door did not throw" above would be
303+
// indistinguishable from a door that was never wired to throw at all.
234304
throwOn.on_approved = 'the child branch blew up';
235305
const automation = boot();
236306
await automation.execute('deal_approval', {
237307
object: 'crm_deal', record: { id: 'd3', amount: 100 }, userId: 'submitter',
238308
} as never);
239309
const req = await pendingRequest();
240-
const outcome = await service
310+
311+
const err = await service
241312
.decide(req.id, { decision: 'approve', actorId: 'u1' }, SYSTEM_CTX)
242-
.then(r => ({ ok: true as const, r }), (e: Error) => ({ ok: false as const, e }));
243-
// eslint-disable-next-line no-console
244-
console.log('CTRL-B door =', JSON.stringify(outcome.ok ? outcome.r : { threw: outcome.e.message, details: strandedDecisionDetails(outcome.e) }));
313+
.then(() => null, (e: Error) => e);
314+
315+
expect(err, 'the direct shape is reported — this door can fail').toBeTruthy();
316+
expect(err?.message).toMatch(/^RESUME_FAILED/);
317+
expect(strandedDecisionDetails(err)).toEqual({
318+
finalized: true, decision: 'approve', runId: req.flow_run_id, repairable: true,
319+
});
245320
});
246321
});

0 commit comments

Comments
 (0)