AiM Tours · Engineering Execution Brief

Listing Video Resilience: Code & Contract Rollout

A dependency-ordered plan to recover expensive renders without duplicate provider spend, classify failures without false positives, salvage usable media, and notify customers exactly once when recovery is genuinely exhausted.

Status: Proposed implementation sequenceUpdated: July 15, 2026Scope: Contracts, persistence, Trigger.dev, providers, FFmpeg, email & UI

Implementation decision

Build recovery memory before enabling recovery behavior.

The first production change should not be “retry more.” The system must first remember purchased provider jobs and same-run assets, separate attempt failures from terminal run failure, and make terminalization idempotent. Only then is an OOM retry on a larger machine safe.

What the code says today

This plan is grounded in the current repository contracts and the previous production failure analysis. It deliberately keeps the existing lifecycle values because completion currently controls credit settlement, result ownership, download, UI readiness, and email delivery.

Current seamObserved contractRiskRequired change
tour_render_runsqueued | running | completed | failed | cancelled; result asset belongs to completed; errors belong to failed/cancelled.A new “degraded” lifecycle value would break existing consumers and credit invariants.Add nullable completion quality, warnings, and normalized failure code. Do not change lifecycle status.
triggers/render-tour-project.tsScene children mark the whole run failed and rethrow. Root can catch and return { ok: false }.An attempt can terminalize too early, while Trigger.dev can record the root as completed.Children only report attempts. One idempotent service owns final state. Root throws on unrecovered failure.
generate-tour-project-video.tsMany branches directly call markShellFailed().Failure policy is spread through business stages and cannot distinguish retryable from terminal.Return/throw typed stage failures and centralize disposition and terminalization.
tours-api-client.tsFresh render disables every existing-asset reuse option.Correct fresh behavior also prevents same-run recovery after a crash.Separate cross-run reuse from mandatory same-run recovery reuse.
OpenRouter scene providerComposite render call returns only after provider completion.OOM or process death after provider acceptance can lose the job identity before an asset exists.Persist provider job ID immediately after submission, then poll/resume by checkpoint.
HeyGen avatar providerCreate/poll/download already exist as separate calls, but the job ID is not persisted between them.The code can resume in principle, but process loss still causes a second create.Add a job-submitted checkpoint before polling.
final-render.tsJoined duration is probed; final mux uses -shortest; file is over 1,000 lines.A usable video can fail on a small mismatch, while -shortest can hide a material mismatch.Extract a probed timeline policy and repair module rather than growing this file.
Failure email taskApplication-level terminal state drives notification.Trigger lifecycle hooks do not cover every crash/system termination.Send from idempotent terminalization; add watchdog reconciliation for hook gaps.
Production evidence: the analyzed final-video failure occurred after scene generation, at the final join duration check. A separate production-shaped reproduction produced only a 0.002667-second output delta. This supports deterministic trim/re-probe and a warning threshold, not blind full-render retry.
Important correction about asset reuse: already-generated assets can and should be reused. The missing contract is not whether reuse is possible; it is a durable, same-run recovery lookup that remains enabled even when the customer intentionally requested a fresh render with cross-run reuse disabled.

Target architecture

ValidateRepair plan before paid provider work.
CheckpointPersist operation and provider job identity.
ExecuteRun idempotent child operation.
PersistSave same-run asset and usage.
RepairNormalize media and record warnings.
FinalizeComplete or fail exactly once; notify once.

Lifecycle owner

A single transition service decides completed, failed, or cancelled. Attempts and stage failures are events until recovery is exhausted.

Recovery memory

Provider-job checkpoints cover work that has been purchased but not imported. Same-run assets cover work already persisted.

Reconciler

A watchdog handles OOM, system failure, and max-duration gaps that cannot be trusted to call task hooks.

State ownership rule

attempt failure  -> append event + classifier disposition
retry scheduled  -> run remains running
asset recovered  -> continue pipeline with same run
usable result    -> completed + quality + warnings
recovery exhausted and unusable -> failed + failure_code + one email

Additive persistence and API contracts

Deploy schema before code that reads or writes it. Keep all additions backward compatible during the rollout.

1. Extend tour_render_runs

