Skip to content

feat(cli): check replays an unchanged project's last pass and takes --no-craft - #7398

Merged
miguel-heygen merged 10 commits into
mainfrom
perf/check-cache
Nov 8, 2026
Merged

miguel-heygen merged 10 commits into
mainfrom
perf/check-cache

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Nov 8, 2026 •

Copy link
Copy Markdown
Collaborator

What

Two switches that make a repeated hyperframes check cheap.

  1. hyperframes check on a project that has not changed since its last passing check returns that pass in under a second instead of re-running the browser pass. The output says it is the stored result, and --no-cache runs the check again.

  2. --no-craft skips the craft pass (dead stretches, unread lines, silent films); the report shows Craft as skipped and the pass stays on by default.

Why

A repeated check on an untouched tree costs the full run (about 12 s on a small film, over a minute on a long one), which is longer than the render it guards when an agent re-checks after every step. On a 78 s film the craft pass is about half of a full check (599 of its 600 samples), and a render preflight had no way to leave it out.

Related work

Refs the request to skip a tree unchanged since the last pass and to make the craft pass switchable. Start-up costs follow separately.

How

  • utils/checkCache.ts keys a result on the CLI version, every flag (timeout included), the HYPERFRAME*, PRODUCER_* and HF_* environment, and the project files: content hash up to 2 MB per file, above that size plus mtime, ctime and inode (a tool cannot put ctime back). The browser binary (path, size, mtime, ctime, inode) is in the key, and no installed browser means no cache. Under .hyperframes only what the CLI writes (artifacts, cache, history-id, video-state*, project.lock) and .git are left out; a project with node_modules or a linked directory is never cached.
  • Only a pass that ran the browser is stored, one result per project in .hyperframes/cache/check.json. A failure, a browser-skipped run, a --budget run and the artifact-producing flags (--snapshots, finding frames, inspect) are never stored or replayed.
  • The cached report carries cached: { at } in --json and a one-line notice in the human report.
  • Lookup, run and store all happen under the project lock, and a pass is stored only when the tree still hashes to the key after the run, so a tree that moved during the check is never replayed. A hit does no checkpoint or history work.
  • Remote scripts and assets are not part of the key and are not fetched again on a hit; the cached notice and the docs say so, and --no-cache re-checks them.
  • craft is read by craftEnabled, the one place that decides whether craft frames are sampled; --range already turned it off through the same line.
  • Searched for an existing project-tree hash or result cache (git grep -n -i "contentHash\|treeHash\|hashTree" over packages/cli/src): only lint telemetry hashes single files, so there was nothing to reuse.

Test plan

  • Unit tests: an unchanged tree hits, one changed byte misses, a failed report and a browser-skipped report are never stored, and different flags, --no-cache and --budget re-run. Each fails when its guard is removed (stored-failure guard, content hashing).
  • Timings on one machine and film (registry/examples/kinetic-type):
    • --no-cache, same work as before this change: 12235 ms, 12164 ms, 11840 ms
    • first run with a cold cache: 12195 ms
    • repeat on the unchanged tree: 837 ms, 845 ms, 696 ms
  • --no-craft timings, same machine, a 13-scene 78 s synthetic film, alternating runs:
    • default: 10091, 8800, 8267, 8548, 8545 ms
    • --no-craft: 4310, 4117, 4315, 4280, 4498 ms
  • Documentation updated (--no-cache in the CLI reference)
  • Comments say why, not what

Size

About 300 changed lines across two small switches that share the check command's flag and option plumbing, kept in one PR so one screen has one PR.

@mintlify

mintlify Bot commented Nov 8, 2026 •

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated
hyperframes 🟢 Ready View Preview Nov 8, 2026, 1:14 PM

💡 Tip: Enable Automations to automatically generate PRs for you.

@github-actions

github-actions Bot commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 267 (base branch 267), smooth 98 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (8)

@jerrai-bot-heygen jerrai-bot-heygen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At 4f3f314, stale-PASS cache paths and a failing required CLI test remain; details and concrete fixes inline. A pass must not be replayed for different executable inputs. No merge or enqueue by reviewer. — Jerrai

