Skip to content

Commit 3e17091

Browse files
committed
fix(automation): tell approval decide() callers when a subflow parent strands
bubbleToParent already resumes a subflow's parent when the child completes, but a parent that then strands downstream was reported only to the engine's own error log -- the decide() caller (an approvals decision) still answered resumed: true with nothing to distinguish it from a healthy composition, and its runId still named the healthy child, never the stranded parent. Fill the already-declared, previously-unfilled ApprovalDecisionResult. resumeFailure slot (#16472 family ruling, decision batch #76, option A): the door's status code does not move, but a decide() whose own run resumed while its subflow parent stranded now also carries resumeFailure (the parent's runId + repairable) and a matching resumeError, sourced from a new internal AutomationEngine.takeSubflowParentStrand(childRunId) read-once channel that bubbleToParent populates on its 'stranded' exit only -- RESUME_IN_PROGRESS / STORE_UNAVAILABLE stay the untouched functional degradation, and the engine's own error log line is unchanged. Updates the #15556 reproduction test to its designed-to-go-red truthful shape, keeping both its healthy-composition and direct-throw controls. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj
1 parent d88a47d commit 3e17091

5 files changed

Lines changed: 287 additions & 55 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
"@objectstack/plugin-approvals": patch
4+
---
5+
6+
An approval `decide()` that resumes a subflow CHILD now tells the caller when that resume bubbles into a PARENT run that stranded — instead of answering full success with nothing to distinguish it from a healthy composition (#15556; the #16472 family ruling, decision batch #76, option A).
7+
8+
**The composition.** A parent flow parks at a `subflow` node whose child hosts the `approval` node, so the approvals row names the CHILD run. The decision door resumes the child, the child completes, `bubbleToParent` resumes the parent, and the parent's own downstream node throws. The parent lands on the engine's `'stranded'` exit — it consumed its suspension and is now terminal, repairable only by an operator's `restoreConsumedSuspension` — and `bubbleToParent` already logged that at `error` (unchanged by this fix). What the caller was TOLD did not: `resumed: true`, no `resumeError`, and a `runId` naming the healthy child — identical to what a fully healthy composition answers.
9+
10+
```
11+
FROM service.decide(requestId, { decision: 'approve' }, ctx)
12+
-> { finalized: true, decision: 'approve', runId: '<child>', resumed: true }
13+
// identical to a healthy composition's answer — no caller can tell
14+
15+
TO service.decide(requestId, { decision: 'approve' }, ctx)
16+
-> { finalized: true, decision: 'approve', runId: '<child>', resumed: true,
17+
resumeError: "RESUME_FAILED: … its own flow run '<child>' resumed, but the " +
18+
"subflow parent above it — run '<parent>' — consumed its suspension " +
19+
"and is now stranded: <downstream error>",
20+
resumeFailure: { code: 'RESUME_FAILED', runId: '<parent>', status: 'stranded', repairable: true } }
21+
```
22+
23+
**Additive only — no migration.** `ApprovalDecisionResult.resumeFailure` was already declared (and pinned) in `@objectstack/spec` ahead of this card; this fix is the first producer that fills it. No existing field changes shape, no status code moves (the door still never throws for this shape — `AGENTS.md`'s "a failure handed to the caller" answer does not apply here, since before this fix no caller was told at all), and the door's `error` log line is untouched. A consumer that already ignores unknown fields sees no difference; a consumer that reads `resumeFailure` can now tell a bubbled parent strand from a clean resume without diffing `runId` against a durable run history.
24+
25+
**What did not move, on purpose.** `RESUME_IN_PROGRESS` / `STORE_UNAVAILABLE` bubble outcomes stay the functional degradation they always were (`warn`, unreported on `resumeFailure`) — the #16472 ruling is scoped to the one exit the engine calls `'stranded'`. The sibling `recall` door (`ApprovalRecallResult.resumeFailure`, #15970) is a separate card and is not touched here.

‎packages/plugins/plugin-approvals/src/approval-service.ts‎

Lines changed: 88 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import type {
4747
ApprovalResubmitResult,
4848
ApprovalStatus,
4949
ApprovalCancelReason,
50+
ResumeFailureReport,
5051
} from '@objectstack/spec/contracts';
5152
// [#7135] The full `resolveAuthzContext` envelope — what `IApprovalService`
5253
// declares for every one of these context parameters since #6523 (the #6206
@@ -226,6 +227,26 @@ export interface ApprovalResumeSurface {
226227
| { repairable: true }
227228
| { repairable: false; reason: 'RUN_SUSPENDED' | 'SNAPSHOT_DROPPED' | 'NO_CONSUMED_SUSPENSION' }
228229
>;
230+
/**
231+
* [#15556; the #16472 family ruling] The subflow PARENT strand that
232+
* `childRunId`'s own completion bubbled into, if the engine's up-bubble
233+
* hit the `'stranded'` exit — read, and CLEARED, by
234+
* {@link ApprovalService.resumeRecordedOutcome} right after a resume it
235+
* issued reports success, so a decision whose OWN run advanced can still
236+
* tell the caller a run further up the chain did not.
237+
*
238+
* ⚠️ Declares a method `AutomationEngine` ALREADY implements publicly
239+
* (`takeSubflowParentStrand`); it widens no wire surface — the engine's
240+
* generic `resume()` / `AutomationResult` answer is UNCHANGED by this
241+
* member's existence, which is exactly why a caller must ask for it by
242+
* name instead of finding it riding the resume result.
243+
*
244+
* `runId` here is the PARENT's — never `childRunId` itself, and never the
245+
* run a caller of `decide`/`resumeRecordedOutcome` was resuming. Optional:
246+
* an engine that predates this member simply never reports a bubbled
247+
* strand, exactly as before this card.
248+
*/
249+
takeSubflowParentStrand?(childRunId: string): { runId: string; repairable: boolean; error: string } | undefined;
229250
}
230251

231252
/** What {@link ApprovalResumeSurface.inspectConsumedSuspension} answers. */
@@ -668,8 +689,10 @@ export interface StrandedContinuationSignal {
668689
* Outcome of {@link ApprovalService.continueRestoredRun} (#15389).
669690
*
670691
* ⚠️ Deliberately its own shape rather than a reuse of `ApprovalDecisionResult`:
671-
* that contract is the subject of an OPEN maintainer ruling on #15556, and this
672-
* card must not pre-empt it. Nothing here changes what `decide` answers.
692+
* the #16472 family ruling settled that contract for `decide` (#15556) and
693+
* `recall` (#15970) by name, and this repair-replay verb is neither of those
694+
* doors — reusing their shape here would answer a question nobody asked.
695+
* Nothing here changes what `decide` answers.
673696
*/
674697
export interface ApprovalContinuationResult {
675698
/** True when the restored pause was consumed and the flow moved on. */
@@ -3113,11 +3136,19 @@ export class ApprovalService implements IApprovalService {
31133136
* how an approval could be recorded, reported as resumed, and leave its flow
31143137
* stranded forever (#4420). The thrown error carries {@link resumeCodeOf}'s
31153138
* `resumeCode` so callers can tell a benign duplicate from a dead run.
3139+
*
3140+
* [#15556; the #16472 family ruling] On the SUCCESS path this also asks
3141+
* {@link ApprovalResumeSurface.takeSubflowParentStrand} whether resuming
3142+
* `runId` bubbled into a parent that then stranded — a fact the engine's
3143+
* own `resume()` answer never carries (its `AutomationResult` is unchanged
3144+
* by this card, on purpose: that value can also reach a raw REST resume
3145+
* caller verbatim, and this is not a wire member). `undefined` on every
3146+
* other outcome, exactly like the surface member itself.
31163147
*/
31173148
private async serviceResume(
31183149
runId: string,
31193150
signal: { output?: Record<string, unknown>; branchLabel?: string },
3120-
): Promise<void> {
3151+
): Promise<{ runId: string; repairable: boolean; error: string } | undefined> {
31213152
const result = await this.automation!.resume!(runId, { ...signal, [RESUME_AUTHORITY_SERVICE]: true });
31223153
const reported = result as
31233154
{ success?: boolean; code?: string; error?: string; status?: string } | undefined;
@@ -3132,6 +3163,7 @@ export class ApprovalService implements IApprovalService {
31323163
err.resumeStatus = reported.status;
31333164
throw err;
31343165
}
3166+
return this.automation?.takeSubflowParentStrand?.(runId);
31353167
}
31363168

31373169
/** The engine failure code behind a {@link serviceResume} rejection, if any. */
@@ -3279,6 +3311,26 @@ export class ApprovalService implements IApprovalService {
32793311
* happen", which is the misreading that makes a caller retry or escalate
32803312
* against a decision that IS durable.
32813313
*
3314+
* ## A resume that SUCCEEDED can still carry a strand (#15556)
3315+
*
3316+
* #13807 above is about THIS run failing to resume. A DIFFERENT run can
3317+
* strand as a side effect of this one succeeding: `runId` parks inside a
3318+
* subflow, so resuming it can bubble into a PARENT that consumed ITS OWN
3319+
* suspension and then failed downstream (`AutomationEngine.bubbleToParent`,
3320+
* `service-automation`). Before the #16472 family ruling that fact reached
3321+
* nobody — `resumed: true`, no `resumeError`, and the `runId` this method
3322+
* returns names the run that is genuinely fine, never the stranded parent.
3323+
* The ruling (option A): the door's status code still does not move — this
3324+
* method still returns normally — but `resumeFailure` on the return value
3325+
* carries the PARENT's `runId` and `repairable`, read via
3326+
* {@link serviceResume}'s post-success
3327+
* {@link ApprovalResumeSurface.takeSubflowParentStrand} check, and
3328+
* `resumeError` carries the same event in prose. Absent on every OTHER
3329+
* success — a plain resume, or one whose subflow parent (if any) advanced
3330+
* cleanly — so a caller reading this member sees a report only when there
3331+
* is one to make, exactly {@link ApprovalDecisionResult.resumeFailure}'s
3332+
* absence rule.
3333+
*
32823334
* @param what - how the recorded outcome reads in the error, e.g.
32833335
* `"the approve decision"`.
32843336
* @param decision - the outcome label for the machine-readable envelope
@@ -3293,11 +3345,35 @@ export class ApprovalService implements IApprovalService {
32933345
what: string,
32943346
signal: { output?: Record<string, unknown>; branchLabel?: string },
32953347
decision: string,
3296-
): Promise<{ resumed: boolean; resumeError?: string }> {
3348+
): Promise<{ resumed: boolean; resumeError?: string; resumeFailure?: ResumeFailureReport }> {
32973349
const missing = this.missingRunCapability(runId, requestId, what, 'resume');
32983350
if (missing) return { resumed: false, resumeError: missing };
32993351
try {
3300-
await this.serviceResume(runId, signal);
3352+
const bubbleStrand = await this.serviceResume(runId, signal);
3353+
if (bubbleStrand) {
3354+
// #15556: this door's OWN resume succeeded — `runId` really did
3355+
// advance — but the subflow parent it bubbled into did not. Told on
3356+
// BOTH halves of the ONE telling (spec docblock, `ApprovalDecisionResult`):
3357+
// `resumeFailure` for a machine, `resumeError` for a human, never
3358+
// gated on `resumed`, which stays `true` here. ⛔ No NEW log line: the
3359+
// #16472 ruling left logging alone — `bubbleToParent`'s own `error`
3360+
// line (`service-automation`) already said this ONCE, with the
3361+
// consequence and the fix, per AGENTS.md's "say it once" durability
3362+
// rule; a second statement here would be the same event twice.
3363+
return {
3364+
resumed: true,
3365+
resumeError:
3366+
`RESUME_FAILED: ${what} was recorded on request ${requestId} and its own flow run '${runId}' ` +
3367+
`resumed, but the subflow parent above it — run '${bubbleStrand.runId}' — consumed its ` +
3368+
`suspension and is now stranded: ${bubbleStrand.error}`,
3369+
resumeFailure: {
3370+
code: 'RESUME_FAILED',
3371+
runId: bubbleStrand.runId,
3372+
status: 'stranded',
3373+
repairable: bubbleStrand.repairable,
3374+
},
3375+
};
3376+
}
33013377
return { resumed: true };
33023378
} catch (err: any) {
33033379
const reason = err?.message ?? String(err);
@@ -3360,6 +3436,7 @@ export class ApprovalService implements IApprovalService {
33603436

33613437
let resumed = false;
33623438
let resumeError: string | undefined;
3439+
let resumeFailure: ResumeFailureReport | undefined;
33633440
// No `typeof this.automation?.resume === 'function'` guard here (#4420):
33643441
// skipping the call when no engine is attached is precisely how a decision
33653442
// against a parked run returned 200 / `resumed: false` with nothing logged.
@@ -3383,6 +3460,7 @@ export class ApprovalService implements IApprovalService {
33833460
);
33843461
resumed = outcome.resumed;
33853462
resumeError = outcome.resumeError;
3463+
resumeFailure = outcome.resumeFailure;
33863464
}
33873465

33883466
return {
@@ -3392,6 +3470,11 @@ export class ApprovalService implements IApprovalService {
33923470
runId: result.runId,
33933471
resumed,
33943472
...(resumeError ? { resumeError } : {}),
3473+
// [#15556; #16472 ruling] Additive — see the field's own docblock in
3474+
// `@objectstack/spec/contracts`'s absence rule: omitted entirely rather
3475+
// than `undefined`, so a consumer that merely checks `'resumeFailure'
3476+
// in result` reads presence correctly.
3477+
...(resumeFailure ? { resumeFailure } : {}),
33953478
};
33963479
}
33973480

0 commit comments

Comments
 (0)