ColumnShapeInvariantRollout note
completion_qualitynullable textstandard | repaired | degraded; meaningful only when completed.Add nullable; code defaults older completed rows to standard. Backfill later, then add strict constraint.
warningsjsonb not null default '[]'Array of stable warning records, not customer prose.Add JSON-array check. API may omit when empty during compatibility window.
failure_codenullable textStable internal/public-safe code for failed runs.Keep error_message as safe customer summary. Never expose raw provider/FFmpeg text.
type TourRenderCompletionQuality = "standard" | "repaired" | "degraded";

type TourRenderWarning = {
  code: TourRenderWarningCode;
  stage: TourRenderStage;
  severity: "info" | "warning";
  customerVisible: boolean;
  details: Record<string, string | number | boolean | null>;
};

2. Add tour_render_provider_jobs

This is the durable boundary between “the provider may have accepted money/work” and “AiM has imported an asset.” It should be service-role only: provider job IDs and diagnostics are operational data, not a browser contract.

tour_render_provider_jobs
  id, run_id, project_id, scene_id?
  stage, provider, operation_key
  request_fingerprint_hash
  provider_job_id, polling_url?
  status: submitted | in_progress | completed | imported | failed
  result_asset_id?
  provider_metadata jsonb
  last_polled_at, created_at, updated_at

unique (run_id, operation_key)
unique (provider, provider_job_id)

Security: enable RLS and create no user-facing policy. Use the service role through repository methods. Do not persist signed output URLs longer than necessary; refresh them from provider status when possible.

3. Separate fresh generation from recovery reuse

// Always checked first for the current run, regardless of fresh-render options.
findRunRecoveryAsset({ runId, projectId, kind, sceneId, fingerprintHash })

// Checked only when the customer's cross-run reuse option allows it.
findReusableAsset({ projectId, kind, fingerprintHash })
Contract: “Fresh render” means do not borrow outputs from an earlier run. It does not mean forget outputs already generated by this run after a process retry.

4. Normalize failures before customer mapping

type RenderFailureDisposition =
  | { kind: "retry_same_machine"; retryAt?: Date }
  | { kind: "retry_larger_machine" }
  | { kind: "repair"; strategy: RenderRepairStrategy }
  | { kind: "customer_action"; code: TourRenderFailureCode }
  | { kind: "terminal"; code: TourRenderFailureCode };

classifyTourRenderFailure({ error, stage, attempt, checkpoint }): RenderFailureDisposition

The same low-level code may have different dispositions by stage and checkpoint. A timeout before a provider accepted a job is retryable; an ambiguous timeout immediately after create requires reconciliation before another submission.

5. Backward-compatible response

{
  "status": "completed",
  "result": { "assetId": "...", "quality": "degraded", "warnings": [...] },
  "error": null
}

{
  "status": "failed",
  "result": null,
  "error": { "code": "PROVIDER_CREDITS_EXHAUSTED", "message": "..." }
}

Older clients continue to work from lifecycle status and result asset. New clients can surface quality and safe recovery guidance.

Provider checkpoint and resume contracts

OpenRouter scene video
  1. Build and fingerprint request.
  2. Look up same-run asset.
  3. Look up provider checkpoint.
  4. If absent, submit once and immediately persist job ID.
  5. Poll existing job, download, normalize, upload, create asset.
  6. Mark checkpoint imported with asset ID.

Refactor the current composite render() boundary into submit/resume operations or expose an immediate on-submitted callback.

HeyGen avatar / narration
  1. Reuse existing split create/status/download methods.
  2. Persist video_id between create and poll.
  3. On retry, fetch status by saved ID instead of creating again.
  4. Refresh expiring output URL from status, then import asset.

The provider client already has the right physical operations; the missing piece is durable orchestration state.

Ambiguous create response: if the connection drops after a provider accepted a POST but before AiM receives the ID, a retry can still duplicate work unless the provider supports request idempotency. No create-request idempotency guarantee was verified for these calls. Treat this as a known residual risk: reconcile using provider history where possible, otherwise delay and alert rather than immediately resubmit.

Operation key examples