Comment thread packages/cli/src/commands/check.ts Outdated
project: ProjectDir,
options: CheckOptions,
): Promise<SavedCheckReport> {
const key = checkCacheable(options) ? checkCacheKey(project.dir, options, VERSION) : undefined;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cache key can be associated with a different revision than the one checked. The fingerprint and replay happen before withProjectLock, and storePass uses that original key after the locked run without re-fingerprinting. If a writer holds the lock while revision A would fail, check hashes A and waits; the writer finishes passing revision B, check checks B and stores PASS under A's key. Restoring A now returns exit 0 without checking it. A cache hit also bypasses a held project lock. Move fingerprint/lookup inside the lock and verify the fingerprint again after the run before storing a reusable PASS; add a controlled writer/check interleaving test. This is distinct from merely making cache writes atomic.

const path = join(dir, entry.name);
const name = `${prefix}${entry.name}`;
if (entry.isDirectory()) hashTree(path, `${name}/`, hash);
else hash.update(`${name}\0${fileFingerprint(path)}\0`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Local executable inputs are omitted. This walk skips every node_modules directory, and a directory symlink is treated as a file whose fileFingerprint returns the constant "dir"; it never traverses its target. A composition that imports a local JS module through node_modules or a symlinked asset directory can pass, then have that imported module changed to throw while the tree key remains identical. Fresh browser check would report a page error, but cached check returns PASS. Hash the actual resolved dependency graph/content (or disable replay when a skipped/symlinked executable dependency is reachable); add an external-assets edit regression, not just an index.html edit.

const stat = fstatSync(fd);
if (stat.isDirectory()) return "dir";
return stat.size <= HASH_CONTENT_UP_TO
? createHash("sha256").update(readFileSync(fd)).digest("hex")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Size+mtime is not a content-equivalence check above 2 MiB. A passing HTML/composition or media file over this threshold can be modified to the same byte length while its mtime is preserved/restored (some editors or sync tools do this). The key remains unchanged even if a required composition ID is removed or media corrupted; a fresh check would fail but the cache returns PASS. For a success verdict, hash content regardless of size or make a non-hashed large input uncacheable; test a same-size/same-mtime changed asset. This is especially important when an agent treats check as the release preflight.

Comment thread packages/cli/src/utils/checkCache.ts Outdated
): string | undefined {
const hash = createHash("sha256").update(`${version}\0`);
const { timeout: _timeout, budget: _budget, cache: _cache, ...verdictOptions } = options;
hash.update(`${JSON.stringify(verdictOptions)}\0`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict-changing parameters are stripped or absent. timeout is excluded here even though it controls browser navigation/readiness and the pipeline deadline: pass with 45 s, rerun identical files with --timeout 500, and the old PASS is replayed instead of exercising the shorter timeout. The key also omits HYPERFRAME_RUNTIME_URL, which changes the injected runtime URL; an unchanged project can replay PASS after that runtime starts returning an error. Mutable remote scripts/assets have the same invalidation gap because no response identity or freshness is captured. Include the timeout and behavior-changing env in the key, and either revalidate remote dependencies or explicitly make projects with them uncacheable; add tests for a changed runtime/timeout after a cached pass.

@miguel-heygen miguel-heygen changed the title feat(cli): check returns an unchanged project's last pass in about a second feat(cli): check replays an unchanged project's last pass and takes --no-craft Nov 8, 2026
@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

Fixed at a34814c: lookup, run and store now sit under the project lock and a pass is stored only if the tree still hashes to its key afterwards (test: a writer changes the tree during the run, then the old content returns); node_modules and linked directories make a project uncacheable; files over 2 MiB are keyed by size, mtime, ctime and inode (test: same size, mtime put back); timeout and the HYPERFRAME*/PRODUCER_/HF_ env are in the key (test: timeout and runtime URL change). Each new test fails when its guard is removed. Remote scripts and assets are still not re-fetched on a hit; the cached notice and docs say so, and --no-cache re-checks them. The failing CLI test was the warm check test comparing a run with its own cached pass; it now passes --no-cache.

@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

Re-review requested at ef994ce. Findings from 4f3f314 are answered in a34814c and 6aad021 (lock + recheck, node_modules/links uncacheable, ctime for large files, timeout and env in the key, each with a test shown to fail without its guard). Main is merged in, so CI now runs on the ffmpeg install fix.

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: APPROVE · 0 blocker · 0 important · 2 nits · head ef994ced

I checked all five points from @jerrai-bot-heygen's 4f3f314 review against the code, and each one holds. Lookup, run and store sit inside withProjectLock, and the tree is fingerprinted again before a store. node_modules and linked directories return Uncacheable. Files over 2 MiB are keyed by size, mtime, ctime and inode. The timeout and the HYPERFRAMES?_/PRODUCER_/HF_ env are in the key, and the warm daemon declines when the env digest differs. Remote assets are disclosed and left to --no-cache.

  • nit: the browser is not in the key. When the CLI falls back to system Chrome (browser/manager.ts:445), a Chrome auto-update replays a pass made in the old browser. Folding the resolved executable path and its version into the key would close this.
  • nit: running from source, VERSION is 0.0.0-dev (version.ts:3). Someone editing the checker and re-running check on the same example gets the old pass. The notice line points to --no-cache, so this only affects development.
Scope

Read in full: checkCache.ts, the check.ts changes (checkOrReplay, flags, notice), craftEnabled, the new tests and the warm-check env proof. The existing tests use /project, which does not exist, so they never write a cache. Every CI workflow on this head passed, including Test (cli 1/2, 2/2), CLI smoke and Typecheck. I did not run the tests locally because this sandbox has no bun.

@jerrai-bot-heygen jerrai-bot-heygen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At ef994ced, the earlier five fixes and current-head CI are verified, but the two inline stale-PASS cases remain. Please close those cache-key omissions before a stamp. No merge/enqueue by reviewer.

— Jerrai

Comment thread packages/cli/src/utils/checkCache.ts Outdated
a.name < b.name ? -1 : 1,
);
for (const entry of entries) {
if (SKIPPED_DIRS.has(entry.name)) continue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The excluded .hyperframes tree can contain executable composition input. hashTree skips any .hyperframes directory before checking its files or symlinks, but the HTML bundler reads relative local scripts and the project server serves any in-root file, including .hyperframes/probe.js. A project whose HTML already references that script can pass, then the script alone changes to console.error(...); an identical cached check returns PASS while --no-cache reports console_error. A directory symlink inside this skipped tree likewise bypasses the new linked-directory rejection. Please fingerprint referenced authored files in the namespace or make such projects uncacheable; test a change solely under .hyperframes.

const env = Object.entries(process.env)
.filter(([name]) => VERDICT_ENV.test(name))
.sort();
hash.update(`${JSON.stringify([verdictOptions, env])}\0`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The key does not identify the browser that produced the PASS. It captures the HYPERFRAMES_BROWSER_PATH string but not the executable at that path or its version; browser/manager.ts may also select a managed/system Chrome that updates independently of the project. For a concrete failure, pass with a browser wrapper at a stable override path, replace the wrapper with one that exits unsuccessfully, and rerun the same project/options: the cache returns the old PASS while --no-cache cannot run browser validation. Include the resolved browser executable identity/version in the key (or disable replay when it can change), and add a changed-browser regression.

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: APPROVE · 1 resolved, 1 open (nit), 1 new (important, non-blocking) · ef994ced → 43d185f0

Comment thread packages/cli/src/utils/checkCache.ts Outdated
a.name < b.name ? -1 : 1,
);
for (const entry of entries) {
if (entry.name === ".git") continue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

important (non-blocking): this skips the wrong outcome rarely but misses the speed-up often. Projects whose video Chrome cannot decode never store a pass, so the cache never hits for them. check runs preResolveHostileMediaProxies → resolveProxies → resolveProxy. For a copy that already exists, resolveProxy calls markCacheEntryUsed, which runs utimesSync (studio-server/src/helpers/proxyTranscoder.ts:362). The copies live in <project>/.transcode-cache and are usually over 2 MiB, so their mtime and ctime are in the key. The re-key after the run therefore never matches. The copies are derived from sources that are already in the key, so skipping that directory like .git keeps the key sound:

Suggested change
if (entry.name === ".git") continue;
if (entry.name === ".git" || entry.name === ".transcode-cache") continue;

A test with a >2 MiB file under .transcode-cache that is touched between two runs would pin it (PROXY_CACHE_DIR_NAME is the shared name).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Fixed in 4fc5f87

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: APPROVE · 1 resolved, 1 open (nit), 0 new · 43d185f0 → 4fc5f875

@jerrai-bot-heygen jerrai-bot-heygen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed 4fc5f875 against main and my two ef994ced findings. The project-tree key now descends into .hyperframes and excludes only named tool-owned state at its root; a script there and a linked directory under it are visible to the fingerprint/uncacheable rule. The resolved browser binary is identified before lookup and again before store using path, size, mtime, ctime and inode; if no browser is installed there is no cache hit or store. The tests cover a changed .hyperframes/probe.js, a linked directory there, a changed browser identity and a missing browser. The cached human notice and CLI reference now explicitly say remote scripts/assets are not re-fetched; --no-cache is the fresh-read option. The intermediate .transcode-cache fix uses the shared proxy-transcoder constant and avoids invalidation from derived video-copy touches. Earlier lock/post-run hash, visible node_modules/symlink, large-file ctime/inode, timeout/env and warm-check fixes remain. Exact-head hosted CLI shards, typecheck and other checks executed green. No confirmed remaining blocker in the scoped cache contract. Runtime replay and author's speed numbers were not independently reproduced. This approval supersedes my prior change requests; no reviewer merge/enqueue.

— Jerrai

@miguel-heygen
miguel-heygen added this pull request to the merge queue Nov 8, 2026
Merged via the queue into main with commit 070b964 Nov 8, 2026
86 checks passed
@miguel-heygen
miguel-heygen deleted the perf/check-cache branch November 8, 2026 14:18
This was referenced Nov 8, 2026
SanHsien added a commit to SanHsien/hyperframes that referenced this pull request Nov 9, 2026
…red-row line counts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants