mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(producer): enable the parallel-DE router by default, behind a per-install circuit breaker
The DE parallel router (HF_DE_PARALLEL_ROUTER) becomes default-ON. The soak answered the safety question it was gated on: zero damaged frames shipped — every fallback was the self-verification net catching a bad frame and recovering on the screenshot path. Verify PSNR p10 sits flat near 40 dB against a 32 dB floor. The residual 2.31% revert rate is an efficiency cost (a revert forfeits the speedup, never the output), accepted in exchange for parallelizing the >=700-frame band — roughly 80% of all DE capture wall-clock, frame-weighted. Default-ON is safe because the per-install circuit breaker stays underneath it. That distinction matters: 9.8% of installs hit a revert, and they are latched off permanently after the first one. Without the breaker those installs would go from "one slow render, then protected" to "every eligible render is slow". The breaker, adapted for a default-ON flag: - Writes an explicit HF_DE_PARALLEL_ROUTER=false and persists it to ~/.hyperframes/config.json, so the install stays off across processes. Absent no longer means off, so the switch has to be written, not unset. - Trips only on a real fallback, never on render count — a healthy install keeps the speedup indefinitely. - Independent of telemetry state: opting out of analytics must not cost a user the faster renderer. Telemetry governs reporting, not behavior. - An explicit user value wins in both directions, latched before the breaker can write the var and make the two indistinguishable. - The user is told when it trips and how to re-enable. isDeParallelRouterEnabled() parses the kill switch properly: false/0/off/no (case- and space-insensitive) disable; unset or empty is the default. A bare `!== "false"` would silently ignore every spelling but one and hand parallel DE to a user who asked for none. Refs PRINFRA-384 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a850e97f3d
commit
c6df112ac1
+170
-152
@@ -424,21 +424,26 @@ export interface RenderOptions {
|
||||
/** Skip the interactive feedback prompt after a successful render. */
|
||||
skipFeedback?: boolean;
|
||||
/**
|
||||
* OPT IN to the DE parallel-router CLI trial
|
||||
* (`maybeEnableDeParallelRouterTrial`) for this render. Default OFF —
|
||||
* OPT IN to managing the DE parallel-router circuit breaker
|
||||
* (`applyDeParallelRouterCircuitBreaker`) for this render. Default OFF —
|
||||
* only the top-level CLI render command's own call sites should ever set
|
||||
* this (review): the trial mechanism shares one process-wide env var and
|
||||
* two module-level flags across every `renderLocal` call in the process,
|
||||
* this (review): the mechanism shares one process-wide env var and two
|
||||
* module-level flags across every `renderLocal` call in the process,
|
||||
* which is safe for SEQUENTIAL calls (single render, single-concurrency
|
||||
* batch rows) but not for genuinely concurrent ones — racing invocations
|
||||
* could tear down or misattribute each other's outcome. Programmatic
|
||||
* consumers importing `renderLocal` (a future studio-server path, test
|
||||
* harnesses, distributed runners) therefore get NO trial unless they
|
||||
* explicitly opt in AND guarantee sequential invocation. The CLI sets
|
||||
* this for single renders and for `--batch` at concurrency 1; it leaves
|
||||
* it unset for `--batch-concurrency N>=2`.
|
||||
* harnesses, distributed runners) therefore do not manage the breaker
|
||||
* unless they explicitly opt in AND guarantee sequential invocation. The
|
||||
* CLI sets this for single renders and for `--batch` at concurrency 1; it
|
||||
* leaves it unset for `--batch-concurrency N>=2`.
|
||||
*
|
||||
* NOTE the asymmetry: the ROUTER itself is default-on for every consumer
|
||||
* (the producer decides that). This flag only governs whether we
|
||||
* additionally enforce the per-install breaker, because that is the part
|
||||
* with process-wide state.
|
||||
*/
|
||||
enableDeParallelRouterTrial?: boolean;
|
||||
manageDeParallelRouterBreaker?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -841,10 +846,12 @@ export async function renderLocal(
|
||||
}
|
||||
|
||||
const producer = await loadProducer();
|
||||
const deParallelRouterTrialArmed = maybeEnableDeParallelRouterTrial(
|
||||
options.quiet,
|
||||
options.enableDeParallelRouterTrial === true,
|
||||
);
|
||||
const deParallelRouterActive =
|
||||
options.manageDeParallelRouterBreaker === true
|
||||
? applyDeParallelRouterCircuitBreaker(options.quiet)
|
||||
: // Not managing the breaker: the router still runs (producer default),
|
||||
// we just don't enforce or record the per-install trip.
|
||||
false;
|
||||
|
||||
const startTime = Date.now();
|
||||
const logger = createRenderTelemetryLogger(
|
||||
@@ -894,7 +901,7 @@ export async function renderLocal(
|
||||
try {
|
||||
await producer.executeRenderJob(job, projectDir, outputPath, onProgress);
|
||||
} catch (error: unknown) {
|
||||
maybeConsumeDeParallelRouterTrial(deParallelRouterTrialArmed, job, options.quiet);
|
||||
maybeConsumeDeParallelRouterTrial(deParallelRouterActive, job, options.quiet);
|
||||
handleRenderError(
|
||||
error,
|
||||
options,
|
||||
@@ -913,7 +920,7 @@ export async function renderLocal(
|
||||
// (win32/x64, CLI 0.7.58): valid MP4 on disk, exited 1 with no error print.
|
||||
markRenderSucceeded();
|
||||
|
||||
maybeConsumeDeParallelRouterTrial(deParallelRouterTrialArmed, job, options.quiet);
|
||||
maybeConsumeDeParallelRouterTrial(deParallelRouterActive, job, options.quiet);
|
||||
const elapsed = Date.now() - startTime;
|
||||
if (job.outcome === "completed_with_warnings") {
|
||||
for (const warning of job.warnings) {
|
||||
@@ -1072,37 +1079,40 @@ function createNoopProducerLogger(): ProducerLogger {
|
||||
};
|
||||
}
|
||||
|
||||
/** Backstop cap: even absent an actual router failure, stop offering the
|
||||
* trial after this many engaged (routed or reverted) renders for an
|
||||
* install. Without this, a healthy router that never reverts would stay
|
||||
* force-enabled on every eligible render forever (review finding). */
|
||||
const DE_PARALLEL_ROUTER_TRIAL_MAX_RENDERS = 25;
|
||||
|
||||
/**
|
||||
* True across every `renderLocal` call in THIS process once the trial has
|
||||
* armed `HF_DE_PARALLEL_ROUTER` here — distinct from the env var's own
|
||||
* value, which stays "true" across an entire `--batch` run. Without this,
|
||||
* a second batch row's `process.env.HF_DE_PARALLEL_ROUTER !== undefined`
|
||||
* check can't tell "we set this ourselves on row 1" from "the user set
|
||||
* this" and would wrongly treat itself as un-armed, silently dropping that
|
||||
* row's outcome from ever reaching `maybeConsumeDeParallelRouterTrial`
|
||||
* (review finding).
|
||||
* The 25-render exposure cap that bounded the old opt-in TRIAL is gone: the
|
||||
* router is default-ON as of 2026-07-27, so "stop offering it after N
|
||||
* renders" would mean switching a shipped default off behind the user's
|
||||
* back. What survives is the half that was always safety rather than
|
||||
* sampling — the per-install circuit breaker below, which latches the router
|
||||
* off for good the first time a render has to fall back.
|
||||
*/
|
||||
let deParallelRouterTrialManagedByUs = false;
|
||||
|
||||
/**
|
||||
* In-process latch mirroring `deParallelRouterTrialFired`: set the moment we
|
||||
* DECIDE the trial is over, independent of whether persisting that decision
|
||||
* to `~/.hyperframes/config.json` succeeds. `writeConfig` swallows all fs
|
||||
* The user set `HF_DE_PARALLEL_ROUTER` themselves (either polarity), latched
|
||||
* once at first observation. Their choice wins over the circuit breaker in
|
||||
* BOTH directions: we never overwrite an explicit opt-in with `"false"` on a
|
||||
* fallback, and never overwrite an explicit opt-out either. Latched rather
|
||||
* than re-read because the breaker itself writes the var — after the first
|
||||
* write a live `process.env` read could no longer tell "the user set this"
|
||||
* from "we set this" (the same distinction the old trial needed for
|
||||
* `--batch` rows sharing one process).
|
||||
*/
|
||||
let deParallelRouterUserManaged = false;
|
||||
let deParallelRouterUserManagedResolved = false;
|
||||
|
||||
/**
|
||||
* In-process latch mirroring the persisted `deParallelRouterTrialFired`: set
|
||||
* the moment the breaker trips, independent of whether persisting that to
|
||||
* `~/.hyperframes/config.json` succeeds. `writeConfig` swallows all fs
|
||||
* errors (by design — telemetry must never break the CLI), so on an
|
||||
* unwritable config (root-owned file, disk full) the fired flag can never
|
||||
* stick on disk; without this latch the trial would silently re-arm and
|
||||
* re-fail on every subsequent render in this process forever (review
|
||||
* finding). Later processes still re-arm — disk is the only cross-process
|
||||
* channel — but each process now stops after at most one failure it
|
||||
* couldn't record.
|
||||
* unwritable config (root-owned file, disk full) the flag can never stick on
|
||||
* disk; without this latch the router would re-enable and re-fail on every
|
||||
* subsequent render in this process. Later processes re-arm — disk is the
|
||||
* only cross-process channel — but each process now stops after at most one
|
||||
* failure it couldn't record.
|
||||
*/
|
||||
let deParallelRouterTrialFiredThisProcess = false;
|
||||
let deParallelRouterBreakerTrippedThisProcess = false;
|
||||
|
||||
/**
|
||||
* Test-only reset for the module-level trial state — a real CLI process
|
||||
@@ -1111,111 +1121,101 @@ let deParallelRouterTrialFiredThisProcess = false;
|
||||
* one imported module instance.
|
||||
*/
|
||||
export function __resetDeParallelRouterTrialStateForTests(): void {
|
||||
deParallelRouterTrialManagedByUs = false;
|
||||
deParallelRouterTrialFiredThisProcess = false;
|
||||
deParallelRouterBreakerTrippedThisProcess = false;
|
||||
deParallelRouterUserManaged = false;
|
||||
deParallelRouterUserManagedResolved = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* True once the trial should stop offering itself: already failed (on disk
|
||||
* or via this process's in-memory latch), hit the render-count backstop, or
|
||||
* telemetry isn't actually recordable right now.
|
||||
* Has this install's router circuit breaker already tripped — on disk, or via
|
||||
* this process's in-memory latch?
|
||||
*
|
||||
* Checks BOTH `shouldTrack()` and `config.telemetryEnabled` directly, not
|
||||
* `shouldTrack()` alone: `shouldTrack()` (`../telemetry/client.js`) memoizes
|
||||
* its verdict once per process and never invalidates, so during a long
|
||||
* `--batch` run (all rows share one process) a `hyperframes telemetry off`
|
||||
* issued from another terminal mid-batch would never be observed. The
|
||||
* caller must pass a `readConfigFresh()` snapshot for the same reason —
|
||||
* `readConfig()` serves a process-lifetime cache that is exactly as stale
|
||||
* as the `shouldTrack()` memoization this check exists to bypass (review
|
||||
* finding).
|
||||
* Deliberately does NOT consider telemetry state. The old opt-in trial did:
|
||||
* there was no point running an experimental path if the resulting signal
|
||||
* couldn't be recorded. Now that the router is a shipped default, gating it
|
||||
* on telemetry would mean users who opted out of analytics silently get a
|
||||
* slower renderer — punishing a privacy choice with a performance penalty
|
||||
* (review finding). Telemetry state governs REPORTING, never behavior.
|
||||
*/
|
||||
function isDeParallelRouterTrialBlocked(config: HyperframesConfig): boolean {
|
||||
const overRenderCap =
|
||||
(config.deParallelRouterTrialRenderCount ?? 0) >= DE_PARALLEL_ROUTER_TRIAL_MAX_RENDERS;
|
||||
return (
|
||||
deParallelRouterTrialFiredThisProcess ||
|
||||
Boolean(config.deParallelRouterTrialFired) ||
|
||||
overRenderCap ||
|
||||
!config.telemetryEnabled ||
|
||||
!shouldTrack() ||
|
||||
// cli.ts shows the first-run telemetry disclosure via a fire-and-forget,
|
||||
// unawaited dynamic import — there's no guarantee it has printed before
|
||||
// this render command reaches this point. Requiring telemetryNoticeShown
|
||||
// means the trial simply never offers itself on a fresh install's very
|
||||
// first invocation (before the disclosure is guaranteed to have run at
|
||||
// least once), rather than racing an experimental opt-in message against
|
||||
// the disclosure it depends on (review finding).
|
||||
!config.telemetryNoticeShown
|
||||
);
|
||||
}
|
||||
|
||||
/** Shared cleanup for both `maybeEnableDeParallelRouterTrial` (this process
|
||||
* should stop offering the trial) and `maybeConsumeDeParallelRouterTrial`
|
||||
* (the trial just failed/hit its cap) — a no-op unless WE were the ones
|
||||
* managing the env var. */
|
||||
function stopManagingDeParallelRouterTrial(): void {
|
||||
if (!deParallelRouterTrialManagedByUs) return;
|
||||
delete process.env.HF_DE_PARALLEL_ROUTER;
|
||||
deParallelRouterTrialManagedByUs = false;
|
||||
function hasDeParallelRouterBreakerTripped(config: HyperframesConfig): boolean {
|
||||
return deParallelRouterBreakerTrippedThisProcess || Boolean(config.deParallelRouterTrialFired);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable the DE parallel-router experiment (`HF_DE_PARALLEL_ROUTER`, default
|
||||
* off) for this render, on every eligible render for this install (up to
|
||||
* `DE_PARALLEL_ROUTER_TRIAL_MAX_RENDERS`), so we get real-traffic router
|
||||
* telemetry (revert rate, verify-db distribution) without requiring anyone
|
||||
* to manually set the env var — see `HyperframesConfig.deParallelRouterTrialFired`.
|
||||
* See `maybeConsumeDeParallelRouterTrial` for what turns it off. Returns
|
||||
* whether this call armed it (so the caller knows to check for consumption
|
||||
* afterward) — false unless the caller explicitly opted in (`enabled` —
|
||||
* OPT-IN polarity, review: only the top-level CLI render command's own
|
||||
* sequential call sites set it; programmatic `renderLocal` consumers get no
|
||||
* trial by default because the mechanism's process-wide state is unsafe
|
||||
* under concurrent invocation — see
|
||||
* `RenderOptions.enableDeParallelRouterTrial`), if it's already failed (or
|
||||
* hit the render cap) for this install, if the user already set the env var
|
||||
* themselves (never override an explicit choice — see
|
||||
* `deParallelRouterTrialManagedByUs` for how a later `--batch` row
|
||||
* distinguishes that from our own earlier arm), or if telemetry isn't
|
||||
* actually recordable right now (see `isDeParallelRouterTrialBlocked`; no
|
||||
* point risking the experimental path if we can't even record the
|
||||
* resulting signal).
|
||||
* Latch the router OFF for the rest of this process by writing an explicit
|
||||
* `"false"`.
|
||||
*
|
||||
* Under the old default-OFF flag this deleted the var, because absent meant
|
||||
* off. With the router default-ON, deleting means ON — the same call would
|
||||
* silently RE-ENABLE the router on exactly the host that just failed
|
||||
* (review finding). Writing the explicit value is what makes the breaker a
|
||||
* breaker. No-op when the user set the var themselves: their choice wins in
|
||||
* both directions.
|
||||
*/
|
||||
function maybeEnableDeParallelRouterTrial(quiet: boolean, enabled: boolean): boolean {
|
||||
if (!enabled) return false;
|
||||
// The in-process latch alone decides once it's set — short-circuit before
|
||||
// the disk read so post-fired batch rows don't pay a config read + parse +
|
||||
// shared-cache invalidation per row for an answer module state already
|
||||
// knows (review finding).
|
||||
if (deParallelRouterTrialFiredThisProcess) {
|
||||
stopManagingDeParallelRouterTrial();
|
||||
return false;
|
||||
}
|
||||
const userSetIt =
|
||||
process.env.HF_DE_PARALLEL_ROUTER !== undefined && !deParallelRouterTrialManagedByUs;
|
||||
if (userSetIt) return false;
|
||||
/**
|
||||
* Mirror of the producer's `isDeParallelRouterEnabled`. Deliberately
|
||||
* duplicated rather than imported: `@hyperframes/producer` is lazily loaded
|
||||
* (`loadProducer()`) to keep CLI startup fast, and this runs on the startup
|
||||
* path. Keep the two in sync — the producer copy is the source of truth.
|
||||
*/
|
||||
function userValueEnablesDeParallelRouter(): boolean {
|
||||
const raw = process.env.HF_DE_PARALLEL_ROUTER?.trim().toLowerCase();
|
||||
if (raw === undefined || raw === "") return true;
|
||||
return !(raw === "false" || raw === "0" || raw === "off" || raw === "no");
|
||||
}
|
||||
|
||||
// readConfigFresh, NOT readConfig: the cached read is exactly as stale as
|
||||
// the shouldTrack() memoization the blocked-check exists to bypass — a
|
||||
// mid-batch `hyperframes telemetry off` (or another process persisting
|
||||
// fired=true) would never be observed through the cache (review finding).
|
||||
if (isDeParallelRouterTrialBlocked(readConfigFresh())) {
|
||||
stopManagingDeParallelRouterTrial();
|
||||
return false;
|
||||
function applyDeParallelRouterBreaker(): void {
|
||||
if (deParallelRouterUserManaged) return;
|
||||
process.env.HF_DE_PARALLEL_ROUTER = "false";
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply this install's router circuit breaker before a render.
|
||||
*
|
||||
* The router is default-ON, so the normal path does NOTHING here — the
|
||||
* producer's own default takes over. This exists for the one case that must
|
||||
* survive a shipped default: an install that already had a render fall back
|
||||
* stays off, permanently, across processes (the verdict is persisted to
|
||||
* `~/.hyperframes/config.json`). See `maybeConsumeDeParallelRouterTrial` for
|
||||
* what trips it.
|
||||
*
|
||||
* Returns whether the router is active for this render, so the caller knows
|
||||
* to inspect the outcome afterward.
|
||||
*/
|
||||
function applyDeParallelRouterCircuitBreaker(quiet: boolean): boolean {
|
||||
// Latch the user's own choice on first observation, BEFORE the breaker can
|
||||
// write the var itself and make the two indistinguishable.
|
||||
if (!deParallelRouterUserManagedResolved) {
|
||||
deParallelRouterUserManaged = process.env.HF_DE_PARALLEL_ROUTER !== undefined;
|
||||
deParallelRouterUserManagedResolved = true;
|
||||
}
|
||||
if (deParallelRouterUserManaged) {
|
||||
// Explicit choice, either polarity — report whether it enables the
|
||||
// router so outcomes are still consumed, but never override it.
|
||||
return userValueEnablesDeParallelRouter();
|
||||
}
|
||||
|
||||
if (deParallelRouterTrialManagedByUs) return true;
|
||||
deParallelRouterTrialManagedByUs = true;
|
||||
process.env.HF_DE_PARALLEL_ROUTER = "true";
|
||||
if (!quiet) {
|
||||
console.log(
|
||||
c.dim(
|
||||
" Trying the experimental parallel drawElement capture path for this install " +
|
||||
"(disabled automatically if it ever needs to fall back; opt out anytime: " +
|
||||
"HF_DE_PARALLEL_ROUTER=false)",
|
||||
),
|
||||
);
|
||||
// The in-process latch decides once set — short-circuit before the disk
|
||||
// read so post-trip batch rows don't pay a config read + parse per row for
|
||||
// an answer module state already knows.
|
||||
if (deParallelRouterBreakerTrippedThisProcess) {
|
||||
applyDeParallelRouterBreaker();
|
||||
return false;
|
||||
}
|
||||
// readConfigFresh, NOT readConfig: the cached read is process-lifetime, so
|
||||
// another process persisting a trip mid-`--batch` would never be observed.
|
||||
if (hasDeParallelRouterBreakerTripped(readConfigFresh())) {
|
||||
deParallelRouterBreakerTrippedThisProcess = true;
|
||||
applyDeParallelRouterBreaker();
|
||||
if (!quiet) {
|
||||
console.log(
|
||||
c.dim(
|
||||
" Parallel drawElement capture stays off for this install (a previous render " +
|
||||
"had to fall back). Re-enable with HF_DE_PARALLEL_ROUTER=true.",
|
||||
),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -1302,40 +1302,58 @@ function persistDeParallelRouterTrialFired(): boolean {
|
||||
* `persistDeParallelRouterTrialFired`.
|
||||
*/
|
||||
function maybeConsumeDeParallelRouterTrial(
|
||||
trialArmed: boolean,
|
||||
routerActive: boolean,
|
||||
job: RenderJob,
|
||||
quiet: boolean,
|
||||
): void {
|
||||
if (!trialArmed) return;
|
||||
if (!routerActive) return;
|
||||
const outcome = resolveDeParallelRouterOutcome(job);
|
||||
if (outcome === undefined) return;
|
||||
|
||||
const config = readConfigFresh();
|
||||
const renderCount = (config.deParallelRouterTrialRenderCount ?? 0) + 1;
|
||||
config.deParallelRouterTrialRenderCount = renderCount;
|
||||
const fired = outcome === "reverted" || renderCount >= DE_PARALLEL_ROUTER_TRIAL_MAX_RENDERS;
|
||||
// Trip ONLY on an actual fallback. The old trial also tripped at a
|
||||
// 25-render exposure cap, which was sampling logic: bound how long an
|
||||
// experiment force-enables itself. Under a shipped default that would
|
||||
// switch the feature off behind the user's back after 25 good renders.
|
||||
const fired = outcome === "reverted";
|
||||
if (fired) {
|
||||
config.deParallelRouterTrialFired = true;
|
||||
// Latch BEFORE attempting persistence — the decision holds for this
|
||||
// process even if the disk write never sticks (unwritable config).
|
||||
deParallelRouterTrialFiredThisProcess = true;
|
||||
stopManagingDeParallelRouterTrial();
|
||||
deParallelRouterBreakerTrippedThisProcess = true;
|
||||
applyDeParallelRouterBreaker();
|
||||
}
|
||||
writeConfig(config);
|
||||
// `!quiet`-gated like every other trial message: quiet/batch-json renders
|
||||
// must produce no unexpected terminal output — CI wrappers asserting
|
||||
// empty stderr would misread the warning as a render failure (review
|
||||
// finding). The in-process latch above already guarantees the safety
|
||||
// behavior the warning describes, whether or not it prints.
|
||||
if (fired && !persistDeParallelRouterTrialFired() && !quiet) {
|
||||
console.warn(
|
||||
c.warn(
|
||||
" Could not persist the parallel drawElement trial's off-switch to " +
|
||||
"~/.hyperframes/config.json (unwritable?). The experiment stays off for this " +
|
||||
"process; future runs may retry it. Set HF_DE_PARALLEL_ROUTER=false to opt out.",
|
||||
),
|
||||
);
|
||||
}
|
||||
if (fired) reportDeParallelRouterBreakerTrip(quiet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the user the breaker tripped, and warn if the verdict couldn't be
|
||||
* persisted. All output is `!quiet`-gated: quiet/batch-json renders must
|
||||
* produce no unexpected terminal output — CI wrappers asserting empty stderr
|
||||
* would misread a warning as a render failure (review finding). The
|
||||
* in-process latch guarantees the safety behavior either way.
|
||||
*/
|
||||
function reportDeParallelRouterBreakerTrip(quiet: boolean): void {
|
||||
const persisted = persistDeParallelRouterTrialFired();
|
||||
if (quiet) return;
|
||||
console.log(
|
||||
c.dim(
|
||||
" A frame failed verification, so parallel drawElement capture fell back to the " +
|
||||
"screenshot path and is now off for this install. Re-enable: " +
|
||||
"HF_DE_PARALLEL_ROUTER=true",
|
||||
),
|
||||
);
|
||||
if (persisted) return;
|
||||
console.warn(
|
||||
c.warn(
|
||||
" Could not persist the parallel drawElement circuit breaker to " +
|
||||
"~/.hyperframes/config.json (unwritable?). It stays off for this process; " +
|
||||
"future runs may retry it. Set HF_DE_PARALLEL_ROUTER=false to opt out for good.",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function handleRenderError(
|
||||
|
||||
Reference in New Issue
Block a user