Skip to content

fix(service-automation): a triggered run carries the flow author's successMessage / errorMessage — execute() and both retry exits (#9414) - #9514

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-9414-execute-terminal-messages
Aug 18, 2026
Merged

fix(service-automation): a triggered run carries the flow author's successMessage / errorMessage — execute() and both retry exits (#9414)#9514
os-project-manager merged 2 commits into
mainfrom
claude/issue-9414-execute-terminal-messages

Conversation

@os-project-manager

@os-project-manageros-project-manager commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Fixes#9414

AutomationResult declares successMessage / errorMessage as a general terminal-result
feature (packages/spec/src/contracts/automation-service.ts):

Friendly terminal messages copied from the flow definition (flow.successMessage /
flow.errorMessage) so a screen-flow runner can show a meaningful toast instead of a
generic "Done" / the raw error. successMessage is set on terminal success,
errorMessage on failure.

One producer honoured it. resumeInternal set both on its terminal returns; execute()
set neither, on either exit, and neither did executeWithoutRetry or retryExecution. So
a flow's own words reached a caller only if the run happened to pause and be resumed. A
flow dispatched straight through POST /api/v1/automation/:name/trigger — or the legacy
trigger/:name that client.automation.trigger() calls — carried nothing, though the flow
declared the text and the contract said it was set. One declaration, two behaviours decided
by ROUTE rather than by authoring.

This is a declared-vs-enforced pair three docs pages and two consumers were already written against

