Skip to content

fix(apple-runner): trim build scratch from runner cache keys and keep packaging out of the shared cache - #3248

Open
janicduplessis wants to merge 5 commits into
callstack:mainfrom
janicduplessis:fix/3246-runner-cache-trim
Open

janicduplessis wants to merge 5 commits into
callstack:mainfrom
janicduplessis:fix/3246-runner-cache-trim

Conversation

@janicduplessis

@janicduplessis janicduplessis commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #3246, covering the three items it lists after the eviction PR. This PR is independent of #3247: it touches buildXctestrunArtifact rather than ensureXctestrunArtifact. Merging the two textually conflicts in two places, both trivial: the decision-reason union in runner-cache.ts (each adds one member) and the Apple runner setup row in configuration.md (each adds one env var). Whichever lands second rebases.

1. Trim build scratch from a runner cache key after its build. After the manifest is written, and still under the cache lock, the build removes everything in the key except Build/Products and .agent-device-runner-cache.json: Build/Intermediates.noindex, SDKExplicitPrecompiledModules, ModuleCache.noindex, Logs, CompilationCache.noindex, SDKStatCaches.noindex, SourcePackages, info.plist. Reuse and launch read only the products and the metadata. The kept set is derived from the .xctestrun and its product paths, resolved through realpath: a product outside Build/Products is kept too, a symlinked product keeps its target's directory, and a product outside the key makes the trim a no-op.

  • A set AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH is never trimmed, and neither is a directory that is not a cache-<hash> key. A fixed override path is the development loop that rebuilds into the same tree, and eviction already skips it.
  • A trim failure never fails the runner start, and the module loads lazily, so the eager closure budgets are unchanged.

Trade-off: a trimmed key cannot be built into incrementally. Nothing in the daemon does that today. A source or Xcode change mints a new key (a cold build in either case), a key that fails certification is deleted and rebuilt from scratch (cleanRunnerDerivedArtifacts), and only a key with no metadata is built into, which a trimmed key never is. So the default costs no rebuild time. The only incremental build left is pnpm build:xcuitest:*, which uses its own path and is not trimmed.

2. Legacy ~/.agent-device/ios-runner. Documented, not deleted. Current versions never read it, but an older agent-device installed on the same machine still uses that layout, including leases next to it, and an automatic delete would pull products from under it. commands.md says to rm -rf ~/.agent-device/ios-runner once no old version runs.

3. pnpm build:package / prepack no longer write to the shared cache root. scripts/build-package-xcuitest.mjs (pnpm package:xcuitest, run by build:package) builds ios, macos, tvos and visionos with build-xcuitest-apple.sh into <repo>/.tmp/package-xcuitest/<platform>, and removes that directory before the builds (an interrupted run leaves nothing behind) and after them, also when a build fails. The script is not named build:xcuitest:* because setup-apple-runner-build hashes those script names into the CI runner cache key. Nothing reads those products (the package ships the runner source), so the compile check is unchanged. Packaging now always builds from scratch, so the isolation scan covers every file. pnpm build:xcuitest:<platform> is unchanged. npm users are unaffected: scripts are not in the tarball, and the daemon's keyed cache is not touched.

Validation

Measured on macOS 27 / Xcode 27.0, an iOS 27.0 simulator created for the run, with HOME pointed at a scratch directory so the shared ~/.agent-device was never touched.

Untrimmed key Trimmed key
Total 165.7 MB 5.9 MB (-96%)
Build/Products 5.8 MB 5.8 MB
Build/Intermediates.noindex 67.0 MB removed
SDKExplicitPrecompiledModules 76.1 MB removed
ModuleCache.noindex 15.1 MB removed
  • prepare ios-runner on a fresh home built the key (about 10 s) and trimmed it; the runner then launched from the trimmed key and open + snapshot -i returned the Settings tree through the xctest backend. The trim itself took 66 ms for 165 MB.
  • After stopping the daemon and the runner, a second prepare ios-runner logged reuse_ready and no new built_new: no rebuild.
  • After appending a line to a runner Swift source, prepare ios-runner built a second key (about 8 s, trimmed to 5.9 MB) and left the first in place.
  • For scale: build-xcuitest-apple.sh in one directory takes 8.0 s cold and 3.6 s incrementally, which is what an in-place rebuild would save, and the daemon never takes that path.
  • buildPackageXcuitest({ platforms: ['ios'] }) built into .tmp/package-xcuitest, removed it, and left the shared derived/ untouched.

