feat(vinpin): VINPIN_RPARTSTORE_ENABLED flag skips Rpartstore during known outage
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled

Rpartstore is DOWN upstream. Every Renault decode still tried to OPEN it
first → launch-error, stuck "Loading application..." app (dirty-resume for
the next decode), ~30-50s burned, and the in-memory down-cooldown resets on
every worker restart so the first decode after each restart repaid the full
cost. That overhead pushed decodes over VINPIN_DECODE_BUDGET_MS → budget
abort → sessionPoisoned → cascade.

Add a persistent kill-switch: VINPIN_RPARTSTORE_ENABLED=false makes BOTH
Renault paths (warm warmRenaultDecode + cold runRenaultFlow) skip opening
Rpartstore entirely and route straight to Dialogys — mirroring the existing
rpartstoreInCooldown() skip but surviving worker restarts. Flag-disabled is
treated as "Rpartstore unavailable" exactly like cooldown, so primaryRan
stays false and a clean Dialogys not_found is definitive (no retry thrash).
Also gated the _warmUp Rpartstore-open so a future warm session with the
flag off pays no launch cost / leaves no stray app.

DEFAULT true (only the exact string "false" disables) → behaviour with the
flag unset is completely unchanged. tsc clean; vinpin unit tests green
(+3 flag tests: warm/cold skip + Dialogys-definitive, and default-true still
attempts Rpartstore).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 07:36:20 +03:00
parent 6df9c18efb
commit c3ea03db03
3 changed files with 136 additions and 11 deletions

View File

