Lifecycle owner
A single transition service decides completed, failed, or cancelled. Attempts and stage failures are events until recovery is exhausted.
AiM Tours · Engineering Execution Brief
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.
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.
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 seam | Observed contract | Risk | Required change |
|---|---|---|---|
tour_render_runs | queued | 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.ts | Scene 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.ts | Many 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.ts | Fresh 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 provider | Composite 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 provider | Create/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.ts | Joined 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 task | Application-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. |
A single transition service decides completed, failed, or cancelled. Attempts and stage failures are events until recovery is exhausted.
Provider-job checkpoints cover work that has been purchased but not imported. Same-run assets cover work already persisted.
A watchdog handles OOM, system failure, and max-duration gaps that cannot be trusted to call task hooks.
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
Deploy schema before code that reads or writes it. Keep all additions backward compatible during the rollout.
tour_render_runs| Column | Shape | Invariant | Rollout note |
|---|---|---|---|
completion_quality | nullable text | standard | repaired | degraded; meaningful only when completed. | Add nullable; code defaults older completed rows to standard. Backfill later, then add strict constraint. |
warnings | jsonb not null default '[]' | Array of stable warning records, not customer prose. | Add JSON-array check. API may omit when empty during compatibility window. |
failure_code | nullable text | Stable 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>;
};
tour_render_provider_jobsThis 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.
// 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 })
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.
{
"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.
Refactor the current composite render() boundary into submit/resume operations or expose an immediate on-submitted callback.
video_id between create and poll.The provider client already has the right physical operations; the missing piece is durable orchestration state.
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 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.
| Task | Initial machine | Retry policy | Terminal DB authority |
|---|---|---|---|
| Root coordinator | small-1x | Explicit 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 generation | medium-1x | Classified transient retries; OOM escalation to medium-2x or large-1x. | None. Append attempt/exhaustion event only. |
| Avatar generation | medium-1x | Resume saved HeyGen job; classified transient retry; OOM escalation if local import/normalize fails. | None. |
| Final assembly | large-1x | Reuse same-run joined/final assets; OOM escalation to large-2x. | None. |
| Failure finalizer | small | Idempotent retry until DB transition and email enqueue are confirmed. | Sole failed-state writer. |
| Watchdog / recovery coordinator | small | Scheduled; leases stale active runs; checks Trigger status before action. | May invoke the same finalizer after recovery exhaustion. |
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
Transport retry and semantic repair are different budgets. Keep each bounded and observable.
A one-second difference is a warning/investigation boundary, not an automatic failure boundary.
| Observed mismatch | Action | Quality | Fail? |
|---|---|---|---|
| Within frame/timestamp tolerance | Canonical trim/pad, re-probe. | repaired if output changed; otherwise standard. | No. |
| Visual or avatar tail shorter/longer by up to 1 second | Trim 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 honest | Apply bounded repair, record warning, deliver with retry option. | degraded. | No. |
| Narration materially cut, corrupt stream, missing scenes, impossible timeline, or validation still fails after repair | Preserve 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
| Fault injection | Expected recovery | Assertions |
|---|---|---|
| Malformed LLM JSON, then valid response | Caller-scoped retry succeeds. | One plan; no provider work before validation. |
| Valid JSON with unsupported duration | One semantic repair call. | Repaired plan validates or terminates pre-spend with stable code. |
| OpenRouter 429/5xx/timeout before submission accepted | Bounded classified retry. | Backoff honored; attempt events recorded; run remains active. |
| Crash after provider job ID persisted | Resume poll/download. | Provider create count remains one. |
| Crash after asset creation but before child return | Same-run asset lookup wins. | No provider call; no duplicate asset usage/result. |
| OOM during FFmpeg | Larger-machine retry. | Existing provider assets reused; final operation key stable. |
| 0.003-second join mismatch | Canonical repair/re-probe. | Completed repaired; warning contains measured values. |
| Avatar ends 0.8 seconds early | Trim/fade/freeze by role. | Voiceover intact; completed repaired or degraded. |
| Narration cut by 2 seconds | Reject as unusable. | Failed with safe code; purchased assets preserved; one email. |
| Provider credits exhausted | No retry. | Stable provider-credit code; customer action copy; no raw provider payload. |
Trigger crash without onFailure | Watchdog recovery. | Lease prevents concurrent recovery; no age-only false failure. |
| Concurrent finalizers | Single compare-and-set winner. | One terminal event, one credit transition, one email enqueue. |
Classifier, timeline policy, warning aggregation, customer copy mapping.
Unique races, compare-and-set terminalization, RLS, same-run vs cross-run lookup.
Retries, idempotency keys, provider checkpoints, email exactly once.
Frame/timestamp deltas, short presenter, long scenes, corrupt streams, re-probe.
TOURS_RESILIENCE_SHADOWTOURS_SAME_RUN_RECOVERYTOURS_PROVIDER_JOB_RESUMETOURS_OOM_ESCALATIONTOURS_MEDIA_SALVAGETOURS_RENDER_WATCHDOGFlags are capability gates, not one master switch. This allows provider resume to remain on if media salvage needs rollback.
| Capability | Enable only when | Rollback signal |
|---|---|---|
| Provider resume | Crash tests prove checkpoint load and one provider create. | Any duplicate-paid-call regression or stuck checkpoint growth. |
| OOM escalation | Same-run assets and provider resume are live and measured. | Duplicate spend, materially higher cost without recovery, or retry loops. |
| Media salvage | Fixture matrix passes and UI/API understand quality/warnings. | Playback regression, narration loss, or misleading success. |
| Watchdog terminalization | Shadow mode shows zero false terminal classifications. | Any active provider/Trigger run incorrectly finalized. |
Customer-facing messages should communicate outcome, responsibility, action, and money/credit implication—without raw stack traces, provider payloads, model responses, or internal machine details.
| Outcome | Customer message | Support context |
|---|---|---|
| Completed / repaired | Normal 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 failure | Specific 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.
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.
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.
catchError, conditional retry, and skip behavior.This document proposes contracts and rollout order. It does not represent code already deployed.