Checks: pnpm check:affected --run (the full set, because package.json is workflow tooling) passes, including check:fallow --base origin/main, the eager-closure and package-closure tests, and all of packages/platform-apple/src/runner. src/__tests__/npm-package-scripts.test.ts now expects package:xcuitest in build:package. New tests cover the kept and removed entries, a build under a derived path override that keeps its scratch, a product outside the key, a symlinked product, a real ensureXctestrunArtifact build (stubbed xcodebuild) followed by a reuse with no second build, and the packaging script's scratch cleanup on success, failure and after an interrupted run.

Not run: macOS, tvOS and visionOS runners and a physical device from a trimmed key (the trim walks the same product paths for every platform, and the macOS product repair reads only those paths); the packaging script for macos, tvos and visionos; pnpm package:npm end to end.

@thymikee

thymikee commented Oct 6, 2026

Copy link
Copy Markdown
Member

The code in a790173 looks good and I found nothing that blocks it. Live evidence is still pending: the iOS simulator run (trim, then launch and reuse_ready) comes from the PR body, and I did not re-run it. The macOS and physical-device runner routes were not run by the author, and I did not check whether xcodebuild test-without-building reads anything outside Build/Products there, such as ModuleCache. The real runBuildScript path in the packaging script (execFileSync with the override env) is also not exercised, because the tests inject build. Could you run pnpm package:npm end to end and show that output, plus one macOS or device run that launches after a trim?

Not blocking, so take or leave these. runner-cache-trim.ts:319 copies the keyed-path decision with a regex and an env re-read, when resolveRunnerDerivedPath and resolveRunnerCacheKey already make it and buildXctestrunArtifact has expectedCacheMetadata in hand. The empty catch {} at runner-artifact.ts:303 hides a trim failure, so a best-effort diagnostic there would help. AGENT_DEVICE_IOS_RUNNER_CACHE_TRIM=0 at runner-cache-trim.ts:338 is a new public opt-out that #3246 did not ask for. The PR body says the test expects build:xcuitest:package, but the code and test use package:xcuitest.

On simplicity, the trim fits at the cache seam: it sits beside cleanRunnerDerivedArtifacts, runs under the same lock, and keeps the paths the manifest certifies, so I found no smaller owner. Could it be smaller still by gating on resolveRunnerCacheKey(expectedCacheMetadata) or a keyed flag from resolveRunnerDerivedPath, and by dropping the opt-out? The packaging script is already minimal.

All 16 checks pass and there are no conflicts. The PR is still a draft, so the author needs to mark it ready for review. After this PR or #3247 merges, the other must rebase over the textual conflicts in runner-cache.ts and configuration.md.

@janicduplessis
janicduplessis marked this pull request as ready for review October 6, 2026 12:38
Copilot AI balanced review requested due to automatic review settings October 6, 2026 12:38

Copilot AI 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.

Copilot review overview

🟢 Approval recommended

The production changes are coherent and well covered; the remaining test-environment restoration finding is non-blocking.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
What changed in this PR

Adds post-build Apple runner cache trimming and isolates package-time XCTest builds from the shared cache.

Changes:

  • Retains only certified runner products and metadata in keyed caches.
  • Builds package XCTest targets in disposable repository-local scratch space.
  • Documents cleanup behavior, configuration, and legacy caches.