@@ -267,7 +267,7 @@ describe("VinpinDriverService — Rpartstore-down cooldown routes Renault → Di
const result = await (
any.warmRenaultDecode as (p: unknown, v: string, c: unknown, d: number) => Promise<unknown>
).call(driver, page, "VF1LM1B0A37829019", {}, FAR_DEADLINE());
).call(driver, page, "VF1LM1B0A37829019", { rpartstoreEnabled: true }, FAR_DEADLINE());
expect(acquireSpy).not.toHaveBeenCalled(); // Rpartstore never opened during the outage
expect(runRpartstore).not.toHaveBeenCalled();
@@ -502,7 +502,7 @@ describe("VinpinDriverService — runRenaultFlow definitive not_found when Rpart
c: unknown,
d: number,
) => Promise<{ status: string }>
).call(driver, page, VIN, {}, FAR_DEADLINE());
).call(driver, page, VIN, { rpartstoreEnabled: true }, FAR_DEADLINE());
it("cooldown (Rpartstore down) + Dialogys not_found → DEFINITIVE not_found, no Rpartstore, no retry", async () => {
const driver = new VinpinDriverService();
@@ -574,3 +574,107 @@ describe("VinpinDriverService — runRenaultFlow definitive not_found when Rpart
expect(outcome.status).toBe("not_found");
});
});
/**
* VINPIN_RPARTSTORE_ENABLED=false kill-switch (KNOWN Rpartstore outage). Unlike the
* in-memory down-cooldown (which resets on every worker restart, so the first decode
* after each restart pays the full Rpartstore launch cost + leaves a stuck app), this
* config flag persists: when set, BOTH Renault paths (warm + cold) skip opening
* Rpartstore entirely and go straight to Dialogys, and a clean Dialogys not_found is
* DEFINITIVE (a flag-disabled Rpartstore is "unavailable" exactly like the cooldown).
* Default (unset / not "false") is TRUE — behaviour completely unchanged.
*/
describe("VinpinDriverService — VINPIN_RPARTSTORE_ENABLED=false skips Rpartstore in both Renault paths", () => {
const savedEnv = { ...process.env };
const FAR_DEADLINE = () => Date.now() + 5 * 60_000;
beforeEach(() => {
process.env.VINPIN_ENABLED = "true";
mockOcr.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
process.env = { ...savedEnv };
});
it("COLD runRenaultFlow (flag=false): never opens Rpartstore, goes straight to Dialogys, and a Dialogys not_found is DEFINITIVE (no retry)", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as AnyDriver;
// NOT in cooldown — the ONLY reason to skip is the disabled flag.
const acquire = vi.spyOn(any as never, "acquireLoadedRpartstore");
const ensureR = vi.spyOn(any as never, "ensureRpartstore");
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 (
(any as AnyDriver).runRenaultFlow as (
p: unknown,
v: string,
c: unknown,
d: number,
) => Promise<{ status: string }>
).call(driver, fakePage(), "VF1553K05TR596166", { rpartstoreEnabled: false }, FAR_DEADLINE());
expect(acquire).not.toHaveBeenCalled(); // Rpartstore never opened
expect(ensureR).not.toHaveBeenCalled();
expect(runRpartstore).not.toHaveBeenCalled();
expect(runDialogys).toHaveBeenCalledTimes(1); // single shot — no budget-burn retry
expect(outcome.status).toBe("not_found"); // definitive — no ambiguous downgrade
});
it("WARM warmRenaultDecode (flag=false): never opens Rpartstore, decodes via Dialogys", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as AnyDriver;
const acquire = vi.spyOn(any as never, "acquireLoadedRpartstore");
const ensureR = vi.spyOn(any as never, "ensureRpartstore");
const runRpartstore = vi.spyOn(any as never, "runRpartstore");
vi.spyOn(any as never, "ensureWarmWindow").mockResolvedValue(true as never);
vi.spyOn(any as never, "runDialogysSearch").mockResolvedValue({
status: "found",
parsed: { brand: "RENAULT", model: "MEGANE", modelYear: null },
rawText: "Megane",
via: "dialogys",
} as never);
const page = { keyboard: { press: vi.fn(async () => undefined) } };
const result = await (
any.warmRenaultDecode as (p: unknown, v: string, c: unknown, d: number) => Promise<unknown>
).call(driver, page, "VF1LM1B0A37829019", { rpartstoreEnabled: false }, FAR_DEADLINE());
expect(acquire).not.toHaveBeenCalled(); // Rpartstore never opened
expect(ensureR).not.toHaveBeenCalled();
expect(runRpartstore).not.toHaveBeenCalled();
expect(result).toMatchObject({
brand: "RENAULT",
model: "MEGANE",
raw: { source: "vinpin-dialogys" },
});
});
it("DEFAULT (flag=true): COLD path still ATTEMPTS Rpartstore (behaviour unchanged)", async () => {
const driver = new VinpinDriverService();
const any = driver as unknown as AnyDriver;
const acquire = vi
.spyOn(any as never, "acquireLoadedRpartstore")
.mockResolvedValue(true as never);
vi.spyOn(any as never, "runRpartstore").mockResolvedValue({
status: "found",
parsed: { brand: "RENAULT", model: "CLIO", modelYear: null },
rawText: "Clio",
via: "rpartstore",
} as never);
const outcome = await (
(any as AnyDriver).runRenaultFlow as (
p: unknown,
v: string,
c: unknown,
d: number,
) => Promise<{ status: string }>
).call(driver, fakePage(), "VF1553K05TR596166", { rpartstoreEnabled: true }, FAR_DEADLINE());
expect(acquire).toHaveBeenCalledTimes(1); // Rpartstore still primary by default
expect(outcome.status).toBe("found");
});
});

View File

