From b1faacb3ad3f1eed33b02ff5dd92bc70e681a3ec Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sat, 8 Aug 2026 12:16:07 -0700 Subject: [PATCH] fix(producer): classify the audio failure that takes the whole mix down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `runAudioStage` catches the rejection `processCompositionAudio` throws when an FX failure cannot be degraded past — and returned `audioFailures: undefined` with it. `applyDistributedAudioWarningPolicy` reads owner, retryability, reason and stage off that list, so the FATAL failure was reported with an empty reasons array, an empty stages array and no owner: strictly less classification than a single dropped track gets, which is the opposite of what the stage's own comment says it exists to do. A failure is now synthesised from the error. "internal" is the honest bucket — the stage names enumerate ffmpeg steps and this is the FX render, which is none of them — and system/non-retryable is right for a browser or chain that will not build. The detail is bounded at 2000 characters like the mixer's own. Falsified by restoring `audioFailures: undefined`: both assertions fail. Producer's suite has 50 pre-existing collect failures under vitest (files that import `bun:test`); passing goes 533 -> 534, unchanged otherwise. Co-Authored-By: Claude Opus 5 (1M context) --- .../services/render/stages/audioStage.test.ts | 22 ++++++++++++++++++- .../src/services/render/stages/audioStage.ts | 20 +++++++++++++++-- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/packages/producer/src/services/render/stages/audioStage.test.ts b/packages/producer/src/services/render/stages/audioStage.test.ts index 62dc0ba1b9..b0b145a5c6 100644 --- a/packages/producer/src/services/render/stages/audioStage.test.ts +++ b/packages/producer/src/services/render/stages/audioStage.test.ts @@ -122,7 +122,27 @@ describe("runAudioStage", () => { const result = await runAudioStage(makeInput()); expect(result.hasAudio).toBe(false); expect(result.audioError).toMatch(/Audio FX failed for track bgm/); - expect(result.audioFailures).toBeUndefined(); + // And it is classified. This used to come back undefined, so the warning + // policy — which reads owner, retryability, reason and stage off this list + // — described the FATAL failure with strictly less detail than a single + // dropped track gets. + expect(result.audioFailures).toEqual([ + { + stage: "internal", + reason: "internal", + owner: "system", + retryable: false, + detail: "Audio FX failed for track bgm: browser launch failed", + }, + ]); + }); + + it("bounds the synthesised failure's detail", async () => { + // `detail` is contractually bounded diagnostic text; an ffmpeg-flavoured + // message can run to tens of kilobytes. + processCompositionAudioMock.mockRejectedValue(new Error("x".repeat(5_000))); + const result = await runAudioStage(makeInput()); + expect(result.audioFailures?.[0]?.detail.length).toBe(2_000); }); it("lets an abort keep its own shape rather than becoming an audio error", async () => { diff --git a/packages/producer/src/services/render/stages/audioStage.ts b/packages/producer/src/services/render/stages/audioStage.ts index bac3d3f552..7dd17cd133 100644 --- a/packages/producer/src/services/render/stages/audioStage.ts +++ b/packages/producer/src/services/render/stages/audioStage.ts @@ -84,12 +84,28 @@ export async function runAudioStage(input: AudioStageInput): Promise