Skip to content

Commit 941167d

Browse files
authored
Merge pull request #2874 from heygen-com/feat/breaker-carryover
feat(cli): roll circuit-breaker state over across config wipes
2 parents b2e7d76 + ec76985 commit 941167d

5 files changed

Lines changed: 276 additions & 10 deletions

File tree

packages/cli/src/commands/telemetry.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ async function loadTelemetryCommand(options?: {
2727
vi.resetModules();
2828
vi.doMock("../telemetry/config.js", () => ({
2929
CONFIG_PATH: "/test/.hyperframes/config.json",
30+
STATE_PATH: "/test/.local/state/hyperframes/install-state.json",
3031
readConfig: () => {
3132
throw new Error("telemetry commands must bypass stale cached config");
3233
},

packages/cli/src/commands/telemetry.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { defineCommand } from "citty";
2-
import { writeConfigWithResult, readConfigFresh, CONFIG_PATH } from "../telemetry/config.js";
2+
import {
3+
writeConfigWithResult,
4+
readConfigFresh,
5+
CONFIG_PATH,
6+
STATE_PATH,
7+
} from "../telemetry/config.js";
38
import { effectiveTelemetryStatus, type TelemetryStatusSource } from "../telemetry/policy.js";
49
import { c } from "../ui/colors.js";
510
import { failCommand } from "../utils/commandResult.js";
@@ -56,6 +61,9 @@ function runStatus(): void {
5661
console.log(` ${c.dim("Status:")} ${status}`);
5762
console.log(` ${c.dim("Source:")} ${effective.source}`);
5863
console.log(` ${c.dim("Config:")} ${c.accent(CONFIG_PATH)}`);
64+
// Machine-local safety state (no identity): survives a config wipe so a
65+
// tripped experiment circuit breaker stays tripped. Listed for transparency.
66+
console.log(` ${c.dim("State:")} ${c.accent(STATE_PATH)}`);
5967
console.log(` ${c.dim("Tracked commands:")} ${c.bold(String(config.commandCount))}`);
6068
console.log();
6169
console.log(` ${c.dim("Disable:")} ${c.accent("hyperframes telemetry disable")}`);

packages/cli/src/telemetry/client.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,12 @@ export function trackEvent(
7373
// New-agent discovery signals — populated only when agent_runtime is null.
7474
agent_hint: sys.agent_hint ?? undefined,
7575
term_program: sys.term_program ?? undefined,
76+
// Did this install's mint find a previous install's state marker?
77+
// The fleet-wide rate of `true` IS the recoverable-churn fraction —
78+
// the share of "new" ids that are really a config wipe on a machine
79+
// we already knew. Absent (not false) when the config predates the
80+
// marker. Resolved after the shouldTrack guard.
81+
install_predecessor_found: readConfig().predecessorFound,
7682
agent_env_hints: sys.agent_env_hints ?? undefined,
7783
},
7884
distinctId,

packages/cli/src/telemetry/config.test.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,3 +129,120 @@ describe("config.ts — readConfig / readConfigFresh / writeConfig (real module,
129129
});
130130
});
131131
});
132+
133+
describe("install-state rollover (breaker survives a config wipe)", () => {
134+
let readConfig: typeof import("./config.js").readConfig;
135+
let readConfigFresh: typeof import("./config.js").readConfigFresh;
136+
let writeConfig: typeof import("./config.js").writeConfig;
137+
let CONFIG_PATH: typeof import("./config.js").CONFIG_PATH;
138+
let STATE_PATH: typeof import("./config.js").STATE_PATH;
139+
140+
beforeEach(async () => {
141+
fsState.files.clear();
142+
vi.resetModules();
143+
({ readConfig, readConfigFresh, writeConfig, CONFIG_PATH, STATE_PATH } =
144+
await import("./config.js"));
145+
});
146+
147+
/** Simulate the identity reset this feature exists for. */
148+
function wipeConfig(): void {
149+
fsState.files.delete(CONFIG_PATH);
150+
}
151+
152+
function stateFile(): Record<string, unknown> {
153+
const raw = fsState.files.get(STATE_PATH);
154+
expect(raw, "install-state file should exist").toBeDefined();
155+
return JSON.parse(raw as string) as Record<string, unknown>;
156+
}
157+
158+
it("a truly fresh install writes the marker and records predecessorFound: false", () => {
159+
const config = readConfig();
160+
expect(config.predecessorFound).toBe(false);
161+
expect(stateFile()["markerAt"]).toEqual(expect.any(String));
162+
});
163+
164+
it("a re-mint after a config wipe finds the marker: predecessorFound is true, id is fresh", () => {
165+
const first = readConfig();
166+
wipeConfig();
167+
const second = readConfigFresh();
168+
expect(second.predecessorFound).toBe(true);
169+
// The rollover carries safety state, never identity.
170+
expect(second.anonymousId).not.toBe(first.anonymousId);
171+
});
172+
173+
it("a tripped breaker survives a config wipe — the whole point", () => {
174+
const config = readConfig();
175+
config.deParallelRouterTrialFired = true;
176+
writeConfig(config);
177+
wipeConfig();
178+
const reborn = readConfigFresh();
179+
expect(reborn.deParallelRouterTrialFired).toBe(true);
180+
});
181+
182+
it("an untripped breaker does NOT get invented by the rollover", () => {
183+
readConfig();
184+
wipeConfig();
185+
expect(readConfigFresh().deParallelRouterTrialFired).toBeUndefined();
186+
});
187+
188+
it("a tripped breaker survives config CORRUPTION via the same path", () => {
189+
const config = readConfig();
190+
config.deParallelRouterTrialFired = true;
191+
writeConfig(config);
192+
fsState.files.set(CONFIG_PATH, "{not valid json");
193+
expect(readConfigFresh().deParallelRouterTrialFired).toBe(true);
194+
});
195+
196+
it("the state file holds no identity — only the marker timestamp and breaker fact", () => {
197+
const config = readConfig();
198+
config.deParallelRouterTrialFired = true;
199+
writeConfig(config);
200+
expect(Object.keys(stateFile()).sort()).toEqual(["deParallelRouterTrialFired", "markerAt"]);
201+
expect(JSON.stringify(stateFile())).not.toContain(config.anonymousId);
202+
});
203+
204+
it("marker timestamp is written once, not refreshed by later config writes", () => {
205+
const config = readConfig();
206+
const minted = stateFile()["markerAt"];
207+
config.commandCount = 42;
208+
config.deParallelRouterTrialFired = true;
209+
writeConfig(config);
210+
expect(stateFile()["markerAt"]).toBe(minted);
211+
});
212+
213+
it("a corrupted state file reads as absent rather than breaking the mint", () => {
214+
fsState.files.set(STATE_PATH, "{not valid json");
215+
const config = readConfig();
216+
expect(config.predecessorFound).toBe(false);
217+
expect(config.anonymousId).toBeTruthy();
218+
// And the corrupt file was replaced with a valid marker by the mint's write.
219+
expect(stateFile()["markerAt"]).toEqual(expect.any(String));
220+
});
221+
222+
it("a state-file write failure never breaks the config write", async () => {
223+
const fs = await import("node:fs");
224+
const config = readConfig(); // marker already written by the mint
225+
fsState.files.delete(STATE_PATH); // force a re-sync attempt...
226+
const { __resetInstallStateSyncForTests } = await import("./config.js");
227+
__resetInstallStateSyncForTests();
228+
let calls = 0;
229+
vi.mocked(fs.writeFileSync).mockImplementation((path, content) => {
230+
calls++;
231+
if (String(path).startsWith(STATE_PATH)) throw new Error("EACCES");
232+
fsState.files.set(String(path), String(content));
233+
});
234+
config.commandCount = 1;
235+
expect(writeConfig(config)).toBe(true); // ...that fails, swallowed
236+
expect(calls).toBeGreaterThan(1);
237+
vi.mocked(fs.writeFileSync).mockImplementation((path, content) => {
238+
fsState.files.set(String(path), String(content));
239+
});
240+
});
241+
242+
it("predecessorFound on an EXISTING config predating the field reads as undefined, not false", () => {
243+
const base = readConfig();
244+
const { predecessorFound: _dropped, ...legacy } = base;
245+
fsState.files.set(CONFIG_PATH, JSON.stringify(legacy));
246+
expect(readConfigFresh().predecessorFound).toBeUndefined();
247+
});
248+
});

packages/cli/src/telemetry/config.ts

Lines changed: 143 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,128 @@ import { normalizeErrorMessage } from "../utils/errorMessage.js";
1111
const CONFIG_DIR = join(homedir(), ".hyperframes");
1212
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
1313

14+
// ---------------------------------------------------------------------------
15+
// Install-state file: ~/.local/state/hyperframes/install-state.json
16+
//
17+
// A second, deliberately separate location from CONFIG_DIR, so it survives
18+
// the most common identity reset — deleting or reinstalling ~/.hyperframes.
19+
// It exists to carry exactly two facts across that reset, and nothing else:
20+
//
21+
// 1. `markerAt` — "a hyperframes install existed on this machine". Written
22+
// unconditionally, so the fraction of fresh installs that find it is a
23+
// direct measurement of recoverable id churn (config wiped, machine
24+
// persisted) vs unrecoverable (fresh machine/container/new user).
25+
// 2. `deParallelRouterTrialFired` — the DE parallel-router circuit
26+
// breaker's tripped state. Without this, a config wipe re-enrols the
27+
// install into an experimental path that already FAILED on this exact
28+
// machine; the breaker's whole point is that a real failure turns the
29+
// trial off for good.
30+
//
31+
// It intentionally holds NO identity: no anonymousId, no counters, nothing
32+
// that could link the old install to the new one. A user who wipes their
33+
// config gets a fresh id unconditionally — this file only stops the wipe
34+
// from also discarding a safety fact about the machine.
35+
// ---------------------------------------------------------------------------
36+
37+
const STATE_DIR = join(homedir(), ".local", "state", "hyperframes");
38+
const STATE_FILE = join(STATE_DIR, "install-state.json");
39+
40+
interface InstallState {
41+
/** ISO timestamp of when the marker was first written. */
42+
markerAt: string;
43+
/** Rolled-over circuit-breaker state — see HyperframesConfig's field. */
44+
deParallelRouterTrialFired?: boolean;
45+
}
46+
47+
/** Read the install-state file; any parse/shape failure reads as absent. */
48+
function readInstallState(): InstallState | null {
49+
try {
50+
if (!existsSync(STATE_FILE)) return null;
51+
const parsed = JSON.parse(readFileSync(STATE_FILE, "utf-8")) as Partial<InstallState>;
52+
if (typeof parsed.markerAt !== "string") return null;
53+
return {
54+
markerAt: parsed.markerAt,
55+
deParallelRouterTrialFired: parsed.deParallelRouterTrialFired === true ? true : undefined,
56+
};
57+
} catch {
58+
return null;
59+
}
60+
}
61+
62+
// Sync bookkeeping, so the existsSync+read doesn't run on every writeConfig:
63+
// `stateMarkerSynced` = the marker is known present; `stateFiredSynced` = the
64+
// state file is known to already carry fired=true.
65+
let stateMarkerSynced = false;
66+
let stateFiredSynced = false;
67+
68+
/** Test-only: reset the sync memo (module state leaks across vitest cases). */
69+
export function __resetInstallStateSyncForTests(): void {
70+
stateMarkerSynced = false;
71+
stateFiredSynced = false;
72+
}
73+
74+
/**
75+
* Atomic for the same reason writeConfig is: a torn read must never exist,
76+
* since a corrupted state file silently reads as absent.
77+
*/
78+
function writeInstallState(next: InstallState): void {
79+
mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 });
80+
const tmpFile = `${STATE_FILE}.${process.pid}.tmp`;
81+
writeFileSync(tmpFile, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
82+
renameSync(tmpFile, STATE_FILE);
83+
}
84+
85+
/**
86+
* Bring the install-state file up to date with this config write: ensure the
87+
* marker exists, and mirror a tripped breaker. Called from `writeConfig` so
88+
* no breaker write site can forget it. Never throws — same contract as the
89+
* rest of this file, telemetry must not break the CLI.
90+
*/
91+
/** What the state file should say after this config write; null = already correct. */
92+
function nextInstallState(state: InstallState | null, wantFired: boolean): InstallState | null {
93+
const hadFired = state?.deParallelRouterTrialFired === true;
94+
if (state !== null && (hadFired || !wantFired)) return null;
95+
// Every path reaching here has hadFired === false (state is either null, or
96+
// the guard above already returned when hadFired was true) — the field is
97+
// simply wantFired, not a merge of the two (review nit, two independent
98+
// reviewers).
99+
return {
100+
markerAt: state?.markerAt ?? new Date().toISOString(),
101+
deParallelRouterTrialFired: wantFired || undefined,
102+
};
103+
}
104+
105+
function syncInstallState(config: HyperframesConfig): void {
106+
const wantFired = config.deParallelRouterTrialFired === true;
107+
if (stateMarkerSynced && (stateFiredSynced || !wantFired)) return;
108+
try {
109+
const state = readInstallState();
110+
const next = nextInstallState(state, wantFired);
111+
if (next !== null) writeInstallState(next);
112+
stateMarkerSynced = true;
113+
stateFiredSynced = wantFired || state?.deParallelRouterTrialFired === true;
114+
} catch {
115+
// Leave the memo unset so a later write retries.
116+
}
117+
}
118+
119+
/**
120+
* Build a brand-new config for an install with no (readable) config file,
121+
* consulting the install-state file for what a previous install on this
122+
* machine left behind.
123+
*/
124+
function mintConfig(): HyperframesConfig {
125+
const state = readInstallState();
126+
return {
127+
...DEFAULT_CONFIG,
128+
anonymousId: randomUUID(),
129+
predecessorFound: state !== null,
130+
// The rollover itself: a breaker tripped by a previous install on this
131+
// machine stays tripped for the new one.
132+
deParallelRouterTrialFired: state?.deParallelRouterTrialFired === true ? true : undefined,
133+
};
134+
}
135+
14136
export interface HyperframesConfig {
15137
/** Whether anonymous telemetry is enabled (default: true in production) */
16138
telemetryEnabled: boolean;
@@ -86,6 +208,14 @@ export interface HyperframesConfig {
86208
* `DE_PARALLEL_ROUTER_TRIAL_MAX_RENDERS` in `render.ts`.
87209
*/
88210
deParallelRouterTrialRenderCount?: number;
211+
/**
212+
* Whether a previous install's state marker existed on this machine when
213+
* this config was minted. Attached to telemetry so the fraction of fresh
214+
* installs that are RECOVERABLE churn (config wiped, machine persisted) is
215+
* measurable directly. `undefined` on configs minted before this field
216+
* existed — a different fact from `false` (minted fresh, no predecessor).
217+
*/
218+
predecessorFound?: boolean;
89219
/**
90220
* Ring of the last few local renders (newest last). `hyperframes feedback`
91221
* attaches these ids — which are the `render_job_id` /
@@ -140,7 +270,7 @@ export function readConfig(): HyperframesConfig {
140270
if (cachedConfig) return { ...cachedConfig };
141271

142272
if (!existsSync(CONFIG_FILE)) {
143-
const config = { ...DEFAULT_CONFIG, anonymousId: randomUUID() };
273+
const config = mintConfig();
144274
writeConfig(config);
145275
return config;
146276
}
@@ -176,6 +306,8 @@ export function readConfig(): HyperframesConfig {
176306
typeof parsed.deParallelRouterTrialRenderCount === "number"
177307
? parsed.deParallelRouterTrialRenderCount
178308
: undefined,
309+
predecessorFound:
310+
typeof parsed.predecessorFound === "boolean" ? parsed.predecessorFound : undefined,
179311
recentRenders: Array.isArray(parsed.recentRenders)
180312
? parsed.recentRenders
181313
.filter(
@@ -195,14 +327,10 @@ export function readConfig(): HyperframesConfig {
195327
} catch {
196328
// A missing file is handled above. Any failure here means an existing
197329
// preference could not be read safely (corrupt JSON, permissions, I/O).
198-
// Preserve the historical recovery behavior for the rest of the config,
199-
// but fail closed for the privacy control: recovery must never silently
200-
// turn telemetry back on.
201-
const config = {
202-
...DEFAULT_CONFIG,
203-
telemetryEnabled: false,
204-
anonymousId: randomUUID(),
205-
};
330+
// Recover through the same mint path as a missing file — so a tripped
331+
// breaker survives config corruption too — but fail closed for the
332+
// privacy control: recovery must never silently turn telemetry back on.
333+
const config = { ...mintConfig(), telemetryEnabled: false };
206334
writeConfig(config);
207335
return config;
208336
}
@@ -254,6 +382,9 @@ export function writeConfigWithResult(config: HyperframesConfig): ConfigWriteRes
254382
writeFileSync(tmpFile, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
255383
renameSync(tmpFile, CONFIG_FILE);
256384
cachedConfig = { ...config };
385+
// Mirror into the install-state file (marker + tripped breaker) so no
386+
// breaker write site has to remember to do it.
387+
syncInstallState(config);
257388
return { ok: true };
258389
} catch (error) {
259390
// Non-fatal — telemetry should never break the CLI
@@ -273,3 +404,6 @@ export function incrementCommandCount(): number {
273404

274405
/** Expose the config directory path for the telemetry command output */
275406
export const CONFIG_PATH = CONFIG_FILE;
407+
408+
/** Expose the install-state path for the telemetry command output. */
409+
export const STATE_PATH = STATE_FILE;

0 commit comments

Comments
 (0)