From 4d06c93ba939d27093faf0139ea9ed81f95e2489 Mon Sep 17 00:00:00 2001 From: x Date: Thu, 6 Aug 2026 20:04:58 +0800 Subject: [PATCH] =?UTF-8?q?AIT-563:=20LandingToolPanel=20busy-leave=20?= =?UTF-8?q?=E2=80=94=20non-destructive=20keep-generating=20exit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-AIT-554 residual: Landing mid-generate only exposed abort. Wire planGenerateWaitLeave("detach") + shouldShowGenerateWaitDetach parity with Create/Batch/Image. Unmount no longer auto-aborts; detach settle still pushHistory without inventing fail/refund UI. AIT-545 Retry gate retained. Source-lock in generate-wait-honesty-regression. Co-authored-by: multica-agent --- components/LandingToolPanel.tsx | 146 +++++++++++++++++-- scripts/generate-wait-honesty-regression.mjs | 88 ++++++++++- 2 files changed, 221 insertions(+), 13 deletions(-) diff --git a/components/LandingToolPanel.tsx b/components/LandingToolPanel.tsx index 813b5351..926598be 100644 --- a/components/LandingToolPanel.tsx +++ b/components/LandingToolPanel.tsx @@ -1,12 +1,17 @@ "use client"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { useCallback, useEffect, useRef, useState } from "react"; import { historyFieldsFromSuccess, postGenerateWithRetry, } from "@/lib/generateClient"; -import { canRetryGenerateFailure } from "@/lib/generateRecoveryPolicy"; +import { + canRetryGenerateFailure, + planGenerateWaitLeave, + shouldShowGenerateWaitDetach, +} from "@/lib/generateRecoveryPolicy"; import { downloadVideoFile, pushHistory } from "@/lib/history"; import { canLiveGenerate, @@ -117,8 +122,22 @@ export function LandingToolPanel({ const [privateResult, setPrivateResult] = useState(false); /** Device-local bible SKU — carry into AfterPath Next SKU / Seller Pack hops. */ const [toySku, setToySku] = useState(""); + /** + * Durable recovery UI — checking/waiting unlocks non-destructive leave + * immediately (Create/Batch parity). Never invent a second generate. + */ + const [recoveringSavedResult, setRecoveringSavedResult] = useState(false); + const [awaitingPrimaryAfterRecovery, setAwaitingPrimaryAfterRecovery] = + useState(false); const generateAbortRef = useRef(null); + /** + * When true, UI stopped waiting but the original /api/generate must keep + * running (no abort, no ledger cancel invent, no refund restore invent). + */ + const detachedWaitRef = useRef(false); + const landingMountedRef = useRef(true); const resultVideoRef = useRef(null); + const router = useRouter(); const toast = useToast(); const downloadAllowed = canDownloadResult({ demo, @@ -210,17 +229,27 @@ export function LandingToolPanel({ }, []); useEffect(() => { + landingMountedRef.current = true; return () => { - generateAbortRef.current?.abort(); + // Non-destructive leave: drop UI ownership only. Explicit Cancel is the + // sole path that aborts primary + best-effort cancels the ledger. + landingMountedRef.current = false; generateAbortRef.current = null; }; }, []); function cancelInFlightGenerate() { + const plan = planGenerateWaitLeave("cancel"); + if (!plan.abortPrimary) return; const ctrl = generateAbortRef.current; if (!ctrl) return; + // Explicit cancel only — abort signal triggers ledger cancel in generateClient. + // Detach (leaveWaitingKeepBackground) never calls abort(). ctrl.abort(); generateAbortRef.current = null; + detachedWaitRef.current = false; + setRecoveringSavedResult(false); + setAwaitingPrimaryAfterRecovery(false); // Immediate settlement honesty until the aborted POST resolves. setFailCreditState("refund unconfirmed"); toast( @@ -228,6 +257,31 @@ export function LandingToolPanel({ ); } + /** + * Stop waiting on Landing without aborting the original generate POST or + * inventing a refund restore. User can open Library while the same private + * task finishes. + */ + function leaveWaitingKeepBackground() { + const plan = planGenerateWaitLeave("detach"); + if (plan.abortPrimary || plan.cancelLedger || plan.startNewGenerate) { + // Defensive: detach plan must never harm the in-flight job. + return; + } + // Drop the AbortController ref without abort() so cleanup cannot cancel. + generateAbortRef.current = null; + detachedWaitRef.current = true; + setRecoveringSavedResult(false); + setAwaitingPrimaryAfterRecovery(false); + setElapsed(0); + setStatus("idle"); + toast( + "Still generating in the background · open Library when ready — no cancel sent" + ); + // Soft client navigation keeps the original fetch alive in this document. + router.push("/library"); + } + function replayResultVideo() { document .getElementById("landing-result") @@ -425,6 +479,9 @@ export function LandingToolPanel({ setError(null); setFailRetryAfterSec(null); setFailCreditState(null); + setLastFailCode(null); + setLastFailFatal(false); + setLastFailPaywall(false); setVideoUrl(null); setRequestId(null); setPrivateResult(false); @@ -432,7 +489,11 @@ export function LandingToolPanel({ setResultSettlement(null); setServerEcho(false); setElapsed(0); + setRecoveringSavedResult(false); + setAwaitingPrimaryAfterRecovery(false); + detachedWaitRef.current = false; setStatus("generating"); + // Abort any prior in-flight POST before starting a new one (explicit replace). generateAbortRef.current?.abort(); const abortCtrl = new AbortController(); generateAbortRef.current = abortCtrl; @@ -455,11 +516,42 @@ export function LandingToolPanel({ maxRetries: 1, fallbackImage: useAsset && image ? image : undefined, signal: abortCtrl.signal, + onRecoveryState: (state) => { + if (detachedWaitRef.current || !landingMountedRef.current) return; + setRecoveringSavedResult( + state === "checking" || state === "waiting" + ); + setAwaitingPrimaryAfterRecovery(state === "awaiting_primary"); + }, } ); if (generateAbortRef.current === abortCtrl) { generateAbortRef.current = null; } + + // Detached wait / unmounted Landing: never abort, cancel, or setState for + // fail/refund invent. On success still persist to device Library. + if (detachedWaitRef.current || !landingMountedRef.current) { + if (result.ok) { + const data = result.data; + pushHistory( + historyFieldsFromSuccess(data, { + effect: effectSlug, + effectName, + fallbackDuration: duration, + fallbackAspect: aspectRatio, + fallbackResolution: resolution, + sku: toySku || undefined, + }) + ); + } + detachedWaitRef.current = false; + return; + } + + setRecoveringSavedResult(false); + setAwaitingPrimaryAfterRecovery(false); + // Dead asset after TTL/process restart — clear and re-register for next try. if ( (!result.ok && result.code === "ASSET_NOT_FOUND") || @@ -565,6 +657,16 @@ export function LandingToolPanel({ : status === "done" ? 100 : 0; + /** + * Live recovery / long-wait unlocks non-destructive leave (Create/Batch + * parity). Demo Lab never detaches to private Library. + */ + const showLandingDetach = shouldShowGenerateWaitDetach({ + demoMode, + elapsedSec: elapsed, + recoveryChecking: recoveringSavedResult, + awaitingPrimary: awaitingPrimaryAfterRecovery, + }); // Prefer samples tagged for this effect, else all const samples = [ @@ -790,14 +892,35 @@ export function LandingToolPanel({ {busy ? ( - +
+ + {showLandingDetach ? ( + + ) : null} +

+ {showLandingDetach + ? "Leave without cancel — original job may finish server-side. No refund invent; check balance before a new attempt." + : "Generating… Cancel aborts this tab wait. Live debit may still settle — check balance before retry."} +

+
) : trialDone && isFree && freeLiveOpen ? (

@@ -907,6 +1030,9 @@ export function LandingToolPanel({ image={image} effectLabel={effectName} onCancel={cancelInFlightGenerate} + onLeaveToLibrary={leaveWaitingKeepBackground} + recoveryChecking={recoveringSavedResult} + awaitingPrimary={awaitingPrimaryAfterRecovery} compact className="min-h-[220px]" /> diff --git a/scripts/generate-wait-honesty-regression.mjs b/scripts/generate-wait-honesty-regression.mjs index 95e87154..69c33c20 100644 --- a/scripts/generate-wait-honesty-regression.mjs +++ b/scripts/generate-wait-honesty-regression.mjs @@ -1,6 +1,7 @@ /** - * AIT-533 / AIT-545 / AIT-554 — Studio generate-wait recovery fail-closed - * residual (recovery exit + server-gated Retry + Batch/Image busy-leave). + * AIT-533 / AIT-545 / AIT-554 / AIT-563 — Studio generate-wait recovery + * fail-closed residual (recovery exit + server-gated Retry + Batch/Image/ + * Create form/Landing busy-leave). * * Source contract (no network, no provider): * 1. Recovery checking/waiting always unlocks a non-destructive detach exit @@ -12,6 +13,7 @@ * 7. Image Studio Fail panel uses the same pure canRetryGenerateFailure gate * 8. CreateStudio form-side busy-leave (AIT-571) next to Cancel * 9. Image Studio mid-still busy-leave: pure detach (no abort / no refund invent) + * 10. LandingToolPanel mid-generate busy-leave: pure detach (no abort / no refund invent) * * Run: npm run generate-wait-honesty-regression */ @@ -590,6 +592,86 @@ const read = (rel) => readFileSync(join(root, rel), "utf8"); } +// ─── 11. AIT-563 LandingToolPanel busy-leave — non-destructive keep-generating ─ + +{ + const landing = read("components/LandingToolPanel.tsx"); + assert.match(landing, /leaveWaitingKeepBackground/); + assert.match(landing, /planGenerateWaitLeave\("detach"\)/); + assert.match(landing, /planGenerateWaitLeave\("cancel"\)/); + assert.match(landing, /shouldShowGenerateWaitDetach/); + assert.match(landing, /detachedWaitRef/); + assert.match(landing, /landingMountedRef/); + assert.match(landing, /data-generate-leave="detach"/); + assert.match(landing, /data-generate-leave="cancel"/); + assert.match(landing, /data-landing-leave="detach"/); + assert.match(landing, /Open Library · keep generating/); + assert.match(landing, /router\.push\(["']\/library["']\)/); + assert.match(landing, /recoveringSavedResult/); + assert.match(landing, /awaitingPrimaryAfterRecovery/); + assert.match(landing, /onRecoveryState/); + assert.match( + landing, + /GenerateWaitStage[\s\S]{0,600}onLeaveToLibrary=\{leaveWaitingKeepBackground\}/ + ); + assert.match( + landing, + /GenerateWaitStage[\s\S]{0,800}recoveryChecking=\{recoveringSavedResult\}/ + ); + // Detach function never aborts primary, cancels ledger, or invents restore. + { + const leaveFn = landing.match( + /function leaveWaitingKeepBackground\(\) \{[\s\S]*?\n \}/ + )?.[0]; + assert.ok( + leaveFn, + "leaveWaitingKeepBackground must exist on LandingToolPanel" + ); + assert.match(leaveFn, /planGenerateWaitLeave\("detach"\)/); + assert.match(leaveFn, /generateAbortRef\.current = null/); + assert.match(leaveFn, /detachedWaitRef\.current = true/); + assert.match(leaveFn, /router\.push\(["']\/library["']\)/); + assert.doesNotMatch(leaveFn, /\.abort\s*\(/); + assert.doesNotMatch(leaveFn, /setFailCreditState/); + assert.doesNotMatch(leaveFn, /10 restored|credits restored/i); + } + // Explicit cancel remains abort + refund unconfirmed (never invent restore). + { + const cancelFn = landing.match( + /function cancelInFlightGenerate\(\) \{[\s\S]*?\n \}/ + )?.[0]; + assert.ok(cancelFn, "cancelInFlightGenerate must exist on LandingToolPanel"); + assert.match(cancelFn, /planGenerateWaitLeave\("cancel"\)/); + assert.match(cancelFn, /ctrl\.abort\(\)/); + assert.match(cancelFn, /setFailCreditState\(["']refund unconfirmed["']\)/); + assert.doesNotMatch(cancelFn, /10 restored/); + } + // Detached settle path persists history without inventing fail/refund UI. + assert.match( + landing, + /detachedWaitRef\.current \|\| !landingMountedRef\.current/ + ); + assert.match( + landing, + /if \(detachedWaitRef\.current \|\| !landingMountedRef\.current\) \{[\s\S]{0,500}pushHistory/ + ); + // Unmount must not auto-abort (would kill detach leave). + assert.doesNotMatch( + landing, + /return \(\) => \{[\s\S]{0,220}generateAbortRef\.current\?\.abort\(\)/, + "Landing unmount must not abort in-flight generate POST (detach semantics)" + ); + // AIT-545 Retry gate retained — AUTH / paywall / fatal / durable hold blocked. + assert.match(landing, /canRetryGenerateFailure/); + assert.match(landing, /lastFailCode/); + assert.match(landing, /lastFailFatal/); + assert.match(landing, /lastFailPaywall/); + assert.match( + landing, + /canRetryGenerateFailure\(\{[\s\S]{0,220}code: lastFailCode[\s\S]{0,120}fatal: lastFailFatal[\s\S]{0,120}paywall:[\s\S]{0,100}busy[\s\S]{0,80}hasInput:/ + ); +} + console.log( - "generate-wait-honesty-regression: PASS (detach on recovery · cancel vs detach · server-gated Retry · AIT-237 Create/Landing gate · fail-closed refund copy · Batch wiring · AIT-545 Image Studio gate · AIT-571 Create form busy-leave · AIT-554 Image busy-leave)" + "generate-wait-honesty-regression: PASS (detach on recovery · cancel vs detach · server-gated Retry · AIT-237 Create/Landing gate · fail-closed refund copy · Batch wiring · AIT-545 Image Studio gate · AIT-571 Create form busy-leave · AIT-554 Image busy-leave · AIT-563 Landing busy-leave)" );