fix(vinpin): stop budget-burn + seat-poison on undecodable Renault when Rpartstore down
FIX A (runRenaultFlow): when Rpartstore is UNAVAILABLE (down-cooldown or it
never loaded, so `primary` is only a placeholder ambiguous), a CLEAN Dialogys
not_found is now DEFINITIVE. The old gate required BOTH catalogs to say
not_found, so every undecodable Renault while Rpartstore was down got
downgraded to ambiguous → 3x retry → 180s budget → sessionPoisoned, which
then degraded later decodes. A genuinely-ambiguous Dialogys (unreachable)
still retries. When Rpartstore actually ran, both-must-agree is preserved.
runDialogysSearch now logs its outcome + truncated OCR header so this class
is diagnosable from prod logs.
FIX B (vinpin.constants): add old R-number + TR-badge Renault model tokens
(R5/R9/R11/R12/R19/R21/R25, Europa/Broadway/Toros/Flash). R-prefixed form
only — no bare numerics that could false-match year/engine digits.
FIX C (vin-validator extractModelYear): the position-10 year code repeats every
30 years ("T" = 1996 or 2026) with no clean VIN-only rule. New optional
{modelResolved:false} signal: for a brand-only decode of an old-shaped Renault
VIN (Renault WMI + numeric-led VDS type code) whose code pins to the current
cycle's leading edge, roll back one 30-year cycle so a ~1996 R19 isn't labelled
2026. Narrow: model-resolved or modern-shaped VINs are unchanged. Corgi's
WMI-only decoder wired to pass modelResolved:false.
Keeps never-throw / VINPIN_DECODE_BUDGET_MS / sessionPoisoned semantics and the
Fiat + working Renault paths intact. tsc clean; vinpin + corgi + extractModelYear
tests green (new tests cover A and C).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -33,7 +33,11 @@ export class CorgiService {
|
||||
}
|
||||
|
||||
private extractYear(vin: string): number | null {
|
||||
return extractModelYear(vin);
|
||||
// Corgi is a WMI-only decoder — it resolves brand + year but never a model.
|
||||
// Signalling modelResolved:false lets extractModelYear roll a 30-year-ambiguous
|
||||
// position-10 code (e.g. "T" = 1996-or-2026) back a cycle for an old-shaped
|
||||
// Renault VIN, so a ~1996 R19 isn't mislabelled as a 2026 car (see vin-validator).
|
||||
return extractModelYear(vin, undefined, { modelResolved: false });
|
||||
}
|
||||
|
||||
getBrandFromWmi(wmi: string): string | null {
|
||||
|
||||
@@ -469,3 +469,108 @@ describe("VinpinDriverService — ambiguous Renault retry is a CHEAP in-session
|
||||
expect(close).toHaveBeenCalledTimes(1); // graduated hard reset on the broken session
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* runRenaultFlow definitive-not_found gate (the budget-burn + seat-poison fix).
|
||||
* When Rpartstore is UNAVAILABLE (down-cooldown, or it never loaded so `primary`
|
||||
* is only the placeholder ambiguous), a CLEAN Dialogys not_found is DEFINITIVE on
|
||||
* its own — the old gate required BOTH catalogs to say not_found, so every
|
||||
* undecodable Renault while Rpartstore was down got downgraded to ambiguous →
|
||||
* retry loop → 180s budget → sessionPoisoned. A genuinely-ambiguous Dialogys
|
||||
* (couldn't be reached) still returns ambiguous so the transient-failure retry
|
||||
* survives. When Rpartstore actually RAN, the both-must-agree gate is preserved.
|
||||
*/
|
||||
describe("VinpinDriverService — runRenaultFlow definitive not_found when Rpartstore unavailable", () => {
|
||||
const savedEnv = { ...process.env };
|
||||
const FAR_DEADLINE = () => Date.now() + 5 * 60_000;
|
||||
const VIN = "VF1553K05TR596166"; // old (~1996) Renault 19 — the live budget-burn case
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.VINPIN_ENABLED = "true";
|
||||
mockOcr.mockReset();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
process.env = { ...savedEnv };
|
||||
});
|
||||
|
||||
const runFlow = (driver: VinpinDriverService, page: unknown) =>
|
||||
(
|
||||
(driver as unknown as AnyDriver).runRenaultFlow as (
|
||||
p: unknown,
|
||||
v: string,
|
||||
c: unknown,
|
||||
d: number,
|
||||
) => Promise<{ status: string }>
|
||||
).call(driver, page, VIN, {}, FAR_DEADLINE());
|
||||
|
||||
it("cooldown (Rpartstore down) + Dialogys not_found → DEFINITIVE not_found, no Rpartstore, no retry", async () => {
|
||||
const driver = new VinpinDriverService();
|
||||
const any = driver as unknown as AnyDriver;
|
||||
any.rpartstoreCooldownUntil = Date.now() + 60_000; // Rpartstore in down-cooldown
|
||||
const acquire = vi.spyOn(any as never, "acquireLoadedRpartstore");
|
||||
const runRpartstore = vi.spyOn(any as never, "runRpartstore");
|
||||
const runDialogys = vi
|
||||
.spyOn(any as never, "runDialogys")
|
||||
.mockResolvedValue({ status: "not_found" } as never);
|
||||
|
||||
const outcome = await runFlow(driver, fakePage());
|
||||
|
||||
expect(outcome.status).toBe("not_found"); // definitive — no ambiguous downgrade
|
||||
expect(acquire).not.toHaveBeenCalled(); // Rpartstore skipped during the outage
|
||||
expect(runRpartstore).not.toHaveBeenCalled();
|
||||
expect(runDialogys).toHaveBeenCalledTimes(1); // single shot — no budget-burn retry
|
||||
});
|
||||
|
||||
it("Rpartstore never loaded (not cooldown) + Dialogys not_found → DEFINITIVE not_found", async () => {
|
||||
const driver = new VinpinDriverService();
|
||||
const any = driver as unknown as AnyDriver;
|
||||
// Not in cooldown, but the acquire fails to load Rpartstore → primary never ran.
|
||||
vi.spyOn(any as never, "acquireLoadedRpartstore").mockResolvedValue(false as never);
|
||||
const runRpartstore = vi.spyOn(any as never, "runRpartstore");
|
||||
vi.spyOn(any as never, "runDialogys").mockResolvedValue({ status: "not_found" } as never);
|
||||
|
||||
const outcome = await runFlow(driver, fakePage());
|
||||
|
||||
expect(outcome.status).toBe("not_found");
|
||||
expect(runRpartstore).not.toHaveBeenCalled(); // acquire failed → primary placeholder only
|
||||
});
|
||||
|
||||
it("Rpartstore unavailable + Dialogys AMBIGUOUS (unreachable) → still ambiguous (transient retry survives)", async () => {
|
||||
const driver = new VinpinDriverService();
|
||||
const any = driver as unknown as AnyDriver;
|
||||
any.rpartstoreCooldownUntil = Date.now() + 60_000; // down-cooldown
|
||||
vi.spyOn(any as never, "runDialogys").mockResolvedValue({ status: "ambiguous" } as never);
|
||||
|
||||
const outcome = await runFlow(driver, fakePage());
|
||||
|
||||
expect(outcome.status).toBe("ambiguous"); // couldn't confirm not_found → caller retries
|
||||
});
|
||||
|
||||
it("REGRESSION: Rpartstore RAN + ambiguous, Dialogys not_found → still ambiguous (both-must-agree preserved)", async () => {
|
||||
const driver = new VinpinDriverService();
|
||||
const any = driver as unknown as AnyDriver;
|
||||
// Rpartstore is UP: it loads and runs but is unsure (ambiguous). Dialogys then
|
||||
// cleanly misses. Because Rpartstore actually ran, we must NOT treat this as a
|
||||
// definitive not_found — the pre-fix both-must-agree behaviour is preserved.
|
||||
vi.spyOn(any as never, "acquireLoadedRpartstore").mockResolvedValue(true as never);
|
||||
vi.spyOn(any as never, "runRpartstore").mockResolvedValue({ status: "ambiguous" } as never);
|
||||
vi.spyOn(any as never, "runDialogys").mockResolvedValue({ status: "not_found" } as never);
|
||||
|
||||
const outcome = await runFlow(driver, fakePage());
|
||||
|
||||
expect(outcome.status).toBe("ambiguous");
|
||||
});
|
||||
|
||||
it("REGRESSION: Rpartstore RAN + not_found, Dialogys not_found → definitive not_found (unchanged)", async () => {
|
||||
const driver = new VinpinDriverService();
|
||||
const any = driver as unknown as AnyDriver;
|
||||
vi.spyOn(any as never, "acquireLoadedRpartstore").mockResolvedValue(true as never);
|
||||
vi.spyOn(any as never, "runRpartstore").mockResolvedValue({ status: "not_found" } as never);
|
||||
vi.spyOn(any as never, "runDialogys").mockResolvedValue({ status: "not_found" } as never);
|
||||
|
||||
const outcome = await runFlow(driver, fakePage());
|
||||
|
||||
expect(outcome.status).toBe("not_found");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1781,10 +1781,15 @@ export class VinpinDriverService implements OnModuleDestroy {
|
||||
// When Rpartstore is in the down-cooldown (a recent launch-error/load-failure),
|
||||
// skip opening it entirely (persistent outage) and go straight to Dialogys.
|
||||
let primary: RenaultOutcome = { status: "ambiguous" };
|
||||
// Track whether Rpartstore actually RAN a search. It only runs when it wasn't in
|
||||
// cooldown AND loaded — otherwise `primary` stays a placeholder "ambiguous" that
|
||||
// must NOT be read as "Rpartstore was unsure" (see the definitive gate below).
|
||||
let primaryRan = false;
|
||||
if (this.rpartstoreInCooldown()) {
|
||||
this.logger.log(`${vin}: Rpartstore in down-cooldown — routing straight to Dialogys`);
|
||||
} else if (await this.acquireLoadedRpartstore(page, cfg, deadline, false)) {
|
||||
primary = await this.runRpartstore(page, vin);
|
||||
primaryRan = true;
|
||||
if (primary.status === "found") return primary;
|
||||
} else {
|
||||
this.logger.warn(
|
||||
@@ -1798,8 +1803,18 @@ export class VinpinDriverService implements OnModuleDestroy {
|
||||
);
|
||||
if (fallback.status === "found") return fallback;
|
||||
|
||||
// Definitive not-found only when BOTH said so; otherwise ambiguous → retry.
|
||||
if (primary.status === "not_found" && fallback.status === "not_found") {
|
||||
// Definitive not-found. When Rpartstore actually ran, require BOTH catalogs to
|
||||
// say not_found. But when Rpartstore was UNAVAILABLE (down-cooldown, or it never
|
||||
// loaded so `primary` is only the placeholder ambiguous), a CLEAN Dialogys
|
||||
// not_found is definitive on its own — otherwise every undecodable Renault gets
|
||||
// downgraded to ambiguous and thrashes the retry loop into the 180s budget +
|
||||
// seat-poison. A genuinely-ambiguous Dialogys (couldn't be reached → ambiguous)
|
||||
// still falls through to the retry, which is the correct transient-failure path.
|
||||
const rpartstoreUnavailable = this.rpartstoreInCooldown() || !primaryRan;
|
||||
if (
|
||||
fallback.status === "not_found" &&
|
||||
(primary.status === "not_found" || rpartstoreUnavailable)
|
||||
) {
|
||||
return { status: "not_found" };
|
||||
}
|
||||
return { status: "ambiguous" };
|
||||
@@ -2297,17 +2312,34 @@ export class VinpinDriverService implements OnModuleDestroy {
|
||||
const header = await ocrRegion(page, VINPIN_RENAULT_HEADER_REGION);
|
||||
const parsed = parseRenaultHeader(header);
|
||||
if (isUsableRenaultParse(parsed)) {
|
||||
this.logger.log(
|
||||
`dialogys ${vin}: found → ${parsed.brand} ${parsed.model} [header: "${this.truncateForLog(header)}"]`,
|
||||
);
|
||||
return { status: "found", parsed, rawText: header, via: "dialogys" };
|
||||
}
|
||||
const full = await ocrRegion(page);
|
||||
const parsed2 = parseRenaultHeader(full);
|
||||
if (isUsableRenaultParse(parsed2)) {
|
||||
this.logger.log(
|
||||
`dialogys ${vin}: found → ${parsed2.brand} ${parsed2.model} (full-frame fallback)`,
|
||||
);
|
||||
return { status: "found", parsed: parsed2, rawText: full, via: "dialogys" };
|
||||
}
|
||||
// Dialogys stays on the form when it can't decode → treat as not_found.
|
||||
// Dialogys stays on the form when it can't decode → treat as not_found. Log the
|
||||
// OCR'd header so this "clean not_found" class is diagnosable from prod logs
|
||||
// (distinguishes a real miss from an OCR/render fault reading empty text).
|
||||
this.logger.log(
|
||||
`dialogys ${vin}: not_found (no model parsed) [header: "${this.truncateForLog(header)}"]`,
|
||||
);
|
||||
return { status: "not_found" };
|
||||
}
|
||||
|
||||
/** Collapse whitespace + clip OCR text to keep diagnostic logs readable/bounded. */
|
||||
private truncateForLog(text: string | null | undefined, max = 120): string {
|
||||
const flat = (text ?? "").replace(/\s+/g, " ").trim();
|
||||
return flat.length > max ? `${flat.slice(0, max)}…` : flat;
|
||||
}
|
||||
|
||||
private async close(): Promise<void> {
|
||||
this.loggedIn = false;
|
||||
this.authed = false;
|
||||
|
||||
@@ -566,6 +566,22 @@ export const RENAULT_MODEL_TOKENS = [
|
||||
"DOKKER",
|
||||
"LODGY",
|
||||
"SPRING",
|
||||
// Old R-number platforms + TR-market badges (pre-2000). These genuinely need
|
||||
// Rpartstore to VIN-resolve, so this mostly future-proofs the header parse for
|
||||
// an old Renault (e.g. VF1553… R19). The parser tokenises on non-alphanumerics,
|
||||
// so a BARE "19" would collide with year/engine digits — the R-prefixed form
|
||||
// ("R19") is required so it only matches the actual model badge, never a number.
|
||||
"R5",
|
||||
"R9",
|
||||
"R11",
|
||||
"R12",
|
||||
"R19",
|
||||
"R21",
|
||||
"R25",
|
||||
"EUROPA", // R19 Europa (TR)
|
||||
"BROADWAY", // R9 Broadway (TR)
|
||||
"TOROS", // R12 Toros (TR)
|
||||
"FLASH", // R11 Flash (TR)
|
||||
] as const;
|
||||
|
||||
/** WMIs that route to the Renault (Rpartstore/Dialogys) flow. VF1/VF2 are the
|
||||
|
||||
Reference in New Issue
Block a user