scene-video:{runId}:{sceneId}:{fingerprintHash}
avatar-video:{runId}:{avatarFingerprintHash}
voiceover:{runId}:{voiceFingerprintHash}
joined-scenes:{runId}:{joinedFingerprintHash}
final-video:{runId}:{finalFingerprintHash}

Provider checkpoint writes and asset records must be idempotent under these stable operation identities. A unique-constraint collision should load the winner, not fail the render.

Trigger.dev retry and terminal ownership

Trigger.dev supports task-level idempotency and caching successful child results across parent retries. It also detects OOM from Node, the overall process, and child processes such as FFmpeg, and can retry on a larger machine. Those capabilities become safe after the persistence work above.

TaskInitial machineRetry policyTerminal DB authority
Root coordinatorsmall-1xExplicit maxAttempts: 1. Do not replay the whole workflow by default.Calls centralized terminalization only after child recovery is exhausted; throws so Trigger and DB agree.
Scene generationmedium-1xClassified transient retries; OOM escalation to medium-2x or large-1x.None. Append attempt/exhaustion event only.
Avatar generationmedium-1xResume saved HeyGen job; classified transient retry; OOM escalation if local import/normalize fails.None.
Final assemblylarge-1xReuse same-run joined/final assets; OOM escalation to large-2x.None.
Failure finalizersmallIdempotent retry until DB transition and email enqueue are confirmed.Sole failed-state writer.
Watchdog / recovery coordinatorsmallScheduled; leases stale active runs; checks Trigger status before action.May invoke the same finalizer after recovery exhaustion.

Why a watchdog is mandatory

Trigger.dev documents that onFailure fires only after normal retries are exhausted and does not fire for crashed, system-failure, or cancelled statuses. It also documents that max-duration termination skips cleanup, onSuccess, and onFailure. Therefore email and terminal state cannot depend exclusively on task hooks.

watchdog every 5 minutes
  query queued/running runs older than stage-specific threshold
  acquire advisory/row lease
  inspect Trigger root status and provider checkpoint
  active/waiting          -> leave alone
  Trigger complete        -> reconcile persisted final asset and mark completed
  crashed/system/timeout  -> trigger idempotent recovery coordinator
  recovery exhausted      -> finalize failure + enqueue one email
  unknown                 -> event + alert; do not guess terminal state
False-failure guard: elapsed time alone never marks a run failed. Provider polling and Trigger waits can be quiet for a long time. The watchdog must combine age with Trigger status, durable provider-job state, and a lease.

Planner validation and media salvage

Before provider spend: strict

  1. Parse raw LLM response with the existing malformed-JSON retry.
  2. Validate IDs, schema, word/read time, requested duration, transition handles, and provider maximums before silently clamping.
  3. Give the model one constrained repair request containing only the validation issues and immutable project facts.
  4. Validate again. If still invalid, fail before TTS/video provider work.

Transport retry and semantic repair are different budgets. Keep each bounded and observable.

After provider spend: flexible

  1. Probe narration, joined scenes, presenter/avatar, and final output.
  2. Choose a canonical timeline—normally narration when voiceover exists.
  3. Trim or extend visual tails deterministically; fade where it avoids an abrupt ending.
  4. Re-probe once and classify the result as standard, repaired, degraded, or unusable.

A one-second difference is a warning/investigation boundary, not an automatic failure boundary.

Proposed timeline policy

Observed mismatchActionQualityFail?
Within frame/timestamp toleranceCanonical trim/pad, re-probe.repaired if output changed; otherwise standard.No.
Visual or avatar tail shorter/longer by up to 1 secondTrim to canonical end, freeze/extend background, or fade presenter based on stream role.repaired or degraded.No, if playable and narration is intact.
More than 1 second but deterministic salvage remains honestApply bounded repair, record warning, deliver with retry option.degraded.No.
Narration materially cut, corrupt stream, missing scenes, impossible timeline, or validation still fails after repairPreserve assets and stop.Unusable.Yes.

Extract this work into focused modules such as final-render/timeline-policy.ts, final-render/timeline-repair.ts, and their tests. Do not add more responsibility to the current 1,100-line final-render.ts.

Dependency-ordered implementation slices

