fix(producer): classify the audio failure that takes the whole mix down

`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) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-08 12:16:07 -07:00
co-authored by Claude Opus 5
parent eb0080dec8
commit b1faacb3ad
2 changed files with 39 additions and 3 deletions
@@ -122,7 +122,27 @@ describe("runAudioStage", () => {
const result = await runAudioStage(makeInput()); const result = await runAudioStage(makeInput());
expect(result.hasAudio).toBe(false); expect(result.hasAudio).toBe(false);
expect(result.audioError).toMatch(/Audio FX failed for track bgm/); 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 () => { it("lets an abort keep its own shape rather than becoming an audio error", async () => {
@@ -84,12 +84,28 @@ export async function runAudioStage(input: AudioStageInput): Promise<AudioStageR
} catch (err) { } catch (err) {
// An abort is the caller's own signal and must keep its own shape. // An abort is the caller's own signal and must keep its own shape.
assertNotAborted(); assertNotAborted();
const detail = err instanceof Error ? err.message : String(err);
return { return {
audioOutputPath, audioOutputPath,
hasAudio: false, hasAudio: false,
audioProcessMs: Date.now() - stage3Start, audioProcessMs: Date.now() - stage3Start,
audioError: err instanceof Error ? err.message : String(err), audioError: detail,
audioFailures: undefined, // Synthesised rather than left undefined. The warning policy reads
// owner, retryability, reason and stage off this list, so reporting the
// FATAL failure — the one that took the whole mix down — with an empty
// one gave it strictly less classification than a single dropped track
// gets, which is the opposite of what this stage exists to do.
// "internal" is the honest bucket: the stages enumerate ffmpeg steps
// and this is the FX render, which is none of them.
audioFailures: [
{
stage: "internal",
reason: "internal",
owner: "system",
retryable: false,
detail: detail.slice(0, 2_000),
},
],
}; };
} }
assertNotAborted(); assertNotAborted();