@@ -444,7 +444,7 @@ describe("VinpinDriverService — Rpartstore spinner guard", () => {
const result = await (
any.warmRenaultDecode as (p: unknown, v: string, c: unknown, d: number) => Promise<unknown>
).call(driver, page, "VF1RFE00653633190", {}, FAR_DEADLINE());
).call(driver, page, "VF1RFE00653633190", { rpartstoreEnabled: true }, FAR_DEADLINE());
expect(result).toMatchObject({
brand: "RENAULT",
@@ -470,7 +470,7 @@ describe("VinpinDriverService — Rpartstore spinner guard", () => {
const result = await (
any.warmRenaultDecode as (p: unknown, v: string, c: unknown, d: number) => Promise<unknown>
).call(driver, page, "VF1LM1B0A37829019", {}, FAR_DEADLINE());
).call(driver, page, "VF1LM1B0A37829019", { rpartstoreEnabled: true }, FAR_DEADLINE());
expect(runRpartstore).not.toHaveBeenCalled(); // never ran the search on a stuck Rpartstore
expect(result).toMatchObject({

View File

@@ -98,6 +98,11 @@ interface VinpinConfig {
/** Whether the warm-session daemon path is allowed (kill-switch: set
* VINPIN_WARM_DAEMON=false to force the legacy per-decode cold path in prod). */
warmDaemon: boolean;
/** Whether opening Rpartstore is allowed at all (kill-switch: set
* VINPIN_RPARTSTORE_ENABLED=false during a KNOWN Rpartstore outage to skip
* opening it entirely and route Renault decodes straight to Dialogys — mirrors
* the down-cooldown skip, but persists across worker restarts). DEFAULT true. */
rpartstoreEnabled: boolean;
}
function readConfig(): VinpinConfig {
@@ -110,6 +115,7 @@ function readConfig(): VinpinConfig {
headful: process.env.VINPIN_HEADFUL === "true",
budgetMs: Number(process.env.VINPIN_DECODE_BUDGET_MS) || VINPIN_DECODE_BUDGET_MS,
warmDaemon: process.env.VINPIN_WARM_DAEMON !== "false",
rpartstoreEnabled: process.env.VINPIN_RPARTSTORE_ENABLED !== "false",
};
}
@@ -502,9 +508,12 @@ export class VinpinDriverService implements OnModuleDestroy {
await this.establishSession(cfg);
const fiatUp = this.establishedFlow === "fiat";
// Window 2: Renault Rpartstore — background ePER by raising the grid, then
// open (ensureRpartstore opens from the grid and does NOT close ePER).
// open (ensureRpartstore opens from the grid and does NOT close ePER). Skipped
// entirely when VINPIN_RPARTSTORE_ENABLED=false (known outage): don't pay the
// launch cost / leave a stray "Loading application..." app during warm-up.
let okR = false;
if (await this.raiseGridViaTaskbar(page)) okR = await this.ensureRpartstore(page);
if (cfg.rpartstoreEnabled && (await this.raiseGridViaTaskbar(page)))
okR = await this.ensureRpartstore(page);
// If Rpartstore did NOT come up (e.g. it's DOWN and left its hard launch-error
// modal on screen), clear any leftover blocking modal BEFORE opening Dialogys.
// Otherwise the launch-error dialog dirties the desktop and can drive the
@@ -710,8 +719,12 @@ export class VinpinDriverService implements OnModuleDestroy {
// opening it entirely — it's a persistent server-side outage and reopening only
// re-dirties the desktop — and go straight to the proven ~30s Dialogys path.
let primary: RenaultOutcome = { status: "ambiguous" };
if (this.rpartstoreInCooldown()) {
this.logger.log(`warm ${vin}: Rpartstore in down-cooldown — routing straight to Dialogys`);
if (this.rpartstoreInCooldown() || !cfg.rpartstoreEnabled) {
this.logger.log(
cfg.rpartstoreEnabled
? `warm ${vin}: Rpartstore in down-cooldown — routing straight to Dialogys`
: `warm ${vin}: Rpartstore disabled (VINPIN_RPARTSTORE_ENABLED=false) — routing straight to Dialogys`,
);
} else if (await this.acquireLoadedRpartstore(page, cfg, deadline, true)) {
primary = await this.runRpartstore(page, vin);
await page.keyboard.press("Escape").catch(() => undefined);
@@ -1785,8 +1798,15 @@ export class VinpinDriverService implements OnModuleDestroy {
// 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`);
if (this.rpartstoreInCooldown() || !cfg.rpartstoreEnabled) {
// primaryRan stays false → the definitive-not_found gate below treats a clean
// Dialogys not_found as definitive (a flag-disabled Rpartstore is "unavailable"
// exactly like the down-cooldown).
this.logger.log(
cfg.rpartstoreEnabled
? `${vin}: Rpartstore in down-cooldown — routing straight to Dialogys`
: `${vin}: Rpartstore disabled (VINPIN_RPARTSTORE_ENABLED=false) — routing straight to Dialogys`,
);
} else if (await this.acquireLoadedRpartstore(page, cfg, deadline, false)) {
primary = await this.runRpartstore(page, vin);
primaryRan = true;
@@ -1810,7 +1830,8 @@ export class VinpinDriverService implements OnModuleDestroy {
// 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;
const rpartstoreUnavailable =
this.rpartstoreInCooldown() || !cfg.rpartstoreEnabled || !primaryRan;
if (
fallback.status === "not_found" &&
(primary.status === "not_found" || rpartstoreUnavailable)