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:
2026-07-15 15:39:26 +03:00
parent d7d78a77a6
commit c03e7f4079
6 changed files with 232 additions and 7 deletions

View File

@@ -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 {

View File

@@ -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");
});
});

View File

@@ -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;

View File

@@ -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