From fba7b753ecbb2d24c2e3060d0d8da3c028cf46ce Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:35:51 -0700 Subject: [PATCH] Cache mobile typechecks and overlap CI setup (#28192) * ci: run mobile runtime test groups on separate runners * ci: use a lightweight runner for the mobile result gate * ci: distribute Bun tests across both mobile runners * ci: reuse mobile compiler state without adding runner queues * test: verify eviction of source variants in one cache bucket * test: baseline existing serializer limits in the capacity capture --- .github/workflows/mobile.yml | 36 +++++++++++++- config/mobile-ci.vitest.config.mjs | 1 + .../ci-dependency-download-cache.test.mjs | 1 + .../scripts/mobile-test-change-scope.test.mjs | 10 +++- .../scripts/mobile-typecheck-cache.test.mjs | 41 ++++++++++++++++ mobile/.gitignore | 2 + ...-tests-typecheck-incremental-cache.test.ts | 47 +++++++++++++++++++ .../scripts/check-tests-typecheck-ratchet.mjs | 24 ++++++---- .../compiled-operation-module.test.ts | 14 ++++++ .../compiled-operation-module.ts | 32 +++++++++---- .../golden-value-pool-serialization.test.ts | 30 ++++++++++++ .../rpc-recording/golden-value-pool.ts | 8 +++- .../serialize-grid-transcript-replay.test.ts | 2 + 13 files changed, 224 insertions(+), 24 deletions(-) create mode 100644 config/scripts/mobile-typecheck-cache.test.mjs create mode 100644 mobile/scripts/check-tests-typecheck-incremental-cache.test.ts create mode 100644 mobile/src/test-support/rpc-recording/golden-value-pool-serialization.test.ts diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml index 57ac9819d91..f49f000e47b 100644 --- a/.github/workflows/mobile.yml +++ b/.github/workflows/mobile.yml @@ -81,6 +81,7 @@ jobs: # from the Gemfile, which is how the release jobs could land on different # fastlane versions in the first place. Fail here instead. BUNDLE_FROZEN: 'true' + ORCA_MOBILE_TYPECHECK_CACHE_DIR: ${{ github.workspace }}/mobile/.cache/typechecks defaults: run: @@ -94,6 +95,7 @@ jobs: - uses: ./.github/actions/install-node-dependencies with: + cache-pnpm-store: 'false' cache-dependency-path: | pnpm-lock.yaml mobile/pnpm-lock.yaml @@ -114,6 +116,8 @@ jobs: # proves the pinned fastlane the release workflow depends on still # resolves — before a release run finds out. - name: Setup Ruby and fastlane + id: ruby-tools + background: true if: steps.ruby-scope.outputs.should_run != 'false' uses: ruby/setup-ruby@v1 with: @@ -124,11 +128,18 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Cache mobile typecheck state + uses: actions/cache@v5 + with: + path: mobile/.cache/typechecks/*.tsbuildinfo + key: mobile-typechecks-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml', 'mobile/pnpm-lock.yaml', 'mobile/tsconfig*.json') }}-${{ github.sha }} + restore-keys: mobile-typechecks-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml', 'mobile/pnpm-lock.yaml', 'mobile/tsconfig*.json') }}- + # Call installed tools so pnpm's dependency refresh cannot race between checks. - name: Typecheck id: production-types background: true - run: node node_modules/typescript/bin/tsc --noEmit + run: node node_modules/typescript/bin/tsc --noEmit --incremental --tsBuildInfoFile "$ORCA_MOBILE_TYPECHECK_CACHE_DIR/production.tsbuildinfo" # Why a ratchet and not the raw typecheck: mobile/tsconfig.json excludes test files, so until # tsconfig.test.json existed nothing checked them, and at introduction 127 of the 632 had @@ -169,7 +180,7 @@ jobs: run: node ../config/scripts/run-mobile-ci-tests.mjs # All checks read the completed install; compilers need not hold up test workers. - - wait: [production-types, test-types] + - wait: [production-types, test-types, ruby-tools] # Reports, never gates: the goldens are content-addressed JSON, so their raw diff is hashes. # This decodes which recorded behaviour the pull request moves into the job summary, and runs @@ -207,6 +218,8 @@ jobs: main-tests: name: Mobile tests on main + env: + ORCA_MOBILE_TYPECHECK_CACHE_DIR: ${{ github.workspace }}/mobile/.cache/typechecks if: github.event_name == 'push' # Why ARM: the same pure-Node Vitest run `verify` makes on a pull request. runs-on: ubuntu-24.04-arm @@ -230,6 +243,23 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Cache mobile typecheck state + uses: actions/cache@v5 + with: + path: mobile/.cache/typechecks/*.tsbuildinfo + key: mobile-typechecks-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml', 'mobile/pnpm-lock.yaml', 'mobile/tsconfig*.json') }}-${{ github.sha }} + restore-keys: mobile-typechecks-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml', 'mobile/pnpm-lock.yaml', 'mobile/tsconfig*.json') }}- + + - name: Typecheck + id: production-types + background: true + run: node node_modules/typescript/bin/tsc --noEmit --incremental --tsBuildInfoFile "$ORCA_MOBILE_TYPECHECK_CACHE_DIR/production.tsbuildinfo" + + - name: Typecheck tests (ratchet) + id: test-types + background: true + run: node scripts/check-tests-typecheck-ratchet.mjs + # The whole suite, not only the recordings: the mutant and page-bridge suites read the goldens # too. A red run here names each golden and the `rpc:record` command; the author of the merge # that turned it red re-records in a follow-up. @@ -242,3 +272,5 @@ jobs: env: ORCA_BACKGROUND_LAUNCH: '1' run: node ../config/scripts/run-mobile-ci-tests.mjs + + - wait: [production-types, test-types] diff --git a/config/mobile-ci.vitest.config.mjs b/config/mobile-ci.vitest.config.mjs index aefae4d5ffe..a5c7684ae42 100644 --- a/config/mobile-ci.vitest.config.mjs +++ b/config/mobile-ci.vitest.config.mjs @@ -3,6 +3,7 @@ import original from '../mobile/vitest.config.ts' // Keep real process, native, and runtime-specific contracts on Node. const nodeFiles = [ + 'scripts/check-tests-typecheck-incremental-cache.test.ts', 'src/tasks/mobile-agent-launch-architecture.test.ts', 'src/tasks/agent-launch-mobile-replay.test.ts', 'src/mock-server-key-pair.test.ts', diff --git a/config/scripts/ci-dependency-download-cache.test.mjs b/config/scripts/ci-dependency-download-cache.test.mjs index e5e80b9293f..8db466b4637 100644 --- a/config/scripts/ci-dependency-download-cache.test.mjs +++ b/config/scripts/ci-dependency-download-cache.test.mjs @@ -26,6 +26,7 @@ describe('CI dependency download caches', () => { const mobile = workflow('mobile').jobs.verify.steps.find((step) => step.uses?.includes('install-node-dependencies') ) + expect(mobile.with['cache-pnpm-store']).toBe('false') expect(mobile.with['cache-dependency-path'].trim().split('\n')).toEqual([ 'pnpm-lock.yaml', 'mobile/pnpm-lock.yaml' diff --git a/config/scripts/mobile-test-change-scope.test.mjs b/config/scripts/mobile-test-change-scope.test.mjs index 7bdb513fd62..06d5273ec74 100644 --- a/config/scripts/mobile-test-change-scope.test.mjs +++ b/config/scripts/mobile-test-change-scope.test.mjs @@ -91,11 +91,11 @@ it('overlaps both typechecks with tests and collects their failures before the s const waits = steps.filter((step) => step.wait !== undefined) expect(waits).toHaveLength(1) const [wait] = waits - expect(wait.wait).toEqual(['production-types', 'test-types']) + expect(wait.wait).toEqual(['production-types', 'test-types', 'ruby-tools']) expect(wait.if).toBeUndefined() expect(steps.indexOf(wait)).toBeGreaterThan(test) expect(steps.indexOf(wait)).toBeLessThan(summary) - for (const id of wait.wait) { + for (const id of ['production-types', 'test-types']) { const step = steps.find((candidate) => candidate.id === id) expect(step.background).toBe(true) expect(step['continue-on-error']).toBeUndefined() @@ -104,4 +104,10 @@ it('overlaps both typechecks with tests and collects their failures before the s expect(steps.indexOf(step)).toBeLessThan(test) expect(step.run).toMatch(/^node /) } + const ruby = steps.find((step) => step.id === 'ruby-tools') + expect(ruby.uses).toBe('ruby/setup-ruby@v1') + expect(ruby.background).toBe(true) + expect(ruby.if).toBe("steps.ruby-scope.outputs.should_run != 'false'") + expect(ruby['continue-on-error']).toBeUndefined() + expect(steps.indexOf(ruby)).toBeLessThan(install) }) diff --git a/config/scripts/mobile-typecheck-cache.test.mjs b/config/scripts/mobile-typecheck-cache.test.mjs new file mode 100644 index 00000000000..f4ed6e6d1e8 --- /dev/null +++ b/config/scripts/mobile-typecheck-cache.test.mjs @@ -0,0 +1,41 @@ +import { readFileSync } from 'node:fs' +import { expect, it } from 'vitest' +import { parse } from 'yaml' + +it('reuses compiler state without skipping validation, and seeds it from main', () => { + const workflow = parse(readFileSync('.github/workflows/mobile.yml', 'utf8')) + for (const job of [workflow.jobs.verify, workflow.jobs['main-tests']]) { + expect(job.env.ORCA_MOBILE_TYPECHECK_CACHE_DIR).toBe( + '${{ github.workspace }}/mobile/.cache/typechecks' + ) + const steps = job.steps + const cache = steps.find((step) => step.name === 'Cache mobile typecheck state') + expect(cache.uses).toBe('actions/cache@v5') + expect(cache.with.path).toBe('mobile/.cache/typechecks/*.tsbuildinfo') + expect(cache.with.key).toBe(`${cache.with['restore-keys']}${'${{ github.sha }}'}`) + for (const input of [ + 'runner.os', + 'runner.arch', + 'pnpm-lock.yaml', + 'mobile/pnpm-lock.yaml', + 'mobile/tsconfig*.json' + ]) { + expect(cache.with['restore-keys']).toContain(input) + } + const install = steps.findIndex((step) => step.name === 'Install dependencies') + const wait = steps.find((step) => step.wait) + for (const id of ['production-types', 'test-types']) { + const check = steps.find((step) => step.id === id) + expect(check.if).toBeUndefined() + expect(check.background).toBe(true) + expect(check['continue-on-error']).toBeUndefined() + expect(steps.indexOf(cache)).toBeGreaterThan(install) + expect(steps.indexOf(cache)).toBeLessThan(steps.indexOf(check)) + expect(wait.wait).toContain(id) + expect(steps.indexOf(wait)).toBeGreaterThan(steps.indexOf(check)) + } + expect(steps.find((step) => step.id === 'production-types').run).toContain( + '--incremental --tsBuildInfoFile "$ORCA_MOBILE_TYPECHECK_CACHE_DIR/production.tsbuildinfo"' + ) + } +}) diff --git a/mobile/.gitignore b/mobile/.gitignore index 85bf6e054a2..57ead410bec 100644 --- a/mobile/.gitignore +++ b/mobile/.gitignore @@ -16,3 +16,5 @@ dist/ *.mobileprovision *.orig.* web-build/ + +.cache/typechecks/ diff --git a/mobile/scripts/check-tests-typecheck-incremental-cache.test.ts b/mobile/scripts/check-tests-typecheck-incremental-cache.test.ts new file mode 100644 index 00000000000..fc088bda139 --- /dev/null +++ b/mobile/scripts/check-tests-typecheck-incremental-cache.test.ts @@ -0,0 +1,47 @@ +import { existsSync, mkdtempSync, realpathSync, writeFileSync, unlinkSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it, vi } from 'vitest' +import { removeTreeSync } from '../../src/shared/windows-transient-lock-removal' +import { collectTypecheckPass, findTsNocheckFiles } from './check-tests-typecheck-ratchet.mjs' + +it('rechecks changed types, diagnostics and test membership with incremental state', () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'orca-mobile-typecheck-'))) + const cache = join(root, '.cache') + vi.stubEnv('ORCA_MOBILE_TYPECHECK_CACHE_DIR', cache) + try { + writeFileSync( + join(root, 'tsconfig.test.json'), + JSON.stringify({ + compilerOptions: { strict: true, skipLibCheck: true, types: [], target: 'ES2022' }, + include: ['*.ts'] + }) + ) + writeFileSync(join(root, 'model.ts'), 'export interface Shape { value: number }') + const test = join(root, 'value.test.ts') + const source = "import type { Shape } from './model'; const shape: Shape = { value: 1 }" + writeFileSync(test, source) + const clean = collectTypecheckPass(root) + expect(clean).toEqual({ failing: [], programTestFiles: ['value.test.ts'] }) + expect(existsSync(join(cache, 'tests.tsbuildinfo'))).toBe(true) + expect(collectTypecheckPass(root)).toEqual(clean) + + writeFileSync(join(root, 'model.ts'), 'export interface Shape { value: string }') + const failed = collectTypecheckPass(root) + expect(failed.failing).toEqual(['value.test.ts']) + expect(collectTypecheckPass(root)).toEqual(failed) + + writeFileSync(join(root, 'new.test.ts'), 'export const value: number = 1') + expect(collectTypecheckPass(root).programTestFiles).toEqual(['new.test.ts', 'value.test.ts']) + writeFileSync(test, `// @ts-nocheck\n${source}`) + const nocheck = collectTypecheckPass(root) + expect(nocheck.failing).toEqual([]) + expect(findTsNocheckFiles(root, nocheck.programTestFiles)).toEqual(['value.test.ts']) + + unlinkSync(test) + expect(collectTypecheckPass(root)).toEqual({ failing: [], programTestFiles: ['new.test.ts'] }) + } finally { + vi.unstubAllEnvs() + removeTreeSync(root) + } +}, 15_000) diff --git a/mobile/scripts/check-tests-typecheck-ratchet.mjs b/mobile/scripts/check-tests-typecheck-ratchet.mjs index bd1cfae6a39..5c6a25f2751 100644 --- a/mobile/scripts/check-tests-typecheck-ratchet.mjs +++ b/mobile/scripts/check-tests-typecheck-ratchet.mjs @@ -1,4 +1,4 @@ -import { spawnSync } from 'node:child_process' +import { runProcessSync } from '@orca/process-host' import fs from 'node:fs' import { createRequire } from 'node:module' import path from 'node:path' @@ -148,22 +148,26 @@ export function diffBaseline(current, baseline) { // script: on Windows the shim is tsc.CMD and an extensionless path gets .exe appended. function runTsc(root, args) { const entry = createRequire(import.meta.url).resolve('typescript/lib/tsc.js') - const result = spawnSync(process.execPath, [entry, ...args], { + return runProcessSync({ + program: process.execPath, + args: [entry, ...args], cwd: root, - encoding: 'utf8', - shell: false, - maxBuffer: 64 * 1024 * 1024 + timeoutMs: null, + maxOutputBytes: 64 * 1024 * 1024 }) - if (result.error) { - throw result.error - } - return result } // tsc exits non-zero on type errors, which is the expected state here, so only a crash is fatal. // One pass answers both questions: --listFiles names the program, the diagnostics name the failures. export function collectTypecheckPass(root = process.cwd()) { - const result = runTsc(root, ['--noEmit', '--listFiles', '-p', PROJECT]) + const cache = process.env.ORCA_MOBILE_TYPECHECK_CACHE_DIR + const result = runTsc(root, [ + '--noEmit', + '--listFiles', + '-p', + PROJECT, + ...(cache ? ['--incremental', '--tsBuildInfoFile', path.join(cache, 'tests.tsbuildinfo')] : []) + ]) const output = `${result.stdout ?? ''}${result.stderr ?? ''}` return { failing: parseFailingFiles(output), diff --git a/mobile/src/test-support/rpc-recording/compiled-operation-module.test.ts b/mobile/src/test-support/rpc-recording/compiled-operation-module.test.ts index c79a7141d5c..a944904cf91 100644 --- a/mobile/src/test-support/rpc-recording/compiled-operation-module.test.ts +++ b/mobile/src/test-support/rpc-recording/compiled-operation-module.test.ts @@ -13,6 +13,7 @@ describe('compiled operation code', () => { original ) expect(compiledOperationModule('different.ts', source, '')).not.toBe(original) + expect(compiledOperationModule('original.ts', source, '')).toBe(original) }) it('bounds retained code and still evaluates an evicted module correctly', () => { @@ -27,4 +28,17 @@ describe('compiled operation code', () => { recompiled(() => undefined, exports) expect(exports).toEqual({ value: 42 }) }) + + it('bounds retained variants of one file, not only the number of filenames', () => { + const source = 'export const value = 42' + const original = compiledOperationModule('variants.ts', source, '') + for (let index = 0; index < 512; index++) { + compiledOperationModule('variants.ts', `${source}; export const variant = ${index}`, '') + } + const recompiled = compiledOperationModule('variants.ts', source, '') + expect(recompiled).not.toBe(original) + const exports = {} + recompiled(() => undefined, exports) + expect(exports).toEqual({ value: 42 }) + }) }) diff --git a/mobile/src/test-support/rpc-recording/compiled-operation-module.ts b/mobile/src/test-support/rpc-recording/compiled-operation-module.ts index ca9f14a6ed9..a1d6a643f4f 100644 --- a/mobile/src/test-support/rpc-recording/compiled-operation-module.ts +++ b/mobile/src/test-support/rpc-recording/compiled-operation-module.ts @@ -1,15 +1,21 @@ import { compileFunction } from 'node:vm' import ts from 'typescript' -const compiled = new Map>() +type CompiledModule = { + source: string + evaluate: ReturnType +} + +const compiled = new Map>() +const insertionOrder = new Map() const MAX_COMPILED_MODULES = 512 /** Only code is shared: each invocation receives a fresh require closure and exports object. */ export function compiledOperationModule(file: string, source: string, exposure: string) { - const key = JSON.stringify([file, source, exposure]) - const cached = compiled.get(key) + const key = JSON.stringify([file, exposure]) + const cached = compiled.get(key)?.get(source) if (cached) { - return cached + return cached.evaluate } const output = ts.transpileModule(source, { compilerOptions: { @@ -20,12 +26,22 @@ export function compiledOperationModule(file: string, source: string, exposure: } }).outputText const evaluate = compileFunction(output + exposure, ['require', 'exports'], { filename: file }) - if (compiled.size >= MAX_COMPILED_MODULES) { - const oldest = compiled.keys().next().value + if (insertionOrder.size >= MAX_COMPILED_MODULES) { + const oldest = insertionOrder.entries().next().value if (oldest !== undefined) { - compiled.delete(oldest) + const [entry, variantKey] = oldest + insertionOrder.delete(entry) + const entries = compiled.get(variantKey) + entries?.delete(entry.source) + if (!entries?.size) { + compiled.delete(variantKey) + } } } - compiled.set(key, evaluate) + const entry = { source, evaluate } + const entries = compiled.get(key) ?? new Map() + entries.set(source, entry) + compiled.set(key, entries) + insertionOrder.set(entry, key) return evaluate } diff --git a/mobile/src/test-support/rpc-recording/golden-value-pool-serialization.test.ts b/mobile/src/test-support/rpc-recording/golden-value-pool-serialization.test.ts new file mode 100644 index 00000000000..36199c84161 --- /dev/null +++ b/mobile/src/test-support/rpc-recording/golden-value-pool-serialization.test.ts @@ -0,0 +1,30 @@ +import { expect, it, vi } from 'vitest' +import { internRecording, resolveRecording } from './golden-value-pool' + +it('serializes each pooled value once for its hash and collision check', () => { + const recording = { + scenario: 'serialization', + checkpoints: [ + { + id: 'one', + observation: { + sender: [], + payloads: [], + settlements: {}, + state: { value: 'one' }, + effects: [] + } + } + ] + } + const stringify = vi.spyOn(JSON, 'stringify') + try { + const interned = internRecording(recording) + const calls = stringify.mock.calls.length + stringify.mockRestore() + expect(calls).toBe(2) + expect(resolveRecording(interned.values, interned.recording)).toEqual(recording) + } finally { + stringify.mockRestore() + } +}) diff --git a/mobile/src/test-support/rpc-recording/golden-value-pool.ts b/mobile/src/test-support/rpc-recording/golden-value-pool.ts index 2e91dbb55c7..03a8fa04c23 100644 --- a/mobile/src/test-support/rpc-recording/golden-value-pool.ts +++ b/mobile/src/test-support/rpc-recording/golden-value-pool.ts @@ -61,7 +61,11 @@ export function canonicalJson(value: RecordedValue): string { } export function valueHash(value: RecordedValue): string { - return createHash('sha256').update(canonicalJson(value)).digest('hex').slice(0, 12) + return canonicalJsonHash(canonicalJson(value)) +} + +function canonicalJsonHash(json: string): string { + return createHash('sha256').update(json).digest('hex').slice(0, 12) } function listEntries(at: string, value: RecordedValue): RecordedValue[] { @@ -86,8 +90,8 @@ export function internRecording(recording: Recording): { const pool: ValuePool = {} const canonical = new Map() const intern = (entry: RecordedValue, at: string): string => { - const hash = valueHash(entry) const json = canonicalJson(entry) + const hash = canonicalJsonHash(json) const seen = canonical.get(hash) if (seen !== undefined && seen !== json) { throw new Error(`Golden value hash collision at ${hash} (${at})`) diff --git a/src/main/daemon/serialize-grid-transcript-replay.test.ts b/src/main/daemon/serialize-grid-transcript-replay.test.ts index 41b840ea644..250b1fb2581 100644 --- a/src/main/daemon/serialize-grid-transcript-replay.test.ts +++ b/src/main/daemon/serialize-grid-transcript-replay.test.ts @@ -79,6 +79,8 @@ const KNOWN_PREEXISTING_I2_FAILURES: Record = { 'codex-fullscreen-early-input': 4, 'codex-fullscreen-multiline-early-input': 10, 'codex-fullscreen-startup': 14, + // Capacity capture: identical attribute leaks with the pre-capture 4559c250b3e serializer. + 'codex-model-capacity-fullscreen': 12, 'claude-dialog-trust-workspace-answered': 13, // DSH-TUI's whale intro paints whole rows of 24-bit background, and every one of this // transcript's divergences is the same shape: `visible-grid row=0`, a true-colour