The header's Export button started renders with no options at all, so the
request carried no `composition` and the server fell back to index.html.
Selecting a sub-composition in the Comps panel showed its canvas and timeline
but exported the root file instead.
Studio starts renders from three controls, and the render target was owned by
each of them separately: the Renders panel resolved it, the header omitted it,
the sidebar's per-composition button named one explicitly. Give it one owner
in `startRender`, which all three route through, defaulting to the active
composition and leaving an explicit argument to win.
Fixes#3549
The Comps panel sets activeCompositionPath to the selected file, but
useCompositionStack's effect only pushed a stack level when that path
started with compositions/. A project laying its comps out anywhere
else, for example a generated multi-part build with parts/part-1.html
next to the root index.html, matched no branch at all: the row
highlighted and the URL hash updated while the stack silently kept the
master mounted, so the canvas and timeline stayed on index.html and any
edit landed in the root file instead of the part.
Replaced the prefix test with a plain truthiness check, so the root
stays on the master level and every other path pushes its own level.
Label derivation is unchanged, matching CompositionsTab's own
convention.
## Summary
- The `useStudioAgentTools` test "registers nothing when the browser has no WebMCP" asserted `document.modelContext` was absent after mount
- Since #3514 added the `@mcp-b/global` polyfill, the hook installs `document.modelContext` even without native browser support — that's the polyfill's purpose
- Updated the test to verify that mounting doesn't throw (the real contract), without asserting polyfill absence
This fixes the `CI / Test` failure on `main`.
## Other main CI failures (not ours)
- **Docs / Validate docs** — `npm error notarget No matching version found for mint@4.2.837` (docs tool dependency)
- **Windows / Tests on windows-latest** — `ECONNREFUSED ::1:3000` (Windows network test infra)
- **regression** — cancelled, not failing
## Test plan
- [x] All 9 `useStudioAgentTools` tests pass with `NODE_ENV=development`
- [x] Pre-commit hooks: lint, format, fallow, typecheck all green
— Miga
🤖 Generated with [Claude Code](https://claude.com/claude-code)
The "registers nothing when the browser has no WebMCP" test asserted
that document.modelContext was absent after mount. Since #3514 added
the @mcp-b/global polyfill fallback, the hook now installs
document.modelContext even when the browser has no native support —
that is the polyfill's job. The real assertion is that mounting does
not throw, which still holds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The extraction failure policy defaulted to "off", silently swallowing
per-source errors. The plumbing to surface them (typed error, retryable
classification, caller throw) was fully built but gated behind an
env-var opt-in. Flip the default to "enforce" so extraction failures
fail the render instead of producing misleading coverage aborts.
Set HF_VIDEO_EXTRACTION_FAILURE_MODE=off to restore the old behavior.
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
Residual of #3340: runtime-assigned src is skipped by the static parse, so
the browser snapshot was still keying clips by author id. Colliding scenes
collapsed onto one window.
When a composition script throws during execution, the GSAP timeline
registration never arrives and pollSubCompositionTimelines times out.
Previously the render continued with a degenerate 2-frame output and
reported success — now it fails loudly.
Two changes:
1. Detect composition script runtime errors in the browser console
handler and feed them into scriptLoadFailures, triggering the
existing fail-fast path (same as script load 404s).
2. Make sub_timeline_script_failure a fatal warning in
applyRenderWarningPolicy, alongside audio_processing_failed.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(producer): assert render artifact duration and frame count before commit
Refuse to publish an artifact that is significantly shorter or has fewer frames
than the capture pipeline just reported. Adds a duration/frame-count gate on top
of the existing readable-non-empty check inside ArtifactTransaction.validate(),
keyed off the values the orchestrator already carries. Closes#3395.
* fix(producer): wire ffprobe frame count into the artifact duration probe
The frame-count gate added in #3395 accepts an expectedFrames value from
the orchestrator, but defaultArtifactDurationProbe was still returning
only durationSeconds - so the wire was half-built and the assertion
short-circuited on undefined for every real render. Forward meta.frames
from ffprobe so the field-packet case the issue names (container duration
correct, stream shorter) is actually caught by the frame-count check,
not just the duration one.
extractMediaMetadata now populates a new frames field from the video
stream's nb_frames tag, returning undefined when the demuxer did not
report one (fragmented MP4, malformed streams, muxes that require
-count_packets). Callers that gate on the count must treat undefined as
no answer; the assertion already does.
The previous CI run (#32589981916) cancelled shard-6 at the 1h job
timeout after bun install failed to extract the aws-cdk-lib tarball
mid-Docker-build - a cache flake, not a code regression. Pushing a
follow-up commit retriggers CI against the now-populated cache layer;
the regression should clear without further code changes.
---------
Co-authored-by: Santhi Prakash <b.santhiprakash@gmail.com>
When a color-graded element has a CSS entrance animation (e.g. opacity:
0→1), the first drawEntry() copies the animation's initial opacity "0"
to sourceOpacityForCanvas, then hideSourceElement() sets opacity:0
!important on the source. On subsequent frames the hiddenByColorGrading
guard correctly prevents reading back grading's own hide — but also
prevents updating the canvas opacity as the animation progresses,
freezing both source and canvas at opacity 0 for the entire render.
Fix: when the source is hidden by color grading, temporarily restore the
authored inline opacity before reading getComputedStyle, so the CSS
animation's current value shows through. The restore–read–rehide is
synchronous, so no repaint occurs between the style writes.
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
* feat(studio): fall back to a WebMCP polyfill where the browser has none
WebMCP is an Origin Trial. Chrome 149 and Edge 150 have it behind a flag,
ChatGPT Desktop ships it, and everything else does not. Without a fallback the
tools registered in the previous change are invisible on stable Chrome, which
is exactly where a bridge extension would connect from.
Adds `@mcp-b/global` (MIT) as a DYNAMIC import, so a browser with native
support never fetches it. Verified in the build output rather than asserted:
the bundle keeps a bare `import("@mcp-b/global")` instead of inlining it.
Chosen over the smaller `@mcp-b/webmcp-polyfill` because that one only defines
`document.modelContext`. `@mcp-b/global` also stands up the in-page MCP server
a bridge extension attaches to, and serving that case is the only reason the
fallback exists at all.
The load is guarded by a module-level promise so two mounts racing share one
load, and an import failure is caught and logged rather than thrown: a missing
agent surface must never stop Studio booting. The registration path re-checks
the abort signal after the await, so unmounting mid-import registers nothing.
Two things the type checker forced, both worth keeping:
Installing the package brings its own global `Document.modelContext`
declaration, which collided with the local one. Studio now reads the property
through a type guard instead of augmenting `Document`, so there is only one
declaration of that global and it is the package's.
Studio keeps its own narrow tool types rather than importing the package's.
Theirs overload `registerTool` to infer argument types from a literal
`inputSchema`, which helps when registering one tool inline and fights a
uniform registration loop. The comment in `types.ts` says so, and names the
drift risk that choice accepts.
The polyfill test asserts promise identity rather than counting imports. The
ESM registry dedupes the import either way, so a call count would pass whether
or not the guard existed.
* fix(studio): observe and retry WebMCP fallback
* feat(studio): expose Studio's live state to an agentic browser
Registers a `studio_look` tool on `document.modelContext`, so an agent in a
browser that supports it can read what Studio knows: the open project and
composition, the playhead, the human's current selection with its
capabilities, and the timeline's elements with a handle for each.
The API is `document.modelContext`, not `navigator.modelContext`. The latter
is a polyfill compatibility shim rather than a spec member, so feature
detecting it is wrong even where a published sample appears to work.
Three decisions worth knowing:
Registration happens ONCE per mount, with the dependencies held in a ref that
every render refreshes. Depending on the handlers instead re-runs on nearly
every interaction, because the DomEdit actions object changes identity with
the selection and the element list. Each re-run aborts the registration signal
and unregisters everything, and the spec warns that a quick unregister-then-
reregister can apply an old call's arguments against the new schema. The test
for this is the important one in the unit; breaking the empty dependency array
fails it and nothing else.
Tools resolve with a tagged result, they never reject. That is forced by the
spec: a rejected `execute` has its reason discarded and the caller sees a bare
UnknownError, so rejecting would guarantee the agent cannot learn why an edit
failed.
Elements are addressed by a minted handle, not by `TimelineElement.id`. That
id is a synthesised identity, so `getElementById` misses most elements; the
handle carries `data-hf-id`, else the DOM id, else a selector plus occurrence.
Mounted from `EditorShell` rather than `App`, because the DomEdit contexts are
only readable below `DomEditProvider` and `App.tsx` is three lines under the
600-line cap.
The undo signal is reported as the shell actually exposes it, `canUndo` and a
label, rather than as a revision counter. The depth lives in component-local
state and is not reachable without plumbing it through the shell context, so
the field says what it is instead of implying precision it does not have.
Writes are not in this change. `canWrite` is optimistic and the comment says
so; the write tools need a real guard against the paused-save and external-
conflict states, which are not on any context this component can reach yet.
* fix(studio): bound WebMCP look filters
* fix(studio): remove premature WebMCP write state
* docs(studio): name WebMCP singleton assumption
* fix(studio): surface WebMCP registration failures
* fix(studio): let a failed text or style commit report itself
`runDomEditCommit` catches a persist failure, reverts, fires `onError` and
then resolves. That contract is deliberate and its docstring says so: the
human path learns the write failed from the toast `onError` puts on screen,
so a rejection would be redundant. It also means a caller awaiting
`handleDomTextCommit` or `handleDomStyleCommit` cannot tell a landed write
from a reverted one, because both resolve with `undefined`.
The runner already offers `onSettled` as the way out. Text and style were
the two commits that never got it wired.
Add `runReportedDomEditCommit`, which owns `onSettled` (forwarding to a
caller-supplied one rather than dropping it) and returns whether the write
landed. Both handlers now return a tagged outcome, so the three preconditions
that previously returned early and silently are each distinguishable:
no selection, a manual-geometry property the style path refuses, and a
selection that cannot edit styles. Same for text: no selection versus not
text-editable.
Human-facing behaviour is unchanged and the tests assert that: the toast
still fires and the optimistic DOM change is still reverted.
The callback props that carry these handlers ignore the result, so their
declared type widens from `Promise<void>` to `Promise<unknown>`. That type is
hand-copied in fourteen places; consolidating it is worth its own change.
`useDomEditTextCommits.ts` is now 593 lines against the 600-line cap. The
next change to it needs a split.
* fix(studio): stop a paused save queue reporting a position edit as saved
Two more commits that could not tell a caller they had failed.
`useDomEditPositionPatchCommit` swallowed `DomEditSaveQueueOpenError` and
resolved. The intent was right, a paused save queue already puts a banner on
screen and one toast per blocked edit is noise, but swallowing it also
skipped the caller's revert: `useDomGeometryCommits` only restores the
optimistic offset, size or rotation from its `.catch`. So once the breaker
opened, a drag left the element where the user dropped it while nothing
reached the file, and the next reload snapped it back.
It now rejects without toasting. The banner still does the telling; the
caller gets to revert.
`handleDomEditElementsDelete` caught everything and only toasted, so an
unpatchable target and a completed delete were indistinguishable to a caller.
It now returns an outcome, with `no-project` and `no-selection` separated from
a failed write rather than all three sharing an early `return`.
Adds the first test for `useDomEditPositionPatchCommit`, covering the paused
queue, an ordinary failure, and success.
* fix(studio): honor DOM edit failure outcomes
* fix(studio): classify stale delete previews
* fix(studio): enforce DOM edit outcome types
* fix(media-use): repoint the dead videogen tier, demote past unusable models
`LOCAL_MODELS.videogen`'s `large` tier named `dgrauet/ltx-2.3-mlx-bf16`, which
returns HTTP 401 and cannot be downloaded at all. It was not a dormant entry:
`rankedByPreference` sorts by descending `needs.ramMB` when no `rank` is set,
so the largest fitting tier is tried FIRST by design. Any machine clearing
32 GB *available* RAM selected the dead entry, `ltxVideoGenerate` caught the
failure and returned a bare `null`, and since `ltx.local` is last in
`["heygen.video", "ltx.local"]` and network providers are skipped under
`--local-only` (`registry.mjs:206`), local video generation failed outright
instead of falling back to the tier that works.
It survived review because the table landed with "live verification on a 24GB
M-series Mac" - and a 24 GB machine cannot select a 32 GB tier, so that entry
was unreachable on the only machine that validated it. The unit fixtures
inherit the same ceiling (`fittingSpecs` is 20000MB), so every existing test
exercised the medium tier alone.
Two changes:
1. Repoint to `dgrauet/ltx-2.3-mlx-q8` (reachable) and correct `sizeMB` from
45000 to 28800. Measured against the HF API: the q8 repo totals 87.5 GB,
and the registry's own targeted `--include` subset is 28.76 GB. That
matches the sibling q4 entry's convention (`sizeMB: 20000` vs a measured
19.48 GB subset), so 45000 was wrong under either reading. `--low-ram` is
added because the entry's own note calls it required at this tier's 32 GB
floor, and the invoke omitted it.
2. A repoint alone is one bad URL from a repeat, so add the missing recovery.
`selectModelLadder` returns every fitting model best-first;
`selectModel`'s pick is now defined as that list's head. All three sites
that previously selected exactly one model and failed terminally walk the
ladder instead, demoting past a tier that cannot run here - gated weights,
runner off PATH, an OOM at a tier that nominally fits:
- `ltx-video-provider.mjs` (videogen, the reported failure)
- `mflux-provider.mjs` (imagegen - same shape, and its 32 GB/64 GB tiers
are equally unverifiable on a 24 GB machine)
- `local-run.mjs` (tts/asr/upscale - `fish-speech` missing should still
get you Kokoro)
Every demotion is logged rather than silent, so a quietly smaller model is
never mistaken for the tier the machine nominally qualified for.
Also fixes the `install` string both videogen entries share: it ended at
`uv sync --all-extras`, which leaves the entry point in `.venv/bin`, so the
"`ltx-2-mlx` not on PATH" hint named a command that following the instruction
would not put on PATH.
The q8 tier is NOT live-verified - no 32 GB+ Apple Silicon machine was
available - and its notes say so. Shipping it unverified is safe precisely
because of change 2: a wrong tier now costs one failed attempt, not the whole
local path.
- Rames Jusso
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(media-use): report the real videogen download size, disclose it, discard failed partials
Addresses review feedback on #3509 (CHANGES_REQUESTED at 90df164a), plus the
follow-on ask to tell the user what a download costs before they accept it.
1. `sizeMB` described a targeted `--include` subset that no run ever gets.
Both videogen invokes pass a repo id to `--model`, and upstream
`resolve_model_dir()` (`ltx_pipelines_mlx/utils/_orchestration.py:35-40`)
calls `snapshot_download(repo)` with no `allow_patterns`, so the full repo
lands regardless of what was pre-fetched. Corrected to measured repo
totals: q8 87500 (87,511,991,375 B) and q4 59700 (59,686,429,583 B). q4 was
wrong the same way at 20000, so both are fixed together rather than leaving
one convention on each side.
My earlier claim that 28800 "matches the sibling q4 entry's convention" was
wrong in the way that matters: the convention itself described a subset the
runner does not honor. The file's own comment already said "blind
snapshot-downloads the lot (60 GB q4, 88 GB q8)" three lines above the
fields that contradicted it, and the original report measured it too ("the
q4 cache ended at 56 GB and q8 at 82 GB"), which reconciles exactly once
read as GiB: 59.69 GB = 55.6 GiB, 87.51 GB = 81.5 GiB. So the download is
the complete repo both times, not a partial fetch.
Removed the `--include` recipe rather than repairing it: it is ineffective
(the runner refetches at generate time) and insufficient (`--two-stage` is
"dev model + CFG at half-res, upscale, distilled LoRA refine" per upstream's
own help text, so it needs transformer-dev AND transformer-distilled AND
spatial_upscaler_x2; `--distilled` needs an upscaler too). The q4 tier
verified on a 24 GB Mac only worked BECAUSE the download is unfiltered.
2. Nothing told the user what they were agreeing to before a tool started
pulling tens of GB. `describeDownload()` in `specs.mjs` names the size and
the directory the weights land in, and checks free space with `statfs`
against that directory rather than cwd, since the weights do not land in
cwd. A tier that will not fit is still offered, with a plain statement that
it will not fit: hiding it would make a machine that could free up space
look like it has no large tier. Unknown free space reports as unknown, not
as zero. Wired into both providers' install hints, the `runLocalModel`
install payload (now carrying `sizeMB`), and `describeModelLadder`.
3. Each retry attempt mints its own timestamped temp path, so a partial
artifact from a failed tier was orphaned rather than overwritten, and a
lower tier then succeeding hid it. Both providers discard the partial before
demoting, guarded so a file that cannot be removed never masks the generate
failure it came from. Video is the material case: a partial mp4 is large.
`local-run.mjs` is deliberately unchanged here. Its `out` is caller-provided
and identical across attempts, so a partial is overwritten rather than
orphaned, and unlinking a path the caller named would be a footgun. The rule
the two providers follow is: clean up what you allocate.
Tests: 553/553 across `skills/**/*.test.mjs` (+15). New coverage pins the
cleanup (failed tier's partial removed, returned artifact survives, one discard
per attempt on the all-fail path, an unremovable partial still surfaces the
real failure) and the disclosure (cache-dir precedence, statfs walk-up to the
deepest existing ancestor, unknown-vs-zero, and the will-not-fit wording).
Every new guard mutation-tested: removing any one of them turns tests red.
- Rames Jusso
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
`muxVideoWithAudio` passed `-avoid_negative_ts make_zero` unless the caller
set `preserveAudioPrimingEditList`. In practice the dominant path is an AAC
sidecar copied into mp4, where that flag is actively harmful: ffmpeg's
default is `auto`, which the mp4/mov muxers (AVFMT_TS_NEGATIVE) already
resolve to `disabled`. Forcing `make_zero` overrides the correct default,
discards the priming edit list the sidecar encode created, shifts the video
start_time forward by one AAC frame and writes an empty video edit at t=0 —
which edit-list-honoring players (QuickTime/Safari) render as a black first
frame.
Verified with ffprobe on a copy mux of a 30fps h264 mp4 and an AAC sidecar:
with `make_zero` video start_time 0.066000, elst: [media time -1,
dur 5940] + [media time 6000, dur 180000]
audio start_time 0.042993, elst: [media time -1, ...]
without (this fix) video start_time 0.000000, elst: [media time 6000,
dur 180000]
audio start_time 0.000000, elst: [media time 1024, ...]
The empty leading edit and the offset both disappear, and the audio keeps
its 1024-sample priming edit.
The flag is now never passed for a mux, in any mode. `preserveAudioPrimingEditList`
is part of the exported engine API, so it stays on `MuxVideoWithAudioOptions`
as `@deprecated` and no-op rather than being removed; the two internal callers
that set it (`assembleStage`, distributed `assemble`) drop it.
`buildEncoderArgs` and `streamingEncoder` still pass the flag for video-only
output and are deliberately left alone — those chunks are consumed as
intermediates, not as a delivered mp4/mov.
Fixes#3487
Co-authored-by: Alexandru Mincu <alex@mountsoftware.ro>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(cli): keep phrase-level CJK and Thai transcripts as separate cues
wordsToCues inferred whether entries were already grouped into phrases
by testing for internal whitespace. Chinese, Japanese, Thai and the
other scripts written without inter-word spaces never satisfy that
test, so their phrase-level transcripts were treated as word-level and
re-grouped into a single cue covering the whole clip.
A three-phrase Chinese transcript produced one cue; the same transcript
in English produced three. The failure was silent: the export
succeeded, and the user found out by watching the captions.
For entries with no whitespace at all, fall back to entry length when
they are in a spaceless script. Whisper emits word-level tokens for
those scripts one or two characters at a time, while a phrase-level cue
runs to several times that. The median is used so one long token cannot
declare word-level input pre-grouped, and a couple of short cues cannot
declare a real transcript word-level.
--preserve-cues still forces the same thing, and behaviour for
space-separated scripts is unchanged.
Fixes#3353
* test(cli): pin the spaceless phrase length threshold
Parent src-only scans skipped multi-format <video>/<audio> markup, so those
elements were never extracted, downloaded, or mixed and rendered blank/silent.
Lint now accepts a child <source src> as a resolvable media src.
lookupCacheEntry reported a hit purely on the presence of the
.hf-complete sentinel. The sentinel records that extraction finished,
not that the frames survived, so any per-file cleanup that empties the
directory leaves an entry that rehydrates with zero frames.
rehydrateCacheEntry then returns totalFrames: 0, the clip reaches the
coverage gate with nothing, and the render aborts with a message about
capture coverage. Because the poison is on disk rather than in the
composition, every later render of the project fails the same way with
nothing the user can change to fix it.
An entry now counts as a hit only when it carries the sentinel AND
still holds at least one frame file, so an emptied entry re-extracts.
The check is format-agnostic: a hit must be usable whatever extension
the frames carry.
Addresses the cache half of #3372.
compileTimingAttrs/injectDurations used parseFloat, so data-start="intro"
wrote a NaN data-end and extract preferred that over duration; parseNumeric
now skips the id-ref (parseVideoElements already resolves it).
collectRenderMedia's resolveHostWindow likewise read host data-start with
parseFloat, so chained sub-composition slots (data-start="hook") stacked at
0-2s and every scene after the first rendered black. It now resolves host
starts through the shared resolveReferencedStart, matching the media parsers.
Fixes#3361.
* fix(producer): fall back to screenshot capture on drawElement canvas-not-initialized
The fast-capture drawElement path only special-cased the "No cached
paint record" error to trigger a per-frame screenshot fallback; every
other error (including "drawElement canvas not initialized", seen at
frame 0 on some macOS/Chrome combinations) was rethrown, hard-failing
the whole render even though the docs promise automatic fallback on
incompatible compositions.
Extend the existing fallback branch (in both captureFrameCore and
captureFrameToBufferPipelined) to also catch canvas-not-initialized
errors via a shared isRecoverableDrawElementError predicate, with a
diagnostic message identifying which case triggered the fallback.
Closes#3423
Co-Authored-By: Miga <noreply@anthropic.com>
* fix(producer): address review — tighten error matching, audit batch path, add fallback-ratio guard
* fix(engine): add prepareFrameForCapture to batch screenshot fallback loop
* fix(engine): split canvas-not-initialized from composition-root-missing errors
drawElementService threw the same HF_DE_CANVAS_NOT_INITIALIZED error for
both !canvas and !root. Missing composition root (navigated/broken page)
was classified recoverable and fell back to pageScreenshotCapture, which
captured blank or wrong content silently.
Now:
- !root → HF_DE_COMPOSITION_ROOT_MISSING (not recoverable, hard fail)
- !canvas → HF_DE_CANVAS_NOT_INITIALIZED (recoverable, screenshot fallback)
Split applied at all 3 emit sites (serial, pipelined, batch).
Co-Authored-By: miga-heygen <miguel.sierra_miga@heygen.com>
---------
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
Co-authored-by: Miga <noreply@anthropic.com>
Root cause: the per-worker capture calls in captureFrameRange
(parallelCoordinator.ts) take no abort signal of their own, and only
checked `signal.aborted` BEFORE starting each frame — a no-op once a
worker is already awaiting an in-flight call. On WSL2, the native
drawElement/BeginFrame capture call can hang indefinitely at frame 0
with no error. The DE parallel-router's existing stall watchdog
(captureStreamingStage.ts) correctly fires `stallController.abort()`
after HF_DE_STALL_MS, but that abort had no way to reach a
worker already wedged inside a hung capture call — so
executeParallelCapture's Promise.all waited forever, the render hung
indefinitely, and the CLI's circuit breaker (which only runs after
executeRenderJob settles) never got a chance to trip.
Fix: race each per-frame capture call against the signal actually
firing (raceAgainstAbort), the same "can't cancel, only race" pattern
already used by the sequential capture path. Once the watchdog's abort
is observed, the wedged worker rejects, executeParallelCapture settles,
and the existing pinned-fallback retry / "reverted" outcome / circuit
breaker machinery (already correct) runs end to end.
Also widen the CLI breaker's trip condition from the literal string
"reverted" to "not a clean routed success", so any future non-success
outcome the observability layer records also latches the breaker
instead of silently falling through.
Closes#3441
Co-authored-by: Miga <noreply@anthropic.com>