File Description
website/​docs/​docs/​configuration.md Documents the trim environment variable.
website/​docs/​docs/​commands.md Explains trimming and legacy cache cleanup.
vitest.config.ts Adds the packaging test to the fast suite.
src/​__tests__/​npm-package-scripts.test.ts Updates package-build expectations.
scripts/​build-package-xcuitest.mjs Builds XCTest targets in temporary storage.
scripts/​__tests__/​build-package-xcuitest.test.ts Tests scratch cleanup and failure handling.
packages/​platform-apple/​src/​runner/​runner-cache.ts Adds the trim diagnostic reason.
packages/​platform-apple/​src/​runner/​runner-cache-trim.ts Implements safe cache-key trimming.
packages/​platform-apple/​src/​runner/​runner-artifact.ts Invokes trimming after cache certification.
packages/​platform-apple/​src/​runner/​__tests__/​runner-cache-trim.test.ts Tests trimming and reuse behavior.
package.json Routes package builds through the isolated script.
CONTRIBUTING.md Documents package-build scratch isolation.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@cubic-dev-ai cubic-dev-ai 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.

4 issues found across 12 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/platform-apple/src/runner/runner-artifact.ts">

<violation number="1" location="packages/platform-apple/src/runner/runner-artifact.ts:395">
P2: This trim is uninterruptible and unbounded while holding the per-key cache lock. The `catch {}` swallows every error, including `createRequestCanceledError`, and `trimRunnerBuildScratch` has no deadline or signal of its own, so a canceled request waits out the full recursive delete (potentially hundreds of MB of scratch) before the phase returns — and the cache process lock is held for the whole abort. The PR treats trim failures as non-fatal, but cancellation should still be honored: check the request signal around the removal loop (and before the lock-hold work) instead of absorbing all errors indiscriminately.</violation>
</file>

<file name="packages/platform-apple/src/runner/__tests__/runner-cache-trim.test.ts">

<violation number="1" location="packages/platform-apple/src/runner/__tests__/runner-cache-trim.test.ts:121">
P2: `elsewhere.app` does not exist, so `realpathSync` fails before the trim checks whether a real product is outside the cache. Create an existing outside product to exercise this safety guard.</violation>
</file>

<file name="packages/platform-apple/src/runner/runner-cache-trim.ts">

<violation number="1" location="packages/platform-apple/src/runner/runner-cache-trim.ts:40">
P1: A keyed `derived` path can itself be a symlink; resolving it here does not stop `trimDirectory` from deleting unkept entries in the symlink target. Refuse to trim when `lstat(derived)` reports a symlink.</violation>
</file>

<file name="website/docs/docs/commands.md">

<violation number="1" location="website/docs/docs/commands.md:283">
P3: The trimmed-size claim "a key goes from about 160-230 MB to about 5 MB" generalizes a range that was never measured: the only validated run in this PR is a single iOS simulator key going 165.7 MB to 5.9 MB, and the PR description explicitly states macOS, tvOS, visionOS, and physical-device runs were not performed. Reword to state the observed single data point, or soften the range, so the docs don't assert unvalidated numbers.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Turn on auto-fix | Re-trigger cubic

