From 7909e72d2da494697fd519a70543e09fee5b50a6 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:07:49 -0700 Subject: [PATCH] ci: move PR analysis and Linux unit tests to Blacksmith (#29362) * ci: report final PR checks outside the shared runner queue * ci: fix inherited mobile test file size failure * ci: move PR analysis and ARM test shards to Blacksmith * Qualify x64 Blacksmith capacity and reject incompatible runner images early * Keep extracted mobile test adapters within the test-only lint scope * Identify the runner profile overriding shell fixture homes * Preserve fixture HOME through the Blacksmith login profile * Make cache invalidation fixtures observable within one filesystem clock tick * Scope Blacksmith system-profile preparation to Linux runners * Wait for observable ctime in daemon hash cache fixtures * Use the mobile test lint scope already merged on main * Stabilize metadata and timer fixtures on fast CI runners --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../ci-cache-warmup-platform-jobs.yml | 10 ++++-- .github/workflows/pr.yml | 13 +++----- .github/workflows/unit-tests.yml | 32 ++++++++++++++++++- .../ci-dependency-download-cache.test.mjs | 15 +++++---- config/scripts/ci-unit-module-cache.test.mjs | 21 ++++++++++++ config/scripts/pr-preflight-gates.test.mjs | 2 +- .../session-scanner-cursor-chat-meta.test.ts | 4 +++ .../scanner-incremental-append.test.ts | 22 ++++++++++++- src/main/codex/codex-hook-reconcile.test.ts | 4 +-- .../daemon-host-file-hash-cache.test.ts | 16 ++++++---- src/main/linear/client.test.ts | 2 ++ src/shared/repo-ref-maintenance.test.ts | 3 +- 12 files changed, 114 insertions(+), 30 deletions(-) diff --git a/.github/workflows/ci-cache-warmup-platform-jobs.yml b/.github/workflows/ci-cache-warmup-platform-jobs.yml index a8f4ab00c67..38ca56e5750 100644 --- a/.github/workflows/ci-cache-warmup-platform-jobs.yml +++ b/.github/workflows/ci-cache-warmup-platform-jobs.yml @@ -7,12 +7,16 @@ permissions: contents: read jobs: - warm-linux-arm: + warm-linux-runtime: if: github.event_name != 'pull_request' concurrency: - group: ci-cache-warmup-linux-arm-${{ github.event.pull_request.number || github.ref }} + group: ci-cache-warmup-linux-runtime-${{ matrix.runner }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.event_name != 'schedule' }} - runs-on: ubuntu-24.04-arm + strategy: + fail-fast: false + matrix: + runner: [ubuntu-24.04-arm, blacksmith-4vcpu-ubuntu-2404] + runs-on: ${{ matrix.runner }} timeout-minutes: 10 env: ORCA_BACKGROUND_LAUNCH: '1' diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index c28d4e9116f..aa6b78fdd4f 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -25,8 +25,8 @@ jobs: # inputs are unchanged; empty diffs fail closed and run everything. code_paths: name: detect changes and check repository guards - # Reuse one lightweight checkout for detection and the always-required guards. - runs-on: ubuntu-slim + # Start routing without waiting behind the GitHub-hosted test queue. + runs-on: blacksmith-2vcpu-ubuntu-2404 timeout-minutes: 5 permissions: contents: read @@ -175,11 +175,8 @@ jobs: name: static analysis and typecheck needs: [code_paths] if: needs.code_paths.outputs.static_analysis == 'true' || needs.code_paths.outputs.typecheck == 'true' - # Why ARM: measured 128s against 172s on ubuntu-latest, with every compute step faster -- - # type-aware 24s->15s, anti-slop 28->19s, localization extraction 67->46s, the orcad smoke - # 39->14s. Both lint engines ship linux-arm64 and the Bun target follows process.arch, so - # the whole toolchain resolves. Free for public repositories. - runs-on: ubuntu-24.04-arm + # Leave headroom for typechecking and overlapping checks. + runs-on: blacksmith-8vcpu-ubuntu-2404 outputs: shards: ${{ steps.unit-plan.outputs.shards }} managed_hook_node18: ${{ steps.managed_hook_node18.outcome }} @@ -896,7 +893,7 @@ jobs: uses: ./.github/workflows/unit-tests.yml with: node_versions: '["24"]' - runner: ubuntu-24.04-arm + runner: blacksmith-4vcpu-ubuntu-2404 shards: ${{ needs.preflight.outputs.shards }} run_shards: ${{ needs.code_paths.outputs.mobile_ci_tools != 'true' }} browser_contract: ${{ needs.code_paths.outputs.orcad_browser == 'true' }} diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 330070c0b36..180bd6c6e15 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -91,6 +91,36 @@ jobs: - name: Install Electron package binary for tests run: node config/scripts/install-electron-package-binary.mjs + # The image resets HOME during login; shell fixtures intentionally use temporary homes. + - name: Preserve explicit login-shell homes on Blacksmith + if: runner.os == 'Linux' && startsWith(inputs.runner, 'blacksmith-') + run: | + sudo python3 - <<'PYTHON' + from pathlib import Path + profile = Path('/etc/profile.d/blacksmith.sh') + if profile.exists(): + original = profile.read_text() + updated = original.replace('export HOME="$RUNNER_HOME"', 'export HOME="${HOME:-$RUNNER_HOME}"') + if updated != original: + profile.write_text(updated) + PYTHON + + - name: Verify Blacksmith unit runner environment + if: runner.os == 'Linux' && startsWith(inputs.runner, 'blacksmith-') + run: | + ELECTRON_RUN_AS_NODE=1 "$(node -p "require('electron')")" --version + fixture_home="$(mktemp -d)" + trap 'rm -rf "$fixture_home"' EXIT + printf 'export ORCA_CI_PROFILE_LOADED=1\n' > "$fixture_home/.bash_profile" + HOME="$fixture_home" ORCA_CI_FIXTURE_HOME="$fixture_home" /bin/bash -lc ' + test "$HOME" = "$ORCA_CI_FIXTURE_HOME" && test "$ORCA_CI_PROFILE_LOADED" = 1 + ' || { + echo '::error::Runner login profile overrides fixture HOME' + grep -nH 'HOME' /etc/profile /etc/profile.d/*.sh || true + HOME="$fixture_home" /bin/bash -lc 'printf "Observed HOME=%s\n" "$HOME"' + exit 1 + } + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version-file: config/.bun-version @@ -145,7 +175,7 @@ jobs: export ORCA_SHARD_SOURCE_SHA node config/scripts/run-vitest.mjs run --config config/vitest.config.ts \ --shard=${{ matrix.shard.index }}/${{ matrix.shard.count }} \ - ${{ inputs.runner == 'ubuntu-24.04-arm' && '--maxWorkers=4' || '' }} + ${{ (runner.arch == 'ARM64' || inputs.runner == 'blacksmith-4vcpu-ubuntu-2404') && '--maxWorkers=4' || '' }} - name: Seal unit module cache id: module-cache-seal diff --git a/config/scripts/ci-dependency-download-cache.test.mjs b/config/scripts/ci-dependency-download-cache.test.mjs index 7af6b9f6a1d..ec3cb8ef362 100644 --- a/config/scripts/ci-dependency-download-cache.test.mjs +++ b/config/scripts/ci-dependency-download-cache.test.mjs @@ -295,7 +295,7 @@ describe('CI dependency download caches', () => { expect(warmer.with['cache-pnpm-store']).toBe('false') expect(warmer.with['native-runtime']).toBe('node') expect(persistence.with['cache-pnpm-store-lookup-only']).toBe('true') - for (const job of [workflow('ci-cache-warmup').jobs.warm, platformJobs['warm-linux-arm']]) { + for (const job of [workflow('ci-cache-warmup').jobs.warm, platformJobs['warm-linux-runtime']]) { const install = job.steps.find((step) => step.uses?.includes('install-node-dependencies')) expect(install.with['cache-pnpm-store-lookup-only']).toBe('true') } @@ -346,7 +346,7 @@ describe('CI dependency download caches', () => { const shared = workflow('ci-cache-warmup') const platforms = read(workflow('ci-cache-warmup-platforms').jobs['warm-platforms'].uses) const context = { github: { event_name: event } } - expect(runInNewContext(platforms.jobs['warm-linux-arm'].if, context)).toBe(armRuns) + expect(runInNewContext(platforms.jobs['warm-linux-runtime'].if, context)).toBe(armRuns) expect(runInNewContext(shared.jobs['warm-platforms'].if, context)).toBe(sharedPlatforms) expect(shared.jobs.warm.if).toBeUndefined() expect(platforms.jobs['warm-windows'].if).toBeUndefined() @@ -356,17 +356,18 @@ describe('CI dependency download caches', () => { ]) }) - it('validates the omitted ARM warmer preparation in the real PR consumers', () => { + it('validates the omitted Linux warmer preparation in the real PR consumers', () => { const platforms = read(workflow('ci-cache-warmup-platforms').jobs['warm-platforms'].uses) - const warm = platforms.jobs['warm-linux-arm'] + const warm = platforms.jobs['warm-linux-runtime'] const pr = workflow('pr') const units = workflow('unit-tests').jobs.test const installer = './.github/actions/install-node-dependencies' const warmInstall = warm.steps.find((step) => step.uses === installer) const unitInstall = units.steps.find((step) => step.uses === installer) - expect(warm['runs-on']).toBe('ubuntu-24.04-arm') - expect(pr.jobs.preflight['runs-on']).toBe(warm['runs-on']) - expect(pr.jobs.test.with.runner).toBe(warm['runs-on']) + expect(warm['runs-on']).toBe('${{ matrix.runner }}') + expect(warm.strategy.matrix.runner).toEqual(['ubuntu-24.04-arm', pr.jobs.test.with.runner]) + expect(pr.jobs.preflight['runs-on']).toBe('blacksmith-8vcpu-ubuntu-2404') + expect(pr.jobs.test.with.runner).toBe('blacksmith-4vcpu-ubuntu-2404') expect(units['runs-on']).toBe('${{ inputs.runner }}') expect(JSON.parse(pr.jobs.test.with.node_versions)).toEqual(['24']) expect(warmInstall.if).toBeUndefined() diff --git a/config/scripts/ci-unit-module-cache.test.mjs b/config/scripts/ci-unit-module-cache.test.mjs index 59270e0b247..31ee2d567e7 100644 --- a/config/scripts/ci-unit-module-cache.test.mjs +++ b/config/scripts/ci-unit-module-cache.test.mjs @@ -1,6 +1,7 @@ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { runInNewContext } from 'node:vm' import { afterEach, describe, expect, it } from 'vitest' import { parse } from 'yaml' import { @@ -146,6 +147,26 @@ it('uses an optional exact restore after install and saves successful scoped pro expect(workflow.on.workflow_call.inputs.run_relay_integration.default).toBe(true) }) +it('retains four workers on ARM and the four-core Blacksmith unit runner', () => { + const workflow = parse(readFileSync('.github/workflows/unit-tests.yml', 'utf8')) + const step = workflow.jobs.test.steps.find((step) => step.name === 'Test shard') + const expression = [...step.run.matchAll(/\$\{\{(.+?)\}\}/g)].find((match) => + match[1].includes('--maxWorkers') + )[1] + for (const [arch, runner] of [ + ['ARM64', 'ubuntu-24.04-arm'], + ['ARM64', 'blacksmith-4vcpu-ubuntu-2404-arm'], + ['X64', 'blacksmith-4vcpu-ubuntu-2404'] + ]) { + expect(runInNewContext(expression, { runner: { arch }, inputs: { runner } })).toBe( + '--maxWorkers=4' + ) + } + expect( + runInNewContext(expression, { runner: { arch: 'X64' }, inputs: { runner: 'ubuntu-latest' } }) + ).toBe('') +}) + it('runs full same-source controls serially with five four-worker shards and included producer costs', () => { const workflow = parse(readFileSync('.github/workflows/unit-module-cache-benchmark.yml', 'utf8')) const { baseline, producer, warm } = workflow.jobs diff --git a/config/scripts/pr-preflight-gates.test.mjs b/config/scripts/pr-preflight-gates.test.mjs index c91a49a31d6..7396e6d5235 100644 --- a/config/scripts/pr-preflight-gates.test.mjs +++ b/config/scripts/pr-preflight-gates.test.mjs @@ -16,7 +16,7 @@ it('shares one setup and overlaps the compiler only after type-aware checks fini expect(workflow.jobs.static_analysis).toBeUndefined() expect(workflow.jobs.typecheck).toBeUndefined() expect(preflight.needs).toEqual(['code_paths']) - expect(preflight['runs-on']).toBe('ubuntu-24.04-arm') + expect(preflight['runs-on']).toBe('blacksmith-8vcpu-ubuntu-2404') const installs = steps.filter( (step) => step.uses === './.github/actions/install-node-dependencies' ) diff --git a/src/main/ai-vault/session-scanner-cursor-chat-meta.test.ts b/src/main/ai-vault/session-scanner-cursor-chat-meta.test.ts index 3bf74862084..130f2c13c94 100644 --- a/src/main/ai-vault/session-scanner-cursor-chat-meta.test.ts +++ b/src/main/ai-vault/session-scanner-cursor-chat-meta.test.ts @@ -185,12 +185,16 @@ describe('cursor chat meta', () => { it('re-indexes after a chat appears under an already indexed workspace', async () => { const cursorHome = await createCursorHome() await writeChatMeta(cursorHome, 'workspace-hash', 'chat-first') + const workspacePath = join(cursorHome, 'chats', 'workspace-hash') + // The directory update must be visible even within one filesystem clock tick. + await utimes(workspacePath, 1, 1) const firstTranscript = await writeTranscript(cursorHome, 'slug', 'chat-first', []) expect(await cursorChatMetaPath(firstTranscript)).toBeDefined() const laterMetaPath = await writeChatMeta(cursorHome, 'workspace-hash', 'chat-later') const laterTranscript = await writeTranscript(cursorHome, 'slug', 'chat-later', []) + expect((await stat(workspacePath)).mtimeMs).not.toBe(1_000) expect(await cursorChatMetaPath(laterTranscript)).toBe(laterMetaPath) }) diff --git a/src/main/codex-usage/scanner-incremental-append.test.ts b/src/main/codex-usage/scanner-incremental-append.test.ts index b00054894ec..d60ef629d8e 100644 --- a/src/main/codex-usage/scanner-incremental-append.test.ts +++ b/src/main/codex-usage/scanner-incremental-append.test.ts @@ -129,7 +129,15 @@ vi.mock('../usage/jsonl-line-offsets', async () => { } }) -import { mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync, appendFileSync } from 'node:fs' +import { + mkdirSync, + mkdtempSync, + rmSync, + statSync, + utimesSync, + writeFileSync, + appendFileSync +} from 'node:fs' import { scanCodexUsageFiles } from './scanner' import type { CodexUsagePersistedFile } from './types' @@ -526,6 +534,8 @@ describe('scanCodexUsageFiles incremental append', () => { const original = `${sessionMeta('session-a')}${usageRecordRange(0, RESUMABLE_RECORDS)}` writeFileSync(rolloutPath, original, 'utf-8') + // Keep the rewrite observable on filesystems whose clock has not ticked yet. + utimesSync(rolloutPath, 1, 1) const first = await scanCodexUsageFiles([], []) expect(totalTokens(first.dailyAggregates)).toBe(RESUMABLE_RECORDS) expect(recordedResumeOffset(first.processedFiles, rolloutPath)).toBeGreaterThan(0) @@ -536,6 +546,7 @@ describe('scanCodexUsageFiles incremental append', () => { writeFileSync(rolloutPath, replacement, 'utf-8') expect(statSync(rolloutPath).size).toBe(original.length) + expect(statSync(rolloutPath).mtimeMs).not.toBe(first.processedFiles[0]?.mtimeMs) const second = await scanCodexUsageFiles([], first.processedFiles) const fromScratch = await scanCodexUsageFiles([], []) expect(second.dailyAggregates).toEqual(fromScratch.dailyAggregates) @@ -608,12 +619,15 @@ describe('scanCodexUsageFiles incremental append', () => { expect(original.length - sharedHead.length).toBeLessThan(BOUNDARY_WINDOW_BYTES) writeFileSync(rolloutPath, original, 'utf-8') + // Keep the rewrite observable on filesystems whose clock has not ticked yet. + utimesSync(rolloutPath, 1, 1) const first = await scanCodexUsageFiles([], []) expect(totalTokens(first.dailyAggregates)).toBe(swappedTo) expect(recordedResumeOffset(first.processedFiles, rolloutPath)).toBeGreaterThan(0) writeFileSync(rolloutPath, replacement, 'utf-8') + expect(statSync(rolloutPath).mtimeMs).not.toBe(first.processedFiles[0]?.mtimeMs) const second = await scanCodexUsageFiles([], first.processedFiles) const fromScratch = await scanCodexUsageFiles([], []) expect(second.dailyAggregates).toEqual(fromScratch.dailyAggregates) @@ -628,12 +642,15 @@ describe('scanCodexUsageFiles incremental append', () => { const { original, replacement } = prefixSwapPair('session-prefix-rotated') writeFileSync(rolloutPath, original, 'utf-8') + // Keep the rewrite observable on filesystems whose clock has not ticked yet. + utimesSync(rolloutPath, 1, 1) const first = await scanCodexUsageFiles([], []) expect(totalTokens(first.dailyAggregates)).toBe(40) rmSync(rolloutPath) writeFileSync(rolloutPath, replacement, 'utf-8') + expect(statSync(rolloutPath).mtimeMs).not.toBe(first.processedFiles[0]?.mtimeMs) const second = await scanCodexUsageFiles([], first.processedFiles) const fromScratch = await scanCodexUsageFiles([], []) expect(second.dailyAggregates).toEqual(fromScratch.dailyAggregates) @@ -648,6 +665,8 @@ describe('scanCodexUsageFiles incremental append', () => { const { original, replacement } = prefixSwapPair('session-prefix-in-place') writeFileSync(rolloutPath, original, 'utf-8') + // Keep the rewrite observable on filesystems whose clock has not ticked yet. + utimesSync(rolloutPath, 1, 1) const first = await scanCodexUsageFiles([], []) expect(totalTokens(first.dailyAggregates)).toBe(40) @@ -656,6 +675,7 @@ describe('scanCodexUsageFiles incremental append', () => { // Pins why this test is not a duplicate of the rotation case above. expect(statSync(rolloutPath).ino).toBe(inodeBefore) + expect(statSync(rolloutPath).mtimeMs).not.toBe(first.processedFiles[0]?.mtimeMs) const second = await scanCodexUsageFiles([], first.processedFiles) const fromScratch = await scanCodexUsageFiles([], []) expect(second.dailyAggregates).toEqual(fromScratch.dailyAggregates) diff --git a/src/main/codex/codex-hook-reconcile.test.ts b/src/main/codex/codex-hook-reconcile.test.ts index 73933afef6a..f7baa8e7843 100644 --- a/src/main/codex/codex-hook-reconcile.test.ts +++ b/src/main/codex/codex-hook-reconcile.test.ts @@ -514,8 +514,8 @@ describe('while the lookup is still asking Codex', () => { it('writes nothing while an entry is already in place', async () => { await start() const before = snapshot(codexHome()) - // Why new bytes: a Codex update the lookup has not asked about yet. - writeFileSync(mocks.codexPath, 'codex 0.161.0') + // A different size invalidates the binary fingerprint even within one filesystem clock tick. + writeFileSync(mocks.codexPath, 'codex 0.161.0 updated') const release = holdDerivation() await finishHeldLookupWait(reconcileCodexHooksForLaunch, true) diff --git a/src/main/daemon/daemon-host-file-hash-cache.test.ts b/src/main/daemon/daemon-host-file-hash-cache.test.ts index abdce401801..936452b508c 100644 --- a/src/main/daemon/daemon-host-file-hash-cache.test.ts +++ b/src/main/daemon/daemon-host-file-hash-cache.test.ts @@ -51,12 +51,16 @@ function pinMtime(path: string): void { utimesSync(path, PINNED_MTIME_SECONDS, PINNED_MTIME_SECONDS) } -function rewriteKeepingSizeAndMtime(path: string, contents: string): void { +async function rewriteKeepingSizeAndMtime(path: string, contents: string): Promise { const before = statSync(path) expect(contents.length).toBe(before.size) + // Allow ctime to tick before restoring mtime on the same-size rewrite. + await new Promise((resolve) => setTimeout(resolve, 20)) writeFileSync(path, contents) pinMtime(path) - expect(statSync(path).mtimeMs).toBe(before.mtimeMs) + const after = statSync(path) + expect(after.mtimeMs).toBe(before.mtimeMs) + expect(after.ctimeMs).not.toBe(before.ctimeMs) } beforeEach(() => { @@ -103,10 +107,10 @@ describe('daemon-host code hash cache', () => { expect(codeReads()).toEqual([]) }) - it('detects a same-size source edit that restores mtime', () => { + it('detects a same-size source edit that restores mtime', async () => { pinMtime(join(installDir, chunkRel)) const root = materializedRoot() - rewriteKeepingSizeAndMtime(join(installDir, chunkRel), 'chunX') + await rewriteKeepingSizeAndMtime(join(installDir, chunkRel), 'chunX') expect(getRelocatedDaemonHost()).toBeNull() expect(codeReads()).toContain(join(installDir, chunkRel)) @@ -115,11 +119,11 @@ describe('daemon-host code hash cache', () => { expect(readFileSync(join(rebuilt, chunkRel), 'utf8')).toBe('chunX') }) - it('detects a same-size mirror edit that restores mtime', () => { + it('detects a same-size mirror edit that restores mtime', async () => { const root = materializedRoot() pinMtime(join(root, chunkRel)) expect(getRelocatedDaemonHost()).not.toBeNull() - rewriteKeepingSizeAndMtime(join(root, chunkRel), 'chunX') + await rewriteKeepingSizeAndMtime(join(root, chunkRel), 'chunX') expect(getRelocatedDaemonHost()).toBeNull() }) diff --git a/src/main/linear/client.test.ts b/src/main/linear/client.test.ts index a44fa1b995f..0df741ef540 100644 --- a/src/main/linear/client.test.ts +++ b/src/main/linear/client.test.ts @@ -509,6 +509,8 @@ describe('Linear workspace file cross-process visibility', () => { [{ id: 'ws-alpha', token: 'token-alpha', credentialRevision: 3 }], 'ws-alpha' ) + // A revision update must change metadata even within one filesystem clock tick. + utimesSync(join(tempHome, '.orca', 'linear-workspaces.json'), 1, 1) const linear = await loadClientModule() const signal = new AbortController().signal const captured = linear.getClients('ws-alpha', signal)[0] diff --git a/src/shared/repo-ref-maintenance.test.ts b/src/shared/repo-ref-maintenance.test.ts index c9940d13dd3..7b8ba2bcac2 100644 --- a/src/shared/repo-ref-maintenance.test.ts +++ b/src/shared/repo-ref-maintenance.test.ts @@ -141,7 +141,8 @@ async function until(predicate: () => boolean, what: string): Promise { async function untilWithTimers(predicate: () => boolean, what: string): Promise { const deadline = Date.now() + 10_000 while (!predicate() && Date.now() < deadline) { - await vi.advanceTimersByTimeAsync(QUIET_MS) + // Leave real I/O time to settle before consuming the attempt deadline. + await vi.advanceTimersByTimeAsync(1) await yieldToIo() } if (!predicate()) {