fix(cli): atomic config writes, gated trial warning, and write-failure signal

Five findings from a fifth (final scoped) max-effort review of the previous
commit, all local:

1. writeConfig now writes atomically (pid-suffixed temp file + renameSync —
   rename within one directory is atomic on POSIX). This closes the real
   hazard behind the review's torn-read finding: readConfig's corrupted-file
   catch RESETS the config to defaults (telemetry re-enabled, anonymousId
   rotated, trial fields wiped), so a concurrent reader catching a
   non-atomic write mid-flight would silently destroy the user's config —
   and the previous commit's per-render readConfigFresh() at the arm site
   multiplied exposure to exactly that window. Verified against a real
   filesystem, not just the mocked unit tests.

2. writeConfig now returns whether the write landed (errors still swallowed
   — telemetry must never break the CLI). persistDeParallelRouterTrialFired
   uses it to stop immediately on a genuine fs failure (retrying an
   unwritable file is pointless) and reserve its retries for actual
   concurrent clobbers, instead of 3 blind write attempts + 4 disk reads.

3. The persistence-failure console.warn is now !quiet-gated like every
   other trial message — a quiet/batch-json render on an unwritable
   ~/.hyperframes no longer emits unexpected stderr that CI wrappers
   asserting empty stderr would misread as a render failure. The in-process
   latch already guarantees the safety behavior whether or not the warning
   prints.

4. The arm site short-circuits on the in-process fired latch BEFORE the
   fresh config read — post-fired batch rows no longer pay a per-row config
   read + parse + shared-cache invalidation for an answer module state
   already knows.

5. Replaced the new `as T` assertions in render.test.ts's config-state
   factory with an explicitly typed vi.hoisted return (repo TypeScript
   convention: no `as T`).

config.test.ts: node:fs mock gains renameSync (faithful to the new atomic
write); new test covers the success/failure return and asserts no temp file
survives a write.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-07-10 14:51:11 -07:00
co-authored by Claude Fable 5
parent 2542e94277
commit 6172d79dc2
4 changed files with 96 additions and 33 deletions
+26 -21
View File
@@ -10,26 +10,30 @@ const producerState = vi.hoisted(() => ({
executeImpl: async (_job: Record<string, unknown>): Promise<void> => undefined,
}));
const configState = vi.hoisted(() => ({
// Defaults to "trial already fired" so the pre-existing renderLocal tests
// below (which predate the DE-parallel-router trial and don't expect
// HF_DE_PARALLEL_ROUTER to be touched) keep their exact prior behavior.
//
// `disk` is the authoritative "file"; `cache` models config.ts's real
// process-lifetime cachedConfig. Modeling them SEPARATELY matters: a mock
// where readConfig/readConfigFresh both read one live object hides
// exactly the class of bug where production code reads the stale cache
// when it needed a fresh disk read (review finding).
disk: {
telemetryEnabled: true,
deParallelRouterTrialFired: true,
} as Record<string, unknown>,
cache: null as Record<string, unknown> | null,
writeConfigCalls: [] as Array<Record<string, unknown>>,
// Simulate the real writeConfig's silent fs-error swallowing (unwritable
// ~/.hyperframes): the next N writes are recorded but never reach `disk`.
failWrites: 0,
}));
// Defaults to "trial already fired" so the pre-existing renderLocal tests
// below (which predate the DE-parallel-router trial and don't expect
// HF_DE_PARALLEL_ROUTER to be touched) keep their exact prior behavior.
//
// `disk` is the authoritative "file"; `cache` models config.ts's real
// process-lifetime cachedConfig. Modeling them SEPARATELY matters: a mock
// where readConfig/readConfigFresh both read one live object hides exactly
// the class of bug where production code reads the stale cache when it
// needed a fresh disk read (review finding). `failWrites` simulates the
// real writeConfig's silent fs-error swallowing (unwritable ~/.hyperframes):
// the next N writes are recorded but never reach `disk`.
const configState = vi.hoisted(
(): {
disk: Record<string, unknown>;
cache: Record<string, unknown> | null;
writeConfigCalls: Array<Record<string, unknown>>;
failWrites: number;
} => ({
disk: { telemetryEnabled: true, deParallelRouterTrialFired: true },
cache: null,
writeConfigCalls: [],
failWrites: 0,
}),
);
const trackingState = vi.hoisted(() => ({
// maybeEnableDeParallelRouterTrial gates on the real shouldTrack(), which
@@ -91,10 +95,11 @@ vi.mock("../telemetry/config.js", () => ({
configState.writeConfigCalls.push({ ...config });
if (configState.failWrites > 0) {
configState.failWrites--;
return; // swallowed silently, like the real writeConfig's catch {}
return false; // swallowed silently, like the real writeConfig's catch {}
}
configState.disk = { ...config };
configState.cache = { ...config };
return true;
}),
}));