Each slice is deployable and reversible. The ordering is a safety contract: later behavior must not be enabled before its prerequisite persistence and observability exist.

  1. Contract foundation and shadow classification

    Add warning/failure/quality TypeScript types under lib/tours/rendering/resilience/. Implement a pure classifier and structured events in shadow mode; do not alter retry or terminal behavior.

    Exit Known catalog errors classify deterministically in unit tests; unknown errors remain unknown/internal, never guessed as customer-caused.

  2. Additive database and repository contracts

    Add run quality/warnings/failure code plus tour_render_provider_jobs. Update repository selects/mappers/types and response contracts with safe defaults. Add SQL constraint/RLS tests and mapper tests.

    Exit Old and new rows both read successfully; no UI behavior changes; provider job table is inaccessible to user sessions.

  3. Centralize terminalization and align Trigger status

    Create finalizeTourRenderFailure() with compare-and-set semantics and an email idempotency key. Remove terminal writes from scene/avatar/final children. Make the root explicit maxAttempts: 1 and throw after genuine terminal failure.

    Exit Attempt failures leave the run active; one terminal transition and one failure email occur under concurrent calls; Trigger and DB terminal states agree for ordinary failures.

  4. Same-run asset recovery and child idempotency

    Add findRunRecoveryAsset() and call it before every paid or expensive stage. Add stable Trigger child idempotency keys to scene, avatar, joined-scenes, final-video, and email work. Keep existing cross-run fresh-render behavior unchanged.

    Exit Retrying a run reuses its own completed scene/assets even when reuseExistingAssets:false; a previous run is never borrowed in fresh mode.

  5. Provider job checkpoint and resume

    Refactor OpenRouter scenes to submit/checkpoint/poll/import. Add immediate HeyGen checkpoint callbacks and resume paths for avatar and combined narration. Treat unique-key races as load-existing.

    Exit Injected crash after provider submission resumes by provider job ID without a second create call; injected crash after asset import reuses the same-run asset.

  6. Enable classified retries and OOM escalation

    Use task catchError for skip/retry decisions and retry.outOfMemory.machine for scene, avatar, and final assembly. Limit retries by operation and provider; do not retry permanent credit/auth/unsupported-input failures.

    Exit OOM fault injection retries on the intended larger machine and proves zero duplicate provider creates. Permanent failures skip retry immediately.

  7. Plan repair and media salvage

    Add pre-spend semantic validation plus one bounded LLM repair. Extract FFmpeg timeline policy/repair, return structured warnings, and complete usable repaired/degraded results.

    Exit Unsupported plans never reach providers; small and moderate media mismatches yield validated outputs; unusable outputs still fail honestly.

  8. Watchdog, customer surfaces, and production canary

    Add scheduled reconciliation with leases and Trigger status checks. Add failure email code-to-copy mapping, degraded Listing-page notice, support context, and metrics. Roll out provider resume, retries, and salvage behind independent flags.

    Exit Crashed/system/max-duration runs are reconciled; no duplicate emails; canary metrics meet the gates below before full enablement.

Verification and failure-injection matrix

Fault injectionExpected recoveryAssertions
Malformed LLM JSON, then valid responseCaller-scoped retry succeeds.One plan; no provider work before validation.
Valid JSON with unsupported durationOne semantic repair call.Repaired plan validates or terminates pre-spend with stable code.
OpenRouter 429/5xx/timeout before submission acceptedBounded classified retry.Backoff honored; attempt events recorded; run remains active.
Crash after provider job ID persistedResume poll/download.Provider create count remains one.
Crash after asset creation but before child returnSame-run asset lookup wins.No provider call; no duplicate asset usage/result.
OOM during FFmpegLarger-machine retry.Existing provider assets reused; final operation key stable.
0.003-second join mismatchCanonical repair/re-probe.Completed repaired; warning contains measured values.
Avatar ends 0.8 seconds earlyTrim/fade/freeze by role.Voiceover intact; completed repaired or degraded.
Narration cut by 2 secondsReject as unusable.Failed with safe code; purchased assets preserved; one email.
Provider credits exhaustedNo retry.Stable provider-credit code; customer action copy; no raw provider payload.
Trigger crash without onFailureWatchdog recovery.Lease prevents concurrent recovery; no age-only false failure.
Concurrent finalizersSingle compare-and-set winner.One terminal event, one credit transition, one email enqueue.