The strongest argument for the route triage chose is that nothing here was speculative.
Everything downstream of the engine had already been built against the declaration:

  • The published documentation already promises the wire shape.
    content/docs/automation/flows.mdx:1322 says, of the trigger route:

    A 400 additionally carries the flow author's own errorMessage (when the flow
    declares one) at error.details.errorMessage

    That sentence was false on main. The card's own measurement is that errorMessage
    is always absent on the trigger path, because only resumeInternal ever produced it.
    The documentation described the declaration; the implementation never honoured it. This
    PR does not invalidate the doc — it makes the doc true, which is why the docs-drift
    advisory on this PR (flows.mdx, ui/actions.mdx, protocol/kernel/http-protocol.mdx,
    all reached through the /api/v1/automation/:name/trigger route anchor) is a true
    positive that resolves to no doc edit
    . Nothing in those pages is now wrong; the prose
    is the acceptance criterion, and the pins below are written against it.

  • The route already carries it.respondToFlowTrigger maps result.errorMessage into
    error.details.errorMessage on the 400 (fix(automation): answer real HTTP status codes on both trigger routes #9413) — the one place the console reads it
    from.

  • The console already reads it. objectui flowResponse.ts (ci(release): 解堵 17.0.0-rc.2,并让空 changeset 不再吃掉一整轮发布 (#4898) #4899) reads that exact
    key, and got nothing on every non-screen flow.

So this is the production half catching up with a declaration that was already documented
and already consumed (ADR-0049 enforce-or-remove, restoration direction) — no new keys, no
contract edit. The alternative, narrowing the contract text to "screen-flow only", was
considered and rejected at triage; note that it would have required falsifying three
published pages
to make a bug disappear.

What changed

packages/services/service-automation/src/engine.ts — every exit a triggered run can leave
through:

exitfield
execute() terminal successsuccessMessage
execute() terminal failureerrorMessage, beside the raw error, not instead of it
executeWithoutRetry() successsuccessMessage
executeWithoutRetry() failureerrorMessage
retryExecution() exhausted exiterrorMessage

Two of those are the reason "and its retry wrappers" is in the card:

  • retryExecution's exhausted exit is a different exit from execute()'s own failure
    return.
    A flow under errorHandling.strategy: 'retry' is handed off before that return
    and never reaches it, so a repair stopping at execute() would have left the author's
    message missing for exactly the runs most likely to need it — the ones that failed over
    and over. retryExecution takes the author's text as a parameter for the same reason it
    already takes errorHandling rather than re-reading it: execute() holds the parsed
    flow, and the exhausted exit should report the definition this dispatch started under,
    not whatever a hot-reload re-registered while the loop slept between attempts.
  • executeWithoutRetry's success exit is what a run that succeeded on attempt 2+ leaves
    through
    retryExecution returns that result verbatim. Without it, successMessage
    would depend on which attempt happened to work.

The strings pass through untouched.flows.mdx:82 documents both fields as plain
author-declared strings with {var} explicitly NOT interpolated, so templating, trimming or
HTML-escaping on the way out would contradict the shipped contract. Nothing on this path
touches them, and that is pinned with a deliberately hostile string rather than assumed.

Nothing else gained a message, deliberately, and the boundaries are pinned rather than
only described: the paused return is not terminal; the skip exits (condition_not_met,
reentrancy_loop_guard) return success: true for a run that executed no node, so a toast
there would be about work nobody did; the never-dispatched exits (flow not found / disabled
/ no start node) carry no status on purpose — that absence is what proves the transport
reads a verdict instead of guessing (#9378 / #9415) — and errorMessage must not become a
second channel implying a run failed when none started.

Tests — the doc's own path and key, end to end

Two new files, and the split is deliberate:

  1. packages/services/service-automation/src/flow-terminal-messages.test.ts (8 pins) — the
    PRODUCER half: every terminal exit of execute(), executeWithoutRetry() and
    retryExecution(), plus the boundaries that must stay empty.

  2. packages/verify/src/automation-trigger-terminal-messages.test.ts (4 pins) — the WIRE
    half, end to end. A pin that stops at AutomationResult cannot fail when the route
    mapping is the half that breaks, and the documented sentence is about
    error.details.errorMessage, not about an engine result object. @objectstack/verify is
    the one package already depending on both@objectstack/runtime and
    @objectstack/service-automation, so a real AutomationEngine is driven through a real
    HttpDispatcher at POST /notify_owner/trigger and the response body is read at the
    doc's exact path:

    • 400body.error.details.errorMessage is the author's text, body.error.message
      still carries the raw node failure and does not contain the author's text, and
      body.data is absent (ADR-0112, no inner envelope);
    • 200body.data.successMessage is the author's text;
    • verbatim: a message of ' {amount} items — "R&D" & 5 > 3, kept verbatim ' comes
      back byte-identical on both exits, with amount: 42 supplied as a real trigger param —
      so an interpolating, trimming or escaping producer fails loudly;
    • a flow declaring no messages produces no key at all — the doc's "when the flow
      declares one" half.

    The route-side pins driven with a scripted result stay where they are
    (packages/runtime/src/domains/automation-trigger-route-status.test.ts, 26 tests, green
    at this head); this file is what proves the two halves actually meet.

Reverse verification, direction predicted before running (the #9085 lesson: a pin green
both before and after is measuring the wrong thing). The fix was committed first, then
engine.ts alone was restored to origin/main and both files re-run:

service-automation Tests 4 failed | 4 passed (8)
verify (end-to-end) Tests 3 failed | 1 passed (4)
AssertionError: expected undefined to be 'We could not create the opportunity —…'

The reds are exactly the pins that assert the author's text — undefined where it belongs,
on both execute() exits, both retry exits, and both wire exits. The greens are fences that
are green either way on purpose, and each says so in place: three boundary pins, one
resume symmetry anchor, and the "declares no messages" case.

⚠️The end-to-end file resolves both packages through their built dist/, as every
dependent of theirs in this workspace does. So the ablation leg was run properly rather than
assumed: @objectstack/service-automation was rebuilt after the mutation, and the
mutation was proved to have reached the artifact before the run — the two markers in
dist/index.js drop from 3 sites to 1 (only resumeInternal's) and return to 3 after the
restore. Without that rebuild the ablation would have run the pre-mutation build and
reported green over an assertion that could never fail.

Verification — all at 3fc129b77 (the final commit)

pnpm --filter '@objectstack/service-automation^...' build → 0
pnpm --filter '@objectstack/verify^...' build → 0
pnpm --filter @objectstack/service-automation test → 81 files, 982 tests passed
pnpm --filter @objectstack/verify test → 7 files, 32 tests passed
pnpm --filter @objectstack/verify typecheck → 0 (real script, output echoed)
npx vitest run src/automation-trigger-terminal-messages.test.ts → 4 passed
npx vitest run src/domains/automation-trigger-route-status.test.ts (runtime) → 26 passed

Gate union re-derived at the new head from the actual changed paths via
node scripts/pm/dispatch-gates.mjs (paths from git merge-base origin/main HEAD, not the
two-dot form), and every family it named was re-run after the second commit:

check:nul-bytes → OK (6133 files, no raw control bytes)
check:changeset-gate-self-tests → OK
check:objectui-changeset → OK
check:test-source-alias → OK (72 packages)
check:type-source-resolution → OK (76 packages)
check:engine-double-contract → OK (319 pinned, none new)
check:where-matcher → OK (253 matchers, none new)
check:query-options-erasure → OK (ratchet holds, no files added)
check:type-check-coverage → OK (64/77 packages)
check:type-check-debt (--re-measure) → OK (33 entries in 217.2s, none above its
recorded number; "surplus: none")
check-adr-0087-registration.mjs --base MERGE_BASE → OK (no declared-breaking changeset)
check-changeset-no-major.mjs --base MERGE_BASE → OK
check-empty-changeset.mjs --base MERGE_BASE → OK (1 declaring changeset added)
check-cross-package-test-inputs.mjs → OK (12 packages, all declared)
docs-audit/check-affected-docs.mjs → OK (212 self-test cases)

The ratchet was re-run against a freshly built workspace closure
(turbo run build --filter=./packages/* --filter=./packages/*/*, 70 tasks) at the new head,
as its own failure text requires — an unbuilt run refuses rather than measuring. Both new
test files add zero errors: the @objectstack/service-automation and @objectstack/verify
ledger entries each sit exactly at their recorded numbers, and the hidden-test file count
moves 937 → 938, which is the new verify file being seen.

Changeset: .changeset/automation-execute-terminal-messages.md (patch on
@objectstack/service-automation). A triggered run's response body gains a field authors
already declared, so it is user-visible.

Filed, not fixed here


Generated by Claude Code

… messages (#9414)
`AutomationResult` declares `successMessage` / `errorMessage` as a general
terminal-result feature, but `resumeInternal` was the only producer. A flow
dispatched through `POST /api/v1/automation/:name/trigger` carried neither,
so the author's own text reached a caller only when the run happened to
pause and be resumed — and the console consumer that reads
`error.details.errorMessage` (#9413, objectui `flowResponse.ts`) got nothing
on every non-screen flow.
Four terminal exits now produce the pair: `execute()`'s success and failure
returns, both of `executeWithoutRetry`'s, and `retryExecution`'s exhausted
exit — a different exit from `execute()`'s own, reached by exactly the runs
that failed repeatedly.
Paused, skipped and never-dispatched exits keep carrying neither, and that
boundary is pinned rather than only described.
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26DJEHSBhhAQ6wwfsHNza
@github-actionsgithub-actionsBot added size/m documentation Improvements or additions to documentation tests tooling labels Aug 18, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-automation, touching 4 documentable anchor(s).

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx(via /api/v1/automation/:name/trigger (route))
  • content/docs/protocol/kernel/http-protocol.mdx(via /api/v1/automation/:name/trigger (route))
  • content/docs/ui/actions.mdx(via /api/v1/automation/:name/trigger (route))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx(via AutomationEngine (symbol))
  • content/docs/releases/v17.mdx(via AutomationEngine (symbol), retryExecution (symbol))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json origin/mainpackageMentionDocs.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

… the documented wire path (#9414)
`content/docs/automation/flows.mdx` promises that a 400 from
`POST /api/v1/automation/:name/trigger` carries the flow author's own
`errorMessage` at `error.details.errorMessage`. That sentence was false
before the engine repair — the field was always absent at the source on
this route — so the docs described the declaration while the
implementation never honoured it.
The engine pins assert `AutomationResult`; the route pins drive a scripted
result. Neither proves the documented sentence. `@objectstack/verify` is
the one package depending on both `@objectstack/runtime` and
`@objectstack/service-automation`, so a real engine is driven through a
real `HttpDispatcher` here and the response body is read at the doc's own
path and key — on both exits, plus the "when the flow declares one" half.
One case drives a deliberately hostile string (braces, padding, `&`,
quotes) and asserts byte identity: `flows.mdx` documents both fields as
plain strings with `{var}` explicitly NOT interpolated, so templating,
trimming or escaping on the way out would contradict the shipped contract.
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26DJEHSBhhAQ6wwfsHNza
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-project-manager@claude