function resolveKeptPaths(derived: string, protectedPaths: readonly string[]): Set<string> | null {
const kept = new Set<string>([RUNNER_CACHE_METADATA_FILE]);
try {
const realDerived = fs.realpathSync(derived);

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.

P1: A keyed derived path can itself be a symlink; resolving it here does not stop trimDirectory from deleting unkept entries in the symlink target. Refuse to trim when lstat(derived) reports a symlink.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/platform-apple/src/runner/runner-cache-trim.ts, line 40:

<comment>A keyed `derived` path can itself be a symlink; resolving it here does not stop `trimDirectory` from deleting unkept entries in the symlink target. Refuse to trim when `lstat(derived)` reports a symlink.</comment>

<file context>
@@ -0,0 +1,84 @@
+function resolveKeptPaths(derived: string, protectedPaths: readonly string[]): Set<string> | null {
+  const kept = new Set<string>([RUNNER_CACHE_METADATA_FILE]);
+  try {
+    const realDerived = fs.realpathSync(derived);
+    for (const protectedPath of protectedPaths) {
+      const lexical = path.relative(derived, protectedPath);
</file context>

const removed = await trimRunnerBuildScratch(derived, protectedPaths);
if (removed.length > 0)
emitRunnerXctestrunDecision('clean', 'build_scratch_trimmed', { derived });
} catch {}

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.

P2: This trim is uninterruptible and unbounded while holding the per-key cache lock. The catch {} swallows every error, including createRequestCanceledError, and trimRunnerBuildScratch has no deadline or signal of its own, so a canceled request waits out the full recursive delete (potentially hundreds of MB of scratch) before the phase returns — and the cache process lock is held for the whole abort. The PR treats trim failures as non-fatal, but cancellation should still be honored: check the request signal around the removal loop (and before the lock-hold work) instead of absorbing all errors indiscriminately.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/platform-apple/src/runner/runner-artifact.ts, line 395:

<comment>This trim is uninterruptible and unbounded while holding the per-key cache lock. The `catch {}` swallows every error, including `createRequestCanceledError`, and `trimRunnerBuildScratch` has no deadline or signal of its own, so a canceled request waits out the full recursive delete (potentially hundreds of MB of scratch) before the phase returns — and the cache process lock is held for the whole abort. The PR treats trim failures as non-fatal, but cancellation should still be honored: check the request signal around the removal loop (and before the lock-hold work) instead of absorbing all errors indiscriminately.</comment>

<file context>
@@ -381,6 +382,19 @@ async function buildXctestrunArtifact(params: {
+    const removed = await trimRunnerBuildScratch(derived, protectedPaths);
+    if (removed.length > 0)
+      emitRunnerXctestrunDecision('clean', 'build_scratch_trimmed', { derived });
+  } catch {}
+}
+
</file context>

Comment thread packages/platform-apple/src/runner/__tests__/runner-cache-trim.test.ts Outdated
const before = tree(derived);

assert.deepEqual(
await trimRunnerBuildScratch(derived, [path.join(base, 'elsewhere.app')], {}),

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.

P2: elsewhere.app does not exist, so realpathSync fails before the trim checks whether a real product is outside the cache. Create an existing outside product to exercise this safety guard.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/platform-apple/src/runner/__tests__/runner-cache-trim.test.ts, line 121:

<comment>`elsewhere.app` does not exist, so `realpathSync` fails before the trim checks whether a real product is outside the cache. Create an existing outside product to exercise this safety guard.</comment>

<file context>
@@ -0,0 +1,194 @@
+  const before = tree(derived);
+
+  assert.deepEqual(
+    await trimRunnerBuildScratch(derived, [path.join(base, 'elsewhere.app')], {}),
+    [],
+  );
</file context>
Suggested change
await trimRunnerBuildScratch(derived, [path.join(base, 'elsewhere.app')], {}),
await trimRunnerBuildScratch(derived, [fs.mkdtempSync(path.join(base, 'elsewhere.app-'))], {}),

Comment thread website/docs/docs/commands.md Outdated
- If health checking exposes a bad restored runner artifact, Agent Device marks that artifact bad and rebuilds once.
- If a fresh runner launch gets stuck before accepting connections, Agent Device invalidates that runner session and launches it once more without forcing a rebuild.
- CI may cache `~/.agent-device/apple-runner/derived` when the cache key includes the exact Agent Device package contents and selected Xcode version.
- After a successful build, Agent Device removes the build scratch from the new cache key (intermediates, precompiled and module caches, logs) and keeps `Build/Products` and the metadata file, which is all reuse and launch read; a key goes from about 160-230 MB to about 5 MB. A key that fails certification is rebuilt from scratch either way, and a runner source or Xcode change mints a new key, so the scratch is never used again. `AGENT_DEVICE_IOS_RUNNER_CACHE_TRIM=0` keeps it. A set `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` is never trimmed, because a fixed path rebuilds into the same tree incrementally.

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.

P3: The trimmed-size claim "a key goes from about 160-230 MB to about 5 MB" generalizes a range that was never measured: the only validated run in this PR is a single iOS simulator key going 165.7 MB to 5.9 MB, and the PR description explicitly states macOS, tvOS, visionOS, and physical-device runs were not performed. Reword to state the observed single data point, or soften the range, so the docs don't assert unvalidated numbers.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At website/docs/docs/commands.md, line 283:

<comment>The trimmed-size claim "a key goes from about 160-230 MB to about 5 MB" generalizes a range that was never measured: the only validated run in this PR is a single iOS simulator key going 165.7 MB to 5.9 MB, and the PR description explicitly states macOS, tvOS, visionOS, and physical-device runs were not performed. Reword to state the observed single data point, or soften the range, so the docs don't assert unvalidated numbers.</comment>

<file context>
@@ -280,6 +280,8 @@ agent-device prepare ios-runner --platform ios --timeout 240000
 - If health checking exposes a bad restored runner artifact, Agent Device marks that artifact bad and rebuilds once.
 - If a fresh runner launch gets stuck before accepting connections, Agent Device invalidates that runner session and launches it once more without forcing a rebuild.
 - CI may cache `~/.agent-device/apple-runner/derived` when the cache key includes the exact Agent Device package contents and selected Xcode version.
+- After a successful build, Agent Device removes the build scratch from the new cache key (intermediates, precompiled and module caches, logs) and keeps `Build/Products` and the metadata file, which is all reuse and launch read; a key goes from about 160-230 MB to about 5 MB. A key that fails certification is rebuilt from scratch either way, and a runner source or Xcode change mints a new key, so the scratch is never used again. `AGENT_DEVICE_IOS_RUNNER_CACHE_TRIM=0` keeps it. A set `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` is never trimmed, because a fixed path rebuilds into the same tree incrementally.
+- Agent Device versions from before the Apple runner rename kept their cache in `~/.agent-device/ios-runner`. Current versions never read it, and it is not removed automatically because an older version installed on the same machine may still use it. Delete it with `rm -rf ~/.agent-device/ios-runner` once no old version runs.
 - Runner reuse is authorized only by the cache metadata's content manifest: a restored tree whose files no longer match the recorded digests, modes, or symlink targets is discarded and rebuilt. A cache key must stay exact — the runtime never falls back to a broader cache.
</file context>
Suggested change
- After a successful build, Agent Device removes the build scratch from the new cache key (intermediates, precompiled and module caches, logs) and keeps `Build/Products` and the metadata file, which is all reuse and launch read; a key goes from about 160-230 MB to about 5 MB. A key that fails certification is rebuilt from scratch either way, and a runner source or Xcode change mints a new key, so the scratch is never used again. `AGENT_DEVICE_IOS_RUNNER_CACHE_TRIM=0` keeps it. A set `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` is never trimmed, because a fixed path rebuilds into the same tree incrementally.
- After a successful build, Agent Device removes the build scratch from the new cache key (intermediates, precompiled and module caches, logs) and keeps `Build/Products` and the metadata file, which is all reuse and launch read; a reported iOS simulator key went from 165.7 MB to 5.9 MB. A key that fails certification is rebuilt from scratch either way, and a runner source or Xcode change mints a new key, so the scratch is never used again. `AGENT_DEVICE_IOS_RUNNER_CACHE_TRIM=0` keeps it. A set `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` is never trimmed, because a fixed path rebuilds into the same tree incrementally.

Copilot AI balanced review requested due to automatic review settings October 6, 2026 13:00
@janicduplessis

Copy link
Copy Markdown
Contributor Author

Pushed e88a58f. Evidence first, then the changes.

pnpm package:npm end to end (this head, exit 0)

First run failed at prepare-publish-assets with ANDROID_HOME or ANDROID_SDK_ROOT must point to an Android SDK (no SDK variable in my shell, unrelated to the PR). With ANDROID_HOME set it passes. The relevant part of the output:

$ pnpm build && pnpm package:xcuitest && pnpm build:macos-helper:clean && pnpm prepare:publish-assets
$ tsdown ... (dist/src/runner-cache-trim.js is in the bundle)
$ node scripts/build-package-xcuitest.mjs
  xcodebuild build-for-testing ... -derivedDataPath <repo>/.tmp/package-xcuitest/ios ...   (also macos, tvos, visionos)
** TEST BUILD SUCCEEDED ** x4
$ node scripts/build-macos-helper ... swift build -c release   -> Build complete!
$ node scripts/prepare-publish-assets.mjs   -> Prepared publish assets for 0.21.20.
$ node --experimental-strip-types scripts/check-package.ts
Linted the tarball with publint and attw.
Confirmed the installed tree carries no daemon source, so its version pins its code.
Verified the dependency closure: ai.
Imported all 14 published entry points from a clean install.
Ran the published CLI 0.21.20 on Node 22.22.2.
The package npm would publish is sound.

The real runBuildScript path (execFileSync with the override env) ran for all four platforms, and .tmp/package-xcuitest was gone afterwards (.tmp held only tsconfig.tsbuildinfo).

macOS runner launch after a trim (partly blocked)

Setup: scratch HOME (runner cache, leases and daemon state under it; ~/Library/Developer symlinked so Xcode works), AGENT_DEVICE_MACOS_APP_BACKEND unset, my own fixture app (open <bundle id> --platform macos, then snapshot -i).

  • The first snapshot built the macOS key cache-62ae068c8e205be6 and trimmed it: diagnostics rebuild/cache_metadata_missing, clean/build_scratch_trimmed, build/built_new, and the key went to 5.7 MB (.agent-device-runner-cache.json, Build/Products with the .xctestrun, Debug/AgentDeviceRunner.app and AgentDeviceRunnerUITests-Runner.app, plus a Logs dir that xcodebuild test-without-building writes itself).
  • The same request then reused the trimmed key (reuse/reuse_ready) and started xcodebuild test-without-building, which never got past launching the UI test runner: Daemon request timed out after 90 s, with the xcodebuild sample parked in XCTHTestTargetRunner requestNewWorker. A second run from the trimmed key behaved the same.
  • Control: the same flow with the fix(apple-runner): evict stale runner cache keys after a build #3247 build (no trim) in a second scratch home, a full untrimmed key, hangs identically. So the hang is the host, not the trim. A macOS runner started by someone else 20 h ago (AgentDeviceRunner.app, parent launchd, session claim held by another workspace on host-macos-local) is still alive on this Mac, and I did not touch it; I suspect it holds the single automation session.
  • So I could not show a launched macOS runner from a trimmed key. What I can say about ModuleCache: after the launch attempts the trimmed key still held only metadata, Build and Logs; xcodebuild test-without-building did not recreate ModuleCache.noindex, SDKExplicitPrecompiledModules or Intermediates.noindex, and reuse certified the products without them. I did not observe a test actually executing, so this does not rule out reads after launch.
  • Not run: any physical device (none available), tvOS and visionOS launches. The iOS simulator launch after a trim is the one in the PR body.

Review nits

  • Gate: buildXctestrunArtifact now trims only when path.basename(derived) === resolveRunnerCacheKey(expectedCacheMetadata). The regex and the AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH re-read in runner-cache-trim.ts are gone. A new test builds under a derived path override and asserts the scratch is kept (it fails without the gate).
  • Empty catch: a trim failure now emits a warn diagnostic (runner_xctestrun_cache_trim_failed) and the start still never fails.
  • AGENT_DEVICE_IOS_RUNNER_CACHE_TRIM=0: dropped, along with its tests and the two docs mentions. Apple runner cache under ~/.agent-device/apple-runner grows without bound (4.4 GB measured) #3246 did not ask for it, and you asked whether it could go. Its only purpose would be an incremental build into a trimmed key, which nothing does, and the override path already covers that development loop.
  • PR body: the test name is now package:xcuitest and the test list matches.

apple-runner project (70 files), format:check, lint, typecheck, fallow audit --base origin/main, the eager-closure and test-size ratchets and npm-package-scripts pass locally.

One thing I noticed while debugging: on startup the daemon runs pkill -f 'xcodebuild.*test-without-building.*AgentDeviceRunner\.env\.session-host-macos-local-[0-9]'. That pattern matches any other daemon's macOS runner xcodebuild on the same Mac, whatever the state dir. Out of scope here, but it could surprise two agents sharing one Mac.

Copilot AI 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.

Copilot review overview

🟡 Changes recommended

A cache-shaped derived-path override can be trimmed despite the documented guarantee that overrides remain untouched.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
Resolved since last review (1)

Comment on lines +369 to +372
// An AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH override is a fixed tree rebuilt incrementally, not a keyed cache.
if (path.basename(derived) === resolveRunnerCacheKey(expectedCacheMetadata)) {
await trimRunnerBuildScratchBestEffort(derived, [built, ...builtProductPaths]);
}

@cubic-dev-ai cubic-dev-ai 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.

1 issue found across 5 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/platform-apple/src/runner/runner-artifact.ts">

<violation number="1" location="packages/platform-apple/src/runner/runner-artifact.ts:370">
P2: This basename check also trims an explicit `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` override when its final component matches the generated cache key. Skip trimming whenever the override is set; only the default resolver produces a keyed cache path.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Turn on auto-fix | Re-trigger cubic

derived,
);
// An AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH override is a fixed tree rebuilt incrementally, not a keyed cache.
if (path.basename(derived) === resolveRunnerCacheKey(expectedCacheMetadata)) {

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.

P2: This basename check also trims an explicit AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH override when its final component matches the generated cache key. Skip trimming whenever the override is set; only the default resolver produces a keyed cache path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/platform-apple/src/runner/runner-artifact.ts, line 370:

<comment>This basename check also trims an explicit `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` override when its final component matches the generated cache key. Skip trimming whenever the override is set; only the default resolver produces a keyed cache path.</comment>

<file context>
@@ -365,7 +366,10 @@ async function buildXctestrunArtifact(params: {
   );
-  await trimRunnerBuildScratchBestEffort(derived, [built, ...builtProductPaths]);
+  // An AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH override is a fixed tree rebuilt incrementally, not a keyed cache.
+  if (path.basename(derived) === resolveRunnerCacheKey(expectedCacheMetadata)) {
+    await trimRunnerBuildScratchBestEffort(derived, [built, ...builtProductPaths]);
+  }
</file context>
Suggested change
if (path.basename(derived) === resolveRunnerCacheKey(expectedCacheMetadata)) {
if (!process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH?.trim()) {

@thymikee

thymikee commented Oct 6, 2026

Copy link
Copy Markdown
Member

The cache-key trim in e88a58f looks correct to me, and it fixes what I raised on a790173: the opt-out env var and the hook that deleted it are gone. All 17 checks pass and there are no conflicts, but three open inline threads listed below still apply and need a fix before merge. I did not re-run pnpm package:npm, the iOS simulator launch after a trim, or the macOS trim plus reuse_ready run, so those rely on your quoted output. No macOS runner launch from a trimmed key was observed: the host hung, and an untrimmed control key hung the same way. I ran no physical device, tvOS, or visionOS launch after a trim, so a read outside Build/Products after launch on those routes is not ruled out, although the xctestrun ProductPaths are TESTROOT-relative. I also did not run the test suite, so I judged the override test from reading the code. A macOS launch from a trimmed key, on a host with no other runner session, would close the last evidence gap.

Not blocking: you can take or leave the small duplication where isOutside in runner-cache-trim.ts (https://github.com/callstack/agent-device/blob/e88a58f/packages/platform-apple/src/runner/runner-cache-trim.ts#L51) repeats the empty, leading '..' and absolute checks of isPathInsideDirectory in runner-artifact-manifest.ts (https://github.com/callstack/agent-device/blob/e88a58f/packages/platform-apple/src/runner/runner-artifact-manifest.ts#L660). Exporting that helper and calling it in resolveKeptPaths would keep one source of truth.

Of the open inline threads, these still apply: the override-basename trim in runner-artifact.ts (#3248 (comment), also #3248 (comment)), the symlinked derived path (#3248 (comment)), and the outside-product test that never reaches the check (#3248 (comment)). One lower-priority docs thread also still applies. The two env var threads (#3248 (comment) and #3248 (comment)) are fixed at e88a58f, so you can resolve them. The cancellation thread (#3248 (comment)) does not apply: the trim never reads the request signal, and it runs only after a successful build under the same lock.

This branch has not been deployed

No deployments
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