Test layers

Pure unit

Classifier, timeline policy, warning aggregation, customer copy mapping.

Repository

Unique races, compare-and-set terminalization, RLS, same-run vs cross-run lookup.

Task integration

Retries, idempotency keys, provider checkpoints, email exactly once.

Media fixtures

Frame/timestamp deltas, short presenter, long scenes, corrupt streams, re-probe.

Rollout controls and production gates

Independent flags

  • TOURS_RESILIENCE_SHADOW
  • TOURS_SAME_RUN_RECOVERY
  • TOURS_PROVIDER_JOB_RESUME
  • TOURS_OOM_ESCALATION
  • TOURS_MEDIA_SALVAGE
  • TOURS_RENDER_WATCHDOG

Flags are capability gates, not one master switch. This allows provider resume to remain on if media salvage needs rollback.

Metrics

  • Terminal failure rate by normalized code and stage
  • Recovery rate after provider spend
  • Provider jobs created vs resumed
  • Duplicate provider submissions detected
  • OOM retry success by machine transition
  • Standard/repaired/degraded completion mix
  • Failure-email precision and dedupe count
  • Watchdog recovered / finalized / unknown

Promotion gates

CapabilityEnable only whenRollback signal
Provider resumeCrash tests prove checkpoint load and one provider create.Any duplicate-paid-call regression or stuck checkpoint growth.
OOM escalationSame-run assets and provider resume are live and measured.Duplicate spend, materially higher cost without recovery, or retry loops.
Media salvageFixture matrix passes and UI/API understand quality/warnings.Playback regression, narration loss, or misleading success.
Watchdog terminalizationShadow mode shows zero false terminal classifications.Any active provider/Trigger run incorrectly finalized.
Non-negotiable sequencing: never enable retries before idempotency/checkpoints; never enable degraded completion before API/UI/download consumers accept it; never let the watchdog finalize based only on elapsed time.

Customer and support contract

Customer-facing messages should communicate outcome, responsibility, action, and money/credit implication—without raw stack traces, provider payloads, model responses, or internal machine details.

OutcomeCustomer messageSupport context
Completed / repairedNormal ready message.Warnings and repair actions remain available on the run.
Completed / degraded“Your video is ready. We adjusted part of the ending so you could still use it.” Offer Try again.Measured mismatch, repair strategy, affected stage/assets.
Customer-resolvable failureSpecific safe reason: provider credits, missing access/input, or unsupported configuration. Link to Listing scene.Provider, normalized code, attempt history, checkpoint and spend state.
Internal/transient exhausted“We couldn’t finish this render. Try again or contact support.” Link to Listing scene.Raw cause, Trigger run, stage, asset/job checkpoints, classifier evidence.

Email dedupe should be a durable operation key such as render-failed-email:{runId}. If email delivery itself fails, the render remains terminal and the notification task retries independently.

Decisions to lock before coding

Recommended defaults
  • Keep lifecycle status unchanged; add completion quality.
  • Use narration as canonical duration when narration exists.
  • Treat one second as a warning/investigation threshold, not a hard failure.
  • Allow degraded delivery only when video is playable, promised content is present, and narration is not materially cut.
  • Give the planner one semantic repair attempt after schema parsing.
  • Run the watchdog in shadow mode before it can recover or terminalize.
  • Make provider-job records service-role only.
Residual risk that needs product acceptance

A network break after a provider accepts a create request but before AiM receives the job ID is not fully solvable without provider-side create idempotency or a reliable job-history reconciliation API. The safe default is not immediate resubmission: wait, inspect/reconcile, alert if ambiguous, then make a bounded decision. This is rarer and more honest than pretending exactly-once submission can be guaranteed locally.

Suggested issue boundary

Implement the eight rollout slices as separate, dependency-linked local issues. Do not combine migration, provider refactor, OOM behavior, media policy, watchdog, and customer UI into one branch. Each slice has an observable contract and rollback boundary.

Research sources and related documents

This document proposes contracts and rollout order. It does not represent code already deployed.