fix(automation): tell approval decide() callers when a subflow parent strands - #17908
Conversation
… 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
📓 Docs Drift CheckThis PR changes 2 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 7 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 0861e20b78bd192d6d41de5f60ae89351a917e9a && git checkout 0861e20b78bd192d6d41de5f60ae89351a917e9a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 84e6b05b6d295f1c744d236921300f447cf7791e 41479de9a4dadd56d8ff686ea083723a1527666a && git checkout -B drift-repro 84e6b05b6d295f1c744d236921300f447cf7791e && git merge --no-ff 41479de9a4dadd56d8ff686ea083723a1527666a
node scripts/docs-audit/affected-docs.mjs --json 84e6b05b6d295f1c744d236921300f447cf7791e
|
|
What is failing
Why — and the timeline matters, because the first red had a different cause
The Clause-② correction behind itThis seat declared The claim-time measurement was right as far as it went — Remedy — route 1 of the two the gate names, and ⛔ not route 2The gate offers two non-interchangeable routes: raise the widened package, or correct the declaration at its producer. Route 2 is measurably closed here — the declaration is right; it is the ⛔ Not dropping the changeset, ⛔ not downgrading anything, ⛔ not adding a A changeset is a repository file, so ⛔ this seat does not edit it: the rework is with the implementing dev and the fix arrives as a push to this branch. No re-run is needed or has been spent — the push re-triggers the gate. ⭐ Recorded because it should not be lost in the correction: the dev's reason for not reusing
Generated by Claude Code |
…correction) The delivered diff adds new public surface -- AutomationEngine. takeSubflowParentStrand() and the exported SubflowParentStrand interface on @objectstack/service-automation, plus a matching optional ApprovalResumeSurface member on @objectstack/plugin-approvals -- so Clause-2 is yes on the mechanical "new exported symbol" floor, independent of the wire-compatibility argument in the PR body. Neither package's changes reach any wire payload; the grade moves because the export surface grew, not because behaviour did. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj
Contract reviewHead reviewed: In-seat Clause-② review, default judgement tier (a non-spec seat's Clause-② review is default-tier self-review plus the gates; the at-tier isolated reviewer is the spec seat's requirement, and this PR touches zero ① Derived judgments — every accept-set and public-surface change, named from the diff
② Semver grading — consistent
③ Boundary flags and
|
|
Blocked on #17909 — a Clause-② provenance (the clear)
What is failing, and why no fix comes from this lane
The guard requires the stamping package to own the row, or a ⛔ And there is no spec-free path.
StateCard #15556 → ⇒ When #17909 lands, no code change is expected on this branch: re-run
Generated by Claude Code |
…cision-door-resume-failure
Contract review — RE-ISSUED for the post-base-merge headHead reviewed: Implemented-by:
① What the merge did and did not change — measured
⇒ the merge brought ② Clause-② — re-derived at THIS head, ⛔ not carried overLimb (a) — a new exported symbol reachable from the published entry. Satisfied, twice:
⇒ Clause-②: ⭐ Recorded because this is the determination this seat got wrong at claim time and corrected: the claim-time ⭐ One asymmetry, deliberate and worth naming so a later reader does not "fix" it: the approvals side declares its member with an inline structural type, ⛔ not by importing Limb (b) — a new key on an already-published payload: none. The changeset states it and the diff agrees — ③ The lane red line — held⛔ Verdict: PASS at
|
|
Fixes #15556
Update (2026-09-13)
The review seat corrected this PR's contract classification after reading the delivered diff: it adds a new public method (
AutomationEngine.takeSubflowParentStrand) and a new exported interface (SubflowParentStrand) to@objectstack/service-automation, and a matching optional member on@objectstack/plugin-approvals' already-exportedApprovalResumeSurface— new public surface, whatever the wire does. The changeset now grades both packagesminorinstead ofpatch, andneeds:contract-reviewis attached. Nothing else about the delivery changed — see the changeset for exactly what grew and why.What changed
bubbleToParent(packages/services/service-automation/src/engine.ts) already resumes a subflow's PARENT when its child completes. When that parent consumes its own suspension and then fails downstream — the engine's'stranded'exit — the failure was reported only to the engine's ownerrorlog. The caller who resumed the child (an approvalsdecide()) still answeredresumed: truewith nothing to distinguish it from a fully healthy composition, and itsrunIdstill named the healthy CHILD, never the stranded PARENT.This fills the slot the #16472 family ruling (maintainer 2026-09-07, decision batch #76, option A) already declared and left unfilled:
ApprovalDecisionResult.resumeFailure?: ResumeFailureReportin@objectstack/spec(packages/spec/src/contracts/approval-service.ts:785). The door's status code does not move —decide()still never throws for this shape — but the answer now carries the strand behind it.Plumbing — and why it is new public surface, not a wire change.
bubbleToParent's'stranded'exit now also records aSubflowParentStrand({ runId, repairable: true, error }) in a new bounded, per-processMap<childRunId, SubflowParentStrand>onAutomationEngine, read once (and cleared) via a new publictakeSubflowParentStrand(childRunId).plugin-approvals'sserviceResumecalls it right after its own resume reports success, andresumeRecordedOutcometurns a hit intoresumeFailure+resumeErroron thedecide()result.RESUME_IN_PROGRESS/STORE_UNAVAILABLEbubble outcomes record nothing — they stay the untouched functional degradation — andbubbleToParent's ownerrorlog line is byte-for-byte unchanged (the ruling explicitly leaves logging alone).I deliberately did not reuse the generic
AutomationEngine.resume()/AutomationResultreturn value as the carrier: that object is served verbatim to a raw RESTPOST …/resumecaller too (deps.success(result)inpackages/runtime/src/domains/automation.ts, out of my file surface), so adding a field there would leak an undeclared key onto that wire path for every subflow resume, not just an approvals-mediated one. The alternative — a new engine method mirroring the existinginspectConsumedSuspension/hasSuspendedRun/listSuspendedRunsDurablepattern already onApprovalResumeSurface— avoids that leak, but it is itself new exported surface on both packages (see the changeset), which is the thing declared here rather than argued away.Updated the #15556 reproduction test (
subflow-hosted-approval-strand.test.ts) to its designed-to-go-red truthful shape (its own header said the fix must turn it red on purpose), keeping both controls: CONTROL A (healthy composition — still the sharedFULL_SUCCESSliteral, now asserted to diverge from the stranded case) and CONTROL B (the #13807 direct-throw shape, unaffected). Also refreshed two now-stale "#15556 open decision" doc comments inservice-automationandplugin-approvalsthat this PR itself closes.Scope discipline
packages/spectouched. Verified before starting:ApprovalDecisionResult.resumeFailure, its docblock's absence rule, and the widenedresumeErrordocblocks are already landed and pinned (resume-failure-report.pin.test.ts, re-run below, green, untouched). This PR only produces a value for the already-declared slot.recall()(approvals: arecallwhose resume strands reports it as an ordinary non-failure — norepairablediscriminator, where the identical strand throughdecidecarries one #15970's door) not touched — same file, deliberately left alone per the sibling fence. It callsserviceResumetoo and would trivially gain the same fix (its return value is just discarded today), but that fold-in belongs to approvals: arecallwhose resume strands reports it as an ordinary non-failure — norepairablediscriminator, where the identical strand throughdecidecarries one #15970, not this PR.errorfor'stranded',warnfor the two tolerated arms) is untouched, per the ruling.Tests
pnpm --filter @objectstack/plugin-approvals exec vitest run src/subflow-hosted-approval-strand.test.ts— 3/3 pass (the fixed reproduction + both controls).pnpm --filter @objectstack/service-automation exec vitest run src/subflow-bubble-strand-log-level.test.ts src/nested-strand-chain-restore.test.ts src/engine-residual-log-cause.test.ts— 38/38 pass (log-level pins unaffected, both directions).pnpm --filter @objectstack/spec exec vitest run src/contracts/resume-failure-report.pin.test.ts— 6/6 pass (spec side untouched, still green).pnpm --filter @objectstack/service-automation test— 132 files / 1564 tests pass.pnpm --filter @objectstack/plugin-approvals test— 44 files / 733 tests pass.pnpm --filter @objectstack/service-automation typecheckandpnpm --filter @objectstack/plugin-approvals typecheck— both exit 0 (plugin-approvals' pre-existing 324-error test-typecheck debt ledger is unchanged, not mine).pnpm --filter @objectstack/service-automation build/pnpm --filter @objectstack/plugin-approvals build(with their dependency closures) — both exit 0, DTS emitted clean.node scripts/pm/dispatch-gates.mjs --commands(re-derived after the changeset landed) named 63 families; all 63 run. 60 exit 0. 3 exit3(PREREQUISITE NOT MET), reported as NOT MEASURED, never as a pass:check:dual-build-cjs-loads,check:i18n,check:type-check-debt— all three refuse because they read a full-monorepodist/this local run never built (out of the ①②③ local-verification scope; CI builds the whole tree). None is in a family my diff plausibly affects.Changeset
.changeset/15556-subflow-parent-strand-on-decide.md—minoron both@objectstack/service-automationand@objectstack/plugin-approvals(corrected from an initialpatch— see Update above): the fix is additive with no migration, but it adds genuinely new public surface (AutomationEngine.takeSubflowParentStrand,SubflowParentStrand, and a matching optionalApprovalResumeSurfacemember), which is what aminorgrade is for.Generated by Claude Code